254 lines
18 KiB
JavaScript
254 lines
18 KiB
JavaScript
// Gmail and iCloud mail through IMAP (reading, folders, flags, moving) and SMTP (sending), with an app password.
|
|
// Same interface as the Outlook provider in mail.js: every function takes the account and the provider's own ids.
|
|
// Message ids are base64url JSON [folder path, uid]; folders are the same aliases as Outlook (inbox, drafts, sentitems, archive, junkemail,
|
|
// deleteditems) or "p-<base64url path>" for the rest.
|
|
const { ImapFlow } = require('imapflow');
|
|
const nodemailer = require('nodemailer');
|
|
const MailComposer = require('nodemailer/lib/mail-composer');
|
|
const { simpleParser } = require('mailparser');
|
|
|
|
const PRESETS = {
|
|
gmail: { name: 'Gmail', host: 'imap.gmail.com', port: 993, secure: true, smtpHost: 'smtp.gmail.com', smtpPort: 465, smtpSecure: true, appendSent: false },
|
|
icloud: { name: 'iCloud Mail', host: 'imap.mail.me.com', port: 993, secure: true, smtpHost: 'smtp.mail.me.com', smtpPort: 587, smtpSecure: false, appendSent: true },
|
|
};
|
|
// Only the tests set these (a fake server on localhost, no TLS).
|
|
const TEST = process.env.MAIL_TEST_SERVERS ? JSON.parse(process.env.MAIL_TEST_SERVERS) : null;
|
|
const fail = (status, message) => Object.assign(new Error(message), { status });
|
|
const EMAIL = /^[^\s@<>,;]+@[^\s@<>,;]+\.[^\s@<>,;]+$/;
|
|
const MAX_SOURCE = 25 * 1024 * 1024;
|
|
const escHtml = s => String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
const textToHtml = t => '<div style="white-space:pre-wrap;font-family:sans-serif">' + escHtml(t).replace(/\r?\n/g, '<br>') + '</div>';
|
|
const enc = o => Buffer.from(JSON.stringify(o)).toString('base64url');
|
|
const dec = s => { try { const v = JSON.parse(Buffer.from(String(s), 'base64url').toString()); if (Array.isArray(v) && typeof v[0] === 'string' && Number.isInteger(v[1])) return v; } catch {} throw fail(400, 'Bad id'); };
|
|
|
|
const cfgOf = a => {
|
|
const p = PRESETS[a.type]; if (!p) throw fail(400, 'Unknown mail provider');
|
|
return TEST ? { ...p, ...TEST.imap, smtpHost: TEST.smtp.host, smtpPort: TEST.smtp.port, smtpSecure: false, smtpIgnoreTLS: true, test: true } : p;
|
|
};
|
|
/** What went wrong, in words a person can act on. */
|
|
function friendly(a, e) {
|
|
const t = (e && (e.responseText || e.message) || '') + ' ' + (e && e.code || '');
|
|
const p = PRESETS[a.type] || {};
|
|
if (e && (e.authenticationFailed || /AUTHENTICATIONFAILED|Invalid credentials|auth.*fail|535|534|Username and Password not accepted/i.test(t)))
|
|
return a.type === 'gmail' ? 'Google rejected the password. Use an app password (Google account -> Security -> 2-Step Verification -> App passwords), not your normal password.'
|
|
: 'Apple rejected the password. Use an app-specific password (appleid.apple.com -> Sign-In and Security -> App-Specific Passwords), not your Apple ID password.';
|
|
if (/ENOTFOUND|EAI_AGAIN/.test(t)) return `Could not find ${p.name || 'the mail server'} (no internet from the server, or DNS is down).`;
|
|
if (/ECONNREFUSED|ETIMEDOUT|ECONNRESET|timed out|Timeout/i.test(t)) return `Could not reach ${p.name || 'the mail server'}. Try again in a moment.`;
|
|
if (/IMAP.*disabled|not enabled/i.test(t)) return 'IMAP is turned off for this account. Turn it on in the account\'s mail settings.';
|
|
return (e && e.responseText) || (e && e.message) || 'The mail server refused the request';
|
|
}
|
|
|
|
// ---- connections: one per account, kept for a minute after the last use
|
|
const pool = new Map(); // account id -> { client, timer, key }
|
|
const forget = id => { const e = pool.get(id); if (e) { clearTimeout(e.timer); try { e.client.close(); } catch {} pool.delete(id); } };
|
|
const keyOf = a => a.address + '\n' + a.pass;
|
|
async function open(a) {
|
|
const e = pool.get(a.id);
|
|
if (e && e.key === keyOf(a) && e.client.usable) { clearTimeout(e.timer); e.timer = setTimeout(() => forget(a.id), 60e3).unref(); return e.client; }
|
|
forget(a.id);
|
|
const c = cfgOf(a);
|
|
const client = new ImapFlow({ host: c.host, port: c.port, secure: c.secure, ...(c.test ? { doSTARTTLS: false, tls: { rejectUnauthorized: false } } : {}), auth: { user: a.address, pass: a.pass }, logger: false, emitLogs: false, socketTimeout: 60e3, greetingTimeout: 20e3, connectionTimeout: 20e3 });
|
|
client.on('error', () => {}); client.on('close', () => { const cur = pool.get(a.id); if (cur && cur.client === client) pool.delete(a.id); });
|
|
try { await client.connect(); } catch (err) { throw fail(err.authenticationFailed ? 401 : 502, friendly(a, err)); }
|
|
pool.set(a.id, { client, key: keyOf(a), timer: setTimeout(() => forget(a.id), 60e3).unref() });
|
|
return client;
|
|
}
|
|
/** Run `fn(client)`; if the connection had gone stale, reconnect once. */
|
|
async function withClient(a, fn) {
|
|
for (let attempt = 0; ; attempt++) {
|
|
const client = await open(a);
|
|
try { return await fn(client); }
|
|
catch (err) {
|
|
if (err.status) throw err;
|
|
if (attempt === 0 && (!client.usable || /closed|ECONNRESET|socket|EPIPE|Connection not available/i.test(err.message || ''))) { forget(a.id); continue; }
|
|
throw fail(502, friendly(a, err));
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- folders
|
|
const boxCache = new Map(); // account id -> { t, v }
|
|
const SPECIAL = { inbox: ['\\Inbox'], sentitems: ['\\Sent'], drafts: ['\\Drafts'], deleteditems: ['\\Trash'], junkemail: ['\\Junk'], archive: ['\\Archive', '\\All'] };
|
|
const GUESS = { sentitems: /^(\[.*\]\/)?sent( (mail|items|messages))?$/i, drafts: /^(\[.*\]\/)?drafts?$/i, deleteditems: /^(\[.*\]\/)?(trash|deleted( (items|messages))?|bin)$/i, junkemail: /^(\[.*\]\/)?(junk( e-?mail)?|spam)$/i, archive: /^(\[.*\]\/)?(archive|all mail)$/i };
|
|
const NAMES = { inbox: 'Inbox', drafts: 'Drafts', sentitems: 'Sent', archive: 'Archive', junkemail: 'Junk', deleteditems: 'Deleted' };
|
|
async function boxes(a, client) {
|
|
const c = boxCache.get(a.id); if (c && Date.now() - c.t < 60e3) return c.v;
|
|
const list = (await client.list()).filter(b => !b.flags.has('\\Noselect') && !b.flags.has('\\NonExistent'));
|
|
const alias = {}; // alias -> path
|
|
const take = (k, b) => { if (b && !alias[k]) alias[k] = b.path; };
|
|
take('inbox', list.find(b => b.path.toUpperCase() === 'INBOX'));
|
|
for (const k of Object.keys(SPECIAL)) for (const f of SPECIAL[k]) take(k, list.find(b => b.specialUse === f));
|
|
for (const k of Object.keys(GUESS)) take(k, list.find(b => GUESS[k].test(b.path) || GUESS[k].test(b.name)));
|
|
const v = { list, alias };
|
|
boxCache.set(a.id, { t: Date.now(), v }); return v;
|
|
}
|
|
async function pathOf(a, client, folder) {
|
|
const { list, alias } = await boxes(a, client);
|
|
if (alias[folder]) return alias[folder];
|
|
if (/^p-/.test(folder)) { try { const p = Buffer.from(folder.slice(2), 'base64url').toString(); if (list.some(b => b.path === p)) return p; } catch {} }
|
|
throw fail(404, folder === 'archive' ? 'This account has no Archive folder' : 'That folder does not exist');
|
|
}
|
|
|
|
const addrOf = x => x ? { name: x.name || '', address: x.address || '' } : { name: '', address: '' };
|
|
const hasAttachment = n => !!n && (((n.disposition || '').toLowerCase() === 'attachment') || (n.childNodes || []).some(hasAttachment));
|
|
|
|
async function folders(a) {
|
|
return withClient(a, async client => {
|
|
const { list, alias } = await boxes(a, client), byPath = {}; for (const [k, p] of Object.entries(alias)) byPath[p] = k;
|
|
const skip = b => b.flags.has('\\Noselect') || ['\\Flagged', '\\Important'].includes(b.specialUse);
|
|
const rows = [];
|
|
for (const b of list) {
|
|
if (skip(b)) continue;
|
|
let st = {}; try { st = await client.status(b.path, { messages: true, unseen: true }); } catch {}
|
|
const k = byPath[b.path];
|
|
rows.push({ id: k || 'p-' + Buffer.from(b.path).toString('base64url'), name: k ? NAMES[k] : b.path.replace(/^\[.*?\]\//, ''), unread: st.unseen || 0, total: st.messages || 0, well: !!k });
|
|
}
|
|
const order = Object.keys(NAMES);
|
|
return rows.sort((x, y) => (x.well ? order.indexOf(x.id) : 99) - (y.well ? order.indexOf(y.id) : 99) || x.name.localeCompare(y.name));
|
|
});
|
|
}
|
|
|
|
async function list(a, { folder, skip, q }) {
|
|
return withClient(a, async client => {
|
|
const path = await pathOf(a, client, folder || 'inbox');
|
|
const lock = await client.getMailboxLock(path);
|
|
try {
|
|
const total = client.mailbox.exists, query = { uid: true, flags: true, envelope: true, bodyStructure: true, internalDate: true };
|
|
let range, opts = {}, more = false;
|
|
if (q) {
|
|
const uids = (await client.search({ or: [{ subject: q }, { from: q }, { to: q }] }, { uid: true })) || [];
|
|
if (!uids.length) return { items: [], more: false, next: 0 };
|
|
range = uids.slice(-50).join(','); opts = { uid: true };
|
|
} else {
|
|
const hi = total - skip; if (hi < 1) return { items: [], more: false, next: skip };
|
|
range = `${Math.max(1, hi - 29)}:${hi}`; more = hi - 30 >= 1;
|
|
}
|
|
const items = [];
|
|
for await (const m of client.fetch(range, query, opts)) {
|
|
const env = m.envelope || {};
|
|
items.push({ id: enc([path, m.uid]), subject: env.subject || '(No subject)', from: addrOf((env.from || [])[0]), date: (env.date || m.internalDate || new Date()).toISOString(), read: m.flags.has('\\Seen'), attach: hasAttachment(m.bodyStructure), preview: '', flagged: m.flags.has('\\Flagged') });
|
|
}
|
|
items.sort((x, y) => String(y.date).localeCompare(String(x.date)));
|
|
return { items, more, next: skip + items.length };
|
|
} finally { lock.release(); }
|
|
});
|
|
}
|
|
|
|
/** Download and parse one message (refusing anything huge). */
|
|
async function source(a, mid) {
|
|
const [path, uid] = dec(mid);
|
|
return withClient(a, async client => {
|
|
const lock = await client.getMailboxLock(path);
|
|
try {
|
|
const dl = await client.download(String(uid), undefined, { uid: true });
|
|
if (!dl || !dl.content) throw fail(404, 'That message is gone');
|
|
const chunks = []; let n = 0;
|
|
for await (const ch of dl.content) { n += ch.length; if (n > MAX_SOURCE) throw fail(400, 'That message is too large to open here'); chunks.push(ch); }
|
|
return { parsed: await simpleParser(Buffer.concat(chunks)), path, uid };
|
|
} finally { lock.release(); }
|
|
});
|
|
}
|
|
const pick = (list, f) => (list || []).map(x => f(x));
|
|
const addrs = v => (v && v.value ? v.value : []).map(x => ({ name: x.name || '', address: x.address || '' }));
|
|
|
|
async function message(a, mid) {
|
|
const { parsed: p, path, uid } = await source(a, mid);
|
|
let html = p.html || (p.textAsHtml ? p.textAsHtml : textToHtml(p.text || ''));
|
|
const atts = p.attachments || [];
|
|
for (const x of atts) if (x.cid && /^image\//i.test(x.contentType || '') && x.size < 1.5 * 1024 * 1024) html = html.split('cid:' + x.cid).join(`data:${x.contentType};base64,${x.content.toString('base64')}`);
|
|
const flags = await withClient(a, async client => { const lock = await client.getMailboxLock(path); try { const m = await client.fetchOne(String(uid), { flags: true }, { uid: true }); return m ? m.flags : new Set(); } finally { lock.release(); } });
|
|
return { id: mid, subject: p.subject || '(No subject)', from: addrs(p.from)[0] || { name: '', address: '' }, to: addrs(p.to), cc: addrs(p.cc), date: (p.date || new Date()).toISOString(), read: flags.has('\\Seen'), flagged: flags.has('\\Flagged'),
|
|
html, attachments: atts.map((x, i) => ({ x, i })).filter(({ x }) => !x.related).map(({ x, i }) => ({ id: String(i), name: x.filename || 'attachment', type: x.contentType, size: x.size })) };
|
|
}
|
|
async function attachment(a, mid, aid) {
|
|
const { parsed: p } = await source(a, mid), x = (p.attachments || [])[parseInt(aid, 10)];
|
|
if (!x) throw fail(404, 'Attachment not found');
|
|
return { name: x.filename || 'attachment', type: x.contentType, buffer: x.content };
|
|
}
|
|
|
|
async function update(a, mid, { read, flagged }) {
|
|
const [path, uid] = dec(mid);
|
|
if (typeof read !== 'boolean' && typeof flagged !== 'boolean') throw fail(400, 'Nothing to change');
|
|
return withClient(a, async client => {
|
|
const lock = await client.getMailboxLock(path);
|
|
try {
|
|
if (typeof read === 'boolean') await (read ? client.messageFlagsAdd : client.messageFlagsRemove).call(client, String(uid), ['\\Seen'], { uid: true });
|
|
if (typeof flagged === 'boolean') await (flagged ? client.messageFlagsAdd : client.messageFlagsRemove).call(client, String(uid), ['\\Flagged'], { uid: true });
|
|
} finally { lock.release(); }
|
|
});
|
|
}
|
|
async function move(a, mid, to) {
|
|
const [path, uid] = dec(mid);
|
|
return withClient(a, async client => {
|
|
const dest = await pathOf(a, client, to);
|
|
const lock = await client.getMailboxLock(path);
|
|
try { await client.messageMove(String(uid), dest, { uid: true }); } finally { lock.release(); }
|
|
});
|
|
}
|
|
async function del(a, mid, permanent) {
|
|
if (!permanent) return move(a, mid, 'deleteditems');
|
|
const [path, uid] = dec(mid);
|
|
return withClient(a, async client => { const lock = await client.getMailboxLock(path); try { await client.messageDelete(String(uid), { uid: true }); } finally { lock.release(); } });
|
|
}
|
|
async function unread(a) { return withClient(a, async client => (await client.status('INBOX', { unseen: true })).unseen || 0); }
|
|
|
|
// ---- sending
|
|
function transport(a) {
|
|
const c = cfgOf(a);
|
|
return nodemailer.createTransport({ host: c.smtpHost, port: c.smtpPort, secure: c.smtpSecure, ...(c.smtpIgnoreTLS ? { ignoreTLS: true } : { requireTLS: !c.smtpSecure }), auth: { user: a.address, pass: a.pass }, tls: c.test ? { rejectUnauthorized: false } : undefined, connectionTimeout: 20e3, greetingTimeout: 20e3, socketTimeout: 60e3 });
|
|
}
|
|
const fromOf = a => ({ name: a.name || '', address: a.address });
|
|
async function deliver(a, mail) {
|
|
const opts = { from: fromOf(a), ...mail };
|
|
const raw = await new MailComposer(opts).compile().build();
|
|
const all = [...(opts.to || []), ...(opts.cc || []), ...(opts.bcc || [])].map(x => x.address || x);
|
|
try { await transport(a).sendMail({ envelope: { from: a.address, to: all }, raw }); }
|
|
catch (err) { throw fail(err.responseCode === 535 || err.code === 'EAUTH' ? 401 : 502, friendly(a, err)); }
|
|
if (cfgOf(a).appendSent) { // iCloud does not keep a copy of what you send through SMTP, so put it in Sent ourselves
|
|
try { await withClient(a, async client => { const { alias } = await boxes(a, client); if (alias.sentitems) await client.append(alias.sentitems, raw, ['\\Seen']); }); } catch { /* the mail was sent; only the copy is missing */ }
|
|
}
|
|
}
|
|
async function send(a, { to, cc, bcc, subject, body, attachments }) {
|
|
await deliver(a, { to, cc, bcc, subject, text: body, html: textToHtml(body), attachments: attachments.map(x => ({ filename: x.name, contentType: x.type, content: Buffer.from(x.data, 'base64') })) });
|
|
}
|
|
const quote = t => String(t || '').split(/\r?\n/).map(l => '> ' + l).join('\n');
|
|
/** Reply, reply all or forward: built here (IMAP has no such call) so the thread headers and the quoted original are right. */
|
|
async function reply(a, { id: mid, mode, comment, to }) {
|
|
const { parsed: p } = await source(a, mid), me = a.address.toLowerCase();
|
|
const orig = { from: addrs(p.from), replyTo: addrs(p.replyTo), to: addrs(p.to), cc: addrs(p.cc) };
|
|
const when = (p.date || new Date()).toLocaleString('en-US', { dateStyle: 'medium', timeStyle: 'short' }), who = orig.from[0] ? (orig.from[0].name || orig.from[0].address) : 'someone';
|
|
const subj = String(p.subject || '').replace(/^((re|fwd?):\s*)+/i, '');
|
|
const origHtml = (p.html || textToHtml(p.text || '')).replace(/<script[\s\S]*?<\/script>/gi, '');
|
|
const headers = {};
|
|
let mail;
|
|
if (mode === 'forward') {
|
|
const note = `---------- Forwarded message ----------\nFrom: ${who}\nDate: ${when}\nSubject: ${p.subject || ''}\n\n`;
|
|
const files = (p.attachments || []).filter(x => !x.related && x.size < 3 * 1024 * 1024).slice(0, 10).map(x => ({ filename: x.filename || 'attachment', contentType: x.contentType, content: x.content }));
|
|
mail = { to, subject: 'Fwd: ' + subj, text: (comment ? comment + '\n\n' : '') + note + (p.text || ''), html: (comment ? textToHtml(comment) : '') + `<p>${escHtml(note).replace(/\n/g, '<br>')}</p>` + origHtml, attachments: files };
|
|
} else {
|
|
const first = (orig.replyTo[0] || orig.from[0]); if (!first) throw fail(400, 'That message has no sender to reply to');
|
|
let rcpt = [first];
|
|
if (mode === 'replyAll') for (const x of [...orig.to, ...orig.cc]) if (x.address && x.address.toLowerCase() !== me && !rcpt.some(r => r.address.toLowerCase() === x.address.toLowerCase())) rcpt.push(x);
|
|
if (p.messageId) { headers.inReplyTo = p.messageId; headers.references = [].concat(p.references || [], p.messageId).join(' '); }
|
|
mail = { to: rcpt.filter(x => x.address.toLowerCase() !== me || rcpt.length === 1), subject: 'Re: ' + subj, text: (comment ? comment + '\n\n' : '') + `On ${when}, ${who} wrote:\n` + quote(p.text || ''), html: textToHtml(comment || '') + `<p>On ${escHtml(when)}, ${escHtml(who)} wrote:</p><blockquote style="margin:0 0 0 8px;padding-left:10px;border-left:3px solid #ccc">${origHtml}</blockquote>`, ...headers };
|
|
}
|
|
await deliver(a, mail);
|
|
}
|
|
|
|
// ---- adding an account: check the password against the real server first
|
|
async function connectNew({ type, address, password }) {
|
|
const preset = PRESETS[type]; if (!preset) throw fail(400, 'Choose Gmail or iCloud Mail');
|
|
const addr = String(address || '').trim(), pass = String(password || '').replace(/\s+/g, '');
|
|
if (!EMAIL.test(addr)) throw fail(400, 'Enter the full email address');
|
|
if (pass.length < 8) throw fail(400, 'Enter the app password (not your normal password)');
|
|
const a = { id: 'new-' + Math.random().toString(36).slice(2, 8), type, address: addr, pass, name: '' };
|
|
try {
|
|
const client = await open(a);
|
|
await client.list();
|
|
await transport(a).verify().catch(err => { throw fail(err.responseCode === 535 || err.code === 'EAUTH' ? 401 : 502, friendly(a, err)); });
|
|
} finally { forget(a.id); }
|
|
return { type, address: addr, pass, name: '' };
|
|
}
|
|
|
|
module.exports = { PRESETS: Object.fromEntries(Object.entries(PRESETS).map(([k, v]) => [k, { name: v.name }])), folders, list, message, attachment, update, move, del, unread, send, reply, connectNew, forget };
|