inbound-mailbox-delete.test.js 54 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205
  1. import assert from 'node:assert/strict';
  2. import { mkdtempSync, readFileSync } from 'node:fs';
  3. import { chmod, rename as renameFile, stat as statFile, unlink, writeFile } 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. bulkUpdateInboundMailboxGrants,
  9. completeInboundMailboxDeletionJob,
  10. createApiToken,
  11. createDomain,
  12. createInboundFolder,
  13. createInboundMailbox,
  14. createInboundMessage,
  15. createInboundMessageWithWebhook,
  16. createImportedInboundMessage,
  17. createUser,
  18. createWebhook,
  19. createWebmailLoginTicket,
  20. deleteDomain,
  21. deleteInboundMailboxWithMessageTransfer,
  22. executeUserMerge,
  23. getInboundMailbox,
  24. getInboundMailboxByAddress,
  25. getInboundMessage,
  26. initDatabase,
  27. listPendingInboundMailboxDeletionJobs,
  28. listInboundMailboxFolders,
  29. listInboundMailboxTransferMessages,
  30. markInboundMessageRead,
  31. prepareInboundMailboxDeletion,
  32. recordInboundMessageMaildirStorage,
  33. replaceInboundMailboxGrants,
  34. restorePreparedInboundMailboxDeletion,
  35. transferDomain,
  36. updateInboundMailboxDeletionJob,
  37. updateDomain,
  38. updateInboundMailbox,
  39. updateWebhook
  40. } from '../src/db.js';
  41. import {
  42. publishInboundMailboxMaildirTransfer,
  43. recoverPendingInboundMailboxDeletions,
  44. rollbackInboundMailboxMaildirTransfer,
  45. stageInboundMailboxMaildirTransfer,
  46. verifyPublishedInboundMailboxMaildirTransfer
  47. } from '../src/inbound-mailbox-delete.js';
  48. import {
  49. assertQuarantinedMaildirMailboxUnchanged,
  50. quarantineMaildirMailbox,
  51. readMaildirMessage,
  52. restoreQuarantinedMaildirMailbox,
  53. scanMaildirMailbox,
  54. snapshotQuarantinedMaildirMailbox,
  55. writeMaildirMessage
  56. } from '../src/maildir-store.js';
  57. test('owned mailbox deletion moves visible messages and retires mailbox access state atomically', () => {
  58. const database = initDatabase(
  59. mkdtempSync(path.join(tmpdir(), 'mailhub-mailbox-delete-db-')),
  60. 'mailbox-delete-secret'
  61. );
  62. const owner = createUser({ username: 'delete-owner', email: 'delete-owner@example.com', password: 'password123' });
  63. const assignee = createUser({ username: 'delete-assignee', email: 'delete-assignee@example.com', password: 'password123' });
  64. const sourceDomain = createTestDomain(owner.id, 'delete-source.example');
  65. const targetDomain = createTestDomain(owner.id, 'delete-target.example');
  66. const source = createInboundMailbox(owner.id, { address: 'source@delete-source.example' });
  67. const target = createInboundMailbox(owner.id, { address: 'target@delete-target.example' });
  68. createInboundFolder(source, 'Projects/2026');
  69. replaceInboundMailboxGrants(source.id, [{ userId: assignee.id, receive: true }]);
  70. const webmail = createWebmailLoginTicket(owner.id, source.id, { audience: 'https://roundcube.test' });
  71. const webhook = createWebhook(owner.id, {
  72. name: 'source received',
  73. url: 'https://hooks.example.com/received',
  74. events: ['received'],
  75. mailboxId: source.id
  76. });
  77. const visibleMessage = createInboundMessage(source, {
  78. folder: 'Projects/2026',
  79. sender: 'sender@example.net',
  80. recipients: [source.address],
  81. subject: 'Move me',
  82. rawMessage: 'Subject: Move me\r\n\r\nOriginal raw MIME',
  83. textBody: 'Original raw MIME'
  84. });
  85. database.prepare(`
  86. UPDATE inbound_messages
  87. SET read_state = 'true', flags_json = '["\\\\Seen","\\\\Flagged"]', keywords_json = '["important"]'
  88. WHERE id = ?
  89. `).run(visibleMessage.id);
  90. const removedMessage = createInboundMessage(source, {
  91. sender: 'removed@example.net',
  92. recipients: [source.address],
  93. subject: 'Already removed',
  94. rawMessage: 'Subject: Already removed\r\n\r\nRemoved body',
  95. textBody: 'Removed body'
  96. });
  97. database.prepare('UPDATE inbound_messages SET deleted_at = ? WHERE id = ?')
  98. .run(new Date().toISOString(), removedMessage.id);
  99. const prepared = prepareInboundMailboxDeletion(owner.id, source.id, target.id, {
  100. confirmAddress: source.address
  101. });
  102. assert.equal(prepared.sourceMailbox.id, source.id);
  103. assert.equal(prepared.targetMailbox.id, target.id);
  104. assert.equal(prepared.originalSourceStatus, 'active');
  105. assert.equal(getInboundMailboxByAddress(source.address), null);
  106. const result = deleteInboundMailboxWithMessageTransfer(owner.id, source.id, target.id);
  107. assert.deepEqual(result.deletedMailbox, { id: source.id, address: source.address });
  108. assert.equal(result.deleted, true);
  109. assert.equal(result.targetMailbox.id, target.id);
  110. assert.equal(result.targetMailbox.messageCount, 1);
  111. assert.equal(result.migratedMessageCount, 1);
  112. assert.equal(getInboundMailbox(source.id, owner.id), null);
  113. const moved = getInboundMessage(owner.id, visibleMessage.id);
  114. assert.equal(moved.mailboxId, target.id);
  115. assert.equal(moved.userId, owner.id);
  116. assert.equal(moved.domainId, targetDomain.id);
  117. assert.equal(moved.folder, 'Projects/2026');
  118. assert.equal(moved.rawMessage, 'Subject: Move me\r\n\r\nOriginal raw MIME');
  119. assert.deepEqual(moved.flags, ['\\Seen', '\\Flagged']);
  120. assert.deepEqual(moved.keywords, ['important']);
  121. assert.equal(moved.read, true);
  122. assert.ok(listInboundMailboxFolders(owner.id, target.id).some((folder) => folder.name === 'Projects/2026'));
  123. const removedRow = database.prepare('SELECT mailbox_id, domain_id, deleted_at FROM inbound_messages WHERE id = ?')
  124. .get(removedMessage.id);
  125. assert.equal(removedRow.mailbox_id, source.id);
  126. assert.equal(removedRow.domain_id, sourceDomain.id);
  127. assert.ok(removedRow.deleted_at);
  128. assert.equal(database.prepare('SELECT COUNT(*) AS count FROM inbound_mailbox_grants WHERE mailbox_id = ?').get(source.id).count, 0);
  129. assert.ok(database.prepare('SELECT revoked_at FROM webmail_sessions WHERE id = ?').get(webmail.id).revoked_at);
  130. assert.equal(database.prepare('SELECT enabled FROM webhooks WHERE id = ?').get(webhook.id).enabled, 'false');
  131. assert.ok(database.prepare('SELECT deleted_at FROM inbound_folders WHERE mailbox_id = ? AND name = ?')
  132. .get(source.id, 'Projects/2026').deleted_at);
  133. assert.equal(deleteDomain(sourceDomain.id, owner.id), true);
  134. });
  135. test('mailbox deletion validates ownership, target state, and permits an empty mailbox without a target', () => {
  136. const database = initDatabase(
  137. mkdtempSync(path.join(tmpdir(), 'mailhub-mailbox-delete-validation-')),
  138. 'mailbox-delete-validation-secret'
  139. );
  140. const owner = createUser({ username: 'validation-owner', email: 'validation-owner@example.com', password: 'password123' });
  141. const other = createUser({ username: 'validation-other', email: 'validation-other@example.com', password: 'password123' });
  142. createTestDomain(owner.id, 'validation-owner.example');
  143. createTestDomain(other.id, 'validation-other.example');
  144. const source = createInboundMailbox(owner.id, { address: 'source@validation-owner.example' });
  145. const target = createInboundMailbox(owner.id, { address: 'target@validation-owner.example' });
  146. const empty = createInboundMailbox(owner.id, { address: 'empty@validation-owner.example' });
  147. const otherTarget = createInboundMailbox(other.id, { address: 'target@validation-other.example' });
  148. createInboundMessage(source, {
  149. sender: 'sender@example.net',
  150. recipients: [source.address],
  151. subject: 'Needs a target',
  152. rawMessage: 'Subject: Needs a target\r\n\r\nBody'
  153. });
  154. assertDeletionCode(
  155. () => prepareInboundMailboxDeletion(owner.id, source.id, target.id),
  156. 'INBOUND_MAILBOX_DELETE_CONFIRMATION_MISMATCH'
  157. );
  158. assertDeletionCode(
  159. () => prepareInboundMailboxDeletion(owner.id, source.id, target.id, { confirmAddress: ` ${source.address}` }),
  160. 'INBOUND_MAILBOX_DELETE_CONFIRMATION_MISMATCH'
  161. );
  162. assertDeletionCode(
  163. () => prepareInboundMailboxDeletion(owner.id, source.id, source.id, { confirmAddress: source.address }),
  164. 'INBOUND_MAILBOX_DELETE_SAME_TARGET'
  165. );
  166. assertDeletionCode(
  167. () => prepareInboundMailboxDeletion(owner.id, source.id, otherTarget.id, { confirmAddress: source.address }),
  168. 'INBOUND_MAILBOX_DELETE_TARGET_NOT_FOUND'
  169. );
  170. assertDeletionCode(
  171. () => prepareInboundMailboxDeletion(owner.id, source.id, null, { confirmAddress: source.address }),
  172. 'INBOUND_MAILBOX_DELETE_TARGET_REQUIRED'
  173. );
  174. updateInboundMailbox(owner.id, target.id, { status: 'disabled' });
  175. assertDeletionCode(
  176. () => prepareInboundMailboxDeletion(owner.id, source.id, target.id, { confirmAddress: source.address }),
  177. 'INBOUND_MAILBOX_DELETE_TARGET_INACTIVE'
  178. );
  179. const preparedEmpty = prepareInboundMailboxDeletion(owner.id, empty.id, null, {
  180. confirmAddress: empty.address
  181. });
  182. assert.equal(preparedEmpty.targetMailbox, null);
  183. const deletedEmpty = deleteInboundMailboxWithMessageTransfer(owner.id, empty.id, null);
  184. assert.equal(deletedEmpty.targetMailbox, null);
  185. assert.equal(deletedEmpty.migratedMessageCount, 0);
  186. updateInboundMailbox(owner.id, target.id, { status: 'active' });
  187. const prepared = prepareInboundMailboxDeletion(owner.id, source.id, target.id, {
  188. confirmAddress: source.address
  189. });
  190. assertDeletionCode(
  191. () => deleteInboundMailboxWithMessageTransfer(owner.id, source.id, target.id, { storageUpdates: [] }),
  192. 'INBOUND_MAILBOX_DELETE_MESSAGE_CONFLICT'
  193. );
  194. assert.equal(database.prepare('SELECT mailbox_id FROM inbound_messages WHERE mailbox_id = ?').get(source.id).mailbox_id, source.id);
  195. assert.equal(restorePreparedInboundMailboxDeletion(owner.id, source.id, prepared.originalSourceStatus), true);
  196. assert.equal(getInboundMailboxByAddress(source.address).id, source.id);
  197. assert.equal(listInboundMailboxTransferMessages(other.id, source.id), null);
  198. const staleRoute = getInboundMailboxByAddress(source.address);
  199. updateInboundMailbox(owner.id, source.id, { status: 'disabled' });
  200. assertDeletionCode(
  201. () => createInboundMessageWithWebhook(staleRoute, {
  202. sender: 'late@example.net',
  203. recipients: [source.address],
  204. rawMessage: 'Subject: Too late\r\n\r\nBody'
  205. }),
  206. 'INBOUND_MAILBOX_DELIVERY_UNAVAILABLE'
  207. );
  208. assert.equal(database.prepare(`
  209. SELECT COUNT(*) AS count
  210. FROM inbound_messages
  211. WHERE mailbox_id = ? AND deleted_at IS NULL
  212. `).get(source.id).count, 1);
  213. });
  214. test('mailbox deletion reports catch-all, forwarding, and selected-token blockers without rebinding them', () => {
  215. initDatabase(
  216. mkdtempSync(path.join(tmpdir(), 'mailhub-mailbox-delete-blockers-')),
  217. 'mailbox-delete-blockers-secret'
  218. );
  219. const owner = createUser({ username: 'blocker-owner', email: 'blocker-owner@example.com', password: 'password123' });
  220. const domain = createTestDomain(owner.id, 'delete-blockers.example');
  221. const source = createInboundMailbox(owner.id, { address: 'source@delete-blockers.example' });
  222. const target = createInboundMailbox(owner.id, { address: 'target@delete-blockers.example' });
  223. const forwarding = createInboundMailbox(owner.id, { address: 'forwarding@delete-blockers.example' });
  224. updateDomain(domain.id, owner.id, { catchAllAddress: source.address });
  225. updateInboundMailbox(owner.id, forwarding.id, { forwardTo: [source.address] });
  226. createApiToken(owner.id, 'selected source', {
  227. scopes: ['mailboxes:read'],
  228. mailboxAccess: 'selected',
  229. mailboxIds: [source.id]
  230. });
  231. assert.throws(
  232. () => prepareInboundMailboxDeletion(owner.id, source.id, target.id, { confirmAddress: source.address }),
  233. (error) => {
  234. assert.equal(error.code, 'INBOUND_MAILBOX_DELETE_REFERENCES_BLOCKED');
  235. assert.deepEqual(error.blockers, [
  236. { type: 'domainCatchAll', count: 1 },
  237. { type: 'mailboxForwarding', count: 1 },
  238. { type: 'apiTokenSelection', count: 1 }
  239. ]);
  240. return true;
  241. }
  242. );
  243. assert.equal(getInboundMailboxByAddress(source.address).status, 'active');
  244. assert.equal(getInboundMailboxByAddress(forwarding.address).forwardTo[0], source.address);
  245. const lateSource = createInboundMailbox(owner.id, { address: 'late-source@delete-blockers.example' });
  246. const prepared = prepareInboundMailboxDeletion(owner.id, lateSource.id, target.id, {
  247. confirmAddress: lateSource.address
  248. });
  249. updateInboundMailbox(owner.id, forwarding.id, { forwardTo: [lateSource.address] });
  250. assert.throws(
  251. () => deleteInboundMailboxWithMessageTransfer(owner.id, lateSource.id, target.id),
  252. (error) => {
  253. assert.equal(error.code, 'INBOUND_MAILBOX_DELETE_REFERENCES_BLOCKED');
  254. assert.deepEqual(error.blockers, [{ type: 'mailboxForwarding', count: 1 }]);
  255. return true;
  256. }
  257. );
  258. assert.equal(restorePreparedInboundMailboxDeletion(
  259. owner.id,
  260. lateSource.id,
  261. prepared.originalSourceStatus
  262. ), true);
  263. assert.equal(getInboundMailboxByAddress(lateSource.address).status, 'active');
  264. });
  265. test('mailbox deletion job rejects a skipped filesystem phase and mailbox address drift', () => {
  266. const database = initDatabase(
  267. mkdtempSync(path.join(tmpdir(), 'mailhub-mailbox-delete-job-guard-')),
  268. 'mailbox-delete-job-guard-secret'
  269. );
  270. const owner = createUser({ username: 'job-guard-owner', email: 'job-guard-owner@example.com', password: 'password123' });
  271. const mergeTarget = createUser({ username: 'job-guard-target', email: 'job-guard-target@example.com', password: 'password123' });
  272. const domain = createTestDomain(owner.id, 'job-guard.example');
  273. const target = createInboundMailbox(owner.id, { address: 'target@job-guard.example' });
  274. const phaseSource = createInboundMailbox(owner.id, { address: 'phase-source@job-guard.example' });
  275. const phaseJobId = '10000000000000000000000000000001';
  276. const phasePrepared = prepareInboundMailboxDeletion(owner.id, phaseSource.id, target.id, {
  277. confirmAddress: phaseSource.address,
  278. jobId: phaseJobId
  279. });
  280. assertDeletionCode(
  281. () => deleteInboundMailboxWithMessageTransfer(owner.id, phaseSource.id, target.id, {
  282. deletionJobId: phaseJobId
  283. }),
  284. 'INBOUND_MAILBOX_DELETE_NOT_PREPARED'
  285. );
  286. assertDeletionCode(
  287. () => updateInboundMailbox(owner.id, phaseSource.id, { status: 'active' }),
  288. 'INBOUND_MAILBOX_DELETE_IN_PROGRESS'
  289. );
  290. assertDeletionCode(
  291. () => updateInboundMailbox(owner.id, target.id, { displayName: 'Must stay stable' }),
  292. 'INBOUND_MAILBOX_DELETE_IN_PROGRESS'
  293. );
  294. assertDeletionCode(
  295. () => transferDomain({
  296. actorUserId: owner.id,
  297. domainId: domain.id,
  298. targetUserId: mergeTarget.id
  299. }),
  300. 'INBOUND_MAILBOX_DELETE_IN_PROGRESS'
  301. );
  302. assertDeletionCode(
  303. () => executeUserMerge({
  304. actorUserId: owner.id,
  305. sourceUserId: owner.id,
  306. targetUserId: mergeTarget.id,
  307. confirmation: `MERGE ${owner.username} INTO ${mergeTarget.username}`
  308. }),
  309. 'INBOUND_MAILBOX_DELETE_IN_PROGRESS'
  310. );
  311. assert.equal(database.prepare('SELECT user_id FROM domains WHERE id = ?').get(domain.id).user_id, owner.id);
  312. assert.equal(restorePreparedInboundMailboxDeletion(
  313. owner.id,
  314. phaseSource.id,
  315. phasePrepared.originalSourceStatus
  316. ), true);
  317. assert.equal(completeInboundMailboxDeletionJob(phaseJobId, 'rolled_back'), true);
  318. assert.equal(updateInboundMailbox(owner.id, target.id, { displayName: 'Stable again' }).displayName, 'Stable again');
  319. const source = createInboundMailbox(owner.id, { address: 'source@job-guard.example' });
  320. const sourceJobId = '10000000000000000000000000000002';
  321. const sourcePrepared = prepareInboundMailboxDeletion(owner.id, source.id, target.id, {
  322. confirmAddress: source.address,
  323. jobId: sourceJobId
  324. });
  325. updateInboundMailboxDeletionJob(sourceJobId, { phase: 'committing_database' });
  326. database.prepare('UPDATE inbound_mailboxes SET address = ? WHERE id = ?')
  327. .run('renamed-source@job-guard.example', source.id);
  328. assertDeletionCode(
  329. () => deleteInboundMailboxWithMessageTransfer(owner.id, source.id, target.id, {
  330. deletionJobId: sourceJobId
  331. }),
  332. 'INBOUND_MAILBOX_DELETE_NOT_PREPARED'
  333. );
  334. assert.equal(restorePreparedInboundMailboxDeletion(
  335. owner.id,
  336. source.id,
  337. sourcePrepared.originalSourceStatus
  338. ), true);
  339. assert.equal(completeInboundMailboxDeletionJob(sourceJobId, 'rolled_back'), true);
  340. const targetDriftSource = createInboundMailbox(owner.id, { address: 'target-drift-source@job-guard.example' });
  341. const targetJobId = '10000000000000000000000000000003';
  342. const targetPrepared = prepareInboundMailboxDeletion(owner.id, targetDriftSource.id, target.id, {
  343. confirmAddress: targetDriftSource.address,
  344. jobId: targetJobId
  345. });
  346. updateInboundMailboxDeletionJob(targetJobId, { phase: 'committing_database' });
  347. database.prepare('UPDATE inbound_mailboxes SET address = ? WHERE id = ?')
  348. .run('renamed-target@job-guard.example', target.id);
  349. assertDeletionCode(
  350. () => deleteInboundMailboxWithMessageTransfer(owner.id, targetDriftSource.id, target.id, {
  351. deletionJobId: targetJobId
  352. }),
  353. 'INBOUND_MAILBOX_DELETE_NOT_PREPARED'
  354. );
  355. assert.equal(restorePreparedInboundMailboxDeletion(
  356. owner.id,
  357. targetDriftSource.id,
  358. targetPrepared.originalSourceStatus
  359. ), true);
  360. assert.equal(completeInboundMailboxDeletionJob(targetJobId, 'rolled_back'), true);
  361. });
  362. test('pending mailbox deletion blocks grants, mailbox webhooks, and message mutations', () => {
  363. const database = initDatabase(
  364. mkdtempSync(path.join(tmpdir(), 'mailhub-mailbox-delete-mutation-guard-')),
  365. 'mailbox-delete-mutation-guard-secret'
  366. );
  367. const owner = createUser({
  368. username: 'mutation-guard-owner',
  369. email: 'mutation-guard-owner@example.com',
  370. password: 'password123'
  371. });
  372. const assignee = createUser({
  373. username: 'mutation-guard-assignee',
  374. email: 'mutation-guard-assignee@example.com',
  375. password: 'password123'
  376. });
  377. createTestDomain(owner.id, 'mutation-guard.example');
  378. const source = createInboundMailbox(owner.id, { address: 'source@mutation-guard.example' });
  379. const target = createInboundMailbox(owner.id, { address: 'target@mutation-guard.example' });
  380. const inboundMessage = createInboundMessage(source, {
  381. sender: 'sender@example.net',
  382. recipients: [source.address],
  383. subject: 'Mutation guard',
  384. rawMessage: 'Subject: Mutation guard\r\n\r\nBody'
  385. });
  386. const existingWebhook = createWebhook(owner.id, {
  387. name: 'existing mailbox webhook',
  388. url: 'https://hooks.example.com/existing',
  389. events: ['received'],
  390. mailboxId: source.id
  391. });
  392. const jobId = '10000000000000000000000000000004';
  393. const prepared = prepareInboundMailboxDeletion(owner.id, source.id, target.id, {
  394. confirmAddress: source.address,
  395. jobId
  396. });
  397. assertDeletionCode(
  398. () => replaceInboundMailboxGrants(source.id, [{ userId: assignee.id, receive: true }]),
  399. 'INBOUND_MAILBOX_DELETE_IN_PROGRESS'
  400. );
  401. assertDeletionCode(
  402. () => bulkUpdateInboundMailboxGrants({
  403. actorUserId: owner.id,
  404. mailboxIds: [target.id],
  405. userIds: [assignee.id],
  406. operation: 'upsert',
  407. permissions: { receive: true }
  408. }),
  409. 'INBOUND_MAILBOX_DELETE_IN_PROGRESS'
  410. );
  411. assertDeletionCode(
  412. () => createWebhook(owner.id, {
  413. name: 'late mailbox webhook',
  414. url: 'https://hooks.example.com/late',
  415. events: ['received'],
  416. mailboxId: source.id
  417. }),
  418. 'INBOUND_MAILBOX_DELETE_IN_PROGRESS'
  419. );
  420. assertDeletionCode(
  421. () => updateWebhook(owner.id, existingWebhook.id, { name: 'must not change' }),
  422. 'INBOUND_MAILBOX_DELETE_IN_PROGRESS'
  423. );
  424. assertDeletionCode(
  425. () => recordInboundMessageMaildirStorage(inboundMessage.id, {
  426. key: 'mhsmtp-mutation-guard',
  427. relpath: 'mail/cur/mhsmtp-mutation-guard:2,S',
  428. size: 1,
  429. mtimeMs: 1,
  430. indexedAt: '2026-01-02T03:04:05.000Z'
  431. }, { mailboxId: source.id }),
  432. 'INBOUND_MAILBOX_DELETE_IN_PROGRESS'
  433. );
  434. assertDeletionCode(
  435. () => markInboundMessageRead(owner.id, inboundMessage.id, true, {}, { mailboxId: source.id }),
  436. 'INBOUND_MAILBOX_DELETE_IN_PROGRESS'
  437. );
  438. assert.equal(database.prepare('SELECT COUNT(*) AS count FROM inbound_mailbox_grants').get().count, 0);
  439. assert.equal(database.prepare('SELECT name FROM webhooks WHERE id = ?').get(existingWebhook.id).name, 'existing mailbox webhook');
  440. const unchangedMessage = database.prepare(`
  441. SELECT read_state, storage_backend
  442. FROM inbound_messages
  443. WHERE id = ?
  444. `).get(inboundMessage.id);
  445. assert.equal(unchangedMessage.read_state, 'false');
  446. assert.equal(unchangedMessage.storage_backend, 'sqlite');
  447. assert.equal(restorePreparedInboundMailboxDeletion(
  448. owner.id,
  449. source.id,
  450. prepared.originalSourceStatus
  451. ), true);
  452. assert.equal(completeInboundMailboxDeletionJob(jobId, 'rolled_back'), true);
  453. const driftSource = createInboundMailbox(owner.id, { address: 'drift-source@mutation-guard.example' });
  454. const driftMessage = createInboundMessage(driftSource, {
  455. sender: 'sender@example.net',
  456. recipients: [driftSource.address],
  457. subject: 'Mailbox drift guard',
  458. rawMessage: 'Subject: Mailbox drift guard\r\n\r\nBody'
  459. });
  460. prepareInboundMailboxDeletion(owner.id, driftSource.id, target.id, {
  461. confirmAddress: driftSource.address
  462. });
  463. deleteInboundMailboxWithMessageTransfer(owner.id, driftSource.id, target.id);
  464. assert.equal(recordInboundMessageMaildirStorage(driftMessage.id, {
  465. key: 'mhsmtp-stale-source-write',
  466. relpath: 'mail/cur/mhsmtp-stale-source-write:2,S',
  467. size: 1,
  468. mtimeMs: 1,
  469. indexedAt: '2026-01-02T03:04:05.000Z'
  470. }, { mailboxId: driftSource.id }), false);
  471. assert.equal(
  472. markInboundMessageRead(owner.id, driftMessage.id, true, {}, { mailboxId: driftSource.id }),
  473. null
  474. );
  475. const movedMessage = getInboundMessage(owner.id, driftMessage.id);
  476. assert.equal(movedMessage.mailboxId, target.id);
  477. assert.equal(movedMessage.read, false);
  478. assert.equal(
  479. database.prepare('SELECT storage_backend FROM inbound_messages WHERE id = ?').get(driftMessage.id).storage_backend,
  480. 'sqlite'
  481. );
  482. });
  483. test('mailbox deletion preparation rejects overlapping source and target participants', () => {
  484. const database = initDatabase(
  485. mkdtempSync(path.join(tmpdir(), 'mailhub-mailbox-delete-overlap-guard-')),
  486. 'mailbox-delete-overlap-guard-secret'
  487. );
  488. const owner = createUser({
  489. username: 'overlap-guard-owner',
  490. email: 'overlap-guard-owner@example.com',
  491. password: 'password123'
  492. });
  493. createTestDomain(owner.id, 'overlap-guard.example');
  494. const source = createInboundMailbox(owner.id, { address: 'source@overlap-guard.example' });
  495. const target = createInboundMailbox(owner.id, { address: 'target@overlap-guard.example' });
  496. const third = createInboundMailbox(owner.id, { address: 'third@overlap-guard.example' });
  497. const firstJobId = '10000000000000000000000000000005';
  498. const prepared = prepareInboundMailboxDeletion(owner.id, source.id, target.id, {
  499. confirmAddress: source.address,
  500. jobId: firstJobId
  501. });
  502. assertDeletionCode(
  503. () => prepareInboundMailboxDeletion(owner.id, target.id, third.id, {
  504. confirmAddress: target.address,
  505. jobId: '10000000000000000000000000000006'
  506. }),
  507. 'INBOUND_MAILBOX_DELETE_IN_PROGRESS'
  508. );
  509. assertDeletionCode(
  510. () => prepareInboundMailboxDeletion(owner.id, third.id, target.id, {
  511. confirmAddress: third.address,
  512. jobId: '10000000000000000000000000000007'
  513. }),
  514. 'INBOUND_MAILBOX_DELETE_IN_PROGRESS'
  515. );
  516. assertDeletionCode(
  517. () => prepareInboundMailboxDeletion(owner.id, third.id, source.id, {
  518. confirmAddress: third.address,
  519. jobId: '10000000000000000000000000000008'
  520. }),
  521. 'INBOUND_MAILBOX_DELETE_IN_PROGRESS'
  522. );
  523. assert.equal(database.prepare(`
  524. SELECT COUNT(*) AS count
  525. FROM inbound_mailbox_deletion_jobs
  526. WHERE completed_at IS NULL
  527. `).get().count, 1);
  528. assert.equal(database.prepare('SELECT status FROM inbound_mailboxes WHERE id = ?').get(source.id).status, 'disabled');
  529. assert.equal(database.prepare('SELECT status FROM inbound_mailboxes WHERE id = ?').get(target.id).status, 'active');
  530. assert.equal(database.prepare('SELECT status FROM inbound_mailboxes WHERE id = ?').get(third.id).status, 'active');
  531. assert.equal(restorePreparedInboundMailboxDeletion(
  532. owner.id,
  533. source.id,
  534. prepared.originalSourceStatus
  535. ), true);
  536. assert.equal(completeInboundMailboxDeletionJob(firstJobId, 'rolled_back'), true);
  537. });
  538. test('mailbox deletion preparation serializes participant checks before state changes', () => {
  539. const dbSource = readFileSync(new URL('../src/db.js', import.meta.url), 'utf8');
  540. const prepareStart = dbSource.indexOf('export function prepareInboundMailboxDeletion');
  541. const prepareEnd = dbSource.indexOf('export function updateInboundMailboxDeletionJob', prepareStart);
  542. const prepareSource = dbSource.slice(prepareStart, prepareEnd);
  543. const participantGuard = prepareSource.indexOf('assertNoPendingInboundMailboxDeletion({');
  544. const disableSource = prepareSource.indexOf("SET status = 'disabled'");
  545. assert.ok(prepareStart >= 0 && prepareEnd > prepareStart);
  546. assert.ok(participantGuard >= 0 && participantGuard < disableSource);
  547. assert.match(prepareSource, /\}, \{ immediate: true \}\);/);
  548. const serverSource = readFileSync(new URL('../src/server.js', import.meta.url), 'utf8');
  549. const mapperStart = serverSource.indexOf('function sendInboundMailboxDeletionError');
  550. const mapperEnd = serverSource.indexOf('function auditUserIdParam', mapperStart);
  551. assert.match(
  552. serverSource.slice(mapperStart, mapperEnd),
  553. /'INBOUND_MAILBOX_DELETE_IN_PROGRESS'/
  554. );
  555. });
  556. test('Dovecot mailbox transfer stages target files, keeps source recovery files, and supports rollback', async () => {
  557. const dataDir = mkdtempSync(path.join(tmpdir(), 'mailhub-mailbox-delete-maildir-'));
  558. const root = path.join(dataDir, 'maildir');
  559. initDatabase(dataDir, 'mailbox-delete-maildir-secret');
  560. const owner = createUser({ username: 'maildir-owner', email: 'maildir-owner@example.com', password: 'password123' });
  561. createTestDomain(owner.id, 'maildir-delete.example');
  562. const source = createInboundMailbox(owner.id, { address: 'source@maildir-delete.example' });
  563. const target = createInboundMailbox(owner.id, { address: 'target@maildir-delete.example' });
  564. const sourceRaw = Buffer.from('Subject: Physical\r\n\r\nPhysical body');
  565. const sourceStorage = await writeMaildirMessage({
  566. root,
  567. address: source.address,
  568. rawMessageBytes: sourceRaw,
  569. folder: 'Archive',
  570. flags: ['\\Seen', '\\Flagged'],
  571. keywords: ['keep-me'],
  572. read: true,
  573. storageKey: 'mhsmtp-shared-key',
  574. receivedAt: '2026-01-02T03:04:05.000Z'
  575. });
  576. createImportedInboundMessage(source, {
  577. importSource: 'mailbox-delete-test',
  578. sourceKey: 'physical-message',
  579. folder: 'Archive',
  580. flags: ['\\Seen', '\\Flagged'],
  581. keywords: ['keep-me'],
  582. read: true,
  583. receivedAt: '2026-01-02T03:04:05.000Z',
  584. rawMessageBytes: sourceRaw,
  585. storage: sourceStorage
  586. });
  587. createInboundMessage(source, {
  588. sender: 'sqlite@example.net',
  589. recipients: [source.address],
  590. subject: 'SQLite message',
  591. rawMessage: 'Subject: SQLite message\r\n\r\nSQLite body',
  592. textBody: 'SQLite body'
  593. });
  594. const prepared = prepareInboundMailboxDeletion(owner.id, source.id, target.id, {
  595. confirmAddress: source.address
  596. });
  597. const messages = listInboundMailboxTransferMessages(owner.id, source.id);
  598. const staged = await stageInboundMailboxMaildirTransfer({
  599. root,
  600. sourceMailbox: prepared.sourceMailbox,
  601. targetMailbox: prepared.targetMailbox,
  602. messages,
  603. reservedStorageKeys: ['mhsmtp-shared-key']
  604. });
  605. assert.equal(staged.storageUpdates.length, 2);
  606. assert.equal(staged.storageUpdates.some((update) => update.storage.key === 'mhsmtp-shared-key'), false);
  607. assert.equal((await scanMaildirMailbox({ root, address: source.address })).length, 1);
  608. assert.equal((await scanMaildirMailbox({ root, address: target.address })).length, 0);
  609. const rollbackQuarantine = await quarantineMaildirMailbox({
  610. root,
  611. address: source.address,
  612. mailboxId: source.id
  613. });
  614. assert.equal(rollbackQuarantine.moved, true);
  615. assert.match(rollbackQuarantine.quarantineRelpath, /^\.mailhub-quarantine\//);
  616. assert.equal((await scanMaildirMailbox({ root, address: source.address })).length, 0);
  617. assert.equal(await restoreQuarantinedMaildirMailbox({ root, quarantine: rollbackQuarantine }), true);
  618. assert.equal((await scanMaildirMailbox({ root, address: source.address })).length, 1);
  619. await publishInboundMailboxMaildirTransfer({
  620. root,
  621. targetMailbox: prepared.targetMailbox,
  622. stagedMessages: staged.stagedMessages
  623. });
  624. assert.equal((await scanMaildirMailbox({ root, address: target.address })).length, 2);
  625. const retainedQuarantine = await quarantineMaildirMailbox({
  626. root,
  627. address: source.address,
  628. mailboxId: source.id
  629. });
  630. assert.equal(retainedQuarantine.moved, true);
  631. const result = deleteInboundMailboxWithMessageTransfer(owner.id, source.id, target.id, {
  632. storageUpdates: staged.storageUpdates
  633. });
  634. assert.equal(result.migratedMessageCount, 2);
  635. const targetEntries = await scanMaildirMailbox({ root, address: target.address });
  636. assert.equal(targetEntries.length, 2);
  637. assert.equal((await scanMaildirMailbox({ root, address: source.address })).length, 0);
  638. const archive = targetEntries.find((entry) => entry.folder === 'Archive');
  639. assert.ok(archive);
  640. assert.deepEqual((await readMaildirMessage(archive)).bytes, sourceRaw);
  641. assert.deepEqual(archive.flags.sort(), ['\\Flagged', '\\Seen']);
  642. assert.deepEqual(archive.keywords, ['keep-me']);
  643. const rollbackSource = createInboundMailbox(owner.id, { address: 'rollback@maildir-delete.example' });
  644. const rollbackRaw = Buffer.from('Subject: Rollback\r\n\r\nRollback body');
  645. const rollbackStorage = await writeMaildirMessage({
  646. root,
  647. address: rollbackSource.address,
  648. rawMessageBytes: rollbackRaw,
  649. storageKey: 'mhsmtp-rollback-source'
  650. });
  651. createImportedInboundMessage(rollbackSource, {
  652. importSource: 'mailbox-delete-test',
  653. sourceKey: 'rollback-message',
  654. receivedAt: '2026-01-02T03:04:05.000Z',
  655. rawMessageBytes: rollbackRaw,
  656. storage: rollbackStorage
  657. });
  658. const rollbackPrepared = prepareInboundMailboxDeletion(owner.id, rollbackSource.id, target.id, {
  659. confirmAddress: rollbackSource.address
  660. });
  661. const rollbackStage = await stageInboundMailboxMaildirTransfer({
  662. root,
  663. sourceMailbox: rollbackPrepared.sourceMailbox,
  664. targetMailbox: rollbackPrepared.targetMailbox,
  665. messages: listInboundMailboxTransferMessages(owner.id, rollbackSource.id),
  666. reservedStorageKeys: targetEntries.map((entry) => entry.storageKey)
  667. });
  668. assert.equal((await scanMaildirMailbox({ root, address: target.address })).length, 2);
  669. await publishInboundMailboxMaildirTransfer({
  670. root,
  671. targetMailbox: rollbackPrepared.targetMailbox,
  672. stagedMessages: rollbackStage.stagedMessages
  673. });
  674. assert.equal((await scanMaildirMailbox({ root, address: target.address })).length, 3);
  675. const failedQuarantine = await quarantineMaildirMailbox({
  676. root,
  677. address: rollbackSource.address,
  678. mailboxId: rollbackSource.id
  679. });
  680. assertDeletionCode(
  681. () => deleteInboundMailboxWithMessageTransfer(owner.id, rollbackSource.id, target.id, { storageUpdates: [] }),
  682. 'INBOUND_MAILBOX_DELETE_MESSAGE_CONFLICT'
  683. );
  684. assert.equal(await restoreQuarantinedMaildirMailbox({ root, quarantine: failedQuarantine }), true);
  685. await rollbackInboundMailboxMaildirTransfer({
  686. root,
  687. targetMailbox: rollbackPrepared.targetMailbox,
  688. stagedMessages: rollbackStage.stagedMessages,
  689. published: true
  690. });
  691. assert.equal((await scanMaildirMailbox({ root, address: target.address })).length, 2);
  692. restorePreparedInboundMailboxDeletion(owner.id, rollbackSource.id, rollbackPrepared.originalSourceStatus);
  693. assert.equal((await scanMaildirMailbox({ root, address: rollbackSource.address })).length, 1);
  694. });
  695. test('quarantined Maildir is read-only until rollback restores mailbox permissions', async () => {
  696. const dataDir = mkdtempSync(path.join(tmpdir(), 'mailhub-mailbox-delete-freeze-'));
  697. const root = path.join(dataDir, 'maildir');
  698. initDatabase(dataDir, 'mailbox-delete-freeze-secret');
  699. const owner = createUser({ username: 'freeze-owner', email: 'freeze-owner@example.com', password: 'password123' });
  700. createTestDomain(owner.id, 'freeze.example');
  701. const source = createInboundMailbox(owner.id, { address: 'source@freeze.example' });
  702. await writeMaildirMessage({
  703. root,
  704. address: source.address,
  705. rawMessageBytes: Buffer.from('Subject: Freeze\r\n\r\nBody'),
  706. storageKey: 'mhsmtp-freeze'
  707. });
  708. const quarantine = await quarantineMaildirMailbox({
  709. root,
  710. address: source.address,
  711. mailboxId: source.id,
  712. jobId: '30000000000000000000000000000001'
  713. });
  714. const snapshot = await snapshotQuarantinedMaildirMailbox({ root, quarantine });
  715. const entry = snapshot.entries[0];
  716. assert.ok(entry);
  717. assert.equal(await assertQuarantinedMaildirMailboxUnchanged({ root, quarantine, snapshot }), true);
  718. assert.equal((await statFile(entry.filePath)).mode & 0o777, 0o400);
  719. assert.equal((await statFile(path.dirname(entry.filePath))).mode & 0o777, 0o500);
  720. if (typeof process.getuid !== 'function' || process.getuid() !== 0) {
  721. await assert.rejects(writeFile(entry.filePath, Buffer.from('changed')), (error) => (
  722. ['EACCES', 'EPERM'].includes(error?.code)
  723. ));
  724. await assert.rejects(renameFile(entry.filePath, `${entry.filePath}.renamed`), (error) => (
  725. ['EACCES', 'EPERM'].includes(error?.code)
  726. ));
  727. }
  728. await chmod(entry.filePath, 0o600);
  729. await writeFile(entry.filePath, Buffer.from('Subject: Freeze changed\r\n\r\nBody'));
  730. await chmod(entry.filePath, 0o400);
  731. await assert.rejects(
  732. assertQuarantinedMaildirMailboxUnchanged({ root, quarantine, snapshot }),
  733. /已发生变化/
  734. );
  735. assert.equal(await restoreQuarantinedMaildirMailbox({ root, quarantine }), true);
  736. const restored = (await scanMaildirMailbox({ root, address: source.address }))[0];
  737. assert.equal((await statFile(restored.filePath)).mode & 0o777, 0o600);
  738. assert.equal((await statFile(path.dirname(restored.filePath))).mode & 0o777, 0o700);
  739. await writeMaildirMessage({
  740. root,
  741. address: source.address,
  742. rawMessageBytes: Buffer.from('Subject: Writable again\r\n\r\nBody'),
  743. storageKey: 'mhsmtp-writable-again'
  744. });
  745. assert.equal((await scanMaildirMailbox({ root, address: source.address })).length, 2);
  746. });
  747. test('published target transfer follows IMAP relpaths and rejects missing files before database commit', async () => {
  748. const dataDir = mkdtempSync(path.join(tmpdir(), 'mailhub-mailbox-delete-target-verify-'));
  749. const root = path.join(dataDir, 'maildir');
  750. initDatabase(dataDir, 'mailbox-delete-target-verify-secret');
  751. const owner = createUser({ username: 'target-verify-owner', email: 'target-verify-owner@example.com', password: 'password123' });
  752. createTestDomain(owner.id, 'target-verify.example');
  753. const source = createInboundMailbox(owner.id, { address: 'source@target-verify.example' });
  754. const target = createInboundMailbox(owner.id, { address: 'target@target-verify.example' });
  755. const raw = Buffer.from('Subject: Target verify\r\n\r\nBody');
  756. const storage = await writeMaildirMessage({
  757. root,
  758. address: source.address,
  759. rawMessageBytes: raw,
  760. storageKey: 'mhsmtp-target-verify'
  761. });
  762. createImportedInboundMessage(source, {
  763. importSource: 'mailbox-delete-target-verify',
  764. sourceKey: 'message',
  765. rawMessageBytes: raw,
  766. receivedAt: '2026-01-02T03:04:05.000Z',
  767. storage
  768. });
  769. const jobId = '30000000000000000000000000000002';
  770. const staged = await stageInboundMailboxMaildirTransfer({
  771. root,
  772. sourceMailbox: source,
  773. targetMailbox: target,
  774. messages: listInboundMailboxTransferMessages(owner.id, source.id),
  775. jobId
  776. });
  777. await publishInboundMailboxMaildirTransfer({
  778. root,
  779. targetMailbox: target,
  780. stagedMessages: staged.stagedMessages
  781. });
  782. const published = (await scanMaildirMailbox({ root, address: target.address }))[0];
  783. const renamedPath = path.join(path.dirname(path.dirname(published.filePath)), 'cur', `${path.basename(published.filePath)}:2,S`);
  784. await renameFile(published.filePath, renamedPath);
  785. const verified = await verifyPublishedInboundMailboxMaildirTransfer({
  786. root,
  787. targetMailbox: target,
  788. storageUpdates: staged.storageUpdates,
  789. jobId
  790. });
  791. assert.equal(verified.length, 1);
  792. assert.match(verified[0].storage.relpath, /\/cur\//);
  793. await unlink(renamedPath);
  794. await assert.rejects(
  795. verifyPublishedInboundMailboxMaildirTransfer({
  796. root,
  797. targetMailbox: target,
  798. storageUpdates: staged.storageUpdates,
  799. jobId
  800. }),
  801. (error) => error?.code === 'INBOUND_MAILBOX_DELETE_MESSAGE_CONFLICT'
  802. );
  803. });
  804. test('HTTP mailbox deletion freezes source before publish and verifies target immediately before commit', () => {
  805. const serverSource = readFileSync(new URL('../src/server.js', import.meta.url), 'utf8');
  806. const routeStart = serverSource.indexOf("if (inboundMailboxMatch && method === 'DELETE')");
  807. const routeEnd = serverSource.indexOf("if (method === 'GET' && pathname === '/api/inbound-messages')", routeStart);
  808. const route = serverSource.slice(routeStart, routeEnd);
  809. const sourceVerification = route.indexOf('await assertQuarantinedMaildirMailboxUnchanged');
  810. const targetPublish = route.indexOf('await publishInboundMailboxMaildirTransfer');
  811. const targetVerification = route.indexOf('await verifyPublishedInboundMailboxMaildirTransfer');
  812. const committingPhase = route.indexOf("phase: 'committing_database'");
  813. const databaseCommit = route.indexOf('deleteInboundMailboxWithMessageTransfer');
  814. assert.ok(routeStart >= 0 && routeEnd > routeStart);
  815. assert.ok(sourceVerification >= 0 && sourceVerification < targetPublish);
  816. assert.ok(targetPublish < targetVerification);
  817. assert.ok(targetVerification < committingPhase);
  818. assert.ok(committingPhase < databaseCommit);
  819. assert.match(
  820. route,
  821. /restoreError\?\.maildirSourceRenamed === true/
  822. );
  823. });
  824. test('HTTP mutation routes reserve deletion participants before mailbox-scoped writes', () => {
  825. const serverSource = readFileSync(new URL('../src/server.js', import.meta.url), 'utf8');
  826. const messageRouteStart = serverSource.indexOf("const inboundMessageMatch = pathname.match");
  827. const messageRouteEnd = serverSource.indexOf("const sendEventMatch = pathname.match", messageRouteStart);
  828. const messageRoute = serverSource.slice(messageRouteStart, messageRouteEnd);
  829. const lockedMailbox = messageRoute.indexOf('const lockedMailboxId = storage.mailboxId');
  830. const acquireMessageLock = messageRoute.indexOf('await acquireMaildirReconciliationLocks([lockedMailboxId])');
  831. const reloadMessageStorage = messageRoute.indexOf('storage = getInboundMessageMaildirStorage', acquireMessageLock);
  832. const verifyLockedMailbox = messageRoute.indexOf('storage.mailboxId !== lockedMailboxId', reloadMessageStorage);
  833. const pendingMessageGuard = messageRoute.indexOf('pendingInboundMailboxDeletions.has(storage.mailboxId)', verifyLockedMailbox);
  834. const persistedMessageGuard = messageRoute.indexOf('assertInboundMailboxDeletionNotInProgress(storage.mailboxId)', pendingMessageGuard);
  835. const mutateMessageFile = messageRoute.indexOf('await setMaildirMessageSeen', persistedMessageGuard);
  836. const recordMessageStorage = messageRoute.indexOf('recordInboundMessageMaildirStorage', mutateMessageFile);
  837. assert.ok(messageRouteStart >= 0 && messageRouteEnd > messageRouteStart);
  838. assert.ok(lockedMailbox >= 0 && lockedMailbox < acquireMessageLock);
  839. assert.ok(acquireMessageLock < reloadMessageStorage);
  840. assert.ok(reloadMessageStorage < verifyLockedMailbox);
  841. assert.ok(verifyLockedMailbox < pendingMessageGuard);
  842. assert.ok(pendingMessageGuard < persistedMessageGuard);
  843. assert.ok(persistedMessageGuard < mutateMessageFile);
  844. assert.ok(mutateMessageFile < recordMessageStorage);
  845. assert.equal((messageRoute.match(/pendingInboundMailboxDeletions\.has\(storage\.mailboxId\)/g) || []).length, 1);
  846. const webhookCreateStart = serverSource.indexOf("if (method === 'POST' && pathname === '/api/webhooks')");
  847. const webhookCreateEnd = serverSource.indexOf('const webhookMatch = pathname.match', webhookCreateStart);
  848. const webhookCreateRoute = serverSource.slice(webhookCreateStart, webhookCreateEnd);
  849. assert.ok(webhookCreateStart >= 0 && webhookCreateEnd > webhookCreateStart);
  850. assert.ok(webhookCreateRoute.indexOf('await assertSafeWebhookUrl') < webhookCreateRoute.indexOf('const requestedMailbox ='));
  851. assert.ok(webhookCreateRoute.indexOf('const requestedMailbox =') < webhookCreateRoute.indexOf('hasPendingInboundMailboxDeletion([requestedMailbox.id])'));
  852. assert.ok(webhookCreateRoute.indexOf('hasPendingInboundMailboxDeletion([requestedMailbox.id])') < webhookCreateRoute.indexOf('createWebhook(user.id'));
  853. const webhookUpdateStart = serverSource.indexOf("if (method === 'PATCH' && !action)", webhookCreateEnd);
  854. const webhookUpdateEnd = serverSource.indexOf("if (method === 'DELETE' && !action)", webhookUpdateStart);
  855. const webhookUpdateRoute = serverSource.slice(webhookUpdateStart, webhookUpdateEnd);
  856. assert.ok(webhookUpdateStart >= 0 && webhookUpdateEnd > webhookUpdateStart);
  857. assert.ok(webhookUpdateRoute.indexOf('const currentWebhook = getWebhook') < webhookUpdateRoute.indexOf('hasPendingInboundMailboxDeletion(['));
  858. assert.ok(webhookUpdateRoute.indexOf('hasPendingInboundMailboxDeletion([') < webhookUpdateRoute.indexOf('updateWebhook(user.id'));
  859. const bulkGrantStart = serverSource.indexOf("if (method === 'POST' && pathname === '/api/admin/inbound-mailboxes/access/bulk')");
  860. const bulkGrantEnd = serverSource.indexOf('const mailboxAccessMatch = pathname.match', bulkGrantStart);
  861. const bulkGrantRoute = serverSource.slice(bulkGrantStart, bulkGrantEnd);
  862. assert.ok(bulkGrantStart >= 0 && bulkGrantEnd > bulkGrantStart);
  863. assert.ok(bulkGrantRoute.indexOf('const body = await readJson(req)') < bulkGrantRoute.indexOf('hasPendingInboundMailboxDeletion('));
  864. assert.ok(bulkGrantRoute.indexOf('hasPendingInboundMailboxDeletion(') < bulkGrantRoute.indexOf('bulkUpdateInboundMailboxGrants'));
  865. const singleGrantStart = serverSource.indexOf("if (mailboxAccessMatch && (method === 'PUT' || method === 'PATCH'))");
  866. const singleGrantEnd = serverSource.indexOf('const transferDomainMatch = pathname.match', singleGrantStart);
  867. const singleGrantRoute = serverSource.slice(singleGrantStart, singleGrantEnd);
  868. assert.ok(singleGrantStart >= 0 && singleGrantEnd > singleGrantStart);
  869. assert.ok(singleGrantRoute.indexOf('const body = await readJson(req)') < singleGrantRoute.indexOf('hasPendingInboundMailboxDeletion([mailboxId])'));
  870. assert.ok(singleGrantRoute.indexOf('hasPendingInboundMailboxDeletion([mailboxId])') < singleGrantRoute.indexOf('replaceInboundMailboxGrants'));
  871. });
  872. test('interrupted mailbox deletion jobs recover every filesystem phase idempotently', async (t) => {
  873. const phases = ['prepared', 'source_quarantined', 'target_staged', 'target_published', 'db_committed'];
  874. for (const [phaseIndex, phase] of phases.entries()) {
  875. await t.test(phase, async () => {
  876. const dataDir = mkdtempSync(path.join(tmpdir(), `mailhub-mailbox-delete-recovery-${phase}-`));
  877. const root = path.join(dataDir, 'maildir');
  878. initDatabase(dataDir, `mailbox-delete-recovery-${phase}-secret`);
  879. const owner = createUser({
  880. username: `recovery-owner-${phaseIndex}`,
  881. email: `recovery-owner-${phaseIndex}@example.com`,
  882. password: 'password123'
  883. });
  884. createTestDomain(owner.id, `recovery-${phaseIndex}.example`);
  885. const source = createInboundMailbox(owner.id, { address: `source@recovery-${phaseIndex}.example` });
  886. const target = createInboundMailbox(owner.id, { address: `target@recovery-${phaseIndex}.example` });
  887. const raw = Buffer.from(`Subject: Recovery ${phase}\r\n\r\nBody`);
  888. const sourceStorage = await writeMaildirMessage({
  889. root,
  890. address: source.address,
  891. rawMessageBytes: raw,
  892. storageKey: `mhsmtp-recovery-${phaseIndex}`
  893. });
  894. createImportedInboundMessage(source, {
  895. importSource: 'mailbox-delete-recovery-test',
  896. sourceKey: phase,
  897. rawMessageBytes: raw,
  898. receivedAt: '2026-01-02T03:04:05.000Z',
  899. storage: sourceStorage
  900. });
  901. const jobId = (phaseIndex + 1).toString(16).padStart(32, '0');
  902. const prepared = prepareInboundMailboxDeletion(owner.id, source.id, target.id, {
  903. confirmAddress: source.address,
  904. jobId
  905. });
  906. let quarantine = null;
  907. let staged = null;
  908. if (phase !== 'prepared') {
  909. const quarantineRelpath = `.mailhub-quarantine/delete-${jobId}`;
  910. updateInboundMailboxDeletionJob(jobId, {
  911. phase: 'quarantining_source',
  912. quarantineRelpath
  913. });
  914. quarantine = await quarantineMaildirMailbox({
  915. root,
  916. address: source.address,
  917. mailboxId: source.id,
  918. jobId
  919. });
  920. updateInboundMailboxDeletionJob(jobId, {
  921. phase: 'source_quarantined',
  922. quarantineRelpath: quarantine.quarantineRelpath,
  923. quarantineMoved: quarantine.moved
  924. });
  925. }
  926. if (['target_staged', 'target_published', 'db_committed'].includes(phase)) {
  927. const snapshot = await snapshotQuarantinedMaildirMailbox({ root, quarantine });
  928. staged = await stageInboundMailboxMaildirTransfer({
  929. root,
  930. sourceMailbox: prepared.sourceMailbox,
  931. targetMailbox: prepared.targetMailbox,
  932. messages: listInboundMailboxTransferMessages(owner.id, source.id),
  933. sourceEntries: snapshot.entries,
  934. jobId
  935. });
  936. updateInboundMailboxDeletionJob(jobId, {
  937. phase: 'target_staged',
  938. targetArtifacts: staged.stagedMessages.map((message, index) => ({
  939. messageId: staged.storageUpdates[index].messageId,
  940. storageKey: message.storage.key,
  941. storageRelpath: message.storage.relpath,
  942. stagingRelpath: message.stagingRelpath
  943. }))
  944. });
  945. }
  946. if (['target_published', 'db_committed'].includes(phase)) {
  947. await publishInboundMailboxMaildirTransfer({
  948. root,
  949. targetMailbox: prepared.targetMailbox,
  950. stagedMessages: staged.stagedMessages
  951. });
  952. updateInboundMailboxDeletionJob(jobId, { phase: 'target_published' });
  953. }
  954. if (phase === 'db_committed') {
  955. updateInboundMailboxDeletionJob(jobId, { phase: 'committing_database' });
  956. deleteInboundMailboxWithMessageTransfer(owner.id, source.id, target.id, {
  957. storageUpdates: staged.storageUpdates,
  958. deletionJobId: jobId
  959. });
  960. }
  961. assert.equal(listPendingInboundMailboxDeletionJobs().length, 1);
  962. assert.equal(await recoverPendingInboundMailboxDeletions({ root, maildirEnabled: true, logger: {} }), 1);
  963. assert.equal(listPendingInboundMailboxDeletionJobs().length, 0);
  964. assert.equal(await recoverPendingInboundMailboxDeletions({ root, maildirEnabled: true, logger: {} }), 0);
  965. if (phase === 'db_committed') {
  966. assert.equal(getInboundMailbox(source.id, owner.id), null);
  967. assert.equal((await scanMaildirMailbox({ root, address: target.address })).length, 1);
  968. } else {
  969. assert.equal(getInboundMailboxByAddress(source.address).status, 'active');
  970. assert.equal((await scanMaildirMailbox({ root, address: source.address })).length, 1);
  971. assert.equal((await scanMaildirMailbox({ root, address: target.address })).length, 0);
  972. }
  973. });
  974. }
  975. });
  976. test('mailbox deletion recovery distinguishes planned, absent, restored, and lost quarantine states', async (t) => {
  977. await t.test('planned quarantine with no source Maildir restores database state', async () => {
  978. const dataDir = mkdtempSync(path.join(tmpdir(), 'mailhub-mailbox-delete-planned-'));
  979. const root = path.join(dataDir, 'maildir');
  980. initDatabase(dataDir, 'mailbox-delete-planned-secret');
  981. const owner = createUser({ username: 'planned-owner', email: 'planned-owner@example.com', password: 'password123' });
  982. createTestDomain(owner.id, 'planned.example');
  983. const source = createInboundMailbox(owner.id, { address: 'source@planned.example' });
  984. const jobId = '20000000000000000000000000000001';
  985. prepareInboundMailboxDeletion(owner.id, source.id, null, {
  986. confirmAddress: source.address,
  987. jobId
  988. });
  989. updateInboundMailboxDeletionJob(jobId, {
  990. phase: 'quarantine_planned',
  991. quarantineRelpath: `.mailhub-quarantine/delete-${jobId}`
  992. });
  993. assert.equal(await recoverPendingInboundMailboxDeletions({ root, maildirEnabled: false, logger: {} }), 1);
  994. assert.equal(getInboundMailboxByAddress(source.address).status, 'active');
  995. });
  996. await t.test('known absent source Maildir removes tombstone and restores database state', async () => {
  997. const dataDir = mkdtempSync(path.join(tmpdir(), 'mailhub-mailbox-delete-absent-'));
  998. const root = path.join(dataDir, 'maildir');
  999. initDatabase(dataDir, 'mailbox-delete-absent-secret');
  1000. const owner = createUser({ username: 'absent-owner', email: 'absent-owner@example.com', password: 'password123' });
  1001. createTestDomain(owner.id, 'absent.example');
  1002. const source = createInboundMailbox(owner.id, { address: 'source@absent.example' });
  1003. const jobId = '20000000000000000000000000000002';
  1004. prepareInboundMailboxDeletion(owner.id, source.id, null, {
  1005. confirmAddress: source.address,
  1006. jobId
  1007. });
  1008. const quarantineRelpath = `.mailhub-quarantine/delete-${jobId}`;
  1009. updateInboundMailboxDeletionJob(jobId, { phase: 'quarantine_planned', quarantineRelpath });
  1010. const quarantine = await quarantineMaildirMailbox({
  1011. root,
  1012. address: source.address,
  1013. mailboxId: source.id,
  1014. jobId,
  1015. beforeQuarantine: ({ moved }) => updateInboundMailboxDeletionJob(jobId, {
  1016. phase: 'quarantining_source',
  1017. quarantineMoved: moved
  1018. })
  1019. });
  1020. assert.equal(quarantine.moved, false);
  1021. updateInboundMailboxDeletionJob(jobId, {
  1022. phase: 'source_quarantined',
  1023. quarantineRelpath: quarantine.quarantineRelpath,
  1024. quarantineMoved: false
  1025. });
  1026. assert.equal(await recoverPendingInboundMailboxDeletions({ root, maildirEnabled: false, logger: {} }), 1);
  1027. assert.equal(getInboundMailboxByAddress(source.address).status, 'active');
  1028. });
  1029. await t.test('recovery is idempotent when rename succeeds before restore completion', async () => {
  1030. const dataDir = mkdtempSync(path.join(tmpdir(), 'mailhub-mailbox-delete-restored-'));
  1031. const root = path.join(dataDir, 'maildir');
  1032. initDatabase(dataDir, 'mailbox-delete-restored-secret');
  1033. const owner = createUser({ username: 'restored-owner', email: 'restored-owner@example.com', password: 'password123' });
  1034. createTestDomain(owner.id, 'restored.example');
  1035. const source = createInboundMailbox(owner.id, { address: 'source@restored.example' });
  1036. await writeMaildirMessage({
  1037. root,
  1038. address: source.address,
  1039. rawMessageBytes: Buffer.from('Subject: Restored\r\n\r\nBody'),
  1040. storageKey: 'mhsmtp-restored'
  1041. });
  1042. const jobId = '20000000000000000000000000000003';
  1043. prepareInboundMailboxDeletion(owner.id, source.id, null, {
  1044. confirmAddress: source.address,
  1045. jobId
  1046. });
  1047. const quarantine = await quarantineMaildirMailbox({
  1048. root,
  1049. address: source.address,
  1050. mailboxId: source.id,
  1051. jobId,
  1052. beforeQuarantine: ({ moved, quarantineRelpath }) => updateInboundMailboxDeletionJob(jobId, {
  1053. phase: 'quarantining_source',
  1054. quarantineRelpath,
  1055. quarantineMoved: moved
  1056. })
  1057. });
  1058. updateInboundMailboxDeletionJob(jobId, {
  1059. phase: 'source_quarantined',
  1060. quarantineRelpath: quarantine.quarantineRelpath,
  1061. quarantineMoved: true
  1062. });
  1063. updateInboundMailboxDeletionJob(jobId, { phase: 'restoring_source' });
  1064. await assert.rejects(
  1065. restoreQuarantinedMaildirMailbox({
  1066. root,
  1067. quarantine,
  1068. flush: async () => {
  1069. throw new Error('simulated restore flush failure');
  1070. }
  1071. }),
  1072. (error) => error?.maildirSourceRenamed === true
  1073. );
  1074. assert.equal(await recoverPendingInboundMailboxDeletions({ root, maildirEnabled: false, logger: {} }), 1);
  1075. assert.equal(getInboundMailboxByAddress(source.address).status, 'active');
  1076. assert.equal((await scanMaildirMailbox({ root, address: source.address })).length, 1);
  1077. });
  1078. await t.test('recorded moved quarantine missing outside restore phase fails closed', async () => {
  1079. const dataDir = mkdtempSync(path.join(tmpdir(), 'mailhub-mailbox-delete-lost-'));
  1080. const root = path.join(dataDir, 'maildir');
  1081. initDatabase(dataDir, 'mailbox-delete-lost-secret');
  1082. const owner = createUser({ username: 'lost-owner', email: 'lost-owner@example.com', password: 'password123' });
  1083. createTestDomain(owner.id, 'lost.example');
  1084. const source = createInboundMailbox(owner.id, { address: 'source@lost.example' });
  1085. await writeMaildirMessage({
  1086. root,
  1087. address: source.address,
  1088. rawMessageBytes: Buffer.from('Subject: Lost\r\n\r\nBody'),
  1089. storageKey: 'mhsmtp-lost'
  1090. });
  1091. const jobId = '20000000000000000000000000000004';
  1092. prepareInboundMailboxDeletion(owner.id, source.id, null, {
  1093. confirmAddress: source.address,
  1094. jobId
  1095. });
  1096. const quarantine = await quarantineMaildirMailbox({
  1097. root,
  1098. address: source.address,
  1099. mailboxId: source.id,
  1100. jobId,
  1101. beforeQuarantine: ({ moved, quarantineRelpath }) => updateInboundMailboxDeletionJob(jobId, {
  1102. phase: 'quarantining_source',
  1103. quarantineRelpath,
  1104. quarantineMoved: moved
  1105. })
  1106. });
  1107. updateInboundMailboxDeletionJob(jobId, {
  1108. phase: 'source_quarantined',
  1109. quarantineRelpath: quarantine.quarantineRelpath,
  1110. quarantineMoved: true
  1111. });
  1112. assert.equal(await restoreQuarantinedMaildirMailbox({ root, quarantine }), true);
  1113. updateInboundMailboxDeletionJob(jobId, { phase: 'rollback_failed' });
  1114. await assert.rejects(
  1115. recoverPendingInboundMailboxDeletions({ root, maildirEnabled: false, logger: {} }),
  1116. /隔离目录缺失/
  1117. );
  1118. assert.equal(getInboundMailboxByAddress(source.address), null);
  1119. assert.equal(listPendingInboundMailboxDeletionJobs()[0].phase, 'recovery_failed');
  1120. await assert.rejects(
  1121. recoverPendingInboundMailboxDeletions({ root, maildirEnabled: false, logger: {} }),
  1122. /隔离目录缺失/
  1123. );
  1124. });
  1125. });
  1126. function createTestDomain(userId, domain) {
  1127. return createDomain(userId, {
  1128. domain,
  1129. selector: 'mh',
  1130. verificationToken: `verify-${domain}`,
  1131. dkimPublic: 'public',
  1132. dkimPrivate: 'private',
  1133. senderHost: `mail.${domain}`,
  1134. sendingIp: '192.0.2.50',
  1135. spfExtra: '',
  1136. dmarcPolicy: 'none',
  1137. dmarcRua: ''
  1138. });
  1139. }
  1140. function assertDeletionCode(callback, code) {
  1141. assert.throws(callback, (error) => {
  1142. assert.equal(error.code, code);
  1143. return true;
  1144. });
  1145. }