maildir-sync.test.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400
  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. acquireMaildirReconciliationLocks,
  31. migrateInboundMessagesToMaildir,
  32. reconcileMaildirMailbox
  33. } from '../src/maildir-sync.js';
  34. test('SQLite migration is resumable and Maildir changes reconcile into the management index', async () => {
  35. const dataDir = mkdtempSync(path.join(tmpdir(), 'mailhub-maildir-sync-db-'));
  36. const maildirRoot = path.join(dataDir, 'maildir');
  37. initDatabase(dataDir, 'maildir-sync-secret');
  38. const user = createUser({
  39. username: 'maildir-sync-user',
  40. email: 'maildir-sync-user@example.com',
  41. password: 'password123'
  42. });
  43. createDomain(user.id, {
  44. domain: 'maildir-sync.example',
  45. selector: 'mh',
  46. verificationToken: 'verify',
  47. dkimPublic: 'public',
  48. dkimPrivate: 'private',
  49. senderHost: 'mail.maildir-sync.example',
  50. sendingIp: '192.0.2.70',
  51. spfExtra: '',
  52. dmarcPolicy: 'none',
  53. dmarcRua: ''
  54. });
  55. const mailbox = createInboundMailbox(user.id, {
  56. address: 'box@maildir-sync.example',
  57. password: 'mailbox-pass-123'
  58. });
  59. const originalRaw = 'From: alice@example.net\r\nSubject: Existing message\r\n\r\nBody';
  60. const original = createInboundMessage(mailbox, {
  61. sender: 'alice@example.net',
  62. recipients: [mailbox.address],
  63. subject: 'Existing message',
  64. rawMessage: originalRaw,
  65. textBody: 'Body'
  66. });
  67. const interruptedStorage = await writeMaildirMessage({
  68. root: maildirRoot,
  69. address: mailbox.address,
  70. rawMessageBytes: Buffer.from(originalRaw, 'utf8'),
  71. storageKey: `mhdb-${original.id}`,
  72. durable: false
  73. });
  74. stageInboundMessageMaildirStorageBatch([{ id: original.id, storage: interruptedStorage }]);
  75. const first = await migrateInboundMessagesToMaildir({ root: maildirRoot });
  76. assert.deepEqual(first, { processed: 1, written: 1, reused: 0, lastId: original.id });
  77. const migratedFiles = await scanMaildirMailbox({ root: maildirRoot, address: mailbox.address });
  78. assert.equal(migratedFiles.length, 1);
  79. assert.equal(migratedFiles[0].storageKey, `mhdb-${original.id}`);
  80. assert.equal(listInboundMaildirIndex(mailbox.id)[0].storageKey, `mhdb-${original.id}`);
  81. const second = await migrateInboundMessagesToMaildir({ root: maildirRoot });
  82. assert.deepEqual(second, { processed: 0, written: 0, reused: 0, lastId: 0 });
  83. assert.equal((await scanMaildirMailbox({ root: maildirRoot, address: mailbox.address })).length, 1);
  84. const archiveCur = path.join(maildirFolderPath(maildirRoot, mailbox.address, 'Archive'), 'cur');
  85. await mkdir(archiveCur, { recursive: true });
  86. const archivedPath = path.join(
  87. archiveCur,
  88. `${migratedFiles[0].baseName}:2,S`
  89. );
  90. await rename(migratedFiles[0].filePath, archivedPath);
  91. const missingCounts = new Map();
  92. const moved = await reconcileMaildirMailbox({ root: maildirRoot, mailbox, missingCounts });
  93. assert.equal(moved.updated, 1);
  94. assert.equal(getInboundMessage(user.id, original.id).folder, 'Archive');
  95. assert.equal(getInboundMessage(user.id, original.id).read, true);
  96. await writeMaildirMessage({
  97. root: maildirRoot,
  98. address: mailbox.address,
  99. rawMessageBytes: Buffer.from('From: bob@example.net\r\nSubject: Appended message\r\n\r\nNew body'),
  100. folder: 'Sent',
  101. read: true,
  102. storageKey: 'mhappend-client-1'
  103. });
  104. const appended = await reconcileMaildirMailbox({ root: maildirRoot, mailbox, missingCounts });
  105. assert.equal(appended.created, 1);
  106. const messages = listInboundMessages(user.id, { folder: 'Sent' });
  107. assert.equal(messages.length, 1);
  108. assert.equal(messages[0].subject, 'Appended message');
  109. await ensureMaildirMailbox(maildirRoot, mailbox.address, ['team.ops']);
  110. const folderAdded = await reconcileMaildirMailbox({ root: maildirRoot, mailbox, missingCounts });
  111. assert.equal(folderAdded.foldersCreatedOrRestored, 1);
  112. assert.equal(
  113. listInboundMailboxFolders(user.id, mailbox.id).some((folder) => folder.name === 'team.ops'),
  114. true
  115. );
  116. await rm(maildirFolderPath(maildirRoot, mailbox.address, 'team.ops'), { recursive: true });
  117. const folderRemoved = await reconcileMaildirMailbox({ root: maildirRoot, mailbox, missingCounts });
  118. assert.equal(folderRemoved.foldersDeleted, 1);
  119. assert.equal(
  120. listInboundMailboxFolders(user.id, mailbox.id).some((folder) => folder.name === 'team.ops'),
  121. false
  122. );
  123. const archiveEntry = (await scanMaildirMailbox({ root: maildirRoot, address: mailbox.address }))
  124. .find((entry) => entry.storageKey === `mhdb-${original.id}`);
  125. await rm(archiveEntry.filePath);
  126. const firstMissing = await reconcileMaildirMailbox({ root: maildirRoot, mailbox, missingCounts });
  127. assert.equal(firstMissing.deleted, 0);
  128. const secondMissing = await reconcileMaildirMailbox({ root: maildirRoot, mailbox, missingCounts });
  129. assert.equal(secondMissing.deleted, 1);
  130. assert.equal(getInboundMessage(user.id, original.id), null);
  131. });
  132. test('Maildir reconciliation waits for sorted mailbox locks and releases them idempotently', async () => {
  133. const dataDir = mkdtempSync(path.join(tmpdir(), 'mailhub-maildir-lock-db-'));
  134. const maildirRoot = path.join(dataDir, 'maildir');
  135. initDatabase(dataDir, 'maildir-lock-secret');
  136. const user = createUser({
  137. username: 'maildir-lock-user',
  138. email: 'maildir-lock-user@example.com',
  139. password: 'password123'
  140. });
  141. createDomain(user.id, {
  142. domain: 'maildir-lock.example',
  143. selector: 'mh',
  144. verificationToken: 'verify',
  145. dkimPublic: 'public',
  146. dkimPrivate: 'private',
  147. senderHost: 'mail.maildir-lock.example',
  148. sendingIp: '192.0.2.74',
  149. spfExtra: '',
  150. dmarcPolicy: 'none',
  151. dmarcRua: ''
  152. });
  153. const source = createInboundMailbox(user.id, {
  154. address: 'source@maildir-lock.example',
  155. password: 'mailbox-pass-123'
  156. });
  157. const target = createInboundMailbox(user.id, {
  158. address: 'target@maildir-lock.example',
  159. password: 'mailbox-pass-456'
  160. });
  161. const releaseTransfer = await acquireMaildirReconciliationLocks([
  162. target.id,
  163. source.id,
  164. target.id
  165. ]);
  166. let sourceSettled = false;
  167. let targetSettled = false;
  168. const sourceReconcile = reconcileMaildirMailbox({ root: maildirRoot, mailbox: source })
  169. .finally(() => { sourceSettled = true; });
  170. const targetReconcile = reconcileMaildirMailbox({ root: maildirRoot, mailbox: target })
  171. .finally(() => { targetSettled = true; });
  172. await new Promise((resolve) => setImmediate(resolve));
  173. assert.equal(sourceSettled, false);
  174. assert.equal(targetSettled, false);
  175. const lockedReport = await reconcileMaildirMailbox({
  176. root: maildirRoot,
  177. mailbox: source,
  178. skipLock: true
  179. });
  180. assert.equal(lockedReport.mailboxId, source.id);
  181. assert.equal(sourceSettled, false);
  182. releaseTransfer();
  183. releaseTransfer();
  184. const reports = await Promise.all([sourceReconcile, targetReconcile]);
  185. assert.deepEqual(reports.map((report) => report.mailboxId).sort((left, right) => left - right), [
  186. source.id,
  187. target.id
  188. ]);
  189. const releaseAgain = await acquireMaildirReconciliationLocks([source.id, target.id]);
  190. let reversedOrderAcquired = false;
  191. const reversedOrder = acquireMaildirReconciliationLocks([target.id, source.id])
  192. .then((release) => {
  193. reversedOrderAcquired = true;
  194. return release;
  195. });
  196. await new Promise((resolve) => setImmediate(resolve));
  197. assert.equal(reversedOrderAcquired, false);
  198. releaseAgain();
  199. const releaseReversedOrder = await reversedOrder;
  200. assert.equal(reversedOrderAcquired, true);
  201. releaseReversedOrder();
  202. await assert.rejects(
  203. acquireMaildirReconciliationLocks([source.id, 0]),
  204. /邮箱锁 ID 不正确/
  205. );
  206. });
  207. test('SQLite migration stops when a resumable Maildir key contains different bytes', async () => {
  208. const dataDir = mkdtempSync(path.join(tmpdir(), 'mailhub-maildir-conflict-db-'));
  209. const maildirRoot = path.join(dataDir, 'maildir');
  210. initDatabase(dataDir, 'maildir-conflict-secret');
  211. const user = createUser({
  212. username: 'maildir-conflict-user',
  213. email: 'maildir-conflict-user@example.com',
  214. password: 'password123'
  215. });
  216. createDomain(user.id, {
  217. domain: 'maildir-conflict.example',
  218. selector: 'mh',
  219. verificationToken: 'verify',
  220. dkimPublic: 'public',
  221. dkimPrivate: 'private',
  222. senderHost: 'mail.maildir-conflict.example',
  223. sendingIp: '192.0.2.71',
  224. spfExtra: '',
  225. dmarcPolicy: 'none',
  226. dmarcRua: ''
  227. });
  228. const mailbox = createInboundMailbox(user.id, {
  229. address: 'box@maildir-conflict.example',
  230. password: 'mailbox-pass-123'
  231. });
  232. const preparedBeforeFailure = createInboundMessage(mailbox, {
  233. sender: 'first@example.net',
  234. recipients: [mailbox.address],
  235. subject: 'Prepared before conflict',
  236. rawMessage: 'From: first@example.net\r\nSubject: Prepared before conflict\r\n\r\nFirst'
  237. });
  238. const original = createInboundMessage(mailbox, {
  239. sender: 'alice@example.net',
  240. recipients: [mailbox.address],
  241. subject: 'Original message',
  242. rawMessage: 'From: alice@example.net\r\nSubject: Original message\r\n\r\nOriginal'
  243. });
  244. await writeMaildirMessage({
  245. root: maildirRoot,
  246. address: mailbox.address,
  247. rawMessageBytes: Buffer.from('From: mallory@example.net\r\nSubject: Conflict\r\n\r\nConflict'),
  248. storageKey: `mhdb-${original.id}`
  249. });
  250. await assert.rejects(
  251. migrateInboundMessagesToMaildir({ root: maildirRoot }),
  252. /存储标识已被其他内容占用/
  253. );
  254. assert.equal(listInboundMaildirIndex(mailbox.id).length, 0);
  255. const remaining = await scanMaildirMailbox({ root: maildirRoot, address: mailbox.address });
  256. assert.equal(remaining.some((entry) => entry.storageKey === `mhdb-${preparedBeforeFailure.id}`), false);
  257. assert.equal(remaining.some((entry) => entry.storageKey === `mhdb-${original.id}`), true);
  258. });
  259. test('Maildir cutover rejects a staging count mismatch before changing the message index', () => {
  260. const dataDir = mkdtempSync(path.join(tmpdir(), 'mailhub-maildir-count-db-'));
  261. initDatabase(dataDir, 'maildir-count-secret');
  262. const user = createUser({
  263. username: 'maildir-count-user',
  264. email: 'maildir-count-user@example.com',
  265. password: 'password123'
  266. });
  267. createDomain(user.id, {
  268. domain: 'maildir-count.example',
  269. selector: 'mh',
  270. verificationToken: 'verify',
  271. dkimPublic: 'public',
  272. dkimPrivate: 'private',
  273. senderHost: 'mail.maildir-count.example',
  274. sendingIp: '192.0.2.72',
  275. spfExtra: '',
  276. dmarcPolicy: 'none',
  277. dmarcRua: ''
  278. });
  279. const mailbox = createInboundMailbox(user.id, {
  280. address: 'box@maildir-count.example',
  281. password: 'mailbox-pass-123'
  282. });
  283. const message = createInboundMessage(mailbox, {
  284. sender: 'alice@example.net',
  285. recipients: [mailbox.address],
  286. subject: 'Count guard',
  287. rawMessage: 'From: alice@example.net\r\nSubject: Count guard\r\n\r\nBody'
  288. });
  289. stageInboundMessageMaildirStorageBatch([{
  290. id: message.id,
  291. storage: {
  292. backend: 'maildir',
  293. key: `mhdb-${message.id}`,
  294. relpath: `mail/new/mhdb-${message.id}.mailhub`,
  295. sha256: 'a'.repeat(64),
  296. size: 4,
  297. mtimeMs: Date.now(),
  298. indexedAt: new Date().toISOString()
  299. }
  300. }]);
  301. assert.throws(
  302. () => commitInboundMaildirMigrationStaging(2),
  303. /Maildir 迁移暂存数量不一致/
  304. );
  305. assert.equal(listInboundMaildirIndex(mailbox.id).length, 0);
  306. assert.equal(listInboundMaildirMigrationStaging().length, 1);
  307. });
  308. test('Maildir reconciliation restores a missing SMTP index and queues its receipt webhook once', async () => {
  309. const dataDir = mkdtempSync(path.join(tmpdir(), 'mailhub-maildir-webhook-db-'));
  310. const maildirRoot = path.join(dataDir, 'maildir');
  311. const database = initDatabase(dataDir, 'maildir-webhook-secret');
  312. const user = createUser({
  313. username: 'maildir-webhook-user',
  314. email: 'maildir-webhook-user@example.com',
  315. password: 'password123'
  316. });
  317. createDomain(user.id, {
  318. domain: 'maildir-webhook.example',
  319. selector: 'mh',
  320. verificationToken: 'verify',
  321. dkimPublic: 'public',
  322. dkimPrivate: 'private',
  323. senderHost: 'mail.maildir-webhook.example',
  324. sendingIp: '192.0.2.73',
  325. spfExtra: '',
  326. dmarcPolicy: 'none',
  327. dmarcRua: ''
  328. });
  329. const mailbox = createInboundMailbox(user.id, {
  330. address: 'box@maildir-webhook.example',
  331. password: 'mailbox-pass-123'
  332. });
  333. createWebhook(user.id, {
  334. name: 'Recovered SMTP receipt',
  335. url: 'https://hooks.example.com/recovered-smtp',
  336. events: ['received'],
  337. mailboxId: mailbox.id
  338. });
  339. await writeMaildirMessage({
  340. root: maildirRoot,
  341. address: mailbox.address,
  342. rawMessageBytes: Buffer.from([
  343. 'Message-ID: <recovered-smtp@example.net>',
  344. 'From: sender@example.net',
  345. `To: ${mailbox.address}`,
  346. 'Subject: Recovered SMTP receipt',
  347. '',
  348. 'Recovered body'
  349. ].join('\r\n')),
  350. storageKey: 'mhsmtp-recovered-1'
  351. });
  352. database.exec(`
  353. CREATE TRIGGER reject_recovered_smtp_webhook
  354. BEFORE INSERT ON webhook_deliveries
  355. WHEN NEW.inbound_message_id IS NOT NULL
  356. BEGIN
  357. SELECT RAISE(ABORT, 'forced webhook failure');
  358. END;
  359. `);
  360. await assert.rejects(
  361. reconcileMaildirMailbox({ root: maildirRoot, mailbox }),
  362. /forced webhook failure/
  363. );
  364. assert.equal(listInboundMessages(user.id, { mailboxId: mailbox.id }).length, 0);
  365. assert.equal(listWebhookDeliveries(user.id, { eventType: 'received' }).length, 0);
  366. database.exec('DROP TRIGGER reject_recovered_smtp_webhook;');
  367. const first = await reconcileMaildirMailbox({ root: maildirRoot, mailbox });
  368. assert.equal(first.created, 1);
  369. const [message] = listInboundMessages(user.id, { mailboxId: mailbox.id });
  370. assert.equal(message.subject, 'Recovered SMTP receipt');
  371. let deliveries = listWebhookDeliveries(user.id, { eventType: 'received' });
  372. assert.equal(deliveries.length, 1);
  373. assert.equal(deliveries[0].inboundMessageId, message.id);
  374. assert.equal(JSON.parse(deliveries[0].payloadJson).type, 'email.received');
  375. const second = await reconcileMaildirMailbox({ root: maildirRoot, mailbox });
  376. assert.equal(second.created, 0);
  377. deliveries = listWebhookDeliveries(user.id, { eventType: 'received' });
  378. assert.equal(deliveries.length, 1);
  379. });