228 lines
6.2 KiB
JavaScript
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,
|
|
};
|