diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..31ff65f --- /dev/null +++ b/.gitattributes @@ -0,0 +1,12 @@ +# Shell scripts MUST keep LF line endings. +# +# core.autocrlf=true (set on this Windows checkout) rewrites files to CRLF when +# they land in the working tree. For a .sh file that is bind-mounted into a +# Linux container this is fatal rather than cosmetic: the kernel reads the +# shebang as `/bin/bash\r`, which is not a real path, so exec() fails with +# ENOENT — Docker reports it as "no such file or directory" even though the +# script is right there. A trailing \r on every other line also poisons the +# value of any variable assigned from it. +# +# This attribute overrides autocrlf for shell scripts on every platform. +*.sh text eol=lf diff --git a/.gitignore b/.gitignore index 55e3689..548b7b6 100644 --- a/.gitignore +++ b/.gitignore @@ -43,6 +43,7 @@ docker/certs/ .pnpm-store/ .claude/ .data/ +.superpowers/ # OnlyOffice custom fonts live in docker/onlyoffice/fonts/ on each # deployment. Keep the directory (via .keep) but never commit the diff --git a/app/automation/components/MailboxManager.tsx b/app/automation/components/MailboxManager.tsx new file mode 100644 index 0000000..636f3b6 --- /dev/null +++ b/app/automation/components/MailboxManager.tsx @@ -0,0 +1,424 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { useTranslations } from "next-intl"; +import { toast } from "sonner"; +import axios from "@/lib/axios"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet"; +import { getApiErrorMessage } from "@/lib/apiError"; +import { + EMPTY_MAILBOX_FORM, + firstMailboxFormIssue, + mailboxUpdatePayload, + type MailboxFormIssue, + type MailboxFormState, +} from "@/lib/automation/mailbox-form"; +import { normalizeMailboxId, type MailboxOption } from "@/lib/automation/mailbox-id"; + +/** + * 邮箱列表项:`GET /api/v1/automation-mailboxes` 的返回形状。 + * + * `lastError` 目前由接口决定是否下发(列归属模块 E.1):本任务只渲染拿到的东西, + * 有值才显示,不去发明一个服务端还没有的字段。 + */ +export type MailboxManagerItem = MailboxOption & { lastError?: string }; + +type MailboxManagerProps = { + open: boolean; + onClose: () => void; + /** + * 编辑 / 删除成功后通知宿主页刷新它的邮箱名单。 + * + * 向导的名单只在页面挂载时拉一次(有意为之:反复重拉会把用户正在做的选择冲掉), + * 不通知的话抽屉里改完、向导里还是旧数据,用户会以为没保存成功。 + */ + onMailboxesChanged?: () => void; +}; + +const MAILBOX_ENDPOINT = "/api/v1/automation-mailboxes"; + +/** 编辑表单初值:只回填可编辑字段,**密码恒为空**(服务端从不回传已存密码)。 */ +function formFromItem(item: MailboxManagerItem): MailboxFormState { + return { + name: String(item?.name ?? ""), + email: String(item?.email ?? ""), + username: String(item?.username ?? ""), + password: "", + imapHost: String(item?.imapHost ?? ""), + imapPort: String(item?.imapPort ?? 993), + folder: String(item?.folder ?? "INBOX"), + }; +} + +export default function MailboxManager({ open, onClose, onMailboxesChanged }: MailboxManagerProps) { + const t = useTranslations("automation"); + + function issueText(issue: MailboxFormIssue) { + if (issue === "email") return t("mailboxManagerIssueEmail"); + if (issue === "username") return t("mailboxManagerIssueUsername"); + if (issue === "imapHost") return t("mailboxManagerIssueImapHost"); + return t("mailboxManagerIssueImapPort"); + } + + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(false); + const [loadError, setLoadError] = useState(""); + + const [editingId, setEditingId] = useState(null); + const [editForm, setEditForm] = useState(EMPTY_MAILBOX_FORM); + const [editIssue, setEditIssue] = useState(null); + const [editError, setEditError] = useState(""); + const [saving, setSaving] = useState(false); + + const [confirmingDeleteId, setConfirmingDeleteId] = useState(null); + const [deletingId, setDeletingId] = useState(null); + + const loadMailboxes = useCallback(async () => { + setLoading(true); + setLoadError(""); + try { + const response = await axios.get(MAILBOX_ENDPOINT, { suppressErrorToast: true } as never); + const list = Array.isArray(response.data?.items) ? response.data.items : []; + // 拿不到合法 id 的行不渲染:它们点不动、也编辑不了。 + setItems(list.filter((item: MailboxManagerItem) => normalizeMailboxId(item?.id) !== null)); + } catch (error) { + setLoadError(getApiErrorMessage(error, t("mailboxManagerLoadFailed"))); + } finally { + setLoading(false); + } + }, [t]); + + useEffect(() => { + if (!open) return; + // 每次打开都重新拉:这个抽屉的职责就是看当前状态,展示缓存比展示空更糟。 + setEditingId(null); + setConfirmingDeleteId(null); + void loadMailboxes(); + }, [open, loadMailboxes]); + + function startEdit(item: MailboxManagerItem) { + const mailboxId = normalizeMailboxId(item?.id); + if (mailboxId === null) return; + setEditingId(mailboxId); + setEditForm(formFromItem(item)); + setEditIssue(null); + setEditError(""); + setConfirmingDeleteId(null); + } + + function cancelEdit() { + setEditingId(null); + setEditIssue(null); + setEditError(""); + } + + function updateField(field: keyof MailboxFormState, value: string) { + setEditForm((current) => ({ ...current, [field]: value })); + } + + async function saveEdit() { + if (editingId === null) return; + + // 密码可留空(留空=不修改);其余字段与新建同一套规则。 + const issue = firstMailboxFormIssue(editForm, { passwordOptional: true }); + setEditIssue(issue); + setEditError(""); + if (issue) return; + + setSaving(true); + try { + const response = await axios.put( + `${MAILBOX_ENDPOINT}/${editingId}`, + mailboxUpdatePayload(editForm), + { suppressErrorToast: true } as never + ); + const updated = response.data as MailboxManagerItem; + setItems((current) => + current.map((item) => + normalizeMailboxId(item.id) === editingId ? { ...item, ...updated } : item + ) + ); + setEditingId(null); + toast.success(t("mailboxManagerUpdated")); + onMailboxesChanged?.(); + } catch (error) { + // 接口在写入前会真实连一次 IMAP,连接失败的原因(主机、端口、授权码)原样显示。 + setEditError(getApiErrorMessage(error, t("mailboxManagerSaveFailed"))); + } finally { + setSaving(false); + } + } + + async function removeMailbox(mailboxId: number) { + setDeletingId(mailboxId); + try { + await axios.delete(`${MAILBOX_ENDPOINT}/${mailboxId}`, { + suppressErrorToast: true, + } as never); + setItems((current) => current.filter((item) => normalizeMailboxId(item.id) !== mailboxId)); + setConfirmingDeleteId(null); + if (editingId === mailboxId) setEditingId(null); + toast.success(t("mailboxManagerDeleted")); + onMailboxesChanged?.(); + } catch (error) { + const dependents = (error as { response?: { data?: { dependents?: unknown } } })?.response + ?.data?.dependents; + if (Array.isArray(dependents)) { + // 409 + 依赖清单:本地化文案比原文更清楚,且能把数量说进句子里。 + toast.error(t("mailboxManagerDeleteBlocked", { count: dependents.length })); + } else { + toast.error(getApiErrorMessage(error, t("mailboxManagerDeleteFailed"))); + } + } finally { + setDeletingId(null); + } + } + + return ( + { + if (!next) onClose(); + }} + > + + + {t("mailboxManagerTitle")} + {t("mailboxManagerDescription")} + + + {loading && ( +
+ {t("mailboxManagerLoading")} +
+ )} + + {!loading && loadError && ( +
+ {loadError} +
+ )} + + {!loading && !loadError && items.length === 0 && ( +
+
{t("mailboxManagerEmpty")}
+
{t("mailboxManagerEmptyHint")}
+
+ )} + +
+ {items.map((item) => { + const mailboxId = normalizeMailboxId(item.id); + if (mailboxId === null) return null; + + const status = String(item.status ?? "connected"); + const isError = status === "error"; + const lastError = String(item.lastError ?? "").trim(); + const editing = editingId === mailboxId; + const confirming = confirmingDeleteId === mailboxId; + const busy = deletingId === mailboxId; + + return ( +
+
+
+
+ + {String(item.name || item.email || "")} + + + {isError + ? t("mailboxManagerStatusError") + : t("mailboxManagerStatusConnected")} + +
+
{item.email}
+
+ {t("mailboxManagerImapServer")}: {item.imapHost}:{item.imapPort} + {item.folder ? ` · ${item.folder}` : ""} +
+
+ {t("mailboxManagerLastError")}: {lastError || t("mailboxManagerNoError")} +
+
+ +
+ {confirming ? ( + <> + + {t("mailboxManagerDeleteConfirm")} + + + + + ) : ( + <> + + + + )} +
+
+ + {editing && ( +
+
+
+ + updateField("name", event.target.value)} + /> +
+ +
+ + updateField("email", event.target.value)} + /> +
+ +
+ + updateField("username", event.target.value)} + /> +
+ +
+ + updateField("password", event.target.value)} + /> + + {t("mailboxManagerPasswordHint")} + +
+ +
+ + updateField("imapHost", event.target.value)} + /> +
+ +
+ + updateField("imapPort", event.target.value)} + /> +
+ +
+ + updateField("folder", event.target.value)} + /> +
+
+ +

+ {t("mailboxManagerCursorHint")} +

+ + {editIssue && ( +

{issueText(editIssue)}

+ )} + {editError &&

{editError}

} + +
+ + +
+
+ )} +
+ ); + })} +
+
+
+ ); +} diff --git a/app/automation/page.tsx b/app/automation/page.tsx index f93a87c..d87d6d5 100644 --- a/app/automation/page.tsx +++ b/app/automation/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import axios from "@/lib/axios"; import { toast } from "sonner"; import { useLocale } from "next-intl"; @@ -8,6 +8,7 @@ import { Bell, Clock3, Mail, + Settings2, Webhook, GitBranch, LayoutGrid, @@ -18,6 +19,41 @@ import { Trash2, X, } from "lucide-react"; +import MailboxManager from "./components/MailboxManager"; +import { + MAIL_RULE_FIELDS, + MAIL_RULE_OPERATORS, + doesMailRuleMatch, + doesMailRuleSetMatch, + mailConflictLevel, + mailRuleSource, + mailRulesSummary, + mailRuleText, + type MailRuleField, + type MailRuleMessage, + type MailRuleMode, + type MailRuleOperator, + type MailRuleSet, + type MailTriggerRule, +} from "@/lib/automation/mail-rules"; +import { + MAILBOX_NEW_OPTION, + automationMailboxLabel, + defaultMailboxSelection, + isMailboxSelectionUnresolved, + mailboxOptionLabel, + mailboxSelectValue, + normalizeMailboxId, + selectMailboxScopedAutomations, + type MailboxOption, +} from "@/lib/automation/mailbox-id"; +import { + EMPTY_MAILBOX_FORM, + firstMailboxFormIssue, + mailboxCreatePayload, + type MailboxFormIssue, + type MailboxFormState, +} from "@/lib/automation/mailbox-form"; type AutomationStatus = "running" | "paused" | "error"; type RunStatus = @@ -31,32 +67,6 @@ type RunStatus = | "timed_out"; type TriggerType = "定时触发" | "邮件触发" | "Webhook / API" | "自动化完成触发"; type StrategyType = "仅生成结果" | "需要确认后执行" | "自动执行"; -type MailRuleMode = "all" | "any"; -type MailRuleField = - | "发件人" - | "发件人域名" - | "收件人" - | "邮件主题" - | "邮件正文" - | "是否包含附件" - | "附件名称" - | "附件类型"; -type MailRuleOperator = "等于" | "包含" | "不包含" | "开头是" | "结尾是" | "是否存在"; -interface MailTriggerRule { - id: string; - field: MailRuleField; - operator: MailRuleOperator; - value: string; -} - -type MailRuleTestMessage = { - from: string; - to: string; - subject: string; - body: string; - attachments: string[]; -}; - interface AppOption { id: number; name: string; @@ -90,12 +100,11 @@ interface Automation { scheduleDayOfMonth?: number; scheduleMissingDayPolicy?: "last_day" | "skip"; scheduleDate?: string; - mailboxKey?: string; + mailboxId?: number | null; mailboxLabel?: string; mailFolder?: string; mailRuleMode?: MailRuleMode; mailRules?: MailTriggerRule[]; - mailPriority?: number; upstreamAutomationId?: number | null; upstreamCondition?: string; passPreviousResult?: boolean; @@ -183,18 +192,19 @@ interface AutomationNotification { readAt?: string; } +// 优先级机制已下线:服务端不再产生 suppressed_by_priority,但库里仍有存量行写着它, +// 统计接口的 recent 列表还会把这些老行返回。保留该成员只为给存量行一个可读文案 +// (见下方 outcomeText),不要为它新增任何写入路径。 type EmailRoutingOutcome = "triggered" | "suppressed_by_priority" | "not_matched" | "duplicate"; interface EmailRoutingEvent { id: number; - mailboxKey?: string; + mailboxId?: number; messageKey?: string; messageUid?: number; automationId: number; outcome: EmailRoutingOutcome; - winnerAutomationId?: number; matchedRule?: string; - priority?: number; from?: string; to?: string; subject?: string; @@ -202,11 +212,12 @@ interface EmailRoutingEvent { createdAt?: string; } +// 各计数器相互独立、不是对 scanned 的划分:存量 suppressed_by_priority 行计入 +// scanned 与 matched,但不落在任何一个计数桶里,所以不要假设它们能相加成 scanned。 interface EmailRoutingStats { scanned: number; matched: number; triggered: number; - suppressed: number; notMatched: number; duplicate: number; recent: EmailRoutingEvent[]; @@ -216,7 +227,6 @@ const emptyEmailRoutingStats: EmailRoutingStats = { scanned: 0, matched: 0, triggered: 0, - suppressed: 0, notMatched: 0, duplicate: 0, recent: [], @@ -226,18 +236,6 @@ const CHAIN_PROCESSED_STORAGE_KEY = "ragent_chain_processed_runs_v1"; const WEBHOOK_POLL_INTERVAL_MS = 5000; -const MAIL_RULE_FIELDS: MailRuleField[] = [ - "发件人", - "发件人域名", - "收件人", - "邮件主题", - "邮件正文", - "是否包含附件", - "附件名称", - "附件类型", -]; -const MAIL_RULE_OPERATORS: MailRuleOperator[] = ["等于", "包含", "不包含", "开头是", "结尾是", "是否存在"]; - function newMailRule(): MailTriggerRule { return { id: `mail-rule-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, @@ -247,47 +245,9 @@ function newMailRule(): MailTriggerRule { }; } -function mailRuleText(rule: MailTriggerRule) { - if (rule.operator === "是否存在" || rule.field === "是否包含附件") { - return `${rule.field}${rule.value || "是"}`; - } - return `${rule.field}${rule.operator}“${rule.value}”`; -} - -function mailRulesSummary(item: Automation) { - const rules = Array.isArray(item.mailRules) ? item.mailRules : []; - if (rules.length === 0) return "收到新邮件即触发"; - const prefix = item.mailRuleMode === "any" ? "任一" : "全部"; - return `${prefix}:${rules.map(mailRuleText).join(";")}`; -} - -function normalizedMailRule(rule: MailTriggerRule) { - return `${rule.field}|${rule.operator}|${String(rule.value || "").trim().toLowerCase()}`; -} - -function mailRuleSetsEqual( - leftRules: MailTriggerRule[], - leftMode: MailRuleMode, - rightRules: MailTriggerRule[], - rightMode: MailRuleMode, -) { - if (leftMode !== rightMode || leftRules.length !== rightRules.length) return false; - const left = leftRules.map(normalizedMailRule).sort(); - const right = rightRules.map(normalizedMailRule).sort(); - return left.every((value, index) => value === right[index]); -} - -function mailConflictLevel( - currentRules: MailTriggerRule[], - currentMode: MailRuleMode, - other: Automation, -): "high" | "possible" { - const otherRules = Array.isArray(other.mailRules) ? other.mailRules : []; - const otherMode = other.mailRuleMode === "any" ? "any" : "all"; - - if (currentRules.length === 0 || otherRules.length === 0) return "high"; - if (mailRuleSetsEqual(currentRules, currentMode, otherRules, otherMode)) return "high"; - return "possible"; +// Automation(接口返回)到规范化规则集的适配;规则逻辑本身在 mail-rules.ts。 +function automationMailRuleSet(item: Automation): MailRuleSet { + return { rules: item.mailRules, mode: item.mailRuleMode }; } function mailFolderDisplay(value: unknown) { @@ -304,65 +264,6 @@ function emailContextText(value: unknown, fallback = "-") { return text || fallback; } -function extractMailSenderDomain(value?: string) { - const match = String(value || "").match(/@([^>\s,;]+)/); - return match?.[1]?.toLowerCase() || ""; -} - -function mailAttachmentExtensions(names?: string[]) { - return (Array.isArray(names) ? names : []) - .map((item) => { - const match = String(item).toLowerCase().match(/(\.[a-z0-9]+)$/i); - return match?.[1] || ""; - }) - .filter(Boolean) - .join(" "); -} - -function mailRuleTestSource(rule: MailTriggerRule, message: MailRuleTestMessage) { - const attachments = Array.isArray(message.attachments) ? message.attachments : []; - switch (rule.field) { - case "发件人": return String(message.from || ""); - case "发件人域名": return extractMailSenderDomain(message.from); - case "收件人": return String(message.to || ""); - case "邮件主题": return String(message.subject || ""); - case "邮件正文": return String(message.body || ""); - case "是否包含附件": return attachments.length > 0 ? "是" : "否"; - case "附件名称": return attachments.join(" "); - case "附件类型": return mailAttachmentExtensions(attachments); - default: return ""; - } -} - -function doesMailRuleTestMatch(rule: MailTriggerRule, message: MailRuleTestMessage) { - const source = mailRuleTestSource(rule, message).toLowerCase(); - const wanted = String(rule.value || "").trim().toLowerCase(); - - if (rule.operator === "是否存在" || rule.field === "是否包含附件") { - const exists = rule.field === "是否包含附件" ? source === "是" : source.trim().length > 0; - const wantExists = !["否", "false", "0", "no"].includes(wanted || "是"); - return exists === wantExists; - } - - if (!wanted) return false; - if (rule.operator === "等于") return source.trim() === wanted; - if (rule.operator === "包含") return source.includes(wanted); - if (rule.operator === "不包含") return !source.includes(wanted); - if (rule.operator === "开头是") return source.startsWith(wanted); - if (rule.operator === "结尾是") return source.endsWith(wanted); - return false; -} - -function doMailRulesTestMatch( - rules: MailTriggerRule[], - mode: MailRuleMode, - message: MailRuleTestMessage, -) { - if (rules.length === 0) return true; - const results = rules.map((rule) => doesMailRuleTestMatch(rule, message)); - return mode === "any" ? results.some(Boolean) : results.every(Boolean); -} - function splitMailTestAttachments(value: string) { return String(value || "") .split(/[\n,,]/) @@ -370,6 +271,12 @@ function splitMailTestAttachments(value: string) { .filter(Boolean); } +// 接口错误文案提取:不使用 any,避免新增 lint 诊断。 +function apiErrorDetail(error: unknown) { + const detail = (error as { response?: { data?: { detail?: unknown } } })?.response?.data?.detail; + return typeof detail === "string" ? detail : ""; +} + const initialAutomations: Automation[] = []; const automationTemplates: AutomationTemplate[] = [ @@ -384,7 +291,7 @@ const automationTemplates: AutomationTemplate[] = [ { id: "customer-inquiry-mail", name: "客户询价处理", - description: "系统邮箱收到新邮件后,由数字员工提取客户需求并生成处理建议。", + description: "监听邮箱收到新邮件后,由数字员工提取客户需求并生成处理建议。", trigger: "邮件触发", strategy: "自动执行", task: "读取新邮件中的发件人、主题、正文和附件信息,提取客户需求、产品、数量与交期,并生成清晰的处理建议。", @@ -408,7 +315,7 @@ const automationTemplates: AutomationTemplate[] = [ { id: "complaint-processing", name: "售后投诉处理", - description: "系统邮箱收到售后邮件后自动分析投诉内容并输出处理建议。", + description: "监听邮箱收到售后邮件后自动分析投诉内容并输出处理建议。", trigger: "邮件触发", strategy: "需要确认后执行", task: "分析新收到的售后邮件,识别投诉类型、紧急程度和核心诉求,并生成建议处理方案。", @@ -434,7 +341,7 @@ const AUTOMATION_TEMPLATE_EN: Record< }, "customer-inquiry-mail": { name: "Customer Inquiry Processing", - description: "When the system mailbox receives a new email, a digital employee extracts customer needs and generates handling suggestions.", + description: "When the monitored mailbox receives a new email, a digital employee extracts customer needs and generates handling suggestions.", task: "Read the sender, subject, body, and attachment information from the new email. Extract customer needs, products, quantity, and delivery date, then generate clear handling suggestions.", }, "order-risk-check": { @@ -449,7 +356,7 @@ const AUTOMATION_TEMPLATE_EN: Record< }, "complaint-processing": { name: "After-sales Complaint Processing", - description: "Automatically analyze after-sales emails received by the system mailbox and generate handling suggestions.", + description: "Automatically analyze after-sales emails received by the monitored mailbox and generate handling suggestions.", task: "Analyze the newly received after-sales email, identify the complaint type, urgency, and core request, and generate a recommended handling plan.", }, "inventory-check": { @@ -459,15 +366,6 @@ const AUTOMATION_TEMPLATE_EN: Record< }, }; -const LEGACY_DEMO_AUTOMATION_NAMES = new Set([ - "每日经营日报", - "客户询价处理", - "订单风险检测", - "销售日报分析", - "售后投诉分级", - "库存异常检查", -]); - const initialRunRecords: RunRecord[] = []; function StatusBadge({ status, text }: { status: AutomationStatus | RunStatus; text: string }) { const cls = @@ -625,10 +523,19 @@ export default function AutomationPage() { const [mailResultEmail, setMailResultEmail] = useState(""); const [resultEmailIncludeAttachments, setResultEmailIncludeAttachments] = useState(true); - // 邮件触发固定使用系统设置中的单一系统邮箱,不允许在自动化页面新增或切换邮箱。 + // 监听邮箱由用户自己配置(系统邮箱已下线):mailboxId 指向 automation_mailboxes, + // mailboxFormOpen 表示"正在内联配置新邮箱",此时尚未有可提交的 mailboxId。 + const [mailboxes, setMailboxes] = useState([]); + const [mailboxesLoaded, setMailboxesLoaded] = useState(false); + const [mailboxId, setMailboxId] = useState(null); + const [mailboxFormOpen, setMailboxFormOpen] = useState(false); + const [mailboxForm, setMailboxForm] = useState(EMPTY_MAILBOX_FORM); + const [mailboxFormIssue, setMailboxFormIssue] = useState(null); + const [mailboxSaveError, setMailboxSaveError] = useState(""); + const [mailboxTesting, setMailboxTesting] = useState(false); + const [mailboxManagerOpen, setMailboxManagerOpen] = useState(false); const [mailRuleMode, setMailRuleMode] = useState("all"); const [mailRules, setMailRules] = useState([]); - const [mailPriority, setMailPriority] = useState(50); const [mailTesterOpen, setMailTesterOpen] = useState(false); const [mailTestFrom, setMailTestFrom] = useState("customer@example.com"); const [mailTestTo, setMailTestTo] = useState(""); @@ -660,30 +567,62 @@ export default function AutomationPage() { const [rejectDialogOpen, setRejectDialogOpen] = useState(false); const [rejectionReason, setRejectionReason] = useState(""); + // 有选择、但邮箱名单里查不到它(名单已加载完):遗留行、邮箱被删、或名单拉取失败。 + // 此时必须让用户重新选择——绝不把选择静默换成名单里的第一条,否则一次普通保存 + // 就会把自动化改绑到另一个收件箱。 + const mailboxSelectionUnresolved = isMailboxSelectionUnresolved(mailboxId, mailboxes, mailboxesLoaded); + + // 内联表单是否展开:用户主动展开,或选择不可解析(必须提示重选)。 + const mailboxFormVisible = mailboxFormOpen || mailboxSelectionUnresolved; + + const mailboxPickerValue = mailboxSelectValue(mailboxId, mailboxFormVisible); + + const selectedMailbox = useMemo( + () => (mailboxFormVisible ? null : mailboxes.find((item) => item.id === mailboxId) ?? null), + [mailboxFormVisible, mailboxId, mailboxes], + ); + + // 调度器实际监听的是邮箱记录里的文件夹,这里同步派生,避免运行详情显示错文件夹。 + const mailFolderValue = String(selectedMailbox?.folder || "").trim() || "INBOX"; + + // 该邮箱被多少个邮件自动化引用:与删除接口的依赖检查口径一致。 + const selectedMailboxDependents = useMemo(() => { + if (!selectedMailbox) return 0; + return automations.filter( + (item) => item.trigger === "邮件触发" && normalizeMailboxId(item.mailboxId) === selectedMailbox.id, + ).length; + }, [automations, selectedMailbox]); + + // D.7:冲突检测与命中预测只在同一监听邮箱内比较——调度器的分组键是 + // `${userId}:${mailboxId}`,跨邮箱比较会误报冲突。 + // 正在配置新邮箱(或选择不可解析)时没有可比较的分组,候选为空。 + const mailboxScopedAutomations = useMemo( + () => + selectMailboxScopedAutomations(automations, { + mailboxId: mailboxFormVisible ? null : mailboxId, + excludeId: editingAutomationId, + }), + [automations, editingAutomationId, mailboxFormVisible, mailboxId], + ); + const mailConflictCandidates = useMemo(() => { if (trigger !== "邮件触发") return []; - return automations - .filter((item) => { - if (item.id === editingAutomationId) return false; - return item.trigger === "邮件触发" && item.status === "running"; - }) + return mailboxScopedAutomations .map((item) => ({ item, - level: mailConflictLevel(mailRules, mailRuleMode, item), - priority: Number.isFinite(Number(item.mailPriority)) ? Number(item.mailPriority) : 50, + level: mailConflictLevel({ rules: mailRules, mode: mailRuleMode }, automationMailRuleSet(item)), })) .sort((a, b) => { if (a.level !== b.level) return a.level === "high" ? -1 : 1; - if (a.priority !== b.priority) return b.priority - a.priority; return a.item.id - b.item.id; }); - }, [automations, editingAutomationId, mailRuleMode, mailRules, trigger]); + }, [mailRuleMode, mailRules, mailboxScopedAutomations, trigger]); const mailRuleTestResult = useMemo(() => { if (trigger !== "邮件触发") return null; - const message: MailRuleTestMessage = { + const message: MailRuleMessage = { from: mailTestFrom, to: mailTestTo, subject: mailTestSubject, @@ -692,28 +631,17 @@ export default function AutomationPage() { }; const currentRuleResults = mailRules.map((rule) => ({ rule, - matched: doesMailRuleTestMatch(rule, message), - source: mailRuleTestSource(rule, message), + matched: doesMailRuleMatch(rule, message), + source: mailRuleSource(rule, message), })); - const currentMatched = doMailRulesTestMatch(mailRules, mailRuleMode, message); + const currentMatched = doesMailRuleSetMatch({ rules: mailRules, mode: mailRuleMode }, message); const currentId = editingAutomationId ?? Number.MAX_SAFE_INTEGER; - const candidates = automations - .filter((item) => { - if (item.id === editingAutomationId) return false; - return item.trigger === "邮件触发" && item.status === "running"; - }) - .filter((item) => - doMailRulesTestMatch( - Array.isArray(item.mailRules) ? item.mailRules : [], - item.mailRuleMode === "any" ? "any" : "all", - message, - ), - ) + const candidates = mailboxScopedAutomations + .filter((item) => doesMailRuleSetMatch(automationMailRuleSet(item), message)) .map((item) => ({ id: item.id, name: item.name, - priority: Number.isFinite(Number(item.mailPriority)) ? Number(item.mailPriority) : 50, current: false, })); @@ -721,27 +649,19 @@ export default function AutomationPage() { candidates.push({ id: currentId, name: name.trim() || tt("当前自动化", "Current automation"), - priority: mailPriority, current: true, }); } - candidates.sort((a, b) => { - if (a.priority !== b.priority) return b.priority - a.priority; - return a.id - b.id; - }); - + // 命中即全部执行:不再排序、不再挑胜出者,matchedCandidates 就是最终结果。 return { message, currentMatched, currentRuleResults, - winner: candidates[0] || null, matchedCandidates: candidates, }; }, [ - automations, editingAutomationId, - mailPriority, mailRuleMode, mailRules, mailTestAttachments, @@ -749,6 +669,7 @@ export default function AutomationPage() { mailTestFrom, mailTestSubject, mailTestTo, + mailboxScopedAutomations, name, trigger, ]); @@ -902,6 +823,39 @@ export default function AutomationPage() { }; }, []); + /** + * 监听邮箱列表:供向导内联选择/配置使用。一条都没有(或列表拉取失败)时直接 + * 展开内联表单,避免用户卡在"没有邮箱可选"的空状态。 + * + * 同时暴露给邮箱管理抽屉:抽屉里改密码/删除后回拉一次,向导的下拉才不会停留在旧名单上 + * (页面的名单只在挂载时拉取一次,见 `mailboxSelectionUnresolved` 对"查不到"的处理)。 + */ + const loadMailboxOptions = useCallback(async () => { + try { + const response = await axios.get("/api/v1/automation-mailboxes"); + const items = Array.isArray(response.data?.items) + ? (response.data.items as MailboxOption[]) + : []; + + setMailboxes(items); + // 已有选择原样保留(查不到时由 mailboxSelectionUnresolved 让用户重选), + // 只有还没选择过才用名单第一条作默认值。 + setMailboxId((current) => defaultMailboxSelection(current, items)); + // 名单为空必须展开表单(空状态不是死路);名单非空时不改写此前的展开状态, + // 那可能是用户点的"配置新邮箱",也可能是遗留行要求重选。 + if (items.length === 0) setMailboxFormOpen(true); + } catch (error) { + console.error("加载监听邮箱列表失败:", error); + setMailboxFormOpen(true); + } finally { + setMailboxesLoaded(true); + } + }, []); + + useEffect(() => { + void loadMailboxOptions(); + }, [loadMailboxOptions]); + useEffect(() => { let alive = true; @@ -1098,11 +1052,18 @@ export default function AutomationPage() { { value: "{{trigger.timezone}}", label: tt("触发时区", "Trigger timezone") }, ]; + /** 已保存自动化要显示的监听邮箱名:解析不到时明确说"未配置",不再回退到系统邮箱。 */ + function automationMailboxText(item: Automation) { + return ( + automationMailboxLabel(item, mailboxes) ?? + tt("监听邮箱未配置", "Mailbox not configured") + ); + } + function localizedTriggerDetail(item: Automation) { if (item.trigger === "邮件触发") { - const ruleText = mailRulesSummary(item); - const priority = Number.isFinite(Number(item.mailPriority)) ? Number(item.mailPriority) : 50; - return `${tt("系统邮箱", "System Mailbox")} · ${ruleText} · ${tt("优先级", "Priority")} ${priority}`; + const ruleText = mailRulesSummary(automationMailRuleSet(item)); + return `${automationMailboxText(item)} · ${ruleText}`; } if (item.trigger === "Webhook / API") { return tt("由外部系统通过 Webhook / API 触发", "Triggered by an external system through Webhook / API"); @@ -1164,6 +1125,82 @@ export default function AutomationPage() { return value; } + function resetMailboxForm() { + setMailboxForm(EMPTY_MAILBOX_FORM); + setMailboxFormIssue(null); + setMailboxSaveError(""); + } + + function handleMailboxSelectionChange(value: string) { + setMailboxSaveError(""); + if (value === MAILBOX_NEW_OPTION) { + // 只展开表单、保留原有选择:取消配置时可以回到原邮箱。 + setMailboxFormOpen(true); + return; + } + + const next = normalizeMailboxId(Number(value)); + if (next === null) return; + setMailboxFormOpen(false); + setMailboxId(next); + } + + function cancelMailboxForm() { + setMailboxFormOpen(false); + resetMailboxForm(); + setMailboxId((current) => current ?? mailboxes[0]?.id ?? null); + } + + function mailboxFormIssueText(issue: MailboxFormIssue) { + if (issue === "email") return tt("请输入正确的邮箱地址", "Please enter a valid email address"); + if (issue === "username") return tt("请填写邮箱登录账号", "Please enter the mailbox login account"); + if (issue === "password") return tt("请填写邮箱授权码或密码", "Please enter the mailbox password"); + if (issue === "imapHost") return tt("请填写 IMAP 服务器", "Please enter the IMAP server"); + return tt("IMAP 端口不正确", "Invalid IMAP port"); + } + + /** + * 保存内联配置的监听邮箱。 + * + * 服务端在写入前会真实连接一次 IMAP,连接失败即 400——不可达的邮箱无法保存, + * 这是有意接受的约束(安全优先):这里只把失败原因显示在表单里,不提供跳过校验 + * 或"仍然保存"的路径。 + */ + async function saveMailboxFromForm() { + const issue = firstMailboxFormIssue(mailboxForm); + setMailboxFormIssue(issue); + setMailboxSaveError(""); + if (issue) return; + + setMailboxTesting(true); + try { + const response = await axios.post("/api/v1/automation-mailboxes", mailboxCreatePayload(mailboxForm)); + const created = response.data as MailboxOption; + const createdId = normalizeMailboxId(created?.id); + if (createdId === null) { + setMailboxSaveError(tt("监听邮箱保存失败", "Failed to save the mailbox")); + return; + } + + setMailboxes((items) => [created, ...items.filter((item) => item.id !== createdId)]); + setMailboxId(createdId); + setMailboxFormOpen(false); + resetMailboxForm(); + toast.success(tt("监听邮箱已保存", "Mailbox saved")); + } catch (error) { + console.error("保存监听邮箱失败:", error); + setMailboxSaveError( + apiErrorDetail(error) || + tt( + "邮箱连接失败,请检查 IMAP 服务器、账号与授权码", + "Connection failed. Check the IMAP server, account, and password.", + ), + ); + } finally { + setMailboxTesting(false); + } + } + function resetWizard() { setStep(1); setName(""); @@ -1184,9 +1221,14 @@ export default function AutomationPage() { setScheduleDate(""); setMailResultEmail(""); setResultEmailIncludeAttachments(true); + // 监听邮箱:默认选中第一条已保存邮箱;确认一条都没有时直接展开内联表单。 + // 名单尚未加载完时先不展开——此时"没有邮箱"只是数据还没到,展开会误伤 + // 那些其实有邮箱的用户(名单到位后由 loadMailboxOptions 决定)。 + setMailboxId(mailboxes[0]?.id ?? null); + setMailboxFormOpen(mailboxesLoaded && mailboxes.length === 0); + resetMailboxForm(); setMailRuleMode("all"); setMailRules([]); - setMailPriority(50); setMailTesterOpen(false); setMailTestFrom("customer@example.com"); setMailTestTo(""); @@ -1235,9 +1277,15 @@ export default function AutomationPage() { setScheduleDate(item.scheduleDate || ""); setMailResultEmail(item.resultEmail || ""); setResultEmailIncludeAttachments(item.resultEmailIncludeAttachments === true); + // 监听邮箱:原样载入该自动化的选择。遗留行(旧字符串键,没有整数 mailboxId) + // 直接展开配置表单提示重选;选中的邮箱若在名单里查不到(不论名单先到还是后到), + // 由 mailboxSelectionUnresolved 派生展开表单并拦下保存,而不是替用户另选一个。 + const savedMailboxId = normalizeMailboxId(item.mailboxId); + setMailboxId(savedMailboxId); + setMailboxFormOpen(savedMailboxId === null); + resetMailboxForm(); setMailRuleMode(item.mailRuleMode === "any" ? "any" : "all"); setMailRules(Array.isArray(item.mailRules) ? item.mailRules : []); - setMailPriority(Number.isFinite(Number(item.mailPriority)) ? Number(item.mailPriority) : 50); setUpstreamAutomationId( item.upstreamAutomationId ?? automations.find((automation) => automation.id !== item.id)?.id ?? @@ -1290,12 +1338,11 @@ export default function AutomationPage() { return `每天 ${scheduleTime} · ${timeZoneLabel(scheduleTimezone)}`; } if (trigger === "邮件触发") { - const temp: Automation = { - id: 0, name: "", trigger: "邮件触发", triggerDetail: "", appId: null, agent: "", - strategy, status: "running", statusText: "", time: "", task: "", returnDetail: "", - mailboxLabel: "系统邮箱", mailRuleMode, mailRules, mailPriority, - }; - return `${tt("系统邮箱", "System Mailbox")} · ${mailRulesSummary(temp)} · ${tt("优先级", "Priority")} ${mailPriority}`; + const ruleText = mailRulesSummary({ rules: mailRules, mode: mailRuleMode }); + const mailboxText = selectedMailbox + ? mailboxOptionLabel(selectedMailbox) + : tt("未选择监听邮箱", "No mailbox selected"); + return `${mailboxText} · ${ruleText}`; } if (trigger === "Webhook / API") { return "由外部系统通过 Webhook / API 触发"; @@ -1306,6 +1353,10 @@ export default function AutomationPage() { } function buildAutomationPayload() { + // 监听邮箱完全由向导内的选择派生:正在配置新邮箱、或选择已不可解析时视为未选择 + // (两种情况下提交都会被 validateMailboxSelection 拦下)。 + const mailboxIdForPayload = mailboxFormVisible ? null : normalizeMailboxId(mailboxId); + return { name: name.trim(), appId: selectedApp?.id, @@ -1346,12 +1397,14 @@ export default function AutomationPage() { trigger === "定时触发" && schedulePeriod === "仅一次" ? scheduleDate || undefined : undefined, - mailboxKey: trigger === "邮件触发" ? "system" : undefined, - mailboxLabel: trigger === "邮件触发" ? "系统邮箱" : undefined, - mailFolder: trigger === "邮件触发" ? "INBOX" : undefined, + // 监听邮箱为整数 mailboxId,指向 automation_mailboxes;服务端会校验归属, + // 并按邮箱记录重新派生 mailboxLabel(客户端传入的 label 不作为展示来源)。 + mailboxId: trigger === "邮件触发" ? mailboxIdForPayload ?? undefined : undefined, + mailboxLabel: + trigger === "邮件触发" && selectedMailbox ? mailboxOptionLabel(selectedMailbox) : undefined, + mailFolder: trigger === "邮件触发" ? mailFolderValue : undefined, mailRuleMode: trigger === "邮件触发" ? mailRuleMode : undefined, mailRules: trigger === "邮件触发" ? mailRules : undefined, - mailPriority: trigger === "邮件触发" ? mailPriority : undefined, upstreamAutomationId: trigger === "自动化完成触发" ? upstreamAutomationId : undefined, upstreamCondition: @@ -1361,6 +1414,36 @@ export default function AutomationPage() { }; } + /** 邮件触发的监听邮箱必须来自邮箱列表(服务端同样会校验归属),未选定时不允许保存。 */ + function validateMailboxSelection() { + if (mailboxSelectionUnresolved) { + toast.error( + tt( + "原监听邮箱已不存在或不可用,请重新选择监听邮箱", + "The previous monitored mailbox is unavailable. Please choose another one", + ), + ); + return false; + } + + if (mailboxFormOpen) { + toast.error( + tt( + "请先完成监听邮箱配置并测试连接,再保存自动化", + "Configure and test the monitored mailbox before saving", + ), + ); + return false; + } + + if (normalizeMailboxId(mailboxId) === null) { + toast.error(tt("请为邮件触发选择监听邮箱", "Please choose a monitored mailbox for the email trigger")); + return false; + } + + return true; + } + function validateScheduleConfiguration() { if (trigger !== "定时触发") return true; @@ -1424,6 +1507,11 @@ export default function AutomationPage() { setStep(2); return; } + + if (!validateMailboxSelection()) { + setStep(2); + return; + } } try { @@ -1488,6 +1576,11 @@ export default function AutomationPage() { setStep(2); return; } + + if (!validateMailboxSelection()) { + setStep(2); + return; + } } try { @@ -2265,6 +2358,16 @@ export default function AutomationPage() { )} + + + {/* 选择不可解析时不给"取消"——收起后表单会立刻再次出现(提示必须保留), + 用户只能重新选择一个邮箱。 */} + {mailboxes.length > 0 && !mailboxSelectionUnresolved && ( + + )} + + +
+ {tt( + "平台会先真实连接该邮箱验证凭据,通过后立即保存并选中;连接失败的邮箱不会被保存。", + "The platform connects to the mailbox to verify the credentials, then saves and selects it. Mailboxes that fail to connect are never saved.", + )} +
+ + {(mailboxFormIssue || mailboxSaveError) && ( +
+ {mailboxFormIssue ? mailboxFormIssueText(mailboxFormIssue) : mailboxSaveError} +
+ )} - + )} +
{tt( "监听邮箱与任务完成后的通知邮箱相互独立。", @@ -3048,12 +3264,12 @@ export default function AutomationPage() {
- {tt("收件箱(INBOX)", "Inbox (INBOX)")} + {mailFolderDisplay(mailboxFormVisible ? mailboxForm.folder : selectedMailbox?.folder)}
{tt( - "当前仅监听系统邮箱的收件箱中新到达的邮件。", - "Currently, only new messages arriving in the system mailbox inbox are monitored.", + "监听文件夹来自所选邮箱的配置,同一邮箱上的所有自动化共用同一文件夹。", + "The monitored folder comes from the selected mailbox; all automations on that mailbox share it.", )}
@@ -3116,13 +3332,6 @@ export default function AutomationPage() {
- - setMailPriority(Math.max(0, Math.min(100, Number(e.target.value) || 0)))} className="input-base" /> -
- {tt("同一封邮件同时命中多个自动化时,只执行优先级最高的一条。建议使用 0–100。", "If multiple automations match the same email, only the highest-priority one runs. Recommended range: 0–100.")} -
-
- {mailConflictCandidates.length > 0 && (
@@ -3130,12 +3339,12 @@ export default function AutomationPage() {
{tt( - "这些规则可能同时命中同一封邮件。平台仍只会执行优先级最高的一条,建议确认优先级是否符合业务顺序。", - "These rules may match the same email. The platform still runs only the highest-priority automation, so confirm that the priority order matches the business requirement.", + "这些规则可能同时命中同一封邮件。届时这些自动化都会执行(每条各自运行一次),请确认这是否符合预期。", + "These rules may match the same email. All of them will then run — each one separately. Confirm that this is what you want.", )}
- {mailConflictCandidates.slice(0, 3).map(({ item, level, priority }) => ( + {mailConflictCandidates.slice(0, 3).map(({ item, level }) => (
{item.name} @@ -3145,9 +3354,6 @@ export default function AutomationPage() { : tt("存在潜在重叠", "Potential overlap")}
-
- {tt("优先级", "Priority")} {priority} -
))} {mailConflictCandidates.length > 3 && ( @@ -3159,22 +3365,6 @@ export default function AutomationPage() {
)}
- {mailConflictCandidates.some(({ priority }) => priority > mailPriority) && ( -
- {tt( - "当前优先级低于其中部分自动化;若同一邮件同时命中,当前自动化将不会执行。", - "The current priority is lower than at least one existing automation. If the same email matches both, this automation will not run.", - )} -
- )} - {mailConflictCandidates.some(({ priority }) => priority === mailPriority) && ( -
- {tt( - "存在相同优先级。相同优先级时系统按固定顺序只执行一条,建议使用不同优先级避免业务歧义。", - "An equal priority exists. With equal priorities, the system uses a fixed order and runs only one; use distinct priorities to avoid ambiguity.", - )} -
- )} )} @@ -3207,24 +3397,24 @@ export default function AutomationPage() {
- {mailRuleTestResult.winner?.current + {mailRuleTestResult.currentMatched ? tt("结果:当前自动化会触发", "Result: current automation will run") - : mailRuleTestResult.currentMatched && mailRuleTestResult.winner - ? tt(`结果:当前规则命中,但会由「${mailRuleTestResult.winner.name}」优先执行`, `Result: current rules match, but “${mailRuleTestResult.winner.name}” wins by priority`) - : tt("结果:当前自动化不会触发", "Result: current automation will not run")} + : tt("结果:当前自动化不会触发", "Result: current automation will not run")}
- {mailRuleTestResult.winner && ( -
- {tt("最终命中", "Winner")}: {mailRuleTestResult.winner.name} · {tt("优先级", "Priority")} {mailRuleTestResult.winner.priority} -
- )} + {mailRuleTestResult.currentMatched && + mailRuleTestResult.matchedCandidates.length > 1 && ( +
+ {tt( + "同一封邮件还会触发以下自动化,它们会与当前自动化各自运行一次。", + "The same email will also trigger the following automations; each will run separately.", + )} +
+ )}
{mailRules.length > 0 && ( @@ -3252,7 +3442,7 @@ export default function AutomationPage() {
{mailRuleTestResult.matchedCandidates.map((item) => ( - {item.name} · {item.priority} + {item.name} ))}
@@ -3263,12 +3453,6 @@ export default function AutomationPage() { -
-
{tt("自动传入邮件内容", "Automatically Pass Email Content")}
-
- {tt("监听邮箱、发件人、收件人、主题、正文、附件和命中规则都会写入本次运行上下文,便于追溯。", "Mailbox, sender, recipient, subject, body, attachments, and the matched rule are stored in the run context.")} -
-
)} @@ -3538,8 +3722,8 @@ export default function AutomationPage() {
{tt( - "路由统计记录服务端实际检查结果,包括未命中和被更高优先级规则截获的邮件。", - "Routing statistics include server-side checks, unmatched messages, and messages suppressed by higher-priority rules.", + "路由统计记录服务端实际检查结果,包括未命中的邮件与重复投递的邮件。", + "Routing statistics cover server-side checks, including unmatched messages and duplicate deliveries.", )}
{emailRoutingStatsLoading && ( @@ -3564,10 +3748,6 @@ export default function AutomationPage() {
{tt("未命中", "Not Matched")}
{emailRoutingStats.notMatched}
-
-
{tt("被高优先级截获", "Suppressed")}
-
{emailRoutingStats.suppressed}
-
{tt("重复拦截", "Deduplicated")}
{emailRoutingStats.duplicate}
@@ -3602,13 +3782,16 @@ export default function AutomationPage() { ) : (
{emailRoutingStats.recent.slice(0, 5).map((event) => { + // suppressed_by_priority 只可能来自存量行:服务端已不再产生它, + // 但历史行仍会出现在 recent 里,必须给它一个可读文案(否则会 + // 掉进"未命中",把旧版的拦截误报成没匹配上)。 const outcomeText = event.outcome === "triggered" ? tt("已触发", "Triggered") - : event.outcome === "suppressed_by_priority" - ? tt("被高优先级截获", "Suppressed") - : event.outcome === "duplicate" - ? tt("重复拦截", "Deduplicated") + : event.outcome === "duplicate" + ? tt("重复拦截", "Deduplicated") + : event.outcome === "suppressed_by_priority" + ? tt("未执行(旧版优先级规则)", "Not run (legacy priority rule)") : tt("未命中", "Not Matched"); return (
@@ -3622,11 +3805,6 @@ export default function AutomationPage() {
{outcomeText}
- {event.priority != null && ( -
- {tt("优先级", "Priority")} {event.priority} -
- )}
); @@ -3759,7 +3937,7 @@ export default function AutomationPage() {
- { + const payload: Record = { + messages: [{ role: "user", content: params.question }], + app_id: params.appId, + }; + + const withKey = (params.attachments ?? []).filter( + (attachment) => String(attachment?.objectKey || "").trim().length > 0 + ); + + if (withKey.length > 0) { + payload.attachments = withKey.map((attachment) => ({ + object_key: attachment.objectKey, + filename: attachment.filename, + content_type: attachment.contentType, + size: attachment.size, + })); + } + + return payload; +} + +/** + * 正文的规范化:去两端空白 → 超长截断 → 空内容给一句可读的占位。 + * + * 抽出来是为了让调度器(写 `triggerContext`)与提示词拼装共用同一份口径。两处各写一遍 + * 截断阈值,改一处漏一处时,人看到的正文与模型看到的正文就会不一致——而那个不一致极难发现。 + */ +export function normalizeEmailBody(value: unknown): string { + const raw = typeof value === "string" ? value.trim() : ""; + + if (raw.length > EMAIL_BODY_MAX_CHARS) { + return `${raw.slice(0, EMAIL_BODY_MAX_CHARS)}\n\n[正文较长,已截取前 ${EMAIL_BODY_MAX_CHARS} 个字符]`; + } + + return raw || "(无正文)"; +} + +/** + * 拼邮件触发的提示词:任务说明 + 本次邮件的结构化描述 + 正文。 + * + * 附件的说法是这一段的关键。文件由后端按 `attachments` 结构化字段取回、写进 skill 沙箱的 + * `inputs/`,所以这里**只说文件名**——object_key 不进提示词(见上)。 + * + * 没传成的附件必须**点名**:模型看不到「本该有几个」,若只报传成功的,它会默认收到的就是 + * 全部,进而对缺数据给出错误的解释("成绩单里没有分数")。实测踩过这个坑。 + */ +export function buildEmailAutomationQuestion(params: { + task: string; + mailboxLabel: string; + from?: string; + to?: string; + subject?: string; + date?: string; + body?: string; + /** 已放入 inputs/ 的附件名。 */ + delivered?: readonly string[]; + /** 因超限或上传失败没传成的附件名。 */ + skipped?: readonly string[]; +}): string { + const body = normalizeEmailBody(params.body); + + const delivered = params.delivered ?? []; + const skipped = params.skipped ?? []; + + const attachmentLines = + delivered.length > 0 + ? [ + "附件(已放入工作目录 inputs/,请按文件名引用):", + ...delivered.map((name) => `- ${name}`), + ] + : ["附件:无"]; + + const skippedLines = + skipped.length > 0 + ? [ + "以下附件未传入(超过大小上限或上传失败),其中若有任务需要的数据,请说明缺少它:", + ...skipped.map((name) => `- ${name}`), + ] + : []; + + return [ + "【自动化任务】", + String(params.task || ""), + "", + "【本次收到的新邮件】", + `监听邮箱:${params.mailboxLabel}`, + `发件人:${params.from || "未知"}`, + `收件人:${params.to || "未知"}`, + `主题:${params.subject || "无主题"}`, + `时间:${params.date || "未知"}`, + ...attachmentLines, + ...skippedLines, + "正文:", + body, + "", + "【执行要求】", + "请根据上面的真实邮件内容完成自动化任务。", + "只输出本次邮件的处理结果,不要自行回复邮件,除非用户指明要回复邮件。", + ].join("\n"); +} diff --git a/lib/automation/execute.ts b/lib/automation/execute.ts index c01e3ab..66c19d0 100644 --- a/lib/automation/execute.ts +++ b/lib/automation/execute.ts @@ -1,5 +1,9 @@ import jwt from "jsonwebtoken"; import { extractSseErrorMessage, isSseCommentLine } from "@/lib/chatSse"; +import { + buildAutomationAgentPayload, + type AutomationAgentAttachment, +} from "@/lib/automation/agent-payload"; export interface AutomationExecutionAttachment { filename: string; @@ -79,6 +83,11 @@ export async function executeAutomationAgent(params: { userId: number; appId: number; question: string; + /** + * 邮件附件(已上传 OSS)。交给后端在 skill 沙箱起容器前取回、写进 `inputs/`, + * 模型按文件名引用即可——object_key 不进提示词,见 `agent-payload.ts` 的说明。 + */ + attachments?: readonly AutomationAgentAttachment[]; }): Promise { const jwtSecret = requiredEnv("JWT_SECRET"); const backendUrl = requiredEnv("EXTERNAL_API_BASE_URL").replace(/\/+$/, ""); @@ -91,10 +100,13 @@ export async function executeAutomationAgent(params: { "Content-Type": "application/json", Authorization: `Bearer ${token}`, }, - body: JSON.stringify({ - messages: [{ role: "user", content: params.question }], - app_id: params.appId, - }), + body: JSON.stringify( + buildAutomationAgentPayload({ + question: params.question, + appId: params.appId, + attachments: params.attachments, + }), + ), }); if (!response.ok) { diff --git a/lib/automation/imap-client.ts b/lib/automation/imap-client.ts new file mode 100644 index 0000000..0191b76 --- /dev/null +++ b/lib/automation/imap-client.ts @@ -0,0 +1,424 @@ +/** + * IMAP 收信:本进程内实现(不再调 ragent-service 的 `/api/v1/email/unread-config`)。 + * + * 那个端点只以临时补丁脚本的形式存在过、从未进入部署镜像,于是每次保存邮箱与每 10 秒一次的 + * 轮询都 404,错误文案里没有任何关键词、落不到 400 分支,被兜底成 500。收信因此搬进 ragent + * 进程——`instrumentation.ts` 本来就在 Next 进程内跑调度器(`ENABLE_CRON=true`),不是新模式。 + * + * 这是 Python 参考实现(`adce8b1:patch_backend_multi_mailbox.py`,含 + * `patch_backend_mail_batch.py` 的批处理修复)的移植,不是重新设计。三处反直觉的地方: + * + * 1. **只读打开文件夹**(`readOnly: true`),绝不改动用户邮箱的已读状态。处理与否由我们 + * 自己的游标与去重表决定,与 `\Seen` 无关——也正因如此,用户在客户端读过的信仍会被处理。 + * 2. **`latest_uid` 是「文件夹当前最大 UID」**,在游标判断之前就取好;它是调用方建基线的 + * 依据,与「本批取到了哪些」无关(首次运行甚至一封都不取)。 + * 3. **一批取「游标之后最早的 20 封」**,不是最新 20 封。游标逐封推进,取最新一批会让它 + * 一次跳过中间所有邮件,而这些邮件再也不会被读到——静默丢信。 + * + * 纯逻辑(UID 截取、正文与附件名提取、字段规范化)都抽成了可直接喂参数的函数, + * 单测 `test/imapClient.test.ts` 不连网也不连库。 + */ +import { ImapFlow } from "imapflow"; +import { simpleParser } from "mailparser"; + +/** 连接超时:与参考实现的 `timeout=15` 对齐(连接、问候、以及无响应等待都是这个量级)。 */ +export const IMAP_TIMEOUT_MS = 15_000; + +/** 单批最多读取的邮件数。积压更多时由下一次轮询继续,游标只在真正处理过的邮件上前进。 */ +export const IMAP_MESSAGE_BATCH_SIZE = 20; + +export type MailboxConnection = { + email?: string; + username: string; + password: string; + imapHost: string; + imapPort: number; + imapSecure: boolean; + folder: string; +}; + +/** 单封邮件:调用方(调度器)依赖的 8 个字段恒存在。 */ +export type MailboxUnreadMessage = { + uid: number; + message_id: string; + from: string; + to: string; + subject: string; + date: string; + body: string; + attachments: string[]; +}; + +export type MailboxUnreadResult = { + success: boolean; + latest_uid: number; + messages: MailboxUnreadMessage[]; +}; + +/** 本地纯函数取值时用到的解析结果视图:`mailparser` 的 `ParsedMail` 是它的超集。 */ +export type ParsedMailView = { + text?: unknown; + html?: unknown; + subject?: unknown; + messageId?: unknown; + from?: unknown; + to?: unknown; + attachments?: unknown; + headerLines?: unknown; +}; + +/** 文件夹 UID 全集 → 「当前最大 UID」;空文件夹为 0。 */ +export function latestUidFrom(uids: readonly number[]): number { + let latest = 0; + for (const uid of uids) { + const value = Number(uid); + if (Number.isInteger(value) && value > latest) latest = value; + } + return latest; +} + +/** + * 本轮该取哪些邮件,以及该回报的 `latest_uid`。 + * + * 顺序是承重的:`latestUid` 先算(它来自文件夹的 UID 全集),`afterUid` 只决定**本批**取哪些。 + * 游标尚未建立(不传 / null / 非整数)时只回报基线、一封不取——调用方拿它写 `initialized` + * 游标,下次轮询才开始真正收信。此时若返回 0(或返回本批的最大值),下一次就会把历史邮件 + * 重放一遍。 + */ +export function planMailboxFetch( + uids: readonly number[], + afterUid?: number | null +): { latestUid: number; targetUids: number[] } { + const latestUid = latestUidFrom(uids); + + if (!Number.isInteger(afterUid)) return { latestUid, targetUids: [] }; + + const cursor = Number(afterUid); + const targetUids = uids + .map(Number) + .filter((uid) => Number.isInteger(uid) && uid > cursor) + .sort((left, right) => left - right) + .slice(0, IMAP_MESSAGE_BATCH_SIZE); + + return { latestUid, targetUids }; +} + +function textValue(value: unknown): string { + return typeof value === "string" ? value : ""; +} + +/** + * `search({all:true},{uid:true})` 的结果 → UID 列表。 + * + * 不是数组即失败。`search` 的失败面比重连不选中邮箱宽得多(`imapflow@2.0.2`): + * `commands/search.js` 在命令抛错时 catch 住并**返回 `false`**(服务端 NO/BAD、命令中途 + * 断连都落在这里,不往外抛),`imap-flow.js` 在没选中邮箱时返回 `undefined`、在 `run()` + * 拿到假值时 `|| false`。把其中任何一种当成空文件夹,**基线调用**就会把 `latest_uid: 0` + * 写进游标,下一次轮询从 0 开始把整个邮箱的历史按 20 封一批重放——正是基线机制要防的 + * 反方向。参考实现在这里同样是直接失败(`无法读取邮箱 UID`),不兜底。 + * + * 正常成功时返回的是排好序的 UID 数组(空文件夹是 `[]`,非空真值,不会被 `|| false` 吃掉), + * 所以这条守卫不会误伤空邮箱。 + */ +export function uidsFromSearchResult(found: unknown): number[] { + if (!Array.isArray(found)) throw new Error("IMAP 未返回 UID 列表"); + return found.map(Number); +} + +/** + * 正文:纯文本优先,HTML 兜底,都没有则空串(不是 undefined)。 + * + * 单一取值来源,因此不会有「HTML-only 邮件返回带标签的源码、`开头是`/`等于` 规则拿标签 + * 去匹配」之外的第二套语义。前提是解析时传了 `skipHtmlToText`:默认行为会把 HTML + * 「翻译」成一段纯文本塞进 `text`,那样就再也分不清「本来有纯文本」与「只有 HTML」。 + */ +export function extractBody(parsed: ParsedMailView): string { + const text = textValue(parsed?.text).trim(); + if (text) return text; + return textValue(parsed?.html).trim(); +} + +/** + * 附件名:`mailparser` 判定为附件的 part 里,取解码后的文件名(inline 也算)。 + * + * 与参考实现(Python `msg.walk()` + `part.get_filename()`:不按 disposition 过滤、且对 + * `Content-Type` 的 `name` 参数兜底)有几处不一致,都记在这里,因为漏报会让 + * `是否包含附件`/`附件名称`/`附件类型` 失配,而调度器在**未命中时也会推进游标** + * (`automation-scheduler.ts`),那封信的触发就此永久消失: + * + * - **少报**:带文件名但被 `mailparser` 判成正文的 part 不计入——`Content-Disposition: + * inline; filename="page.html"` 的 text/html、或只有 `Content-Type: text/plain; + * name="notes.txt"` 而没有 disposition 的 part(两者内容都进正文)。 + * - **转发邮件(`message/rfc822`)的行为取决于容器的 disposition**(实测,逐条见 + * `test/imapClient.test.ts` 的 `FORWARDED`): + * - 容器**不透明**(内层一条都看不到):无 disposition、`attachment`、带 + * `Content-Transfer-Encoding: base64` 三种。此时只见容器自己——它带 filename(含 + * 只有 `name=` 参数)就报容器名,什么都没带就整条被下面的 filename 过滤掉。 + * - 容器**透明**(`Content-Disposition: inline`):内层 part 直接出现在附件列表里 + * (内层的 `attachment; filename="inner.pdf"` 会报出来),**容器自己的 filename 反而 + * 不报**,内层正文还会并进正文。与不透明那三种正好相反。 + * 机制不是"mailparser 不肯下钻":`@zone-eu/mailsplit` 的 `message-splitter.js` 只对 + * `Content-Disposition: inline` 的 `message/rfc822` 设 `messageNode = true`(分叉成嵌套 + * 邮件并继续下钻),mailparser 对该节点 `break`——容器因此不进附件列表,内层走嵌套那条路。 + * - **需过滤**:`attachments` 里会出现没有 filename 的条目(只有 `Content-ID` 的内嵌图片、 + * 或上面那种不透明的 `message/rfc822` 容器),所以必须按 filename 过滤。 + * + * 完全对齐参考实现要放弃 `simpleParser` 自己走 MIME 树,代价是丢掉当前正确的那些形态 + * (编码词文件名、RFC 2231、嵌套 multipart、逐 part charset),不划算。故按现状记录, + * 并由 `test/imapClient.test.ts` 把这个判定钉住——将来要改就是一次有意识的选择。 + */ +export function extractAttachmentNames(parsed: ParsedMailView): string[] { + const attachments = Array.isArray(parsed?.attachments) ? parsed.attachments : []; + const names: string[] = []; + + for (const attachment of attachments) { + const filename = textValue((attachment as { filename?: unknown })?.filename); + if (filename.trim()) names.push(filename); + } + + return names; +} + +/** 附件随运行落地的形态:文件名 + 解码后的字节 + 类型。 */ +export type MailboxAttachmentFile = { + filename: string; + content: Buffer; + contentType: string; + size: number; +}; + +/** + * 附件内容:与 `extractAttachmentNames` **同判定、同顺序**,只是多带上字节。 + * + * 两者必须一致——规则引擎按名字判定(`附件名称`/`附件类型`),而这里决定实际传给数字员工 + * 的是哪几个文件。判定一旦分叉,就会出现「规则说有附件、模型却没收到」这种对不上的情况。 + * + * 拿不到字节的 part(`mailparser` 没给 `content`)直接跳过,而不是留一个空壳: + * 没有字节的附件传过去也没用,徒增一次无意义的上传。 + */ +export function toAttachmentFiles(parsed: ParsedMailView): MailboxAttachmentFile[] { + const attachments = Array.isArray(parsed?.attachments) ? parsed.attachments : []; + const files: MailboxAttachmentFile[] = []; + + for (const attachment of attachments) { + const record = attachment as { + filename?: unknown; + content?: unknown; + contentType?: unknown; + }; + + const filename = textValue(record?.filename); + if (!filename.trim()) continue; + + const content = record?.content; + if (!Buffer.isBuffer(content)) continue; + + files.push({ + filename, + content, + contentType: textValue(record?.contentType), + size: content.length, + }); + } + + return files; +} + +/** 原始报文 → 附件内容列表。执行前按 UID 取回单封报文时使用。 */ +export async function parseAttachmentFiles( + source: Buffer | string +): Promise { + const parsed = await simpleParser(source, { skipHtmlToText: true, keepCidLinks: true }); + return toAttachmentFiles(parsed); +} + +/** 单个附件的上传上限,对齐平台既有的 `ASSET_MAX_FILE_BYTES`(技能资产)。 */ +export const ATTACHMENT_MAX_FILE_BYTES = 20 * 1024 * 1024; + +/** + * 按大小把附件分成「可上传」与「超限跳过」两组,各自保持原顺序。 + * + * 越限的挑出来而不是就地丢掉:调用方要在提示词里**点名**说明哪个附件没传, + * 否则模型会把收到的当成全部,进而对缺数据这件事给出错误的解释。 + */ +export function splitAttachmentsBySize( + files: readonly MailboxAttachmentFile[], + maxBytes: number = ATTACHMENT_MAX_FILE_BYTES +): { accepted: MailboxAttachmentFile[]; skipped: MailboxAttachmentFile[] } { + const accepted: MailboxAttachmentFile[] = []; + const skipped: MailboxAttachmentFile[] = []; + + for (const file of files) { + if (file.size <= maxBytes) accepted.push(file); + else skipped.push(file); + } + + return { accepted, skipped }; +} + +/** 原始头部文本(`mailparser` 的 `headerLines` 是 `{key, line}` 列表,key 已小写)。 */ +export function rawHeaderValue(headerLines: unknown, key: string): string { + if (!Array.isArray(headerLines)) return ""; + + const wanted = key.toLowerCase(); + for (const entry of headerLines) { + const record = entry as { key?: unknown; line?: unknown }; + if (textValue(record?.key).toLowerCase() !== wanted) continue; + + const line = textValue(record?.line); + const colon = line.indexOf(":"); + return (colon >= 0 ? line.slice(colon + 1) : line).trim(); + } + + return ""; +} + +/** 地址头部取可读文本(`"张三" `);同一头部出现多次时 `mailparser` 给的是数组。 */ +function addressText(value: unknown): string { + if (Array.isArray(value)) { + return value + .map((item) => addressText(item)) + .filter(Boolean) + .join(", "); + } + return textValue((value as { text?: unknown })?.text); +} + +/** 解析结果 → `InboxMessage`:8 个字段恒存在,缺失的一律是空串而非 undefined。 */ +export function toInboxMessage(uid: number, parsed: ParsedMailView): MailboxUnreadMessage { + return { + uid, + message_id: textValue(parsed?.messageId), + from: addressText(parsed?.from), + to: addressText(parsed?.to), + subject: textValue(parsed?.subject), + // 与参考实现一致:用 `Date:` 头部原文(不是重新格式化的时间),拿不到就是空串。 + date: rawHeaderValue(parsed?.headerLines, "date"), + body: extractBody(parsed), + attachments: extractAttachmentNames(parsed), + }; +} + +/** + * 原始报文 → 单封邮件。 + * + * `keepCidLinks` 让 HTML 保持原样(默认会把内嵌图片的 cid 链接改写成 data URI); + * 与 `skipHtmlToText` 合起来,`text`/`html` 才与参考实现的取法一一对应。 + */ +export async function parseInboxMessage( + uid: number, + source: Buffer | string +): Promise { + const parsed = await simpleParser(source, { skipHtmlToText: true, keepCidLinks: true }); + return toInboxMessage(uid, parsed); +} + +/** 失败文案统一加前缀:映射层靠 `IMAP` 这个词把它归到连接类失败(400),而不是兜底成 500。 */ +export function imapFailureMessage(error: unknown): string { + const detail = error instanceof Error ? error.message : String(error ?? ""); + return `IMAP 收件失败: ${detail}`; +} + +function requireConnectionField(value: string, label: string) { + const text = String(value ?? "").trim(); + if (!text) throw new Error(`IMAP 连接参数不完整:缺少${label}`); + return text; +} + +/** + * 短连接的统一出入口:连上 → 只读打开文件夹 → 跑 → 退出。 + * + * 两处收信功能(批量收信、按 UID 取附件)共用,避免连接参数校验与超时配置被复制成两份。 + * 失败文案统一在这里加 `IMAP` 前缀,映射层照旧归到连接类失败(400)而不是 500。 + */ +async function withReadOnlyMailbox( + connection: MailboxConnection, + run: (client: ImapFlow) => Promise +): Promise { + const host = requireConnectionField(connection.imapHost, "IMAP 服务器"); + const username = requireConnectionField(connection.username, "登录账号"); + const password = requireConnectionField(connection.password, "授权码或密码"); + const folder = String(connection.folder || "").trim() || "INBOX"; + const port = Number.isInteger(connection.imapPort) ? Number(connection.imapPort) : 993; + + const client = new ImapFlow({ + host, + port, + secure: connection.imapSecure !== false, + auth: { user: username, pass: password }, + // 短连接:连上、读一批、退出。不监听新邮件,因此不需要 IDLE。 + disableAutoIdle: true, + logger: false, + connectionTimeout: IMAP_TIMEOUT_MS, + greetingTimeout: IMAP_TIMEOUT_MS, + socketTimeout: IMAP_TIMEOUT_MS, + }); + + try { + await client.connect(); + + // 只读打开:绝不给用户的邮件打上已读标记(处理与否只由游标与去重表决定)。 + await client.mailboxOpen(folder, { readOnly: true }); + + return await run(client); + } catch (error) { + throw new Error(imapFailureMessage(error), { cause: error }); + } finally { + try { + await client.logout(); + } catch { + // 连接可能已经断了:logout 失败无所谓,原始错误在上面那个 catch 里已经成形。 + } + } +} + +/** + * 收一封信箱的未读(严格说是「游标之后的」)邮件。 + * + * 契约见调度器的 `fetchConfiguredMailboxUnread`:不传 `afterUid` 表示游标尚未建立, + * 此时只回报 `latest_uid`、`messages` 为空。 + */ +export async function fetchMailboxUnread(params: { + afterUid?: number | null; + connection: MailboxConnection; +}): Promise { + return withReadOnlyMailbox(params.connection, async (client) => { + const found = await client.search({ all: true }, { uid: true }); + const uids = uidsFromSearchResult(found); + const { latestUid, targetUids } = planMailboxFetch(uids, params.afterUid); + + const messages: MailboxUnreadMessage[] = []; + for (const uid of targetUids) { + const fetched = await client.fetchOne(String(uid), { source: true }, { uid: true }); + const source = fetched ? fetched.source : undefined; + if (!source) continue; + + messages.push(await parseInboxMessage(uid, source)); + } + + return { success: true, latest_uid: latestUid, messages }; + }); +} + +/** + * 按 UID 取单封邮件的附件内容。 + * + * 为什么是「执行前按需再取一次」而不是随批次一起返回:批量收信会把整批(最多 20 封) + * 全部解析完再返回,把附件字节挂在每封上意味着整批的附件同时驻留内存。按需取只有一封, + * 代价是多一次 IMAP 往返——而那封邮件本来就要跑一次几十秒的任务,这一次往返可以忽略。 + * + * 取不到报文时返回空数组而不是抛错:附件取不到不该让整次运行失败,调用方按「没有附件」 + * 继续即可(见 `buildEmailAutomationQuestion` 对缺失附件的说明)。 + */ +export async function fetchMessageAttachments( + connection: MailboxConnection, + uid: number +): Promise { + return withReadOnlyMailbox(connection, async (client) => { + const fetched = await client.fetchOne(String(uid), { source: true }, { uid: true }); + const source = fetched ? fetched.source : undefined; + if (!source) return []; + + return parseAttachmentFiles(source); + }); +} diff --git a/lib/automation/mail-rules.ts b/lib/automation/mail-rules.ts new file mode 100644 index 0000000..1eb9e2c --- /dev/null +++ b/lib/automation/mail-rules.ts @@ -0,0 +1,222 @@ +/** + * 邮件触发规则匹配的唯一实现(模块 D.6)。 + * + * 服务端调度器(lib/cron/automation-scheduler.ts)与前端向导页 + * (app/automation/page.tsx,"use client")都从这里取规则逻辑,因此 + * 「向导里预览的命中结果」与「调度器实际执行的结果」不可能分叉。 + * + * 同构约束:本模块会被打进客户端 bundle,只允许纯 TypeScript 与纯函数, + * 禁止引入 lib/env、pg、node:* 等仅服务端可用的依赖。 + */ + +export type MailRuleMode = "all" | "any"; + +/** 规范化字段清单:前端下拉与规则取值共用同一份。 */ +export const MAIL_RULE_FIELDS = [ + "发件人", + "发件人域名", + "收件人", + "邮件主题", + "邮件正文", + "是否包含附件", + "附件名称", + "附件类型", +] as const; + +export type MailRuleField = (typeof MAIL_RULE_FIELDS)[number]; + +/** 规范化操作符清单。 */ +export const MAIL_RULE_OPERATORS = [ + "等于", + "包含", + "不包含", + "开头是", + "结尾是", + "是否存在", +] as const; + +export type MailRuleOperator = (typeof MAIL_RULE_OPERATORS)[number]; + +/** + * 单条规则。字段与操作符来自 JSON 配置,落库前未做运行时校验, + * 因此类型上保持宽松(string),可选值由上面的两个清单约束。 + */ +export type MailTriggerRule = { + id?: string; + field: string; + operator: string; + value?: string; +}; + +/** 参与匹配的邮件视图:调度器的 InboxMessage 与前端的测试邮件都是它的超集。 */ +export type MailRuleMessage = { + from?: string; + to?: string; + subject?: string; + body?: string; + attachments?: string[]; +}; + +/** + * 规范化后的规则集输入:调度器从 task.trigger_config 取,前端从 Automation 取, + * 字段名不同但语义一致,各调用点自行组装,规则逻辑本身只有这一份。 + */ +export type MailRuleSet = { + rules?: readonly MailTriggerRule[] | null; + mode?: MailRuleMode; +}; + +/** 规则列表兜底:配置来自 JSON,可能不是数组。 */ +function mailRuleList(set: MailRuleSet): readonly MailTriggerRule[] { + return Array.isArray(set.rules) ? set.rules : []; +} + +/** 模式兜底:只认 "any",其余(含缺省)一律按 "all" 处理。 */ +function normalizeMailRuleMode(mode?: MailRuleMode): MailRuleMode { + return mode === "any" ? "any" : "all"; +} + +/** 取发件人域名:第一个 @ 之后、到 > 空格 逗号 分号 为止,统一小写。 */ +export function extractMailSenderDomain(value?: string) { + const match = String(value || "").match(/@([^>\s,;]+)/); + return match?.[1]?.toLowerCase() || ""; +} + +/** 取附件扩展名:逐个取末尾 .ext 并小写,无扩展名的忽略,空格连接。 */ +export function mailAttachmentExtensions(names?: string[]) { + return (Array.isArray(names) ? names : []) + .map((name) => { + const match = String(name) + .toLowerCase() + .match(/(\.[a-z0-9]+)$/i); + return match?.[1] || ""; + }) + .filter(Boolean) + .join(" "); +} + +/** 按字段取出规则要比对的原始文本(保持原大小写,比较时统一小写)。 */ +export function mailRuleSource(rule: MailTriggerRule, message: MailRuleMessage) { + const attachments = Array.isArray(message.attachments) ? message.attachments : []; + switch (rule.field) { + case "发件人": + return String(message.from || ""); + case "发件人域名": + return extractMailSenderDomain(message.from); + case "收件人": + return String(message.to || ""); + case "邮件主题": + return String(message.subject || ""); + case "邮件正文": + return String(message.body || ""); + case "是否包含附件": + return attachments.length > 0 ? "是" : "否"; + case "附件名称": + return attachments.join(" "); + case "附件类型": + return mailAttachmentExtensions(attachments); + default: + return ""; + } +} + +/** + * 单条规则是否命中。 + * + * 「是否存在」与「是否包含附件」走存在性判断:值为 否/false/0/no(或空缺省为是) + * 表示期望不存在;其余操作符按文本比较,大小写不敏感,空值直接不命中。 + */ +export function doesMailRuleMatch(rule: MailTriggerRule, message: MailRuleMessage) { + const source = mailRuleSource(rule, message).toLowerCase(); + const wanted = String(rule.value || "") + .trim() + .toLowerCase(); + + if (rule.operator === "是否存在" || rule.field === "是否包含附件") { + const exists = rule.field === "是否包含附件" ? source === "是" : source.trim().length > 0; + const wantExists = !["否", "false", "0", "no"].includes(wanted || "是"); + return exists === wantExists; + } + + if (!wanted) return false; + if (rule.operator === "等于") return source.trim() === wanted; + if (rule.operator === "包含") return source.includes(wanted); + if (rule.operator === "不包含") return !source.includes(wanted); + if (rule.operator === "开头是") return source.startsWith(wanted); + if (rule.operator === "结尾是") return source.endsWith(wanted); + return false; +} + +/** 规则集是否命中:无规则视为「收到新邮件即触发」;any 为任一命中,否则全部命中。 */ +export function doesMailRuleSetMatch(set: MailRuleSet, message: MailRuleMessage) { + const rules = mailRuleList(set); + if (rules.length === 0) return true; + const results = rules.map((rule) => doesMailRuleMatch(rule, message)); + const mode = normalizeMailRuleMode(set.mode); + return mode === "any" ? results.some(Boolean) : results.every(Boolean); +} + +/** 规则文案:存在性判断不带引号,其余带引号。 */ +export function mailRuleText(rule: MailTriggerRule) { + if (rule.operator === "是否存在" || rule.field === "是否包含附件") { + return `${rule.field}${rule.value || "是"}`; + } + return `${rule.field}${rule.operator}“${rule.value || ""}”`; +} + +/** 规则集摘要:向导与运行详情共用。 */ +export function mailRulesSummary(set: MailRuleSet) { + const rules = mailRuleList(set); + if (rules.length === 0) return "收到新邮件即触发"; + const prefix = normalizeMailRuleMode(set.mode) === "any" ? "任一" : "全部"; + return `${prefix}:${rules.map(mailRuleText).join(";")}`; +} + +/** + * 规则集精简摘要:自动化**列表接口** triggerDetail 用的文案。 + * + * 注意:它与 mailRulesSummary 的输出格式不同(只显示首条 + 条数,且不区分 + * 存在性判断),这是重构前 store.ts 里既有的行为,本次原样迁移、未做统一—— + * 统一会改变接口返回文案,属于行为变更,需另行决策。前端列表页不使用该字段, + * 它由 localizedTriggerDetail 用 mailRulesSummary 重新生成。 + */ +export function mailRulesBriefSummary(rules?: readonly Partial[] | null) { + const list = Array.isArray(rules) ? rules : []; + if (list.length === 0) return "收到新邮件即触发"; + + const first: Partial = list[0] || {}; + const firstText = `${first.field || "邮件"}${first.operator || "包含"}${first.value ? `“${first.value}”` : ""}`; + if (list.length === 1) return firstText; + return `${firstText} 等 ${list.length} 条`; +} + +/** 规则归一化键:字段|操作符|值(去空格 + 小写),用于比较两组规则是否等价。 */ +export function normalizedMailRule(rule: MailTriggerRule) { + return `${rule.field}|${rule.operator}|${String(rule.value || "") + .trim() + .toLowerCase()}`; +} + +/** 两组规则是否等价:模式与条数相同,且归一化后逐条相同(与顺序无关)。 */ +export function mailRuleSetsEqual(left: MailRuleSet, right: MailRuleSet) { + const leftRules = mailRuleList(left); + const rightRules = mailRuleList(right); + + if ( + normalizeMailRuleMode(left.mode) !== normalizeMailRuleMode(right.mode) || + leftRules.length !== rightRules.length + ) { + return false; + } + + const leftKeys = leftRules.map(normalizedMailRule).sort(); + const rightKeys = rightRules.map(normalizedMailRule).sort(); + return leftKeys.every((value, index) => value === rightKeys[index]); +} + +/** 前端冲突提示等级:任一侧没有规则、或规则完全相同,都视为高风险。 */ +export function mailConflictLevel(current: MailRuleSet, other: MailRuleSet) { + if (mailRuleList(current).length === 0 || mailRuleList(other).length === 0) return "high"; + if (mailRuleSetsEqual(current, other)) return "high"; + return "possible"; +} diff --git a/lib/automation/mailbox-client.ts b/lib/automation/mailbox-client.ts deleted file mode 100644 index 2106d72..0000000 --- a/lib/automation/mailbox-client.ts +++ /dev/null @@ -1,54 +0,0 @@ -export type MailboxConnection = { - email?: string; - username: string; - password: string; - imapHost: string; - imapPort: number; - imapSecure: boolean; - folder: string; -}; - -export async function fetchMailboxUnread(params: { - authorization?: string; - afterUid?: number; - connection: MailboxConnection; -}) { - const backendBaseUrl = (process.env.EXTERNAL_API_BASE_URL || "http://localhost:8010").replace(/\/+$/, ""); - - const response = await fetch(`${backendBaseUrl}/api/v1/email/unread-config`, { - method: "POST", - headers: { - "Content-Type": "application/json", - ...(params.authorization ? { Authorization: params.authorization } : {}), - }, - body: JSON.stringify({ - after_uid: Number.isInteger(params.afterUid) ? params.afterUid : null, - imap_host: params.connection.imapHost, - imap_port: params.connection.imapPort, - imap_secure: params.connection.imapSecure, - username: params.connection.username, - password: params.connection.password, - folder: params.connection.folder || "INBOX", - }), - }); - - const raw = await response.text(); - let data: any = null; - try { - data = raw ? JSON.parse(raw) : null; - } catch { - data = raw; - } - - if (!response.ok) { - const detail = - typeof data === "object" && data?.detail - ? data.detail - : typeof data === "string" && data - ? data - : `Backend returned ${response.status}`; - throw new Error(String(detail)); - } - - return data ?? { success: true, latest_uid: 0, messages: [] }; -} diff --git a/lib/automation/mailbox-credentials.ts b/lib/automation/mailbox-credentials.ts new file mode 100644 index 0000000..32d0863 --- /dev/null +++ b/lib/automation/mailbox-credentials.ts @@ -0,0 +1,60 @@ +/** + * 监听邮箱凭据的对称加解密(AES-256-GCM)。 + * + * 为什么单独成模块:`mailboxes.ts` 依赖 `lib/db`,测试进程不连数据库,import 不到它; + * 而「加解密往返」是 spec §七 明确要求覆盖的行为,必须是能真实失败断言,而不是对着 + * mock 断言。密钥由调用方传入(生产在 `mailboxes.ts` 里读 `AUTOMATION_MAILBOX_SECRET`, + * 未配置时回退 `JWT_SECRET`),本模块不碰 process.env,因此零依赖、可直接 import。 + * + * 密文格式:`v1::<认证标签 base64>:<密文 base64>`。版本前缀是留给未来的算法 + * 升级位:解析不到 v1 前缀即判为无效,而不是拿旧格式硬解。 + */ +import crypto from "node:crypto"; + +const CIPHER = "aes-256-gcm"; +const VERSION = "v1"; +/** GCM 的推荐 IV 长度(96 bit)。每次加密都必须是新的随机值,否则会泄露明文异或关系。 */ +const IV_BYTES = 12; +/** 凭据无法解密(格式非法、密钥不符、密文被改动)时对外统一使用的错误码。 */ +const CREDENTIAL_INVALID_ERROR = "MAILBOX_CREDENTIAL_INVALID"; + +/** 派生 32 字节密钥:密钥原文允许任意长度(JWT_SECRET 常常很长),统一 sha256 收敛。 */ +function deriveKey(secret: string) { + return crypto.createHash("sha256").update(String(secret)).digest(); +} + +export function encryptMailboxPassword(secret: string, value: string): string { + const iv = crypto.randomBytes(IV_BYTES); + const cipher = crypto.createCipheriv(CIPHER, deriveKey(secret), iv); + const encrypted = Buffer.concat([cipher.update(String(value), "utf8"), cipher.final()]); + const tag = cipher.getAuthTag(); + return `${VERSION}:${iv.toString("base64")}:${tag.toString("base64")}:${encrypted.toString("base64")}`; +} + +export function decryptMailboxPassword(secret: string, value: string): string { + const [version, ivText, tagText, encryptedText] = String(value || "").split(":"); + if (version !== VERSION || !ivText || !tagText || !encryptedText) { + throw new Error(CREDENTIAL_INVALID_ERROR); + } + + // 认证失败(密钥不符、密文被改动)必须收敛成同一个可识别的错误码,而不是把 Node 的 + // 「Unsupported state or unable to authenticate data」抛出去:那句 OpenSSL 措辞既不在 + // 接口错误映射表里、也不在连接失败的启发式里,会一路落成 500(模块 E.3 说的正是这个场景 + // ——密钥被换过之后所有已存凭据失效,用户要看到的是一句能照做的话)。 + try { + const decipher = crypto.createDecipheriv( + CIPHER, + deriveKey(secret), + Buffer.from(ivText, "base64") + ); + // 先设认证标签再解密:密钥不符或密文被改动时 final() 会抛错,不会返回脏明文。 + decipher.setAuthTag(Buffer.from(tagText, "base64")); + return Buffer.concat([ + decipher.update(Buffer.from(encryptedText, "base64")), + decipher.final(), + ]).toString("utf8"); + } catch (error) { + // 原始原因挂在 cause 上:运维排查("是不是换过密钥")需要它,用户不需要。 + throw new Error(CREDENTIAL_INVALID_ERROR, { cause: error }); + } +} diff --git a/lib/automation/mailbox-errors.ts b/lib/automation/mailbox-errors.ts new file mode 100644 index 0000000..cfda843 --- /dev/null +++ b/lib/automation/mailbox-errors.ts @@ -0,0 +1,90 @@ +/** + * 邮箱接口的错误映射:错误码 → HTTP 状态 + 用户可读文案。 + * + * 创建(POST)、编辑(PUT)、列表(GET)三个分支共用一份,避免同一种失败在不同端点 + * 返回不同的话术——这种漂移用户最先发现,而且最难复现。 + */ +import type { NextApiResponse } from "next"; + +const MAILBOX_ERROR_MESSAGES: Record = { + MAILBOX_EMAIL_INVALID: "请输入正确的邮箱地址", + MAILBOX_USERNAME_REQUIRED: "请填写邮箱登录账号", + MAILBOX_PASSWORD_REQUIRED: "请填写邮箱授权码或密码", + MAILBOX_IMAP_HOST_REQUIRED: "请填写 IMAP 服务器", + MAILBOX_IMAP_PORT_INVALID: "IMAP 端口不正确", + MAILBOX_CREDENTIAL_INVALID: "邮箱凭据已失效,请重新填写授权码或密码", + AUTOMATION_MAILBOX_SECRET_MISSING: "服务端尚未配置邮箱凭证加密密钥", +}; + +/** + * 上游 IMAP 失败沿用原文:它带回的是真实原因(主机、端口、认证),改写只会丢失信息。 + * + * 匹配**大小写不敏感**:收信现在跑在本进程里,抛出来的是 Node 与 imapflow 的原文, + * 大小写并不统一(`ECONNREFUSED` / `Authentication failed` / `Socket timeout`)。 + * 漏掉一种,用户看到的就是一句「邮箱操作失败」,而主机写错、授权码失效这些真实原因 + * 已经丢在路上——这正是「保存邮箱只报 500」那次故障的形状。 + */ +const CONNECTION_ERROR_HINTS = [ + "imap", + "登录", + "连接", + "认证", + "authentication", + // 网络层:Node 的系统错误码(连接被拒 / 超时 / 域名解析不了 / 地址不可达) + "econnrefused", + "econnreset", + "etimedout", + "ehostunreach", + "enetunreach", + "enotfound", + "getaddrinfo", + // imapflow 自己的文案 + "socket timeout", + // TLS:自签名、过期、域名不匹配都是配置问题,不是我们这边的故障 + "certificate", + "self-signed", + "tls", +]; + +/** 邮箱地址在本用户下唯一(UNIQUE(created_by_user_id, email))时的唯一约束冲突。 */ +const UNIQUE_VIOLATION = "23505"; + +/** + * 错误码 → 用户可读文案;不在表里的(通常是上游 IMAP 带回的真实原因)原样返回。 + * + * 与 `mailboxApiError` 共用同一张表:**写进 `automation_mailboxes.last_error`、进而显示在 + * 抽屉「最后错误」与通知中心里的文案,必须与接口返回的是同一句话**。只映射接口那一侧的话, + * 同一个故障会在两个地方说两种话——接口说「请重新填写授权码」,通知里却写着 + * `MAILBOX_CREDENTIAL_INVALID`(模块 E.1/E.2 的写入点因此必须先过这里)。 + */ +export function mailboxErrorDisplayText(error: unknown): string { + const code = String((error as { message?: unknown })?.message ?? "").trim(); + return MAILBOX_ERROR_MESSAGES[code] || code; +} + +export function mailboxApiError(error: unknown): { status: number; detail: string } { + const code = String((error as { message?: unknown })?.message ?? ""); + const known = MAILBOX_ERROR_MESSAGES[code]; + if (known) return { status: 400, detail: known }; + + if ((error as { code?: unknown })?.code === UNIQUE_VIOLATION) { + return { + status: 409, + detail: "该邮箱地址已用于你的另一条邮箱记录,请改用其它地址或直接编辑那一条", + }; + } + + const normalizedCode = code.toLowerCase(); + if (CONNECTION_ERROR_HINTS.some((hint) => normalizedCode.includes(hint))) { + return { status: 400, detail: code }; + } + + return { status: 500, detail: code || "邮箱操作失败" }; +} + +/** 直接把映射结果写成响应;只有 500 才打日志(4xx 是预期内的用户错误,不该刷错误日志)。 */ +export function respondMailboxApiError(res: NextApiResponse, error: unknown) { + const { status, detail } = mailboxApiError(error); + if (status >= 500) console.error("[Automation Mailboxes API] error:", error); + return res.status(status).json({ detail }); +} diff --git a/lib/automation/mailbox-form.ts b/lib/automation/mailbox-form.ts new file mode 100644 index 0000000..60c74bb --- /dev/null +++ b/lib/automation/mailbox-form.ts @@ -0,0 +1,104 @@ +/** + * 向导内联邮箱配置的表单状态与请求体(模块 B)。 + * + * 本模块是零依赖纯函数模块:会被打进客户端 bundle,也被 node:test 直接 import + * 来验证"未提供的密码不得退化成空串"这类约束,因此不允许引入 React、axios、 + * lib/env 等依赖。选中的邮箱标识与展示名在 `mailbox-id.ts`,不在本模块。 + */ + +/** 内联新建邮箱表单的可编辑字段:全部按字符串保存,提交时再规整。 */ +export type MailboxFormState = { + name: string; + email: string; + username: string; + password: string; + imapHost: string; + imapPort: string; + folder: string; +}; + +/** 表单初值:端口与文件夹用服务端默认值,其余留空由用户填写。 */ +export const EMPTY_MAILBOX_FORM: MailboxFormState = { + name: "", + email: "", + username: "", + password: "", + imapHost: "", + imapPort: "993", + folder: "INBOX", +}; + +/** 表单必填项的问题代码;调用方负责翻译成文案(与接口错误码一一对应)。 */ +export type MailboxFormIssue = "email" | "username" | "password" | "imapHost" | "imapPort"; + +/** + * 与邮箱接口 `normalizeMailboxConfig` 同一套校验规则:先在前端拦下,避免无谓的连接尝试。 + * + * `passwordOptional`:编辑已有邮箱时密码留空表示"不修改",因此不再是必填项。 + * 默认(新建)仍然必填,与服务端的 MAILBOX_PASSWORD_REQUIRED 一致。 + */ +export function firstMailboxFormIssue( + form: MailboxFormState, + options: { passwordOptional?: boolean } = {} +): MailboxFormIssue | null { + if (!/^\S+@\S+\.\S+$/.test(String(form?.email ?? "").trim())) return "email"; + if (!String(form?.username ?? "").trim()) return "username"; + if (!options.passwordOptional && !String(form?.password ?? "")) return "password"; + if (!String(form?.imapHost ?? "").trim()) return "imapHost"; + + const imapPort = Number(form?.imapPort ?? ""); + if (!Number.isInteger(imapPort) || imapPort <= 0 || imapPort > 65535) return "imapPort"; + + return null; +} + +/** 新建邮箱的请求体。 */ +export type MailboxCreatePayload = { + name: string; + email: string; + username: string; + imapHost: string; + imapPort: number; + folder: string; + password?: string; +}; + +/** + * 组装新建邮箱请求体(模块 B 陷阱 1)。 + * + * 密码为空表示"未提供":此时**不下发该字段**,而不是下发空串。创建路径下空密码会被 + * 服务端直接拒绝(MAILBOX_PASSWORD_REQUIRED),而下发空串还会让"没填"和"填了个空" + * 变成同一件事。向导只新建、不更新,因此这里恒定走"未提供即省略"的语义。 + */ +export function mailboxCreatePayload(form: MailboxFormState): MailboxCreatePayload { + const payload: MailboxCreatePayload = { + name: String(form?.name ?? "").trim(), + email: String(form?.email ?? "").trim(), + username: String(form?.username ?? "").trim(), + imapHost: String(form?.imapHost ?? "").trim(), + imapPort: Number(form?.imapPort) || 993, + folder: String(form?.folder ?? "").trim() || "INBOX", + }; + + const password = String(form?.password ?? ""); + if (password) payload.password = password; + + return payload; +} + +/** 编辑已有邮箱的请求体(模块 C 的 PUT):字段与新建相同,密码语义不同。 */ +export type MailboxUpdatePayload = MailboxCreatePayload & { password: string }; + +/** + * 组装编辑邮箱请求体。 + * + * 与 `mailboxCreatePayload` 的唯一差别:password **总是下发**,留空时是空串。 + * 服务端对空串的规则是"保留原凭据"(`resolveMailboxUpdate`),所以走这条路径时 + * 生产环境每次都在考验"空串不得覆盖密码"这条规则本身——它一旦退化,这里立刻失败, + * 而不是因为省略了字段而被悄悄绕过。 + * + * 已存密码永远不会回传前端,因此表单里的密码框始终是空的,不存在"看起来像原密码"的假值。 + */ +export function mailboxUpdatePayload(form: MailboxFormState): MailboxUpdatePayload { + return { ...mailboxCreatePayload(form), password: String(form?.password ?? "") }; +} diff --git a/lib/automation/mailbox-health.ts b/lib/automation/mailbox-health.ts new file mode 100644 index 0000000..de733cb --- /dev/null +++ b/lib/automation/mailbox-health.ts @@ -0,0 +1,107 @@ +/** + * 监听邮箱的健康状态(模块 E.1 / E.2):连接失败如何记录、如何变成一条用户可见的提醒。 + * + * 为什么单独成模块:`mailboxes.ts`(写状态)与 `store.ts`(派生提醒)都依赖 `lib/db`, + * 测试进程不连数据库、也解析不了 `@/` 别名,而「提醒的 eventKey 到底长什么样」是前端 + * 依赖的契约(页面按 eventKey 去重 toast,见 `page.tsx` 的 toastedNotificationKeysRef)。 + * 与 `mailbox-id.ts`、`mailbox-credentials.ts` 同一手法:零依赖纯函数模块,可直接 import。 + * + * 展示名(label)由调用方按 `mailboxLabelFromRow` 派生后传入,本模块不重复实现那套规则—— + * 「同一个邮箱在哪都叫同一个名字」靠的是只有一份派生逻辑。 + */ + +/** 连接失败提醒的 eventKey 前缀。 */ +export const MAILBOX_ERROR_EVENT_KEY_PREFIX = "mailbox-error:"; + +/** + * 连接失败提醒的 eventKey:**固定为 `mailbox-error:`,不含时间戳**。 + * + * 这是有意的,且是承重的:一个邮箱的失败状态只对应**一条**稳定提醒。因此 + * - 邮箱没恢复时,反复失败不会不断产生新提醒(页面按 eventKey 去重,不会重复弹窗); + * - 邮箱恢复后(status 不再是 error)提醒自动消失,不需要任何"已解决"状态或去抖逻辑。 + * + * 加上时间戳("每次失败一条")会同时破坏这两条性质,所以本函数刻意不接受任何时间参数。 + */ +export function mailboxErrorEventKey(mailboxId: number): string { + return `${MAILBOX_ERROR_EVENT_KEY_PREFIX}${mailboxId}`; +} + +/** 落库的 `last_error` 长度上限:上游报错可能很长,存储必须有界。 */ +export const MAILBOX_ERROR_MAX_LENGTH = 500; +/** 提醒里展示的错误长度:与 store.ts 的 clipNotificationError 同一尺度,列表里一行放得下。 */ +const NOTIFICATION_ERROR_MAX_LENGTH = 120; + +/** 归一化错误原文:去空白、截断;取不到内容时返回空串(由调用方决定兜底文案)。 */ +export function mailboxErrorText( + value: unknown, + maxLength: number = MAILBOX_ERROR_MAX_LENGTH +): string { + const text = String(value ?? "").trim(); + if (!text) return ""; + return text.length > maxLength ? `${text.slice(0, maxLength)}…` : text; +} + +/** 派生提醒所需的最小行视图:调用方从 `automation_mailboxes` 取行后映射成本形状。 */ +export type MailboxErrorRow = { + mailboxId: number; + /** 展示名,由调用方按 `mailboxLabelFromRow` 派生。 */ + label?: string; + error?: string | null; + errorAt?: string | Date | null; +}; + +/** + * 与 `store.ts` 的 `AutomationNotificationItem` 结构一致(kind 复用既有的 `email_failed`)。 + * 本模块不 import 那个类型:它住在 `store.ts` 里,而 `store.ts` 依赖 `lib/db`。 + */ +export type MailboxErrorNotificationItem = { + eventKey: string; + kind: "email_failed"; + level: "strong"; + title: string; + message: string; + automationId: null; + runId: number; + createdAt: string; + read: false; +}; + +/** + * 模块 E.2:把 `status='error'` 的邮箱行派生为提醒条目。 + * + * 调用方只传当前处于 error 的行(SQL 侧过滤),因此"邮箱恢复后提醒消失"是**结构性**的: + * 恢复即不再是 error 行,就再也派生不出条目,无需额外的状态清理。 + * + * `runId` 恒为 0:连接失败发生在建 Run 之前,没有对应的运行记录——前端据此不会打开 + * 运行详情(`drawerRun` 查不到就什么都不渲染),而不是跳到一个不存在的运行上。 + * 同理 `automationId` 为 null:一条邮箱管道可能被多个自动化共用。 + */ +export function mailboxErrorNotificationItems( + rows: readonly MailboxErrorRow[], + now: Date = new Date() +): MailboxErrorNotificationItem[] { + return rows + .filter((row) => Number.isInteger(row?.mailboxId) && Number(row.mailboxId) > 0) + .map((row) => { + const mailboxId = Number(row.mailboxId); + const label = String(row.label ?? "").trim() || `邮箱 ${mailboxId}`; + const error = mailboxErrorText(row.error, NOTIFICATION_ERROR_MAX_LENGTH); + const errorAt = row.errorAt ? new Date(row.errorAt) : null; + const createdAt = + errorAt && !Number.isNaN(errorAt.getTime()) ? errorAt : new Date(now.getTime()); + + return { + eventKey: mailboxErrorEventKey(mailboxId), + kind: "email_failed" as const, + level: "strong" as const, + title: `【失败】${label} 连接失败`, + message: error + ? `无法连接监听邮箱:${error}` + : "无法连接监听邮箱,请在邮箱管理中检查服务器与授权码。", + automationId: null, + runId: 0, + createdAt: createdAt.toISOString(), + read: false as const, + }; + }); +} diff --git a/lib/automation/mailbox-id.ts b/lib/automation/mailbox-id.ts new file mode 100644 index 0000000..e7ee7dd --- /dev/null +++ b/lib/automation/mailbox-id.ts @@ -0,0 +1,213 @@ +/** + * 邮件触发监听邮箱的标识与展示名处理。 + * + * 历史格式 `mailbox:`(以及作为哨兵的 `"system"`)已废弃:mailboxId 一律是 + * 指向 `automation_mailboxes` 的正整数。任何读取该标识的地方都不再做隐式回退:缺失或 + * 非法抛 MAILBOX_ID_REQUIRED,恰为已下线的哨兵值抛 MAILBOX_SYSTEM_RETIRED,展示层 + * 则返回 null 由调用方显示「未配置」——都不再静默落到已下线的系统邮箱。 + * + * 本模块是零依赖纯函数模块:服务端 store、邮箱记录的 API 映射(`mailboxes.ts`) + * 与 node:test 用例共用它,因此不允许引入 `pg`、`lib/env` 等需要运行环境的依赖。 + */ + +export const MAILBOX_ID_REQUIRED = "MAILBOX_ID_REQUIRED"; + +/** 监听邮箱不存在,或不属于当前用户(模块 D.2 归属校验失败)。 */ +export const MAILBOX_NOT_OWNED = "MAILBOX_NOT_OWNED"; + +/** 模块 A:提交的正是已下线的平台系统邮箱哨兵值,需要专门的用户提示。 */ +export const MAILBOX_SYSTEM_RETIRED = "MAILBOX_SYSTEM_RETIRED"; + +/** 模块 A:监听邮箱展示名缺失(遗留行没有存储名)。 */ +export const MAILBOX_LABEL_REQUIRED = "MAILBOX_LABEL_REQUIRED"; + +/** 已下线的系统邮箱哨兵值:`mailboxKey` 时代用它表示"平台共享监听邮箱"。 */ +const RETIRED_SYSTEM_MAILBOX_KEY = "system"; + +/** 归一化邮箱标识:仅接受正整数,其余(含遗留的 `mailbox:`、`"system"`)返回 null。 */ +export function normalizeMailboxId(value: unknown): number | null { + return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : null; +} + +/** + * 读取邮箱标识:取不到合法值时抛错,而不是回退到 "system"。 + * + * 模块 A:取值恰为已下线的哨兵值 `"system"` 时抛出 MAILBOX_SYSTEM_RETIRED。它同样是非法 + * 标识,但需要单独识别——旧客户端仍会提交它,接口据此给出「系统邮箱已下线」的专门提示, + * 而不是通用的"缺少监听邮箱"。正整数 id 不会与之冲突。 + */ +export function requireMailboxId(value: unknown): number { + const mailboxId = normalizeMailboxId(value); + if (mailboxId === null) { + if (typeof value === "string" && value.trim() === RETIRED_SYSTEM_MAILBOX_KEY) { + throw new Error(MAILBOX_SYSTEM_RETIRED); + } + throw new Error(MAILBOX_ID_REQUIRED); + } + return mailboxId; +} + +/** + * 调度器读取监听邮箱展示名:缺失时抛错,没有已下线系统邮箱的兜底(模块 A)。 + * + * 存储名在创建/更新时按邮箱记录派生(模块 D.3),因此正常数据必然有值;取不到说明该行是 + * 遗留或被改坏的数据。此时抛错比显示一个错误的邮箱名更好:调用方按"用户:邮箱"分组捕获, + * 只影响这一组,其他邮箱与其他触发类型的自动化不受影响。 + */ +export function requireMailboxLabel(value: unknown): string { + const label = String(value ?? "").trim(); + if (!label) throw new Error(MAILBOX_LABEL_REQUIRED); + return label; +} + +/** + * 展示层读取监听邮箱展示名:缺失时返回 null,由调用方显示「未配置」,不回退到系统邮箱。 + * + * 与 `requireMailboxLabel` 只差在失败方式:列表接口与运行详情不能因为一行遗留数据的展示名 + * 缺失就整体报错(那会连带影响同一响应里其他触发类型的自动化)。 + */ +export function storedMailboxLabel(value: unknown): string | null { + const label = String(value ?? "").trim(); + return label || null; +} + +/** 调度分组键(同一用户同一监听邮箱为一组,决定 claim 与去重的作用范围)。 */ +export function mailboxGroupKey(userId: number, mailboxId: number): string { + return `${userId}:${mailboxId}`; +} + +/** + * 由邮箱记录派生展示名(模块 D.3)。 + * + * 展示名只能来自邮箱记录本身,客户端传上来的 `mailboxLabel` 一律忽略,避免伪造。 + * 规则与邮箱列表接口(`mailboxes.ts` 的 `mailboxRowToApi`)完全一致,因此 + * 「向导里选中的邮箱」与「自动化列表里显示的邮箱」必然是同一个字符串。 + */ +export function mailboxLabelFromRow(row: { name?: unknown; email?: unknown } | null | undefined): string { + const name = String(row?.name ?? ""); + const email = String(row?.email ?? ""); + return name && name !== email ? `${name} · ${email}` : email; +} + +/* + * 以下为向导端("use client")的选择与展示派生逻辑(模块 B / D.7)。 + * 放在本模块是因为它们判断的是同一件事——邮箱标识与展示名,且必须与上面的 + * 分组键、邮箱列表接口(mailboxes.ts 的 mailboxRowToApi)保持一致,不能各写一份。 + */ + +/** 下拉中「+ 配置新邮箱…」的哨兵值:mailboxId 恒为正整数,不会与之冲突。 */ +export const MAILBOX_NEW_OPTION = "new"; + +/** 下拉的显示值:选中已保存邮箱时是它的 id,配置新邮箱时是哨兵值。 */ +export function mailboxSelectValue(mailboxId: number | null, formOpen: boolean): string { + if (formOpen) return MAILBOX_NEW_OPTION; + const id = normalizeMailboxId(mailboxId); + return id === null ? MAILBOX_NEW_OPTION : String(id); +} + +/** 邮箱列表接口(`mailboxes.ts` 的 mailboxRowToApi)返回项的可见字段。 */ +export type MailboxOption = { + id: number; + label?: string; + name?: string; + email?: string; + username?: string; + imapHost?: string; + imapPort?: number; + imapSecure?: boolean; + folder?: string; + status?: string; +}; + +/** 参与邮件触发的自动化最小视图(page.tsx 的 Automation 是它的超集)。 */ +export type MailboxScopedAutomation = { + id: number; + trigger: string; + status: string; + mailboxId?: number | null; +}; + +/** 下拉项展示名:优先用接口给的 label,缺失时按与接口一致的规则派生。 */ +export function mailboxOptionLabel(option: MailboxOption): string { + const label = String(option?.label ?? "").trim(); + return label || mailboxLabelFromRow(option); +} + +/** + * 邮箱名单加载完成后的**默认**选择:只有还没有选择时才用名单里的第一条。 + * + * 已有选择一律原样保留,**即使它在名单里查不到**——把查不到的选择静默换成名单第一条, + * 会让一次普通保存把自动化改绑到另一个收件箱(监听错邮箱、可能用错账号回信)。 + * 查不到的选择由 `isMailboxSelectionUnresolved` 判定并要求用户重新选择。 + */ + +export function defaultMailboxSelection( + mailboxId: number | null, + mailboxes: readonly MailboxOption[], +): number | null { + const selected = normalizeMailboxId(mailboxId); + if (selected !== null) return selected; + return normalizeMailboxId(mailboxes[0]?.id) ?? null; +} + +/** + * 有选择、名单也加载完了,但名单里没有它:遗留行、邮箱已被删除,或名单拉取失败。 + * + * 名单尚未加载完时一律返回 false——那时"查不到"只说明数据还没到,不能据此判定失败, + * 否则向导会在加载窗口里无故展开表单。判定为 true 时调用方必须让用户重新选择, + * 而不是替他选一个。 + */ +export function isMailboxSelectionUnresolved( + mailboxId: number | null, + mailboxes: readonly MailboxOption[], + mailboxesLoaded: boolean, +): boolean { + if (!mailboxesLoaded) return false; + const selected = normalizeMailboxId(mailboxId); + if (selected === null) return false; + return !mailboxes.some((option) => normalizeMailboxId(option.id) === selected); +} + +/** + * D.7:与当前选择监听同一邮箱、同属邮件触发且正在运行的其他自动化。 + * + * 没有选中邮箱时(含"正在配置新邮箱")返回空候选——此时不存在可比较的分组。 + * `mailboxId` 缺失的遗留行(旧字符串键)不属于任何分组,因此不会被误判为冲突。 + * 与 `mailboxGroupKey` 的范围一致:只有同一 `userId:mailboxId` 才落在同一个 claim 与去重范围内。 + */ +export function selectMailboxScopedAutomations( + items: readonly T[], + options: { mailboxId: number | null; excludeId?: number | null }, +): T[] { + const mailboxId = normalizeMailboxId(options.mailboxId); + if (mailboxId === null) return []; + + return items.filter( + (item) => + item.id !== options.excludeId && + item.trigger === "邮件触发" && + item.status === "running" && + normalizeMailboxId(item.mailboxId) === mailboxId, + ); +} + +/** + * 自动化列表 / 运行详情要显示的监听邮箱名。 + * + * 返回 null 表示该自动化没有可解析的监听邮箱(遗留行,或名单里查不到且自身也没有 + * 存储名):调用方据此给出「未配置」提示,而不是回退到已下线的"系统邮箱"。 + * 优先按 id 现查名单,邮箱改名后展示名跟着更新;查不到时退回服务端派生的存储名。 + */ +export function automationMailboxLabel( + item: { mailboxId?: number | null; mailboxLabel?: string }, + mailboxes: readonly MailboxOption[], +): string | null { + const mailboxId = normalizeMailboxId(item?.mailboxId); + if (mailboxId === null) return null; + + const matched = mailboxes.find((option) => normalizeMailboxId(option.id) === mailboxId); + if (matched) return mailboxOptionLabel(matched); + + const stored = String(item?.mailboxLabel ?? "").trim(); + return stored || null; +} diff --git a/lib/automation/mailbox-input.ts b/lib/automation/mailbox-input.ts new file mode 100644 index 0000000..721ed9d --- /dev/null +++ b/lib/automation/mailbox-input.ts @@ -0,0 +1,203 @@ +/** + * 监听邮箱配置的归一化校验(创建)与更新合并(编辑)。 + * + * 创建(POST)与编辑(PUT)必须共用同一套字段规则——各写一份必然漂移,而漂移的后果是 + * 「向导里能存、编辑器里存不了」这类无解工单。另外编辑路径的密码语义是本模块存在的 + * 主要原因:**密码留空表示保留原凭据**,把已存密码覆盖成空串会让所有绑定该邮箱的 + * 自动化在下次轮询时静默失败,因此它必须住在零依赖模块里、能被 node:test 直接断言 + * (`mailboxes.ts` 依赖 `lib/db`,测试进程连不了数据库)。 + */ + +/** 邮箱配置的原始入参:创建时 email/password/imapHost 必填,编辑时全部可选。 */ +export type MailboxConfigInput = { + name?: string; + email?: string; + username?: string; + password?: string; + imapHost?: string; + imapPort?: number; + imapSecure?: boolean; + folder?: string; +}; + +/** 归一化后的邮箱配置:字段齐备且已通过校验,可直接落库或建连接。 */ +export type MailboxConfig = { + name: string; + email: string; + username: string; + password: string; + imapHost: string; + imapPort: number; + imapSecure: boolean; + folder: string; +}; + +function normalizedEmail(value: unknown) { + return String(value ?? "") + .trim() + .toLowerCase(); +} + +function normalizedHost(value: unknown) { + return String(value ?? "") + .trim() + .toLowerCase(); +} + +function normalizedFolder(value: unknown) { + const folder = String(value ?? "").trim(); + return folder || "INBOX"; +} + +/** + * 归一化并校验邮箱配置。非法取值抛可识别的错误码(接口层翻译成文案), + * 不做任何静默兜底:写进库的必须是用户真正填的那套配置。 + */ +export function normalizeMailboxConfig(input: MailboxConfigInput): MailboxConfig { + const email = normalizedEmail(input?.email); + const username = String(input?.username ?? "").trim() || email; + const password = String(input?.password ?? ""); + const imapHost = normalizedHost(input?.imapHost); + const imapPort = + input?.imapPort === undefined || input?.imapPort === null ? 993 : Number(input.imapPort); + const imapSecure = input?.imapSecure !== false; + const folder = normalizedFolder(input?.folder); + const name = String(input?.name ?? "").trim() || email; + + if (!email || !/^\S+@\S+\.\S+$/.test(email)) throw new Error("MAILBOX_EMAIL_INVALID"); + if (!username) throw new Error("MAILBOX_USERNAME_REQUIRED"); + if (!password) throw new Error("MAILBOX_PASSWORD_REQUIRED"); + if (!imapHost) throw new Error("MAILBOX_IMAP_HOST_REQUIRED"); + if (!Number.isInteger(imapPort) || imapPort <= 0 || imapPort > 65535) { + throw new Error("MAILBOX_IMAP_PORT_INVALID"); + } + + return { name, email, username, password, imapHost, imapPort, imapSecure, folder }; +} + +export type MailboxUpdateResolution = { + /** 合并后的完整配置(含生效的密码),可直接用于落库与建立 IMAP 连接。 */ + config: MailboxConfig; + /** 模块 D.4:连接身份(主机 / 账号 / 文件夹)变了才需要重置游标。 */ + cursorResetRequired: boolean; +}; + +/** PUT 请求体里允许出现的字段。 */ +const UPDATE_BODY_TEXT_FIELDS = [ + "name", + "email", + "username", + "password", + "imapHost", + "folder", +] as const; + +/** + * 从编辑(PUT)请求体里读出**真正出现过**的字段。 + * + * 与创建路径相反:创建时缺失字段各有默认值,编辑时缺失字段表示"保留原值"。因此这里 + * 不做任何补齐——尤其不能把缺失的 `password` 补成空串。当前"未提供"与"提供了空串" + * 都表示"不修改既有凭据",但两者必须一路可区分:一旦中间层把它们抹平成同一个值, + * 未来的语义分叉(例如允许显式清空)就没有任何判断依据了。 + */ +export function mailboxUpdateInputFromBody(body: unknown): MailboxConfigInput { + const source: Record = + body && typeof body === "object" ? (body as Record) : {}; + const input: MailboxConfigInput = {}; + + for (const field of UPDATE_BODY_TEXT_FIELDS) { + const value = source[field]; + if (typeof value === "string") input[field] = value; + } + + // 端口:数字与数字串都收;空串/null/undefined 视为"没提供",保持原端口。 + const imapPort = source.imapPort; + if (imapPort !== undefined && imapPort !== null && imapPort !== "") { + input.imapPort = Number(imapPort); + } + if (typeof source.imapSecure === "boolean") input.imapSecure = source.imapSecure; + + return input; +} + +/** + * 编辑语义:把请求里**出现过的**字段覆盖到既有配置上,未出现的字段保留原值。 + * + * 密码的两条空值路径——未提供(undefined)与提供了空串("")——都表示"不修改": + * 邮箱凭据不允许为空,因此空串不可能是一个有效的"新密码",只能理解为"别动它"。 + * 两者的区别在于调用方必须真的区分它们:`input.password || existing.password` 这类 + * 写法一旦被改成 `input.password ?? existing.password`,空串就会覆盖掉存储的凭据。 + */ +export function resolveMailboxUpdate( + existing: MailboxConfig, + input: MailboxConfigInput +): MailboxUpdateResolution { + const provided = (value: unknown) => value !== undefined && value !== null; + const password = String(input?.password ?? ""); + + const config = normalizeMailboxConfig({ + name: provided(input?.name) ? input.name : existing.name, + email: provided(input?.email) ? input.email : existing.email, + username: provided(input?.username) ? input.username : existing.username, + password: password === "" ? existing.password : password, + imapHost: provided(input?.imapHost) ? input.imapHost : existing.imapHost, + imapPort: provided(input?.imapPort) ? input.imapPort : existing.imapPort, + imapSecure: provided(input?.imapSecure) ? input.imapSecure : existing.imapSecure, + folder: provided(input?.folder) ? input.folder : existing.folder, + }); + + return { config, cursorResetRequired: mailboxIdentityChanged(existing, config) }; +} + +/** + * 编辑请求是否带来了可用的**新密码**(空串与未提供都算"没带",与 `resolveMailboxUpdate` + * 的判定同源)。 + * + * 调用方据此决定要不要去解密既有密文:带了新密码时旧密文不会被用到,而它此刻可能根本 + * 解不开(模块 E.3:密钥被换过)。先解密会让用户卡在"照提示重新填写授权码 → 还是同一条 + * 凭据失效错误"的循环里。 + */ +export function mailboxUpdateSuppliesPassword(input?: MailboxConfigInput | null): boolean { + return String(input?.password ?? "") !== ""; +} + +/** 决定 UID 基线的三个字段:IMAP 主机 / 登录账号 / 文件夹。邮箱记录与配置都按它比较。 */ +export type MailboxIdentity = Pick; + +/** + * 模块 D.4:UID 基线由 IMAP 主机 + 账号 + 文件夹共同决定,三者任一变化都意味着 + * "这就是另一个收件箱了",必须把游标打回未初始化;否则调度器会拿旧基线去比新邮箱的 + * UID,要么漏邮件、要么重复处理。 + * + * 纯密码、端口、加密方式、名称的变化不移动 UID 基线,因此不重置:重置会让下一次轮询 + * 重新取一遍最新 UID 作基线,白丢掉这一段窗口里的邮件。 + * + * 两侧都按写入时的规则归一化再比较:主机大小写、文件夹首尾空格、空文件夹等于 INBOX + * 都不算变更,避免"只改了个大小写就重置"。 + * + * **创建(POST 是按 email 就地更新的 upsert)与编辑(PUT)两条路径共用本判定**:写入 + * 会移动 UID 基线这件事与"改的是哪个字段"有关,与"走的是哪个端点"无关,各写一份比较 + * 必然漂移。 + */ +export function mailboxIdentityChanged(existing: MailboxIdentity, next: MailboxIdentity): boolean { + return ( + normalizedHost(existing?.imapHost) !== normalizedHost(next?.imapHost) || + String(existing?.username ?? "").trim() !== String(next?.username ?? "").trim() || + normalizedFolder(existing?.folder) !== normalizedFolder(next?.folder) + ); +} + +/** + * 创建路径(POST 的 upsert)是否需要重置游标:**该地址已有记录,且这次写入移动了 UID 基线**。 + * + * 没有既有行时返回 false:那是真正的插入,新记录此刻还没有游标行可重置。 + * 判定委托给 `mailboxIdentityChanged`,与 PUT 路径的 `cursorResetRequired` 同源—— + * 创建走的是 `ON CONFLICT (created_by_user_id, email) DO UPDATE`,它同样会改写主机/账号/ + * 文件夹,因此同样会让旧游标高水位变成"新服务器上永远追不上的值"。 + */ +export function createMailboxCursorResetRequired( + existing: MailboxIdentity | null | undefined, + next: MailboxIdentity +): boolean { + return !!existing && mailboxIdentityChanged(existing, next); +} diff --git a/lib/automation/mailboxes.ts b/lib/automation/mailboxes.ts index 6e790a5..f205772 100644 --- a/lib/automation/mailboxes.ts +++ b/lib/automation/mailboxes.ts @@ -1,5 +1,21 @@ -import crypto from "crypto"; import pool from "@/lib/db"; +import { fetchMailboxUnread } from "@/lib/automation/imap-client"; +import { assertAutomationTablesReady } from "@/lib/automation/schema"; +import { + decryptMailboxPassword, + encryptMailboxPassword, +} from "@/lib/automation/mailbox-credentials"; +import { mailboxLabelFromRow } from "@/lib/automation/mailbox-id"; +import { mailboxErrorText } from "@/lib/automation/mailbox-health"; +import { mailboxErrorDisplayText } from "@/lib/automation/mailbox-errors"; +import { + createMailboxCursorResetRequired, + mailboxUpdateSuppliesPassword, + normalizeMailboxConfig, + resolveMailboxUpdate, + type MailboxConfigInput, + type MailboxIdentity, +} from "@/lib/automation/mailbox-input"; import { getUserTenantId } from "@/lib/tenantMapping"; export type AutomationMailboxInput = { @@ -13,99 +29,62 @@ export type AutomationMailboxInput = { folder?: string; }; -let mailboxInitPromise: Promise | null = null; - -async function ensureAutomationMailboxTable() { - if (mailboxInitPromise) return mailboxInitPromise; - - mailboxInitPromise = pool.query(` - CREATE TABLE IF NOT EXISTS automation_mailboxes ( - id SERIAL PRIMARY KEY, - tenant_id INTEGER, - created_by_user_id INTEGER NOT NULL, - name VARCHAR(200) NOT NULL, - email VARCHAR(320) NOT NULL, - username VARCHAR(320) NOT NULL, - password_ciphertext TEXT NOT NULL, - imap_host VARCHAR(255) NOT NULL, - imap_port INTEGER NOT NULL DEFAULT 993, - imap_secure BOOLEAN NOT NULL DEFAULT TRUE, - folder VARCHAR(255) NOT NULL DEFAULT 'INBOX', - status VARCHAR(20) NOT NULL DEFAULT 'connected', - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - UNIQUE(created_by_user_id, email) - ); - - CREATE INDEX IF NOT EXISTS idx_automation_mailboxes_owner - ON automation_mailboxes(created_by_user_id, created_at DESC); - `).then(() => undefined).catch((error) => { - mailboxInitPromise = null; - throw error; - }); - - return mailboxInitPromise; -} +/** + * 编辑邮箱的入参:全部字段可选——**未出现的字段保留原值**。 + * 与创建不同,这里不能对缺失字段套默认值,否则一次"只改密码"的保存会把主机、账号、 + * 文件夹一起改写。密码的语义见 `mailbox-input.ts` 的 `resolveMailboxUpdate`。 + */ +export type AutomationMailboxUpdateInput = MailboxConfigInput; -function encryptionKey() { +/** 加密密钥:优先专用密钥,未配置时回退 JWT_SECRET(轮换 JWT_SECRET 会让旧密文解不开)。 */ +function encryptionSecret() { const secret = process.env.AUTOMATION_MAILBOX_SECRET || process.env.JWT_SECRET; if (!secret) { throw new Error("AUTOMATION_MAILBOX_SECRET_MISSING"); } - return crypto.createHash("sha256").update(secret).digest(); + return secret; } function encryptPassword(value: string) { - const iv = crypto.randomBytes(12); - const cipher = crypto.createCipheriv("aes-256-gcm", encryptionKey(), iv); - const encrypted = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]); - const tag = cipher.getAuthTag(); - return `v1:${iv.toString("base64")}:${tag.toString("base64")}:${encrypted.toString("base64")}`; + return encryptMailboxPassword(encryptionSecret(), value); } function decryptPassword(value: string) { - const [version, ivText, tagText, encryptedText] = String(value || "").split(":"); - if (version !== "v1" || !ivText || !tagText || !encryptedText) { - throw new Error("MAILBOX_CREDENTIAL_INVALID"); - } + return decryptMailboxPassword(encryptionSecret(), value); +} - const decipher = crypto.createDecipheriv( - "aes-256-gcm", - encryptionKey(), - Buffer.from(ivText, "base64"), +/** + * 模块 D.4:把游标打回未初始化,`last_uid` 也必须归零。 + * + * `saveAutomationEmailMailboxCursor` 用 `GREATEST(旧值, 新值)` 写回,所以只置 + * `initialized=false` 是不够的:换主机后若新邮箱的 UID 空间更小(新机器最新只有 100, + * 旧基线是 5000),重新建立基线时会保留 5000,随后所有新邮件都因为"UID 不大于 5000" + * 被过滤掉——正是 D.4 要避免的漏邮件。归零后重新建立的基线恰好是新邮箱的最新 UID。 + */ +async function resetAutomationMailboxCursor(userId: number, mailboxId: number) { + await pool.query( + `UPDATE automation_email_mailbox_cursors + SET last_uid=0, initialized=FALSE, updated_at=NOW() + WHERE created_by_user_id=$1 AND mailbox_id=$2`, + [userId, mailboxId], ); - decipher.setAuthTag(Buffer.from(tagText, "base64")); - return Buffer.concat([ - decipher.update(Buffer.from(encryptedText, "base64")), - decipher.final(), - ]).toString("utf8"); -} - -function normalizeInput(input: AutomationMailboxInput) { - const email = String(input.email || "").trim().toLowerCase(); - const username = String(input.username || email).trim(); - const password = String(input.password || ""); - const imapHost = String(input.imapHost || "").trim().toLowerCase(); - const imapPort = Number(input.imapPort || 993); - const imapSecure = input.imapSecure !== false; - const folder = String(input.folder || "INBOX").trim() || "INBOX"; - const name = String(input.name || email).trim() || email; - - if (!email || !/^\S+@\S+\.\S+$/.test(email)) throw new Error("MAILBOX_EMAIL_INVALID"); - if (!username) throw new Error("MAILBOX_USERNAME_REQUIRED"); - if (!password) throw new Error("MAILBOX_PASSWORD_REQUIRED"); - if (!imapHost) throw new Error("MAILBOX_IMAP_HOST_REQUIRED"); - if (!Number.isInteger(imapPort) || imapPort <= 0 || imapPort > 65535) { - throw new Error("MAILBOX_IMAP_PORT_INVALID"); - } +} - return { name, email, username, password, imapHost, imapPort, imapSecure, folder }; +/** + * 模块 D.5:删掉邮箱记录时一并清游标,避免 id 复用把上一条记录的游标接到新邮箱上(游标串号)。 + * 主键含 `created_by_user_id`,删除范围必须同样带上它。 + */ +async function deleteAutomationMailboxCursor(userId: number, mailboxId: number) { + await pool.query( + `DELETE FROM automation_email_mailbox_cursors + WHERE created_by_user_id=$1 AND mailbox_id=$2`, + [userId, mailboxId], + ); } export function mailboxRowToApi(row: any) { return { id: Number(row.id), - key: `mailbox:${row.id}`, name: row.name, email: row.email, username: row.username, @@ -114,14 +93,18 @@ export function mailboxRowToApi(row: any) { imapSecure: row.imap_secure !== false, folder: row.folder || "INBOX", status: row.status || "connected", - label: row.name && row.name !== row.email ? `${row.name} · ${row.email}` : row.email, + // 模块 E.1:连接失败的原因与时间。列表接口是抽屉「状态徽标 + 最后错误」的唯一数据源, + // 因此这两列必须在这里露面——列有了但接口不下发,抽屉就永远显示"无"。 + lastError: row.last_error || undefined, + lastErrorAt: row.last_error_at || undefined, + label: mailboxLabelFromRow(row), createdAt: row.created_at, updatedAt: row.updated_at || row.created_at, }; } export async function listAutomationMailboxes(userId: number) { - await ensureAutomationMailboxTable(); + await assertAutomationTablesReady(); const result = await pool.query( `SELECT * FROM automation_mailboxes WHERE created_by_user_id=$1 @@ -131,12 +114,45 @@ export async function listAutomationMailboxes(userId: number) { return result.rows; } +/** 既有行 → 身份三要素。与 PUT 路径共用 `mailboxIdentityChanged`,不另写比较。 */ +function mailboxIdentityFromRow(row: { + imap_host?: unknown; + username?: unknown; + folder?: unknown; +}): MailboxIdentity { + return { + imapHost: String(row.imap_host || ""), + username: String(row.username || ""), + folder: String(row.folder || "INBOX"), + }; +} + +/** 按 upsert 的冲突目标 `(created_by_user_id, email)` 取既有行;没有则返回 null。 */ +async function findAutomationMailboxByEmail(userId: number, email: string) { + const result = await pool.query( + `SELECT * FROM automation_mailboxes + WHERE created_by_user_id=$1 AND email=$2 + LIMIT 1`, + [userId, email], + ); + return result.rows[0] || null; +} + export async function createAutomationMailbox(userId: number, input: AutomationMailboxInput) { - await ensureAutomationMailboxTable(); + await assertAutomationTablesReady(); const tenantId = await getUserTenantId(userId); - const config = normalizeInput(input); + const config = normalizeMailboxConfig(input); const encrypted = encryptPassword(config.password); + // 模块 D.4 对创建路径同样成立:本函数是按 `(created_by_user_id, email)` 的 upsert, + // 用户重填一个已登记的地址、却换了主机/账号/文件夹时走的就是这条路径,既有记录的 UID + // 基线当场失效。既有行必须在写入**之前**取出——upsert 之后旧身份就查不回来了。 + const existingRow = await findAutomationMailboxByEmail(userId, config.email); + const cursorResetRequired = createMailboxCursorResetRequired( + existingRow ? mailboxIdentityFromRow(existingRow) : null, + config, + ); + const result = await pool.query( `INSERT INTO automation_mailboxes ( tenant_id, created_by_user_id, name, email, username, @@ -151,6 +167,8 @@ export async function createAutomationMailbox(userId: number, input: AutomationM imap_secure=EXCLUDED.imap_secure, folder=EXCLUDED.folder, status='connected', + last_error=NULL, + last_error_at=NULL, updated_at=NOW() RETURNING *`, [ @@ -166,11 +184,19 @@ export async function createAutomationMailbox(userId: number, input: AutomationM config.folder, ], ); + + // 只有写成功了才动游标(与 PUT 路径同一顺序、同一理由):写失败时基线必须原样保留。 + // 少了这一步的后果是静默的——旧高水位留在新服务器上,所有 `uid <= 旧值` 的邮件被 + // 无声丢弃,而状态仍显示「已连接」。 + if (cursorResetRequired) { + await resetAutomationMailboxCursor(userId, Number(result.rows[0].id)); + } + return result.rows[0]; } export async function getAutomationMailboxForUser(userId: number, mailboxId: number) { - await ensureAutomationMailboxTable(); + await assertAutomationTablesReady(); const result = await pool.query( `SELECT * FROM automation_mailboxes WHERE id=$1 AND created_by_user_id=$2 @@ -180,11 +206,71 @@ export async function getAutomationMailboxForUser(userId: number, mailboxId: num return result.rows[0] || null; } -export function mailboxConnectionFromRow(row: any) { +/** + * 模块 E.1:把邮箱标记为连接失败,并记下原因与时间。 + * + * 这是「一次写入、三处亮起」的那一次写入——抽屉的状态徽标、抽屉的「最后错误」、 + * 通知中心里按 `status='error'` 派生的那条提醒,都来自这一行。 + * + * **本函数不抛错**(失败只记日志):它记录的是一个已经发生的失败,绝不能反过来把调用方 + * 手里那个原始错误盖掉,或让调度器的错误处理多出一条无关的堆栈。 + */ +export async function markAutomationMailboxConnectionError( + userId: number, + mailboxId: number, + error: unknown, +) { + // 先翻译成用户可读文案再落库:`last_error` 是抽屉「最后错误」与通知中心的同一份文案来源, + // 不能让 `MAILBOX_CREDENTIAL_INVALID` 这类错误码原样出现在界面上(模块 E.3 的一半价值 + // 就在于这句「请重新填写授权码」真的能被人看懂并照做)。 + const message = mailboxErrorText(mailboxErrorDisplayText(error)) || "邮箱连接失败"; + + try { + await assertAutomationTablesReady(); + await pool.query( + `UPDATE automation_mailboxes + SET status='error', last_error=$3, last_error_at=NOW(), updated_at=NOW() + WHERE id=$1 AND created_by_user_id=$2`, + [mailboxId, userId, message], + ); + } catch (recordError) { + console.error("[Automation Mailboxes] failed to record connection error:", recordError); + } +} + +/** + * 模块 E.1:连接成功时恢复为 `connected` 并清掉上次的错误记录。 + * + * 只在"当前不是 connected"时才该被调用(调度器按已取到的行判断),因此正常轮询—— + * 每 10 秒一次——不产生任何写库;这里的 `status <> 'connected'` 只是并发下的兜底。 + * 与写入同一个道理:恢复状态是副作用,不能让它把一次成功的轮询变成失败。 + */ +export async function markAutomationMailboxConnected(userId: number, mailboxId: number) { + try { + await assertAutomationTablesReady(); + await pool.query( + `UPDATE automation_mailboxes + SET status='connected', last_error=NULL, last_error_at=NULL, updated_at=NOW() + WHERE id=$1 AND created_by_user_id=$2 AND status <> 'connected'`, + [mailboxId, userId], + ); + } catch (error) { + console.error("[Automation Mailboxes] failed to mark mailbox connected:", error); + } +} + +/** + * 邮箱行 → 连接参数。 + * + * `options.password` 用于**顶替**行内密文解出的密码,只在确实有新密码时传(见 + * `updateAutomationMailbox`):传了就不去解密旧密文。不传则按原样解密,解不开即抛 + * `MAILBOX_CREDENTIAL_INVALID`。 + */ +export function mailboxConnectionFromRow(row: any, options: { password?: string } = {}) { return { email: String(row.email || ""), username: String(row.username || ""), - password: decryptPassword(String(row.password_ciphertext || "")), + password: options.password ?? decryptPassword(String(row.password_ciphertext || "")), imapHost: String(row.imap_host || ""), imapPort: Number(row.imap_port || 993), imapSecure: row.imap_secure !== false, @@ -192,16 +278,102 @@ export function mailboxConnectionFromRow(row: any) { }; } +/** + * 编辑已有邮箱(模块 C 的新增后端能力)。 + * + * 三件事必须一起发生,缺任何一个都会留下难查的坏状态: + * 1. **先真实连一次 IMAP**,与创建一致——不可达的配置不落库; + * 2. **密码留空保留原值**(判定在 `resolveMailboxUpdate` 里,空串与未提供都表示不修改); + * 3. **连接身份变了就重置游标**(模块 D.4),否则调度器会拿旧基线比新邮箱的 UID。 + * + * 返回 null 表示该 id 不存在或不属于当前用户——两种情况都按 404 处理,不泄露他人资源的存在性。 + */ +export async function updateAutomationMailbox( + userId: number, + mailboxId: number, + input: AutomationMailboxUpdateInput, +) { + await assertAutomationTablesReady(); + const existingRow = await getAutomationMailboxForUser(userId, mailboxId); + if (!existingRow) return null; + + const { config, cursorResetRequired } = resolveMailboxUpdate( + { + name: String(existingRow.name || ""), + // 请求里带了新密码就不去解密旧密文:密钥被换过之后旧密文必然解不开,而用户此刻提交的 + // 正是"重新填写授权码"这件事本身。先解密的话,接口会一直报「凭据已失效」,用户照提示 + // 重填还是同一条错误——那是模块 E.3 承诺的唯一自救路径,不能是死循环。 + // 没带新密码时照旧解密(合并结果要用它),解不开就如实报 400 而不是 500。 + ...mailboxConnectionFromRow( + existingRow, + mailboxUpdateSuppliesPassword(input) ? { password: String(input.password) } : {}, + ), + }, + input, + ); + + // 保存前先真实连接一次:既有凭据解不开、或新配置连不上,都在这里失败并保持原记录不变。 + await fetchMailboxUnread({ + connection: { + email: config.email, + username: config.username, + password: config.password, + imapHost: config.imapHost, + imapPort: config.imapPort, + imapSecure: config.imapSecure, + folder: config.folder, + }, + }); + + const result = await pool.query( + `UPDATE automation_mailboxes SET + name=$3, email=$4, username=$5, password_ciphertext=$6, + imap_host=$7, imap_port=$8, imap_secure=$9, folder=$10, + status='connected', last_error=NULL, last_error_at=NULL, updated_at=NOW() + WHERE id=$1 AND created_by_user_id=$2 + RETURNING *`, + [ + mailboxId, + userId, + config.name, + config.email, + config.username, + encryptPassword(config.password), + config.imapHost, + config.imapPort, + config.imapSecure, + config.folder, + ], + ); + + if (result.rows.length === 0) return null; + + // 只有真的写成功了才动游标:写失败时基线必须原样保留。 + if (cursorResetRequired) await resetAutomationMailboxCursor(userId, mailboxId); + + return result.rows[0]; +} + export async function deleteAutomationMailbox(userId: number, mailboxId: number) { - await ensureAutomationMailboxTable(); - const mailboxKey = `mailbox:${mailboxId}`; + await assertAutomationTablesReady(); + // 依赖检查认两种引用,两者都是"这条自动化绑着某个邮箱": + // 1. 键格式统一后的 `trigger_config.mailboxId`(整数,与 $2::text 比较); + // 2. **任何仍带 `mailboxKey` 的遗留行**。A.1 的清理只删 `mailboxKey='system'`,其余遗留键 + // (`mailbox:`)会原样留下,它们同样是邮件触发、同样依赖着某条邮箱记录。 + // 这里只做**键存在性**判断(jsonb 的 `?` 操作符),不取值、不解码:§十一 禁止的是字符串 + // 编解码,而不是"这一行有没有旧键"这个事实。遗留行无法归属到具体某个整数 id,因此按 + // 最保守的方向处理——算作每个邮箱的依赖,宁可拒删(409 有提示),不可漏判(删掉后 + // 那条自动化的邮箱就没了)。 const dependentResult = await pool.query( `SELECT id, name FROM automation_tasks WHERE created_by_user_id=$1 AND trigger_type='邮件触发' - AND trigger_config->>'mailboxKey'=$2 + AND ( + trigger_config->>'mailboxId'=$2::text + OR trigger_config ? 'mailboxKey' + ) ORDER BY id`, - [userId, mailboxKey], + [userId, mailboxId], ); if (dependentResult.rows.length > 0) { @@ -214,5 +386,10 @@ export async function deleteAutomationMailbox(userId: number, mailboxId: number) RETURNING id`, [mailboxId, userId], ); - return { deleted: result.rows.length > 0, dependents: [] }; + const deleted = result.rows.length > 0; + + // 依赖检查命中时会先返回 409、邮箱仍在库里,游标也就必须保留;只有删除真的发生才清理。 + if (deleted) await deleteAutomationMailboxCursor(userId, mailboxId); + + return { deleted, dependents: [] }; } diff --git a/lib/automation/retention.ts b/lib/automation/retention.ts new file mode 100644 index 0000000..b766305 --- /dev/null +++ b/lib/automation/retention.ts @@ -0,0 +1,30 @@ +/** + * 邮件去重表的保留期(模块 E.4)。 + * + * `automation_email_processed_messages` 每处理一封新邮件就写一行,没有删除路径就会无界增长。 + * 保留 30 天。 + * + * 为什么 30 天足够,以及为什么删旧行不会与游标状态打架:一行去重记录的使命只是"这封邮件 + * 已被处理过,别再处理一次",而它只需要活到该邮件的 UID 落到游标高水位以下为止——正常情况下 + * 调度器处理完这封邮件就立刻推进游标(`saveAutomationEmailMailboxCursor`),所以这个窗口只有 + * 几秒。30 天是留给"游标被 D.4 打回重来"这类事件的宽裕余量:重置后调度器先重建基线、 + * 不处理历史邮件,因此真正需要去重的仍然只有最新的一批邮件。反过来,`GREATEST` 保证游标 + * 高水位只前进,调度器不会回看早于基线的邮件,所以删掉 30 天前的去重行不会让历史邮件被 + * 重新处理(唯一能让游标倒退的是显式重置,而重置后会重新建立基线)。 + * + * 零依赖纯函数模块:截止时刻在 JS 里算好再作为查询参数传入,因此"保留多少天"是可断言的 + * 行为,而不是埋在 `INTERVAL '30 days'` 字符串里对测试不可见。 + */ + +/** 去重表保留天数(spec §四 模块 E.4)。 */ +export const EMAIL_PROCESSED_RETENTION_DAYS = 30; + +const DAY_MS = 24 * 60 * 60 * 1000; + +/** 保留期截止时刻:`created_at` 早于它的去重行会被清理。 */ +export function emailProcessedRetentionCutoff( + now: Date, + retentionDays: number = EMAIL_PROCESSED_RETENTION_DAYS +): Date { + return new Date(now.getTime() - retentionDays * DAY_MS); +} diff --git a/lib/automation/schema.ts b/lib/automation/schema.ts new file mode 100644 index 0000000..b42c914 --- /dev/null +++ b/lib/automation/schema.ts @@ -0,0 +1,95 @@ +import pool from "@/lib/db"; + +/** + * 自动化数据表的**校验清单**,不是建表清单。 + * + * 表结构由受控的部署流程建立,应用进程不做任何 DDL。 + * 这个清单与代码里实际用到的表名的一致性由 `test/automationSchemaFile.test.ts` 守住。 + * + * 单独成模块而不是挂在 `store.ts` 上:`mailboxes.ts` 需要同一套校验,而它被 `store.ts` + * 反向依赖,直接 import 会成环。 + */ +export const AUTOMATION_TABLES = [ + "automation_tasks", + "automation_runs", + "automation_run_review_history", + "automation_run_actions", + "automation_email_processed_messages", + "automation_email_mailbox_cursors", + "automation_email_rule_events", + "automation_notification_preferences", + "automation_notification_states", + "automation_mailboxes", +] as const; + +/** 去重表的唯一键,与 claim 的 ON CONFLICT 目标同源。 */ +const EMAIL_CLAIM_CONSTRAINT = "automation_email_processed_once_per_automation"; + +/** 该唯一键的列,按建表语句的顺序写全。缺少 automation_id 就是本次改造要做的那件事没生效。 */ +const EMAIL_CLAIM_COLUMNS = "(created_by_user_id, mailbox_id, message_key, automation_id)"; + +let schemaReadyPromise: Promise | null = null; + +/** + * 断言自动化表已经就位(幂等;进程内只真正查一次,之后每次调用都是一个已 resolve 的 Promise)。 + * + * **这里不建表。** 原因很直接:本函数在 30 多处 store 函数开头都会被调用,把它当建表入口, + * 等于让应用进程长期握有 DDL 权限、并在每次冷启动时对生产库做一次结构写入——那是部署动作, + * 不是运行时动作。 + * + * 找不到表就抛错,且**把缺哪几张列出来**:这是唯一能同时覆盖"全新部署忘了导 SQL"与 + * "某张表被改名/删掉"两种情形的信号,比一句通用的 500 有用得多。 + * + * 表在 ≠ 结构对,所以查完表之后还查一次去重表的唯一键:只比表名的话,一个"十张表齐全、 + * 唯一键还是旧的三列"的库会照常通过,直到第一封来信才以 42P10 爆出来——正是本文件要提前到 + * 启动期的那类问题。 + * + * 失败时重置缓存:数据库暂时连不上属于可恢复故障,下一次调用应当重试,而不是把进程 + * 永久钉在失败态(与改造前的语义一致)。 + */ +export async function assertAutomationTablesReady() { + if (schemaReadyPromise) return schemaReadyPromise; + + schemaReadyPromise = (async () => { + const { rows } = await pool.query<{ name: string }>( + `SELECT candidate.name + FROM unnest($1::text[]) AS candidate(name) + WHERE to_regclass('public.' || candidate.name) IS NULL`, + [[...AUTOMATION_TABLES]] + ); + + if (rows.length > 0) { + throw new Error( + "AUTOMATION_SCHEMA_UNAVAILABLE: 自动化所需服务尚未就绪,请联系系统管理员。" + ); + } + + // 表在 ≠ 结构对:去重表的唯一键就是本次改造(一封邮件命中的每条自动化各领一次)的开关, + // 因此连列一起核。旧的(或被人改回三列的)唯一键下,claimAutomationEmailMessage 的 + // `ON CONFLICT (…, automation_id)` 找不到匹配的约束,PostgreSQL 对**每一次** claim 报 + // 42P10 —— 而十张表一张不少,只查表名拦不住它,故障于是推迟到第一封来信。 + // 约束名由建表/迁移语句显式指定(PostgreSQL 自动生成的名字会截断到 63 字节,不可依赖)。 + const { rows: claimConstraints } = await pool.query<{ definition: string }>( + `SELECT pg_get_constraintdef(c.oid) AS definition + FROM pg_constraint c + JOIN pg_class t ON t.oid = c.conrelid + JOIN pg_namespace n ON n.oid = t.relnamespace + WHERE n.nspname = 'public' + AND t.relname = 'automation_email_processed_messages' + AND c.conname = 'automation_email_processed_once_per_automation' + AND c.contype = 'u'` + ); + + // pg_get_constraintdef 会按 PostgreSQL 自己的排版重排,先折叠空白再比对列。 + const definition = claimConstraints[0]?.definition?.replace(/\s+/g, " ") ?? null; + + if (!definition?.includes(EMAIL_CLAIM_COLUMNS)) { + throw new Error("AUTOMATION_SCHEMA_UNAVAILABLE: 自动化所需服务尚未就绪,请联系系统管理员。"); + } + })().catch((error) => { + schemaReadyPromise = null; + throw error; + }); + + return schemaReadyPromise; +} diff --git a/lib/automation/store.ts b/lib/automation/store.ts index 2451315..9a470a5 100644 --- a/lib/automation/store.ts +++ b/lib/automation/store.ts @@ -1,4 +1,22 @@ import pool from "@/lib/db"; +import { + MAILBOX_NOT_OWNED, + mailboxLabelFromRow, + normalizeMailboxId, + requireMailboxId, + storedMailboxLabel, +} from "@/lib/automation/mailbox-id"; +import { + MAIL_RULE_FIELDS, + MAIL_RULE_OPERATORS, + mailRulesBriefSummary, +} from "@/lib/automation/mail-rules"; +import { + getAutomationMailboxForUser, +} from "@/lib/automation/mailboxes"; +import { mailboxErrorNotificationItems } from "@/lib/automation/mailbox-health"; +import { emailProcessedRetentionCutoff } from "@/lib/automation/retention"; +import { assertAutomationTablesReady } from "@/lib/automation/schema"; import { getUserTenantId } from "@/lib/tenantMapping"; export type AutomationTriggerType = "定时触发" | "邮件触发" | "Webhook / API" | "自动化完成触发"; @@ -23,238 +41,6 @@ export interface ScheduleConfig { runAt?: string; } -let initPromise: Promise | null = null; - -export async function ensureAutomationTables() { - if (initPromise) return initPromise; - - initPromise = (async () => { - await pool.query(` - CREATE TABLE IF NOT EXISTS automation_tasks ( - id SERIAL PRIMARY KEY, - tenant_id INTEGER, - created_by_user_id INTEGER NOT NULL, - name VARCHAR(200) NOT NULL, - app_id INTEGER NOT NULL, - agent_name VARCHAR(200) NOT NULL DEFAULT '', - task TEXT NOT NULL, - trigger_type VARCHAR(64) NOT NULL, - trigger_config JSONB NOT NULL DEFAULT '{}'::jsonb, - strategy VARCHAR(64) NOT NULL, - status VARCHAR(20) NOT NULL DEFAULT 'running', - result_config JSONB NOT NULL DEFAULT '{}'::jsonb, - next_run_at TIMESTAMPTZ, - last_run_at TIMESTAMPTZ, - last_run_status VARCHAR(20), - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() - ); - - CREATE INDEX IF NOT EXISTS idx_automation_tasks_owner - ON automation_tasks(created_by_user_id, created_at DESC); - - CREATE INDEX IF NOT EXISTS idx_automation_tasks_tenant - ON automation_tasks(tenant_id, created_at DESC); - - CREATE INDEX IF NOT EXISTS idx_automation_tasks_due - ON automation_tasks(status, trigger_type, next_run_at) - WHERE status = 'running' AND trigger_type = '定时触发'; - - CREATE TABLE IF NOT EXISTS automation_runs ( - id SERIAL PRIMARY KEY, - automation_id INTEGER, - tenant_id INTEGER, - created_by_user_id INTEGER NOT NULL, - automation_name VARCHAR(200) NOT NULL, - app_id INTEGER NOT NULL, - agent_name VARCHAR(200) NOT NULL DEFAULT '', - trigger_type VARCHAR(64) NOT NULL, - trigger_context JSONB NOT NULL DEFAULT '{}'::jsonb, - task_snapshot TEXT, - strategy_snapshot VARCHAR(64), - result_config_snapshot JSONB NOT NULL DEFAULT '{}'::jsonb, - status VARCHAR(20) NOT NULL, - result TEXT, - ai_result TEXT, - review_content TEXT, - final_result TEXT, - result_attachments JSONB NOT NULL DEFAULT '[]'::jsonb, - ai_version INTEGER NOT NULL DEFAULT 0, - review_status VARCHAR(20) NOT NULL DEFAULT 'not_required', - reviewer_user_id INTEGER, - reviewed_at TIMESTAMPTZ, - rejection_reason TEXT, - action_status VARCHAR(20) NOT NULL DEFAULT 'not_started', - error TEXT, - started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - processing_started_at TIMESTAMPTZ, - finished_at TIMESTAMPTZ, - duration_ms INTEGER, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() - ); - - ALTER TABLE automation_runs ADD COLUMN IF NOT EXISTS task_snapshot TEXT; - ALTER TABLE automation_runs ADD COLUMN IF NOT EXISTS strategy_snapshot VARCHAR(64); - ALTER TABLE automation_runs ADD COLUMN IF NOT EXISTS result_config_snapshot JSONB NOT NULL DEFAULT '{}'::jsonb; - ALTER TABLE automation_runs ADD COLUMN IF NOT EXISTS ai_result TEXT; - ALTER TABLE automation_runs ADD COLUMN IF NOT EXISTS review_content TEXT; - ALTER TABLE automation_runs ADD COLUMN IF NOT EXISTS final_result TEXT; - ALTER TABLE automation_runs ADD COLUMN IF NOT EXISTS result_attachments JSONB NOT NULL DEFAULT '[]'::jsonb; - ALTER TABLE automation_runs ADD COLUMN IF NOT EXISTS ai_version INTEGER NOT NULL DEFAULT 0; - ALTER TABLE automation_runs ADD COLUMN IF NOT EXISTS review_status VARCHAR(20) NOT NULL DEFAULT 'not_required'; - ALTER TABLE automation_runs ADD COLUMN IF NOT EXISTS reviewer_user_id INTEGER; - ALTER TABLE automation_runs ADD COLUMN IF NOT EXISTS reviewed_at TIMESTAMPTZ; - ALTER TABLE automation_runs ADD COLUMN IF NOT EXISTS rejection_reason TEXT; - ALTER TABLE automation_runs ADD COLUMN IF NOT EXISTS action_status VARCHAR(20) NOT NULL DEFAULT 'not_started'; - ALTER TABLE automation_runs ADD COLUMN IF NOT EXISTS processing_started_at TIMESTAMPTZ; - ALTER TABLE automation_runs ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(); - - UPDATE automation_runs - SET processing_started_at = COALESCE(processing_started_at, started_at) - WHERE status IN ('running', 'regenerating') - AND processing_started_at IS NULL; - - UPDATE automation_runs AS run - SET - task_snapshot = COALESCE(run.task_snapshot, task.task), - strategy_snapshot = COALESCE(run.strategy_snapshot, task.strategy), - result_config_snapshot = CASE - WHEN (run.task_snapshot IS NULL OR run.strategy_snapshot IS NULL) - AND run.result_config_snapshot = '{}'::jsonb - THEN task.result_config - ELSE run.result_config_snapshot - END - FROM automation_tasks AS task - WHERE run.automation_id = task.id - AND (run.task_snapshot IS NULL OR run.strategy_snapshot IS NULL); - - CREATE INDEX IF NOT EXISTS idx_automation_runs_owner - ON automation_runs(created_by_user_id, created_at DESC); - - CREATE INDEX IF NOT EXISTS idx_automation_runs_automation - ON automation_runs(automation_id, created_at DESC); - - CREATE INDEX IF NOT EXISTS idx_automation_runs_review - ON automation_runs(created_by_user_id, status, created_at DESC) - WHERE status IN ('pending', 'regenerating'); - - CREATE TABLE IF NOT EXISTS automation_run_review_history ( - id SERIAL PRIMARY KEY, - run_id INTEGER NOT NULL REFERENCES automation_runs(id) ON DELETE CASCADE, - event_type VARCHAR(64) NOT NULL, - ai_version INTEGER, - content TEXT, - note TEXT, - actor_user_id INTEGER, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() - ); - - CREATE INDEX IF NOT EXISTS idx_automation_run_review_history_run - ON automation_run_review_history(run_id, created_at ASC, id ASC); - - CREATE TABLE IF NOT EXISTS automation_run_actions ( - id SERIAL PRIMARY KEY, - run_id INTEGER NOT NULL REFERENCES automation_runs(id) ON DELETE CASCADE, - action_key VARCHAR(64) NOT NULL, - action_type VARCHAR(32) NOT NULL, - status VARCHAR(20) NOT NULL DEFAULT 'pending', - attempt_count INTEGER NOT NULL DEFAULT 0, - config JSONB NOT NULL DEFAULT '{}'::jsonb, - result JSONB NOT NULL DEFAULT '{}'::jsonb, - error TEXT, - started_at TIMESTAMPTZ, - finished_at TIMESTAMPTZ, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - UNIQUE(run_id, action_key) - ); - - CREATE INDEX IF NOT EXISTS idx_automation_run_actions_run - ON automation_run_actions(run_id, id ASC); - - CREATE INDEX IF NOT EXISTS idx_automation_run_actions_pending - ON automation_run_actions(run_id, status, id ASC); - - CREATE TABLE IF NOT EXISTS automation_email_processed_messages ( - id SERIAL PRIMARY KEY, - created_by_user_id INTEGER NOT NULL, - mailbox_key VARCHAR(128) NOT NULL, - message_key VARCHAR(500) NOT NULL, - automation_id INTEGER NOT NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - UNIQUE(created_by_user_id, mailbox_key, message_key) - ); - - CREATE INDEX IF NOT EXISTS idx_automation_email_processed_owner - ON automation_email_processed_messages(created_by_user_id, created_at DESC); - - - CREATE TABLE IF NOT EXISTS automation_email_mailbox_cursors ( - created_by_user_id INTEGER NOT NULL, - mailbox_key VARCHAR(128) NOT NULL, - last_uid BIGINT NOT NULL DEFAULT 0, - initialized BOOLEAN NOT NULL DEFAULT FALSE, - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - PRIMARY KEY (created_by_user_id, mailbox_key) - ); - - CREATE INDEX IF NOT EXISTS idx_automation_email_cursor_updated - ON automation_email_mailbox_cursors(updated_at DESC); - - CREATE TABLE IF NOT EXISTS automation_email_rule_events ( - id SERIAL PRIMARY KEY, - created_by_user_id INTEGER NOT NULL, - mailbox_key VARCHAR(128) NOT NULL, - message_key VARCHAR(500) NOT NULL, - message_uid BIGINT, - automation_id INTEGER NOT NULL, - outcome VARCHAR(40) NOT NULL, - winner_automation_id INTEGER, - matched_rule TEXT, - priority INTEGER, - from_address TEXT, - to_address TEXT, - subject TEXT, - message_date TEXT, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - UNIQUE(created_by_user_id, mailbox_key, message_key, automation_id) - ); - - CREATE INDEX IF NOT EXISTS idx_automation_email_rule_events_automation - ON automation_email_rule_events(created_by_user_id, automation_id, created_at DESC); - - CREATE INDEX IF NOT EXISTS idx_automation_email_rule_events_mailbox - ON automation_email_rule_events(created_by_user_id, mailbox_key, created_at DESC); - - CREATE TABLE IF NOT EXISTS automation_notification_preferences ( - user_id INTEGER PRIMARY KEY, - initialized_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - success_enabled BOOLEAN NOT NULL DEFAULT TRUE, - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() - ); - - CREATE TABLE IF NOT EXISTS automation_notification_states ( - user_id INTEGER NOT NULL, - event_key VARCHAR(255) NOT NULL, - read_at TIMESTAMPTZ, - dismissed_at TIMESTAMPTZ, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - PRIMARY KEY (user_id, event_key) - ); - - CREATE INDEX IF NOT EXISTS idx_automation_notification_states_user - ON automation_notification_states(user_id, updated_at DESC); - `); - })().catch((error) => { - initPromise = null; - throw error; - }); - - return initPromise; -} - function scheduleError(code: string): never { throw new Error(code); } @@ -638,17 +424,10 @@ type EmailTriggerRule = { function normalizeEmailRules(input: any): EmailTriggerRule[] { if (!Array.isArray(input)) return []; - const allowedFields = new Set([ - "发件人", - "发件人域名", - "收件人", - "邮件主题", - "邮件正文", - "是否包含附件", - "附件名称", - "附件类型", - ]); - const allowedOperators = new Set(["等于", "包含", "不包含", "开头是", "结尾是", "是否存在"]); + // 白名单直接取自 mail-rules.ts 的规范化清单——前端下拉就是从同一份清单渲染的。 + // 各写一份的话,新增字段会出现"向导里能选、能提交、写库时被这里静默丢弃"的前后端分叉。 + const allowedFields = new Set(MAIL_RULE_FIELDS); + const allowedOperators = new Set(MAIL_RULE_OPERATORS); return input .map((rule: any, index: number) => ({ @@ -661,24 +440,21 @@ function normalizeEmailRules(input: any): EmailTriggerRule[] { .slice(0, 20); } -function normalizeEmailPriority(value: any) { - const parsed = Number(value); - if (!Number.isFinite(parsed)) return 50; - return Math.max(0, Math.min(100, Math.round(parsed))); -} - -function emailRuleSummary(config: Record) { - const rules = Array.isArray(config.rules) ? config.rules : []; - if (rules.length === 0) return "收到新邮件即触发"; - - const first = rules[0] || {}; - const firstText = `${first.field || "邮件"}${first.operator || "包含"}${first.value ? `“${first.value}”` : ""}`; - if (rules.length === 1) return firstText; - return `${firstText} 等 ${rules.length} 条`; +/** + * 取监听邮箱并校验归属(模块 D.2)。 + * + * 邮箱不存在、或存在但不属于该用户,一律拒绝:客户端不得把自动化指向别人的监听邮箱。 + * 归属判断复用 `getAutomationMailboxForUser` 的 `id + created_by_user_id` 过滤,不另写权限逻辑。 + * 不再有 `"system"` 特例——该值只会作为非法 id 被 `requireMailboxId` 拒绝。 + */ +async function requireOwnedMailbox(userId: number, value: unknown) { + const mailbox = await getAutomationMailboxForUser(userId, requireMailboxId(value)); + if (!mailbox) throw new Error(MAILBOX_NOT_OWNED); + return mailbox; } export async function createAutomation(userId: number, input: any) { - await ensureAutomationTables(); + await assertAutomationTablesReady(); const tenantId = await getUserTenantId(userId); const appId = Number(input.appId ?? input.app_id); @@ -721,13 +497,14 @@ export async function createAutomation(userId: number, input: any) { Object.assign(triggerConfig, schedule); nextRunAt = status === "running" ? computeNextRunAt(schedule) : null; } else if (triggerType === "邮件触发") { + const mailbox = await requireOwnedMailbox(userId, input.mailboxId ?? input.triggerConfig?.mailboxId); Object.assign(triggerConfig, { - mailboxKey: String(input.mailboxKey ?? input.triggerConfig?.mailboxKey ?? "system"), - mailboxLabel: String(input.mailboxLabel ?? input.triggerConfig?.mailboxLabel ?? "系统邮箱"), + mailboxId: Number(mailbox.id), + // 展示名一律由邮箱记录派生,客户端传入的 mailboxLabel 忽略(模块 D.3) + mailboxLabel: mailboxLabelFromRow(mailbox), folder: String(input.mailFolder ?? input.triggerConfig?.folder ?? "INBOX"), ruleMode: (input.mailRuleMode ?? input.triggerConfig?.ruleMode) === "any" ? "any" : "all", rules: normalizeEmailRules(input.mailRules ?? input.triggerConfig?.rules), - priority: normalizeEmailPriority(input.mailPriority ?? input.triggerConfig?.priority), }); nextRunAt = null; } else if (triggerType === "自动化完成触发") { @@ -773,7 +550,7 @@ export async function createAutomation(userId: number, input: any) { } export async function listAutomations(userId: number) { - await ensureAutomationTables(); + await assertAutomationTablesReady(); const result = await pool.query( `SELECT * FROM automation_tasks WHERE created_by_user_id = $1 @@ -784,7 +561,7 @@ export async function listAutomations(userId: number) { } export async function getAutomation(userId: number, id: number) { - await ensureAutomationTables(); + await assertAutomationTablesReady(); const result = await pool.query( `SELECT * FROM automation_tasks WHERE id = $1 AND created_by_user_id = $2 LIMIT 1`, @@ -871,13 +648,17 @@ export async function updateAutomation(userId: number, id: number, input: any) { } triggerConfig = schedule; } else if (triggerType === "邮件触发") { + const mailbox = await requireOwnedMailbox( + userId, + input.mailboxId ?? input.triggerConfig?.mailboxId ?? triggerConfig.mailboxId, + ); triggerConfig = { - mailboxKey: String(input.mailboxKey ?? input.triggerConfig?.mailboxKey ?? triggerConfig.mailboxKey ?? "system"), - mailboxLabel: String(input.mailboxLabel ?? input.triggerConfig?.mailboxLabel ?? triggerConfig.mailboxLabel ?? "系统邮箱"), + mailboxId: Number(mailbox.id), + // 展示名一律由邮箱记录派生,客户端传入的 mailboxLabel 忽略(模块 D.3) + mailboxLabel: mailboxLabelFromRow(mailbox), folder: String(input.mailFolder ?? input.triggerConfig?.folder ?? triggerConfig.folder ?? "INBOX"), ruleMode: (input.mailRuleMode ?? input.triggerConfig?.ruleMode ?? triggerConfig.ruleMode) === "any" ? "any" : "all", rules: normalizeEmailRules(input.mailRules ?? input.triggerConfig?.rules ?? triggerConfig.rules), - priority: normalizeEmailPriority(input.mailPriority ?? input.triggerConfig?.priority ?? triggerConfig.priority), }; nextRunAt = null; } else if (triggerType === "自动化完成触发") { @@ -950,7 +731,7 @@ export async function updateAutomation(userId: number, id: number, input: any) { } export async function deleteAutomation(userId: number, id: number) { - await ensureAutomationTables(); + await assertAutomationTablesReady(); const dependentResult = await pool.query( `SELECT id, name FROM automation_tasks @@ -1014,7 +795,7 @@ export async function createRun( status: "running" | "pending", triggerContext: Record = {} ) { - await ensureAutomationTables(); + await assertAutomationTablesReady(); return insertRunRow(pool, task, status, triggerContext); } @@ -1028,7 +809,7 @@ export async function createRun( * 这样可以避免“next_run_at 已推进但 Run 尚未创建”时进程异常导致的丢任务窗口。 */ export async function claimDueScheduledRun(automationId: number) { - await ensureAutomationTables(); + await assertAutomationTablesReady(); const client = await pool.connect(); try { @@ -1195,7 +976,7 @@ async function insertRunActionSpecs(client: any, runId: number, specs: RunAction } export async function listRunActions(userId: number, runId: number) { - await ensureAutomationTables(); + await assertAutomationTablesReady(); const result = await pool.query( `SELECT action.* @@ -1232,7 +1013,7 @@ export async function prepareAutomaticRunActions( runId: number, resultText: string ) { - await ensureAutomationTables(); + await assertAutomationTablesReady(); const client = await pool.connect(); try { @@ -1290,7 +1071,7 @@ export async function prepareAutomaticRunActions( } export async function claimNextRunAction(userId: number, runId: number) { - await ensureAutomationTables(); + await assertAutomationTablesReady(); const result = await pool.query( `UPDATE automation_run_actions AS action SET @@ -1326,7 +1107,7 @@ export async function completeRunAction( resultData?: Record, errorText?: string ) { - await ensureAutomationTables(); + await assertAutomationTablesReady(); const result = await pool.query( `UPDATE automation_run_actions AS action SET @@ -1348,7 +1129,7 @@ export async function completeRunAction( } export async function finalizeRunActions(userId: number, runId: number) { - await ensureAutomationTables(); + await assertAutomationTablesReady(); const client = await pool.connect(); try { @@ -1416,7 +1197,7 @@ export async function prepareFailedRunActionsForRetry( userId: number, runId: number ) { - await ensureAutomationTables(); + await assertAutomationTablesReady(); const client = await pool.connect(); try { @@ -1488,7 +1269,7 @@ export async function saveRunExecutionArtifacts( runId: number, attachments: Array> = [] ) { - await ensureAutomationTables(); + await assertAutomationTablesReady(); const safeAttachments = (Array.isArray(attachments) ? attachments : []) .map((item) => ({ @@ -1519,7 +1300,7 @@ export async function markRunPendingReview( actorUserId?: number, note?: string ) { - await ensureAutomationTables(); + await assertAutomationTablesReady(); const client = await pool.connect(); try { @@ -1594,7 +1375,7 @@ export async function finishRun( resultText?: string, errorText?: string ) { - await ensureAutomationTables(); + await assertAutomationTablesReady(); const result = await pool.query( `UPDATE automation_runs SET @@ -1628,7 +1409,7 @@ export async function finishRun( } export async function getRunForUser(userId: number, runId: number) { - await ensureAutomationTables(); + await assertAutomationTablesReady(); const result = await pool.query( `SELECT * FROM automation_runs @@ -1641,7 +1422,7 @@ export async function getRunForUser(userId: number, runId: number) { } export async function listRunReviewHistory(userId: number, runId: number) { - await ensureAutomationTables(); + await assertAutomationTablesReady(); const result = await pool.query( `SELECT history.* @@ -1660,7 +1441,7 @@ export async function saveRunReviewDraft( runId: number, content: string ) { - await ensureAutomationTables(); + await assertAutomationTablesReady(); const client = await pool.connect(); try { @@ -1713,7 +1494,7 @@ export async function beginRunRegeneration( runId: number, advice: string ) { - await ensureAutomationTables(); + await assertAutomationTablesReady(); const client = await pool.connect(); try { @@ -1768,7 +1549,7 @@ export async function restoreRunAfterRegenerationFailure( runId: number, errorText: string ) { - await ensureAutomationTables(); + await assertAutomationTablesReady(); const client = await pool.connect(); try { @@ -1832,7 +1613,7 @@ export async function approveRunReview( runId: number, content?: string ) { - await ensureAutomationTables(); + await assertAutomationTablesReady(); const client = await pool.connect(); try { @@ -1899,7 +1680,7 @@ export async function rejectRunReview( runId: number, reason?: string ) { - await ensureAutomationTables(); + await assertAutomationTablesReady(); const client = await pool.connect(); try { @@ -1954,46 +1735,79 @@ export async function rejectRunReview( } } +/** + * 领取一封邮件 —— 语义是「**本条**自动化是否首次领取它」。 + * + * 不是「这封邮件的唯一主人是谁」:唯一键含 automation_id,命中的每条自动化各领各的, + * 所以一封邮件可以让多条自动化各自领到一次、各自执行。 + * 返回 `true` 表示本条自动化首次领取这封信、应当执行;`false` 表示本条已经领过它 + * (重复投递、游标回退重扫),应当跳过。 + * + * 并发安全靠数据库唯一键,因此 ON CONFLICT 的目标必须与表上的 + * `automation_email_processed_once_per_automation` 四列完全一致 —— 少一列 PostgreSQL + * 会以「no unique or exclusion constraint matching」拒绝每一次 claim。改唯一键与改这里必须同批发布。 + */ export async function claimAutomationEmailMessage( userId: number, - mailboxKey: string, + mailboxId: number, messageKey: string, automationId: number ) { - await ensureAutomationTables(); + await assertAutomationTablesReady(); - const safeMailboxKey = String(mailboxKey || "system").trim() || "system"; + const safeMailboxId = requireMailboxId(mailboxId); const safeMessageKey = String(messageKey || "").trim(); if (!safeMessageKey) throw new Error("EMAIL_MESSAGE_KEY_REQUIRED"); const result = await pool.query( `INSERT INTO automation_email_processed_messages ( - created_by_user_id, mailbox_key, message_key, automation_id + created_by_user_id, mailbox_id, message_key, automation_id ) VALUES ($1,$2,$3,$4) - ON CONFLICT (created_by_user_id, mailbox_key, message_key) DO NOTHING + ON CONFLICT (created_by_user_id, mailbox_id, message_key, automation_id) DO NOTHING RETURNING id`, - [userId, safeMailboxKey, safeMessageKey.slice(0, 500), automationId] + [userId, safeMailboxId, safeMessageKey.slice(0, 500), automationId] ); return result.rows.length > 0; } +/** + * 模块 E.4:清理邮件去重表中的过期行(保留 30 天,随调度器每天执行一次)。 + * + * 为什么删旧去重行是安全的(推理见 `lib/automation/retention.ts` 的模块注释):一行去重记录的 + * 唯一作用是"这封邮件已被处理过",它只需要活到该邮件的 UID 落到游标高水位以下——游标在处理完 + * 该邮件后立刻推进,所以正常情况这个窗口只有几秒;30 天是给"游标被 D.4 打回重来"的宽裕余量。 + * 反方向也不会出问题:`saveAutomationEmailMailboxCursor` 用 `GREATEST` 写游标,高水位只会前进, + * 调度器不会回看早于基线的邮件,因此去重行消失不会让历史邮件被重新处理(唯一能让游标倒退的 + * 显式重置,之后也会先重建基线、不处理历史)。 + * + * 保留期在 JS 里算好当参数传下去(而不是写 `INTERVAL '30 days'`),这样"保留多少天"是可测的。 + */ +export async function cleanupAutomationEmailProcessedMessages(now: Date = new Date()) { + await assertAutomationTablesReady(); + + const result = await pool.query( + `DELETE FROM automation_email_processed_messages + WHERE created_at < $1`, + [emailProcessedRetentionCutoff(now)] + ); + + return result.rowCount ?? 0; +} + export type AutomationEmailRuleOutcome = | "triggered" - | "suppressed_by_priority" | "not_matched" | "duplicate"; export type AutomationEmailRuleEvaluationInput = { userId: number; - mailboxKey: string; + mailboxId: number; messageKey: string; messageUid?: number; automationId: number; outcome: AutomationEmailRuleOutcome; - winnerAutomationId?: number | null; matchedRule?: string; - priority?: number; from?: string; to?: string; subject?: string; @@ -2003,12 +1817,12 @@ export type AutomationEmailRuleEvaluationInput = { export async function recordAutomationEmailRuleEvaluations( evaluations: AutomationEmailRuleEvaluationInput[] ) { - await ensureAutomationTables(); + await assertAutomationTablesReady(); const safe = evaluations .filter((item) => Number.isInteger(Number(item.userId)) && Number.isInteger(Number(item.automationId))) .map((item) => ({ ...item, - mailboxKey: String(item.mailboxKey || "system").trim() || "system", + mailboxId: requireMailboxId(item.mailboxId), messageKey: String(item.messageKey || "").trim().slice(0, 500), })) .filter((item) => item.messageKey); @@ -2021,43 +1835,40 @@ export async function recordAutomationEmailRuleEvaluations( const start = params.length; params.push( item.userId, - item.mailboxKey, + item.mailboxId, item.messageKey, Number.isFinite(Number(item.messageUid)) ? Number(item.messageUid) : null, item.automationId, item.outcome, - item.winnerAutomationId ?? null, item.matchedRule || null, - Number.isFinite(Number(item.priority)) ? Number(item.priority) : null, item.from || null, item.to || null, item.subject || null, item.date || null, ); - const indexes = Array.from({ length: 13 }, (_, i) => `$${start + i + 1}`); + const indexes = Array.from({ length: 11 }, (_, i) => `$${start + i + 1}`); values.push(`(${indexes.join(",")},NOW())`); } await pool.query( `INSERT INTO automation_email_rule_events ( - created_by_user_id, mailbox_key, message_key, message_uid, - automation_id, outcome, winner_automation_id, matched_rule, priority, + created_by_user_id, mailbox_id, message_key, message_uid, + automation_id, outcome, matched_rule, from_address, to_address, subject, message_date, created_at ) VALUES ${values.join(",")} - ON CONFLICT (created_by_user_id, mailbox_key, message_key, automation_id) DO NOTHING`, + ON CONFLICT (created_by_user_id, mailbox_id, message_key, automation_id) DO NOTHING`, params, ); } export async function getAutomationEmailRoutingStats(userId: number, automationId: number) { - await ensureAutomationTables(); + await assertAutomationTablesReady(); const statsResult = await pool.query( `SELECT COUNT(*)::int AS scanned, COUNT(*) FILTER (WHERE outcome <> 'not_matched')::int AS matched, COUNT(*) FILTER (WHERE outcome = 'triggered')::int AS triggered, - COUNT(*) FILTER (WHERE outcome = 'suppressed_by_priority')::int AS suppressed, COUNT(*) FILTER (WHERE outcome = 'not_matched')::int AS not_matched, COUNT(*) FILTER (WHERE outcome = 'duplicate')::int AS duplicate FROM automation_email_rule_events @@ -2067,8 +1878,8 @@ export async function getAutomationEmailRoutingStats(userId: number, automationI const recentResult = await pool.query( `SELECT - id, mailbox_key, message_key, message_uid, automation_id, outcome, - winner_automation_id, matched_rule, priority, + id, mailbox_id, message_key, message_uid, automation_id, outcome, + matched_rule, from_address, to_address, subject, message_date, created_at FROM automation_email_rule_events WHERE created_by_user_id=$1 AND automation_id=$2 @@ -2082,20 +1893,16 @@ export async function getAutomationEmailRoutingStats(userId: number, automationI scanned: Number(row.scanned || 0), matched: Number(row.matched || 0), triggered: Number(row.triggered || 0), - suppressed: Number(row.suppressed || 0), notMatched: Number(row.not_matched || 0), duplicate: Number(row.duplicate || 0), recent: recentResult.rows.map((item: any) => ({ id: Number(item.id), - mailboxKey: item.mailbox_key, + mailboxId: Number(item.mailbox_id), messageKey: item.message_key, messageUid: item.message_uid == null ? undefined : Number(item.message_uid), automationId: Number(item.automation_id), outcome: item.outcome, - winnerAutomationId: - item.winner_automation_id == null ? undefined : Number(item.winner_automation_id), matchedRule: item.matched_rule || undefined, - priority: item.priority == null ? undefined : Number(item.priority), from: item.from_address || undefined, to: item.to_address || undefined, subject: item.subject || undefined, @@ -2127,7 +1934,7 @@ export interface AutomationNotificationItem { } async function getAutomationNotificationInitializedAt(userId: number) { - await ensureAutomationTables(); + await assertAutomationTablesReady(); await pool.query( `INSERT INTO automation_notification_preferences (user_id) VALUES ($1) @@ -2163,6 +1970,10 @@ function clipNotificationError(value: unknown) { export async function listAutomationNotifications(userId: number) { const { initializedAt, successEnabled } = await getAutomationNotificationInitializedAt(userId); + // 邮箱记录表由 mailboxes.ts 按需创建,本函数可能先于它被调用(自动化页同时拉两个接口), + // 因此这里显式保证它存在,而不是让下面那段查询以 42P01 把整个提醒接口打成 500。 + await assertAutomationTablesReady(); + const runResult = await pool.query( `SELECT id, automation_id, automation_name, status, action_status, ai_version, error, started_at, created_at, updated_at, finished_at, @@ -2196,7 +2007,39 @@ export async function listAutomationNotifications(userId: number) { [userId, initializedAt] ); - const items: AutomationNotificationItem[] = []; + /* + * 模块 E.2:第三段派生——监听邮箱连接失败。 + * + * 为什么是"派生"而不是"插入一条通知":邮箱连接失败发生在建 Run 之前,`automation_runs` + * / `automation_run_actions` 里什么都没有,没有 run 记录可派生;系统邮箱下线后每个邮箱 + * 都有 `automation_mailboxes` 行,所以这张表本身就是健康状态的唯一事实来源,不需要 + * (也不该有)第二张健康状态表。 + * + * 只取 `status='error'`:因此"邮箱恢复 → 提醒自动消失"是结构性的,不需要任何"已解决" + * 状态;而 eventKey 固定为 `mailbox-error:`(见 mailboxErrorEventKey), + * 同一个邮箱反复失败也只是同一条提醒,不会越滚越多。 + * + * 刻意**不**按 initializedAt 过滤:那一段时间窗是为了不把历史运行/失败在用户第一次打开 + * 通知中心时一次性倒出来,而这里取的是"当前状态"——邮箱现在就是坏的,什么时候开始坏的 + * 并不影响用户该看到它。 + */ + const mailboxErrorResult = await pool.query( + `SELECT id, name, email, last_error, last_error_at + FROM automation_mailboxes + WHERE created_by_user_id=$1 AND status='error' + ORDER BY last_error_at DESC NULLS LAST, id DESC + LIMIT 50`, + [userId] + ); + + const items: AutomationNotificationItem[] = mailboxErrorNotificationItems( + mailboxErrorResult.rows.map((row) => ({ + mailboxId: Number(row.id), + label: mailboxLabelFromRow(row), + error: row.last_error, + errorAt: row.last_error_at, + })) + ); for (const row of runResult.rows) { const runId = Number(row.id); @@ -2320,7 +2163,7 @@ export async function listAutomationNotifications(userId: number) { } export async function markAutomationNotificationsRead(userId: number, eventKeys: string[]) { - await ensureAutomationTables(); + await assertAutomationTablesReady(); const keys = Array.from( new Set( (Array.isArray(eventKeys) ? eventKeys : []) @@ -2343,7 +2186,7 @@ export async function markAutomationNotificationsRead(userId: number, eventKeys: } export async function listRuns(userId: number, automationId?: number) { - await ensureAutomationTables(); + await assertAutomationTablesReady(); const params: any[] = [userId]; let automationFilter = ""; @@ -2409,7 +2252,7 @@ export function automationRowToApi(row: any) { row.trigger_type === "定时触发" ? `${scheduleSummary} · ${scheduleTimezone}` : row.trigger_type === "邮件触发" - ? `${config.mailboxLabel || "系统邮箱"} · ${emailRuleSummary(config)} · 优先级 ${normalizeEmailPriority(config.priority)}` + ? `${storedMailboxLabel(config.mailboxLabel) ?? "监听邮箱未配置"} · ${mailRulesBriefSummary(config.rules)}` : row.trigger_type === "Webhook / API" ? "由外部系统通过 Webhook / API 触发" : "上游自动化完成后触发"; @@ -2466,12 +2309,14 @@ export function automationRowToApi(row: any) { scheduleMissingDayPolicy: config.missingDayPolicy === "skip" ? "skip" : "last_day", scheduleDate, - mailboxKey: config.mailboxKey || "system", - mailboxLabel: config.mailboxLabel || "系统邮箱", + // 展示层不再回退到已下线的系统邮箱:遗留数据没有 mailboxId / 存储名时原样返回 + // null,交由前端显示「监听邮箱未配置」;真正依赖该标识的路径(调度器、游标、 + // 去重、创建/更新)会显式报错。 + mailboxId: normalizeMailboxId(config.mailboxId), + mailboxLabel: storedMailboxLabel(config.mailboxLabel) ?? undefined, mailFolder: config.folder || "INBOX", mailRuleMode: (config.ruleMode === "any" ? "any" : "all") as EmailRuleMode, mailRules: normalizeEmailRules(config.rules), - mailPriority: normalizeEmailPriority(config.priority), upstreamAutomationId: config.upstreamAutomationId ?? undefined, upstreamCondition: config.upstreamCondition ?? undefined, passPreviousResult: config.passPreviousResult ?? undefined, @@ -2538,7 +2383,7 @@ export function runRowToApi(row: any) { } export async function listActiveEmailAutomationsForScheduler() { - await ensureAutomationTables(); + await assertAutomationTablesReady(); const result = await pool.query( `SELECT * FROM automation_tasks WHERE trigger_type='邮件触发' @@ -2550,16 +2395,16 @@ export async function listActiveEmailAutomationsForScheduler() { export async function getAutomationEmailMailboxCursor( userId: number, - mailboxKey: string + mailboxId: number ) { - await ensureAutomationTables(); - const safeMailboxKey = String(mailboxKey || "system").trim() || "system"; + await assertAutomationTablesReady(); + const safeMailboxId = requireMailboxId(mailboxId); const result = await pool.query( `SELECT last_uid, initialized, updated_at FROM automation_email_mailbox_cursors - WHERE created_by_user_id=$1 AND mailbox_key=$2 + WHERE created_by_user_id=$1 AND mailbox_id=$2 LIMIT 1`, - [userId, safeMailboxKey] + [userId, safeMailboxId] ); const row = result.rows[0]; @@ -2572,26 +2417,25 @@ export async function getAutomationEmailMailboxCursor( export async function saveAutomationEmailMailboxCursor( userId: number, - mailboxKey: string, + mailboxId: number, lastUid: number, initialized = true ) { - await ensureAutomationTables(); - const safeMailboxKey = String(mailboxKey || "system").trim() || "system"; + await assertAutomationTablesReady(); + const safeMailboxId = requireMailboxId(mailboxId); const safeUid = Number.isFinite(Number(lastUid)) ? Math.max(0, Math.floor(Number(lastUid))) : 0; const result = await pool.query( `INSERT INTO automation_email_mailbox_cursors ( - created_by_user_id, mailbox_key, last_uid, initialized, updated_at + created_by_user_id, mailbox_id, last_uid, initialized, updated_at ) VALUES ($1,$2,$3,$4,NOW()) - ON CONFLICT (created_by_user_id, mailbox_key) DO UPDATE SET + ON CONFLICT (created_by_user_id, mailbox_id) DO UPDATE SET last_uid=GREATEST(automation_email_mailbox_cursors.last_uid, EXCLUDED.last_uid), initialized=automation_email_mailbox_cursors.initialized OR EXCLUDED.initialized, updated_at=NOW() RETURNING *`, - [userId, safeMailboxKey, safeUid, initialized] + [userId, safeMailboxId, safeUid, initialized] ); return result.rows[0] || null; } - diff --git a/lib/cron/automation-scheduler.ts b/lib/cron/automation-scheduler.ts index 5631566..7904f9c 100644 --- a/lib/cron/automation-scheduler.ts +++ b/lib/cron/automation-scheduler.ts @@ -1,13 +1,13 @@ import cron, { type ScheduledTask } from "node-cron"; -import jwt from "jsonwebtoken"; import pool from "@/lib/db"; +import { assertAutomationTablesReady } from "@/lib/automation/schema"; import { executeAutomationAgent, isAutomationTimeoutError } from "@/lib/automation/execute"; import { executeRunActions } from "@/lib/automation/actions"; import { claimAutomationEmailMessage, claimDueScheduledRun, + cleanupAutomationEmailProcessedMessages, createRun, - ensureAutomationTables, finishRun, getAutomationEmailMailboxCursor, listActiveEmailAutomationsForScheduler, @@ -20,8 +20,32 @@ import { import { getAutomationMailboxForUser, mailboxConnectionFromRow, + markAutomationMailboxConnected, + markAutomationMailboxConnectionError, } from "@/lib/automation/mailboxes"; -import { fetchMailboxUnread } from "@/lib/automation/mailbox-client"; +import { + mailboxGroupKey, + normalizeMailboxId, + requireMailboxId, + requireMailboxLabel, +} from "@/lib/automation/mailbox-id"; +import { + fetchMailboxUnread, + fetchMessageAttachments, + splitAttachmentsBySize, + type MailboxAttachmentFile, +} from "@/lib/automation/imap-client"; +import { + buildEmailAutomationQuestion, + normalizeEmailBody, + type AutomationAgentAttachment, +} from "@/lib/automation/agent-payload"; +import { ossClient } from "@/lib/ossClient"; +import { + doesMailRuleSetMatch, + type MailRuleSet, + mailRulesSummary, +} from "@/lib/automation/mail-rules"; declare global { // eslint-disable-next-line no-var @@ -32,6 +56,8 @@ declare global { var automationEmailCronTask: ScheduledTask | undefined; // eslint-disable-next-line no-var var automationEmailCronBusy: boolean | undefined; + // eslint-disable-next-line no-var + var automationEmailCleanupTask: ScheduledTask | undefined; } const ADVISORY_LOCK_NAMESPACE = 20260903; @@ -47,13 +73,6 @@ type InboxMessage = { attachments?: string[]; }; -type MailTriggerRule = { - id?: string; - field: string; - operator: string; - value?: string; -}; - function isScheduleConfigurationError(error: unknown) { const message = error instanceof Error ? error.message : String(error || ""); return message.startsWith("SCHEDULE_"); @@ -188,7 +207,7 @@ export async function scanDueAutomations() { global.automationCronBusy = true; try { - await ensureAutomationTables(); + await assertAutomationTablesReady(); const result = await pool.query(` SELECT id FROM automation_tasks @@ -208,182 +227,178 @@ export async function scanDueAutomations() { } } -function requiredEnv(name: string) { - const value = process.env[name]; - if (!value) throw new Error(`Missing required environment variable: ${name}`); - return value; -} - -function serverAuthorization(userId: number) { - const token = jwt.sign({ userId }, requiredEnv("JWT_SECRET"), { expiresIn: "15m" }); - return `Bearer ${token}`; +// 任务行(automation_tasks)到规范化规则集的适配;规则逻辑本身在 mail-rules.ts。 +function mailRuleSetFromTask(task: any): MailRuleSet { + const config = task.trigger_config || {}; + return { rules: config.rules, mode: config.ruleMode }; } -function mailboxIdFromKey(mailboxKey: string) { - const match = String(mailboxKey || "").match(/^mailbox:(\d+)$/); - return match ? Number(match[1]) : null; -} +async function fetchConfiguredMailboxUnread( + userId: number, + mailboxId: number, + afterUid?: number +) { + const mailbox = await getAutomationMailboxForUser(userId, mailboxId); + if (!mailbox) throw new Error(`监听邮箱不存在:${mailboxId}`); -function extractSenderDomain(value?: string) { - const match = String(value || "").match(/@([^>\s,;]+)/); - return match?.[1]?.toLowerCase() || ""; -} + try { + // 解密失败(凭据被换过密钥、授权码被清空)与 IMAP 连不上在这里是同一件事: + // 这条邮箱管道这次收信失败了,用户看到的都该是"邮箱连接失败"。 + // 收信是本进程内的直接调用(`lib/automation/imap-client.ts`):不再需要服务间 JWT, + // 也就不再需要一遍自我 HTTP 回调。 + const data = await fetchMailboxUnread({ + afterUid, + connection: mailboxConnectionFromRow(mailbox), + }); -function attachmentExtensions(names?: string[]) { - return (Array.isArray(names) ? names : []) - .map((name) => { - const match = String(name).toLowerCase().match(/(\.[a-z0-9]+)$/i); - return match?.[1] || ""; - }) - .filter(Boolean) - .join(" "); -} + // 模块 E.1:连上了就恢复状态。只在"当前不是 connected"时才写库——正常轮询(每 10 秒一次) + // 不产生任何写入;判断用的是上面取到的那一行,省掉一次查询。 + if (mailbox.status !== "connected") { + await markAutomationMailboxConnected(userId, mailboxId); + } -function mailRuleSource(rule: MailTriggerRule, message: InboxMessage) { - const attachments = Array.isArray(message.attachments) ? message.attachments : []; - switch (rule.field) { - case "发件人": return String(message.from || ""); - case "发件人域名": return extractSenderDomain(message.from); - case "收件人": return String(message.to || ""); - case "邮件主题": return String(message.subject || ""); - case "邮件正文": return String(message.body || ""); - case "是否包含附件": return attachments.length > 0 ? "是" : "否"; - case "附件名称": return attachments.join(" "); - case "附件类型": return attachmentExtensions(attachments); - default: return ""; + return data; + } catch (error) { + // 模块 E.1/E.2:一次写入点亮三处(抽屉徽标、抽屉的「最后错误」、通知中心里按 + // status='error' 派生的那条提醒)。记录失败不抛错,原始错误照旧往上抛给分组层的日志。 + await markAutomationMailboxConnectionError(userId, mailboxId, error); + throw error; } } -function doesMailRuleMatch(rule: MailTriggerRule, message: InboxMessage) { - const source = mailRuleSource(rule, message).toLowerCase(); - const wanted = String(rule.value || "").trim().toLowerCase(); - - if (rule.operator === "是否存在" || rule.field === "是否包含附件") { - const exists = rule.field === "是否包含附件" ? source === "是" : source.trim().length > 0; - const wantExists = !["否", "false", "0", "no"].includes(wanted || "是"); - return exists === wantExists; - } +/** + * 附件准备只用到任务行上的这两个字段。窄类型而非 `any`:顺带说明这个 helper 不碰 + * 任务的其他部分,也让「它需要什么」在签名上直接可读。 + */ +type EmailAttachmentTask = { + created_by_user_id?: unknown; + trigger_config?: { mailboxId?: unknown } | null; +}; - if (!wanted) return false; - if (rule.operator === "等于") return source.trim() === wanted; - if (rule.operator === "包含") return source.includes(wanted); - if (rule.operator === "不包含") return !source.includes(wanted); - if (rule.operator === "开头是") return source.startsWith(wanted); - if (rule.operator === "结尾是") return source.endsWith(wanted); - return false; -} +/** + * 把邮件附件取回来 → 筛掉超限的 → 传上 OSS。 + * + * 每一层失败都只降级、不抛错:附件是本次任务的输入之一,为它拖垮整次运行不划算。 + * 失败与超限的结果都进 `skippedNames`,最终由提示词点名——模型因此不会把 + * 「只收到一部分」误当成「附件就这些」,进而对缺数据给出错误的解释。 + * + * 三个名单互斥且穷尽:规则引擎看到过的每个附件名,要么在 `deliveredNames` 里、 + * 要么在 `skippedNames` 里。 + */ +async function prepareEmailAttachments( + task: EmailAttachmentTask, + message: InboxMessage +): Promise<{ + delivered: AutomationAgentAttachment[]; + deliveredNames: string[]; + skippedNames: string[]; +}> { + // 规则引擎判定时看到的名字(`extractAttachmentNames` 的结果)。没拿到字节的也要在这里露面。 + const seenNames = Array.isArray(message.attachments) ? [...message.attachments] : []; + const nothingDelivered = { + delivered: [] as AutomationAgentAttachment[], + deliveredNames: [] as string[], + skippedNames: seenNames, + }; -function mailAutomationMatches(task: any, message: InboxMessage) { - const config = task.trigger_config || {}; - const rules: MailTriggerRule[] = Array.isArray(config.rules) ? config.rules : []; - if (rules.length === 0) return true; - const results = rules.map((rule) => doesMailRuleMatch(rule, message)); - return config.ruleMode === "any" ? results.some(Boolean) : results.every(Boolean); -} + let files: MailboxAttachmentFile[]; + try { + const mailbox = await getAutomationMailboxForUser( + Number(task.created_by_user_id), + requireMailboxId(task.trigger_config?.mailboxId) + ); + if (!mailbox) return nothingDelivered; -function mailRuleText(rule: MailTriggerRule) { - if (rule.operator === "是否存在" || rule.field === "是否包含附件") { - return `${rule.field}${rule.value || "是"}`; + files = await fetchMessageAttachments( + mailboxConnectionFromRow(mailbox), + Number(message.uid) + ); + } catch (error) { + // 取信失败(连接断了、凭据被换过)不该让任务失败——按「没有附件可读」继续。 + console.error("[Automation Email] 读取附件失败,本次按无附件处理:", error); + return nothingDelivered; } - return `${rule.field}${rule.operator}“${rule.value || ""}”`; -} -function mailRulesSummary(task: any) { - const config = task.trigger_config || {}; - const rules: MailTriggerRule[] = Array.isArray(config.rules) ? config.rules : []; - if (rules.length === 0) return "收到新邮件即触发"; - const prefix = config.ruleMode === "any" ? "任一" : "全部"; - return `${prefix}:${rules.map(mailRuleText).join(";")}`; -} + const { accepted, skipped } = splitAttachmentsBySize(files); + const skippedNames = skipped.map((file) => file.filename); -async function fetchSystemMailboxUnread(userId: number, afterUid?: number) { - const backendUrl = requiredEnv("EXTERNAL_API_BASE_URL").replace(/\/+$/, ""); - const params = new URLSearchParams(); - if (Number.isInteger(afterUid)) params.set("after_uid", String(afterUid)); - const response = await fetch( - `${backendUrl}/api/v1/email/unread${params.toString() ? `?${params.toString()}` : ""}`, - { headers: { Authorization: serverAuthorization(userId) } } - ); + // `mailparser` 没给出字节的 part:规则看得到名字,却没有可传的内容。 + const withBytes = new Set(files.map((file) => file.filename)); + for (const name of seenNames) { + if (!withBytes.has(name)) skippedNames.push(name); + } - const raw = await response.text(); - let data: any = raw; - try { data = raw ? JSON.parse(raw) : null; } catch { /* keep raw */ } + const delivered: AutomationAgentAttachment[] = []; + for (const file of accepted) { + try { + const objectKey = await ossClient.upload({ + filename: file.filename, + content: file.content, + contentType: file.contentType || "application/octet-stream", + category: "attachments", + }); - if (!response.ok) { - const detail = typeof data === "object" && data?.detail ? data.detail : String(data || `HTTP ${response.status}`); - throw new Error(detail); + delivered.push({ + objectKey, + filename: file.filename, + contentType: file.contentType, + size: file.size, + }); + } catch (error) { + console.error(`[Automation Email] 附件上传失败,跳过《${file.filename}》:`, error); + skippedNames.push(file.filename); + } } - return data ?? { success: true, latest_uid: 0, messages: [] }; + + return { + delivered, + deliveredNames: delivered.map((item) => item.filename), + skippedNames, + }; } -async function fetchConfiguredMailboxUnread( - userId: number, - mailboxKey: string, - afterUid?: number -) { - if (mailboxKey === "system") { - return fetchSystemMailboxUnread(userId, afterUid); - } +async function executeEmailAutomation(task: any, message: InboxMessage) { + const config = task.trigger_config || {}; + // 模块 A:展示名在创建/更新时按邮箱记录派生(模块 D.3),这里取不到即数据有问题, + // 显式抛错而不是兜底成一个已下线的邮箱名。抛错由分组扫描按组记录,不影响其他分组。 + const mailboxLabel = requireMailboxLabel(config.mailboxLabel); - const mailboxId = mailboxIdFromKey(mailboxKey); - if (!mailboxId) throw new Error(`监听邮箱标识无效:${mailboxKey}`); + // 规则判定早就做完了,这里才把字节取回来——只处理真正要执行的那封。 + const emailAttachments = await prepareEmailAttachments(task, message); - const mailbox = await getAutomationMailboxForUser(userId, mailboxId); - if (!mailbox) throw new Error(`监听邮箱不存在:${mailboxKey}`); + const body = normalizeEmailBody(message.body); - return fetchMailboxUnread({ - authorization: serverAuthorization(userId), - afterUid, - connection: mailboxConnectionFromRow(mailbox), + const question = buildEmailAutomationQuestion({ + task: String(task.task || ""), + mailboxLabel, + from: message.from, + to: message.to, + subject: message.subject, + date: message.date, + // 传原始正文:规范化由 builder 统一做,避免两处各截一次。 + body: message.body, + delivered: emailAttachments.deliveredNames, + skipped: emailAttachments.skippedNames, }); -} - -async function executeEmailAutomation(task: any, message: InboxMessage) { - const config = task.trigger_config || {}; - const attachments = Array.isArray(message.attachments) && message.attachments.length > 0 - ? message.attachments.join("、") - : "无"; - - const rawBody = typeof message.body === "string" ? message.body.trim() : ""; - const body = rawBody.length > 20000 - ? `${rawBody.slice(0, 20000)}\n\n[正文较长,已截取前 20000 个字符]` - : rawBody || "(无正文)"; - - const question = [ - "【自动化任务】", - String(task.task || ""), - "", - "【本次收到的新邮件】", - `监听邮箱:${config.mailboxLabel || "系统邮箱"}`, - `发件人:${message.from || "未知"}`, - `收件人:${message.to || "未知"}`, - `主题:${message.subject || "无主题"}`, - `时间:${message.date || "未知"}`, - `附件:${attachments}`, - "正文:", - body, - "", - "【执行要求】", - "请根据上面的真实邮件内容完成自动化任务。", - "只输出本次邮件的处理结果,不要自行调用发送邮件、通知或其他外部发送工具;结果将由自动化统一发送。", - ].join("\n"); const triggerContext = { source: "email-server", firedAt: new Date().toISOString(), uid: Number(message.uid), messageId: message.message_id || undefined, - mailboxKey: config.mailboxKey || "system", - mailbox: config.mailboxLabel || "系统邮箱", + mailboxId: requireMailboxId(config.mailboxId), + mailbox: mailboxLabel, folder: config.folder || "INBOX", - matchedRule: mailRulesSummary(task), - priority: Number(config.priority ?? 50), + matchedRule: mailRulesSummary(mailRuleSetFromTask(task)), from: message.from, to: message.to, subject: message.subject, date: message.date, body, + // 保持既有形状:规则引擎与运行详情都按「名字列表」读它。 attachments: message.attachments || [], + // 新增:已上传成功的附件元信息,供运行详情给出下载入口。 + attachmentFiles: emailAttachments.delivered, }; const run = await createRun(task, "running", triggerContext); @@ -393,6 +408,9 @@ async function executeEmailAutomation(task: any, message: InboxMessage) { userId: Number(task.created_by_user_id), appId: Number(task.app_id), question, + // 附件以结构化字段下发:后端在 skill 沙箱起容器前取回、写进 inputs/, + // 模型按文件名引用即可。object_key 不进提示词(见 agent-payload.ts)。 + attachments: emailAttachments.delivered, }); const answer = result.answer || "任务已完成,未返回文本结果"; @@ -453,12 +471,13 @@ async function processEmailMailboxGroup(tasks: any[]) { const userId = Number(tasks[0].created_by_user_id); const config = tasks[0].trigger_config || {}; - const mailboxKey = String(config.mailboxKey || "system").trim() || "system"; - const cursor = await getAutomationEmailMailboxCursor(userId, mailboxKey); + // 遗留数据缺少整数 mailboxId 时直接抛错(不再回退 system),错误由 scanEmailAutomations 按分组记录。 + const mailboxId = requireMailboxId(config.mailboxId); + const cursor = await getAutomationEmailMailboxCursor(userId, mailboxId); const data = await fetchConfiguredMailboxUnread( userId, - mailboxKey, + mailboxId, cursor.initialized ? cursor.lastUid : undefined ); @@ -467,7 +486,7 @@ async function processEmailMailboxGroup(tasks: any[]) { // 第一次建立服务端基线,不处理历史邮件。 if (!cursor.initialized) { - await saveAutomationEmailMailboxCursor(userId, mailboxKey, latestUid, true); + await saveAutomationEmailMailboxCursor(userId, mailboxId, latestUid, true); return; } @@ -482,47 +501,41 @@ async function processEmailMailboxGroup(tasks: any[]) { // 防止平台自己发送的结果邮件再次触发自动化形成循环。 if (!subject.startsWith("自动化执行结果:") && !subject.startsWith("[AI对话]")) { const messageKey = String(message.message_id || "").trim() || `uid:${uid}`; - const matched = tasks - .filter((task) => mailAutomationMatches(task, message)) - .sort((a, b) => { - const priorityDelta = Number(b.trigger_config?.priority ?? 50) - Number(a.trigger_config?.priority ?? 50); - return priorityDelta !== 0 ? priorityDelta : Number(a.id) - Number(b.id); - }); + const matched = tasks.filter((task) => + doesMailRuleSetMatch(mailRuleSetFromTask(task), message) + ); - const winner = matched[0]; - let claimed = false; - if (winner) { - claimed = await claimAutomationEmailMessage( - userId, - mailboxKey, - messageKey, - Number(winner.id) - ); + // 每条自动化各自 claim(唯一键含 automation_id,互不阻塞)。 + // 一封邮件命中的多条自动化会**全部执行**,不再由优先级选出一条胜出。 + const claimedTasks: any[] = []; + const claimedIds = new Set(); + for (const task of matched) { + const taskId = Number(task.id); + if (await claimAutomationEmailMessage(userId, mailboxId, messageKey, taskId)) { + claimedIds.add(taskId); + claimedTasks.push(task); + } } const matchedIds = new Set(matched.map((task) => Number(task.id))); await recordAutomationEmailRuleEvaluations( tasks.map((task) => { const taskId = Number(task.id); - let outcome: "triggered" | "suppressed_by_priority" | "not_matched" | "duplicate"; + let outcome: "triggered" | "not_matched" | "duplicate"; if (!matchedIds.has(taskId)) { outcome = "not_matched"; - } else if (winner && taskId === Number(winner.id)) { - outcome = claimed ? "triggered" : "duplicate"; } else { - outcome = "suppressed_by_priority"; + outcome = claimedIds.has(taskId) ? "triggered" : "duplicate"; } return { userId, - mailboxKey, + mailboxId, messageKey, messageUid: uid, automationId: taskId, outcome, - winnerAutomationId: winner ? Number(winner.id) : null, - matchedRule: mailRulesSummary(task), - priority: Number(task.trigger_config?.priority ?? 50), + matchedRule: mailRulesSummary(mailRuleSetFromTask(task)), from: message.from, to: message.to, subject: message.subject, @@ -531,13 +544,29 @@ async function processEmailMailboxGroup(tasks: any[]) { }) ); - if (winner && claimed) { - await executeEmailAutomation(winner, message); - } + // 并发执行,失败互不影响(allSettled 而非 all);等全部结束再推进游标, + // 保持与改动前一致的 at-most-once 语义:崩溃时游标落后 → 重新读到这封邮件 + // → claim 已写入 → 判为 duplicate → 不重复执行。 + const results = await Promise.allSettled( + claimedTasks.map((task) => executeEmailAutomation(task, message)) + ); + // 不重抛:一条失败不该拖累同一封邮件命中的其他自动化。但也不能不记—— + // executeEmailAutomation 的 try 从 createRun 之后才开始,requireMailboxLabel / + // requireMailboxId / createRun 抛出时不会写 failed 状态、也没有任何日志, + // 只在这里落一条带 automation id 的记录,否则这类失败对运维完全不可见。 + // (claim 已写入,重扫会判 duplicate,所以这里只补可观测性,不涉及重试。) + results.forEach((result, index) => { + if (result.status === "rejected") { + console.error( + `[Automation Email] automation ${claimedTasks[index]?.id} failed: user=${userId} mailboxId=${mailboxId} uid=${uid}`, + result.reason + ); + } + }); } // 无论是否命中规则都推进游标;规则调整不会回溯历史邮件。 - await saveAutomationEmailMailboxCursor(userId, mailboxKey, uid, true); + await saveAutomationEmailMailboxCursor(userId, mailboxId, uid, true); } } @@ -551,8 +580,10 @@ export async function scanEmailAutomations() { for (const task of tasks) { const userId = Number(task.created_by_user_id); - const mailboxKey = String(task.trigger_config?.mailboxKey || "system").trim() || "system"; - const key = `${userId}:${mailboxKey}`; + // 分组键:同一用户同一监听邮箱为一组(决定 claim 与去重的作用范围)。 + // 遗留数据没有整数 mailboxId,单独归组后由 processEmailMailboxGroup 抛错。 + const mailboxId = normalizeMailboxId(task.trigger_config?.mailboxId); + const key = mailboxId === null ? `${userId}:MAILBOX_ID_REQUIRED` : mailboxGroupKey(userId, mailboxId); const current = groups.get(key) || []; current.push(task); groups.set(key, current); @@ -564,7 +595,7 @@ export async function scanEmailAutomations() { } catch (error) { const first = groupTasks[0]; console.error( - `[Automation Email] mailbox scan failed: user=${first?.created_by_user_id} mailbox=${first?.trigger_config?.mailboxKey || "system"}`, + `[Automation Email] mailbox scan failed: user=${first?.created_by_user_id} automation=${first?.id} mailboxId=${first?.trigger_config?.mailboxId ?? "(缺失)"}`, error ); } @@ -576,8 +607,22 @@ export async function scanEmailAutomations() { } } +/** + * 模块 E.4:去重表保留期清理(每次执行都幂等——一条按 `created_at` 截止的 DELETE)。 + * + * 失败只记日志:这是维护动作,不该影响邮件扫描本身;下一次执行会补上。 + */ +async function runAutomationEmailRetentionCleanup() { + try { + const removed = await cleanupAutomationEmailProcessedMessages(); + console.log(`[Automation Email] dedup retention cleanup: ${removed} row(s) removed`); + } catch (error) { + console.error("[Automation Email] dedup retention cleanup failed:", error); + } +} + export async function initAutomationScheduler() { - await ensureAutomationTables(); + await assertAutomationTablesReady(); if (!global.automationCronTask) { await scanDueAutomations(); @@ -598,4 +643,15 @@ export async function initAutomationScheduler() { } else { console.log("[Automation Email] scheduler already initialized"); } + + // 模块 E.4:去重表清理,每天一次。与上面两个扫描任务同一套做法(同一个 node-cron、 + // 同样的 global 句柄防重复初始化),不另起计时器;启动时也跑一次,保证进程活不到每天那个 + // 时刻(频繁重启的部署)也总有机会清理——清理语句幂等,多跑一次没有副作用。 + if (!global.automationEmailCleanupTask) { + await runAutomationEmailRetentionCleanup(); + global.automationEmailCleanupTask = cron.schedule("0 3 * * *", () => { + void runAutomationEmailRetentionCleanup(); + }); + console.log("[Automation Email] dedup retention cleanup initialized (daily at 03:00)"); + } } diff --git a/messages/en/automation.json b/messages/en/automation.json new file mode 100644 index 0000000..63f2639 --- /dev/null +++ b/messages/en/automation.json @@ -0,0 +1,37 @@ +{ + "mailboxManagerTitle": "Mailbox management", + "mailboxManagerDescription": "Manage your monitored mailboxes: update the password or connection settings, or delete one. Add a new mailbox from the automation wizard.", + "mailboxManagerLoading": "Loading mailboxes…", + "mailboxManagerLoadFailed": "Failed to load the monitored mailboxes", + "mailboxManagerEmpty": "No monitored mailbox yet", + "mailboxManagerEmptyHint": "Add one from the automation wizard by choosing the new-mailbox option.", + "mailboxManagerStatusConnected": "Connected", + "mailboxManagerStatusError": "Connection error", + "mailboxManagerImapServer": "IMAP server", + "mailboxManagerLastError": "Last error", + "mailboxManagerNoError": "None", + "mailboxManagerEdit": "Edit", + "mailboxManagerDelete": "Delete", + "mailboxManagerDeleteConfirm": "Delete this mailbox?", + "mailboxManagerCancel": "Cancel", + "mailboxManagerSave": "Save", + "mailboxManagerSaving": "Saving…", + "mailboxManagerUpdated": "Mailbox updated", + "mailboxManagerDeleted": "Mailbox deleted", + "mailboxManagerSaveFailed": "Failed to save the mailbox", + "mailboxManagerDeleteFailed": "Failed to delete the mailbox", + "mailboxManagerDeleteBlocked": "This mailbox is still used by {count} automation(s). Update those automations first.", + "mailboxManagerFieldName": "Name", + "mailboxManagerFieldEmail": "Email address", + "mailboxManagerFieldUsername": "Login account", + "mailboxManagerFieldPassword": "Password", + "mailboxManagerFieldImapHost": "IMAP server", + "mailboxManagerFieldImapPort": "IMAP port", + "mailboxManagerFieldFolder": "Mail folder", + "mailboxManagerPasswordHint": "Leave blank to keep the current password", + "mailboxManagerCursorHint": "Changing the IMAP server, login account, or mail folder re-baselines this mailbox. Historical mail is not processed again.", + "mailboxManagerIssueEmail": "Please enter a valid email address", + "mailboxManagerIssueUsername": "Please enter the mailbox login account", + "mailboxManagerIssueImapHost": "Please enter the IMAP server", + "mailboxManagerIssueImapPort": "Invalid IMAP port" +} diff --git a/messages/zh-CN/automation.json b/messages/zh-CN/automation.json new file mode 100644 index 0000000..dc14062 --- /dev/null +++ b/messages/zh-CN/automation.json @@ -0,0 +1,37 @@ +{ + "mailboxManagerTitle": "邮箱管理", + "mailboxManagerDescription": "管理已配置的监听邮箱:改密码、改连接信息、删除。新增邮箱请到自动化向导里配置。", + "mailboxManagerLoading": "正在加载邮箱…", + "mailboxManagerLoadFailed": "监听邮箱列表加载失败", + "mailboxManagerEmpty": "还没有配置监听邮箱", + "mailboxManagerEmptyHint": "在新建自动化的向导中选择「+ 配置新邮箱…」即可添加。", + "mailboxManagerStatusConnected": "已连接", + "mailboxManagerStatusError": "连接异常", + "mailboxManagerImapServer": "IMAP 服务器", + "mailboxManagerLastError": "最后错误", + "mailboxManagerNoError": "无", + "mailboxManagerEdit": "编辑", + "mailboxManagerDelete": "删除", + "mailboxManagerDeleteConfirm": "确认删除该邮箱?", + "mailboxManagerCancel": "取消", + "mailboxManagerSave": "保存", + "mailboxManagerSaving": "保存中…", + "mailboxManagerUpdated": "监听邮箱已更新", + "mailboxManagerDeleted": "监听邮箱已删除", + "mailboxManagerSaveFailed": "监听邮箱保存失败", + "mailboxManagerDeleteFailed": "监听邮箱删除失败", + "mailboxManagerDeleteBlocked": "该邮箱仍被 {count} 个自动化使用,请先修改这些自动化的监听邮箱", + "mailboxManagerFieldName": "名称", + "mailboxManagerFieldEmail": "邮箱地址", + "mailboxManagerFieldUsername": "登录账号", + "mailboxManagerFieldPassword": "授权码 / 密码", + "mailboxManagerFieldImapHost": "IMAP 服务器", + "mailboxManagerFieldImapPort": "IMAP 端口", + "mailboxManagerFieldFolder": "收件文件夹", + "mailboxManagerPasswordHint": "留空表示不修改现有密码", + "mailboxManagerCursorHint": "修改 IMAP 服务器、登录账号或收件文件夹后,该邮箱的收信进度会重新建立基线,历史邮件不会被重复处理。", + "mailboxManagerIssueEmail": "请输入正确的邮箱地址", + "mailboxManagerIssueUsername": "请填写邮箱登录账号", + "mailboxManagerIssueImapHost": "请填写 IMAP 服务器", + "mailboxManagerIssueImapPort": "IMAP 端口不正确" +} diff --git a/package.json b/package.json index 1c9f3d0..80c51d8 100644 --- a/package.json +++ b/package.json @@ -49,10 +49,12 @@ "fernet": "^0.3.3", "form-data": "^4.0.4", "formidable": "^3.5.4", + "imapflow": "^2.0.2", "isomorphic-dompurify": "^2.33.0", "jsonwebtoken": "^9.0.2", "jszip": "^3.10.1", "lucide-react": "^0.454.0", + "mailparser": "^3.9.26", "mammoth": "^1.10.0", "next": "15.2.6", "next-intl": "^4.7.0", diff --git a/pages/api/automation/check-email.ts b/pages/api/automation/check-email.ts deleted file mode 100644 index 6055536..0000000 --- a/pages/api/automation/check-email.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { requireAuth } from "@/lib/auth"; -import { logError } from "@/lib/logError"; -import type { NextApiRequest, NextApiResponse } from "next"; - -export default async function handler(req: NextApiRequest, res: NextApiResponse) { - if (!requireAuth(req, res)) return; - - if (req.method !== "GET") { - res.setHeader("Allow", "GET"); - return res.status(405).json({ error: "Method Not Allowed" }); - } - - const rawAfterUid = req.query.after_uid; - let afterUid: string | undefined; - - if (typeof rawAfterUid === "string" && rawAfterUid.trim() !== "") { - const parsed = Number(rawAfterUid); - - if (!Number.isInteger(parsed) || parsed < 0) { - return res.status(400).json({ error: "Invalid after_uid" }); - } - - afterUid = String(parsed); - } - - try { - const backendBaseUrl = - process.env.EXTERNAL_API_BASE_URL || "http://localhost:8010"; - - const authorization = req.headers.authorization; - - const params = new URLSearchParams(); - if (afterUid !== undefined) { - params.set("after_uid", afterUid); - } - - const url = `${backendBaseUrl}/api/v1/email/unread${ - params.toString() ? `?${params.toString()}` : "" - }`; - - const response = await fetch(url, { - method: "GET", - headers: { - ...(authorization ? { Authorization: authorization } : {}), - }, - }); - - const raw = await response.text(); - - let data: any = null; - try { - data = raw ? JSON.parse(raw) : null; - } catch { - data = raw; - } - - if (!response.ok) { - return res.status(response.status).json({ - error: "Email check failed", - detail: - typeof data === "object" && data?.detail - ? data.detail - : typeof data === "string" - ? data - : `Backend returned ${response.status}`, - }); - } - - return res.status(200).json( - data ?? { - success: true, - latest_uid: 0, - messages: [], - }, - ); - } catch (error: any) { - console.error("[Automation Check Email API] Error:", error); - logError(error); - - return res.status(500).json({ - error: "Email check failed", - detail: - process.env.NODE_ENV === "development" - ? error?.message || "Unknown error" - : undefined, - }); - } -} diff --git a/pages/api/automation/send-email.ts b/pages/api/automation/send-email.ts deleted file mode 100644 index 51d8eee..0000000 --- a/pages/api/automation/send-email.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { requireAuth } from "@/lib/auth"; -import { logError } from "@/lib/logError"; -import type { NextApiRequest, NextApiResponse } from "next"; - -export default async function handler(req: NextApiRequest, res: NextApiResponse) { - if (!requireAuth(req, res)) return; - - if (req.method !== "POST") { - res.setHeader("Allow", "POST"); - return res.status(405).json({ error: "Method Not Allowed" }); - } - - const { title, body, to, is_html = false } = req.body || {}; - - if (!title || typeof title !== "string") { - return res.status(400).json({ error: "Missing title" }); - } - - if (!body || typeof body !== "string") { - return res.status(400).json({ error: "Missing body" }); - } - - if (!to || (typeof to !== "string" && !Array.isArray(to))) { - return res.status(400).json({ error: "Missing to" }); - } - - try { - const backendBaseUrl = - process.env.EXTERNAL_API_BASE_URL || "http://localhost:8010"; - - const authorization = req.headers.authorization; - - const response = await fetch(`${backendBaseUrl}/api/v1/email/send`, { - method: "POST", - headers: { - "Content-Type": "application/json", - ...(authorization ? { Authorization: authorization } : {}), - }, - body: JSON.stringify({ - title, - body, - to, - is_html, - }), - }); - - const raw = await response.text(); - - let data: any = null; - try { - data = raw ? JSON.parse(raw) : null; - } catch { - data = raw; - } - - if (!response.ok) { - return res.status(response.status).json({ - error: "Email sending failed", - detail: - typeof data === "object" && data?.detail - ? data.detail - : typeof data === "string" - ? data - : `Backend returned ${response.status}`, - }); - } - - return res.status(200).json(data ?? { success: true }); - } catch (error: any) { - console.error("[Automation Send Email API] Error:", error); - logError(error); - - return res.status(500).json({ - error: "Email sending failed", - detail: - process.env.NODE_ENV === "development" - ? error?.message || "Unknown error" - : undefined, - }); - } -} diff --git a/pages/api/v1/automation-email/claim.ts b/pages/api/v1/automation-email/claim.ts deleted file mode 100644 index a448a65..0000000 --- a/pages/api/v1/automation-email/claim.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { NextApiRequest, NextApiResponse } from "next"; -import { getUserIdFromRequest } from "@/lib/auth"; -import { claimAutomationEmailMessage, getAutomation } from "@/lib/automation/store"; - -export default async function handler(req: NextApiRequest, res: NextApiResponse) { - const userId = getUserIdFromRequest(req); - if (!userId) return res.status(401).json({ detail: "Unauthorized" }); - - if (req.method !== "POST") { - res.setHeader("Allow", ["POST"]); - return res.status(405).json({ detail: "Method Not Allowed" }); - } - - const automationId = Number(req.body?.automationId); - const mailboxKey = String(req.body?.mailboxKey || "system").trim() || "system"; - const messageKey = String(req.body?.messageKey || "").trim(); - - if (!Number.isInteger(automationId) || automationId <= 0) { - return res.status(400).json({ detail: "自动化 ID 无效" }); - } - if (!messageKey) { - return res.status(400).json({ detail: "邮件唯一标识不能为空" }); - } - - try { - const automation = await getAutomation(userId, automationId); - if (!automation || automation.trigger_type !== "邮件触发") { - return res.status(404).json({ detail: "邮件触发自动化不存在" }); - } - - const claimed = await claimAutomationEmailMessage( - userId, - mailboxKey, - messageKey, - automationId, - ); - - return res.status(200).json({ claimed }); - } catch (error) { - console.error("[Automation Email Claim API] error:", error); - return res.status(500).json({ detail: "邮件去重记录失败" }); - } -} diff --git a/pages/api/v1/automation-mailboxes/[id].ts b/pages/api/v1/automation-mailboxes/[id].ts index cd610f9..2f8e4a3 100644 --- a/pages/api/v1/automation-mailboxes/[id].ts +++ b/pages/api/v1/automation-mailboxes/[id].ts @@ -1,6 +1,12 @@ import type { NextApiRequest, NextApiResponse } from "next"; import { getUserIdFromRequest } from "@/lib/auth"; -import { deleteAutomationMailbox } from "@/lib/automation/mailboxes"; +import { respondMailboxApiError } from "@/lib/automation/mailbox-errors"; +import { mailboxUpdateInputFromBody } from "@/lib/automation/mailbox-input"; +import { + deleteAutomationMailbox, + mailboxRowToApi, + updateAutomationMailbox, +} from "@/lib/automation/mailboxes"; function parseId(value: string | string[] | undefined) { const raw = Array.isArray(value) ? value[0] : value; @@ -14,6 +20,20 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) const mailboxId = parseId(req.query.id); if (!mailboxId) return res.status(400).json({ detail: "Invalid mailbox id" }); + if (req.method === "PUT") { + try { + const row = await updateAutomationMailbox( + userId, + mailboxId, + mailboxUpdateInputFromBody(req.body) + ); + if (!row) return res.status(404).json({ detail: "邮箱不存在" }); + return res.status(200).json(mailboxRowToApi(row)); + } catch (error) { + return respondMailboxApiError(res, error); + } + } + if (req.method === "DELETE") { try { const result = await deleteAutomationMailbox(userId, mailboxId); @@ -31,6 +51,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) } } - res.setHeader("Allow", ["DELETE"]); + res.setHeader("Allow", ["PUT", "DELETE"]); return res.status(405).json({ detail: "Method Not Allowed" }); } diff --git a/pages/api/v1/automation-mailboxes/index.ts b/pages/api/v1/automation-mailboxes/index.ts index 391aad5..d91349e 100644 --- a/pages/api/v1/automation-mailboxes/index.ts +++ b/pages/api/v1/automation-mailboxes/index.ts @@ -1,26 +1,12 @@ import type { NextApiRequest, NextApiResponse } from "next"; import { getUserIdFromRequest } from "@/lib/auth"; +import { respondMailboxApiError } from "@/lib/automation/mailbox-errors"; import { createAutomationMailbox, listAutomationMailboxes, mailboxRowToApi, } from "@/lib/automation/mailboxes"; -import { fetchMailboxUnread } from "@/lib/automation/mailbox-client"; - -function errorResponse(res: NextApiResponse, error: any) { - const code = String(error?.message || ""); - const map: Record = { - MAILBOX_EMAIL_INVALID: "请输入正确的邮箱地址", - MAILBOX_USERNAME_REQUIRED: "请填写邮箱登录账号", - MAILBOX_PASSWORD_REQUIRED: "请填写邮箱授权码或密码", - MAILBOX_IMAP_HOST_REQUIRED: "请填写 IMAP 服务器", - MAILBOX_IMAP_PORT_INVALID: "IMAP 端口不正确", - AUTOMATION_MAILBOX_SECRET_MISSING: "服务端尚未配置邮箱凭证加密密钥", - }; - if (map[code]) return res.status(400).json({ detail: map[code] }); - console.error("[Automation Mailboxes API] error:", error); - return res.status(500).json({ detail: code || "邮箱操作失败" }); -} +import { fetchMailboxUnread } from "@/lib/automation/imap-client"; export default async function handler(req: NextApiRequest, res: NextApiResponse) { const userId = getUserIdFromRequest(req); @@ -30,8 +16,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) try { const rows = await listAutomationMailboxes(userId); return res.status(200).json({ items: rows.map(mailboxRowToApi) }); - } catch (error: any) { - return errorResponse(res, error); + } catch (error) { + return respondMailboxApiError(res, error); } } @@ -50,7 +36,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) try { // 保存前先真实连接一次,避免把不可用的账号写入“已连接邮箱”。 await fetchMailboxUnread({ - authorization: req.headers.authorization, connection: { email: input.email, username: input.username || input.email, @@ -64,18 +49,9 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) const row = await createAutomationMailbox(userId, input); return res.status(201).json(mailboxRowToApi(row)); - } catch (error: any) { - const message = String(error?.message || "邮箱连接失败"); - if ( - message.includes("IMAP") || - message.includes("登录") || - message.includes("连接") || - message.includes("认证") || - message.includes("AUTHENTICATION") - ) { - return res.status(400).json({ detail: message }); - } - return errorResponse(res, error); + } catch (error) { + // 连接类失败沿用上游原文(见 mailbox-errors.ts),其余按错误码映射。 + return respondMailboxApiError(res, error); } } diff --git a/pages/api/v1/automations/[id].ts b/pages/api/v1/automations/[id].ts index 503e82c..675df56 100644 --- a/pages/api/v1/automations/[id].ts +++ b/pages/api/v1/automations/[id].ts @@ -74,6 +74,15 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) if (code === "APP_NOT_FOUND") return res.status(404).json({ detail: "数字员工不存在" }); if (code === "NAME_REQUIRED") return res.status(400).json({ detail: "请填写自动化名称" }); if (code === "TASK_REQUIRED") return res.status(400).json({ detail: "请填写任务说明" }); + if (code === "MAILBOX_ID_REQUIRED") { + return res.status(400).json({ detail: "请为邮件触发选择监听邮箱" }); + } + if (code === "MAILBOX_NOT_OWNED") { + return res.status(400).json({ detail: "监听邮箱不存在或无权使用" }); + } + if (code === "MAILBOX_SYSTEM_RETIRED") { + return res.status(400).json({ detail: "系统邮箱已下线,请配置监听邮箱" }); + } const scheduleMessage = scheduleErrorResponse(code); if (scheduleMessage) return res.status(400).json({ detail: scheduleMessage }); diff --git a/pages/api/v1/automations/index.ts b/pages/api/v1/automations/index.ts index 7488f12..50b15c3 100644 --- a/pages/api/v1/automations/index.ts +++ b/pages/api/v1/automations/index.ts @@ -45,6 +45,15 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) if (code === "APP_NOT_FOUND") return res.status(404).json({ detail: "数字员工不存在" }); if (code === "NAME_REQUIRED") return res.status(400).json({ detail: "请填写自动化名称" }); if (code === "TASK_REQUIRED") return res.status(400).json({ detail: "请填写任务说明" }); + if (code === "MAILBOX_ID_REQUIRED") { + return res.status(400).json({ detail: "请为邮件触发选择监听邮箱" }); + } + if (code === "MAILBOX_NOT_OWNED") { + return res.status(400).json({ detail: "监听邮箱不存在或无权使用" }); + } + if (code === "MAILBOX_SYSTEM_RETIRED") { + return res.status(400).json({ detail: "系统邮箱已下线,请配置监听邮箱" }); + } const scheduleMessage = scheduleErrorResponse(code); if (scheduleMessage) return res.status(400).json({ detail: scheduleMessage }); diff --git a/pages/api/v1/email/unread-config.ts b/pages/api/v1/email/unread-config.ts new file mode 100644 index 0000000..1c087e8 --- /dev/null +++ b/pages/api/v1/email/unread-config.ts @@ -0,0 +1,50 @@ +/** + * `POST /api/v1/email/unread-config`:读一个监听邮箱里「游标之后」的邮件。 + * + * 薄路由:真正的实现在 `lib/automation/imap-client.ts`,调度器与「保存前试连」 + * 都是直接调用那个函数、不走 HTTP(每 10 秒一次的轮询没必要自我回调一次)。 + * + * 入参用 `mailboxId` 而不是原始连接信息:服务端按 `getAutomationMailboxForUser` 查库取 + * 凭据(归属已校验),否则任何登录用户都能让服务器拿任意主机 + 凭据发起 IMAP 连接。 + * + * ``` + * 请求 { mailboxId: number, afterUid?: number } // 游标未初始化时不传 afterUid + * 响应 { success: true, latest_uid: number, messages: [...8 个字段] } + * ``` + */ +import type { NextApiRequest, NextApiResponse } from "next"; +import { getUserIdFromRequest } from "@/lib/auth"; +import { fetchMailboxUnread } from "@/lib/automation/imap-client"; +import { respondMailboxApiError } from "@/lib/automation/mailbox-errors"; +import { getAutomationMailboxForUser, mailboxConnectionFromRow } from "@/lib/automation/mailboxes"; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + const userId = getUserIdFromRequest(req); + if (!userId) return res.status(401).json({ detail: "Unauthorized" }); + + if (req.method !== "POST") { + res.setHeader("Allow", ["POST"]); + return res.status(405).json({ detail: "Method Not Allowed" }); + } + + const mailboxId = Number(req.body?.mailboxId); + if (!Number.isInteger(mailboxId) || mailboxId <= 0) { + return res.status(400).json({ detail: "缺少 mailboxId" }); + } + + try { + const mailbox = await getAutomationMailboxForUser(userId, mailboxId); + if (!mailbox) return res.status(404).json({ detail: "邮箱不存在" }); + + const data = await fetchMailboxUnread({ + // 非整数(含缺省)一律当作「游标尚未建立」:此时只回报 latest_uid,不取信。 + afterUid: Number.isInteger(req.body?.afterUid) ? Number(req.body.afterUid) : undefined, + connection: mailboxConnectionFromRow(mailbox), + }); + + return res.status(200).json(data); + } catch (error) { + // 连接类失败(主机、授权码、证书)沿用原文映射成 400,其余按错误码处理。 + return respondMailboxApiError(res, error); + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3a02738..a5d2b48 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -98,6 +98,9 @@ importers: formidable: specifier: ^3.5.4 version: 3.5.4 + imapflow: + specifier: ^2.0.2 + version: 2.0.2 isomorphic-dompurify: specifier: ^2.33.0 version: 2.36.0(@noble/hashes@1.8.0) @@ -110,6 +113,9 @@ importers: lucide-react: specifier: ^0.454.0 version: 0.454.0(react@19.2.8) + mailparser: + specifier: ^3.9.26 + version: 3.9.26 mammoth: specifier: ^1.10.0 version: 1.12.2 @@ -924,6 +930,9 @@ packages: '@pdf-lib/upng@1.0.1': resolution: {integrity: sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==} + '@pinojs/redact@0.4.0': + resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} @@ -1671,6 +1680,11 @@ packages: '@schummar/icu-type-parser@1.21.5': resolution: {integrity: sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw==} + '@selderee/plugin-htmlparser2@0.12.0': + resolution: {integrity: sha512-oELmoyA6ML9jDRMV3kgcMQFKxUfBU0yFVn6yTctVaLT5ygXnxH52I3TZEgV9EhXJC68/uFvE5Daj1/25c0Xa/A==} + peerDependencies: + selderee: ~0.12.0 + '@swc/core-darwin-arm64@1.15.8': resolution: {integrity: sha512-M9cK5GwyWWRkRGwwCbREuj6r8jKdES/haCZ3Xckgkl8MUQJZA3XB7IXXK1IXRNeLjg6m7cnoMICpXv1v1hlJOg==} engines: {node: '>=10'} @@ -1917,10 +1931,15 @@ packages: '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + deprecated: Potential CWE-502 - Update to 1.3.1 or higher '@xmldom/xmldom@0.8.11': resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==} engines: {node: '>=10.0.0'} + deprecated: this version has critical issues, please update to the latest version + + '@zone-eu/mailsplit@5.4.16': + resolution: {integrity: sha512-zQ9iXvlT3Wi/hazeC1MdI4rQc1UJwJ6IQ6QzSZ5KDxLZZWQSazWLOzImLFluXadKShJ9WJvI1xH+AyVS8b9azg==} adler-32@1.3.1: resolution: {integrity: sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==} @@ -1981,6 +2000,10 @@ packages: asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + atomic-sleep@1.0.0: + resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} + engines: {node: '>=8.0.0'} + axios@1.20.0: resolution: {integrity: sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==} @@ -2176,6 +2199,7 @@ packages: crypto-js@4.2.0: resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==} + deprecated: Active development of CryptoJS has been discontinued. This library is no longer maintained. css-select@1.2.0: resolution: {integrity: sha512-dUQOBoqdR7QwV90WysXPLXG5LO7nhYBgiWVfxF80DKPF8zx1t/pUd2FYy73emg3zrjtM6dzmYgbHKfV2rxiHQA==} @@ -2370,6 +2394,10 @@ packages: decode-named-character-reference@1.2.0: resolution: {integrity: sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==} + deepmerge-ts@8.0.2: + resolution: {integrity: sha512-uqbvqLUMrc6p0MO+WBRtTxY55hmyh94WRwI5a++PZe54X+bfVh59FSN7uWCBCW1CCVjzjnrwzfI8zidE2obMMw==} + engines: {node: '>=16.9.0'} + delaunator@5.0.1: resolution: {integrity: sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==} @@ -2414,12 +2442,22 @@ packages: dom-serializer@0.1.1: resolution: {integrity: sha512-l0IU0pPzLWSHBcieZbpOKgkIn3ts3vAh7ZuFyXNwJxJXk/c4Gwj9xaTJwIDVQCXawWD0qb3IzMGH5rglQaO0XA==} + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + domelementtype@1.3.1: resolution: {integrity: sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w==} + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + domhandler@2.4.2: resolution: {integrity: sha512-JiK04h0Ht5u/80fdLMCEmV4zkNh2BcoMFBmZ/91WtYZ8qVXSKjiw7fXMgFPnHcSZgOo3XdinHvmnDUeMf5R4wA==} + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + dompurify@3.4.14: resolution: {integrity: sha512-dVoH9z+MY+C9IilgGCk3YfFqjLi3fChm2OiKJMzh6axrJ5qwxqWaZamgmHrpv22CN/KdbZJuGEGgfQoL00LTdg==} @@ -2429,6 +2467,9 @@ packages: domutils@1.7.0: resolution: {integrity: sha512-Lgd2XcJ/NjEw+7tFvfKxOzCYKZsdct5lczQ2ZaQY8Djz7pfAD3Gbp8ySJWtreII/vDlMVmxwa6pHmdxIYgttDg==} + domutils@3.2.2: + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + dotenv@17.4.2: resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} engines: {node: '>=12'} @@ -2461,13 +2502,25 @@ packages: emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + encoding-japanese@2.3.0: + resolution: {integrity: sha512-eQyh1vzHz13DUkZcJO+0IOAoKXRQwKV5IBffeuYsWZyRLGiSzfzXObCqWvqFXdX0UU8qOk+lBXbkUhMCpdJe4Q==} + engines: {node: '>=18.0.0'} + entities@1.1.2: resolution: {integrity: sha512-f2LZMYl1Fzu7YSBKg+RoROelpOaNrcGmE9AZubeDfrCEia483oW4MI4VyFd5VNHIgQ/7qm1I0wUHK1eJnn2y2w==} + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + entities@6.0.1: resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} engines: {node: '>=0.12'} + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + epub2@1.3.4: resolution: {integrity: sha512-KSkxyVaIDZfez+epxNVr9ALql4TxAuyJeweBFJ/bQ8J+bLALb5qZdfe16T7ZbyuC1GXlyUMVDkSUP7SB95pc/A==} @@ -2595,6 +2648,7 @@ packages: glob@10.4.5: resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true gopd@1.2.0: @@ -2649,6 +2703,10 @@ packages: hastscript@9.0.1: resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==} + he@1.2.0: + resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} + hasBin: true + hosted-git-info@2.8.9: resolution: {integrity: sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==} @@ -2660,12 +2718,19 @@ packages: resolution: {integrity: sha512-0md7tlUUyb0BEQGsZzbqty1CgV6RESOoxdivt94AScqhBhYsPCCQCOaGvur/RospMjYpPJ7iFe3zw4Bu4SVA8g==} engines: {'0': node >= 0.4.0} + html-to-text@10.0.1: + resolution: {integrity: sha512-GiVhRI1BatGARSCmlXWNCjDT0cWrwBWoeduLoV0WSKAgaV/wa+hUWy5LiQLUs4UwiUrE52ZCMfBGiKD87TDPrg==} + engines: {node: '>=20.19.0'} + html-url-attributes@3.0.1: resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} html-void-elements@3.0.0: resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + htmlparser2@10.1.0: + resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==} + htmlparser2@3.10.1: resolution: {integrity: sha512-IgieNijUMbkDovyoKObU1DUhm1iwNYE/fuifEoEHfd1oZKZDaONBSkal7Y01shxsM49R4XaMdGez3WnF9UfiCQ==} @@ -2689,6 +2754,14 @@ packages: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} + iconv-lite@0.7.3: + resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} + engines: {node: '>=0.10.0'} + + imapflow@2.0.2: + resolution: {integrity: sha512-jrWWecWFifN3Feum2MqaTUupNeYSAdPEltKuwGdr8IPdxW72NZXf7ObVUfTWoUH2MPrli0q7SZm9pRt8ob/9XQ==} + engines: {node: '>=20.0.0'} + immediate@3.0.6: resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} @@ -2709,6 +2782,10 @@ packages: intl-messageformat@10.7.18: resolution: {integrity: sha512-m3Ofv/X/tV8Y3tHXLohcuVuhWKo7BBq62cqY15etqmLxg2DZ34AGGgQDeR+SCta2+zICb1NX83af0GJmbQ1++g==} + ip-address@10.7.0: + resolution: {integrity: sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==} + engines: {node: '>= 12'} + is-alphabetical@2.0.1: resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} @@ -2831,6 +2908,18 @@ packages: jws@3.2.2: resolution: {integrity: sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==} + leac@0.7.0: + resolution: {integrity: sha512-qMrZeyEekgdRQ9o6a4NAB2EQZrv827GJdn1vnapwSJ90hWRB4TzUSunvacPkxQ2TnNqHNI1/zSt0hlo0crG8Jw==} + + libbase64@1.3.0: + resolution: {integrity: sha512-GgOXd0Eo6phYgh0DJtjQ2tO8dc0IVINtZJeARPeiIJqge+HdsWSuaDTe8ztQ7j/cONByDZ3zeB325AHiv5O0dg==} + + libmime@5.4.3: + resolution: {integrity: sha512-di9BoDabBUMqjeD/wGj+hHpSgdqAph5ui7w6OdY6NpzU6O6VFLQsMOg9tqCjm/zf9OHzAM9EZxSOF7uIb8O8Hw==} + + libqp@2.1.1: + resolution: {integrity: sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==} + lie@3.3.0: resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} @@ -2841,6 +2930,9 @@ packages: lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + linkify-it@5.0.2: + resolution: {integrity: sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==} + load-json-file@1.1.0: resolution: {integrity: sha512-cy7ZdNRXdablkXYNI049pthVeXFurRyb9+hA/dZzerZ0pGTx42z+y+ssxBaVV2l70t1muq5IdKhn4UtcoGUY9A==} engines: {node: '>=0.10.0'} @@ -2906,6 +2998,10 @@ packages: peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc + mailparser@3.9.26: + resolution: {integrity: sha512-YUdylKj8RnSoyavscy2Kl3ZTh9l0k4PC/KEpW6M/IwTnicKwR/fMLaVXshlF8MfRg1p4HTsW+TVER6YaIeKnow==} + engines: {node: '>=20.0.0'} + mammoth@1.12.2: resolution: {integrity: sha512-MH2vkgafD/2MYUaEOtoXLKrHQZ7yLYTHGQgyLNtYZHiUxU1K1QQ+8qMFquDAfhBm06wSGSws3J+Q9QTsKtR44g==} engines: {node: '>=12.0.0'} @@ -3132,6 +3228,7 @@ packages: next@15.2.6: resolution: {integrity: sha512-DIKFctUpZoCq5ok2ztVU+PqhWsbiqM9xNP7rHL2cAp29NQcmDp7Y6JnBBhHRbFt4bCsCZigj6uh+/Gwh2158Wg==} engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} + deprecated: This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/security-update-2025-12-11 for more details. hasBin: true peerDependencies: '@opentelemetry/api': ^1.1.0 @@ -3164,6 +3261,10 @@ packages: resolution: {integrity: sha512-1cBMgRxdMWE8KeWCqk2RIOrvUb0XCwYfEsY5/y2NlXyq4Y/RumnOZvTj4Nbr77+Vb2C+kyBoRTdkNOS8L3d/aQ==} engines: {node: '>=0.10.0'} + nodemailer@10.0.9: + resolution: {integrity: sha512-BF0qcyplCwp+jMk6HCjFykBz/YhhZSsxrARhOldLwFWH+8kGjQsd2WIMIZhqEuyXRoGFi0ONbDeWDMDoL8MhLw==} + engines: {node: '>=20.0.0'} + normalize-package-data@2.5.0: resolution: {integrity: sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==} @@ -3182,6 +3283,10 @@ packages: resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} engines: {node: '>= 6'} + on-exit-leak-free@2.1.2: + resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} + engines: {node: '>=14.0.0'} + once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} @@ -3217,6 +3322,9 @@ packages: parse5@8.0.0: resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==} + parseley@0.13.1: + resolution: {integrity: sha512-uNBJZzmb60l6p6VWLTmevizNAGnE0xoSf1n0B4q3ntegDNzcS68NRCcBDZTcyXHxt2XhBChsCuqj4M+nChvE/A==} + path-exists@2.1.0: resolution: {integrity: sha512-yTltuKuhtNeFJKa1PiRzfLAU5182q1y4Eb4XCJ3PBqyzEDkAZRzBrKKBct682ls9reBVHf9udYLN5Nd+K1B9BQ==} engines: {node: '>=0.10.0'} @@ -3251,6 +3359,9 @@ packages: resolution: {integrity: sha512-SFOPNOajIvJTkbdsTXuaO2Dh0x1h1sdak4W4nt6qY7pW2mV9Q8EavRQXTt/3xGZnYyhwq99KifcSYF+w9KBk8w==} hasBin: true + peberminta@0.10.0: + resolution: {integrity: sha512-80B2AsU+I4Qdb0ZAPSfe9UwvGzwkM37IKIFEvdS3D/3Ndgv2bsuJ0bfG1+iEYO+l7Gfd4EUJmuRyq7efLgRMzQ==} + pend@1.2.0: resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} @@ -3310,6 +3421,16 @@ packages: resolution: {integrity: sha512-MnUuEycAemtSaeFSjXKW/aroV7akBbY+Sv+RkyqFjgAe73F+MR0TBWKBRDkmfWq/HiFmdavfZ1G7h4SPZXaCSg==} engines: {node: '>=0.10.0'} + pino-abstract-transport@3.0.0: + resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==} + + pino-std-serializers@7.1.0: + resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==} + + pino@10.3.1: + resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==} + hasBin: true + pirates@4.0.7: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} @@ -3385,6 +3506,9 @@ packages: process-nextick-args@2.0.1: resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + process-warning@5.1.0: + resolution: {integrity: sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==} + prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} @@ -3398,6 +3522,10 @@ packages: resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} engines: {node: '>=10'} + punycode.js@2.3.1: + resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} + engines: {node: '>=6'} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -3405,6 +3533,9 @@ packages: queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + quick-format-unescaped@4.0.4: + resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + ragent-oss@0.1.0: resolution: {integrity: sha512-5VZ4/Yhs7BI7qo6sdm4lQmRKhW1AZG+scZGtvIoQy5HIBxgO4tWytAevA+flCwS8prImzpQRx8onlyj/EqXgPA==} @@ -3519,12 +3650,20 @@ packages: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} + real-require@0.2.0: + resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} + engines: {node: '>= 12.13.0'} + + real-require@1.0.0: + resolution: {integrity: sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==} + recharts-scale@0.4.5: resolution: {integrity: sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==} recharts@2.15.0: resolution: {integrity: sha512-cIvMxDfpAmqAmVgc4yb7pgm/O1tmmkl/CjrvXuW+62/+7jj/iF9Ykm+hb/UJt42TREHMyd3gb+pkgoa2MxgDIw==} engines: {node: '>=14'} + deprecated: 1.x and 2.x branches are no longer active. Bump to Recharts v3 to receive latest features and bugfixes. See https://github.com/recharts/recharts/wiki/3.0-migration-guide peerDependencies: react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 @@ -3583,6 +3722,10 @@ packages: safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} @@ -3596,6 +3739,9 @@ packages: scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + selderee@0.12.0: + resolution: {integrity: sha512-b1YMh3+DHZp59DLna3qVwQ5iOla/nrI6mLBNW02XxU77M3046Df6VLkoaJyFz20VsGIG5kkp+FK0kg4K4HnUFw==} + semver@5.7.2: resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==} hasBin: true @@ -3635,6 +3781,17 @@ packages: simple-swizzle@0.2.4: resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==} + smart-buffer@4.2.0: + resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} + engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} + + socks@2.8.10: + resolution: {integrity: sha512-e0VyvkVTwVYViNovRkZ9aodhxVlyoMn7eJhVUPxZ+eK9P/7CBkxvvsBOHqFPEH416726W8tLXXXjKwqgTErrCQ==} + engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} + + sonic-boom@4.2.1: + resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} + sonner@1.7.1: resolution: {integrity: sha512-b6LHBfH32SoVasRFECrdY8p8s7hXPDn3OHUFbZZbiB1ctLS9Gdh6rpX2dVrpQA0kiL5jcRzDDldwwLkSKk3+QQ==} peerDependencies: @@ -3775,6 +3932,10 @@ packages: thenify@3.3.1: resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + thread-stream@4.2.0: + resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==} + engines: {node: '>=20'} + three@0.161.0: resolution: {integrity: sha512-LC28VFtjbOyEu5b93K0bNRLw1rQlMJ85lilKsYj6dgTu+7i17W+JCCEbvrpmNHF1F3NAUqDSWq50UD7w9H2xQw==} @@ -3785,6 +3946,10 @@ packages: tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + tlds@1.261.0: + resolution: {integrity: sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA==} + hasBin: true + tldts-core@7.4.11: resolution: {integrity: sha512-CW3WN2rIIE/Of21mulhgnGOwoDyEFNygyIBOONSdyAuSATgMMUCpLeUlB+E8sAwA5xRV9hYPl+kyZ9citHCaKg==} @@ -3831,6 +3996,9 @@ packages: engines: {node: '>=12.20'} hasBin: true + uc.micro@2.1.0: + resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} + underscore@1.13.7: resolution: {integrity: sha512-GMXzWtsc57XAtguZgaQViUOzs0KTkk8ojr3/xAxXLITqf/3EMwxC0inyETfDFjH/Krbhuep0HNbbjI9i/q3F3g==} @@ -4606,6 +4774,8 @@ snapshots: dependencies: pako: 1.0.11 + '@pinojs/redact@0.4.0': {} + '@pkgjs/parseargs@0.11.0': optional: true @@ -5367,6 +5537,12 @@ snapshots: '@schummar/icu-type-parser@1.21.5': {} + '@selderee/plugin-htmlparser2@0.12.0(selderee@0.12.0)': + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + selderee: 0.12.0 + '@swc/core-darwin-arm64@1.15.8': optional: true @@ -5618,6 +5794,12 @@ snapshots: '@xmldom/xmldom@0.8.11': {} + '@zone-eu/mailsplit@5.4.16': + dependencies: + libbase64: 1.3.0 + libmime: 5.4.3 + libqp: 2.1.1 + adler-32@1.3.1: {} adm-zip@0.4.16: {} @@ -5663,6 +5845,8 @@ snapshots: asynckit@0.4.0: {} + atomic-sleep@1.0.0: {} + axios@1.20.0(debug@4.4.1): dependencies: follow-redirects: 1.16.0(debug@4.4.1) @@ -6056,6 +6240,8 @@ snapshots: dependencies: character-entities: 2.0.2 + deepmerge-ts@8.0.2: {} + delaunator@5.0.1: dependencies: robust-predicates: 3.0.2 @@ -6096,12 +6282,24 @@ snapshots: domelementtype: 1.3.1 entities: 1.1.2 + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + domelementtype@1.3.1: {} + domelementtype@2.3.0: {} + domhandler@2.4.2: dependencies: domelementtype: 1.3.1 + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 + dompurify@3.4.14: optionalDependencies: '@types/trusted-types': 2.0.7 @@ -6116,6 +6314,12 @@ snapshots: dom-serializer: 0.1.1 domelementtype: 1.3.1 + domutils@3.2.2: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + dotenv@17.4.2: {} duck@0.1.12: @@ -6151,10 +6355,16 @@ snapshots: emoji-regex@9.2.2: {} + encoding-japanese@2.3.0: {} + entities@1.1.2: {} + entities@4.5.0: {} + entities@6.0.1: {} + entities@7.0.1: {} + epub2@1.3.4: dependencies: adm-zip: 0.4.16 @@ -6413,6 +6623,8 @@ snapshots: property-information: 7.1.0 space-separated-tokens: 2.0.2 + he@1.2.0: {} + hosted-git-info@2.8.9: {} html-encoding-sniffer@6.0.0(@noble/hashes@1.8.0): @@ -6423,10 +6635,25 @@ snapshots: html-entities@1.2.0: {} + html-to-text@10.0.1: + dependencies: + '@selderee/plugin-htmlparser2': 0.12.0(selderee@0.12.0) + deepmerge-ts: 8.0.2 + dom-serializer: 2.0.0 + htmlparser2: 10.1.0 + selderee: 0.12.0 + html-url-attributes@3.0.1: {} html-void-elements@3.0.0: {} + htmlparser2@10.1.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + entities: 7.0.1 + htmlparser2@3.10.1: dependencies: domelementtype: 1.3.1 @@ -6463,6 +6690,21 @@ snapshots: dependencies: safer-buffer: 2.1.2 + iconv-lite@0.7.3: + dependencies: + safer-buffer: 2.1.2 + + imapflow@2.0.2: + dependencies: + '@zone-eu/mailsplit': 5.4.16 + encoding-japanese: 2.3.0 + iconv-lite: 0.7.3 + libbase64: 1.3.0 + libmime: 5.4.3 + libqp: 2.1.1 + pino: 10.3.1 + socks: 2.8.10 + immediate@3.0.6: {} indent-string@2.1.0: @@ -6482,6 +6724,8 @@ snapshots: '@formatjs/icu-messageformat-parser': 2.11.4 tslib: 2.8.1 + ip-address@10.7.0: {} + is-alphabetical@2.0.1: {} is-alphanumerical@2.0.1: @@ -6625,6 +6869,19 @@ snapshots: jwa: 1.4.2 safe-buffer: 5.2.1 + leac@0.7.0: {} + + libbase64@1.3.0: {} + + libmime@5.4.3: + dependencies: + encoding-japanese: 2.3.0 + iconv-lite: 0.7.3 + libbase64: 1.3.0 + libqp: 2.1.1 + + libqp@2.1.1: {} + lie@3.3.0: dependencies: immediate: 3.0.6 @@ -6633,6 +6890,10 @@ snapshots: lines-and-columns@1.2.4: {} + linkify-it@5.0.2: + dependencies: + uc.micro: 2.1.0 + load-json-file@1.1.0: dependencies: graceful-fs: 4.2.11 @@ -6688,6 +6949,19 @@ snapshots: dependencies: react: 19.2.8 + mailparser@3.9.26: + dependencies: + '@zone-eu/mailsplit': 5.4.16 + encoding-japanese: 2.3.0 + he: 1.2.0 + html-to-text: 10.0.1 + iconv-lite: 0.7.3 + libmime: 5.4.3 + linkify-it: 5.0.2 + nodemailer: 10.0.9 + punycode.js: 2.3.1 + tlds: 1.261.0 + mammoth@1.12.2: dependencies: '@xmldom/xmldom': 0.8.11 @@ -7159,6 +7433,8 @@ snapshots: node-status-codes@1.0.0: {} + nodemailer@10.0.9: {} + normalize-package-data@2.5.0: dependencies: hosted-git-info: 2.8.9 @@ -7176,6 +7452,8 @@ snapshots: object-hash@3.0.0: {} + on-exit-leak-free@2.1.2: {} + once@1.4.0: dependencies: wrappy: 1.0.2 @@ -7216,6 +7494,11 @@ snapshots: dependencies: entities: 6.0.1 + parseley@0.13.1: + dependencies: + leac: 0.7.0 + peberminta: 0.10.0 + path-exists@2.1.0: dependencies: pinkie-promise: 2.0.1 @@ -7255,6 +7538,8 @@ snapshots: dependencies: yargs: 1.3.3 + peberminta@0.10.0: {} + pend@1.2.0: {} pg-cloudflare@1.4.0: @@ -7306,6 +7591,26 @@ snapshots: pinkie@2.0.4: {} + pino-abstract-transport@3.0.0: + dependencies: + split2: 4.2.0 + + pino-std-serializers@7.1.0: {} + + pino@10.3.1: + dependencies: + '@pinojs/redact': 0.4.0 + atomic-sleep: 1.0.0 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 3.0.0 + pino-std-serializers: 7.1.0 + process-warning: 5.1.0 + quick-format-unescaped: 4.0.4 + real-require: 0.2.0 + safe-stable-stringify: 2.5.0 + sonic-boom: 4.2.1 + thread-stream: 4.2.0 + pirates@4.0.7: {} po-parser@2.1.1: {} @@ -7367,6 +7672,8 @@ snapshots: process-nextick-args@2.0.1: {} + process-warning@5.1.0: {} + prop-types@15.8.1: dependencies: loose-envify: 1.4.0 @@ -7379,10 +7686,14 @@ snapshots: proxy-from-env@2.1.0: {} + punycode.js@2.3.1: {} + punycode@2.3.1: {} queue-microtask@1.2.3: {} + quick-format-unescaped@4.0.4: {} + ragent-oss@0.1.0: {} react-dom@19.2.8(react@19.2.8): @@ -7528,6 +7839,10 @@ snapshots: dependencies: picomatch: 2.3.1 + real-require@0.2.0: {} + + real-require@1.0.0: {} + recharts-scale@0.4.5: dependencies: decimal.js-light: 2.5.1 @@ -7621,6 +7936,8 @@ snapshots: safe-buffer@5.2.1: {} + safe-stable-stringify@2.5.0: {} + safer-buffer@2.1.2: {} sax@1.4.1: {} @@ -7631,6 +7948,10 @@ snapshots: scheduler@0.27.0: {} + selderee@0.12.0: + dependencies: + parseley: 0.13.1 + semver@5.7.2: {} semver@7.7.2: {} @@ -7682,6 +8003,17 @@ snapshots: is-arrayish: 0.3.4 optional: true + smart-buffer@4.2.0: {} + + socks@2.8.10: + dependencies: + ip-address: 10.7.0 + smart-buffer: 4.2.0 + + sonic-boom@4.2.1: + dependencies: + atomic-sleep: 1.0.0 + sonner@1.7.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: react: 19.2.8 @@ -7853,12 +8185,18 @@ snapshots: dependencies: any-promise: 1.3.0 + thread-stream@4.2.0: + dependencies: + real-require: 1.0.0 + three@0.161.0: {} timed-out@3.1.3: {} tiny-invariant@1.3.3: {} + tlds@1.261.0: {} + tldts-core@7.4.11: {} tldts@7.4.11: @@ -7893,6 +8231,8 @@ snapshots: typescript@5.0.2: {} + uc.micro@2.1.0: {} + underscore@1.13.7: {} undici-types@6.21.0: {} diff --git a/test/automationAgentPayload.test.ts b/test/automationAgentPayload.test.ts new file mode 100644 index 0000000..ac20c64 --- /dev/null +++ b/test/automationAgentPayload.test.ts @@ -0,0 +1,195 @@ +/** + * 自动化请求体的形状守卫。 + * + * 抽成零依赖纯模块才测得到:`execute.ts` 依赖 `@/lib/chatSse`,而本套件用 + * `node --experimental-strip-types` 跑,解析不了 `@/` 别名(与 `mail-rules.ts` 同样的理由)。 + * + * 这里钉住两件事: + * - 附件元信息按 ragent-service 认的字段名下发——`lib/qaCore.ts` 的对话链路已经在用同一组 + * 名字(`object_key` / `filename` / `content_type` / `size`),自动化这条路必须对齐, + * 否则后端取不回文件,模型还是读不到附件; + * - **`object_key` 只能留在服务端**。它一旦出现在 messages 文本里,模型就等同于拿到了 + * 对象存储的任意读能力——这是对话链路已经定下的规矩,不能因为换了入口就松掉。 + */ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + EMAIL_BODY_MAX_CHARS, + buildAutomationAgentPayload, + buildEmailAutomationQuestion, + normalizeEmailBody, +} from "../lib/automation/agent-payload.ts"; + +test("无附件时不带 attachments 字段,请求体与改造前一致", () => { + const payload = buildAutomationAgentPayload({ question: "算一下总分", appId: 7 }); + + assert.deepEqual(payload, { + messages: [{ role: "user", content: "算一下总分" }], + app_id: 7, + }); +}); + +test("附件按后端认的字段名下发:object_key / filename / content_type / size", () => { + const payload = buildAutomationAgentPayload({ + question: "算一下总分", + appId: 7, + attachments: [ + { + objectKey: "attachments/2026/09/abc-成绩单.xlsx", + filename: "成绩单.xlsx", + contentType: + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + size: 12345, + }, + ], + }); + + assert.deepEqual(payload.attachments, [ + { + object_key: "attachments/2026/09/abc-成绩单.xlsx", + filename: "成绩单.xlsx", + content_type: + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + size: 12345, + }, + ]); +}); + +test("object_key 绝不出现在 messages 里:模型不该拿到对象存储的读能力", () => { + const payload = buildAutomationAgentPayload({ + question: "读一下附件", + appId: 7, + attachments: [ + { + objectKey: "attachments/2026/09/very-secret-key-成绩单.xlsx", + filename: "成绩单.xlsx", + }, + ], + }); + + assert.ok( + !JSON.stringify(payload.messages).includes("very-secret-key"), + "messages 里出现了 object_key —— 模型会因此拿到对象存储的任意读能力", + ); +}); + +test("没有 object_key 的附件不下发:后端取不回文件,发了也只是噪音", () => { + const payload = buildAutomationAgentPayload({ + question: "读一下附件", + appId: 7, + attachments: [ + { objectKey: "", filename: "取不回的.pdf" }, + { objectKey: "attachments/ok.pdf", filename: "取得到的.pdf" }, + ], + }); + + assert.deepEqual( + (payload.attachments as Array<{ filename: string }>).map((a) => a.filename), + ["取得到的.pdf"], + ); +}); + +test("全部附件都取不回时,不带 attachments 字段而不是发一个空数组", () => { + const payload = buildAutomationAgentPayload({ + question: "读一下附件", + appId: 7, + attachments: [{ objectKey: "", filename: "取不回的.pdf" }], + }); + + assert.ok(!("attachments" in payload)); +}); + +test("提示词:没有附件时保持原样说「无」", () => { + const question = buildEmailAutomationQuestion({ + task: "算一下总分", + mailboxLabel: "sales@corp.com", + subject: "测试", + body: "正文", + }); + + assert.match(question, /附件:无/); +}); + +test("提示词:已传入的附件要说明在 inputs/ 里,并叫模型按文件名引用", () => { + const question = buildEmailAutomationQuestion({ + task: "算一下总分", + mailboxLabel: "sales@corp.com", + subject: "测试", + body: "正文", + delivered: ["成绩单.xlsx"], + }); + + assert.match(question, /成绩单\.xlsx/); + assert.match(question, /inputs\//); +}); + +test("提示词:没传成的附件必须点名,否则模型会把收到的当成全部", () => { + const question = buildEmailAutomationQuestion({ + task: "算一下总分", + mailboxLabel: "sales@corp.com", + subject: "测试", + body: "正文", + delivered: ["小.xlsx"], + skipped: ["太大.zip"], + }); + + assert.match(question, /太大\.zip/); + assert.match(question, /未传入|没传|跳过/); +}); + +test("提示词:object_key 不出现——交给后端按结构化字段取文件", () => { + const question = buildEmailAutomationQuestion({ + task: "算一下总分", + mailboxLabel: "sales@corp.com", + subject: "测试", + body: "正文", + delivered: ["成绩单.xlsx"], + }); + + assert.ok( + !question.includes("object_key") && !question.includes("attachments/"), + "提示词里出现了对象存储的 key", + ); +}); + +test("提示词:正文为空时说「无正文」,不是留白", () => { + const question = buildEmailAutomationQuestion({ + task: "算一下总分", + mailboxLabel: "sales@corp.com", + subject: "测试", + body: " ", + }); + + assert.match(question, /(无正文)/); +}); + +test("提示词:超长正文截断到 20000 字符并注明", () => { + const question = buildEmailAutomationQuestion({ + task: "算一下总分", + mailboxLabel: "sales@corp.com", + subject: "测试", + body: "字".repeat(20001), + }); + + assert.match(question, /已截取前 20000 个字符/); + assert.equal(question.includes("字".repeat(20001)), false); +}); + +test("正文规范化:空、纯空白、非字符串一律落到「无正文」", () => { + for (const value of ["", " ", "\n\t ", undefined, null, 123]) { + assert.equal(normalizeEmailBody(value as unknown as string), "(无正文)"); + } +}); + +test("正文规范化:恰好到上限不截断,多一个字才截", () => { + const exact = "字".repeat(EMAIL_BODY_MAX_CHARS); + assert.equal(normalizeEmailBody(exact), exact); + + const truncated = normalizeEmailBody(`${exact}多`); + assert.equal(truncated.startsWith(exact), true, "截断保留了前 20000 个字符"); + assert.match(truncated, /已截取前 20000 个字符/); +}); + +test("正文规范化:两端空白先去,避免正文里带一串空行", () => { + assert.equal(normalizeEmailBody(" 正文 \n\n"), "正文"); +}); diff --git a/test/automationEmailParallelRun.test.ts b/test/automationEmailParallelRun.test.ts new file mode 100644 index 0000000..9fe1bf2 --- /dev/null +++ b/test/automationEmailParallelRun.test.ts @@ -0,0 +1,97 @@ +/** + * 「一封邮件命中的每条自动化都执行」的源码守卫。 + * + * 为什么读源码而不是跑行为:`lib/cron/automation-scheduler.ts` 依赖 `lib/db`,本套件不连数据库、 + * 也不 import `@/lib`,没有可执行的断言入口(仓库既有做法,见 + * test/automationSystemMailboxRetired.test.ts)。 + * + * 这里盯的是本次改动最容易回退的两处: + * 1. claim 必须**逐条**发生。回到「只对 matched[0] 调用一次」,功能就静默退回单条执行, + * 而且不会产生任何类型错误或报错; + * 2. 游标必须仍在执行之后推进。提前推进会让「崩溃时邮件未执行但不重试」的窗口出现。 + */ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { test } from "node:test"; + +const SCHEDULER = join(process.cwd(), "lib/cron/automation-scheduler.ts"); + +function body(source: string, name: string): string { + const start = source.indexOf(`function ${name}(`); + assert.ok(start >= 0, `找不到函数 ${name}`); + const braceStart = source.indexOf("{", start); + let depth = 0; + for (let index = braceStart; index < source.length; index += 1) { + if (source[index] === "{") depth += 1; + else if (source[index] === "}") { + depth -= 1; + if (depth === 0) return source.slice(braceStart, index + 1); + } + } + throw new Error(`函数 ${name} 的花括号不配对`); +} + +test("每封邮件对命中的每条自动化逐条 claim,而不是只 claim 第一条", () => { + const fn = body(readFileSync(SCHEDULER, "utf8"), "processEmailMailboxGroup"); + + assert.match(fn, /for \(const task of matched\)/, "必须遍历 matched 逐条 claim"); + assert.match( + fn, + /for \(const task of matched\)\s*\{[\s\S]{0,300}?claimAutomationEmailMessage\(/, + "claimAutomationEmailMessage 必须在遍历 matched 的循环体内" + ); + assert.doesNotMatch(fn, /matched\[0\]/, "不得再出现「只取第一条」的写法"); +}); + +test("全部 claim 成功的任务交给 Promise.allSettled 并发执行", () => { + const fn = body(readFileSync(SCHEDULER, "utf8"), "processEmailMailboxGroup"); + + assert.match(fn, /Promise\.allSettled\(/, "并发执行必须用 Promise.allSettled(失败互不影响)"); + assert.match( + fn, + /Promise\.allSettled\([\s\S]{0,300}?executeEmailAutomation\(/, + "allSettled 的每一项都应当是 executeEmailAutomation" + ); +}); + +test("settle 结果必须被检视:执行失败落日志,而不是被 allSettled 无声吞掉", () => { + const fn = body(readFileSync(SCHEDULER, "utf8"), "processEmailMailboxGroup"); + + assert.match( + fn, + /(?:const|let)\s+\w+\s*=\s*await Promise\.allSettled\(/, + "allSettled 的返回值必须被接收;裸 await 会让 rejection 完全静默" + ); + assert.match( + fn, + /"rejected"/, + "必须显式处理 rejected 分支:executeEmailAutomation 的 requireMailboxLabel / " + + "requireMailboxId / createRun 都在其内部 try 之外,抛出时会变成零日志" + ); + + const executed = fn.indexOf("Promise.allSettled("); + const tail = fn.slice(executed); + assert.match(tail, /console\.error\(/, "rejected 分支必须用 console.error 落日志"); + assert.match( + tail, + /\[Automation Email\][\s\S]{0,300}?\.id/, + "日志要能定位到是哪条自动化失败(至少带 automation id),且沿用 [Automation Email] 前缀" + ); +}); + +test("游标在全部执行 settle 之后推进", () => { + const fn = body(readFileSync(SCHEDULER, "utf8"), "processEmailMailboxGroup"); + const executed = fn.indexOf("Promise.allSettled("); + assert.ok(executed >= 0, "找不到 Promise.allSettled("); + + // 必须从 executed 之后再找游标写入:本函数开头建立基线时(`if (!cursor.initialized)` + // 那个早退分支)也会调一次 saveAutomationEmailMailboxCursor,用 indexOf 会取到那一次, + // 位置在执行之前 —— 正确的代码也会被判红。 + const cursor = fn.indexOf("saveAutomationEmailMailboxCursor(", executed); + + assert.ok( + cursor >= 0, + "执行之后必须仍有游标推进:提前推进会让崩溃时「邮件已标记处理、实际没跑、且不再重试」" + ); +}); diff --git a/test/automationEmailRetention.test.ts b/test/automationEmailRetention.test.ts new file mode 100644 index 0000000..3489d5a --- /dev/null +++ b/test/automationEmailRetention.test.ts @@ -0,0 +1,142 @@ +/** + * 邮件去重表的保留期(模块 E.4:`automation_email_processed_messages` 保留 30 天,每天清理一次)。 + * + * 两件事: + * 1. 截止时刻的计算——保留期在 JS 里算好再作为查询参数传下去,所以"保留多少天"是可断言的 + * 行为,而不是埋在 `INTERVAL '30 days'` 字符串里; + * 2. "清理确实每天跑一次"——清理函数住在 store.ts、依赖 `lib/db`,测试进程 import 不到它, + * 因此按仓库既有做法(`test/mailboxCursorSql.test.ts`、`test/skillsProxyQuery.test.ts`) + * 读源码文本钉住:DELETE 只能碰去重这一张表、必须用参数化截止时刻、并且挂在一条 + * 每天执行的 node-cron 上(不另起计时器)。 + * + * 为什么要盯住删除范围:这张表与游标表相邻,而游标(`GREATEST` 写回的高水位)一旦被误删或 + * 被一起清掉,后果是"历史邮件被重新处理"或"新邮件全部漏掉",两种都很难在测试环境复现。 + */ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { test } from "node:test"; +import { + EMAIL_PROCESSED_RETENTION_DAYS, + emailProcessedRetentionCutoff, +} from "../lib/automation/retention.ts"; + +const STORE = join(process.cwd(), "lib/automation/store.ts"); +const SCHEDULER = join(process.cwd(), "lib/cron/automation-scheduler.ts"); + +function withoutComments(source: string) { + return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^[ \t]*\/\/.*$/gm, ""); +} + +function sqlStatements(source: string): string[] { + return [...withoutComments(source).matchAll(/`([^`]*)`/g)] + .map((match) => match[1]) + .filter((text) => /\b(SELECT|UPDATE|DELETE|INSERT|CREATE|ALTER)\b/i.test(text)); +} + +function functionBody(source: string, name: string): string { + const start = source.indexOf(`function ${name}(`); + assert.ok(start >= 0, `找不到函数 ${name}`); + const braceStart = source.indexOf("{", start); + let depth = 0; + for (let index = braceStart; index < source.length; index += 1) { + if (source[index] === "{") depth += 1; + else if (source[index] === "}") { + depth -= 1; + if (depth === 0) return source.slice(braceStart, index + 1); + } + } + throw new Error(`函数 ${name} 的花括号不配对`); +} + +test("保留期是 30 天", () => { + assert.equal(EMAIL_PROCESSED_RETENTION_DAYS, 30); + assert.equal( + emailProcessedRetentionCutoff(new Date("2026-09-11T10:00:00.000Z")).toISOString(), + "2026-08-12T10:00:00.000Z" + ); +}); + +test("截止时刻跨月、跨年都算得对(不是「减一个月」这类近似)", () => { + assert.equal( + emailProcessedRetentionCutoff(new Date("2026-01-15T00:00:00.000Z")).toISOString(), + "2025-12-16T00:00:00.000Z" + ); + assert.equal( + emailProcessedRetentionCutoff(new Date("2026-03-01T23:30:00.000Z")).toISOString(), + "2026-01-30T23:30:00.000Z" + ); +}); + +test("截止时刻只由传入的时间决定,与系统当前时间无关", () => { + const first = emailProcessedRetentionCutoff(new Date("2026-09-11T10:00:00.000Z")); + const second = emailProcessedRetentionCutoff(new Date("2026-09-12T10:00:00.000Z")); + + assert.equal(second.getTime() - first.getTime(), 24 * 60 * 60 * 1000); + assert.ok(first.getTime() < new Date("2026-09-11T10:00:00.000Z").getTime(), "截止时刻在过去"); +}); + +test("保留天数可覆盖(保留 1 天与 30 天相差 29 天)", () => { + const now = new Date("2026-09-11T10:00:00.000Z"); + + assert.equal(emailProcessedRetentionCutoff(now, 1).toISOString(), "2026-09-10T10:00:00.000Z"); + assert.equal( + emailProcessedRetentionCutoff(now, 1).getTime() - emailProcessedRetentionCutoff(now).getTime(), + 29 * 24 * 60 * 60 * 1000 + ); +}); + +test("清理语句只删去重表、用参数化截止时刻(E.4)", () => { + const source = readFileSync(STORE, "utf8"); + const cleanups = sqlStatements(source).filter((sql) => + /\bDELETE\s+FROM\s+automation_email_processed_messages\b/i.test(sql) + ); + + assert.equal( + cleanups.length, + 1, + `期望恰好一条清理去重表的 DELETE,实际 ${cleanups.length} 条 —— 语句被挪走/删掉/写了两份?` + ); + const sql = cleanups[0]; + + // 删除范围只由时间决定:误加 mailbox_id / created_by_user_id 之类的条件会让某些用户的 + // 过期行永远清不掉(表无界增长),而这张表本来就不该按用户维度清理。 + assert.match(sql, /WHERE\s+created_at\s*<\s*\$\d/i); + assert.doesNotMatch(sql, /mailbox_id|created_by_user_id/i); + assert.doesNotMatch(sql, /INTERVAL/i, "保留期必须在 JS 里算出来(可测),不要写成 SQL 字面量"); + assert.doesNotMatch( + sql, + /automation_email_mailbox_cursors|automation_email_rule_events/, + "清理只碰去重表:游标被删会让邮件重复处理或漏掉,这是本模块最危险的邻接改动" + ); + + // 截止时刻必须来自 retention.ts,而不是在这个函数里重新减一遍天数。 + assert.match( + functionBody(source, "cleanupAutomationEmailProcessedMessages"), + /emailProcessedRetentionCutoff\(/ + ); +}); + +test("清理挂在调度器上,每天跑一次(不另起计时器)", () => { + const source = readFileSync(SCHEDULER, "utf8"); + + assert.match( + source, + /cleanupAutomationEmailProcessedMessages/, + "调度器必须调用 store 的清理函数" + ); + + // 与既有两个扫描任务同一套做法:同一个 node-cron、同样的 global 句柄防重复初始化。 + const scheduled = [ + ...source.matchAll(/cron\.schedule\(\s*"([^"]+)"\s*,\s*\(\)\s*=>\s*\{([\s\S]*?)\}\s*\)/g), + ].map((match) => ({ expression: match[1], body: match[2] })); + const daily = scheduled.filter((item) => /^\d+ \d+ \* \* \*$/.test(item.expression)); + + assert.equal( + daily.length, + 1, + `期望恰好一条「每天一次」的 cron,实际 ${daily.length} 条 —— 清理被改成高频或换成了别的计时器?` + ); + assert.match(daily[0].body, /runAutomationEmailRetentionCleanup\(\)/); + assert.doesNotMatch(source, /setInterval|setTimeout/, "调度器不另起计时器"); +}); diff --git a/test/automationMailboxId.test.ts b/test/automationMailboxId.test.ts new file mode 100644 index 0000000..2387081 --- /dev/null +++ b/test/automationMailboxId.test.ts @@ -0,0 +1,271 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + MAILBOX_ID_REQUIRED, + MAILBOX_LABEL_REQUIRED, + MAILBOX_NEW_OPTION, + MAILBOX_SYSTEM_RETIRED, + automationMailboxLabel, + defaultMailboxSelection, + isMailboxSelectionUnresolved, + mailboxGroupKey, + mailboxOptionLabel, + mailboxSelectValue, + normalizeMailboxId, + requireMailboxId, + requireMailboxLabel, + selectMailboxScopedAutomations, + storedMailboxLabel, + type MailboxOption, +} from "../lib/automation/mailbox-id.ts"; + +test("normalizeMailboxId: 正整数原样返回", () => { + assert.equal(normalizeMailboxId(12), 12); + assert.equal(normalizeMailboxId(1), 1); + assert.equal(normalizeMailboxId(999999), 999999); +}); + +test("normalizeMailboxId: 遗留的 mailbox: 字符串与 system 一律视为无效", () => { + assert.equal(normalizeMailboxId("mailbox:12"), null); + assert.equal(normalizeMailboxId("system"), null); + assert.equal(normalizeMailboxId("12"), null); +}); + +test("normalizeMailboxId: 非正整数与其他类型一律视为无效", () => { + const invalid = [ + 0, + -1, + 1.5, + Number.NaN, + Number.POSITIVE_INFINITY, + "", + null, + undefined, + true, + {}, + [], + ]; + for (const value of invalid) { + assert.equal(normalizeMailboxId(value), null, `${String(value)} 应判定为无效`); + } +}); + +test("requireMailboxId: 合法取值通过", () => { + assert.equal(requireMailboxId(7), 7); +}); + +test("requireMailboxId: 缺失 mailboxId 时抛出可识别错误,而非回退 system", () => { + for (const value of [undefined, null, "mailbox:7", 0, "7"]) { + assert.throws( + () => requireMailboxId(value), + (error: Error) => error.message === MAILBOX_ID_REQUIRED, + `${String(value)} 应抛错` + ); + } +}); + +test("requireMailboxId: 错误信息指明缺失的 mailboxId 字段", () => { + assert.throws(() => requireMailboxId(undefined), /MAILBOX_ID_REQUIRED/); +}); + +// ── 模块 A:系统邮箱下线 ────────────────────────────────────────────── + +test("A.4 requireMailboxId: 提交已下线的系统邮箱哨兵值时抛 MAILBOX_SYSTEM_RETIRED", () => { + // 单独一个错误码,接口据此回「系统邮箱已下线,请配置监听邮箱」,而不是通用的"缺少邮箱"。 + for (const value of ["system", " system ", "system "]) { + assert.throws( + () => requireMailboxId(value), + (error: Error) => error.message === MAILBOX_SYSTEM_RETIRED, + `${JSON.stringify(value)} 应判定为已下线的系统邮箱` + ); + } +}); + +test("A.4 requireMailboxId: 其他非法值仍走通用错误码,不被哨兵值判断吞掉", () => { + for (const value of [undefined, null, "mailbox:7", "systems", 0, ""]) { + assert.throws( + () => requireMailboxId(value), + (error: Error) => error.message === MAILBOX_ID_REQUIRED, + `${JSON.stringify(value ?? null)} 应走 MAILBOX_ID_REQUIRED` + ); + } +}); + +test("A.5 requireMailboxLabel: 展示名缺失时抛错,不兜底成已下线的邮箱名", () => { + assert.equal(requireMailboxLabel("销售部邮箱 · sales@corp.com"), "销售部邮箱 · sales@corp.com"); + assert.equal(requireMailboxLabel(" 财务邮箱 · finance@corp.com "), "财务邮箱 · finance@corp.com"); + + for (const value of [undefined, null, "", " "]) { + assert.throws( + () => requireMailboxLabel(value), + (error: Error) => error.message === MAILBOX_LABEL_REQUIRED, + `${JSON.stringify(value ?? null)} 应抛错` + ); + } +}); + +test("A.5 storedMailboxLabel: 展示层缺失时返回 null,由调用方显示「未配置」", () => { + assert.equal(storedMailboxLabel("销售部邮箱 · sales@corp.com"), "销售部邮箱 · sales@corp.com"); + assert.equal(storedMailboxLabel(" 财务邮箱 "), "财务邮箱"); + + for (const value of [undefined, null, "", " "]) { + assert.equal(storedMailboxLabel(value), null, `${JSON.stringify(value ?? null)} 应返回 null`); + } +}); + +test("mailboxGroupKey: 分组键为 userId:mailboxId", () => { + assert.equal(mailboxGroupKey(3, 12), "3:12"); +}); + +test("mailboxGroupKey: 不再出现 mailbox: 字符串编码", () => { + assert.equal(mailboxGroupKey(3, 12).includes("mailbox:"), false); +}); + +test("mailboxGroupKey: 不同用户或不同邮箱不会落进同一分组", () => { + const keys = new Set([mailboxGroupKey(1, 2), mailboxGroupKey(1, 3), mailboxGroupKey(2, 2)]); + assert.equal(keys.size, 3); +}); + +// ── 模块 B / D.7:向导邮箱下拉的选择与展示派生 ────────────────────────── + +test("mailboxSelectValue: 选中已保存邮箱时下拉落在该 id 上", () => { + assert.equal(mailboxSelectValue(12, false), "12"); + assert.equal(mailboxSelectValue(12, true), MAILBOX_NEW_OPTION); +}); + +test("mailboxSelectValue: 未选择邮箱或正在配置新邮箱时落在「+ 配置新邮箱…」", () => { + assert.equal(mailboxSelectValue(null, false), MAILBOX_NEW_OPTION); + assert.equal(mailboxSelectValue(null, true), MAILBOX_NEW_OPTION); + assert.equal(mailboxSelectValue(0, false), MAILBOX_NEW_OPTION); + assert.equal(mailboxSelectValue(1.5, false), MAILBOX_NEW_OPTION); +}); + +test("mailboxOptionLabel: 优先用接口 label,缺失时按 name · email 派生", () => { + assert.equal(mailboxOptionLabel({ id: 7, label: "销售部邮箱 · sales@corp.com" }), "销售部邮箱 · sales@corp.com"); + assert.equal(mailboxOptionLabel({ id: 8, name: "财务邮箱", email: "finance@corp.com" }), "财务邮箱 · finance@corp.com"); + assert.equal(mailboxOptionLabel({ id: 9, name: "finance@corp.com", email: "finance@corp.com" }), "finance@corp.com"); +}); + +const mailboxOptions: MailboxOption[] = [ + { id: 7, label: "销售部邮箱 · sales@corp.com", name: "销售部邮箱", email: "sales@corp.com" }, + { id: 8, label: "售后邮箱 · support@corp.com", name: "售后邮箱", email: "support@corp.com" }, +]; + +test("D.7 selectMailboxScopedAutomations: 只返回同一监听邮箱下正在运行的邮件自动化", () => { + const scoped = selectMailboxScopedAutomations( + [ + { id: 1, trigger: "邮件触发", status: "running", mailboxId: 7 }, + { id: 2, trigger: "邮件触发", status: "running", mailboxId: 8 }, + { id: 3, trigger: "邮件触发", status: "paused", mailboxId: 7 }, + { id: 4, trigger: "定时触发", status: "running", mailboxId: 7 }, + { id: 5, trigger: "邮件触发", status: "running", mailboxId: 7 }, + ], + { mailboxId: 7 }, + ); + + assert.deepEqual(scoped.map((item) => item.id), [1, 5]); +}); + +test("D.7 selectMailboxScopedAutomations: 不同邮箱的自动化互不算冲突", () => { + const items = [ + { id: 1, trigger: "邮件触发", status: "running", mailboxId: 7 }, + { id: 2, trigger: "邮件触发", status: "running", mailboxId: 8 }, + ]; + + assert.deepEqual(selectMailboxScopedAutomations(items, { mailboxId: 7 }).map((item) => item.id), [1]); + assert.deepEqual(selectMailboxScopedAutomations(items, { mailboxId: 8 }).map((item) => item.id), [2]); +}); + +test("D.7 selectMailboxScopedAutomations: 未选择邮箱时没有候选", () => { + const items = [{ id: 1, trigger: "邮件触发", status: "running", mailboxId: 7 }]; + + assert.deepEqual(selectMailboxScopedAutomations(items, { mailboxId: null }), []); + assert.deepEqual(selectMailboxScopedAutomations(items, { mailboxId: 0 }), []); +}); + +test("D.7 selectMailboxScopedAutomations: 遗留行(无整数 mailboxId)不参与任何分组", () => { + const scoped = selectMailboxScopedAutomations( + [ + { id: 1, trigger: "邮件触发", status: "running", mailboxId: null }, + { id: 2, trigger: "邮件触发", status: "running" }, + { id: 3, trigger: "邮件触发", status: "running", mailboxId: 7 }, + ], + { mailboxId: 7 }, + ); + + assert.deepEqual(scoped.map((item) => item.id), [3]); +}); + +test("D.7 selectMailboxScopedAutomations: 正在编辑的自动化自身不进入候选", () => { + const items = [ + { id: 1, trigger: "邮件触发", status: "running", mailboxId: 7 }, + { id: 2, trigger: "邮件触发", status: "running", mailboxId: 7 }, + ]; + + assert.deepEqual(selectMailboxScopedAutomations(items, { mailboxId: 7, excludeId: 2 }).map((item) => item.id), [1]); + assert.deepEqual(selectMailboxScopedAutomations(items, { mailboxId: 7, excludeId: null }).map((item) => item.id), [1, 2]); +}); + +test("automationMailboxLabel: 按 id 现查名单,邮箱改名后展示名同步更新", () => { + assert.equal( + automationMailboxLabel({ mailboxId: 7, mailboxLabel: "旧名字 · sales@corp.com" }, mailboxOptions), + "销售部邮箱 · sales@corp.com", + ); +}); + +test("automationMailboxLabel: 名单里查不到时退回服务端派生的存储名", () => { + assert.equal( + automationMailboxLabel({ mailboxId: 7, mailboxLabel: "销售部邮箱 · sales@corp.com" }, []), + "销售部邮箱 · sales@corp.com", + ); +}); + +test("automationMailboxLabel: 遗留行不再回退到「系统邮箱」", () => { + // 遗留行没有整数 mailboxId;它旧的存储名(哪怕正是"系统邮箱")也不作为展示来源。 + assert.equal(automationMailboxLabel({ mailboxId: null, mailboxLabel: "系统邮箱" }, mailboxOptions), null); + assert.equal(automationMailboxLabel({ mailboxId: undefined }, mailboxOptions), null); + assert.equal(automationMailboxLabel({ mailboxId: 0 }, mailboxOptions), null); + assert.equal(automationMailboxLabel({ mailboxId: 7 }, mailboxOptions), "销售部邮箱 · sales@corp.com"); +}); + +// ── 名单加载后的默认选择与"查不到"判定(评审修复:绝不静默改绑) ────────── + +test("defaultMailboxSelection: 还没有选择时才用名单第一条", () => { + assert.equal(defaultMailboxSelection(null, mailboxOptions), 7); + assert.equal(defaultMailboxSelection(null, []), null); +}); + +test("defaultMailboxSelection: 已有选择一律原样保留,即使名单里查不到", () => { + // 回归:老实现在这里会返回名单第一条(7),把自动化静默改绑到另一个收件箱。 + assert.equal(defaultMailboxSelection(99, mailboxOptions), 99); + assert.equal(defaultMailboxSelection(99, []), 99); + assert.equal(defaultMailboxSelection(8, mailboxOptions), 8); +}); + +test("defaultMailboxSelection: 非法选择按未选择处理", () => { + assert.equal(defaultMailboxSelection(0, mailboxOptions), 7); + assert.equal(defaultMailboxSelection(1.5, mailboxOptions), 7); +}); + +test("isMailboxSelectionUnresolved: 名单加载完且查不到该邮箱时判定为未解析", () => { + assert.equal(isMailboxSelectionUnresolved(99, mailboxOptions, true), true); + // 名单拉取失败(已加载但为空)时同样判为未解析:此时无法确认选择,必须让用户重选。 + assert.equal(isMailboxSelectionUnresolved(99, [], true), true); +}); + +test("isMailboxSelectionUnresolved: 名单里存在该邮箱时不算未解析", () => { + assert.equal(isMailboxSelectionUnresolved(7, mailboxOptions, true), false); + assert.equal(isMailboxSelectionUnresolved("7", mailboxOptions, true), false); +}); + +test("isMailboxSelectionUnresolved: 名单未加载完时一律不算未解析", () => { + // 加载窗口里"查不到"只说明数据还没到,据此展开表单会误伤正常用户。 + assert.equal(isMailboxSelectionUnresolved(99, [], false), false); + assert.equal(isMailboxSelectionUnresolved(99, mailboxOptions, false), false); +}); + +test("isMailboxSelectionUnresolved: 未选择或非法选择不算未解析", () => { + assert.equal(isMailboxSelectionUnresolved(null, mailboxOptions, true), false); + assert.equal(isMailboxSelectionUnresolved(0, mailboxOptions, true), false); + assert.equal(isMailboxSelectionUnresolved(undefined as unknown as number | null, mailboxOptions, true), false); +}); diff --git a/test/automationMailboxOwnership.test.ts b/test/automationMailboxOwnership.test.ts new file mode 100644 index 0000000..0bcd989 --- /dev/null +++ b/test/automationMailboxOwnership.test.ts @@ -0,0 +1,95 @@ +/** + * 模块 D.2「监听邮箱归属校验」的源码守卫。 + * + * 归属校验是这条链路上唯一的安全边界:没有它,客户端可以把自动化指向**别人的**监听邮箱, + * 此后调度器就会用那个邮箱的凭据去读信、并按配置回信。它依赖 `lib/db`,测试进程连不了 + * 数据库(也不 import `@/lib`),因此这里钉住的是"哪几处必须在"这个结构事实——与 + * test/mailboxCursorSql.test.ts、test/automationSystemMailboxRetired.test.ts 同一手法: + * + * - `store.ts` 的创建(createAutomation)与更新(updateAutomation)邮件分支各**恰好** + * 调用一次 `requireOwnedMailbox`:少一处就是一个可以写入他人邮箱 id 的入口; + * - 取不到邮箱时抛 `MAILBOX_NOT_OWNED`,不是放行、也不是回退到某个默认邮箱; + * - 归属判断本身仍按 `id + created_by_user_id` 过滤:漏掉用户条件(或把两个占位符的 + * 顺序写反)会让校验退化成"这个 id 存在即可",看起来仍然在跑,实际等于没有校验。 + */ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { test } from "node:test"; + +const STORE = join(process.cwd(), "lib/automation/store.ts"); +const MAILBOXES = join(process.cwd(), "lib/automation/mailboxes.ts"); + +/** 按花括号配对取出函数体(不依赖"它是文件里最后一个函数"这种脆弱前提)。 */ +function functionBody(source: string, name: string): string { + const start = source.indexOf(`function ${name}(`); + assert.ok(start >= 0, `找不到函数 ${name}`); + const braceStart = source.indexOf("{", start); + let depth = 0; + for (let index = braceStart; index < source.length; index += 1) { + if (source[index] === "{") depth += 1; + else if (source[index] === "}") { + depth -= 1; + if (depth === 0) return source.slice(braceStart, index + 1); + } + } + throw new Error(`函数 ${name} 的花括号不配对`); +} + +/** 只取邮件触发分支:从 `triggerType === "邮件触发"` 到下一个 `} else if (` 为止。 */ +function emailBranch(source: string, functionName: string): string { + const body = functionBody(source, functionName); + const start = body.indexOf(`triggerType === "邮件触发"`); + assert.ok(start >= 0, `${functionName} 里找不到邮件触发分支`); + + const branch = body.slice(start); + const next = branch.search(/[\r\n]\s*\} else if \(/); + return next >= 0 ? branch.slice(0, next) : branch; +} + +test("D.2 创建与更新两条邮件分支都校验邮箱归属", () => { + const source = readFileSync(STORE, "utf8"); + + for (const [label, functionName] of [ + ["创建", "createAutomation"], + ["更新", "updateAutomation"], + ] as const) { + const calls = [...emailBranch(source, functionName).matchAll(/await\s+requireOwnedMailbox\(/g)]; + assert.equal( + calls.length, + 1, + `${label}分支(${functionName})应当恰好调用一次 requireOwnedMailbox,实际 ${calls.length} 次 —— ` + + "漏掉就等于允许把自动化指向别人的监听邮箱" + ); + } +}); + +test("D.2 归属校验失败时抛 MAILBOX_NOT_OWNED,不放行也不回退默认邮箱", () => { + const body = functionBody(readFileSync(STORE, "utf8"), "requireOwnedMailbox"); + + assert.match( + body, + /if\s*\(\s*!mailbox\s*\)\s*throw new Error\(MAILBOX_NOT_OWNED\)/, + "邮箱不存在或不属于该用户时必须抛错(两种情况都按 404 处理,不泄露他人资源的存在性)" + ); + assert.match( + body, + /getAutomationMailboxForUser\(userId,\s*requireMailboxId\(value\)\)/, + "邮箱 id 必须先经 requireMailboxId 归一化:遗留的字符串键与已下线的「system」都会在这里被拒" + ); +}); + +test("D.2 归属谓词仍按 id + created_by_user_id 过滤", () => { + const predicate = functionBody(readFileSync(MAILBOXES, "utf8"), "getAutomationMailboxForUser"); + + assert.match( + predicate, + /WHERE\s+id\s*=\s*\$1\s+AND\s+created_by_user_id\s*=\s*\$2/i, + "漏掉 created_by_user_id(或写反 $1/$2)会让归属校验退化成「这个 id 存在即可」" + ); + assert.match( + predicate, + /\[\s*mailboxId\s*,\s*userId\s*\]/, + "参数顺序必须是 [mailboxId, userId]:$1 是邮箱 id,$2 是当前用户" + ); +}); diff --git a/test/automationSchemaFile.test.ts b/test/automationSchemaFile.test.ts new file mode 100644 index 0000000..211d67f --- /dev/null +++ b/test/automationSchemaFile.test.ts @@ -0,0 +1,139 @@ +/** + * 自动化表结构的一致性守卫。 + * + * 应用进程不执行 DDL。这个改动换来一个静默的失效模式:校验清单与实际查询表名 + * 分属不同模块,谁都不会因为另一方改动而自动报错。 + * + * 1. `lib/automation/schema.ts` —— 启动校验用的清单 AUTOMATION_TABLES + * 2. `lib/` 下各模块里的 FROM/JOIN/INTO/UPDATE —— 代码真正查的表 + * + * 两者漂移的后果都不是类型错误: + * - 清单多一张表 → 每次启动都因校验失败,整个自动化瘫痪; + * - 代码查了清单里没有的表 → 校验永远发现不了它缺失。 + * + * 另一条守卫是"自动化模块里不得再出现 DDL":这次改造的全部价值就在于此, + * 而它同样不会被任何类型检查发现 —— 谁随手写一句 CREATE TABLE IF NOT EXISTS, + * 应用就又开始在建表了。 + * + * 手法与其他源码守卫一致:钉源码文本(这些模块依赖 + * `lib/db`,本套件不连数据库、也不 import `@/lib`,没有可执行的断言入口)。 + */ +import assert from "node:assert/strict"; +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { test } from "node:test"; + +const SCHEMA_MODULE = join(process.cwd(), "lib/automation/schema.ts"); + +/** 去掉注释:注释里提到表名是允许的(也确实需要),能断言的只有可执行的那一份。 */ +function withoutComments(source: string) { + return source + .replace(/\/\*[\s\S]*?\*\//g, "") + .replace(/^[ \t]*\/\/.*$/gm, "") + .replace(/^[ \t]*--.*$/gm, ""); +} + +/** 递归收集某个源码目录下的全部 .ts/.tsx(不跟随符号链接,也不会碰到构建产物目录)。 */ +function sourceFilesUnder(root: string): string[] { + const collected: string[] = []; + + for (const entry of readdirSync(join(process.cwd(), root), { withFileTypes: true })) { + const relative = join(root, entry.name); + if (entry.isDirectory()) collected.push(...sourceFilesUnder(relative)); + else if (/\.tsx?$/.test(entry.name)) collected.push(join(process.cwd(), relative)); + } + + return collected; +} + +/** lib/automation/schema.ts 里 AUTOMATION_TABLES 声明的表名(启动校验清单)。 */ +function tablesDeclaredInModule(): string[] { + const source = readFileSync(SCHEMA_MODULE, "utf8"); + const array = source.match(/AUTOMATION_TABLES\s*=\s*\[([\s\S]*?)\]/); + assert.ok(array, "lib/automation/schema.ts 里找不到 AUTOMATION_TABLES 数组"); + + return [...array[1].matchAll(/"([a-z_][a-z0-9_]*)"/g)].map((match) => match[1]).sort(); +} + +/** 代码里真正查询过的 automation_* 表名(FROM / JOIN / INTO / UPDATE 后面那个词)。 */ +function tablesUsedInCode(): string[] { + const found = new Set(); + + for (const root of ["lib", "pages", "app"]) { + for (const file of sourceFilesUnder(root)) { + const source = withoutComments(readFileSync(file, "utf8")); + for (const match of source.matchAll( + /\b(?:FROM|JOIN|INTO|UPDATE)\s+(automation_[a-z0-9_]*)/gi + )) { + found.add(match[1]); + } + } + } + + return [...found].sort(); +} + +test("校验清单与代码实际查询的表名一致", () => { + const inModule = tablesDeclaredInModule(); + const inCode = tablesUsedInCode(); + + // 先确认两边都非空:任何一边的解析写错了,下面的相等断言都会退化成"空 == 空"而永远通过。 + assert.ok(inModule.length > 0, "校验清单解析为空 —— 扫描规则写错了"); + assert.ok(inCode.length > 0, "没有扫到任何查询 automation_* 的语句 —— 扫描规则写错了"); + + // 清单多表会使自动化无法启用,代码查了清单外的表则无法提前发现缺失。 + assert.deepEqual( + inCode, + inModule, + "代码查询的表与校验清单不一致:清单少表则缺表时静默通过,多表则启动即抛错" + ); +}); + +test("自动化模块里不再有任何 DDL", () => { + const roots = ["lib/automation"]; + const extra = [join(process.cwd(), "lib/cron/automation-scheduler.ts")]; + const offenders: string[] = []; + + for (const file of roots.flatMap(sourceFilesUnder).concat(extra)) { + const source = withoutComments(readFileSync(file, "utf8")); + for (const match of source.matchAll( + /\b(CREATE\s+TABLE|CREATE\s+INDEX|ALTER\s+TABLE|DROP\s+TABLE)\b/gi + )) { + offenders.push(`${file}: ${match[1]}`); + } + } + + assert.deepEqual( + offenders, + [], + `应用进程不得建表或改表:\n${offenders.join("\n")}` + ); +}); + +test("claim 的 ON CONFLICT 目标是四列,与唯一键一致", () => { + const source = withoutComments( + readFileSync(join(process.cwd(), "lib/automation/store.ts"), "utf8") + ); + const body = source.slice(source.indexOf("export async function claimAutomationEmailMessage")); + + assert.match( + body.slice(0, 1200), + /ON CONFLICT \(created_by_user_id, mailbox_id, message_key, automation_id\)/i, + "ON CONFLICT 目标必须与唯一键的四列完全一致" + ); +}); + +test("自动化模块里不再出现优先级 / 胜出者概念", () => { + const offenders: string[] = []; + for (const root of ["lib/automation", "lib/cron"]) { + for (const file of sourceFilesUnder(root)) { + const source = withoutComments(readFileSync(file, "utf8")); + for (const match of source.matchAll( + /suppressed_by_priority|winnerAutomationId|normalizeEmailPriority|mailPriority/g + )) { + offenders.push(`${file}: ${match[0]}`); + } + } + } + assert.deepEqual(offenders, [], `优先级与胜出者概念应已彻底移除:\n${offenders.join("\n")}`); +}); diff --git a/test/automationSystemMailboxRetired.test.ts b/test/automationSystemMailboxRetired.test.ts new file mode 100644 index 0000000..6e1a926 --- /dev/null +++ b/test/automationSystemMailboxRetired.test.ts @@ -0,0 +1,108 @@ +/** + * 模块 A(系统邮箱下线)的源码守卫。 + * + * 为什么读源码文本而不是跑函数:这些代码依赖 `lib/db`,测试进程不连数据库 + * (也不 import `@/lib`),没有可执行的断言入口。 + * 而这些恰好是最容易悄悄回退的地方: + * + * - 防循环判断(模块 A.6)是明确的保留项——结果邮件可能从用户自己的邮箱发出, + * 最容易被下一次"清理系统邮箱代码"顺手删掉; + * - `||` / `??` 兜底一旦有一处冒回来,就是静默落回已下线分支的入口。这一条按**目录**扫描 + * `lib/`、`app/`、`pages/` 的全部源码,而不是一份手写文件清单:清单漏掉的那个文件, + * 正是下一次兜底会冒出来的地方。 + * + * 与 test/mailboxCursorSql.test.ts 同一手法:钉住那一行的内容,而不是验证执行结果 + * (执行行为已在抛弃库上做过集成验证,但那不可提交、因此没有回归价值)。 + */ +import assert from "node:assert/strict"; +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { test } from "node:test"; + +const SCHEDULER = join(process.cwd(), "lib/cron/automation-scheduler.ts"); +const AUTOMATIONS_INDEX = join(process.cwd(), "pages/api/v1/automations/index.ts"); +const AUTOMATIONS_ID = join(process.cwd(), "pages/api/v1/automations/[id].ts"); + +/** 去掉注释:注释里提到旧值/旧格式是允许的(也确实是需要的),可执行代码里不允许。 */ +function withoutComments(source: string) { + return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^[ \t]*\/\/.*$/gm, ""); +} + +/** 按花括号配对取出函数体(不依赖"它是文件里最后一个函数"这种脆弱前提)。 */ +function functionBody(source: string, name: string): string { + const start = source.indexOf(`function ${name}(`); + assert.ok(start >= 0, `找不到函数 ${name}`); + const braceStart = source.indexOf("{", start); + let depth = 0; + for (let index = braceStart; index < source.length; index += 1) { + if (source[index] === "{") depth += 1; + else if (source[index] === "}") { + depth -= 1; + if (depth === 0) return source.slice(braceStart, index + 1); + } + } + throw new Error(`函数 ${name} 的花括号不配对`); +} + +test("A.6 防循环判断保留:自己发出的结果邮件不得再次触发", () => { + const body = functionBody(readFileSync(SCHEDULER, "utf8"), "processEmailMailboxGroup"); + + for (const prefix of ["自动化执行结果:", "[AI对话]"]) { + assert.ok( + body.includes(`!subject.startsWith("${prefix}")`), + `防循环的主题前缀判断「${prefix}」被删了:结果邮件可能从用户自己的监听邮箱发出` + ); + } +}); + +/** 递归收集某个源码目录下的全部 .ts/.tsx(不跟随符号链接,也不会碰到构建产物目录)。 */ +function sourceFilesUnder(root: string): string[] { + const collected: string[] = []; + + for (const entry of readdirSync(join(process.cwd(), root), { withFileTypes: true })) { + const relative = join(root, entry.name); + if (entry.isDirectory()) collected.push(...sourceFilesUnder(relative)); + else if (/\.tsx?$/.test(entry.name)) collected.push(join(process.cwd(), relative)); + } + + return collected; +} + +test("§十一 不再有 system / 系统邮箱 的兜底值(|| 与 ?? 皆算,全仓库扫描)", () => { + // 这一条是验收标准里"不再存在任何 system 分支"的自动兜底,因此它必须覆盖**全部**业务源码, + // 而不是一份手写文件清单——清单漏掉的那个文件,正是下一次兜底冒出来的地方。`??` 与 `||` + // 同样致命:`config.mailboxLabel ?? "系统邮箱"` 就是一个静默落回已下线分支的入口。 + const offenders: string[] = []; + + for (const root of ["lib", "app", "pages"]) { + const files = sourceFilesUnder(root); + assert.ok( + files.length > 0, + `没有扫到 ${root}/ 下的任何源文件:扫描范围写错了,这条守卫会变成空转` + ); + + for (const file of files) { + const source = withoutComments(readFileSync(file, "utf8")); + const match = source.match(/(?:\|\||\?\?)\s*["'`](?:system|系统邮箱)["'`]/); + if (match) offenders.push(`${file}: ${match[0]}`); + } + } + + assert.deepEqual( + offenders, + [], + `这些地方仍有兜底值:\n${offenders.join("\n")}\n兜底必须改为显式校验(抛错或显示「未配置」)` + ); +}); + +test("A.4 创建/更新被拒时返回的是专门文案,不是通用校验失败", () => { + for (const file of [AUTOMATIONS_INDEX, AUTOMATIONS_ID]) { + const source = readFileSync(file, "utf8"); + const start = source.indexOf("MAILBOX_SYSTEM_RETIRED"); + assert.ok(start >= 0, `${file} 没有处理 MAILBOX_SYSTEM_RETIRED`); + assert.ok( + source.slice(start, start + 200).includes("系统邮箱已下线,请配置监听邮箱"), + `${file} 对 MAILBOX_SYSTEM_RETIRED 没有返回约定文案` + ); + } +}); diff --git a/test/imapClient.test.ts b/test/imapClient.test.ts new file mode 100644 index 0000000..1719bf3 --- /dev/null +++ b/test/imapClient.test.ts @@ -0,0 +1,519 @@ +/** + * IMAP 收信的纯逻辑守卫(收信链路现在实现于 ragent 进程内,不再打 ragent-service)。 + * + * 这里钉住的是移植过程中最容易写反、而写反之后**不会报错、只会静默错**的几处: + * + * - `latest_uid` 是「文件夹当前最大 UID」(空文件夹为 0),**不是**「本批返回里最大的那个」。 + * 取小了,下次轮询就会把历史邮件重新处理一遍;首次运行时调用方正是拿它建基线的。 + * - 一批取「游标之后**最早**的 20 封」。取最新 20 封时,逐封推进的游标会一次跳过中间 + * 所有邮件,而这些邮件再也不会被读到——静默丢信(`patch_backend_mail_batch.py` 修的就是它)。 + * - 正文纯文本优先、HTML 兜底:HTML-only 邮件若被当成纯文本(或反过来把 HTML 源码 + * 当成纯文本返回),`开头是`/`等于` 类规则会拿标签去匹配。 + * + * 本文件不连网、不连库:纯函数直接喂参数,解析类用构造出来的 RFC822 报文喂 `mailparser`。 + */ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { test } from "node:test"; +import { + ATTACHMENT_MAX_FILE_BYTES, + IMAP_MESSAGE_BATCH_SIZE, + IMAP_TIMEOUT_MS, + extractAttachmentNames, + extractBody, + imapFailureMessage, + parseAttachmentFiles, + parseInboxMessage, + planMailboxFetch, + rawHeaderValue, + splitAttachmentsBySize, + toAttachmentFiles, + toInboxMessage, + uidsFromSearchResult, +} from "../lib/automation/imap-client.ts"; + +const IMAP_CLIENT = join(process.cwd(), "lib/automation/imap-client.ts"); + +/** + * 去掉注释后再断言源码:注释里会**引用**被禁止的写法(正是那些注释在解释为什么不能那么写), + * 不剥掉的话守卫会被自己的说明文字满足——`readOnly: true` 就同时出现在文档注释里。 + */ +function withoutComments(source: string): string { + return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^[ \t]*\/\/.*$/gm, ""); +} + +/** 拼一封原始报文:头部 + 空行 + 正文,用 CRLF(与真实 SMTP 一致)。 */ +function rawMessage(headers: string[], body: string): Buffer { + return Buffer.from([...headers, "", body].join("\r\n"), "utf8"); +} + +const PLAIN_ONLY = rawMessage( + [ + "From: =?utf-8?B?5byg5LiJ?= ", + "To: Bot ", + "Subject: 月度报表", + "Message-ID: ", + "Date: Mon, 1 Sep 2025 10:00:00 +0800", + "Content-Type: text/plain; charset=utf-8", + ], + "纯文本正文" +); + +const HTML_ONLY = rawMessage( + ["From: a@corp.com", "Subject: html only", "Content-Type: text/html; charset=utf-8"], + "

Hello World

" +); + +const ALTERNATIVE = rawMessage( + [ + "From: a@corp.com", + "Subject: alternative", + "MIME-Version: 1.0", + 'Content-Type: multipart/alternative; boundary="ALT"', + ], + [ + "--ALT", + "Content-Type: text/plain; charset=utf-8", + "", + "纯文本版本", + "--ALT", + "Content-Type: text/html; charset=utf-8", + "", + "

HTML 版本

", + "--ALT--", + "", + ].join("\r\n") +); + +const MIXED = rawMessage( + [ + "From: a@corp.com", + "Subject: mixed", + "MIME-Version: 1.0", + 'Content-Type: multipart/mixed; boundary="MIX"', + ], + [ + "--MIX", + "Content-Type: text/plain; charset=utf-8", + "", + "第一段正文", + "--MIX", + "Content-Type: text/plain; charset=utf-8", + "", + "第二段正文", + "--MIX", + // 带 attachment 处置的纯文本 part:是附件,不能进正文。 + "Content-Type: text/plain; charset=utf-8", + 'Content-Disposition: attachment; filename="notes.txt"', + "", + "这段是附件正文,不该出现在正文里", + "--MIX", + // inline 但带 filename:也要算附件。 + 'Content-Type: image/png; name="inline.png"', + "Content-Transfer-Encoding: base64", + 'Content-Disposition: inline; filename="inline.png"', + "", + "aGVsbG8=", + "--MIX", + // 有 Content-ID 但没有 filename 的裸图片:不算附件。 + "Content-Type: image/gif", + "Content-Transfer-Encoding: base64", + "Content-ID: ", + "", + "R0lGODlh", + "--MIX", + // 文件名是 RFC2047 编码的:必须解码后再返回。 + "Content-Type: application/pdf", + "Content-Transfer-Encoding: base64", + 'Content-Disposition: attachment; filename="=?utf-8?B?5oql5ZGKLnBkZg==?="', + "", + "aGVsbG8=", + "--MIX--", + "", + ].join("\r\n") +); + +const BARE = rawMessage(["Content-Type: text/plain; charset=us-ascii"], "没有头部的一封信"); + +/** + * 两封"带文件名、但 `mailparser` 不把它当附件"的报文。参考实现(Python `msg.walk()` + + * `part.get_filename()`)会报出这些名字,本实现不会——见 `extractAttachmentNames` 的说明。 + * 下面两例断言的是**当前、已被记录的**行为,不是期望行为:改判定就会翻红。 + */ +const INLINE_NAMED = rawMessage( + [ + "From: a@corp.com", + "Subject: inline named", + "MIME-Version: 1.0", + 'Content-Type: multipart/mixed; boundary="NAMED"', + ], + [ + "--NAMED", + "Content-Type: text/plain; charset=utf-8", + "", + "正文", + "--NAMED", + // 带 filename 的 inline 正文 part:mailparser 判为正文,不报成附件。 + "Content-Type: text/html; charset=utf-8", + 'Content-Disposition: inline; filename="page.html"', + "", + "

名义上的附件

", + "--NAMED", + // 只有 Content-Type 的 name 参数、没有 disposition:Python 的 get_filename() 会兜底到它。 + 'Content-Type: text/plain; charset=utf-8; name="notes.txt"', + "", + "第二段正文", + "--NAMED--", + "", + ].join("\r\n") +); + +const FORWARDED = rawMessage( + [ + "From: a@corp.com", + "Subject: forwarded", + "MIME-Version: 1.0", + 'Content-Type: multipart/mixed; boundary="FWD"', + ], + [ + "--FWD", + "Content-Type: text/plain; charset=utf-8", + "", + "请看转发", + "--FWD", + // 转发邮件(容器无 disposition → 不透明):内层的 inner.pdf 看不到;容器也没有 filename, + // 于是整条被 filename 过滤掉。"不透明"来自 mailsplit 只对 inline 的 message/rfc822 分叉。 + "Content-Type: message/rfc822", + "", + "From: inner@corp.com", + "Subject: inner", + "MIME-Version: 1.0", + 'Content-Type: multipart/mixed; boundary="INNER"', + "", + "--INNER", + "Content-Type: text/plain", + "", + "内层正文", + "--INNER", + "Content-Type: application/pdf", + "Content-Transfer-Encoding: base64", + 'Content-Disposition: attachment; filename="inner.pdf"', + "", + "aGVsbG8=", + "--INNER--", + "", + "--FWD", + // 容器自己带 filename(attachment → 依然不透明):能看到的就是这一层。 + "Content-Type: message/rfc822", + 'Content-Disposition: attachment; filename="forwarded.eml"', + "", + "From: inner2@corp.com", + "Subject: inner2", + "", + "内层正文二", + "--FWD--", + "", + ].join("\r\n") +); + +test("latest_uid 取文件夹当前最大 UID,空文件夹为 0", () => { + assert.equal(planMailboxFetch([]).latestUid, 0); + assert.equal(planMailboxFetch([3, 9, 7]).latestUid, 9); + assert.equal(planMailboxFetch([3, 9, 7], 4).latestUid, 9); +}); + +test("首次轮询只建基线:不传 afterUid 时给 latest_uid、一封都不取", () => { + const plan = planMailboxFetch([5, 6, 7]); + + // 返回 0 会让下次轮询把历史邮件全部重放;返回批次里的最大值则漏掉基线之上的那部分。 + assert.equal(plan.latestUid, 7); + assert.deepEqual(plan.targetUids, []); +}); + +test("afterUid 为空值(undefined / null)与不传等价", () => { + for (const value of [undefined, null]) { + assert.equal(planMailboxFetch([5, 6, 7], value).latestUid, 7); + assert.deepEqual(planMailboxFetch([5, 6, 7], value).targetUids, []); + } +}); + +test("一批取游标之后最早的 20 封,不是最新 20 封", () => { + assert.equal(IMAP_MESSAGE_BATCH_SIZE, 20); + + const uids = Array.from({ length: 25 }, (_, index) => index + 1); + assert.deepEqual( + planMailboxFetch(uids, 0).targetUids, + Array.from({ length: 20 }, (_, index) => index + 1) + ); + + // 积压 25 封、游标停在 3:这一批必须是 4..23,剩下的 24、25 留给下一批。 + assert.deepEqual( + planMailboxFetch(uids, 3).targetUids, + Array.from({ length: 20 }, (_, index) => index + 4) + ); +}); + +test("search 没返回数组时直接失败,绝不当作空文件夹", () => { + // 当作空文件夹 → 基线调用写下游标 0 → 下次轮询把整个邮箱的历史重放一遍。 + assert.throws(() => uidsFromSearchResult(false), /IMAP/); + assert.throws(() => uidsFromSearchResult(undefined), /IMAP/); + assert.throws(() => uidsFromSearchResult({}), /IMAP/); + + assert.deepEqual(uidsFromSearchResult([]), []); + assert.deepEqual(uidsFromSearchResult([3, 1]), [3, 1]); + // imapflow 给的是数字;字符串型 UID 也要能用(顺带钉住 map(Number))。 + assert.deepEqual(uidsFromSearchResult(["7"]), [7]); +}); + +test("只取 uid 大于游标的邮件,并按升序返回", () => { + assert.deepEqual(planMailboxFetch([9, 4, 7, 5], 5).targetUids, [7, 9]); + assert.deepEqual(planMailboxFetch([2, 2, 3], 1).targetUids, [2, 2, 3]); +}); + +test("正文纯文本优先:同一封邮件里同时有纯文本与 HTML 时取纯文本", () => { + assert.equal(extractBody({ text: "纯文本正文", html: "

HTML 正文

" }), "纯文本正文"); +}); + +test("HTML-only 邮件回落到 HTML 源码(而不是被转换过的文本)", async () => { + const message = await parseInboxMessage(1, HTML_ONLY); + assert.equal(message.body, "

Hello World

"); +}); + +test("正文:没有可读 part 时是空串,不是 undefined", () => { + assert.equal(extractBody({}), ""); + assert.equal(extractBody({ text: " ", html: "" }), ""); + assert.equal(extractBody({ text: undefined, html: undefined }), ""); +}); + +test("解析真实报文:跳过附件 part,多个纯文本 part 用换行连接", async () => { + const message = await parseInboxMessage(11, MIXED); + + assert.equal(message.body, "第一段正文\n第二段正文"); +}); + +test("解析真实报文:multipart/alternative 取纯文本那一支", async () => { + const message = await parseInboxMessage(12, ALTERNATIVE); + assert.equal(message.body, "纯文本版本"); +}); + +test("附件名提取:任何带 filename 的 part 都算(inline 也算),没有 filename 的不算", async () => { + const message = await parseInboxMessage(11, MIXED); + + assert.deepEqual(message.attachments, ["notes.txt", "inline.png", "报告.pdf"]); +}); + +test("附件名提取的兜底:只有非空字符串文件名才算数", () => { + assert.deepEqual( + extractAttachmentNames({ + attachments: [{ filename: "报表.pdf" }, { contentType: "image/gif" }, { filename: "" }], + }), + ["报表.pdf"] + ); + assert.deepEqual(extractAttachmentNames({ attachments: undefined }), []); + assert.deepEqual(extractAttachmentNames({}), []); +}); + +test("附件内容提取:文件名、字节、类型、大小都对得上,且与附件名同序", async () => { + const files = await parseAttachmentFiles(MIXED); + + // 与 extractAttachmentNames 完全同名同序——两者判定一旦分叉,规则命中的附件 + // 与实际传给数字员工的附件就会对不上。 + assert.deepEqual( + files.map((file) => file.filename), + ["notes.txt", "inline.png", "报告.pdf"] + ); + + assert.equal(files[0].content.toString("utf8"), "这段是附件正文,不该出现在正文里"); + assert.equal(files[0].contentType, "text/plain"); + assert.equal(files[0].size, Buffer.byteLength("这段是附件正文,不该出现在正文里")); + + // inline 图片:base64 "aGVsbG8=" 解码后是 hello + assert.equal(files[1].content.toString("utf8"), "hello"); + assert.equal(files[1].contentType, "image/png"); + assert.equal(files[1].size, 5); + + // 文件名是 RFC2047 编码的那个 pdf + assert.equal(files[2].filename, "报告.pdf"); + assert.equal(files[2].content.toString("utf8"), "hello"); +}); + +test("附件内容提取:拿不到字节的 part 直接跳过,不会留一个空壳", () => { + const files = toAttachmentFiles({ + attachments: [ + { filename: "有内容.pdf", content: Buffer.from("abc"), contentType: "application/pdf" }, + { filename: "没内容.pdf" }, + { filename: "" }, + { contentType: "image/gif", content: Buffer.from("x") }, + ], + }); + + assert.deepEqual( + files.map((file) => file.filename), + ["有内容.pdf"] + ); + assert.equal(files[0].size, 3); +}); + +test("附件内容提取:没有附件的邮件返回空数组", () => { + assert.deepEqual(toAttachmentFiles({}), []); + assert.deepEqual(toAttachmentFiles({ attachments: undefined }), []); +}); + +test("超限附件被挑出来而不是被丢掉:跳过的那些要能在提示词里点名", () => { + const files = [ + { filename: "小.xlsx", content: Buffer.alloc(10), contentType: "application/x", size: 10 }, + { + filename: "刚好到上限.xlsx", + content: Buffer.alloc(ATTACHMENT_MAX_FILE_BYTES), + contentType: "application/x", + size: ATTACHMENT_MAX_FILE_BYTES, + }, + { + filename: "超了.xlsx", + content: Buffer.alloc(ATTACHMENT_MAX_FILE_BYTES + 1), + contentType: "application/x", + size: ATTACHMENT_MAX_FILE_BYTES + 1, + }, + ]; + + const { accepted, skipped } = splitAttachmentsBySize(files); + + // 边界取「不超过」:恰好等于上限的要传,否则上限就比标称值小了一字节。 + assert.deepEqual( + accepted.map((file) => file.filename), + ["小.xlsx", "刚好到上限.xlsx"] + ); + assert.deepEqual( + skipped.map((file) => file.filename), + ["超了.xlsx"] + ); +}); + +test("上限可注入:便于测试与将来按部署调整", () => { + const files = [ + { filename: "a.pdf", content: Buffer.alloc(5), contentType: "application/pdf", size: 5 }, + { filename: "b.pdf", content: Buffer.alloc(50), contentType: "application/pdf", size: 50 }, + ]; + + const { accepted, skipped } = splitAttachmentsBySize(files, 10); + + assert.deepEqual( + accepted.map((file) => file.filename), + ["a.pdf"] + ); + assert.deepEqual( + skipped.map((file) => file.filename), + ["b.pdf"] + ); +}); + +test("被判成正文的 part 不计入附件(已记录的偏差,改判定即翻红)", async () => { + const message = await parseInboxMessage(21, INLINE_NAMED); + + assert.deepEqual(message.attachments, []); + // 顺带钉住"它们去哪了":两个 part 都没丢,只是归类不同。`name="notes.txt"` 那一段被当成 + // 正文,就在返回值里;`filename="page.html"` 那一段进了 HTML 分支——正文此刻取的是纯文本, + // 所以它不出现在返回值里,但它同样没有被算成附件。断言的是归类,不是空白字符。 + assert.match(message.body, /第二段正文/); + assert.doesNotMatch(message.body, /名义上的附件/); +}); + +test("不带 disposition 的转发邮件内层不可见,容器带 filename 时才报容器(已记录的偏差)", async () => { + const message = await parseInboxMessage(22, FORWARDED); + + // 这里两段容器都**不透明**(第一段无 disposition、第二段是 attachment),所以内层的 + // inner.pdf 看不到、参考实现的 msg.walk() 会看到。注意结论随容器的 disposition 而变: + // `Content-Disposition: inline` 的容器是透明的(内层会报出来、容器自己的名字反而丢掉), + // 本夹具没有覆盖那一种——判定规则见 extractAttachmentNames 的注释。 + assert.deepEqual(message.attachments, ["forwarded.eml"]); +}); + +test("date 取原始头部文本(与参考实现一致),取不到时是空串", () => { + const headerLines = [{ key: "date", line: "Date: Mon, 1 Sep 2025 10:00:00 +0800" }]; + + assert.equal(rawHeaderValue(headerLines, "date"), "Mon, 1 Sep 2025 10:00:00 +0800"); + assert.equal(rawHeaderValue([{ key: "subject", line: "Subject: hi" }], "date"), ""); + assert.equal(rawHeaderValue([], "date"), ""); + assert.equal(rawHeaderValue(undefined, "date"), ""); +}); + +test("8 个字段恒存在:缺失的头部一律是空串,不是 undefined", async () => { + const message = await parseInboxMessage(42, BARE); + + assert.deepEqual(message, { + uid: 42, + message_id: "", + from: "", + to: "", + subject: "", + date: "", + body: "没有头部的一封信", + attachments: [], + }); +}); + +test("头部齐全时 8 个字段都带上解析后的值", async () => { + const message = await parseInboxMessage(7, PLAIN_ONLY); + + assert.deepEqual(message, { + uid: 7, + message_id: "", + from: '"张三" ', + to: '"Bot" ', + subject: "月度报表", + date: "Mon, 1 Sep 2025 10:00:00 +0800", + body: "纯文本正文", + attachments: [], + }); +}); + +test("规范化:解析结果为空对象时也只有空串,不会漏字段", () => { + assert.deepEqual(toInboxMessage(3, {}), { + uid: 3, + message_id: "", + from: "", + to: "", + subject: "", + date: "", + body: "", + attachments: [], + }); +}); + +test("失败文案统一带 IMAP 前缀,否则命中不了连接类错误映射", () => { + assert.equal( + imapFailureMessage(new Error("connect ECONNREFUSED 127.0.0.1:993")), + "IMAP 收件失败: connect ECONNREFUSED 127.0.0.1:993" + ); + assert.match(imapFailureMessage("boom"), /IMAP/); + assert.match(imapFailureMessage(undefined), /IMAP/); +}); + +test("文件夹只读打开:源码里不得出现任何写已读状态的调用", () => { + const source = withoutComments(readFileSync(IMAP_CLIENT, "utf8")); + + assert.match(source, /readOnly:\s*true/); + assert.doesNotMatch(source, /messageFlags(Add|Set|Remove)\(/); +}); + +test("连接超时 15 秒", () => { + const source = withoutComments(readFileSync(IMAP_CLIENT, "utf8")); + + assert.equal(IMAP_TIMEOUT_MS, 15_000); + assert.match(source, /connectionTimeout:\s*IMAP_TIMEOUT_MS/); + assert.match(source, /greetingTimeout:\s*IMAP_TIMEOUT_MS/); +}); + +test("latest_uid 来自文件夹 UID 全集,且在游标判断之前取好", () => { + const source = withoutComments(readFileSync(IMAP_CLIENT, "utf8")); + + const fromFolder = source.indexOf("latestUidFrom(uids)"); + const cursorCheck = source.indexOf("Number.isInteger(afterUid)"); + + assert.ok(fromFolder >= 0, "latest_uid 必须来自文件夹 UID 全集(uid search ALL)"); + assert.ok(cursorCheck >= 0, "找不到游标判断"); + assert.ok(fromFolder < cursorCheck, "latest_uid 必须先算出来,游标判断只决定本批取哪些"); + // 「本批取到了什么」不得反过来决定 latest_uid:首次轮询本批是空的,那样会回报 0。 + assert.doesNotMatch(source, /latestUid\s*=\s*targetUids/); +}); diff --git a/test/mail-rules.test.ts b/test/mail-rules.test.ts new file mode 100644 index 0000000..b7279f7 --- /dev/null +++ b/test/mail-rules.test.ts @@ -0,0 +1,472 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { test } from "node:test"; +import { + MAIL_RULE_FIELDS, + MAIL_RULE_OPERATORS, + doesMailRuleMatch, + doesMailRuleSetMatch, + extractMailSenderDomain, + mailAttachmentExtensions, + mailConflictLevel, + mailRuleSetsEqual, + mailRuleSource, + mailRulesBriefSummary, + mailRulesSummary, + mailRuleText, + normalizedMailRule, + type MailRuleMessage, + type MailTriggerRule, +} from "../lib/automation/mail-rules.ts"; + +const message: MailRuleMessage = { + from: "张三 ", + to: "sales@ragent.com", + subject: "【询价】服务器采购 2026 Q3", + body: "请报 10 台服务器的价格,详见附件。", + attachments: ["报价单.XLSX", "清单", "readme.md"], +}; + +const emptyMessage: MailRuleMessage = {}; + +function rule(field: string, operator: string, value?: string): MailTriggerRule { + return { field, operator, value }; +} + +// 除「是否包含附件」外的 7 个字段:它们的 source 由测试用例自行推导。 +const VALUE_FIELDS = [ + "发件人", + "发件人域名", + "收件人", + "邮件主题", + "邮件正文", + "附件名称", + "附件类型", +] as const; + +function sourceOf(field: string) { + return mailRuleSource(rule(field, "包含"), message).toLowerCase(); +} + +test("MAIL_RULE_FIELDS / MAIL_RULE_OPERATORS: 规范化清单", () => { + assert.deepEqual( + [...MAIL_RULE_FIELDS], + [ + "发件人", + "发件人域名", + "收件人", + "邮件主题", + "邮件正文", + "是否包含附件", + "附件名称", + "附件类型", + ] + ); + assert.deepEqual( + [...MAIL_RULE_OPERATORS], + ["等于", "包含", "不包含", "开头是", "结尾是", "是否存在"] + ); +}); + +test("服务端白名单与向导下拉同源:store.ts 从本模块的清单派生,不再内联一份", () => { + // 向导的下拉、提交、落库三处必须认同一份清单。store.ts 若再写一份字面量清单,新增字段就会 + // "向导里能选、能提交、写库时被 normalizeEmailRules 静默丢弃"——这正是 spec §十一 + // 「规则匹配逻辑单一来源,前后端行为不可能分叉」要防的分叉,而且没有任何报错入口。 + const source = readFileSync(join(process.cwd(), "lib/automation/store.ts"), "utf8"); + + assert.match( + source, + /new Set\(MAIL_RULE_FIELDS\)/, + "字段白名单应当直接取自 MAIL_RULE_FIELDS" + ); + assert.match( + source, + /new Set\(MAIL_RULE_OPERATORS\)/, + "操作符白名单应当直接取自 MAIL_RULE_OPERATORS" + ); + assert.doesNotMatch( + source, + /new Set\([^)]*"发件人"/, + "白名单里又出现了内联的字段字面量:它必然与向导下拉分叉" + ); + assert.doesNotMatch( + source, + /new Set\([^)]*"等于"/, + "白名单里又出现了内联的操作符字面量:它必然与向导下拉分叉" + ); +}); + +test("mailRuleSource: 各字段取值", () => { + assert.equal(mailRuleSource(rule("发件人", "包含"), message), "张三 "); + assert.equal(mailRuleSource(rule("发件人域名", "包含"), message), "example.com"); + assert.equal(mailRuleSource(rule("收件人", "包含"), message), "sales@ragent.com"); + assert.equal(mailRuleSource(rule("邮件主题", "包含"), message), "【询价】服务器采购 2026 Q3"); + assert.equal( + mailRuleSource(rule("邮件正文", "包含"), message), + "请报 10 台服务器的价格,详见附件。" + ); + assert.equal(mailRuleSource(rule("是否包含附件", "包含"), message), "是"); + assert.equal(mailRuleSource(rule("附件名称", "包含"), message), "报价单.XLSX 清单 readme.md"); + assert.equal(mailRuleSource(rule("附件类型", "包含"), message), ".xlsx .md"); +}); + +test("mailRuleSource: 未知字段返回空串(默认值保持原逻辑)", () => { + assert.equal(mailRuleSource(rule("未知字段", "包含"), message), ""); +}); + +test("mailRuleSource: 空邮件与缺字段均取空值或否定值", () => { + assert.equal(mailRuleSource(rule("发件人", "包含"), emptyMessage), ""); + assert.equal(mailRuleSource(rule("收件人", "包含"), emptyMessage), ""); + assert.equal(mailRuleSource(rule("邮件主题", "包含"), emptyMessage), ""); + assert.equal(mailRuleSource(rule("邮件正文", "包含"), emptyMessage), ""); + assert.equal(mailRuleSource(rule("是否包含附件", "包含"), emptyMessage), "否"); + assert.equal(mailRuleSource(rule("附件名称", "包含"), emptyMessage), ""); + assert.equal(mailRuleSource(rule("附件类型", "包含"), emptyMessage), ""); + assert.equal(mailRuleSource(rule("是否包含附件", "包含"), { attachments: [] }), "否"); + assert.equal(mailRuleSource(rule("附件名称", "包含"), { attachments: undefined }), ""); +}); + +test("extractMailSenderDomain: 取第一个 @ 之后的域名并小写", () => { + assert.equal(extractMailSenderDomain("a@b.com"), "b.com"); + assert.equal(extractMailSenderDomain("张三 "), "example.com"); + assert.equal(extractMailSenderDomain("A@B.com; C@D.com"), "b.com"); + assert.equal(extractMailSenderDomain("A@B.com,"), "b.com"); + assert.equal(extractMailSenderDomain("A@B.com>"), "b.com"); +}); + +test("extractMailSenderDomain: 无 @ 或空值返回空串", () => { + assert.equal(extractMailSenderDomain("没有邮箱"), ""); + assert.equal(extractMailSenderDomain(""), ""); + assert.equal(extractMailSenderDomain(undefined), ""); +}); + +test("mailAttachmentExtensions: 逐个小写提取末尾扩展名", () => { + assert.equal(mailAttachmentExtensions(["报价单.XLSX", "清单", "readme.md"]), ".xlsx .md"); + assert.equal(mailAttachmentExtensions(["A.PDF", "B.DocX"]), ".pdf .docx"); + assert.equal(mailAttachmentExtensions(["无扩展名", "尾部是点."]), ""); + assert.equal(mailAttachmentExtensions([]), ""); + assert.equal(mailAttachmentExtensions(undefined), ""); +}); + +test("doesMailRuleMatch: 各字段 × 各操作符 —— 命中", () => { + for (const field of VALUE_FIELDS) { + const source = sourceOf(field); + assert.equal( + doesMailRuleMatch(rule(field, "等于", source), message), + true, + `${field} 等于 应命中` + ); + assert.equal( + doesMailRuleMatch(rule(field, "包含", source.slice(0, 4)), message), + true, + `${field} 包含 应命中` + ); + assert.equal( + doesMailRuleMatch(rule(field, "不包含", "zzz-不存在-zzz"), message), + true, + `${field} 不包含 应命中` + ); + assert.equal( + doesMailRuleMatch(rule(field, "开头是", source.slice(0, 4)), message), + true, + `${field} 开头是 应命中` + ); + assert.equal( + doesMailRuleMatch(rule(field, "结尾是", source.slice(-4)), message), + true, + `${field} 结尾是 应命中` + ); + assert.equal( + doesMailRuleMatch(rule(field, "是否存在", ""), message), + true, + `${field} 是否存在 应命中` + ); + } +}); + +test("doesMailRuleMatch: 各字段 × 各操作符 —— 未命中", () => { + for (const field of VALUE_FIELDS) { + const source = sourceOf(field); + for (const operator of ["等于", "包含", "开头是", "结尾是"] as const) { + assert.equal( + doesMailRuleMatch(rule(field, operator, "绝不匹配的内容zzz"), message), + false, + `${field} ${operator} 不应命中` + ); + } + assert.equal( + doesMailRuleMatch(rule(field, "不包含", source.slice(0, 4)), message), + false, + `${field} 不包含 不应命中` + ); + assert.equal( + doesMailRuleMatch(rule(field, "是否存在", "否"), message), + false, + `${field} 是否存在 否 不应命中` + ); + } +}); + +test("doesMailRuleMatch: 未知操作符一律不命中", () => { + assert.equal(doesMailRuleMatch(rule("邮件主题", "正则匹配", "询价"), message), false); +}); + +test("doesMailRuleMatch: 是否包含附件强制按存在性判断,忽略操作符", () => { + for (const operator of MAIL_RULE_OPERATORS) { + assert.equal( + doesMailRuleMatch(rule("是否包含附件", operator, "是"), message), + true, + `${operator} + 是 应命中` + ); + assert.equal( + doesMailRuleMatch(rule("是否包含附件", operator, ""), message), + true, + `${operator} + 空值(默认是)应命中` + ); + assert.equal( + doesMailRuleMatch(rule("是否包含附件", operator, "否"), message), + false, + `${operator} + 否 不应命中` + ); + } +}); + +test("是否存在: 否定取值判定为「不期望存在」", () => { + for (const value of ["否", "false", "0", "no", "NO", "False"]) { + assert.equal( + doesMailRuleMatch(rule("邮件主题", "是否存在", value), message), + false, + `${value} 应判定为不期望存在` + ); + } + for (const value of ["是", "true", "1", "yes", "任意其他"]) { + assert.equal( + doesMailRuleMatch(rule("邮件主题", "是否存在", value), message), + true, + `${value} 应判定为期望存在` + ); + } +}); + +test("是否存在: 空值字段(邮箱缺失)判定为不存在", () => { + assert.equal(doesMailRuleMatch(rule("发件人域名", "是否存在", "是"), emptyMessage), false); + assert.equal(doesMailRuleMatch(rule("发件人域名", "是否存在", "否"), emptyMessage), true); +}); + +test("是否包含附件: 无附件时按不存在处理", () => { + assert.equal(doesMailRuleMatch(rule("是否包含附件", "是否存在", "是"), emptyMessage), false); + assert.equal(doesMailRuleMatch(rule("是否包含附件", "是否存在", "否"), emptyMessage), true); +}); + +test("边界值: 空值/缺失值的规则一律不命中", () => { + assert.equal(doesMailRuleMatch(rule("邮件主题", "包含", ""), message), false); + assert.equal(doesMailRuleMatch(rule("邮件主题", "包含", " "), message), false); + assert.equal(doesMailRuleMatch(rule("邮件主题", "包含"), message), false); + // 空值在「不包含」上同样直接判定为不命中(守卫先于操作符分派,保持原逻辑)。 + assert.equal(doesMailRuleMatch(rule("邮件主题", "不包含", ""), message), false); + assert.equal(doesMailRuleMatch(rule("邮件主题", "等于", ""), message), false); + assert.equal(doesMailRuleMatch(rule("邮件主题", "开头是", " "), message), false); +}); + +test("大小写: 字段值与规则值均按小写比较", () => { + assert.equal(doesMailRuleMatch(rule("发件人", "包含", "ZHANG.SAN"), message), true); + assert.equal( + doesMailRuleMatch(rule("发件人", "等于", "张三 "), message), + true + ); + assert.equal(doesMailRuleMatch(rule("邮件主题", "结尾是", "q3"), message), true); + assert.equal(doesMailRuleMatch(rule("附件名称", "包含", ".XLSX"), message), true); + assert.equal(doesMailRuleMatch(rule("发件人", "包含", "zhang.san@example.org"), message), false); +}); + +test("附件类型: 通过扩展名匹配", () => { + assert.equal(doesMailRuleMatch(rule("附件类型", "包含", ".md"), message), true); + assert.equal(doesMailRuleMatch(rule("附件类型", "等于", ".xlsx .md"), message), true); + assert.equal(doesMailRuleMatch(rule("附件类型", "包含", ".docx"), message), false); +}); + +test("doesMailRuleSetMatch: 空规则集恒命中(与模式无关)", () => { + assert.equal(doesMailRuleSetMatch({ rules: [] }, message), true); + assert.equal(doesMailRuleSetMatch({ rules: [], mode: "any" }, message), true); + assert.equal(doesMailRuleSetMatch({ rules: null }, message), true); + assert.equal(doesMailRuleSetMatch({}, message), true); +}); + +test("doesMailRuleSetMatch: AND 模式(all)", () => { + const hit = rule("邮件主题", "包含", "询价"); + const hit2 = rule("发件人域名", "等于", "example.com"); + const miss = rule("收件人", "包含", "不存在的收件人"); + + assert.equal(doesMailRuleSetMatch({ rules: [hit, hit2], mode: "all" }, message), true); + assert.equal(doesMailRuleSetMatch({ rules: [hit, hit2] }, message), true); + assert.equal(doesMailRuleSetMatch({ rules: [hit, miss], mode: "all" }, message), false); + assert.equal(doesMailRuleSetMatch({ rules: [hit, miss] }, message), false); +}); + +test("doesMailRuleSetMatch: OR 模式(any)", () => { + const hit = rule("邮件主题", "包含", "询价"); + const miss = rule("收件人", "包含", "不存在的收件人"); + const miss2 = rule("附件类型", "包含", ".docx"); + + assert.equal(doesMailRuleSetMatch({ rules: [hit, miss], mode: "any" }, message), true); + assert.equal(doesMailRuleSetMatch({ rules: [miss, miss2], mode: "any" }, message), false); +}); + +test("doesMailRuleSetMatch: 规则集缺失/非数组时视为无规则", () => { + assert.equal(doesMailRuleSetMatch({ rules: undefined, mode: "any" }, message), true); + assert.equal( + doesMailRuleSetMatch({ rules: "not-an-array" as unknown as MailTriggerRule[] }, message), + true + ); +}); + +test("mailRuleText: 普通操作符带引号,存在性判断不加引号", () => { + assert.equal( + mailRuleText(rule("发件人域名", "等于", "example.com")), + "发件人域名等于“example.com”" + ); + assert.equal(mailRuleText(rule("邮件主题", "包含", "询价")), "邮件主题包含“询价”"); + assert.equal(mailRuleText(rule("邮件主题", "是否存在", "否")), "邮件主题否"); + assert.equal(mailRuleText(rule("邮件主题", "是否存在", "")), "邮件主题是"); +}); + +test("mailRuleText: 是否包含附件字段同样按存在性文案渲染", () => { + assert.equal(mailRuleText(rule("是否包含附件", "包含", "是")), "是否包含附件是"); + assert.equal(mailRuleText(rule("是否包含附件", "包含", "否")), "是否包含附件否"); + assert.equal(mailRuleText(rule("是否包含附件", "包含")), "是否包含附件是"); +}); + +test("mailRuleText: 值缺失时渲染为空串而非 undefined", () => { + assert.equal(mailRuleText(rule("邮件主题", "包含")), "邮件主题包含“”"); + assert.equal(mailRuleText(rule("邮件主题", "包含", "")), "邮件主题包含“”"); +}); + +test("mailRulesSummary: 无规则时提示收到即触发", () => { + assert.equal(mailRulesSummary({ rules: [] }), "收到新邮件即触发"); + assert.equal(mailRulesSummary({ rules: [], mode: "any" }), "收到新邮件即触发"); + assert.equal(mailRulesSummary({}), "收到新邮件即触发"); +}); + +test("mailRulesSummary: 全部/任一前缀与多条规则的连接", () => { + const first = rule("邮件主题", "包含", "询价"); + const second = rule("发件人域名", "等于", "example.com"); + + assert.equal(mailRulesSummary({ rules: [first], mode: "all" }), "全部:邮件主题包含“询价”"); + assert.equal(mailRulesSummary({ rules: [first] }), "全部:邮件主题包含“询价”"); + assert.equal(mailRulesSummary({ rules: [first], mode: "any" }), "任一:邮件主题包含“询价”"); + assert.equal( + mailRulesSummary({ rules: [first, second], mode: "all" }), + "全部:邮件主题包含“询价”;发件人域名等于“example.com”" + ); + assert.equal( + mailRulesSummary({ rules: [first, second], mode: "any" }), + "任一:邮件主题包含“询价”;发件人域名等于“example.com”" + ); +}); + +test("mailRulesBriefSummary: 列表接口 triggerDetail 用的精简摘要(保留原有格式)", () => { + // 该格式与 mailRulesSummary 不同(只展示首条 + 条数),是重构前 store.ts + // 既有行为,本次原样迁移、未统一,避免改变接口返回文案。 + const first = rule("邮件主题", "包含", "询价"); + const second = rule("发件人域名", "等于", "example.com"); + + assert.equal(mailRulesBriefSummary([]), "收到新邮件即触发"); + assert.equal(mailRulesBriefSummary(null), "收到新邮件即触发"); + assert.equal(mailRulesBriefSummary(undefined), "收到新邮件即触发"); + assert.equal(mailRulesBriefSummary([first]), "邮件主题包含“询价”"); + assert.equal(mailRulesBriefSummary([first, second]), "邮件主题包含“询价” 等 2 条"); +}); + +test("mailRulesBriefSummary: 空值规则与缺省字段的兜底", () => { + assert.equal(mailRulesBriefSummary([rule("邮件主题", "包含", "")]), "邮件主题包含"); + assert.equal(mailRulesBriefSummary([rule("", "")]), "邮件包含"); +}); + +test("mailRulesBriefSummary 与 mailRulesSummary 的差异(存在性字段)", () => { + const exists = rule("是否包含附件", "是否存在", "是"); + assert.equal(mailRulesBriefSummary([exists]), "是否包含附件是否存在“是”"); + assert.equal(mailRulesSummary({ rules: [exists], mode: "all" }), "全部:是否包含附件是"); +}); + +test("normalizedMailRule: 字段|操作符|值(去空格 + 小写)", () => { + assert.equal(normalizedMailRule(rule("邮件主题", "包含", " 询价 ")), "邮件主题|包含|询价"); + assert.equal( + normalizedMailRule(rule("发件人域名", "等于", "Example.COM")), + "发件人域名|等于|example.com" + ); + assert.equal(normalizedMailRule(rule("邮件主题", "包含")), "邮件主题|包含|"); +}); + +test("mailRuleSetsEqual: 规则顺序无关,值的大小写与空格无关", () => { + const left = rule("邮件主题", "包含", "询价"); + const right = rule("发件人域名", "等于", "example.com"); + + assert.equal( + mailRuleSetsEqual({ rules: [left, right], mode: "all" }, { rules: [right, left], mode: "all" }), + true + ); + assert.equal( + mailRuleSetsEqual( + { rules: [rule("邮件主题", "包含", " 询价 ")], mode: "all" }, + { rules: [rule("邮件主题", "包含", "询价")], mode: "all" } + ), + true + ); +}); + +test("mailRuleSetsEqual: 模式、条数、内容不同均判定为不相等", () => { + const left = rule("邮件主题", "包含", "询价"); + const right = rule("发件人域名", "等于", "example.com"); + + assert.equal( + mailRuleSetsEqual({ rules: [left], mode: "all" }, { rules: [left], mode: "any" }), + false + ); + assert.equal( + mailRuleSetsEqual({ rules: [left], mode: "all" }, { rules: [left, right], mode: "all" }), + false + ); + assert.equal( + mailRuleSetsEqual({ rules: [left], mode: "all" }, { rules: [right], mode: "all" }), + false + ); + assert.equal(mailRuleSetsEqual({ rules: [], mode: "all" }, { rules: [], mode: "all" }), true); +}); + +test("mailRuleSetsEqual: 模式缺省按 all 处理", () => { + const left = rule("邮件主题", "包含", "询价"); + assert.equal(mailRuleSetsEqual({ rules: [left] }, { rules: [left], mode: "all" }), true); + assert.equal(mailRuleSetsEqual({ rules: [left] }, { rules: [left], mode: "any" }), false); +}); + +test("mailConflictLevel: 任一侧无规则视为高风险冲突", () => { + const left = rule("邮件主题", "包含", "询价"); + assert.equal( + mailConflictLevel({ rules: [], mode: "all" }, { rules: [left], mode: "all" }), + "high" + ); + assert.equal( + mailConflictLevel({ rules: [left], mode: "all" }, { rules: [], mode: "all" }), + "high" + ); + assert.equal(mailConflictLevel({ rules: [] }, {}), "high"); +}); + +test("mailConflictLevel: 规则集完全相同为高风险,否则为可能冲突", () => { + const left = rule("邮件主题", "包含", "询价"); + const other = rule("发件人域名", "等于", "example.com"); + + assert.equal( + mailConflictLevel({ rules: [left], mode: "all" }, { rules: [left], mode: "all" }), + "high" + ); + assert.equal( + mailConflictLevel({ rules: [left], mode: "all" }, { rules: [other], mode: "all" }), + "possible" + ); + assert.equal( + mailConflictLevel({ rules: [left], mode: "all" }, { rules: [left], mode: "any" }), + "possible" + ); +}); diff --git a/test/mailboxCredentials.test.ts b/test/mailboxCredentials.test.ts new file mode 100644 index 0000000..8652d15 --- /dev/null +++ b/test/mailboxCredentials.test.ts @@ -0,0 +1,90 @@ +/** + * 邮箱凭据加解密(spec §七:`lib/automation/mailboxes.ts` 加解密往返)。 + * + * 这里断言的是真实行为:密文能解回原文、篡改与换密钥都会失败。做成"对着 mock 断言" + * 的话,把加密函数整个换成明文存储也照样通过。 + */ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + decryptMailboxPassword, + encryptMailboxPassword, +} from "../lib/automation/mailbox-credentials.ts"; + +const SECRET = "test-secret"; + +test("加解密往返: 密文能解回原文", () => { + const plaintext = "auth-code-1234"; + const ciphertext = encryptMailboxPassword(SECRET, plaintext); + + assert.notEqual(ciphertext, plaintext); + assert.equal(decryptMailboxPassword(SECRET, ciphertext), plaintext); +}); + +test("加解密往返: 中文、空格与超长授权码原样往返", () => { + const samples = [ + "授权码 abc 123", + " pass with leading and trailing space ", + "x".repeat(2000), + "密码含:冒号与,逗号", + ]; + + for (const plaintext of samples) { + assert.equal( + decryptMailboxPassword(SECRET, encryptMailboxPassword(SECRET, plaintext)), + plaintext + ); + } +}); + +test("加密: 同一明文两次加密得到不同密文(IV 随机),且都能解开", () => { + const first = encryptMailboxPassword(SECRET, "same-password"); + const second = encryptMailboxPassword(SECRET, "same-password"); + + assert.notEqual(first, second); + assert.equal(decryptMailboxPassword(SECRET, first), "same-password"); + assert.equal(decryptMailboxPassword(SECRET, second), "same-password"); +}); + +test("加密: 密文里不含明文", () => { + const stem = "very-distinctive-secret-value"; + assert.equal(encryptMailboxPassword(SECRET, stem).includes(stem), false); +}); + +test("解密: 换密钥会失败,而不是返回错误明文", () => { + const ciphertext = encryptMailboxPassword("secret-a", "auth-code"); + + assert.throws( + () => decryptMailboxPassword("secret-b", ciphertext), + (error: Error) => error.message === "MAILBOX_CREDENTIAL_INVALID", + "密钥不符时必须抛可识别的错误码:Node 抛的是 " + + "「Unsupported state or unable to authenticate data」,它既不在接口错误映射表里、" + + "也不在连接失败启发式里,会一路落成 500。而模块 E.3 说的正是这个场景——" + + "轮换密钥后所有已存凭据失效,用户需要看到「请重新填写授权码」并且真的能重填。" + ); +}); + +test("解密: 密文被篡改会失败(AES-GCM 认证标签)", () => { + const ciphertext = encryptMailboxPassword(SECRET, "auth-code"); + const [version, iv, tag, body] = ciphertext.split(":"); + const tamperedBody = Buffer.from(body, "base64"); + tamperedBody[0] = (tamperedBody[0] + 1) % 256; + + const tampered = [version, iv, tag, tamperedBody.toString("base64")].join(":"); + assert.throws( + () => decryptMailboxPassword(SECRET, tampered), + (error: Error) => error.message === "MAILBOX_CREDENTIAL_INVALID" + ); +}); + +test("解密: 格式非法时抛可识别的错误码,而不是静默返回空串", () => { + const invalid = ["", "auth-code-plain-text", "v2:a:b:c", "v1:onlyiv:onlytag", "v1::tag:body"]; + + for (const value of invalid) { + assert.throws( + () => decryptMailboxPassword(SECRET, value), + /MAILBOX_CREDENTIAL_INVALID/, + `期望 ${JSON.stringify(value)} 被判为无效凭据` + ); + } +}); diff --git a/test/mailboxCursorSql.test.ts b/test/mailboxCursorSql.test.ts new file mode 100644 index 0000000..9411d04 --- /dev/null +++ b/test/mailboxCursorSql.test.ts @@ -0,0 +1,185 @@ +/** + * 监听邮箱的游标 SQL 守卫(模块 D.4 游标重置 / D.5 游标清理)。 + * + * 为什么读源码文本而不是跑函数:`lib/automation/mailboxes.ts` 依赖 `lib/db`,测试进程不连 + * 数据库(也不 import `@/lib`),这两条语句没有可执行的断言入口。而它们恰好是本任务最容易 + * 写错的地方: + * - Task 1 把游标表的键列从字符串 `mailbox_key` 改成了整数 `mailbox_id`。写成旧列名不会产生 + * 任何类型错误,只在运行时 500; + * - 少写 `last_uid=0` 更隐蔽:游标"看起来"重置了(initialized=false 确实写进去了),但 + * `saveAutomationEmailMailboxCursor` 用 `GREATEST(旧值, 新值)` 写回,重建基线时会把旧基线 + * 写回去,此后 `uid > last_uid` 恒不成立,新邮件全部被过滤; + * - 少写 `created_by_user_id` 的范围限定,删除/重置会波及他人同 id 的游标行(游标串号); + * - 只给编辑(PUT)路径接上重置、漏掉创建(POST 的 upsert)路径:后者同样按 email 就地 + * 改写主机/账号/文件夹,漏掉的后果是旧高水位留在新服务器上,新邮件被静默丢弃。 + * + * 与 test/skillsProxyQuery.test.ts 同一手法:钉住那一行的内容,而不是验证 SQL 的执行结果 + * (执行行为已在抛弃库上做过集成验证,但那不可提交、因此没有回归价值)。 + */ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { test } from "node:test"; + +const MAILBOXES = join(process.cwd(), "lib/automation/mailboxes.ts"); +const MAILBOX_INPUT = join(process.cwd(), "lib/automation/mailbox-input.ts"); + +/** 去掉注释:注释里提到旧列名是允许的(也确实是需要的),可执行 SQL 里不允许。 */ +function withoutComments(source: string) { + return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^[ \t]*\/\/.*$/gm, ""); +} + +/** 源码里所有「像 SQL」的反引号模板串。 */ +function sqlStatements(source: string): string[] { + return [...withoutComments(source).matchAll(/`([^`]*)`/g)] + .map((match) => match[1]) + .filter((text) => /\b(SELECT|UPDATE|DELETE|INSERT|CREATE)\b/i.test(text)); +} + +/** 取游标表上唯一的某条语句;语句被删掉或多出一条都会在这里红。 */ +function cursorStatement(source: string, verb: RegExp): string { + const found = sqlStatements(source).filter((sql) => verb.test(sql)); + assert.equal( + found.length, + 1, + `期望游标表上恰好有一条 ${verb} 语句,实际 ${found.length} 条 —— 语句被挪走/删掉/写了两份?` + ); + return found[0]; +} + +/** 按花括号配对取出函数体(不依赖"它是文件里最后一个函数"这种脆弱前提)。 */ +function functionBody(source: string, name: string): string { + const start = source.indexOf(`function ${name}(`); + assert.ok(start >= 0, `找不到函数 ${name}`); + const braceStart = source.indexOf("{", start); + let depth = 0; + for (let index = braceStart; index < source.length; index += 1) { + if (source[index] === "{") depth += 1; + else if (source[index] === "}") { + depth -= 1; + if (depth === 0) return source.slice(braceStart, index + 1); + } + } + throw new Error(`函数 ${name} 的花括号不配对`); +} + +const source = readFileSync(MAILBOXES, "utf8"); +const resetSql = cursorStatement(source, /UPDATE\s+automation_email_mailbox_cursors/i); +const cleanupSql = cursorStatement(source, /DELETE\s+FROM\s+automation_email_mailbox_cursors/i); + +test("两条游标语句都用整数 mailbox_id,不用 Task 1 之前的字符串列 mailbox_key", () => { + assert.match(resetSql, /mailbox_id/); + assert.match(cleanupSql, /mailbox_id/); +}); + +test("可执行 SQL 里不出现旧列名 mailbox_key(注释里可以提)", () => { + const offenders = sqlStatements(source).filter((sql) => sql.includes("mailbox_key")); + assert.deepEqual( + offenders, + [], + `这些 SQL 还在用旧列名 mailbox_key:\n${offenders.join("\n---\n")}` + ); +}); + +test("D.4 重置同时归零 last_uid 并把 initialized 置为 FALSE", () => { + assert.match( + resetSql, + /last_uid\s*=\s*0/, + "D.4 必须把 last_uid 一起归零:saveAutomationEmailMailboxCursor 用 GREATEST 写回," + + "只置 initialized=false 会在重建基线时恢复旧的高水位,此后所有新邮件都被过滤掉。" + ); + assert.match(resetSql, /initialized\s*=\s*FALSE/i); +}); + +test("D.4 与 D.5 都按 created_by_user_id + mailbox_id 限定范围", () => { + for (const [label, sql] of [ + ["D.4 重置", resetSql], + ["D.5 清理", cleanupSql], + ]) { + assert.match( + sql, + /created_by_user_id\s*=\s*\$\d/, + `${label} 缺少 created_by_user_id 范围限定(游标串号)` + ); + assert.match(sql, /mailbox_id\s*=\s*\$\d/, `${label} 缺少 mailbox_id 范围限定`); + } +}); + +test("D.4 的触发条件只看主机/账号/文件夹,密码与名称不参与比较", () => { + const identity = functionBody(readFileSync(MAILBOX_INPUT, "utf8"), "mailboxIdentityChanged"); + + for (const field of ["imapHost", "username", "folder"]) { + assert.match(identity, new RegExp(field), `身份比较里应当有 ${field}`); + } + assert.doesNotMatch( + identity, + /password/, + "密码不移动 UID 基线:把它写进身份比较会让一次改密码无谓地丢弃一个轮询窗口的邮件。" + ); + assert.doesNotMatch(identity, /\bname\b/, "名称只是展示名,同样不参与身份比较。"); +}); + +test("D.4 只在身份变化时重置,D.5 只在删除成功后清理", () => { + assert.match( + source, + /if\s*\(\s*cursorResetRequired\s*\)\s*await\s+resetAutomationMailboxCursor\(/, + "重置必须以 resolveMailboxUpdate 的 cursorResetRequired 为条件" + ); + assert.match( + source, + /if\s*\(\s*deleted\s*\)\s*await\s+deleteAutomationMailboxCursor\(/, + "D.5 只能在删除真的发生(deleted)后清理:依赖检查返回 409 时邮箱还在,游标必须保留" + ); +}); + +/** + * 创建路径(POST)也走 D.4。 + * + * 创建接口按 `(created_by_user_id, email)` 做 upsert,因此"重填一个已登记的地址、却换了 + * 主机/账号/文件夹"走的就是这条路径。它的失败是静默的:旧高水位留在新服务器上,所有 + * `uid <= 旧值` 的邮件被丢弃,而状态仍显示「已连接」——没有任何可观测的报错入口,只能靠 + * 这组断言钉住。 + */ +test("D.4 创建路径:既有行在 upsert 之前取出,身份变了才在写入之后重置游标", () => { + const create = functionBody(source, "createAutomationMailbox"); + + const insertAt = create.search(/INSERT INTO automation_mailboxes/); + assert.ok(insertAt >= 0, "创建路径应当仍是一条 INSERT ... ON CONFLICT 的 upsert"); + assert.match( + create.slice(insertAt), + /ON CONFLICT\s*\(\s*created_by_user_id\s*,\s*email\s*\)/i, + "upsert 的冲突目标变了:既有行是按 (created_by_user_id, email) 查的,必须与之一致" + ); + + // 先查既有行:upsert 之后旧身份已被覆盖,此时的比较恒为 false,游标就永远不会被重置。 + const lookupAt = create.indexOf("findAutomationMailboxByEmail("); + assert.ok( + lookupAt >= 0 && lookupAt < insertAt, + "既有行必须在 upsert **之前**取出:写入之后旧主机/账号/文件夹已经查不回来了" + ); + + // 判定必须来自单源 helper,不能在这里内联一份比较(否则它与 PUT 路径必然漂移)。 + assert.match( + create, + /createMailboxCursorResetRequired\(/, + "创建路径的游标重置判定应当复用 mailbox-input.ts 的 helper(与 PUT 路径同源)" + ); + + const reset = /if\s*\(\s*cursorResetRequired\s*\)\s*\{\s*await\s+resetAutomationMailboxCursor\(/.exec( + create + ); + assert.ok( + reset, + "创建路径缺少 `if (cursorResetRequired) { await resetAutomationMailboxCursor(...) }`:" + + "身份变了却不重置,调度器会拿旧基线比新邮箱的 UID,静默漏掉全部新邮件" + ); + assert.ok( + (reset?.index ?? -1) > insertAt, + "重置必须在 upsert **成功之后**:写失败时既有基线必须原样保留" + ); + assert.match( + create.slice(reset?.index ?? 0, (reset?.index ?? 0) + 120), + /resetAutomationMailboxCursor\(\s*userId\s*,/, + "重置要按邮箱归属用户限定范围(游标主键含 created_by_user_id,否则会波及他人同 id 的游标行)" + ); +}); diff --git a/test/mailboxForm.test.ts b/test/mailboxForm.test.ts new file mode 100644 index 0000000..0885ca8 --- /dev/null +++ b/test/mailboxForm.test.ts @@ -0,0 +1,122 @@ +/** + * 向导内联新建邮箱的表单校验与请求体(模块 B)。 + * + * 重点是陷阱 1:密码为空表示"未提供",请求体里必须**没有** password 字段, + * 而不是 password: ""——后者在编辑路径下等同于"把凭据覆盖为空"。 + */ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + EMPTY_MAILBOX_FORM, + firstMailboxFormIssue, + mailboxCreatePayload, + mailboxUpdatePayload, + type MailboxFormState, +} from "../lib/automation/mailbox-form.ts"; + +function form(overrides: Partial = {}): MailboxFormState { + return { + ...EMPTY_MAILBOX_FORM, + email: "sales@corp.com", + username: "sales@corp.com", + password: "auth-code", + imapHost: "imap.corp.com", + ...overrides, + }; +} + +test("EMPTY_MAILBOX_FORM: 端口与文件夹使用服务端默认值", () => { + assert.equal(EMPTY_MAILBOX_FORM.imapPort, "993"); + assert.equal(EMPTY_MAILBOX_FORM.folder, "INBOX"); + assert.equal(firstMailboxFormIssue(form()), null); +}); + +test("表单校验: 填写完整时没有阻断问题", () => { + assert.equal(firstMailboxFormIssue(form({ imapPort: "143", folder: "INBOX/Alert" })), null); +}); + +test("表单校验: 必填项缺失时返回对应问题代码", () => { + assert.equal(firstMailboxFormIssue(form({ email: "" })), "email"); + assert.equal(firstMailboxFormIssue(form({ email: "not-an-email" })), "email"); + assert.equal(firstMailboxFormIssue(form({ username: " " })), "username"); + assert.equal(firstMailboxFormIssue(form({ password: "" })), "password"); + assert.equal(firstMailboxFormIssue(form({ imapHost: "" })), "imapHost"); +}); + +test("表单校验: 端口必须是 1-65535 的整数", () => { + assert.equal(firstMailboxFormIssue(form({ imapPort: "" })), "imapPort"); + assert.equal(firstMailboxFormIssue(form({ imapPort: "abc" })), "imapPort"); + assert.equal(firstMailboxFormIssue(form({ imapPort: "0" })), "imapPort"); + assert.equal(firstMailboxFormIssue(form({ imapPort: "1.5" })), "imapPort"); + assert.equal(firstMailboxFormIssue(form({ imapPort: "70000" })), "imapPort"); + assert.equal(firstMailboxFormIssue(form({ imapPort: "65535" })), null); +}); + +test("陷阱 1: 密码未填写时请求体不含 password 字段,而不是空串", () => { + const payload = mailboxCreatePayload(form({ password: "" })); + + assert.equal("password" in payload, false); + assert.deepEqual(Object.keys(payload).sort(), [ + "email", + "folder", + "imapHost", + "imapPort", + "name", + "username", + ]); +}); + +test("新建请求体: 填写了密码时原样下发,不做 trim", () => { + // 授权码可能含首尾空格,改动它会导致登录失败。 + assert.equal( + mailboxCreatePayload(form({ password: " pass with space " })).password, + " pass with space " + ); +}); + +test("新建请求体: 文本字段去空格,端口与文件夹回落默认值", () => { + const payload = mailboxCreatePayload( + form({ imapPort: "", folder: " ", imapHost: " imap.corp.com ", email: " sales@corp.com " }) + ); + + assert.equal(payload.imapPort, 993); + assert.equal(payload.folder, "INBOX"); + assert.equal(payload.imapHost, "imap.corp.com"); + assert.equal(payload.email, "sales@corp.com"); +}); + +test("新建请求体: 名称留空时下发空串,由服务端回落到邮箱地址", () => { + assert.equal(mailboxCreatePayload(form({ name: "" })).name, ""); + assert.equal(mailboxCreatePayload(form({ name: " 销售部邮箱 " })).name, "销售部邮箱"); +}); + +test("编辑表单校验: 密码可留空(留空表示不修改凭据),其余必填项照旧", () => { + assert.equal(firstMailboxFormIssue(form({ password: "" }), { passwordOptional: true }), null); + assert.equal( + firstMailboxFormIssue(form({ password: "", imapHost: "" }), { passwordOptional: true }), + "imapHost" + ); + assert.equal( + firstMailboxFormIssue(form({ password: "", imapPort: "0" }), { passwordOptional: true }), + "imapPort" + ); +}); + +test("编辑请求体: 密码留空时下发空串(而不是省略字段),由服务端理解为保留原值", () => { + const payload = mailboxUpdatePayload(form({ password: "" })); + + assert.equal("password" in payload, true); + assert.equal(payload.password, ""); +}); + +test("编辑请求体: 填写了密码时原样下发,不做 trim", () => { + assert.equal(mailboxUpdatePayload(form({ password: " code " })).password, " code "); +}); + +test("编辑请求体: 其余字段与新建请求体一致", () => { + const state = form({ name: " 销售部邮箱 ", imapPort: "143", folder: " Alerts " }); + const { password, ...rest } = mailboxUpdatePayload(state); + + assert.equal(password, "auth-code"); + assert.deepEqual(rest, mailboxCreatePayload({ ...state, password: "" })); +}); diff --git a/test/mailboxHealth.test.ts b/test/mailboxHealth.test.ts new file mode 100644 index 0000000..133580b --- /dev/null +++ b/test/mailboxHealth.test.ts @@ -0,0 +1,275 @@ +/** + * 监听邮箱连接失败的状态与提醒(模块 E.1 状态列 / E.2 提醒派生)。 + * + * 核心是一条**前端依赖的契约**:提醒的 `eventKey` 恰好是 `mailbox-error:`。 + * 加时间戳("每次失败一条")会让页面按 eventKey 去重的 toast 反复弹窗,也会让"邮箱恢复前 + * 只有一条稳定提醒"这条性质消失,所以这里断言的是**字符串相等**,不是"包含前缀"。 + * + * 另有三组属于"跑不了就钉源码"(`lib/automation/mailboxes.ts`、`store.ts`、 + * `automation-scheduler.ts` 都依赖 `lib/db`,本套件不连数据库、也不 import `@/lib`)。 + * 每一组都有一个"写错了不会有类型错误、只会在运行时出问题"的坑: + * - 提醒的第三段派生必须来自 automation_mailboxes 且带 `status='error'`:少了状态过滤, + * 所有邮箱都会变成"连接失败"。 + * - 状态写入必须挂在真实的连接路径上(连不上记 error、连上了恢复 connected), + * 否则徽标永远是装饰、`status='error'` 永远没有行。 + * + * 最后一组是连接失败的**错误映射**(`mailbox-errors.ts` 的 `mailboxApiError`):IMAP 收信 + * 搬进本进程后抛出的是 Node / imapflow 的原文(`ECONNREFUSED`、`Authentication failed`、 + * `Socket timeout`),大小写不统一。漏掉一种形态,用户拿到的就是一句 500「邮箱操作失败」, + * 而真实原因(主机写错、授权码失效)已经丢在路上——这正是「保存邮箱只报 500」的形状。 + */ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { test } from "node:test"; +import { mailboxApiError, mailboxErrorDisplayText } from "../lib/automation/mailbox-errors.ts"; +import { + MAILBOX_ERROR_EVENT_KEY_PREFIX, + MAILBOX_ERROR_MAX_LENGTH, + mailboxErrorEventKey, + mailboxErrorNotificationItems, + mailboxErrorText, + type MailboxErrorRow, +} from "../lib/automation/mailbox-health.ts"; + +const STORE = join(process.cwd(), "lib/automation/store.ts"); +const MAILBOXES = join(process.cwd(), "lib/automation/mailboxes.ts"); +const SCHEDULER = join(process.cwd(), "lib/cron/automation-scheduler.ts"); + +/** 去掉注释:注释里可以提旧写法,可执行 SQL / 代码里不允许。 */ +function withoutComments(source: string) { + return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^[ \t]*\/\/.*$/gm, ""); +} + +/** 源码里所有「像 SQL」的反引号模板串。 */ +function sqlStatements(source: string): string[] { + return [...withoutComments(source).matchAll(/`([^`]*)`/g)] + .map((match) => match[1]) + .filter((text) => /\b(SELECT|UPDATE|DELETE|INSERT|CREATE|ALTER)\b/i.test(text)); +} + +/** 按花括号配对取出函数体(不依赖"它是文件里最后一个函数"这种脆弱前提)。 */ +function functionBody(source: string, name: string): string { + const start = source.indexOf(`function ${name}(`); + assert.ok(start >= 0, `找不到函数 ${name}`); + const braceStart = source.indexOf("{", start); + let depth = 0; + for (let index = braceStart; index < source.length; index += 1) { + if (source[index] === "{") depth += 1; + else if (source[index] === "}") { + depth -= 1; + if (depth === 0) return source.slice(braceStart, index + 1); + } + } + throw new Error(`函数 ${name} 的花括号不配对`); +} + +function errorItem(overrides: Partial = {}) { + const [item] = mailboxErrorNotificationItems([ + { + mailboxId: 7, + label: "销售部邮箱 · sales@corp.com", + error: "认证失败:用户名或授权码不正确", + errorAt: "2026-09-11T02:00:00.000Z", + ...overrides, + }, + ]); + return item; +} + +test("eventKey 恰好是 mailbox-error:,不含时间戳", () => { + assert.equal(MAILBOX_ERROR_EVENT_KEY_PREFIX, "mailbox-error:"); + assert.equal(mailboxErrorEventKey(7), "mailbox-error:7"); + assert.equal(mailboxErrorEventKey(1024), "mailbox-error:1024"); + // 派生的提醒条目用的是同一个字符串,不是另写一份拼接。 + assert.equal(errorItem().eventKey, "mailbox-error:7"); +}); + +test("同一邮箱多次失败只派生一条 eventKey 相同的提醒(失败时刻不影响 eventKey)", () => { + const first = errorItem({ errorAt: "2026-09-11T02:00:00.000Z", error: "连接超时" }); + const second = errorItem({ errorAt: "2026-09-11T09:30:00.000Z", error: "认证失败" }); + + assert.equal(first.eventKey, second.eventKey); + assert.equal(first.eventKey, `mailbox-error:7`); + assert.notEqual(first.createdAt, second.createdAt, "提醒时间会更新,但事件标识不变"); +}); + +test("邮箱恢复后不再有 error 行时,派生结果为空(提醒消失是结构性的)", () => { + // 恢复 = 该邮箱不再是 status='error' 的查询结果,因此这里根本没有行可传。 + assert.deepEqual(mailboxErrorNotificationItems([]), []); + assert.deepEqual(mailboxErrorNotificationItems([], new Date("2026-09-11T00:00:00Z")), []); +}); + +test("提醒复用 email_failed、level 为 strong、不指向任何运行记录", () => { + const item = errorItem(); + + assert.equal(item.kind, "email_failed"); + assert.equal(item.level, "strong"); + // 连接失败发生在建 Run 之前:runId 0 表示没有对应运行,automationId 为 null(一条管道 + // 可能被多个自动化共用)。前端据此不会打开一个不存在的运行详情。 + assert.equal(item.runId, 0); + assert.equal(item.automationId, null); + assert.equal(item.read, false); +}); + +test("提醒文案带上邮箱展示名与错误原文", () => { + const item = errorItem(); + + assert.ok(item.title.includes("销售部邮箱 · sales@corp.com"), item.title); + assert.ok(item.message.includes("认证失败:用户名或授权码不正确"), item.message); +}); + +test("错误原文过长会截断,提醒不会变成一大段日志", () => { + const long = "上游报错".repeat(500); + const item = errorItem({ error: long }); + + assert.ok(item.message.length < 400, `提醒文案过长:${item.message.length}`); + assert.ok(item.message.endsWith("…")); + assert.equal( + mailboxErrorText(` ${long} `, MAILBOX_ERROR_MAX_LENGTH).length, + MAILBOX_ERROR_MAX_LENGTH + 1 + ); +}); + +test("没有错误原文时给出可操作的兜底文案", () => { + const item = errorItem({ error: "" }); + + assert.ok(!item.message.includes(":"), item.message); + assert.ok(item.message.includes("邮箱管理"), item.message); +}); + +test("错误时间为空或非法时退回当前时间,且不产生 NaN 时间戳", () => { + const now = new Date("2026-09-11T12:00:00.000Z"); + assert.equal( + mailboxErrorNotificationItems([{ mailboxId: 3 }], now)[0].createdAt, + now.toISOString() + ); + assert.equal( + mailboxErrorNotificationItems([{ mailboxId: 3, errorAt: "not-a-date" }], now)[0].createdAt, + now.toISOString() + ); +}); + +test("邮箱 id 非法的行不派生提醒(不会拼出 mailbox-error:NaN)", () => { + const items = mailboxErrorNotificationItems([ + { mailboxId: Number.NaN }, + { mailboxId: 0 }, + { mailboxId: -1 }, + {} as { mailboxId: number }, + { mailboxId: 5 }, + ]); + + assert.deepEqual( + items.map((item) => item.eventKey), + ["mailbox-error:5"] + ); +}); + +test("邮箱列表接口下发 lastError(抽屉「最后错误」的唯一数据源)", () => { + const source = readFileSync(MAILBOXES, "utf8"); + + assert.match(functionBody(source, "mailboxRowToApi"), /lastError:\s*row\.last_error/); + assert.match(functionBody(source, "mailboxRowToApi"), /lastErrorAt:\s*row\.last_error_at/); +}); + +test("错误码在落库/展示前换成用户可读文案,而不是裸错误码", () => { + // 凭据失效(模块 E.3:密钥被换过)与密钥未配置都是"已知原因",用户看到的是该怎么办。 + assert.equal( + mailboxErrorDisplayText(new Error("MAILBOX_CREDENTIAL_INVALID")), + "邮箱凭据已失效,请重新填写授权码或密码" + ); + assert.equal( + mailboxErrorDisplayText(new Error("AUTOMATION_MAILBOX_SECRET_MISSING")), + "服务端尚未配置邮箱凭证加密密钥" + ); + // 上游 IMAP 失败带回的是真实原因,不在表里,原样保留(改写只会丢信息)。 + assert.equal(mailboxErrorDisplayText(new Error("登录失败: 授权码错误")), "登录失败: 授权码错误"); + assert.equal(mailboxErrorDisplayText(undefined), ""); +}); + +test("连接失败写库前先做映射(否则通知与「最后错误」会显示 MAILBOX_CREDENTIAL_INVALID)", () => { + const body = functionBody( + readFileSync(MAILBOXES, "utf8"), + "markAutomationMailboxConnectionError" + ); + + assert.match( + body, + /mailboxErrorDisplayText\(/, + "last_error 是抽屉「最后错误」与通知中心的同一份文案来源,裸错误码必须在写入前翻译掉" + ); +}); + +test("状态写入挂在真实连接路径上:连不上记 error,连上了恢复 connected(E.1)", () => { + const source = readFileSync(SCHEDULER, "utf8"); + const fetchBody = functionBody(source, "fetchConfiguredMailboxUnread"); + + assert.match(fetchBody, /fetchMailboxUnread\(/); + assert.match(fetchBody, /markAutomationMailboxConnectionError\(/); + assert.match(fetchBody, /markAutomationMailboxConnected\(/); + + // 记录失败不能把原始错误吞掉:错误必须继续往上抛,分组层才会照旧打日志。 + assert.match(fetchBody, /catch[\s\S]{0,400}throw error;/); + // 恢复只在"当前不是 connected"时写库,正常轮询不产生写入。 + assert.match(fetchBody, /status !== "connected"/); +}); + +test("提醒的第三段派生来自 automation_mailboxes 且只取 status='error'(E.2)", () => { + const source = readFileSync(STORE, "utf8"); + const body = functionBody(source, "listAutomationNotifications"); + + const mailboxQueries = sqlStatements(body).filter((sql) => + /\bFROM\s+automation_mailboxes\b/i.test(sql) + ); + assert.equal( + mailboxQueries.length, + 1, + `期望提醒派生里恰好有一段查 automation_mailboxes 的语句,实际 ${mailboxQueries.length} 段` + ); + + const query = mailboxQueries[0]; + assert.match(query, /status\s*=\s*'error'/i, "少了状态过滤,所有邮箱都会变成「连接失败」提醒"); + assert.match(query, /created_by_user_id\s*=\s*\$\d/, "只取当前用户自己的邮箱"); + assert.match(query, /last_error/, "提醒要能说明失败原因"); + assert.match( + body, + /mailboxErrorNotificationItems\(/, + "派生结果必须经 mailbox-health.ts 生成,eventKey 的格式才有单一来源" + ); + // initialize 通知偏好之前就坏掉的邮箱同样该被看到:这是"当前状态",不是历史事件。 + assert.doesNotMatch(query, /initializedAt|initialized_at/); +}); + +test("连接类失败映射成 400,而不是 500", () => { + for (const detail of [ + "IMAP 收件失败: 无法打开邮箱文件夹:INBOX", + "IMAP 收件失败: connect ECONNREFUSED 127.0.0.1:993", + "IMAP 收件失败: getaddrinfo ENOTFOUND imap.example.com", + "IMAP 收件失败: Socket timeout", + "IMAP 收件失败: self-signed certificate in certificate chain", + "IMAP 收件失败: authentication failed", + ]) { + assert.equal(mailboxApiError(new Error(detail)).status, 400, detail); + // 沿用上游原文:用户与运维要看的是真实原因,不是被改写过的通用话术。 + assert.equal(mailboxApiError(new Error(detail)).detail, detail); + } +}); + +test("不带 IMAP 前缀的网络/TLS 形态同样映射成 400(大小写不敏感)", () => { + // 这些是 Node 与 imapflow 的原文形态:大小写并不统一,而且不一定带 "IMAP" 字样。 + // 漏掉任何一种,用户拿到的都是一句 500「邮箱操作失败」。 + for (const detail of [ + "ECONNREFUSED 127.0.0.1:993", + "getaddrinfo ENOTFOUND imap.example.com", + "Socket timeout", + "self-signed certificate", + "Authentication failed", + ]) { + assert.equal(mailboxApiError(new Error(detail)).status, 400, detail); + } +}); + +test("非连接类失败仍然落 500(兜底行为不变)", () => { + assert.equal(mailboxApiError(new Error("Not Found")).status, 500); + assert.equal(mailboxApiError(new Error("")).status, 500); +}); diff --git a/test/mailboxLabel.test.ts b/test/mailboxLabel.test.ts new file mode 100644 index 0000000..245a93e --- /dev/null +++ b/test/mailboxLabel.test.ts @@ -0,0 +1,40 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { mailboxLabelFromRow } from "../lib/automation/mailbox-id.ts"; + +test("mailboxLabelFromRow: 名称与邮箱不同时派生为「名称 · 邮箱」", () => { + assert.equal( + mailboxLabelFromRow({ name: "售后客服", email: "support@example.com" }), + "售后客服 · support@example.com" + ); +}); + +test("mailboxLabelFromRow: 名称与邮箱相同时只显示邮箱,避免重复", () => { + assert.equal( + mailboxLabelFromRow({ name: "support@example.com", email: "support@example.com" }), + "support@example.com" + ); +}); + +test("mailboxLabelFromRow: 名称为空或缺失时退回邮箱", () => { + assert.equal(mailboxLabelFromRow({ email: "support@example.com" }), "support@example.com"); + assert.equal( + mailboxLabelFromRow({ name: "", email: "support@example.com" }), + "support@example.com" + ); +}); + +test("mailboxLabelFromRow: 记录缺失时不抛错,返回空串而非 undefined", () => { + assert.equal(mailboxLabelFromRow(null), ""); + assert.equal(mailboxLabelFromRow(undefined), ""); +}); + +test("mailboxLabelFromRow: 展示名只来自邮箱记录,客户端字段不参与派生(模块 D.3)", () => { + const row = { + name: "售后客服", + email: "support@example.com", + // 客户端可伪造的同名字段:派生必须忽略它 + mailboxLabel: "财务专用邮箱", + }; + assert.equal(mailboxLabelFromRow(row), "售后客服 · support@example.com"); +}); diff --git a/test/mailboxUpdate.test.ts b/test/mailboxUpdate.test.ts new file mode 100644 index 0000000..75f6451 --- /dev/null +++ b/test/mailboxUpdate.test.ts @@ -0,0 +1,274 @@ +/** + * 编辑监听邮箱的合并语义(模块 C:密码留空保留原值 + 模块 D.4 游标重置)。 + * + * 密码那组用例是本次改动里后果最重的一条:把已存凭据覆盖成空串,不会报错、不会告警, + * 只会让所有绑定该邮箱的自动化在下次轮询时全部登录失败。因此这里断言的是合并后的 + * 密码本身,而不是"函数被调用过"。 + */ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { test } from "node:test"; +import { + createMailboxCursorResetRequired, + mailboxUpdateInputFromBody, + mailboxUpdateSuppliesPassword, + normalizeMailboxConfig, + resolveMailboxUpdate, + type MailboxConfig, + type MailboxConfigInput, + type MailboxIdentity, +} from "../lib/automation/mailbox-input.ts"; + +function existingConfig(overrides: Partial = {}): MailboxConfig { + return { + name: "销售部邮箱", + email: "sales@corp.com", + username: "sales@corp.com", + password: "stored-auth-code", + imapHost: "imap.corp.com", + imapPort: 993, + imapSecure: true, + folder: "INBOX", + ...overrides, + }; +} + +/** 只改一个字段的编辑请求。 */ +function update(input: MailboxConfigInput) { + return resolveMailboxUpdate(existingConfig(), input); +} + +/** 既有邮箱记录的身份三要素(mailboxes.ts 的 mailboxIdentityFromRow 的等价物)。 */ +function identityOf(config: MailboxConfig): MailboxIdentity { + return { imapHost: config.imapHost, username: config.username, folder: config.folder }; +} + +test("密码留空: 提供了空串时保留原密码,而不是把凭据覆盖为空", () => { + const { config } = update({ password: "" }); + + assert.equal(config.password, "stored-auth-code"); +}); + +test("密码留空: 未提供 password 字段时保留原密码", () => { + const { config } = update({ name: "售后邮箱" }); + + assert.equal(config.password, "stored-auth-code"); +}); + +test("密码留空: undefined 与空串都保留原值,两者含义一致且都不落空", () => { + assert.equal(update({ password: undefined }).config.password, "stored-auth-code"); + assert.equal(update({ password: "" }).config.password, "stored-auth-code"); +}); + +test("密码更新: 提供了新密码时按新密码生效,且不做 trim(授权码可能含空格)", () => { + assert.equal(update({ password: "new-code" }).config.password, "new-code"); + assert.equal(update({ password: " code " }).config.password, " code "); +}); + +test("未提供的字段一律保留原值", () => { + const { config } = update({ password: "" }); + + assert.deepEqual(config, existingConfig()); +}); + +test("已提供的字段覆盖原值,并做归一化(邮箱与主机小写、去首尾空格)", () => { + const { config } = update({ + email: " Sales@Corp.COM ", + username: " sales ", + imapHost: " IMAP2.Corp.com ", + imapPort: 143, + imapSecure: false, + folder: " Alerts ", + name: " 销售二线 ", + }); + + assert.equal(config.email, "sales@corp.com"); + assert.equal(config.username, "sales"); + assert.equal(config.imapHost, "imap2.corp.com"); + assert.equal(config.imapPort, 143); + assert.equal(config.imapSecure, false); + assert.equal(config.folder, "Alerts"); + assert.equal(config.name, "销售二线"); +}); + +test("账号留空时回落到邮箱地址(与创建路径一致)", () => { + const { config } = update({ username: "" }); + + assert.equal(config.username, "sales@corp.com"); +}); + +test("D.4 游标重置: 改 IMAP 主机要重置", () => { + assert.equal(update({ imapHost: "imap2.corp.com", password: "" }).cursorResetRequired, true); +}); + +test("D.4 游标重置: 改账号要重置", () => { + assert.equal(update({ username: "sales2@corp.com", password: "" }).cursorResetRequired, true); +}); + +test("D.4 游标重置: 改文件夹要重置", () => { + assert.equal(update({ folder: "Alerts", password: "" }).cursorResetRequired, true); +}); + +test("D.4 游标重置: 只改密码不重置——密码不移动 UID 基线", () => { + assert.equal(update({ password: "new-code" }).cursorResetRequired, false); +}); + +test("D.4 游标重置: 只改名称、端口或加密方式不重置", () => { + assert.equal(update({ name: "改名了" }).cursorResetRequired, false); + assert.equal(update({ imapPort: 143 }).cursorResetRequired, false); + assert.equal(update({ imapSecure: false }).cursorResetRequired, false); +}); + +test("D.4 游标重置: 主机大小写、文件夹首尾空格、空文件夹等写法差异不算变更", () => { + assert.equal(update({ imapHost: "IMAP.CORP.COM", folder: "INBOX" }).cursorResetRequired, false); + assert.equal(update({ imapHost: " imap.corp.com " }).cursorResetRequired, false); + assert.equal(update({ folder: "INBOX " }).cursorResetRequired, false); + assert.equal(resolveMailboxUpdate(existingConfig({ folder: "" }), {}).cursorResetRequired, false); +}); + +test("D.4 游标重置: 同一份配置原样提交不重置(编辑器点一次保存不该丢基线)", () => { + const { config, cursorResetRequired } = resolveMailboxUpdate(existingConfig(), { + name: existingConfig().name, + email: existingConfig().email, + username: existingConfig().username, + password: "", + imapHost: existingConfig().imapHost, + imapPort: existingConfig().imapPort, + imapSecure: existingConfig().imapSecure, + folder: existingConfig().folder, + }); + + assert.deepEqual(config, existingConfig()); + assert.equal(cursorResetRequired, false); +}); + +test("创建路径 (D.4): 同一邮箱地址重填但换了主机/账号/文件夹 → 必须重置游标", () => { + // 创建接口是按 (created_by_user_id, email) 的 upsert:用户重填一个已登记的地址、 + // 却指向另一个收件箱时,旧的高水位会留在新服务器上,此后的新邮件全被静默丢弃 + // (状态仍显示「已连接」),因此这三条与 PUT 路径一样必须重置。 + const existing = identityOf(existingConfig()); + + assert.equal( + createMailboxCursorResetRequired(existing, existingConfig({ imapHost: "imap2.corp.com" })), + true + ); + assert.equal( + createMailboxCursorResetRequired(existing, existingConfig({ username: "sales2@corp.com" })), + true + ); + assert.equal( + createMailboxCursorResetRequired(existing, existingConfig({ folder: "Alerts" })), + true + ); +}); + +test("创建路径 (D.4): 全新地址不重置——那是真正的插入,还没有游标行", () => { + assert.equal(createMailboxCursorResetRequired(null, existingConfig()), false); + assert.equal(createMailboxCursorResetRequired(undefined, existingConfig()), false); +}); + +test("创建路径 (D.4): 身份没变不重置——重填同一邮箱只是更新凭据/名称/端口", () => { + const existing = identityOf(existingConfig()); + + assert.equal( + createMailboxCursorResetRequired( + existing, + existingConfig({ password: "new-code", name: "改名了", imapPort: 143 }) + ), + false + ); + // 写法差异同样不算变更(与 PUT 路径同一套归一化规则)。 + assert.equal( + createMailboxCursorResetRequired( + identityOf(existingConfig({ folder: "" })), + existingConfig({ imapHost: " IMAP.CORP.COM " }) + ), + false + ); +}); + +test("编辑校验: 合并后的配置仍按创建时的规则校验,非法取值抛可识别的错误码", () => { + assert.throws(() => update({ email: "not-an-email" }), /MAILBOX_EMAIL_INVALID/); + assert.throws(() => update({ imapHost: "" }), /MAILBOX_IMAP_HOST_REQUIRED/); + assert.throws(() => update({ imapPort: 0 }), /MAILBOX_IMAP_PORT_INVALID/); + assert.throws(() => update({ imapPort: 70000 }), /MAILBOX_IMAP_PORT_INVALID/); +}); + +test("创建路径: 归一化后端口默认 993、文件夹默认 INBOX、名称回落到邮箱", () => { + const config = normalizeMailboxConfig({ + email: "sales@corp.com", + password: "code", + imapHost: "imap.corp.com", + }); + + assert.equal(config.imapPort, 993); + assert.equal(config.imapSecure, true); + assert.equal(config.folder, "INBOX"); + assert.equal(config.name, "sales@corp.com"); + assert.equal(config.username, "sales@corp.com"); +}); + +test("创建路径: 缺少密码或主机时拒绝,不静默补默认值", () => { + assert.throws( + () => + normalizeMailboxConfig({ email: "sales@corp.com", password: "", imapHost: "imap.corp.com" }), + /MAILBOX_PASSWORD_REQUIRED/ + ); + assert.throws( + () => normalizeMailboxConfig({ email: "sales@corp.com", password: "code", imapHost: " " }), + /MAILBOX_IMAP_HOST_REQUIRED/ + ); +}); + +test("编辑请求体: 只收下出现过的字段,缺口不补默认值(否则只改密码会顺带改写主机/文件夹)", () => { + const input = mailboxUpdateInputFromBody({ password: "new-code" }); + + assert.deepEqual(Object.keys(input), ["password"]); +}); + +test("编辑请求体: 密码「未提供」与「空串」必须能区分——前者不下发该字段,后者原样下发", () => { + assert.deepEqual(mailboxUpdateInputFromBody({ name: "改名" }), { name: "改名" }); + assert.deepEqual(mailboxUpdateInputFromBody({ name: "改名", password: "" }), { + name: "改名", + password: "", + }); +}); + +test("编辑请求体: 端口数字与数字串都收,空串/null 视为未提供;非数字留给服务端报 400", () => { + assert.equal(mailboxUpdateInputFromBody({ imapPort: 143 }).imapPort, 143); + assert.equal(mailboxUpdateInputFromBody({ imapPort: "143" }).imapPort, 143); + assert.equal("imapPort" in mailboxUpdateInputFromBody({ imapPort: "" }), false); + assert.equal("imapPort" in mailboxUpdateInputFromBody({ imapPort: null }), false); + assert.equal(Number.isNaN(mailboxUpdateInputFromBody({ imapPort: "abc" }).imapPort), true); +}); + +test("编辑请求体: imapSecure 只收布尔;非文本字段与非法 body 一律忽略", () => { + assert.deepEqual(mailboxUpdateInputFromBody({ imapSecure: false }), { imapSecure: false }); + assert.equal("imapSecure" in mailboxUpdateInputFromBody({ imapSecure: "false" }), false); + assert.deepEqual(mailboxUpdateInputFromBody({ name: 123, email: null }), {}); + assert.deepEqual(mailboxUpdateInputFromBody(null), {}); + assert.deepEqual(mailboxUpdateInputFromBody("not-an-object"), {}); + assert.deepEqual(mailboxUpdateInputFromBody(undefined), {}); +}); + +test("编辑请求是否带新密码:空串与未提供都算「没带」,与 resolveMailboxUpdate 同一判定", () => { + assert.equal(mailboxUpdateSuppliesPassword({ password: "new-auth-code" }), true); + assert.equal(mailboxUpdateSuppliesPassword({ password: " " }), true); + assert.equal(mailboxUpdateSuppliesPassword({ password: "" }), false); + assert.equal(mailboxUpdateSuppliesPassword({ name: "只改名" }), false); + assert.equal(mailboxUpdateSuppliesPassword(undefined), false); + assert.equal(mailboxUpdateSuppliesPassword(null), false); +}); + +test("带新密码的编辑不去解密旧密文(否则密钥被换过后用户永远自救不了)", () => { + // 密钥轮换后旧密文必然解不开,而用户此刻提交的正是"重新填写授权码"这件事本身: + // 若在合并前先解密旧密文,接口会一直报「凭据已失效」,用户照提示重填还是同一条错误。 + const source = readFileSync(join(process.cwd(), "lib/automation/mailboxes.ts"), "utf8"); + + assert.match( + source, + /mailboxConnectionFromRow\(\s*existingRow\s*,\s*mailboxUpdateSuppliesPassword\(input\)\s*\?/, + "更新路径必须按「请求是否带新密码」决定要不要解密既有密文" + ); +}); diff --git a/types/i18n.d.ts b/types/i18n.d.ts index 6536707..de8ac73 100644 --- a/types/i18n.d.ts +++ b/types/i18n.d.ts @@ -39,6 +39,7 @@ type Messages = typeof import("../messages/en/common.json") & { zdObserve: typeof import("../messages/en/zdObserve.json"); skills: typeof import("../messages/en/skills.json"); reviews: typeof import("../messages/en/reviews.json"); + automation: typeof import("../messages/en/automation.json"); }; declare module "next-intl" { diff --git a/types/mailparser.d.ts b/types/mailparser.d.ts new file mode 100644 index 0000000..b6d38e4 --- /dev/null +++ b/types/mailparser.d.ts @@ -0,0 +1,66 @@ +/** + * `mailparser` 没有自带类型声明,npm 上也没有与之匹配的 `@types/mailparser`。 + * + * 与 `types/pdf-parse.d.ts` 同一手法:只声明本仓库真正用到的部分,不抄上游的完整结构—— + * 声明得越全,越容易在库升级时变成一句没人维护的谎话。收信侧只读 `text` / `html` / + * 头部字段 / `attachments` 的文件名,其余一概不碰。 + */ +declare module "mailparser" { + interface MailParserAddress { + value?: Array<{ name?: string; address?: string }>; + /** 可读文本,形如 `"张三" ` */ + text?: string; + html?: string; + } + + interface MailParserAttachment { + /** 已解码(RFC 2047 / RFC 2231)的文件名;只有带 filename 的 part 才有这个字段 */ + filename?: string; + contentType?: string; + contentDisposition?: string; + contentId?: string; + cid?: string; + related?: boolean; + content?: Buffer; + size?: number; + } + + /** 头部原始行:`key` 已小写,`line` 是完整的 `Key: value` 一行 */ + interface MailParserHeaderLine { + key: string; + line: string; + } + + interface ParsedMail { + text?: string; + html?: string | false; + subject?: string; + messageId?: string; + date?: Date; + from?: MailParserAddress; + to?: MailParserAddress | MailParserAddress[]; + cc?: MailParserAddress | MailParserAddress[]; + attachments?: MailParserAttachment[]; + headerLines?: MailParserHeaderLine[]; + headers?: Map; + } + + interface SimpleParserOptions { + /** true 时不再把 HTML 翻译成纯文本塞进 `text`(收信侧依赖这个来区分「只有 HTML」) */ + skipHtmlToText?: boolean; + /** true 时保持 HTML 原样,不改写内嵌图片的 cid 链接 */ + keepCidLinks?: boolean; + skipTextToHtml?: boolean; + skipImageLinks?: boolean; + keepDeliveryStatus?: boolean; + maxHtmlLengthToParse?: number; + } + + function simpleParser( + input: string | Buffer | NodeJS.ReadableStream, + options?: SimpleParserOptions + ): Promise; + + export { simpleParser }; + export type { MailParserAddress, MailParserAttachment, MailParserHeaderLine, ParsedMail }; +}