mail-access.test.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364
  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. initDatabase,
  13. listInboundMessages
  14. } from '../src/db.js';
  15. import { startMailboxAccessServers } from '../src/mail-access.js';
  16. test('IMAP clients can log in and fetch mailbox messages', async () => {
  17. initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-imap-test-')), 'mail-access-secret');
  18. const { user, mailbox } = createMailboxFixture('imap.example', 'imap-user');
  19. createInboundMessage(mailbox, {
  20. sender: 'alice@example.net',
  21. recipients: ['admin@imap.example'],
  22. subject: 'IMAP hello',
  23. messageId: '<imap-hello@example.net>',
  24. rawMessage: [
  25. 'From: Alice <alice@example.net>',
  26. 'To: admin@imap.example',
  27. 'Subject: IMAP hello',
  28. 'Message-ID: <imap-hello@example.net>',
  29. '',
  30. 'Hello through IMAP.'
  31. ].join('\r\n'),
  32. textBody: 'Hello through IMAP.'
  33. });
  34. const [server] = startMailboxAccessServers({
  35. hostname: 'mail.imap.example',
  36. imapEnabled: true,
  37. imapListeners: [{ port: 0, protocol: 'imap' }],
  38. pop3Enabled: false,
  39. pop3Listeners: [],
  40. allowInsecureAuth: true
  41. });
  42. await waitForListening(server);
  43. try {
  44. const port = server.address().port;
  45. const client = await connectClient(port);
  46. await client.readUntil(/\* OK .* IMAP ready\r\n/);
  47. assert.match(await client.command('A1 LOGIN "admin@imap.example" "mailbox-pass-123"', /A1 OK/), /LOGIN completed/);
  48. const selected = await client.command('A2 SELECT INBOX', /A2 OK/);
  49. assert.match(selected, /\* 1 EXISTS/);
  50. const fetched = await client.command('A3 UID FETCH 1:* (UID FLAGS RFC822.SIZE BODY.PEEK[])', /A3 OK/);
  51. assert.match(fetched, /\* 1 FETCH/);
  52. assert.match(fetched, /UID 1/);
  53. assert.match(fetched, /Subject: IMAP hello/);
  54. assert.match(fetched, /Hello through IMAP\./);
  55. await client.command('A4 LOGOUT', /A4 OK/);
  56. client.close();
  57. assert.equal(listInboundMessages(user.id).length, 1);
  58. } finally {
  59. await closeServer(server);
  60. }
  61. });
  62. test('IMAP exposes MIME body structures and individual parts for Roundcube', async () => {
  63. initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-imap-mime-test-')), 'mail-access-secret');
  64. const { mailbox } = createMailboxFixture('mime.example', 'mime-user');
  65. createInboundMessage(mailbox, {
  66. sender: 'alice@example.net',
  67. recipients: ['admin@mime.example'],
  68. subject: 'MIME message',
  69. messageId: '<mime-message@example.net>',
  70. rawMessage: [
  71. 'From: Alice <alice@example.net>',
  72. 'To: admin@mime.example',
  73. 'Subject: MIME message',
  74. 'MIME-Version: 1.0',
  75. 'Content-Type: multipart/alternative; boundary="mailhub-boundary"',
  76. '',
  77. '--mailhub-boundary',
  78. 'Content-Type: text/plain; charset=UTF-8',
  79. 'Content-Transfer-Encoding: quoted-printable',
  80. '',
  81. 'Plain message body.',
  82. '--mailhub-boundary',
  83. 'Content-Type: text/html; charset=UTF-8',
  84. '',
  85. '<p>HTML message body.</p>',
  86. '--mailhub-boundary--',
  87. ''
  88. ].join('\r\n'),
  89. textBody: 'Plain message body.',
  90. htmlBody: '<p>HTML message body.</p>'
  91. });
  92. const [server] = startMailboxAccessServers({
  93. hostname: 'mail.mime.example',
  94. imapEnabled: true,
  95. imapListeners: [{ port: 0, protocol: 'imap' }],
  96. pop3Enabled: false,
  97. pop3Listeners: [],
  98. allowInsecureAuth: true
  99. });
  100. await waitForListening(server);
  101. let client;
  102. try {
  103. client = await connectClient(server.address().port);
  104. await client.readUntil(/\* OK .* IMAP ready\r\n/);
  105. assert.match(await client.command('A1 LOGIN "admin@mime.example" "mailbox-pass-123"', /A1 OK/), /LOGIN completed/);
  106. await client.command('A2 SELECT INBOX', /A2 OK/);
  107. const structure = await client.command('A3 UID FETCH 1 (UID BODYSTRUCTURE)', /A3 OK/);
  108. assert.match(structure, /BODYSTRUCTURE \(\("TEXT" "PLAIN" \("CHARSET" "UTF-8"\)/);
  109. assert.match(structure, /"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. 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: Sent copy',
  175. 'Message-ID: <sent-copy@append.example>',
  176. '',
  177. 'This copy belongs in Sent.'
  178. ].join('\r\n');
  179. client = await connectClient(server.address().port);
  180. await client.readUntil(/\* OK .* IMAP ready\r\n/);
  181. assert.match(await client.command('A1 LOGIN "admin@append.example" "mailbox-pass-123"', /A1 OK/), /LOGIN completed/);
  182. await client.append(`A2 APPEND Sent (\\Seen) {${Buffer.byteLength(sentMessage, 'utf8')}}`, sentMessage, /A2 OK/);
  183. const selectedSent = await client.command('A3 SELECT Sent', /A3 OK/);
  184. assert.match(selectedSent, /\* 1 EXISTS/);
  185. const fetchedSent = await client.command('A4 UID FETCH 1:* (UID FLAGS BODY.PEEK[])', /A4 OK/);
  186. assert.match(fetchedSent, /FLAGS \(\\Seen\)/);
  187. assert.match(fetchedSent, /Subject: Sent copy/);
  188. assert.match(fetchedSent, /This copy belongs in Sent\./);
  189. const selectedInbox = await client.command('A5 SELECT INBOX', /A5 OK/);
  190. assert.match(selectedInbox, /\* 0 EXISTS/);
  191. await client.command('A6 LOGOUT', /A6 OK/);
  192. client.close();
  193. } finally {
  194. client?.close();
  195. await closeServer(server);
  196. }
  197. });
  198. test('POP3 clients can retrieve and delete messages on quit', async () => {
  199. initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-pop3-test-')), 'mail-access-secret');
  200. const { user, mailbox } = createMailboxFixture('pop3.example', 'pop3-user');
  201. createInboundMessage(mailbox, {
  202. sender: 'bob@example.net',
  203. recipients: ['admin@pop3.example'],
  204. subject: 'POP3 hello',
  205. messageId: '<pop3-hello@example.net>',
  206. rawMessage: [
  207. 'From: Bob <bob@example.net>',
  208. 'To: admin@pop3.example',
  209. 'Subject: POP3 hello',
  210. 'Message-ID: <pop3-hello@example.net>',
  211. '',
  212. 'Hello through POP3.'
  213. ].join('\r\n'),
  214. textBody: 'Hello through POP3.'
  215. });
  216. const [server] = startMailboxAccessServers({
  217. hostname: 'mail.pop3.example',
  218. imapEnabled: false,
  219. imapListeners: [],
  220. pop3Enabled: true,
  221. pop3Listeners: [{ port: 0, protocol: 'pop3' }],
  222. allowInsecureAuth: true
  223. });
  224. await waitForListening(server);
  225. try {
  226. const client = await connectClient(server.address().port);
  227. await client.readUntil(/\+OK .* POP3 ready\r\n/);
  228. assert.match(await client.command('USER admin@pop3.example', /\+OK/), /User accepted/);
  229. assert.match(await client.command('PASS mailbox-pass-123', /\+OK/), /ready/);
  230. assert.match(await client.command('STAT', /\+OK \d+ \d+/), /\+OK 1 /);
  231. assert.match(await client.command('UIDL 1', /\+OK 1 mh-1/), /\+OK 1 mh-1/);
  232. const retrieved = await client.command('RETR 1', /\r\n\.\r\n/);
  233. assert.match(retrieved, /Subject: POP3 hello/);
  234. assert.match(retrieved, /Hello through POP3\./);
  235. assert.match(await client.command('DELE 1', /\+OK/), /deleted/);
  236. await client.command('QUIT', /\+OK Bye/);
  237. client.close();
  238. assert.equal(listInboundMessages(user.id).length, 0);
  239. } finally {
  240. await closeServer(server);
  241. }
  242. });
  243. function createMailboxFixture(domainName, username) {
  244. const user = createUser({ username, email: `${username}@example.com`, password: 'password123' });
  245. createDomain(user.id, {
  246. domain: domainName,
  247. selector: 'mh',
  248. verificationToken: 'verify',
  249. dkimPublic: 'public',
  250. dkimPrivate: 'private',
  251. senderHost: `mail.${domainName}`,
  252. sendingIp: '192.0.2.30',
  253. spfExtra: '',
  254. dmarcPolicy: 'none',
  255. dmarcRua: ''
  256. });
  257. const mailbox = createInboundMailbox(user.id, {
  258. address: `admin@${domainName}`,
  259. password: 'mailbox-pass-123'
  260. });
  261. return { user, mailbox };
  262. }
  263. function connectClient(port) {
  264. return new Promise((resolve, reject) => {
  265. const socket = net.createConnection({ host: '127.0.0.1', port });
  266. socket.setEncoding('utf8');
  267. socket.setTimeout(5000);
  268. let buffer = '';
  269. const waiters = [];
  270. socket.on('data', (chunk) => {
  271. buffer += chunk;
  272. for (const waiter of [...waiters]) {
  273. if (waiter.pattern.test(buffer)) {
  274. waiters.splice(waiters.indexOf(waiter), 1);
  275. const output = buffer;
  276. buffer = '';
  277. waiter.resolve(output);
  278. }
  279. }
  280. });
  281. socket.once('connect', () => resolve({
  282. command(command, pattern) {
  283. socket.write(`${command}\r\n`);
  284. return this.readUntil(pattern);
  285. },
  286. async append(command, literal, pattern) {
  287. socket.write(`${command}\r\n`);
  288. await this.readUntil(/^\+ /m);
  289. socket.write(`${literal}\r\n`);
  290. return this.readUntil(pattern);
  291. },
  292. readUntil(pattern) {
  293. if (pattern.test(buffer)) {
  294. const output = buffer;
  295. buffer = '';
  296. return Promise.resolve(output);
  297. }
  298. return new Promise((waitResolve, waitReject) => {
  299. const waiter = {
  300. pattern,
  301. resolve(output) {
  302. clearTimeout(waiter.timer);
  303. waitResolve(output);
  304. },
  305. reject(error) {
  306. clearTimeout(waiter.timer);
  307. waitReject(error);
  308. },
  309. timer: null
  310. };
  311. waiter.timer = setTimeout(() => {
  312. waiters.splice(waiters.indexOf(waiter), 1);
  313. waitReject(new Error(`Timed out waiting for ${pattern}; buffered response: ${buffer}`));
  314. }, 5000);
  315. waiters.push(waiter);
  316. });
  317. },
  318. close() {
  319. socket.destroy();
  320. }
  321. }));
  322. socket.once('error', reject);
  323. socket.once('timeout', () => reject(new Error('Mail access client timed out')));
  324. });
  325. }
  326. function waitForListening(server) {
  327. if (server.listening) return Promise.resolve();
  328. return new Promise((resolve) => server.once('listening', resolve));
  329. }
  330. function closeServer(server) {
  331. return new Promise((resolve, reject) => {
  332. server.close((error) => error ? reject(error) : resolve());
  333. });
  334. }