- engine/(registry/types/runtime/expression)物理迁入包, 解耦shared/types(域类型归包, 宿主type-only re-export) - 31个纯Node能力+27个测试迁入包; 宿主留守Electron/浏览器/AI依赖能力, index.ts经@rpa/core/capabilities拉起 - 新增createEngine()/defaultEngine: excel缓存/Excel句柄/db连接/FTP客户端/OCR实例/拟真设置挂引擎实例 - remaining-core子流程依赖改为host-bridge注入, 包内不再import宿主flows - 宿主main/index.ts启动时注入子流程源; electron-vite/vitest/tsconfig配@rpa/core源码别名 - 验证: typecheck绿, 快照909个id diff为零, 332测试过(2个桌面冒烟因会话无交互SetForegroundWindow被拒, 环境阻塞)
175 lines
11 KiB
TypeScript
175 lines
11 KiB
TypeScript
import nodemailer from "nodemailer";
|
||
import { register } from "../engine/registry";
|
||
import { RuntimeError } from "../engine/types";
|
||
import { powershell } from "./desktop";
|
||
|
||
function text(args: Record<string, unknown>, 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<string, unknown> {
|
||
if (value && typeof value === "object") return value as Record<string, unknown>;
|
||
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<string, unknown>): Record<string, unknown> {
|
||
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<string, unknown>) ? (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<string, unknown>, uid = ""): NormalizedMail {
|
||
const attachments = Array.isArray(input.attachments) ? input.attachments as Array<Record<string, unknown>> : [];
|
||
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<string, unknown>): 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<string, unknown>, node: { id: string; componentId: string }): Promise<NormalizedMail[]> {
|
||
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<string, unknown> = 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, unknown>, 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, unknown>): 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<string, unknown>) => 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);
|
||
});
|