mail-access.test.js 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295
  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 standard folders expected by mainstream clients', async () => {
  63. initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-imap-folders-test-')), 'mail-access-secret');
  64. createMailboxFixture('folders.example', 'folders-user');
  65. const [server] = startMailboxAccessServers({
  66. hostname: 'mail.folders.example',
  67. imapEnabled: true,
  68. imapListeners: [{ port: 0, protocol: 'imap' }],
  69. pop3Enabled: false,
  70. pop3Listeners: [],
  71. allowInsecureAuth: true
  72. });
  73. await waitForListening(server);
  74. let client;
  75. try {
  76. client = await connectClient(server.address().port);
  77. await client.readUntil(/\* OK .* IMAP ready\r\n/);
  78. assert.match(await client.command('A1 LOGIN "admin@folders.example" "mailbox-pass-123"', /A1 OK/), /LOGIN completed/);
  79. const listed = await client.command('A2 LIST "" "*"', /A2 OK/);
  80. assert.match(listed, /\* LIST .* "INBOX"/);
  81. assert.match(listed, /\* LIST .*\\Sent.* "Sent"/);
  82. assert.match(listed, /\* LIST .*\\Drafts.* "Drafts"/);
  83. assert.match(listed, /\* LIST .*\\Trash.* "Trash"/);
  84. assert.match(listed, /\* LIST .*\\Junk.* "Junk"/);
  85. assert.match(listed, /\* LIST .*\\Archive.* "Archive"/);
  86. const selected = await client.command('A3 SELECT Sent', /A3 OK/);
  87. assert.match(selected, /\* 0 EXISTS/);
  88. await client.command('A4 LOGOUT', /A4 OK/);
  89. client.close();
  90. } finally {
  91. client?.close();
  92. await closeServer(server);
  93. }
  94. });
  95. test('IMAP APPEND stores sent messages in the Sent folder', async () => {
  96. initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-imap-append-test-')), 'mail-access-secret');
  97. createMailboxFixture('append.example', 'append-user');
  98. const [server] = startMailboxAccessServers({
  99. hostname: 'mail.append.example',
  100. imapEnabled: true,
  101. imapListeners: [{ port: 0, protocol: 'imap' }],
  102. pop3Enabled: false,
  103. pop3Listeners: [],
  104. allowInsecureAuth: true
  105. });
  106. await waitForListening(server);
  107. let client;
  108. try {
  109. const sentMessage = [
  110. 'From: Admin <admin@append.example>',
  111. 'To: Bob <bob@example.net>',
  112. 'Subject: Sent copy',
  113. 'Message-ID: <sent-copy@append.example>',
  114. '',
  115. 'This copy belongs in Sent.'
  116. ].join('\r\n');
  117. client = await connectClient(server.address().port);
  118. await client.readUntil(/\* OK .* IMAP ready\r\n/);
  119. assert.match(await client.command('A1 LOGIN "admin@append.example" "mailbox-pass-123"', /A1 OK/), /LOGIN completed/);
  120. await client.append(`A2 APPEND Sent (\\Seen) {${Buffer.byteLength(sentMessage, 'utf8')}}`, sentMessage, /A2 OK/);
  121. const selectedSent = await client.command('A3 SELECT Sent', /A3 OK/);
  122. assert.match(selectedSent, /\* 1 EXISTS/);
  123. const fetchedSent = await client.command('A4 UID FETCH 1:* (UID FLAGS BODY.PEEK[])', /A4 OK/);
  124. assert.match(fetchedSent, /FLAGS \(\\Seen\)/);
  125. assert.match(fetchedSent, /Subject: Sent copy/);
  126. assert.match(fetchedSent, /This copy belongs in Sent\./);
  127. const selectedInbox = await client.command('A5 SELECT INBOX', /A5 OK/);
  128. assert.match(selectedInbox, /\* 0 EXISTS/);
  129. await client.command('A6 LOGOUT', /A6 OK/);
  130. client.close();
  131. } finally {
  132. client?.close();
  133. await closeServer(server);
  134. }
  135. });
  136. test('POP3 clients can retrieve and delete messages on quit', async () => {
  137. initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-pop3-test-')), 'mail-access-secret');
  138. const { user, mailbox } = createMailboxFixture('pop3.example', 'pop3-user');
  139. createInboundMessage(mailbox, {
  140. sender: 'bob@example.net',
  141. recipients: ['admin@pop3.example'],
  142. subject: 'POP3 hello',
  143. messageId: '<pop3-hello@example.net>',
  144. rawMessage: [
  145. 'From: Bob <bob@example.net>',
  146. 'To: admin@pop3.example',
  147. 'Subject: POP3 hello',
  148. 'Message-ID: <pop3-hello@example.net>',
  149. '',
  150. 'Hello through POP3.'
  151. ].join('\r\n'),
  152. textBody: 'Hello through POP3.'
  153. });
  154. const [server] = startMailboxAccessServers({
  155. hostname: 'mail.pop3.example',
  156. imapEnabled: false,
  157. imapListeners: [],
  158. pop3Enabled: true,
  159. pop3Listeners: [{ port: 0, protocol: 'pop3' }],
  160. allowInsecureAuth: true
  161. });
  162. await waitForListening(server);
  163. try {
  164. const client = await connectClient(server.address().port);
  165. await client.readUntil(/\+OK .* POP3 ready\r\n/);
  166. assert.match(await client.command('USER admin@pop3.example', /\+OK/), /User accepted/);
  167. assert.match(await client.command('PASS mailbox-pass-123', /\+OK/), /ready/);
  168. assert.match(await client.command('STAT', /\+OK \d+ \d+/), /\+OK 1 /);
  169. assert.match(await client.command('UIDL 1', /\+OK 1 mh-1/), /\+OK 1 mh-1/);
  170. const retrieved = await client.command('RETR 1', /\r\n\.\r\n/);
  171. assert.match(retrieved, /Subject: POP3 hello/);
  172. assert.match(retrieved, /Hello through POP3\./);
  173. assert.match(await client.command('DELE 1', /\+OK/), /deleted/);
  174. await client.command('QUIT', /\+OK Bye/);
  175. client.close();
  176. assert.equal(listInboundMessages(user.id).length, 0);
  177. } finally {
  178. await closeServer(server);
  179. }
  180. });
  181. function createMailboxFixture(domainName, username) {
  182. const user = createUser({ username, email: `${username}@example.com`, password: 'password123' });
  183. createDomain(user.id, {
  184. domain: domainName,
  185. selector: 'mh',
  186. verificationToken: 'verify',
  187. dkimPublic: 'public',
  188. dkimPrivate: 'private',
  189. senderHost: `mail.${domainName}`,
  190. sendingIp: '192.0.2.30',
  191. spfExtra: '',
  192. dmarcPolicy: 'none',
  193. dmarcRua: ''
  194. });
  195. const mailbox = createInboundMailbox(user.id, {
  196. address: `admin@${domainName}`,
  197. password: 'mailbox-pass-123'
  198. });
  199. return { user, mailbox };
  200. }
  201. function connectClient(port) {
  202. return new Promise((resolve, reject) => {
  203. const socket = net.createConnection({ host: '127.0.0.1', port });
  204. socket.setEncoding('utf8');
  205. socket.setTimeout(5000);
  206. let buffer = '';
  207. const waiters = [];
  208. socket.on('data', (chunk) => {
  209. buffer += chunk;
  210. for (const waiter of [...waiters]) {
  211. if (waiter.pattern.test(buffer)) {
  212. waiters.splice(waiters.indexOf(waiter), 1);
  213. const output = buffer;
  214. buffer = '';
  215. waiter.resolve(output);
  216. }
  217. }
  218. });
  219. socket.once('connect', () => resolve({
  220. command(command, pattern) {
  221. socket.write(`${command}\r\n`);
  222. return this.readUntil(pattern);
  223. },
  224. async append(command, literal, pattern) {
  225. socket.write(`${command}\r\n`);
  226. await this.readUntil(/^\+ /m);
  227. socket.write(`${literal}\r\n`);
  228. return this.readUntil(pattern);
  229. },
  230. readUntil(pattern) {
  231. if (pattern.test(buffer)) {
  232. const output = buffer;
  233. buffer = '';
  234. return Promise.resolve(output);
  235. }
  236. return new Promise((waitResolve, waitReject) => {
  237. const waiter = {
  238. pattern,
  239. resolve(output) {
  240. clearTimeout(waiter.timer);
  241. waitResolve(output);
  242. },
  243. reject(error) {
  244. clearTimeout(waiter.timer);
  245. waitReject(error);
  246. },
  247. timer: null
  248. };
  249. waiter.timer = setTimeout(() => {
  250. waiters.splice(waiters.indexOf(waiter), 1);
  251. waitReject(new Error(`Timed out waiting for ${pattern}; buffered response: ${buffer}`));
  252. }, 5000);
  253. waiters.push(waiter);
  254. });
  255. },
  256. close() {
  257. socket.destroy();
  258. }
  259. }));
  260. socket.once('error', reject);
  261. socket.once('timeout', () => reject(new Error('Mail access client timed out')));
  262. });
  263. }
  264. function waitForListening(server) {
  265. if (server.listening) return Promise.resolve();
  266. return new Promise((resolve) => server.once('listening', resolve));
  267. }
  268. function closeServer(server) {
  269. return new Promise((resolve, reject) => {
  270. server.close((error) => error ? reject(error) : resolve());
  271. });
  272. }