mail-access.js 41 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138
  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) this.reloadMessages();
  306. const values = this.messages.map((message, index) => byUid ? message.id : index + 1);
  307. this.write(`* SEARCH ${values.join(' ')}`.trimEnd());
  308. this.write(`${tag} OK SEARCH completed`);
  309. }
  310. fetch(tag, rest, byUid) {
  311. if (!this.selected) return this.write(`${tag} NO Select INBOX first`);
  312. const [set, items = ''] = splitFirst(rest);
  313. const entries = resolveMessageSet(set, this.messages, byUid);
  314. for (const entry of entries) this.sendFetch(entry, items, byUid);
  315. this.write(`${tag} OK FETCH completed`);
  316. }
  317. sendFetch(entry, items, byUid) {
  318. const upper = String(items || '').toUpperCase();
  319. const contentMessage = fetchNeedsRawMessage(items)
  320. ? loadProtocolMessage(this.mailbox, this.selectedFolder, entry.message)
  321. : entry.message;
  322. const attrs = [];
  323. if (byUid || /\bUID\b/.test(upper)) attrs.push(`UID ${entry.message.id}`);
  324. if (!upper || /\bFLAGS\b/.test(upper)) attrs.push(`FLAGS (${imapFlags(entry.message, this.deletedUids).join(' ')})`);
  325. if (/\bINTERNALDATE\b/.test(upper)) attrs.push(`INTERNALDATE "${imapDate(entry.message.receivedAt)}"`);
  326. if (/RFC822\.SIZE|RFC822|BODY(?:\.PEEK)?\[/i.test(items)) attrs.push(`RFC822.SIZE ${messageBytes(entry.message)}`);
  327. if (/\bENVELOPE\b/.test(upper)) attrs.push(`ENVELOPE ${imapEnvelope(entry.message)}`);
  328. if (/\bBODYSTRUCTURE\b/.test(upper)) attrs.push(`BODYSTRUCTURE ${imapBodyStructure(contentMessage)}`);
  329. const literal = resolveFetchLiteral(items, contentMessage);
  330. if (!literal) {
  331. this.write(`* ${entry.seq} FETCH (${attrs.join(' ')})`);
  332. return;
  333. }
  334. const literalBytes = Buffer.isBuffer(literal.value)
  335. ? literal.value.length
  336. : Buffer.byteLength(literal.value, 'utf8');
  337. const prefix = `* ${entry.seq} FETCH (${[...attrs, `${literal.label} {${literalBytes}}`].join(' ')}\r\n`;
  338. this.socket.write(prefix);
  339. this.socket.write(literal.value);
  340. this.socket.write('\r\n)\r\n');
  341. }
  342. store(tag, rest, byUid) {
  343. const parsed = rest.match(/^(\S+)\s+(\S+)\s+(.+)$/);
  344. if (!parsed) return this.write(`${tag} BAD STORE expects sequence, item, and flags`);
  345. const [, set, itemRaw, flagsRaw] = parsed;
  346. const item = itemRaw.toUpperCase();
  347. const silent = item.includes('.SILENT');
  348. const entries = resolveMessageSet(set, this.messages, byUid);
  349. const flags = parseFlags(flagsRaw);
  350. for (const entry of entries) {
  351. if (flags.has('\\SEEN')) {
  352. const read = !item.startsWith('-FLAGS');
  353. markInboundMessageRead(this.mailbox.userId, entry.message.id, read);
  354. entry.message.read = read;
  355. }
  356. if (flags.has('\\DELETED')) {
  357. if (item.startsWith('-FLAGS')) this.deletedUids.delete(entry.message.id);
  358. else this.deletedUids.add(entry.message.id);
  359. }
  360. if (!silent) this.write(`* ${entry.seq} FETCH (FLAGS (${imapFlags(entry.message, this.deletedUids).join(' ')}))`);
  361. }
  362. this.write(`${tag} OK STORE completed`);
  363. }
  364. expunge(tag) {
  365. const entries = this.messages
  366. .map((message, index) => ({ message, seq: index + 1 }))
  367. .filter((entry) => this.deletedUids.has(entry.message.id));
  368. softDeleteInboundMessages(this.mailbox.userId, this.mailbox.id, entries.map((entry) => entry.message.id), { folder: this.selectedFolder });
  369. for (const entry of entries.reverse()) this.write(`* ${entry.seq} EXPUNGE`);
  370. this.deletedUids.clear();
  371. this.reloadMessages();
  372. this.write(`${tag} OK EXPUNGE completed`);
  373. }
  374. closeMailbox(tag) {
  375. const ids = [...this.deletedUids];
  376. if (ids.length) softDeleteInboundMessages(this.mailbox.userId, this.mailbox.id, ids, { folder: this.selectedFolder });
  377. this.deletedUids.clear();
  378. this.selected = false;
  379. this.messages = [];
  380. this.write(`${tag} OK CLOSE completed`);
  381. }
  382. idle(tag) {
  383. this.idleTag = tag;
  384. this.write('+ idling');
  385. }
  386. reloadMessages() {
  387. this.messages = mailboxProtocolMessages(this.mailbox, this.selectedFolder);
  388. }
  389. upgradeToTls() {
  390. this.socket.removeListener('data', this.onDataBound);
  391. const secureSocket = new tls.TLSSocket(this.socket, {
  392. isServer: true,
  393. secureContext: this.config.secureContext
  394. });
  395. this.socket = secureSocket;
  396. this.buffer = Buffer.alloc(0);
  397. this.config = { ...this.config, tlsActive: true, startTlsAvailable: false };
  398. secureSocket.on('data', this.onDataBound);
  399. secureSocket.on('error', () => null);
  400. }
  401. canAuthenticate() {
  402. return this.config.tlsActive || this.config.allowInsecureAuth;
  403. }
  404. verifyCredential(username, password) {
  405. return authenticateWithRateLimit({
  406. limiter: this.authRateLimiter,
  407. ip: this.remoteAddress,
  408. account: username,
  409. authenticate: () => verifyInboundMailboxCredential(username, password)
  410. });
  411. }
  412. write(line) {
  413. this.socket.write(`${line}\r\n`);
  414. }
  415. }
  416. class Pop3Session {
  417. constructor(socket, config) {
  418. this.socket = socket;
  419. this.config = config;
  420. this.buffer = '';
  421. this.username = '';
  422. this.authenticated = false;
  423. this.authRateLimiter = config.authRateLimiter || authenticationRateLimiter;
  424. this.remoteAddress = socket.remoteAddress || '';
  425. this.user = null;
  426. this.mailbox = null;
  427. this.messages = [];
  428. this.deletedIndexes = new Set();
  429. this.onDataBound = (chunk) => this.onData(chunk);
  430. socket.setEncoding('utf8');
  431. socket.on('data', this.onDataBound);
  432. socket.on('error', () => null);
  433. this.write(`+OK ${config.hostname} MailHub POP3 ready`);
  434. }
  435. onData(chunk) {
  436. this.buffer += chunk;
  437. let index;
  438. while ((index = this.buffer.indexOf('\n')) !== -1) {
  439. const line = this.buffer.slice(0, index).replace(/\r$/, '');
  440. this.buffer = this.buffer.slice(index + 1);
  441. this.onLine(line);
  442. }
  443. }
  444. onLine(line) {
  445. const [rawCommand, ...parts] = line.split(' ');
  446. const command = String(rawCommand || '').toUpperCase();
  447. const rest = parts.join(' ').trim();
  448. if (command === 'CAPA') return this.capa();
  449. if (command === 'QUIT') return this.quit();
  450. if (command === 'NOOP') return this.write('+OK');
  451. if (command === 'STLS') return this.startTls();
  452. if (command === 'USER') return this.userCommand(rest);
  453. if (command === 'PASS') return this.pass(rest);
  454. if (command === 'AUTH') return this.auth(rest);
  455. if (!this.authenticated) return this.write('-ERR Authentication required');
  456. if (command === 'STAT') return this.stat();
  457. if (command === 'LIST') return this.list(rest);
  458. if (command === 'UIDL') return this.uidl(rest);
  459. if (command === 'RETR') return this.retr(rest);
  460. if (command === 'TOP') return this.top(rest);
  461. if (command === 'DELE') return this.dele(rest);
  462. if (command === 'RSET') {
  463. this.deletedIndexes.clear();
  464. return this.write('+OK');
  465. }
  466. return this.write('-ERR Command not implemented');
  467. }
  468. capa() {
  469. this.write('+OK Capability list follows');
  470. this.write('USER');
  471. this.write('UIDL');
  472. this.write('TOP');
  473. if (this.config.startTlsAvailable && !this.config.tlsActive) this.write('STLS');
  474. this.write('.');
  475. }
  476. startTls() {
  477. if (!this.config.startTlsAvailable || !this.config.secureContext) return this.write('-ERR TLS is not available');
  478. this.write('+OK Begin TLS negotiation now');
  479. this.upgradeToTls();
  480. }
  481. userCommand(username) {
  482. if (!this.canAuthenticate()) return this.write('-ERR Encryption required for authentication');
  483. this.username = username;
  484. this.write('+OK User accepted');
  485. }
  486. pass(password) {
  487. if (!this.canAuthenticate()) return this.write('-ERR Encryption required for authentication');
  488. if (!this.username) return this.write('-ERR USER required before PASS');
  489. return this.finishAuth(this.username, password);
  490. }
  491. auth(rest) {
  492. if (!this.canAuthenticate()) return this.write('-ERR Encryption required for authentication');
  493. const [method, response] = rest.split(/\s+/, 2);
  494. if (String(method || '').toUpperCase() !== 'PLAIN' || !response) return this.write('-ERR Unsupported authentication method');
  495. const parts = decodeBase64(response).split('\u0000');
  496. return this.finishAuth(parts[1] || parts[0] || '', parts[2] || parts[1] || '');
  497. }
  498. finishAuth(username, password) {
  499. const auth = authenticateWithRateLimit({
  500. limiter: this.authRateLimiter,
  501. ip: this.remoteAddress,
  502. account: username,
  503. authenticate: () => verifyInboundMailboxCredential(username, password)
  504. });
  505. if (!auth) return this.write('-ERR Authentication failed');
  506. this.user = auth.user;
  507. this.mailbox = auth.mailbox;
  508. this.authenticated = true;
  509. this.messages = mailboxProtocolMessages(this.mailbox);
  510. this.deletedIndexes.clear();
  511. return this.write('+OK Mailbox locked and ready');
  512. }
  513. stat() {
  514. const active = this.activeMessages();
  515. this.write(`+OK ${active.length} ${active.reduce((total, item) => total + pop3MessageBytes(item.message), 0)}`);
  516. }
  517. list(rest) {
  518. if (rest) {
  519. const entry = this.messageByNumber(rest);
  520. if (!entry) return this.write('-ERR No such message');
  521. return this.write(`+OK ${entry.index} ${pop3MessageBytes(entry.message)}`);
  522. }
  523. this.write('+OK Message list follows');
  524. for (const entry of this.activeMessages()) this.write(`${entry.index} ${pop3MessageBytes(entry.message)}`);
  525. this.write('.');
  526. }
  527. uidl(rest) {
  528. if (rest) {
  529. const entry = this.messageByNumber(rest);
  530. if (!entry) return this.write('-ERR No such message');
  531. return this.write(`+OK ${entry.index} ${pop3Uid(entry.message)}`);
  532. }
  533. this.write('+OK Unique IDs follow');
  534. for (const entry of this.activeMessages()) this.write(`${entry.index} ${pop3Uid(entry.message)}`);
  535. this.write('.');
  536. }
  537. retr(rest) {
  538. const entry = this.messageByNumber(rest);
  539. if (!entry) return this.write('-ERR No such message');
  540. const rawMessage = pop3RawMessageBytes(loadProtocolMessage(this.mailbox, 'INBOX', entry.message));
  541. this.write(`+OK ${rawMessage.length} octets`);
  542. this.socket.write(dotStuffBytes(rawMessage));
  543. this.socket.write('.\r\n');
  544. }
  545. top(rest) {
  546. const [messageNumber, lineCountRaw] = rest.split(/\s+/, 2);
  547. const entry = this.messageByNumber(messageNumber);
  548. if (!entry) return this.write('-ERR No such message');
  549. const lineCount = Math.max(0, Number(lineCountRaw || 0) || 0);
  550. const message = loadProtocolMessage(this.mailbox, 'INBOX', entry.message);
  551. const preview = Buffer.from(
  552. topLines(pop3RawMessageBytes(message).toString('latin1'), lineCount),
  553. 'latin1'
  554. );
  555. this.write('+OK Top of message follows');
  556. this.socket.write(dotStuffBytes(ensureTrailingCrlf(preview)));
  557. this.socket.write('.\r\n');
  558. }
  559. dele(rest) {
  560. const entry = this.messageByNumber(rest);
  561. if (!entry) return this.write('-ERR No such message');
  562. this.deletedIndexes.add(entry.index);
  563. this.write(`+OK Message ${entry.index} deleted`);
  564. }
  565. quit() {
  566. if (this.authenticated && this.deletedIndexes.size) {
  567. const ids = [...this.deletedIndexes]
  568. .map((index) => this.messages[index - 1]?.id)
  569. .filter(Boolean);
  570. softDeleteInboundMessages(this.mailbox.userId, this.mailbox.id, ids);
  571. }
  572. this.write('+OK Bye');
  573. this.socket.end();
  574. }
  575. activeMessages() {
  576. return this.messages
  577. .map((message, index) => ({ message, index: index + 1 }))
  578. .filter((entry) => !this.deletedIndexes.has(entry.index));
  579. }
  580. messageByNumber(value) {
  581. const index = Number(value);
  582. if (!Number.isInteger(index) || index < 1 || index > this.messages.length || this.deletedIndexes.has(index)) return null;
  583. return { message: this.messages[index - 1], index };
  584. }
  585. upgradeToTls() {
  586. this.socket.removeListener('data', this.onDataBound);
  587. const secureSocket = new tls.TLSSocket(this.socket, {
  588. isServer: true,
  589. secureContext: this.config.secureContext
  590. });
  591. this.socket = secureSocket;
  592. this.buffer = '';
  593. this.config = { ...this.config, tlsActive: true, startTlsAvailable: false };
  594. secureSocket.setEncoding('utf8');
  595. secureSocket.on('data', this.onDataBound);
  596. secureSocket.on('error', () => null);
  597. }
  598. canAuthenticate() {
  599. return this.config.tlsActive || this.config.allowInsecureAuth;
  600. }
  601. write(line) {
  602. this.socket.write(`${line}\r\n`);
  603. }
  604. }
  605. function loadTlsMaterial(config) {
  606. if (!config.tlsKeyPath || !config.tlsCertPath) return null;
  607. try {
  608. const key = readFileSync(config.tlsKeyPath);
  609. const cert = readFileSync(config.tlsCertPath);
  610. return {
  611. key,
  612. cert,
  613. secureContext: tls.createSecureContext({ key, cert })
  614. };
  615. } catch (error) {
  616. console.warn(`Unable to load mailbox access TLS certificate: ${error.message}`);
  617. return null;
  618. }
  619. }
  620. function publicProtocolLabel(protocol, tlsEnabled) {
  621. if (protocol === 'imaps') return 'IMAPS';
  622. if (protocol === 'pop3s') return 'POP3S';
  623. if (protocol === 'imap') return tlsEnabled ? 'IMAP + STARTTLS' : 'IMAP';
  624. return tlsEnabled ? 'POP3 + STLS' : 'POP3';
  625. }
  626. function mailboxProtocolMessages(mailbox, folder = 'INBOX') {
  627. return listInboundMailboxProtocolMessages(mailbox, { folder });
  628. }
  629. function loadProtocolMessage(mailbox, folder, summary) {
  630. return getInboundMailboxProtocolMessage(mailbox, summary.id, { folder }) || summary;
  631. }
  632. function fetchNeedsRawMessage(items) {
  633. return /\bBODYSTRUCTURE\b|\bRFC822\b(?!\.SIZE)|BODY(?:\.PEEK)?\[/i.test(String(items || ''));
  634. }
  635. function tokenizeImap(value) {
  636. const tokens = [];
  637. const input = String(value || '');
  638. let token = '';
  639. let quoted = false;
  640. let escaping = false;
  641. for (const char of input) {
  642. if (escaping) {
  643. token += char;
  644. escaping = false;
  645. continue;
  646. }
  647. if (quoted && char === '\\') {
  648. escaping = true;
  649. continue;
  650. }
  651. if (char === '"') {
  652. quoted = !quoted;
  653. continue;
  654. }
  655. if (!quoted && /\s/.test(char)) {
  656. if (token) {
  657. tokens.push(token);
  658. token = '';
  659. }
  660. continue;
  661. }
  662. token += char;
  663. }
  664. if (token) tokens.push(token);
  665. return tokens;
  666. }
  667. function takeLiteralBytes(input, byteCount) {
  668. const buffer = Buffer.isBuffer(input) ? input : Buffer.from(input || '');
  669. if (buffer.length < byteCount) return null;
  670. return {
  671. value: buffer.subarray(0, byteCount),
  672. rest: buffer.subarray(byteCount)
  673. };
  674. }
  675. function parseMessageHeaders(rawMessage) {
  676. const headers = {};
  677. let current = '';
  678. for (const line of headerBlock(rawMessage).replace(/\r\n\r\n$/, '').split('\r\n')) {
  679. if (!line) continue;
  680. if (/^[\t ]/.test(line) && current) {
  681. headers[current] = `${headers[current]} ${line.trim()}`.trim();
  682. continue;
  683. }
  684. const separator = line.indexOf(':');
  685. if (separator === -1) continue;
  686. current = line.slice(0, separator).trim().toLowerCase();
  687. headers[current] = line.slice(separator + 1).trim();
  688. }
  689. return headers;
  690. }
  691. function splitFirst(value) {
  692. const input = String(value || '').trim();
  693. const index = input.search(/\s/);
  694. if (index === -1) return [input, ''];
  695. return [input.slice(0, index), input.slice(index + 1).trim()];
  696. }
  697. function normalizeImapFolder(value) {
  698. const raw = decodeModifiedUtf7(String(value || '').trim().replace(/^"|"$/g, '')).replace(/\\/g, '/');
  699. if (!raw || /[\r\n\u0000]/.test(raw)) return '';
  700. if (raw.toUpperCase() === 'INBOX') return 'INBOX';
  701. const standard = STANDARD_INBOUND_FOLDERS.find((folder) => folder.toLowerCase() === raw.toLowerCase());
  702. if (standard) return standard;
  703. return raw
  704. .split('/')
  705. .map((part) => part.trim())
  706. .filter(Boolean)
  707. .join('/');
  708. }
  709. function imapFolderAttributes(folder) {
  710. const attrs = ['\\HasNoChildren'];
  711. const specialUse = {
  712. Sent: '\\Sent',
  713. Drafts: '\\Drafts',
  714. Trash: '\\Trash',
  715. Junk: '\\Junk',
  716. Archive: '\\Archive'
  717. }[folder];
  718. if (specialUse) attrs.push(specialUse);
  719. return attrs;
  720. }
  721. function resolveMessageSet(set, messages, byUid) {
  722. const max = messages.length;
  723. const entries = [];
  724. for (const part of String(set || '').split(',').filter(Boolean)) {
  725. const [startRaw, endRaw] = part.split(':');
  726. const start = resolveSetValue(startRaw, messages, byUid);
  727. const end = endRaw === undefined ? start : resolveSetValue(endRaw, messages, byUid);
  728. if (start === null || end === null) continue;
  729. const low = Math.min(start, end);
  730. const high = Math.max(start, end);
  731. for (let index = 0; index < max; index += 1) {
  732. const value = byUid ? messages[index].id : index + 1;
  733. if (value >= low && value <= high) entries.push({ seq: index + 1, message: messages[index] });
  734. }
  735. }
  736. return [...new Map(entries.map((entry) => [entry.message.id, entry])).values()];
  737. }
  738. function resolveSetValue(value, messages, byUid) {
  739. const clean = String(value || '').trim();
  740. if (clean === '*') return byUid ? messages.at(-1)?.id || 0 : messages.length;
  741. const number = Number(clean);
  742. return Number.isInteger(number) && number >= 0 ? number : null;
  743. }
  744. function resolveFetchLiteral(items, message) {
  745. const rawBytes = exactRawMessageBytes(message);
  746. const raw = rawBytes.toString('latin1');
  747. if (/\bRFC822\b(?!\.SIZE|\.HEADER|\.TEXT)/i.test(items)) return { label: 'RFC822', value: rawBytes };
  748. if (/RFC822\.HEADER/i.test(items)) return { label: 'RFC822.HEADER', value: Buffer.from(headerBlock(raw), 'latin1') };
  749. if (/RFC822\.TEXT/i.test(items)) return { label: 'RFC822.TEXT', value: Buffer.from(bodyBlock(raw), 'latin1') };
  750. const bodyMatch = String(items || '').match(/BODY(?:\.PEEK)?\[([^\]]*)\]/i);
  751. if (!bodyMatch) return null;
  752. const section = bodyMatch[1] || '';
  753. return {
  754. label: `BODY[${section}]`,
  755. value: section ? Buffer.from(bodySection(raw, section), 'latin1') : rawBytes
  756. };
  757. }
  758. function bodySection(raw, section) {
  759. const clean = String(section || '').trim().toUpperCase();
  760. if (!clean) return raw;
  761. if (clean === 'HEADER') return headerBlock(raw);
  762. if (clean === 'TEXT') return bodyBlock(raw);
  763. if (clean.startsWith('HEADER.FIELDS')) return selectedHeaders(raw, clean);
  764. const match = clean.match(/^(\d+(?:\.\d+)*)(?:\.(MIME|HEADER|TEXT))?$/);
  765. if (match) {
  766. const node = resolveMimeSection(parseMimeNode(raw), match[1]);
  767. if (!node) return '';
  768. if (match[2] === 'MIME' || match[2] === 'HEADER') return headerBlock(node.raw);
  769. return node.body;
  770. }
  771. return raw;
  772. }
  773. function imapBodyStructure(message) {
  774. return imapMimeNodeStructure(parseMimeNode(exactRawMessageBytes(message).toString('latin1')));
  775. }
  776. function imapMimeNodeStructure(node) {
  777. if (node.children.length) {
  778. return `(${node.children.map(imapMimeNodeStructure).join(' ')} ${imapNString(node.contentType.subtype.toUpperCase())} ${imapBodyParameters(node.contentType.parameters)})`;
  779. }
  780. const values = [
  781. imapNString(node.contentType.primary.toUpperCase()),
  782. imapNString(node.contentType.subtype.toUpperCase()),
  783. imapBodyParameters(node.contentType.parameters),
  784. imapNString(node.headers['content-id'] || ''),
  785. imapNString(node.headers['content-description'] || ''),
  786. imapNString(node.encoding.toUpperCase()),
  787. String(Buffer.byteLength(node.body, 'latin1'))
  788. ];
  789. if (node.contentType.primary === 'text') values.push(String(imapLineCount(node.body)));
  790. return `(${values.join(' ')})`;
  791. }
  792. function imapBodyParameters(parameters) {
  793. const entries = Object.entries(parameters);
  794. if (!entries.length) return 'NIL';
  795. return `(${entries.map(([name, value]) => `${imapNString(name.toUpperCase())} ${imapNString(value)}`).join(' ')})`;
  796. }
  797. function imapLineCount(value) {
  798. const body = String(value || '').replace(/\r\n$/, '');
  799. return body ? body.split('\r\n').length : 0;
  800. }
  801. function parseMimeNode(rawMessage) {
  802. const raw = String(rawMessage || '').replace(/\r?\n/g, '\r\n');
  803. const headers = parseMessageHeaders(raw);
  804. const contentType = parseMimeContentType(headers['content-type']);
  805. const body = bodyBlock(raw);
  806. const boundary = contentType.primary === 'multipart' ? contentType.parameters.boundary : '';
  807. return {
  808. raw,
  809. headers,
  810. body,
  811. contentType,
  812. encoding: normalizeTransferEncoding(headers['content-transfer-encoding']),
  813. children: boundary ? splitMultipartParts(body, boundary).map(parseMimeNode) : []
  814. };
  815. }
  816. function parseMimeContentType(value) {
  817. const source = String(value || 'text/plain');
  818. const mediaType = source.split(';', 1)[0].trim().toLowerCase();
  819. const [primary = 'text', subtype = 'plain'] = mediaType.split('/');
  820. return {
  821. primary: normalizeMimeToken(primary, 'text'),
  822. subtype: normalizeMimeToken(subtype, 'plain'),
  823. parameters: parseMimeParameters(source)
  824. };
  825. }
  826. function parseMimeParameters(value) {
  827. const parameters = {};
  828. const expression = /;\s*([^=;\s]+)\s*=\s*(?:"((?:\\.|[^"])*)"|([^;]*))/g;
  829. for (const match of String(value || '').matchAll(expression)) {
  830. const name = String(match[1] || '').trim().toLowerCase();
  831. const parameterValue = String(match[2] ?? match[3] ?? '').trim().replace(/\\(.)/g, '$1');
  832. if (name) parameters[name] = parameterValue;
  833. }
  834. return parameters;
  835. }
  836. function normalizeMimeToken(value, fallback) {
  837. const token = String(value || '').trim().replace(/[^a-z0-9!#$&^_.+-]/gi, '');
  838. return token || fallback;
  839. }
  840. function normalizeTransferEncoding(value) {
  841. const encoding = String(value || '7bit').trim().toLowerCase();
  842. return normalizeMimeToken(encoding, '7bit');
  843. }
  844. function splitMultipartParts(body, boundary) {
  845. const marker = `--${boundary}`;
  846. const parts = [];
  847. let current = null;
  848. for (const line of String(body || '').split('\r\n')) {
  849. if (line === marker || line === `${marker}--`) {
  850. if (current !== null) parts.push(current.join('\r\n'));
  851. if (line === `${marker}--`) break;
  852. current = [];
  853. continue;
  854. }
  855. if (current) current.push(line);
  856. }
  857. return parts.filter((part) => part.trim());
  858. }
  859. function resolveMimeSection(root, section) {
  860. const indexes = String(section || '').split('.').map(Number);
  861. if (!indexes.every((index) => Number.isInteger(index) && index > 0)) return null;
  862. if (!root.children.length) return indexes.length === 1 && indexes[0] === 1 ? root : null;
  863. let node = root;
  864. for (const index of indexes) {
  865. node = node.children[index - 1];
  866. if (!node) return null;
  867. }
  868. return node;
  869. }
  870. function selectedHeaders(raw, section) {
  871. const names = new Set((section.match(/\(([^)]*)\)/)?.[1] || '')
  872. .split(/\s+/)
  873. .map((name) => name.toLowerCase())
  874. .filter(Boolean));
  875. if (!names.size) return headerBlock(raw);
  876. const output = [];
  877. let keep = false;
  878. for (const line of headerBlock(raw).split('\r\n')) {
  879. if (!line) continue;
  880. if (/^[\t ]/.test(line)) {
  881. if (keep) output.push(line);
  882. continue;
  883. }
  884. const separator = line.indexOf(':');
  885. const name = separator === -1 ? '' : line.slice(0, separator).toLowerCase();
  886. keep = Boolean(name) && names.has(name);
  887. if (keep) output.push(line);
  888. }
  889. return `${output.join('\r\n')}\r\n\r\n`;
  890. }
  891. function headerBlock(raw) {
  892. const { header } = splitMessageSections(raw);
  893. return `${header.split(/\r\n|\n|\r/).join('\r\n')}\r\n\r\n`;
  894. }
  895. function bodyBlock(raw) {
  896. return splitMessageSections(raw).body;
  897. }
  898. function splitMessageSections(raw) {
  899. const source = String(raw || '');
  900. const separator = /\r\n\r\n|\n\n|\r\r/.exec(source);
  901. if (!separator) return { header: source, body: '' };
  902. return {
  903. header: source.slice(0, separator.index),
  904. body: source.slice(separator.index + separator[0].length)
  905. };
  906. }
  907. function parseFlags(value) {
  908. return new Set(String(value || '').toUpperCase().match(/\\[A-Z]+/g) || []);
  909. }
  910. function imapFlags(message, deletedUids) {
  911. const stored = [...(message.flags || []), ...(message.keywords || [])]
  912. .map(normalizeImapFlag)
  913. .filter((flag) => flag && flag.toLowerCase() !== '\\seen');
  914. if (message.read) stored.push('\\Seen');
  915. if (deletedUids.has(message.id)) stored.push('\\Deleted');
  916. return uniqueImapFlags(stored);
  917. }
  918. function imapAdvertisedFlags(messages) {
  919. const stored = messages.flatMap((message) => [
  920. ...(message.flags || []),
  921. ...(message.keywords || [])
  922. ]);
  923. return uniqueImapFlags([
  924. '\\Seen',
  925. '\\Answered',
  926. '\\Flagged',
  927. '\\Deleted',
  928. '\\Draft',
  929. ...stored
  930. ].map(normalizeImapFlag).filter(Boolean));
  931. }
  932. function normalizeImapFlag(value) {
  933. const flag = String(value || '').trim();
  934. if (/^\\[A-Za-z][A-Za-z0-9._-]*$/.test(flag)) return flag;
  935. if (/^[^\x00-\x20\x7f(){%*"\\\]]+$/.test(flag)) return flag;
  936. return '';
  937. }
  938. function uniqueImapFlags(flags) {
  939. return [...new Map(flags.map((flag) => [flag.toLowerCase(), flag])).values()];
  940. }
  941. function imapEnvelope(message) {
  942. return `("${imapDate(message.receivedAt)}" ${imapNString(message.subject)} ${addressList(message.sender)} NIL NIL ${addressList(message.sender)} ${addressList(message.sender)} NIL NIL ${imapNString(message.messageId)})`;
  943. }
  944. function addressList(address) {
  945. const clean = String(address || '');
  946. const [localPart, domain] = clean.split('@');
  947. if (!localPart || !domain) return 'NIL';
  948. return `((NIL NIL ${imapNString(localPart)} ${imapNString(domain)}))`;
  949. }
  950. function imapNString(value) {
  951. if (!value) return 'NIL';
  952. return `"${String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
  953. }
  954. function imapDate(value) {
  955. const date = value ? new Date(value) : new Date();
  956. return date.toUTCString().replace(',', '');
  957. }
  958. function uidNext(messages) {
  959. return Math.max(0, ...messages.map((message) => Number(message.id) || 0)) + 1;
  960. }
  961. function messageBytes(message) {
  962. if (Number.isFinite(Number(message.rawMessageSize))) return Number(message.rawMessageSize);
  963. return exactRawMessageBytes(message).length;
  964. }
  965. function pop3MessageBytes(message) {
  966. if (Number.isFinite(Number(message.pop3MessageSize))) return Number(message.pop3MessageSize);
  967. return pop3RawMessageBytes(message).length;
  968. }
  969. function pop3RawMessageBytes(message) {
  970. const normalized = exactRawMessageBytes(message)
  971. .toString('latin1')
  972. .replace(/\r?\n/g, '\r\n');
  973. return ensureTrailingCrlf(Buffer.from(normalized, 'latin1'));
  974. }
  975. function ensureTrailingCrlf(value) {
  976. const buffer = Buffer.isBuffer(value) ? value : Buffer.from(value || '');
  977. return buffer.length >= 2 && buffer.at(-2) === 0x0d && buffer.at(-1) === 0x0a
  978. ? buffer
  979. : Buffer.concat([buffer, Buffer.from('\r\n')]);
  980. }
  981. function dotStuffBytes(value) {
  982. const input = Buffer.isBuffer(value) ? value : Buffer.from(value || '');
  983. const extraDots = input.reduce((count, byte, index) => (
  984. byte === 0x2e && (index === 0 || input[index - 1] === 0x0a) ? count + 1 : count
  985. ), 0);
  986. const output = Buffer.alloc(input.length + extraDots);
  987. let offset = 0;
  988. for (let index = 0; index < input.length; index += 1) {
  989. if (input[index] === 0x2e && (index === 0 || input[index - 1] === 0x0a)) output[offset++] = 0x2e;
  990. output[offset++] = input[index];
  991. }
  992. return output;
  993. }
  994. function exactRawMessageBytes(message) {
  995. if (Buffer.isBuffer(message.rawMessageBytes)) return message.rawMessageBytes;
  996. if (message.rawMessageBytes instanceof Uint8Array) return Buffer.from(message.rawMessageBytes);
  997. return Buffer.from(normalizeRawMessage(message), 'utf8');
  998. }
  999. function normalizeRawMessage(message) {
  1000. const raw = String(message.rawMessage || fallbackRawMessage(message) || '').replace(/\r?\n/g, '\r\n');
  1001. return raw.endsWith('\r\n') ? raw : `${raw}\r\n`;
  1002. }
  1003. function fallbackRawMessage(message) {
  1004. return [
  1005. message.sender ? `From: ${message.sender}` : '',
  1006. message.recipients?.length ? `To: ${message.recipients.join(', ')}` : '',
  1007. message.subject ? `Subject: ${message.subject}` : '',
  1008. message.messageId ? `Message-ID: ${message.messageId}` : '',
  1009. message.receivedAt ? `Date: ${new Date(message.receivedAt).toUTCString()}` : '',
  1010. '',
  1011. message.textBody || message.preview || ''
  1012. ].filter((line, index) => line || index >= 5).join('\r\n');
  1013. }
  1014. function topLines(rawMessage, lineCount) {
  1015. const header = headerBlock(rawMessage).replace(/\r\n\r\n$/, '');
  1016. const lines = bodyBlock(rawMessage).split('\r\n').slice(0, lineCount).join('\r\n');
  1017. return `${header}\r\n\r\n${lines}`;
  1018. }
  1019. function pop3Uid(message) {
  1020. return `mh-${message.id}`;
  1021. }
  1022. function decodeBase64(value) {
  1023. try {
  1024. return Buffer.from(String(value || ''), 'base64').toString('utf8');
  1025. } catch {
  1026. return '';
  1027. }
  1028. }