Aktueller Stand

This commit is contained in:
2026-01-23 14:01:49 +01:00
parent 2766dd12c5
commit e16f6d50fb
46 changed files with 5482 additions and 311 deletions

19
.env
View File

@@ -48,6 +48,21 @@ OAUTH_STATE_TTL_SECONDS=600
# Cleanup scan limit (0 = no limit) # Cleanup scan limit (0 = no limit)
CLEANUP_SCAN_LIMIT=0 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 # Disallow custom IMAP/SMTP hosts unless explicitly enabled
ALLOW_CUSTOM_MAIL_HOSTS=false ALLOW_CUSTOM_MAIL_HOSTS=false
@@ -76,3 +91,7 @@ SEED_TENANT=Default Tenant
SEED_TENANT_ID=seed-tenant SEED_TENANT_ID=seed-tenant
SEED_ENABLED=false SEED_ENABLED=false
SEED_FORCE_PASSWORD_UPDATE=false SEED_FORCE_PASSWORD_UPDATE=false
UNSUBSCRIBE_HISTORY_TTL_DAYS=180
# Unsubscribe method preference: auto | http | mailto
UNSUBSCRIBE_METHOD_PREFERENCE=http

View File

@@ -48,6 +48,26 @@ OAUTH_STATE_TTL_SECONDS=600
# Cleanup scan limit (0 = no limit) # Cleanup scan limit (0 = no limit)
CLEANUP_SCAN_LIMIT=0 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 # Disallow custom IMAP/SMTP hosts unless explicitly enabled
ALLOW_CUSTOM_MAIL_HOSTS=false ALLOW_CUSTOM_MAIL_HOSTS=false

View File

@@ -95,8 +95,11 @@ Runs the full scan and logs what *would* happen, but **does not move/delete/unsu
**Unsubscribe aktiv** **Unsubscribe aktiv**
Enables `ListUnsubscribe` handling. Enables `ListUnsubscribe` handling.
- HTTP links are called (oneclick POST when supported). - **Preference** is controlled by the admin setting **“UnsubscribeMethode bevorzugen”** (`UNSUBSCRIBE_METHOD_PREFERENCE`): `http` (default), `mailto`, or `auto`.
- Mailto links are sent via SMTP (requires SMTP host + app password). - **HTTP** is tried first when preference is `http` or `auto` (oneclick 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** **Routing aktiv**
Applies your configured rules (conditions → actions). Applies your configured rules (conditions → actions).
@@ -172,6 +175,38 @@ Set these in `.env` before going public:
## Environment ## Environment
All config lives in the repo root `.env` (see `.env.example`). 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 singlepage 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 shortlived 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 settings:
- `EXPORT_DIR` (default `/tmp/mailcleaner-exports`) - `EXPORT_DIR` (default `/tmp/mailcleaner-exports`)
- `EXPORT_TTL_HOURS` (default `24`) - `EXPORT_TTL_HOURS` (default `24`)

View File

@@ -0,0 +1,2 @@
ALTER TABLE "CleanupJobCandidate"
ADD COLUMN "listUnsubscribePost" TEXT;

View File

@@ -0,0 +1,2 @@
ALTER TABLE "CleanupJobCandidate"
ADD COLUMN "unsubscribeDetails" JSONB;

View File

@@ -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;

View File

@@ -0,0 +1 @@
ALTER TYPE "RuleConditionType" ADD VALUE IF NOT EXISTS 'HEADER_MISSING';

View File

@@ -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");

View File

@@ -0,0 +1 @@
ALTER TABLE "Rule" ADD COLUMN "stopOnMatch" BOOLEAN NOT NULL DEFAULT false;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "UnsubscribeAttempt" ADD COLUMN "dedupeKey" TEXT;
-- CreateIndex
CREATE UNIQUE INDEX "UnsubscribeAttempt_jobId_dedupeKey_key" ON "UnsubscribeAttempt"("jobId", "dedupeKey");

View File

@@ -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';

View File

@@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "RuleConditionType" ADD VALUE IF NOT EXISTS 'SCORE';

View File

@@ -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");

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "CleanupJobCandidate" ADD COLUMN "reviewed" BOOLEAN NOT NULL DEFAULT false;

View File

@@ -0,0 +1,5 @@
-- CreateEnum
CREATE TYPE "RuleMatchMode" AS ENUM ('ALL', 'ANY');
-- AlterTable
ALTER TABLE "Rule" ADD COLUMN "matchMode" "RuleMatchMode" NOT NULL DEFAULT 'ALL';

View File

@@ -31,14 +31,19 @@ enum RuleActionType {
DELETE DELETE
ARCHIVE ARCHIVE
LABEL LABEL
MARK_READ
MARK_UNREAD
} }
enum RuleConditionType { enum RuleConditionType {
HEADER HEADER
HEADER_MISSING
SUBJECT SUBJECT
FROM FROM
LIST_UNSUBSCRIBE LIST_UNSUBSCRIBE
LIST_ID LIST_ID
UNSUBSCRIBE_STATUS
SCORE
} }
enum ExportStatus { enum ExportStatus {
@@ -60,6 +65,42 @@ model Tenant {
mailboxAccounts MailboxAccount[] mailboxAccounts MailboxAccount[]
rules Rule[] rules Rule[]
jobs CleanupJob[] 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 { model ExportJob {
@@ -120,6 +161,7 @@ model MailboxAccount {
tenant Tenant @relation(fields: [tenantId], references: [id]) tenant Tenant @relation(fields: [tenantId], references: [id])
folders MailboxFolder[] folders MailboxFolder[]
jobs CleanupJob[] jobs CleanupJob[]
candidates CleanupJobCandidate[]
@@index([tenantId]) @@index([tenantId])
} }
@@ -161,6 +203,9 @@ model Rule {
tenantId String tenantId String
name String name String
enabled Boolean @default(true) enabled Boolean @default(true)
matchMode RuleMatchMode @default(ALL)
position Int @default(0)
stopOnMatch Boolean @default(false)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -169,6 +214,12 @@ model Rule {
actions RuleAction[] actions RuleAction[]
@@index([tenantId]) @@index([tenantId])
@@index([tenantId, position])
}
enum RuleMatchMode {
ALL
ANY
} }
model RuleCondition { model RuleCondition {
@@ -205,6 +256,12 @@ model CleanupJob {
checkpointUpdatedAt DateTime? checkpointUpdatedAt DateTime?
processedMessages Int? processedMessages Int?
totalMessages Int? totalMessages Int?
listingSeconds Int?
processingSeconds Int?
unsubscribeSeconds Int?
routingSeconds Int?
unsubscribeAttempts Int?
actionAttempts Int?
startedAt DateTime? startedAt DateTime?
finishedAt DateTime? finishedAt DateTime?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@ -212,17 +269,50 @@ model CleanupJob {
tenant Tenant @relation(fields: [tenantId], references: [id]) tenant Tenant @relation(fields: [tenantId], references: [id])
mailboxAccount MailboxAccount @relation(fields: [mailboxAccountId], references: [id]) mailboxAccount MailboxAccount @relation(fields: [mailboxAccountId], references: [id])
unsubscribeAttempts UnsubscribeAttempt[] unsubscribeAttemptItems UnsubscribeAttempt[]
events CleanupJobEvent[] events CleanupJobEvent[]
candidates CleanupJobCandidate[]
@@index([tenantId]) @@index([tenantId])
@@index([mailboxAccountId]) @@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 { model UnsubscribeAttempt {
id String @id @default(cuid()) id String @id @default(cuid())
jobId String jobId String
mailItemId String? mailItemId String?
dedupeKey String?
method String method String
target String target String
status String status String
@@ -231,6 +321,19 @@ model UnsubscribeAttempt {
job CleanupJob @relation(fields: [jobId], references: [id]) job CleanupJob @relation(fields: [jobId], references: [id])
@@index([jobId]) @@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 { model CleanupJobEvent {

View File

@@ -1,6 +1,7 @@
import { FastifyInstance } from "fastify"; import { FastifyInstance } from "fastify";
import { z } from "zod"; import { z } from "zod";
import { prisma } from "../db.js"; import { prisma } from "../db.js";
import { config } from "../config.js";
import { logJobEvent } from "../queue/jobEvents.js"; import { logJobEvent } from "../queue/jobEvents.js";
import { queueCleanupJob, removeQueueJob, queueExportJob } from "../queue/queue.js"; import { queueCleanupJob, removeQueueJob, queueExportJob } from "../queue/queue.js";
import { createReadStream } from "node:fs"; import { createReadStream } from "node:fs";
@@ -28,7 +29,17 @@ const allowedSettings = [
"google.client_id", "google.client_id",
"google.client_secret", "google.client_secret",
"google.redirect_uri", "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; ] as const;
export async function adminRoutes(app: FastifyInstance) { 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_id": process.env.GOOGLE_CLIENT_ID ?? null,
"google.client_secret": process.env.GOOGLE_CLIENT_SECRET ?? null, "google.client_secret": process.env.GOOGLE_CLIENT_SECRET ?? null,
"google.redirect_uri": process.env.GOOGLE_REDIRECT_URI ?? 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<Record<string, { value: string | null; source: "db" | "env" | "unset" }>>((acc, key) => { const settings = keys.reduce<Record<string, { value: string | null; source: "db" | "env" | "unset" }>>((acc, key) => {
const dbValue = stored[key]; const dbValue = stored[key];
@@ -223,6 +244,7 @@ export async function adminRoutes(app: FastifyInstance) {
const jobIds = jobs.map((job) => job.id); const jobIds = jobs.map((job) => job.id);
await tx.cleanupJobEvent.deleteMany({ where: { jobId: { in: jobIds } } }); await tx.cleanupJobEvent.deleteMany({ where: { jobId: { in: jobIds } } });
await tx.unsubscribeAttempt.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.cleanupJob.deleteMany({ where: { tenantId: tenant.id } });
await tx.ruleAction.deleteMany({ where: { rule: { tenantId: tenant.id } } }); await tx.ruleAction.deleteMany({ where: { rule: { tenantId: tenant.id } } });
await tx.ruleCondition.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 prisma.$transaction(async (tx) => {
await tx.cleanupJobEvent.deleteMany({ where: { jobId: job.id } }); await tx.cleanupJobEvent.deleteMany({ where: { jobId: job.id } });
await tx.unsubscribeAttempt.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 } }); await tx.cleanupJob.delete({ where: { id: job.id } });
}); });

View File

@@ -30,6 +30,21 @@ const envSchema = z.object({
SSE_TOKEN_TTL_SECONDS: z.coerce.number().default(300), SSE_TOKEN_TTL_SECONDS: z.coerce.number().default(300),
OAUTH_STATE_TTL_SECONDS: z.coerce.number().default(600), OAUTH_STATE_TTL_SECONDS: z.coerce.number().default(600),
CLEANUP_SCAN_LIMIT: z.coerce.number().default(0), 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), ALLOW_CUSTOM_MAIL_HOSTS: envBoolean(false),
BLOCK_PRIVATE_NETWORKS: envBoolean(true), BLOCK_PRIVATE_NETWORKS: envBoolean(true),
ENCRYPTION_KEY: z.string().optional(), ENCRYPTION_KEY: z.string().optional(),
@@ -67,6 +82,18 @@ const parsed = envSchema.safeParse({
SSE_TOKEN_TTL_SECONDS: process.env.SSE_TOKEN_TTL_SECONDS, SSE_TOKEN_TTL_SECONDS: process.env.SSE_TOKEN_TTL_SECONDS,
OAUTH_STATE_TTL_SECONDS: process.env.OAUTH_STATE_TTL_SECONDS, OAUTH_STATE_TTL_SECONDS: process.env.OAUTH_STATE_TTL_SECONDS,
CLEANUP_SCAN_LIMIT: process.env.CLEANUP_SCAN_LIMIT, 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, ALLOW_CUSTOM_MAIL_HOSTS: process.env.ALLOW_CUSTOM_MAIL_HOSTS,
BLOCK_PRIVATE_NETWORKS: process.env.BLOCK_PRIVATE_NETWORKS, BLOCK_PRIVATE_NETWORKS: process.env.BLOCK_PRIVATE_NETWORKS,
ENCRYPTION_KEY: process.env.ENCRYPTION_KEY, ENCRYPTION_KEY: process.env.ENCRYPTION_KEY,

View File

@@ -5,7 +5,7 @@ import { createImapClient, fetchHeadersByUids, listMailboxes } from "./imap.js";
import { detectNewsletter } from "./newsletter.js"; import { detectNewsletter } from "./newsletter.js";
import { matchRules } from "./rules.js"; import { matchRules } from "./rules.js";
import { unsubscribeFromHeader } from "./unsubscribe.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) => { export const runCleanup = async (cleanupJobId: string, mailboxAccountId: string) => {
const account = await prisma.mailboxAccount.findUnique({ where: { id: mailboxAccountId } }); 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({ const rules = await prisma.rule.findMany({
where: { tenantId: job.tenantId }, 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); await logJobEvent(cleanupJobId, "info", `Connecting to ${account.email}`, 5);
const isGmail = account.provider === "GMAIL"; 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<ReturnType<typeof gmailClientForAccount>>["gmail"];
const processMessage = async (msg: { const processMessage = async (msg: {
uid: number; uid: number;
subject?: string; subject?: string;
from?: string; from?: string;
receivedAt?: Date;
headers: Map<string, string>; headers: Map<string, string>;
gmailMessageId?: string; gmailMessageId?: string;
}) => { mailbox?: string;
}, gmailContext?: { gmail: GmailClient; resolveLabelId: (label: string) => Promise<string> }) => {
const ctx = { const ctx = {
headers: msg.headers, headers: msg.headers,
subject: msg.subject ?? "", subject: msg.subject ?? "",
from: msg.from ?? "" from: msg.from ?? ""
}; };
const result = detectNewsletter(ctx); const result = detectNewsletter({ ...ctx, config: newsletterConfig });
if (!result.isNewsletter) { if (!result.isNewsletter) {
return false; 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<string, any> | 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) { if (actions.length > 0) {
for (const action of actions) { for (const action of actions) {
if (job.dryRun) { if (job.dryRun) {
await logJobEvent(cleanupJobId, "info", `DRY RUN: ${action.type} ${action.target ?? ""}`); await logJobEvent(cleanupJobId, "info", `DRY RUN: ${action.type} ${action.target ?? ""}`);
actionLog.push({ type: action.type, target: action.target ?? null, status: "dry-run" });
continue; continue;
} }
if (account.provider === "GMAIL" && msg.gmailMessageId) { if (account.provider === "GMAIL" && msg.gmailMessageId && gmailContext) {
await applyGmailAction({ const actionStart = Date.now();
account, actionAttempts += 1;
gmailMessageId: msg.gmailMessageId, const actionLogItems = actions.map((item) => ({
action: action.type, type: item.type,
target: action.target target: item.target ?? null,
}); status: "pending" as const,
await logJobEvent(cleanupJobId, "info", `Gmail action ${action.type} applied`); error: undefined as string | undefined
continue; }));
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<string>();
const removeLabelIds = new Set<string>();
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) { if (!imapClient) {
await logJobEvent(cleanupJobId, "info", "Skipping IMAP action: no IMAP client"); await logJobEvent(cleanupJobId, "info", "Skipping IMAP action: no IMAP client");
actionLog.push({ type: action.type, target: action.target ?? null, status: "skipped" });
} else { } else {
if ((action.type === "MOVE" || action.type === "ARCHIVE" || action.type === "LABEL") && action.target) { const actionStart = Date.now();
await imapClient.mailboxCreate(action.target).catch(() => undefined); actionAttempts += 1;
await imapClient.messageMove(msg.uid, action.target); try {
await logJobEvent(cleanupJobId, "info", `Moved message ${msg.uid} to ${action.target}`); if ((action.type === "MOVE" || action.type === "ARCHIVE" || action.type === "LABEL") && action.target) {
} await imapClient.mailboxCreate(action.target).catch(() => undefined);
if (action.type === "DELETE") { await imapClient.messageMove(msg.uid, action.target);
await imapClient.messageDelete(msg.uid); await logJobEvent(cleanupJobId, "info", `Moved message ${msg.uid} to ${action.target}`);
await logJobEvent(cleanupJobId, "info", `Deleted message ${msg.uid}`); }
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) { if (actionLog.length || unsubscribeStatus !== "pending" || unsubscribeTarget) {
const listUnsubscribe = msg.headers.get("list-unsubscribe") ?? null; await prisma.cleanupJobCandidate.update({
const listUnsubscribePost = msg.headers.get("list-unsubscribe-post") ?? null; where: { id: candidate.id },
if (listUnsubscribe) { data: {
const attempt = await prisma.unsubscribeAttempt.create({ actions: actionLog.length ? actionLog : undefined,
data: { unsubscribeStatus,
jobId: cleanupJobId, unsubscribeMessage,
method: "list-unsubscribe", unsubscribeTarget,
target: listUnsubscribe, unsubscribeDetails
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}`);
} }
} });
} }
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; return true;
}; };
@@ -204,9 +491,38 @@ export const runCleanup = async (cleanupJobId: string, mailboxAccountId: string)
let nextIndex = 0; let nextIndex = 0;
let messageIds: string[] = []; let messageIds: string[] = [];
let imapUids: number[] = []; 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<string>();
if (isGmail && hasGmailOAuth) { if (isGmail && hasGmailOAuth) {
const { gmail } = await gmailClientForAccount(account); const { gmail } = await gmailClientForAccount(account);
const labelCache = new Map<string, string>();
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") { if (checkpoint?.provider === "GMAIL") {
messageIds = checkpoint.messageIds ?? []; messageIds = checkpoint.messageIds ?? [];
total = checkpoint.total ?? messageIds.length; total = checkpoint.total ?? messageIds.length;
@@ -215,6 +531,7 @@ export const runCleanup = async (cleanupJobId: string, mailboxAccountId: string)
} }
if (!messageIds.length) { if (!messageIds.length) {
const listingStart = Date.now();
const ids: string[] = []; const ids: string[] = [];
let pageToken: string | undefined; let pageToken: string | undefined;
let pageCount = 0; let pageCount = 0;
@@ -250,6 +567,7 @@ export const runCleanup = async (cleanupJobId: string, mailboxAccountId: string)
total total
}; };
await saveCheckpoint(checkpoint, nextIndex, 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); await logJobEvent(cleanupJobId, "info", `Prepared ${total} Gmail messages`, 12);
} else { } else {
await logJobEvent(cleanupJobId, "info", `Resuming Gmail cleanup at ${nextIndex}/${total}`, 12); 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) { if (total === 0) {
await logJobEvent(cleanupJobId, "info", "No Gmail messages to process", 90); 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; return;
} }
await logJobEvent(cleanupJobId, "info", `Processing ${total} Gmail messages`, 35); await logJobEvent(cleanupJobId, "info", `Processing ${total} Gmail messages`, 35);
const processingStart = Date.now(); const processingStart = Date.now();
let newsletterCount = 0; 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<string>(["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++) { for (let index = nextIndex; index < messageIds.length; index++) {
const statusCheck = await prisma.cleanupJob.findUnique({ where: { id: cleanupJobId } }); const statusCheck = await prisma.cleanupJob.findUnique({ where: { id: cleanupJobId } });
@@ -300,11 +638,13 @@ export const runCleanup = async (cleanupJobId: string, mailboxAccountId: string)
uid: 0, uid: 0,
subject: headers.get("subject"), subject: headers.get("subject"),
from: headers.get("from"), from: headers.get("from"),
receivedAt: meta.data.internalDate ? new Date(Number(meta.data.internalDate)) : undefined,
headers, headers,
gmailMessageId: id gmailMessageId: id,
mailbox: "INBOX"
}; };
const isNewsletter = await processMessage(msg); const isNewsletter = await processMessage(msg, { gmail, resolveLabelId });
if (isNewsletter) newsletterCount += 1; if (isNewsletter) newsletterCount += 1;
const processed = index + 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); 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; return;
} }
@@ -342,6 +694,7 @@ export const runCleanup = async (cleanupJobId: string, mailboxAccountId: string)
await imapClient.mailboxOpen(targetMailbox, { readOnly: job.dryRun }); await imapClient.mailboxOpen(targetMailbox, { readOnly: job.dryRun });
if (!imapUids.length) { if (!imapUids.length) {
const listingStart = Date.now();
await logJobEvent(cleanupJobId, "info", `Scanning ${targetMailbox}`, 15); await logJobEvent(cleanupJobId, "info", `Scanning ${targetMailbox}`, 15);
const search = await imapClient.search({ all: true }); const search = await imapClient.search({ all: true });
const limited = scanLimit && scanLimit > 0 ? search.slice(-scanLimit) : search; const limited = scanLimit && scanLimit > 0 ? search.slice(-scanLimit) : search;
@@ -357,6 +710,7 @@ export const runCleanup = async (cleanupJobId: string, mailboxAccountId: string)
total total
}; };
await saveCheckpoint(checkpoint, nextIndex, 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); await logJobEvent(cleanupJobId, "info", `Prepared ${total} IMAP messages`, 18);
} else { } else {
await logJobEvent(cleanupJobId, "info", `Resuming IMAP cleanup at ${nextIndex}/${total}`, 18); 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) { if (total === 0) {
await logJobEvent(cleanupJobId, "info", "No IMAP messages to process", 90); 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; return;
} }
@@ -401,7 +766,7 @@ export const runCleanup = async (cleanupJobId: string, mailboxAccountId: string)
processed += 1; processed += 1;
continue; continue;
} }
const isNewsletter = await processMessage(msg); const isNewsletter = await processMessage({ ...msg, mailbox: targetMailbox });
if (isNewsletter) newsletterCount += 1; if (isNewsletter) newsletterCount += 1;
processed += 1; processed += 1;
} }
@@ -418,6 +783,18 @@ export const runCleanup = async (cleanupJobId: string, mailboxAccountId: string)
} }
await logJobEvent(cleanupJobId, "info", `Detected ${newsletterCount} newsletter candidates`, 92); 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 { } finally {
await imapClient?.logout().catch(() => undefined); await imapClient?.logout().catch(() => undefined);
} }

View File

@@ -105,7 +105,7 @@ export const ensureGmailLabel = async (gmail: ReturnType<typeof google.gmail>, l
export const applyGmailAction = async (params: { export const applyGmailAction = async (params: {
account: MailboxAccount; account: MailboxAccount;
gmailMessageId: string; gmailMessageId: string;
action: "LABEL" | "MOVE" | "ARCHIVE" | "DELETE"; action: "LABEL" | "MOVE" | "ARCHIVE" | "DELETE" | "MARK_READ" | "MARK_UNREAD";
target?: string | null; target?: string | null;
}) => { }) => {
const { gmail } = await gmailClientForAccount(params.account); const { gmail } = await gmailClientForAccount(params.account);
@@ -124,13 +124,34 @@ export const applyGmailAction = async (params: {
return; 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") { if (params.action === "MOVE" || params.action === "LABEL") {
const labelName = params.target ?? "Newsletter"; const labelName = params.target ?? "Newsletter";
const labelId = await ensureGmailLabel(gmail, labelName); const labelId = await ensureGmailLabel(gmail, labelName);
await gmail.users.messages.modify({ await gmail.users.messages.modify({
userId: "me", userId: "me",
id: params.gmailMessageId, id: params.gmailMessageId,
requestBody: { addLabelIds: [labelId] } requestBody: {
addLabelIds: [labelId],
...(params.action === "MOVE" ? { removeLabelIds: ["INBOX"] } : {})
}
}); });
} }
}; };

View File

@@ -35,6 +35,7 @@ export const fetchHeaders = async (
uid: number; uid: number;
subject?: string; subject?: string;
from?: string; from?: string;
receivedAt?: Date;
headers: Map<string, string>; headers: Map<string, string>;
gmailMessageId?: string; gmailMessageId?: string;
}[]; }[];
@@ -55,6 +56,7 @@ export const fetchHeaders = async (
uid: msg.uid, uid: msg.uid,
subject: parsed.subject, subject: parsed.subject,
from: parsed.from?.text, from: parsed.from?.text,
receivedAt: parsed.date ?? undefined,
headers, headers,
gmailMessageId: (msg as { gmailMessageId?: string }).gmailMessageId gmailMessageId: (msg as { gmailMessageId?: string }).gmailMessageId
}); });
@@ -72,6 +74,7 @@ export const fetchHeadersByUids = async (client: ImapFlow, uids: number[]) => {
uid: number; uid: number;
subject?: string; subject?: string;
from?: string; from?: string;
receivedAt?: Date;
headers: Map<string, string>; headers: Map<string, string>;
gmailMessageId?: string; gmailMessageId?: string;
}[]; }[];
@@ -88,6 +91,7 @@ export const fetchHeadersByUids = async (client: ImapFlow, uids: number[]) => {
uid: msg.uid, uid: msg.uid,
subject: parsed.subject, subject: parsed.subject,
from: parsed.from?.text, from: parsed.from?.text,
receivedAt: parsed.date ?? undefined,
headers, headers,
gmailMessageId: (msg as { gmailMessageId?: string }).gmailMessageId gmailMessageId: (msg as { gmailMessageId?: string }).gmailMessageId
}); });

View File

@@ -1,5 +1,33 @@
const headerIncludes = (headers: Map<string, string>, key: string) => export type NewsletterConfig = {
headers.has(key.toLowerCase()); 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<string, string>, key: string) => const headerValue = (headers: Map<string, string>, key: string) =>
headers.get(key.toLowerCase()) ?? ""; headers.get(key.toLowerCase()) ?? "";
@@ -7,39 +35,64 @@ const headerValue = (headers: Map<string, string>, key: string) =>
const containsAny = (value: string, tokens: string[]) => const containsAny = (value: string, tokens: string[]) =>
tokens.some((token) => value.includes(token)); tokens.some((token) => value.includes(token));
const normalizeList = (items: string[]) =>
items.map((item) => item.trim().toLowerCase()).filter(Boolean);
export const detectNewsletter = (params: { export const detectNewsletter = (params: {
headers: Map<string, string>; headers: Map<string, string>;
subject?: string | null; subject?: string | null;
from?: string | null; from?: string | null;
config?: Partial<NewsletterConfig>;
}) => { }) => {
const subject = (params.subject ?? "").toLowerCase(); const subject = (params.subject ?? "").toLowerCase();
const from = (params.from ?? "").toLowerCase(); const from = (params.from ?? "").toLowerCase();
const headers = params.headers; 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 matchedHeaderKeys = config.headerKeys.filter((key) => headers.has(key));
const hasListId = headerIncludes(headers, "list-id");
const precedence = headerValue(headers, "precedence").toLowerCase(); const precedence = headerValue(headers, "precedence").toLowerCase();
const bulkHeader = headerValue(headers, "x-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"]) || const subjectMatches = config.subjectTokens.filter((token) => subject.includes(token));
containsAny(bulkHeader, ["bulk", "list"]) || const fromMatches = config.fromTokens.filter((token) => from.includes(token));
headerIncludes(headers, "list-unsubscribe-post");
const subjectHints = containsAny(subject, ["newsletter", "unsubscribe", "update", "news", "digest"]); const headerScore = matchedHeaderKeys.length * config.weightHeader;
const fromHints = containsAny(from, ["newsletter", "no-reply", "noreply", "news", "updates"]); const precedenceScore = precedenceHint ? config.weightPrecedence : 0;
const subjectScore = subjectMatches.length ? config.weightSubject : 0;
const score = [hasListUnsubscribe, hasListId, headerHints, subjectHints, fromHints].filter(Boolean).length; const fromScore = fromMatches.length ? config.weightFrom : 0;
const score = headerScore + precedenceScore + subjectScore + fromScore;
return { return {
isNewsletter: score >= 2, isNewsletter: score >= config.threshold,
score, score,
signals: { signals: {
hasListUnsubscribe, headerKeys: matchedHeaderKeys,
hasListId, precedenceHint,
headerHints, subjectTokens: subjectMatches,
subjectHints, fromTokens: fromMatches,
fromHints 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
}
} }
}; };
}; };

View File

@@ -203,6 +203,9 @@ export async function mailRoutes(app: FastifyInstance) {
await tx.unsubscribeAttempt.deleteMany({ await tx.unsubscribeAttempt.deleteMany({
where: { job: { mailboxAccountId: account.id } } where: { job: { mailboxAccountId: account.id } }
}); });
await tx.cleanupJobCandidate.deleteMany({
where: { mailboxAccountId: account.id }
});
await tx.cleanupJob.deleteMany({ await tx.cleanupJob.deleteMany({
where: { mailboxAccountId: account.id } where: { mailboxAccountId: account.id }
}); });

View File

@@ -6,25 +6,66 @@ const getHeader = (headers: Map<string, string>, name: string) =>
const contains = (value: string, needle: string) => const contains = (value: string, needle: string) =>
value.toLowerCase().includes(needle.toLowerCase()); value.toLowerCase().includes(needle.toLowerCase());
const isWildcard = (value: string) => value.trim() === "*";
const matchCondition = (condition: RuleCondition, ctx: { const matchCondition = (condition: RuleCondition, ctx: {
subject: string; subject: string;
from: string; from: string;
headers: Map<string, string>; headers: Map<string, string>;
unsubscribeStatus?: string | null;
newsletterScore?: number | null;
}) => { }) => {
const value = condition.value; const value = condition.value;
switch (condition.type) { switch (condition.type) {
case "SUBJECT": case "SUBJECT":
return contains(ctx.subject, value); return isWildcard(value) ? ctx.subject.trim().length > 0 : contains(ctx.subject, value);
case "FROM": case "FROM":
return contains(ctx.from, value); return isWildcard(value) ? ctx.from.trim().length > 0 : contains(ctx.from, value);
case "LIST_ID": 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": 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": { case "HEADER": {
const [headerName, headerValue] = value.split(":"); const parts = value.split(":");
if (!headerName || !headerValue) return false; const headerName = parts[0]?.trim();
return contains(getHeader(ctx.headers, headerName.trim()), headerValue.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: default:
return false; return false;
@@ -35,14 +76,21 @@ export const matchRules = (rules: (Rule & { conditions: RuleCondition[]; actions
subject: string; subject: string;
from: string; from: string;
headers: Map<string, string>; headers: Map<string, string>;
unsubscribeStatus?: string | null;
newsletterScore?: number | null;
}) => { }) => {
const matched: RuleAction[] = []; const matched: RuleAction[] = [];
for (const rule of rules) { for (const rule of rules) {
if (!rule.enabled) continue; if (!rule.enabled) continue;
const allMatch = rule.conditions.every((condition) => matchCondition(condition, ctx)); 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); matched.push(...rule.actions);
if (rule.stopOnMatch) {
break;
}
} }
} }

View File

@@ -3,6 +3,8 @@ import { MailboxAccount } from "@prisma/client";
import { isPrivateHost } from "../security/ssrf.js"; import { isPrivateHost } from "../security/ssrf.js";
import { decryptSecret } from "../security/crypto.js"; import { decryptSecret } from "../security/crypto.js";
import { config } from "../config.js"; import { config } from "../config.js";
import { gmailClientForAccount } from "./gmail.js";
import { getSetting } from "../admin/settings.js";
const parseListUnsubscribe = (value: string) => { const parseListUnsubscribe = (value: string) => {
const tokens = value const tokens = value
@@ -24,67 +26,123 @@ export const unsubscribeFromHeader = async (params: {
from?: string | null; from?: string | null;
}) => { }) => {
if (!params.listUnsubscribe) { 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 { httpLinks, mailtoLinks } = parseListUnsubscribe(params.listUnsubscribe);
const postHint = (params.listUnsubscribePost ?? "").toLowerCase(); const postHint = (params.listUnsubscribePost ?? "").toLowerCase();
const preference = ((await getSetting("unsubscribe.method_preference")) ?? config.UNSUBSCRIBE_METHOD_PREFERENCE ?? "auto").toLowerCase();
if (httpLinks.length > 0) { const tryHttp = async (target: string) => {
const target = httpLinks[0];
let parsed: URL; let parsed: URL;
try { try {
parsed = new URL(target); parsed = new URL(target);
} catch { } 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)) { 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)) { 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 usePost = postHint.includes("one-click");
const controller = new AbortController(); try {
const timeout = setTimeout(() => controller.abort(), 8000); const controller = new AbortController();
const response = await fetch(target, { const timeout = setTimeout(() => controller.abort(), 8000);
method: usePost ? "POST" : "GET", const response = await fetch(target, {
headers: usePost ? { "Content-Type": "application/x-www-form-urlencoded" } : undefined, method: usePost ? "POST" : "GET",
body: usePost ? "List-Unsubscribe=One-Click" : undefined, headers: usePost ? { "Content-Type": "application/x-www-form-urlencoded" } : undefined,
redirect: "manual", body: usePost ? "List-Unsubscribe=One-Click" : undefined,
signal: controller.signal redirect: "manual",
}); signal: controller.signal
clearTimeout(timeout); });
clearTimeout(timeout);
if (response.status >= 300 && response.status < 400) { if (response.status >= 300 && response.status < 400) {
const location = response.headers.get("location"); const location = response.headers.get("location");
if (!location) { if (!location) {
return { status: "failed", message: `HTTP ${response.status}` }; return { status: "failed", message: `HTTP ${response.status}`, details: { method: usePost ? "POST" : "GET", url: target, status: response.status } };
} }
try { try {
const redirected = new URL(location, parsed); const redirected = new URL(location, parsed);
if (config.BLOCK_PRIVATE_NETWORKS && await isPrivateHost(redirected.hostname)) { if (config.BLOCK_PRIVATE_NETWORKS && await isPrivateHost(redirected.hostname)) {
return { status: "failed", message: "Blocked private redirect" }; 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 smtpHost = params.account.smtpHost;
const smtpPort = params.account.smtpPort ?? 587; const smtpPort = params.account.smtpPort ?? 587;
const smtpTLS = params.account.smtpTLS ?? true; const smtpTLS = params.account.smtpTLS ?? true;
if (!smtpHost || !params.account.appPassword) { 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({ const transporter = nodemailer.createTransport({
@@ -99,13 +157,57 @@ export const unsubscribeFromHeader = async (params: {
await transporter.sendMail({ await transporter.sendMail({
from: params.account.email, from: params.account.email,
to: target.replace("mailto:", ""), to,
subject: params.subject ?? "Unsubscribe", subject: mailSubject,
text: "Please unsubscribe me from this mailing list." 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<string, unknown> }) => {
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" } };
}; };

View File

@@ -1,6 +1,10 @@
import { FastifyInstance } from "fastify"; import { FastifyInstance } from "fastify";
import { prisma } from "../db.js"; import { prisma } from "../db.js";
import { config } from "../config.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) { export async function queueRoutes(app: FastifyInstance) {
app.addHook("preHandler", app.authenticate); app.addHook("preHandler", app.authenticate);
@@ -35,7 +39,35 @@ export async function queueRoutes(app: FastifyInstance) {
orderBy: { createdAt: "desc" } 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) => { app.get("/:id", async (request, reply) => {
@@ -71,6 +103,704 @@ export async function queueRoutes(app: FastifyInstance) {
return { events }; 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<string, unknown> = { 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<GroupField, string | null>)[field] ?? "",
count: item._count.id
}))
};
}
const where: Record<string, unknown> = { ...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<string, unknown> = { 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<string, unknown> = { 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<string, unknown> = { 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) => { app.get("/:id/stream-token", async (request, reply) => {
const params = request.params as { id: string }; const params = request.params as { id: string };
const job = await prisma.cleanupJob.findFirst({ const job = await prisma.cleanupJob.findFirst({

View File

@@ -5,12 +5,14 @@ import { prisma } from "../db.js";
const ruleSchema = z.object({ const ruleSchema = z.object({
name: z.string().min(2), name: z.string().min(2),
enabled: z.boolean().optional(), enabled: z.boolean().optional(),
matchMode: z.enum(["ALL", "ANY"]).optional(),
stopOnMatch: z.boolean().optional(),
conditions: z.array(z.object({ 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) value: z.string().min(1)
})), })),
actions: z.array(z.object({ 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() target: z.string().optional()
})) }))
}); });
@@ -21,18 +23,28 @@ export async function rulesRoutes(app: FastifyInstance) {
app.get("/", async (request) => { app.get("/", async (request) => {
const rules = await prisma.rule.findMany({ const rules = await prisma.rule.findMany({
where: { tenantId: request.user.tenantId }, where: { tenantId: request.user.tenantId },
include: { conditions: true, actions: true } include: { conditions: true, actions: true },
orderBy: [{ position: "asc" }, { createdAt: "asc" }]
}); });
return { rules }; return { rules };
}); });
app.post("/", async (request, reply) => { app.post("/", async (request, reply) => {
const input = ruleSchema.parse(request.body); 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({ const rule = await prisma.rule.create({
data: { data: {
tenantId: request.user.tenantId, tenantId: request.user.tenantId,
name: input.name, name: input.name,
enabled: input.enabled ?? true, enabled: input.enabled ?? true,
matchMode: input.matchMode ?? "ALL",
position: nextPosition,
stopOnMatch: input.stopOnMatch ?? false,
conditions: { conditions: {
create: input.conditions create: input.conditions
}, },
@@ -45,6 +57,37 @@ export async function rulesRoutes(app: FastifyInstance) {
return reply.code(201).send({ rule }); 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) => { app.put("/:id", async (request, reply) => {
const params = request.params as { id: string }; const params = request.params as { id: string };
const input = ruleSchema.parse(request.body); const input = ruleSchema.parse(request.body);
@@ -64,6 +107,8 @@ export async function rulesRoutes(app: FastifyInstance) {
data: { data: {
name: input.name, name: input.name,
enabled: input.enabled ?? true, enabled: input.enabled ?? true,
matchMode: input.matchMode ?? "ALL",
stopOnMatch: input.stopOnMatch ?? false,
conditions: { create: input.conditions }, conditions: { create: input.conditions },
actions: { create: input.actions } actions: { create: input.actions }
}, },

View File

@@ -8,6 +8,10 @@ import { runExportJob, startExportCleanupLoop } from "./admin/exportWorker.js";
const connection = new IORedis(config.REDIS_URL, { maxRetriesPerRequest: null }); 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( const worker = new Worker(
"cleanup", "cleanup",
async (job) => { async (job) => {
@@ -35,6 +39,138 @@ const worker = new Worker(
data: { status: "SUCCEEDED", finishedAt: new Date() } data: { status: "SUCCEEDED", finishedAt: new Date() }
}); });
await logJobEvent(cleanupJobId, "info", "Cleanup finished", 100); 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<string, number> = {};
const counts: Record<string, number> = {};
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 { } catch {
return { ok: false, skipped: true }; return { ok: false, skipped: true };
} }

View File

@@ -3,6 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="/favicon.ico" />
<title>Simple Mail Cleaner</title> <title>Simple Mail Cleaner</title>
</head> </head>
<body> <body>

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 B

BIN
frontend/public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 B

File diff suppressed because it is too large Load Diff

View File

@@ -85,7 +85,17 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
googleClientId: "", googleClientId: "",
googleClientSecret: "", googleClientSecret: "",
googleRedirectUri: "", googleRedirectUri: "",
cleanupScanLimit: "" cleanupScanLimit: "",
newsletterThreshold: "",
newsletterSubjectTokens: "",
newsletterFromTokens: "",
newsletterHeaderKeys: "",
newsletterWeightHeader: "",
newsletterWeightPrecedence: "",
newsletterWeightSubject: "",
newsletterWeightFrom: "",
unsubscribeHistoryTtlDays: "",
unsubscribeMethodPreference: "auto"
}); });
const [settingsStatus, setSettingsStatus] = useState<"idle" | "saving" | "saved" | "error">("idle"); const [settingsStatus, setSettingsStatus] = useState<"idle" | "saving" | "saved" | "error">("idle");
const [showGoogleSecret, setShowGoogleSecret] = useState(false); const [showGoogleSecret, setShowGoogleSecret] = useState(false);
@@ -162,7 +172,17 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
googleClientId: next["google.client_id"]?.value ?? "", googleClientId: next["google.client_id"]?.value ?? "",
googleClientSecret: next["google.client_secret"]?.value ?? "", googleClientSecret: next["google.client_secret"]?.value ?? "",
googleRedirectUri: next["google.redirect_uri"]?.value ?? "", googleRedirectUri: next["google.redirect_uri"]?.value ?? "",
cleanupScanLimit: next["cleanup.scan_limit"]?.value ?? "" cleanupScanLimit: next["cleanup.scan_limit"]?.value ?? "",
newsletterThreshold: next["newsletter.threshold"]?.value ?? "",
newsletterSubjectTokens: next["newsletter.subject_tokens"]?.value ?? "",
newsletterFromTokens: next["newsletter.from_tokens"]?.value ?? "",
newsletterHeaderKeys: next["newsletter.header_keys"]?.value ?? "",
newsletterWeightHeader: next["newsletter.weight_header"]?.value ?? "",
newsletterWeightPrecedence: next["newsletter.weight_precedence"]?.value ?? "",
newsletterWeightSubject: next["newsletter.weight_subject"]?.value ?? "",
newsletterWeightFrom: next["newsletter.weight_from"]?.value ?? "",
unsubscribeHistoryTtlDays: next["unsubscribe.history_ttl_days"]?.value ?? "",
unsubscribeMethodPreference: next["unsubscribe.method_preference"]?.value ?? "auto"
}); });
}; };
@@ -452,7 +472,17 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
"google.client_id": settingsDraft.googleClientId, "google.client_id": settingsDraft.googleClientId,
"google.client_secret": settingsDraft.googleClientSecret, "google.client_secret": settingsDraft.googleClientSecret,
"google.redirect_uri": settingsDraft.googleRedirectUri, "google.redirect_uri": settingsDraft.googleRedirectUri,
"cleanup.scan_limit": settingsDraft.cleanupScanLimit "cleanup.scan_limit": settingsDraft.cleanupScanLimit,
"newsletter.threshold": settingsDraft.newsletterThreshold,
"newsletter.subject_tokens": settingsDraft.newsletterSubjectTokens,
"newsletter.from_tokens": settingsDraft.newsletterFromTokens,
"newsletter.header_keys": settingsDraft.newsletterHeaderKeys,
"newsletter.weight_header": settingsDraft.newsletterWeightHeader,
"newsletter.weight_precedence": settingsDraft.newsletterWeightPrecedence,
"newsletter.weight_subject": settingsDraft.newsletterWeightSubject,
"newsletter.weight_from": settingsDraft.newsletterWeightFrom,
"unsubscribe.history_ttl_days": settingsDraft.unsubscribeHistoryTtlDays,
"unsubscribe.method_preference": settingsDraft.unsubscribeMethodPreference
} }
}) })
}, },
@@ -532,12 +562,31 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
{t("countJobs", { count: tenant._count?.jobs ?? 0 })} {t("countJobs", { count: tenant._count?.jobs ?? 0 })}
</p> </p>
</div> </div>
<div className="inline-actions"> <div className="inline-actions icon-actions">
<button className="ghost" onClick={() => exportTenant(tenant)}>{t("adminExportStart")}</button> <button
<button className="ghost" onClick={() => toggleTenant(tenant)}> className="ghost icon-only"
{tenant.isActive ? t("adminDisable") : t("adminEnable")} onClick={() => exportTenant(tenant)}
title={t("adminExportStart")}
aria-label={t("adminExportStart")}
>
</button>
<button
className="ghost icon-only"
onClick={() => toggleTenant(tenant)}
title={tenant.isActive ? t("adminDisable") : t("adminEnable")}
aria-label={tenant.isActive ? t("adminDisable") : t("adminEnable")}
>
</button>
<button
className="ghost icon-only"
onClick={() => deleteTenant(tenant)}
title={t("adminDelete")}
aria-label={t("adminDelete")}
>
🗑
</button> </button>
<button className="ghost" onClick={() => deleteTenant(tenant)}>{t("adminDelete")}</button>
</div> </div>
</div> </div>
))} ))}
@@ -567,7 +616,7 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
</select> </select>
</label> </label>
<button <button
className="ghost" className="ghost icon-only"
onClick={async () => { onClick={async () => {
try { try {
await apiFetch("/admin/exports/purge", { method: "POST" }, token); await apiFetch("/admin/exports/purge", { method: "POST" }, token);
@@ -577,8 +626,10 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
pushToast(getErrorMessage(err), "error"); pushToast(getErrorMessage(err), "error");
} }
}} }}
title={t("adminExportPurge")}
aria-label={t("adminExportPurge")}
> >
{t("adminExportPurge")} 🗑
</button> </button>
</div> </div>
{exportsFiltered.map((item) => ( {exportsFiltered.map((item) => (
@@ -600,13 +651,13 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
<p>{t("exportProgress", { progress: item.progress })}</p> <p>{t("exportProgress", { progress: item.progress })}</p>
)} )}
</div> </div>
<div className="inline-actions"> <div className="inline-actions icon-actions">
<span> <span>
{item.createdAt ? new Date(item.createdAt).toLocaleString() : "-"} ·{" "} {item.createdAt ? new Date(item.createdAt).toLocaleString() : "-"} ·{" "}
{t("exportExpires")}: {item.expiresAt ? new Date(item.expiresAt).toLocaleString() : "-"} {t("exportExpires")}: {item.expiresAt ? new Date(item.expiresAt).toLocaleString() : "-"}
</span> </span>
<button <button
className="ghost" className="ghost icon-only"
disabled={item.status !== "DONE" || (item.expiresAt ? new Date(item.expiresAt) < new Date() : false)} disabled={item.status !== "DONE" || (item.expiresAt ? new Date(item.expiresAt) < new Date() : false)}
onClick={async () => { onClick={async () => {
const response = await downloadExport(token, item.id); const response = await downloadExport(token, item.id);
@@ -615,11 +666,13 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
downloadFile(blob, `export-${item.id}.zip`); downloadFile(blob, `export-${item.id}.zip`);
} }
}} }}
title={t("exportDownload")}
aria-label={t("exportDownload")}
> >
{t("exportDownload")}
</button> </button>
<button <button
className="ghost" className="ghost icon-only"
onClick={async () => { onClick={async () => {
try { try {
await apiFetch(`/admin/exports/${item.id}`, { method: "DELETE" }, token); await apiFetch(`/admin/exports/${item.id}`, { method: "DELETE" }, token);
@@ -629,8 +682,10 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
pushToast(getErrorMessage(err), "error"); pushToast(getErrorMessage(err), "error");
} }
}} }}
title={t("delete")}
aria-label={t("delete")}
> >
{t("delete")} 🗑
</button> </button>
</div> </div>
</div> </div>
@@ -664,18 +719,38 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
</div> </div>
<p>{user.role} · {user.tenant?.name ?? "-"}</p> <p>{user.role} · {user.tenant?.name ?? "-"}</p>
</div> </div>
<div className="inline-actions"> <div className="inline-actions icon-actions">
<button className="ghost" onClick={() => setRole(user, user.role === "ADMIN" ? "USER" : "ADMIN")}> <button
{user.role === "ADMIN" ? t("adminMakeUser") : t("adminMakeAdmin")} className="ghost icon-only"
onClick={() => setRole(user, user.role === "ADMIN" ? "USER" : "ADMIN")}
title={user.role === "ADMIN" ? t("adminMakeUser") : t("adminMakeAdmin")}
aria-label={user.role === "ADMIN" ? t("adminMakeUser") : t("adminMakeAdmin")}
>
</button> </button>
<button className="ghost" onClick={() => toggleUser(user)}> <button
{user.isActive ? t("adminDisable") : t("adminEnable")} className="ghost icon-only"
onClick={() => toggleUser(user)}
title={user.isActive ? t("adminDisable") : t("adminEnable")}
aria-label={user.isActive ? t("adminDisable") : t("adminEnable")}
>
</button> </button>
<button className="ghost" onClick={() => impersonate(user)}> <button
{t("adminImpersonate")} className="ghost icon-only"
onClick={() => impersonate(user)}
title={t("adminImpersonate")}
aria-label={t("adminImpersonate")}
>
👤
</button> </button>
<button className="ghost" onClick={() => setResetUserId(user.id)}> <button
{t("adminResetPassword")} className="ghost icon-only"
onClick={() => setResetUserId(user.id)}
title={t("adminResetPassword")}
aria-label={t("adminResetPassword")}
>
🔑
</button> </button>
</div> </div>
</div> </div>
@@ -740,8 +815,13 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
</p> </p>
)} )}
</div> </div>
<button className="ghost" onClick={() => toggleAccount(account)}> <button
{account.isActive ? t("adminDisable") : t("adminEnable")} className="ghost icon-only"
onClick={() => toggleAccount(account)}
title={account.isActive ? t("adminDisable") : t("adminEnable")}
aria-label={account.isActive ? t("adminDisable") : t("adminEnable")}
>
</button> </button>
</div> </div>
))} ))}
@@ -770,12 +850,26 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
/> />
{t("selectAll")} {t("selectAll")}
</label> </label>
<div className="inline-actions"> <div className="inline-actions icon-actions">
<button className="ghost" type="button" onClick={cancelSelectedJobs} disabled={selectedJobIds.length === 0}> <button
{t("adminCancelSelected")} className="ghost icon-only"
type="button"
onClick={cancelSelectedJobs}
disabled={selectedJobIds.length === 0}
title={t("adminCancelSelected")}
aria-label={t("adminCancelSelected")}
>
</button> </button>
<button className="ghost" type="button" onClick={deleteSelectedJobs} disabled={selectedJobIds.length === 0}> <button
{t("adminDeleteSelected")} className="ghost icon-only"
type="button"
onClick={deleteSelectedJobs}
disabled={selectedJobIds.length === 0}
title={t("adminDeleteSelected")}
aria-label={t("adminDeleteSelected")}
>
🗑
</button> </button>
</div> </div>
</div> </div>
@@ -785,16 +879,37 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
<strong>{mapJobStatus(job.status)}</strong> <strong>{mapJobStatus(job.status)}</strong>
<p>{job.tenant?.name ?? "-"} · {job.mailboxAccount?.email ?? "-"}</p> <p>{job.tenant?.name ?? "-"} · {job.mailboxAccount?.email ?? "-"}</p>
</div> </div>
<div className="inline-actions"> <div className="inline-actions icon-actions">
<input <input
className="checkbox-input" className="checkbox-input"
type="checkbox" type="checkbox"
checked={selectedJobIds.includes(job.id)} checked={selectedJobIds.includes(job.id)}
onChange={() => toggleJobSelection(job.id)} onChange={() => toggleJobSelection(job.id)}
/> />
<button className="ghost" onClick={() => retryJob(job)}>{t("adminRetry")}</button> <button
<button className="ghost" onClick={() => cancelJob(job)}>{t("adminCancelJob")}</button> className="ghost icon-only"
<button className="ghost" onClick={() => deleteJob(job)}>{t("adminDelete")}</button> onClick={() => retryJob(job)}
title={t("adminRetry")}
aria-label={t("adminRetry")}
>
</button>
<button
className="ghost icon-only"
onClick={() => cancelJob(job)}
title={t("adminCancelJob")}
aria-label={t("adminCancelJob")}
>
</button>
<button
className="ghost icon-only"
onClick={() => deleteJob(job)}
title={t("adminDelete")}
aria-label={t("adminDelete")}
>
🗑
</button>
<span>{new Date(job.createdAt).toLocaleString()}</span> <span>{new Date(job.createdAt).toLocaleString()}</span>
</div> </div>
</div> </div>
@@ -842,9 +957,109 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
/> />
<small className="hint-text">{t("adminCleanupScanLimitHint")}</small> <small className="hint-text">{t("adminCleanupScanLimitHint")}</small>
</label> </label>
<div className="inline-actions"> <div className="panel-divider" />
<button className="ghost" onClick={() => setShowGoogleSecret((prev) => !prev)}> <h4>{t("adminNewsletterSettings")}</h4>
{showGoogleSecret ? t("adminHideSecret") : t("adminShowSecret")} <p className="hint-text">{t("adminNewsletterSettingsHint")}</p>
<label className="field-row">
<span>{t("adminNewsletterThreshold")}</span>
<input
type="number"
min="1"
step="1"
value={settingsDraft.newsletterThreshold}
onChange={(event) => setSettingsDraft((prev) => ({ ...prev, newsletterThreshold: event.target.value }))}
/>
</label>
<label className="field-row">
<span>{t("adminNewsletterHeaderKeys")}</span>
<input
value={settingsDraft.newsletterHeaderKeys}
onChange={(event) => setSettingsDraft((prev) => ({ ...prev, newsletterHeaderKeys: event.target.value }))}
/>
<small className="hint-text">{t("adminNewsletterHeaderKeysHint")}</small>
</label>
<label className="field-row">
<span>{t("adminNewsletterWeightHeader")}</span>
<input
type="number"
step="1"
value={settingsDraft.newsletterWeightHeader}
onChange={(event) => setSettingsDraft((prev) => ({ ...prev, newsletterWeightHeader: event.target.value }))}
/>
</label>
<label className="field-row">
<span>{t("adminNewsletterWeightPrecedence")}</span>
<input
type="number"
step="1"
value={settingsDraft.newsletterWeightPrecedence}
onChange={(event) => setSettingsDraft((prev) => ({ ...prev, newsletterWeightPrecedence: event.target.value }))}
/>
</label>
<label className="field-row">
<span>{t("adminNewsletterSubjectTokens")}</span>
<input
value={settingsDraft.newsletterSubjectTokens}
onChange={(event) => setSettingsDraft((prev) => ({ ...prev, newsletterSubjectTokens: event.target.value }))}
/>
<small className="hint-text">{t("adminNewsletterSubjectTokensHint")}</small>
</label>
<label className="field-row">
<span>{t("adminNewsletterWeightSubject")}</span>
<input
type="number"
step="1"
value={settingsDraft.newsletterWeightSubject}
onChange={(event) => setSettingsDraft((prev) => ({ ...prev, newsletterWeightSubject: event.target.value }))}
/>
</label>
<label className="field-row">
<span>{t("adminNewsletterFromTokens")}</span>
<input
value={settingsDraft.newsletterFromTokens}
onChange={(event) => setSettingsDraft((prev) => ({ ...prev, newsletterFromTokens: event.target.value }))}
/>
<small className="hint-text">{t("adminNewsletterFromTokensHint")}</small>
</label>
<label className="field-row">
<span>{t("adminNewsletterWeightFrom")}</span>
<input
type="number"
step="1"
value={settingsDraft.newsletterWeightFrom}
onChange={(event) => setSettingsDraft((prev) => ({ ...prev, newsletterWeightFrom: event.target.value }))}
/>
</label>
<label className="field-row">
<span>{t("adminUnsubscribeHistoryTtl")}</span>
<input
type="number"
step="1"
value={settingsDraft.unsubscribeHistoryTtlDays}
onChange={(event) => setSettingsDraft((prev) => ({ ...prev, unsubscribeHistoryTtlDays: event.target.value }))}
/>
<small className="hint-text">{t("adminUnsubscribeHistoryTtlHint")}</small>
</label>
<label className="field-row">
<span>{t("adminUnsubscribeMethod")}</span>
<select
value={settingsDraft.unsubscribeMethodPreference}
onChange={(event) => setSettingsDraft((prev) => ({ ...prev, unsubscribeMethodPreference: event.target.value }))}
>
<option value="auto">{t("adminUnsubscribeMethodAuto")}</option>
<option value="http">{t("adminUnsubscribeMethodHttp")}</option>
<option value="mailto">{t("adminUnsubscribeMethodMailto")}</option>
</select>
<small className="hint-text">{t("adminUnsubscribeMethodHint")}</small>
</label>
<div className="inline-actions icon-actions">
<button
className="ghost icon-only"
onClick={() => setShowGoogleSecret((prev) => !prev)}
title={showGoogleSecret ? t("adminHideSecret") : t("adminShowSecret")}
aria-label={showGoogleSecret ? t("adminHideSecret") : t("adminShowSecret")}
>
👁
</button> </button>
<button className="primary" onClick={saveSettings} disabled={settingsStatus === "saving"}> <button className="primary" onClick={saveSettings} disabled={settingsStatus === "saving"}>
{settingsStatus === "saving" ? t("adminSaving") : t("adminSaveSettings")} {settingsStatus === "saving" ? t("adminSaving") : t("adminSaveSettings")}
@@ -859,6 +1074,20 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
redirect: settings["google.redirect_uri"]?.source ?? "unset" redirect: settings["google.redirect_uri"]?.source ?? "unset"
})} })}
</div> </div>
<div className="status-note">
{t("adminSettingsSourceNewsletter", {
threshold: settings["newsletter.threshold"]?.source ?? "unset",
headers: settings["newsletter.header_keys"]?.source ?? "unset",
subject: settings["newsletter.subject_tokens"]?.source ?? "unset",
from: settings["newsletter.from_tokens"]?.source ?? "unset",
weightHeader: settings["newsletter.weight_header"]?.source ?? "unset",
weightPrecedence: settings["newsletter.weight_precedence"]?.source ?? "unset",
weightSubject: settings["newsletter.weight_subject"]?.source ?? "unset",
weightFrom: settings["newsletter.weight_from"]?.source ?? "unset",
history: settings["unsubscribe.history_ttl_days"]?.source ?? "unset",
method: settings["unsubscribe.method_preference"]?.source ?? "unset"
})}
</div>
</div> </div>
</div> </div>
)} )}
@@ -867,8 +1096,14 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
<div className="modal" onClick={(event) => event.stopPropagation()}> <div className="modal" onClick={(event) => event.stopPropagation()}>
<div className="modal-header"> <div className="modal-header">
<h3>{t("confirmTitle")}</h3> <h3>{t("confirmTitle")}</h3>
<button className="ghost" type="button" onClick={closeConfirmDialog}> <button
{t("close")} className="ghost icon-only"
type="button"
onClick={closeConfirmDialog}
title={t("close")}
aria-label={t("close")}
>
</button> </button>
</div> </div>
<div className="modal-body"> <div className="modal-body">

View File

@@ -67,6 +67,26 @@
"adminGoogleRedirectUri": "Redirect-URL", "adminGoogleRedirectUri": "Redirect-URL",
"adminCleanupScanLimit": "Max. Mails pro Bereinigung", "adminCleanupScanLimit": "Max. Mails pro Bereinigung",
"adminCleanupScanLimitHint": "0 = unbegrenzt. Praktisch für Tests.", "adminCleanupScanLimitHint": "0 = unbegrenzt. Praktisch für Tests.",
"adminNewsletterSettings": "Newsletter-Erkennung",
"adminNewsletterSettingsHint": "Signale und Schwellen für die Newsletter-Erkennung. Kommagetrennte Listen. Gewichte bestimmen, wie stark ein Signal den Score erhöht.",
"adminNewsletterThreshold": "Mindest-Signale",
"adminNewsletterHeaderKeys": "Header-Keys",
"adminNewsletterHeaderKeysHint": "Kommagetrennte List-Header (z.B. list-unsubscribe,list-id,...).",
"adminNewsletterWeightHeader": "Gewicht: Header-Treffer",
"adminNewsletterWeightPrecedence": "Gewicht: Bulk/List-Precedence",
"adminNewsletterSubjectTokens": "Betreff-Keywords",
"adminNewsletterSubjectTokensHint": "Kommagetrennte Tokens, die im Betreff gesucht werden.",
"adminNewsletterWeightSubject": "Gewicht: Betreff-Treffer",
"adminNewsletterFromTokens": "Absender-Keywords",
"adminNewsletterFromTokensHint": "Kommagetrennte Tokens, die im Absender gesucht werden.",
"adminNewsletterWeightFrom": "Gewicht: Absender-Treffer",
"adminUnsubscribeHistoryTtl": "Abmelde-Dedupe-Zeitfenster (Tage)",
"adminUnsubscribeHistoryTtlHint": "Verhindert erneutes Abmelden innerhalb dieses Zeitfensters. 0 deaktiviert.",
"adminUnsubscribeMethod": "Abmelde-Methode bevorzugen",
"adminUnsubscribeMethodHint": "Auto nutzt HTTP und fällt bei Fehler auf mailto zurück.",
"adminUnsubscribeMethodAuto": "Auto (HTTP → mailto Fallback)",
"adminUnsubscribeMethodHttp": "HTTP bevorzugen",
"adminUnsubscribeMethodMailto": "mailto bevorzugen",
"adminSaveSettings": "Einstellungen speichern", "adminSaveSettings": "Einstellungen speichern",
"adminSaving": "Speichert...", "adminSaving": "Speichert...",
"adminSettingsSaved": "Gespeichert", "adminSettingsSaved": "Gespeichert",
@@ -74,6 +94,7 @@
"adminShowSecret": "Secret anzeigen", "adminShowSecret": "Secret anzeigen",
"adminHideSecret": "Secret verbergen", "adminHideSecret": "Secret verbergen",
"adminSettingsSource": "Quellen - Client-ID: {{id}}, Secret: {{secret}}, Redirect: {{redirect}}", "adminSettingsSource": "Quellen - Client-ID: {{id}}, Secret: {{secret}}, Redirect: {{redirect}}",
"adminSettingsSourceNewsletter": "Quellen - Schwelle: {{threshold}}, Header: {{headers}}, Betreff-Tokens: {{subject}}, Absender-Tokens: {{from}}, Gewichte (Header/Precedence/Betreff/Absender): {{weightHeader}}/{{weightPrecedence}}/{{weightSubject}}/{{weightFrom}}, Abmelde-History: {{history}}, Methode: {{method}}",
"selectAll": "Alle auswählen", "selectAll": "Alle auswählen",
"adminCancelSelected": "Auswahl abbrechen", "adminCancelSelected": "Auswahl abbrechen",
"adminDeleteSelected": "Auswahl löschen", "adminDeleteSelected": "Auswahl löschen",
@@ -94,30 +115,175 @@
"mailboxCancelEdit": "Abbrechen", "mailboxCancelEdit": "Abbrechen",
"mailboxEmpty": "Noch keine Mailbox. Füge eine hinzu, um zu starten.", "mailboxEmpty": "Noch keine Mailbox. Füge eine hinzu, um zu starten.",
"cleanupStart": "Bereinigung starten", "cleanupStart": "Bereinigung starten",
"cleanupDryRun": "Dry run (keine Änderungen)", "cleanupDryRun": "Nur simulieren (keine Änderungen)",
"cleanupUnsubscribe": "Unsubscribe aktiv", "cleanupUnsubscribe": "Unsubscribe aktiv",
"cleanupRouting": "Routing aktiv", "cleanupRouting": "Routing aktiv",
"cleanupDisabled": "Bereinigung ist noch nicht verfügbar.", "cleanupDisabled": "Bereinigung ist noch nicht verfügbar.",
"cleanupSelectMailbox": "Bitte ein Postfach auswählen.", "cleanupSelectMailbox": "Bitte ein Postfach auswählen.",
"cleanupOauthRequired": "Bitte Gmail OAuth verbinden, bevor die Bereinigung startet.", "cleanupOauthRequired": "Bitte Gmail OAuth verbinden, bevor die Bereinigung startet.",
"cleanupDryRunHint": "Dry run simuliert Routing und Unsubscribe. Es werden keine Änderungen durchgeführt und keine E-Mails gesendet.", "cleanupDryRunHint": "Nur simulieren: Routing und Abmelden werden simuliert. Es werden keine Änderungen durchgeführt und keine E-Mails gesendet.",
"cleanupUnsubscribeHint": "Versucht Newsletter per List-Unsubscribe abzumelden. Im Nur-simulieren-Modus werden nur die Checks geloggt.",
"cleanupRoutingHint": "Wendet deine Regeln an (Verschieben/Löschen/Label). Im Nur-simulieren-Modus nur Simulation.",
"rulesTitle": "Regeln", "rulesTitle": "Regeln",
"rulesAdd": "Regel hinzufügen", "rulesAdd": "Regel hinzufügen",
"rulesReorder": "Ziehen zum Sortieren",
"rulesAddTitle": "Regel erstellen", "rulesAddTitle": "Regel erstellen",
"rulesEditTitle": "Regel bearbeiten", "rulesEditTitle": "Regel bearbeiten",
"rulesName": "Rule Name", "rulesName": "Rule Name",
"rulesEnabled": "Rule aktiv", "rulesEnabled": "Rule aktiv",
"rulesMatchMode": "Regel-Logik",
"rulesMatchAll": "Alle Bedingungen (UND)",
"rulesMatchAny": "Mindestens eine (ODER)",
"rulesMatchAnyLabel": "ODER",
"rulesStopOnMatch": "Nach Treffer stoppen (erste Regel gewinnt)",
"rulesStopOnMatchBadge": "ERSTE",
"rulesConditions": "Bedingungen", "rulesConditions": "Bedingungen",
"rulesActions": "Aktionen", "rulesActions": "Aktionen",
"rulesAddCondition": "+ Bedingung", "rulesAddCondition": "+ Bedingung",
"rulesAddAction": "+ Aktion", "rulesAddAction": "+ Aktion",
"rulesSave": "Regel speichern", "rulesSave": "Regel speichern",
"rulesExampleHint": "Beispiel: Abmelde-Status = OK + List-Unsubscribe = * → Aktion: In \"Newsletter-Abgemeldet\" verschieben.",
"remove": "Entfernen",
"ruleConditionUnsubscribeStatus": "Abmelde-Status",
"ruleConditionScore": "Newsletter-Score",
"ruleConditionScorePlaceholder": "z.B. >=2",
"ruleConditionHeaderMissing": "Header fehlt",
"ruleConditionHeaderMissingPlaceholder": "Header-Name (z.B. List-Unsubscribe)",
"ruleUnsubStatusAny": "Beliebig",
"ruleUnsubStatusOk": "OK",
"ruleUnsubStatusDryRun": "Nur simuliert",
"ruleUnsubStatusFailed": "Fehlgeschlagen",
"ruleUnsubStatusSkipped": "Übersprungen",
"ruleUnsubStatusDuplicate": "Übersprungen (Duplikat)",
"ruleUnsubStatusDisabled": "Deaktiviert",
"ruleActionMarkRead": "Als gelesen markieren",
"ruleActionMarkUnread": "Als ungelesen markieren",
"rulesOverview": "Regeln Übersicht", "rulesOverview": "Regeln Übersicht",
"rulesEmpty": "Noch keine Regeln.", "rulesEmpty": "Noch keine Regeln.",
"jobsTitle": "Jobs", "jobsTitle": "Jobs",
"jobsEmpty": "Noch keine Jobs.", "jobsEmpty": "Noch keine Jobs.",
"jobCandidatesTitle": "Ergebnisdetails",
"jobCandidatesHint": "Zeigt jeden Newsletter-Kandidaten inkl. Aktionen, Abmelde-Status und Metadaten.",
"jobCandidatesGroupBy": "Gruppieren nach",
"jobCandidatesGroupDomain": "Absender-Domain",
"jobCandidatesGroupFrom": "Absender",
"jobCandidatesGroupListId": "List-ID",
"jobCandidatesGroupNone": "Keine Gruppierung",
"jobCandidatesGroupsEmpty": "Noch keine Newsletter-Kandidaten.",
"jobCandidatesShowSignals": "Details anzeigen",
"jobCandidatesCount": "{{count}} Kandidaten",
"jobCandidatesLoadMore": "Mehr laden",
"jobCandidatesBack": "Zurück zu Gruppen",
"jobCandidatesRefresh": "Aktualisieren",
"jobCandidatesUnknown": "Unbekannt",
"jobCandidatesSubject": "Betreff",
"jobCandidatesFrom": "Von",
"jobCandidatesListId": "List-ID",
"jobCandidatesDate": "Datum",
"jobCandidatesActions": "Aktionen",
"jobCandidatesUnsubscribe": "Abmelden",
"jobCandidatesUnsubscribeNone": "Kein List-Unsubscribe Header",
"jobCandidatesUnsubscribeDisabled": "Deaktiviert",
"jobCandidatesUnsubscribeDryRun": "Nur simuliert",
"jobCandidatesUnsubscribeOk": "OK",
"jobCandidatesUnsubscribeFailed": "Fehlgeschlagen",
"jobCandidatesUnsubscribeDuplicate": "Übersprungen (Duplikat)",
"resultsTitle": "Ergebnisdetails",
"resultsHint": "Öffnet eine detaillierte Liste aller erkannten E-Mails mit Vorschau.",
"resultsOpen": "Ergebnisse öffnen",
"resultsGroups": "Gruppen",
"resultsGroupsDisabled": "Gruppierung deaktiviert. Bitte eine Gruppierung wählen.",
"resultsItems": "Nachrichten",
"resultsPreview": "Vorschau",
"resultsSelectGroup": "Bitte eine Gruppe auswählen, um Nachrichten zu laden.",
"resultsSelectItem": "Bitte eine Nachricht auswählen, um sie anzuzeigen.",
"resultsPreviewLoading": "Vorschau wird geladen...",
"resultsPreviewEmpty": "Keine Vorschau verfügbar.",
"resultsSearch": "Suche Betreff/Absender/List-ID",
"resultsFilterAll": "Alle Status",
"resultsReviewedAll": "Alle",
"resultsReviewed": "Geprüft",
"resultsUnreviewed": "Offen",
"resultsExportCsv": "CSV",
"resultsExportGroupCsv": "Gruppen-CSV",
"resultsSelectAll": "Alle",
"resultsMarkSelectedReviewed": "Ausgewählt geprüft",
"resultsMarkSelectedUnreviewed": "Ausgewählt offen",
"resultsBulkSelect": "Nur offen",
"resultsBulkMarkReviewed": "Alle geprüft",
"resultsDeleteSelected": "Ausgewählte löschen",
"resultsAttachments": "Anhänge",
"resultsDownloadAttachment": "Download",
"resultsHistory": "Abmelde-Historie",
"resultsUnsubscribeDetails": "Abmelde-Details",
"resultsUnsubscribeStatus": "Status",
"resultsUnsubscribeMethod": "Methode",
"resultsUnsubscribeTarget": "Ziel",
"resultsUnsubscribeMessage": "Ergebnis",
"resultsListId": "List-ID",
"resultsListUnsubscribe": "List-Unsubscribe",
"resultsListUnsubscribePost": "List-Unsubscribe-Post",
"resultsMailtoSubject": "Mailto-Betreff",
"resultsMailtoBody": "Mailto-Text",
"resultsRequestMethod": "Request-Methode",
"resultsRequestUrl": "Request-URL",
"resultsResponseStatus": "Response-Status",
"resultsResponseRedirect": "Redirect",
"resultsMailtoVia": "Versendet via",
"resultsMailtoTo": "Mailto",
"resultsMailtoReplyTo": "Reply-To",
"resultsMailtoListUnsubscribe": "List-Unsubscribe-Header",
"resultsUnsubscribeReason": "Grund",
"resultsUnsubscribeError": "Fehler",
"resultsLive": "Live",
"resultsStatic": "Statisch",
"resultsBackToJob": "Zurück zum Job",
"jobCandidatesActionApplied": "Ausgeführt",
"jobCandidatesActionDryRun": "Nur simuliert",
"jobCandidatesActionFailed": "Fehlgeschlagen",
"jobCandidatesActionSkipped": "Übersprungen",
"jobCandidatesSignals": "Signale",
"jobCandidatesScore": "Score",
"jobCandidatesScoreHint": "Der Score basiert u. a. auf Betreff, Absender, Header-Signalen und dem Newsletter-Erkennungsmodell.",
"jobCandidatesHeaderSignals": "Header",
"jobCandidatesSubjectSignals": "Betreff-Treffer",
"jobCandidatesFromSignals": "Absender-Treffer",
"jobCandidatesPrecedenceSignal": "Bulk/List-Precedence",
"jobEventCleanupStarted": "Bereinigung gestartet",
"jobEventCleanupFinished": "Bereinigung abgeschlossen",
"jobEventConnecting": "Verbinde mit {{email}}",
"jobEventListingGmail": "Gmail-Nachrichten werden aufgelistet",
"jobEventListedGmail": "{{count}} Gmail-Nachrichten bisher gefunden",
"jobEventPreparedGmail": "{{count}} Gmail-Nachrichten vorbereitet",
"jobEventResumeGmail": "Gmail-Bereinigung fortgesetzt bei {{current}}/{{total}}",
"jobEventProcessingGmail": "{{count}} Gmail-Nachrichten werden verarbeitet",
"jobEventNoGmail": "Keine Gmail-Nachrichten zu verarbeiten",
"jobEventFoundMailboxes": "{{count}} Postfächer gefunden",
"jobEventScanningMailbox": "Scanne {{mailbox}}",
"jobEventPreparedImap": "{{count}} IMAP-Nachrichten vorbereitet",
"jobEventResumeImap": "IMAP-Bereinigung fortgesetzt bei {{current}}/{{total}}",
"jobEventProcessingImap": "{{count}} IMAP-Nachrichten werden verarbeitet",
"jobEventNoImap": "Keine IMAP-Nachrichten zu verarbeiten",
"jobEventDetectedCandidates": "{{count}} Newsletter-Kandidaten erkannt",
"jobEventProcessed": "Verarbeitet {{current}}/{{total}}",
"jobEventProcessedCount": "Verarbeitet {{current}}",
"jobEventGmailActionAppliedList": "GmailAktion angewendet: {{actions}}",
"jobEventGmailActionApplied": "GmailAktion angewendet: {{action}}",
"jobEventGmailActionSkippedNoChanges": "GmailAktion übersprungen: keine LabelÄnderungen",
"jobEventGmailActionFailedSimple": "GmailAktion fehlgeschlagen: {{error}}",
"jobEventGmailActionFailed": "GmailAktion fehlgeschlagen ({{action}}): {{error}}",
"jobEventImapActionFailed": "IMAPAktion fehlgeschlagen ({{action}}): {{error}}",
"jobEventDryRunAction": "Nur simulieren: {{action}}",
"jobEventCanceledByAdmin": "Job vom Admin abgebrochen",
"jobEventCanceledBeforeStart": "Job vor Start abgebrochen",
"jobEventFailed": "Job fehlgeschlagen: {{error}}",
"loadingCandidates": "Kandidaten werden geladen...",
"jobsProgress": "Fortschritt", "jobsProgress": "Fortschritt",
"jobsEta": "Restzeit", "jobsEta": "Restzeit",
"jobsEtaDone": "Fertig",
"jobsEtaQueued": "Wartet",
"jobsEtaRecalculating": "Restzeit wird neu berechnet…",
"jobsEtaCalculating": "Berechne…",
"jobDetailsTitle": "Job-Details", "jobDetailsTitle": "Job-Details",
"jobNoEvents": "Noch keine Events.", "jobNoEvents": "Noch keine Events.",
"jobEvents": "Job Events", "jobEvents": "Job Events",
@@ -128,7 +294,13 @@
"phaseProcessingPending": "Warte auf Verarbeitung.", "phaseProcessingPending": "Warte auf Verarbeitung.",
"phaseUnsubscribePending": "Warte auf Abmeldung.", "phaseUnsubscribePending": "Warte auf Abmeldung.",
"phaseUnsubscribeDisabled": "Abmeldung ist für diesen Job deaktiviert.", "phaseUnsubscribeDisabled": "Abmeldung ist für diesen Job deaktiviert.",
"phaseUnsubscribeSummary": "Abgemeldet {{ok}} · Fehlgeschlagen {{failed}} · Dry-Run {{dryRun}} ({{total}} gesamt).", "phaseUnsubscribeSummary": "Abgemeldet {{ok}} · Fehlgeschlagen {{failed}} · Nur simuliert {{dryRun}} ({{total}} gesamt).",
"phaseUnsubscribeSummaryWithProcessed": "Abgemeldet {{ok}} · Fehlgeschlagen {{failed}} · Nur simuliert {{dryRun}} ({{total}} Kandidaten, {{processed}}/{{overall}} verarbeitet).",
"phaseUnsubscribeSummaryNoDryRun": "Abgemeldet {{ok}} · Fehlgeschlagen {{failed}} ({{total}} gesamt).",
"phaseUnsubscribeSummaryNoDryRunWithProcessed": "Abgemeldet {{ok}} · Fehlgeschlagen {{failed}} ({{total}} Kandidaten, {{processed}}/{{overall}} verarbeitet).",
"phaseUnsubscribeDryRunPending": "Nur simulieren: warte auf Abmelde-Checks.",
"phaseUnsubscribeDryRunSummary": "Nur simulieren: {{dryRun}} Abmelde-Checks ({{total}} gesamt).",
"phaseUnsubscribeDryRunSummaryWithProcessed": "Nur simulieren: {{dryRun}} Abmelde-Checks ({{total}} Kandidaten, {{processed}}/{{overall}} verarbeitet).",
"phaseStatusActive": "Aktiv", "phaseStatusActive": "Aktiv",
"phaseStatusDone": "Fertig", "phaseStatusDone": "Fertig",
"phaseStatusPending": "Ausstehend", "phaseStatusPending": "Ausstehend",
@@ -173,6 +345,7 @@
"statusRunning": "Laufend", "statusRunning": "Laufend",
"statusQueued": "In Warteschlange", "statusQueued": "In Warteschlange",
"statusSucceeded": "Erfolgreich", "statusSucceeded": "Erfolgreich",
"statusFinished": "Bereinigung abgeschlossen",
"statusFailed": "Fehlgeschlagen", "statusFailed": "Fehlgeschlagen",
"statusCanceled": "Abgebrochen", "statusCanceled": "Abgebrochen",
"oauthStatusLabel": "OAuth Status" "oauthStatusLabel": "OAuth Status"
@@ -204,10 +377,10 @@
"ruleConditionFrom": "From", "ruleConditionFrom": "From",
"ruleConditionListUnsub": "List-Unsubscribe", "ruleConditionListUnsub": "List-Unsubscribe",
"ruleConditionListId": "List-Id", "ruleConditionListId": "List-Id",
"ruleActionMove": "Move", "ruleActionMove": "Verschieben",
"ruleActionDelete": "Delete", "ruleActionDelete": "Löschen",
"ruleActionArchive": "Archive", "ruleActionArchive": "Archivieren",
"ruleActionLabel": "Label", "ruleActionLabel": "Label setzen",
"adminExportFormat": "Format", "adminExportFormat": "Format",
"exportFormatJson": "JSON", "exportFormatJson": "JSON",
"exportFormatCsv": "CSV", "exportFormatCsv": "CSV",
@@ -250,11 +423,14 @@
"toastMailboxDeleted": "Mailbox gelöscht.", "toastMailboxDeleted": "Mailbox gelöscht.",
"toastRuleSaved": "Regel gespeichert.", "toastRuleSaved": "Regel gespeichert.",
"toastRuleDeleted": "Regel gelöscht.", "toastRuleDeleted": "Regel gelöscht.",
"toastRuleOrderSaved": "Reihenfolge aktualisiert.",
"toastDeleteSelected": "{{deleted}} gelöscht · {{missing}} fehlend · {{failed}} fehlgeschlagen",
"toastCleanupStarted": "Bereinigung gestartet.", "toastCleanupStarted": "Bereinigung gestartet.",
"toastLoggedOut": "Ausgeloggt.", "toastLoggedOut": "Ausgeloggt.",
"toastExportQueued": "Export in Warteschlange.", "toastExportQueued": "Export in Warteschlange.",
"toastExportReady": "Export bereit.", "toastExportReady": "Export bereit.",
"toastExportFailed": "Export fehlgeschlagen.", "toastExportFailed": "Export fehlgeschlagen.",
"toastDownloadFailed": "Download fehlgeschlagen.",
"toastExportPurged": "Abgelaufene Exporte entfernt.", "toastExportPurged": "Abgelaufene Exporte entfernt.",
"toastExportDeleted": "Export gelöscht.", "toastExportDeleted": "Export gelöscht.",
"toastTenantUpdated": "Tenant aktualisiert.", "toastTenantUpdated": "Tenant aktualisiert.",
@@ -270,5 +446,6 @@
"toastSettingsSaved": "Einstellungen gespeichert.", "toastSettingsSaved": "Einstellungen gespeichert.",
"toastSettingsFailed": "Einstellungen konnten nicht gespeichert werden.", "toastSettingsFailed": "Einstellungen konnten nicht gespeichert werden.",
"confirmMailboxDelete": "Mailbox {{email}} löschen? Dabei werden alle zugehörigen Daten entfernt.", "confirmMailboxDelete": "Mailbox {{email}} löschen? Dabei werden alle zugehörigen Daten entfernt.",
"confirmDeleteSelected": "{{count}} ausgewählte Nachrichten löschen? Nachrichten können bereits gelöscht sein.",
"adminDeleteJobConfirm": "Diesen Job samt Events löschen?" "adminDeleteJobConfirm": "Diesen Job samt Events löschen?"
} }

View File

@@ -67,6 +67,26 @@
"adminGoogleRedirectUri": "Redirect URL", "adminGoogleRedirectUri": "Redirect URL",
"adminCleanupScanLimit": "Max emails per cleanup", "adminCleanupScanLimit": "Max emails per cleanup",
"adminCleanupScanLimitHint": "0 = unlimited. Useful for testing.", "adminCleanupScanLimitHint": "0 = unlimited. Useful for testing.",
"adminNewsletterSettings": "Newsletter detection",
"adminNewsletterSettingsHint": "Signals and thresholds used to detect newsletters. Comma-separated lists. Weights define how much each signal adds to the score.",
"adminNewsletterThreshold": "Minimum signals",
"adminNewsletterHeaderKeys": "Header keys",
"adminNewsletterHeaderKeysHint": "Comma-separated list headers (e.g. list-unsubscribe,list-id,...).",
"adminNewsletterWeightHeader": "Weight: header matches",
"adminNewsletterWeightPrecedence": "Weight: bulk/list precedence",
"adminNewsletterSubjectTokens": "Subject keywords",
"adminNewsletterSubjectTokensHint": "Comma-separated tokens matched against the subject.",
"adminNewsletterWeightSubject": "Weight: subject match",
"adminNewsletterFromTokens": "From keywords",
"adminNewsletterFromTokensHint": "Comma-separated tokens matched against the sender.",
"adminNewsletterWeightFrom": "Weight: from match",
"adminUnsubscribeHistoryTtl": "Unsubscribe dedupe window (days)",
"adminUnsubscribeHistoryTtlHint": "Prevents running unsubscribe again within this time window. Set to 0 to disable.",
"adminUnsubscribeMethod": "Unsubscribe method preference",
"adminUnsubscribeMethodHint": "Auto uses HTTP when available and falls back to mailto if HTTP fails.",
"adminUnsubscribeMethodAuto": "Auto (HTTP → mailto fallback)",
"adminUnsubscribeMethodHttp": "Prefer HTTP",
"adminUnsubscribeMethodMailto": "Prefer mailto",
"adminSaveSettings": "Save settings", "adminSaveSettings": "Save settings",
"adminSaving": "Saving...", "adminSaving": "Saving...",
"adminSettingsSaved": "Saved", "adminSettingsSaved": "Saved",
@@ -74,6 +94,7 @@
"adminShowSecret": "Show secret", "adminShowSecret": "Show secret",
"adminHideSecret": "Hide secret", "adminHideSecret": "Hide secret",
"adminSettingsSource": "Sources - Client ID: {{id}}, Secret: {{secret}}, Redirect: {{redirect}}", "adminSettingsSource": "Sources - Client ID: {{id}}, Secret: {{secret}}, Redirect: {{redirect}}",
"adminSettingsSourceNewsletter": "Sources - Threshold: {{threshold}}, Headers: {{headers}}, Subject tokens: {{subject}}, From tokens: {{from}}, Weights (header/precedence/subject/from): {{weightHeader}}/{{weightPrecedence}}/{{weightSubject}}/{{weightFrom}}, Unsubscribe history: {{history}}, Method: {{method}}",
"selectAll": "Select all", "selectAll": "Select all",
"adminCancelSelected": "Cancel selected", "adminCancelSelected": "Cancel selected",
"adminDeleteSelected": "Delete selected", "adminDeleteSelected": "Delete selected",
@@ -94,30 +115,175 @@
"mailboxCancelEdit": "Cancel", "mailboxCancelEdit": "Cancel",
"mailboxEmpty": "No mailboxes yet. Add one to start cleaning.", "mailboxEmpty": "No mailboxes yet. Add one to start cleaning.",
"cleanupStart": "Start cleanup", "cleanupStart": "Start cleanup",
"cleanupDryRun": "Dry run (no changes)", "cleanupDryRun": "Simulate only (no changes)",
"cleanupUnsubscribe": "Unsubscribe enabled", "cleanupUnsubscribe": "Unsubscribe enabled",
"cleanupRouting": "Routing enabled", "cleanupRouting": "Routing enabled",
"cleanupDisabled": "Cleanup is not available yet.", "cleanupDisabled": "Cleanup is not available yet.",
"cleanupSelectMailbox": "Select a mailbox to start cleanup.", "cleanupSelectMailbox": "Select a mailbox to start cleanup.",
"cleanupOauthRequired": "Connect Gmail OAuth before starting cleanup.", "cleanupOauthRequired": "Connect Gmail OAuth before starting cleanup.",
"cleanupDryRunHint": "Dry run simulates routing and unsubscribe actions. No changes or emails are sent.", "cleanupDryRunHint": "Simulation only: routing and unsubscribe are simulated. No changes or emails are sent.",
"cleanupUnsubscribeHint": "Tries to unsubscribe newsletters via List-Unsubscribe. In simulation mode, it only logs the checks.",
"cleanupRoutingHint": "Applies your rules (move/delete/label). In simulation mode, it only simulates the actions.",
"rulesTitle": "Rules", "rulesTitle": "Rules",
"rulesAdd": "Add rule", "rulesAdd": "Add rule",
"rulesReorder": "Drag to reorder",
"rulesAddTitle": "Create rule", "rulesAddTitle": "Create rule",
"rulesEditTitle": "Edit rule", "rulesEditTitle": "Edit rule",
"rulesName": "Rule name", "rulesName": "Rule name",
"rulesEnabled": "Rule enabled", "rulesEnabled": "Rule enabled",
"rulesMatchMode": "Match mode",
"rulesMatchAll": "All conditions (AND)",
"rulesMatchAny": "Any condition (OR)",
"rulesMatchAnyLabel": "OR",
"rulesStopOnMatch": "Stop after match (first match wins)",
"rulesStopOnMatchBadge": "FIRST",
"rulesConditions": "Conditions", "rulesConditions": "Conditions",
"rulesActions": "Actions", "rulesActions": "Actions",
"rulesAddCondition": "+ Add condition", "rulesAddCondition": "+ Add condition",
"rulesAddAction": "+ Add action", "rulesAddAction": "+ Add action",
"rulesSave": "Save rule", "rulesSave": "Save rule",
"rulesExampleHint": "Example: Unsubscribe status = OK + List-Unsubscribe = * → Action: Move to \"Newsletter-Abgemeldet\".",
"remove": "Remove",
"ruleConditionUnsubscribeStatus": "Unsubscribe status",
"ruleConditionScore": "Newsletter score",
"ruleConditionScorePlaceholder": "e.g. >=2",
"ruleConditionHeaderMissing": "Header missing",
"ruleConditionHeaderMissingPlaceholder": "Header name (e.g. List-Unsubscribe)",
"ruleUnsubStatusAny": "Any",
"ruleUnsubStatusOk": "OK",
"ruleUnsubStatusDryRun": "Simulated",
"ruleUnsubStatusFailed": "Failed",
"ruleUnsubStatusSkipped": "Skipped",
"ruleUnsubStatusDuplicate": "Skipped (duplicate)",
"ruleUnsubStatusDisabled": "Disabled",
"ruleActionMarkRead": "Mark as read",
"ruleActionMarkUnread": "Mark as unread",
"rulesOverview": "Rules overview", "rulesOverview": "Rules overview",
"rulesEmpty": "No rules yet.", "rulesEmpty": "No rules yet.",
"jobsTitle": "Jobs", "jobsTitle": "Jobs",
"jobsEmpty": "No jobs yet.", "jobsEmpty": "No jobs yet.",
"jobCandidatesTitle": "Result details",
"jobCandidatesHint": "Shows each newsletter candidate with actions, unsubscribe outcome, and metadata.",
"jobCandidatesGroupBy": "Group by",
"jobCandidatesGroupDomain": "Sender domain",
"jobCandidatesGroupFrom": "From address",
"jobCandidatesGroupListId": "List-ID",
"jobCandidatesGroupNone": "No grouping",
"jobCandidatesGroupsEmpty": "No newsletter candidates yet.",
"jobCandidatesShowSignals": "Show details",
"jobCandidatesCount": "{{count}} candidates",
"jobCandidatesLoadMore": "Load more",
"jobCandidatesBack": "Back to groups",
"jobCandidatesRefresh": "Refresh",
"jobCandidatesUnknown": "Unknown",
"jobCandidatesSubject": "Subject",
"jobCandidatesFrom": "From",
"jobCandidatesListId": "List-ID",
"jobCandidatesDate": "Date",
"jobCandidatesActions": "Actions",
"jobCandidatesUnsubscribe": "Unsubscribe",
"jobCandidatesUnsubscribeNone": "No List-Unsubscribe header",
"jobCandidatesUnsubscribeDisabled": "Disabled",
"jobCandidatesUnsubscribeDryRun": "Simulated",
"jobCandidatesUnsubscribeOk": "OK",
"jobCandidatesUnsubscribeFailed": "Failed",
"jobCandidatesUnsubscribeDuplicate": "Skipped (duplicate)",
"resultsTitle": "Result details",
"resultsHint": "Open a detailed list of all detected emails with a preview of each message.",
"resultsOpen": "Open results",
"resultsGroups": "Groups",
"resultsGroupsDisabled": "Grouping disabled. Switch to a grouping option to see categories.",
"resultsItems": "Messages",
"resultsPreview": "Preview",
"resultsSelectGroup": "Select a group to load its messages.",
"resultsSelectItem": "Select a message to preview it.",
"resultsPreviewLoading": "Loading preview...",
"resultsPreviewEmpty": "No preview text available.",
"resultsSearch": "Search subject/from/list",
"resultsFilterAll": "All statuses",
"resultsReviewedAll": "All",
"resultsReviewed": "Reviewed",
"resultsUnreviewed": "Unreviewed",
"resultsExportCsv": "CSV",
"resultsExportGroupCsv": "Group CSV",
"resultsSelectAll": "All",
"resultsMarkSelectedReviewed": "Selected reviewed",
"resultsMarkSelectedUnreviewed": "Selected unreviewed",
"resultsBulkSelect": "Unreviewed",
"resultsBulkMarkReviewed": "All reviewed",
"resultsDeleteSelected": "Delete selected",
"resultsAttachments": "Attachments",
"resultsDownloadAttachment": "Download",
"resultsHistory": "Unsubscribe history",
"resultsUnsubscribeDetails": "Unsubscribe details",
"resultsUnsubscribeStatus": "Status",
"resultsUnsubscribeMethod": "Method",
"resultsUnsubscribeTarget": "Target",
"resultsUnsubscribeMessage": "Result",
"resultsListId": "List-ID",
"resultsListUnsubscribe": "List-Unsubscribe",
"resultsListUnsubscribePost": "List-Unsubscribe-Post",
"resultsMailtoSubject": "Mailto subject",
"resultsMailtoBody": "Mailto body",
"resultsRequestMethod": "Request method",
"resultsRequestUrl": "Request URL",
"resultsResponseStatus": "Response status",
"resultsResponseRedirect": "Redirect",
"resultsMailtoVia": "Mail sent via",
"resultsMailtoTo": "Mailto",
"resultsMailtoReplyTo": "Reply-To",
"resultsMailtoListUnsubscribe": "List-Unsubscribe header",
"resultsUnsubscribeReason": "Reason",
"resultsUnsubscribeError": "Error",
"resultsLive": "Live",
"resultsStatic": "Static",
"resultsBackToJob": "Back to job",
"jobCandidatesActionApplied": "Applied",
"jobCandidatesActionDryRun": "Simulated",
"jobCandidatesActionFailed": "Failed",
"jobCandidatesActionSkipped": "Skipped",
"jobCandidatesSignals": "Signals",
"jobCandidatesScore": "Score",
"jobCandidatesScoreHint": "Score is derived from subject, sender, header signals, and the newsletter detection model.",
"jobCandidatesHeaderSignals": "Headers",
"jobCandidatesSubjectSignals": "Subject matches",
"jobCandidatesFromSignals": "From matches",
"jobCandidatesPrecedenceSignal": "Bulk/List precedence",
"jobEventCleanupStarted": "Cleanup started",
"jobEventCleanupFinished": "Cleanup finished",
"jobEventConnecting": "Connecting to {{email}}",
"jobEventListingGmail": "Listing Gmail messages",
"jobEventListedGmail": "Listed {{count}} Gmail messages so far",
"jobEventPreparedGmail": "Prepared {{count}} Gmail messages",
"jobEventResumeGmail": "Resuming Gmail cleanup at {{current}}/{{total}}",
"jobEventProcessingGmail": "Processing {{count}} Gmail messages",
"jobEventNoGmail": "No Gmail messages to process",
"jobEventFoundMailboxes": "Found {{count}} mailboxes",
"jobEventScanningMailbox": "Scanning {{mailbox}}",
"jobEventPreparedImap": "Prepared {{count}} IMAP messages",
"jobEventResumeImap": "Resuming IMAP cleanup at {{current}}/{{total}}",
"jobEventProcessingImap": "Processing {{count}} IMAP messages",
"jobEventNoImap": "No IMAP messages to process",
"jobEventDetectedCandidates": "Detected {{count}} newsletter candidates",
"jobEventProcessed": "Processed {{current}}/{{total}}",
"jobEventProcessedCount": "Processed {{current}}",
"jobEventGmailActionAppliedList": "Gmail action applied: {{actions}}",
"jobEventGmailActionApplied": "Gmail action applied: {{action}}",
"jobEventGmailActionSkippedNoChanges": "Gmail action skipped: no label changes",
"jobEventGmailActionFailedSimple": "Gmail action failed: {{error}}",
"jobEventGmailActionFailed": "Gmail action failed ({{action}}): {{error}}",
"jobEventImapActionFailed": "IMAP action failed ({{action}}): {{error}}",
"jobEventDryRunAction": "Simulate only: {{action}}",
"jobEventCanceledByAdmin": "Job canceled by admin",
"jobEventCanceledBeforeStart": "Job canceled before start",
"jobEventFailed": "Job failed: {{error}}",
"loadingCandidates": "Loading candidates...",
"jobsProgress": "Progress", "jobsProgress": "Progress",
"jobsEta": "ETA", "jobsEta": "ETA",
"jobsEtaDone": "Done",
"jobsEtaQueued": "Queued",
"jobsEtaRecalculating": "Recalculating ETA…",
"jobsEtaCalculating": "Calculating…",
"jobDetailsTitle": "Job details", "jobDetailsTitle": "Job details",
"jobNoEvents": "No events yet.", "jobNoEvents": "No events yet.",
"jobEvents": "Job events", "jobEvents": "Job events",
@@ -128,7 +294,13 @@
"phaseProcessingPending": "Waiting for processing.", "phaseProcessingPending": "Waiting for processing.",
"phaseUnsubscribePending": "Waiting for unsubscribe.", "phaseUnsubscribePending": "Waiting for unsubscribe.",
"phaseUnsubscribeDisabled": "Unsubscribe disabled for this job.", "phaseUnsubscribeDisabled": "Unsubscribe disabled for this job.",
"phaseUnsubscribeSummary": "Unsubscribed {{ok}} · Failed {{failed}} · Dry run {{dryRun}} ({{total}} total).", "phaseUnsubscribeSummary": "Unsubscribed {{ok}} · Failed {{failed}} · Simulated {{dryRun}} ({{total}} total).",
"phaseUnsubscribeSummaryWithProcessed": "Unsubscribed {{ok}} · Failed {{failed}} · Simulated {{dryRun}} ({{total}} candidates, {{processed}}/{{overall}} processed).",
"phaseUnsubscribeSummaryNoDryRun": "Unsubscribed {{ok}} · Failed {{failed}} ({{total}} total).",
"phaseUnsubscribeSummaryNoDryRunWithProcessed": "Unsubscribed {{ok}} · Failed {{failed}} ({{total}} candidates, {{processed}}/{{overall}} processed).",
"phaseUnsubscribeDryRunPending": "Simulation: waiting for unsubscribe checks.",
"phaseUnsubscribeDryRunSummary": "Simulation: {{dryRun}} unsubscribe checks ({{total}} total).",
"phaseUnsubscribeDryRunSummaryWithProcessed": "Simulation: {{dryRun}} unsubscribe checks ({{total}} candidates, {{processed}}/{{overall}} processed).",
"phaseStatusActive": "Active", "phaseStatusActive": "Active",
"phaseStatusDone": "Done", "phaseStatusDone": "Done",
"phaseStatusPending": "Pending", "phaseStatusPending": "Pending",
@@ -173,6 +345,7 @@
"statusRunning": "Running", "statusRunning": "Running",
"statusQueued": "Queued", "statusQueued": "Queued",
"statusSucceeded": "Succeeded", "statusSucceeded": "Succeeded",
"statusFinished": "Cleanup finished",
"statusFailed": "Failed", "statusFailed": "Failed",
"statusCanceled": "Canceled", "statusCanceled": "Canceled",
"oauthStatusLabel": "OAuth status" "oauthStatusLabel": "OAuth status"
@@ -250,11 +423,14 @@
"toastMailboxDeleted": "Mailbox deleted.", "toastMailboxDeleted": "Mailbox deleted.",
"toastRuleSaved": "Rule saved.", "toastRuleSaved": "Rule saved.",
"toastRuleDeleted": "Rule deleted.", "toastRuleDeleted": "Rule deleted.",
"toastRuleOrderSaved": "Rule order updated.",
"toastDeleteSelected": "{{deleted}} deleted · {{missing}} missing · {{failed}} failed",
"toastCleanupStarted": "Cleanup job started.", "toastCleanupStarted": "Cleanup job started.",
"toastLoggedOut": "Logged out.", "toastLoggedOut": "Logged out.",
"toastExportQueued": "Export queued.", "toastExportQueued": "Export queued.",
"toastExportReady": "Export ready.", "toastExportReady": "Export ready.",
"toastExportFailed": "Export failed.", "toastExportFailed": "Export failed.",
"toastDownloadFailed": "Download failed.",
"toastExportPurged": "Expired exports purged.", "toastExportPurged": "Expired exports purged.",
"toastExportDeleted": "Export deleted.", "toastExportDeleted": "Export deleted.",
"toastTenantUpdated": "Tenant updated.", "toastTenantUpdated": "Tenant updated.",
@@ -270,5 +446,6 @@
"toastSettingsSaved": "Settings saved.", "toastSettingsSaved": "Settings saved.",
"toastSettingsFailed": "Settings save failed.", "toastSettingsFailed": "Settings save failed.",
"confirmMailboxDelete": "Delete mailbox {{email}}? This will remove all related data.", "confirmMailboxDelete": "Delete mailbox {{email}}? This will remove all related data.",
"confirmDeleteSelected": "Delete {{count}} selected messages? Messages might already be deleted.",
"adminDeleteJobConfirm": "Delete this job and all its events?" "adminDeleteJobConfirm": "Delete this job and all its events?"
} }

View File

@@ -27,6 +27,10 @@ body {
min-height: 100vh; min-height: 100vh;
} }
body.modal-open {
overflow: hidden;
}
.toast-container { .toast-container {
position: fixed; position: fixed;
top: 20px; top: 20px;
@@ -370,6 +374,13 @@ button.ghost {
gap: 12px; gap: 12px;
} }
.panel-divider {
height: 1px;
background: rgba(148, 163, 184, 0.35);
border-radius: 999px;
margin: 8px 0 4px;
}
.section-block { .section-block {
margin: 16px 0 18px; margin: 16px 0 18px;
padding: 12px 14px; padding: 12px 14px;
@@ -490,6 +501,48 @@ select {
gap: 8px; gap: 8px;
} }
.row-with-action {
grid-template-columns: 1fr 2fr auto;
align-items: center;
}
.icon-button {
border: 1px solid rgba(148, 163, 184, 0.4);
background: #fff;
color: var(--muted);
border-radius: 10px;
padding: 6px 10px;
font-size: 12px;
cursor: pointer;
height: 36px;
}
.icon-button:hover {
border-color: rgba(37, 99, 235, 0.4);
color: var(--primary-strong);
}
.icon-only {
width: 32px;
height: 32px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 1;
font-size: 14px;
}
.icon-button.icon-only {
width: 32px;
height: 32px;
padding: 0;
}
.icon-actions {
gap: 6px;
}
.rule-block { .rule-block {
margin-top: 8px; margin-top: 8px;
display: grid; display: grid;
@@ -521,19 +574,197 @@ select {
} }
.list-item { .list-item {
display: flex; display: grid;
justify-content: space-between; grid-template-columns: 1fr auto;
align-items: center; align-items: start;
gap: 12px; gap: 12px;
padding: 10px 0; padding: 8px 0;
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
} }
.list-actions { .rule-item {
display: flex; grid-template-columns: auto 1fr auto;
gap: 8px;
align-items: center; align-items: center;
flex-wrap: wrap; padding: 10px 8px;
border-radius: 14px;
transition: background 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
}
.rule-item .rule-details {
align-self: center;
}
.rule-item.drag-over {
background: rgba(37, 99, 235, 0.08);
border-color: rgba(37, 99, 235, 0.35);
box-shadow: 0 10px 24px rgba(37, 99, 235, 0.16);
}
.rule-item.dragging {
opacity: 0.5;
transform: scale(0.985);
}
.rule-order {
display: inline-flex;
align-items: center;
gap: 8px;
}
.rule-order .order-badge {
min-width: 26px;
height: 26px;
border-radius: 999px;
background: rgba(37, 99, 235, 0.12);
color: var(--primary-strong);
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 600;
}
.rule-item .drag-handle {
cursor: grab;
}
.rule-item .drag-handle:active {
cursor: grabbing;
}
.rule-item .rule-details {
display: grid;
gap: 2px;
}
.rule-tail {
display: inline-flex;
align-items: center;
gap: 10px;
}
.rule-flags {
display: inline-flex;
align-items: center;
gap: 6px;
}
.rule-badge.rule-badge-strong {
background: rgba(37, 99, 235, 0.18);
color: var(--primary-strong);
}
.drag-ghost {
opacity: 0.85;
border-radius: 14px;
background: #fff;
box-shadow: 0 16px 36px rgba(15, 23, 42, 0.18);
}
.list-item > div {
min-width: 0;
}
.list-item p {
margin: 2px 0 0;
font-size: 12px;
color: var(--muted);
}
.list-item strong {
font-size: 13px;
font-weight: 600;
}
.list-item .badge {
letter-spacing: 0;
text-transform: none;
font-size: 12px;
color: var(--ink);
margin-bottom: 6px;
}
.list-item .badge strong {
letter-spacing: 0;
text-transform: none;
font-size: 12px;
}
.list-actions {
display: grid;
gap: 6px;
align-items: start;
justify-items: end;
min-width: 140px;
grid-column: 2;
}
.list-actions .ghost {
padding: 6px 10px;
font-size: 12px;
border-radius: 10px;
}
.list-actions .ghost {
min-width: 96px;
}
.list-actions.icon-actions {
min-width: auto;
justify-items: end;
grid-auto-flow: column;
grid-auto-columns: min-content;
}
.list-actions.icon-actions .ghost {
min-width: 0;
}
.job-item {
align-items: center;
}
.job-item .list-actions {
min-width: auto;
}
.job-item .job-action {
min-width: 32px;
border-radius: 10px;
}
.job-row {
width: 100%;
background: transparent;
border: none;
text-align: left;
cursor: pointer;
border-radius: 14px;
padding: 8px 10px;
}
.job-row:hover {
background: rgba(37, 99, 235, 0.06);
}
.job-meta {
display: grid;
gap: 2px;
}
.job-meta span {
font-size: 11px;
color: var(--muted);
}
.list-item .rule-badge {
grid-column: 2;
justify-self: end;
align-self: start;
}
.rule-item .list-actions {
min-width: 0;
} }
.events { .events {
@@ -690,6 +921,578 @@ select {
height: min(92vh, 1000px); height: min(92vh, 1000px);
display: grid; display: grid;
grid-template-rows: auto auto 1fr; grid-template-rows: auto auto 1fr;
overflow: auto;
}
.results-modal {
width: min(1400px, 98vw);
height: min(92vh, 1100px);
grid-template-rows: auto auto 1fr;
}
.results-header {
align-items: center;
gap: 16px;
}
.results-header-actions {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.results-toolbar {
display: grid;
gap: 10px;
padding: 10px 12px 12px;
border-bottom: 1px solid rgba(148, 163, 184, 0.2);
background: rgba(15, 23, 42, 0.02);
border-radius: 14px;
}
.results-toolbar-row {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
}
.results-toolbar-group {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.results-toolbar input,
.results-toolbar select {
width: auto;
min-width: 140px;
height: 32px;
font-size: 12px;
padding: 6px 10px;
}
.results-toolbar .search-input {
min-width: 220px;
}
.results-toolbar .field-row {
grid-template-columns: auto auto;
width: auto;
}
.results-toolbar .field-row span {
font-size: 12px;
}
.results-toolbar .toggle {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 12px;
}
.results-toolbar .toggle input {
width: 14px;
height: 14px;
margin: 0;
padding: 0;
transform: none;
appearance: auto;
}
.results-toolbar .toggle input[type="checkbox"] {
width: 14px;
height: 14px;
flex: 0 0 14px;
min-width: 14px;
min-height: 14px;
max-width: 14px;
max-height: 14px;
line-height: 14px;
appearance: checkbox;
accent-color: #0f172a;
}
/* fallback for global checkbox scaling */
.results-toolbar input[type="checkbox"] {
transform: scale(1);
}
.results-toolbar-actions button {
font-size: 12px;
padding: 6px 12px;
border-radius: 999px;
}
.is-hidden {
display: none !important;
}
.results-toolbar-actions {
gap: 8px;
}
.results-group-list li button {
padding: 6px 10px;
border-radius: 999px;
}
.results-group-list li.group-new {
animation: groupIn 0.35s ease-out;
}
.results-group-list li.group-updated button {
animation: groupPulse 0.55s ease-out;
}
.results-group-list li.group-moved button {
animation: groupMove 0.35s ease-out;
}
@keyframes groupIn {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes groupPulse {
0% {
background: rgba(37, 99, 235, 0.22);
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.35);
}
60% {
background: rgba(37, 99, 235, 0.12);
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.15);
}
100% {
background: #fff;
box-shadow: none;
}
}
@keyframes groupMove {
0% {
background: rgba(59, 130, 246, 0.14);
}
100% {
background: #fff;
}
}
.results-layout {
display: grid;
grid-template-columns: minmax(200px, 0.7fr) minmax(420px, 1.6fr) minmax(360px, 1.7fr);
gap: 16px;
height: 100%;
overflow: hidden;
}
@media (max-width: 1400px) {
.results-layout {
grid-template-columns: minmax(190px, 0.7fr) minmax(380px, 1.4fr) minmax(320px, 1.3fr);
}
}
@media (max-width: 1200px) {
.results-layout {
grid-template-columns: minmax(180px, 0.65fr) minmax(320px, 1.2fr) minmax(280px, 1.1fr);
}
}
@media (max-width: 1024px) {
.results-layout {
grid-template-columns: 1fr;
}
}
.results-panel {
border: 1px solid var(--border);
border-radius: 14px;
padding: 12px;
background: rgba(255, 255, 255, 0.9);
display: grid;
gap: 8px;
min-height: 0;
overflow: hidden;
align-content: start;
}
.results-panel h4 {
margin: 0;
}
.results-panel-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.results-list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 4px;
overflow-y: auto;
align-content: start;
}
.results-list li {
display: grid;
gap: 6px;
}
.results-group-list li {
grid-template-columns: 1fr;
will-change: transform;
}
.results-message-row {
grid-template-columns: 18px 1fr auto;
align-items: center;
}
.results-list li button {
width: 100%;
border: 1px solid rgba(148, 163, 184, 0.3);
background: #fff;
border-radius: 12px;
padding: 6px 10px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
text-align: left;
font-size: 12px;
cursor: pointer;
}
.results-message-list li button {
padding: 6px 10px;
border-radius: 12px;
box-shadow: none;
}
.results-message-list li button > div {
display: grid;
gap: 2px;
}
.results-message-content {
display: grid;
gap: 2px;
}
.results-message-meta {
font-size: 11px;
color: var(--muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.results-message-history {
font-size: 10px;
color: var(--muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.results-message-status {
font-size: 11px;
color: var(--muted);
white-space: nowrap;
border: 1px solid rgba(148, 163, 184, 0.3);
padding: 2px 8px;
border-radius: 999px;
}
.results-row-actions {
display: flex;
justify-content: flex-end;
padding: 0;
}
.results-row-select {
display: flex;
align-items: center;
padding: 0 6px 0 2px;
}
.results-row-history {
grid-column: 2 / span 2;
margin: 0;
font-size: 10px;
}
.results-row-select input {
width: 14px;
height: 14px;
}
.results-message-list .toggle {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 11px;
}
.toggle.compact {
gap: 0;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}
.search-input {
border: 1px solid var(--border);
border-radius: 999px;
padding: 8px 12px;
font-size: 12px;
min-width: 180px;
}
.live-indicator {
padding: 6px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
border: 1px solid rgba(148, 163, 184, 0.3);
color: var(--muted);
background: rgba(148, 163, 184, 0.08);
}
.live-indicator.live {
border-color: rgba(34, 197, 94, 0.4);
color: #15803d;
background: rgba(34, 197, 94, 0.12);
}
.rule-badge {
padding: 4px 8px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
color: var(--primary-strong);
border: 1px solid rgba(37, 99, 235, 0.3);
background: rgba(37, 99, 235, 0.08);
}
.results-list li button span {
font-size: 11px;
color: var(--muted);
}
.results-list li button strong {
display: block;
font-size: 12px;
line-height: 1.2;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.results-list li button div span {
display: block;
font-size: 11px;
line-height: 1.2;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.results-list li button.active {
border-color: rgba(37, 99, 235, 0.5);
background: rgba(37, 99, 235, 0.08);
color: var(--text);
}
.results-preview {
overflow: hidden;
display: grid;
grid-template-rows: auto 1fr;
min-height: 0;
}
.preview-card {
display: flex;
flex-direction: column;
gap: 10px;
height: 100%;
min-height: 0;
overflow: hidden;
}
.preview-meta {
display: grid;
gap: 6px;
font-size: 12px;
color: var(--text);
}
.preview-subject strong {
font-size: 14px;
}
.preview-line {
display: flex;
flex-wrap: wrap;
gap: 8px;
color: var(--muted);
font-size: 12px;
}
.preview-badges {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.preview-badge {
border: 1px solid rgba(148, 163, 184, 0.35);
background: rgba(148, 163, 184, 0.08);
color: var(--muted);
padding: 2px 8px;
border-radius: 999px;
font-size: 11px;
}
.preview-badge-action {
cursor: pointer;
border-color: rgba(37, 99, 235, 0.35);
background: rgba(37, 99, 235, 0.08);
color: var(--primary-strong);
}
.preview-details {
border: 1px solid rgba(148, 163, 184, 0.25);
border-radius: 12px;
padding: 8px 10px;
background: rgba(255, 255, 255, 0.8);
font-size: 12px;
}
.preview-details summary {
cursor: pointer;
font-weight: 600;
color: var(--ink);
}
.preview-detail-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 8px;
margin-top: 8px;
}
.preview-detail-grid span {
display: block;
font-size: 11px;
color: var(--muted);
}
.preview-detail-grid strong {
font-size: 12px;
font-weight: 600;
color: var(--ink);
word-break: break-word;
}
.preview-frame {
border: 1px solid rgba(148, 163, 184, 0.2);
border-radius: 12px;
width: 100%;
flex: 1;
min-height: 0;
background: #fff;
}
.preview-text {
white-space: pre-wrap;
background: #fff;
border-radius: 12px;
border: 1px solid rgba(148, 163, 184, 0.2);
padding: 12px;
flex: 1;
min-height: 0;
overflow: auto;
font-size: 12px;
}
.preview-attachments {
border: 1px solid rgba(148, 163, 184, 0.2);
border-radius: 12px;
padding: 10px 12px;
background: rgba(255, 255, 255, 0.8);
font-size: 12px;
}
.preview-attachments ul {
margin: 6px 0 0;
padding-left: 0;
list-style: none;
display: grid;
gap: 6px;
}
.preview-attachments li {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.preview-attachments .att-meta {
color: #1f2937;
}
.preview-attachments .ghost.small {
padding: 4px 10px;
font-size: 11px;
border-radius: 999px;
}
.results-cta {
margin-top: 16px;
padding: 14px;
border-radius: 16px;
border: 1px solid rgba(37, 99, 235, 0.2);
background: rgba(37, 99, 235, 0.08);
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
@media (max-width: 900px) {
.results-layout {
grid-template-columns: 1fr;
}
.results-modal {
height: auto;
}
.preview-frame,
.preview-text {
min-height: 320px;
}
} }
.job-hero { .job-hero {
@@ -880,6 +1683,102 @@ select {
opacity: 0.6; opacity: 0.6;
} }
.job-candidates {
display: grid;
gap: 12px;
margin-top: 16px;
}
.candidate-toolbar {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
justify-content: space-between;
}
.candidate-toolbar .inline-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: center;
}
.candidate-groups {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 12px;
}
.candidate-group {
border-radius: 14px;
border: 1px solid rgba(148, 163, 184, 0.3);
background: rgba(255, 255, 255, 0.6);
padding: 12px 14px;
text-align: left;
display: grid;
gap: 6px;
transition: all 0.2s ease;
}
.candidate-group:hover {
border-color: rgba(37, 99, 235, 0.35);
box-shadow: 0 8px 18px rgba(37, 99, 235, 0.12);
}
.candidate-group span {
color: var(--muted);
font-size: 12px;
}
.candidate-list {
display: grid;
gap: 10px;
}
.candidate-item {
border-radius: 14px;
border: 1px solid rgba(148, 163, 184, 0.2);
padding: 12px 14px;
background: rgba(255, 255, 255, 0.75);
display: grid;
gap: 6px;
}
.candidate-item h5 {
margin: 0;
font-size: 14px;
}
.candidate-meta {
display: flex;
gap: 12px;
flex-wrap: wrap;
font-size: 12px;
color: var(--muted);
}
.candidate-signal-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.candidate-action-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.candidate-chip {
padding: 4px 8px;
border-radius: 999px;
font-size: 11px;
border: 1px solid rgba(37, 99, 235, 0.2);
background: rgba(37, 99, 235, 0.08);
color: var(--primary-strong);
}
.modal-header { .modal-header {
display: flex; display: flex;
align-items: center; align-items: center;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB