@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 +1 -1
- package/src/handlers/append-sent-message.test.ts +144 -17
- package/src/handlers/append-sent-message.ts +91 -21
- package/src/processor.ts +4 -3
- package/src/sent-mailbox.test.ts +112 -0
- package/src/sent-mailbox.ts +20 -0
package/package.json
CHANGED
|
@@ -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: {
|
|
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
|
-
{
|
|
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
|
-
|
|
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("
|
|
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 = [
|
|
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
|
-
|
|
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("
|
|
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
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
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 !==
|
|
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
|
-
|
|
153
|
+
await settleUnfiled(UNFILED_NO_SENT_MAILBOX);
|
|
114
154
|
return;
|
|
115
155
|
}
|
|
116
156
|
|
|
117
|
-
|
|
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
|
|
22
|
-
* — each knows from it when this is the last attempt
|
|
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);
|