mail-access.js 43 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180
  1. import net from 'node:net';
  2. import tls from 'node:tls';
  3. import { readFileSync } from 'node:fs';
  4. import {
  5. STANDARD_INBOUND_FOLDERS,
  6. createInboundFolder,
  7. createInboundMessage,
  8. getInboundMailboxProtocolMessage,
  9. inboundFolderExists,
  10. listInboundFolders,
  11. listInboundMailboxProtocolMessages,
  12. markInboundMessageRead,
  13. softDeleteInboundMessages,
  14. verifyInboundMailboxCredential
  15. } from './db.js';
  16. import { parseInboundMessage } from './inbound-mail.js';
  17. import { decodeModifiedUtf7, encodeModifiedUtf7 } from './imap-utf7.js';
  18. import { authenticateWithRateLimit, authenticationRateLimiter } from './auth-rate-limit.js';
  19. export function startMailboxAccessServers(config) {
  20. const tlsMaterial = loadTlsMaterial(config);
  21. return [
  22. ...startProtocolServers('imap', config.imapEnabled, config.imapListeners, config, tlsMaterial),
  23. ...startProtocolServers('pop3', config.pop3Enabled, config.pop3Listeners, config, tlsMaterial)
  24. ];
  25. }
  26. export function parseMailboxAccessListeners(value, fallback) {
  27. return String(value || fallback || '')
  28. .split(',')
  29. .map((item) => item.trim())
  30. .filter(Boolean)
  31. .map((item) => {
  32. const [portRaw, protocolRaw = ''] = item.split(':');
  33. const port = Number(portRaw);
  34. const protocol = protocolRaw.toLowerCase();
  35. if (!Number.isInteger(port) || port <= 0 || port > 65535) return null;
  36. if (!['imap', 'imaps', 'pop3', 'pop3s'].includes(protocol)) return null;
  37. return { port, protocol };
  38. })
  39. .filter(Boolean);
  40. }
  41. export function publicMailboxAccessListeners(listeners, { tls: tlsEnabled = false } = {}) {
  42. return listeners.map((listener) => ({
  43. port: listener.port,
  44. protocol: publicProtocolLabel(listener.protocol, tlsEnabled)
  45. }));
  46. }
  47. function startProtocolServers(kind, enabled, listeners = [], config, tlsMaterial) {
  48. if (!enabled) return [];
  49. const servers = [];
  50. for (const listener of listeners.filter((item) => item.protocol.startsWith(kind))) {
  51. const implicitTls = listener.protocol.endsWith('s');
  52. if (implicitTls && !tlsMaterial) {
  53. console.warn(`MailHub ${listener.protocol.toUpperCase()} listener on ${listener.port} skipped; TLS certificate is not configured.`);
  54. continue;
  55. }
  56. const listenerConfig = {
  57. ...config,
  58. port: listener.port,
  59. protocol: listener.protocol,
  60. secureContext: tlsMaterial?.secureContext || null,
  61. tlsActive: implicitTls,
  62. startTlsAvailable: !implicitTls && Boolean(tlsMaterial?.secureContext)
  63. };
  64. const handler = (socket) => (
  65. kind === 'imap'
  66. ? new ImapSession(socket, listenerConfig)
  67. : new Pop3Session(socket, listenerConfig)
  68. );
  69. const server = implicitTls
  70. ? tls.createServer({ key: tlsMaterial.key, cert: tlsMaterial.cert }, handler)
  71. : net.createServer(handler);
  72. server.listen(listener.port, '0.0.0.0', () => {
  73. console.log(`MailHub ${listener.protocol.toUpperCase()} listening on 0.0.0.0:${listener.port}`);
  74. });
  75. servers.push(server);
  76. }
  77. return servers;
  78. }
  79. class ImapSession {
  80. constructor(socket, config) {
  81. this.socket = socket;
  82. this.config = config;
  83. this.buffer = Buffer.alloc(0);
  84. this.authenticated = false;
  85. this.authRateLimiter = config.authRateLimiter || authenticationRateLimiter;
  86. this.remoteAddress = socket.remoteAddress || '';
  87. this.user = null;
  88. this.mailbox = null;
  89. this.selectedFolder = 'INBOX';
  90. this.selected = false;
  91. this.messages = [];
  92. this.deletedUids = new Set();
  93. this.authContinuation = null;
  94. this.pendingAppend = null;
  95. this.appendProcessing = false;
  96. this.idleTag = '';
  97. this.onDataBound = (chunk) => this.onData(chunk);
  98. socket.on('data', this.onDataBound);
  99. socket.on('error', () => null);
  100. this.write(`* OK ${config.hostname} MailHub IMAP ready`);
  101. }
  102. onData(chunk) {
  103. const incoming = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk || ''), 'utf8');
  104. if (incoming.length) this.buffer = Buffer.concat([this.buffer, incoming]);
  105. if (this.appendProcessing) return;
  106. while (true) {
  107. if (this.pendingAppend) {
  108. const literal = takeLiteralBytes(this.buffer, this.pendingAppend.bytes);
  109. if (!literal) return;
  110. this.buffer = literal.rest;
  111. if (this.buffer[0] === 0x0d && this.buffer[1] === 0x0a) this.buffer = this.buffer.subarray(2);
  112. else if (this.buffer[0] === 0x0a) this.buffer = this.buffer.subarray(1);
  113. const pending = this.pendingAppend;
  114. this.pendingAppend = null;
  115. this.appendProcessing = true;
  116. void this.finishAppend(pending, literal.value)
  117. .catch((error) => {
  118. console.error(`MailHub IMAP APPEND failed: ${error.message || error}`);
  119. this.write(`${pending.tag} NO APPEND failed`);
  120. })
  121. .finally(() => {
  122. this.appendProcessing = false;
  123. this.onData('');
  124. });
  125. return;
  126. }
  127. const index = this.buffer.indexOf(0x0a);
  128. if (index === -1) return;
  129. let line = this.buffer.subarray(0, index);
  130. this.buffer = this.buffer.subarray(index + 1);
  131. if (line.at(-1) === 0x0d) line = line.subarray(0, -1);
  132. this.onLine(line.toString('utf8'));
  133. }
  134. }
  135. onLine(line) {
  136. if (this.idleTag) {
  137. if (line.toUpperCase() === 'DONE') {
  138. const tag = this.idleTag;
  139. this.idleTag = '';
  140. this.write(`${tag} OK IDLE completed`);
  141. }
  142. return;
  143. }
  144. if (this.authContinuation) {
  145. const continuation = this.authContinuation;
  146. this.authContinuation = null;
  147. return this.finishAuthenticatePlain(continuation.tag, line);
  148. }
  149. const parsed = line.match(/^(\S+)\s+(\S+)(?:\s+(.*))?$/);
  150. if (!parsed) return this.write('* BAD Invalid command');
  151. const [, tag, rawCommand, rest = ''] = parsed;
  152. const command = rawCommand.toUpperCase();
  153. if (command === 'CAPABILITY') return this.capability(tag);
  154. if (command === 'NOOP') return this.write(`${tag} OK NOOP completed`);
  155. if (command === 'LOGOUT') {
  156. this.write('* BYE MailHub IMAP closing connection');
  157. this.write(`${tag} OK LOGOUT completed`);
  158. return this.socket.end();
  159. }
  160. if (command === 'STARTTLS') return this.startTls(tag);
  161. if (command === 'LOGIN') return this.login(tag, rest);
  162. if (command === 'AUTHENTICATE') return this.authenticate(tag, rest);
  163. if (!this.authenticated) return this.write(`${tag} NO Authentication required`);
  164. if (command === 'LIST' || command === 'LSUB') return this.list(tag);
  165. if (command === 'NAMESPACE') return this.namespace(tag);
  166. if (command === 'ID') return this.write(`${tag} OK ID completed`);
  167. if (command === 'SELECT' || command === 'EXAMINE') return this.select(tag, rest, command === 'EXAMINE');
  168. if (command === 'STATUS') return this.status(tag, rest);
  169. if (command === 'CREATE') return this.createFolder(tag, rest);
  170. if (command === 'APPEND') return this.append(tag, rest);
  171. if (command === 'SEARCH') return this.search(tag, rest, false);
  172. if (command === 'UID') return this.uid(tag, rest);
  173. if (!this.selected) return this.write(`${tag} NO Select a mailbox first`);
  174. if (command === 'FETCH') return this.fetch(tag, rest, false);
  175. if (command === 'STORE') return this.store(tag, rest, false);
  176. if (command === 'EXPUNGE') return this.expunge(tag);
  177. if (command === 'CLOSE') return this.closeMailbox(tag);
  178. if (command === 'IDLE') return this.idle(tag);
  179. return this.write(`${tag} BAD Command not implemented`);
  180. }
  181. capability(tag) {
  182. const capabilities = ['IMAP4rev1', 'UIDPLUS', 'IDLE', 'NAMESPACE', 'SPECIAL-USE'];
  183. if (this.config.startTlsAvailable && !this.config.tlsActive) capabilities.push('STARTTLS');
  184. if (this.canAuthenticate()) capabilities.push('AUTH=PLAIN');
  185. this.write(`* CAPABILITY ${capabilities.join(' ')}`);
  186. this.write(`${tag} OK CAPABILITY completed`);
  187. }
  188. startTls(tag) {
  189. if (!this.config.startTlsAvailable || !this.config.secureContext) return this.write(`${tag} NO TLS is not available`);
  190. this.write(`${tag} OK Begin TLS negotiation now`);
  191. this.upgradeToTls();
  192. }
  193. login(tag, rest) {
  194. if (!this.canAuthenticate()) return this.write(`${tag} NO Encryption required for authentication`);
  195. const [username, password] = tokenizeImap(rest);
  196. if (!username || password === undefined) return this.write(`${tag} BAD LOGIN expects username and password`);
  197. const auth = this.verifyCredential(username, password);
  198. if (!auth) return this.write(`${tag} NO Authentication failed`);
  199. this.user = auth.user;
  200. this.mailbox = auth.mailbox;
  201. this.authenticated = true;
  202. this.write(`${tag} OK LOGIN completed`);
  203. }
  204. authenticate(tag, rest) {
  205. if (!this.canAuthenticate()) return this.write(`${tag} NO Encryption required for authentication`);
  206. const [method, initial] = tokenizeImap(rest);
  207. if (String(method || '').toUpperCase() !== 'PLAIN') return this.write(`${tag} NO Unsupported authentication method`);
  208. if (initial) return this.finishAuthenticatePlain(tag, initial);
  209. this.authContinuation = { tag };
  210. this.write('+');
  211. }
  212. finishAuthenticatePlain(tag, response) {
  213. const decoded = decodeBase64(response);
  214. const parts = decoded.split('\u0000');
  215. const username = parts[1] || parts[0] || '';
  216. const password = parts[2] || parts[1] || '';
  217. const auth = this.verifyCredential(username, password);
  218. if (!auth) return this.write(`${tag} NO Authentication failed`);
  219. this.user = auth.user;
  220. this.mailbox = auth.mailbox;
  221. this.authenticated = true;
  222. this.write(`${tag} OK AUTHENTICATE completed`);
  223. }
  224. list(tag) {
  225. for (const folder of listInboundFolders(this.mailbox)) {
  226. this.write(`* LIST (${imapFolderAttributes(folder).join(' ')}) "/" ${imapNString(encodeModifiedUtf7(folder))}`);
  227. }
  228. this.write(`${tag} OK LIST completed`);
  229. }
  230. namespace(tag) {
  231. this.write('* NAMESPACE (("" "/")) NIL NIL');
  232. this.write(`${tag} OK NAMESPACE completed`);
  233. }
  234. select(tag, rest, readOnly) {
  235. const [mailboxName] = tokenizeImap(rest);
  236. const folder = normalizeImapFolder(mailboxName);
  237. if (!inboundFolderExists(this.mailbox, folder)) return this.write(`${tag} NO Mailbox does not exist`);
  238. this.selectedFolder = folder;
  239. this.reloadMessages();
  240. this.selected = true;
  241. this.write(`* FLAGS (${imapAdvertisedFlags(this.messages).join(' ')})`);
  242. this.write(`* ${this.messages.length} EXISTS`);
  243. this.write('* 0 RECENT');
  244. this.write(`* OK [UIDVALIDITY ${this.mailbox.id}] UIDs valid`);
  245. this.write(`* OK [UIDNEXT ${uidNext(this.messages)}] Predicted next UID`);
  246. this.write('* OK [PERMANENTFLAGS (\\Seen \\Deleted)] Limited flags permitted');
  247. this.write(`${tag} OK [${readOnly ? 'READ-ONLY' : 'READ-WRITE'}] SELECT completed`);
  248. }
  249. status(tag, rest) {
  250. const [mailboxName] = tokenizeImap(rest);
  251. const folder = normalizeImapFolder(mailboxName);
  252. if (!inboundFolderExists(this.mailbox, folder)) return this.write(`${tag} NO Mailbox does not exist`);
  253. const messages = mailboxProtocolMessages(this.mailbox, folder);
  254. const unseen = messages.filter((message) => !message.read).length;
  255. this.write(`* STATUS ${imapNString(encodeModifiedUtf7(folder))} (MESSAGES ${messages.length} UNSEEN ${unseen} UIDNEXT ${uidNext(messages)} UIDVALIDITY ${this.mailbox.id})`);
  256. this.write(`${tag} OK STATUS completed`);
  257. }
  258. createFolder(tag, rest) {
  259. const [mailboxName] = tokenizeImap(rest);
  260. const folder = normalizeImapFolder(mailboxName);
  261. if (!folder) return this.write(`${tag} BAD CREATE expects a mailbox name`);
  262. createInboundFolder(this.mailbox, folder);
  263. this.write(`${tag} OK CREATE completed`);
  264. }
  265. append(tag, rest) {
  266. const literalMatch = String(rest || '').match(/\{(\d+)\+?\}\s*$/);
  267. if (!literalMatch) return this.write(`${tag} BAD APPEND expects a literal message`);
  268. const bytes = Number(literalMatch[1]);
  269. if (!Number.isInteger(bytes) || bytes < 0) return this.write(`${tag} BAD APPEND literal size is invalid`);
  270. const prefix = rest.slice(0, literalMatch.index).trim();
  271. const [mailboxName] = tokenizeImap(prefix);
  272. const folder = normalizeImapFolder(mailboxName);
  273. if (!inboundFolderExists(this.mailbox, folder)) return this.write(`${tag} NO Mailbox does not exist`);
  274. this.pendingAppend = {
  275. tag,
  276. folder,
  277. flags: parseFlags(prefix),
  278. bytes
  279. };
  280. this.write('+ Ready for literal data');
  281. }
  282. async finishAppend(pending, rawMessage) {
  283. const parsedMessage = await parseInboundMessage(rawMessage);
  284. const normalizedRaw = normalizeRawMessage(parsedMessage);
  285. const message = createInboundMessage(this.mailbox, {
  286. ...parsedMessage,
  287. folder: pending.folder,
  288. rawMessage: normalizedRaw,
  289. rawMessageBytes: rawMessage
  290. });
  291. if (pending.flags.has('\\SEEN')) markInboundMessageRead(this.mailbox.userId, message.id, true);
  292. this.write(`${pending.tag} OK APPEND completed`);
  293. }
  294. uid(tag, rest) {
  295. const parsed = rest.match(/^(\S+)(?:\s+(.*))?$/);
  296. if (!parsed) return this.write(`${tag} BAD UID expects a subcommand`);
  297. const subcommand = parsed[1].toUpperCase();
  298. const args = parsed[2] || '';
  299. if (subcommand === 'FETCH') return this.fetch(tag, args, true);
  300. if (subcommand === 'STORE') return this.store(tag, args, true);
  301. if (subcommand === 'SEARCH') return this.search(tag, args, true);
  302. return this.write(`${tag} BAD UID subcommand not implemented`);
  303. }
  304. search(tag, rest, byUid) {
  305. if (!this.selected) return this.write(`${tag} NO Select a mailbox first`);
  306. const criteria = parseImapSearchCriteria(rest);
  307. if (criteria.error) return this.write(`${tag} BAD ${criteria.error}`);
  308. const values = this.messages
  309. .map((message, index) => ({ message, seq: index + 1 }))
  310. .filter(({ message }) => matchesImapSearchCriteria(message, this.deletedUids, criteria.flags))
  311. .map(({ message, seq }) => byUid ? message.id : seq);
  312. this.write(`* SEARCH ${values.join(' ')}`.trimEnd());
  313. this.write(`${tag} OK SEARCH completed`);
  314. }
  315. fetch(tag, rest, byUid) {
  316. if (!this.selected) return this.write(`${tag} NO Select INBOX first`);
  317. const [set, items = ''] = splitFirst(rest);
  318. const entries = resolveMessageSet(set, this.messages, byUid);
  319. for (const entry of entries) this.sendFetch(entry, items, byUid);
  320. this.write(`${tag} OK FETCH completed`);
  321. }
  322. sendFetch(entry, items, byUid) {
  323. const upper = String(items || '').toUpperCase();
  324. const contentMessage = fetchNeedsRawMessage(items)
  325. ? loadProtocolMessage(this.mailbox, this.selectedFolder, entry.message)
  326. : entry.message;
  327. const attrs = [];
  328. if (byUid || /\bUID\b/.test(upper)) attrs.push(`UID ${entry.message.id}`);
  329. if (!upper || /\bFLAGS\b/.test(upper)) attrs.push(`FLAGS (${imapFlags(entry.message, this.deletedUids).join(' ')})`);
  330. if (/\bINTERNALDATE\b/.test(upper)) attrs.push(`INTERNALDATE "${imapDate(entry.message.receivedAt)}"`);
  331. if (/RFC822\.SIZE|RFC822|BODY(?:\.PEEK)?\[/i.test(items)) attrs.push(`RFC822.SIZE ${messageBytes(entry.message)}`);
  332. if (/\bENVELOPE\b/.test(upper)) attrs.push(`ENVELOPE ${imapEnvelope(entry.message)}`);
  333. if (/\bBODYSTRUCTURE\b/.test(upper)) attrs.push(`BODYSTRUCTURE ${imapBodyStructure(contentMessage)}`);
  334. const literal = resolveFetchLiteral(items, contentMessage);
  335. if (!literal) {
  336. this.write(`* ${entry.seq} FETCH (${attrs.join(' ')})`);
  337. return;
  338. }
  339. const literalBytes = Buffer.isBuffer(literal.value)
  340. ? literal.value.length
  341. : Buffer.byteLength(literal.value, 'utf8');
  342. const prefix = `* ${entry.seq} FETCH (${[...attrs, `${literal.label} {${literalBytes}}`].join(' ')}\r\n`;
  343. this.socket.write(prefix);
  344. this.socket.write(literal.value);
  345. this.socket.write('\r\n)\r\n');
  346. }
  347. store(tag, rest, byUid) {
  348. const parsed = rest.match(/^(\S+)\s+(\S+)\s+(.+)$/);
  349. if (!parsed) return this.write(`${tag} BAD STORE expects sequence, item, and flags`);
  350. const [, set, itemRaw, flagsRaw] = parsed;
  351. const item = itemRaw.toUpperCase();
  352. const silent = item.includes('.SILENT');
  353. const entries = resolveMessageSet(set, this.messages, byUid);
  354. const flags = parseFlags(flagsRaw);
  355. for (const entry of entries) {
  356. if (flags.has('\\SEEN')) {
  357. const read = !item.startsWith('-FLAGS');
  358. markInboundMessageRead(this.mailbox.userId, entry.message.id, read);
  359. entry.message.read = read;
  360. }
  361. if (flags.has('\\DELETED')) {
  362. if (item.startsWith('-FLAGS')) this.deletedUids.delete(entry.message.id);
  363. else this.deletedUids.add(entry.message.id);
  364. }
  365. if (!silent) this.write(`* ${entry.seq} FETCH (FLAGS (${imapFlags(entry.message, this.deletedUids).join(' ')}))`);
  366. }
  367. this.write(`${tag} OK STORE completed`);
  368. }
  369. expunge(tag) {
  370. const entries = this.messages
  371. .map((message, index) => ({ message, seq: index + 1 }))
  372. .filter((entry) => this.deletedUids.has(entry.message.id));
  373. softDeleteInboundMessages(this.mailbox.userId, this.mailbox.id, entries.map((entry) => entry.message.id), { folder: this.selectedFolder });
  374. for (const entry of entries.reverse()) this.write(`* ${entry.seq} EXPUNGE`);
  375. this.deletedUids.clear();
  376. this.reloadMessages();
  377. this.write(`${tag} OK EXPUNGE completed`);
  378. }
  379. closeMailbox(tag) {
  380. const ids = [...this.deletedUids];
  381. if (ids.length) softDeleteInboundMessages(this.mailbox.userId, this.mailbox.id, ids, { folder: this.selectedFolder });
  382. this.deletedUids.clear();
  383. this.selected = false;
  384. this.messages = [];
  385. this.write(`${tag} OK CLOSE completed`);
  386. }
  387. idle(tag) {
  388. this.idleTag = tag;
  389. this.write('+ idling');
  390. }
  391. reloadMessages() {
  392. this.messages = mailboxProtocolMessages(this.mailbox, this.selectedFolder);
  393. }
  394. upgradeToTls() {
  395. this.socket.removeListener('data', this.onDataBound);
  396. const secureSocket = new tls.TLSSocket(this.socket, {
  397. isServer: true,
  398. secureContext: this.config.secureContext
  399. });
  400. this.socket = secureSocket;
  401. this.buffer = Buffer.alloc(0);
  402. this.config = { ...this.config, tlsActive: true, startTlsAvailable: false };
  403. secureSocket.on('data', this.onDataBound);
  404. secureSocket.on('error', () => null);
  405. }
  406. canAuthenticate() {
  407. return this.config.tlsActive || this.config.allowInsecureAuth;
  408. }
  409. verifyCredential(username, password) {
  410. return authenticateWithRateLimit({
  411. limiter: this.authRateLimiter,
  412. ip: this.remoteAddress,
  413. account: username,
  414. authenticate: () => verifyInboundMailboxCredential(username, password)
  415. });
  416. }
  417. write(line) {
  418. this.socket.write(`${line}\r\n`);
  419. }
  420. }
  421. class Pop3Session {
  422. constructor(socket, config) {
  423. this.socket = socket;
  424. this.config = config;
  425. this.buffer = '';
  426. this.username = '';
  427. this.authenticated = false;
  428. this.authRateLimiter = config.authRateLimiter || authenticationRateLimiter;
  429. this.remoteAddress = socket.remoteAddress || '';
  430. this.user = null;
  431. this.mailbox = null;
  432. this.messages = [];
  433. this.deletedIndexes = new Set();
  434. this.onDataBound = (chunk) => this.onData(chunk);
  435. socket.setEncoding('utf8');
  436. socket.on('data', this.onDataBound);
  437. socket.on('error', () => null);
  438. this.write(`+OK ${config.hostname} MailHub POP3 ready`);
  439. }
  440. onData(chunk) {
  441. this.buffer += chunk;
  442. let index;
  443. while ((index = this.buffer.indexOf('\n')) !== -1) {
  444. const line = this.buffer.slice(0, index).replace(/\r$/, '');
  445. this.buffer = this.buffer.slice(index + 1);
  446. this.onLine(line);
  447. }
  448. }
  449. onLine(line) {
  450. const [rawCommand, ...parts] = line.split(' ');
  451. const command = String(rawCommand || '').toUpperCase();
  452. const rest = parts.join(' ').trim();
  453. if (command === 'CAPA') return this.capa();
  454. if (command === 'QUIT') return this.quit();
  455. if (command === 'NOOP') return this.write('+OK');
  456. if (command === 'STLS') return this.startTls();
  457. if (command === 'USER') return this.userCommand(rest);
  458. if (command === 'PASS') return this.pass(rest);
  459. if (command === 'AUTH') return this.auth(rest);
  460. if (!this.authenticated) return this.write('-ERR Authentication required');
  461. if (command === 'STAT') return this.stat();
  462. if (command === 'LIST') return this.list(rest);
  463. if (command === 'UIDL') return this.uidl(rest);
  464. if (command === 'RETR') return this.retr(rest);
  465. if (command === 'TOP') return this.top(rest);
  466. if (command === 'DELE') return this.dele(rest);
  467. if (command === 'RSET') {
  468. this.deletedIndexes.clear();
  469. return this.write('+OK');
  470. }
  471. return this.write('-ERR Command not implemented');
  472. }
  473. capa() {
  474. this.write('+OK Capability list follows');
  475. this.write('USER');
  476. this.write('UIDL');
  477. this.write('TOP');
  478. if (this.config.startTlsAvailable && !this.config.tlsActive) this.write('STLS');
  479. this.write('.');
  480. }
  481. startTls() {
  482. if (!this.config.startTlsAvailable || !this.config.secureContext) return this.write('-ERR TLS is not available');
  483. this.write('+OK Begin TLS negotiation now');
  484. this.upgradeToTls();
  485. }
  486. userCommand(username) {
  487. if (!this.canAuthenticate()) return this.write('-ERR Encryption required for authentication');
  488. this.username = username;
  489. this.write('+OK User accepted');
  490. }
  491. pass(password) {
  492. if (!this.canAuthenticate()) return this.write('-ERR Encryption required for authentication');
  493. if (!this.username) return this.write('-ERR USER required before PASS');
  494. return this.finishAuth(this.username, password);
  495. }
  496. auth(rest) {
  497. if (!this.canAuthenticate()) return this.write('-ERR Encryption required for authentication');
  498. const [method, response] = rest.split(/\s+/, 2);
  499. if (String(method || '').toUpperCase() !== 'PLAIN' || !response) return this.write('-ERR Unsupported authentication method');
  500. const parts = decodeBase64(response).split('\u0000');
  501. return this.finishAuth(parts[1] || parts[0] || '', parts[2] || parts[1] || '');
  502. }
  503. finishAuth(username, password) {
  504. const auth = authenticateWithRateLimit({
  505. limiter: this.authRateLimiter,
  506. ip: this.remoteAddress,
  507. account: username,
  508. authenticate: () => verifyInboundMailboxCredential(username, password)
  509. });
  510. if (!auth) return this.write('-ERR Authentication failed');
  511. this.user = auth.user;
  512. this.mailbox = auth.mailbox;
  513. this.authenticated = true;
  514. this.messages = mailboxProtocolMessages(this.mailbox);
  515. this.deletedIndexes.clear();
  516. return this.write('+OK Mailbox locked and ready');
  517. }
  518. stat() {
  519. const active = this.activeMessages();
  520. this.write(`+OK ${active.length} ${active.reduce((total, item) => total + pop3MessageBytes(item.message), 0)}`);
  521. }
  522. list(rest) {
  523. if (rest) {
  524. const entry = this.messageByNumber(rest);
  525. if (!entry) return this.write('-ERR No such message');
  526. return this.write(`+OK ${entry.index} ${pop3MessageBytes(entry.message)}`);
  527. }
  528. this.write('+OK Message list follows');
  529. for (const entry of this.activeMessages()) this.write(`${entry.index} ${pop3MessageBytes(entry.message)}`);
  530. this.write('.');
  531. }
  532. uidl(rest) {
  533. if (rest) {
  534. const entry = this.messageByNumber(rest);
  535. if (!entry) return this.write('-ERR No such message');
  536. return this.write(`+OK ${entry.index} ${pop3Uid(entry.message)}`);
  537. }
  538. this.write('+OK Unique IDs follow');
  539. for (const entry of this.activeMessages()) this.write(`${entry.index} ${pop3Uid(entry.message)}`);
  540. this.write('.');
  541. }
  542. retr(rest) {
  543. const entry = this.messageByNumber(rest);
  544. if (!entry) return this.write('-ERR No such message');
  545. const rawMessage = pop3RawMessageBytes(loadProtocolMessage(this.mailbox, 'INBOX', entry.message));
  546. this.write(`+OK ${rawMessage.length} octets`);
  547. this.socket.write(dotStuffBytes(rawMessage));
  548. this.socket.write('.\r\n');
  549. }
  550. top(rest) {
  551. const [messageNumber, lineCountRaw] = rest.split(/\s+/, 2);
  552. const entry = this.messageByNumber(messageNumber);
  553. if (!entry) return this.write('-ERR No such message');
  554. const lineCount = Math.max(0, Number(lineCountRaw || 0) || 0);
  555. const message = loadProtocolMessage(this.mailbox, 'INBOX', entry.message);
  556. const preview = Buffer.from(
  557. topLines(pop3RawMessageBytes(message).toString('latin1'), lineCount),
  558. 'latin1'
  559. );
  560. this.write('+OK Top of message follows');
  561. this.socket.write(dotStuffBytes(ensureTrailingCrlf(preview)));
  562. this.socket.write('.\r\n');
  563. }
  564. dele(rest) {
  565. const entry = this.messageByNumber(rest);
  566. if (!entry) return this.write('-ERR No such message');
  567. this.deletedIndexes.add(entry.index);
  568. this.write(`+OK Message ${entry.index} deleted`);
  569. }
  570. quit() {
  571. if (this.authenticated && this.deletedIndexes.size) {
  572. const ids = [...this.deletedIndexes]
  573. .map((index) => this.messages[index - 1]?.id)
  574. .filter(Boolean);
  575. softDeleteInboundMessages(this.mailbox.userId, this.mailbox.id, ids);
  576. }
  577. this.write('+OK Bye');
  578. this.socket.end();
  579. }
  580. activeMessages() {
  581. return this.messages
  582. .map((message, index) => ({ message, index: index + 1 }))
  583. .filter((entry) => !this.deletedIndexes.has(entry.index));
  584. }
  585. messageByNumber(value) {
  586. const index = Number(value);
  587. if (!Number.isInteger(index) || index < 1 || index > this.messages.length || this.deletedIndexes.has(index)) return null;
  588. return { message: this.messages[index - 1], index };
  589. }
  590. upgradeToTls() {
  591. this.socket.removeListener('data', this.onDataBound);
  592. const secureSocket = new tls.TLSSocket(this.socket, {
  593. isServer: true,
  594. secureContext: this.config.secureContext
  595. });
  596. this.socket = secureSocket;
  597. this.buffer = '';
  598. this.config = { ...this.config, tlsActive: true, startTlsAvailable: false };
  599. secureSocket.setEncoding('utf8');
  600. secureSocket.on('data', this.onDataBound);
  601. secureSocket.on('error', () => null);
  602. }
  603. canAuthenticate() {
  604. return this.config.tlsActive || this.config.allowInsecureAuth;
  605. }
  606. write(line) {
  607. this.socket.write(`${line}\r\n`);
  608. }
  609. }
  610. function loadTlsMaterial(config) {
  611. if (!config.tlsKeyPath || !config.tlsCertPath) return null;
  612. try {
  613. const key = readFileSync(config.tlsKeyPath);
  614. const cert = readFileSync(config.tlsCertPath);
  615. return {
  616. key,
  617. cert,
  618. secureContext: tls.createSecureContext({ key, cert })
  619. };
  620. } catch (error) {
  621. console.warn(`Unable to load mailbox access TLS certificate: ${error.message}`);
  622. return null;
  623. }
  624. }
  625. function publicProtocolLabel(protocol, tlsEnabled) {
  626. if (protocol === 'imaps') return 'IMAPS';
  627. if (protocol === 'pop3s') return 'POP3S';
  628. if (protocol === 'imap') return tlsEnabled ? 'IMAP + STARTTLS' : 'IMAP';
  629. return tlsEnabled ? 'POP3 + STLS' : 'POP3';
  630. }
  631. function mailboxProtocolMessages(mailbox, folder = 'INBOX') {
  632. return listInboundMailboxProtocolMessages(mailbox, { folder });
  633. }
  634. function loadProtocolMessage(mailbox, folder, summary) {
  635. return getInboundMailboxProtocolMessage(mailbox, summary.id, { folder }) || summary;
  636. }
  637. function fetchNeedsRawMessage(items) {
  638. return /\bBODYSTRUCTURE\b|\bRFC822\b(?!\.SIZE)|BODY(?:\.PEEK)?\[/i.test(String(items || ''));
  639. }
  640. function tokenizeImap(value) {
  641. const tokens = [];
  642. const input = String(value || '');
  643. let token = '';
  644. let quoted = false;
  645. let escaping = false;
  646. for (const char of input) {
  647. if (escaping) {
  648. token += char;
  649. escaping = false;
  650. continue;
  651. }
  652. if (quoted && char === '\\') {
  653. escaping = true;
  654. continue;
  655. }
  656. if (char === '"') {
  657. quoted = !quoted;
  658. continue;
  659. }
  660. if (!quoted && /\s/.test(char)) {
  661. if (token) {
  662. tokens.push(token);
  663. token = '';
  664. }
  665. continue;
  666. }
  667. token += char;
  668. }
  669. if (token) tokens.push(token);
  670. return tokens;
  671. }
  672. function parseImapSearchCriteria(value) {
  673. let tokens = tokenizeImap(value);
  674. if (String(tokens[0] || '').toUpperCase() === 'CHARSET') {
  675. if (!tokens[1]) return { error: 'SEARCH CHARSET expects a name', flags: [] };
  676. if (tokens[1].toUpperCase() !== 'UTF-8') return { error: 'Unsupported SEARCH charset', flags: [] };
  677. tokens = tokens.slice(2);
  678. }
  679. if (!tokens.length) return { error: 'SEARCH expects criteria', flags: [] };
  680. const flagCriteria = {
  681. SEEN: ['\\Seen', true],
  682. UNSEEN: ['\\Seen', false],
  683. DELETED: ['\\Deleted', true],
  684. UNDELETED: ['\\Deleted', false],
  685. FLAGGED: ['\\Flagged', true],
  686. UNFLAGGED: ['\\Flagged', false],
  687. ANSWERED: ['\\Answered', true],
  688. UNANSWERED: ['\\Answered', false],
  689. DRAFT: ['\\Draft', true],
  690. UNDRAFT: ['\\Draft', false]
  691. };
  692. const flags = [];
  693. for (const token of tokens) {
  694. const criterion = token.toUpperCase();
  695. if (criterion === 'ALL') continue;
  696. if (!flagCriteria[criterion]) return { error: `Unsupported SEARCH criterion: ${token}`, flags: [] };
  697. const [flag, present] = flagCriteria[criterion];
  698. flags.push({ flag, present });
  699. }
  700. return { error: '', flags };
  701. }
  702. function matchesImapSearchCriteria(message, deletedUids, criteria) {
  703. const flags = new Set(imapFlags(message, deletedUids).map((flag) => flag.toUpperCase()));
  704. return criteria.every(({ flag, present }) => flags.has(flag.toUpperCase()) === present);
  705. }
  706. function takeLiteralBytes(input, byteCount) {
  707. const buffer = Buffer.isBuffer(input) ? input : Buffer.from(input || '');
  708. if (buffer.length < byteCount) return null;
  709. return {
  710. value: buffer.subarray(0, byteCount),
  711. rest: buffer.subarray(byteCount)
  712. };
  713. }
  714. function parseMessageHeaders(rawMessage) {
  715. const headers = {};
  716. let current = '';
  717. for (const line of headerBlock(rawMessage).replace(/\r\n\r\n$/, '').split('\r\n')) {
  718. if (!line) continue;
  719. if (/^[\t ]/.test(line) && current) {
  720. headers[current] = `${headers[current]} ${line.trim()}`.trim();
  721. continue;
  722. }
  723. const separator = line.indexOf(':');
  724. if (separator === -1) continue;
  725. current = line.slice(0, separator).trim().toLowerCase();
  726. headers[current] = line.slice(separator + 1).trim();
  727. }
  728. return headers;
  729. }
  730. function splitFirst(value) {
  731. const input = String(value || '').trim();
  732. const index = input.search(/\s/);
  733. if (index === -1) return [input, ''];
  734. return [input.slice(0, index), input.slice(index + 1).trim()];
  735. }
  736. function normalizeImapFolder(value) {
  737. const raw = decodeModifiedUtf7(String(value || '').trim().replace(/^"|"$/g, '')).replace(/\\/g, '/');
  738. if (!raw || /[\r\n\u0000]/.test(raw)) return '';
  739. if (raw.toUpperCase() === 'INBOX') return 'INBOX';
  740. const standard = STANDARD_INBOUND_FOLDERS.find((folder) => folder.toLowerCase() === raw.toLowerCase());
  741. if (standard) return standard;
  742. return raw
  743. .split('/')
  744. .map((part) => part.trim())
  745. .filter(Boolean)
  746. .join('/');
  747. }
  748. function imapFolderAttributes(folder) {
  749. const attrs = ['\\HasNoChildren'];
  750. const specialUse = {
  751. Sent: '\\Sent',
  752. Drafts: '\\Drafts',
  753. Trash: '\\Trash',
  754. Junk: '\\Junk',
  755. Archive: '\\Archive'
  756. }[folder];
  757. if (specialUse) attrs.push(specialUse);
  758. return attrs;
  759. }
  760. function resolveMessageSet(set, messages, byUid) {
  761. const max = messages.length;
  762. const entries = [];
  763. for (const part of String(set || '').split(',').filter(Boolean)) {
  764. const [startRaw, endRaw] = part.split(':');
  765. const start = resolveSetValue(startRaw, messages, byUid);
  766. const end = endRaw === undefined ? start : resolveSetValue(endRaw, messages, byUid);
  767. if (start === null || end === null) continue;
  768. const low = Math.min(start, end);
  769. const high = Math.max(start, end);
  770. for (let index = 0; index < max; index += 1) {
  771. const value = byUid ? messages[index].id : index + 1;
  772. if (value >= low && value <= high) entries.push({ seq: index + 1, message: messages[index] });
  773. }
  774. }
  775. return [...new Map(entries.map((entry) => [entry.message.id, entry])).values()];
  776. }
  777. function resolveSetValue(value, messages, byUid) {
  778. const clean = String(value || '').trim();
  779. if (clean === '*') return byUid ? messages.at(-1)?.id || 0 : messages.length;
  780. const number = Number(clean);
  781. return Number.isInteger(number) && number >= 0 ? number : null;
  782. }
  783. function resolveFetchLiteral(items, message) {
  784. const rawBytes = exactRawMessageBytes(message);
  785. const raw = rawBytes.toString('latin1');
  786. if (/\bRFC822\b(?!\.SIZE|\.HEADER|\.TEXT)/i.test(items)) return { label: 'RFC822', value: rawBytes };
  787. if (/RFC822\.HEADER/i.test(items)) return { label: 'RFC822.HEADER', value: Buffer.from(headerBlock(raw), 'latin1') };
  788. if (/RFC822\.TEXT/i.test(items)) return { label: 'RFC822.TEXT', value: Buffer.from(bodyBlock(raw), 'latin1') };
  789. const bodyMatch = String(items || '').match(/BODY(?:\.PEEK)?\[([^\]]*)\]/i);
  790. if (!bodyMatch) return null;
  791. const section = bodyMatch[1] || '';
  792. return {
  793. label: `BODY[${section}]`,
  794. value: section ? Buffer.from(bodySection(raw, section), 'latin1') : rawBytes
  795. };
  796. }
  797. function bodySection(raw, section) {
  798. const clean = String(section || '').trim().toUpperCase();
  799. if (!clean) return raw;
  800. if (clean === 'HEADER') return headerBlock(raw);
  801. if (clean === 'TEXT') return bodyBlock(raw);
  802. if (clean.startsWith('HEADER.FIELDS')) return selectedHeaders(raw, clean);
  803. const match = clean.match(/^(\d+(?:\.\d+)*)(?:\.(MIME|HEADER|TEXT))?$/);
  804. if (match) {
  805. const node = resolveMimeSection(parseMimeNode(raw), match[1]);
  806. if (!node) return '';
  807. if (match[2] === 'MIME' || match[2] === 'HEADER') return headerBlock(node.raw);
  808. return node.body;
  809. }
  810. return raw;
  811. }
  812. function imapBodyStructure(message) {
  813. return imapMimeNodeStructure(parseMimeNode(exactRawMessageBytes(message).toString('latin1')));
  814. }
  815. function imapMimeNodeStructure(node) {
  816. if (node.children.length) {
  817. return `(${node.children.map(imapMimeNodeStructure).join(' ')} ${imapNString(node.contentType.subtype.toUpperCase())} ${imapBodyParameters(node.contentType.parameters)})`;
  818. }
  819. const values = [
  820. imapNString(node.contentType.primary.toUpperCase()),
  821. imapNString(node.contentType.subtype.toUpperCase()),
  822. imapBodyParameters(node.contentType.parameters),
  823. imapNString(node.headers['content-id'] || ''),
  824. imapNString(node.headers['content-description'] || ''),
  825. imapNString(node.encoding.toUpperCase()),
  826. String(Buffer.byteLength(node.body, 'latin1'))
  827. ];
  828. if (node.contentType.primary === 'text') values.push(String(imapLineCount(node.body)));
  829. return `(${values.join(' ')})`;
  830. }
  831. function imapBodyParameters(parameters) {
  832. const entries = Object.entries(parameters);
  833. if (!entries.length) return 'NIL';
  834. return `(${entries.map(([name, value]) => `${imapNString(name.toUpperCase())} ${imapNString(value)}`).join(' ')})`;
  835. }
  836. function imapLineCount(value) {
  837. const body = String(value || '').replace(/\r\n$/, '');
  838. return body ? body.split('\r\n').length : 0;
  839. }
  840. function parseMimeNode(rawMessage) {
  841. const raw = String(rawMessage || '').replace(/\r?\n/g, '\r\n');
  842. const headers = parseMessageHeaders(raw);
  843. const contentType = parseMimeContentType(headers['content-type']);
  844. const body = bodyBlock(raw);
  845. const boundary = contentType.primary === 'multipart' ? contentType.parameters.boundary : '';
  846. return {
  847. raw,
  848. headers,
  849. body,
  850. contentType,
  851. encoding: normalizeTransferEncoding(headers['content-transfer-encoding']),
  852. children: boundary ? splitMultipartParts(body, boundary).map(parseMimeNode) : []
  853. };
  854. }
  855. function parseMimeContentType(value) {
  856. const source = String(value || 'text/plain');
  857. const mediaType = source.split(';', 1)[0].trim().toLowerCase();
  858. const [primary = 'text', subtype = 'plain'] = mediaType.split('/');
  859. return {
  860. primary: normalizeMimeToken(primary, 'text'),
  861. subtype: normalizeMimeToken(subtype, 'plain'),
  862. parameters: parseMimeParameters(source)
  863. };
  864. }
  865. function parseMimeParameters(value) {
  866. const parameters = {};
  867. const expression = /;\s*([^=;\s]+)\s*=\s*(?:"((?:\\.|[^"])*)"|([^;]*))/g;
  868. for (const match of String(value || '').matchAll(expression)) {
  869. const name = String(match[1] || '').trim().toLowerCase();
  870. const parameterValue = String(match[2] ?? match[3] ?? '').trim().replace(/\\(.)/g, '$1');
  871. if (name) parameters[name] = parameterValue;
  872. }
  873. return parameters;
  874. }
  875. function normalizeMimeToken(value, fallback) {
  876. const token = String(value || '').trim().replace(/[^a-z0-9!#$&^_.+-]/gi, '');
  877. return token || fallback;
  878. }
  879. function normalizeTransferEncoding(value) {
  880. const encoding = String(value || '7bit').trim().toLowerCase();
  881. return normalizeMimeToken(encoding, '7bit');
  882. }
  883. function splitMultipartParts(body, boundary) {
  884. const marker = `--${boundary}`;
  885. const parts = [];
  886. let current = null;
  887. for (const line of String(body || '').split('\r\n')) {
  888. if (line === marker || line === `${marker}--`) {
  889. if (current !== null) parts.push(current.join('\r\n'));
  890. if (line === `${marker}--`) break;
  891. current = [];
  892. continue;
  893. }
  894. if (current) current.push(line);
  895. }
  896. return parts.filter((part) => part.trim());
  897. }
  898. function resolveMimeSection(root, section) {
  899. const indexes = String(section || '').split('.').map(Number);
  900. if (!indexes.every((index) => Number.isInteger(index) && index > 0)) return null;
  901. if (!root.children.length) return indexes.length === 1 && indexes[0] === 1 ? root : null;
  902. let node = root;
  903. for (const index of indexes) {
  904. node = node.children[index - 1];
  905. if (!node) return null;
  906. }
  907. return node;
  908. }
  909. function selectedHeaders(raw, section) {
  910. const names = new Set((section.match(/\(([^)]*)\)/)?.[1] || '')
  911. .split(/\s+/)
  912. .map((name) => name.toLowerCase())
  913. .filter(Boolean));
  914. if (!names.size) return headerBlock(raw);
  915. const output = [];
  916. let keep = false;
  917. for (const line of headerBlock(raw).split('\r\n')) {
  918. if (!line) continue;
  919. if (/^[\t ]/.test(line)) {
  920. if (keep) output.push(line);
  921. continue;
  922. }
  923. const separator = line.indexOf(':');
  924. const name = separator === -1 ? '' : line.slice(0, separator).toLowerCase();
  925. keep = Boolean(name) && names.has(name);
  926. if (keep) output.push(line);
  927. }
  928. return `${output.join('\r\n')}\r\n\r\n`;
  929. }
  930. function headerBlock(raw) {
  931. const { header } = splitMessageSections(raw);
  932. return `${header.split(/\r\n|\n|\r/).join('\r\n')}\r\n\r\n`;
  933. }
  934. function bodyBlock(raw) {
  935. return splitMessageSections(raw).body;
  936. }
  937. function splitMessageSections(raw) {
  938. const source = String(raw || '');
  939. const separator = /\r\n\r\n|\n\n|\r\r/.exec(source);
  940. if (!separator) return { header: source, body: '' };
  941. return {
  942. header: source.slice(0, separator.index),
  943. body: source.slice(separator.index + separator[0].length)
  944. };
  945. }
  946. function parseFlags(value) {
  947. return new Set(String(value || '').toUpperCase().match(/\\[A-Z]+/g) || []);
  948. }
  949. function imapFlags(message, deletedUids) {
  950. const stored = [...(message.flags || []), ...(message.keywords || [])]
  951. .map(normalizeImapFlag)
  952. .filter((flag) => flag && flag.toLowerCase() !== '\\seen');
  953. if (message.read) stored.push('\\Seen');
  954. if (deletedUids.has(message.id)) stored.push('\\Deleted');
  955. return uniqueImapFlags(stored);
  956. }
  957. function imapAdvertisedFlags(messages) {
  958. const stored = messages.flatMap((message) => [
  959. ...(message.flags || []),
  960. ...(message.keywords || [])
  961. ]);
  962. return uniqueImapFlags([
  963. '\\Seen',
  964. '\\Answered',
  965. '\\Flagged',
  966. '\\Deleted',
  967. '\\Draft',
  968. ...stored
  969. ].map(normalizeImapFlag).filter(Boolean));
  970. }
  971. function normalizeImapFlag(value) {
  972. const flag = String(value || '').trim();
  973. if (/^\\[A-Za-z][A-Za-z0-9._-]*$/.test(flag)) return flag;
  974. if (/^[^\x00-\x20\x7f(){%*"\\\]]+$/.test(flag)) return flag;
  975. return '';
  976. }
  977. function uniqueImapFlags(flags) {
  978. return [...new Map(flags.map((flag) => [flag.toLowerCase(), flag])).values()];
  979. }
  980. function imapEnvelope(message) {
  981. return `("${imapDate(message.receivedAt)}" ${imapNString(message.subject)} ${addressList(message.sender)} NIL NIL ${addressList(message.sender)} ${addressList(message.sender)} NIL NIL ${imapNString(message.messageId)})`;
  982. }
  983. function addressList(address) {
  984. const clean = String(address || '');
  985. const [localPart, domain] = clean.split('@');
  986. if (!localPart || !domain) return 'NIL';
  987. return `((NIL NIL ${imapNString(localPart)} ${imapNString(domain)}))`;
  988. }
  989. function imapNString(value) {
  990. if (!value) return 'NIL';
  991. return `"${String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
  992. }
  993. function imapDate(value) {
  994. const date = value ? new Date(value) : new Date();
  995. return date.toUTCString().replace(',', '');
  996. }
  997. function uidNext(messages) {
  998. return Math.max(0, ...messages.map((message) => Number(message.id) || 0)) + 1;
  999. }
  1000. function messageBytes(message) {
  1001. if (Number.isFinite(Number(message.rawMessageSize))) return Number(message.rawMessageSize);
  1002. return exactRawMessageBytes(message).length;
  1003. }
  1004. function pop3MessageBytes(message) {
  1005. if (Number.isFinite(Number(message.pop3MessageSize))) return Number(message.pop3MessageSize);
  1006. return pop3RawMessageBytes(message).length;
  1007. }
  1008. function pop3RawMessageBytes(message) {
  1009. const normalized = exactRawMessageBytes(message)
  1010. .toString('latin1')
  1011. .replace(/\r?\n/g, '\r\n');
  1012. return ensureTrailingCrlf(Buffer.from(normalized, 'latin1'));
  1013. }
  1014. function ensureTrailingCrlf(value) {
  1015. const buffer = Buffer.isBuffer(value) ? value : Buffer.from(value || '');
  1016. return buffer.length >= 2 && buffer.at(-2) === 0x0d && buffer.at(-1) === 0x0a
  1017. ? buffer
  1018. : Buffer.concat([buffer, Buffer.from('\r\n')]);
  1019. }
  1020. function dotStuffBytes(value) {
  1021. const input = Buffer.isBuffer(value) ? value : Buffer.from(value || '');
  1022. const extraDots = input.reduce((count, byte, index) => (
  1023. byte === 0x2e && (index === 0 || input[index - 1] === 0x0a) ? count + 1 : count
  1024. ), 0);
  1025. const output = Buffer.alloc(input.length + extraDots);
  1026. let offset = 0;
  1027. for (let index = 0; index < input.length; index += 1) {
  1028. if (input[index] === 0x2e && (index === 0 || input[index - 1] === 0x0a)) output[offset++] = 0x2e;
  1029. output[offset++] = input[index];
  1030. }
  1031. return output;
  1032. }
  1033. function exactRawMessageBytes(message) {
  1034. if (Buffer.isBuffer(message.rawMessageBytes)) return message.rawMessageBytes;
  1035. if (message.rawMessageBytes instanceof Uint8Array) return Buffer.from(message.rawMessageBytes);
  1036. return Buffer.from(normalizeRawMessage(message), 'utf8');
  1037. }
  1038. function normalizeRawMessage(message) {
  1039. const raw = String(message.rawMessage || fallbackRawMessage(message) || '').replace(/\r?\n/g, '\r\n');
  1040. return raw.endsWith('\r\n') ? raw : `${raw}\r\n`;
  1041. }
  1042. function fallbackRawMessage(message) {
  1043. return [
  1044. message.sender ? `From: ${message.sender}` : '',
  1045. message.recipients?.length ? `To: ${message.recipients.join(', ')}` : '',
  1046. message.subject ? `Subject: ${message.subject}` : '',
  1047. message.messageId ? `Message-ID: ${message.messageId}` : '',
  1048. message.receivedAt ? `Date: ${new Date(message.receivedAt).toUTCString()}` : '',
  1049. '',
  1050. message.textBody || message.preview || ''
  1051. ].filter((line, index) => line || index >= 5).join('\r\n');
  1052. }
  1053. function topLines(rawMessage, lineCount) {
  1054. const header = headerBlock(rawMessage).replace(/\r\n\r\n$/, '');
  1055. const lines = bodyBlock(rawMessage).split('\r\n').slice(0, lineCount).join('\r\n');
  1056. return `${header}\r\n\r\n${lines}`;
  1057. }
  1058. function pop3Uid(message) {
  1059. return `mh-${message.id}`;
  1060. }
  1061. function decodeBase64(value) {
  1062. try {
  1063. return Buffer.from(String(value || ''), 'base64').toString('utf8');
  1064. } catch {
  1065. return '';
  1066. }
  1067. }