@remit/mailbox-service 0.0.15 → 0.0.17
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/body-parse.test.ts +55 -0
- package/src/body-parse.ts +61 -0
- package/src/body-sync-quarantine.test.ts +219 -0
- package/src/body-sync-terminal.test.ts +48 -3
- package/src/body-sync-terminal.ts +7 -3
- package/src/body-sync.ts +167 -3
- package/src/flag-push-terminal.test.ts +55 -2
- package/src/flag-push-terminal.ts +16 -6
- package/src/imapflow-connection.test.ts +97 -2
- package/src/imapflow-connection.ts +30 -27
- package/src/index.ts +16 -0
- package/src/message-presence.test.ts +50 -0
- package/src/message-presence.ts +35 -0
- package/src/message-sync-quarantine.test.ts +407 -0
- package/src/message-sync.ts +135 -18
- package/src/placement-move-terminal.test.ts +58 -2
- package/src/placement-move-terminal.ts +16 -5
- package/src/quarantine.test.ts +191 -0
- package/src/quarantine.ts +228 -0
|
@@ -33,13 +33,21 @@ const buildLogger = (): {
|
|
|
33
33
|
};
|
|
34
34
|
};
|
|
35
35
|
|
|
36
|
-
const buildConnection = (
|
|
36
|
+
const buildConnection = (
|
|
37
|
+
present: Set<number>,
|
|
38
|
+
fetchDrops: Set<number> = new Set(),
|
|
39
|
+
): IImapConnection =>
|
|
37
40
|
({
|
|
38
41
|
openBox: async () => ({}) as never,
|
|
39
42
|
fetchMessages: async (uids: number[]) =>
|
|
40
43
|
uids
|
|
41
|
-
.filter((uid) => present.has(uid))
|
|
44
|
+
.filter((uid) => present.has(uid) && !fetchDrops.has(uid))
|
|
42
45
|
.map((uid) => ({ uid }) as unknown as never),
|
|
46
|
+
search: async (criteria: unknown[]) => {
|
|
47
|
+
const [, value] = (criteria as Array<[string, string]>)[0];
|
|
48
|
+
const uid = Number(value);
|
|
49
|
+
return present.has(uid) ? [uid] : [];
|
|
50
|
+
},
|
|
43
51
|
}) as unknown as IImapConnection;
|
|
44
52
|
|
|
45
53
|
describe("resolveExhaustedPlacementMoveFailure — the two terminal outcomes (mirrors #1270 for placement moves)", () => {
|
|
@@ -148,6 +156,54 @@ describe("resolveExhaustedPlacementMoveFailure — the two terminal outcomes (mi
|
|
|
148
156
|
);
|
|
149
157
|
});
|
|
150
158
|
|
|
159
|
+
it("a dropped FETCH row is not absence: the message is still at the source — marker and local rows survive", async () => {
|
|
160
|
+
const deletedMessages: string[] = [];
|
|
161
|
+
const deletedThreadMessages: unknown[] = [];
|
|
162
|
+
const markerDeletes: string[] = [];
|
|
163
|
+
const { log, errors } = buildLogger();
|
|
164
|
+
|
|
165
|
+
const deps: ResolveExhaustedPlacementMoveDeps = {
|
|
166
|
+
markerService: {
|
|
167
|
+
delete: async (id: string) => {
|
|
168
|
+
markerDeletes.push(id);
|
|
169
|
+
},
|
|
170
|
+
},
|
|
171
|
+
messageService: {
|
|
172
|
+
delete: async (id: string) => {
|
|
173
|
+
deletedMessages.push(id);
|
|
174
|
+
},
|
|
175
|
+
} as unknown as Pick<IMessageRepository, "delete">,
|
|
176
|
+
threadMessageService: {
|
|
177
|
+
findAllByMessageId: async () => [
|
|
178
|
+
{ accountConfigId: "cfg-1", threadMessageId: "tm-msg-live" },
|
|
179
|
+
],
|
|
180
|
+
deleteMany: async (keys: unknown[]) => {
|
|
181
|
+
deletedThreadMessages.push(...keys);
|
|
182
|
+
},
|
|
183
|
+
} as unknown as Pick<
|
|
184
|
+
IThreadMessageRepository,
|
|
185
|
+
"findAllByMessageId" | "deleteMany"
|
|
186
|
+
>,
|
|
187
|
+
log,
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const result = await resolveExhaustedPlacementMoveFailure(deps, {
|
|
191
|
+
accountId: "acc-1",
|
|
192
|
+
accountConfigId: "cfg-1",
|
|
193
|
+
messageId: "msg-live",
|
|
194
|
+
uid: 303,
|
|
195
|
+
sourceMailboxPath: "INBOX",
|
|
196
|
+
getConnection: async () =>
|
|
197
|
+
buildConnection(new Set([303]), new Set([303])),
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
assert.equal(result.outcome, "broken");
|
|
201
|
+
assert.deepEqual(markerDeletes, []);
|
|
202
|
+
assert.deepEqual(deletedMessages, []);
|
|
203
|
+
assert.deepEqual(deletedThreadMessages, []);
|
|
204
|
+
assert.ok(errors.some((e) => e.obj.alert === "placement_move_failed"));
|
|
205
|
+
});
|
|
206
|
+
|
|
151
207
|
it("never throws — both outcomes are terminal, the caller always acks", async () => {
|
|
152
208
|
const { log } = buildLogger();
|
|
153
209
|
const deps: ResolveExhaustedPlacementMoveDeps = {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isMessageGoneFromOpenMailbox } from "./message-presence.js";
|
|
1
2
|
import type { PlacementMoveLogger } from "./placement-move.js";
|
|
2
3
|
import {
|
|
3
4
|
reconcileStaleMessage,
|
|
@@ -33,9 +34,11 @@ export interface ResolveExhaustedPlacementMoveResult {
|
|
|
33
34
|
* taxonomy (epic #1281 invariant 3) — no third, softer outcome.
|
|
34
35
|
*
|
|
35
36
|
* 1. RECONCILED (expected) — the message no longer exists at its pending-move
|
|
36
|
-
* source on IMAP
|
|
37
|
-
*
|
|
38
|
-
*
|
|
37
|
+
* source on IMAP, confirmed by {@link isMessageGoneFromOpenMailbox} rather
|
|
38
|
+
* than by a FETCH coming back empty. Per invariant 2, an external delete
|
|
39
|
+
* supersedes the marker entirely: the marker is dropped and the stale
|
|
40
|
+
* Message/ThreadMessage rows are deleted via
|
|
41
|
+
* {@link reconcileStaleMessage}. This is also the
|
|
39
42
|
* outcome for the (rarer, functionally indistinguishable from here) case
|
|
40
43
|
* where a foreign client moved the message elsewhere — either way, our
|
|
41
44
|
* prediction no longer holds, and the marker cannot be honoured. Metric
|
|
@@ -49,6 +52,15 @@ export interface ResolveExhaustedPlacementMoveResult {
|
|
|
49
52
|
* `alert`-shaped entry for an operator alarm; never re-thrown (terminal —
|
|
50
53
|
* the caller acks either way, since retrying a stale or permanently-broken
|
|
51
54
|
* move can never succeed).
|
|
55
|
+
*
|
|
56
|
+
* An operator reading `placement_move_failed` should know one case where the
|
|
57
|
+
* message is not actually at the source: a message another client expunged
|
|
58
|
+
* mid-session can answer an empty FETCH while the server still lists its UID
|
|
59
|
+
* in SEARCH, until it is allowed to send the untagged EXPUNGE. That message
|
|
60
|
+
* lands in BROKEN, and BROKEN is terminal — the marker stays pending and the
|
|
61
|
+
* alert stands until someone clears it. The reverse mistake deletes live
|
|
62
|
+
* mail, so the cost is paid deliberately: a stale alert is recoverable, a
|
|
63
|
+
* deleted message is not.
|
|
52
64
|
*/
|
|
53
65
|
export const resolveExhaustedPlacementMoveFailure = async (
|
|
54
66
|
deps: ResolveExhaustedPlacementMoveDeps,
|
|
@@ -65,9 +77,8 @@ export const resolveExhaustedPlacementMoveFailure = async (
|
|
|
65
77
|
|
|
66
78
|
const connection = await getConnection();
|
|
67
79
|
await connection.openBox(sourceMailboxPath);
|
|
68
|
-
const found = await connection.fetchMessages([uid]);
|
|
69
80
|
|
|
70
|
-
if (
|
|
81
|
+
if (await isMessageGoneFromOpenMailbox(connection, uid)) {
|
|
71
82
|
await deps.markerService.delete(messageId);
|
|
72
83
|
const { threadMessagesDeleted } = await reconcileStaleMessage(
|
|
73
84
|
deps,
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import type {
|
|
4
|
+
IMailboxSpecialUseRepository,
|
|
5
|
+
IQuarantineRepository,
|
|
6
|
+
MessageData,
|
|
7
|
+
MessageItem,
|
|
8
|
+
QuarantineItem,
|
|
9
|
+
QuarantineUpsertInput,
|
|
10
|
+
} from "@remit/data-ports";
|
|
11
|
+
import {
|
|
12
|
+
QuarantinedUids,
|
|
13
|
+
QuarantineService,
|
|
14
|
+
resolveMailboxRole,
|
|
15
|
+
shapeFromMessageData,
|
|
16
|
+
} from "./quarantine.js";
|
|
17
|
+
|
|
18
|
+
const noopLog = { info: () => {}, warn: () => {} };
|
|
19
|
+
|
|
20
|
+
const buildService = (
|
|
21
|
+
specialUse: string[] = [],
|
|
22
|
+
): {
|
|
23
|
+
service: QuarantineService;
|
|
24
|
+
writes: QuarantineUpsertInput[];
|
|
25
|
+
listed: string[];
|
|
26
|
+
} => {
|
|
27
|
+
const writes: QuarantineUpsertInput[] = [];
|
|
28
|
+
const listed: string[] = [];
|
|
29
|
+
const repository = {
|
|
30
|
+
listByAccountConfigId: async (accountConfigId: string) => {
|
|
31
|
+
listed.push(accountConfigId);
|
|
32
|
+
return [] as QuarantineItem[];
|
|
33
|
+
},
|
|
34
|
+
upsert: async (input: QuarantineUpsertInput) => {
|
|
35
|
+
writes.push(input);
|
|
36
|
+
},
|
|
37
|
+
} satisfies IQuarantineRepository;
|
|
38
|
+
|
|
39
|
+
const mailboxSpecialUseService = {
|
|
40
|
+
listByMailboxId: async () => specialUse.map((use) => ({ specialUse: use })),
|
|
41
|
+
} as unknown as IMailboxSpecialUseRepository;
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
service: new QuarantineService(
|
|
45
|
+
repository,
|
|
46
|
+
mailboxSpecialUseService,
|
|
47
|
+
"sha-abc",
|
|
48
|
+
noopLog,
|
|
49
|
+
),
|
|
50
|
+
writes,
|
|
51
|
+
listed,
|
|
52
|
+
};
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const context = {
|
|
56
|
+
accountId: "acct-1",
|
|
57
|
+
accountConfigId: "cfg-1",
|
|
58
|
+
mailboxId: "mbx-1",
|
|
59
|
+
mailboxPath: "INBOX",
|
|
60
|
+
uidValidity: 1_712_000_000,
|
|
61
|
+
attempts: 2,
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const failure = {
|
|
65
|
+
stage: "BodyParse" as const,
|
|
66
|
+
code: "UnreadableBody" as const,
|
|
67
|
+
message: "the parser said no",
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
describe("QuarantineService.record", () => {
|
|
71
|
+
it("stamps the worker build, not the client's", async () => {
|
|
72
|
+
const { service, writes } = buildService();
|
|
73
|
+
await service.record(context, 40217, failure);
|
|
74
|
+
assert.equal(writes[0]?.workerVersion, "sha-abc");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("names the message by mailbox, uidValidity and uid", async () => {
|
|
78
|
+
const { service, writes } = buildService();
|
|
79
|
+
await service.record(context, 40217, failure);
|
|
80
|
+
assert.deepEqual(
|
|
81
|
+
{
|
|
82
|
+
mailboxId: writes[0]?.mailboxId,
|
|
83
|
+
uidValidity: writes[0]?.uidValidity,
|
|
84
|
+
uid: writes[0]?.uid,
|
|
85
|
+
},
|
|
86
|
+
{ mailboxId: "mbx-1", uidValidity: 1_712_000_000, uid: 40217 },
|
|
87
|
+
);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("supplies no quarantineId, so the random-id default is unreachable", async () => {
|
|
91
|
+
const { service, writes } = buildService();
|
|
92
|
+
await service.record(context, 40217, failure);
|
|
93
|
+
assert.ok(!("quarantineId" in (writes[0] as object)));
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("omits an absent diagnostic rather than writing a null through", async () => {
|
|
97
|
+
const { service, writes } = buildService();
|
|
98
|
+
await service.record(
|
|
99
|
+
{ ...context, mailboxPath: "Clients/Acme" },
|
|
100
|
+
1,
|
|
101
|
+
failure,
|
|
102
|
+
);
|
|
103
|
+
assert.ok(!("failurePartPath" in (writes[0] as object)));
|
|
104
|
+
assert.ok(!("mailboxRole" in (writes[0] as object)));
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("reads the folder's role off the server's SPECIAL-USE", async () => {
|
|
108
|
+
const { service, writes } = buildService(["Junk"]);
|
|
109
|
+
await service.record({ ...context, mailboxPath: "Spam" }, 1, failure);
|
|
110
|
+
assert.equal(writes[0]?.mailboxRole, "Junk");
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe("resolveMailboxRole", () => {
|
|
115
|
+
it("gives INBOX its role without a SPECIAL-USE flag, which it never has", () => {
|
|
116
|
+
assert.equal(resolveMailboxRole("INBOX", []), "Inbox");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("leaves a plain folder roleless instead of inventing one", () => {
|
|
120
|
+
assert.equal(resolveMailboxRole("Clients/Acme", []), undefined);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("drops a SPECIAL-USE that is not a canonical role", () => {
|
|
124
|
+
assert.equal(resolveMailboxRole("Priority", ["Important"]), undefined);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe("QuarantinedUids", () => {
|
|
129
|
+
const entries = [
|
|
130
|
+
{ mailboxId: "mbx-1", uidValidity: 10, uid: 5 },
|
|
131
|
+
] as QuarantineItem[];
|
|
132
|
+
|
|
133
|
+
it("matches a uid on the same mailbox and UIDVALIDITY", () => {
|
|
134
|
+
assert.equal(new QuarantinedUids(entries).has("mbx-1", 10, 5), true);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("does not match the same uid on a new UIDVALIDITY, which is a different message", () => {
|
|
138
|
+
assert.equal(new QuarantinedUids(entries).has("mbx-1", 11, 5), false);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("does not match the same uid in another mailbox", () => {
|
|
142
|
+
assert.equal(new QuarantinedUids(entries).has("mbx-2", 10, 5), false);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe("shapeFromMessageData", () => {
|
|
147
|
+
const message = {
|
|
148
|
+
rfc822Size: 2048,
|
|
149
|
+
messageIdHeader: "<xyz@example.com>",
|
|
150
|
+
} as MessageItem;
|
|
151
|
+
|
|
152
|
+
const data = {
|
|
153
|
+
bodyPart: [
|
|
154
|
+
{
|
|
155
|
+
bodyPartId: "bp-0",
|
|
156
|
+
partPath: "0",
|
|
157
|
+
mediaType: "multipart",
|
|
158
|
+
mediaSubtype: "alternative",
|
|
159
|
+
transferEncoding: "7bit",
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
bodyPartId: "bp-1",
|
|
163
|
+
partPath: "1",
|
|
164
|
+
mediaType: "text",
|
|
165
|
+
mediaSubtype: "plain",
|
|
166
|
+
transferEncoding: "quoted-printable",
|
|
167
|
+
},
|
|
168
|
+
],
|
|
169
|
+
bodyPartParameter: [
|
|
170
|
+
{
|
|
171
|
+
bodyPartId: "bp-0",
|
|
172
|
+
parameterName: "charset",
|
|
173
|
+
parameterValue: "iso-8859-1",
|
|
174
|
+
},
|
|
175
|
+
],
|
|
176
|
+
} as unknown as MessageData;
|
|
177
|
+
|
|
178
|
+
it("rebuilds the tree from the rows metadata sync already wrote", () => {
|
|
179
|
+
assert.deepEqual(shapeFromMessageData(message, data).structure, [
|
|
180
|
+
{ depth: 0, contentType: "multipart/alternative" },
|
|
181
|
+
{ depth: 1, contentType: "text/plain" },
|
|
182
|
+
]);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("takes the root part's declared charset and encoding", () => {
|
|
186
|
+
const shape = shapeFromMessageData(message, data);
|
|
187
|
+
assert.equal(shape.charset, "iso-8859-1");
|
|
188
|
+
assert.equal(shape.transferEncoding, "7bit");
|
|
189
|
+
assert.equal(shape.contentType, "multipart/alternative");
|
|
190
|
+
});
|
|
191
|
+
});
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
IMailboxSpecialUseRepository,
|
|
3
|
+
IQuarantineRepository,
|
|
4
|
+
MessageData,
|
|
5
|
+
MessageItem,
|
|
6
|
+
QuarantineItem,
|
|
7
|
+
QuarantineMimeNodeItem,
|
|
8
|
+
} from "@remit/data-ports";
|
|
9
|
+
import { quarantineMessageIdHash, ROOT_PART_PATH } from "@remit/data-ports/id";
|
|
10
|
+
import { CanonicalMailboxRole } from "@remit/domain-enums";
|
|
11
|
+
|
|
12
|
+
type CanonicalRole = NonNullable<QuarantineItem["mailboxRole"]>;
|
|
13
|
+
type FailureStage = QuarantineItem["failureStage"];
|
|
14
|
+
type FailureCode = QuarantineItem["failureCode"];
|
|
15
|
+
|
|
16
|
+
const CANONICAL_ROLES = new Set<string>(Object.values(CanonicalMailboxRole));
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The canonical role of the folder a quarantined message arrived in, read from
|
|
20
|
+
* the server's own RFC 6154 SPECIAL-USE declaration — the same source the
|
|
21
|
+
* appointment flow seeds from. A folder the server declares nothing about has
|
|
22
|
+
* no role, which is the normal state for a plain folder and is why the field
|
|
23
|
+
* is optional. `Important` is a SPECIAL-USE with no canonical role and drops
|
|
24
|
+
* out here rather than being invented into one.
|
|
25
|
+
*/
|
|
26
|
+
export const resolveMailboxRole = (
|
|
27
|
+
mailboxPath: string,
|
|
28
|
+
specialUse: readonly string[],
|
|
29
|
+
): CanonicalRole | undefined => {
|
|
30
|
+
if (mailboxPath.toUpperCase() === "INBOX") return CanonicalMailboxRole.Inbox;
|
|
31
|
+
const matched = specialUse.find((use) => CANONICAL_ROLES.has(use));
|
|
32
|
+
return matched as CanonicalRole | undefined;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** Everything about the quarantine that is the same for every uid in a round. */
|
|
36
|
+
export interface QuarantineContext {
|
|
37
|
+
accountId: string;
|
|
38
|
+
accountConfigId: string;
|
|
39
|
+
mailboxId: string;
|
|
40
|
+
mailboxPath: string;
|
|
41
|
+
uidValidity: number;
|
|
42
|
+
/** Rounds tried before the message was set aside. */
|
|
43
|
+
attempts: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The repro fingerprint: what the message declared itself to be. Every field
|
|
48
|
+
* is optional-by-absence because a message can fail before its shape was read,
|
|
49
|
+
* and a required column would force the writer to invent a value or drop the
|
|
50
|
+
* record.
|
|
51
|
+
*/
|
|
52
|
+
export interface QuarantineMessageShape {
|
|
53
|
+
contentType?: string;
|
|
54
|
+
transferEncoding?: string;
|
|
55
|
+
charset?: string;
|
|
56
|
+
sizeBytes?: number;
|
|
57
|
+
structure: QuarantineMimeNodeItem[];
|
|
58
|
+
messageIdHash?: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface QuarantineFailure {
|
|
62
|
+
stage: FailureStage;
|
|
63
|
+
code: FailureCode;
|
|
64
|
+
/** Parser error text. Stored, shown on screen, never published. */
|
|
65
|
+
message: string;
|
|
66
|
+
partPath?: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const EMPTY_SHAPE: QuarantineMessageShape = { structure: [] };
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Message shape off the rows metadata sync already wrote. The body path has no
|
|
73
|
+
* FETCH result to read — it streams raw bytes — but the MIME tree it needs was
|
|
74
|
+
* walked into BodyPart rows when the message's metadata was synced, so the
|
|
75
|
+
* fingerprint comes from there rather than from re-reading headers the parser
|
|
76
|
+
* has just refused.
|
|
77
|
+
*/
|
|
78
|
+
export const shapeFromMessageData = (
|
|
79
|
+
message: MessageItem,
|
|
80
|
+
data: MessageData,
|
|
81
|
+
): QuarantineMessageShape => {
|
|
82
|
+
const parts = [...data.bodyPart].sort((a, b) =>
|
|
83
|
+
a.partPath.localeCompare(b.partPath, "en", { numeric: true }),
|
|
84
|
+
);
|
|
85
|
+
const root = parts.find((part) => part.partPath === ROOT_PART_PATH);
|
|
86
|
+
const rootCharset = root
|
|
87
|
+
? data.bodyPartParameter.find(
|
|
88
|
+
(param) =>
|
|
89
|
+
param.bodyPartId === root.bodyPartId &&
|
|
90
|
+
param.parameterName.toLowerCase() === "charset",
|
|
91
|
+
)?.parameterValue
|
|
92
|
+
: undefined;
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
...(root ? { contentType: `${root.mediaType}/${root.mediaSubtype}` } : {}),
|
|
96
|
+
...(root ? { transferEncoding: root.transferEncoding } : {}),
|
|
97
|
+
...(rootCharset ? { charset: rootCharset } : {}),
|
|
98
|
+
...(message.rfc822Size ? { sizeBytes: message.rfc822Size } : {}),
|
|
99
|
+
structure: parts.map((part) => ({
|
|
100
|
+
depth: partPathDepth(part.partPath),
|
|
101
|
+
contentType: `${part.mediaType}/${part.mediaSubtype}`,
|
|
102
|
+
})),
|
|
103
|
+
...hashOf(message.messageIdHeader),
|
|
104
|
+
};
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const partPathDepth = (partPath: string): number =>
|
|
108
|
+
partPath === ROOT_PART_PATH ? 0 : partPath.split(".").length;
|
|
109
|
+
|
|
110
|
+
const hashOf = (
|
|
111
|
+
messageIdHeader: string | undefined,
|
|
112
|
+
): { messageIdHash?: string } => {
|
|
113
|
+
const messageIdHash = quarantineMessageIdHash(messageIdHeader);
|
|
114
|
+
return messageIdHash ? { messageIdHash } : {};
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The set of messages already set aside, held for the duration of one sync
|
|
119
|
+
* round. A round loads it once and filters against it in memory: the list is
|
|
120
|
+
* small by design — a growing one is a bug being reported, not a page to
|
|
121
|
+
* paginate — and a lookup per message would put a query on the hot path for a
|
|
122
|
+
* state that is almost always empty.
|
|
123
|
+
*/
|
|
124
|
+
export class QuarantinedUids {
|
|
125
|
+
private readonly keys: ReadonlySet<string>;
|
|
126
|
+
|
|
127
|
+
constructor(entries: readonly QuarantineItem[]) {
|
|
128
|
+
this.keys = new Set(
|
|
129
|
+
entries.map((entry) =>
|
|
130
|
+
uidKey(entry.mailboxId, entry.uidValidity, entry.uid),
|
|
131
|
+
),
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
get size(): number {
|
|
136
|
+
return this.keys.size;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
has(mailboxId: string, uidValidity: number, uid: number): boolean {
|
|
140
|
+
return this.keys.has(uidKey(mailboxId, uidValidity, uid));
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const uidKey = (mailboxId: string, uidValidity: number, uid: number): string =>
|
|
145
|
+
`${mailboxId}:${uidValidity}:${uid}`;
|
|
146
|
+
|
|
147
|
+
export interface QuarantineLogger {
|
|
148
|
+
info(obj: Record<string, unknown>, msg: string): void;
|
|
149
|
+
warn(obj: Record<string, unknown>, msg: string): void;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Writes the record a message becomes when the sync path cannot apply it
|
|
154
|
+
* (issue #72), and loads the set a round filters against.
|
|
155
|
+
*
|
|
156
|
+
* Only a message defect reaches this class. Deciding that is the caller's job
|
|
157
|
+
* and it is made at exactly one kind of catch site — one narrow enough that
|
|
158
|
+
* the error can only have come from the message itself. An S3, queue or
|
|
159
|
+
* database failure propagates; writing one here would advance a cursor past
|
|
160
|
+
* mail that is fine and tell the user it was unreadable.
|
|
161
|
+
*/
|
|
162
|
+
export class QuarantineService {
|
|
163
|
+
constructor(
|
|
164
|
+
private readonly repository: IQuarantineRepository,
|
|
165
|
+
private readonly mailboxSpecialUseService: IMailboxSpecialUseRepository,
|
|
166
|
+
private readonly workerVersion: string,
|
|
167
|
+
private readonly log: QuarantineLogger,
|
|
168
|
+
) {}
|
|
169
|
+
|
|
170
|
+
async load(accountConfigId: string): Promise<QuarantinedUids> {
|
|
171
|
+
return new QuarantinedUids(
|
|
172
|
+
await this.repository.listByAccountConfigId(accountConfigId),
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Set a message aside. Resolves only once the row is durable, because the
|
|
178
|
+
* caller's next act is to let a cursor move past this uid — and a cursor
|
|
179
|
+
* that moves past work no record survives is the silent loss this whole
|
|
180
|
+
* feature exists to end.
|
|
181
|
+
*/
|
|
182
|
+
async record(
|
|
183
|
+
context: QuarantineContext,
|
|
184
|
+
uid: number,
|
|
185
|
+
failure: QuarantineFailure,
|
|
186
|
+
shape: QuarantineMessageShape = EMPTY_SHAPE,
|
|
187
|
+
): Promise<void> {
|
|
188
|
+
// Resolved here rather than per round: a round almost never writes a row,
|
|
189
|
+
// so the lookup belongs on the write and not on the hot path.
|
|
190
|
+
const specialUse = await this.mailboxSpecialUseService.listByMailboxId(
|
|
191
|
+
context.mailboxId,
|
|
192
|
+
);
|
|
193
|
+
const mailboxRole = resolveMailboxRole(
|
|
194
|
+
context.mailboxPath,
|
|
195
|
+
specialUse.map((entry) => entry.specialUse),
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
await this.repository.upsert({
|
|
199
|
+
accountConfigId: context.accountConfigId,
|
|
200
|
+
accountId: context.accountId,
|
|
201
|
+
mailboxId: context.mailboxId,
|
|
202
|
+
uidValidity: context.uidValidity,
|
|
203
|
+
uid,
|
|
204
|
+
...(mailboxRole ? { mailboxRole } : {}),
|
|
205
|
+
mailboxPath: context.mailboxPath,
|
|
206
|
+
quarantinedAt: Date.now(),
|
|
207
|
+
attempts: context.attempts,
|
|
208
|
+
failureStage: failure.stage,
|
|
209
|
+
failureCode: failure.code,
|
|
210
|
+
failureMessage: failure.message,
|
|
211
|
+
...(failure.partPath ? { failurePartPath: failure.partPath } : {}),
|
|
212
|
+
workerVersion: this.workerVersion,
|
|
213
|
+
...shape,
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
this.log.warn(
|
|
217
|
+
{
|
|
218
|
+
mailboxId: context.mailboxId,
|
|
219
|
+
mailboxPath: context.mailboxPath,
|
|
220
|
+
uid,
|
|
221
|
+
uidValidity: context.uidValidity,
|
|
222
|
+
failureStage: failure.stage,
|
|
223
|
+
failureCode: failure.code,
|
|
224
|
+
},
|
|
225
|
+
"Message quarantined; cursor may advance past it",
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
}
|