From e16f6d50fb50f9d8e40d9b65377632989806960f Mon Sep 17 00:00:00 2001 From: Meik Date: Fri, 23 Jan 2026 14:01:49 +0100 Subject: [PATCH] Aktueller Stand --- .env | 19 + .env.example | 20 + README.md | 39 +- .../migration.sql | 2 + .../migration.sql | 2 + .../migration.sql | 16 + .../migration.sql | 1 + .../20260123133000_rule_order/migration.sql | 15 + .../migration.sql | 1 + .../migration.sql | 6 + .../migration.sql | 23 + .../migration.sql | 4 + .../migration.sql | 33 + .../migration.sql | 7 + .../migration.sql | 5 + .../20260123200500_rule_updates/migration.sql | 6 + .../20260123202000_rule_score/migration.sql | 2 + .../migration.sql | 17 + .../migration.sql | 2 + .../migration.sql | 5 + backend/prisma/schema.prisma | 105 +- backend/src/admin/routes.ts | 27 +- backend/src/config.ts | 27 + backend/src/mail/cleanup.ts | 517 ++++- backend/src/mail/gmail.ts | 25 +- backend/src/mail/imap.ts | 4 + backend/src/mail/newsletter.ts | 89 +- backend/src/mail/routes.ts | 3 + backend/src/mail/rules.ts | 64 +- backend/src/mail/unsubscribe.ts | 178 +- backend/src/queue/routes.ts | 732 +++++- backend/src/rules/routes.ts | 51 +- backend/src/worker.ts | 136 ++ frontend/index.html | 1 + frontend/public/favicon.ico | Bin 0 -> 195 bytes frontend/public/favicon.png | Bin 0 -> 173 bytes frontend/src/App.tsx | 2005 ++++++++++++++++- frontend/src/admin.tsx | 315 ++- frontend/src/locales/de/translation.json | 191 +- frontend/src/locales/en/translation.json | 183 +- frontend/src/styles.css | 915 +++++++- screenshot/Screenshot 2026-01-22 194754.png | Bin 148567 -> 0 bytes screenshot/Screenshot 2026-01-23 102257.png | Bin 0 -> 158755 bytes screenshot/Screenshot 2026-01-23 102413.png | Bin 0 -> 77075 bytes screenshot/Screenshot 2026-01-23 111613.png | Bin 0 -> 97377 bytes screenshot/Screenshot 2026-01-23 115715.png | Bin 0 -> 19110 bytes 46 files changed, 5482 insertions(+), 311 deletions(-) create mode 100644 backend/prisma/migrations/20250927000000_add_list_unsubscribe_post/migration.sql create mode 100644 backend/prisma/migrations/20250927000001_add_unsubscribe_details/migration.sql create mode 100644 backend/prisma/migrations/20250927000002_add_tenant_metrics/migration.sql create mode 100644 backend/prisma/migrations/20250927000003_add_header_missing_condition/migration.sql create mode 100644 backend/prisma/migrations/20260123133000_rule_order/migration.sql create mode 100644 backend/prisma/migrations/20260123134500_rule_stop_on_match/migration.sql create mode 100644 backend/prisma/migrations/20260123141500_cleanup_job_timings/migration.sql create mode 100644 backend/prisma/migrations/20260123142000_tenant_provider_metrics/migration.sql create mode 100644 backend/prisma/migrations/20260123150000_tenant_provider_metric_seconds/migration.sql create mode 100644 backend/prisma/migrations/20260123190000_cleanup_candidates/migration.sql create mode 100644 backend/prisma/migrations/20260123194500_candidate_details/migration.sql create mode 100644 backend/prisma/migrations/20260123195500_unsubscribe_dedupe/migration.sql create mode 100644 backend/prisma/migrations/20260123200500_rule_updates/migration.sql create mode 100644 backend/prisma/migrations/20260123202000_rule_score/migration.sql create mode 100644 backend/prisma/migrations/20260123203500_unsubscribe_history/migration.sql create mode 100644 backend/prisma/migrations/20260123205000_candidate_reviewed/migration.sql create mode 100644 backend/prisma/migrations/20260123211000_rule_match_mode/migration.sql create mode 100644 frontend/public/favicon.ico create mode 100644 frontend/public/favicon.png delete mode 100644 screenshot/Screenshot 2026-01-22 194754.png create mode 100644 screenshot/Screenshot 2026-01-23 102257.png create mode 100644 screenshot/Screenshot 2026-01-23 102413.png create mode 100644 screenshot/Screenshot 2026-01-23 111613.png create mode 100644 screenshot/Screenshot 2026-01-23 115715.png diff --git a/.env b/.env index 323db16c..c2a77684 100644 --- a/.env +++ b/.env @@ -48,6 +48,21 @@ OAUTH_STATE_TTL_SECONDS=600 # Cleanup scan limit (0 = no limit) CLEANUP_SCAN_LIMIT=0 +# Newsletter detection (comma-separated lists) +NEWSLETTER_THRESHOLD=2 +NEWSLETTER_SUBJECT_TOKENS=newsletter,unsubscribe,update,news,digest +NEWSLETTER_FROM_TOKENS=newsletter,no-reply,noreply,news,updates +NEWSLETTER_HEADER_KEYS=list-unsubscribe,list-id,list-help,list-archive,list-post,list-owner,list-subscribe,list-unsubscribe-post +NEWSLETTER_WEIGHT_HEADER=1 +NEWSLETTER_WEIGHT_PRECEDENCE=1 +NEWSLETTER_WEIGHT_SUBJECT=1 +NEWSLETTER_WEIGHT_FROM=1 +# EMA smoothing for ETA/metrics (0.05-0.95) +METRICS_EMA_ALPHA=0.3 + +# Max attachment size for download (bytes) +ATTACHMENT_MAX_BYTES=10485760 + # Disallow custom IMAP/SMTP hosts unless explicitly enabled ALLOW_CUSTOM_MAIL_HOSTS=false @@ -76,3 +91,7 @@ SEED_TENANT=Default Tenant SEED_TENANT_ID=seed-tenant SEED_ENABLED=false SEED_FORCE_PASSWORD_UPDATE=false + +UNSUBSCRIBE_HISTORY_TTL_DAYS=180 +# Unsubscribe method preference: auto | http | mailto +UNSUBSCRIBE_METHOD_PREFERENCE=http diff --git a/.env.example b/.env.example index a350596a..d6d94824 100644 --- a/.env.example +++ b/.env.example @@ -48,6 +48,26 @@ OAUTH_STATE_TTL_SECONDS=600 # Cleanup scan limit (0 = no limit) CLEANUP_SCAN_LIMIT=0 +# Newsletter detection (comma-separated lists) +NEWSLETTER_THRESHOLD=2 +NEWSLETTER_SUBJECT_TOKENS=newsletter,unsubscribe,update,news,digest +NEWSLETTER_FROM_TOKENS=newsletter,no-reply,noreply,news,updates +NEWSLETTER_HEADER_KEYS=list-unsubscribe,list-id,list-help,list-archive,list-post,list-owner,list-subscribe,list-unsubscribe-post +NEWSLETTER_WEIGHT_HEADER=1 +NEWSLETTER_WEIGHT_PRECEDENCE=1 +NEWSLETTER_WEIGHT_SUBJECT=1 +NEWSLETTER_WEIGHT_FROM=1 +# EMA smoothing for ETA/metrics (0.05-0.95) +METRICS_EMA_ALPHA=0.3 + +# Max attachment size for download (bytes) +ATTACHMENT_MAX_BYTES=10485760 + +# Unsubscribe history (cross-job dedupe window) +UNSUBSCRIBE_HISTORY_TTL_DAYS=180 +# Unsubscribe method preference: auto | http | mailto +UNSUBSCRIBE_METHOD_PREFERENCE=http + # Disallow custom IMAP/SMTP hosts unless explicitly enabled ALLOW_CUSTOM_MAIL_HOSTS=false diff --git a/README.md b/README.md index 4c2d0b9a..3a3b971a 100644 --- a/README.md +++ b/README.md @@ -95,8 +95,11 @@ Runs the full scan and logs what *would* happen, but **does not move/delete/unsu **Unsubscribe aktiv** Enables `List‑Unsubscribe` handling. -- HTTP links are called (one‑click POST when supported). -- Mailto links are sent via SMTP (requires SMTP host + app password). +- **Preference** is controlled by the admin setting **“Unsubscribe‑Methode bevorzugen”** (`UNSUBSCRIBE_METHOD_PREFERENCE`): `http` (default), `mailto`, or `auto`. +- **HTTP** is tried first when preference is `http` or `auto` (one‑click POST when supported). +- **Fallback:** if HTTP fails, the worker **automatically falls back to mailto** when available. +- **MAILTO** sends an email via SMTP (requires SMTP host + app password). +- If preference is `mailto`, mailto is tried first; HTTP is only attempted if no mailto target exists. **Routing aktiv** Applies your configured rules (conditions → actions). @@ -172,6 +175,38 @@ Set these in `.env` before going public: ## Environment All config lives in the repo root `.env` (see `.env.example`). +## Docker services (docker-compose) +The stack is split into small, single-purpose services so each part can scale and restart independently: + +- **web** (Frontend UI) + - React + Vite single‑page app. + - Serves the user/admin interface and talks to the API via `VITE_API_URL`. + - In dev it runs the Vite server; in production it serves the built assets. + +- **api** (Backend API) + - Fastify server with auth, rules, mail account management, and job control. + - Issues short‑lived SSE tokens and exposes the job/event endpoints. + - Connects to Postgres for persistence and Redis for queues. + +- **worker** (Background jobs) + - BullMQ worker that executes cleanup jobs and export jobs. + - Handles IMAP/Gmail processing, unsubscribe actions, and rule execution. + - Writes progress events and updates job state in Postgres. + +- **postgres** (Database) + - Primary data store for users, tenants, mailboxes, rules, jobs, candidates, and settings. + - Keeps all state so jobs can resume after restarts. + +- **redis** (Queue & cache) + - BullMQ queue backend for cleanup/export jobs. + - Used for fast job coordination between API and worker. + +How they interact: +1. **web** calls **api** for login, rule management, and job start. +2. **api** enqueues jobs in **redis** and persists state in **postgres**. +3. **worker** consumes jobs from **redis**, processes mailboxes, and writes results to **postgres**. +4. **web** streams job events from **api** (SSE) for live progress. + Export settings: - `EXPORT_DIR` (default `/tmp/mailcleaner-exports`) - `EXPORT_TTL_HOURS` (default `24`) diff --git a/backend/prisma/migrations/20250927000000_add_list_unsubscribe_post/migration.sql b/backend/prisma/migrations/20250927000000_add_list_unsubscribe_post/migration.sql new file mode 100644 index 00000000..23c9a383 --- /dev/null +++ b/backend/prisma/migrations/20250927000000_add_list_unsubscribe_post/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE "CleanupJobCandidate" +ADD COLUMN "listUnsubscribePost" TEXT; diff --git a/backend/prisma/migrations/20250927000001_add_unsubscribe_details/migration.sql b/backend/prisma/migrations/20250927000001_add_unsubscribe_details/migration.sql new file mode 100644 index 00000000..d413e098 --- /dev/null +++ b/backend/prisma/migrations/20250927000001_add_unsubscribe_details/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE "CleanupJobCandidate" +ADD COLUMN "unsubscribeDetails" JSONB; diff --git a/backend/prisma/migrations/20250927000002_add_tenant_metrics/migration.sql b/backend/prisma/migrations/20250927000002_add_tenant_metrics/migration.sql new file mode 100644 index 00000000..a67e8bc7 --- /dev/null +++ b/backend/prisma/migrations/20250927000002_add_tenant_metrics/migration.sql @@ -0,0 +1,16 @@ +CREATE TABLE "TenantMetric" ( + "id" TEXT NOT NULL, + "tenantId" TEXT NOT NULL, + "avgProcessingRate" DOUBLE PRECISION, + "sampleCount" INTEGER NOT NULL DEFAULT 0, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "TenantMetric_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "TenantMetric_tenantId_key" ON "TenantMetric"("tenantId"); + +ALTER TABLE "TenantMetric" +ADD CONSTRAINT "TenantMetric_tenantId_fkey" +FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") +ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/backend/prisma/migrations/20250927000003_add_header_missing_condition/migration.sql b/backend/prisma/migrations/20250927000003_add_header_missing_condition/migration.sql new file mode 100644 index 00000000..1aaffd61 --- /dev/null +++ b/backend/prisma/migrations/20250927000003_add_header_missing_condition/migration.sql @@ -0,0 +1 @@ +ALTER TYPE "RuleConditionType" ADD VALUE IF NOT EXISTS 'HEADER_MISSING'; diff --git a/backend/prisma/migrations/20260123133000_rule_order/migration.sql b/backend/prisma/migrations/20260123133000_rule_order/migration.sql new file mode 100644 index 00000000..ef45c5bf --- /dev/null +++ b/backend/prisma/migrations/20260123133000_rule_order/migration.sql @@ -0,0 +1,15 @@ +ALTER TABLE "Rule" ADD COLUMN "position" INTEGER NOT NULL DEFAULT 0; + +WITH ordered AS ( + SELECT + "id", + "tenantId", + ROW_NUMBER() OVER (PARTITION BY "tenantId" ORDER BY "createdAt" ASC, "id" ASC) - 1 AS pos + FROM "Rule" +) +UPDATE "Rule" r +SET "position" = ordered.pos +FROM ordered +WHERE ordered.id = r.id; + +CREATE INDEX "Rule_tenantId_position_idx" ON "Rule"("tenantId", "position"); diff --git a/backend/prisma/migrations/20260123134500_rule_stop_on_match/migration.sql b/backend/prisma/migrations/20260123134500_rule_stop_on_match/migration.sql new file mode 100644 index 00000000..96154c4e --- /dev/null +++ b/backend/prisma/migrations/20260123134500_rule_stop_on_match/migration.sql @@ -0,0 +1 @@ +ALTER TABLE "Rule" ADD COLUMN "stopOnMatch" BOOLEAN NOT NULL DEFAULT false; diff --git a/backend/prisma/migrations/20260123141500_cleanup_job_timings/migration.sql b/backend/prisma/migrations/20260123141500_cleanup_job_timings/migration.sql new file mode 100644 index 00000000..74679655 --- /dev/null +++ b/backend/prisma/migrations/20260123141500_cleanup_job_timings/migration.sql @@ -0,0 +1,6 @@ +ALTER TABLE "CleanupJob" ADD COLUMN "listingSeconds" INTEGER; +ALTER TABLE "CleanupJob" ADD COLUMN "processingSeconds" INTEGER; +ALTER TABLE "CleanupJob" ADD COLUMN "unsubscribeSeconds" INTEGER; +ALTER TABLE "CleanupJob" ADD COLUMN "routingSeconds" INTEGER; +ALTER TABLE "CleanupJob" ADD COLUMN "unsubscribeAttempts" INTEGER; +ALTER TABLE "CleanupJob" ADD COLUMN "actionAttempts" INTEGER; diff --git a/backend/prisma/migrations/20260123142000_tenant_provider_metrics/migration.sql b/backend/prisma/migrations/20260123142000_tenant_provider_metrics/migration.sql new file mode 100644 index 00000000..fb846e72 --- /dev/null +++ b/backend/prisma/migrations/20260123142000_tenant_provider_metrics/migration.sql @@ -0,0 +1,23 @@ +CREATE TABLE "TenantProviderMetric" ( + "id" TEXT NOT NULL, + "tenantId" TEXT NOT NULL, + "provider" "MailProvider" NOT NULL, + "avgListingRate" DOUBLE PRECISION, + "avgProcessingRate" DOUBLE PRECISION, + "avgUnsubscribeRate" DOUBLE PRECISION, + "avgRoutingRate" DOUBLE PRECISION, + "listingSampleCount" INTEGER NOT NULL DEFAULT 0, + "processingSampleCount" INTEGER NOT NULL DEFAULT 0, + "unsubscribeSampleCount" INTEGER NOT NULL DEFAULT 0, + "routingSampleCount" INTEGER NOT NULL DEFAULT 0, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "TenantProviderMetric_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "TenantProviderMetric_tenantId_provider_key" ON "TenantProviderMetric"("tenantId", "provider"); +CREATE INDEX "TenantProviderMetric_tenantId_idx" ON "TenantProviderMetric"("tenantId"); + +ALTER TABLE "TenantProviderMetric" +ADD CONSTRAINT "TenantProviderMetric_tenantId_fkey" +FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/backend/prisma/migrations/20260123150000_tenant_provider_metric_seconds/migration.sql b/backend/prisma/migrations/20260123150000_tenant_provider_metric_seconds/migration.sql new file mode 100644 index 00000000..1487aab1 --- /dev/null +++ b/backend/prisma/migrations/20260123150000_tenant_provider_metric_seconds/migration.sql @@ -0,0 +1,4 @@ +ALTER TABLE "TenantProviderMetric" ADD COLUMN "avgListingSecondsPerMessage" DOUBLE PRECISION; +ALTER TABLE "TenantProviderMetric" ADD COLUMN "avgProcessingSecondsPerMessage" DOUBLE PRECISION; +ALTER TABLE "TenantProviderMetric" ADD COLUMN "avgUnsubscribeSecondsPerMessage" DOUBLE PRECISION; +ALTER TABLE "TenantProviderMetric" ADD COLUMN "avgRoutingSecondsPerMessage" DOUBLE PRECISION; diff --git a/backend/prisma/migrations/20260123190000_cleanup_candidates/migration.sql b/backend/prisma/migrations/20260123190000_cleanup_candidates/migration.sql new file mode 100644 index 00000000..9255e3e0 --- /dev/null +++ b/backend/prisma/migrations/20260123190000_cleanup_candidates/migration.sql @@ -0,0 +1,33 @@ +-- CreateTable +CREATE TABLE "CleanupJobCandidate" ( + "id" TEXT NOT NULL, + "jobId" TEXT NOT NULL, + "mailboxAccountId" TEXT NOT NULL, + "provider" "MailProvider" NOT NULL, + "externalId" TEXT NOT NULL, + "subject" TEXT, + "from" TEXT, + "fromDomain" TEXT, + "listId" TEXT, + "listUnsubscribe" TEXT, + "score" INTEGER NOT NULL, + "signals" JSONB NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "CleanupJobCandidate_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "CleanupJobCandidate_jobId_externalId_key" ON "CleanupJobCandidate"("jobId", "externalId"); + +-- CreateIndex +CREATE INDEX "CleanupJobCandidate_jobId_idx" ON "CleanupJobCandidate"("jobId"); + +-- CreateIndex +CREATE INDEX "CleanupJobCandidate_jobId_fromDomain_idx" ON "CleanupJobCandidate"("jobId", "fromDomain"); + +-- AddForeignKey +ALTER TABLE "CleanupJobCandidate" ADD CONSTRAINT "CleanupJobCandidate_jobId_fkey" FOREIGN KEY ("jobId") REFERENCES "CleanupJob"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "CleanupJobCandidate" ADD CONSTRAINT "CleanupJobCandidate_mailboxAccountId_fkey" FOREIGN KEY ("mailboxAccountId") REFERENCES "MailboxAccount"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/backend/prisma/migrations/20260123194500_candidate_details/migration.sql b/backend/prisma/migrations/20260123194500_candidate_details/migration.sql new file mode 100644 index 00000000..a298308a --- /dev/null +++ b/backend/prisma/migrations/20260123194500_candidate_details/migration.sql @@ -0,0 +1,7 @@ +-- AlterTable +ALTER TABLE "CleanupJobCandidate" +ADD COLUMN "receivedAt" TIMESTAMP(3), +ADD COLUMN "actions" JSONB, +ADD COLUMN "unsubscribeStatus" TEXT, +ADD COLUMN "unsubscribeMessage" TEXT, +ADD COLUMN "unsubscribeTarget" TEXT; diff --git a/backend/prisma/migrations/20260123195500_unsubscribe_dedupe/migration.sql b/backend/prisma/migrations/20260123195500_unsubscribe_dedupe/migration.sql new file mode 100644 index 00000000..6f7fd848 --- /dev/null +++ b/backend/prisma/migrations/20260123195500_unsubscribe_dedupe/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "UnsubscribeAttempt" ADD COLUMN "dedupeKey" TEXT; + +-- CreateIndex +CREATE UNIQUE INDEX "UnsubscribeAttempt_jobId_dedupeKey_key" ON "UnsubscribeAttempt"("jobId", "dedupeKey"); diff --git a/backend/prisma/migrations/20260123200500_rule_updates/migration.sql b/backend/prisma/migrations/20260123200500_rule_updates/migration.sql new file mode 100644 index 00000000..e15e59b4 --- /dev/null +++ b/backend/prisma/migrations/20260123200500_rule_updates/migration.sql @@ -0,0 +1,6 @@ +-- AlterEnum +ALTER TYPE "RuleActionType" ADD VALUE IF NOT EXISTS 'MARK_READ'; +ALTER TYPE "RuleActionType" ADD VALUE IF NOT EXISTS 'MARK_UNREAD'; + +-- AlterEnum +ALTER TYPE "RuleConditionType" ADD VALUE IF NOT EXISTS 'UNSUBSCRIBE_STATUS'; diff --git a/backend/prisma/migrations/20260123202000_rule_score/migration.sql b/backend/prisma/migrations/20260123202000_rule_score/migration.sql new file mode 100644 index 00000000..b393b0e0 --- /dev/null +++ b/backend/prisma/migrations/20260123202000_rule_score/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "RuleConditionType" ADD VALUE IF NOT EXISTS 'SCORE'; diff --git a/backend/prisma/migrations/20260123203500_unsubscribe_history/migration.sql b/backend/prisma/migrations/20260123203500_unsubscribe_history/migration.sql new file mode 100644 index 00000000..286f321d --- /dev/null +++ b/backend/prisma/migrations/20260123203500_unsubscribe_history/migration.sql @@ -0,0 +1,17 @@ +-- CreateTable +CREATE TABLE "UnsubscribeHistory" ( + "id" TEXT NOT NULL, + "tenantId" TEXT NOT NULL, + "dedupeKey" TEXT NOT NULL, + "target" TEXT NOT NULL, + "status" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "UnsubscribeHistory_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "UnsubscribeHistory_tenantId_dedupeKey_key" ON "UnsubscribeHistory"("tenantId", "dedupeKey"); + +-- CreateIndex +CREATE INDEX "UnsubscribeHistory_tenantId_idx" ON "UnsubscribeHistory"("tenantId"); diff --git a/backend/prisma/migrations/20260123205000_candidate_reviewed/migration.sql b/backend/prisma/migrations/20260123205000_candidate_reviewed/migration.sql new file mode 100644 index 00000000..272a3f3a --- /dev/null +++ b/backend/prisma/migrations/20260123205000_candidate_reviewed/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "CleanupJobCandidate" ADD COLUMN "reviewed" BOOLEAN NOT NULL DEFAULT false; diff --git a/backend/prisma/migrations/20260123211000_rule_match_mode/migration.sql b/backend/prisma/migrations/20260123211000_rule_match_mode/migration.sql new file mode 100644 index 00000000..4be7529f --- /dev/null +++ b/backend/prisma/migrations/20260123211000_rule_match_mode/migration.sql @@ -0,0 +1,5 @@ +-- CreateEnum +CREATE TYPE "RuleMatchMode" AS ENUM ('ALL', 'ANY'); + +-- AlterTable +ALTER TABLE "Rule" ADD COLUMN "matchMode" "RuleMatchMode" NOT NULL DEFAULT 'ALL'; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 61895ddb..66842544 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -31,14 +31,19 @@ enum RuleActionType { DELETE ARCHIVE LABEL + MARK_READ + MARK_UNREAD } enum RuleConditionType { HEADER + HEADER_MISSING SUBJECT FROM LIST_UNSUBSCRIBE LIST_ID + UNSUBSCRIBE_STATUS + SCORE } enum ExportStatus { @@ -60,6 +65,42 @@ model Tenant { mailboxAccounts MailboxAccount[] rules Rule[] jobs CleanupJob[] + metric TenantMetric? + providerMetrics TenantProviderMetric[] +} + +model TenantMetric { + id String @id @default(cuid()) + tenantId String @unique + avgProcessingRate Float? + sampleCount Int @default(0) + updatedAt DateTime @updatedAt + + tenant Tenant @relation(fields: [tenantId], references: [id]) +} + +model TenantProviderMetric { + id String @id @default(cuid()) + tenantId String + provider MailProvider + avgListingRate Float? + avgProcessingRate Float? + avgUnsubscribeRate Float? + avgRoutingRate Float? + avgListingSecondsPerMessage Float? + avgProcessingSecondsPerMessage Float? + avgUnsubscribeSecondsPerMessage Float? + avgRoutingSecondsPerMessage Float? + listingSampleCount Int @default(0) + processingSampleCount Int @default(0) + unsubscribeSampleCount Int @default(0) + routingSampleCount Int @default(0) + updatedAt DateTime @updatedAt + + tenant Tenant @relation(fields: [tenantId], references: [id]) + + @@unique([tenantId, provider]) + @@index([tenantId]) } model ExportJob { @@ -120,6 +161,7 @@ model MailboxAccount { tenant Tenant @relation(fields: [tenantId], references: [id]) folders MailboxFolder[] jobs CleanupJob[] + candidates CleanupJobCandidate[] @@index([tenantId]) } @@ -161,6 +203,9 @@ model Rule { tenantId String name String enabled Boolean @default(true) + matchMode RuleMatchMode @default(ALL) + position Int @default(0) + stopOnMatch Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -169,6 +214,12 @@ model Rule { actions RuleAction[] @@index([tenantId]) + @@index([tenantId, position]) +} + +enum RuleMatchMode { + ALL + ANY } model RuleCondition { @@ -205,6 +256,12 @@ model CleanupJob { checkpointUpdatedAt DateTime? processedMessages Int? totalMessages Int? + listingSeconds Int? + processingSeconds Int? + unsubscribeSeconds Int? + routingSeconds Int? + unsubscribeAttempts Int? + actionAttempts Int? startedAt DateTime? finishedAt DateTime? createdAt DateTime @default(now()) @@ -212,17 +269,50 @@ model CleanupJob { tenant Tenant @relation(fields: [tenantId], references: [id]) mailboxAccount MailboxAccount @relation(fields: [mailboxAccountId], references: [id]) - unsubscribeAttempts UnsubscribeAttempt[] + unsubscribeAttemptItems UnsubscribeAttempt[] events CleanupJobEvent[] + candidates CleanupJobCandidate[] @@index([tenantId]) @@index([mailboxAccountId]) } +model CleanupJobCandidate { + id String @id @default(cuid()) + jobId String + mailboxAccountId String + provider MailProvider + externalId String + subject String? + from String? + fromDomain String? + receivedAt DateTime? + listId String? + listUnsubscribe String? + listUnsubscribePost String? + score Int + signals Json + actions Json? + unsubscribeStatus String? + unsubscribeMessage String? + unsubscribeTarget String? + unsubscribeDetails Json? + reviewed Boolean @default(false) + createdAt DateTime @default(now()) + + job CleanupJob @relation(fields: [jobId], references: [id]) + mailboxAccount MailboxAccount @relation(fields: [mailboxAccountId], references: [id]) + + @@unique([jobId, externalId]) + @@index([jobId]) + @@index([jobId, fromDomain]) +} + model UnsubscribeAttempt { id String @id @default(cuid()) jobId String mailItemId String? + dedupeKey String? method String target String status String @@ -231,6 +321,19 @@ model UnsubscribeAttempt { job CleanupJob @relation(fields: [jobId], references: [id]) @@index([jobId]) + @@unique([jobId, dedupeKey]) +} + +model UnsubscribeHistory { + id String @id @default(cuid()) + tenantId String + dedupeKey String + target String + status String + createdAt DateTime @default(now()) + + @@unique([tenantId, dedupeKey]) + @@index([tenantId]) } model CleanupJobEvent { diff --git a/backend/src/admin/routes.ts b/backend/src/admin/routes.ts index 71e5b04a..d4ae43e6 100644 --- a/backend/src/admin/routes.ts +++ b/backend/src/admin/routes.ts @@ -1,6 +1,7 @@ import { FastifyInstance } from "fastify"; import { z } from "zod"; import { prisma } from "../db.js"; +import { config } from "../config.js"; import { logJobEvent } from "../queue/jobEvents.js"; import { queueCleanupJob, removeQueueJob, queueExportJob } from "../queue/queue.js"; import { createReadStream } from "node:fs"; @@ -28,7 +29,17 @@ const allowedSettings = [ "google.client_id", "google.client_secret", "google.redirect_uri", - "cleanup.scan_limit" + "cleanup.scan_limit", + "newsletter.threshold", + "newsletter.subject_tokens", + "newsletter.from_tokens", + "newsletter.header_keys", + "newsletter.weight_header", + "newsletter.weight_precedence", + "newsletter.weight_subject", + "newsletter.weight_from", + "unsubscribe.history_ttl_days", + "unsubscribe.method_preference" ] as const; export async function adminRoutes(app: FastifyInstance) { @@ -41,7 +52,17 @@ export async function adminRoutes(app: FastifyInstance) { "google.client_id": process.env.GOOGLE_CLIENT_ID ?? null, "google.client_secret": process.env.GOOGLE_CLIENT_SECRET ?? null, "google.redirect_uri": process.env.GOOGLE_REDIRECT_URI ?? null, - "cleanup.scan_limit": process.env.CLEANUP_SCAN_LIMIT ?? null + "cleanup.scan_limit": process.env.CLEANUP_SCAN_LIMIT ?? String(config.CLEANUP_SCAN_LIMIT), + "newsletter.threshold": process.env.NEWSLETTER_THRESHOLD ?? String(config.NEWSLETTER_THRESHOLD), + "newsletter.subject_tokens": process.env.NEWSLETTER_SUBJECT_TOKENS ?? config.NEWSLETTER_SUBJECT_TOKENS, + "newsletter.from_tokens": process.env.NEWSLETTER_FROM_TOKENS ?? config.NEWSLETTER_FROM_TOKENS, + "newsletter.header_keys": process.env.NEWSLETTER_HEADER_KEYS ?? config.NEWSLETTER_HEADER_KEYS, + "newsletter.weight_header": process.env.NEWSLETTER_WEIGHT_HEADER ?? String(config.NEWSLETTER_WEIGHT_HEADER), + "newsletter.weight_precedence": process.env.NEWSLETTER_WEIGHT_PRECEDENCE ?? String(config.NEWSLETTER_WEIGHT_PRECEDENCE), + "newsletter.weight_subject": process.env.NEWSLETTER_WEIGHT_SUBJECT ?? String(config.NEWSLETTER_WEIGHT_SUBJECT), + "newsletter.weight_from": process.env.NEWSLETTER_WEIGHT_FROM ?? String(config.NEWSLETTER_WEIGHT_FROM), + "unsubscribe.history_ttl_days": process.env.UNSUBSCRIBE_HISTORY_TTL_DAYS ?? String(config.UNSUBSCRIBE_HISTORY_TTL_DAYS), + "unsubscribe.method_preference": process.env.UNSUBSCRIBE_METHOD_PREFERENCE ?? config.UNSUBSCRIBE_METHOD_PREFERENCE }; const settings = keys.reduce>((acc, key) => { const dbValue = stored[key]; @@ -223,6 +244,7 @@ export async function adminRoutes(app: FastifyInstance) { const jobIds = jobs.map((job) => job.id); await tx.cleanupJobEvent.deleteMany({ where: { jobId: { in: jobIds } } }); await tx.unsubscribeAttempt.deleteMany({ where: { jobId: { in: jobIds } } }); + await tx.cleanupJobCandidate.deleteMany({ where: { jobId: { in: jobIds } } }); await tx.cleanupJob.deleteMany({ where: { tenantId: tenant.id } }); await tx.ruleAction.deleteMany({ where: { rule: { tenantId: tenant.id } } }); await tx.ruleCondition.deleteMany({ where: { rule: { tenantId: tenant.id } } }); @@ -427,6 +449,7 @@ export async function adminRoutes(app: FastifyInstance) { await prisma.$transaction(async (tx) => { await tx.cleanupJobEvent.deleteMany({ where: { jobId: job.id } }); await tx.unsubscribeAttempt.deleteMany({ where: { jobId: job.id } }); + await tx.cleanupJobCandidate.deleteMany({ where: { jobId: job.id } }); await tx.cleanupJob.delete({ where: { id: job.id } }); }); diff --git a/backend/src/config.ts b/backend/src/config.ts index 376ea98c..e2b3b341 100644 --- a/backend/src/config.ts +++ b/backend/src/config.ts @@ -30,6 +30,21 @@ const envSchema = z.object({ SSE_TOKEN_TTL_SECONDS: z.coerce.number().default(300), OAUTH_STATE_TTL_SECONDS: z.coerce.number().default(600), CLEANUP_SCAN_LIMIT: z.coerce.number().default(0), + NEWSLETTER_THRESHOLD: z.coerce.number().default(2), + NEWSLETTER_SUBJECT_TOKENS: z.string().default("newsletter,unsubscribe,update,news,digest"), + NEWSLETTER_FROM_TOKENS: z.string().default("newsletter,no-reply,noreply,news,updates"), + NEWSLETTER_HEADER_KEYS: z.string().default("list-unsubscribe,list-id,list-help,list-archive,list-post,list-owner,list-subscribe,list-unsubscribe-post"), + NEWSLETTER_WEIGHT_HEADER: z.coerce.number().default(1), + NEWSLETTER_WEIGHT_PRECEDENCE: z.coerce.number().default(1), + NEWSLETTER_WEIGHT_SUBJECT: z.coerce.number().default(1), + NEWSLETTER_WEIGHT_FROM: z.coerce.number().default(1), + UNSUBSCRIBE_HISTORY_TTL_DAYS: z.coerce.number().default(180), + UNSUBSCRIBE_METHOD_PREFERENCE: z.preprocess( + (value) => (typeof value === "string" ? value.toLowerCase() : value), + z.enum(["auto", "http", "mailto"]).default("http") + ), + METRICS_EMA_ALPHA: z.coerce.number().default(0.3), + ATTACHMENT_MAX_BYTES: z.coerce.number().default(10 * 1024 * 1024), ALLOW_CUSTOM_MAIL_HOSTS: envBoolean(false), BLOCK_PRIVATE_NETWORKS: envBoolean(true), ENCRYPTION_KEY: z.string().optional(), @@ -67,6 +82,18 @@ const parsed = envSchema.safeParse({ SSE_TOKEN_TTL_SECONDS: process.env.SSE_TOKEN_TTL_SECONDS, OAUTH_STATE_TTL_SECONDS: process.env.OAUTH_STATE_TTL_SECONDS, CLEANUP_SCAN_LIMIT: process.env.CLEANUP_SCAN_LIMIT, + NEWSLETTER_THRESHOLD: process.env.NEWSLETTER_THRESHOLD, + NEWSLETTER_SUBJECT_TOKENS: process.env.NEWSLETTER_SUBJECT_TOKENS, + NEWSLETTER_FROM_TOKENS: process.env.NEWSLETTER_FROM_TOKENS, + NEWSLETTER_HEADER_KEYS: process.env.NEWSLETTER_HEADER_KEYS, + NEWSLETTER_WEIGHT_HEADER: process.env.NEWSLETTER_WEIGHT_HEADER, + NEWSLETTER_WEIGHT_PRECEDENCE: process.env.NEWSLETTER_WEIGHT_PRECEDENCE, + NEWSLETTER_WEIGHT_SUBJECT: process.env.NEWSLETTER_WEIGHT_SUBJECT, + NEWSLETTER_WEIGHT_FROM: process.env.NEWSLETTER_WEIGHT_FROM, + UNSUBSCRIBE_HISTORY_TTL_DAYS: process.env.UNSUBSCRIBE_HISTORY_TTL_DAYS, + UNSUBSCRIBE_METHOD_PREFERENCE: process.env.UNSUBSCRIBE_METHOD_PREFERENCE, + METRICS_EMA_ALPHA: process.env.METRICS_EMA_ALPHA, + ATTACHMENT_MAX_BYTES: process.env.ATTACHMENT_MAX_BYTES, ALLOW_CUSTOM_MAIL_HOSTS: process.env.ALLOW_CUSTOM_MAIL_HOSTS, BLOCK_PRIVATE_NETWORKS: process.env.BLOCK_PRIVATE_NETWORKS, ENCRYPTION_KEY: process.env.ENCRYPTION_KEY, diff --git a/backend/src/mail/cleanup.ts b/backend/src/mail/cleanup.ts index 86ce5939..54962296 100644 --- a/backend/src/mail/cleanup.ts +++ b/backend/src/mail/cleanup.ts @@ -5,7 +5,7 @@ import { createImapClient, fetchHeadersByUids, listMailboxes } from "./imap.js"; import { detectNewsletter } from "./newsletter.js"; import { matchRules } from "./rules.js"; import { unsubscribeFromHeader } from "./unsubscribe.js"; -import { applyGmailAction, gmailClientForAccount } from "./gmail.js"; +import { gmailClientForAccount } from "./gmail.js"; export const runCleanup = async (cleanupJobId: string, mailboxAccountId: string) => { const account = await prisma.mailboxAccount.findUnique({ where: { id: mailboxAccountId } }); @@ -27,9 +27,73 @@ export const runCleanup = async (cleanupJobId: string, mailboxAccountId: string) const rules = await prisma.rule.findMany({ where: { tenantId: job.tenantId }, - include: { conditions: true, actions: true } + include: { conditions: true, actions: true }, + orderBy: [{ position: "asc" }, { createdAt: "asc" }] }); + const parseList = (value: string | null | undefined, fallback: string[]) => { + if (!value) return fallback; + return value.split(",").map((item) => item.trim().toLowerCase()).filter(Boolean); + }; + + const parseThreshold = (value: string | null | undefined, fallback: number) => { + if (!value) return fallback; + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) ? parsed : fallback; + }; + + const defaultHeaderKeys = [ + "list-unsubscribe", + "list-id", + "list-help", + "list-archive", + "list-post", + "list-owner", + "list-subscribe", + "list-unsubscribe-post" + ]; + const defaultSubjectTokens = ["newsletter", "unsubscribe", "update", "news", "digest"]; + const defaultFromTokens = ["newsletter", "no-reply", "noreply", "news", "updates"]; + + const newsletterSettings = await prisma.appSetting.findMany({ + where: { key: { in: ["newsletter.threshold", "newsletter.subject_tokens", "newsletter.from_tokens", "newsletter.header_keys"] } } + }); + const newsletterMap = new Map(newsletterSettings.map((setting) => [setting.key, setting.value])); + const newsletterConfig = { + threshold: parseThreshold( + newsletterMap.get("newsletter.threshold"), + config.NEWSLETTER_THRESHOLD + ), + weightHeader: parseThreshold( + newsletterMap.get("newsletter.weight_header"), + config.NEWSLETTER_WEIGHT_HEADER + ), + weightPrecedence: parseThreshold( + newsletterMap.get("newsletter.weight_precedence"), + config.NEWSLETTER_WEIGHT_PRECEDENCE + ), + weightSubject: parseThreshold( + newsletterMap.get("newsletter.weight_subject"), + config.NEWSLETTER_WEIGHT_SUBJECT + ), + weightFrom: parseThreshold( + newsletterMap.get("newsletter.weight_from"), + config.NEWSLETTER_WEIGHT_FROM + ), + subjectTokens: parseList( + newsletterMap.get("newsletter.subject_tokens") ?? config.NEWSLETTER_SUBJECT_TOKENS, + defaultSubjectTokens + ), + fromTokens: parseList( + newsletterMap.get("newsletter.from_tokens") ?? config.NEWSLETTER_FROM_TOKENS, + defaultFromTokens + ), + headerKeys: parseList( + newsletterMap.get("newsletter.header_keys") ?? config.NEWSLETTER_HEADER_KEYS, + defaultHeaderKeys + ) + }; + await logJobEvent(cleanupJobId, "info", `Connecting to ${account.email}`, 5); const isGmail = account.provider === "GMAIL"; @@ -94,106 +158,329 @@ export const runCleanup = async (cleanupJobId: string, mailboxAccountId: string) }); }; + const extractDomain = (value?: string | null) => { + if (!value) return null; + const match = value.match(/@([^>\s]+)/); + if (!match) return null; + return match[1].toLowerCase(); + }; + + const normalizeListUnsubscribe = (value: string) => { + const tokens = value + .split(",") + .map((token) => token.trim()) + .map((token) => token.replace(/^<|>$/g, "")) + .filter(Boolean) + .map((token) => token.toLowerCase()); + if (!tokens.length) return null; + return tokens.sort().join(","); + }; + + type GmailClient = Awaited>["gmail"]; + const processMessage = async (msg: { uid: number; subject?: string; from?: string; + receivedAt?: Date; headers: Map; gmailMessageId?: string; - }) => { + mailbox?: string; + }, gmailContext?: { gmail: GmailClient; resolveLabelId: (label: string) => Promise }) => { const ctx = { headers: msg.headers, subject: msg.subject ?? "", from: msg.from ?? "" }; - const result = detectNewsletter(ctx); + const result = detectNewsletter({ ...ctx, config: newsletterConfig }); if (!result.isNewsletter) { return false; } - const actions = job.routingEnabled ? matchRules(rules, ctx) : []; + const listId = msg.headers.get("list-id") ?? null; + const listUnsubscribe = msg.headers.get("list-unsubscribe") ?? null; + const listUnsubscribePost = msg.headers.get("list-unsubscribe-post") ?? null; + const externalId = msg.gmailMessageId + ? `gmail:${msg.gmailMessageId}` + : `imap:${msg.mailbox ?? account.email}:${msg.uid}`; + + const candidate = await prisma.cleanupJobCandidate.upsert({ + where: { jobId_externalId: { jobId: cleanupJobId, externalId } }, + update: { + subject: msg.subject ?? null, + from: msg.from ?? null, + fromDomain: extractDomain(msg.from), + receivedAt: msg.receivedAt ?? null, + listId, + listUnsubscribe, + listUnsubscribePost, + score: result.score, + signals: result.signals + }, + create: { + jobId: cleanupJobId, + mailboxAccountId: account.id, + provider: account.provider, + externalId, + subject: msg.subject ?? null, + from: msg.from ?? null, + fromDomain: extractDomain(msg.from), + receivedAt: msg.receivedAt ?? null, + listId, + listUnsubscribe, + listUnsubscribePost, + score: result.score, + signals: result.signals + } + }); + + let unsubscribeStatus = job.unsubscribeEnabled ? "pending" : "disabled"; + let unsubscribeMessage: string | null = null; + let unsubscribeDetails: Record | null = null; + const unsubscribeTarget = listUnsubscribe; + if (job.unsubscribeEnabled) { + if (listUnsubscribe) { + const dedupeKey = listId + ? `list-id:${listId.toLowerCase()}` + : normalizeListUnsubscribe(listUnsubscribe); + + if (dedupeKey) { + const existing = await prisma.unsubscribeAttempt.findFirst({ + where: { jobId: cleanupJobId, dedupeKey } + }); + const history = await prisma.unsubscribeHistory.findFirst({ + where: { tenantId: job.tenantId, dedupeKey } + }); + const historyAgeLimit = config.UNSUBSCRIBE_HISTORY_TTL_DAYS; + const historyEnabled = historyAgeLimit > 0; + const isHistoryFresh = historyEnabled && history + ? history.status !== "dry-run" && + Date.now() - history.createdAt.getTime() <= historyAgeLimit * 24 * 3600 * 1000 + : false; + if (existing || isHistoryFresh) { + unsubscribeStatus = "skipped-duplicate"; + unsubscribeMessage = "Duplicate unsubscribe target"; + unsubscribeDetails = { reason: "duplicate" }; + } else { + const attempt = await prisma.unsubscribeAttempt.create({ + data: { + jobId: cleanupJobId, + mailItemId: externalId, + dedupeKey, + method: "list-unsubscribe", + target: listUnsubscribe, + status: "pending" + } + }); + unsubscribeAttempts += 1; + + const attemptStart = Date.now(); + try { + if (job.dryRun) { + await prisma.unsubscribeAttempt.update({ + where: { id: attempt.id }, + data: { status: "dry-run" } + }); + await logJobEvent(cleanupJobId, "info", `DRY RUN: unsubscribe ${listUnsubscribe}`); + unsubscribeStatus = "dry-run"; + unsubscribeDetails = { reason: "dry-run" }; + } else { + const response = await unsubscribeFromHeader({ + account, + listUnsubscribe, + listUnsubscribePost, + subject: msg.subject, + from: msg.from + }); + + await prisma.unsubscribeAttempt.update({ + where: { id: attempt.id }, + data: { status: response.status } + }); + + await logJobEvent(cleanupJobId, "info", `Unsubscribe ${response.status}: ${response.message}`); + unsubscribeStatus = response.status; + unsubscribeMessage = response.message; + unsubscribeDetails = response.details ?? null; + await prisma.unsubscribeHistory.upsert({ + where: { tenantId_dedupeKey: { tenantId: job.tenantId, dedupeKey } }, + update: { status: response.status, target: listUnsubscribe, createdAt: new Date() }, + create: { tenantId: job.tenantId, dedupeKey, target: listUnsubscribe, status: response.status } + }); + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + await prisma.unsubscribeAttempt.update({ + where: { id: attempt.id }, + data: { status: "failed" } + }); + await logJobEvent(cleanupJobId, "error", `Unsubscribe failed: ${message}`); + unsubscribeStatus = "failed"; + unsubscribeMessage = message; + unsubscribeDetails = { reason: "exception", error: message }; + await prisma.unsubscribeHistory.upsert({ + where: { tenantId_dedupeKey: { tenantId: job.tenantId, dedupeKey } }, + update: { status: "failed", target: listUnsubscribe, createdAt: new Date() }, + create: { tenantId: job.tenantId, dedupeKey, target: listUnsubscribe, status: "failed" } + }); + } + unsubscribeSeconds += (Date.now() - attemptStart) / 1000; + } + } else { + unsubscribeStatus = "skipped"; + unsubscribeMessage = "No usable List-Unsubscribe target"; + unsubscribeDetails = { reason: "missing-target" }; + } + } else { + unsubscribeStatus = "skipped"; + unsubscribeDetails = { reason: "disabled" }; + } + } + + const routingCtx = { ...ctx, unsubscribeStatus, newsletterScore: result.score }; + const actions = job.routingEnabled ? matchRules(rules, routingCtx) : []; + const actionLog: { type: string; target?: string | null; status: string; error?: string }[] = []; if (actions.length > 0) { for (const action of actions) { if (job.dryRun) { await logJobEvent(cleanupJobId, "info", `DRY RUN: ${action.type} ${action.target ?? ""}`); + actionLog.push({ type: action.type, target: action.target ?? null, status: "dry-run" }); continue; } - if (account.provider === "GMAIL" && msg.gmailMessageId) { - await applyGmailAction({ - account, - gmailMessageId: msg.gmailMessageId, - action: action.type, - target: action.target - }); - await logJobEvent(cleanupJobId, "info", `Gmail action ${action.type} applied`); - continue; + if (account.provider === "GMAIL" && msg.gmailMessageId && gmailContext) { + const actionStart = Date.now(); + actionAttempts += 1; + const actionLogItems = actions.map((item) => ({ + type: item.type, + target: item.target ?? null, + status: "pending" as const, + error: undefined as string | undefined + })); + const hasDelete = actions.some((item) => item.type === "DELETE"); + try { + if (hasDelete) { + await gmailContext.gmail.users.messages.delete({ userId: "me", id: msg.gmailMessageId }); + await logJobEvent(cleanupJobId, "info", "Gmail action DELETE applied"); + for (const item of actionLogItems) { + if (item.type === "DELETE") { + item.status = "applied"; + } else { + item.status = "skipped"; + } + } + } else { + const addLabelIds = new Set(); + const removeLabelIds = new Set(); + for (const item of actions) { + if ((item.type === "MOVE" || item.type === "LABEL") && item.target) { + const labelId = await gmailContext.resolveLabelId(item.target); + addLabelIds.add(labelId); + if (item.type === "MOVE") { + removeLabelIds.add("INBOX"); + } + } + if (item.type === "ARCHIVE") { + removeLabelIds.add("INBOX"); + } + if (item.type === "MARK_READ") { + removeLabelIds.add("UNREAD"); + } + if (item.type === "MARK_UNREAD") { + addLabelIds.add("UNREAD"); + } + } + if (addLabelIds.size === 0 && removeLabelIds.size === 0) { + await logJobEvent(cleanupJobId, "info", "Gmail action skipped: no label changes"); + for (const item of actionLogItems) { + item.status = "skipped"; + } + } else { + await gmailContext.gmail.users.messages.modify({ + userId: "me", + id: msg.gmailMessageId, + requestBody: { + addLabelIds: Array.from(addLabelIds), + removeLabelIds: Array.from(removeLabelIds) + } + }); + await logJobEvent(cleanupJobId, "info", `Gmail action applied: ${actions.map((a) => a.type).join(", ")}`); + for (const item of actionLogItems) { + item.status = "applied"; + } + } + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + await logJobEvent(cleanupJobId, "error", `Gmail action failed: ${message}`); + for (const item of actionLogItems) { + if (item.status === "pending") { + item.status = "failed"; + item.error = message; + } + } + } finally { + routingSeconds += (Date.now() - actionStart) / 1000; + } + actionLog.push(...actionLogItems); + break; } if (!imapClient) { await logJobEvent(cleanupJobId, "info", "Skipping IMAP action: no IMAP client"); + actionLog.push({ type: action.type, target: action.target ?? null, status: "skipped" }); } else { - if ((action.type === "MOVE" || action.type === "ARCHIVE" || action.type === "LABEL") && action.target) { - await imapClient.mailboxCreate(action.target).catch(() => undefined); - await imapClient.messageMove(msg.uid, action.target); - await logJobEvent(cleanupJobId, "info", `Moved message ${msg.uid} to ${action.target}`); - } - if (action.type === "DELETE") { - await imapClient.messageDelete(msg.uid); - await logJobEvent(cleanupJobId, "info", `Deleted message ${msg.uid}`); + const actionStart = Date.now(); + actionAttempts += 1; + try { + if ((action.type === "MOVE" || action.type === "ARCHIVE" || action.type === "LABEL") && action.target) { + await imapClient.mailboxCreate(action.target).catch(() => undefined); + await imapClient.messageMove(msg.uid, action.target); + await logJobEvent(cleanupJobId, "info", `Moved message ${msg.uid} to ${action.target}`); + } + if (action.type === "DELETE") { + await imapClient.messageDelete(msg.uid); + await logJobEvent(cleanupJobId, "info", `Deleted message ${msg.uid}`); + } + if (action.type === "MARK_READ") { + await imapClient.messageFlagsAdd(msg.uid, ["\\Seen"]); + await logJobEvent(cleanupJobId, "info", `Marked message ${msg.uid} as read`); + } + if (action.type === "MARK_UNREAD") { + await imapClient.messageFlagsRemove(msg.uid, ["\\Seen"]); + await logJobEvent(cleanupJobId, "info", `Marked message ${msg.uid} as unread`); + } + actionLog.push({ type: action.type, target: action.target ?? null, status: "applied" }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + await logJobEvent(cleanupJobId, "error", `IMAP action ${action.type} failed: ${message}`); + actionLog.push({ type: action.type, target: action.target ?? null, status: "failed", error: message }); + } finally { + routingSeconds += (Date.now() - actionStart) / 1000; } } } } - if (job.unsubscribeEnabled) { - const listUnsubscribe = msg.headers.get("list-unsubscribe") ?? null; - const listUnsubscribePost = msg.headers.get("list-unsubscribe-post") ?? null; - if (listUnsubscribe) { - const attempt = await prisma.unsubscribeAttempt.create({ - data: { - jobId: cleanupJobId, - method: "list-unsubscribe", - target: listUnsubscribe, - status: "pending" - } - }); - - try { - if (job.dryRun) { - await prisma.unsubscribeAttempt.update({ - where: { id: attempt.id }, - data: { status: "dry-run" } - }); - await logJobEvent(cleanupJobId, "info", `DRY RUN: unsubscribe ${listUnsubscribe}`); - } else { - const response = await unsubscribeFromHeader({ - account, - listUnsubscribe, - listUnsubscribePost, - subject: msg.subject, - from: msg.from - }); - - await prisma.unsubscribeAttempt.update({ - where: { id: attempt.id }, - data: { status: response.status } - }); - - await logJobEvent(cleanupJobId, "info", `Unsubscribe ${response.status}: ${response.message}`); - } - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - await prisma.unsubscribeAttempt.update({ - where: { id: attempt.id }, - data: { status: "failed" } - }); - await logJobEvent(cleanupJobId, "error", `Unsubscribe failed: ${message}`); + if (actionLog.length || unsubscribeStatus !== "pending" || unsubscribeTarget) { + await prisma.cleanupJobCandidate.update({ + where: { id: candidate.id }, + data: { + actions: actionLog.length ? actionLog : undefined, + unsubscribeStatus, + unsubscribeMessage, + unsubscribeTarget, + unsubscribeDetails } - } + }); } + const cleanupBefore = new Date(Date.now() - config.UNSUBSCRIBE_HISTORY_TTL_DAYS * 24 * 3600 * 1000); + await prisma.unsubscribeHistory.deleteMany({ + where: { tenantId: job.tenantId, createdAt: { lt: cleanupBefore } } + }); + return true; }; @@ -204,9 +491,38 @@ export const runCleanup = async (cleanupJobId: string, mailboxAccountId: string) let nextIndex = 0; let messageIds: string[] = []; let imapUids: number[] = []; + let listingSeconds: number | null = null; + let processingSeconds: number | null = null; + let unsubscribeSeconds = 0; + let routingSeconds = 0; + let unsubscribeAttempts = 0; + let actionAttempts = 0; + const imapMailboxCache = new Set(); if (isGmail && hasGmailOAuth) { const { gmail } = await gmailClientForAccount(account); + const labelCache = new Map(); + let labelsLoaded = false; + const resolveLabelId = async (labelName: string) => { + if (labelCache.has(labelName)) return labelCache.get(labelName)!; + if (!labelsLoaded) { + const list = await gmail.users.labels.list({ userId: "me" }); + for (const label of list.data.labels ?? []) { + if (label.name && label.id) { + labelCache.set(label.name, label.id); + } + } + labelsLoaded = true; + if (labelCache.has(labelName)) return labelCache.get(labelName)!; + } + const created = await gmail.users.labels.create({ + userId: "me", + requestBody: { name: labelName, labelListVisibility: "labelShow", messageListVisibility: "show" } + }); + const id = created.data.id ?? labelName; + labelCache.set(labelName, id); + return id; + }; if (checkpoint?.provider === "GMAIL") { messageIds = checkpoint.messageIds ?? []; total = checkpoint.total ?? messageIds.length; @@ -215,6 +531,7 @@ export const runCleanup = async (cleanupJobId: string, mailboxAccountId: string) } if (!messageIds.length) { + const listingStart = Date.now(); const ids: string[] = []; let pageToken: string | undefined; let pageCount = 0; @@ -250,6 +567,7 @@ export const runCleanup = async (cleanupJobId: string, mailboxAccountId: string) total }; await saveCheckpoint(checkpoint, nextIndex, total); + listingSeconds = Math.max(1, Math.round((Date.now() - listingStart) / 1000)); await logJobEvent(cleanupJobId, "info", `Prepared ${total} Gmail messages`, 12); } else { await logJobEvent(cleanupJobId, "info", `Resuming Gmail cleanup at ${nextIndex}/${total}`, 12); @@ -268,13 +586,33 @@ export const runCleanup = async (cleanupJobId: string, mailboxAccountId: string) if (total === 0) { await logJobEvent(cleanupJobId, "info", "No Gmail messages to process", 90); + await prisma.cleanupJob.update({ + where: { id: cleanupJobId }, + data: { + listingSeconds, + processingSeconds: processingSeconds ?? null, + unsubscribeSeconds: unsubscribeSeconds ? Math.max(1, Math.round(unsubscribeSeconds)) : null, + routingSeconds: routingSeconds ? Math.max(1, Math.round(routingSeconds)) : null, + unsubscribeAttempts: unsubscribeAttempts || null, + actionAttempts: actionAttempts || null + } + }); return; } await logJobEvent(cleanupJobId, "info", `Processing ${total} Gmail messages`, 35); const processingStart = Date.now(); let newsletterCount = 0; - const headersWanted = ["Subject", "From", "List-Id", "List-Unsubscribe", "List-Unsubscribe-Post", "Message-Id"]; + const normalizeHeaderName = (value: string) => + value + .split("-") + .map((part) => (part ? part[0].toUpperCase() + part.slice(1) : part)) + .join("-"); + const headerSet = new Set(["Subject", "From", "Message-Id", "Precedence", "X-Precedence"]); + for (const key of newsletterConfig.headerKeys) { + headerSet.add(normalizeHeaderName(key)); + } + const headersWanted = Array.from(headerSet); for (let index = nextIndex; index < messageIds.length; index++) { const statusCheck = await prisma.cleanupJob.findUnique({ where: { id: cleanupJobId } }); @@ -300,11 +638,13 @@ export const runCleanup = async (cleanupJobId: string, mailboxAccountId: string) uid: 0, subject: headers.get("subject"), from: headers.get("from"), + receivedAt: meta.data.internalDate ? new Date(Number(meta.data.internalDate)) : undefined, headers, - gmailMessageId: id + gmailMessageId: id, + mailbox: "INBOX" }; - const isNewsletter = await processMessage(msg); + const isNewsletter = await processMessage(msg, { gmail, resolveLabelId }); if (isNewsletter) newsletterCount += 1; const processed = index + 1; @@ -321,6 +661,18 @@ export const runCleanup = async (cleanupJobId: string, mailboxAccountId: string) } await logJobEvent(cleanupJobId, "info", `Detected ${newsletterCount} newsletter candidates`, 92); + processingSeconds = Math.max(1, Math.round((Date.now() - processingStart) / 1000)); + await prisma.cleanupJob.update({ + where: { id: cleanupJobId }, + data: { + listingSeconds, + processingSeconds, + unsubscribeSeconds: unsubscribeSeconds ? Math.max(1, Math.round(unsubscribeSeconds)) : null, + routingSeconds: routingSeconds ? Math.max(1, Math.round(routingSeconds)) : null, + unsubscribeAttempts: unsubscribeAttempts || null, + actionAttempts: actionAttempts || null + } + }); return; } @@ -342,6 +694,7 @@ export const runCleanup = async (cleanupJobId: string, mailboxAccountId: string) await imapClient.mailboxOpen(targetMailbox, { readOnly: job.dryRun }); if (!imapUids.length) { + const listingStart = Date.now(); await logJobEvent(cleanupJobId, "info", `Scanning ${targetMailbox}`, 15); const search = await imapClient.search({ all: true }); const limited = scanLimit && scanLimit > 0 ? search.slice(-scanLimit) : search; @@ -357,6 +710,7 @@ export const runCleanup = async (cleanupJobId: string, mailboxAccountId: string) total }; await saveCheckpoint(checkpoint, nextIndex, total); + listingSeconds = Math.max(1, Math.round((Date.now() - listingStart) / 1000)); await logJobEvent(cleanupJobId, "info", `Prepared ${total} IMAP messages`, 18); } else { await logJobEvent(cleanupJobId, "info", `Resuming IMAP cleanup at ${nextIndex}/${total}`, 18); @@ -376,6 +730,17 @@ export const runCleanup = async (cleanupJobId: string, mailboxAccountId: string) if (total === 0) { await logJobEvent(cleanupJobId, "info", "No IMAP messages to process", 90); + await prisma.cleanupJob.update({ + where: { id: cleanupJobId }, + data: { + listingSeconds, + processingSeconds: processingSeconds ?? null, + unsubscribeSeconds: unsubscribeSeconds ? Math.max(1, Math.round(unsubscribeSeconds)) : null, + routingSeconds: routingSeconds ? Math.max(1, Math.round(routingSeconds)) : null, + unsubscribeAttempts: unsubscribeAttempts || null, + actionAttempts: actionAttempts || null + } + }); return; } @@ -401,7 +766,7 @@ export const runCleanup = async (cleanupJobId: string, mailboxAccountId: string) processed += 1; continue; } - const isNewsletter = await processMessage(msg); + const isNewsletter = await processMessage({ ...msg, mailbox: targetMailbox }); if (isNewsletter) newsletterCount += 1; processed += 1; } @@ -418,6 +783,18 @@ export const runCleanup = async (cleanupJobId: string, mailboxAccountId: string) } await logJobEvent(cleanupJobId, "info", `Detected ${newsletterCount} newsletter candidates`, 92); + processingSeconds = Math.max(1, Math.round((Date.now() - processingStart) / 1000)); + await prisma.cleanupJob.update({ + where: { id: cleanupJobId }, + data: { + listingSeconds, + processingSeconds, + unsubscribeSeconds: unsubscribeSeconds ? Math.max(1, Math.round(unsubscribeSeconds)) : null, + routingSeconds: routingSeconds ? Math.max(1, Math.round(routingSeconds)) : null, + unsubscribeAttempts: unsubscribeAttempts || null, + actionAttempts: actionAttempts || null + } + }); } finally { await imapClient?.logout().catch(() => undefined); } diff --git a/backend/src/mail/gmail.ts b/backend/src/mail/gmail.ts index 7aa93c47..ff3d6c90 100644 --- a/backend/src/mail/gmail.ts +++ b/backend/src/mail/gmail.ts @@ -105,7 +105,7 @@ export const ensureGmailLabel = async (gmail: ReturnType, l export const applyGmailAction = async (params: { account: MailboxAccount; gmailMessageId: string; - action: "LABEL" | "MOVE" | "ARCHIVE" | "DELETE"; + action: "LABEL" | "MOVE" | "ARCHIVE" | "DELETE" | "MARK_READ" | "MARK_UNREAD"; target?: string | null; }) => { const { gmail } = await gmailClientForAccount(params.account); @@ -124,13 +124,34 @@ export const applyGmailAction = async (params: { return; } + if (params.action === "MARK_READ") { + await gmail.users.messages.modify({ + userId: "me", + id: params.gmailMessageId, + requestBody: { removeLabelIds: ["UNREAD"] } + }); + return; + } + + if (params.action === "MARK_UNREAD") { + await gmail.users.messages.modify({ + userId: "me", + id: params.gmailMessageId, + requestBody: { addLabelIds: ["UNREAD"] } + }); + return; + } + if (params.action === "MOVE" || params.action === "LABEL") { const labelName = params.target ?? "Newsletter"; const labelId = await ensureGmailLabel(gmail, labelName); await gmail.users.messages.modify({ userId: "me", id: params.gmailMessageId, - requestBody: { addLabelIds: [labelId] } + requestBody: { + addLabelIds: [labelId], + ...(params.action === "MOVE" ? { removeLabelIds: ["INBOX"] } : {}) + } }); } }; diff --git a/backend/src/mail/imap.ts b/backend/src/mail/imap.ts index 66f3724d..32debb0b 100644 --- a/backend/src/mail/imap.ts +++ b/backend/src/mail/imap.ts @@ -35,6 +35,7 @@ export const fetchHeaders = async ( uid: number; subject?: string; from?: string; + receivedAt?: Date; headers: Map; gmailMessageId?: string; }[]; @@ -55,6 +56,7 @@ export const fetchHeaders = async ( uid: msg.uid, subject: parsed.subject, from: parsed.from?.text, + receivedAt: parsed.date ?? undefined, headers, gmailMessageId: (msg as { gmailMessageId?: string }).gmailMessageId }); @@ -72,6 +74,7 @@ export const fetchHeadersByUids = async (client: ImapFlow, uids: number[]) => { uid: number; subject?: string; from?: string; + receivedAt?: Date; headers: Map; gmailMessageId?: string; }[]; @@ -88,6 +91,7 @@ export const fetchHeadersByUids = async (client: ImapFlow, uids: number[]) => { uid: msg.uid, subject: parsed.subject, from: parsed.from?.text, + receivedAt: parsed.date ?? undefined, headers, gmailMessageId: (msg as { gmailMessageId?: string }).gmailMessageId }); diff --git a/backend/src/mail/newsletter.ts b/backend/src/mail/newsletter.ts index e411663c..4483f082 100644 --- a/backend/src/mail/newsletter.ts +++ b/backend/src/mail/newsletter.ts @@ -1,5 +1,33 @@ -const headerIncludes = (headers: Map, key: string) => - headers.has(key.toLowerCase()); +export type NewsletterConfig = { + threshold: number; + headerKeys: string[]; + subjectTokens: string[]; + fromTokens: string[]; + weightHeader: number; + weightPrecedence: number; + weightSubject: number; + weightFrom: number; +}; + +const DEFAULT_CONFIG: NewsletterConfig = { + threshold: 2, + headerKeys: [ + "list-unsubscribe", + "list-id", + "list-help", + "list-archive", + "list-post", + "list-owner", + "list-subscribe", + "list-unsubscribe-post" + ], + subjectTokens: ["newsletter", "unsubscribe", "update", "news", "digest"], + fromTokens: ["newsletter", "no-reply", "noreply", "news", "updates"], + weightHeader: 1, + weightPrecedence: 1, + weightSubject: 1, + weightFrom: 1 +}; const headerValue = (headers: Map, key: string) => headers.get(key.toLowerCase()) ?? ""; @@ -7,39 +35,64 @@ const headerValue = (headers: Map, key: string) => const containsAny = (value: string, tokens: string[]) => tokens.some((token) => value.includes(token)); +const normalizeList = (items: string[]) => + items.map((item) => item.trim().toLowerCase()).filter(Boolean); + export const detectNewsletter = (params: { headers: Map; subject?: string | null; from?: string | null; + config?: Partial; }) => { const subject = (params.subject ?? "").toLowerCase(); const from = (params.from ?? "").toLowerCase(); const headers = params.headers; + const config: NewsletterConfig = { + threshold: params.config?.threshold ?? DEFAULT_CONFIG.threshold, + headerKeys: normalizeList(params.config?.headerKeys ?? DEFAULT_CONFIG.headerKeys), + subjectTokens: normalizeList(params.config?.subjectTokens ?? DEFAULT_CONFIG.subjectTokens), + fromTokens: normalizeList(params.config?.fromTokens ?? DEFAULT_CONFIG.fromTokens), + weightHeader: params.config?.weightHeader ?? DEFAULT_CONFIG.weightHeader, + weightPrecedence: params.config?.weightPrecedence ?? DEFAULT_CONFIG.weightPrecedence, + weightSubject: params.config?.weightSubject ?? DEFAULT_CONFIG.weightSubject, + weightFrom: params.config?.weightFrom ?? DEFAULT_CONFIG.weightFrom + }; - const hasListUnsubscribe = headerIncludes(headers, "list-unsubscribe"); - const hasListId = headerIncludes(headers, "list-id"); - + const matchedHeaderKeys = config.headerKeys.filter((key) => headers.has(key)); const precedence = headerValue(headers, "precedence").toLowerCase(); const bulkHeader = headerValue(headers, "x-precedence").toLowerCase(); + const precedenceHint = containsAny(precedence, ["bulk", "list"]) || containsAny(bulkHeader, ["bulk", "list"]); - const headerHints = containsAny(precedence, ["bulk", "list"]) || - containsAny(bulkHeader, ["bulk", "list"]) || - headerIncludes(headers, "list-unsubscribe-post"); + const subjectMatches = config.subjectTokens.filter((token) => subject.includes(token)); + const fromMatches = config.fromTokens.filter((token) => from.includes(token)); - const subjectHints = containsAny(subject, ["newsletter", "unsubscribe", "update", "news", "digest"]); - const fromHints = containsAny(from, ["newsletter", "no-reply", "noreply", "news", "updates"]); - - const score = [hasListUnsubscribe, hasListId, headerHints, subjectHints, fromHints].filter(Boolean).length; + const headerScore = matchedHeaderKeys.length * config.weightHeader; + const precedenceScore = precedenceHint ? config.weightPrecedence : 0; + const subjectScore = subjectMatches.length ? config.weightSubject : 0; + const fromScore = fromMatches.length ? config.weightFrom : 0; + const score = headerScore + precedenceScore + subjectScore + fromScore; return { - isNewsletter: score >= 2, + isNewsletter: score >= config.threshold, score, signals: { - hasListUnsubscribe, - hasListId, - headerHints, - subjectHints, - fromHints + headerKeys: matchedHeaderKeys, + precedenceHint, + subjectTokens: subjectMatches, + fromTokens: fromMatches, + scoreBreakdown: { + headerMatches: matchedHeaderKeys.length, + headerWeight: config.weightHeader, + headerScore, + precedenceWeight: config.weightPrecedence, + precedenceScore, + subjectMatches: subjectMatches.length, + subjectWeight: config.weightSubject, + subjectScore, + fromMatches: fromMatches.length, + fromWeight: config.weightFrom, + fromScore + } } }; }; diff --git a/backend/src/mail/routes.ts b/backend/src/mail/routes.ts index 3a47f966..d7715dd8 100644 --- a/backend/src/mail/routes.ts +++ b/backend/src/mail/routes.ts @@ -203,6 +203,9 @@ export async function mailRoutes(app: FastifyInstance) { await tx.unsubscribeAttempt.deleteMany({ where: { job: { mailboxAccountId: account.id } } }); + await tx.cleanupJobCandidate.deleteMany({ + where: { mailboxAccountId: account.id } + }); await tx.cleanupJob.deleteMany({ where: { mailboxAccountId: account.id } }); diff --git a/backend/src/mail/rules.ts b/backend/src/mail/rules.ts index aeeb97fb..607f5cf4 100644 --- a/backend/src/mail/rules.ts +++ b/backend/src/mail/rules.ts @@ -6,25 +6,66 @@ const getHeader = (headers: Map, name: string) => const contains = (value: string, needle: string) => value.toLowerCase().includes(needle.toLowerCase()); +const isWildcard = (value: string) => value.trim() === "*"; + const matchCondition = (condition: RuleCondition, ctx: { subject: string; from: string; headers: Map; + unsubscribeStatus?: string | null; + newsletterScore?: number | null; }) => { const value = condition.value; switch (condition.type) { case "SUBJECT": - return contains(ctx.subject, value); + return isWildcard(value) ? ctx.subject.trim().length > 0 : contains(ctx.subject, value); case "FROM": - return contains(ctx.from, value); + return isWildcard(value) ? ctx.from.trim().length > 0 : contains(ctx.from, value); case "LIST_ID": - return contains(getHeader(ctx.headers, "list-id"), value); + return isWildcard(value) + ? getHeader(ctx.headers, "list-id").trim().length > 0 + : contains(getHeader(ctx.headers, "list-id"), value); case "LIST_UNSUBSCRIBE": - return contains(getHeader(ctx.headers, "list-unsubscribe"), value); + return isWildcard(value) + ? getHeader(ctx.headers, "list-unsubscribe").trim().length > 0 + : contains(getHeader(ctx.headers, "list-unsubscribe"), value); case "HEADER": { - const [headerName, headerValue] = value.split(":"); - if (!headerName || !headerValue) return false; - return contains(getHeader(ctx.headers, headerName.trim()), headerValue.trim()); + const parts = value.split(":"); + const headerName = parts[0]?.trim(); + const headerValue = parts.slice(1).join(":").trim(); + if (!headerName) return false; + const headerContent = getHeader(ctx.headers, headerName); + if (!headerValue || isWildcard(headerValue)) { + return headerContent.trim().length > 0; + } + return contains(headerContent, headerValue); + } + case "HEADER_MISSING": { + const parts = value.split(":"); + const headerName = parts[0]?.trim(); + if (!headerName) return false; + const headerContent = getHeader(ctx.headers, headerName); + return headerContent.trim().length === 0; + } + case "UNSUBSCRIBE_STATUS": { + if (!ctx.unsubscribeStatus) return false; + return isWildcard(value) + ? ctx.unsubscribeStatus.trim().length > 0 + : ctx.unsubscribeStatus.toLowerCase() === value.toLowerCase(); + } + case "SCORE": { + if (ctx.newsletterScore === null || ctx.newsletterScore === undefined) return false; + const trimmed = value.trim(); + const match = trimmed.match(/^(>=|<=|>|<|=)?\s*(\d+)$/); + if (!match) return false; + const op = match[1] ?? ">="; + const threshold = Number.parseInt(match[2], 10); + if (!Number.isFinite(threshold)) return false; + if (op === ">") return ctx.newsletterScore > threshold; + if (op === "<") return ctx.newsletterScore < threshold; + if (op === "<=") return ctx.newsletterScore <= threshold; + if (op === "=") return ctx.newsletterScore === threshold; + return ctx.newsletterScore >= threshold; } default: return false; @@ -35,14 +76,21 @@ export const matchRules = (rules: (Rule & { conditions: RuleCondition[]; actions subject: string; from: string; headers: Map; + unsubscribeStatus?: string | null; + newsletterScore?: number | null; }) => { const matched: RuleAction[] = []; for (const rule of rules) { if (!rule.enabled) continue; const allMatch = rule.conditions.every((condition) => matchCondition(condition, ctx)); - if (allMatch) { + const anyMatch = rule.conditions.some((condition) => matchCondition(condition, ctx)); + const shouldApply = rule.matchMode === "ANY" ? anyMatch : allMatch; + if (shouldApply) { matched.push(...rule.actions); + if (rule.stopOnMatch) { + break; + } } } diff --git a/backend/src/mail/unsubscribe.ts b/backend/src/mail/unsubscribe.ts index c3327dbf..0f49c95a 100644 --- a/backend/src/mail/unsubscribe.ts +++ b/backend/src/mail/unsubscribe.ts @@ -3,6 +3,8 @@ import { MailboxAccount } from "@prisma/client"; import { isPrivateHost } from "../security/ssrf.js"; import { decryptSecret } from "../security/crypto.js"; import { config } from "../config.js"; +import { gmailClientForAccount } from "./gmail.js"; +import { getSetting } from "../admin/settings.js"; const parseListUnsubscribe = (value: string) => { const tokens = value @@ -24,67 +26,123 @@ export const unsubscribeFromHeader = async (params: { from?: string | null; }) => { if (!params.listUnsubscribe) { - return { status: "skipped", message: "No List-Unsubscribe header" }; + return { status: "skipped", message: "No List-Unsubscribe header", details: { method: "NONE" } }; } const { httpLinks, mailtoLinks } = parseListUnsubscribe(params.listUnsubscribe); const postHint = (params.listUnsubscribePost ?? "").toLowerCase(); + const preference = ((await getSetting("unsubscribe.method_preference")) ?? config.UNSUBSCRIBE_METHOD_PREFERENCE ?? "auto").toLowerCase(); - if (httpLinks.length > 0) { - const target = httpLinks[0]; + const tryHttp = async (target: string) => { let parsed: URL; try { parsed = new URL(target); } catch { - return { status: "failed", message: "Invalid unsubscribe URL" }; + return { status: "failed", message: "Invalid unsubscribe URL", details: { method: "HTTP", url: target } }; } if (!["http:", "https:"].includes(parsed.protocol)) { - return { status: "failed", message: "Unsupported URL scheme" }; + return { status: "failed", message: "Unsupported URL scheme", details: { method: "HTTP", url: target } }; } if (config.BLOCK_PRIVATE_NETWORKS && await isPrivateHost(parsed.hostname)) { - return { status: "failed", message: "Blocked private network URL" }; + return { status: "failed", message: "Blocked private network URL", details: { method: "HTTP", url: target } }; } const usePost = postHint.includes("one-click"); - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 8000); - const response = await fetch(target, { - method: usePost ? "POST" : "GET", - headers: usePost ? { "Content-Type": "application/x-www-form-urlencoded" } : undefined, - body: usePost ? "List-Unsubscribe=One-Click" : undefined, - redirect: "manual", - signal: controller.signal - }); - clearTimeout(timeout); + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 8000); + const response = await fetch(target, { + method: usePost ? "POST" : "GET", + headers: usePost ? { "Content-Type": "application/x-www-form-urlencoded" } : undefined, + body: usePost ? "List-Unsubscribe=One-Click" : undefined, + redirect: "manual", + signal: controller.signal + }); + clearTimeout(timeout); - if (response.status >= 300 && response.status < 400) { - const location = response.headers.get("location"); - if (!location) { - return { status: "failed", message: `HTTP ${response.status}` }; - } - try { - const redirected = new URL(location, parsed); - if (config.BLOCK_PRIVATE_NETWORKS && await isPrivateHost(redirected.hostname)) { - return { status: "failed", message: "Blocked private redirect" }; + if (response.status >= 300 && response.status < 400) { + const location = response.headers.get("location"); + if (!location) { + return { status: "failed", message: `HTTP ${response.status}`, details: { method: usePost ? "POST" : "GET", url: target, status: response.status } }; + } + try { + const redirected = new URL(location, parsed); + if (config.BLOCK_PRIVATE_NETWORKS && await isPrivateHost(redirected.hostname)) { + return { status: "failed", message: "Blocked private redirect", details: { method: usePost ? "POST" : "GET", url: target, redirect: location } }; + } + } catch { + return { status: "failed", message: "Invalid redirect URL", details: { method: usePost ? "POST" : "GET", url: target, redirect: location } }; } - } catch { - return { status: "failed", message: "Invalid redirect URL" }; } + + return { + status: response.ok ? "ok" : "failed", + message: `HTTP ${response.status}`, + details: { method: usePost ? "POST" : "GET", url: target, status: response.status } + }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { status: "failed", message: "HTTP request failed", details: { method: "HTTP", url: target, error: message } }; + } + }; + + const tryMailto = async (target: string) => { + const mailtoUrl = (() => { + try { + return new URL(target); + } catch { + return null; + } + })(); + const to = mailtoUrl ? decodeURIComponent(mailtoUrl.pathname) : target.replace("mailto:", ""); + const mailSubject = mailtoUrl?.searchParams.get("subject") ?? params.subject ?? "Unsubscribe"; + const mailBody = mailtoUrl?.searchParams.get("body") ?? "Please unsubscribe me from this mailing list."; + + if (params.account.provider === "GMAIL" && (params.account.oauthAccessToken || params.account.oauthRefreshToken)) { + const { gmail } = await gmailClientForAccount(params.account); + const rawMessage = [ + `From: ${params.account.email}`, + `To: ${to}`, + `Subject: ${mailSubject}`, + ...(params.from ? [`Reply-To: ${params.from}`] : []), + ...(params.listUnsubscribe ? [`List-Unsubscribe: ${params.listUnsubscribe}`] : []), + "MIME-Version: 1.0", + "Content-Type: text/plain; charset=UTF-8", + "", + mailBody + ].join("\r\n"); + const raw = Buffer.from(rawMessage) + .toString("base64") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/g, ""); + await gmail.users.messages.send({ + userId: "me", + requestBody: { raw } + }); + return { + status: "ok", + message: "Unsubscribe email sent via Gmail API", + details: { + method: "MAILTO", + via: "gmail", + to, + subject: mailSubject, + body: mailBody, + replyTo: params.from ?? null, + listUnsubscribe: params.listUnsubscribe ?? null + } + }; } - return { status: response.ok ? "ok" : "failed", message: `HTTP ${response.status}` }; - } - - if (mailtoLinks.length > 0) { - const target = mailtoLinks[0]; const smtpHost = params.account.smtpHost; const smtpPort = params.account.smtpPort ?? 587; const smtpTLS = params.account.smtpTLS ?? true; if (!smtpHost || !params.account.appPassword) { - return { status: "failed", message: "SMTP credentials missing" }; + return { status: "failed", message: "SMTP credentials missing", details: { method: "MAILTO", via: "smtp", to } }; } const transporter = nodemailer.createTransport({ @@ -99,13 +157,57 @@ export const unsubscribeFromHeader = async (params: { await transporter.sendMail({ from: params.account.email, - to: target.replace("mailto:", ""), - subject: params.subject ?? "Unsubscribe", - text: "Please unsubscribe me from this mailing list." + to, + subject: mailSubject, + text: mailBody, + replyTo: params.from ?? undefined, + headers: params.listUnsubscribe ? { "List-Unsubscribe": params.listUnsubscribe } : undefined }); - return { status: "ok", message: "Unsubscribe email sent" }; + return { + status: "ok", + message: "Unsubscribe email sent", + details: { + method: "MAILTO", + via: "smtp", + to, + subject: mailSubject, + body: mailBody, + replyTo: params.from ?? null, + listUnsubscribe: params.listUnsubscribe ?? null + } + }; + }; + + const hasHttp = httpLinks.length > 0; + const hasMailto = mailtoLinks.length > 0; + + const fallbackToMailto = async (httpResult: { status: string; message: string; details?: Record }) => { + if (!hasMailto) return httpResult; + const mailResult = await tryMailto(mailtoLinks[0]); + return { + ...mailResult, + message: mailResult.status === "ok" ? `${mailResult.message} (HTTP failed)` : `${mailResult.message} (HTTP failed)` + }; + }; + + if (preference === "mailto") { + if (hasMailto) { + return tryMailto(mailtoLinks[0]); + } + if (hasHttp) { + return tryHttp(httpLinks[0]); + } + } else { + if (hasHttp) { + const httpResult = await tryHttp(httpLinks[0]); + if (httpResult.status === "ok") return httpResult; + return fallbackToMailto(httpResult); + } + if (hasMailto) { + return tryMailto(mailtoLinks[0]); + } } - return { status: "failed", message: "No supported unsubscribe link" }; + return { status: "failed", message: "No supported unsubscribe link", details: { method: "NONE" } }; }; diff --git a/backend/src/queue/routes.ts b/backend/src/queue/routes.ts index 9d42dc8f..b9aae6d6 100644 --- a/backend/src/queue/routes.ts +++ b/backend/src/queue/routes.ts @@ -1,6 +1,10 @@ import { FastifyInstance } from "fastify"; import { prisma } from "../db.js"; import { config } from "../config.js"; +import { createImapClient } from "../mail/imap.js"; +import { applyGmailAction, gmailClientForAccount } from "../mail/gmail.js"; +import { createImapClient } from "../mail/imap.js"; +import { simpleParser } from "mailparser"; export async function queueRoutes(app: FastifyInstance) { app.addHook("preHandler", app.authenticate); @@ -35,7 +39,35 @@ export async function queueRoutes(app: FastifyInstance) { orderBy: { createdAt: "desc" } }); - return { jobs }; + const metric = await prisma.tenantMetric.findUnique({ + where: { tenantId: request.user.tenantId } + }); + const providerMetrics = await prisma.tenantProviderMetric.findMany({ + where: { tenantId: request.user.tenantId } + }); + + return { + jobs, + meta: { + avgProcessingRate: metric?.avgProcessingRate ?? null, + sampleCount: metric?.sampleCount ?? 0, + providerMetrics: providerMetrics.map((item) => ({ + provider: item.provider, + avgListingRate: item.avgListingRate, + avgProcessingRate: item.avgProcessingRate, + avgUnsubscribeRate: item.avgUnsubscribeRate, + avgRoutingRate: item.avgRoutingRate, + avgListingSecondsPerMessage: item.avgListingSecondsPerMessage, + avgProcessingSecondsPerMessage: item.avgProcessingSecondsPerMessage, + avgUnsubscribeSecondsPerMessage: item.avgUnsubscribeSecondsPerMessage, + avgRoutingSecondsPerMessage: item.avgRoutingSecondsPerMessage, + listingSampleCount: item.listingSampleCount, + processingSampleCount: item.processingSampleCount, + unsubscribeSampleCount: item.unsubscribeSampleCount, + routingSampleCount: item.routingSampleCount + })) + } + }; }); app.get("/:id", async (request, reply) => { @@ -71,6 +103,704 @@ export async function queueRoutes(app: FastifyInstance) { return { events }; }); + app.get("/:id/candidates", async (request, reply) => { + const params = request.params as { id: string }; + const query = request.query as { + groupBy?: string; + groupValue?: string; + limit?: string; + cursor?: string; + q?: string; + status?: string; + reviewed?: string; + }; + + const job = await prisma.cleanupJob.findFirst({ + where: { id: params.id, tenantId: request.user.tenantId } + }); + if (!job) { + return reply.code(404).send({ message: "Job not found" }); + } + + const groupBy = query.groupBy ?? "none"; + const limitRaw = query.limit ? Number.parseInt(query.limit, 10) : 200; + const limit = Number.isFinite(limitRaw) ? Math.min(Math.max(limitRaw, 1), 500) : 200; + const cursor = query.cursor ?? null; + + type GroupField = "fromDomain" | "from" | "listId"; + const field: GroupField | null = + groupBy === "domain" + ? "fromDomain" + : groupBy === "from" + ? "from" + : groupBy === "listId" + ? "listId" + : null; + + const baseWhere: Record = { jobId: job.id }; + if (query.q) { + baseWhere.OR = [ + { subject: { contains: query.q, mode: "insensitive" } }, + { from: { contains: query.q, mode: "insensitive" } }, + { listId: { contains: query.q, mode: "insensitive" } } + ]; + } + if (query.status) { + baseWhere.unsubscribeStatus = query.status; + } + if (query.reviewed === "true") { + baseWhere.reviewed = true; + } + if (query.reviewed === "false") { + baseWhere.reviewed = false; + } + + if (field && query.groupValue === undefined) { + const groups = await prisma.cleanupJobCandidate.groupBy({ + by: [field], + where: baseWhere, + _count: { id: true }, + orderBy: { _count: { id: "desc" } } + }); + return { + groups: groups.map((item) => ({ + key: (item as Record)[field] ?? "", + count: item._count.id + })) + }; + } + + const where: Record = { ...baseWhere }; + if (field) { + const groupValue = query.groupValue ?? ""; + where[field] = groupValue ? groupValue : null; + } + + const total = await prisma.cleanupJobCandidate.count({ where }); + const items = await prisma.cleanupJobCandidate.findMany({ + where, + orderBy: { id: "desc" }, + take: limit + 1, + ...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}) + }); + const hasMore = items.length > limit; + const slice = hasMore ? items.slice(0, limit) : items; + + const dedupeKeys = items + .map((item) => { + if (item.listId) return `list-id:${item.listId.toLowerCase()}`; + if (item.listUnsubscribe) { + return item.listUnsubscribe + .split(",") + .map((token) => token.trim().replace(/^<|>$/g, "").toLowerCase()) + .filter(Boolean) + .sort() + .join(","); + } + return null; + }) + .filter(Boolean) as string[]; + + const histories = dedupeKeys.length + ? await prisma.unsubscribeHistory.findMany({ + where: { tenantId: job.tenantId, dedupeKey: { in: dedupeKeys } } + }) + : []; + const historyMap = new Map(histories.map((item) => [item.dedupeKey, item])); + + return { + total, + nextCursor: hasMore ? slice[slice.length - 1]?.id ?? null : null, + items: slice.map((item) => ({ + id: item.id, + subject: item.subject, + from: item.from, + fromDomain: item.fromDomain, + receivedAt: item.receivedAt, + listId: item.listId, + listUnsubscribe: item.listUnsubscribe, + listUnsubscribePost: item.listUnsubscribePost, + score: item.score, + signals: item.signals, + actions: item.actions, + unsubscribeStatus: item.unsubscribeStatus, + unsubscribeMessage: item.unsubscribeMessage, + unsubscribeTarget: item.unsubscribeTarget, + unsubscribeDetails: item.unsubscribeDetails, + history: (() => { + const key = item.listId + ? `list-id:${item.listId.toLowerCase()}` + : item.listUnsubscribe + ? item.listUnsubscribe + .split(",") + .map((token) => token.trim().replace(/^<|>$/g, "").toLowerCase()) + .filter(Boolean) + .sort() + .join(",") + : null; + if (!key) return null; + const history = historyMap.get(key); + if (!history) return null; + return { + status: history.status, + createdAt: history.createdAt, + target: history.target + }; + })(), + reviewed: item.reviewed + })) + }; + }); + + app.patch("/:id/candidates/:candidateId", async (request, reply) => { + const params = request.params as { id: string; candidateId: string }; + const body = request.body as { reviewed?: boolean }; + const job = await prisma.cleanupJob.findFirst({ + where: { id: params.id, tenantId: request.user.tenantId } + }); + if (!job) { + return reply.code(404).send({ message: "Job not found" }); + } + const updated = await prisma.cleanupJobCandidate.updateMany({ + where: { id: params.candidateId, jobId: job.id }, + data: { reviewed: body.reviewed ?? false } + }); + if (!updated.count) { + return reply.code(404).send({ message: "Candidate not found" }); + } + return { success: true }; + }); + + app.post("/:id/candidates/mark-reviewed", async (request, reply) => { + const params = request.params as { id: string }; + const query = request.query as { q?: string; status?: string; reviewed?: string }; + const body = request.body as { reviewed?: boolean }; + const job = await prisma.cleanupJob.findFirst({ + where: { id: params.id, tenantId: request.user.tenantId } + }); + if (!job) { + return reply.code(404).send({ message: "Job not found" }); + } + + const where: Record = { jobId: job.id }; + if (query.q) { + where.OR = [ + { subject: { contains: query.q, mode: "insensitive" } }, + { from: { contains: query.q, mode: "insensitive" } }, + { listId: { contains: query.q, mode: "insensitive" } } + ]; + } + if (query.status) { + where.unsubscribeStatus = query.status; + } + if (query.reviewed === "true") { + where.reviewed = true; + } + if (query.reviewed === "false") { + where.reviewed = false; + } + + const updated = await prisma.cleanupJobCandidate.updateMany({ + where, + data: { reviewed: body.reviewed ?? true } + }); + return { updated: updated.count }; + }); + + app.post("/:id/candidates/batch", async (request, reply) => { + const params = request.params as { id: string }; + const body = request.body as { ids?: string[]; reviewed?: boolean }; + const job = await prisma.cleanupJob.findFirst({ + where: { id: params.id, tenantId: request.user.tenantId } + }); + if (!job) { + return reply.code(404).send({ message: "Job not found" }); + } + const ids = (body.ids ?? []).filter(Boolean); + if (!ids.length) { + return reply.code(400).send({ message: "No candidate ids provided" }); + } + const updated = await prisma.cleanupJobCandidate.updateMany({ + where: { jobId: job.id, id: { in: ids } }, + data: { reviewed: body.reviewed ?? true } + }); + return { updated: updated.count }; + }); + + app.post("/:id/candidates/delete-gmail", async (request, reply) => { + const params = request.params as { id: string }; + const body = request.body as { ids?: string[] }; + const job = await prisma.cleanupJob.findFirst({ + where: { id: params.id, tenantId: request.user.tenantId } + }); + if (!job) { + return reply.code(404).send({ message: "Job not found" }); + } + const ids = (body.ids ?? []).filter(Boolean); + if (!ids.length) { + return reply.code(400).send({ message: "No candidate ids provided" }); + } + const account = await prisma.mailboxAccount.findUnique({ where: { id: job.mailboxAccountId } }); + if (!account || account.provider !== "GMAIL") { + return reply.code(400).send({ message: "Gmail account required" }); + } + + const candidates = await prisma.cleanupJobCandidate.findMany({ + where: { jobId: job.id, id: { in: ids } } + }); + + let deleted = 0; + let missing = 0; + let failed = 0; + + for (const candidate of candidates) { + const externalId = candidate.externalId ?? ""; + const gmailMessageId = externalId.startsWith("gmail:") ? externalId.slice(6) : null; + if (!gmailMessageId) { + failed += 1; + continue; + } + try { + await applyGmailAction({ + account, + gmailMessageId, + action: "DELETE" + }); + deleted += 1; + } catch (err) { + const status = (err as { code?: number; response?: { status?: number } })?.response?.status + ?? (err as { code?: number }).code; + if (status === 404 || status === 410) { + missing += 1; + } else { + failed += 1; + } + } + } + + return { deleted, missing, failed, total: candidates.length }; + }); + + app.post("/:id/candidates/delete", async (request, reply) => { + const params = request.params as { id: string }; + const body = request.body as { ids?: string[] }; + const job = await prisma.cleanupJob.findFirst({ + where: { id: params.id, tenantId: request.user.tenantId } + }); + if (!job) { + return reply.code(404).send({ message: "Job not found" }); + } + const ids = (body.ids ?? []).filter(Boolean); + if (!ids.length) { + return reply.code(400).send({ message: "No candidate ids provided" }); + } + const account = await prisma.mailboxAccount.findUnique({ where: { id: job.mailboxAccountId } }); + if (!account) { + return reply.code(404).send({ message: "Mailbox account not found" }); + } + + const candidates = await prisma.cleanupJobCandidate.findMany({ + where: { jobId: job.id, id: { in: ids } } + }); + + let deleted = 0; + let missing = 0; + let failed = 0; + + if (account.provider === "GMAIL") { + for (const candidate of candidates) { + const externalId = candidate.externalId ?? ""; + const gmailMessageId = externalId.startsWith("gmail:") ? externalId.slice(6) : null; + if (!gmailMessageId) { + failed += 1; + continue; + } + try { + await applyGmailAction({ + account, + gmailMessageId, + action: "DELETE" + }); + deleted += 1; + } catch (err) { + const status = (err as { response?: { status?: number }; code?: number })?.response?.status + ?? (err as { code?: number }).code; + if (status === 404 || status === 410) { + missing += 1; + } else { + failed += 1; + } + } + } + return { deleted, missing, failed, total: candidates.length }; + } + + const imapClient = createImapClient(account); + let currentMailbox: string | null = null; + try { + await imapClient.connect(); + for (const candidate of candidates) { + const externalId = candidate.externalId ?? ""; + if (!externalId.startsWith("imap:")) { + failed += 1; + continue; + } + const rest = externalId.slice(5); + const lastColon = rest.lastIndexOf(":"); + if (lastColon === -1) { + failed += 1; + continue; + } + const mailbox = rest.slice(0, lastColon); + const uidRaw = rest.slice(lastColon + 1); + const uid = Number.parseInt(uidRaw, 10); + if (!Number.isFinite(uid)) { + failed += 1; + continue; + } + try { + if (mailbox && mailbox !== currentMailbox) { + await imapClient.mailboxOpen(mailbox, { readOnly: false }); + currentMailbox = mailbox; + } + await imapClient.messageDelete(uid); + deleted += 1; + } catch (err) { + const message = (err as Error).message?.toLowerCase?.() ?? ""; + if (message.includes("no such message") || message.includes("not found") || message.includes("does not exist")) { + missing += 1; + } else { + failed += 1; + } + } + } + } finally { + await imapClient.logout().catch(() => undefined); + } + + return { deleted, missing, failed, total: candidates.length }; + }); + + app.get("/:id/candidates/export", async (request, reply) => { + const params = request.params as { id: string }; + const query = request.query as { q?: string; status?: string; reviewed?: string }; + const job = await prisma.cleanupJob.findFirst({ + where: { id: params.id, tenantId: request.user.tenantId } + }); + if (!job) { + return reply.code(404).send({ message: "Job not found" }); + } + + const where: Record = { jobId: job.id }; + if (query.status === "group") { + // reserved for group export when used with dedicated endpoint + } + if (query.q) { + where.OR = [ + { subject: { contains: query.q, mode: "insensitive" } }, + { from: { contains: query.q, mode: "insensitive" } }, + { listId: { contains: query.q, mode: "insensitive" } } + ]; + } + if (query.status) { + where.unsubscribeStatus = query.status; + } + if (query.reviewed === "true") { + where.reviewed = true; + } + if (query.reviewed === "false") { + where.reviewed = false; + } + + const candidates = await prisma.cleanupJobCandidate.findMany({ + where, + orderBy: { createdAt: "desc" } + }); + + const header = [ + "subject", + "from", + "receivedAt", + "listId", + "unsubscribeStatus", + "unsubscribeMessage", + "score", + "reviewed" + ].join(","); + const rows = candidates.map((item) => [ + JSON.stringify(item.subject ?? ""), + JSON.stringify(item.from ?? ""), + JSON.stringify(item.receivedAt ? item.receivedAt.toISOString() : ""), + JSON.stringify(item.listId ?? ""), + JSON.stringify(item.unsubscribeStatus ?? ""), + JSON.stringify(item.unsubscribeMessage ?? ""), + item.score, + item.reviewed ? "true" : "false" + ].join(",")); + reply.header("Content-Type", "text/csv"); + return reply.send([header, ...rows].join("\\n")); + }); + + app.get("/:id/candidates/export-group", async (request, reply) => { + const params = request.params as { id: string }; + const query = request.query as { groupBy?: string; groupValue?: string; q?: string; status?: string; reviewed?: string }; + const job = await prisma.cleanupJob.findFirst({ + where: { id: params.id, tenantId: request.user.tenantId } + }); + if (!job) { + return reply.code(404).send({ message: "Job not found" }); + } + + const where: Record = { jobId: job.id }; + const field = + query.groupBy === "domain" + ? "fromDomain" + : query.groupBy === "from" + ? "from" + : query.groupBy === "listId" + ? "listId" + : null; + if (field && query.groupValue !== undefined) { + const value = query.groupValue ?? ""; + where[field] = value ? value : null; + } + if (query.q) { + where.OR = [ + { subject: { contains: query.q, mode: "insensitive" } }, + { from: { contains: query.q, mode: "insensitive" } }, + { listId: { contains: query.q, mode: "insensitive" } } + ]; + } + if (query.status) { + where.unsubscribeStatus = query.status; + } + if (query.reviewed === "true") { + where.reviewed = true; + } + if (query.reviewed === "false") { + where.reviewed = false; + } + + const candidates = await prisma.cleanupJobCandidate.findMany({ + where, + orderBy: { createdAt: "desc" } + }); + + const header = [ + "subject", + "from", + "receivedAt", + "listId", + "unsubscribeStatus", + "unsubscribeMessage", + "score", + "reviewed" + ].join(","); + const rows = candidates.map((item) => [ + JSON.stringify(item.subject ?? ""), + JSON.stringify(item.from ?? ""), + JSON.stringify(item.receivedAt ? item.receivedAt.toISOString() : ""), + JSON.stringify(item.listId ?? ""), + JSON.stringify(item.unsubscribeStatus ?? ""), + JSON.stringify(item.unsubscribeMessage ?? ""), + item.score, + item.reviewed ? "true" : "false" + ].join(",")); + reply.header("Content-Type", "text/csv"); + return reply.send([header, ...rows].join("\\n")); + }); + + app.get("/:id/candidates/:candidateId/preview", async (request, reply) => { + const params = request.params as { id: string; candidateId: string }; + const job = await prisma.cleanupJob.findFirst({ + where: { id: params.id, tenantId: request.user.tenantId } + }); + if (!job) { + return reply.code(404).send({ message: "Job not found" }); + } + + const candidate = await prisma.cleanupJobCandidate.findFirst({ + where: { id: params.candidateId, jobId: job.id } + }); + if (!candidate) { + return reply.code(404).send({ message: "Candidate not found" }); + } + + const account = await prisma.mailboxAccount.findUnique({ + where: { id: candidate.mailboxAccountId } + }); + if (!account) { + return reply.code(404).send({ message: "Mailbox account not found" }); + } + + const trimPayload = (value?: string | null) => { + if (!value) return null; + const limit = 200000; + return value.length > limit ? `${value.slice(0, limit)}...` : value; + }; + + if (account.provider === "GMAIL" && candidate.externalId.startsWith("gmail:")) { + const messageId = candidate.externalId.replace("gmail:", ""); + const { gmail } = await gmailClientForAccount(account); + const raw = await gmail.users.messages.get({ userId: "me", id: messageId, format: "raw" }); + const rawValue = raw.data.raw ?? ""; + const normalized = rawValue.replace(/-/g, "+").replace(/_/g, "/"); + const padding = normalized.length % 4 ? "=".repeat(4 - (normalized.length % 4)) : ""; + const buffer = Buffer.from(normalized + padding, "base64"); + const parsed = await simpleParser(buffer); + return { + subject: parsed.subject ?? candidate.subject, + from: parsed.from?.text ?? candidate.from, + to: parsed.to?.text ?? null, + date: parsed.date?.toISOString() ?? null, + text: trimPayload(parsed.text ?? null), + html: trimPayload(typeof parsed.html === "string" ? parsed.html : null), + attachments: (parsed.attachments ?? []).map((item, index) => ({ + id: index, + filename: item.filename ?? null, + contentType: item.contentType ?? null, + size: item.size ?? null + })) + }; + } + + if (candidate.externalId.startsWith("imap:")) { + const rest = candidate.externalId.replace("imap:", ""); + const lastIndex = rest.lastIndexOf(":"); + const mailbox = lastIndex === -1 ? "INBOX" : rest.slice(0, lastIndex); + const uid = lastIndex === -1 ? Number.parseInt(rest, 10) : Number.parseInt(rest.slice(lastIndex + 1), 10); + if (!Number.isFinite(uid)) { + return reply.code(400).send({ message: "Invalid IMAP message reference" }); + } + + const client = createImapClient(account); + await client.connect(); + try { + await client.mailboxOpen(mailbox, { readOnly: true }); + let source: Buffer | null = null; + for await (const msg of client.fetch([uid], { source: true, envelope: true })) { + source = msg.source ?? null; + } + if (!source) { + return reply.code(404).send({ message: "Message not found" }); + } + const parsed = await simpleParser(source); + return { + subject: parsed.subject ?? candidate.subject, + from: parsed.from?.text ?? candidate.from, + to: parsed.to?.text ?? null, + date: parsed.date?.toISOString() ?? null, + text: trimPayload(parsed.text ?? null), + html: trimPayload(typeof parsed.html === "string" ? parsed.html : null), + attachments: (parsed.attachments ?? []).map((item, index) => ({ + id: index, + filename: item.filename ?? null, + contentType: item.contentType ?? null, + size: item.size ?? null + })) + }; + } finally { + await client.logout().catch(() => undefined); + } + } + + return reply.code(400).send({ message: "Unsupported provider" }); + }); + + app.get("/:id/candidates/:candidateId/attachments/:index", async (request, reply) => { + const params = request.params as { id: string; candidateId: string; index: string }; + const attachmentIndex = Number.parseInt(params.index, 10); + if (!Number.isFinite(attachmentIndex) || attachmentIndex < 0) { + return reply.code(400).send({ message: "Invalid attachment index" }); + } + + const job = await prisma.cleanupJob.findFirst({ + where: { id: params.id, tenantId: request.user.tenantId } + }); + if (!job) { + return reply.code(404).send({ message: "Job not found" }); + } + + const candidate = await prisma.cleanupJobCandidate.findFirst({ + where: { id: params.candidateId, jobId: job.id } + }); + if (!candidate) { + return reply.code(404).send({ message: "Candidate not found" }); + } + + const account = await prisma.mailboxAccount.findUnique({ + where: { id: candidate.mailboxAccountId } + }); + if (!account) { + return reply.code(404).send({ message: "Mailbox account not found" }); + } + + const resolveAttachment = async () => { + if (account.provider === "GMAIL" && candidate.externalId.startsWith("gmail:")) { + const messageId = candidate.externalId.replace("gmail:", ""); + const { gmail } = await gmailClientForAccount(account); + const raw = await gmail.users.messages.get({ userId: "me", id: messageId, format: "raw" }); + const rawValue = raw.data.raw ?? ""; + const normalized = rawValue.replace(/-/g, "+").replace(/_/g, "/"); + const padding = normalized.length % 4 ? "=".repeat(4 - (normalized.length % 4)) : ""; + const buffer = Buffer.from(normalized + padding, "base64"); + const parsed = await simpleParser(buffer); + return parsed.attachments ?? []; + } + + if (candidate.externalId.startsWith("imap:")) { + const rest = candidate.externalId.replace("imap:", ""); + const lastIndex = rest.lastIndexOf(":"); + const mailbox = lastIndex === -1 ? "INBOX" : rest.slice(0, lastIndex); + const uid = lastIndex === -1 ? Number.parseInt(rest, 10) : Number.parseInt(rest.slice(lastIndex + 1), 10); + if (!Number.isFinite(uid)) { + return null; + } + const client = createImapClient(account); + await client.connect(); + try { + await client.mailboxOpen(mailbox, { readOnly: true }); + let source: Buffer | null = null; + for await (const msg of client.fetch([uid], { source: true })) { + source = msg.source ?? null; + } + if (!source) { + return null; + } + const parsed = await simpleParser(source); + return parsed.attachments ?? []; + } finally { + await client.logout().catch(() => undefined); + } + } + + return null; + }; + + const attachments = await resolveAttachment(); + if (!attachments) { + return reply.code(400).send({ message: "Unsupported provider" }); + } + + const attachment = attachments[attachmentIndex]; + if (!attachment || !attachment.content) { + return reply.code(404).send({ message: "Attachment not found" }); + } + + const size = attachment.size ?? attachment.content.length ?? 0; + if (size > config.ATTACHMENT_MAX_BYTES) { + return reply.code(413).send({ message: "Attachment too large" }); + } + + const safeName = (attachment.filename ?? `attachment-${attachmentIndex}`) + .replace(/[^a-zA-Z0-9._-]/g, "_") + .slice(0, 180); + reply.header("Content-Type", "application/octet-stream"); + reply.header("Content-Disposition", `attachment; filename=\"${safeName}\"`); + reply.header("Content-Length", size); + return reply.send(attachment.content); + }); + app.get("/:id/stream-token", async (request, reply) => { const params = request.params as { id: string }; const job = await prisma.cleanupJob.findFirst({ diff --git a/backend/src/rules/routes.ts b/backend/src/rules/routes.ts index a1911f12..80bcd499 100644 --- a/backend/src/rules/routes.ts +++ b/backend/src/rules/routes.ts @@ -5,12 +5,14 @@ import { prisma } from "../db.js"; const ruleSchema = z.object({ name: z.string().min(2), enabled: z.boolean().optional(), + matchMode: z.enum(["ALL", "ANY"]).optional(), + stopOnMatch: z.boolean().optional(), conditions: z.array(z.object({ - type: z.enum(["HEADER", "SUBJECT", "FROM", "LIST_UNSUBSCRIBE", "LIST_ID"]), + type: z.enum(["HEADER", "HEADER_MISSING", "SUBJECT", "FROM", "LIST_UNSUBSCRIBE", "LIST_ID", "UNSUBSCRIBE_STATUS", "SCORE"]), value: z.string().min(1) })), actions: z.array(z.object({ - type: z.enum(["MOVE", "DELETE", "ARCHIVE", "LABEL"]), + type: z.enum(["MOVE", "DELETE", "ARCHIVE", "LABEL", "MARK_READ", "MARK_UNREAD"]), target: z.string().optional() })) }); @@ -21,18 +23,28 @@ export async function rulesRoutes(app: FastifyInstance) { app.get("/", async (request) => { const rules = await prisma.rule.findMany({ where: { tenantId: request.user.tenantId }, - include: { conditions: true, actions: true } + include: { conditions: true, actions: true }, + orderBy: [{ position: "asc" }, { createdAt: "asc" }] }); return { rules }; }); app.post("/", async (request, reply) => { const input = ruleSchema.parse(request.body); + const lastRule = await prisma.rule.findFirst({ + where: { tenantId: request.user.tenantId }, + orderBy: { position: "desc" }, + select: { position: true } + }); + const nextPosition = (lastRule?.position ?? -1) + 1; const rule = await prisma.rule.create({ data: { tenantId: request.user.tenantId, name: input.name, enabled: input.enabled ?? true, + matchMode: input.matchMode ?? "ALL", + position: nextPosition, + stopOnMatch: input.stopOnMatch ?? false, conditions: { create: input.conditions }, @@ -45,6 +57,37 @@ export async function rulesRoutes(app: FastifyInstance) { return reply.code(201).send({ rule }); }); + app.put("/reorder", async (request) => { + const input = z.object({ + orderedIds: z.array(z.string()).min(1) + }).parse(request.body); + + const uniqueIds = Array.from(new Set(input.orderedIds)); + if (uniqueIds.length !== input.orderedIds.length) { + return { success: false, message: "Duplicate ids provided" }; + } + + const existing = await prisma.rule.findMany({ + where: { tenantId: request.user.tenantId, id: { in: input.orderedIds } }, + select: { id: true } + }); + + if (existing.length !== input.orderedIds.length) { + return { success: false, message: "One or more rules not found" }; + } + + await prisma.$transaction( + input.orderedIds.map((id, index) => + prisma.rule.update({ + where: { id }, + data: { position: index } + }) + ) + ); + + return { success: true }; + }); + app.put("/:id", async (request, reply) => { const params = request.params as { id: string }; const input = ruleSchema.parse(request.body); @@ -64,6 +107,8 @@ export async function rulesRoutes(app: FastifyInstance) { data: { name: input.name, enabled: input.enabled ?? true, + matchMode: input.matchMode ?? "ALL", + stopOnMatch: input.stopOnMatch ?? false, conditions: { create: input.conditions }, actions: { create: input.actions } }, diff --git a/backend/src/worker.ts b/backend/src/worker.ts index 941632d7..e345929b 100644 --- a/backend/src/worker.ts +++ b/backend/src/worker.ts @@ -8,6 +8,10 @@ import { runExportJob, startExportCleanupLoop } from "./admin/exportWorker.js"; const connection = new IORedis(config.REDIS_URL, { maxRetriesPerRequest: null }); +const clampAlpha = (value: number) => Math.min(0.95, Math.max(0.05, value)); +const ema = (prev: number | null | undefined, next: number, alpha: number) => + prev === null || prev === undefined ? next : prev + alpha * (next - prev); + const worker = new Worker( "cleanup", async (job) => { @@ -35,6 +39,138 @@ const worker = new Worker( data: { status: "SUCCEEDED", finishedAt: new Date() } }); await logJobEvent(cleanupJobId, "info", "Cleanup finished", 100); + if (latest?.tenantId && latest.startedAt && latest.finishedAt && latest.processedMessages) { + const durationSeconds = Math.max(1, (latest.finishedAt.getTime() - latest.startedAt.getTime()) / 1000); + const rate = latest.processedMessages / durationSeconds; + if (Number.isFinite(rate) && rate > 0) { + const metric = await prisma.tenantMetric.findUnique({ where: { tenantId: latest.tenantId } }); + const alpha = clampAlpha(config.METRICS_EMA_ALPHA); + if (!metric) { + await prisma.tenantMetric.create({ + data: { tenantId: latest.tenantId, avgProcessingRate: rate, sampleCount: 1 } + }); + } else { + const newCount = metric.sampleCount + 1; + const blended = ema(metric.avgProcessingRate, rate, alpha); + await prisma.tenantMetric.update({ + where: { tenantId: latest.tenantId }, + data: { avgProcessingRate: blended, sampleCount: newCount } + }); + } + } + } + + if (latest?.tenantId) { + const account = await prisma.mailboxAccount.findUnique({ where: { id: latest.mailboxAccountId } }); + const provider = account?.provider ?? null; + if (provider) { + const listingRate = latest.listingSeconds && latest.totalMessages + ? latest.totalMessages / latest.listingSeconds + : null; + const processingRate = latest.processingSeconds && latest.processedMessages + ? latest.processedMessages / latest.processingSeconds + : null; + const allowSideEffects = !latest.dryRun; + const unsubscribeRate = allowSideEffects && latest.unsubscribeSeconds && latest.unsubscribeAttempts + ? latest.unsubscribeAttempts / latest.unsubscribeSeconds + : null; + const routingRate = allowSideEffects && latest.routingSeconds && latest.actionAttempts + ? latest.actionAttempts / latest.routingSeconds + : null; + const listingSecPerMsg = latest.listingSeconds && latest.totalMessages + ? latest.listingSeconds / latest.totalMessages + : null; + const processingSecPerMsg = latest.processingSeconds && latest.processedMessages + ? latest.processingSeconds / latest.processedMessages + : null; + const unsubscribeSecPerMsg = allowSideEffects && latest.unsubscribeSeconds && latest.processedMessages + ? latest.unsubscribeSeconds / latest.processedMessages + : null; + const routingSecPerMsg = allowSideEffects && latest.routingSeconds && latest.processedMessages + ? latest.routingSeconds / latest.processedMessages + : null; + + const existing = await prisma.tenantProviderMetric.findUnique({ + where: { tenantId_provider: { tenantId: latest.tenantId, provider } } + }); + const alpha = clampAlpha(config.METRICS_EMA_ALPHA); + + if (!existing) { + await prisma.tenantProviderMetric.create({ + data: { + tenantId: latest.tenantId, + provider, + avgListingRate: listingRate ?? undefined, + avgProcessingRate: processingRate ?? undefined, + avgUnsubscribeRate: unsubscribeRate ?? undefined, + avgRoutingRate: routingRate ?? undefined, + avgListingSecondsPerMessage: listingSecPerMsg ?? undefined, + avgProcessingSecondsPerMessage: processingSecPerMsg ?? undefined, + avgUnsubscribeSecondsPerMessage: unsubscribeSecPerMsg ?? undefined, + avgRoutingSecondsPerMessage: routingSecPerMsg ?? undefined, + listingSampleCount: listingRate ? 1 : 0, + processingSampleCount: processingRate ? 1 : 0, + unsubscribeSampleCount: unsubscribeRate ? 1 : 0, + routingSampleCount: routingRate ? 1 : 0 + } + }); + } else { + const updates: Record = {}; + const counts: Record = {}; + + const hasListingSample = Boolean((listingRate && listingRate > 0) || (listingSecPerMsg && listingSecPerMsg > 0)); + if (hasListingSample) { + counts.listingSampleCount = existing.listingSampleCount + 1; + if (listingRate && listingRate > 0) { + updates.avgListingRate = ema(existing.avgListingRate, listingRate, alpha); + } + if (listingSecPerMsg && listingSecPerMsg > 0) { + updates.avgListingSecondsPerMessage = ema(existing.avgListingSecondsPerMessage, listingSecPerMsg, alpha); + } + } + + const hasProcessingSample = Boolean((processingRate && processingRate > 0) || (processingSecPerMsg && processingSecPerMsg > 0)); + if (hasProcessingSample) { + counts.processingSampleCount = existing.processingSampleCount + 1; + if (processingRate && processingRate > 0) { + updates.avgProcessingRate = ema(existing.avgProcessingRate, processingRate, alpha); + } + if (processingSecPerMsg && processingSecPerMsg > 0) { + updates.avgProcessingSecondsPerMessage = ema(existing.avgProcessingSecondsPerMessage, processingSecPerMsg, alpha); + } + } + + const hasUnsubscribeSample = Boolean((unsubscribeRate && unsubscribeRate > 0) || (unsubscribeSecPerMsg && unsubscribeSecPerMsg > 0)); + if (hasUnsubscribeSample) { + counts.unsubscribeSampleCount = existing.unsubscribeSampleCount + 1; + if (unsubscribeRate && unsubscribeRate > 0) { + updates.avgUnsubscribeRate = ema(existing.avgUnsubscribeRate, unsubscribeRate, alpha); + } + if (unsubscribeSecPerMsg && unsubscribeSecPerMsg > 0) { + updates.avgUnsubscribeSecondsPerMessage = ema(existing.avgUnsubscribeSecondsPerMessage, unsubscribeSecPerMsg, alpha); + } + } + + const hasRoutingSample = Boolean((routingRate && routingRate > 0) || (routingSecPerMsg && routingSecPerMsg > 0)); + if (hasRoutingSample) { + counts.routingSampleCount = existing.routingSampleCount + 1; + if (routingRate && routingRate > 0) { + updates.avgRoutingRate = ema(existing.avgRoutingRate, routingRate, alpha); + } + if (routingSecPerMsg && routingSecPerMsg > 0) { + updates.avgRoutingSecondsPerMessage = ema(existing.avgRoutingSecondsPerMessage, routingSecPerMsg, alpha); + } + } + + if (Object.keys(updates).length > 0) { + await prisma.tenantProviderMetric.update({ + where: { tenantId_provider: { tenantId: latest.tenantId, provider } }, + data: { ...updates, ...counts } + }); + } + } + } + } } catch { return { ok: false, skipped: true }; } diff --git a/frontend/index.html b/frontend/index.html index 1f708dd0..21ac1abc 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -3,6 +3,7 @@ + Simple Mail Cleaner diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..7e00da5b8b6882de5537584118be74626aaf1319 GIT binary patch literal 195 zcmZQzU<5)11qKkwu$F;=K@5mH1N_{1xum#&OkPh9mmnam0Kyz>K(e^1>I;x6@pN$v zshIQjiX#_;A_waQy~`(`{g7Q0$?KBR8-KD%e+Sps#K2SjD<{t6P-tLa>SL>5W%4D^m l?gj?yNeoOJ3JNkGm`=XFS$kcv5PuQ+lsC~~k~(7Sx{ z*$>%8k-RP`z40fT^mlN5O$x*3=x$)Jp2Wb!p`al1f$8Ln W|JBpB([]); const [rules, setRules] = useState([]); + const [dragRuleId, setDragRuleId] = useState(null); + const [dragOverRuleId, setDragOverRuleId] = useState(null); const [jobs, setJobs] = useState([]); + const [tenantAvgRate, setTenantAvgRate] = useState(null); const [selectedJobId, setSelectedJobId] = useState( localStorage.getItem("ui.selectedJobId") ); const [events, setEvents] = useState([]); + const [etaTick, setEtaTick] = useState(0); + const [tenantProviderMetrics, setTenantProviderMetrics] = useState>({}); + const etaSmoothRef = useRef<{ value: number; updatedAt: number; lastIncreaseAt?: number } | null>(null); + const etaRecalcRef = useRef<{ until: number; lastTriggered: number } | null>(null); const [jobModalOpen, setJobModalOpen] = useState(false); + const [resultsModalOpen, setResultsModalOpen] = useState(false); + const [candidateGroupBy, setCandidateGroupBy] = useState<"domain" | "from" | "listId" | "none">( + (localStorage.getItem("ui.candidateGroupBy") as "domain" | "from" | "listId" | "none") ?? "domain" + ); + const [candidateGroups, setCandidateGroups] = useState([]); + const [candidateGroupAnimations, setCandidateGroupAnimations] = useState>({}); + const candidateGroupRefs = useRef>({}); + const candidateGroupFlip = useRef | null>(null); + const candidateGroupMoved = useRef>(new Set()); + const [candidateItems, setCandidateItems] = useState([]); + const [candidateGroupValue, setCandidateGroupValue] = useState(null); + const [candidateCursor, setCandidateCursor] = useState(null); + const [candidateHasMore, setCandidateHasMore] = useState(false); + const [candidateTotal, setCandidateTotal] = useState(null); + const [candidateLoading, setCandidateLoading] = useState(false); + const [candidateShowSignals] = useState(true); + const [candidateSearch, setCandidateSearch] = useState(""); + const [candidateStatusFilter, setCandidateStatusFilter] = useState("all"); + const [candidateReviewedFilter, setCandidateReviewedFilter] = useState("all"); + const [selectedCandidateIds, setSelectedCandidateIds] = useState([]); + const [previewCandidateId, setPreviewCandidateId] = useState(null); + const [previewData, setPreviewData] = useState(null); + const [previewLoading, setPreviewLoading] = useState(false); + const [previewError, setPreviewError] = useState(null); + const [showUnsubscribeDetails, setShowUnsubscribeDetails] = useState(false); + const previewCacheRef = useRef(new Map()); + const PREVIEW_CACHE_TTL = 5 * 60 * 1000; + const lastResultsEventCount = useRef(0); const [confirmDialog, setConfirmDialog] = useState<{ open: boolean; message: string; @@ -96,6 +219,8 @@ export default function App() { const [ruleName, setRuleName] = useState(""); const [ruleEnabled, setRuleEnabled] = useState(true); + const [ruleMatchMode, setRuleMatchMode] = useState<"ALL" | "ANY">("ALL"); + const [ruleStopOnMatch, setRuleStopOnMatch] = useState(false); const [conditions, setConditions] = useState([{ ...defaultCondition }]); const [actions, setActions] = useState([{ ...defaultAction }]); const [ruleModalOpen, setRuleModalOpen] = useState(false); @@ -156,6 +281,345 @@ export default function App() { return false; }; + const resetCandidates = () => { + setCandidateGroups([]); + setCandidateItems([]); + setCandidateGroupValue(null); + setCandidateCursor(null); + setCandidateHasMore(false); + setCandidateTotal(null); + setPreviewCandidateId(null); + setPreviewData(null); + setPreviewError(null); + }; + + const buildFilterParams = () => { + const params = new URLSearchParams(); + if (candidateSearch.trim()) { + params.set("q", candidateSearch.trim()); + } + if (candidateStatusFilter !== "all") { + params.set("status", candidateStatusFilter); + } + if (candidateReviewedFilter !== "all") { + params.set("reviewed", candidateReviewedFilter === "reviewed" ? "true" : "false"); + } + return params; + }; + + const mergeCandidateGroups = (incoming: CandidateGroup[]) => { + const prevRects = new Map(); + Object.entries(candidateGroupRefs.current).forEach(([key, el]) => { + if (el) prevRects.set(key, el.getBoundingClientRect()); + }); + candidateGroupFlip.current = prevRects; + candidateGroupMoved.current.clear(); + + setCandidateGroups((prev) => { + const prevMap = new Map(prev.map((group) => [group.key, group])); + const animations: Record = {}; + const next = incoming.map((group) => { + const existing = prevMap.get(group.key); + if (!existing) { + animations[group.key] = "new"; + return group; + } + if (existing.count !== group.count) { + animations[group.key] = "updated"; + } + return group; + }); + + if (Object.keys(animations).length) { + setCandidateGroupAnimations(animations); + window.setTimeout(() => setCandidateGroupAnimations({}), 700); + } + + return next; + }); + }; + + const loadCandidateGroups = async (options?: { preserveTotal?: boolean }) => { + if (!selectedJobId || !token) return; + const showLoading = candidateGroups.length === 0; + if (showLoading) { + setCandidateLoading(true); + } + try { + const params = buildFilterParams(); + params.set("groupBy", candidateGroupBy); + const data = await apiFetch(`/jobs/${selectedJobId}/candidates?${params.toString()}`, {}, token); + mergeCandidateGroups(data.groups ?? []); + if (!options?.preserveTotal) { + setCandidateTotal(null); + } + } catch (err) { + pushToast(getErrorMessage(err), "error"); + } finally { + if (showLoading) { + setCandidateLoading(false); + } + } + }; + + const loadCandidateItems = async (options?: { reset?: boolean; groupValue?: string | null }) => { + if (!selectedJobId || !token) return; + const shouldReset = options?.reset ?? false; + const groupValue = options?.groupValue ?? candidateGroupValue; + setCandidateLoading(true); + try { + const params = buildFilterParams(); + params.set("limit", "200"); + if (candidateGroupBy !== "none") { + params.set("groupBy", candidateGroupBy); + if (groupValue !== null) { + params.set("groupValue", groupValue); + } + } + if (!shouldReset && candidateCursor) { + params.set("cursor", candidateCursor); + } + const data = await apiFetch(`/jobs/${selectedJobId}/candidates?${params.toString()}`, {}, token); + setCandidateItems((prev) => (shouldReset ? data.items ?? [] : [...prev, ...(data.items ?? [])])); + setCandidateCursor(data.nextCursor ?? null); + setCandidateHasMore(Boolean(data.nextCursor)); + setCandidateTotal(typeof data.total === "number" ? data.total : null); + } catch (err) { + pushToast(getErrorMessage(err), "error"); + } finally { + setCandidateLoading(false); + } + }; + + const refreshCandidates = () => { + if (candidateGroupBy !== "none") { + void loadCandidateGroups({ preserveTotal: candidateGroupValue !== null }); + } + if (candidateGroupBy === "none") { + void loadCandidateItems({ reset: true, groupValue: candidateGroupValue ?? null }); + return; + } + if (candidateGroupValue !== null) { + void loadCandidateItems({ reset: true, groupValue: candidateGroupValue }); + return; + } + }; + + const loadPreview = async (candidateId: string) => { + if (!selectedJobId || !token) return; + setPreviewCandidateId(candidateId); + const cached = previewCacheRef.current.get(candidateId); + if (cached && Date.now() - cached.ts < PREVIEW_CACHE_TTL) { + setPreviewData(cached.data); + setPreviewLoading(false); + setPreviewError(null); + return; + } + setPreviewLoading(true); + setPreviewError(null); + try { + const data = await apiFetch(`/jobs/${selectedJobId}/candidates/${candidateId}/preview`, {}, token); + const preview = data as CandidatePreview; + setPreviewData(preview); + previewCacheRef.current.set(candidateId, { data: preview, ts: Date.now() }); + } catch (err) { + setPreviewError(getErrorMessage(err)); + setPreviewData(null); + } finally { + setPreviewLoading(false); + } + }; + + const toggleReviewed = async (candidateId: string, reviewed: boolean) => { + if (!selectedJobId || !token) return; + try { + await apiFetch(`/jobs/${selectedJobId}/candidates/${candidateId}`, { + method: "PATCH", + body: JSON.stringify({ reviewed }) + }, token); + setCandidateItems((prev) => + prev.map((item) => (item.id === candidateId ? { ...item, reviewed } : item)) + ); + } catch (err) { + pushToast(getErrorMessage(err), "error"); + } + }; + + const exportCandidates = async () => { + if (!selectedJobId || !token) return; + const params = buildFilterParams(); + const base = import.meta.env.VITE_API_URL ?? "http://localhost:8000"; + const response = await fetch(`${base}/jobs/${selectedJobId}/candidates/export?${params.toString()}`, { + headers: { Authorization: `Bearer ${token}` } + }); + if (!response.ok) { + pushToast(t("toastExportFailed"), "error"); + return; + } + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = `job-${selectedJobId}-results.csv`; + link.click(); + window.URL.revokeObjectURL(url); + }; + + const exportGroupCandidates = async () => { + if (!selectedJobId || !token || !candidateGroupValue || candidateGroupBy === "none") return; + const params = buildFilterParams(); + params.set("groupBy", candidateGroupBy); + params.set("groupValue", candidateGroupValue); + const base = import.meta.env.VITE_API_URL ?? "http://localhost:8000"; + const response = await fetch(`${base}/jobs/${selectedJobId}/candidates/export-group?${params.toString()}`, { + headers: { Authorization: `Bearer ${token}` } + }); + if (!response.ok) { + pushToast(t("toastExportFailed"), "error"); + return; + } + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = `job-${selectedJobId}-group-${candidateGroupValue}-results.csv`; + link.click(); + window.URL.revokeObjectURL(url); + }; + + const downloadAttachment = async (attachmentId: number, filename?: string | null) => { + if (!selectedJobId || !previewCandidateId || !token) return; + const base = import.meta.env.VITE_API_URL ?? "http://localhost:8000"; + const response = await fetch( + `${base}/jobs/${selectedJobId}/candidates/${previewCandidateId}/attachments/${attachmentId}`, + { headers: { Authorization: `Bearer ${token}` } } + ); + if (!response.ok) { + pushToast(t("toastDownloadFailed"), "error"); + return; + } + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + const safeName = filename && filename.trim().length ? filename.trim() : `attachment-${attachmentId}`; + link.download = safeName; + link.click(); + window.URL.revokeObjectURL(url); + }; + + const buildScoreTooltip = (candidate?: Candidate | null) => { + if (!candidate?.signals?.scoreBreakdown) { + return t("jobCandidatesScoreHint"); + } + const breakdown = candidate.signals.scoreBreakdown; + const headerMatches = breakdown.headerMatches ?? 0; + const headerWeight = breakdown.headerWeight ?? 0; + const headerScore = breakdown.headerScore ?? 0; + const precedenceScore = breakdown.precedenceScore ?? 0; + const precedenceWeight = breakdown.precedenceWeight ?? 0; + const subjectMatches = breakdown.subjectMatches ?? (candidate.signals.subjectTokens?.length ?? 0); + const subjectWeight = breakdown.subjectWeight ?? 0; + const subjectScore = breakdown.subjectScore ?? 0; + const fromMatches = breakdown.fromMatches ?? (candidate.signals.fromTokens?.length ?? 0); + const fromWeight = breakdown.fromWeight ?? 0; + const fromScore = breakdown.fromScore ?? 0; + const subjectTokens = candidate.signals.subjectTokens?.length + ? ` (${candidate.signals.subjectTokens.join(", ")})` + : ""; + const fromTokens = candidate.signals.fromTokens?.length + ? ` (${candidate.signals.fromTokens.join(", ")})` + : ""; + + return [ + `${t("jobCandidatesHeaderSignals")}: ${headerMatches} × ${headerWeight} = ${headerScore}`, + `${t("jobCandidatesPrecedenceSignal")}: ${precedenceScore ? precedenceWeight : 0}`, + `${t("jobCandidatesSubjectSignals")}: ${subjectMatches} × ${subjectWeight} = ${subjectScore}${subjectTokens}`, + `${t("jobCandidatesFromSignals")}: ${fromMatches} × ${fromWeight} = ${fromScore}${fromTokens}`, + `${t("jobCandidatesScore")}: ${candidate.score}` + ].join("\n"); + }; + + const parseListUnsubscribeHeader = (value?: string | null) => { + if (!value) { + return { httpLinks: [] as string[], mailto: null as URL | null }; + } + const tokens = value + .split(",") + .map((token) => token.trim().replace(/^<|>$/g, "")) + .filter(Boolean); + const httpLinks = tokens.filter((token) => token.startsWith("http")); + const mailtoRaw = tokens.find((token) => token.startsWith("mailto:")) ?? null; + let mailto: URL | null = null; + if (mailtoRaw) { + try { + mailto = new URL(mailtoRaw); + } catch { + mailto = null; + } + } + return { httpLinks, mailto }; + }; + + const toggleCandidateSelection = (candidateId: string, checked: boolean) => { + setSelectedCandidateIds((prev) => { + if (checked) { + return prev.includes(candidateId) ? prev : [...prev, candidateId]; + } + return prev.filter((id) => id !== candidateId); + }); + }; + + const clearCandidateSelection = () => setSelectedCandidateIds([]); + + const selectAllVisible = () => { + const ids = candidateItems.map((item) => item.id); + setSelectedCandidateIds(ids); + }; + + const markSelectedReviewed = async (reviewed: boolean) => { + if (!selectedJobId || !token || selectedCandidateIds.length === 0) return; + try { + await apiFetch(`/jobs/${selectedJobId}/candidates/batch`, { + method: "POST", + body: JSON.stringify({ ids: selectedCandidateIds, reviewed }) + }, token); + setCandidateItems((prev) => + prev.map((item) => + selectedCandidateIds.includes(item.id) ? { ...item, reviewed } : item + ) + ); + clearCandidateSelection(); + } catch (err) { + pushToast(getErrorMessage(err), "error"); + } + }; + + const deleteSelectedMessages = () => { + if (!selectedJobId || !token || selectedCandidateIds.length === 0) return; + openConfirmDialog( + t("confirmDeleteSelected", { count: selectedCandidateIds.length }), + async () => { + const result = await apiFetch( + `/jobs/${selectedJobId}/candidates/delete`, + { method: "POST", body: JSON.stringify({ ids: selectedCandidateIds }) }, + token + ); + pushToast( + t("toastDeleteSelected", { + deleted: result.deleted ?? 0, + missing: result.missing ?? 0, + failed: result.failed ?? 0 + }), + result.failed ? "error" : "success" + ); + clearCandidateSelection(); + refreshCandidates(); + }, + t("confirm") + ); + }; + const loadInitial = async (authToken: string) => { const me = await apiFetch("/tenants/me", {}, authToken); setUser(me.user); @@ -203,6 +667,13 @@ export default function App() { const jobsData = await apiFetch("/jobs", {}, authToken); setJobs(jobsData.jobs ?? []); + setTenantAvgRate(typeof jobsData.meta?.avgProcessingRate === "number" ? jobsData.meta.avgProcessingRate : null); + const providerMetrics = Array.isArray(jobsData.meta?.providerMetrics) + ? (jobsData.meta.providerMetrics as ProviderMetric[]) + : []; + setTenantProviderMetrics( + Object.fromEntries(providerMetrics.map((item) => [item.provider, item])) + ); }; useEffect(() => { @@ -221,6 +692,48 @@ export default function App() { }); }, [token]); + const isAnyModalOpen = Boolean( + resultsModalOpen || jobModalOpen || showProviderHelp || passwordResetRequired || confirmDialog.open + ); + + useEffect(() => { + if (isAnyModalOpen) { + document.body.classList.add("modal-open"); + } else { + document.body.classList.remove("modal-open"); + } + return () => { + document.body.classList.remove("modal-open"); + }; + }, [isAnyModalOpen]); + + useEffect(() => { + const handler = () => { + const path = window.location.pathname; + if (path.startsWith("/results/")) { + const jobId = path.replace("/results/", "").trim(); + if (jobId) { + setSelectedJobId(jobId); + setResultsModalOpen(true); + setJobModalOpen(false); + return; + } + } + const params = new URLSearchParams(window.location.search); + const jobId = params.get("job"); + if (jobId) { + setSelectedJobId(jobId); + setJobModalOpen(true); + setResultsModalOpen(false); + return; + } + setJobModalOpen(false); + setResultsModalOpen(false); + }; + window.addEventListener("popstate", handler); + return () => window.removeEventListener("popstate", handler); + }, []); + useEffect(() => { if (cleanupAccountId) { localStorage.setItem("ui.cleanupAccountId", cleanupAccountId); @@ -229,6 +742,37 @@ export default function App() { } }, [cleanupAccountId]); + useEffect(() => { + if (!cleanupAccountId) return; + const storedDryRun = localStorage.getItem(`ui.cleanup.${cleanupAccountId}.dryRun`); + const storedUnsubscribe = localStorage.getItem(`ui.cleanup.${cleanupAccountId}.unsubscribe`); + const storedRouting = localStorage.getItem(`ui.cleanup.${cleanupAccountId}.routing`); + if (storedDryRun !== null) { + setDryRun(storedDryRun === "true"); + } + if (storedUnsubscribe !== null) { + setUnsubscribeEnabled(storedUnsubscribe === "true"); + } + if (storedRouting !== null) { + setRoutingEnabled(storedRouting === "true"); + } + }, [cleanupAccountId]); + + useEffect(() => { + if (!cleanupAccountId) return; + localStorage.setItem(`ui.cleanup.${cleanupAccountId}.dryRun`, String(dryRun)); + }, [cleanupAccountId, dryRun]); + + useEffect(() => { + if (!cleanupAccountId) return; + localStorage.setItem(`ui.cleanup.${cleanupAccountId}.unsubscribe`, String(unsubscribeEnabled)); + }, [cleanupAccountId, unsubscribeEnabled]); + + useEffect(() => { + if (!cleanupAccountId) return; + localStorage.setItem(`ui.cleanup.${cleanupAccountId}.routing`, String(routingEnabled)); + }, [cleanupAccountId, routingEnabled]); + useEffect(() => { localStorage.setItem("ui.showAdmin", String(showAdmin)); }, [showAdmin]); @@ -243,8 +787,15 @@ export default function App() { } else { localStorage.removeItem("ui.selectedJobId"); } + previewCacheRef.current.clear(); + setPreviewData(null); + setPreviewCandidateId(null); }, [selectedJobId]); + useEffect(() => { + localStorage.setItem("ui.candidateGroupBy", candidateGroupBy); + }, [candidateGroupBy]); + useEffect(() => { if (!token) return; const interval = setInterval(() => { @@ -264,6 +815,15 @@ export default function App() { useEffect(() => { if (!token) return; + const path = window.location.pathname; + if (path.startsWith("/results/")) { + const jobId = path.replace("/results/", "").trim(); + if (jobId) { + setSelectedJobId(jobId); + setResultsModalOpen(true); + return; + } + } const params = new URLSearchParams(window.location.search); const jobId = params.get("job"); if (jobId) { @@ -313,6 +873,52 @@ export default function App() { }; }, [selectedJobId, token]); + useEffect(() => { + if (!resultsModalOpen || !selectedJobId || !token) return; + resetCandidates(); + if (candidateGroupBy === "none") { + void loadCandidateItems({ reset: true, groupValue: null }); + } else { + void loadCandidateGroups(); + } + }, [resultsModalOpen, selectedJobId, token, candidateGroupBy]); + + useEffect(() => { + if (!resultsModalOpen) return; + const timer = window.setTimeout(() => { + refreshCandidates(); + }, 300); + return () => window.clearTimeout(timer); + }, [candidateSearch, candidateStatusFilter, candidateReviewedFilter, resultsModalOpen]); + + useEffect(() => { + if (!resultsModalOpen || candidateGroupBy === "none") return; + const timer = window.setTimeout(() => { + void loadCandidateGroups({ preserveTotal: candidateGroupValue !== null }); + }, 300); + return () => window.clearTimeout(timer); + }, [candidateSearch, candidateStatusFilter, candidateReviewedFilter, candidateGroupBy, resultsModalOpen]); + + const selectedJob = useMemo( + () => (selectedJobId ? jobs.find((job) => job.id === selectedJobId) : null), + [jobs, selectedJobId] + ); + + useEffect(() => { + if (!resultsModalOpen || !selectedJob) return; + if (selectedJob.status !== "RUNNING") { + refreshCandidates(); + return; + } + if (events.length === 0) return; + if (events.length === lastResultsEventCount.current) return; + lastResultsEventCount.current = events.length; + const timer = window.setTimeout(() => { + refreshCandidates(); + }, 400); + return () => window.clearTimeout(timer); + }, [events, resultsModalOpen, selectedJob]); + const orderedEvents = useMemo( () => [...events].sort( @@ -321,6 +927,37 @@ export default function App() { [events] ); + useLayoutEffect(() => { + const prev = candidateGroupFlip.current; + if (!prev || prev.size === 0) return; + candidateGroupFlip.current = null; + const raf = window.requestAnimationFrame(() => { + for (const group of candidateGroups) { + const el = candidateGroupRefs.current[group.key]; + const prevRect = prev.get(group.key); + if (!el || !prevRect) continue; + const nextRect = el.getBoundingClientRect(); + const deltaY = prevRect.top - nextRect.top; + const deltaX = prevRect.left - nextRect.left; + if (Math.abs(deltaY) < 1 && Math.abs(deltaX) < 1) continue; + candidateGroupMoved.current.add(group.key); + el.style.transform = `translate(${deltaX}px, ${deltaY}px)`; + el.style.transition = "transform 0s"; + window.requestAnimationFrame(() => { + el.style.transition = "transform 220ms ease"; + el.style.transform = "translate(0, 0)"; + }); + } + }); + const clearTimer = window.setTimeout(() => { + candidateGroupMoved.current.clear(); + }, 400); + return () => { + window.cancelAnimationFrame(raf); + window.clearTimeout(clearTimer); + }; + }, [candidateGroups]); + const handleAuth = async () => { try { if (authMode === "login") { @@ -483,6 +1120,8 @@ export default function App() { setEditingRuleId(null); setRuleName(""); setRuleEnabled(true); + setRuleMatchMode("ALL"); + setRuleStopOnMatch(false); setConditions([{ ...defaultCondition }]); setActions([{ ...defaultAction }]); setRuleModalOpen(true); @@ -492,6 +1131,8 @@ export default function App() { setEditingRuleId(rule.id); setRuleName(rule.name); setRuleEnabled(rule.enabled); + setRuleMatchMode((rule.matchMode as "ALL" | "ANY") ?? "ALL"); + setRuleStopOnMatch(Boolean(rule.stopOnMatch)); setConditions(rule.conditions.map((condition) => ({ ...condition }))); setActions(rule.actions.map((action) => ({ ...action }))); setRuleModalOpen(true); @@ -512,6 +1153,8 @@ export default function App() { body: JSON.stringify({ name: ruleName, enabled: ruleEnabled, + matchMode: ruleMatchMode, + stopOnMatch: ruleStopOnMatch, conditions, actions }) @@ -527,6 +1170,8 @@ export default function App() { body: JSON.stringify({ name: ruleName, enabled: ruleEnabled, + matchMode: ruleMatchMode, + stopOnMatch: ruleStopOnMatch, conditions, actions }) @@ -555,19 +1200,100 @@ export default function App() { } }; + const persistRuleOrder = async (nextRules: Rule[]) => { + setRules(nextRules); + try { + await apiFetch( + "/rules/reorder", + { method: "PUT", body: JSON.stringify({ orderedIds: nextRules.map((rule) => rule.id) }) }, + token + ); + pushToast(t("toastRuleOrderSaved"), "success"); + } catch (err) { + pushToast(getErrorMessage(err), "error"); + try { + const rulesData = await apiFetch("/rules", {}, token); + setRules(rulesData.rules ?? []); + } catch (reloadErr) { + pushToast(getErrorMessage(reloadErr), "error"); + } + } + }; + + const handleRuleDragStart = (event: DragEvent, ruleId: string) => { + event.dataTransfer.effectAllowed = "move"; + event.dataTransfer.setData("text/plain", ruleId); + setDragRuleId(ruleId); + const item = event.currentTarget.closest(".rule-item") as HTMLElement | null; + if (item && event.dataTransfer.setDragImage) { + const ghost = item.cloneNode(true) as HTMLElement; + ghost.classList.add("drag-ghost"); + ghost.style.position = "absolute"; + ghost.style.top = "-9999px"; + ghost.style.left = "-9999px"; + ghost.style.pointerEvents = "none"; + document.body.appendChild(ghost); + event.dataTransfer.setDragImage(ghost, 24, 24); + window.setTimeout(() => { + ghost.remove(); + }, 0); + } + }; + + const handleRuleDragOver = (event: DragEvent, ruleId: string) => { + event.preventDefault(); + if (dragRuleId && ruleId !== dragRuleId) { + setDragOverRuleId(ruleId); + } + }; + + const handleRuleDrop = (event: DragEvent, ruleId: string) => { + event.preventDefault(); + const dragged = dragRuleId; + if (!dragged || dragged === ruleId) return; + const fromIndex = rules.findIndex((rule) => rule.id === dragged); + const toIndex = rules.findIndex((rule) => rule.id === ruleId); + if (fromIndex < 0 || toIndex < 0) return; + const next = [...rules]; + const [moved] = next.splice(fromIndex, 1); + next.splice(toIndex, 0, moved); + setDragRuleId(null); + setDragOverRuleId(null); + void persistRuleOrder(next); + }; + + const handleRuleDragEnd = () => { + setDragRuleId(null); + setDragOverRuleId(null); + }; + const openJobDetails = (jobId: string) => { setSelectedJobId(jobId); setJobModalOpen(true); - const url = new URL(window.location.href); - url.searchParams.set("job", jobId); - window.history.replaceState({}, "", url.toString()); + window.history.pushState({}, "", `/?job=${jobId}`); }; const closeJobDetails = () => { setJobModalOpen(false); - const url = new URL(window.location.href); - url.searchParams.delete("job"); - window.history.replaceState({}, "", url.toString()); + window.history.replaceState({}, "", "/"); + }; + + const openResults = (jobId: string) => { + setSelectedJobId(jobId); + setResultsModalOpen(true); + window.history.pushState({}, "", `/results/${jobId}`); + }; + + const closeResults = () => { + setResultsModalOpen(false); + window.history.replaceState({}, "", "/"); + }; + + const backToJobFromResults = () => { + if (!selectedJobId) return; + setResultsModalOpen(false); + setJobModalOpen(true); + window.history.replaceState({}, "", `/?job=${selectedJobId}`); }; const handleStartCleanup = async () => { @@ -587,6 +1313,13 @@ export default function App() { ); const jobsData = await apiFetch("/jobs", {}, token); setJobs(jobsData.jobs ?? []); + setTenantAvgRate(typeof jobsData.meta?.avgProcessingRate === "number" ? jobsData.meta.avgProcessingRate : null); + const providerMetrics = Array.isArray(jobsData.meta?.providerMetrics) + ? (jobsData.meta.providerMetrics as ProviderMetric[]) + : []; + setTenantProviderMetrics( + Object.fromEntries(providerMetrics.map((item) => [item.provider, item])) + ); setSelectedJobId(result.jobId); setJobModalOpen(true); const url = new URL(window.location.href); @@ -610,6 +1343,10 @@ export default function App() { const addCondition = () => setConditions((prev) => [...prev, { ...defaultCondition }]); const addAction = () => setActions((prev) => [...prev, { ...defaultAction }]); + const removeCondition = (index: number) => + setConditions((prev) => (prev.length > 1 ? prev.filter((_, idx) => idx !== index) : [{ ...defaultCondition }])); + const removeAction = (index: number) => + setActions((prev) => (prev.length > 1 ? prev.filter((_, idx) => idx !== index) : [{ ...defaultAction }])); const mapJobStatus = (status: string) => { switch (status) { @@ -628,6 +1365,44 @@ export default function App() { } }; + const formatCandidateGroup = (value: string) => (value ? value : t("jobCandidatesUnknown")); + + const formatActionStatus = (status?: string | null) => { + switch (status) { + case "applied": + return t("jobCandidatesActionApplied"); + case "dry-run": + return t("jobCandidatesActionDryRun"); + case "failed": + return t("jobCandidatesActionFailed"); + case "skipped": + return t("jobCandidatesActionSkipped"); + default: + return status ?? "-"; + } + }; + + const formatUnsubscribeStatus = (status?: string | null) => { + switch (status) { + case "ok": + return t("jobCandidatesUnsubscribeOk"); + case "failed": + return t("jobCandidatesUnsubscribeFailed"); + case "dry-run": + return t("jobCandidatesUnsubscribeDryRun"); + case "skipped-duplicate": + return t("jobCandidatesUnsubscribeDuplicate"); + case "disabled": + return t("jobCandidatesUnsubscribeDisabled"); + case "skipped": + return t("jobCandidatesUnsubscribeNone"); + case "pending": + return t("statusQueued"); + default: + return status ?? "-"; + } + }; + const handleImpersonate = (impersonationToken: string) => { localStorage.setItem("token", impersonationToken); setToken(impersonationToken); @@ -681,10 +1456,24 @@ export default function App() { const selectedAccount = accounts.find((account) => account.id === cleanupAccountId); const requiresGmailOauth = selectedAccount?.provider === "GMAIL" && !selectedAccount.oauthConnected; const cleanupDisabled = !cleanupEnabled || !cleanupAccountId || requiresGmailOauth; - const selectedJob = selectedJobId ? jobs.find((job) => job.id === selectedJobId) : null; const selectedJobAccount = selectedJob ? accounts.find((account) => account.id === selectedJob.mailboxAccountId) : null; + + useEffect(() => { + if (!selectedJob || selectedJob.status !== "RUNNING") return; + const interval = window.setInterval(() => { + setEtaTick((prev) => prev + 1); + }, 1000); + return () => window.clearInterval(interval); + }, [selectedJob?.id, selectedJob?.status]); + + useEffect(() => { + if (!selectedJob || selectedJob.status !== "RUNNING") { + etaSmoothRef.current = null; + etaRecalcRef.current = null; + } + }, [selectedJob?.id, selectedJob?.status]); const jobProgress = useMemo(() => { if (selectedJob?.status === "SUCCEEDED") return 100; if (selectedJob?.status === "CANCELED") return 0; @@ -703,25 +1492,131 @@ export default function App() { const jobEta = useMemo(() => { if (!selectedJob) return null; - if (selectedJob.status !== "RUNNING") return null; + if (["SUCCEEDED", "FAILED", "CANCELED"].includes(selectedJob.status)) { + return t("jobsEtaDone"); + } + if (selectedJob.status === "QUEUED") { + return t("jobsEtaQueued"); + } + const startedAt = selectedJob.startedAt ? new Date(selectedJob.startedAt).getTime() : null; const total = selectedJob.totalMessages ?? null; const processed = selectedJob.processedMessages ?? null; - if (!total || !processed || processed <= 0 || total <= 0 || processed >= total) return null; - const startedAt = selectedJob.startedAt ? new Date(selectedJob.startedAt).getTime() : null; - if (!startedAt) return null; - const elapsedSeconds = Math.max(1, (Date.now() - startedAt) / 1000); - const rate = processed / elapsedSeconds; - if (!Number.isFinite(rate) || rate <= 0) return null; - const remainingSeconds = (total - processed) / rate; - if (!Number.isFinite(remainingSeconds) || remainingSeconds <= 0) return null; - const rounded = Math.max(1, Math.round(remainingSeconds)); + const done = processed ?? 0; + let remainingSeconds: number | null = null; + const providerMetric = selectedJobAccount?.provider + ? tenantProviderMetrics[selectedJobAccount.provider] + : undefined; + + if (total && total > 0 && done < total) { + let rate: number | null = null; + if (processed && processed > 0 && startedAt) { + const elapsedSeconds = Math.max(1, (Date.now() - startedAt) / 1000); + const currentRate = processed / elapsedSeconds; + if (Number.isFinite(currentRate) && currentRate > 0) { + rate = currentRate; + } + } + let secondsPerMessage: number | null = + providerMetric?.avgProcessingSecondsPerMessage ?? null; + if (!secondsPerMessage && providerMetric?.avgProcessingRate && providerMetric.avgProcessingRate > 0) { + secondsPerMessage = 1 / providerMetric.avgProcessingRate; + } + if (secondsPerMessage) { + const unsubscribePenalty = + selectedJob.unsubscribeEnabled === false + ? providerMetric?.avgUnsubscribeSecondsPerMessage ?? 0 + : 0; + const routingPenalty = + selectedJob.routingEnabled === false + ? providerMetric?.avgRoutingSecondsPerMessage ?? 0 + : 0; + const adjusted = Math.max(0.05, secondsPerMessage - unsubscribePenalty - routingPenalty); + const estRate = 1 / adjusted; + if (!rate) { + rate = estRate; + } + } + if (!rate && providerMetric?.avgProcessingRate && providerMetric.avgProcessingRate > 0) { + rate = providerMetric.avgProcessingRate; + } + if (!rate && tenantAvgRate && tenantAvgRate > 0) { + rate = tenantAvgRate; + } + if (rate) { + remainingSeconds = (total - done) / rate; + } + } else if (selectedJob.status === "RUNNING") { + const progressEvents = events.filter((event) => typeof event.progress === "number"); + if (progressEvents.length >= 2) { + const last = progressEvents.at(-1); + const prev = progressEvents.at(-2); + if (last && prev) { + const delta = (last.progress ?? 0) - (prev.progress ?? 0); + const deltaTime = (new Date(last.createdAt).getTime() - new Date(prev.createdAt).getTime()) / 1000; + if (delta > 0 && deltaTime > 0) { + const ratePct = delta / deltaTime; + remainingSeconds = (100 - (last.progress ?? 0)) / ratePct; + } + } + } + if ((!remainingSeconds || remainingSeconds <= 0) && jobProgress > 0 && startedAt) { + const elapsedSeconds = Math.max(1, (Date.now() - startedAt) / 1000); + remainingSeconds = (elapsedSeconds * (100 - jobProgress)) / jobProgress; + } + } + + const now = Date.now(); + let displaySeconds: number | null = null; + if (remainingSeconds && Number.isFinite(remainingSeconds) && remainingSeconds > 0) { + const smoothState = etaSmoothRef.current; + if (!smoothState) { + etaSmoothRef.current = { value: remainingSeconds, updatedAt: now }; + displaySeconds = remainingSeconds; + } else { + const elapsed = Math.max(0, (now - smoothState.updatedAt) / 1000); + const base = Math.max(0, smoothState.value - elapsed); + const alpha = 0.7; + const smoothed = base * alpha + remainingSeconds * (1 - alpha); + const deltaUp = smoothed - base; + const minIncreaseThreshold = Math.max(60, base * 0.1); + const maxIncreasePerSecond = Math.max(5, base * 0.1 / 60); + const allowedIncrease = maxIncreasePerSecond * Math.max(1, elapsed); + const lastIncreaseAt = smoothState.lastIncreaseAt ?? 0; + const canIncrease = now - lastIncreaseAt > 20000; + let nextValue = Math.min(base > 0 ? base : smoothed, smoothed); + if (deltaUp > minIncreaseThreshold && canIncrease) { + const increase = Math.min(deltaUp, allowedIncrease); + nextValue = base + increase; + smoothState.lastIncreaseAt = now; + } + if (deltaUp > minIncreaseThreshold * 2 || (base > 0 && smoothed / base > 1.25)) { + const lastTriggered = etaRecalcRef.current?.lastTriggered ?? 0; + if (!etaRecalcRef.current || now - lastTriggered > 30000) { + etaRecalcRef.current = { until: now + 3000, lastTriggered: now }; + } + } + etaSmoothRef.current = { value: nextValue, updatedAt: now, lastIncreaseAt: smoothState.lastIncreaseAt }; + displaySeconds = nextValue; + } + } else if (etaSmoothRef.current) { + const elapsed = Math.max(0, (now - etaSmoothRef.current.updatedAt) / 1000); + displaySeconds = Math.max(0, etaSmoothRef.current.value - elapsed); + } + + if (etaRecalcRef.current && etaRecalcRef.current.until > now) { + return t("jobsEtaRecalculating"); + } + if (!displaySeconds || !Number.isFinite(displaySeconds) || displaySeconds <= 0) { + return t("jobsEtaCalculating"); + } + const rounded = Math.max(1, Math.round(displaySeconds)); const hours = Math.floor(rounded / 3600); const minutes = Math.floor((rounded % 3600) / 60); const seconds = rounded % 60; if (hours > 0) return `${hours}h ${String(minutes).padStart(2, "0")}m`; if (minutes > 0) return `${minutes}m ${String(seconds).padStart(2, "0")}s`; return `${seconds}s`; - }, [selectedJob]); + }, [selectedJob, selectedJobAccount, tenantProviderMetrics, tenantAvgRate, jobProgress, events, t, etaTick]); const jobPhase = useMemo(() => { if (!selectedJob) return "listing"; @@ -729,30 +1624,10 @@ export default function App() { if (selectedJob.status === "FAILED") return "processing"; if (selectedJob.status === "CANCELED") return "processing"; if (selectedJob.status === "SUCCEEDED") return "unsubscribe"; - const latest = orderedEvents[0]; - const message = latest?.message?.toLowerCase?.() ?? ""; - if (message.includes("unsubscribe")) return "unsubscribe"; - if ( - message.includes("processing") || - message.includes("processed") || - message.includes("moved") || - message.includes("deleted") || - message.includes("action") || - message.includes("routing") - ) { - return "processing"; - } - if ( - message.includes("listing") || - message.includes("found") || - message.includes("scanning") || - message.includes("prepared") || - message.includes("connecting") - ) { - return "listing"; - } - return "processing"; - }, [selectedJob, orderedEvents]); + if (jobProgress < 20) return "listing"; + if (jobProgress < 90) return "processing"; + return "unsubscribe"; + }, [selectedJob, jobProgress]); const getPhaseForMessage = (message: string) => { const text = message.toLowerCase(); @@ -779,6 +1654,134 @@ export default function App() { return "processing"; }; + const localizeJobMessage = (message: string) => { + const trimmed = message.trim(); + const mapActionToken = (value: string) => { + const token = value.trim(); + const key = token.toUpperCase(); + switch (key) { + case "MOVE": + return t("ruleActionMove"); + case "DELETE": + return t("ruleActionDelete"); + case "ARCHIVE": + return t("ruleActionArchive"); + case "LABEL": + return t("ruleActionLabel"); + case "MARK_READ": + return t("ruleActionMarkRead"); + case "MARK_UNREAD": + return t("ruleActionMarkUnread"); + default: + return token; + } + }; + const mapActionList = (value: string) => + value + .split(",") + .map((entry) => mapActionToken(entry)) + .join(", "); + const mapActionWithTarget = (value: string) => { + const parts = value.trim().split(/\s+/); + if (!parts.length) return value; + const [first, ...rest] = parts; + const mapped = mapActionToken(first); + return rest.length ? `${mapped} ${rest.join(" ")}` : mapped; + }; + let match = trimmed.match(/^Cleanup started$/i); + if (match) return t("jobEventCleanupStarted"); + match = trimmed.match(/^Cleanup finished$/i); + if (match) return t("jobEventCleanupFinished"); + match = trimmed.match(/^Connecting to (.+)$/i); + if (match) return t("jobEventConnecting", { email: match[1] }); + match = trimmed.match(/^Listing Gmail messages$/i); + if (match) return t("jobEventListingGmail"); + match = trimmed.match(/^Listed (\d+) Gmail messages so far$/i); + if (match) return t("jobEventListedGmail", { count: match[1] }); + match = trimmed.match(/^Prepared (\d+) Gmail messages$/i); + if (match) return t("jobEventPreparedGmail", { count: match[1] }); + match = trimmed.match(/^Resuming Gmail cleanup at (\d+)\/(\d+)$/i); + if (match) return t("jobEventResumeGmail", { current: match[1], total: match[2] }); + match = trimmed.match(/^Processing (\d+) Gmail messages$/i); + if (match) return t("jobEventProcessingGmail", { count: match[1] }); + match = trimmed.match(/^No Gmail messages to process$/i); + if (match) return t("jobEventNoGmail"); + match = trimmed.match(/^Found (\d+) mailboxes$/i); + if (match) return t("jobEventFoundMailboxes", { count: match[1] }); + match = trimmed.match(/^Scanning (.+)$/i); + if (match) return t("jobEventScanningMailbox", { mailbox: match[1] }); + match = trimmed.match(/^Prepared (\d+) IMAP messages$/i); + if (match) return t("jobEventPreparedImap", { count: match[1] }); + match = trimmed.match(/^Resuming IMAP cleanup at (\d+)\/(\d+)$/i); + if (match) return t("jobEventResumeImap", { current: match[1], total: match[2] }); + match = trimmed.match(/^Processing (\d+) IMAP messages$/i); + if (match) return t("jobEventProcessingImap", { count: match[1] }); + match = trimmed.match(/^No IMAP messages to process$/i); + if (match) return t("jobEventNoImap"); + match = trimmed.match(/^Detected (\d+) newsletter candidates$/i); + if (match) return t("jobEventDetectedCandidates", { count: match[1] }); + match = trimmed.match(/^Processed (\d+)\/(\d+)$/i); + if (match) return t("jobEventProcessed", { current: match[1], total: match[2] }); + match = trimmed.match(/^Processed (\d+)$/i); + if (match) return t("jobEventProcessedCount", { current: match[1] }); + match = trimmed.match(/^Gmail action applied: (.+)$/i); + if (match) return t("jobEventGmailActionAppliedList", { actions: mapActionList(match[1]) }); + match = trimmed.match(/^Gmail action skipped: no label changes$/i); + if (match) return t("jobEventGmailActionSkippedNoChanges"); + match = trimmed.match(/^Gmail action (.+) applied$/i); + if (match) return t("jobEventGmailActionApplied", { action: mapActionToken(match[1]) }); + match = trimmed.match(/^Gmail action failed: (.+)$/i); + if (match) return t("jobEventGmailActionFailedSimple", { error: match[1] }); + match = trimmed.match(/^Gmail action (.+) failed: (.+)$/i); + if (match) return t("jobEventGmailActionFailed", { action: mapActionToken(match[1]), error: match[2] }); + match = trimmed.match(/^IMAP action (.+) failed: (.+)$/i); + if (match) return t("jobEventImapActionFailed", { action: mapActionToken(match[1]), error: match[2] }); + match = trimmed.match(/^Job canceled by admin$/i); + if (match) return t("jobEventCanceledByAdmin"); + match = trimmed.match(/^Job canceled before start$/i); + if (match) return t("jobEventCanceledBeforeStart"); + match = trimmed.match(/^Job failed: (.+)$/i); + if (match) return t("jobEventFailed", { error: match[1] }); + match = trimmed.match(/^DRY RUN: (.+)$/i); + if (match) return t("jobEventDryRunAction", { action: mapActionWithTarget(match[1]) }); + return message; + }; + + const isNoiseStatus = (message: string) => { + const text = message.toLowerCase(); + return ( + text.startsWith("dry run:") || + text.startsWith("nur simulieren") || + text.startsWith("simulation") || + text.startsWith("simulate only") || + text.includes("moved message") || + text.includes("deleted message") || + text.includes("gmail action") || + text.includes("unsubscribe ") + ); + }; + + const pickStatusForPhase = (phase: "listing" | "processing" | "unsubscribe") => { + if (phase === "unsubscribe") return null; + const candidates = orderedEvents.filter((event) => getPhaseForMessage(event.message) === phase); + const filtered = candidates.filter((event) => !isNoiseStatus(event.message)); + const preferred = filtered.find((event) => { + const text = event.message.toLowerCase(); + if (phase === "listing") { + return ( + text.includes("listing") || + text.includes("found") || + text.includes("prepared") || + text.includes("scanning") || + text.includes("connecting") || + text.includes("resuming") + ); + } + return text.includes("processing") || text.includes("processed"); + }); + return preferred ?? filtered[0] ?? candidates[0] ?? null; + }; + const unsubscribeSummary = useMemo(() => { let total = 0; let failed = 0; @@ -810,7 +1813,31 @@ export default function App() { time: null }; } + if (selectedJob?.dryRun) { + if (!unsubscribeSummary.total) { + return { + phase, + message: t("phaseUnsubscribeDryRunPending"), + time: null + }; + } + const processedTotal = selectedJob?.processedMessages ?? null; + const overallTotal = selectedJob?.totalMessages ?? null; + return { + phase, + message: overallTotal + ? t("phaseUnsubscribeDryRunSummaryWithProcessed", { + ...unsubscribeSummary, + processed: processedTotal ?? 0, + overall: overallTotal + }) + : t("phaseUnsubscribeDryRunSummary", unsubscribeSummary), + time: null + }; + } const match = orderedEvents.find((event) => getPhaseForMessage(event.message) === phase); + const processedTotal = selectedJob?.processedMessages ?? null; + const overallTotal = selectedJob?.totalMessages ?? null; if (!unsubscribeSummary.total) { return { phase, @@ -820,28 +1847,57 @@ export default function App() { } return { phase, - message: t("phaseUnsubscribeSummary", unsubscribeSummary), + message: selectedJob?.dryRun + ? overallTotal + ? t("phaseUnsubscribeSummaryWithProcessed", { + ...unsubscribeSummary, + processed: processedTotal ?? 0, + overall: overallTotal + }) + : t("phaseUnsubscribeSummary", unsubscribeSummary) + : overallTotal + ? t("phaseUnsubscribeSummaryNoDryRunWithProcessed", { + ok: unsubscribeSummary.ok, + failed: unsubscribeSummary.failed, + total: unsubscribeSummary.total, + processed: processedTotal ?? 0, + overall: overallTotal + }) + : t("phaseUnsubscribeSummaryNoDryRun", { + ok: unsubscribeSummary.ok, + failed: unsubscribeSummary.failed, + total: unsubscribeSummary.total + }), time: match?.createdAt ?? null }; } const match = orderedEvents.find((event) => getPhaseForMessage(event.message) === phase); return { phase, - message: match?.message ?? t(`phase${phase.charAt(0).toUpperCase()}${phase.slice(1)}Pending`), + message: match?.message ? localizeJobMessage(match.message) : t(`phase${phase.charAt(0).toUpperCase()}${phase.slice(1)}Pending`), time: match?.createdAt ?? null }; }); }, [orderedEvents, t, unsubscribeSummary, selectedJob]); const statusLine = useMemo(() => { + if (selectedJob?.status === "SUCCEEDED") { + return t("statusFinished"); + } + if (selectedJob?.status === "FAILED") { + return t("statusFailed"); + } + if (selectedJob?.status === "CANCELED") { + return t("statusCanceled"); + } if (jobPhase === "unsubscribe") { const summary = phaseSummaries.find((item) => item.phase === "unsubscribe"); return summary?.message ?? t("statusQueued"); } - const latest = orderedEvents[0]; - if (!latest) return t("statusQueued"); - return latest.message; - }, [orderedEvents, t, jobPhase, phaseSummaries]); + const candidate = pickStatusForPhase(jobPhase); + if (!candidate) return t("statusQueued"); + return localizeJobMessage(candidate.message); + }, [orderedEvents, t, jobPhase, phaseSummaries, selectedJob]); const phaseIndex = useMemo(() => { if (jobPhase === "listing") return 0; @@ -849,6 +1905,11 @@ export default function App() { return 2; }, [jobPhase]); + const previewCandidate = useMemo( + () => candidateItems.find((item) => item.id === previewCandidateId) ?? null, + [candidateItems, previewCandidateId] + ); + if (!isAuthenticated) { return (
@@ -949,7 +2010,9 @@ export default function App() { {showAdmin ? t("userWorkspace") : t("adminConsole")} )} - + setDryRun(e.target.checked)} /> {t("cleanupDryRun")} - {dryRun && ( -

{t("cleanupDryRunHint")}

- )} +

{t("cleanupDryRunHint")}

+

{t("cleanupUnsubscribeHint")}

+

{t("cleanupRoutingHint")}

{rules.length === 0 &&

{t("rulesEmpty")}

} - {rules.map((rule) => ( -
-
+ {rules.map((rule, index) => ( +
handleRuleDragOver(event, rule.id)} + onDrop={(event) => handleRuleDrop(event, rule.id)} + > +
+ {index + 1} + +
+
{rule.name}

{t("ruleConditionsCount", { count: rule.conditions.length })} ·{" "} {t("ruleActionsCount", { count: rule.actions.length })}

-
- - +
+
+ {rule.stopOnMatch && ( + {t("rulesStopOnMatchBadge")} + )} +
+
+ + +
))} @@ -1201,13 +2319,23 @@ export default function App() {
{jobs.length === 0 &&

{t("jobsEmpty")}

} {jobs.map((job) => ( -
-
+ -
+
+ + ℹ + +
+ ))} @@ -1218,8 +2346,14 @@ export default function App() {
event.stopPropagation()}>

{t("confirmTitle")}

-
@@ -1241,8 +2375,14 @@ export default function App() {
event.stopPropagation()}>

{editingAccountId ? t("mailboxEditTitle") : t("mailboxAdd")}

-
{!editingAccountId ? ( @@ -1299,8 +2439,14 @@ export default function App() { />

{providerHint()}

-
)} @@ -1391,8 +2537,14 @@ export default function App() {
event.stopPropagation()}>

{editingRuleId ? t("rulesEditTitle") : t("rulesAddTitle")}

-
@@ -1408,16 +2560,43 @@ export default function App() { setRuleEnabled(e.target.checked)} /> {t("rulesEnabled")} + +

{t("rulesConditions")}

+

{t("rulesExampleHint")}

{conditions.map((condition, idx) => ( -
+
- - setConditions((prev) => - prev.map((item, index) => - index === idx ? { ...item, value: event.target.value } : item + {condition.type === "UNSUBSCRIBE_STATUS" ? ( + + ) : condition.type === "SCORE" ? ( + + setConditions((prev) => + prev.map((item, index) => + index === idx ? { ...item, value: event.target.value } : item + ) + ) + } + /> + ) : ( + + setConditions((prev) => + prev.map((item, index) => + index === idx ? { ...item, value: event.target.value } : item + ) + ) + } + /> + )} +
))} @@ -1446,7 +2674,7 @@ export default function App() {

{t("rulesActions")}

{actions.map((action, idx) => ( -
+
- - setActions((prev) => - prev.map((item, index) => - index === idx ? { ...item, target: event.target.value } : item + {action.type === "MOVE" || action.type === "LABEL" ? ( + + setActions((prev) => + prev.map((item, index) => + index === idx ? { ...item, target: event.target.value } : item + ) ) - ) - } - /> + } + /> + ) : ( + + )} +
))} @@ -1500,8 +2743,14 @@ export default function App() { {mapJobStatus(selectedJob.status)} · {t(`phase${jobPhase.charAt(0).toUpperCase()}${jobPhase.slice(1)}`)} · {statusLine}

-
@@ -1567,8 +2816,10 @@ export default function App() {
{phaseSummaries.map((item) => { - const isActive = item.phase === jobPhase; - const isComplete = ["listing", "processing", "unsubscribe"].indexOf(item.phase) < phaseIndex; + const isActive = item.phase === jobPhase && selectedJob.status === "RUNNING"; + const isComplete = + selectedJob.status === "SUCCEEDED" || + ["listing", "processing", "unsubscribe"].indexOf(item.phase) < phaseIndex; const isDisabled = item.phase === "unsubscribe" && selectedJob.unsubscribeEnabled === false; return (
+
+
+

{t("resultsTitle")}

+

{t("resultsHint")}

+
+ +
+
+
+ )} + + {resultsModalOpen && selectedJob && ( +
+
event.stopPropagation()}> +
+
+

{t("resultsTitle")}

+

{selectedJobAccount?.email ?? "-"}

+
+
+ + {selectedJob?.status === "RUNNING" ? t("resultsLive") : t("resultsStatic")} + + + +
+
+
+
+
+ setCandidateSearch(event.target.value)} + /> + + +
+
+ +
+
+
+ + + {candidateGroupValue && candidateGroupBy !== "none" && ( + + )} + + + + + + +
+
+
+
+

{t("resultsGroups")}

+ {candidateLoading &&

{t("loadingCandidates")}

} + {candidateGroupBy === "none" ? ( +

{t("resultsGroupsDisabled")}

+ ) : candidateGroups.length === 0 ? ( +

{t("jobCandidatesGroupsEmpty")}

+ ) : ( +
    + {candidateGroups.map((group) => ( +
  • { + candidateGroupRefs.current[group.key] = el; + }} + className={[ + candidateGroupAnimations[group.key] ? `group-${candidateGroupAnimations[group.key]}` : "", + candidateGroupMoved.current.has(group.key) ? "group-moved" : "" + ].filter(Boolean).join(" ")} + > + +
  • + ))} +
+ )} +
+
+
+

{t("resultsItems")}

+ {candidateTotal !== null && ( + {t("jobCandidatesCount", { count: candidateTotal })} + )} +
+ {candidateGroupBy !== "none" && candidateGroupValue === null ? ( +

{t("resultsSelectGroup")}

+ ) : candidateItems.length === 0 ? ( +

{t("jobCandidatesGroupsEmpty")}

+ ) : ( +
    + {candidateItems.map((candidate) => ( +
  • +
    + toggleCandidateSelection(candidate.id, event.target.checked)} + /> +
    + +
    + +
    +
  • + ))} +
+ )} + {candidateHasMore && ( + + )} +
+
+

{t("resultsPreview")}

+ {previewLoading &&

{t("resultsPreviewLoading")}

} + {previewError &&

{previewError}

} + {!previewLoading && !previewData && ( +

{t("resultsSelectItem")}

+ )} + {previewData && ( +
+
+
+ {previewData.subject ?? "-"} +
+
+ {previewData.from ?? "-"} + {previewData.to && {previewData.to}} + {previewData.date && {new Date(previewData.date).toLocaleString()}} +
+ {previewCandidate && ( +
+ + {previewCandidate.actions?.length ? ( + + {t("jobCandidatesActions")}: {previewCandidate.actions.map((action) => action.type).join(", ")} + + ) : null} + + {t("jobCandidatesScore")}: {previewCandidate.score} + +
+ )} +
+ {previewData.attachments?.length ? ( +
+ {t("resultsAttachments")} +
    + {previewData.attachments.map((att) => ( +
  • + + {att.filename ?? "-"} · {att.contentType ?? "-"} · {att.size ?? 0} bytes + + +
  • + ))} +
+
+ ) : null} + {previewData.html ? ( +