mail-access.test.js 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893
  1. import assert from 'node:assert/strict';
  2. import { mkdtempSync } from 'node:fs';
  3. import net from 'node:net';
  4. import { tmpdir } from 'node:os';
  5. import path from 'node:path';
  6. import { test } from 'node:test';
  7. import {
  8. createDomain,
  9. createInboundFolder,
  10. createInboundMailbox,
  11. createInboundMessage,
  12. createImportedInboundMessage,
  13. createUser,
  14. deleteInboundMailboxWithMessageTransfer,
  15. getInboundMessage,
  16. inboundFolderExists,
  17. initDatabase,
  18. listInboundMessages,
  19. prepareInboundMailboxDeletion
  20. } from '../src/db.js';
  21. import { startMailboxAccessServers } from '../src/mail-access.js';
  22. test('IMAP SELECT keeps message bodies lazy and FETCH hydrates one message', async () => {
  23. const database = initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-imap-test-')), 'mail-access-secret');
  24. const { user, mailbox } = createMailboxFixture('imap.example', 'imap-user');
  25. const storedMessage = createInboundMessage(mailbox, {
  26. sender: 'alice@example.net',
  27. recipients: ['admin@imap.example'],
  28. subject: 'IMAP hello',
  29. messageId: '<imap-hello@example.net>',
  30. rawMessage: [
  31. 'From: Alice <alice@example.net>',
  32. 'To: admin@imap.example',
  33. 'Subject: IMAP hello',
  34. 'Message-ID: <imap-hello@example.net>',
  35. '',
  36. 'Hello through IMAP.'
  37. ].join('\r\n'),
  38. textBody: 'Hello through IMAP.'
  39. });
  40. const [server] = startMailboxAccessServers({
  41. hostname: 'mail.imap.example',
  42. imapEnabled: true,
  43. imapListeners: [{ port: 0, protocol: 'imap' }],
  44. pop3Enabled: false,
  45. pop3Listeners: [],
  46. allowInsecureAuth: true
  47. });
  48. await waitForListening(server);
  49. try {
  50. const port = server.address().port;
  51. const client = await connectClient(port);
  52. await client.readUntil(/\* OK .* IMAP ready\r\n/);
  53. assert.match(await client.command('A1 LOGIN "admin@imap.example" "mailbox-pass-123"', /A1 OK/), /LOGIN completed/);
  54. const selected = await client.command('A2 SELECT INBOX', /A2 OK/);
  55. assert.match(selected, /\* 1 EXISTS/);
  56. database
  57. .prepare('UPDATE inbound_messages SET raw_message_bytes = ? WHERE id = ?')
  58. .run(Buffer.from(storedMessage.rawMessage.replace('Hello through IMAP.', 'Hallo through IMAP.'), 'utf8'), storedMessage.id);
  59. const fetched = await client.command('A3 UID FETCH 1:* (UID FLAGS RFC822.SIZE BODY.PEEK[])', /A3 OK/);
  60. assert.match(fetched, /\* 1 FETCH/);
  61. assert.match(fetched, /UID 1/);
  62. assert.match(fetched, /Subject: IMAP hello/);
  63. assert.match(fetched, /Hallo through IMAP\./);
  64. assert.doesNotMatch(fetched, /Hello through IMAP\./);
  65. await client.command('A4 LOGOUT', /A4 OK/);
  66. client.close();
  67. assert.equal(listInboundMessages(user.id).length, 1);
  68. } finally {
  69. await closeServer(server);
  70. }
  71. });
  72. test('stale legacy IMAP sessions cannot mutate messages after mailbox deletion', async () => {
  73. initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-imap-deleted-mailbox-test-')), 'mail-access-secret');
  74. const { user, mailbox } = createMailboxFixture('deleted-session.example', 'deleted-session-user');
  75. const target = createInboundMailbox(user.id, {
  76. address: 'archive@deleted-session.example',
  77. password: 'mailbox-pass-123'
  78. });
  79. const message = createInboundMessage(mailbox, {
  80. sender: 'sender@example.net',
  81. recipients: [mailbox.address],
  82. subject: 'Delete session race',
  83. rawMessage: 'Subject: Delete session race\r\n\r\nBody',
  84. textBody: 'Body'
  85. });
  86. const [server] = startMailboxAccessServers({
  87. hostname: 'mail.deleted-session.example',
  88. imapEnabled: true,
  89. imapListeners: [{ port: 0, protocol: 'imap' }],
  90. pop3Enabled: false,
  91. pop3Listeners: [],
  92. allowInsecureAuth: true
  93. });
  94. await waitForListening(server);
  95. try {
  96. const client = await connectClient(server.address().port);
  97. await client.readUntil(/\* OK .* IMAP ready\r\n/);
  98. await client.command('A1 LOGIN "admin@deleted-session.example" "mailbox-pass-123"', /A1 OK/);
  99. await client.command('A2 SELECT INBOX', /A2 OK/);
  100. await client.command(`A3 UID STORE ${message.id} +FLAGS.SILENT (\\Deleted)`, /A3 OK/);
  101. prepareInboundMailboxDeletion(user.id, mailbox.id, target.id, {
  102. confirmAddress: mailbox.address
  103. });
  104. deleteInboundMailboxWithMessageTransfer(user.id, mailbox.id, target.id);
  105. assert.match(
  106. await client.command(`A4 UID STORE ${message.id} +FLAGS.SILENT (\\Seen)`, /A4 NO/),
  107. /Mailbox is no longer available/
  108. );
  109. assert.match(await client.command('A5 EXPUNGE', /A5 NO/), /Mailbox is no longer available/);
  110. assert.match(await client.command('A6 CREATE Projects', /A6 NO/), /Mailbox is no longer available/);
  111. assert.match(await client.command('A7 APPEND INBOX {0}', /A7 NO/), /Mailbox is no longer available/);
  112. await client.command('A8 LOGOUT', /A8 OK/);
  113. client.close();
  114. const moved = getInboundMessage(user.id, message.id);
  115. assert.equal(moved.mailboxId, target.id);
  116. assert.equal(moved.read, false);
  117. } finally {
  118. await closeServer(server);
  119. }
  120. });
  121. test('IMAP exposes imported Maildir flags and Dovecot keywords', async () => {
  122. initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-imap-flags-test-')), 'mail-access-secret');
  123. const { mailbox } = createMailboxFixture('flags.example', 'flags-user');
  124. createImportedInboundMessage(mailbox, {
  125. importSource: 'vesta:flags',
  126. sourceKey: 'message-1',
  127. sender: 'sender@example.net',
  128. recipients: ['admin@flags.example'],
  129. subject: 'Imported flags',
  130. messageId: '<flags@example.net>',
  131. rawMessageBytes: Buffer.from('From: sender@example.net\r\nTo: admin@flags.example\r\nSubject: Imported flags\r\n\r\nBody', 'utf8'),
  132. flags: ['\\Answered', '\\Flagged', '\\Draft', '\\Seen'],
  133. keywords: ['$Label1', 'custom-keyword'],
  134. receivedAt: '2024-01-02T03:04:05.000Z'
  135. });
  136. const [server] = startMailboxAccessServers({
  137. hostname: 'mail.flags.example',
  138. imapEnabled: true,
  139. imapListeners: [{ port: 0, protocol: 'imap' }],
  140. pop3Enabled: false,
  141. pop3Listeners: [],
  142. allowInsecureAuth: true
  143. });
  144. await waitForListening(server);
  145. let client;
  146. try {
  147. client = await connectClient(server.address().port);
  148. await client.readUntil(/\* OK .* IMAP ready\r\n/);
  149. await client.command('A1 LOGIN "admin@flags.example" "mailbox-pass-123"', /A1 OK/);
  150. const selected = await client.command('A2 SELECT INBOX', /A2 OK/);
  151. assert.match(selected, /\* FLAGS \([^\r\n]*\\Answered/);
  152. assert.match(selected, /\* FLAGS \([^\r\n]*\$Label1/);
  153. assert.match(selected, /\* FLAGS \([^\r\n]*custom-keyword/);
  154. const fetched = await client.command('A3 UID FETCH 1:* (UID FLAGS)', /A3 OK/);
  155. for (const flag of ['\\Answered', '\\Flagged', '\\Draft', '\\Seen', '$Label1', 'custom-keyword']) {
  156. assert.ok(fetched.includes(flag));
  157. }
  158. await client.command('A4 LOGOUT', /A4 OK/);
  159. } finally {
  160. client?.close();
  161. await closeServer(server);
  162. }
  163. });
  164. test('IMAP SEARCH filters seen state and rejects invalid contexts or criteria', async () => {
  165. initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-imap-search-test-')), 'mail-access-secret');
  166. const { mailbox: offsetMailbox } = createMailboxFixture('search-offset.example', 'search-offset-user');
  167. createInboundMessage(offsetMailbox, {
  168. sender: 'offset@example.net',
  169. recipients: ['admin@search-offset.example'],
  170. subject: 'UID offset',
  171. messageId: '<offset@search-offset.example>',
  172. rawMessage: 'From: offset@example.net\r\nTo: admin@search-offset.example\r\nSubject: UID offset\r\n\r\nOffset body.',
  173. textBody: 'Offset body.'
  174. });
  175. const { mailbox } = createMailboxFixture('search.example', 'search-user');
  176. const { message: seenMessage } = createImportedInboundMessage(mailbox, {
  177. importSource: 'imap-search-test',
  178. sourceKey: 'seen-message',
  179. sender: 'seen@example.net',
  180. recipients: ['admin@search.example'],
  181. subject: 'Already seen',
  182. messageId: '<seen@search.example>',
  183. rawMessageBytes: Buffer.from('From: seen@example.net\r\nTo: admin@search.example\r\nSubject: Already seen\r\n\r\nSeen body.', 'utf8'),
  184. flags: ['\\Seen'],
  185. receivedAt: '2026-07-15T01:00:00.000Z'
  186. });
  187. const unseenMessage = createInboundMessage(mailbox, {
  188. sender: 'unseen@example.net',
  189. recipients: ['admin@search.example'],
  190. subject: 'Still unread',
  191. messageId: '<unseen@search.example>',
  192. rawMessage: 'From: unseen@example.net\r\nTo: admin@search.example\r\nSubject: Still unread\r\n\r\nUnread body.',
  193. textBody: 'Unread body.'
  194. });
  195. const [server] = startMailboxAccessServers({
  196. hostname: 'mail.search.example',
  197. imapEnabled: true,
  198. imapListeners: [{ port: 0, protocol: 'imap' }],
  199. pop3Enabled: false,
  200. pop3Listeners: [],
  201. allowInsecureAuth: true
  202. });
  203. await waitForListening(server);
  204. let client;
  205. try {
  206. client = await connectClient(server.address().port);
  207. await client.readUntil(/\* OK .* IMAP ready\r\n/);
  208. await client.command('A1 LOGIN "admin@search.example" "mailbox-pass-123"', /A1 OK/);
  209. const searchBeforeSelect = await client.command('A2 SEARCH ALL', /A2 (?:OK|NO|BAD)[^\r\n]*\r\n$/);
  210. const uidSearchBeforeSelect = await client.command('A3 UID SEARCH ALL', /A3 (?:OK|NO|BAD)[^\r\n]*\r\n$/);
  211. await client.command('A4 SELECT INBOX', /A4 OK/);
  212. const all = await client.command('A5 SEARCH ALL', /A5 (?:OK|NO|BAD)[^\r\n]*\r\n$/);
  213. const unseen = await client.command('A6 SEARCH UNSEEN', /A6 (?:OK|NO|BAD)[^\r\n]*\r\n$/);
  214. const seen = await client.command('A7 SEARCH SEEN', /A7 (?:OK|NO|BAD)[^\r\n]*\r\n$/);
  215. const allUnseen = await client.command('A8 SEARCH ALL UNSEEN', /A8 (?:OK|NO|BAD)[^\r\n]*\r\n$/);
  216. const uidUnseen = await client.command('A9 UID SEARCH UNSEEN', /A9 (?:OK|NO|BAD)[^\r\n]*\r\n$/);
  217. const uidCharsetUnseen = await client.command('A10 UID SEARCH CHARSET UTF-8 UNSEEN', /A10 (?:OK|NO|BAD)[^\r\n]*\r\n$/);
  218. const sequenceNumber = await client.command('A10A SEARCH 2', /A10A (?:OK|NO|BAD)[^\r\n]*\r\n$/);
  219. const sequenceRangeUnseen = await client.command('A10B SEARCH 1:* UNSEEN', /A10B (?:OK|NO|BAD)[^\r\n]*\r\n$/);
  220. const uidSequenceNumber = await client.command('A10C UID SEARCH 2', /A10C (?:OK|NO|BAD)[^\r\n]*\r\n$/);
  221. const uidSequenceOutOfRange = await client.command('A10D UID SEARCH 16', /A10D (?:OK|NO|BAD)[^\r\n]*\r\n$/);
  222. const uidRangeUnseen = await client.command('A10E UID SEARCH 1:2 UNSEEN', /A10E (?:OK|NO|BAD)[^\r\n]*\r\n$/);
  223. const uidList = await client.command('A10F UID SEARCH 1,2', /A10F (?:OK|NO|BAD)[^\r\n]*\r\n$/);
  224. const uidCriterion = await client.command(`A10G UID SEARCH UID ${unseenMessage.id}`, /A10G (?:OK|NO|BAD)[^\r\n]*\r\n$/);
  225. const sequenceUidCriterion = await client.command(`A10H SEARCH UID ${unseenMessage.id}`, /A10H (?:OK|NO|BAD)[^\r\n]*\r\n$/);
  226. const stored = await client.command(
  227. `A11 UID STORE ${unseenMessage.id} +FLAGS.SILENT (\\Seen)`,
  228. /A11 (?:OK|NO|BAD)[^\r\n]*\r\n$/
  229. );
  230. const unseenAfterStore = await client.command('A12 SEARCH UNSEEN', /A12 (?:OK|NO|BAD)[^\r\n]*\r\n$/);
  231. const seenAfterStore = await client.command('A13 SEARCH SEEN', /A13 (?:OK|NO|BAD)[^\r\n]*\r\n$/);
  232. const returnCriteria = await client.command('A14 SEARCH RETURN (ALL) ALL', /A14 (?:OK|NO|BAD)[^\r\n]*\r\n$/);
  233. const unknownCriteria = await client.command('A15 SEARCH FROBNICATE', /A15 (?:OK|NO|BAD)[^\r\n]*\r\n$/);
  234. await client.command('A16 LOGOUT', /A16 OK/);
  235. assert.match(searchBeforeSelect, /^A2 (?:NO|BAD) /m);
  236. assert.doesNotMatch(searchBeforeSelect, /^\* SEARCH/m);
  237. assert.match(uidSearchBeforeSelect, /^A3 (?:NO|BAD) /m);
  238. assert.doesNotMatch(uidSearchBeforeSelect, /^\* SEARCH/m);
  239. assertImapSearchResult(all, [1, 2]);
  240. assertImapSearchResult(unseen, [2]);
  241. assertImapSearchResult(seen, [1]);
  242. assertImapSearchResult(allUnseen, [2]);
  243. assertImapSearchResult(uidUnseen, [unseenMessage.id]);
  244. assertImapSearchResult(uidCharsetUnseen, [unseenMessage.id]);
  245. assertImapSearchResult(sequenceNumber, [2]);
  246. assertImapSearchResult(sequenceRangeUnseen, [2]);
  247. assertImapSearchResult(uidSequenceNumber, [unseenMessage.id]);
  248. assertImapSearchResult(uidSequenceOutOfRange, []);
  249. assertImapSearchResult(uidRangeUnseen, [unseenMessage.id]);
  250. assertImapSearchResult(uidList, [seenMessage.id, unseenMessage.id]);
  251. assertImapSearchResult(uidCriterion, [unseenMessage.id]);
  252. assertImapSearchResult(sequenceUidCriterion, [2]);
  253. assert.match(stored, /^A11 OK STORE completed\r?$/m);
  254. assert.doesNotMatch(stored, /^\* \d+ FETCH/m);
  255. assertImapSearchResult(unseenAfterStore, []);
  256. assertImapSearchResult(seenAfterStore, [1, 2]);
  257. assert.match(returnCriteria, /^A14 BAD /m);
  258. assert.doesNotMatch(returnCriteria, /^\* SEARCH/m);
  259. assert.match(unknownCriteria, /^A15 BAD /m);
  260. assert.doesNotMatch(unknownCriteria, /^\* SEARCH/m);
  261. } finally {
  262. client?.close();
  263. await closeServer(server);
  264. }
  265. });
  266. test('IMAP exposes MIME body structures and individual parts for Roundcube', async () => {
  267. initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-imap-mime-test-')), 'mail-access-secret');
  268. const { mailbox } = createMailboxFixture('mime.example', 'mime-user');
  269. createInboundMessage(mailbox, {
  270. sender: 'alice@example.net',
  271. recipients: ['admin@mime.example'],
  272. subject: 'MIME message',
  273. messageId: '<mime-message@example.net>',
  274. rawMessage: [
  275. 'From: Alice <alice@example.net>',
  276. 'To: admin@mime.example',
  277. 'Subject: MIME message',
  278. 'MIME-Version: 1.0',
  279. 'Content-Type: multipart/alternative; boundary="mailhub-boundary"',
  280. '',
  281. '--mailhub-boundary',
  282. 'Content-Type: text/plain; charset=UTF-8',
  283. 'Content-Transfer-Encoding: quoted-printable',
  284. '',
  285. 'Plain message body.',
  286. '--mailhub-boundary',
  287. 'Content-Type: text/html; charset=UTF-8',
  288. '',
  289. '<p>HTML message body.</p>',
  290. '--mailhub-boundary--',
  291. ''
  292. ].join('\r\n'),
  293. textBody: 'Plain message body.',
  294. htmlBody: '<p>HTML message body.</p>'
  295. });
  296. const [server] = startMailboxAccessServers({
  297. hostname: 'mail.mime.example',
  298. imapEnabled: true,
  299. imapListeners: [{ port: 0, protocol: 'imap' }],
  300. pop3Enabled: false,
  301. pop3Listeners: [],
  302. allowInsecureAuth: true
  303. });
  304. await waitForListening(server);
  305. let client;
  306. try {
  307. client = await connectClient(server.address().port);
  308. await client.readUntil(/\* OK .* IMAP ready\r\n/);
  309. assert.match(await client.command('A1 LOGIN "admin@mime.example" "mailbox-pass-123"', /A1 OK/), /LOGIN completed/);
  310. await client.command('A2 SELECT INBOX', /A2 OK/);
  311. const structure = await client.command('A3 UID FETCH 1 (UID BODYSTRUCTURE)', /A3 OK/);
  312. assert.match(structure, /BODYSTRUCTURE \(\("TEXT" "PLAIN" \("CHARSET" "UTF-8"\).*\) \("TEXT" "HTML" \("CHARSET" "UTF-8"\).*\) "ALTERNATIVE" \("BOUNDARY" "mailhub-boundary"\)\)/);
  313. const textPart = await client.command('A4 UID FETCH 1 (BODY.PEEK[1])', /A4 OK/);
  314. assert.match(textPart, /BODY\[1\] \{\d+\}\r\nPlain message body\./);
  315. assert.doesNotMatch(textPart, /Content-Type: text\/plain/);
  316. const htmlPart = await client.command('A5 UID FETCH 1 (BODY.PEEK[2])', /A5 OK/);
  317. assert.match(htmlPart, /BODY\[2\] \{\d+\}\r\n<p>HTML message body\.<\/p>/);
  318. const mimeHeaders = await client.command('A6 UID FETCH 1 (BODY.PEEK[1.MIME])', /A6 OK/);
  319. assert.match(mimeHeaders, /BODY\[1\.MIME\] \{\d+\}\r\nContent-Type: text\/plain; charset=UTF-8/);
  320. await client.command('A7 LOGOUT', /A7 OK/);
  321. client.close();
  322. } finally {
  323. client?.close();
  324. await closeServer(server);
  325. }
  326. });
  327. test('IMAP exposes LF-only Maildir headers and text sections to Roundcube', async () => {
  328. initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-imap-lf-test-')), 'mail-access-secret');
  329. const { mailbox } = createMailboxFixture('lf.example', 'lf-user');
  330. const rawMessageBytes = Buffer.from([
  331. 'From: Alice <alice@example.net>',
  332. 'To: admin@lf.example',
  333. 'Subject: LF-only imported',
  334. ' continuation',
  335. 'Message-ID: <lf-only@example.net>',
  336. 'Content-Type: text/plain; charset=UTF-8',
  337. 'X-Not-Selected: private metadata',
  338. '',
  339. 'LF-only body.',
  340. 'Second line.'
  341. ].join('\n'), 'utf8');
  342. createImportedInboundMessage(mailbox, {
  343. importSource: 'vesta:lf-only',
  344. sourceKey: 'lf-only-message',
  345. sender: 'alice@example.net',
  346. recipients: ['admin@lf.example'],
  347. subject: 'LF-only imported continuation',
  348. messageId: '<lf-only@example.net>',
  349. rawMessageBytes,
  350. receivedAt: '2026-07-14T06:19:40.000Z'
  351. });
  352. const [server] = startMailboxAccessServers({
  353. hostname: 'mail.lf.example',
  354. imapEnabled: true,
  355. imapListeners: [{ port: 0, protocol: 'imap' }],
  356. pop3Enabled: false,
  357. pop3Listeners: [],
  358. allowInsecureAuth: true
  359. });
  360. await waitForListening(server);
  361. let client;
  362. try {
  363. client = await connectClient(server.address().port);
  364. await client.readUntil(/\* OK .* IMAP ready\r\n/);
  365. await client.command('A1 LOGIN "admin@lf.example" "mailbox-pass-123"', /A1 OK/);
  366. await client.command('A2 SELECT INBOX', /A2 OK/);
  367. const headerFieldsLabel = 'BODY[HEADER.FIELDS (DATE FROM TO CC REPLY-TO SUBJECT MESSAGE-ID REFERENCES CONTENT-TYPE X-PRIORITY X-MSMMAIL-PRIORITY IMPORTANCE)]';
  368. const headerFieldsResponse = await client.commandBytes(
  369. `A3 UID FETCH 1 (UID FLAGS RFC822.SIZE INTERNALDATE BODY.PEEK[HEADER.FIELDS (DATE FROM TO CC REPLY-TO SUBJECT MESSAGE-ID REFERENCES CONTENT-TYPE X-PRIORITY X-MSMMAIL-PRIORITY IMPORTANCE)])`,
  370. /A3 OK FETCH completed\r\n$/
  371. );
  372. assert.deepEqual(extractFetchLiteral(headerFieldsResponse, headerFieldsLabel), Buffer.from([
  373. 'From: Alice <alice@example.net>',
  374. 'To: admin@lf.example',
  375. 'Subject: LF-only imported',
  376. ' continuation',
  377. 'Message-ID: <lf-only@example.net>',
  378. 'Content-Type: text/plain; charset=UTF-8',
  379. '',
  380. ''
  381. ].join('\r\n'), 'utf8'));
  382. const fullHeaderResponse = await client.commandBytes(
  383. 'A4 UID FETCH 1 (RFC822.HEADER)',
  384. /A4 OK FETCH completed\r\n$/
  385. );
  386. assert.deepEqual(extractFetchLiteral(fullHeaderResponse, 'RFC822.HEADER'), Buffer.from([
  387. 'From: Alice <alice@example.net>',
  388. 'To: admin@lf.example',
  389. 'Subject: LF-only imported',
  390. ' continuation',
  391. 'Message-ID: <lf-only@example.net>',
  392. 'Content-Type: text/plain; charset=UTF-8',
  393. 'X-Not-Selected: private metadata',
  394. '',
  395. ''
  396. ].join('\r\n'), 'utf8'));
  397. const expectedBody = Buffer.from('LF-only body.\nSecond line.', 'utf8');
  398. const rfc822TextResponse = await client.commandBytes(
  399. 'A5 UID FETCH 1 (RFC822.TEXT)',
  400. /A5 OK FETCH completed\r\n$/
  401. );
  402. assert.deepEqual(extractFetchLiteral(rfc822TextResponse, 'RFC822.TEXT'), expectedBody);
  403. const bodyTextResponse = await client.commandBytes(
  404. 'A6 UID FETCH 1 (BODY.PEEK[TEXT])',
  405. /A6 OK FETCH completed\r\n$/
  406. );
  407. assert.deepEqual(extractFetchLiteral(bodyTextResponse, 'BODY[TEXT]'), expectedBody);
  408. const fullMessageResponse = await client.commandBytes(
  409. 'A7 UID FETCH 1 (BODY.PEEK[])',
  410. /A7 OK FETCH completed\r\n$/
  411. );
  412. assert.deepEqual(extractFetchLiteral(fullMessageResponse, 'BODY[]'), rawMessageBytes);
  413. await client.command('A8 LOGOUT', /A8 OK/);
  414. } finally {
  415. client?.close();
  416. await closeServer(server);
  417. }
  418. });
  419. test('IMAP exposes standard folders expected by mainstream clients', async () => {
  420. initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-imap-folders-test-')), 'mail-access-secret');
  421. createMailboxFixture('folders.example', 'folders-user');
  422. const [server] = startMailboxAccessServers({
  423. hostname: 'mail.folders.example',
  424. imapEnabled: true,
  425. imapListeners: [{ port: 0, protocol: 'imap' }],
  426. pop3Enabled: false,
  427. pop3Listeners: [],
  428. allowInsecureAuth: true
  429. });
  430. await waitForListening(server);
  431. let client;
  432. try {
  433. client = await connectClient(server.address().port);
  434. await client.readUntil(/\* OK .* IMAP ready\r\n/);
  435. assert.match(await client.command('A1 LOGIN "admin@folders.example" "mailbox-pass-123"', /A1 OK/), /LOGIN completed/);
  436. const listed = await client.command('A2 LIST "" "*"', /A2 OK/);
  437. assert.match(listed, /\* LIST .* "INBOX"/);
  438. assert.match(listed, /\* LIST .*\\Sent.* "Sent"/);
  439. assert.match(listed, /\* LIST .*\\Drafts.* "Drafts"/);
  440. assert.match(listed, /\* LIST .*\\Trash.* "Trash"/);
  441. assert.match(listed, /\* LIST .*\\Junk.* "Junk"/);
  442. assert.match(listed, /\* LIST .*\\Archive.* "Archive"/);
  443. const selected = await client.command('A3 SELECT Sent', /A3 OK/);
  444. assert.match(selected, /\* 0 EXISTS/);
  445. await client.command('A4 LOGOUT', /A4 OK/);
  446. client.close();
  447. } finally {
  448. client?.close();
  449. await closeServer(server);
  450. }
  451. });
  452. test('IMAP uses Modified UTF-7 on the wire while storing Unicode folder names', async () => {
  453. initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-imap-utf7-test-')), 'mail-access-secret');
  454. const { user, mailbox } = createMailboxFixture('utf7.example', 'utf7-user');
  455. createInboundFolder(mailbox, '中文 & 项目');
  456. const [server] = startMailboxAccessServers({
  457. hostname: 'mail.utf7.example',
  458. imapEnabled: true,
  459. imapListeners: [{ port: 0, protocol: 'imap' }],
  460. pop3Enabled: false,
  461. pop3Listeners: [],
  462. allowInsecureAuth: true
  463. });
  464. await waitForListening(server);
  465. let client;
  466. try {
  467. client = await connectClient(server.address().port);
  468. await client.readUntil(/\* OK .* IMAP ready\r\n/);
  469. await client.command('A1 LOGIN "admin@utf7.example" "mailbox-pass-123"', /A1 OK/);
  470. const listed = await client.command('A2 LIST "" "*"', /A2 OK/);
  471. assert.match(listed, /"&Ti1lhw- &- &mHl27g-"/);
  472. assert.doesNotMatch(listed, /中文|项目/);
  473. const subscribed = await client.command('A2L LSUB "" "*"', /A2L OK/);
  474. assert.match(subscribed, /"&Ti1lhw- &- &mHl27g-"/);
  475. const selected = await client.command('A3 SELECT "&Ti1lhw- &- &mHl27g-"', /A3 OK/);
  476. assert.match(selected, /\* 0 EXISTS/);
  477. const status = await client.command('A4 STATUS "&Ti1lhw- &- &mHl27g-" \(MESSAGES UNSEEN\)', /A4 OK/);
  478. assert.match(status, /\* STATUS "&Ti1lhw- &- &mHl27g-" \(MESSAGES 0 UNSEEN 0/);
  479. await client.command('A5 CREATE "&ZeVnLIqe-"', /A5 OK/);
  480. assert.equal(inboundFolderExists(mailbox, '日本語'), true);
  481. const rawMessage = [
  482. 'From: Bob <bob@example.net>',
  483. 'To: admin@utf7.example',
  484. 'Subject: UTF-7 folder append',
  485. '',
  486. 'Imported into a Unicode folder.'
  487. ].join('\r\n');
  488. await client.append(
  489. `A6 APPEND "&ZeVnLIqe-" {${Buffer.byteLength(rawMessage, 'utf8')}}`,
  490. rawMessage,
  491. /A6 OK/
  492. );
  493. assert.equal(listInboundMessages(user.id, { folder: '日本語' }).length, 1);
  494. await client.command('A7 LOGOUT', /A7 OK/);
  495. client.close();
  496. } finally {
  497. client?.close();
  498. await closeServer(server);
  499. }
  500. });
  501. test('IMAP APPEND stores sent messages in the Sent folder', async () => {
  502. initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-imap-append-test-')), 'mail-access-secret');
  503. const { user } = createMailboxFixture('append.example', 'append-user');
  504. const [server] = startMailboxAccessServers({
  505. hostname: 'mail.append.example',
  506. imapEnabled: true,
  507. imapListeners: [{ port: 0, protocol: 'imap' }],
  508. pop3Enabled: false,
  509. pop3Listeners: [],
  510. allowInsecureAuth: true
  511. });
  512. await waitForListening(server);
  513. let client;
  514. try {
  515. const sentMessage = [
  516. 'From: Admin <admin@append.example>',
  517. 'To: Bob <bob@example.net>',
  518. 'Subject: =?UTF-8?Q?=E6=A0=B8=E4=BA=91?=',
  519. ' =?UTF-8?Q?=E8=AE=A1=E7=AE=97?=',
  520. 'Message-ID: <sent-copy@append.example>',
  521. 'MIME-Version: 1.0',
  522. 'Content-Type: multipart/alternative; boundary="sent-boundary"',
  523. '',
  524. '--sent-boundary',
  525. 'Content-Type: text/plain; charset=UTF-8',
  526. 'Content-Transfer-Encoding: base64',
  527. '',
  528. Buffer.from('工单正文', 'utf8').toString('base64'),
  529. '--sent-boundary',
  530. 'Content-Type: text/html; charset=UTF-8',
  531. 'Content-Transfer-Encoding: quoted-printable',
  532. '',
  533. '<p>Sent HTML body.</p>',
  534. '--sent-boundary--',
  535. ''
  536. ].join('\r\n');
  537. client = await connectClient(server.address().port);
  538. await client.readUntil(/\* OK .* IMAP ready\r\n/);
  539. assert.match(await client.command('A1 LOGIN "admin@append.example" "mailbox-pass-123"', /A1 OK/), /LOGIN completed/);
  540. await client.append(`A2 APPEND Sent (\\Seen) {${Buffer.byteLength(sentMessage, 'utf8')}}`, sentMessage, /A2 OK/);
  541. const selectedSent = await client.command('A3 SELECT Sent', /A3 OK/);
  542. assert.match(selectedSent, /\* 1 EXISTS/);
  543. const fetchedSent = await client.command('A4 UID FETCH 1:* (UID FLAGS BODY.PEEK[])', /A4 OK/);
  544. assert.match(fetchedSent, /FLAGS \(\\Seen\)/);
  545. assert.match(fetchedSent, /Subject: =\?UTF-8\?Q\?/);
  546. assert.match(fetchedSent, /--sent-boundary/);
  547. const [storedSummary] = listInboundMessages(user.id, { folder: 'Sent' });
  548. const storedMessage = getInboundMessage(user.id, storedSummary.id);
  549. assert.equal(storedMessage.subject, '核云计算');
  550. assert.equal(storedMessage.textBody, '工单正文');
  551. assert.match(storedMessage.htmlBody, /Sent HTML body/);
  552. assert.equal(storedMessage.preview, '工单正文');
  553. assert.match(storedMessage.rawMessage, /--sent-boundary/);
  554. const latin1Message = Buffer.concat([
  555. Buffer.from([
  556. 'From: Admin <admin@append.example>',
  557. 'To: Bob <bob@example.net>',
  558. 'Subject: Latin1 copy',
  559. 'Content-Type: text/plain; charset=ISO-8859-1',
  560. 'Content-Transfer-Encoding: 8bit',
  561. '',
  562. 'caf'
  563. ].join('\r\n'), 'ascii'),
  564. Buffer.from([0xe9])
  565. ]);
  566. await client.append(`A5 APPEND Sent {${latin1Message.length}}`, latin1Message, /A5 OK/);
  567. const latin1Summary = listInboundMessages(user.id, { folder: 'Sent' })
  568. .find((message) => message.subject === 'Latin1 copy');
  569. assert.equal(getInboundMessage(user.id, latin1Summary.id).textBody, 'café');
  570. await client.command('A6 SELECT Sent', /A6 OK/);
  571. const latin1Fetch = await client.commandBytes('A7 UID FETCH 1:* (UID BODY.PEEK[])', /A7 OK/);
  572. assert.equal(latin1Fetch.includes(latin1Message), true);
  573. const selectedInbox = await client.command('A8 SELECT INBOX', /A8 OK/);
  574. assert.match(selectedInbox, /\* 0 EXISTS/);
  575. await client.command('A9 LOGOUT', /A9 OK/);
  576. client.close();
  577. } finally {
  578. client?.close();
  579. await closeServer(server);
  580. }
  581. });
  582. test('POP3 clients can retrieve and delete messages on quit', async () => {
  583. const database = initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-pop3-test-')), 'mail-access-secret');
  584. const { user, mailbox } = createMailboxFixture('pop3.example', 'pop3-user');
  585. const firstRawMessage = [
  586. 'From: Bob <bob@example.net>',
  587. 'To: admin@pop3.example',
  588. 'Subject: POP3 hello',
  589. 'Message-ID: <pop3-hello@example.net>',
  590. '',
  591. 'Hello through POP3.'
  592. ].join('\r\n');
  593. const firstMessage = createInboundMessage(mailbox, {
  594. sender: 'bob@example.net',
  595. recipients: ['admin@pop3.example'],
  596. subject: 'POP3 hello',
  597. messageId: '<pop3-hello@example.net>',
  598. rawMessage: firstRawMessage,
  599. textBody: 'Hello through POP3.'
  600. });
  601. const latin1RawMessage = Buffer.concat([
  602. Buffer.from([
  603. 'From: Alice <alice@example.net>',
  604. 'To: admin@pop3.example',
  605. 'Subject: Latin1 POP3',
  606. 'Content-Type: text/plain; charset=ISO-8859-1',
  607. 'Content-Transfer-Encoding: 8bit',
  608. '',
  609. 'caf'
  610. ].join('\n'), 'ascii'),
  611. Buffer.from([0xe9])
  612. ]);
  613. createInboundMessage(mailbox, {
  614. sender: 'alice@example.net',
  615. recipients: ['admin@pop3.example'],
  616. subject: 'Latin1 POP3',
  617. rawMessage: latin1RawMessage.toString('latin1'),
  618. rawMessageBytes: latin1RawMessage,
  619. textBody: 'café'
  620. });
  621. const firstPop3Message = Buffer.from(`${firstRawMessage}\r\n`, 'utf8');
  622. const latin1Pop3Message = Buffer.concat([
  623. Buffer.from(latin1RawMessage.toString('latin1').replace(/\n/g, '\r\n'), 'latin1'),
  624. Buffer.from('\r\n')
  625. ]);
  626. const totalOctets = firstPop3Message.length + latin1Pop3Message.length;
  627. const [server] = startMailboxAccessServers({
  628. hostname: 'mail.pop3.example',
  629. imapEnabled: false,
  630. imapListeners: [],
  631. pop3Enabled: true,
  632. pop3Listeners: [{ port: 0, protocol: 'pop3' }],
  633. allowInsecureAuth: true
  634. });
  635. await waitForListening(server);
  636. try {
  637. const client = await connectClient(server.address().port);
  638. await client.readUntil(/\+OK .* POP3 ready\r\n/);
  639. assert.match(await client.command('USER admin@pop3.example', /\+OK/), /User accepted/);
  640. assert.match(await client.command('PASS mailbox-pass-123', /\+OK/), /ready/);
  641. assert.match(await client.command('STAT', /\+OK \d+ \d+/), new RegExp(`\\+OK 2 ${totalOctets}`));
  642. const listed = await client.command('LIST', /\r\n\.\r\n/);
  643. assert.match(listed, new RegExp(`1 ${firstPop3Message.length}\\r\\n`));
  644. assert.match(listed, new RegExp(`2 ${latin1Pop3Message.length}\\r\\n`));
  645. assert.match(await client.command('UIDL 1', /\+OK 1 mh-1/), /\+OK 1 mh-1/);
  646. database
  647. .prepare('UPDATE inbound_messages SET raw_message_bytes = ? WHERE id = ?')
  648. .run(Buffer.from(firstRawMessage.replace('Hello through POP3.', 'Hallo through POP3.'), 'utf8'), firstMessage.id);
  649. const retrieved = await client.command('RETR 1', /\r\n\.\r\n/);
  650. assert.match(retrieved, /Subject: POP3 hello/);
  651. assert.match(retrieved, /Hallo through POP3\./);
  652. assert.doesNotMatch(retrieved, /Hello through POP3\./);
  653. const latin1Retrieved = await client.commandBytes('RETR 2', /\r\n\.\r\n/);
  654. assert.deepEqual(latin1Retrieved, Buffer.concat([
  655. Buffer.from(`+OK ${latin1Pop3Message.length} octets\r\n`),
  656. latin1Pop3Message,
  657. Buffer.from('.\r\n')
  658. ]));
  659. assert.match(await client.command('DELE 1', /\+OK/), /deleted/);
  660. assert.match(await client.command('DELE 2', /\+OK/), /deleted/);
  661. await client.command('QUIT', /\+OK Bye/);
  662. client.close();
  663. assert.equal(listInboundMessages(user.id).length, 0);
  664. } finally {
  665. await closeServer(server);
  666. }
  667. });
  668. test('POP3 AUTH PLAIN requires TLS when insecure authentication is disabled', async () => {
  669. const [server] = startMailboxAccessServers({
  670. hostname: 'mail.secure-pop3.example',
  671. imapEnabled: false,
  672. imapListeners: [],
  673. pop3Enabled: true,
  674. pop3Listeners: [{ port: 0, protocol: 'pop3' }],
  675. allowInsecureAuth: false
  676. });
  677. await waitForListening(server);
  678. let client;
  679. try {
  680. client = await connectClient(server.address().port);
  681. await client.readUntil(/\+OK .* POP3 ready\r\n/);
  682. const credentials = Buffer.from('\u0000user@example.com\u0000password').toString('base64');
  683. assert.equal(
  684. await client.command(`AUTH PLAIN ${credentials}`, /\+OK|\-ERR/),
  685. '-ERR Encryption required for authentication\r\n'
  686. );
  687. } finally {
  688. client?.close();
  689. await closeServer(server);
  690. }
  691. });
  692. function createMailboxFixture(domainName, username) {
  693. const user = createUser({ username, email: `${username}@example.com`, password: 'password123' });
  694. createDomain(user.id, {
  695. domain: domainName,
  696. selector: 'mh',
  697. verificationToken: 'verify',
  698. dkimPublic: 'public',
  699. dkimPrivate: 'private',
  700. senderHost: `mail.${domainName}`,
  701. sendingIp: '192.0.2.30',
  702. spfExtra: '',
  703. dmarcPolicy: 'none',
  704. dmarcRua: ''
  705. });
  706. const mailbox = createInboundMailbox(user.id, {
  707. address: `admin@${domainName}`,
  708. password: 'mailbox-pass-123'
  709. });
  710. return { user, mailbox };
  711. }
  712. function connectClient(port) {
  713. return new Promise((resolve, reject) => {
  714. const socket = net.createConnection({ host: '127.0.0.1', port });
  715. socket.setTimeout(5000);
  716. let buffer = '';
  717. let rawBuffer = Buffer.alloc(0);
  718. const waiters = [];
  719. socket.on('data', (chunk) => {
  720. const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
  721. rawBuffer = Buffer.concat([rawBuffer, bytes]);
  722. buffer += bytes.toString('utf8');
  723. for (const waiter of [...waiters]) {
  724. if (waiter.pattern.test(buffer)) {
  725. waiters.splice(waiters.indexOf(waiter), 1);
  726. const output = buffer;
  727. const rawOutput = rawBuffer;
  728. buffer = '';
  729. rawBuffer = Buffer.alloc(0);
  730. waiter.resolve(waiter.raw ? rawOutput : output);
  731. }
  732. }
  733. });
  734. socket.once('connect', () => resolve({
  735. command(command, pattern) {
  736. socket.write(`${command}\r\n`);
  737. return this.readUntil(pattern);
  738. },
  739. commandBytes(command, pattern) {
  740. socket.write(`${command}\r\n`);
  741. return this.readUntil(pattern, true);
  742. },
  743. async append(command, literal, pattern) {
  744. socket.write(`${command}\r\n`);
  745. await this.readUntil(/^\+ /m);
  746. socket.write(literal);
  747. socket.write('\r\n');
  748. return this.readUntil(pattern);
  749. },
  750. readUntil(pattern, raw = false) {
  751. if (pattern.test(buffer)) {
  752. const output = buffer;
  753. const rawOutput = rawBuffer;
  754. buffer = '';
  755. rawBuffer = Buffer.alloc(0);
  756. return Promise.resolve(raw ? rawOutput : output);
  757. }
  758. return new Promise((waitResolve, waitReject) => {
  759. const waiter = {
  760. pattern,
  761. raw,
  762. resolve(output) {
  763. clearTimeout(waiter.timer);
  764. waitResolve(output);
  765. },
  766. reject(error) {
  767. clearTimeout(waiter.timer);
  768. waitReject(error);
  769. },
  770. timer: null
  771. };
  772. waiter.timer = setTimeout(() => {
  773. waiters.splice(waiters.indexOf(waiter), 1);
  774. waitReject(new Error(`Timed out waiting for ${pattern}; buffered response: ${buffer}`));
  775. }, 5000);
  776. waiters.push(waiter);
  777. });
  778. },
  779. close() {
  780. socket.destroy();
  781. }
  782. }));
  783. socket.once('error', reject);
  784. socket.once('timeout', () => reject(new Error('Mail access client timed out')));
  785. });
  786. }
  787. function extractFetchLiteral(response, label) {
  788. const bytes = Buffer.isBuffer(response) ? response : Buffer.from(response || '');
  789. const marker = Buffer.from(`${label} {`, 'ascii');
  790. const markerIndex = bytes.indexOf(marker);
  791. assert.notEqual(markerIndex, -1, `Missing ${label} literal marker`);
  792. const sizeStart = markerIndex + marker.length;
  793. const sizeEndMarker = Buffer.from('}\r\n', 'ascii');
  794. const sizeEnd = bytes.indexOf(sizeEndMarker, sizeStart);
  795. assert.notEqual(sizeEnd, -1, `Missing ${label} literal size terminator`);
  796. const size = Number(bytes.subarray(sizeStart, sizeEnd).toString('ascii'));
  797. assert.equal(Number.isInteger(size) && size >= 0, true, `Invalid ${label} literal size`);
  798. const literalStart = sizeEnd + sizeEndMarker.length;
  799. const literalEnd = literalStart + size;
  800. assert.ok(literalEnd <= bytes.length, `Truncated ${label} literal`);
  801. assert.deepEqual(bytes.subarray(literalEnd, literalEnd + 5), Buffer.from('\r\n)\r\n', 'ascii'));
  802. return bytes.subarray(literalStart, literalEnd);
  803. }
  804. function assertImapSearchResult(response, expected) {
  805. assert.match(response, /^\S+ OK SEARCH completed\r?$/m);
  806. const match = response.match(/^\* SEARCH(?: ([0-9 ]+))?\r?$/m);
  807. assert.ok(match, `Missing SEARCH response in: ${response}`);
  808. const actual = String(match[1] || '')
  809. .split(/\s+/)
  810. .filter(Boolean)
  811. .map(Number);
  812. assert.deepEqual(actual, expected);
  813. }
  814. function waitForListening(server) {
  815. if (server.listening) return Promise.resolve();
  816. return new Promise((resolve) => server.once('listening', resolve));
  817. }
  818. function closeServer(server) {
  819. return new Promise((resolve, reject) => {
  820. server.close((error) => error ? reject(error) : resolve());
  821. });
  822. }