Aktueller Stand
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
-- Add rule execution phase (pre/post unsubscribe)
|
||||||
|
CREATE TYPE "RulePhase" AS ENUM ('PRE_UNSUBSCRIBE', 'POST_UNSUBSCRIBE');
|
||||||
|
|
||||||
|
ALTER TABLE "Rule"
|
||||||
|
ADD COLUMN "phase" "RulePhase" NOT NULL DEFAULT 'POST_UNSUBSCRIBE';
|
||||||
@@ -206,6 +206,7 @@ model Rule {
|
|||||||
matchMode RuleMatchMode @default(ALL)
|
matchMode RuleMatchMode @default(ALL)
|
||||||
position Int @default(0)
|
position Int @default(0)
|
||||||
stopOnMatch Boolean @default(false)
|
stopOnMatch Boolean @default(false)
|
||||||
|
phase RulePhase @default(POST_UNSUBSCRIBE)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@ -222,6 +223,11 @@ enum RuleMatchMode {
|
|||||||
ANY
|
ANY
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum RulePhase {
|
||||||
|
PRE_UNSUBSCRIBE
|
||||||
|
POST_UNSUBSCRIBE
|
||||||
|
}
|
||||||
|
|
||||||
model RuleCondition {
|
model RuleCondition {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
ruleId String
|
ruleId String
|
||||||
|
|||||||
@@ -177,6 +177,19 @@ export const runCleanup = async (cleanupJobId: string, mailboxAccountId: string)
|
|||||||
};
|
};
|
||||||
|
|
||||||
type GmailClient = Awaited<ReturnType<typeof gmailClientForAccount>>["gmail"];
|
type GmailClient = Awaited<ReturnType<typeof gmailClientForAccount>>["gmail"];
|
||||||
|
type RuleAction = { type: string; target?: string | null };
|
||||||
|
type ActionLogItem = { type: string; target?: string | null; status: string; error?: string };
|
||||||
|
type ImapBatchItem = {
|
||||||
|
uid: number;
|
||||||
|
candidateId: string;
|
||||||
|
actions: RuleAction[];
|
||||||
|
actionLogItems: ActionLogItem[];
|
||||||
|
actionLog: ActionLogItem[];
|
||||||
|
hasDelete: boolean;
|
||||||
|
moveAction?: RuleAction | null;
|
||||||
|
markAction?: RuleAction | null;
|
||||||
|
};
|
||||||
|
type ImapBatchQueue = { items: ImapBatchItem[] };
|
||||||
|
|
||||||
const processMessage = async (msg: {
|
const processMessage = async (msg: {
|
||||||
uid: number;
|
uid: number;
|
||||||
@@ -186,7 +199,7 @@ export const runCleanup = async (cleanupJobId: string, mailboxAccountId: string)
|
|||||||
headers: Map<string, string>;
|
headers: Map<string, string>;
|
||||||
gmailMessageId?: string;
|
gmailMessageId?: string;
|
||||||
mailbox?: string;
|
mailbox?: string;
|
||||||
}, gmailContext?: { gmail: GmailClient; resolveLabelId: (label: string) => Promise<string> }) => {
|
}, gmailContext?: { gmail: GmailClient; resolveLabelId: (label: string) => Promise<string> }, imapBatch?: ImapBatchQueue) => {
|
||||||
const ctx = {
|
const ctx = {
|
||||||
headers: msg.headers,
|
headers: msg.headers,
|
||||||
subject: msg.subject ?? "",
|
subject: msg.subject ?? "",
|
||||||
@@ -235,6 +248,225 @@ export const runCleanup = async (cleanupJobId: string, mailboxAccountId: string)
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const actionLog: ActionLogItem[] = [];
|
||||||
|
let deferredImap = false;
|
||||||
|
|
||||||
|
const applyActions = async (actionsToApply: RuleAction[], allowBatch: boolean) => {
|
||||||
|
if (!actionsToApply.length) return;
|
||||||
|
const deferImapActions = Boolean(allowBatch && imapBatch && !job.dryRun && account.provider !== "GMAIL");
|
||||||
|
if (deferImapActions) {
|
||||||
|
deferredImap = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (job.dryRun) {
|
||||||
|
for (const action of actionsToApply) {
|
||||||
|
await logJobEvent(cleanupJobId, "info", `DRY RUN: ${action.type} ${action.target ?? ""}`);
|
||||||
|
actionLog.push({ type: action.type, target: action.target ?? null, status: "dry-run" });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (account.provider === "GMAIL" && msg.gmailMessageId && gmailContext) {
|
||||||
|
const actionStart = Date.now();
|
||||||
|
actionAttempts += 1;
|
||||||
|
const actionLogItems: ActionLogItem[] = actionsToApply.map((item) => ({
|
||||||
|
type: item.type,
|
||||||
|
target: item.target ?? null,
|
||||||
|
status: "pending" as const,
|
||||||
|
error: undefined as string | undefined
|
||||||
|
}));
|
||||||
|
const hasDelete = actionsToApply.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");
|
||||||
|
gmailDeleteCount += 1;
|
||||||
|
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 actionsToApply) {
|
||||||
|
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: ${actionsToApply.map((a) => a.type).join(", ")}`);
|
||||||
|
gmailModifyCount += 1;
|
||||||
|
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);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deferImapActions && imapBatch) {
|
||||||
|
const actionLogItems: ActionLogItem[] = actionsToApply.map((item) => ({
|
||||||
|
type: item.type,
|
||||||
|
target: item.target ?? null,
|
||||||
|
status: "pending",
|
||||||
|
error: undefined
|
||||||
|
}));
|
||||||
|
const moveCandidates = actionsToApply.filter(
|
||||||
|
(item) => (item.type === "MOVE" || item.type === "ARCHIVE" || item.type === "LABEL") && item.target
|
||||||
|
);
|
||||||
|
const moveAction = moveCandidates.length ? moveCandidates[moveCandidates.length - 1] : null;
|
||||||
|
const markCandidates = actionsToApply.filter((item) => item.type === "MARK_READ" || item.type === "MARK_UNREAD");
|
||||||
|
const markAction = markCandidates.length ? markCandidates[markCandidates.length - 1] : null;
|
||||||
|
const hasDelete = actionsToApply.some((item) => item.type === "DELETE");
|
||||||
|
actionLog.push(...actionLogItems);
|
||||||
|
imapBatch.items.push({
|
||||||
|
uid: msg.uid,
|
||||||
|
candidateId: candidate.id,
|
||||||
|
actions: actionsToApply,
|
||||||
|
actionLogItems,
|
||||||
|
actionLog,
|
||||||
|
hasDelete,
|
||||||
|
moveAction,
|
||||||
|
markAction
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!imapClient) {
|
||||||
|
await logJobEvent(cleanupJobId, "info", "Skipping IMAP action: no IMAP client");
|
||||||
|
for (const action of actionsToApply) {
|
||||||
|
actionLog.push({ type: action.type, target: action.target ?? null, status: "skipped" });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const actionStart = Date.now();
|
||||||
|
actionAttempts += 1;
|
||||||
|
const actionLogItems = actionsToApply.map((item) => ({
|
||||||
|
type: item.type,
|
||||||
|
target: item.target ?? null,
|
||||||
|
status: "pending" as const,
|
||||||
|
error: undefined as string | undefined
|
||||||
|
}));
|
||||||
|
const hasDelete = actionsToApply.some((item) => item.type === "DELETE");
|
||||||
|
const moveCandidates = actionsToApply.filter(
|
||||||
|
(item) => (item.type === "MOVE" || item.type === "ARCHIVE" || item.type === "LABEL") && item.target
|
||||||
|
);
|
||||||
|
const moveAction = moveCandidates.length ? moveCandidates[moveCandidates.length - 1] : null;
|
||||||
|
const markCandidates = actionsToApply.filter((item) => item.type === "MARK_READ" || item.type === "MARK_UNREAD");
|
||||||
|
const markAction = markCandidates.length ? markCandidates[markCandidates.length - 1] : null;
|
||||||
|
try {
|
||||||
|
if (hasDelete) {
|
||||||
|
await imapClient.messageDelete(msg.uid);
|
||||||
|
await logJobEvent(cleanupJobId, "info", `Deleted message ${msg.uid}`);
|
||||||
|
for (const item of actionLogItems) {
|
||||||
|
if (item.type === "DELETE") {
|
||||||
|
item.status = "applied";
|
||||||
|
} else {
|
||||||
|
item.status = "skipped";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (markAction?.type === "MARK_READ") {
|
||||||
|
await imapClient.messageFlagsAdd(msg.uid, ["\\Seen"]);
|
||||||
|
await logJobEvent(cleanupJobId, "info", `Marked message ${msg.uid} as read`);
|
||||||
|
}
|
||||||
|
if (markAction?.type === "MARK_UNREAD") {
|
||||||
|
await imapClient.messageFlagsRemove(msg.uid, ["\\Seen"]);
|
||||||
|
await logJobEvent(cleanupJobId, "info", `Marked message ${msg.uid} as unread`);
|
||||||
|
}
|
||||||
|
if (moveAction?.target) {
|
||||||
|
if (!imapMailboxCache.has(moveAction.target)) {
|
||||||
|
await imapClient.mailboxCreate(moveAction.target).catch(() => undefined);
|
||||||
|
imapMailboxCache.add(moveAction.target);
|
||||||
|
}
|
||||||
|
await imapClient.messageMove(msg.uid, moveAction.target);
|
||||||
|
await logJobEvent(cleanupJobId, "info", `Moved message ${msg.uid} to ${moveAction.target}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const item of actionLogItems) {
|
||||||
|
if (markAction && item.type === markAction.type) {
|
||||||
|
item.status = "applied";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (item.type === "MARK_READ" || item.type === "MARK_UNREAD") {
|
||||||
|
item.status = "skipped";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (moveAction && item.type === moveAction.type && item.target === moveAction.target) {
|
||||||
|
item.status = "applied";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (item.type === "MOVE" || item.type === "ARCHIVE" || item.type === "LABEL") {
|
||||||
|
item.status = "skipped";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (item.type === "DELETE") {
|
||||||
|
item.status = "skipped";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
await logJobEvent(cleanupJobId, "error", `IMAP 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);
|
||||||
|
};
|
||||||
|
|
||||||
|
const preRoutingCtx = { ...ctx, unsubscribeStatus: null, newsletterScore: result.score };
|
||||||
|
const preActions = job.routingEnabled ? matchRules(rules, preRoutingCtx, "PRE_UNSUBSCRIBE") : [];
|
||||||
|
await applyActions(preActions, false);
|
||||||
|
|
||||||
let unsubscribeStatus = job.unsubscribeEnabled ? "pending" : "disabled";
|
let unsubscribeStatus = job.unsubscribeEnabled ? "pending" : "disabled";
|
||||||
let unsubscribeMessage: string | null = null;
|
let unsubscribeMessage: string | null = null;
|
||||||
let unsubscribeDetails: Record<string, any> | null = null;
|
let unsubscribeDetails: Record<string, any> | null = null;
|
||||||
@@ -338,141 +570,23 @@ export const runCleanup = async (cleanupJobId: string, mailboxAccountId: string)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const routingCtx = { ...ctx, unsubscribeStatus, newsletterScore: result.score };
|
const postRoutingCtx = { ...ctx, unsubscribeStatus, newsletterScore: result.score };
|
||||||
const actions = job.routingEnabled ? matchRules(rules, routingCtx) : [];
|
const postActions = job.routingEnabled ? matchRules(rules, postRoutingCtx, "POST_UNSUBSCRIBE") : [];
|
||||||
const actionLog: { type: string; target?: string | null; status: string; error?: string }[] = [];
|
await applyActions(postActions, true);
|
||||||
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 && 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 {
|
|
||||||
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 (actionLog.length || unsubscribeStatus !== "pending" || unsubscribeTarget) {
|
if (actionLog.length || unsubscribeStatus !== "pending" || unsubscribeTarget) {
|
||||||
|
const updateData: Record<string, any> = {
|
||||||
|
unsubscribeStatus,
|
||||||
|
unsubscribeMessage,
|
||||||
|
unsubscribeTarget,
|
||||||
|
unsubscribeDetails
|
||||||
|
};
|
||||||
|
if (actionLog.length && !deferredImap) {
|
||||||
|
updateData.actions = actionLog;
|
||||||
|
}
|
||||||
await prisma.cleanupJobCandidate.update({
|
await prisma.cleanupJobCandidate.update({
|
||||||
where: { id: candidate.id },
|
where: { id: candidate.id },
|
||||||
data: {
|
data: updateData
|
||||||
actions: actionLog.length ? actionLog : undefined,
|
|
||||||
unsubscribeStatus,
|
|
||||||
unsubscribeMessage,
|
|
||||||
unsubscribeTarget,
|
|
||||||
unsubscribeDetails
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -498,6 +612,10 @@ export const runCleanup = async (cleanupJobId: string, mailboxAccountId: string)
|
|||||||
let unsubscribeAttempts = 0;
|
let unsubscribeAttempts = 0;
|
||||||
let actionAttempts = 0;
|
let actionAttempts = 0;
|
||||||
const imapMailboxCache = new Set<string>();
|
const imapMailboxCache = new Set<string>();
|
||||||
|
let gmailModifyCount = 0;
|
||||||
|
let gmailDeleteCount = 0;
|
||||||
|
let gmailModifyLogged = 0;
|
||||||
|
let gmailDeleteLogged = 0;
|
||||||
|
|
||||||
if (isGmail && hasGmailOAuth) {
|
if (isGmail && hasGmailOAuth) {
|
||||||
const { gmail } = await gmailClientForAccount(account);
|
const { gmail } = await gmailClientForAccount(account);
|
||||||
@@ -657,10 +775,28 @@ export const runCleanup = async (cleanupJobId: string, mailboxAccountId: string)
|
|||||||
buildProgressMessage("Processed", processed, total, processingStart),
|
buildProgressMessage("Processed", processed, total, processingStart),
|
||||||
progress
|
progress
|
||||||
);
|
);
|
||||||
|
const modifyDelta = gmailModifyCount - gmailModifyLogged;
|
||||||
|
if (modifyDelta > 0) {
|
||||||
|
await logJobEvent(cleanupJobId, "info", `Gmail batch: MODIFY ${modifyDelta}`);
|
||||||
|
gmailModifyLogged = gmailModifyCount;
|
||||||
|
}
|
||||||
|
const deleteDelta = gmailDeleteCount - gmailDeleteLogged;
|
||||||
|
if (deleteDelta > 0) {
|
||||||
|
await logJobEvent(cleanupJobId, "info", `Gmail batch: DELETE ${deleteDelta}`);
|
||||||
|
gmailDeleteLogged = gmailDeleteCount;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await logJobEvent(cleanupJobId, "info", `Detected ${newsletterCount} newsletter candidates`, 92);
|
await logJobEvent(cleanupJobId, "info", `Detected ${newsletterCount} newsletter candidates`, 92);
|
||||||
|
const finalModifyDelta = gmailModifyCount - gmailModifyLogged;
|
||||||
|
if (finalModifyDelta > 0) {
|
||||||
|
await logJobEvent(cleanupJobId, "info", `Gmail batch: MODIFY ${finalModifyDelta}`);
|
||||||
|
}
|
||||||
|
const finalDeleteDelta = gmailDeleteCount - gmailDeleteLogged;
|
||||||
|
if (finalDeleteDelta > 0) {
|
||||||
|
await logJobEvent(cleanupJobId, "info", `Gmail batch: DELETE ${finalDeleteDelta}`);
|
||||||
|
}
|
||||||
processingSeconds = Math.max(1, Math.round((Date.now() - processingStart) / 1000));
|
processingSeconds = Math.max(1, Math.round((Date.now() - processingStart) / 1000));
|
||||||
await prisma.cleanupJob.update({
|
await prisma.cleanupJob.update({
|
||||||
where: { id: cleanupJobId },
|
where: { id: cleanupJobId },
|
||||||
@@ -747,6 +883,157 @@ export const runCleanup = async (cleanupJobId: string, mailboxAccountId: string)
|
|||||||
await logJobEvent(cleanupJobId, "info", `Processing ${total} IMAP messages`, 35);
|
await logJobEvent(cleanupJobId, "info", `Processing ${total} IMAP messages`, 35);
|
||||||
const processingStart = Date.now();
|
const processingStart = Date.now();
|
||||||
let newsletterCount = 0;
|
let newsletterCount = 0;
|
||||||
|
const imapBatchQueue: ImapBatchQueue = { items: [] };
|
||||||
|
const flushImapBatch = async () => {
|
||||||
|
if (!imapBatchQueue.items.length || !imapClient) return;
|
||||||
|
const deleteItems: ImapBatchItem[] = [];
|
||||||
|
const markReadItems: ImapBatchItem[] = [];
|
||||||
|
const markUnreadItems: ImapBatchItem[] = [];
|
||||||
|
const moveItemsByTarget = new Map<string, ImapBatchItem[]>();
|
||||||
|
|
||||||
|
for (const item of imapBatchQueue.items) {
|
||||||
|
if (item.hasDelete) {
|
||||||
|
deleteItems.push(item);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (item.markAction?.type === "MARK_READ") {
|
||||||
|
markReadItems.push(item);
|
||||||
|
}
|
||||||
|
if (item.markAction?.type === "MARK_UNREAD") {
|
||||||
|
markUnreadItems.push(item);
|
||||||
|
}
|
||||||
|
if (item.moveAction?.target) {
|
||||||
|
const list = moveItemsByTarget.get(item.moveAction.target) ?? [];
|
||||||
|
list.push(item);
|
||||||
|
moveItemsByTarget.set(item.moveAction.target, list);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const errors: {
|
||||||
|
delete: string | null;
|
||||||
|
markRead: string | null;
|
||||||
|
markUnread: string | null;
|
||||||
|
move: Map<string, string | null>;
|
||||||
|
} = {
|
||||||
|
delete: null,
|
||||||
|
markRead: null,
|
||||||
|
markUnread: null,
|
||||||
|
move: new Map<string, string | null>()
|
||||||
|
};
|
||||||
|
|
||||||
|
const actionStart = Date.now();
|
||||||
|
try {
|
||||||
|
if (deleteItems.length) {
|
||||||
|
await logJobEvent(cleanupJobId, "info", `IMAP batch: DELETE ${deleteItems.length}`);
|
||||||
|
try {
|
||||||
|
await imapClient.messageDelete(deleteItems.map((item) => item.uid));
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
errors.delete = message;
|
||||||
|
await logJobEvent(cleanupJobId, "error", `IMAP delete batch failed: ${message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (markReadItems.length) {
|
||||||
|
await logJobEvent(cleanupJobId, "info", `IMAP batch: MARK_READ ${markReadItems.length}`);
|
||||||
|
try {
|
||||||
|
await imapClient.messageFlagsAdd(markReadItems.map((item) => item.uid), ["\\Seen"]);
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
errors.markRead = message;
|
||||||
|
await logJobEvent(cleanupJobId, "error", `IMAP mark-read batch failed: ${message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (markUnreadItems.length) {
|
||||||
|
await logJobEvent(cleanupJobId, "info", `IMAP batch: MARK_UNREAD ${markUnreadItems.length}`);
|
||||||
|
try {
|
||||||
|
await imapClient.messageFlagsRemove(markUnreadItems.map((item) => item.uid), ["\\Seen"]);
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
errors.markUnread = message;
|
||||||
|
await logJobEvent(cleanupJobId, "error", `IMAP mark-unread batch failed: ${message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [target, items] of moveItemsByTarget.entries()) {
|
||||||
|
let moveError: string | null = null;
|
||||||
|
try {
|
||||||
|
if (!imapMailboxCache.has(target)) {
|
||||||
|
await imapClient.mailboxCreate(target).catch(() => undefined);
|
||||||
|
imapMailboxCache.add(target);
|
||||||
|
}
|
||||||
|
await logJobEvent(cleanupJobId, "info", `IMAP batch: MOVE ${items.length} -> ${target}`);
|
||||||
|
await imapClient.messageMove(items.map((item) => item.uid), target);
|
||||||
|
} catch (err) {
|
||||||
|
moveError = err instanceof Error ? err.message : String(err);
|
||||||
|
await logJobEvent(cleanupJobId, "error", `IMAP move batch failed: ${moveError}`);
|
||||||
|
}
|
||||||
|
errors.move.set(target, moveError);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
routingSeconds += (Date.now() - actionStart) / 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalizeLog = (item: ImapBatchItem) => {
|
||||||
|
const deleteFailed = item.hasDelete && errors.delete;
|
||||||
|
const deleteSucceeded = item.hasDelete && !errors.delete;
|
||||||
|
const markAction = item.markAction;
|
||||||
|
const moveAction = item.moveAction;
|
||||||
|
const markFailed =
|
||||||
|
markAction?.type === "MARK_READ"
|
||||||
|
? errors.markRead
|
||||||
|
: markAction?.type === "MARK_UNREAD"
|
||||||
|
? errors.markUnread
|
||||||
|
: null;
|
||||||
|
const moveFailed = moveAction?.target ? errors.move.get(moveAction.target) ?? null : null;
|
||||||
|
|
||||||
|
for (const logItem of item.actionLogItems) {
|
||||||
|
if (item.hasDelete) {
|
||||||
|
if (logItem.type === "DELETE") {
|
||||||
|
logItem.status = deleteFailed ? "failed" : "applied";
|
||||||
|
if (deleteFailed) logItem.error = deleteFailed;
|
||||||
|
} else {
|
||||||
|
logItem.status = "skipped";
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (logItem.type === "MARK_READ" || logItem.type === "MARK_UNREAD") {
|
||||||
|
if (markAction && logItem.type === markAction.type) {
|
||||||
|
logItem.status = markFailed ? "failed" : "applied";
|
||||||
|
if (markFailed) logItem.error = markFailed;
|
||||||
|
} else {
|
||||||
|
logItem.status = "skipped";
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (logItem.type === "MOVE" || logItem.type === "ARCHIVE" || logItem.type === "LABEL") {
|
||||||
|
if (moveAction && logItem.type === moveAction.type && logItem.target === moveAction.target) {
|
||||||
|
logItem.status = moveFailed ? "failed" : "applied";
|
||||||
|
if (moveFailed) logItem.error = moveFailed ?? undefined;
|
||||||
|
} else {
|
||||||
|
logItem.status = "skipped";
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (logItem.type === "DELETE") {
|
||||||
|
logItem.status = deleteFailed ? "failed" : deleteSucceeded ? "applied" : "skipped";
|
||||||
|
if (deleteFailed) logItem.error = deleteFailed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const item of imapBatchQueue.items) {
|
||||||
|
finalizeLog(item);
|
||||||
|
await prisma.cleanupJobCandidate.update({
|
||||||
|
where: { id: item.candidateId },
|
||||||
|
data: { actions: item.actionLog }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
actionAttempts += imapBatchQueue.items.length;
|
||||||
|
imapBatchQueue.items = [];
|
||||||
|
};
|
||||||
|
|
||||||
for (let index = nextIndex; index < imapUids.length; index += checkpointEvery) {
|
for (let index = nextIndex; index < imapUids.length; index += checkpointEvery) {
|
||||||
const statusCheck = await prisma.cleanupJob.findUnique({ where: { id: cleanupJobId } });
|
const statusCheck = await prisma.cleanupJob.findUnique({ where: { id: cleanupJobId } });
|
||||||
@@ -766,10 +1053,11 @@ export const runCleanup = async (cleanupJobId: string, mailboxAccountId: string)
|
|||||||
processed += 1;
|
processed += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const isNewsletter = await processMessage({ ...msg, mailbox: targetMailbox });
|
const isNewsletter = await processMessage({ ...msg, mailbox: targetMailbox }, undefined, imapBatchQueue);
|
||||||
if (isNewsletter) newsletterCount += 1;
|
if (isNewsletter) newsletterCount += 1;
|
||||||
processed += 1;
|
processed += 1;
|
||||||
}
|
}
|
||||||
|
await flushImapBatch();
|
||||||
|
|
||||||
const nextProcessed = Math.min(total, index + batch.length);
|
const nextProcessed = Math.min(total, index + batch.length);
|
||||||
await saveProgress(nextProcessed, total);
|
await saveProgress(nextProcessed, total);
|
||||||
|
|||||||
@@ -72,17 +72,23 @@ const matchCondition = (condition: RuleCondition, ctx: {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const matchRules = (rules: (Rule & { conditions: RuleCondition[]; actions: RuleAction[] })[], ctx: {
|
export const matchRules = (
|
||||||
|
rules: (Rule & { conditions: RuleCondition[]; actions: RuleAction[] })[],
|
||||||
|
ctx: {
|
||||||
subject: string;
|
subject: string;
|
||||||
from: string;
|
from: string;
|
||||||
headers: Map<string, string>;
|
headers: Map<string, string>;
|
||||||
unsubscribeStatus?: string | null;
|
unsubscribeStatus?: string | null;
|
||||||
newsletterScore?: number | null;
|
newsletterScore?: number | null;
|
||||||
}) => {
|
},
|
||||||
|
phase?: "PRE_UNSUBSCRIBE" | "POST_UNSUBSCRIBE"
|
||||||
|
) => {
|
||||||
const matched: RuleAction[] = [];
|
const matched: RuleAction[] = [];
|
||||||
|
|
||||||
for (const rule of rules) {
|
for (const rule of rules) {
|
||||||
if (!rule.enabled) continue;
|
if (!rule.enabled) continue;
|
||||||
|
const rulePhase = rule.phase ?? "POST_UNSUBSCRIBE";
|
||||||
|
if (phase && rulePhase !== phase) continue;
|
||||||
const allMatch = rule.conditions.every((condition) => matchCondition(condition, ctx));
|
const allMatch = rule.conditions.every((condition) => matchCondition(condition, ctx));
|
||||||
const anyMatch = rule.conditions.some((condition) => matchCondition(condition, ctx));
|
const anyMatch = rule.conditions.some((condition) => matchCondition(condition, ctx));
|
||||||
const shouldApply = rule.matchMode === "ANY" ? anyMatch : allMatch;
|
const shouldApply = rule.matchMode === "ANY" ? anyMatch : allMatch;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const ruleSchema = z.object({
|
|||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
matchMode: z.enum(["ALL", "ANY"]).optional(),
|
matchMode: z.enum(["ALL", "ANY"]).optional(),
|
||||||
stopOnMatch: z.boolean().optional(),
|
stopOnMatch: z.boolean().optional(),
|
||||||
|
phase: z.enum(["PRE_UNSUBSCRIBE", "POST_UNSUBSCRIBE"]).optional(),
|
||||||
conditions: z.array(z.object({
|
conditions: z.array(z.object({
|
||||||
type: z.enum(["HEADER", "HEADER_MISSING", "SUBJECT", "FROM", "LIST_UNSUBSCRIBE", "LIST_ID", "UNSUBSCRIBE_STATUS", "SCORE"]),
|
type: z.enum(["HEADER", "HEADER_MISSING", "SUBJECT", "FROM", "LIST_UNSUBSCRIBE", "LIST_ID", "UNSUBSCRIBE_STATUS", "SCORE"]),
|
||||||
value: z.string().min(1)
|
value: z.string().min(1)
|
||||||
@@ -45,6 +46,7 @@ export async function rulesRoutes(app: FastifyInstance) {
|
|||||||
matchMode: input.matchMode ?? "ALL",
|
matchMode: input.matchMode ?? "ALL",
|
||||||
position: nextPosition,
|
position: nextPosition,
|
||||||
stopOnMatch: input.stopOnMatch ?? false,
|
stopOnMatch: input.stopOnMatch ?? false,
|
||||||
|
phase: input.phase ?? "POST_UNSUBSCRIBE",
|
||||||
conditions: {
|
conditions: {
|
||||||
create: input.conditions
|
create: input.conditions
|
||||||
},
|
},
|
||||||
@@ -109,6 +111,7 @@ export async function rulesRoutes(app: FastifyInstance) {
|
|||||||
enabled: input.enabled ?? true,
|
enabled: input.enabled ?? true,
|
||||||
matchMode: input.matchMode ?? "ALL",
|
matchMode: input.matchMode ?? "ALL",
|
||||||
stopOnMatch: input.stopOnMatch ?? false,
|
stopOnMatch: input.stopOnMatch ?? false,
|
||||||
|
phase: input.phase ?? "POST_UNSUBSCRIBE",
|
||||||
conditions: { create: input.conditions },
|
conditions: { create: input.conditions },
|
||||||
actions: { create: input.actions }
|
actions: { create: input.actions }
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ type Rule = {
|
|||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
matchMode?: "ALL" | "ANY";
|
matchMode?: "ALL" | "ANY";
|
||||||
stopOnMatch?: boolean;
|
stopOnMatch?: boolean;
|
||||||
|
phase?: "PRE_UNSUBSCRIBE" | "POST_UNSUBSCRIBE";
|
||||||
conditions: { type: string; value: string }[];
|
conditions: { type: string; value: string }[];
|
||||||
actions: { type: string; target?: string | null }[];
|
actions: { type: string; target?: string | null }[];
|
||||||
};
|
};
|
||||||
@@ -221,6 +222,7 @@ export default function App() {
|
|||||||
const [ruleEnabled, setRuleEnabled] = useState(true);
|
const [ruleEnabled, setRuleEnabled] = useState(true);
|
||||||
const [ruleMatchMode, setRuleMatchMode] = useState<"ALL" | "ANY">("ALL");
|
const [ruleMatchMode, setRuleMatchMode] = useState<"ALL" | "ANY">("ALL");
|
||||||
const [ruleStopOnMatch, setRuleStopOnMatch] = useState(false);
|
const [ruleStopOnMatch, setRuleStopOnMatch] = useState(false);
|
||||||
|
const [rulePhase, setRulePhase] = useState<"PRE_UNSUBSCRIBE" | "POST_UNSUBSCRIBE">("POST_UNSUBSCRIBE");
|
||||||
const [conditions, setConditions] = useState([{ ...defaultCondition }]);
|
const [conditions, setConditions] = useState([{ ...defaultCondition }]);
|
||||||
const [actions, setActions] = useState([{ ...defaultAction }]);
|
const [actions, setActions] = useState([{ ...defaultAction }]);
|
||||||
const [ruleModalOpen, setRuleModalOpen] = useState(false);
|
const [ruleModalOpen, setRuleModalOpen] = useState(false);
|
||||||
@@ -1122,6 +1124,7 @@ export default function App() {
|
|||||||
setRuleEnabled(true);
|
setRuleEnabled(true);
|
||||||
setRuleMatchMode("ALL");
|
setRuleMatchMode("ALL");
|
||||||
setRuleStopOnMatch(false);
|
setRuleStopOnMatch(false);
|
||||||
|
setRulePhase("POST_UNSUBSCRIBE");
|
||||||
setConditions([{ ...defaultCondition }]);
|
setConditions([{ ...defaultCondition }]);
|
||||||
setActions([{ ...defaultAction }]);
|
setActions([{ ...defaultAction }]);
|
||||||
setRuleModalOpen(true);
|
setRuleModalOpen(true);
|
||||||
@@ -1133,6 +1136,7 @@ export default function App() {
|
|||||||
setRuleEnabled(rule.enabled);
|
setRuleEnabled(rule.enabled);
|
||||||
setRuleMatchMode((rule.matchMode as "ALL" | "ANY") ?? "ALL");
|
setRuleMatchMode((rule.matchMode as "ALL" | "ANY") ?? "ALL");
|
||||||
setRuleStopOnMatch(Boolean(rule.stopOnMatch));
|
setRuleStopOnMatch(Boolean(rule.stopOnMatch));
|
||||||
|
setRulePhase(rule.phase ?? "POST_UNSUBSCRIBE");
|
||||||
setConditions(rule.conditions.map((condition) => ({ ...condition })));
|
setConditions(rule.conditions.map((condition) => ({ ...condition })));
|
||||||
setActions(rule.actions.map((action) => ({ ...action })));
|
setActions(rule.actions.map((action) => ({ ...action })));
|
||||||
setRuleModalOpen(true);
|
setRuleModalOpen(true);
|
||||||
@@ -1155,6 +1159,7 @@ export default function App() {
|
|||||||
enabled: ruleEnabled,
|
enabled: ruleEnabled,
|
||||||
matchMode: ruleMatchMode,
|
matchMode: ruleMatchMode,
|
||||||
stopOnMatch: ruleStopOnMatch,
|
stopOnMatch: ruleStopOnMatch,
|
||||||
|
phase: rulePhase,
|
||||||
conditions,
|
conditions,
|
||||||
actions
|
actions
|
||||||
})
|
})
|
||||||
@@ -1172,6 +1177,7 @@ export default function App() {
|
|||||||
enabled: ruleEnabled,
|
enabled: ruleEnabled,
|
||||||
matchMode: ruleMatchMode,
|
matchMode: ruleMatchMode,
|
||||||
stopOnMatch: ruleStopOnMatch,
|
stopOnMatch: ruleStopOnMatch,
|
||||||
|
phase: rulePhase,
|
||||||
conditions,
|
conditions,
|
||||||
actions
|
actions
|
||||||
})
|
})
|
||||||
@@ -1728,12 +1734,34 @@ export default function App() {
|
|||||||
if (match) return t("jobEventGmailActionAppliedList", { actions: mapActionList(match[1]) });
|
if (match) return t("jobEventGmailActionAppliedList", { actions: mapActionList(match[1]) });
|
||||||
match = trimmed.match(/^Gmail action skipped: no label changes$/i);
|
match = trimmed.match(/^Gmail action skipped: no label changes$/i);
|
||||||
if (match) return t("jobEventGmailActionSkippedNoChanges");
|
if (match) return t("jobEventGmailActionSkippedNoChanges");
|
||||||
|
match = trimmed.match(/^Gmail batch: MODIFY (\d+)$/i);
|
||||||
|
if (match) return t("jobEventGmailBatchModify", { count: match[1] });
|
||||||
|
match = trimmed.match(/^Gmail batch: DELETE (\d+)$/i);
|
||||||
|
if (match) return t("jobEventGmailBatchDelete", { count: match[1] });
|
||||||
match = trimmed.match(/^Gmail action (.+) applied$/i);
|
match = trimmed.match(/^Gmail action (.+) applied$/i);
|
||||||
if (match) return t("jobEventGmailActionApplied", { action: mapActionToken(match[1]) });
|
if (match) return t("jobEventGmailActionApplied", { action: mapActionToken(match[1]) });
|
||||||
match = trimmed.match(/^Gmail action failed: (.+)$/i);
|
match = trimmed.match(/^Gmail action failed: (.+)$/i);
|
||||||
if (match) return t("jobEventGmailActionFailedSimple", { error: match[1] });
|
if (match) return t("jobEventGmailActionFailedSimple", { error: match[1] });
|
||||||
match = trimmed.match(/^Gmail action (.+) failed: (.+)$/i);
|
match = trimmed.match(/^Gmail action (.+) failed: (.+)$/i);
|
||||||
if (match) return t("jobEventGmailActionFailed", { action: mapActionToken(match[1]), error: match[2] });
|
if (match) return t("jobEventGmailActionFailed", { action: mapActionToken(match[1]), error: match[2] });
|
||||||
|
match = trimmed.match(/^IMAP delete batch failed: (.+)$/i);
|
||||||
|
if (match) return t("jobEventImapDeleteBatchFailed", { error: match[1] });
|
||||||
|
match = trimmed.match(/^IMAP mark-read batch failed: (.+)$/i);
|
||||||
|
if (match) return t("jobEventImapMarkReadBatchFailed", { error: match[1] });
|
||||||
|
match = trimmed.match(/^IMAP mark-unread batch failed: (.+)$/i);
|
||||||
|
if (match) return t("jobEventImapMarkUnreadBatchFailed", { error: match[1] });
|
||||||
|
match = trimmed.match(/^IMAP move batch failed: (.+)$/i);
|
||||||
|
if (match) return t("jobEventImapMoveBatchFailed", { error: match[1] });
|
||||||
|
match = trimmed.match(/^IMAP batch: DELETE (\d+)$/i);
|
||||||
|
if (match) return t("jobEventImapBatchDelete", { count: match[1] });
|
||||||
|
match = trimmed.match(/^IMAP batch: MARK_READ (\d+)$/i);
|
||||||
|
if (match) return t("jobEventImapBatchMarkRead", { count: match[1] });
|
||||||
|
match = trimmed.match(/^IMAP batch: MARK_UNREAD (\d+)$/i);
|
||||||
|
if (match) return t("jobEventImapBatchMarkUnread", { count: match[1] });
|
||||||
|
match = trimmed.match(/^IMAP batch: MOVE (\d+) -> (.+)$/i);
|
||||||
|
if (match) return t("jobEventImapBatchMove", { count: match[1], target: match[2] });
|
||||||
|
match = trimmed.match(/^IMAP action failed: (.+)$/i);
|
||||||
|
if (match) return t("jobEventImapActionFailedSimple", { error: match[1] });
|
||||||
match = trimmed.match(/^IMAP action (.+) failed: (.+)$/i);
|
match = trimmed.match(/^IMAP action (.+) failed: (.+)$/i);
|
||||||
if (match) return t("jobEventImapActionFailed", { action: mapActionToken(match[1]), error: match[2] });
|
if (match) return t("jobEventImapActionFailed", { action: mapActionToken(match[1]), error: match[2] });
|
||||||
match = trimmed.match(/^Job canceled by admin$/i);
|
match = trimmed.match(/^Job canceled by admin$/i);
|
||||||
@@ -2285,6 +2313,9 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="rule-tail">
|
<div className="rule-tail">
|
||||||
<div className="rule-flags">
|
<div className="rule-flags">
|
||||||
|
<span className="rule-badge">
|
||||||
|
{rule.phase === "PRE_UNSUBSCRIBE" ? t("rulePhasePreBadge") : t("rulePhasePostBadge")}
|
||||||
|
</span>
|
||||||
{rule.stopOnMatch && (
|
{rule.stopOnMatch && (
|
||||||
<span className="rule-badge rule-badge-strong">{t("rulesStopOnMatchBadge")}</span>
|
<span className="rule-badge rule-badge-strong">{t("rulesStopOnMatchBadge")}</span>
|
||||||
)}
|
)}
|
||||||
@@ -2567,6 +2598,17 @@ export default function App() {
|
|||||||
<option value="ANY">{t("rulesMatchAny")}</option>
|
<option value="ANY">{t("rulesMatchAny")}</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
<label className="field-row">
|
||||||
|
<span>{t("rulesPhase")}</span>
|
||||||
|
<select
|
||||||
|
value={rulePhase}
|
||||||
|
onChange={(event) => setRulePhase(event.target.value as "PRE_UNSUBSCRIBE" | "POST_UNSUBSCRIBE")}
|
||||||
|
>
|
||||||
|
<option value="PRE_UNSUBSCRIBE">{t("rulesPhasePre")}</option>
|
||||||
|
<option value="POST_UNSUBSCRIBE">{t("rulesPhasePost")}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<p className="hint-text">{t("rulesPhaseHint")}</p>
|
||||||
<label className="toggle">
|
<label className="toggle">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
|||||||
@@ -135,7 +135,13 @@
|
|||||||
"rulesMatchAll": "Alle Bedingungen (UND)",
|
"rulesMatchAll": "Alle Bedingungen (UND)",
|
||||||
"rulesMatchAny": "Mindestens eine (ODER)",
|
"rulesMatchAny": "Mindestens eine (ODER)",
|
||||||
"rulesMatchAnyLabel": "ODER",
|
"rulesMatchAnyLabel": "ODER",
|
||||||
|
"rulesPhase": "Ausführungsphase",
|
||||||
|
"rulesPhasePre": "Vor Abmelden",
|
||||||
|
"rulesPhasePost": "Nach Abmelden",
|
||||||
|
"rulesPhaseHint": "Vor-Regeln laufen vor dem Abmelden (Abmelde-Status noch nicht verfügbar). Nach-Regeln laufen danach.",
|
||||||
"rulesStopOnMatch": "Nach Treffer stoppen (erste Regel gewinnt)",
|
"rulesStopOnMatch": "Nach Treffer stoppen (erste Regel gewinnt)",
|
||||||
|
"rulePhasePreBadge": "Vor Abmelden",
|
||||||
|
"rulePhasePostBadge": "Nach Abmelden",
|
||||||
"rulesStopOnMatchBadge": "ERSTE",
|
"rulesStopOnMatchBadge": "ERSTE",
|
||||||
"rulesConditions": "Bedingungen",
|
"rulesConditions": "Bedingungen",
|
||||||
"rulesActions": "Aktionen",
|
"rulesActions": "Aktionen",
|
||||||
@@ -269,9 +275,20 @@
|
|||||||
"jobEventProcessedCount": "Verarbeitet {{current}}",
|
"jobEventProcessedCount": "Verarbeitet {{current}}",
|
||||||
"jobEventGmailActionAppliedList": "Gmail‑Aktion angewendet: {{actions}}",
|
"jobEventGmailActionAppliedList": "Gmail‑Aktion angewendet: {{actions}}",
|
||||||
"jobEventGmailActionApplied": "Gmail‑Aktion angewendet: {{action}}",
|
"jobEventGmailActionApplied": "Gmail‑Aktion angewendet: {{action}}",
|
||||||
|
"jobEventGmailBatchModify": "Gmail‑Batch: MODIFY {{count}}",
|
||||||
|
"jobEventGmailBatchDelete": "Gmail‑Batch: DELETE {{count}}",
|
||||||
"jobEventGmailActionSkippedNoChanges": "Gmail‑Aktion übersprungen: keine Label‑Änderungen",
|
"jobEventGmailActionSkippedNoChanges": "Gmail‑Aktion übersprungen: keine Label‑Änderungen",
|
||||||
"jobEventGmailActionFailedSimple": "Gmail‑Aktion fehlgeschlagen: {{error}}",
|
"jobEventGmailActionFailedSimple": "Gmail‑Aktion fehlgeschlagen: {{error}}",
|
||||||
"jobEventGmailActionFailed": "Gmail‑Aktion fehlgeschlagen ({{action}}): {{error}}",
|
"jobEventGmailActionFailed": "Gmail‑Aktion fehlgeschlagen ({{action}}): {{error}}",
|
||||||
|
"jobEventImapDeleteBatchFailed": "IMAP‑Lösch‑Batch fehlgeschlagen: {{error}}",
|
||||||
|
"jobEventImapMarkReadBatchFailed": "IMAP‑Lesen‑Batch fehlgeschlagen: {{error}}",
|
||||||
|
"jobEventImapMarkUnreadBatchFailed": "IMAP‑Ungelesen‑Batch fehlgeschlagen: {{error}}",
|
||||||
|
"jobEventImapMoveBatchFailed": "IMAP‑Verschieben‑Batch fehlgeschlagen: {{error}}",
|
||||||
|
"jobEventImapBatchDelete": "IMAP‑Batch: DELETE {{count}}",
|
||||||
|
"jobEventImapBatchMarkRead": "IMAP‑Batch: MARK_READ {{count}}",
|
||||||
|
"jobEventImapBatchMarkUnread": "IMAP‑Batch: MARK_UNREAD {{count}}",
|
||||||
|
"jobEventImapBatchMove": "IMAP‑Batch: MOVE {{count}} → {{target}}",
|
||||||
|
"jobEventImapActionFailedSimple": "IMAP‑Aktion fehlgeschlagen: {{error}}",
|
||||||
"jobEventImapActionFailed": "IMAP‑Aktion fehlgeschlagen ({{action}}): {{error}}",
|
"jobEventImapActionFailed": "IMAP‑Aktion fehlgeschlagen ({{action}}): {{error}}",
|
||||||
"jobEventDryRunAction": "Nur simulieren: {{action}}",
|
"jobEventDryRunAction": "Nur simulieren: {{action}}",
|
||||||
"jobEventCanceledByAdmin": "Job vom Admin abgebrochen",
|
"jobEventCanceledByAdmin": "Job vom Admin abgebrochen",
|
||||||
|
|||||||
@@ -135,7 +135,13 @@
|
|||||||
"rulesMatchAll": "All conditions (AND)",
|
"rulesMatchAll": "All conditions (AND)",
|
||||||
"rulesMatchAny": "Any condition (OR)",
|
"rulesMatchAny": "Any condition (OR)",
|
||||||
"rulesMatchAnyLabel": "OR",
|
"rulesMatchAnyLabel": "OR",
|
||||||
|
"rulesPhase": "Execution phase",
|
||||||
|
"rulesPhasePre": "Before unsubscribe",
|
||||||
|
"rulesPhasePost": "After unsubscribe",
|
||||||
|
"rulesPhaseHint": "Pre rules run before unsubscribe (unsubscribe status not available). Post rules run after unsubscribe.",
|
||||||
"rulesStopOnMatch": "Stop after match (first match wins)",
|
"rulesStopOnMatch": "Stop after match (first match wins)",
|
||||||
|
"rulePhasePreBadge": "Pre‑unsubscribe",
|
||||||
|
"rulePhasePostBadge": "Post‑unsubscribe",
|
||||||
"rulesStopOnMatchBadge": "FIRST",
|
"rulesStopOnMatchBadge": "FIRST",
|
||||||
"rulesConditions": "Conditions",
|
"rulesConditions": "Conditions",
|
||||||
"rulesActions": "Actions",
|
"rulesActions": "Actions",
|
||||||
@@ -269,9 +275,20 @@
|
|||||||
"jobEventProcessedCount": "Processed {{current}}",
|
"jobEventProcessedCount": "Processed {{current}}",
|
||||||
"jobEventGmailActionAppliedList": "Gmail action applied: {{actions}}",
|
"jobEventGmailActionAppliedList": "Gmail action applied: {{actions}}",
|
||||||
"jobEventGmailActionApplied": "Gmail action applied: {{action}}",
|
"jobEventGmailActionApplied": "Gmail action applied: {{action}}",
|
||||||
|
"jobEventGmailBatchModify": "Gmail batch: MODIFY {{count}}",
|
||||||
|
"jobEventGmailBatchDelete": "Gmail batch: DELETE {{count}}",
|
||||||
"jobEventGmailActionSkippedNoChanges": "Gmail action skipped: no label changes",
|
"jobEventGmailActionSkippedNoChanges": "Gmail action skipped: no label changes",
|
||||||
"jobEventGmailActionFailedSimple": "Gmail action failed: {{error}}",
|
"jobEventGmailActionFailedSimple": "Gmail action failed: {{error}}",
|
||||||
"jobEventGmailActionFailed": "Gmail action failed ({{action}}): {{error}}",
|
"jobEventGmailActionFailed": "Gmail action failed ({{action}}): {{error}}",
|
||||||
|
"jobEventImapDeleteBatchFailed": "IMAP delete batch failed: {{error}}",
|
||||||
|
"jobEventImapMarkReadBatchFailed": "IMAP mark-read batch failed: {{error}}",
|
||||||
|
"jobEventImapMarkUnreadBatchFailed": "IMAP mark-unread batch failed: {{error}}",
|
||||||
|
"jobEventImapMoveBatchFailed": "IMAP move batch failed: {{error}}",
|
||||||
|
"jobEventImapBatchDelete": "IMAP batch: DELETE {{count}}",
|
||||||
|
"jobEventImapBatchMarkRead": "IMAP batch: MARK_READ {{count}}",
|
||||||
|
"jobEventImapBatchMarkUnread": "IMAP batch: MARK_UNREAD {{count}}",
|
||||||
|
"jobEventImapBatchMove": "IMAP batch: MOVE {{count}} → {{target}}",
|
||||||
|
"jobEventImapActionFailedSimple": "IMAP action failed: {{error}}",
|
||||||
"jobEventImapActionFailed": "IMAP action failed ({{action}}): {{error}}",
|
"jobEventImapActionFailed": "IMAP action failed ({{action}}): {{error}}",
|
||||||
"jobEventDryRunAction": "Simulate only: {{action}}",
|
"jobEventDryRunAction": "Simulate only: {{action}}",
|
||||||
"jobEventCanceledByAdmin": "Job canceled by admin",
|
"jobEventCanceledByAdmin": "Job canceled by admin",
|
||||||
|
|||||||
Reference in New Issue
Block a user