@remit/imap-worker 0.0.42 → 0.0.44

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/imap-worker",
3
- "version": "0.0.42",
3
+ "version": "0.0.44",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "exports": {
@@ -3,6 +3,7 @@ import { beforeEach, describe, it } from "node:test";
3
3
  import type { Logger } from "@remit/logger-lambda";
4
4
  import type { AppendSentMessageEvent } from "../events.js";
5
5
  import {
6
+ APPEND_SENT_MAX_ATTEMPTS,
6
7
  type AppendSentMessageDeps,
7
8
  handleAppendSentMessage,
8
9
  } from "./append-sent-message.js";
@@ -31,12 +32,19 @@ interface Harness {
31
32
  };
32
33
  outbox: Record<string, unknown>;
33
34
  specialUseSent: { mailboxId: string; fullPath: string } | null;
34
- mailboxes: { mailboxId: string; fullPath: string }[];
35
+ mailboxes: {
36
+ mailboxId: string;
37
+ fullPath: string;
38
+ hierarchyDelimiter: string;
39
+ }[];
35
40
  append: (
36
41
  path: string,
37
42
  raw: Buffer,
38
43
  flags: string[],
39
44
  ) => Promise<{ uid: number; uidValidity: number }>;
45
+ /** Stand in for a terminal auth failure: withOAuthLifecycle ACKs the record
46
+ * without ever running the work. */
47
+ ackWithoutWork: boolean;
40
48
  disconnectCount: number;
41
49
  }
42
50
 
@@ -66,13 +74,18 @@ const fresh = (): Harness => ({
66
74
  },
67
75
  specialUseSent: { mailboxId: "sent-mbx", fullPath: "Sent" },
68
76
  mailboxes: [
69
- { mailboxId: "inbox-mbx", fullPath: "INBOX" },
70
- { mailboxId: "sent-items-mbx", fullPath: "Sent Items" },
77
+ { mailboxId: "inbox-mbx", fullPath: "INBOX", hierarchyDelimiter: "/" },
78
+ {
79
+ mailboxId: "sent-items-mbx",
80
+ fullPath: "Sent Items",
81
+ hierarchyDelimiter: "/",
82
+ },
71
83
  ],
72
84
  append: async (path, raw, flags) => {
73
85
  h.calls.push({ method: "connection.append", args: [path, raw, flags] });
74
86
  return { uid: 55, uidValidity: 7 };
75
87
  },
88
+ ackWithoutWork: false,
76
89
  disconnectCount: 0,
77
90
  });
78
91
 
@@ -87,6 +100,7 @@ const deps = (): AppendSentMessageDeps =>
87
100
  },
88
101
  outboxMessage: {
89
102
  get: async () => h.outbox,
103
+ update: record("outboxMessage.update"),
90
104
  delete: record("outboxMessage.delete"),
91
105
  },
92
106
  outboxAttachment: {
@@ -96,7 +110,7 @@ const deps = (): AppendSentMessageDeps =>
96
110
  findBySpecialUse: async () => h.specialUseSent,
97
111
  },
98
112
  mailbox: {
99
- listByAccount: async () => ({ items: h.mailboxes }),
113
+ listAllByAccount: async () => h.mailboxes,
100
114
  },
101
115
  secrets: {},
102
116
  }),
@@ -106,7 +120,7 @@ const deps = (): AppendSentMessageDeps =>
106
120
  _account: unknown,
107
121
  _log: unknown,
108
122
  cb: (credentials: unknown) => Promise<void>,
109
- ) => cb({}),
123
+ ) => (h.ackWithoutWork ? undefined : cb({})),
110
124
  createConnectionScope: () => ({
111
125
  getConnection: async () => ({
112
126
  append: (path: string, raw: Buffer, flags: string[]) =>
@@ -133,7 +147,7 @@ describe("handleAppendSentMessage", () => {
133
147
  });
134
148
 
135
149
  it("appends a seen RFC822 copy to the Sent folder and drops the outbox row", async () => {
136
- await handleAppendSentMessage(event, noopLog, deps());
150
+ await handleAppendSentMessage(event, noopLog, 1, deps());
137
151
 
138
152
  const append = called("connection.append")[0];
139
153
  assert.equal(append?.args[0], "Sent");
@@ -146,7 +160,7 @@ describe("handleAppendSentMessage", () => {
146
160
  });
147
161
 
148
162
  it("takes the draft's stored attachments with the row (#679)", async () => {
149
- await handleAppendSentMessage(event, noopLog, deps());
163
+ await handleAppendSentMessage(event, noopLog, 1, deps());
150
164
 
151
165
  // The row is the only reference to those objects. Sweeping has to happen
152
166
  // here too, not only on a discard, or a sent message leaves its files
@@ -159,7 +173,7 @@ describe("handleAppendSentMessage", () => {
159
173
  });
160
174
 
161
175
  it("builds the message from the outbox row's own headers", async () => {
162
- await handleAppendSentMessage(event, noopLog, deps());
176
+ await handleAppendSentMessage(event, noopLog, 1, deps());
163
177
 
164
178
  const raw = String(called("connection.append")[0]?.args[1] as Buffer);
165
179
  assert.match(raw, /^From: Alice <alice@example\.com>$/m);
@@ -174,7 +188,7 @@ describe("handleAppendSentMessage", () => {
174
188
  it("uses a bare address when the outbox row carries no display name", async () => {
175
189
  h.outbox = { ...h.outbox, fromName: undefined };
176
190
 
177
- await handleAppendSentMessage(event, noopLog, deps());
191
+ await handleAppendSentMessage(event, noopLog, 1, deps());
178
192
 
179
193
  const raw = String(called("connection.append")[0]?.args[1] as Buffer);
180
194
  assert.match(raw, /^From: alice@example\.com$/m);
@@ -183,25 +197,77 @@ describe("handleAppendSentMessage", () => {
183
197
  it("falls back to a conventionally-named Sent folder when no special-use flag is set", async () => {
184
198
  h.specialUseSent = null;
185
199
 
186
- await handleAppendSentMessage(event, noopLog, deps());
200
+ await handleAppendSentMessage(event, noopLog, 1, deps());
187
201
 
188
202
  assert.equal(called("connection.append")[0]?.args[0], "Sent Items");
189
203
  });
190
204
 
191
- it("skips the append when the account has no Sent folder at all", async () => {
205
+ it("files into a Sent folder nested under INBOX when no special-use flag is set", async () => {
192
206
  h.specialUseSent = null;
193
- h.mailboxes = [{ mailboxId: "inbox-mbx", fullPath: "INBOX" }];
207
+ h.mailboxes = [
208
+ { mailboxId: "inbox-mbx", fullPath: "INBOX", hierarchyDelimiter: "/" },
209
+ {
210
+ mailboxId: "sent-mbx",
211
+ fullPath: "INBOX/Sent",
212
+ hierarchyDelimiter: "/",
213
+ },
214
+ {
215
+ mailboxId: "sent-messages-mbx",
216
+ fullPath: "INBOX/Sent Messages",
217
+ hierarchyDelimiter: "/",
218
+ },
219
+ ];
220
+
221
+ await handleAppendSentMessage(event, noopLog, 1, deps());
222
+
223
+ assert.equal(called("connection.append")[0]?.args[0], "INBOX/Sent");
224
+ assert.deepEqual(called("outboxMessage.delete")[0]?.args, [
225
+ "cfg-1",
226
+ "out-1",
227
+ ]);
228
+ });
229
+
230
+ it("files into a nested Sent folder under a non-INBOX prefix and a dot delimiter", async () => {
231
+ h.specialUseSent = null;
232
+ h.mailboxes = [
233
+ { mailboxId: "inbox-mbx", fullPath: "INBOX", hierarchyDelimiter: "." },
234
+ {
235
+ mailboxId: "sent-mbx",
236
+ fullPath: "Mail.Sent Items",
237
+ hierarchyDelimiter: ".",
238
+ },
239
+ ];
240
+
241
+ await handleAppendSentMessage(event, noopLog, 1, deps());
242
+
243
+ assert.equal(called("connection.append")[0]?.args[0], "Mail.Sent Items");
244
+ });
194
245
 
195
- await handleAppendSentMessage(event, noopLog, deps());
246
+ it("settles the row as unfiled when the account has no Sent folder at all", async () => {
247
+ h.specialUseSent = null;
248
+ h.mailboxes = [
249
+ { mailboxId: "inbox-mbx", fullPath: "INBOX", hierarchyDelimiter: "/" },
250
+ ];
251
+
252
+ await handleAppendSentMessage(event, noopLog, 1, deps());
196
253
 
197
254
  assert.equal(called("connection.append").length, 0);
198
255
  assert.equal(called("outboxMessage.delete").length, 0);
256
+
257
+ // The message was delivered over SMTP. Leaving the row at `sent` hides it
258
+ // from the Outbox list and it exists in no server folder either, so the
259
+ // user loses it entirely.
260
+ const update = called("outboxMessage.update")[0];
261
+ assert.deepEqual(update?.args.slice(0, 2), ["cfg-1", "out-1"]);
262
+ const patch = update?.args[2] as { status: string; lastError: string };
263
+ assert.equal(patch.status, "unfiled");
264
+ assert.match(patch.lastError, /no Sent folder/);
199
265
  });
200
266
 
201
267
  it("skips the append while the outbox row is not yet sent", async () => {
202
268
  h.outbox = { ...h.outbox, status: "pending" };
203
269
 
204
- await handleAppendSentMessage(event, noopLog, deps());
270
+ await handleAppendSentMessage(event, noopLog, 1, deps());
205
271
 
206
272
  assert.equal(called("connection.append").length, 0);
207
273
  assert.equal(called("outboxMessage.delete").length, 0);
@@ -210,23 +276,84 @@ describe("handleAppendSentMessage", () => {
210
276
  it("returns early without touching the outbox when the account is soft-deleted", async () => {
211
277
  h.account = { ...h.account, deletedAt: Date.now() };
212
278
 
213
- await handleAppendSentMessage(event, noopLog, deps());
279
+ await handleAppendSentMessage(event, noopLog, 1, deps());
214
280
 
215
281
  assert.equal(called("connection.append").length, 0);
216
282
  assert.equal(called("outboxMessage.delete").length, 0);
217
283
  });
218
284
 
219
- it("keeps the outbox row when the APPEND fails so the send can be retried", async () => {
285
+ it("rethrows a failed APPEND while the queue still has attempts left", async () => {
220
286
  h.append = async () => {
221
287
  throw new Error("server exploded");
222
288
  };
223
289
 
224
290
  await assert.rejects(
225
- handleAppendSentMessage(event, noopLog, deps()),
291
+ handleAppendSentMessage(event, noopLog, 1, deps()),
226
292
  /server exploded/,
227
293
  );
228
294
 
295
+ // Nothing settled: the row stays `sent` and the redelivered event tries
296
+ // the APPEND again.
229
297
  assert.equal(called("outboxMessage.delete").length, 0);
298
+ assert.equal(called("outboxMessage.update").length, 0);
230
299
  assert.equal(h.disconnectCount, 1);
231
300
  });
301
+
302
+ it("settles the row as unfiled on the last attempt instead of dead-lettering it", async () => {
303
+ h.append = async () => {
304
+ throw new Error("server exploded");
305
+ };
306
+
307
+ await handleAppendSentMessage(
308
+ event,
309
+ noopLog,
310
+ APPEND_SENT_MAX_ATTEMPTS,
311
+ deps(),
312
+ );
313
+
314
+ // A DLQ'd APPEND is how the message went missing: the row would keep the
315
+ // `sent` status the Outbox list hides, and no Sent folder holds it.
316
+ assert.equal(called("outboxMessage.delete").length, 0);
317
+ const patch = called("outboxMessage.update")[0]?.args[2] as {
318
+ status: string;
319
+ lastError: string;
320
+ };
321
+ assert.equal(patch.status, "unfiled");
322
+ assert.match(patch.lastError, /server exploded/);
323
+ });
324
+
325
+ it("settles the row as unfiled when a terminal auth failure acks the record", async () => {
326
+ h.ackWithoutWork = true;
327
+
328
+ await handleAppendSentMessage(event, noopLog, 1, deps());
329
+
330
+ const patch = called("outboxMessage.update")[0]?.args[2] as {
331
+ status: string;
332
+ lastError: string;
333
+ };
334
+ assert.equal(patch.status, "unfiled");
335
+ assert.match(patch.lastError, /signed in again/);
336
+ });
337
+
338
+ it("leaves the row alone when the APPEND landed but the delete did not", async () => {
339
+ const failingDeps = deps();
340
+ const client = await failingDeps.getClient();
341
+ (
342
+ client as unknown as { outboxMessage: { delete: () => Promise<void> } }
343
+ ).outboxMessage.delete = async () => {
344
+ throw new Error("storage down");
345
+ };
346
+
347
+ await assert.rejects(
348
+ handleAppendSentMessage(event, noopLog, APPEND_SENT_MAX_ATTEMPTS, {
349
+ ...failingDeps,
350
+ getClient: async () => client,
351
+ } as AppendSentMessageDeps),
352
+ /storage down/,
353
+ );
354
+
355
+ // The copy is in Sent, so the message is findable — settling it as unfiled
356
+ // would say the opposite.
357
+ assert.equal(called("outboxMessage.update").length, 0);
358
+ });
232
359
  });
@@ -3,7 +3,7 @@ import type {
3
3
  IMailboxRepository,
4
4
  IMailboxSpecialUseRepository,
5
5
  } from "@remit/data-ports";
6
- import { MailboxSpecialUse } from "@remit/domain-enums";
6
+ import { MailboxSpecialUse, OutboxMessageStatus } from "@remit/domain-enums";
7
7
  import type { Logger } from "@remit/logger-lambda";
8
8
  import {
9
9
  buildMailMessage,
@@ -12,6 +12,7 @@ import {
12
12
  import { isAccountDeleted } from "../account-check.js";
13
13
  import { createConnectionScopeWithCredentials } from "../connection-scope.js";
14
14
  import type { AppendSentMessageEvent } from "../events.js";
15
+ import { resolveSentMailboxByName } from "../sent-mailbox.js";
15
16
  import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
16
17
  import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
17
18
 
@@ -28,26 +29,39 @@ const findSentMailbox = async (
28
29
  return bySpecialUse;
29
30
  }
30
31
 
31
- const commonSentNames = [
32
- "Sent",
33
- "Sent Items",
34
- "Sent Messages",
35
- "[Gmail]/Sent Mail",
36
- ];
37
- const mailboxResult = await mailboxService.listByAccount(accountId);
38
-
39
- for (const name of commonSentNames) {
40
- const found = mailboxResult.items.find(
41
- (m) => m.fullPath.toLowerCase() === name.toLowerCase(),
42
- );
43
- if (found) {
44
- return { mailboxId: found.mailboxId, fullPath: found.fullPath };
45
- }
46
- }
32
+ const mailboxes = await mailboxService.listAllByAccount(accountId);
33
+ return resolveSentMailboxByName(mailboxes);
34
+ };
47
35
 
48
- return null;
36
+ const UNFILED_NO_SENT_MAILBOX =
37
+ "Sent, but not filed: this account has no Sent folder. Create one named Sent and later messages will be filed there.";
38
+
39
+ const UNFILED_SIGNED_OUT =
40
+ "Sent, but not filed: this account has to be signed in again before a copy can be stored in Sent.";
41
+
42
+ const unfiledAppendRefused = (fullPath: string, error: unknown): string =>
43
+ `Sent, but not filed: the mail server refused to store a copy in ${fullPath} (${error instanceof Error ? error.message : String(error)}).`;
44
+
45
+ /**
46
+ * Fallback when `APPEND_SENT_MAX_ATTEMPTS` is unset (local dev, unit tests).
47
+ * Matches the shared `MAX_RECEIVE_COUNT` every queue's redrive policy uses,
48
+ * same pattern as `FLAG_PUSH_MAX_ATTEMPTS` / `BODY_SYNC_MAX_ATTEMPTS`.
49
+ */
50
+ const DEFAULT_APPEND_SENT_MAX_ATTEMPTS = 3;
51
+
52
+ export const getAppendSentMaxAttempts = (
53
+ processEnv: NodeJS.ProcessEnv = process.env,
54
+ ): number => {
55
+ const raw = processEnv.APPEND_SENT_MAX_ATTEMPTS;
56
+ if (!raw) return DEFAULT_APPEND_SENT_MAX_ATTEMPTS;
57
+ const parsed = Number.parseInt(raw, 10);
58
+ return Number.isFinite(parsed) && parsed > 0
59
+ ? parsed
60
+ : DEFAULT_APPEND_SENT_MAX_ATTEMPTS;
49
61
  };
50
62
 
63
+ export const APPEND_SENT_MAX_ATTEMPTS = getAppendSentMaxAttempts();
64
+
51
65
  export interface AppendSentMessageDeps {
52
66
  getClient: typeof getClient;
53
67
  buildLifecycleDeps: typeof buildLifecycleDeps;
@@ -65,6 +79,13 @@ const defaultDeps: AppendSentMessageDeps = {
65
79
  export const handleAppendSentMessage = async (
66
80
  event: AppendSentMessageEvent,
67
81
  log: Logger,
82
+ /**
83
+ * SQS's own delivery count for this record. The APPEND is the last step of a
84
+ * message the user has already had delivered, so exhausting the redrive
85
+ * budget must settle the row rather than dead-letter it: a DLQ'd record
86
+ * leaves the row at `sent`, which no view shows.
87
+ */
88
+ receiveCount = 1,
68
89
  deps: AppendSentMessageDeps = defaultDeps,
69
90
  ): Promise<void> => {
70
91
  const {
@@ -96,7 +117,7 @@ export const handleAppendSentMessage = async (
96
117
  account.accountConfigId,
97
118
  outboxMessageId,
98
119
  );
99
- if (outbox.status !== "sent") {
120
+ if (outbox.status !== OutboxMessageStatus.sent) {
100
121
  log.info(
101
122
  { outboxMessageId, status: outbox.status },
102
123
  "Outbox message not in sent status, skipping APPEND",
@@ -104,17 +125,38 @@ export const handleAppendSentMessage = async (
104
125
  return;
105
126
  }
106
127
 
128
+ // The message left over SMTP; only the filing can still fail. Settling the
129
+ // row as `unfiled` keeps it in the Outbox list rather than deleting it, so a
130
+ // delivered message stays readable somewhere. Every path out of this handler
131
+ // that is not a confirmed APPEND ends here.
132
+ const settleUnfiled = async (reason: string): Promise<void> => {
133
+ log.error(
134
+ { accountId, outboxMessageId, reason },
135
+ "Sent message could not be filed, settling the outbox row as unfiled",
136
+ );
137
+ await outboxMessageService.update(
138
+ account.accountConfigId,
139
+ outboxMessageId,
140
+ {
141
+ status: OutboxMessageStatus.unfiled,
142
+ lastError: reason,
143
+ },
144
+ );
145
+ };
146
+
107
147
  const sentMailbox = await findSentMailbox(
108
148
  mailboxSpecialUseService,
109
149
  mailboxService,
110
150
  accountId,
111
151
  );
112
152
  if (!sentMailbox) {
113
- log.info({ accountId }, "No Sent mailbox found, skipping IMAP APPEND");
153
+ await settleUnfiled(UNFILED_NO_SENT_MAILBOX);
114
154
  return;
115
155
  }
116
156
 
117
- await withOAuthLifecycle(
157
+ let appended = false;
158
+
159
+ const failure = await withOAuthLifecycle(
118
160
  buildLifecycleDeps(secrets, accountService),
119
161
  account,
120
162
  log,
@@ -141,6 +183,8 @@ export const handleAppendSentMessage = async (
141
183
  },
142
184
  "Appended sent message to Sent mailbox",
143
185
  );
186
+
187
+ appended = true;
144
188
  })
145
189
  .finally(() => scope.disconnect());
146
190
 
@@ -168,5 +212,31 @@ export const handleAppendSentMessage = async (
168
212
  "Deleted outbox row after successful APPEND to Sent",
169
213
  );
170
214
  },
215
+ ).then(
216
+ () => null,
217
+ (error: unknown) => {
218
+ // Below the redrive budget this is an ordinary retry — connections
219
+ // drop, servers go away, and the row is still `sent` for the next
220
+ // attempt to pick up. At the budget the record would dead-letter, and a
221
+ // dead-lettered APPEND is exactly how a delivered message goes missing.
222
+ //
223
+ // Once the APPEND itself has landed the copy is in Sent whatever else
224
+ // failed, so that case keeps the plain retry semantics.
225
+ if (appended || receiveCount < APPEND_SENT_MAX_ATTEMPTS) throw error;
226
+ return error;
227
+ },
228
+ );
229
+
230
+ // The APPEND landed: the copy is in Sent even if the row delete that follows
231
+ // it failed, so there is nothing to settle and a retry may still run.
232
+ if (appended) return;
233
+
234
+ // A terminal auth failure returns here without throwing — withOAuthLifecycle
235
+ // flips the account to reauth_required and ACKs the record, which without
236
+ // this would leave the row at `sent` and the message nowhere.
237
+ await settleUnfiled(
238
+ failure
239
+ ? unfiledAppendRefused(sentMailbox.fullPath, failure)
240
+ : UNFILED_SIGNED_OUT,
171
241
  );
172
242
  };
package/src/processor.ts CHANGED
@@ -18,8 +18,9 @@ export const processEvent = async (
18
18
  log: Logger,
19
19
  /**
20
20
  * SQS's own delivery count for the record carrying this event (1 on first
21
- * delivery). Read by SYNC_MESSAGE_BODY, PLACEMENT_MOVE_PUSH and FLAG_PUSH
22
- * — each knows from it when this is the last attempt before the queue's
21
+ * delivery). Read by SYNC_MESSAGE_BODY, PLACEMENT_MOVE_PUSH, FLAG_PUSH and
22
+ * APPEND_SENT_MESSAGE — each knows from it when this is the last attempt
23
+ * before the queue's
23
24
  * own redrive policy would DLQ the record, so it can resolve
24
25
  * retry exhaustion into a terminal outcome (issue #1270) instead of
25
26
  * dead-lettering blindly.
@@ -50,7 +51,7 @@ export const processEvent = async (
50
51
  case "EMPTY_TRASH":
51
52
  return handleEmptyTrash(event, log);
52
53
  case "APPEND_SENT_MESSAGE":
53
- return handleAppendSentMessage(event, log);
54
+ return handleAppendSentMessage(event, log, receiveCount);
54
55
  case "DELETE_ACCOUNT_OBJECTS":
55
56
  return handleDeleteAccountObjects(event, log);
56
57
  case "IMAP_WORKER_STOP":
@@ -0,0 +1,112 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import {
4
+ resolveSentMailboxByName,
5
+ type SentMailboxCandidate,
6
+ } from "./sent-mailbox.js";
7
+
8
+ const mailbox = (
9
+ fullPath: string,
10
+ hierarchyDelimiter = "/",
11
+ ): SentMailboxCandidate => ({
12
+ mailboxId: `mbx-${fullPath}`,
13
+ fullPath,
14
+ hierarchyDelimiter,
15
+ });
16
+
17
+ describe("resolveSentMailboxByName", () => {
18
+ it("finds a top-level Sent folder", () => {
19
+ const found = resolveSentMailboxByName([mailbox("INBOX"), mailbox("Sent")]);
20
+
21
+ assert.equal(found?.fullPath, "Sent");
22
+ });
23
+
24
+ it("finds a Sent folder nested under INBOX", () => {
25
+ const found = resolveSentMailboxByName([
26
+ mailbox("INBOX"),
27
+ mailbox("INBOX/Archive"),
28
+ mailbox("INBOX/Sent"),
29
+ ]);
30
+
31
+ assert.equal(found?.fullPath, "INBOX/Sent");
32
+ });
33
+
34
+ it("finds a nested Sent folder under a non-INBOX prefix with a non-slash delimiter", () => {
35
+ const found = resolveSentMailboxByName([
36
+ mailbox("Mail.Drafts", "."),
37
+ mailbox("Mail.Sent Items", "."),
38
+ ]);
39
+
40
+ assert.equal(found?.fullPath, "Mail.Sent Items");
41
+ });
42
+
43
+ it("finds the Gmail Sent folder by its leaf name", () => {
44
+ const found = resolveSentMailboxByName([
45
+ mailbox("INBOX"),
46
+ mailbox("[Gmail]/All Mail"),
47
+ mailbox("[Gmail]/Sent Mail"),
48
+ ]);
49
+
50
+ assert.equal(found?.fullPath, "[Gmail]/Sent Mail");
51
+ });
52
+
53
+ it("prefers the shallowest folder when several leaves match", () => {
54
+ const found = resolveSentMailboxByName([
55
+ mailbox("INBOX/Clients/Sent"),
56
+ mailbox("INBOX/Sent"),
57
+ ]);
58
+
59
+ assert.equal(found?.fullPath, "INBOX/Sent");
60
+ });
61
+
62
+ it("prefers the plain Sent name over the longer variants", () => {
63
+ const found = resolveSentMailboxByName([
64
+ mailbox("INBOX/Sent Messages"),
65
+ mailbox("INBOX/Sent"),
66
+ ]);
67
+
68
+ assert.equal(found?.fullPath, "INBOX/Sent");
69
+ });
70
+
71
+ it("matches the leaf regardless of case", () => {
72
+ const found = resolveSentMailboxByName([mailbox("INBOX.sent items", ".")]);
73
+
74
+ assert.equal(found?.fullPath, "INBOX.sent items");
75
+ });
76
+
77
+ it("does not match a folder that merely starts with a Sent name", () => {
78
+ const found = resolveSentMailboxByName([
79
+ mailbox("INBOX/Sent Archive 2024"),
80
+ mailbox("INBOX/Sentinel"),
81
+ ]);
82
+
83
+ assert.equal(found, null);
84
+ });
85
+
86
+ it("prefers a shallower Sent over a deeper better-named one", () => {
87
+ const found = resolveSentMailboxByName([
88
+ mailbox("INBOX.Sent Items", "."),
89
+ mailbox("INBOX.Trash.Sent", "."),
90
+ ]);
91
+
92
+ assert.equal(found?.fullPath, "INBOX.Sent Items");
93
+ });
94
+
95
+ it("resolves a flat namespace that reports no delimiter at all", () => {
96
+ const found = resolveSentMailboxByName([
97
+ mailbox("INBOX", ""),
98
+ mailbox("Sent", ""),
99
+ ]);
100
+
101
+ assert.equal(found?.fullPath, "Sent");
102
+ });
103
+
104
+ it("returns null when the account has no Sent folder", () => {
105
+ const found = resolveSentMailboxByName([
106
+ mailbox("INBOX"),
107
+ mailbox("INBOX/Trash"),
108
+ ]);
109
+
110
+ assert.equal(found, null);
111
+ });
112
+ });
@@ -0,0 +1,20 @@
1
+ import type { MailboxItem } from "@remit/data-ports";
2
+ import { resolveMailboxByLeafName } from "@remit/data-ports/mailbox-name";
3
+
4
+ export type SentMailboxCandidate = Pick<
5
+ MailboxItem,
6
+ "mailboxId" | "fullPath" | "hierarchyDelimiter"
7
+ >;
8
+
9
+ const SENT_FOLDER_NAMES = ["sent", "sent items", "sent messages", "sent mail"];
10
+
11
+ /**
12
+ * The Sent folder by conventional name, for servers that advertise no `\Sent`
13
+ * special-use. Matches the folder's own leaf segment, so it resolves at any
14
+ * depth under any prefix (`INBOX/Sent`, `Mail.Sent Items`, `[Gmail]/Sent Mail`)
15
+ * without knowing which prefixes a server uses.
16
+ */
17
+ export const resolveSentMailboxByName = (
18
+ mailboxes: SentMailboxCandidate[],
19
+ ): SentMailboxCandidate | null =>
20
+ resolveMailboxByLeafName(mailboxes, SENT_FOLDER_NAMES);