Files
mail-rs/mailService.js
2026-04-25 20:51:08 +08:00

228 lines
6.2 KiB
JavaScript

const nodemailer = require('nodemailer');
const { ImapFlow } = require('imapflow');
const { simpleParser } = require('mailparser');
function createSmtpTransport(channel, account) {
return nodemailer.createTransport({
host: channel.smtp_host,
port: channel.smtp_port,
secure: Boolean(channel.smtp_secure),
auth: {
user: account.email,
pass: account.auth_code,
},
});
}
function createImapClient(channel, account) {
return new ImapFlow({
host: channel.imap_host,
port: channel.imap_port,
secure: Boolean(channel.imap_secure),
auth: {
user: account.email,
pass: account.auth_code,
},
});
}
async function sendMail({ channel, account, payload }) {
const transporter = createSmtpTransport(channel, account);
const info = await transporter.sendMail({
from: payload.from || formatFrom(account),
to: payload.to,
cc: payload.cc,
bcc: payload.bcc,
subject: payload.subject,
text: payload.text,
html: payload.html,
});
return {
messageId: info.messageId,
accepted: info.accepted,
rejected: info.rejected,
response: info.response,
};
}
function formatFrom(account) {
if (account.display_name) {
return `${account.display_name} <${account.email}>`;
}
return account.email;
}
function normalizeAddressList(list = []) {
return list.map((item) => ({
name: item.name || '',
address: item.address || '',
}));
}
async function fetchLatestEmails({ channel, account, limit = 20, folder = 'INBOX', includeAllFolders = true }) {
const client = createImapClient(channel, account);
try {
await client.connect();
const folders = includeAllFolders ? await listReadableFolders(client) : [folder];
const candidates = [];
const perFolderLimit = Math.max(limit * 2, 20);
for (const folderName of folders) {
try {
const folderCandidates = await fetchFolderCandidates(client, folderName, perFolderLimit);
candidates.push(...folderCandidates);
} catch (_error) {
// Some providers expose special folders that are listed but not fetchable.
continue;
}
}
const targetMessages = candidates
.sort((a, b) => compareDatesDesc(a.date, b.date))
.slice(0, limit > 0 ? limit : candidates.length);
return hydrateMessages(client, targetMessages);
} finally {
await client.logout().catch(() => {});
}
}
async function listReadableFolders(client) {
const folders = [];
const listed = await client.list();
for (const mailbox of listed) {
if (mailbox.flags?.has('\\Noselect')) {
continue;
}
folders.push(mailbox.path);
}
if (!folders.length) {
return ['INBOX'];
}
return folders;
}
async function fetchFolderCandidates(client, folder, limit) {
const mailbox = await client.mailboxOpen(folder);
const total = mailbox.exists || 0;
if (!total) {
return [];
}
const messages = [];
const start = Math.max(total - limit + 1, 1);
for await (const message of client.fetch(`${start}:${total}`, {
uid: true,
envelope: true,
})) {
messages.push({
uid: `${folder}:${message.uid}`,
rawUid: String(message.uid),
folder,
subject: message.envelope?.subject || '',
from: normalizeAddressList(message.envelope?.from || []),
to: normalizeAddressList(message.envelope?.to || []),
date: message.envelope?.date?.toISOString?.() || null,
});
}
return messages;
}
async function hydrateMessages(client, messages) {
const result = [];
for (const message of messages) {
try {
await client.mailboxOpen(message.folder);
const downloaded = await client.download(message.rawUid, null, { uid: true });
const source = downloaded?.content ? await streamToString(downloaded.content) : '';
const simple = await simpleParser(source);
result.push({
...message,
subject: simple.subject || message.subject || '',
from: normalizeAddressList(simple.from?.value || message.from || []),
to: normalizeAddressList(simple.to?.value || message.to || []),
date: simple.date ? simple.date.toISOString() : message.date,
text: simple.text || '',
html: simple.html || '',
});
} catch (_error) {
result.push({
...message,
text: '',
html: '',
});
}
}
return result;
}
async function fetchMessageDetail({ channel, account, compositeUid }) {
const client = createImapClient(channel, account);
try {
await client.connect();
const [folder, rawUid] = splitCompositeUid(compositeUid);
await client.mailboxOpen(folder);
const envelope = await client.fetchOne(rawUid, { uid: true, envelope: true }, { uid: true });
const downloaded = await client.download(rawUid, null, { uid: true });
const source = downloaded?.content ? await streamToString(downloaded.content) : '';
const simple = await simpleParser(source);
return {
uid: compositeUid,
rawUid,
folder,
subject: simple.subject || envelope?.envelope?.subject || '',
from: normalizeAddressList(simple.from?.value || envelope?.envelope?.from || []),
to: normalizeAddressList(simple.to?.value || envelope?.envelope?.to || []),
date: simple.date ? simple.date.toISOString() : envelope?.envelope?.date?.toISOString?.() || null,
text: simple.text || '',
html: simple.html || '',
};
} finally {
await client.logout().catch(() => {});
}
}
function splitCompositeUid(value) {
const separatorIndex = String(value).indexOf(':');
if (separatorIndex === -1) {
return ['INBOX', String(value)];
}
return [
String(value).slice(0, separatorIndex),
String(value).slice(separatorIndex + 1),
];
}
function streamToString(stream) {
return new Promise((resolve, reject) => {
const chunks = [];
stream.on('data', (chunk) => chunks.push(Buffer.from(chunk)));
stream.on('error', reject);
stream.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
});
}
function compareDatesDesc(a, b) {
const left = a ? new Date(a).getTime() : 0;
const right = b ? new Date(b).getTime() : 0;
return right - left;
}
module.exports = {
sendMail,
fetchLatestEmails,
fetchMessageDetail,
};