Aktueller Stand
19
.env
@@ -48,6 +48,21 @@ OAUTH_STATE_TTL_SECONDS=600
|
||||
# Cleanup scan limit (0 = no limit)
|
||||
CLEANUP_SCAN_LIMIT=0
|
||||
|
||||
# Newsletter detection (comma-separated lists)
|
||||
NEWSLETTER_THRESHOLD=2
|
||||
NEWSLETTER_SUBJECT_TOKENS=newsletter,unsubscribe,update,news,digest
|
||||
NEWSLETTER_FROM_TOKENS=newsletter,no-reply,noreply,news,updates
|
||||
NEWSLETTER_HEADER_KEYS=list-unsubscribe,list-id,list-help,list-archive,list-post,list-owner,list-subscribe,list-unsubscribe-post
|
||||
NEWSLETTER_WEIGHT_HEADER=1
|
||||
NEWSLETTER_WEIGHT_PRECEDENCE=1
|
||||
NEWSLETTER_WEIGHT_SUBJECT=1
|
||||
NEWSLETTER_WEIGHT_FROM=1
|
||||
# EMA smoothing for ETA/metrics (0.05-0.95)
|
||||
METRICS_EMA_ALPHA=0.3
|
||||
|
||||
# Max attachment size for download (bytes)
|
||||
ATTACHMENT_MAX_BYTES=10485760
|
||||
|
||||
# Disallow custom IMAP/SMTP hosts unless explicitly enabled
|
||||
ALLOW_CUSTOM_MAIL_HOSTS=false
|
||||
|
||||
@@ -76,3 +91,7 @@ SEED_TENANT=Default Tenant
|
||||
SEED_TENANT_ID=seed-tenant
|
||||
SEED_ENABLED=false
|
||||
SEED_FORCE_PASSWORD_UPDATE=false
|
||||
|
||||
UNSUBSCRIBE_HISTORY_TTL_DAYS=180
|
||||
# Unsubscribe method preference: auto | http | mailto
|
||||
UNSUBSCRIBE_METHOD_PREFERENCE=http
|
||||
|
||||
20
.env.example
@@ -48,6 +48,26 @@ OAUTH_STATE_TTL_SECONDS=600
|
||||
# Cleanup scan limit (0 = no limit)
|
||||
CLEANUP_SCAN_LIMIT=0
|
||||
|
||||
# Newsletter detection (comma-separated lists)
|
||||
NEWSLETTER_THRESHOLD=2
|
||||
NEWSLETTER_SUBJECT_TOKENS=newsletter,unsubscribe,update,news,digest
|
||||
NEWSLETTER_FROM_TOKENS=newsletter,no-reply,noreply,news,updates
|
||||
NEWSLETTER_HEADER_KEYS=list-unsubscribe,list-id,list-help,list-archive,list-post,list-owner,list-subscribe,list-unsubscribe-post
|
||||
NEWSLETTER_WEIGHT_HEADER=1
|
||||
NEWSLETTER_WEIGHT_PRECEDENCE=1
|
||||
NEWSLETTER_WEIGHT_SUBJECT=1
|
||||
NEWSLETTER_WEIGHT_FROM=1
|
||||
# EMA smoothing for ETA/metrics (0.05-0.95)
|
||||
METRICS_EMA_ALPHA=0.3
|
||||
|
||||
# Max attachment size for download (bytes)
|
||||
ATTACHMENT_MAX_BYTES=10485760
|
||||
|
||||
# Unsubscribe history (cross-job dedupe window)
|
||||
UNSUBSCRIBE_HISTORY_TTL_DAYS=180
|
||||
# Unsubscribe method preference: auto | http | mailto
|
||||
UNSUBSCRIBE_METHOD_PREFERENCE=http
|
||||
|
||||
# Disallow custom IMAP/SMTP hosts unless explicitly enabled
|
||||
ALLOW_CUSTOM_MAIL_HOSTS=false
|
||||
|
||||
|
||||
39
README.md
@@ -95,8 +95,11 @@ Runs the full scan and logs what *would* happen, but **does not move/delete/unsu
|
||||
|
||||
**Unsubscribe aktiv**
|
||||
Enables `List‑Unsubscribe` handling.
|
||||
- HTTP links are called (one‑click POST when supported).
|
||||
- Mailto links are sent via SMTP (requires SMTP host + app password).
|
||||
- **Preference** is controlled by the admin setting **“Unsubscribe‑Methode bevorzugen”** (`UNSUBSCRIBE_METHOD_PREFERENCE`): `http` (default), `mailto`, or `auto`.
|
||||
- **HTTP** is tried first when preference is `http` or `auto` (one‑click POST when supported).
|
||||
- **Fallback:** if HTTP fails, the worker **automatically falls back to mailto** when available.
|
||||
- **MAILTO** sends an email via SMTP (requires SMTP host + app password).
|
||||
- If preference is `mailto`, mailto is tried first; HTTP is only attempted if no mailto target exists.
|
||||
|
||||
**Routing aktiv**
|
||||
Applies your configured rules (conditions → actions).
|
||||
@@ -172,6 +175,38 @@ Set these in `.env` before going public:
|
||||
## Environment
|
||||
All config lives in the repo root `.env` (see `.env.example`).
|
||||
|
||||
## Docker services (docker-compose)
|
||||
The stack is split into small, single-purpose services so each part can scale and restart independently:
|
||||
|
||||
- **web** (Frontend UI)
|
||||
- React + Vite single‑page app.
|
||||
- Serves the user/admin interface and talks to the API via `VITE_API_URL`.
|
||||
- In dev it runs the Vite server; in production it serves the built assets.
|
||||
|
||||
- **api** (Backend API)
|
||||
- Fastify server with auth, rules, mail account management, and job control.
|
||||
- Issues short‑lived SSE tokens and exposes the job/event endpoints.
|
||||
- Connects to Postgres for persistence and Redis for queues.
|
||||
|
||||
- **worker** (Background jobs)
|
||||
- BullMQ worker that executes cleanup jobs and export jobs.
|
||||
- Handles IMAP/Gmail processing, unsubscribe actions, and rule execution.
|
||||
- Writes progress events and updates job state in Postgres.
|
||||
|
||||
- **postgres** (Database)
|
||||
- Primary data store for users, tenants, mailboxes, rules, jobs, candidates, and settings.
|
||||
- Keeps all state so jobs can resume after restarts.
|
||||
|
||||
- **redis** (Queue & cache)
|
||||
- BullMQ queue backend for cleanup/export jobs.
|
||||
- Used for fast job coordination between API and worker.
|
||||
|
||||
How they interact:
|
||||
1. **web** calls **api** for login, rule management, and job start.
|
||||
2. **api** enqueues jobs in **redis** and persists state in **postgres**.
|
||||
3. **worker** consumes jobs from **redis**, processes mailboxes, and writes results to **postgres**.
|
||||
4. **web** streams job events from **api** (SSE) for live progress.
|
||||
|
||||
Export settings:
|
||||
- `EXPORT_DIR` (default `/tmp/mailcleaner-exports`)
|
||||
- `EXPORT_TTL_HOURS` (default `24`)
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "CleanupJobCandidate"
|
||||
ADD COLUMN "listUnsubscribePost" TEXT;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "CleanupJobCandidate"
|
||||
ADD COLUMN "unsubscribeDetails" JSONB;
|
||||
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TYPE "RuleConditionType" ADD VALUE IF NOT EXISTS 'HEADER_MISSING';
|
||||
@@ -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");
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "Rule" ADD COLUMN "stopOnMatch" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,5 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "UnsubscribeAttempt" ADD COLUMN "dedupeKey" TEXT;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "UnsubscribeAttempt_jobId_dedupeKey_key" ON "UnsubscribeAttempt"("jobId", "dedupeKey");
|
||||
@@ -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';
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "RuleConditionType" ADD VALUE IF NOT EXISTS 'SCORE';
|
||||
@@ -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");
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "CleanupJobCandidate" ADD COLUMN "reviewed" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -0,0 +1,5 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "RuleMatchMode" AS ENUM ('ALL', 'ANY');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Rule" ADD COLUMN "matchMode" "RuleMatchMode" NOT NULL DEFAULT 'ALL';
|
||||
@@ -31,14 +31,19 @@ enum RuleActionType {
|
||||
DELETE
|
||||
ARCHIVE
|
||||
LABEL
|
||||
MARK_READ
|
||||
MARK_UNREAD
|
||||
}
|
||||
|
||||
enum RuleConditionType {
|
||||
HEADER
|
||||
HEADER_MISSING
|
||||
SUBJECT
|
||||
FROM
|
||||
LIST_UNSUBSCRIBE
|
||||
LIST_ID
|
||||
UNSUBSCRIBE_STATUS
|
||||
SCORE
|
||||
}
|
||||
|
||||
enum ExportStatus {
|
||||
@@ -60,6 +65,42 @@ model Tenant {
|
||||
mailboxAccounts MailboxAccount[]
|
||||
rules Rule[]
|
||||
jobs CleanupJob[]
|
||||
metric TenantMetric?
|
||||
providerMetrics TenantProviderMetric[]
|
||||
}
|
||||
|
||||
model TenantMetric {
|
||||
id String @id @default(cuid())
|
||||
tenantId String @unique
|
||||
avgProcessingRate Float?
|
||||
sampleCount Int @default(0)
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||
}
|
||||
|
||||
model TenantProviderMetric {
|
||||
id String @id @default(cuid())
|
||||
tenantId String
|
||||
provider MailProvider
|
||||
avgListingRate Float?
|
||||
avgProcessingRate Float?
|
||||
avgUnsubscribeRate Float?
|
||||
avgRoutingRate Float?
|
||||
avgListingSecondsPerMessage Float?
|
||||
avgProcessingSecondsPerMessage Float?
|
||||
avgUnsubscribeSecondsPerMessage Float?
|
||||
avgRoutingSecondsPerMessage Float?
|
||||
listingSampleCount Int @default(0)
|
||||
processingSampleCount Int @default(0)
|
||||
unsubscribeSampleCount Int @default(0)
|
||||
routingSampleCount Int @default(0)
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||
|
||||
@@unique([tenantId, provider])
|
||||
@@index([tenantId])
|
||||
}
|
||||
|
||||
model ExportJob {
|
||||
@@ -120,6 +161,7 @@ model MailboxAccount {
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||
folders MailboxFolder[]
|
||||
jobs CleanupJob[]
|
||||
candidates CleanupJobCandidate[]
|
||||
|
||||
@@index([tenantId])
|
||||
}
|
||||
@@ -161,6 +203,9 @@ model Rule {
|
||||
tenantId String
|
||||
name String
|
||||
enabled Boolean @default(true)
|
||||
matchMode RuleMatchMode @default(ALL)
|
||||
position Int @default(0)
|
||||
stopOnMatch Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -169,6 +214,12 @@ model Rule {
|
||||
actions RuleAction[]
|
||||
|
||||
@@index([tenantId])
|
||||
@@index([tenantId, position])
|
||||
}
|
||||
|
||||
enum RuleMatchMode {
|
||||
ALL
|
||||
ANY
|
||||
}
|
||||
|
||||
model RuleCondition {
|
||||
@@ -205,6 +256,12 @@ model CleanupJob {
|
||||
checkpointUpdatedAt DateTime?
|
||||
processedMessages Int?
|
||||
totalMessages Int?
|
||||
listingSeconds Int?
|
||||
processingSeconds Int?
|
||||
unsubscribeSeconds Int?
|
||||
routingSeconds Int?
|
||||
unsubscribeAttempts Int?
|
||||
actionAttempts Int?
|
||||
startedAt DateTime?
|
||||
finishedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
@@ -212,17 +269,50 @@ model CleanupJob {
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||
mailboxAccount MailboxAccount @relation(fields: [mailboxAccountId], references: [id])
|
||||
unsubscribeAttempts UnsubscribeAttempt[]
|
||||
unsubscribeAttemptItems UnsubscribeAttempt[]
|
||||
events CleanupJobEvent[]
|
||||
candidates CleanupJobCandidate[]
|
||||
|
||||
@@index([tenantId])
|
||||
@@index([mailboxAccountId])
|
||||
}
|
||||
|
||||
model CleanupJobCandidate {
|
||||
id String @id @default(cuid())
|
||||
jobId String
|
||||
mailboxAccountId String
|
||||
provider MailProvider
|
||||
externalId String
|
||||
subject String?
|
||||
from String?
|
||||
fromDomain String?
|
||||
receivedAt DateTime?
|
||||
listId String?
|
||||
listUnsubscribe String?
|
||||
listUnsubscribePost String?
|
||||
score Int
|
||||
signals Json
|
||||
actions Json?
|
||||
unsubscribeStatus String?
|
||||
unsubscribeMessage String?
|
||||
unsubscribeTarget String?
|
||||
unsubscribeDetails Json?
|
||||
reviewed Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
job CleanupJob @relation(fields: [jobId], references: [id])
|
||||
mailboxAccount MailboxAccount @relation(fields: [mailboxAccountId], references: [id])
|
||||
|
||||
@@unique([jobId, externalId])
|
||||
@@index([jobId])
|
||||
@@index([jobId, fromDomain])
|
||||
}
|
||||
|
||||
model UnsubscribeAttempt {
|
||||
id String @id @default(cuid())
|
||||
jobId String
|
||||
mailItemId String?
|
||||
dedupeKey String?
|
||||
method String
|
||||
target String
|
||||
status String
|
||||
@@ -231,6 +321,19 @@ model UnsubscribeAttempt {
|
||||
job CleanupJob @relation(fields: [jobId], references: [id])
|
||||
|
||||
@@index([jobId])
|
||||
@@unique([jobId, dedupeKey])
|
||||
}
|
||||
|
||||
model UnsubscribeHistory {
|
||||
id String @id @default(cuid())
|
||||
tenantId String
|
||||
dedupeKey String
|
||||
target String
|
||||
status String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([tenantId, dedupeKey])
|
||||
@@index([tenantId])
|
||||
}
|
||||
|
||||
model CleanupJobEvent {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "../db.js";
|
||||
import { config } from "../config.js";
|
||||
import { logJobEvent } from "../queue/jobEvents.js";
|
||||
import { queueCleanupJob, removeQueueJob, queueExportJob } from "../queue/queue.js";
|
||||
import { createReadStream } from "node:fs";
|
||||
@@ -28,7 +29,17 @@ const allowedSettings = [
|
||||
"google.client_id",
|
||||
"google.client_secret",
|
||||
"google.redirect_uri",
|
||||
"cleanup.scan_limit"
|
||||
"cleanup.scan_limit",
|
||||
"newsletter.threshold",
|
||||
"newsletter.subject_tokens",
|
||||
"newsletter.from_tokens",
|
||||
"newsletter.header_keys",
|
||||
"newsletter.weight_header",
|
||||
"newsletter.weight_precedence",
|
||||
"newsletter.weight_subject",
|
||||
"newsletter.weight_from",
|
||||
"unsubscribe.history_ttl_days",
|
||||
"unsubscribe.method_preference"
|
||||
] as const;
|
||||
|
||||
export async function adminRoutes(app: FastifyInstance) {
|
||||
@@ -41,7 +52,17 @@ export async function adminRoutes(app: FastifyInstance) {
|
||||
"google.client_id": process.env.GOOGLE_CLIENT_ID ?? null,
|
||||
"google.client_secret": process.env.GOOGLE_CLIENT_SECRET ?? null,
|
||||
"google.redirect_uri": process.env.GOOGLE_REDIRECT_URI ?? null,
|
||||
"cleanup.scan_limit": process.env.CLEANUP_SCAN_LIMIT ?? null
|
||||
"cleanup.scan_limit": process.env.CLEANUP_SCAN_LIMIT ?? String(config.CLEANUP_SCAN_LIMIT),
|
||||
"newsletter.threshold": process.env.NEWSLETTER_THRESHOLD ?? String(config.NEWSLETTER_THRESHOLD),
|
||||
"newsletter.subject_tokens": process.env.NEWSLETTER_SUBJECT_TOKENS ?? config.NEWSLETTER_SUBJECT_TOKENS,
|
||||
"newsletter.from_tokens": process.env.NEWSLETTER_FROM_TOKENS ?? config.NEWSLETTER_FROM_TOKENS,
|
||||
"newsletter.header_keys": process.env.NEWSLETTER_HEADER_KEYS ?? config.NEWSLETTER_HEADER_KEYS,
|
||||
"newsletter.weight_header": process.env.NEWSLETTER_WEIGHT_HEADER ?? String(config.NEWSLETTER_WEIGHT_HEADER),
|
||||
"newsletter.weight_precedence": process.env.NEWSLETTER_WEIGHT_PRECEDENCE ?? String(config.NEWSLETTER_WEIGHT_PRECEDENCE),
|
||||
"newsletter.weight_subject": process.env.NEWSLETTER_WEIGHT_SUBJECT ?? String(config.NEWSLETTER_WEIGHT_SUBJECT),
|
||||
"newsletter.weight_from": process.env.NEWSLETTER_WEIGHT_FROM ?? String(config.NEWSLETTER_WEIGHT_FROM),
|
||||
"unsubscribe.history_ttl_days": process.env.UNSUBSCRIBE_HISTORY_TTL_DAYS ?? String(config.UNSUBSCRIBE_HISTORY_TTL_DAYS),
|
||||
"unsubscribe.method_preference": process.env.UNSUBSCRIBE_METHOD_PREFERENCE ?? config.UNSUBSCRIBE_METHOD_PREFERENCE
|
||||
};
|
||||
const settings = keys.reduce<Record<string, { value: string | null; source: "db" | "env" | "unset" }>>((acc, key) => {
|
||||
const dbValue = stored[key];
|
||||
@@ -223,6 +244,7 @@ export async function adminRoutes(app: FastifyInstance) {
|
||||
const jobIds = jobs.map((job) => job.id);
|
||||
await tx.cleanupJobEvent.deleteMany({ where: { jobId: { in: jobIds } } });
|
||||
await tx.unsubscribeAttempt.deleteMany({ where: { jobId: { in: jobIds } } });
|
||||
await tx.cleanupJobCandidate.deleteMany({ where: { jobId: { in: jobIds } } });
|
||||
await tx.cleanupJob.deleteMany({ where: { tenantId: tenant.id } });
|
||||
await tx.ruleAction.deleteMany({ where: { rule: { tenantId: tenant.id } } });
|
||||
await tx.ruleCondition.deleteMany({ where: { rule: { tenantId: tenant.id } } });
|
||||
@@ -427,6 +449,7 @@ export async function adminRoutes(app: FastifyInstance) {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.cleanupJobEvent.deleteMany({ where: { jobId: job.id } });
|
||||
await tx.unsubscribeAttempt.deleteMany({ where: { jobId: job.id } });
|
||||
await tx.cleanupJobCandidate.deleteMany({ where: { jobId: job.id } });
|
||||
await tx.cleanupJob.delete({ where: { id: job.id } });
|
||||
});
|
||||
|
||||
|
||||
@@ -30,6 +30,21 @@ const envSchema = z.object({
|
||||
SSE_TOKEN_TTL_SECONDS: z.coerce.number().default(300),
|
||||
OAUTH_STATE_TTL_SECONDS: z.coerce.number().default(600),
|
||||
CLEANUP_SCAN_LIMIT: z.coerce.number().default(0),
|
||||
NEWSLETTER_THRESHOLD: z.coerce.number().default(2),
|
||||
NEWSLETTER_SUBJECT_TOKENS: z.string().default("newsletter,unsubscribe,update,news,digest"),
|
||||
NEWSLETTER_FROM_TOKENS: z.string().default("newsletter,no-reply,noreply,news,updates"),
|
||||
NEWSLETTER_HEADER_KEYS: z.string().default("list-unsubscribe,list-id,list-help,list-archive,list-post,list-owner,list-subscribe,list-unsubscribe-post"),
|
||||
NEWSLETTER_WEIGHT_HEADER: z.coerce.number().default(1),
|
||||
NEWSLETTER_WEIGHT_PRECEDENCE: z.coerce.number().default(1),
|
||||
NEWSLETTER_WEIGHT_SUBJECT: z.coerce.number().default(1),
|
||||
NEWSLETTER_WEIGHT_FROM: z.coerce.number().default(1),
|
||||
UNSUBSCRIBE_HISTORY_TTL_DAYS: z.coerce.number().default(180),
|
||||
UNSUBSCRIBE_METHOD_PREFERENCE: z.preprocess(
|
||||
(value) => (typeof value === "string" ? value.toLowerCase() : value),
|
||||
z.enum(["auto", "http", "mailto"]).default("http")
|
||||
),
|
||||
METRICS_EMA_ALPHA: z.coerce.number().default(0.3),
|
||||
ATTACHMENT_MAX_BYTES: z.coerce.number().default(10 * 1024 * 1024),
|
||||
ALLOW_CUSTOM_MAIL_HOSTS: envBoolean(false),
|
||||
BLOCK_PRIVATE_NETWORKS: envBoolean(true),
|
||||
ENCRYPTION_KEY: z.string().optional(),
|
||||
@@ -67,6 +82,18 @@ const parsed = envSchema.safeParse({
|
||||
SSE_TOKEN_TTL_SECONDS: process.env.SSE_TOKEN_TTL_SECONDS,
|
||||
OAUTH_STATE_TTL_SECONDS: process.env.OAUTH_STATE_TTL_SECONDS,
|
||||
CLEANUP_SCAN_LIMIT: process.env.CLEANUP_SCAN_LIMIT,
|
||||
NEWSLETTER_THRESHOLD: process.env.NEWSLETTER_THRESHOLD,
|
||||
NEWSLETTER_SUBJECT_TOKENS: process.env.NEWSLETTER_SUBJECT_TOKENS,
|
||||
NEWSLETTER_FROM_TOKENS: process.env.NEWSLETTER_FROM_TOKENS,
|
||||
NEWSLETTER_HEADER_KEYS: process.env.NEWSLETTER_HEADER_KEYS,
|
||||
NEWSLETTER_WEIGHT_HEADER: process.env.NEWSLETTER_WEIGHT_HEADER,
|
||||
NEWSLETTER_WEIGHT_PRECEDENCE: process.env.NEWSLETTER_WEIGHT_PRECEDENCE,
|
||||
NEWSLETTER_WEIGHT_SUBJECT: process.env.NEWSLETTER_WEIGHT_SUBJECT,
|
||||
NEWSLETTER_WEIGHT_FROM: process.env.NEWSLETTER_WEIGHT_FROM,
|
||||
UNSUBSCRIBE_HISTORY_TTL_DAYS: process.env.UNSUBSCRIBE_HISTORY_TTL_DAYS,
|
||||
UNSUBSCRIBE_METHOD_PREFERENCE: process.env.UNSUBSCRIBE_METHOD_PREFERENCE,
|
||||
METRICS_EMA_ALPHA: process.env.METRICS_EMA_ALPHA,
|
||||
ATTACHMENT_MAX_BYTES: process.env.ATTACHMENT_MAX_BYTES,
|
||||
ALLOW_CUSTOM_MAIL_HOSTS: process.env.ALLOW_CUSTOM_MAIL_HOSTS,
|
||||
BLOCK_PRIVATE_NETWORKS: process.env.BLOCK_PRIVATE_NETWORKS,
|
||||
ENCRYPTION_KEY: process.env.ENCRYPTION_KEY,
|
||||
|
||||
@@ -5,7 +5,7 @@ import { createImapClient, fetchHeadersByUids, listMailboxes } from "./imap.js";
|
||||
import { detectNewsletter } from "./newsletter.js";
|
||||
import { matchRules } from "./rules.js";
|
||||
import { unsubscribeFromHeader } from "./unsubscribe.js";
|
||||
import { applyGmailAction, gmailClientForAccount } from "./gmail.js";
|
||||
import { gmailClientForAccount } from "./gmail.js";
|
||||
|
||||
export const runCleanup = async (cleanupJobId: string, mailboxAccountId: string) => {
|
||||
const account = await prisma.mailboxAccount.findUnique({ where: { id: mailboxAccountId } });
|
||||
@@ -27,9 +27,73 @@ export const runCleanup = async (cleanupJobId: string, mailboxAccountId: string)
|
||||
|
||||
const rules = await prisma.rule.findMany({
|
||||
where: { tenantId: job.tenantId },
|
||||
include: { conditions: true, actions: true }
|
||||
include: { conditions: true, actions: true },
|
||||
orderBy: [{ position: "asc" }, { createdAt: "asc" }]
|
||||
});
|
||||
|
||||
const parseList = (value: string | null | undefined, fallback: string[]) => {
|
||||
if (!value) return fallback;
|
||||
return value.split(",").map((item) => item.trim().toLowerCase()).filter(Boolean);
|
||||
};
|
||||
|
||||
const parseThreshold = (value: string | null | undefined, fallback: number) => {
|
||||
if (!value) return fallback;
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
};
|
||||
|
||||
const defaultHeaderKeys = [
|
||||
"list-unsubscribe",
|
||||
"list-id",
|
||||
"list-help",
|
||||
"list-archive",
|
||||
"list-post",
|
||||
"list-owner",
|
||||
"list-subscribe",
|
||||
"list-unsubscribe-post"
|
||||
];
|
||||
const defaultSubjectTokens = ["newsletter", "unsubscribe", "update", "news", "digest"];
|
||||
const defaultFromTokens = ["newsletter", "no-reply", "noreply", "news", "updates"];
|
||||
|
||||
const newsletterSettings = await prisma.appSetting.findMany({
|
||||
where: { key: { in: ["newsletter.threshold", "newsletter.subject_tokens", "newsletter.from_tokens", "newsletter.header_keys"] } }
|
||||
});
|
||||
const newsletterMap = new Map(newsletterSettings.map((setting) => [setting.key, setting.value]));
|
||||
const newsletterConfig = {
|
||||
threshold: parseThreshold(
|
||||
newsletterMap.get("newsletter.threshold"),
|
||||
config.NEWSLETTER_THRESHOLD
|
||||
),
|
||||
weightHeader: parseThreshold(
|
||||
newsletterMap.get("newsletter.weight_header"),
|
||||
config.NEWSLETTER_WEIGHT_HEADER
|
||||
),
|
||||
weightPrecedence: parseThreshold(
|
||||
newsletterMap.get("newsletter.weight_precedence"),
|
||||
config.NEWSLETTER_WEIGHT_PRECEDENCE
|
||||
),
|
||||
weightSubject: parseThreshold(
|
||||
newsletterMap.get("newsletter.weight_subject"),
|
||||
config.NEWSLETTER_WEIGHT_SUBJECT
|
||||
),
|
||||
weightFrom: parseThreshold(
|
||||
newsletterMap.get("newsletter.weight_from"),
|
||||
config.NEWSLETTER_WEIGHT_FROM
|
||||
),
|
||||
subjectTokens: parseList(
|
||||
newsletterMap.get("newsletter.subject_tokens") ?? config.NEWSLETTER_SUBJECT_TOKENS,
|
||||
defaultSubjectTokens
|
||||
),
|
||||
fromTokens: parseList(
|
||||
newsletterMap.get("newsletter.from_tokens") ?? config.NEWSLETTER_FROM_TOKENS,
|
||||
defaultFromTokens
|
||||
),
|
||||
headerKeys: parseList(
|
||||
newsletterMap.get("newsletter.header_keys") ?? config.NEWSLETTER_HEADER_KEYS,
|
||||
defaultHeaderKeys
|
||||
)
|
||||
};
|
||||
|
||||
await logJobEvent(cleanupJobId, "info", `Connecting to ${account.email}`, 5);
|
||||
|
||||
const isGmail = account.provider === "GMAIL";
|
||||
@@ -94,106 +158,329 @@ export const runCleanup = async (cleanupJobId: string, mailboxAccountId: string)
|
||||
});
|
||||
};
|
||||
|
||||
const extractDomain = (value?: string | null) => {
|
||||
if (!value) return null;
|
||||
const match = value.match(/@([^>\s]+)/);
|
||||
if (!match) return null;
|
||||
return match[1].toLowerCase();
|
||||
};
|
||||
|
||||
const normalizeListUnsubscribe = (value: string) => {
|
||||
const tokens = value
|
||||
.split(",")
|
||||
.map((token) => token.trim())
|
||||
.map((token) => token.replace(/^<|>$/g, ""))
|
||||
.filter(Boolean)
|
||||
.map((token) => token.toLowerCase());
|
||||
if (!tokens.length) return null;
|
||||
return tokens.sort().join(",");
|
||||
};
|
||||
|
||||
type GmailClient = Awaited<ReturnType<typeof gmailClientForAccount>>["gmail"];
|
||||
|
||||
const processMessage = async (msg: {
|
||||
uid: number;
|
||||
subject?: string;
|
||||
from?: string;
|
||||
receivedAt?: Date;
|
||||
headers: Map<string, string>;
|
||||
gmailMessageId?: string;
|
||||
}) => {
|
||||
mailbox?: string;
|
||||
}, gmailContext?: { gmail: GmailClient; resolveLabelId: (label: string) => Promise<string> }) => {
|
||||
const ctx = {
|
||||
headers: msg.headers,
|
||||
subject: msg.subject ?? "",
|
||||
from: msg.from ?? ""
|
||||
};
|
||||
|
||||
const result = detectNewsletter(ctx);
|
||||
const result = detectNewsletter({ ...ctx, config: newsletterConfig });
|
||||
if (!result.isNewsletter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const actions = job.routingEnabled ? matchRules(rules, ctx) : [];
|
||||
const listId = msg.headers.get("list-id") ?? null;
|
||||
const listUnsubscribe = msg.headers.get("list-unsubscribe") ?? null;
|
||||
const listUnsubscribePost = msg.headers.get("list-unsubscribe-post") ?? null;
|
||||
const externalId = msg.gmailMessageId
|
||||
? `gmail:${msg.gmailMessageId}`
|
||||
: `imap:${msg.mailbox ?? account.email}:${msg.uid}`;
|
||||
|
||||
const candidate = await prisma.cleanupJobCandidate.upsert({
|
||||
where: { jobId_externalId: { jobId: cleanupJobId, externalId } },
|
||||
update: {
|
||||
subject: msg.subject ?? null,
|
||||
from: msg.from ?? null,
|
||||
fromDomain: extractDomain(msg.from),
|
||||
receivedAt: msg.receivedAt ?? null,
|
||||
listId,
|
||||
listUnsubscribe,
|
||||
listUnsubscribePost,
|
||||
score: result.score,
|
||||
signals: result.signals
|
||||
},
|
||||
create: {
|
||||
jobId: cleanupJobId,
|
||||
mailboxAccountId: account.id,
|
||||
provider: account.provider,
|
||||
externalId,
|
||||
subject: msg.subject ?? null,
|
||||
from: msg.from ?? null,
|
||||
fromDomain: extractDomain(msg.from),
|
||||
receivedAt: msg.receivedAt ?? null,
|
||||
listId,
|
||||
listUnsubscribe,
|
||||
listUnsubscribePost,
|
||||
score: result.score,
|
||||
signals: result.signals
|
||||
}
|
||||
});
|
||||
|
||||
let unsubscribeStatus = job.unsubscribeEnabled ? "pending" : "disabled";
|
||||
let unsubscribeMessage: string | null = null;
|
||||
let unsubscribeDetails: Record<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) {
|
||||
for (const action of actions) {
|
||||
if (job.dryRun) {
|
||||
await logJobEvent(cleanupJobId, "info", `DRY RUN: ${action.type} ${action.target ?? ""}`);
|
||||
actionLog.push({ type: action.type, target: action.target ?? null, status: "dry-run" });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (account.provider === "GMAIL" && msg.gmailMessageId) {
|
||||
await applyGmailAction({
|
||||
account,
|
||||
gmailMessageId: msg.gmailMessageId,
|
||||
action: action.type,
|
||||
target: action.target
|
||||
});
|
||||
await logJobEvent(cleanupJobId, "info", `Gmail action ${action.type} applied`);
|
||||
continue;
|
||||
if (account.provider === "GMAIL" && msg.gmailMessageId && gmailContext) {
|
||||
const actionStart = Date.now();
|
||||
actionAttempts += 1;
|
||||
const actionLogItems = actions.map((item) => ({
|
||||
type: item.type,
|
||||
target: item.target ?? null,
|
||||
status: "pending" as const,
|
||||
error: undefined as string | undefined
|
||||
}));
|
||||
const hasDelete = actions.some((item) => item.type === "DELETE");
|
||||
try {
|
||||
if (hasDelete) {
|
||||
await gmailContext.gmail.users.messages.delete({ userId: "me", id: msg.gmailMessageId });
|
||||
await logJobEvent(cleanupJobId, "info", "Gmail action DELETE applied");
|
||||
for (const item of actionLogItems) {
|
||||
if (item.type === "DELETE") {
|
||||
item.status = "applied";
|
||||
} else {
|
||||
item.status = "skipped";
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const addLabelIds = new Set<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) {
|
||||
await logJobEvent(cleanupJobId, "info", "Skipping IMAP action: no IMAP client");
|
||||
actionLog.push({ type: action.type, target: action.target ?? null, status: "skipped" });
|
||||
} else {
|
||||
if ((action.type === "MOVE" || action.type === "ARCHIVE" || action.type === "LABEL") && action.target) {
|
||||
await imapClient.mailboxCreate(action.target).catch(() => undefined);
|
||||
await imapClient.messageMove(msg.uid, action.target);
|
||||
await logJobEvent(cleanupJobId, "info", `Moved message ${msg.uid} to ${action.target}`);
|
||||
}
|
||||
if (action.type === "DELETE") {
|
||||
await imapClient.messageDelete(msg.uid);
|
||||
await logJobEvent(cleanupJobId, "info", `Deleted message ${msg.uid}`);
|
||||
const actionStart = Date.now();
|
||||
actionAttempts += 1;
|
||||
try {
|
||||
if ((action.type === "MOVE" || action.type === "ARCHIVE" || action.type === "LABEL") && action.target) {
|
||||
await imapClient.mailboxCreate(action.target).catch(() => undefined);
|
||||
await imapClient.messageMove(msg.uid, action.target);
|
||||
await logJobEvent(cleanupJobId, "info", `Moved message ${msg.uid} to ${action.target}`);
|
||||
}
|
||||
if (action.type === "DELETE") {
|
||||
await imapClient.messageDelete(msg.uid);
|
||||
await logJobEvent(cleanupJobId, "info", `Deleted message ${msg.uid}`);
|
||||
}
|
||||
if (action.type === "MARK_READ") {
|
||||
await imapClient.messageFlagsAdd(msg.uid, ["\\Seen"]);
|
||||
await logJobEvent(cleanupJobId, "info", `Marked message ${msg.uid} as read`);
|
||||
}
|
||||
if (action.type === "MARK_UNREAD") {
|
||||
await imapClient.messageFlagsRemove(msg.uid, ["\\Seen"]);
|
||||
await logJobEvent(cleanupJobId, "info", `Marked message ${msg.uid} as unread`);
|
||||
}
|
||||
actionLog.push({ type: action.type, target: action.target ?? null, status: "applied" });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
await logJobEvent(cleanupJobId, "error", `IMAP action ${action.type} failed: ${message}`);
|
||||
actionLog.push({ type: action.type, target: action.target ?? null, status: "failed", error: message });
|
||||
} finally {
|
||||
routingSeconds += (Date.now() - actionStart) / 1000;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (job.unsubscribeEnabled) {
|
||||
const listUnsubscribe = msg.headers.get("list-unsubscribe") ?? null;
|
||||
const listUnsubscribePost = msg.headers.get("list-unsubscribe-post") ?? null;
|
||||
if (listUnsubscribe) {
|
||||
const attempt = await prisma.unsubscribeAttempt.create({
|
||||
data: {
|
||||
jobId: cleanupJobId,
|
||||
method: "list-unsubscribe",
|
||||
target: listUnsubscribe,
|
||||
status: "pending"
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
if (job.dryRun) {
|
||||
await prisma.unsubscribeAttempt.update({
|
||||
where: { id: attempt.id },
|
||||
data: { status: "dry-run" }
|
||||
});
|
||||
await logJobEvent(cleanupJobId, "info", `DRY RUN: unsubscribe ${listUnsubscribe}`);
|
||||
} else {
|
||||
const response = await unsubscribeFromHeader({
|
||||
account,
|
||||
listUnsubscribe,
|
||||
listUnsubscribePost,
|
||||
subject: msg.subject,
|
||||
from: msg.from
|
||||
});
|
||||
|
||||
await prisma.unsubscribeAttempt.update({
|
||||
where: { id: attempt.id },
|
||||
data: { status: response.status }
|
||||
});
|
||||
|
||||
await logJobEvent(cleanupJobId, "info", `Unsubscribe ${response.status}: ${response.message}`);
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
await prisma.unsubscribeAttempt.update({
|
||||
where: { id: attempt.id },
|
||||
data: { status: "failed" }
|
||||
});
|
||||
await logJobEvent(cleanupJobId, "error", `Unsubscribe failed: ${message}`);
|
||||
if (actionLog.length || unsubscribeStatus !== "pending" || unsubscribeTarget) {
|
||||
await prisma.cleanupJobCandidate.update({
|
||||
where: { id: candidate.id },
|
||||
data: {
|
||||
actions: actionLog.length ? actionLog : undefined,
|
||||
unsubscribeStatus,
|
||||
unsubscribeMessage,
|
||||
unsubscribeTarget,
|
||||
unsubscribeDetails
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const cleanupBefore = new Date(Date.now() - config.UNSUBSCRIBE_HISTORY_TTL_DAYS * 24 * 3600 * 1000);
|
||||
await prisma.unsubscribeHistory.deleteMany({
|
||||
where: { tenantId: job.tenantId, createdAt: { lt: cleanupBefore } }
|
||||
});
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -204,9 +491,38 @@ export const runCleanup = async (cleanupJobId: string, mailboxAccountId: string)
|
||||
let nextIndex = 0;
|
||||
let messageIds: string[] = [];
|
||||
let imapUids: number[] = [];
|
||||
let listingSeconds: number | null = null;
|
||||
let processingSeconds: number | null = null;
|
||||
let unsubscribeSeconds = 0;
|
||||
let routingSeconds = 0;
|
||||
let unsubscribeAttempts = 0;
|
||||
let actionAttempts = 0;
|
||||
const imapMailboxCache = new Set<string>();
|
||||
|
||||
if (isGmail && hasGmailOAuth) {
|
||||
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") {
|
||||
messageIds = checkpoint.messageIds ?? [];
|
||||
total = checkpoint.total ?? messageIds.length;
|
||||
@@ -215,6 +531,7 @@ export const runCleanup = async (cleanupJobId: string, mailboxAccountId: string)
|
||||
}
|
||||
|
||||
if (!messageIds.length) {
|
||||
const listingStart = Date.now();
|
||||
const ids: string[] = [];
|
||||
let pageToken: string | undefined;
|
||||
let pageCount = 0;
|
||||
@@ -250,6 +567,7 @@ export const runCleanup = async (cleanupJobId: string, mailboxAccountId: string)
|
||||
total
|
||||
};
|
||||
await saveCheckpoint(checkpoint, nextIndex, total);
|
||||
listingSeconds = Math.max(1, Math.round((Date.now() - listingStart) / 1000));
|
||||
await logJobEvent(cleanupJobId, "info", `Prepared ${total} Gmail messages`, 12);
|
||||
} else {
|
||||
await logJobEvent(cleanupJobId, "info", `Resuming Gmail cleanup at ${nextIndex}/${total}`, 12);
|
||||
@@ -268,13 +586,33 @@ export const runCleanup = async (cleanupJobId: string, mailboxAccountId: string)
|
||||
|
||||
if (total === 0) {
|
||||
await logJobEvent(cleanupJobId, "info", "No Gmail messages to process", 90);
|
||||
await prisma.cleanupJob.update({
|
||||
where: { id: cleanupJobId },
|
||||
data: {
|
||||
listingSeconds,
|
||||
processingSeconds: processingSeconds ?? null,
|
||||
unsubscribeSeconds: unsubscribeSeconds ? Math.max(1, Math.round(unsubscribeSeconds)) : null,
|
||||
routingSeconds: routingSeconds ? Math.max(1, Math.round(routingSeconds)) : null,
|
||||
unsubscribeAttempts: unsubscribeAttempts || null,
|
||||
actionAttempts: actionAttempts || null
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await logJobEvent(cleanupJobId, "info", `Processing ${total} Gmail messages`, 35);
|
||||
const processingStart = Date.now();
|
||||
let newsletterCount = 0;
|
||||
const headersWanted = ["Subject", "From", "List-Id", "List-Unsubscribe", "List-Unsubscribe-Post", "Message-Id"];
|
||||
const normalizeHeaderName = (value: string) =>
|
||||
value
|
||||
.split("-")
|
||||
.map((part) => (part ? part[0].toUpperCase() + part.slice(1) : part))
|
||||
.join("-");
|
||||
const headerSet = new Set<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++) {
|
||||
const statusCheck = await prisma.cleanupJob.findUnique({ where: { id: cleanupJobId } });
|
||||
@@ -300,11 +638,13 @@ export const runCleanup = async (cleanupJobId: string, mailboxAccountId: string)
|
||||
uid: 0,
|
||||
subject: headers.get("subject"),
|
||||
from: headers.get("from"),
|
||||
receivedAt: meta.data.internalDate ? new Date(Number(meta.data.internalDate)) : undefined,
|
||||
headers,
|
||||
gmailMessageId: id
|
||||
gmailMessageId: id,
|
||||
mailbox: "INBOX"
|
||||
};
|
||||
|
||||
const isNewsletter = await processMessage(msg);
|
||||
const isNewsletter = await processMessage(msg, { gmail, resolveLabelId });
|
||||
if (isNewsletter) newsletterCount += 1;
|
||||
|
||||
const processed = index + 1;
|
||||
@@ -321,6 +661,18 @@ export const runCleanup = async (cleanupJobId: string, mailboxAccountId: string)
|
||||
}
|
||||
|
||||
await logJobEvent(cleanupJobId, "info", `Detected ${newsletterCount} newsletter candidates`, 92);
|
||||
processingSeconds = Math.max(1, Math.round((Date.now() - processingStart) / 1000));
|
||||
await prisma.cleanupJob.update({
|
||||
where: { id: cleanupJobId },
|
||||
data: {
|
||||
listingSeconds,
|
||||
processingSeconds,
|
||||
unsubscribeSeconds: unsubscribeSeconds ? Math.max(1, Math.round(unsubscribeSeconds)) : null,
|
||||
routingSeconds: routingSeconds ? Math.max(1, Math.round(routingSeconds)) : null,
|
||||
unsubscribeAttempts: unsubscribeAttempts || null,
|
||||
actionAttempts: actionAttempts || null
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -342,6 +694,7 @@ export const runCleanup = async (cleanupJobId: string, mailboxAccountId: string)
|
||||
await imapClient.mailboxOpen(targetMailbox, { readOnly: job.dryRun });
|
||||
|
||||
if (!imapUids.length) {
|
||||
const listingStart = Date.now();
|
||||
await logJobEvent(cleanupJobId, "info", `Scanning ${targetMailbox}`, 15);
|
||||
const search = await imapClient.search({ all: true });
|
||||
const limited = scanLimit && scanLimit > 0 ? search.slice(-scanLimit) : search;
|
||||
@@ -357,6 +710,7 @@ export const runCleanup = async (cleanupJobId: string, mailboxAccountId: string)
|
||||
total
|
||||
};
|
||||
await saveCheckpoint(checkpoint, nextIndex, total);
|
||||
listingSeconds = Math.max(1, Math.round((Date.now() - listingStart) / 1000));
|
||||
await logJobEvent(cleanupJobId, "info", `Prepared ${total} IMAP messages`, 18);
|
||||
} else {
|
||||
await logJobEvent(cleanupJobId, "info", `Resuming IMAP cleanup at ${nextIndex}/${total}`, 18);
|
||||
@@ -376,6 +730,17 @@ export const runCleanup = async (cleanupJobId: string, mailboxAccountId: string)
|
||||
|
||||
if (total === 0) {
|
||||
await logJobEvent(cleanupJobId, "info", "No IMAP messages to process", 90);
|
||||
await prisma.cleanupJob.update({
|
||||
where: { id: cleanupJobId },
|
||||
data: {
|
||||
listingSeconds,
|
||||
processingSeconds: processingSeconds ?? null,
|
||||
unsubscribeSeconds: unsubscribeSeconds ? Math.max(1, Math.round(unsubscribeSeconds)) : null,
|
||||
routingSeconds: routingSeconds ? Math.max(1, Math.round(routingSeconds)) : null,
|
||||
unsubscribeAttempts: unsubscribeAttempts || null,
|
||||
actionAttempts: actionAttempts || null
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -401,7 +766,7 @@ export const runCleanup = async (cleanupJobId: string, mailboxAccountId: string)
|
||||
processed += 1;
|
||||
continue;
|
||||
}
|
||||
const isNewsletter = await processMessage(msg);
|
||||
const isNewsletter = await processMessage({ ...msg, mailbox: targetMailbox });
|
||||
if (isNewsletter) newsletterCount += 1;
|
||||
processed += 1;
|
||||
}
|
||||
@@ -418,6 +783,18 @@ export const runCleanup = async (cleanupJobId: string, mailboxAccountId: string)
|
||||
}
|
||||
|
||||
await logJobEvent(cleanupJobId, "info", `Detected ${newsletterCount} newsletter candidates`, 92);
|
||||
processingSeconds = Math.max(1, Math.round((Date.now() - processingStart) / 1000));
|
||||
await prisma.cleanupJob.update({
|
||||
where: { id: cleanupJobId },
|
||||
data: {
|
||||
listingSeconds,
|
||||
processingSeconds,
|
||||
unsubscribeSeconds: unsubscribeSeconds ? Math.max(1, Math.round(unsubscribeSeconds)) : null,
|
||||
routingSeconds: routingSeconds ? Math.max(1, Math.round(routingSeconds)) : null,
|
||||
unsubscribeAttempts: unsubscribeAttempts || null,
|
||||
actionAttempts: actionAttempts || null
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
await imapClient?.logout().catch(() => undefined);
|
||||
}
|
||||
|
||||
@@ -105,7 +105,7 @@ export const ensureGmailLabel = async (gmail: ReturnType<typeof google.gmail>, l
|
||||
export const applyGmailAction = async (params: {
|
||||
account: MailboxAccount;
|
||||
gmailMessageId: string;
|
||||
action: "LABEL" | "MOVE" | "ARCHIVE" | "DELETE";
|
||||
action: "LABEL" | "MOVE" | "ARCHIVE" | "DELETE" | "MARK_READ" | "MARK_UNREAD";
|
||||
target?: string | null;
|
||||
}) => {
|
||||
const { gmail } = await gmailClientForAccount(params.account);
|
||||
@@ -124,13 +124,34 @@ export const applyGmailAction = async (params: {
|
||||
return;
|
||||
}
|
||||
|
||||
if (params.action === "MARK_READ") {
|
||||
await gmail.users.messages.modify({
|
||||
userId: "me",
|
||||
id: params.gmailMessageId,
|
||||
requestBody: { removeLabelIds: ["UNREAD"] }
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (params.action === "MARK_UNREAD") {
|
||||
await gmail.users.messages.modify({
|
||||
userId: "me",
|
||||
id: params.gmailMessageId,
|
||||
requestBody: { addLabelIds: ["UNREAD"] }
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (params.action === "MOVE" || params.action === "LABEL") {
|
||||
const labelName = params.target ?? "Newsletter";
|
||||
const labelId = await ensureGmailLabel(gmail, labelName);
|
||||
await gmail.users.messages.modify({
|
||||
userId: "me",
|
||||
id: params.gmailMessageId,
|
||||
requestBody: { addLabelIds: [labelId] }
|
||||
requestBody: {
|
||||
addLabelIds: [labelId],
|
||||
...(params.action === "MOVE" ? { removeLabelIds: ["INBOX"] } : {})
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -35,6 +35,7 @@ export const fetchHeaders = async (
|
||||
uid: number;
|
||||
subject?: string;
|
||||
from?: string;
|
||||
receivedAt?: Date;
|
||||
headers: Map<string, string>;
|
||||
gmailMessageId?: string;
|
||||
}[];
|
||||
@@ -55,6 +56,7 @@ export const fetchHeaders = async (
|
||||
uid: msg.uid,
|
||||
subject: parsed.subject,
|
||||
from: parsed.from?.text,
|
||||
receivedAt: parsed.date ?? undefined,
|
||||
headers,
|
||||
gmailMessageId: (msg as { gmailMessageId?: string }).gmailMessageId
|
||||
});
|
||||
@@ -72,6 +74,7 @@ export const fetchHeadersByUids = async (client: ImapFlow, uids: number[]) => {
|
||||
uid: number;
|
||||
subject?: string;
|
||||
from?: string;
|
||||
receivedAt?: Date;
|
||||
headers: Map<string, string>;
|
||||
gmailMessageId?: string;
|
||||
}[];
|
||||
@@ -88,6 +91,7 @@ export const fetchHeadersByUids = async (client: ImapFlow, uids: number[]) => {
|
||||
uid: msg.uid,
|
||||
subject: parsed.subject,
|
||||
from: parsed.from?.text,
|
||||
receivedAt: parsed.date ?? undefined,
|
||||
headers,
|
||||
gmailMessageId: (msg as { gmailMessageId?: string }).gmailMessageId
|
||||
});
|
||||
|
||||
@@ -1,5 +1,33 @@
|
||||
const headerIncludes = (headers: Map<string, string>, key: string) =>
|
||||
headers.has(key.toLowerCase());
|
||||
export type NewsletterConfig = {
|
||||
threshold: number;
|
||||
headerKeys: string[];
|
||||
subjectTokens: string[];
|
||||
fromTokens: string[];
|
||||
weightHeader: number;
|
||||
weightPrecedence: number;
|
||||
weightSubject: number;
|
||||
weightFrom: number;
|
||||
};
|
||||
|
||||
const DEFAULT_CONFIG: NewsletterConfig = {
|
||||
threshold: 2,
|
||||
headerKeys: [
|
||||
"list-unsubscribe",
|
||||
"list-id",
|
||||
"list-help",
|
||||
"list-archive",
|
||||
"list-post",
|
||||
"list-owner",
|
||||
"list-subscribe",
|
||||
"list-unsubscribe-post"
|
||||
],
|
||||
subjectTokens: ["newsletter", "unsubscribe", "update", "news", "digest"],
|
||||
fromTokens: ["newsletter", "no-reply", "noreply", "news", "updates"],
|
||||
weightHeader: 1,
|
||||
weightPrecedence: 1,
|
||||
weightSubject: 1,
|
||||
weightFrom: 1
|
||||
};
|
||||
|
||||
const headerValue = (headers: Map<string, string>, key: string) =>
|
||||
headers.get(key.toLowerCase()) ?? "";
|
||||
@@ -7,39 +35,64 @@ const headerValue = (headers: Map<string, string>, key: string) =>
|
||||
const containsAny = (value: string, tokens: string[]) =>
|
||||
tokens.some((token) => value.includes(token));
|
||||
|
||||
const normalizeList = (items: string[]) =>
|
||||
items.map((item) => item.trim().toLowerCase()).filter(Boolean);
|
||||
|
||||
export const detectNewsletter = (params: {
|
||||
headers: Map<string, string>;
|
||||
subject?: string | null;
|
||||
from?: string | null;
|
||||
config?: Partial<NewsletterConfig>;
|
||||
}) => {
|
||||
const subject = (params.subject ?? "").toLowerCase();
|
||||
const from = (params.from ?? "").toLowerCase();
|
||||
const headers = params.headers;
|
||||
const config: NewsletterConfig = {
|
||||
threshold: params.config?.threshold ?? DEFAULT_CONFIG.threshold,
|
||||
headerKeys: normalizeList(params.config?.headerKeys ?? DEFAULT_CONFIG.headerKeys),
|
||||
subjectTokens: normalizeList(params.config?.subjectTokens ?? DEFAULT_CONFIG.subjectTokens),
|
||||
fromTokens: normalizeList(params.config?.fromTokens ?? DEFAULT_CONFIG.fromTokens),
|
||||
weightHeader: params.config?.weightHeader ?? DEFAULT_CONFIG.weightHeader,
|
||||
weightPrecedence: params.config?.weightPrecedence ?? DEFAULT_CONFIG.weightPrecedence,
|
||||
weightSubject: params.config?.weightSubject ?? DEFAULT_CONFIG.weightSubject,
|
||||
weightFrom: params.config?.weightFrom ?? DEFAULT_CONFIG.weightFrom
|
||||
};
|
||||
|
||||
const hasListUnsubscribe = headerIncludes(headers, "list-unsubscribe");
|
||||
const hasListId = headerIncludes(headers, "list-id");
|
||||
|
||||
const matchedHeaderKeys = config.headerKeys.filter((key) => headers.has(key));
|
||||
const precedence = headerValue(headers, "precedence").toLowerCase();
|
||||
const bulkHeader = headerValue(headers, "x-precedence").toLowerCase();
|
||||
const precedenceHint = containsAny(precedence, ["bulk", "list"]) || containsAny(bulkHeader, ["bulk", "list"]);
|
||||
|
||||
const headerHints = containsAny(precedence, ["bulk", "list"]) ||
|
||||
containsAny(bulkHeader, ["bulk", "list"]) ||
|
||||
headerIncludes(headers, "list-unsubscribe-post");
|
||||
const subjectMatches = config.subjectTokens.filter((token) => subject.includes(token));
|
||||
const fromMatches = config.fromTokens.filter((token) => from.includes(token));
|
||||
|
||||
const subjectHints = containsAny(subject, ["newsletter", "unsubscribe", "update", "news", "digest"]);
|
||||
const fromHints = containsAny(from, ["newsletter", "no-reply", "noreply", "news", "updates"]);
|
||||
|
||||
const score = [hasListUnsubscribe, hasListId, headerHints, subjectHints, fromHints].filter(Boolean).length;
|
||||
const headerScore = matchedHeaderKeys.length * config.weightHeader;
|
||||
const precedenceScore = precedenceHint ? config.weightPrecedence : 0;
|
||||
const subjectScore = subjectMatches.length ? config.weightSubject : 0;
|
||||
const fromScore = fromMatches.length ? config.weightFrom : 0;
|
||||
const score = headerScore + precedenceScore + subjectScore + fromScore;
|
||||
|
||||
return {
|
||||
isNewsletter: score >= 2,
|
||||
isNewsletter: score >= config.threshold,
|
||||
score,
|
||||
signals: {
|
||||
hasListUnsubscribe,
|
||||
hasListId,
|
||||
headerHints,
|
||||
subjectHints,
|
||||
fromHints
|
||||
headerKeys: matchedHeaderKeys,
|
||||
precedenceHint,
|
||||
subjectTokens: subjectMatches,
|
||||
fromTokens: fromMatches,
|
||||
scoreBreakdown: {
|
||||
headerMatches: matchedHeaderKeys.length,
|
||||
headerWeight: config.weightHeader,
|
||||
headerScore,
|
||||
precedenceWeight: config.weightPrecedence,
|
||||
precedenceScore,
|
||||
subjectMatches: subjectMatches.length,
|
||||
subjectWeight: config.weightSubject,
|
||||
subjectScore,
|
||||
fromMatches: fromMatches.length,
|
||||
fromWeight: config.weightFrom,
|
||||
fromScore
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -203,6 +203,9 @@ export async function mailRoutes(app: FastifyInstance) {
|
||||
await tx.unsubscribeAttempt.deleteMany({
|
||||
where: { job: { mailboxAccountId: account.id } }
|
||||
});
|
||||
await tx.cleanupJobCandidate.deleteMany({
|
||||
where: { mailboxAccountId: account.id }
|
||||
});
|
||||
await tx.cleanupJob.deleteMany({
|
||||
where: { mailboxAccountId: account.id }
|
||||
});
|
||||
|
||||
@@ -6,25 +6,66 @@ const getHeader = (headers: Map<string, string>, name: string) =>
|
||||
const contains = (value: string, needle: string) =>
|
||||
value.toLowerCase().includes(needle.toLowerCase());
|
||||
|
||||
const isWildcard = (value: string) => value.trim() === "*";
|
||||
|
||||
const matchCondition = (condition: RuleCondition, ctx: {
|
||||
subject: string;
|
||||
from: string;
|
||||
headers: Map<string, string>;
|
||||
unsubscribeStatus?: string | null;
|
||||
newsletterScore?: number | null;
|
||||
}) => {
|
||||
const value = condition.value;
|
||||
switch (condition.type) {
|
||||
case "SUBJECT":
|
||||
return contains(ctx.subject, value);
|
||||
return isWildcard(value) ? ctx.subject.trim().length > 0 : contains(ctx.subject, value);
|
||||
case "FROM":
|
||||
return contains(ctx.from, value);
|
||||
return isWildcard(value) ? ctx.from.trim().length > 0 : contains(ctx.from, value);
|
||||
case "LIST_ID":
|
||||
return contains(getHeader(ctx.headers, "list-id"), value);
|
||||
return isWildcard(value)
|
||||
? getHeader(ctx.headers, "list-id").trim().length > 0
|
||||
: contains(getHeader(ctx.headers, "list-id"), value);
|
||||
case "LIST_UNSUBSCRIBE":
|
||||
return contains(getHeader(ctx.headers, "list-unsubscribe"), value);
|
||||
return isWildcard(value)
|
||||
? getHeader(ctx.headers, "list-unsubscribe").trim().length > 0
|
||||
: contains(getHeader(ctx.headers, "list-unsubscribe"), value);
|
||||
case "HEADER": {
|
||||
const [headerName, headerValue] = value.split(":");
|
||||
if (!headerName || !headerValue) return false;
|
||||
return contains(getHeader(ctx.headers, headerName.trim()), headerValue.trim());
|
||||
const parts = value.split(":");
|
||||
const headerName = parts[0]?.trim();
|
||||
const headerValue = parts.slice(1).join(":").trim();
|
||||
if (!headerName) return false;
|
||||
const headerContent = getHeader(ctx.headers, headerName);
|
||||
if (!headerValue || isWildcard(headerValue)) {
|
||||
return headerContent.trim().length > 0;
|
||||
}
|
||||
return contains(headerContent, headerValue);
|
||||
}
|
||||
case "HEADER_MISSING": {
|
||||
const parts = value.split(":");
|
||||
const headerName = parts[0]?.trim();
|
||||
if (!headerName) return false;
|
||||
const headerContent = getHeader(ctx.headers, headerName);
|
||||
return headerContent.trim().length === 0;
|
||||
}
|
||||
case "UNSUBSCRIBE_STATUS": {
|
||||
if (!ctx.unsubscribeStatus) return false;
|
||||
return isWildcard(value)
|
||||
? ctx.unsubscribeStatus.trim().length > 0
|
||||
: ctx.unsubscribeStatus.toLowerCase() === value.toLowerCase();
|
||||
}
|
||||
case "SCORE": {
|
||||
if (ctx.newsletterScore === null || ctx.newsletterScore === undefined) return false;
|
||||
const trimmed = value.trim();
|
||||
const match = trimmed.match(/^(>=|<=|>|<|=)?\s*(\d+)$/);
|
||||
if (!match) return false;
|
||||
const op = match[1] ?? ">=";
|
||||
const threshold = Number.parseInt(match[2], 10);
|
||||
if (!Number.isFinite(threshold)) return false;
|
||||
if (op === ">") return ctx.newsletterScore > threshold;
|
||||
if (op === "<") return ctx.newsletterScore < threshold;
|
||||
if (op === "<=") return ctx.newsletterScore <= threshold;
|
||||
if (op === "=") return ctx.newsletterScore === threshold;
|
||||
return ctx.newsletterScore >= threshold;
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
@@ -35,14 +76,21 @@ export const matchRules = (rules: (Rule & { conditions: RuleCondition[]; actions
|
||||
subject: string;
|
||||
from: string;
|
||||
headers: Map<string, string>;
|
||||
unsubscribeStatus?: string | null;
|
||||
newsletterScore?: number | null;
|
||||
}) => {
|
||||
const matched: RuleAction[] = [];
|
||||
|
||||
for (const rule of rules) {
|
||||
if (!rule.enabled) continue;
|
||||
const allMatch = rule.conditions.every((condition) => matchCondition(condition, ctx));
|
||||
if (allMatch) {
|
||||
const anyMatch = rule.conditions.some((condition) => matchCondition(condition, ctx));
|
||||
const shouldApply = rule.matchMode === "ANY" ? anyMatch : allMatch;
|
||||
if (shouldApply) {
|
||||
matched.push(...rule.actions);
|
||||
if (rule.stopOnMatch) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ import { MailboxAccount } from "@prisma/client";
|
||||
import { isPrivateHost } from "../security/ssrf.js";
|
||||
import { decryptSecret } from "../security/crypto.js";
|
||||
import { config } from "../config.js";
|
||||
import { gmailClientForAccount } from "./gmail.js";
|
||||
import { getSetting } from "../admin/settings.js";
|
||||
|
||||
const parseListUnsubscribe = (value: string) => {
|
||||
const tokens = value
|
||||
@@ -24,67 +26,123 @@ export const unsubscribeFromHeader = async (params: {
|
||||
from?: string | null;
|
||||
}) => {
|
||||
if (!params.listUnsubscribe) {
|
||||
return { status: "skipped", message: "No List-Unsubscribe header" };
|
||||
return { status: "skipped", message: "No List-Unsubscribe header", details: { method: "NONE" } };
|
||||
}
|
||||
|
||||
const { httpLinks, mailtoLinks } = parseListUnsubscribe(params.listUnsubscribe);
|
||||
const postHint = (params.listUnsubscribePost ?? "").toLowerCase();
|
||||
const preference = ((await getSetting("unsubscribe.method_preference")) ?? config.UNSUBSCRIBE_METHOD_PREFERENCE ?? "auto").toLowerCase();
|
||||
|
||||
if (httpLinks.length > 0) {
|
||||
const target = httpLinks[0];
|
||||
const tryHttp = async (target: string) => {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(target);
|
||||
} catch {
|
||||
return { status: "failed", message: "Invalid unsubscribe URL" };
|
||||
return { status: "failed", message: "Invalid unsubscribe URL", details: { method: "HTTP", url: target } };
|
||||
}
|
||||
|
||||
if (!["http:", "https:"].includes(parsed.protocol)) {
|
||||
return { status: "failed", message: "Unsupported URL scheme" };
|
||||
return { status: "failed", message: "Unsupported URL scheme", details: { method: "HTTP", url: target } };
|
||||
}
|
||||
|
||||
if (config.BLOCK_PRIVATE_NETWORKS && await isPrivateHost(parsed.hostname)) {
|
||||
return { status: "failed", message: "Blocked private network URL" };
|
||||
return { status: "failed", message: "Blocked private network URL", details: { method: "HTTP", url: target } };
|
||||
}
|
||||
const usePost = postHint.includes("one-click");
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 8000);
|
||||
const response = await fetch(target, {
|
||||
method: usePost ? "POST" : "GET",
|
||||
headers: usePost ? { "Content-Type": "application/x-www-form-urlencoded" } : undefined,
|
||||
body: usePost ? "List-Unsubscribe=One-Click" : undefined,
|
||||
redirect: "manual",
|
||||
signal: controller.signal
|
||||
});
|
||||
clearTimeout(timeout);
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 8000);
|
||||
const response = await fetch(target, {
|
||||
method: usePost ? "POST" : "GET",
|
||||
headers: usePost ? { "Content-Type": "application/x-www-form-urlencoded" } : undefined,
|
||||
body: usePost ? "List-Unsubscribe=One-Click" : undefined,
|
||||
redirect: "manual",
|
||||
signal: controller.signal
|
||||
});
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (response.status >= 300 && response.status < 400) {
|
||||
const location = response.headers.get("location");
|
||||
if (!location) {
|
||||
return { status: "failed", message: `HTTP ${response.status}` };
|
||||
}
|
||||
try {
|
||||
const redirected = new URL(location, parsed);
|
||||
if (config.BLOCK_PRIVATE_NETWORKS && await isPrivateHost(redirected.hostname)) {
|
||||
return { status: "failed", message: "Blocked private redirect" };
|
||||
if (response.status >= 300 && response.status < 400) {
|
||||
const location = response.headers.get("location");
|
||||
if (!location) {
|
||||
return { status: "failed", message: `HTTP ${response.status}`, details: { method: usePost ? "POST" : "GET", url: target, status: response.status } };
|
||||
}
|
||||
try {
|
||||
const redirected = new URL(location, parsed);
|
||||
if (config.BLOCK_PRIVATE_NETWORKS && await isPrivateHost(redirected.hostname)) {
|
||||
return { status: "failed", message: "Blocked private redirect", details: { method: usePost ? "POST" : "GET", url: target, redirect: location } };
|
||||
}
|
||||
} catch {
|
||||
return { status: "failed", message: "Invalid redirect URL", details: { method: usePost ? "POST" : "GET", url: target, redirect: location } };
|
||||
}
|
||||
} catch {
|
||||
return { status: "failed", message: "Invalid redirect URL" };
|
||||
}
|
||||
|
||||
return {
|
||||
status: response.ok ? "ok" : "failed",
|
||||
message: `HTTP ${response.status}`,
|
||||
details: { method: usePost ? "POST" : "GET", url: target, status: response.status }
|
||||
};
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return { status: "failed", message: "HTTP request failed", details: { method: "HTTP", url: target, error: message } };
|
||||
}
|
||||
};
|
||||
|
||||
const tryMailto = async (target: string) => {
|
||||
const mailtoUrl = (() => {
|
||||
try {
|
||||
return new URL(target);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
const to = mailtoUrl ? decodeURIComponent(mailtoUrl.pathname) : target.replace("mailto:", "");
|
||||
const mailSubject = mailtoUrl?.searchParams.get("subject") ?? params.subject ?? "Unsubscribe";
|
||||
const mailBody = mailtoUrl?.searchParams.get("body") ?? "Please unsubscribe me from this mailing list.";
|
||||
|
||||
if (params.account.provider === "GMAIL" && (params.account.oauthAccessToken || params.account.oauthRefreshToken)) {
|
||||
const { gmail } = await gmailClientForAccount(params.account);
|
||||
const rawMessage = [
|
||||
`From: ${params.account.email}`,
|
||||
`To: ${to}`,
|
||||
`Subject: ${mailSubject}`,
|
||||
...(params.from ? [`Reply-To: ${params.from}`] : []),
|
||||
...(params.listUnsubscribe ? [`List-Unsubscribe: ${params.listUnsubscribe}`] : []),
|
||||
"MIME-Version: 1.0",
|
||||
"Content-Type: text/plain; charset=UTF-8",
|
||||
"",
|
||||
mailBody
|
||||
].join("\r\n");
|
||||
const raw = Buffer.from(rawMessage)
|
||||
.toString("base64")
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/g, "");
|
||||
await gmail.users.messages.send({
|
||||
userId: "me",
|
||||
requestBody: { raw }
|
||||
});
|
||||
return {
|
||||
status: "ok",
|
||||
message: "Unsubscribe email sent via Gmail API",
|
||||
details: {
|
||||
method: "MAILTO",
|
||||
via: "gmail",
|
||||
to,
|
||||
subject: mailSubject,
|
||||
body: mailBody,
|
||||
replyTo: params.from ?? null,
|
||||
listUnsubscribe: params.listUnsubscribe ?? null
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return { status: response.ok ? "ok" : "failed", message: `HTTP ${response.status}` };
|
||||
}
|
||||
|
||||
if (mailtoLinks.length > 0) {
|
||||
const target = mailtoLinks[0];
|
||||
const smtpHost = params.account.smtpHost;
|
||||
const smtpPort = params.account.smtpPort ?? 587;
|
||||
const smtpTLS = params.account.smtpTLS ?? true;
|
||||
|
||||
if (!smtpHost || !params.account.appPassword) {
|
||||
return { status: "failed", message: "SMTP credentials missing" };
|
||||
return { status: "failed", message: "SMTP credentials missing", details: { method: "MAILTO", via: "smtp", to } };
|
||||
}
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
@@ -99,13 +157,57 @@ export const unsubscribeFromHeader = async (params: {
|
||||
|
||||
await transporter.sendMail({
|
||||
from: params.account.email,
|
||||
to: target.replace("mailto:", ""),
|
||||
subject: params.subject ?? "Unsubscribe",
|
||||
text: "Please unsubscribe me from this mailing list."
|
||||
to,
|
||||
subject: mailSubject,
|
||||
text: mailBody,
|
||||
replyTo: params.from ?? undefined,
|
||||
headers: params.listUnsubscribe ? { "List-Unsubscribe": params.listUnsubscribe } : undefined
|
||||
});
|
||||
|
||||
return { status: "ok", message: "Unsubscribe email sent" };
|
||||
return {
|
||||
status: "ok",
|
||||
message: "Unsubscribe email sent",
|
||||
details: {
|
||||
method: "MAILTO",
|
||||
via: "smtp",
|
||||
to,
|
||||
subject: mailSubject,
|
||||
body: mailBody,
|
||||
replyTo: params.from ?? null,
|
||||
listUnsubscribe: params.listUnsubscribe ?? null
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const hasHttp = httpLinks.length > 0;
|
||||
const hasMailto = mailtoLinks.length > 0;
|
||||
|
||||
const fallbackToMailto = async (httpResult: { status: string; message: string; details?: Record<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" } };
|
||||
};
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import { prisma } from "../db.js";
|
||||
import { config } from "../config.js";
|
||||
import { createImapClient } from "../mail/imap.js";
|
||||
import { applyGmailAction, gmailClientForAccount } from "../mail/gmail.js";
|
||||
import { createImapClient } from "../mail/imap.js";
|
||||
import { simpleParser } from "mailparser";
|
||||
|
||||
export async function queueRoutes(app: FastifyInstance) {
|
||||
app.addHook("preHandler", app.authenticate);
|
||||
@@ -35,7 +39,35 @@ export async function queueRoutes(app: FastifyInstance) {
|
||||
orderBy: { createdAt: "desc" }
|
||||
});
|
||||
|
||||
return { jobs };
|
||||
const metric = await prisma.tenantMetric.findUnique({
|
||||
where: { tenantId: request.user.tenantId }
|
||||
});
|
||||
const providerMetrics = await prisma.tenantProviderMetric.findMany({
|
||||
where: { tenantId: request.user.tenantId }
|
||||
});
|
||||
|
||||
return {
|
||||
jobs,
|
||||
meta: {
|
||||
avgProcessingRate: metric?.avgProcessingRate ?? null,
|
||||
sampleCount: metric?.sampleCount ?? 0,
|
||||
providerMetrics: providerMetrics.map((item) => ({
|
||||
provider: item.provider,
|
||||
avgListingRate: item.avgListingRate,
|
||||
avgProcessingRate: item.avgProcessingRate,
|
||||
avgUnsubscribeRate: item.avgUnsubscribeRate,
|
||||
avgRoutingRate: item.avgRoutingRate,
|
||||
avgListingSecondsPerMessage: item.avgListingSecondsPerMessage,
|
||||
avgProcessingSecondsPerMessage: item.avgProcessingSecondsPerMessage,
|
||||
avgUnsubscribeSecondsPerMessage: item.avgUnsubscribeSecondsPerMessage,
|
||||
avgRoutingSecondsPerMessage: item.avgRoutingSecondsPerMessage,
|
||||
listingSampleCount: item.listingSampleCount,
|
||||
processingSampleCount: item.processingSampleCount,
|
||||
unsubscribeSampleCount: item.unsubscribeSampleCount,
|
||||
routingSampleCount: item.routingSampleCount
|
||||
}))
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
app.get("/:id", async (request, reply) => {
|
||||
@@ -71,6 +103,704 @@ export async function queueRoutes(app: FastifyInstance) {
|
||||
return { events };
|
||||
});
|
||||
|
||||
app.get("/:id/candidates", async (request, reply) => {
|
||||
const params = request.params as { id: string };
|
||||
const query = request.query as {
|
||||
groupBy?: string;
|
||||
groupValue?: string;
|
||||
limit?: string;
|
||||
cursor?: string;
|
||||
q?: string;
|
||||
status?: string;
|
||||
reviewed?: string;
|
||||
};
|
||||
|
||||
const job = await prisma.cleanupJob.findFirst({
|
||||
where: { id: params.id, tenantId: request.user.tenantId }
|
||||
});
|
||||
if (!job) {
|
||||
return reply.code(404).send({ message: "Job not found" });
|
||||
}
|
||||
|
||||
const groupBy = query.groupBy ?? "none";
|
||||
const limitRaw = query.limit ? Number.parseInt(query.limit, 10) : 200;
|
||||
const limit = Number.isFinite(limitRaw) ? Math.min(Math.max(limitRaw, 1), 500) : 200;
|
||||
const cursor = query.cursor ?? null;
|
||||
|
||||
type GroupField = "fromDomain" | "from" | "listId";
|
||||
const field: GroupField | null =
|
||||
groupBy === "domain"
|
||||
? "fromDomain"
|
||||
: groupBy === "from"
|
||||
? "from"
|
||||
: groupBy === "listId"
|
||||
? "listId"
|
||||
: null;
|
||||
|
||||
const baseWhere: Record<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) => {
|
||||
const params = request.params as { id: string };
|
||||
const job = await prisma.cleanupJob.findFirst({
|
||||
|
||||
@@ -5,12 +5,14 @@ import { prisma } from "../db.js";
|
||||
const ruleSchema = z.object({
|
||||
name: z.string().min(2),
|
||||
enabled: z.boolean().optional(),
|
||||
matchMode: z.enum(["ALL", "ANY"]).optional(),
|
||||
stopOnMatch: z.boolean().optional(),
|
||||
conditions: z.array(z.object({
|
||||
type: z.enum(["HEADER", "SUBJECT", "FROM", "LIST_UNSUBSCRIBE", "LIST_ID"]),
|
||||
type: z.enum(["HEADER", "HEADER_MISSING", "SUBJECT", "FROM", "LIST_UNSUBSCRIBE", "LIST_ID", "UNSUBSCRIBE_STATUS", "SCORE"]),
|
||||
value: z.string().min(1)
|
||||
})),
|
||||
actions: z.array(z.object({
|
||||
type: z.enum(["MOVE", "DELETE", "ARCHIVE", "LABEL"]),
|
||||
type: z.enum(["MOVE", "DELETE", "ARCHIVE", "LABEL", "MARK_READ", "MARK_UNREAD"]),
|
||||
target: z.string().optional()
|
||||
}))
|
||||
});
|
||||
@@ -21,18 +23,28 @@ export async function rulesRoutes(app: FastifyInstance) {
|
||||
app.get("/", async (request) => {
|
||||
const rules = await prisma.rule.findMany({
|
||||
where: { tenantId: request.user.tenantId },
|
||||
include: { conditions: true, actions: true }
|
||||
include: { conditions: true, actions: true },
|
||||
orderBy: [{ position: "asc" }, { createdAt: "asc" }]
|
||||
});
|
||||
return { rules };
|
||||
});
|
||||
|
||||
app.post("/", async (request, reply) => {
|
||||
const input = ruleSchema.parse(request.body);
|
||||
const lastRule = await prisma.rule.findFirst({
|
||||
where: { tenantId: request.user.tenantId },
|
||||
orderBy: { position: "desc" },
|
||||
select: { position: true }
|
||||
});
|
||||
const nextPosition = (lastRule?.position ?? -1) + 1;
|
||||
const rule = await prisma.rule.create({
|
||||
data: {
|
||||
tenantId: request.user.tenantId,
|
||||
name: input.name,
|
||||
enabled: input.enabled ?? true,
|
||||
matchMode: input.matchMode ?? "ALL",
|
||||
position: nextPosition,
|
||||
stopOnMatch: input.stopOnMatch ?? false,
|
||||
conditions: {
|
||||
create: input.conditions
|
||||
},
|
||||
@@ -45,6 +57,37 @@ export async function rulesRoutes(app: FastifyInstance) {
|
||||
return reply.code(201).send({ rule });
|
||||
});
|
||||
|
||||
app.put("/reorder", async (request) => {
|
||||
const input = z.object({
|
||||
orderedIds: z.array(z.string()).min(1)
|
||||
}).parse(request.body);
|
||||
|
||||
const uniqueIds = Array.from(new Set(input.orderedIds));
|
||||
if (uniqueIds.length !== input.orderedIds.length) {
|
||||
return { success: false, message: "Duplicate ids provided" };
|
||||
}
|
||||
|
||||
const existing = await prisma.rule.findMany({
|
||||
where: { tenantId: request.user.tenantId, id: { in: input.orderedIds } },
|
||||
select: { id: true }
|
||||
});
|
||||
|
||||
if (existing.length !== input.orderedIds.length) {
|
||||
return { success: false, message: "One or more rules not found" };
|
||||
}
|
||||
|
||||
await prisma.$transaction(
|
||||
input.orderedIds.map((id, index) =>
|
||||
prisma.rule.update({
|
||||
where: { id },
|
||||
data: { position: index }
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
app.put("/:id", async (request, reply) => {
|
||||
const params = request.params as { id: string };
|
||||
const input = ruleSchema.parse(request.body);
|
||||
@@ -64,6 +107,8 @@ export async function rulesRoutes(app: FastifyInstance) {
|
||||
data: {
|
||||
name: input.name,
|
||||
enabled: input.enabled ?? true,
|
||||
matchMode: input.matchMode ?? "ALL",
|
||||
stopOnMatch: input.stopOnMatch ?? false,
|
||||
conditions: { create: input.conditions },
|
||||
actions: { create: input.actions }
|
||||
},
|
||||
|
||||
@@ -8,6 +8,10 @@ import { runExportJob, startExportCleanupLoop } from "./admin/exportWorker.js";
|
||||
|
||||
const connection = new IORedis(config.REDIS_URL, { maxRetriesPerRequest: null });
|
||||
|
||||
const clampAlpha = (value: number) => Math.min(0.95, Math.max(0.05, value));
|
||||
const ema = (prev: number | null | undefined, next: number, alpha: number) =>
|
||||
prev === null || prev === undefined ? next : prev + alpha * (next - prev);
|
||||
|
||||
const worker = new Worker(
|
||||
"cleanup",
|
||||
async (job) => {
|
||||
@@ -35,6 +39,138 @@ const worker = new Worker(
|
||||
data: { status: "SUCCEEDED", finishedAt: new Date() }
|
||||
});
|
||||
await logJobEvent(cleanupJobId, "info", "Cleanup finished", 100);
|
||||
if (latest?.tenantId && latest.startedAt && latest.finishedAt && latest.processedMessages) {
|
||||
const durationSeconds = Math.max(1, (latest.finishedAt.getTime() - latest.startedAt.getTime()) / 1000);
|
||||
const rate = latest.processedMessages / durationSeconds;
|
||||
if (Number.isFinite(rate) && rate > 0) {
|
||||
const metric = await prisma.tenantMetric.findUnique({ where: { tenantId: latest.tenantId } });
|
||||
const alpha = clampAlpha(config.METRICS_EMA_ALPHA);
|
||||
if (!metric) {
|
||||
await prisma.tenantMetric.create({
|
||||
data: { tenantId: latest.tenantId, avgProcessingRate: rate, sampleCount: 1 }
|
||||
});
|
||||
} else {
|
||||
const newCount = metric.sampleCount + 1;
|
||||
const blended = ema(metric.avgProcessingRate, rate, alpha);
|
||||
await prisma.tenantMetric.update({
|
||||
where: { tenantId: latest.tenantId },
|
||||
data: { avgProcessingRate: blended, sampleCount: newCount }
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (latest?.tenantId) {
|
||||
const account = await prisma.mailboxAccount.findUnique({ where: { id: latest.mailboxAccountId } });
|
||||
const provider = account?.provider ?? null;
|
||||
if (provider) {
|
||||
const listingRate = latest.listingSeconds && latest.totalMessages
|
||||
? latest.totalMessages / latest.listingSeconds
|
||||
: null;
|
||||
const processingRate = latest.processingSeconds && latest.processedMessages
|
||||
? latest.processedMessages / latest.processingSeconds
|
||||
: null;
|
||||
const allowSideEffects = !latest.dryRun;
|
||||
const unsubscribeRate = allowSideEffects && latest.unsubscribeSeconds && latest.unsubscribeAttempts
|
||||
? latest.unsubscribeAttempts / latest.unsubscribeSeconds
|
||||
: null;
|
||||
const routingRate = allowSideEffects && latest.routingSeconds && latest.actionAttempts
|
||||
? latest.actionAttempts / latest.routingSeconds
|
||||
: null;
|
||||
const listingSecPerMsg = latest.listingSeconds && latest.totalMessages
|
||||
? latest.listingSeconds / latest.totalMessages
|
||||
: null;
|
||||
const processingSecPerMsg = latest.processingSeconds && latest.processedMessages
|
||||
? latest.processingSeconds / latest.processedMessages
|
||||
: null;
|
||||
const unsubscribeSecPerMsg = allowSideEffects && latest.unsubscribeSeconds && latest.processedMessages
|
||||
? latest.unsubscribeSeconds / latest.processedMessages
|
||||
: null;
|
||||
const routingSecPerMsg = allowSideEffects && latest.routingSeconds && latest.processedMessages
|
||||
? latest.routingSeconds / latest.processedMessages
|
||||
: null;
|
||||
|
||||
const existing = await prisma.tenantProviderMetric.findUnique({
|
||||
where: { tenantId_provider: { tenantId: latest.tenantId, provider } }
|
||||
});
|
||||
const alpha = clampAlpha(config.METRICS_EMA_ALPHA);
|
||||
|
||||
if (!existing) {
|
||||
await prisma.tenantProviderMetric.create({
|
||||
data: {
|
||||
tenantId: latest.tenantId,
|
||||
provider,
|
||||
avgListingRate: listingRate ?? undefined,
|
||||
avgProcessingRate: processingRate ?? undefined,
|
||||
avgUnsubscribeRate: unsubscribeRate ?? undefined,
|
||||
avgRoutingRate: routingRate ?? undefined,
|
||||
avgListingSecondsPerMessage: listingSecPerMsg ?? undefined,
|
||||
avgProcessingSecondsPerMessage: processingSecPerMsg ?? undefined,
|
||||
avgUnsubscribeSecondsPerMessage: unsubscribeSecPerMsg ?? undefined,
|
||||
avgRoutingSecondsPerMessage: routingSecPerMsg ?? undefined,
|
||||
listingSampleCount: listingRate ? 1 : 0,
|
||||
processingSampleCount: processingRate ? 1 : 0,
|
||||
unsubscribeSampleCount: unsubscribeRate ? 1 : 0,
|
||||
routingSampleCount: routingRate ? 1 : 0
|
||||
}
|
||||
});
|
||||
} else {
|
||||
const updates: Record<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 {
|
||||
return { ok: false, skipped: true };
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<title>Simple Mail Cleaner</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
BIN
frontend/public/favicon.ico
Normal file
|
After Width: | Height: | Size: 195 B |
BIN
frontend/public/favicon.png
Normal file
|
After Width: | Height: | Size: 173 B |
2005
frontend/src/App.tsx
@@ -85,7 +85,17 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
|
||||
googleClientId: "",
|
||||
googleClientSecret: "",
|
||||
googleRedirectUri: "",
|
||||
cleanupScanLimit: ""
|
||||
cleanupScanLimit: "",
|
||||
newsletterThreshold: "",
|
||||
newsletterSubjectTokens: "",
|
||||
newsletterFromTokens: "",
|
||||
newsletterHeaderKeys: "",
|
||||
newsletterWeightHeader: "",
|
||||
newsletterWeightPrecedence: "",
|
||||
newsletterWeightSubject: "",
|
||||
newsletterWeightFrom: "",
|
||||
unsubscribeHistoryTtlDays: "",
|
||||
unsubscribeMethodPreference: "auto"
|
||||
});
|
||||
const [settingsStatus, setSettingsStatus] = useState<"idle" | "saving" | "saved" | "error">("idle");
|
||||
const [showGoogleSecret, setShowGoogleSecret] = useState(false);
|
||||
@@ -162,7 +172,17 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
|
||||
googleClientId: next["google.client_id"]?.value ?? "",
|
||||
googleClientSecret: next["google.client_secret"]?.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_secret": settingsDraft.googleClientSecret,
|
||||
"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 })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="inline-actions">
|
||||
<button className="ghost" onClick={() => exportTenant(tenant)}>{t("adminExportStart")}</button>
|
||||
<button className="ghost" onClick={() => toggleTenant(tenant)}>
|
||||
{tenant.isActive ? t("adminDisable") : t("adminEnable")}
|
||||
<div className="inline-actions icon-actions">
|
||||
<button
|
||||
className="ghost icon-only"
|
||||
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 className="ghost" onClick={() => deleteTenant(tenant)}>{t("adminDelete")}</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -567,7 +616,7 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
|
||||
</select>
|
||||
</label>
|
||||
<button
|
||||
className="ghost"
|
||||
className="ghost icon-only"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await apiFetch("/admin/exports/purge", { method: "POST" }, token);
|
||||
@@ -577,8 +626,10 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
|
||||
pushToast(getErrorMessage(err), "error");
|
||||
}
|
||||
}}
|
||||
title={t("adminExportPurge")}
|
||||
aria-label={t("adminExportPurge")}
|
||||
>
|
||||
{t("adminExportPurge")}
|
||||
🗑
|
||||
</button>
|
||||
</div>
|
||||
{exportsFiltered.map((item) => (
|
||||
@@ -600,13 +651,13 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
|
||||
<p>{t("exportProgress", { progress: item.progress })}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="inline-actions">
|
||||
<div className="inline-actions icon-actions">
|
||||
<span>
|
||||
{item.createdAt ? new Date(item.createdAt).toLocaleString() : "-"} ·{" "}
|
||||
{t("exportExpires")}: {item.expiresAt ? new Date(item.expiresAt).toLocaleString() : "-"}
|
||||
</span>
|
||||
<button
|
||||
className="ghost"
|
||||
className="ghost icon-only"
|
||||
disabled={item.status !== "DONE" || (item.expiresAt ? new Date(item.expiresAt) < new Date() : false)}
|
||||
onClick={async () => {
|
||||
const response = await downloadExport(token, item.id);
|
||||
@@ -615,11 +666,13 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
|
||||
downloadFile(blob, `export-${item.id}.zip`);
|
||||
}
|
||||
}}
|
||||
title={t("exportDownload")}
|
||||
aria-label={t("exportDownload")}
|
||||
>
|
||||
{t("exportDownload")}
|
||||
⤓
|
||||
</button>
|
||||
<button
|
||||
className="ghost"
|
||||
className="ghost icon-only"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await apiFetch(`/admin/exports/${item.id}`, { method: "DELETE" }, token);
|
||||
@@ -629,8 +682,10 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
|
||||
pushToast(getErrorMessage(err), "error");
|
||||
}
|
||||
}}
|
||||
title={t("delete")}
|
||||
aria-label={t("delete")}
|
||||
>
|
||||
{t("delete")}
|
||||
🗑
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -664,18 +719,38 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
|
||||
</div>
|
||||
<p>{user.role} · {user.tenant?.name ?? "-"}</p>
|
||||
</div>
|
||||
<div className="inline-actions">
|
||||
<button className="ghost" onClick={() => setRole(user, user.role === "ADMIN" ? "USER" : "ADMIN")}>
|
||||
{user.role === "ADMIN" ? t("adminMakeUser") : t("adminMakeAdmin")}
|
||||
<div className="inline-actions icon-actions">
|
||||
<button
|
||||
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 className="ghost" onClick={() => toggleUser(user)}>
|
||||
{user.isActive ? t("adminDisable") : t("adminEnable")}
|
||||
<button
|
||||
className="ghost icon-only"
|
||||
onClick={() => toggleUser(user)}
|
||||
title={user.isActive ? t("adminDisable") : t("adminEnable")}
|
||||
aria-label={user.isActive ? t("adminDisable") : t("adminEnable")}
|
||||
>
|
||||
⏻
|
||||
</button>
|
||||
<button className="ghost" onClick={() => impersonate(user)}>
|
||||
{t("adminImpersonate")}
|
||||
<button
|
||||
className="ghost icon-only"
|
||||
onClick={() => impersonate(user)}
|
||||
title={t("adminImpersonate")}
|
||||
aria-label={t("adminImpersonate")}
|
||||
>
|
||||
👤
|
||||
</button>
|
||||
<button className="ghost" onClick={() => setResetUserId(user.id)}>
|
||||
{t("adminResetPassword")}
|
||||
<button
|
||||
className="ghost icon-only"
|
||||
onClick={() => setResetUserId(user.id)}
|
||||
title={t("adminResetPassword")}
|
||||
aria-label={t("adminResetPassword")}
|
||||
>
|
||||
🔑
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -740,8 +815,13 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button className="ghost" onClick={() => toggleAccount(account)}>
|
||||
{account.isActive ? t("adminDisable") : t("adminEnable")}
|
||||
<button
|
||||
className="ghost icon-only"
|
||||
onClick={() => toggleAccount(account)}
|
||||
title={account.isActive ? t("adminDisable") : t("adminEnable")}
|
||||
aria-label={account.isActive ? t("adminDisable") : t("adminEnable")}
|
||||
>
|
||||
⏻
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
@@ -770,12 +850,26 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
|
||||
/>
|
||||
{t("selectAll")}
|
||||
</label>
|
||||
<div className="inline-actions">
|
||||
<button className="ghost" type="button" onClick={cancelSelectedJobs} disabled={selectedJobIds.length === 0}>
|
||||
{t("adminCancelSelected")}
|
||||
<div className="inline-actions icon-actions">
|
||||
<button
|
||||
className="ghost icon-only"
|
||||
type="button"
|
||||
onClick={cancelSelectedJobs}
|
||||
disabled={selectedJobIds.length === 0}
|
||||
title={t("adminCancelSelected")}
|
||||
aria-label={t("adminCancelSelected")}
|
||||
>
|
||||
⏹
|
||||
</button>
|
||||
<button className="ghost" type="button" onClick={deleteSelectedJobs} disabled={selectedJobIds.length === 0}>
|
||||
{t("adminDeleteSelected")}
|
||||
<button
|
||||
className="ghost icon-only"
|
||||
type="button"
|
||||
onClick={deleteSelectedJobs}
|
||||
disabled={selectedJobIds.length === 0}
|
||||
title={t("adminDeleteSelected")}
|
||||
aria-label={t("adminDeleteSelected")}
|
||||
>
|
||||
🗑
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -785,16 +879,37 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
|
||||
<strong>{mapJobStatus(job.status)}</strong>
|
||||
<p>{job.tenant?.name ?? "-"} · {job.mailboxAccount?.email ?? "-"}</p>
|
||||
</div>
|
||||
<div className="inline-actions">
|
||||
<div className="inline-actions icon-actions">
|
||||
<input
|
||||
className="checkbox-input"
|
||||
type="checkbox"
|
||||
checked={selectedJobIds.includes(job.id)}
|
||||
onChange={() => toggleJobSelection(job.id)}
|
||||
/>
|
||||
<button className="ghost" onClick={() => retryJob(job)}>{t("adminRetry")}</button>
|
||||
<button className="ghost" onClick={() => cancelJob(job)}>{t("adminCancelJob")}</button>
|
||||
<button className="ghost" onClick={() => deleteJob(job)}>{t("adminDelete")}</button>
|
||||
<button
|
||||
className="ghost icon-only"
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -842,9 +957,109 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
|
||||
/>
|
||||
<small className="hint-text">{t("adminCleanupScanLimitHint")}</small>
|
||||
</label>
|
||||
<div className="inline-actions">
|
||||
<button className="ghost" onClick={() => setShowGoogleSecret((prev) => !prev)}>
|
||||
{showGoogleSecret ? t("adminHideSecret") : t("adminShowSecret")}
|
||||
<div className="panel-divider" />
|
||||
<h4>{t("adminNewsletterSettings")}</h4>
|
||||
<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 className="primary" onClick={saveSettings} disabled={settingsStatus === "saving"}>
|
||||
{settingsStatus === "saving" ? t("adminSaving") : t("adminSaveSettings")}
|
||||
@@ -859,6 +1074,20 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
|
||||
redirect: settings["google.redirect_uri"]?.source ?? "unset"
|
||||
})}
|
||||
</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>
|
||||
)}
|
||||
@@ -867,8 +1096,14 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
|
||||
<div className="modal" onClick={(event) => event.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h3>{t("confirmTitle")}</h3>
|
||||
<button className="ghost" type="button" onClick={closeConfirmDialog}>
|
||||
{t("close")}
|
||||
<button
|
||||
className="ghost icon-only"
|
||||
type="button"
|
||||
onClick={closeConfirmDialog}
|
||||
title={t("close")}
|
||||
aria-label={t("close")}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
|
||||
@@ -67,6 +67,26 @@
|
||||
"adminGoogleRedirectUri": "Redirect-URL",
|
||||
"adminCleanupScanLimit": "Max. Mails pro Bereinigung",
|
||||
"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",
|
||||
"adminSaving": "Speichert...",
|
||||
"adminSettingsSaved": "Gespeichert",
|
||||
@@ -74,6 +94,7 @@
|
||||
"adminShowSecret": "Secret anzeigen",
|
||||
"adminHideSecret": "Secret verbergen",
|
||||
"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",
|
||||
"adminCancelSelected": "Auswahl abbrechen",
|
||||
"adminDeleteSelected": "Auswahl löschen",
|
||||
@@ -94,30 +115,175 @@
|
||||
"mailboxCancelEdit": "Abbrechen",
|
||||
"mailboxEmpty": "Noch keine Mailbox. Füge eine hinzu, um zu starten.",
|
||||
"cleanupStart": "Bereinigung starten",
|
||||
"cleanupDryRun": "Dry run (keine Änderungen)",
|
||||
"cleanupDryRun": "Nur simulieren (keine Änderungen)",
|
||||
"cleanupUnsubscribe": "Unsubscribe aktiv",
|
||||
"cleanupRouting": "Routing aktiv",
|
||||
"cleanupDisabled": "Bereinigung ist noch nicht verfügbar.",
|
||||
"cleanupSelectMailbox": "Bitte ein Postfach auswählen.",
|
||||
"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",
|
||||
"rulesAdd": "Regel hinzufügen",
|
||||
"rulesReorder": "Ziehen zum Sortieren",
|
||||
"rulesAddTitle": "Regel erstellen",
|
||||
"rulesEditTitle": "Regel bearbeiten",
|
||||
"rulesName": "Rule Name",
|
||||
"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",
|
||||
"rulesActions": "Aktionen",
|
||||
"rulesAddCondition": "+ Bedingung",
|
||||
"rulesAddAction": "+ Aktion",
|
||||
"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",
|
||||
"rulesEmpty": "Noch keine Regeln.",
|
||||
"jobsTitle": "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": "Gmail‑Aktion angewendet: {{actions}}",
|
||||
"jobEventGmailActionApplied": "Gmail‑Aktion angewendet: {{action}}",
|
||||
"jobEventGmailActionSkippedNoChanges": "Gmail‑Aktion übersprungen: keine Label‑Änderungen",
|
||||
"jobEventGmailActionFailedSimple": "Gmail‑Aktion fehlgeschlagen: {{error}}",
|
||||
"jobEventGmailActionFailed": "Gmail‑Aktion fehlgeschlagen ({{action}}): {{error}}",
|
||||
"jobEventImapActionFailed": "IMAP‑Aktion 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",
|
||||
"jobsEta": "Restzeit",
|
||||
"jobsEtaDone": "Fertig",
|
||||
"jobsEtaQueued": "Wartet",
|
||||
"jobsEtaRecalculating": "Restzeit wird neu berechnet…",
|
||||
"jobsEtaCalculating": "Berechne…",
|
||||
"jobDetailsTitle": "Job-Details",
|
||||
"jobNoEvents": "Noch keine Events.",
|
||||
"jobEvents": "Job Events",
|
||||
@@ -128,7 +294,13 @@
|
||||
"phaseProcessingPending": "Warte auf Verarbeitung.",
|
||||
"phaseUnsubscribePending": "Warte auf Abmeldung.",
|
||||
"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",
|
||||
"phaseStatusDone": "Fertig",
|
||||
"phaseStatusPending": "Ausstehend",
|
||||
@@ -173,6 +345,7 @@
|
||||
"statusRunning": "Laufend",
|
||||
"statusQueued": "In Warteschlange",
|
||||
"statusSucceeded": "Erfolgreich",
|
||||
"statusFinished": "Bereinigung abgeschlossen",
|
||||
"statusFailed": "Fehlgeschlagen",
|
||||
"statusCanceled": "Abgebrochen",
|
||||
"oauthStatusLabel": "OAuth Status"
|
||||
@@ -204,10 +377,10 @@
|
||||
"ruleConditionFrom": "From",
|
||||
"ruleConditionListUnsub": "List-Unsubscribe",
|
||||
"ruleConditionListId": "List-Id",
|
||||
"ruleActionMove": "Move",
|
||||
"ruleActionDelete": "Delete",
|
||||
"ruleActionArchive": "Archive",
|
||||
"ruleActionLabel": "Label",
|
||||
"ruleActionMove": "Verschieben",
|
||||
"ruleActionDelete": "Löschen",
|
||||
"ruleActionArchive": "Archivieren",
|
||||
"ruleActionLabel": "Label setzen",
|
||||
"adminExportFormat": "Format",
|
||||
"exportFormatJson": "JSON",
|
||||
"exportFormatCsv": "CSV",
|
||||
@@ -250,11 +423,14 @@
|
||||
"toastMailboxDeleted": "Mailbox gelöscht.",
|
||||
"toastRuleSaved": "Regel gespeichert.",
|
||||
"toastRuleDeleted": "Regel gelöscht.",
|
||||
"toastRuleOrderSaved": "Reihenfolge aktualisiert.",
|
||||
"toastDeleteSelected": "{{deleted}} gelöscht · {{missing}} fehlend · {{failed}} fehlgeschlagen",
|
||||
"toastCleanupStarted": "Bereinigung gestartet.",
|
||||
"toastLoggedOut": "Ausgeloggt.",
|
||||
"toastExportQueued": "Export in Warteschlange.",
|
||||
"toastExportReady": "Export bereit.",
|
||||
"toastExportFailed": "Export fehlgeschlagen.",
|
||||
"toastDownloadFailed": "Download fehlgeschlagen.",
|
||||
"toastExportPurged": "Abgelaufene Exporte entfernt.",
|
||||
"toastExportDeleted": "Export gelöscht.",
|
||||
"toastTenantUpdated": "Tenant aktualisiert.",
|
||||
@@ -270,5 +446,6 @@
|
||||
"toastSettingsSaved": "Einstellungen gespeichert.",
|
||||
"toastSettingsFailed": "Einstellungen konnten nicht gespeichert werden.",
|
||||
"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?"
|
||||
}
|
||||
|
||||
@@ -67,6 +67,26 @@
|
||||
"adminGoogleRedirectUri": "Redirect URL",
|
||||
"adminCleanupScanLimit": "Max emails per cleanup",
|
||||
"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",
|
||||
"adminSaving": "Saving...",
|
||||
"adminSettingsSaved": "Saved",
|
||||
@@ -74,6 +94,7 @@
|
||||
"adminShowSecret": "Show secret",
|
||||
"adminHideSecret": "Hide secret",
|
||||
"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",
|
||||
"adminCancelSelected": "Cancel selected",
|
||||
"adminDeleteSelected": "Delete selected",
|
||||
@@ -94,30 +115,175 @@
|
||||
"mailboxCancelEdit": "Cancel",
|
||||
"mailboxEmpty": "No mailboxes yet. Add one to start cleaning.",
|
||||
"cleanupStart": "Start cleanup",
|
||||
"cleanupDryRun": "Dry run (no changes)",
|
||||
"cleanupDryRun": "Simulate only (no changes)",
|
||||
"cleanupUnsubscribe": "Unsubscribe enabled",
|
||||
"cleanupRouting": "Routing enabled",
|
||||
"cleanupDisabled": "Cleanup is not available yet.",
|
||||
"cleanupSelectMailbox": "Select a mailbox to start 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",
|
||||
"rulesAdd": "Add rule",
|
||||
"rulesReorder": "Drag to reorder",
|
||||
"rulesAddTitle": "Create rule",
|
||||
"rulesEditTitle": "Edit rule",
|
||||
"rulesName": "Rule name",
|
||||
"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",
|
||||
"rulesActions": "Actions",
|
||||
"rulesAddCondition": "+ Add condition",
|
||||
"rulesAddAction": "+ Add action",
|
||||
"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",
|
||||
"rulesEmpty": "No rules yet.",
|
||||
"jobsTitle": "Jobs",
|
||||
"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",
|
||||
"jobsEta": "ETA",
|
||||
"jobsEtaDone": "Done",
|
||||
"jobsEtaQueued": "Queued",
|
||||
"jobsEtaRecalculating": "Recalculating ETA…",
|
||||
"jobsEtaCalculating": "Calculating…",
|
||||
"jobDetailsTitle": "Job details",
|
||||
"jobNoEvents": "No events yet.",
|
||||
"jobEvents": "Job events",
|
||||
@@ -128,7 +294,13 @@
|
||||
"phaseProcessingPending": "Waiting for processing.",
|
||||
"phaseUnsubscribePending": "Waiting for unsubscribe.",
|
||||
"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",
|
||||
"phaseStatusDone": "Done",
|
||||
"phaseStatusPending": "Pending",
|
||||
@@ -173,6 +345,7 @@
|
||||
"statusRunning": "Running",
|
||||
"statusQueued": "Queued",
|
||||
"statusSucceeded": "Succeeded",
|
||||
"statusFinished": "Cleanup finished",
|
||||
"statusFailed": "Failed",
|
||||
"statusCanceled": "Canceled",
|
||||
"oauthStatusLabel": "OAuth status"
|
||||
@@ -250,11 +423,14 @@
|
||||
"toastMailboxDeleted": "Mailbox deleted.",
|
||||
"toastRuleSaved": "Rule saved.",
|
||||
"toastRuleDeleted": "Rule deleted.",
|
||||
"toastRuleOrderSaved": "Rule order updated.",
|
||||
"toastDeleteSelected": "{{deleted}} deleted · {{missing}} missing · {{failed}} failed",
|
||||
"toastCleanupStarted": "Cleanup job started.",
|
||||
"toastLoggedOut": "Logged out.",
|
||||
"toastExportQueued": "Export queued.",
|
||||
"toastExportReady": "Export ready.",
|
||||
"toastExportFailed": "Export failed.",
|
||||
"toastDownloadFailed": "Download failed.",
|
||||
"toastExportPurged": "Expired exports purged.",
|
||||
"toastExportDeleted": "Export deleted.",
|
||||
"toastTenantUpdated": "Tenant updated.",
|
||||
@@ -270,5 +446,6 @@
|
||||
"toastSettingsSaved": "Settings saved.",
|
||||
"toastSettingsFailed": "Settings save failed.",
|
||||
"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?"
|
||||
}
|
||||
|
||||
@@ -27,6 +27,10 @@ body {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
body.modal-open {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
@@ -370,6 +374,13 @@ button.ghost {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.panel-divider {
|
||||
height: 1px;
|
||||
background: rgba(148, 163, 184, 0.35);
|
||||
border-radius: 999px;
|
||||
margin: 8px 0 4px;
|
||||
}
|
||||
|
||||
.section-block {
|
||||
margin: 16px 0 18px;
|
||||
padding: 12px 14px;
|
||||
@@ -490,6 +501,48 @@ select {
|
||||
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 {
|
||||
margin-top: 8px;
|
||||
display: grid;
|
||||
@@ -521,19 +574,197 @@ select {
|
||||
}
|
||||
|
||||
.list-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
align-items: start;
|
||||
gap: 12px;
|
||||
padding: 10px 0;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.list-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
.rule-item {
|
||||
grid-template-columns: auto 1fr auto;
|
||||
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 {
|
||||
@@ -690,6 +921,578 @@ select {
|
||||
height: min(92vh, 1000px);
|
||||
display: grid;
|
||||
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 {
|
||||
@@ -880,6 +1683,102 @@ select {
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
|
Before Width: | Height: | Size: 145 KiB |
BIN
screenshot/Screenshot 2026-01-23 102257.png
Normal file
|
After Width: | Height: | Size: 155 KiB |
BIN
screenshot/Screenshot 2026-01-23 102413.png
Normal file
|
After Width: | Height: | Size: 75 KiB |
BIN
screenshot/Screenshot 2026-01-23 111613.png
Normal file
|
After Width: | Height: | Size: 95 KiB |
BIN
screenshot/Screenshot 2026-01-23 115715.png
Normal file
|
After Width: | Height: | Size: 19 KiB |