mail-access.test.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453
  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. createInboundMailbox,
  10. createInboundMessage,
  11. createUser,
  12. getInboundMessage,
  13. initDatabase,
  14. listInboundMessages
  15. } from '../src/db.js';
  16. import { startMailboxAccessServers } from '../src/mail-access.js';
  17. test('IMAP clients can log in and fetch mailbox messages', async () => {
  18. initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-imap-test-')), 'mail-access-secret');
  19. const { user, mailbox } = createMailboxFixture('imap.example', 'imap-user');
  20. createInboundMessage(mailbox, {
  21. sender: 'alice@example.net',
  22. recipients: ['admin@imap.example'],
  23. subject: 'IMAP hello',
  24. messageId: '<imap-hello@example.net>',
  25. rawMessage: [
  26. 'From: Alice <alice@example.net>',
  27. 'To: admin@imap.example',
  28. 'Subject: IMAP hello',
  29. 'Message-ID: <imap-hello@example.net>',
  30. '',
  31. 'Hello through IMAP.'
  32. ].join('\r\n'),
  33. textBody: 'Hello through IMAP.'
  34. });
  35. const [server] = startMailboxAccessServers({
  36. hostname: 'mail.imap.example',
  37. imapEnabled: true,
  38. imapListeners: [{ port: 0, protocol: 'imap' }],
  39. pop3Enabled: false,
  40. pop3Listeners: [],
  41. allowInsecureAuth: true
  42. });
  43. await waitForListening(server);
  44. try {
  45. const port = server.address().port;
  46. const client = await connectClient(port);
  47. await client.readUntil(/\* OK .* IMAP ready\r\n/);
  48. assert.match(await client.command('A1 LOGIN "admin@imap.example" "mailbox-pass-123"', /A1 OK/), /LOGIN completed/);
  49. const selected = await client.command('A2 SELECT INBOX', /A2 OK/);
  50. assert.match(selected, /\* 1 EXISTS/);
  51. const fetched = await client.command('A3 UID FETCH 1:* (UID FLAGS RFC822.SIZE BODY.PEEK[])', /A3 OK/);
  52. assert.match(fetched, /\* 1 FETCH/);
  53. assert.match(fetched, /UID 1/);
  54. assert.match(fetched, /Subject: IMAP hello/);
  55. assert.match(fetched, /Hello through IMAP\./);
  56. await client.command('A4 LOGOUT', /A4 OK/);
  57. client.close();
  58. assert.equal(listInboundMessages(user.id).length, 1);
  59. } finally {
  60. await closeServer(server);
  61. }
  62. });
  63. test('IMAP exposes MIME body structures and individual parts for Roundcube', async () => {
  64. initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-imap-mime-test-')), 'mail-access-secret');
  65. const { mailbox } = createMailboxFixture('mime.example', 'mime-user');
  66. createInboundMessage(mailbox, {
  67. sender: 'alice@example.net',
  68. recipients: ['admin@mime.example'],
  69. subject: 'MIME message',
  70. messageId: '<mime-message@example.net>',
  71. rawMessage: [
  72. 'From: Alice <alice@example.net>',
  73. 'To: admin@mime.example',
  74. 'Subject: MIME message',
  75. 'MIME-Version: 1.0',
  76. 'Content-Type: multipart/alternative; boundary="mailhub-boundary"',
  77. '',
  78. '--mailhub-boundary',
  79. 'Content-Type: text/plain; charset=UTF-8',
  80. 'Content-Transfer-Encoding: quoted-printable',
  81. '',
  82. 'Plain message body.',
  83. '--mailhub-boundary',
  84. 'Content-Type: text/html; charset=UTF-8',
  85. '',
  86. '<p>HTML message body.</p>',
  87. '--mailhub-boundary--',
  88. ''
  89. ].join('\r\n'),
  90. textBody: 'Plain message body.',
  91. htmlBody: '<p>HTML message body.</p>'
  92. });
  93. const [server] = startMailboxAccessServers({
  94. hostname: 'mail.mime.example',
  95. imapEnabled: true,
  96. imapListeners: [{ port: 0, protocol: 'imap' }],
  97. pop3Enabled: false,
  98. pop3Listeners: [],
  99. allowInsecureAuth: true
  100. });
  101. await waitForListening(server);
  102. let client;
  103. try {
  104. client = await connectClient(server.address().port);
  105. await client.readUntil(/\* OK .* IMAP ready\r\n/);
  106. assert.match(await client.command('A1 LOGIN "admin@mime.example" "mailbox-pass-123"', /A1 OK/), /LOGIN completed/);
  107. await client.command('A2 SELECT INBOX', /A2 OK/);
  108. const structure = await client.command('A3 UID FETCH 1 (UID BODYSTRUCTURE)', /A3 OK/);
  109. assert.match(structure, /BODYSTRUCTURE \(\("TEXT" "PLAIN" \("CHARSET" "UTF-8"\).*\) \("TEXT" "HTML" \("CHARSET" "UTF-8"\).*\) "ALTERNATIVE" \("BOUNDARY" "mailhub-boundary"\)\)/);
  110. const textPart = await client.command('A4 UID FETCH 1 (BODY.PEEK[1])', /A4 OK/);
  111. assert.match(textPart, /BODY\[1\] \{\d+\}\r\nPlain message body\./);
  112. assert.doesNotMatch(textPart, /Content-Type: text\/plain/);
  113. const htmlPart = await client.command('A5 UID FETCH 1 (BODY.PEEK[2])', /A5 OK/);
  114. assert.match(htmlPart, /BODY\[2\] \{\d+\}\r\n<p>HTML message body\.<\/p>/);
  115. const mimeHeaders = await client.command('A6 UID FETCH 1 (BODY.PEEK[1.MIME])', /A6 OK/);
  116. assert.match(mimeHeaders, /BODY\[1\.MIME\] \{\d+\}\r\nContent-Type: text\/plain; charset=UTF-8/);
  117. await client.command('A7 LOGOUT', /A7 OK/);
  118. client.close();
  119. } finally {
  120. client?.close();
  121. await closeServer(server);
  122. }
  123. });
  124. test('IMAP exposes standard folders expected by mainstream clients', async () => {
  125. initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-imap-folders-test-')), 'mail-access-secret');
  126. createMailboxFixture('folders.example', 'folders-user');
  127. const [server] = startMailboxAccessServers({
  128. hostname: 'mail.folders.example',
  129. imapEnabled: true,
  130. imapListeners: [{ port: 0, protocol: 'imap' }],
  131. pop3Enabled: false,
  132. pop3Listeners: [],
  133. allowInsecureAuth: true
  134. });
  135. await waitForListening(server);
  136. let client;
  137. try {
  138. client = await connectClient(server.address().port);
  139. await client.readUntil(/\* OK .* IMAP ready\r\n/);
  140. assert.match(await client.command('A1 LOGIN "admin@folders.example" "mailbox-pass-123"', /A1 OK/), /LOGIN completed/);
  141. const listed = await client.command('A2 LIST "" "*"', /A2 OK/);
  142. assert.match(listed, /\* LIST .* "INBOX"/);
  143. assert.match(listed, /\* LIST .*\\Sent.* "Sent"/);
  144. assert.match(listed, /\* LIST .*\\Drafts.* "Drafts"/);
  145. assert.match(listed, /\* LIST .*\\Trash.* "Trash"/);
  146. assert.match(listed, /\* LIST .*\\Junk.* "Junk"/);
  147. assert.match(listed, /\* LIST .*\\Archive.* "Archive"/);
  148. const selected = await client.command('A3 SELECT Sent', /A3 OK/);
  149. assert.match(selected, /\* 0 EXISTS/);
  150. await client.command('A4 LOGOUT', /A4 OK/);
  151. client.close();
  152. } finally {
  153. client?.close();
  154. await closeServer(server);
  155. }
  156. });
  157. test('IMAP APPEND stores sent messages in the Sent folder', async () => {
  158. initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-imap-append-test-')), 'mail-access-secret');
  159. const { user } = createMailboxFixture('append.example', 'append-user');
  160. const [server] = startMailboxAccessServers({
  161. hostname: 'mail.append.example',
  162. imapEnabled: true,
  163. imapListeners: [{ port: 0, protocol: 'imap' }],
  164. pop3Enabled: false,
  165. pop3Listeners: [],
  166. allowInsecureAuth: true
  167. });
  168. await waitForListening(server);
  169. let client;
  170. try {
  171. const sentMessage = [
  172. 'From: Admin <admin@append.example>',
  173. 'To: Bob <bob@example.net>',
  174. 'Subject: =?UTF-8?Q?=E6=A0=B8=E4=BA=91?=',
  175. ' =?UTF-8?Q?=E8=AE=A1=E7=AE=97?=',
  176. 'Message-ID: <sent-copy@append.example>',
  177. 'MIME-Version: 1.0',
  178. 'Content-Type: multipart/alternative; boundary="sent-boundary"',
  179. '',
  180. '--sent-boundary',
  181. 'Content-Type: text/plain; charset=UTF-8',
  182. 'Content-Transfer-Encoding: base64',
  183. '',
  184. Buffer.from('工单正文', 'utf8').toString('base64'),
  185. '--sent-boundary',
  186. 'Content-Type: text/html; charset=UTF-8',
  187. 'Content-Transfer-Encoding: quoted-printable',
  188. '',
  189. '<p>Sent HTML body.</p>',
  190. '--sent-boundary--',
  191. ''
  192. ].join('\r\n');
  193. client = await connectClient(server.address().port);
  194. await client.readUntil(/\* OK .* IMAP ready\r\n/);
  195. assert.match(await client.command('A1 LOGIN "admin@append.example" "mailbox-pass-123"', /A1 OK/), /LOGIN completed/);
  196. await client.append(`A2 APPEND Sent (\\Seen) {${Buffer.byteLength(sentMessage, 'utf8')}}`, sentMessage, /A2 OK/);
  197. const selectedSent = await client.command('A3 SELECT Sent', /A3 OK/);
  198. assert.match(selectedSent, /\* 1 EXISTS/);
  199. const fetchedSent = await client.command('A4 UID FETCH 1:* (UID FLAGS BODY.PEEK[])', /A4 OK/);
  200. assert.match(fetchedSent, /FLAGS \(\\Seen\)/);
  201. assert.match(fetchedSent, /Subject: =\?UTF-8\?Q\?/);
  202. assert.match(fetchedSent, /--sent-boundary/);
  203. const [storedSummary] = listInboundMessages(user.id, { folder: 'Sent' });
  204. const storedMessage = getInboundMessage(user.id, storedSummary.id);
  205. assert.equal(storedMessage.subject, '核云计算');
  206. assert.equal(storedMessage.textBody, '工单正文');
  207. assert.match(storedMessage.htmlBody, /Sent HTML body/);
  208. assert.equal(storedMessage.preview, '工单正文');
  209. assert.match(storedMessage.rawMessage, /--sent-boundary/);
  210. const latin1Message = Buffer.concat([
  211. Buffer.from([
  212. 'From: Admin <admin@append.example>',
  213. 'To: Bob <bob@example.net>',
  214. 'Subject: Latin1 copy',
  215. 'Content-Type: text/plain; charset=ISO-8859-1',
  216. 'Content-Transfer-Encoding: 8bit',
  217. '',
  218. 'caf'
  219. ].join('\r\n'), 'ascii'),
  220. Buffer.from([0xe9])
  221. ]);
  222. await client.append(`A5 APPEND Sent {${latin1Message.length}}`, latin1Message, /A5 OK/);
  223. const latin1Summary = listInboundMessages(user.id, { folder: 'Sent' })
  224. .find((message) => message.subject === 'Latin1 copy');
  225. assert.equal(getInboundMessage(user.id, latin1Summary.id).textBody, 'café');
  226. await client.command('A6 SELECT Sent', /A6 OK/);
  227. const latin1Fetch = await client.commandBytes('A7 UID FETCH 1:* (UID BODY.PEEK[])', /A7 OK/);
  228. assert.equal(latin1Fetch.includes(latin1Message), true);
  229. const selectedInbox = await client.command('A8 SELECT INBOX', /A8 OK/);
  230. assert.match(selectedInbox, /\* 0 EXISTS/);
  231. await client.command('A9 LOGOUT', /A9 OK/);
  232. client.close();
  233. } finally {
  234. client?.close();
  235. await closeServer(server);
  236. }
  237. });
  238. test('POP3 clients can retrieve and delete messages on quit', async () => {
  239. initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-pop3-test-')), 'mail-access-secret');
  240. const { user, mailbox } = createMailboxFixture('pop3.example', 'pop3-user');
  241. const firstRawMessage = [
  242. 'From: Bob <bob@example.net>',
  243. 'To: admin@pop3.example',
  244. 'Subject: POP3 hello',
  245. 'Message-ID: <pop3-hello@example.net>',
  246. '',
  247. 'Hello through POP3.'
  248. ].join('\r\n');
  249. createInboundMessage(mailbox, {
  250. sender: 'bob@example.net',
  251. recipients: ['admin@pop3.example'],
  252. subject: 'POP3 hello',
  253. messageId: '<pop3-hello@example.net>',
  254. rawMessage: firstRawMessage,
  255. textBody: 'Hello through POP3.'
  256. });
  257. const latin1RawMessage = Buffer.concat([
  258. Buffer.from([
  259. 'From: Alice <alice@example.net>',
  260. 'To: admin@pop3.example',
  261. 'Subject: Latin1 POP3',
  262. 'Content-Type: text/plain; charset=ISO-8859-1',
  263. 'Content-Transfer-Encoding: 8bit',
  264. '',
  265. 'caf'
  266. ].join('\r\n'), 'ascii'),
  267. Buffer.from([0xe9])
  268. ]);
  269. createInboundMessage(mailbox, {
  270. sender: 'alice@example.net',
  271. recipients: ['admin@pop3.example'],
  272. subject: 'Latin1 POP3',
  273. rawMessage: latin1RawMessage.toString('latin1'),
  274. rawMessageBytes: latin1RawMessage,
  275. textBody: 'café'
  276. });
  277. const firstPop3Message = Buffer.from(`${firstRawMessage}\r\n`, 'utf8');
  278. const latin1Pop3Message = Buffer.concat([latin1RawMessage, Buffer.from('\r\n')]);
  279. const totalOctets = firstPop3Message.length + latin1Pop3Message.length;
  280. const [server] = startMailboxAccessServers({
  281. hostname: 'mail.pop3.example',
  282. imapEnabled: false,
  283. imapListeners: [],
  284. pop3Enabled: true,
  285. pop3Listeners: [{ port: 0, protocol: 'pop3' }],
  286. allowInsecureAuth: true
  287. });
  288. await waitForListening(server);
  289. try {
  290. const client = await connectClient(server.address().port);
  291. await client.readUntil(/\+OK .* POP3 ready\r\n/);
  292. assert.match(await client.command('USER admin@pop3.example', /\+OK/), /User accepted/);
  293. assert.match(await client.command('PASS mailbox-pass-123', /\+OK/), /ready/);
  294. assert.match(await client.command('STAT', /\+OK \d+ \d+/), new RegExp(`\\+OK 2 ${totalOctets}`));
  295. const listed = await client.command('LIST', /\r\n\.\r\n/);
  296. assert.match(listed, new RegExp(`1 ${firstPop3Message.length}\\r\\n`));
  297. assert.match(listed, new RegExp(`2 ${latin1Pop3Message.length}\\r\\n`));
  298. assert.match(await client.command('UIDL 1', /\+OK 1 mh-1/), /\+OK 1 mh-1/);
  299. const retrieved = await client.command('RETR 1', /\r\n\.\r\n/);
  300. assert.match(retrieved, /Subject: POP3 hello/);
  301. assert.match(retrieved, /Hello through POP3\./);
  302. const latin1Retrieved = await client.commandBytes('RETR 2', /\r\n\.\r\n/);
  303. assert.deepEqual(latin1Retrieved, Buffer.concat([
  304. Buffer.from(`+OK ${latin1Pop3Message.length} octets\r\n`),
  305. latin1Pop3Message,
  306. Buffer.from('.\r\n')
  307. ]));
  308. assert.match(await client.command('DELE 1', /\+OK/), /deleted/);
  309. assert.match(await client.command('DELE 2', /\+OK/), /deleted/);
  310. await client.command('QUIT', /\+OK Bye/);
  311. client.close();
  312. assert.equal(listInboundMessages(user.id).length, 0);
  313. } finally {
  314. await closeServer(server);
  315. }
  316. });
  317. function createMailboxFixture(domainName, username) {
  318. const user = createUser({ username, email: `${username}@example.com`, password: 'password123' });
  319. createDomain(user.id, {
  320. domain: domainName,
  321. selector: 'mh',
  322. verificationToken: 'verify',
  323. dkimPublic: 'public',
  324. dkimPrivate: 'private',
  325. senderHost: `mail.${domainName}`,
  326. sendingIp: '192.0.2.30',
  327. spfExtra: '',
  328. dmarcPolicy: 'none',
  329. dmarcRua: ''
  330. });
  331. const mailbox = createInboundMailbox(user.id, {
  332. address: `admin@${domainName}`,
  333. password: 'mailbox-pass-123'
  334. });
  335. return { user, mailbox };
  336. }
  337. function connectClient(port) {
  338. return new Promise((resolve, reject) => {
  339. const socket = net.createConnection({ host: '127.0.0.1', port });
  340. socket.setTimeout(5000);
  341. let buffer = '';
  342. let rawBuffer = Buffer.alloc(0);
  343. const waiters = [];
  344. socket.on('data', (chunk) => {
  345. const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
  346. rawBuffer = Buffer.concat([rawBuffer, bytes]);
  347. buffer += bytes.toString('utf8');
  348. for (const waiter of [...waiters]) {
  349. if (waiter.pattern.test(buffer)) {
  350. waiters.splice(waiters.indexOf(waiter), 1);
  351. const output = buffer;
  352. const rawOutput = rawBuffer;
  353. buffer = '';
  354. rawBuffer = Buffer.alloc(0);
  355. waiter.resolve(waiter.raw ? rawOutput : output);
  356. }
  357. }
  358. });
  359. socket.once('connect', () => resolve({
  360. command(command, pattern) {
  361. socket.write(`${command}\r\n`);
  362. return this.readUntil(pattern);
  363. },
  364. commandBytes(command, pattern) {
  365. socket.write(`${command}\r\n`);
  366. return this.readUntil(pattern, true);
  367. },
  368. async append(command, literal, pattern) {
  369. socket.write(`${command}\r\n`);
  370. await this.readUntil(/^\+ /m);
  371. socket.write(literal);
  372. socket.write('\r\n');
  373. return this.readUntil(pattern);
  374. },
  375. readUntil(pattern, raw = false) {
  376. if (pattern.test(buffer)) {
  377. const output = buffer;
  378. const rawOutput = rawBuffer;
  379. buffer = '';
  380. rawBuffer = Buffer.alloc(0);
  381. return Promise.resolve(raw ? rawOutput : output);
  382. }
  383. return new Promise((waitResolve, waitReject) => {
  384. const waiter = {
  385. pattern,
  386. raw,
  387. resolve(output) {
  388. clearTimeout(waiter.timer);
  389. waitResolve(output);
  390. },
  391. reject(error) {
  392. clearTimeout(waiter.timer);
  393. waitReject(error);
  394. },
  395. timer: null
  396. };
  397. waiter.timer = setTimeout(() => {
  398. waiters.splice(waiters.indexOf(waiter), 1);
  399. waitReject(new Error(`Timed out waiting for ${pattern}; buffered response: ${buffer}`));
  400. }, 5000);
  401. waiters.push(waiter);
  402. });
  403. },
  404. close() {
  405. socket.destroy();
  406. }
  407. }));
  408. socket.once('error', reject);
  409. socket.once('timeout', () => reject(new Error('Mail access client timed out')));
  410. });
  411. }
  412. function waitForListening(server) {
  413. if (server.listening) return Promise.resolve();
  414. return new Promise((resolve) => server.once('listening', resolve));
  415. }
  416. function closeServer(server) {
  417. return new Promise((resolve, reject) => {
  418. server.close((error) => error ? reject(error) : resolve());
  419. });
  420. }