@remit/imap-worker 0.0.90 → 0.0.92
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/dist/account-check.d.ts +27 -0
- package/dist/account-check.js +70 -0
- package/dist/body-sync-gate.d.ts +12 -0
- package/dist/body-sync-gate.js +53 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +193 -0
- package/dist/config-env.d.ts +5 -0
- package/dist/config-env.js +37 -0
- package/dist/connection-pool.d.ts +22 -0
- package/dist/connection-pool.js +97 -0
- package/dist/connection-scope.d.ts +8 -0
- package/dist/connection-scope.js +10 -0
- package/dist/context.d.ts +46 -0
- package/dist/context.js +15 -0
- package/dist/e2e-processor-shim.d.ts +2 -0
- package/dist/e2e-processor-shim.js +181 -0
- package/dist/emit.d.ts +15 -0
- package/dist/emit.js +76 -0
- package/dist/events.d.ts +203 -0
- package/dist/events.js +1 -0
- package/dist/failure-kind.d.ts +10 -0
- package/{src/failure-kind.ts → dist/failure-kind.js} +6 -6
- package/dist/handlers/append-sent-message.d.ts +3 -0
- package/dist/handlers/append-sent-message.js +152 -0
- package/dist/handlers/delete-account-objects.d.ts +3 -0
- package/dist/handlers/delete-account-objects.js +35 -0
- package/dist/handlers/empty-trash.d.ts +14 -0
- package/dist/handlers/empty-trash.js +217 -0
- package/dist/handlers/flag-push.d.ts +38 -0
- package/dist/handlers/flag-push.js +195 -0
- package/dist/handlers/imported-folder.d.ts +4 -0
- package/dist/handlers/imported-folder.js +23 -0
- package/dist/handlers/mailbox-management.d.ts +6 -0
- package/dist/handlers/mailbox-management.js +294 -0
- package/dist/handlers/mailbox-sync-order.d.ts +21 -0
- package/dist/handlers/mailbox-sync-order.js +50 -0
- package/dist/handlers/message-copy.d.ts +40 -0
- package/dist/handlers/message-copy.js +371 -0
- package/dist/handlers/message-delete-terminal.d.ts +60 -0
- package/dist/handlers/message-delete-terminal.js +77 -0
- package/dist/handlers/message-delete.d.ts +57 -0
- package/dist/handlers/message-delete.js +520 -0
- package/{src/handlers/message-move-terminal.ts → dist/handlers/message-move-terminal.d.ts} +18 -105
- package/dist/handlers/message-move-terminal.js +94 -0
- package/dist/handlers/message-move.d.ts +146 -0
- package/dist/handlers/message-move.js +490 -0
- package/dist/handlers/placement-move-push.d.ts +62 -0
- package/dist/handlers/placement-move-push.js +307 -0
- package/dist/handlers/probe-message-id.d.ts +2 -0
- package/dist/handlers/probe-message-id.js +6 -0
- package/dist/handlers/sync-mailboxes.d.ts +45 -0
- package/dist/handlers/sync-mailboxes.js +233 -0
- package/dist/handlers/sync-message-body.d.ts +23 -0
- package/dist/handlers/sync-message-body.js +182 -0
- package/dist/handlers/sync-messages.d.ts +29 -0
- package/dist/handlers/sync-messages.js +285 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +67 -0
- package/dist/lambda-context.d.ts +8 -0
- package/dist/lambda-context.js +33 -0
- package/dist/mailbox-row.d.ts +9 -0
- package/dist/mailbox-row.js +6 -0
- package/dist/poller.d.ts +1 -0
- package/dist/poller.js +51 -0
- package/dist/process.d.ts +6 -0
- package/dist/process.js +3 -0
- package/dist/processor.d.ts +3 -0
- package/dist/processor.js +44 -0
- package/dist/queues.d.ts +10 -0
- package/dist/queues.js +1 -0
- package/dist/s3-account-objects.d.ts +3 -0
- package/dist/s3-account-objects.js +26 -0
- package/dist/scheduler/attachment-sweep.d.ts +11 -0
- package/dist/scheduler/attachment-sweep.js +23 -0
- package/dist/scheduler/calendar-subscriptions.d.ts +22 -0
- package/dist/scheduler/calendar-subscriptions.js +35 -0
- package/dist/scheduler/config.d.ts +29 -0
- package/{src/scheduler/config.ts → dist/scheduler/config.js} +9 -28
- package/{src/scheduler/decide-due.ts → dist/scheduler/decide-due.d.ts} +3 -12
- package/dist/scheduler/decide-due.js +17 -0
- package/dist/scheduler/handler.d.ts +21 -0
- package/dist/scheduler/handler.js +50 -0
- package/{src/scheduler/loop.ts → dist/scheduler/loop.d.ts} +7 -23
- package/dist/scheduler/loop.js +8 -0
- package/dist/scheduler/run-tick.d.ts +66 -0
- package/dist/scheduler/run-tick.js +164 -0
- package/dist/scheduler/runner.d.ts +2 -0
- package/dist/scheduler/runner.js +102 -0
- package/dist/scheduler/stranded-placement-redrive.d.ts +16 -0
- package/dist/scheduler/stranded-placement-redrive.js +172 -0
- package/dist/scheduler/tick.d.ts +5 -0
- package/dist/scheduler/tick.js +5 -0
- package/dist/sqs-queues.d.ts +4 -0
- package/dist/sqs-queues.js +22 -0
- package/dist/with-oauth-lifecycle-deps.d.ts +5 -0
- package/dist/with-oauth-lifecycle-deps.js +17 -0
- package/dist/with-oauth-lifecycle.d.ts +56 -0
- package/dist/with-oauth-lifecycle.js +92 -0
- package/{src/worker-version.ts → dist/worker-version.d.ts} +1 -2
- package/dist/worker-version.js +12 -0
- package/package.json +79 -69
- package/src/account-check.test.ts +0 -149
- package/src/account-check.ts +0 -101
- package/src/body-sync-gate.test.ts +0 -185
- package/src/body-sync-gate.ts +0 -86
- package/src/cli.ts +0 -218
- package/src/connection-scope.test.ts +0 -266
- package/src/connection-scope.ts +0 -314
- package/src/credential-less-sync-fence.test.ts +0 -152
- package/src/e2e-processor-shim.ts +0 -232
- package/src/emit.test.ts +0 -145
- package/src/emit.ts +0 -115
- package/src/events.ts +0 -252
- package/src/failure-kind.test.ts +0 -42
- package/src/handlers/append-sent-message.sqlite.test.ts +0 -176
- package/src/handlers/append-sent-message.ts +0 -264
- package/src/handlers/delete-account-objects.test.ts +0 -126
- package/src/handlers/delete-account-objects.ts +0 -96
- package/src/handlers/empty-trash.sqlite.test.ts +0 -209
- package/src/handlers/empty-trash.ts +0 -352
- package/src/handlers/flag-push.ts +0 -285
- package/src/handlers/imported-folder.test.ts +0 -63
- package/src/handlers/imported-folder.ts +0 -38
- package/src/handlers/mailbox-management.sqlite.test.ts +0 -265
- package/src/handlers/mailbox-management.ts +0 -486
- package/src/handlers/mailbox-sync-order.test.ts +0 -93
- package/src/handlers/mailbox-sync-order.ts +0 -65
- package/src/handlers/message-copy.sqlite.test.ts +0 -298
- package/src/handlers/message-copy.ts +0 -569
- package/src/handlers/message-delete-terminal.ts +0 -147
- package/src/handlers/message-delete.sqlite.test.ts +0 -291
- package/src/handlers/message-delete.ts +0 -888
- package/src/handlers/message-move.sqlite.test.ts +0 -271
- package/src/handlers/message-move.test.ts +0 -216
- package/src/handlers/message-move.ts +0 -755
- package/src/handlers/placement-move-push.test.ts +0 -137
- package/src/handlers/placement-move-push.ts +0 -478
- package/src/handlers/placement-push.sqlite.test.ts +0 -299
- package/src/handlers/probe-message-id.sqlite.test.ts +0 -192
- package/src/handlers/probe-message-id.ts +0 -15
- package/src/handlers/sync-mailboxes.test.ts +0 -183
- package/src/handlers/sync-mailboxes.ts +0 -425
- package/src/handlers/sync-message-body.sqlite.test.ts +0 -314
- package/src/handlers/sync-message-body.test.ts +0 -150
- package/src/handlers/sync-message-body.ts +0 -366
- package/src/handlers/sync-messages.sqlite.test.ts +0 -195
- package/src/handlers/sync-messages.ts +0 -537
- package/src/index.test.ts +0 -22
- package/src/index.ts +0 -82
- package/src/mail-sync-selection.sqlite.test.ts +0 -227
- package/src/mailbox-row.ts +0 -20
- package/src/pending-sync.sqlite.test.ts +0 -165
- package/src/poller.ts +0 -55
- package/src/processor.ts +0 -62
- package/src/queues.ts +0 -40
- package/src/scheduler/attachment-sweep.sqlite.test.ts +0 -74
- package/src/scheduler/attachment-sweep.ts +0 -46
- package/src/scheduler/calendar-subscriptions.sqlite.test.ts +0 -147
- package/src/scheduler/calendar-subscriptions.ts +0 -103
- package/src/scheduler/config.test.ts +0 -40
- package/src/scheduler/decide-due.test.ts +0 -44
- package/src/scheduler/half-deleted-accounts.sqlite.test.ts +0 -196
- package/src/scheduler/handler.ts +0 -69
- package/src/scheduler/loop.test.ts +0 -143
- package/src/scheduler/run-tick.sqlite.test.ts +0 -191
- package/src/scheduler/run-tick.ts +0 -281
- package/src/scheduler/runner.ts +0 -125
- package/src/scheduler/stranded-placement-redrive.ts +0 -265
- package/src/stranded-placement.sqlite.test.ts +0 -799
- package/src/test-helpers/test-world.ts +0 -425
- package/src/test-helpers/worker-queues.ts +0 -11
- package/src/thread-join.sqlite.test.ts +0 -106
- package/src/with-oauth-lifecycle-deps.sqlite.test.ts +0 -269
- package/src/with-oauth-lifecycle-deps.ts +0 -71
- package/src/with-oauth-lifecycle.ts +0 -158
- package/tsconfig.json +0 -8
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import cluster from "node:cluster";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { DeleteMessageCommand, ReceiveMessageCommand, } from "@aws-sdk/client-sqs";
|
|
5
|
+
import { createLogger } from "@remit/logger";
|
|
6
|
+
import { installNodeLogger } from "@remit/logger-lambda/node";
|
|
7
|
+
import { createSqsClient } from "@remit/sqs-client/producer";
|
|
8
|
+
import { env } from "expect-env";
|
|
9
|
+
import { handler } from "./index.js";
|
|
10
|
+
installNodeLogger();
|
|
11
|
+
/**
|
|
12
|
+
* E2E-only queue drainer.
|
|
13
|
+
*
|
|
14
|
+
* Production binds the Lambda `handler` (`src/index.ts`) to each queue via an
|
|
15
|
+
* SQS event-source mapping. The e2e/CI stack runs on ElasticMQ, which has no
|
|
16
|
+
* event-source mapping, so this process supplies that missing piece: it
|
|
17
|
+
* long-polls each queue, wraps every received batch in an `SQSEvent`, and
|
|
18
|
+
* invokes the exact production `handler`. It then honours the returned
|
|
19
|
+
* `batchItemFailures` the way the SQS service would — deleting the messages
|
|
20
|
+
* that succeeded and leaving the failures un-deleted so their visibility
|
|
21
|
+
* timeout lapses and SQS redelivers them. No processing or failure logic lives
|
|
22
|
+
* here; the prod handler owns all of it.
|
|
23
|
+
*
|
|
24
|
+
* This is a test harness, not production code. If it crashes the e2e suite
|
|
25
|
+
* fails loudly, which is the desired signal — there is no crash net.
|
|
26
|
+
*/
|
|
27
|
+
// Collect all unique queue URLs to poll. Every required queue URL crashes at
|
|
28
|
+
// init via expect-env instead of silently dropping queues.
|
|
29
|
+
const queueUrls = [
|
|
30
|
+
...new Set([
|
|
31
|
+
// FIFO queues for sync operations
|
|
32
|
+
env.SQS_QUEUE_URL_MAILBOXES,
|
|
33
|
+
env.SQS_QUEUE_URL_MESSAGES,
|
|
34
|
+
env.SQS_QUEUE_URL_FLAGS,
|
|
35
|
+
// Standard body queue (#612) + management queues
|
|
36
|
+
env.SQS_QUEUE_URL_BODY,
|
|
37
|
+
env.SQS_QUEUE_URL_MAILBOX_MGMT,
|
|
38
|
+
env.SQS_QUEUE_URL_MESSAGE_MGMT,
|
|
39
|
+
]),
|
|
40
|
+
];
|
|
41
|
+
if (cluster.isPrimary) {
|
|
42
|
+
// Primary process: fork a worker for each queue
|
|
43
|
+
const log = createLogger();
|
|
44
|
+
log.info({ queueUrls, workerCount: queueUrls.length }, "Primary started");
|
|
45
|
+
// Track which queue each worker handles for restart
|
|
46
|
+
const workerQueues = new Map();
|
|
47
|
+
// Fork a worker for each queue
|
|
48
|
+
for (const queueUrl of queueUrls) {
|
|
49
|
+
const worker = cluster.fork({ WORKER_QUEUE_URL: queueUrl });
|
|
50
|
+
workerQueues.set(worker.id, queueUrl);
|
|
51
|
+
const queueName = new URL(queueUrl).pathname.split("/").pop();
|
|
52
|
+
log.info({ workerId: worker.id, queueName }, "Forked worker for queue");
|
|
53
|
+
}
|
|
54
|
+
// Restart workers that crash
|
|
55
|
+
cluster.on("exit", (worker, code, signal) => {
|
|
56
|
+
const queueUrl = workerQueues.get(worker.id);
|
|
57
|
+
const queueName = queueUrl
|
|
58
|
+
? new URL(queueUrl).pathname.split("/").pop()
|
|
59
|
+
: "unknown";
|
|
60
|
+
if (signal) {
|
|
61
|
+
log.info({ workerId: worker.id, queueName, signal }, "Worker killed");
|
|
62
|
+
}
|
|
63
|
+
else if (code !== 0) {
|
|
64
|
+
log.error({ workerId: worker.id, queueName, code }, "Worker crashed, exiting primary");
|
|
65
|
+
process.exit(1);
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
log.info({ workerId: worker.id, queueName }, "Worker exited cleanly");
|
|
69
|
+
workerQueues.delete(worker.id);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
// Graceful shutdown: signal all workers
|
|
73
|
+
const shutdown = () => {
|
|
74
|
+
log.info("Primary received shutdown signal, stopping workers...");
|
|
75
|
+
for (const worker of Object.values(cluster.workers ?? {})) {
|
|
76
|
+
worker?.process.kill("SIGTERM");
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
process.on("SIGINT", shutdown);
|
|
80
|
+
process.on("SIGTERM", shutdown);
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
// Worker process: poll a single queue
|
|
84
|
+
const queueUrl = env.WORKER_QUEUE_URL;
|
|
85
|
+
const queueName = new URL(queueUrl).pathname.split("/").pop();
|
|
86
|
+
const log = createLogger().child({ queue: queueName });
|
|
87
|
+
const maxMessages = 10; // SQS API limit
|
|
88
|
+
const sqs = createSqsClient({ queueUrl });
|
|
89
|
+
let isShuttingDown = false;
|
|
90
|
+
process.on("SIGINT", () => {
|
|
91
|
+
log.info("Worker received SIGINT, shutting down...");
|
|
92
|
+
isShuttingDown = true;
|
|
93
|
+
});
|
|
94
|
+
process.on("SIGTERM", () => {
|
|
95
|
+
log.info("Worker received SIGTERM, shutting down...");
|
|
96
|
+
isShuttingDown = true;
|
|
97
|
+
});
|
|
98
|
+
// Minimal Lambda Context: `withTelemetry` reads `functionName` for the line it
|
|
99
|
+
// logs per invocation and `awsRequestId` for the scope it opens around it, so
|
|
100
|
+
// every line one batch produces shares a `requestId` (deploy/vps/README.md,
|
|
101
|
+
// "Logs"). Minted per batch, like the production poller does — a context built
|
|
102
|
+
// once would put the whole run under one id, which correlates nothing.
|
|
103
|
+
const nextContext = () => ({
|
|
104
|
+
functionName: `e2e-imap-worker-${queueName}`,
|
|
105
|
+
awsRequestId: randomUUID(),
|
|
106
|
+
});
|
|
107
|
+
const pollQueue = async () => {
|
|
108
|
+
log.info({ maxMessages }, "Worker started, polling...");
|
|
109
|
+
let consecutiveEmptyPolls = 0;
|
|
110
|
+
while (!isShuttingDown) {
|
|
111
|
+
// Use short polling when we just processed messages (likely more waiting)
|
|
112
|
+
// Use long polling after empty polls to reduce API calls
|
|
113
|
+
const waitTime = consecutiveEmptyPolls > 0 ? 20 : 0;
|
|
114
|
+
const response = await sqs.send(new ReceiveMessageCommand({
|
|
115
|
+
QueueUrl: queueUrl,
|
|
116
|
+
MaxNumberOfMessages: maxMessages,
|
|
117
|
+
WaitTimeSeconds: waitTime,
|
|
118
|
+
VisibilityTimeout: 300,
|
|
119
|
+
MessageSystemAttributeNames: ["ApproximateReceiveCount"],
|
|
120
|
+
}));
|
|
121
|
+
if (!response.Messages || response.Messages.length === 0) {
|
|
122
|
+
consecutiveEmptyPolls++;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
consecutiveEmptyPolls = 0;
|
|
126
|
+
const messages = response.Messages.flatMap((m) => m.Body && m.ReceiptHandle && m.MessageId
|
|
127
|
+
? [
|
|
128
|
+
{
|
|
129
|
+
messageId: m.MessageId,
|
|
130
|
+
receiptHandle: m.ReceiptHandle,
|
|
131
|
+
body: m.Body,
|
|
132
|
+
receiveCount: m.Attributes?.ApproximateReceiveCount ?? "1",
|
|
133
|
+
},
|
|
134
|
+
]
|
|
135
|
+
: []);
|
|
136
|
+
if (messages.length === 0) {
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
log.info({ count: messages.length }, "Invoking handler for batch");
|
|
140
|
+
const event = {
|
|
141
|
+
Records: messages.map((m) => ({
|
|
142
|
+
messageId: m.messageId,
|
|
143
|
+
receiptHandle: m.receiptHandle,
|
|
144
|
+
body: m.body,
|
|
145
|
+
attributes: {
|
|
146
|
+
ApproximateReceiveCount: m.receiveCount,
|
|
147
|
+
SentTimestamp: "0",
|
|
148
|
+
SenderId: "e2e",
|
|
149
|
+
ApproximateFirstReceiveTimestamp: "0",
|
|
150
|
+
},
|
|
151
|
+
messageAttributes: {},
|
|
152
|
+
md5OfBody: "",
|
|
153
|
+
eventSource: "aws:sqs",
|
|
154
|
+
eventSourceARN: queueUrl,
|
|
155
|
+
awsRegion: "local",
|
|
156
|
+
})),
|
|
157
|
+
};
|
|
158
|
+
const result = (await handler(event, nextContext(), () => { }));
|
|
159
|
+
const failedIds = new Set((result?.batchItemFailures ?? []).map((f) => f.itemIdentifier));
|
|
160
|
+
// Mirror SQS partial-batch-failure semantics: delete the messages the
|
|
161
|
+
// handler reported as succeeded; leave failures un-deleted so their
|
|
162
|
+
// visibility timeout lapses and SQS redelivers them (and eventually
|
|
163
|
+
// dead-letters once maxReceiveCount is hit).
|
|
164
|
+
const succeeded = messages.filter((m) => !failedIds.has(m.messageId));
|
|
165
|
+
for (const message of succeeded) {
|
|
166
|
+
await sqs.send(new DeleteMessageCommand({
|
|
167
|
+
QueueUrl: queueUrl,
|
|
168
|
+
ReceiptHandle: message.receiptHandle,
|
|
169
|
+
}));
|
|
170
|
+
}
|
|
171
|
+
log.info({ deleted: succeeded.length, leftForRedelivery: failedIds.size }, "Batch processed");
|
|
172
|
+
}
|
|
173
|
+
log.info("Worker polling stopped");
|
|
174
|
+
};
|
|
175
|
+
pollQueue()
|
|
176
|
+
.then(() => process.exit(0))
|
|
177
|
+
.catch((error) => {
|
|
178
|
+
log.error({ error }, "Worker error");
|
|
179
|
+
process.exit(1);
|
|
180
|
+
});
|
|
181
|
+
}
|
package/dist/emit.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { IMailboxLockRepository } from "@remit/data-ports";
|
|
2
|
+
import type { ProcessContext, WorkerMetrics } from "./context.js";
|
|
3
|
+
import type { ImapEvent } from "./events.js";
|
|
4
|
+
import type { WorkerQueues } from "./queues.js";
|
|
5
|
+
type EventInput = Omit<ImapEvent, "eventId" | "timestamp">;
|
|
6
|
+
export type EmitEvent = (event: EventInput) => Promise<void>;
|
|
7
|
+
export declare const SYNC_MESSAGES_PENDING = "SYNC_MESSAGES_PENDING";
|
|
8
|
+
export interface EmitDeps {
|
|
9
|
+
queues: WorkerQueues;
|
|
10
|
+
mailboxLock: IMailboxLockRepository;
|
|
11
|
+
metrics: WorkerMetrics;
|
|
12
|
+
}
|
|
13
|
+
export declare const createEmitEvent: ({ queues, mailboxLock, metrics }: EmitDeps) => EmitEvent;
|
|
14
|
+
export declare const emitFrom: (ctx: Pick<ProcessContext, "queues" | "client" | "metrics">) => EmitEvent;
|
|
15
|
+
export {};
|
package/dist/emit.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// The deduplication id is the event's own id, so the queue suppresses a
|
|
2
|
+
// re-send of one event (the retry SQS's own idempotency guard is for) and
|
|
3
|
+
// nothing else. A shared id per account or per mailbox instead made the
|
|
4
|
+
// 5-minute window a rate limiter: the second sync of a mailbox within five
|
|
5
|
+
// minutes was discarded before any worker saw it, so mail that arrived after
|
|
6
|
+
// a sync could not be fetched until the window elapsed (issue #37).
|
|
7
|
+
const fifo = (queue) => (event) => queue.send({
|
|
8
|
+
body: event,
|
|
9
|
+
groupId: event.accountId,
|
|
10
|
+
deduplicationId: event.eventId,
|
|
11
|
+
});
|
|
12
|
+
const standard = (queue) => (event) => queue.send({ body: event });
|
|
13
|
+
export const SYNC_MESSAGES_PENDING = "SYNC_MESSAGES_PENDING";
|
|
14
|
+
const SYNC_MESSAGES_PENDING_TTL_SECONDS = 300;
|
|
15
|
+
const senderFor = (type, queues) => {
|
|
16
|
+
switch (type) {
|
|
17
|
+
case "SYNC_MAILBOXES":
|
|
18
|
+
return fifo(queues.mailboxes);
|
|
19
|
+
case "SYNC_MESSAGES":
|
|
20
|
+
return fifo(queues.messages);
|
|
21
|
+
// SYNC_MESSAGE_BODY routes to the single standard body queue (#612).
|
|
22
|
+
case "SYNC_MESSAGE_BODY":
|
|
23
|
+
return standard(queues.body);
|
|
24
|
+
case "MAILBOX_CREATE":
|
|
25
|
+
case "MAILBOX_RENAME":
|
|
26
|
+
case "MAILBOX_DELETE":
|
|
27
|
+
return standard(queues.mailboxManagement);
|
|
28
|
+
case "MESSAGE_DELETE":
|
|
29
|
+
case "MESSAGE_MOVE":
|
|
30
|
+
case "MESSAGE_COPY":
|
|
31
|
+
case "EMPTY_TRASH":
|
|
32
|
+
case "APPEND_SENT_MESSAGE":
|
|
33
|
+
// Rides messageMgmtQueue (issue #1271) rather than a dedicated queue — its
|
|
34
|
+
// payload carries only our message id (never a UID, unlike the legacy
|
|
35
|
+
// MESSAGE_MOVE event); per-event-type payload shape, same queue.
|
|
36
|
+
case "PLACEMENT_MOVE_PUSH":
|
|
37
|
+
return standard(queues.messageManagement);
|
|
38
|
+
// Rides the existing flags queue (issue #1273) — this is imap-worker's OWN
|
|
39
|
+
// re-arm hint (the periodic per-mailbox sync tick catching up a marker
|
|
40
|
+
// stuck `pending`), distinct from `FlagPushService`'s user-facing hint
|
|
41
|
+
// sent from the API onto the sync queue. Both land on a queue this worker
|
|
42
|
+
// already consumes; dispatch is by `type`, not by which queue delivered
|
|
43
|
+
// the message.
|
|
44
|
+
case "FLAG_PUSH":
|
|
45
|
+
return fifo(queues.flags);
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
export const createEmitEvent = ({ queues, mailboxLock, metrics }) => async (event) => {
|
|
49
|
+
const fullEvent = {
|
|
50
|
+
...event,
|
|
51
|
+
eventId: crypto.randomUUID(),
|
|
52
|
+
timestamp: Date.now(),
|
|
53
|
+
};
|
|
54
|
+
const send = () => senderFor(fullEvent.type, queues)(fullEvent);
|
|
55
|
+
if (fullEvent.type !== "SYNC_MESSAGES") {
|
|
56
|
+
await send();
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const { accountId, mailboxId, eventId } = fullEvent;
|
|
60
|
+
const previous = await mailboxLock.get(accountId, mailboxId, SYNC_MESSAGES_PENDING);
|
|
61
|
+
const acquired = await mailboxLock.tryAcquireLock(mailboxId, SYNC_MESSAGES_PENDING, accountId, eventId, SYNC_MESSAGES_PENDING_TTL_SECONDS);
|
|
62
|
+
if (!acquired)
|
|
63
|
+
return;
|
|
64
|
+
if (previous && previous.ttl < Math.floor(Date.now() / 1000)) {
|
|
65
|
+
metrics.imapFailure("SYNC_PENDING_EXPIRED", "other", 1);
|
|
66
|
+
}
|
|
67
|
+
await send().catch(async (error) => {
|
|
68
|
+
await mailboxLock.releaseLock(accountId, mailboxId, SYNC_MESSAGES_PENDING, eventId);
|
|
69
|
+
throw error;
|
|
70
|
+
});
|
|
71
|
+
};
|
|
72
|
+
export const emitFrom = (ctx) => createEmitEvent({
|
|
73
|
+
queues: ctx.queues,
|
|
74
|
+
mailboxLock: ctx.client.mailboxLock,
|
|
75
|
+
metrics: ctx.metrics,
|
|
76
|
+
});
|
package/dist/events.d.ts
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import type { MUTATION_EVENT_SCHEMA_VERSION, PATHLESS_MUTATION_EVENT_SCHEMA_VERSION } from "@remit/data-ports/mutation-events";
|
|
2
|
+
export interface BaseEvent {
|
|
3
|
+
accountId: string;
|
|
4
|
+
eventId: string;
|
|
5
|
+
timestamp: number;
|
|
6
|
+
}
|
|
7
|
+
export interface SyncMailboxesEvent extends BaseEvent {
|
|
8
|
+
type: "SYNC_MAILBOXES";
|
|
9
|
+
/**
|
|
10
|
+
* Set by POST /sync — the only trigger a person asks for by name. It makes
|
|
11
|
+
* the fan-out sync every mailbox, skipping the freshness gate that keeps
|
|
12
|
+
* incidental triggers (config load, OAuth connect, the scheduled tick) from
|
|
13
|
+
* re-enumerating folders that were just enumerated.
|
|
14
|
+
*/
|
|
15
|
+
explicitRequest?: boolean;
|
|
16
|
+
}
|
|
17
|
+
export interface SyncMessagesEvent extends BaseEvent {
|
|
18
|
+
type: "SYNC_MESSAGES";
|
|
19
|
+
mailboxId: string;
|
|
20
|
+
fullSync?: boolean;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* A message to body-sync, carrying the UID resolved at envelope-sync time so
|
|
24
|
+
* the consumer can issue one ranged FETCH without a per-message DDB lookup.
|
|
25
|
+
*/
|
|
26
|
+
export interface SyncMessageBodyTarget {
|
|
27
|
+
messageId: string;
|
|
28
|
+
uid: number;
|
|
29
|
+
}
|
|
30
|
+
export interface SyncMessageBodyEvent extends BaseEvent {
|
|
31
|
+
type: "SYNC_MESSAGE_BODY";
|
|
32
|
+
mailboxId: string;
|
|
33
|
+
/**
|
|
34
|
+
* Message ids to sync. Always present for backward compatibility; the
|
|
35
|
+
* consumer falls back to this list (looking up each UID) when `messages`
|
|
36
|
+
* is absent.
|
|
37
|
+
*/
|
|
38
|
+
messageIds: string[];
|
|
39
|
+
/**
|
|
40
|
+
* Preferred shape: messageId+uid pairs. When present, the consumer skips the
|
|
41
|
+
* per-message UID lookup and fetches the whole batch in one ranged FETCH.
|
|
42
|
+
* Optional so older in-flight events (ids only) still process.
|
|
43
|
+
*/
|
|
44
|
+
messages?: SyncMessageBodyTarget[];
|
|
45
|
+
/**
|
|
46
|
+
* Set only by the read-miss re-arm cue (`BodySyncQueueService.requestBodySync`,
|
|
47
|
+
* called when a `/content` read finds `bodyStorageKey` set but the storage
|
|
48
|
+
* object missing). Tells the consumer to bypass the "already stored" skip
|
|
49
|
+
* guard and re-fetch + rewrite the body even though the DB row already
|
|
50
|
+
* carries a `bodyStorageKey` — otherwise stale metadata (key set, object
|
|
51
|
+
* gone) makes the read-miss retry loop forever, since the bulk skip guard
|
|
52
|
+
* would keep treating the message as already synced. Optional so older
|
|
53
|
+
* in-flight events (no force) keep the original skip behavior.
|
|
54
|
+
*/
|
|
55
|
+
force?: boolean;
|
|
56
|
+
}
|
|
57
|
+
export interface MailboxCreateEvent extends BaseEvent {
|
|
58
|
+
type: "MAILBOX_CREATE";
|
|
59
|
+
mailboxId: string;
|
|
60
|
+
path: string;
|
|
61
|
+
subscribe?: boolean;
|
|
62
|
+
}
|
|
63
|
+
export interface MailboxRenameEvent extends BaseEvent {
|
|
64
|
+
type: "MAILBOX_RENAME";
|
|
65
|
+
mailboxId: string;
|
|
66
|
+
oldPath: string;
|
|
67
|
+
newPath: string;
|
|
68
|
+
}
|
|
69
|
+
export interface MailboxDeleteEvent extends BaseEvent {
|
|
70
|
+
type: "MAILBOX_DELETE";
|
|
71
|
+
mailboxId: string;
|
|
72
|
+
path: string;
|
|
73
|
+
}
|
|
74
|
+
export type MailboxManagementEvent = MailboxCreateEvent | MailboxRenameEvent | MailboxDeleteEvent;
|
|
75
|
+
/**
|
|
76
|
+
* Event for deleting a message (move to trash or permanent delete).
|
|
77
|
+
*/
|
|
78
|
+
interface MessageDeleteEventFields extends BaseEvent {
|
|
79
|
+
type: "MESSAGE_DELETE";
|
|
80
|
+
messageId: string;
|
|
81
|
+
mailboxId: string;
|
|
82
|
+
uid: number;
|
|
83
|
+
operation: "move_to_trash" | "permanent_delete";
|
|
84
|
+
destinationMailboxId?: string;
|
|
85
|
+
}
|
|
86
|
+
export interface MessageDeleteEventV2 extends MessageDeleteEventFields {
|
|
87
|
+
schemaVersion: typeof MUTATION_EVENT_SCHEMA_VERSION;
|
|
88
|
+
mailboxPath: string;
|
|
89
|
+
destinationMailboxPath?: string;
|
|
90
|
+
}
|
|
91
|
+
export interface MessageDeleteEventV3 extends MessageDeleteEventFields {
|
|
92
|
+
schemaVersion: typeof PATHLESS_MUTATION_EVENT_SCHEMA_VERSION;
|
|
93
|
+
}
|
|
94
|
+
export type MessageDeleteEvent = MessageDeleteEventV2 | MessageDeleteEventV3;
|
|
95
|
+
/**
|
|
96
|
+
* Event for moving a message to another mailbox.
|
|
97
|
+
*/
|
|
98
|
+
interface MessageMoveEventFields extends BaseEvent {
|
|
99
|
+
type: "MESSAGE_MOVE";
|
|
100
|
+
messageId: string;
|
|
101
|
+
sourceMailboxId: string;
|
|
102
|
+
destinationMailboxId: string;
|
|
103
|
+
uid: number;
|
|
104
|
+
}
|
|
105
|
+
export interface MessageMoveEventV1 extends MessageMoveEventFields {
|
|
106
|
+
schemaVersion?: undefined;
|
|
107
|
+
sourceMailboxPath: string;
|
|
108
|
+
destinationMailboxPath: string;
|
|
109
|
+
}
|
|
110
|
+
export interface MessageMoveEventV3 extends MessageMoveEventFields {
|
|
111
|
+
schemaVersion: typeof PATHLESS_MUTATION_EVENT_SCHEMA_VERSION;
|
|
112
|
+
}
|
|
113
|
+
export type MessageMoveEvent = MessageMoveEventV1 | MessageMoveEventV3;
|
|
114
|
+
/**
|
|
115
|
+
* Event for emptying the Trash mailbox.
|
|
116
|
+
*/
|
|
117
|
+
interface EmptyTrashEventFields extends BaseEvent {
|
|
118
|
+
type: "EMPTY_TRASH";
|
|
119
|
+
trashMailboxId: string;
|
|
120
|
+
/**
|
|
121
|
+
* The Trash folder's UIDVALIDITY at the moment the user consented. The
|
|
122
|
+
* handler compares it against what the SELECT serves: a path reused by a
|
|
123
|
+
* different folder (rename plus recreate) answers with a different value,
|
|
124
|
+
* and this is the only comparison the mutating connection cannot race.
|
|
125
|
+
*/
|
|
126
|
+
trashUidValidity: number;
|
|
127
|
+
}
|
|
128
|
+
export interface EmptyTrashEventV2 extends EmptyTrashEventFields {
|
|
129
|
+
schemaVersion: typeof MUTATION_EVENT_SCHEMA_VERSION;
|
|
130
|
+
trashMailboxPath: string;
|
|
131
|
+
}
|
|
132
|
+
export interface EmptyTrashEventV3 extends EmptyTrashEventFields {
|
|
133
|
+
schemaVersion: typeof PATHLESS_MUTATION_EVENT_SCHEMA_VERSION;
|
|
134
|
+
}
|
|
135
|
+
export type EmptyTrashEvent = EmptyTrashEventV2 | EmptyTrashEventV3;
|
|
136
|
+
/**
|
|
137
|
+
* Event for copying a message to another mailbox.
|
|
138
|
+
*/
|
|
139
|
+
interface MessageCopyEventFields extends BaseEvent {
|
|
140
|
+
type: "MESSAGE_COPY";
|
|
141
|
+
sourceMessageId: string;
|
|
142
|
+
newMessageId: string;
|
|
143
|
+
sourceMailboxId: string;
|
|
144
|
+
destinationMailboxId: string;
|
|
145
|
+
uid: number;
|
|
146
|
+
}
|
|
147
|
+
export interface MessageCopyEventV1 extends MessageCopyEventFields {
|
|
148
|
+
schemaVersion?: undefined;
|
|
149
|
+
sourceMailboxPath: string;
|
|
150
|
+
destinationMailboxPath: string;
|
|
151
|
+
}
|
|
152
|
+
export interface MessageCopyEventV3 extends MessageCopyEventFields {
|
|
153
|
+
schemaVersion: typeof PATHLESS_MUTATION_EVENT_SCHEMA_VERSION;
|
|
154
|
+
}
|
|
155
|
+
export type MessageCopyEvent = MessageCopyEventV1 | MessageCopyEventV3;
|
|
156
|
+
/**
|
|
157
|
+
* Event for appending a sent message to the Sent mailbox via IMAP APPEND.
|
|
158
|
+
*/
|
|
159
|
+
export interface AppendSentMessageEvent extends BaseEvent {
|
|
160
|
+
type: "APPEND_SENT_MESSAGE";
|
|
161
|
+
outboxMessageId: string;
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Drains one pending placement-move marker (issue #1271, epic #1281) to
|
|
165
|
+
* IMAP. Deliberately carries no UID and no destination — both are resolved
|
|
166
|
+
* fresh from the Message row and the `MessagePlacementMove` marker at push
|
|
167
|
+
* time (epic invariant 1), so this event stays valid across any amount of
|
|
168
|
+
* queue delay or an unrelated UIDVALIDITY rebuild (#1272). If the marker is
|
|
169
|
+
* gone by the time this is processed (confirmed, superseded, or an external
|
|
170
|
+
* delete already reconciled it), the handler is a no-op.
|
|
171
|
+
*/
|
|
172
|
+
export interface PlacementMovePushEvent extends BaseEvent {
|
|
173
|
+
type: "PLACEMENT_MOVE_PUSH";
|
|
174
|
+
accountConfigId: string;
|
|
175
|
+
messageId: string;
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Wake-up hint for one pending flag-push marker (issue #1273, epic #1281).
|
|
179
|
+
* Carries only our message id + the flag field — never a UID and never the
|
|
180
|
+
* desired add/remove transition (both live on the `MessageFlagPush` marker,
|
|
181
|
+
* resolved fresh at push time, epic invariant 1). Produced two ways: by
|
|
182
|
+
* `FlagPushService` (remit-mailbox-service) right after a user's flag flip
|
|
183
|
+
* commits locally, and by the periodic per-mailbox sync tick
|
|
184
|
+
* (`sync-messages.ts`) re-arming any marker it finds still stuck `pending`
|
|
185
|
+
* (a prior enqueue that never landed). If the marker is gone or has moved
|
|
186
|
+
* past `pending` by the time this is processed, the handler is a no-op.
|
|
187
|
+
*/
|
|
188
|
+
export interface FlagPushEvent extends BaseEvent {
|
|
189
|
+
type: "FLAG_PUSH";
|
|
190
|
+
accountConfigId: string;
|
|
191
|
+
messageId: string;
|
|
192
|
+
flagName: string;
|
|
193
|
+
}
|
|
194
|
+
export interface DeleteAccountObjectsEvent {
|
|
195
|
+
type: "DELETE_ACCOUNT_OBJECTS";
|
|
196
|
+
accountConfigId: string;
|
|
197
|
+
continuationToken?: string;
|
|
198
|
+
}
|
|
199
|
+
/** Union of all event types the worker can process (including non-IMAP ones). */
|
|
200
|
+
export type WorkerEvent = ImapEvent | DeleteAccountObjectsEvent;
|
|
201
|
+
export type MessageManagementEvent = MessageDeleteEvent | MessageMoveEvent | EmptyTrashEvent | MessageCopyEvent;
|
|
202
|
+
export type ImapEvent = SyncMailboxesEvent | SyncMessagesEvent | SyncMessageBodyEvent | MailboxManagementEvent | MessageManagementEvent | AppendSentMessageEvent | PlacementMovePushEvent | FlagPushEvent;
|
|
203
|
+
export {};
|
package/dist/events.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { ImapFailureKind } from "./context.js";
|
|
2
|
+
/**
|
|
3
|
+
* Which class of failure ended an IMAP operation, for the exported failure
|
|
4
|
+
* counter (standalone-observability D3). `auth` is its own kind because it is
|
|
5
|
+
* the one class that never resolves itself — an expired OAuth grant or a
|
|
6
|
+
* changed password fails identically forever, and it is the most common way a
|
|
7
|
+
* self-hosted mailbox goes quiet. A refresh that cannot mint a token is the
|
|
8
|
+
* same condition arriving one layer earlier.
|
|
9
|
+
*/
|
|
10
|
+
export declare const imapFailureKind: (error: unknown) => ImapFailureKind;
|
|
@@ -1,7 +1,5 @@
|
|
|
1
|
-
import type { MailFailureKind } from "@remit/logger-lambda/metrics";
|
|
2
1
|
import { RefreshTokenError } from "@remit/mail-oauth-service";
|
|
3
2
|
import { MailConnectionError } from "@remit/mailbox-service";
|
|
4
|
-
|
|
5
3
|
/**
|
|
6
4
|
* Which class of failure ended an IMAP operation, for the exported failure
|
|
7
5
|
* counter (standalone-observability D3). `auth` is its own kind because it is
|
|
@@ -10,8 +8,10 @@ import { MailConnectionError } from "@remit/mailbox-service";
|
|
|
10
8
|
* self-hosted mailbox goes quiet. A refresh that cannot mint a token is the
|
|
11
9
|
* same condition arriving one layer earlier.
|
|
12
10
|
*/
|
|
13
|
-
export const imapFailureKind = (error
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
11
|
+
export const imapFailureKind = (error) => {
|
|
12
|
+
if (error instanceof RefreshTokenError)
|
|
13
|
+
return "auth";
|
|
14
|
+
if (error instanceof MailConnectionError)
|
|
15
|
+
return error.kind;
|
|
16
|
+
return "other";
|
|
17
17
|
};
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { APPENDED_UID_NONE, APPENDED_UID_UNREPORTED, isSentCopyFiled, } from "@remit/data-ports";
|
|
2
|
+
import { isNotFoundError } from "@remit/data-ports/errors";
|
|
3
|
+
import { OutboxMessageStatus } from "@remit/domain-enums";
|
|
4
|
+
import { OutboxAttachmentUnavailableError } from "@remit/mailbox-service/outbox-attachment-content";
|
|
5
|
+
import { buildMailMessage, renderRawMessage, } from "@remit/smtp-service/message-builder";
|
|
6
|
+
import { isAccountDeleted } from "../account-check.js";
|
|
7
|
+
import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
|
|
8
|
+
import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
|
|
9
|
+
const UNFILED_NO_SENT_MAILBOX = "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.";
|
|
10
|
+
const UNFILED_SIGNED_OUT = "Sent, but not filed: this account has to be signed in again before a copy can be stored in Sent.";
|
|
11
|
+
const ROW_SURVIVED_ITS_DELETE = "Sent message was filed but its outbox row survived its delete and stays hidden until the boot-time repair";
|
|
12
|
+
const unfiledAppendRefused = (fullPath, error) => error instanceof OutboxAttachmentUnavailableError
|
|
13
|
+
? `Sent, but not filed: the copy for ${fullPath} could not be built because ${error.message}.`
|
|
14
|
+
: `Sent, but not filed: the mail server refused to store a copy in ${fullPath} (${error instanceof Error ? error.message : String(error)}).`;
|
|
15
|
+
export const handleAppendSentMessage = async (event, ctx, receiveCount) => {
|
|
16
|
+
const { log, pool, config } = ctx;
|
|
17
|
+
const { account: accountService, outboxMessage: outboxMessageService, outboxAttachment: outboxAttachmentService, mailboxSpecialUse: mailboxSpecialUseService, secrets, } = ctx.client;
|
|
18
|
+
const { accountId, outboxMessageId } = event;
|
|
19
|
+
log.info({ event: event.type, accountId, outboxMessageId }, "Handling event");
|
|
20
|
+
const account = await accountService.get(accountId);
|
|
21
|
+
if (isAccountDeleted(account, log)) {
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
// A row this event names can be gone by the time the event is redelivered:
|
|
25
|
+
// its own last attempt deleted it, or the boot repair dropped it on the
|
|
26
|
+
// strength of a recorded uid. Either way the work is done, and re-throwing
|
|
27
|
+
// the same NotFoundError on every redelivery only dead-letters it.
|
|
28
|
+
const outbox = await outboxMessageService
|
|
29
|
+
.get(account.accountConfigId, outboxMessageId)
|
|
30
|
+
.catch((error) => {
|
|
31
|
+
if (isNotFoundError(error))
|
|
32
|
+
return null;
|
|
33
|
+
throw error;
|
|
34
|
+
});
|
|
35
|
+
if (!outbox) {
|
|
36
|
+
log.warn({ accountId, outboxMessageId }, "Skipping APPEND_SENT_MESSAGE: the outbox row is already gone");
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (outbox.status !== OutboxMessageStatus.sent) {
|
|
40
|
+
log.info({ outboxMessageId, status: outbox.status }, "Outbox message not in sent status, skipping APPEND");
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
// The message left over SMTP; only the filing can still fail. Settling the
|
|
44
|
+
// row as `unfiled` keeps it in the Outbox list rather than deleting it, so a
|
|
45
|
+
// delivered message stays readable somewhere. Every path out of this handler
|
|
46
|
+
// that is not a confirmed APPEND ends here.
|
|
47
|
+
const settleUnfiled = async (reason) => {
|
|
48
|
+
log.error({ accountId, outboxMessageId, reason }, "Sent message could not be filed, settling the outbox row as unfiled");
|
|
49
|
+
await outboxMessageService.update(account.accountConfigId, outboxMessageId, {
|
|
50
|
+
status: OutboxMessageStatus.unfiled,
|
|
51
|
+
lastError: reason,
|
|
52
|
+
});
|
|
53
|
+
};
|
|
54
|
+
// The copy is in Sent, so the row is a leftover and deleting it is all that
|
|
55
|
+
// is left to do. Both steps are idempotent against a row that is already
|
|
56
|
+
// gone, so nothing here needs to know how far the last attempt got.
|
|
57
|
+
//
|
|
58
|
+
// Files first, row second, the same order outbox-queue.ts discards a draft
|
|
59
|
+
// in. Nothing but this row points at those objects: a row that outlives its
|
|
60
|
+
// files is hidden and the boot repair drops it, while files that outlive
|
|
61
|
+
// their row name a message nothing can reach and are vouched for forever.
|
|
62
|
+
const dropOutboxRow = async () => {
|
|
63
|
+
await outboxAttachmentService.discardAll(account.accountConfigId, accountId, outboxMessageId);
|
|
64
|
+
await outboxMessageService.delete(account.accountConfigId, outboxMessageId);
|
|
65
|
+
log.info({ outboxMessageId }, "Deleted outbox row after successful APPEND to Sent");
|
|
66
|
+
};
|
|
67
|
+
// Everything this recovers from happened after the copy reached Sent, so
|
|
68
|
+
// re-appending is off the table and the delete is the only step left to
|
|
69
|
+
// retry. Below the redrive budget that retry is worth having; at it the
|
|
70
|
+
// record would dead-letter, so the row is left at `sent` for the repair.
|
|
71
|
+
const retryDropWithinBudget = (error) => {
|
|
72
|
+
if (receiveCount < config.attempts.appendSent)
|
|
73
|
+
throw error;
|
|
74
|
+
log.error({ accountId, outboxMessageId, error }, ROW_SURVIVED_ITS_DELETE);
|
|
75
|
+
};
|
|
76
|
+
// A redelivery of an event whose APPEND already landed. The copy is in the
|
|
77
|
+
// user's Sent folder and the only step still owed is the delete that failed
|
|
78
|
+
// last time; starting from the top would file a second copy of a message the
|
|
79
|
+
// user sent once (#858). Ahead of the Sent-mailbox lookup on purpose — a
|
|
80
|
+
// message that is already filed does not need one, and an account that lost
|
|
81
|
+
// its Sent appointment in between must not settle as unfiled.
|
|
82
|
+
if (isSentCopyFiled(outbox)) {
|
|
83
|
+
log.info({ outboxMessageId, appendedUid: outbox.appendedUid }, "Sent copy was already filed by an earlier attempt, skipping the APPEND");
|
|
84
|
+
await dropOutboxRow().catch(retryDropWithinBudget);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const sentMailbox = await mailboxSpecialUseService.findSentMailbox(accountId);
|
|
88
|
+
if (!sentMailbox) {
|
|
89
|
+
await settleUnfiled(UNFILED_NO_SENT_MAILBOX);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
let appendedUid = APPENDED_UID_NONE;
|
|
93
|
+
const failure = await withOAuthLifecycle(buildLifecycleDeps(secrets, accountService, ctx.oauth), account, log, async (credentials) => {
|
|
94
|
+
// Inside the retried unit: a file that cannot be read is retried below
|
|
95
|
+
// the budget and settles `unfiled` at it, rather than dead-lettering a
|
|
96
|
+
// delivered message out of every view.
|
|
97
|
+
const attachments = await outboxAttachmentService.contentsFor(account.accountConfigId, accountId, outboxMessageId);
|
|
98
|
+
const scope = pool.borrow(account, credentials);
|
|
99
|
+
await scope
|
|
100
|
+
.getConnection()
|
|
101
|
+
.then(async (connection) => {
|
|
102
|
+
const rawMessage = await renderRawMessage(buildMailMessage(outbox, attachments));
|
|
103
|
+
const result = await connection.append(sentMailbox.fullPath, rawMessage, ["\\Seen"]);
|
|
104
|
+
log.info({
|
|
105
|
+
outboxMessageId,
|
|
106
|
+
sentMailbox: sentMailbox.fullPath,
|
|
107
|
+
uid: result.uid,
|
|
108
|
+
uidValidity: result.uidValidity,
|
|
109
|
+
}, "Appended sent message to Sent mailbox");
|
|
110
|
+
// A server without UIDPLUS files the copy and names no uid for
|
|
111
|
+
// it, which is still a filed copy and has to read as one.
|
|
112
|
+
appendedUid = result.uid > 0 ? result.uid : APPENDED_UID_UNREPORTED;
|
|
113
|
+
})
|
|
114
|
+
.finally(() => scope.release());
|
|
115
|
+
// The idempotency key, and the only reason a redelivery is safe. It is
|
|
116
|
+
// written before the delete and never after it: from here on every step
|
|
117
|
+
// can fail and be retried without the user gaining a second copy, and
|
|
118
|
+
// the one window that still can is this single row update.
|
|
119
|
+
await outboxMessageService.update(account.accountConfigId, outboxMessageId, { appendedUid });
|
|
120
|
+
// The message now lives in the IMAP Sent folder. Drop the outbox row so
|
|
121
|
+
// the user does not see it twice in the UI (Outbox + Sent). Issue #178.
|
|
122
|
+
await dropOutboxRow();
|
|
123
|
+
}).then(() => null, (error) => {
|
|
124
|
+
// Below the redrive budget this is an ordinary retry — connections
|
|
125
|
+
// drop, servers go away, and the row is still `sent` for the next
|
|
126
|
+
// attempt to pick up. At the budget the record would dead-letter, and a
|
|
127
|
+
// dead-lettered APPEND is exactly how a delivered message goes missing.
|
|
128
|
+
//
|
|
129
|
+
// The budget still binds after a landed APPEND, though no longer to
|
|
130
|
+
// hold off a duplicate: the recorded uid does that, and the retries
|
|
131
|
+
// the budget allows re-drive the delete alone.
|
|
132
|
+
if (receiveCount < config.attempts.appendSent)
|
|
133
|
+
throw error;
|
|
134
|
+
return error;
|
|
135
|
+
});
|
|
136
|
+
// The APPEND landed: the copy is in Sent whatever failed after it, so there
|
|
137
|
+
// is nothing to settle as unfiled. A row that outlives its delete holds
|
|
138
|
+
// `sent`, which every view hides, until the migrator's boot-time stranded-row
|
|
139
|
+
// repair drops it — the next container start, not sooner (#824).
|
|
140
|
+
if (isSentCopyFiled({ appendedUid })) {
|
|
141
|
+
if (failure) {
|
|
142
|
+
log.error({ accountId, outboxMessageId, reason: String(failure) }, ROW_SURVIVED_ITS_DELETE);
|
|
143
|
+
}
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
// A terminal auth failure returns here without throwing — withOAuthLifecycle
|
|
147
|
+
// flips the account to reauth_required and ACKs the record, which without
|
|
148
|
+
// this would leave the row at `sent` and the message nowhere.
|
|
149
|
+
await settleUnfiled(failure
|
|
150
|
+
? unfiledAppendRefused(sentMailbox.fullPath, failure)
|
|
151
|
+
: UNFILED_SIGNED_OUT);
|
|
152
|
+
};
|