@remit/mailbox-service 0.0.97 → 0.0.99

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/body-sync.ts CHANGED
@@ -166,6 +166,7 @@ const extractSnippet = (parsed: ParsedMail): string =>
166
166
  SNIPPET_LENGTH,
167
167
  );
168
168
 
169
+ /**
169
170
  /**
170
171
  * RFC 034 Decision 3.1: `Message.category` is written once and never mutated
171
172
  * after — RFC 030's message-list GSI sort key depends on it never churning.
@@ -183,30 +184,20 @@ const hasDecidedCategory = (
183
184
  ): boolean =>
184
185
  category !== undefined && category !== MessageCategory.uncategorized;
185
186
 
186
- /**
187
- * Issue #383 (RFC 039 Non-goals): whether {@link BodySyncService.computePlacement}
188
- * has already produced a verdict for this message — moved, left in place, or
189
- * archived, confident or unsure alike. Absence means placement has genuinely
190
- * never been evaluated. The same two re-entrant paths `hasDecidedCategory`
191
- * guards (`fetchAndGetBody`'s `NoSuchKey` fallback, `syncBodies(..., force:
192
- * true)`) also re-enter `computePlacement`; without this guard a message a
193
- * user manually rescued (never touched by Remit, so `movedByRemit` never
194
- * recorded anything) can be silently re-evaluated and moved right back.
195
- */
196
- const hasDecidedPlacement = (placementDecidedAt: number | undefined): boolean =>
197
- placementDecidedAt !== undefined;
198
-
199
187
  /**
200
188
  * Issue #499: whether a completed body-sync pass has already stored this
201
189
  * message's body. `bodyStorageKey` is written last, once every derivation of
202
- * that pass has run, so its presence means that pass finished. The same two
203
- * re-entrant paths `hasDecidedCategory` and `hasDecidedPlacement` guard
204
- * (`fetchAndGetBody`'s `NoSuchKey` fallback, `syncBodies(..., force: true)`)
205
- * are the ones that reach the derivations again with it already set.
190
+ * that pass has run, so its presence means that pass finished.
206
191
  *
207
192
  * Key presence, and nothing more. What the classifier decided is
208
193
  * `Message.category`; whether it ran at all is `Message.classificationState`.
209
194
  * Neither is inferred from here — the field this reads is the storage fact.
195
+ *
196
+ * The re-fetch paths that re-enter with the key already set (`syncBodies(...,
197
+ * force: true)` and `fetchAndGetBody`'s `NoSuchKey` fallback) are re-stores
198
+ * that skip the derivations entirely (issue #1011), so this guard is what
199
+ * keeps a first store that lands on an already-stored row from re-deciding
200
+ * anything.
210
201
  */
211
202
  const hasStoredBody = (bodyStorageKey: string | undefined): boolean =>
212
203
  Boolean(bodyStorageKey);
@@ -217,9 +208,9 @@ const hasStoredBody = (bodyStorageKey: string | undefined): boolean =>
217
208
  * means "nothing confident to say":
218
209
  *
219
210
  * - `already-moved-by-remit` is {@link classifyPlacement}'s guard against
220
- * re-deciding a settled placement, and auto-archive is a re-decision. The
221
- * re-entrant paths `hasDecidedPlacement` names reach it on messages Remit
222
- * itself placed, or that a user moved back afterwards.
211
+ * re-deciding a settled placement, and auto-archive is a re-decision. It
212
+ * fires on messages Remit itself placed, or that a user moved back
213
+ * afterwards.
223
214
  * - A message already in Junk gets an unsure `leave` whatever the sender's
224
215
  * `blocked` flag says, since the demote branch only fires outside Junk.
225
216
  * Moving mail out of Junk is a rescue, which this file reserves for a
@@ -397,6 +388,12 @@ export class BodySyncService {
397
388
  // messageId so we can match FETCH rows back and re-enqueue any UID the
398
389
  // server never returns.
399
390
  const pending = new Map<number, string>();
391
+ // Message IDs in this batch whose row already carried `bodyStorageKey`
392
+ // when `force` re-added them to `pending`: a forced re-fetch of an
393
+ // already-stored body is a re-store (issue #1011) — bytes only, no
394
+ // decision re-run. A message with no stored body inside a forced batch
395
+ // is still a first store and takes the full decision pass.
396
+ const reStoredMessageIds = new Set<string>();
400
397
 
401
398
  // One read per round, not per message (issue #72). The list is small by
402
399
  // design and almost always empty, so a lookup per message would put a
@@ -438,6 +435,7 @@ export class BodySyncService {
438
435
  skippedCount++;
439
436
  continue;
440
437
  }
438
+ if (message.bodyStorageKey) reStoredMessageIds.add(messageId);
441
439
  pending.set(message.uid, messageId);
442
440
  }
443
441
 
@@ -468,6 +466,7 @@ export class BodySyncService {
468
466
  accountId,
469
467
  accountConfigId,
470
468
  source,
469
+ reStoredMessageIds.has(messageId),
471
470
  );
472
471
  } catch (error) {
473
472
  // A per-message store failure (e.g. the parsed-body S3 write) is a
@@ -644,12 +643,18 @@ export class BodySyncService {
644
643
  * cache, per-part objects). The S3 upload never sees a whole-body concat —
645
644
  * the storage service streams it — but mailparser still needs the full bytes,
646
645
  * so we collect them in parallel. Later issues move parsing off the hot path.
646
+ *
647
+ * `isReStore` marks a re-fetch of a message whose row already carried
648
+ * `bodyStorageKey` (the `force` re-arm in {@link syncBodies}): the
649
+ * post-store steps then re-materialize the body artifacts without
650
+ * re-running any decision (issue #1011).
647
651
  */
648
652
  private async storeStreamedBody(
649
653
  messageId: string,
650
654
  accountId: string,
651
655
  accountConfigId: string,
652
656
  source: Readable,
657
+ isReStore: boolean,
653
658
  ): Promise<void> {
654
659
  const toStorage = new PassThrough();
655
660
  const chunks: Buffer[] = [];
@@ -693,6 +698,7 @@ export class BodySyncService {
693
698
  {
694
699
  uri: ref.uri,
695
700
  },
701
+ isReStore,
696
702
  );
697
703
  }
698
704
 
@@ -724,6 +730,16 @@ export class BodySyncService {
724
730
  accountConfigId: string,
725
731
  body: Buffer,
726
732
  bodyRef: { uri: string },
733
+ /**
734
+ * Whether this call is a re-store of an already-classified message
735
+ * rather than a first store. The two re-entrant paths (`force: true`
736
+ * in `syncBodies`, and `fetchAndGetBody`'s `NoSuchKey` IMAP fallback) are
737
+ * both body-only re-fetches: a body re-sync fetches and stores bytes and
738
+ * runs no decision logic (issue #1011). When `isReStore` is true the
739
+ * placement/filter/category machinery is skipped entirely and the
740
+ * message's existing category is carried forward unchanged.
741
+ */
742
+ isReStore: boolean,
727
743
  ): Promise<ParsedMail> {
728
744
  // The one operation on this path that can fail because of how the message
729
745
  // is built. Its own try block lives in `parseMessageBody`, so this frame —
@@ -733,6 +749,43 @@ export class BodySyncService {
733
749
  // feed happens later, alongside the Message write (see below), once the
734
750
  // write-once category is decided.
735
751
  const parsed = await parseMessageBody(body);
752
+
753
+ // Body-only re-fetch (issue #1011): the decision pass already ran when
754
+ // this message's body first landed, so a re-store re-materializes only
755
+ // the body-derived artifacts — parsed cache, body parts, the storage
756
+ // key — and re-decides nothing: no classification, placement, filters,
757
+ // labels, read state, and no second engagement-counter increment.
758
+ // Skipping the decisions here is what lets the write-once fields stay
759
+ // safe without per-derivation re-entrancy guards.
760
+ if (isReStore) {
761
+ // Same ordering rule as the first-store path below: the parsed-body
762
+ // cache must be durable before `bodyStorageKey` — the skip-guard
763
+ // signal — is written, so a failure here requeues the message and
764
+ // the retry genuinely re-attempts the artifact writes.
765
+ await this.storeParsedBodyCache(
766
+ accountConfigId,
767
+ accountId,
768
+ messageId,
769
+ parsed,
770
+ );
771
+ if (!isBodyPartDeferralEnabled()) {
772
+ await this.storeBodyPartContents(
773
+ accountConfigId,
774
+ accountId,
775
+ messageId,
776
+ parsed,
777
+ );
778
+ }
779
+ await this.messageService.update(messageId, {
780
+ bodyStorageKey: bodyRef.uri,
781
+ });
782
+ this.log.info(
783
+ { messageId, storageKey: bodyRef.uri },
784
+ "Body re-stored without re-running decisions",
785
+ );
786
+ return parsed;
787
+ }
788
+
736
789
  const snippet = extractSnippet(parsed);
737
790
  const listId = extractListId(parsed);
738
791
 
@@ -892,20 +945,19 @@ export class BodySyncService {
892
945
  // once the parsed cache AND the move (when any) are.
893
946
  //
894
947
  // `category` is RFC 034 D3.1's write-once field (RFC 030's message-list
895
- // GSI sort key depends on it never churning). This step re-enters on an
896
- // already-classified message through two shipped paths — the `NoSuchKey`
897
- // fallback in `fetchAndGetBody` and `syncBodies(..., force: true)` — so a
898
- // real, previously-decided category is carried forward unchanged instead
899
- // of the just-recomputed one.
900
- // This also protects a `flags.category` override (issue #299): without
901
- // this guard a re-entrant pass would let a *later* override silently
902
- // rewrite a category already decided on an earlier message, which is
903
- // exactly the churn RFC 030's GSI-safety argument forbids.
948
+ // GSI sort key depends on it never churning). The re-entrant re-store
949
+ // paths (issue #1011) return before this write; the carry-forward keeps
950
+ // the field write-once even when a first store lands on a row that
951
+ // already carries a real category — a previously-decided category wins
952
+ // over the just-recomputed one. It likewise protects a `flags.category`
953
+ // override (issue #299): recomputing would let a *later* override silently
954
+ // rewrite a category already decided, which is exactly the churn RFC
955
+ // 030's GSI-safety argument forbids.
904
956
  //
905
- // `resolved.placementDecidedAt` (issue #383) guards the same two
906
- // re-entrant paths for placement: `computePlacement` already declined to
907
- // recompute a verdict once this field is set, so it is only ever present
908
- // here on the pass that first decided it.
957
+ // `resolved.placementDecidedAt` (issue #383) is only ever present on
958
+ // the pass that first decided it: the re-store paths skip placement
959
+ // entirely (issue #1011), so `computePlacement` is reached only on a
960
+ // first store.
909
961
  const finalCategory = hasDecidedCategory(existingMessage.category)
910
962
  ? existingMessage.category
911
963
  : classification.category;
@@ -1026,13 +1078,17 @@ export class BodySyncService {
1026
1078
 
1027
1079
  // Same shared step the sync path (storeStreamedBody) runs — issue
1028
1080
  // #1271 unified the two body paths so classification/placement no
1029
- // longer depends on which one fetched the body first.
1081
+ // longer depends on which one fetched the body first. A store from
1082
+ // here is a re-store exactly when the row already claimed a stored
1083
+ // body — the NoSuchKey fallback above; a row with no bodyStorageKey is
1084
+ // a genuine first store and takes the full decision pass.
1030
1085
  parsed = await this.applyPostStoreSteps(
1031
1086
  messageId,
1032
1087
  accountId,
1033
1088
  accountConfigId,
1034
1089
  body,
1035
1090
  { uri: ref.uri },
1091
+ message.bodyStorageKey != null,
1036
1092
  );
1037
1093
  } else {
1038
1094
  parsed = await simpleParser(body);
@@ -1492,14 +1548,12 @@ export class BodySyncService {
1492
1548
 
1493
1549
  const message = await this.messageService.get(messageId);
1494
1550
 
1495
- // Issue #383: placement is meant to run once per message (RFC 039
1496
- // Non-goals). Once a verdict has EVER been decided for this message —
1497
- // moved, left in place, or archived — a re-entrant pass (the `NoSuchKey`
1498
- // fallback in `fetchAndGetBody`, `syncBodies(..., force: true)`) must not
1499
- // recompute it: a message a user has since moved by hand (which never
1500
- // touches `movedByRemit`) would otherwise be silently re-evaluated
1501
- // against the same signals that placed it in the first place.
1502
- if (hasDecidedPlacement(message.placementDecidedAt)) return {};
1551
+ // Issue #383 / #1011: placement still runs once per message (RFC 039
1552
+ // Non-goals), but the once-per-message guarantee now comes from the
1553
+ // caller rather than a guard here — the re-store paths
1554
+ // (`fetchAndGetBody`'s `NoSuchKey` fallback, `syncBodies(..., force:
1555
+ // true)`) skip the decision steps entirely, so only the pass that first
1556
+ // stores a body ever reaches this method.
1503
1557
 
1504
1558
  const junkMailbox =
1505
1559
  await mailboxSpecialUseService.findJunkMailbox(accountId);
@@ -0,0 +1,246 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import type { IMailboxRepository, MailboxItem } from "@remit/data-ports";
4
+ import { MailboxSyncStatus } from "@remit/domain-enums";
5
+ import { MailboxManagementService } from "./mailbox-management.js";
6
+ import { MailboxQueueService } from "./mailbox-queue.js";
7
+ import type { IImapConnection } from "./types.js";
8
+
9
+ const row = (over: Partial<MailboxItem> = {}): MailboxItem =>
10
+ ({
11
+ mailboxId: "mbx-1",
12
+ accountId: "acc-1",
13
+ fullPath: "Receipts",
14
+ hierarchyDelimiter: "/",
15
+ syncStatus: MailboxSyncStatus.synced,
16
+ ...over,
17
+ }) as MailboxItem;
18
+
19
+ const store = (rows: MailboxItem[]) => {
20
+ const byId = new Map(rows.map((r) => [r.mailboxId, { ...r }]));
21
+ const removed: string[] = [];
22
+
23
+ const repo: Pick<
24
+ IMailboxRepository,
25
+ "get" | "transition" | "findByPathPrefix" | "deleteMailboxWithMail"
26
+ > = {
27
+ get: (async (_accountId: string, mailboxId: string) => {
28
+ const found = byId.get(mailboxId as string);
29
+ if (!found) {
30
+ throw Object.assign(new Error(`Mailbox not found: ${mailboxId}`), {
31
+ name: "NotFoundError",
32
+ });
33
+ }
34
+ return found;
35
+ }) as IMailboxRepository["get"],
36
+ findByPathPrefix: async () => [],
37
+ transition: async (_accountId, mailboxId, intent) => {
38
+ const current = byId.get(mailboxId);
39
+ if (!current) return null;
40
+ if (!intent.from.includes(current.syncStatus)) return null;
41
+ const next = { ...current, syncStatus: intent.to } as MailboxItem;
42
+ byId.set(mailboxId, next);
43
+ return next;
44
+ },
45
+ deleteMailboxWithMail: async (_accountId, mailboxId) => {
46
+ removed.push(mailboxId);
47
+ byId.delete(mailboxId);
48
+ },
49
+ };
50
+
51
+ return { repo: repo as IMailboxRepository, byId, removed };
52
+ };
53
+
54
+ const queueOver = (repo: IMailboxRepository) => {
55
+ const sent: string[] = [];
56
+ const service = new MailboxQueueService({
57
+ mailboxService: repo,
58
+ sqsQueueUrl: "http://localhost:4566/000000000000/mailbox",
59
+ });
60
+ (
61
+ service as unknown as {
62
+ sqs: {
63
+ send: (command: { input: { MessageBody: string } }) => Promise<void>;
64
+ };
65
+ }
66
+ ).sqs = {
67
+ send: async (command) => {
68
+ sent.push(command.input.MessageBody);
69
+ },
70
+ };
71
+ return { service, sent };
72
+ };
73
+
74
+ describe("MailboxQueueService.deleteMailbox — the recorded intent", () => {
75
+ it("records `deleting` and enqueues one DELETE", async () => {
76
+ const { repo, byId } = store([row()]);
77
+ const { service, sent } = queueOver(repo);
78
+
79
+ await service.deleteMailbox("mbx-1", "acc-1");
80
+
81
+ assert.equal(byId.get("mbx-1")?.syncStatus, MailboxSyncStatus.deleting);
82
+ assert.equal(sent.length, 1);
83
+ assert.equal(
84
+ (JSON.parse(sent[0]) as { path: string }).path,
85
+ "Receipts",
86
+ "the event names the path the server holds",
87
+ );
88
+ });
89
+
90
+ it("accepts a retry from a failed delete", async () => {
91
+ const { repo, byId } = store([
92
+ row({ syncStatus: MailboxSyncStatus.failed }),
93
+ ]);
94
+ const { service, sent } = queueOver(repo);
95
+
96
+ await service.deleteMailbox("mbx-1", "acc-1");
97
+
98
+ assert.equal(byId.get("mbx-1")?.syncStatus, MailboxSyncStatus.deleting);
99
+ assert.equal(sent.length, 1);
100
+ });
101
+
102
+ it("gives the loser a 409 naming the delete already in flight", async () => {
103
+ const { repo } = store([row({ syncStatus: MailboxSyncStatus.deleting })]);
104
+ const { service, sent } = queueOver(repo);
105
+
106
+ await assert.rejects(
107
+ service.deleteMailbox("mbx-1", "acc-1"),
108
+ (error: unknown) =>
109
+ (error as { statusCode?: number }).statusCode === 409 &&
110
+ (error as Error).message ===
111
+ 'A delete is already in progress for "Receipts".',
112
+ );
113
+ assert.equal(sent.length, 0, "the loser enqueues nothing");
114
+ });
115
+
116
+ it("gives the loser a 409 naming a rename in flight", async () => {
117
+ const { repo } = store([
118
+ row({
119
+ syncStatus: MailboxSyncStatus.pending,
120
+ pendingPath: "Bonnen",
121
+ }),
122
+ ]);
123
+ const { service } = queueOver(repo);
124
+
125
+ await assert.rejects(
126
+ service.deleteMailbox("mbx-1", "acc-1"),
127
+ (error: unknown) =>
128
+ (error as Error).message === 'A rename is in progress for "Receipts".',
129
+ );
130
+ });
131
+ });
132
+
133
+ describe("MailboxManagementService.syncDelete — settling the intent", () => {
134
+ const deleting = () =>
135
+ store([row({ syncStatus: MailboxSyncStatus.deleting })]);
136
+
137
+ const connection = (calls: string[]): IImapConnection =>
138
+ ({
139
+ deleteMailbox: async (path: string) => {
140
+ calls.push(path);
141
+ return { path };
142
+ },
143
+ }) as unknown as IImapConnection;
144
+
145
+ it("takes the folder's mail with it, not the row alone", async () => {
146
+ // Removing the row alone leaves every message and thread_message row keyed
147
+ // to a dead mailboxId — out of every reader, and still in the search index
148
+ // because nothing writes the `message.removed` outbox event (D8).
149
+ const { repo, removed, byId } = deleting();
150
+ const service = new MailboxManagementService(repo);
151
+ const calls: string[] = [];
152
+
153
+ const result = await service.syncDelete(
154
+ "acc-1",
155
+ "mbx-1",
156
+ "Receipts",
157
+ async () => connection(calls),
158
+ );
159
+
160
+ assert.equal(result.success, true);
161
+ assert.deepEqual(calls, ["Receipts"]);
162
+ assert.deepEqual(removed, ["mbx-1"]);
163
+ assert.equal(byId.has("mbx-1"), false);
164
+ });
165
+
166
+ it("issues no DELETE when the row is not deleting", async () => {
167
+ const { repo, removed } = store([row()]);
168
+ const service = new MailboxManagementService(repo);
169
+ const calls: string[] = [];
170
+
171
+ const result = await service.syncDelete(
172
+ "acc-1",
173
+ "mbx-1",
174
+ "Receipts",
175
+ async () => connection(calls),
176
+ );
177
+
178
+ assert.equal(result.success, true);
179
+ assert.deepEqual(calls, [], "the connection is never asked");
180
+ assert.deepEqual(removed, []);
181
+ });
182
+
183
+ it("resolves when the row is already gone", async () => {
184
+ const { repo } = store([]);
185
+ const service = new MailboxManagementService(repo);
186
+ const calls: string[] = [];
187
+
188
+ const result = await service.syncDelete(
189
+ "acc-1",
190
+ "mbx-1",
191
+ "Receipts",
192
+ async () => connection(calls),
193
+ );
194
+
195
+ assert.equal(result.success, true);
196
+ assert.deepEqual(calls, []);
197
+ });
198
+
199
+ it("resumes an interrupted removal on a redelivery", async () => {
200
+ // The row stays `deleting` until the final commit, so a redelivery
201
+ // re-enters, passes the guard and continues where it stopped.
202
+ const { repo, removed } = deleting();
203
+ const service = new MailboxManagementService(repo);
204
+ const calls: string[] = [];
205
+
206
+ await service.settleDelete("acc-1", "mbx-1");
207
+ const result = await service.syncDelete(
208
+ "acc-1",
209
+ "mbx-1",
210
+ "Receipts",
211
+ async () => connection(calls),
212
+ );
213
+
214
+ assert.equal(result.success, true);
215
+ assert.deepEqual(calls, [], "no second IMAP DELETE is issued");
216
+ assert.deepEqual(removed, ["mbx-1"]);
217
+ });
218
+ });
219
+
220
+ describe("MailboxManagementService.failDelete", () => {
221
+ it("marks the row failed, unwinding nothing", async () => {
222
+ // DELETE is all-or-nothing, so fullPath is still what the server holds,
223
+ // and no rename target is written — which is how the client tells a failed
224
+ // delete from a failed rename (T9).
225
+ const { repo, byId } = store([
226
+ row({ syncStatus: MailboxSyncStatus.deleting }),
227
+ ]);
228
+ const service = new MailboxManagementService(repo);
229
+
230
+ await service.failDelete("acc-1", "mbx-1");
231
+
232
+ assert.equal(byId.get("mbx-1")?.syncStatus, MailboxSyncStatus.failed);
233
+ assert.equal(byId.get("mbx-1")?.fullPath, "Receipts");
234
+ assert.equal(byId.get("mbx-1")?.pendingPath, undefined);
235
+ });
236
+
237
+ it("raises a NotFoundError when the row is gone", async () => {
238
+ const { repo } = store([]);
239
+ const service = new MailboxManagementService(repo);
240
+
241
+ await assert.rejects(
242
+ service.failDelete("acc-1", "mbx-1"),
243
+ (error: unknown) => (error as Error).name === "NotFoundError",
244
+ );
245
+ });
246
+ });
@@ -705,15 +705,103 @@ export class MailboxManagementService {
705
705
  path: string,
706
706
  getConnection: () => Promise<IImapConnection>,
707
707
  ): Promise<MailboxManagementSyncResult> => {
708
+ if (!(await this.deleteIntentStanding(accountId, mailboxId))) {
709
+ return { success: true };
710
+ }
711
+
708
712
  const connection = await getConnection();
709
713
 
710
714
  await connection.deleteMailbox(path);
711
715
 
712
- this.log.info({ mailboxId, path }, "Deleted mailbox on IMAP server");
716
+ this.log.info(
717
+ { accountId, mailboxId, intent: "delete", path },
718
+ "Deleted mailbox on IMAP server",
719
+ );
713
720
 
714
- // Delete the mailbox entity from the local store
715
- await this.mailboxService.delete(accountId, mailboxId);
721
+ await this.settleDelete(accountId, mailboxId);
716
722
 
717
723
  return { success: true };
718
724
  };
725
+
726
+ /**
727
+ * Whether the delete this event was enqueued for is still the folder's live
728
+ * intent (D10). Anything else resolves the job with no IMAP call — never a
729
+ * throw, which would hold back every later job in the account's FIFO group
730
+ * (#339).
731
+ */
732
+ private deleteIntentStanding = async (
733
+ accountId: string,
734
+ mailboxId: string,
735
+ ): Promise<boolean> => {
736
+ const row = await this.mailboxService
737
+ .get(accountId, mailboxId)
738
+ .catch((error: unknown) => {
739
+ if (isNotFoundError(error)) return undefined;
740
+ throw error;
741
+ });
742
+ if (row?.syncStatus === MailboxSyncStatus.deleting) return true;
743
+ this.log.info(
744
+ {
745
+ accountId,
746
+ mailboxId,
747
+ intent: "delete",
748
+ from: row?.syncStatus,
749
+ outcome: row ? "superseded" : "already-settled",
750
+ },
751
+ "Skipping MAILBOX_DELETE: the recorded intent has moved on",
752
+ );
753
+ return false;
754
+ };
755
+
756
+ /**
757
+ * T8: the confirmed delete takes the folder's local mail with it (D8).
758
+ * Removing the row alone leaves every `message` and `thread_message` row
759
+ * keyed to a dead mailboxId — out of every reader, and still in the search
760
+ * index, because nothing writes the `message.removed` outbox event.
761
+ *
762
+ * The removal is batched and the mailbox row goes last, so the row stays
763
+ * `deleting` throughout and an SQS redelivery re-enters the handler, passes
764
+ * the guard above, and resumes where it stopped.
765
+ */
766
+ settleDelete = async (
767
+ accountId: string,
768
+ mailboxId: string,
769
+ ): Promise<void> => {
770
+ await this.mailboxService.deleteMailboxWithMail(accountId, mailboxId);
771
+ this.log.info(
772
+ {
773
+ accountId,
774
+ mailboxId,
775
+ intent: "delete",
776
+ from: MailboxSyncStatus.deleting,
777
+ outcome: "settled",
778
+ },
779
+ "Removed folder and its mail",
780
+ );
781
+ };
782
+
783
+ /**
784
+ * T9: the server refused the delete. Nothing is unwound — DELETE is
785
+ * all-or-nothing, so `fullPath` is still what the server holds, and no rename
786
+ * target is written, which is how the client tells a failed delete from a
787
+ * failed rename.
788
+ */
789
+ failDelete = async (accountId: string, mailboxId: string): Promise<void> => {
790
+ const failed = await this.mailboxService.transition(accountId, mailboxId, {
791
+ from: [MailboxSyncStatus.deleting],
792
+ to: MailboxSyncStatus.failed,
793
+ });
794
+ if (!failed) throw new NotFoundError(`Mailbox not found: ${mailboxId}`);
795
+ this.log.info(
796
+ {
797
+ accountId,
798
+ mailboxId,
799
+ intent: "delete",
800
+ from: MailboxSyncStatus.deleting,
801
+ to: MailboxSyncStatus.failed,
802
+ outcome: "refused",
803
+ },
804
+ "Recorded a refused delete",
805
+ );
806
+ };
719
807
  }
@@ -14,7 +14,6 @@ import {
14
14
  recordedByRename,
15
15
  refuseContestedIntent,
16
16
  } from "./mailbox-intent.js";
17
- import { EVERY_MAILBOX_STATE } from "./mailbox-presence.js";
18
17
 
19
18
  /**
20
19
  * MAILBOX_CREATE event structure (matches remit-imap-worker/events.ts)
@@ -330,8 +329,12 @@ export class MailboxQueueService {
330
329
  };
331
330
 
332
331
  /**
333
- * Delete a mailbox.
334
- * Marks for deletion (syncStatus=deleting) and enqueues IMAP DELETE.
332
+ * Record a delete intent (T7) and enqueue IMAP DELETE.
333
+ *
334
+ * The row stays at `deleting` and stays visible until the worker confirms the
335
+ * server delete and removes it with the folder's mail (D8, D11). The enqueue
336
+ * happens only after the transition wins, and an enqueue failure stays thrown
337
+ * — a 500 to the caller, loud — rather than being swallowed or rolled back.
335
338
  *
336
339
  * @param mailboxId - The mailbox to delete
337
340
  * @param accountId - The account ID for the IMAP sync event
@@ -343,17 +346,30 @@ export class MailboxQueueService {
343
346
  // Get current mailbox to capture path
344
347
  const mailbox = await this.mailboxService.get(accountId, mailboxId);
345
348
 
346
- // Mark as deleting (soft delete - worker will do actual delete after IMAP sync)
347
349
  const recorded = await this.mailboxService.transition(
348
350
  accountId,
349
351
  mailboxId,
350
- { from: EVERY_MAILBOX_STATE, to: MailboxSyncStatus.deleting },
352
+ { from: INTENT_RECORDABLE_FROM, to: MailboxSyncStatus.deleting },
351
353
  );
352
- if (!recorded) throw new NotFoundError(`Mailbox not found: ${mailboxId}`);
354
+ if (!recorded) {
355
+ await refuseContestedIntent(
356
+ this.mailboxService,
357
+ accountId,
358
+ mailboxId,
359
+ "folder",
360
+ );
361
+ }
353
362
 
354
363
  this.log.info(
355
- { mailboxId, path: mailbox.fullPath },
356
- "Marked mailbox for deletion (local)",
364
+ {
365
+ accountId,
366
+ mailboxId,
367
+ intent: "delete",
368
+ from: mailbox.syncStatus,
369
+ to: MailboxSyncStatus.deleting,
370
+ path: mailbox.fullPath,
371
+ },
372
+ "Recorded delete intent",
357
373
  );
358
374
 
359
375
  // Enqueue IMAP sync