import nodemailer from "nodemailer"; import { register } from "../engine/registry"; import { RuntimeError } from "../engine/types"; import { powershell } from "./desktop"; function text(args: Record, keys: string[], fallback = ""): string { for (const key of keys) if (args[key] !== undefined && args[key] !== null && args[key] !== "") return String(args[key]); return fallback; } function list(value: string): string[] { return value.split(/[;,,;\s]+/).map((item) => item.trim()).filter(Boolean); } function accountObject(value: unknown): Record { if (value && typeof value === "object") return value as Record; if (typeof value === "string" && value.trim().startsWith("{")) { try { return JSON.parse(value); } catch { return {}; } } return {}; } register(["Mail_Send", "Mail_Send_V1", "Mail_UnionSend", "Mail_UnionSend_v1"], async (_ctx, node, args) => { const configured = accountObject(args.account_obj); const account = text({ ...configured, ...args }, ["account", "from", "username", "user"]); const password = text({ ...configured, ...args }, ["password", "pass"]); const host = text({ ...configured, ...args }, ["host", "smtp", "smtpHost", "server"]); if (!host || !account) throw new Error(`邮件配置不完整(${node.componentId})`); const port = Number(text({ ...configured, ...args }, ["port", "smtpPort"], "25")); const secure = ["true", "1", "是", "True"].includes(text({ ...configured, ...args }, ["is_ssl", "secure", "ssl"], "False")); const transporter = nodemailer.createTransport({ host, port, secure, auth: password ? { user: account, pass: password } : undefined }); try { const result = await transporter.sendMail({ from: account, to: list(text(args, ["to", "receiver"])), cc: list(text(args, ["Cc", "cc"])), bcc: list(text(args, ["Bcc", "bcc"])), subject: text(args, ["subject"]), text: text(args, ["content", "body"]), attachments: text(args, ["attachment"]) ? [{ path: text(args, ["attachment"]) }] : undefined }); return { messageId: result.messageId, accepted: result.accepted }; } finally { transporter.close(); } }); export interface NormalizedMail { uid: string; from: string; to: string[]; cc: string[]; subject: string; text: string; html: string; date: string; messageId: string; hasAttachment: boolean; attachmentNames: string[]; } export function normalizeAccount(args: Record): Record { return accountObject(args.account_obj ?? args.accountObj ?? args.account); } function addressList(value: unknown): string[] { if (!value) return []; if (Array.isArray(value)) return value.map((item) => String(typeof item === "object" && item && "text" in (item as Record) ? (item as { text: unknown }).text : item)).filter(Boolean); if (typeof value === "object") { const record = value as { text?: unknown; value?: unknown }; return addressList(record.text ?? record.value); } return String(value).split(/[;,,;\s]+/).filter(Boolean); } export function normalizeMail(input: Record, uid = ""): NormalizedMail { const attachments = Array.isArray(input.attachments) ? input.attachments as Array> : []; return { uid: String(input.uid ?? uid), from: String(input.from ?? input.sender ?? ""), to: addressList(input.to), cc: addressList(input.cc), subject: String(input.subject ?? ""), text: String(input.text ?? input.body ?? ""), html: String(input.html ?? ""), date: input.date instanceof Date ? input.date.toISOString() : String(input.date ?? input.receivedTime ?? ""), messageId: String(input.messageId ?? ""), hasAttachment: Boolean(input.hasAttachment ?? attachments.length), attachmentNames: attachments.map((item) => String(item.filename ?? item.name ?? "")).filter(Boolean) }; } function matches(mail: NormalizedMail, args: Record): boolean { const contains = (needle: unknown, haystack: string): boolean => !needle || haystack.toLowerCase().includes(String(needle).toLowerCase()); return contains(args.From_data, mail.from) && contains(args.To_data, mail.to.join(" ")) && contains(args.Subject_data, mail.subject) && contains(args.Body_data, mail.text + "\n" + mail.html) && (args.attachment === undefined || String(args.attachment) === "2" || (String(args.attachment) === "1") === mail.hasAttachment); } export async function findImapMail(args: Record, node: { id: string; componentId: string }): Promise { const configured = normalizeAccount(args); const merged = { ...args, ...configured }; const account = text(merged, ["account", "username", "user", "from"]); const password = text(merged, ["password", "pass", "token"]); const host = text(merged, ["imap", "imapHost", "imap_host", "server", "host"]); if (!host || !account) throw new RuntimeError("validation_error", "IMAP 配置不完整:需要 host 和 account", { nodeId: node.id, componentId: node.componentId }); const port = Number(text(merged, ["imapPort", "imap_port", "recvPort", "port"], "993")); const secure = ["true", "1", "是", "True"].includes(text(merged, ["imap_ssl", "imapSsl", "is_ssl", "secure"], "True")); const folder = text(args, ["folder_name", "email_folder", "folder"], "INBOX"); const { ImapFlow } = await import("imapflow"); const client = new ImapFlow({ host, port, secure, auth: { user: account, pass: password }, logger: false }); const mails: NormalizedMail[] = []; try { await client.connect(); const lock = await client.getMailboxLock(folder); try { const range = String(args.search_range ?? "ALL").toUpperCase(); const criteria: Record = range === "UNSEEN" ? { seen: false } : range === "SEEN" ? { seen: true } : range === "FLAGGED" ? { flagged: true } : range === "ANSWERED" ? { answered: true } : range === "DELETED" ? { deleted: true } : range === "DRAFT" ? { draft: true } : { all: true }; if (args.From_data) criteria.from = String(args.From_data); if (args.To_data) criteria.to = String(args.To_data); if (args.Subject_data) criteria.subject = String(args.Subject_data); const uids = await client.search(criteria as never, { uid: true }) || []; const selected = Math.max(1, Number(args.email_num ?? 1) || 1); for (let index = uids.length - 1; index >= 0 && mails.length < Math.max(20, selected); index -= 1) { const download = await client.fetchOne(String(uids[index]), { uid: true, envelope: true, flags: true, bodyStructure: true, source: true }, { uid: true }); if (!download || !download.source) continue; const { simpleParser } = await import("mailparser"); const parsed = await simpleParser(download.source); const mail = normalizeMail(parsed as unknown as Record, String(download.uid)); if (matches(mail, args)) mails.push(mail); } } finally { lock.release(); } } finally { client.close(); } const selected = Math.max(1, Number(args.email_num ?? 1) || 1); return mails.slice(0, selected); } register(["Mail_FindMail", "Mail_FindMail_v1", "Mail_FindMail_v2"], async (_ctx, node, args) => findImapMail(args, node)); register(["Mail_ReplyMail", "Mail_ReplyMail_v1", "Mail_ReplyMail_v2"], async (_ctx, node, args) => { const configured = accountObject(args.mail_obj ?? args.email_obj ?? args.account_obj); const mail = configured && Object.keys(configured).length ? configured as unknown as NormalizedMail : args.mail as unknown as NormalizedMail; const merged = { ...args, ...normalizeAccount(args) }; const account = text(merged, ["account", "username", "user", "from"]); const password = text(merged, ["password", "pass"]); const host = text(merged, ["smtp", "smtpHost", "smtp_host", "server", "host"]); if (!host || !account) throw new RuntimeError("validation_error", "SMTP 配置不完整:需要 host 和 account", { nodeId: node.id, componentId: node.componentId }); const port = Number(text(merged, ["smtpPort", "smtp_port", "sendPort", "port"], "465")); const secure = ["true", "1", "是", "True"].includes(text(merged, ["smtp_ssl", "smtpSsl", "is_ssl", "secure"], "True")); const transporter = nodemailer.createTransport({ host, port, secure, auth: password ? { user: account, pass: password } : undefined }); try { const result = await transporter.sendMail({ from: account, to: mail?.from ? [mail.from] : list(text(args, ["to", "receiver"])), subject: text(args, ["subject"], mail?.subject ? "Re: " + mail.subject : "Re:"), text: text(args, ["content", "body"]), inReplyTo: mail?.messageId, references: mail?.messageId, attachments: text(args, ["attachment"]) ? [{ path: text(args, ["attachment"]) }] : undefined }); return { messageId: result.messageId, accepted: result.accepted }; } finally { transporter.close(); } }); function outlookScript(args: Record): string { const criteria = Buffer.from(JSON.stringify({ to: String(args.To_data ?? ""), from: String(args.From_data ?? ""), subject: String(args.Subject_data ?? ""), body: String(args.Body_data ?? "") }), "utf8").toString("base64"); return [ "$outlook=New-Object -ComObject Outlook.Application;$ns=$outlook.GetNamespace('MAPI')", `$folderPath=${JSON.stringify(String(args.folder_name || args.email_folder || "INBOX"))}`, "$folder=$ns.GetDefaultFolder(6);foreach($part in ($folderPath -split '/')){ if($part -and $part -ne 'INBOX'){ $folder=$folder.Folders.Item($part) } }", "$folder.Items.Sort('[ReceivedTime]',$true)|Out-Null;$rows=@();$count=[Math]::Min(500,$folder.Items.Count)", `$criteria=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${criteria}'))|ConvertFrom-Json`, "for($i=1;$i -le $count;$i++){ $item=$folder.Items.Item($i); $subject=[string]$item.Subject; $body=[string]$item.Body; $to=[string]$item.To; $from=[string]$item.SenderEmailAddress; if(($criteria.to -and $to -notlike ('*'+$criteria.to+'*')) -or ($criteria.from -and $from -notlike ('*'+$criteria.from+'*')) -or ($criteria.subject -and $subject -notlike ('*'+$criteria.subject+'*')) -or ($criteria.body -and $body -notlike ('*'+$criteria.body+'*'))){continue}; $bodyB64=[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($body)); $rows += @{uid=[string]$item.EntryID;from=$from;to=$to;subject=$subject;text=$bodyB64;html='';date=$item.ReceivedTime.ToString('o');messageId=[string]$item.EntryID;hasAttachment=($item.Attachments.Count -gt 0);attachmentNames=@()}; if($rows.Count -ge 100){break} }", "$json=$rows|ConvertTo-Json -Compress -Depth 4;[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($json))|Write-Output" ].join(";"); } register(["Mail_DoGetMailMessage", "Mail_DoGetMailMessage_v1", "Mail_DoGetMailMessage_v2"], async (_ctx, node, args) => { const output = await powershell(outlookScript(args), 120000); if (!output.trim()) throw new RuntimeError("outlook_unavailable", "Outlook COM 未返回数据;请确认 Outlook 已安装并至少配置一个邮箱", { nodeId: node.id, componentId: node.componentId }); const rows = JSON.parse(Buffer.from(output.trim(), "base64").toString("utf8")); const mails = (Array.isArray(rows) ? rows : [rows]).map((row: Record) => normalizeMail({ ...row, text: row.text ? Buffer.from(String(row.text), "base64").toString("utf8") : "" })); const selected = Math.max(1, Number(args.email_num ?? 1) || 1); return mails.slice(0, selected); });