maildir-sync.test.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320
  1. import assert from 'node:assert/strict';
  2. import { mkdtempSync } from 'node:fs';
  3. import { mkdir, rename, rm } from 'node:fs/promises';
  4. import { tmpdir } from 'node:os';
  5. import path from 'node:path';
  6. import { test } from 'node:test';
  7. import {
  8. commitInboundMaildirMigrationStaging,
  9. createDomain,
  10. createInboundMailbox,
  11. createInboundMessage,
  12. createUser,
  13. createWebhook,
  14. getInboundMessage,
  15. initDatabase,
  16. listInboundMailboxFolders,
  17. listInboundMaildirIndex,
  18. listInboundMaildirMigrationStaging,
  19. listInboundMessages,
  20. listWebhookDeliveries,
  21. stageInboundMessageMaildirStorageBatch
  22. } from '../src/db.js';
  23. import {
  24. ensureMaildirMailbox,
  25. maildirFolderPath,
  26. scanMaildirMailbox,
  27. writeMaildirMessage
  28. } from '../src/maildir-store.js';
  29. import {
  30. migrateInboundMessagesToMaildir,
  31. reconcileMaildirMailbox
  32. } from '../src/maildir-sync.js';
  33. test('SQLite migration is resumable and Maildir changes reconcile into the management index', async () => {
  34. const dataDir = mkdtempSync(path.join(tmpdir(), 'mailhub-maildir-sync-db-'));
  35. const maildirRoot = path.join(dataDir, 'maildir');
  36. initDatabase(dataDir, 'maildir-sync-secret');
  37. const user = createUser({
  38. username: 'maildir-sync-user',
  39. email: 'maildir-sync-user@example.com',
  40. password: 'password123'
  41. });
  42. createDomain(user.id, {
  43. domain: 'maildir-sync.example',
  44. selector: 'mh',
  45. verificationToken: 'verify',
  46. dkimPublic: 'public',
  47. dkimPrivate: 'private',
  48. senderHost: 'mail.maildir-sync.example',
  49. sendingIp: '192.0.2.70',
  50. spfExtra: '',
  51. dmarcPolicy: 'none',
  52. dmarcRua: ''
  53. });
  54. const mailbox = createInboundMailbox(user.id, {
  55. address: 'box@maildir-sync.example',
  56. password: 'mailbox-pass-123'
  57. });
  58. const originalRaw = 'From: alice@example.net\r\nSubject: Existing message\r\n\r\nBody';
  59. const original = createInboundMessage(mailbox, {
  60. sender: 'alice@example.net',
  61. recipients: [mailbox.address],
  62. subject: 'Existing message',
  63. rawMessage: originalRaw,
  64. textBody: 'Body'
  65. });
  66. const interruptedStorage = await writeMaildirMessage({
  67. root: maildirRoot,
  68. address: mailbox.address,
  69. rawMessageBytes: Buffer.from(originalRaw, 'utf8'),
  70. storageKey: `mhdb-${original.id}`,
  71. durable: false
  72. });
  73. stageInboundMessageMaildirStorageBatch([{ id: original.id, storage: interruptedStorage }]);
  74. const first = await migrateInboundMessagesToMaildir({ root: maildirRoot });
  75. assert.deepEqual(first, { processed: 1, written: 1, reused: 0, lastId: original.id });
  76. const migratedFiles = await scanMaildirMailbox({ root: maildirRoot, address: mailbox.address });
  77. assert.equal(migratedFiles.length, 1);
  78. assert.equal(migratedFiles[0].storageKey, `mhdb-${original.id}`);
  79. assert.equal(listInboundMaildirIndex(mailbox.id)[0].storageKey, `mhdb-${original.id}`);
  80. const second = await migrateInboundMessagesToMaildir({ root: maildirRoot });
  81. assert.deepEqual(second, { processed: 0, written: 0, reused: 0, lastId: 0 });
  82. assert.equal((await scanMaildirMailbox({ root: maildirRoot, address: mailbox.address })).length, 1);
  83. const archiveCur = path.join(maildirFolderPath(maildirRoot, mailbox.address, 'Archive'), 'cur');
  84. await mkdir(archiveCur, { recursive: true });
  85. const archivedPath = path.join(
  86. archiveCur,
  87. `${migratedFiles[0].baseName}:2,S`
  88. );
  89. await rename(migratedFiles[0].filePath, archivedPath);
  90. const missingCounts = new Map();
  91. const moved = await reconcileMaildirMailbox({ root: maildirRoot, mailbox, missingCounts });
  92. assert.equal(moved.updated, 1);
  93. assert.equal(getInboundMessage(user.id, original.id).folder, 'Archive');
  94. assert.equal(getInboundMessage(user.id, original.id).read, true);
  95. await writeMaildirMessage({
  96. root: maildirRoot,
  97. address: mailbox.address,
  98. rawMessageBytes: Buffer.from('From: bob@example.net\r\nSubject: Appended message\r\n\r\nNew body'),
  99. folder: 'Sent',
  100. read: true,
  101. storageKey: 'mhappend-client-1'
  102. });
  103. const appended = await reconcileMaildirMailbox({ root: maildirRoot, mailbox, missingCounts });
  104. assert.equal(appended.created, 1);
  105. const messages = listInboundMessages(user.id, { folder: 'Sent' });
  106. assert.equal(messages.length, 1);
  107. assert.equal(messages[0].subject, 'Appended message');
  108. await ensureMaildirMailbox(maildirRoot, mailbox.address, ['team.ops']);
  109. const folderAdded = await reconcileMaildirMailbox({ root: maildirRoot, mailbox, missingCounts });
  110. assert.equal(folderAdded.foldersCreatedOrRestored, 1);
  111. assert.equal(
  112. listInboundMailboxFolders(user.id, mailbox.id).some((folder) => folder.name === 'team.ops'),
  113. true
  114. );
  115. await rm(maildirFolderPath(maildirRoot, mailbox.address, 'team.ops'), { recursive: true });
  116. const folderRemoved = await reconcileMaildirMailbox({ root: maildirRoot, mailbox, missingCounts });
  117. assert.equal(folderRemoved.foldersDeleted, 1);
  118. assert.equal(
  119. listInboundMailboxFolders(user.id, mailbox.id).some((folder) => folder.name === 'team.ops'),
  120. false
  121. );
  122. const archiveEntry = (await scanMaildirMailbox({ root: maildirRoot, address: mailbox.address }))
  123. .find((entry) => entry.storageKey === `mhdb-${original.id}`);
  124. await rm(archiveEntry.filePath);
  125. const firstMissing = await reconcileMaildirMailbox({ root: maildirRoot, mailbox, missingCounts });
  126. assert.equal(firstMissing.deleted, 0);
  127. const secondMissing = await reconcileMaildirMailbox({ root: maildirRoot, mailbox, missingCounts });
  128. assert.equal(secondMissing.deleted, 1);
  129. assert.equal(getInboundMessage(user.id, original.id), null);
  130. });
  131. test('SQLite migration stops when a resumable Maildir key contains different bytes', async () => {
  132. const dataDir = mkdtempSync(path.join(tmpdir(), 'mailhub-maildir-conflict-db-'));
  133. const maildirRoot = path.join(dataDir, 'maildir');
  134. initDatabase(dataDir, 'maildir-conflict-secret');
  135. const user = createUser({
  136. username: 'maildir-conflict-user',
  137. email: 'maildir-conflict-user@example.com',
  138. password: 'password123'
  139. });
  140. createDomain(user.id, {
  141. domain: 'maildir-conflict.example',
  142. selector: 'mh',
  143. verificationToken: 'verify',
  144. dkimPublic: 'public',
  145. dkimPrivate: 'private',
  146. senderHost: 'mail.maildir-conflict.example',
  147. sendingIp: '192.0.2.71',
  148. spfExtra: '',
  149. dmarcPolicy: 'none',
  150. dmarcRua: ''
  151. });
  152. const mailbox = createInboundMailbox(user.id, {
  153. address: 'box@maildir-conflict.example',
  154. password: 'mailbox-pass-123'
  155. });
  156. const preparedBeforeFailure = createInboundMessage(mailbox, {
  157. sender: 'first@example.net',
  158. recipients: [mailbox.address],
  159. subject: 'Prepared before conflict',
  160. rawMessage: 'From: first@example.net\r\nSubject: Prepared before conflict\r\n\r\nFirst'
  161. });
  162. const original = createInboundMessage(mailbox, {
  163. sender: 'alice@example.net',
  164. recipients: [mailbox.address],
  165. subject: 'Original message',
  166. rawMessage: 'From: alice@example.net\r\nSubject: Original message\r\n\r\nOriginal'
  167. });
  168. await writeMaildirMessage({
  169. root: maildirRoot,
  170. address: mailbox.address,
  171. rawMessageBytes: Buffer.from('From: mallory@example.net\r\nSubject: Conflict\r\n\r\nConflict'),
  172. storageKey: `mhdb-${original.id}`
  173. });
  174. await assert.rejects(
  175. migrateInboundMessagesToMaildir({ root: maildirRoot }),
  176. /存储标识已被其他内容占用/
  177. );
  178. assert.equal(listInboundMaildirIndex(mailbox.id).length, 0);
  179. const remaining = await scanMaildirMailbox({ root: maildirRoot, address: mailbox.address });
  180. assert.equal(remaining.some((entry) => entry.storageKey === `mhdb-${preparedBeforeFailure.id}`), false);
  181. assert.equal(remaining.some((entry) => entry.storageKey === `mhdb-${original.id}`), true);
  182. });
  183. test('Maildir cutover rejects a staging count mismatch before changing the message index', () => {
  184. const dataDir = mkdtempSync(path.join(tmpdir(), 'mailhub-maildir-count-db-'));
  185. initDatabase(dataDir, 'maildir-count-secret');
  186. const user = createUser({
  187. username: 'maildir-count-user',
  188. email: 'maildir-count-user@example.com',
  189. password: 'password123'
  190. });
  191. createDomain(user.id, {
  192. domain: 'maildir-count.example',
  193. selector: 'mh',
  194. verificationToken: 'verify',
  195. dkimPublic: 'public',
  196. dkimPrivate: 'private',
  197. senderHost: 'mail.maildir-count.example',
  198. sendingIp: '192.0.2.72',
  199. spfExtra: '',
  200. dmarcPolicy: 'none',
  201. dmarcRua: ''
  202. });
  203. const mailbox = createInboundMailbox(user.id, {
  204. address: 'box@maildir-count.example',
  205. password: 'mailbox-pass-123'
  206. });
  207. const message = createInboundMessage(mailbox, {
  208. sender: 'alice@example.net',
  209. recipients: [mailbox.address],
  210. subject: 'Count guard',
  211. rawMessage: 'From: alice@example.net\r\nSubject: Count guard\r\n\r\nBody'
  212. });
  213. stageInboundMessageMaildirStorageBatch([{
  214. id: message.id,
  215. storage: {
  216. backend: 'maildir',
  217. key: `mhdb-${message.id}`,
  218. relpath: `mail/new/mhdb-${message.id}.mailhub`,
  219. sha256: 'a'.repeat(64),
  220. size: 4,
  221. mtimeMs: Date.now(),
  222. indexedAt: new Date().toISOString()
  223. }
  224. }]);
  225. assert.throws(
  226. () => commitInboundMaildirMigrationStaging(2),
  227. /Maildir 迁移暂存数量不一致/
  228. );
  229. assert.equal(listInboundMaildirIndex(mailbox.id).length, 0);
  230. assert.equal(listInboundMaildirMigrationStaging().length, 1);
  231. });
  232. test('Maildir reconciliation restores a missing SMTP index and queues its receipt webhook once', async () => {
  233. const dataDir = mkdtempSync(path.join(tmpdir(), 'mailhub-maildir-webhook-db-'));
  234. const maildirRoot = path.join(dataDir, 'maildir');
  235. const database = initDatabase(dataDir, 'maildir-webhook-secret');
  236. const user = createUser({
  237. username: 'maildir-webhook-user',
  238. email: 'maildir-webhook-user@example.com',
  239. password: 'password123'
  240. });
  241. createDomain(user.id, {
  242. domain: 'maildir-webhook.example',
  243. selector: 'mh',
  244. verificationToken: 'verify',
  245. dkimPublic: 'public',
  246. dkimPrivate: 'private',
  247. senderHost: 'mail.maildir-webhook.example',
  248. sendingIp: '192.0.2.73',
  249. spfExtra: '',
  250. dmarcPolicy: 'none',
  251. dmarcRua: ''
  252. });
  253. const mailbox = createInboundMailbox(user.id, {
  254. address: 'box@maildir-webhook.example',
  255. password: 'mailbox-pass-123'
  256. });
  257. createWebhook(user.id, {
  258. name: 'Recovered SMTP receipt',
  259. url: 'https://hooks.example.com/recovered-smtp',
  260. events: ['received'],
  261. mailboxId: mailbox.id
  262. });
  263. await writeMaildirMessage({
  264. root: maildirRoot,
  265. address: mailbox.address,
  266. rawMessageBytes: Buffer.from([
  267. 'Message-ID: <recovered-smtp@example.net>',
  268. 'From: sender@example.net',
  269. `To: ${mailbox.address}`,
  270. 'Subject: Recovered SMTP receipt',
  271. '',
  272. 'Recovered body'
  273. ].join('\r\n')),
  274. storageKey: 'mhsmtp-recovered-1'
  275. });
  276. database.exec(`
  277. CREATE TRIGGER reject_recovered_smtp_webhook
  278. BEFORE INSERT ON webhook_deliveries
  279. WHEN NEW.inbound_message_id IS NOT NULL
  280. BEGIN
  281. SELECT RAISE(ABORT, 'forced webhook failure');
  282. END;
  283. `);
  284. await assert.rejects(
  285. reconcileMaildirMailbox({ root: maildirRoot, mailbox }),
  286. /forced webhook failure/
  287. );
  288. assert.equal(listInboundMessages(user.id, { mailboxId: mailbox.id }).length, 0);
  289. assert.equal(listWebhookDeliveries(user.id, { eventType: 'received' }).length, 0);
  290. database.exec('DROP TRIGGER reject_recovered_smtp_webhook;');
  291. const first = await reconcileMaildirMailbox({ root: maildirRoot, mailbox });
  292. assert.equal(first.created, 1);
  293. const [message] = listInboundMessages(user.id, { mailboxId: mailbox.id });
  294. assert.equal(message.subject, 'Recovered SMTP receipt');
  295. let deliveries = listWebhookDeliveries(user.id, { eventType: 'received' });
  296. assert.equal(deliveries.length, 1);
  297. assert.equal(deliveries[0].inboundMessageId, message.id);
  298. assert.equal(JSON.parse(deliveries[0].payloadJson).type, 'email.received');
  299. const second = await reconcileMaildirMailbox({ root: maildirRoot, mailbox });
  300. assert.equal(second.created, 0);
  301. deliveries = listWebhookDeliveries(user.id, { eventType: 'received' });
  302. assert.equal(deliveries.length, 1);
  303. });