@remit/imap-worker 0.0.46 → 0.0.48
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
|
@@ -31,12 +31,7 @@ interface Harness {
|
|
|
31
31
|
deletedAt?: number;
|
|
32
32
|
};
|
|
33
33
|
outbox: Record<string, unknown>;
|
|
34
|
-
|
|
35
|
-
mailboxes: {
|
|
36
|
-
mailboxId: string;
|
|
37
|
-
fullPath: string;
|
|
38
|
-
hierarchyDelimiter: string;
|
|
39
|
-
}[];
|
|
34
|
+
sentMailbox: { mailboxId: string; fullPath: string } | null;
|
|
40
35
|
append: (
|
|
41
36
|
path: string,
|
|
42
37
|
raw: Buffer,
|
|
@@ -72,15 +67,7 @@ const fresh = (): Harness => ({
|
|
|
72
67
|
inReplyTo: "parent@example.com",
|
|
73
68
|
sentAt: 1700000000000,
|
|
74
69
|
},
|
|
75
|
-
|
|
76
|
-
mailboxes: [
|
|
77
|
-
{ mailboxId: "inbox-mbx", fullPath: "INBOX", hierarchyDelimiter: "/" },
|
|
78
|
-
{
|
|
79
|
-
mailboxId: "sent-items-mbx",
|
|
80
|
-
fullPath: "Sent Items",
|
|
81
|
-
hierarchyDelimiter: "/",
|
|
82
|
-
},
|
|
83
|
-
],
|
|
70
|
+
sentMailbox: { mailboxId: "sent-mbx", fullPath: "INBOX/Sent" },
|
|
84
71
|
append: async (path, raw, flags) => {
|
|
85
72
|
h.calls.push({ method: "connection.append", args: [path, raw, flags] });
|
|
86
73
|
return { uid: 55, uidValidity: 7 };
|
|
@@ -107,10 +94,7 @@ const deps = (): AppendSentMessageDeps =>
|
|
|
107
94
|
discardAll: record("outboxAttachment.discardAll"),
|
|
108
95
|
},
|
|
109
96
|
mailboxSpecialUse: {
|
|
110
|
-
|
|
111
|
-
},
|
|
112
|
-
mailbox: {
|
|
113
|
-
listAllByAccount: async () => h.mailboxes,
|
|
97
|
+
findSentMailbox: async () => h.sentMailbox,
|
|
114
98
|
},
|
|
115
99
|
secrets: {},
|
|
116
100
|
}),
|
|
@@ -141,6 +125,27 @@ const event: AppendSentMessageEvent = {
|
|
|
141
125
|
const called = (method: string): Call[] =>
|
|
142
126
|
h.calls.filter((c) => c.method === method);
|
|
143
127
|
|
|
128
|
+
type BackendClient = Awaited<ReturnType<AppendSentMessageDeps["getClient"]>>;
|
|
129
|
+
|
|
130
|
+
const depsWithFailingDelete = (): AppendSentMessageDeps => {
|
|
131
|
+
const base = deps();
|
|
132
|
+
return {
|
|
133
|
+
...base,
|
|
134
|
+
getClient: async (): Promise<BackendClient> => {
|
|
135
|
+
const client = await base.getClient();
|
|
136
|
+
return {
|
|
137
|
+
...client,
|
|
138
|
+
outboxMessage: {
|
|
139
|
+
...client.outboxMessage,
|
|
140
|
+
delete: async (): Promise<void> => {
|
|
141
|
+
throw new Error("storage down");
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
};
|
|
148
|
+
|
|
144
149
|
describe("handleAppendSentMessage", () => {
|
|
145
150
|
beforeEach(() => {
|
|
146
151
|
h = fresh();
|
|
@@ -150,7 +155,7 @@ describe("handleAppendSentMessage", () => {
|
|
|
150
155
|
await handleAppendSentMessage(event, noopLog, 1, deps());
|
|
151
156
|
|
|
152
157
|
const append = called("connection.append")[0];
|
|
153
|
-
assert.equal(append?.args[0], "Sent");
|
|
158
|
+
assert.equal(append?.args[0], "INBOX/Sent");
|
|
154
159
|
assert.deepEqual(append?.args[2], ["\\Seen"]);
|
|
155
160
|
assert.deepEqual(called("outboxMessage.delete")[0]?.args, [
|
|
156
161
|
"cfg-1",
|
|
@@ -194,60 +199,8 @@ describe("handleAppendSentMessage", () => {
|
|
|
194
199
|
assert.match(raw, /^From: alice@example\.com$/m);
|
|
195
200
|
});
|
|
196
201
|
|
|
197
|
-
it("falls back to a conventionally-named Sent folder when no special-use flag is set", async () => {
|
|
198
|
-
h.specialUseSent = null;
|
|
199
|
-
|
|
200
|
-
await handleAppendSentMessage(event, noopLog, 1, deps());
|
|
201
|
-
|
|
202
|
-
assert.equal(called("connection.append")[0]?.args[0], "Sent Items");
|
|
203
|
-
});
|
|
204
|
-
|
|
205
|
-
it("files into a Sent folder nested under INBOX when no special-use flag is set", async () => {
|
|
206
|
-
h.specialUseSent = null;
|
|
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
|
-
});
|
|
245
|
-
|
|
246
202
|
it("settles the row as unfiled when the account has no Sent folder at all", async () => {
|
|
247
|
-
h.
|
|
248
|
-
h.mailboxes = [
|
|
249
|
-
{ mailboxId: "inbox-mbx", fullPath: "INBOX", hierarchyDelimiter: "/" },
|
|
250
|
-
];
|
|
203
|
+
h.sentMailbox = null;
|
|
251
204
|
|
|
252
205
|
await handleAppendSentMessage(event, noopLog, 1, deps());
|
|
253
206
|
|
|
@@ -261,7 +214,7 @@ describe("handleAppendSentMessage", () => {
|
|
|
261
214
|
assert.deepEqual(update?.args.slice(0, 2), ["cfg-1", "out-1"]);
|
|
262
215
|
const patch = update?.args[2] as { status: string; lastError: string };
|
|
263
216
|
assert.equal(patch.status, "unfiled");
|
|
264
|
-
assert.match(patch.lastError, /no Sent
|
|
217
|
+
assert.match(patch.lastError, /no folder appointed to the Sent role/);
|
|
265
218
|
});
|
|
266
219
|
|
|
267
220
|
it("skips the append while the outbox row is not yet sent", async () => {
|
|
@@ -336,19 +289,8 @@ describe("handleAppendSentMessage", () => {
|
|
|
336
289
|
});
|
|
337
290
|
|
|
338
291
|
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
292
|
await assert.rejects(
|
|
348
|
-
handleAppendSentMessage(event, noopLog,
|
|
349
|
-
...failingDeps,
|
|
350
|
-
getClient: async () => client,
|
|
351
|
-
} as AppendSentMessageDeps),
|
|
293
|
+
handleAppendSentMessage(event, noopLog, 1, depsWithFailingDelete()),
|
|
352
294
|
/storage down/,
|
|
353
295
|
);
|
|
354
296
|
|
|
@@ -356,4 +298,18 @@ describe("handleAppendSentMessage", () => {
|
|
|
356
298
|
// would say the opposite.
|
|
357
299
|
assert.equal(called("outboxMessage.update").length, 0);
|
|
358
300
|
});
|
|
301
|
+
|
|
302
|
+
it("stops redelivering a landed APPEND at the budget instead of filing another copy", async () => {
|
|
303
|
+
// A redelivery starts from the top and appends again, so retrying past the
|
|
304
|
+
// budget files one copy per attempt in the user's Sent folder (#830).
|
|
305
|
+
await handleAppendSentMessage(
|
|
306
|
+
event,
|
|
307
|
+
noopLog,
|
|
308
|
+
APPEND_SENT_MAX_ATTEMPTS,
|
|
309
|
+
depsWithFailingDelete(),
|
|
310
|
+
);
|
|
311
|
+
|
|
312
|
+
assert.equal(called("connection.append").length, 1);
|
|
313
|
+
assert.equal(called("outboxMessage.update").length, 0);
|
|
314
|
+
});
|
|
359
315
|
});
|
|
@@ -1,9 +1,5 @@
|
|
|
1
1
|
import { getClient } from "@remit/backend/client";
|
|
2
|
-
import
|
|
3
|
-
IMailboxRepository,
|
|
4
|
-
IMailboxSpecialUseRepository,
|
|
5
|
-
} from "@remit/data-ports";
|
|
6
|
-
import { MailboxSpecialUse, OutboxMessageStatus } from "@remit/domain-enums";
|
|
2
|
+
import { OutboxMessageStatus } from "@remit/domain-enums";
|
|
7
3
|
import type { Logger } from "@remit/logger-lambda";
|
|
8
4
|
import {
|
|
9
5
|
buildMailMessage,
|
|
@@ -12,29 +8,11 @@ import {
|
|
|
12
8
|
import { isAccountDeleted } from "../account-check.js";
|
|
13
9
|
import { createConnectionScopeWithCredentials } from "../connection-scope.js";
|
|
14
10
|
import type { AppendSentMessageEvent } from "../events.js";
|
|
15
|
-
import { resolveSentMailboxByName } from "../sent-mailbox.js";
|
|
16
11
|
import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
|
|
17
12
|
import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
|
|
18
13
|
|
|
19
|
-
const findSentMailbox = async (
|
|
20
|
-
mailboxSpecialUseService: IMailboxSpecialUseRepository,
|
|
21
|
-
mailboxService: IMailboxRepository,
|
|
22
|
-
accountId: string,
|
|
23
|
-
): Promise<{ mailboxId: string; fullPath: string } | null> => {
|
|
24
|
-
const bySpecialUse = await mailboxSpecialUseService.findBySpecialUse(
|
|
25
|
-
accountId,
|
|
26
|
-
MailboxSpecialUse.Sent,
|
|
27
|
-
);
|
|
28
|
-
if (bySpecialUse) {
|
|
29
|
-
return bySpecialUse;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
const mailboxes = await mailboxService.listAllByAccount(accountId);
|
|
33
|
-
return resolveSentMailboxByName(mailboxes);
|
|
34
|
-
};
|
|
35
|
-
|
|
36
14
|
const UNFILED_NO_SENT_MAILBOX =
|
|
37
|
-
"Sent, but not filed: this account has no Sent folder.
|
|
15
|
+
"Sent, but not filed: this account has no folder appointed to the Sent role and none that a Sent folder could be recognised by. Appoint one in the account's folder settings and later messages will be filed there.";
|
|
38
16
|
|
|
39
17
|
const UNFILED_SIGNED_OUT =
|
|
40
18
|
"Sent, but not filed: this account has to be signed in again before a copy can be stored in Sent.";
|
|
@@ -100,7 +78,6 @@ export const handleAppendSentMessage = async (
|
|
|
100
78
|
outboxMessage: outboxMessageService,
|
|
101
79
|
outboxAttachment: outboxAttachmentService,
|
|
102
80
|
mailboxSpecialUse: mailboxSpecialUseService,
|
|
103
|
-
mailbox: mailboxService,
|
|
104
81
|
secrets,
|
|
105
82
|
} = await getClient();
|
|
106
83
|
|
|
@@ -144,11 +121,7 @@ export const handleAppendSentMessage = async (
|
|
|
144
121
|
);
|
|
145
122
|
};
|
|
146
123
|
|
|
147
|
-
const sentMailbox = await findSentMailbox(
|
|
148
|
-
mailboxSpecialUseService,
|
|
149
|
-
mailboxService,
|
|
150
|
-
accountId,
|
|
151
|
-
);
|
|
124
|
+
const sentMailbox = await mailboxSpecialUseService.findSentMailbox(accountId);
|
|
152
125
|
if (!sentMailbox) {
|
|
153
126
|
await settleUnfiled(UNFILED_NO_SENT_MAILBOX);
|
|
154
127
|
return;
|
|
@@ -220,16 +193,27 @@ export const handleAppendSentMessage = async (
|
|
|
220
193
|
// attempt to pick up. At the budget the record would dead-letter, and a
|
|
221
194
|
// dead-lettered APPEND is exactly how a delivered message goes missing.
|
|
222
195
|
//
|
|
223
|
-
//
|
|
224
|
-
//
|
|
225
|
-
|
|
196
|
+
// The budget binds whether or not the APPEND landed. A redelivery
|
|
197
|
+
// starts from the top and appends again, so an unbudgeted retry files
|
|
198
|
+
// one copy per attempt in the user's Sent folder.
|
|
199
|
+
if (receiveCount < APPEND_SENT_MAX_ATTEMPTS) throw error;
|
|
226
200
|
return error;
|
|
227
201
|
},
|
|
228
202
|
);
|
|
229
203
|
|
|
230
|
-
// The APPEND landed: the copy is in Sent
|
|
231
|
-
//
|
|
232
|
-
|
|
204
|
+
// The APPEND landed: the copy is in Sent whatever failed after it, so there
|
|
205
|
+
// is nothing to settle as unfiled. A row that outlives its delete holds
|
|
206
|
+
// `sent`, which every view hides, until the migrator's boot-time stranded-row
|
|
207
|
+
// repair settles it — the next container start, not sooner (#824).
|
|
208
|
+
if (appended) {
|
|
209
|
+
if (failure) {
|
|
210
|
+
log.error(
|
|
211
|
+
{ accountId, outboxMessageId, reason: String(failure) },
|
|
212
|
+
"Sent message was filed but its outbox row survived its delete and stays hidden until the boot-time repair",
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
233
217
|
|
|
234
218
|
// A terminal auth failure returns here without throwing — withOAuthLifecycle
|
|
235
219
|
// flips the account to reauth_required and ACKs the record, which without
|
package/src/sent-mailbox.test.ts
DELETED
|
@@ -1,112 +0,0 @@
|
|
|
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
|
-
});
|
package/src/sent-mailbox.ts
DELETED
|
@@ -1,20 +0,0 @@
|
|
|
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);
|