feat: mail find reply and outlook message retrieval

This commit is contained in:
yu committed 2026-10-30 17:43:07 +08:00
1 parent 84d73f1b86
commit e390b1b1eb
4 files changed
+743

No files matched your search

@@ -0,0 +1,67 @@
import net from "node:net";
import { afterEach, describe, expect, it } from "vitest";
import type { CatalogComponent, FlowNode } from "../../shared/types";
import type { RunContext } from "../engine/types";
import { getHandler, hasCapability } from "../engine/registry";
import "./message";
const context = {} as RunContext;
const component = {} as CatalogComponent;
const node = (id: string): FlowNode => ({ id, componentId: id, position: { x: 0, y: 0 }, values: {}, options: {} });
const ids = [
"Mail_FindMail", "Mail_FindMail_v1", "Mail_FindMail_v2",
"Mail_ReplyMail", "Mail_ReplyMail_v1", "Mail_ReplyMail_v2",
"Mail_DoGetMailMessage", "Mail_DoGetMailMessage_v1", "Mail_DoGetMailMessage_v2"
];
afterEach(async () => {});
describe("mail receive and reply", () => {
it("registers find, reply and Outlook message capabilities", () => {
for (const id of ids) expect(hasCapability(id), id).toBe(true);
});
it("replies through SMTP with the selected mail sender", async () => {
let received = "";
const server = net.createServer((socket) => {
socket.setEncoding("utf8");
socket.write("220 localhost ESMTP\r\n");
let buffer = "";
let dataMode = false;
socket.on("data", (chunk) => {
buffer += chunk;
const lines = buffer.split("\r\n");
buffer = lines.pop() || "";
for (const line of lines) {
if (dataMode) {
if (line === ".") {
dataMode = false;
socket.write("250 queued\r\n");
} else received += line + "\n";
} else if (/^EHLO/i.test(line)) socket.write("250-localhost\r\n250 OK\r\n");
else if (/^MAIL FROM/i.test(line)) socket.write("250 ok\r\n");
else if (/^RCPT TO/i.test(line)) socket.write("250 ok\r\n");
else if (/^DATA/i.test(line)) { dataMode = true; socket.write("354 end with dot\r\n"); }
else if (/^QUIT/i.test(line)) { socket.write("221 bye\r\n"); socket.end(); }
else socket.write("250 OK\r\n");
}
});
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const port = (server.address() as net.AddressInfo).port;
try {
const handler = getHandler("Mail_ReplyMail_v2");
expect(handler).toBeTruthy();
const result = await handler!(context, node("Mail_ReplyMail_v2"), {
account_obj: { host: "127.0.0.1", port, account: "robot@example.com", secure: "False" },
mail_obj: { from: "owner@example.com", subject: "status", messageId: "<abc@example.com>" },
content: "reply delivered"
}, component);
expect(result).toMatchObject({ accepted: ["owner@example.com"] });
expect(received).toContain("reply delivered");
expect(received).toContain("Re: status");
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
}, 30000);
});
+130
View File
@@ -1,5 +1,7 @@
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]);
@@ -42,3 +44,131 @@ register(["Mail_Send", "Mail_Send_V1", "Mail_UnionSend", "Mail_UnionSend_v1"], a
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);
}
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);
});