@remit/imap-worker 0.0.1
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/README.md +100 -0
- package/build.mjs +17 -0
- package/package.json +50 -0
- package/src/account-check.test.ts +122 -0
- package/src/account-check.ts +88 -0
- package/src/body-sync-gate.test.ts +185 -0
- package/src/body-sync-gate.ts +86 -0
- package/src/cli.ts +211 -0
- package/src/connection-scope.test.ts +266 -0
- package/src/connection-scope.ts +335 -0
- package/src/e2e-processor-shim.ts +248 -0
- package/src/emit.test.ts +44 -0
- package/src/emit.ts +142 -0
- package/src/events.ts +221 -0
- package/src/handlers/append-sent-message.ts +163 -0
- package/src/handlers/delete-account-objects.test.ts +81 -0
- package/src/handlers/delete-account-objects.ts +116 -0
- package/src/handlers/empty-trash.ts +136 -0
- package/src/handlers/flag-push.test.ts +25 -0
- package/src/handlers/flag-push.ts +224 -0
- package/src/handlers/mailbox-management.ts +266 -0
- package/src/handlers/mailbox-sync-order.test.ts +93 -0
- package/src/handlers/mailbox-sync-order.ts +65 -0
- package/src/handlers/message-copy.ts +219 -0
- package/src/handlers/message-delete.test.ts +176 -0
- package/src/handlers/message-delete.ts +283 -0
- package/src/handlers/message-move.test.ts +168 -0
- package/src/handlers/message-move.ts +298 -0
- package/src/handlers/placement-move-push.test.ts +234 -0
- package/src/handlers/placement-move-push.ts +434 -0
- package/src/handlers/sync-mailboxes.ts +241 -0
- package/src/handlers/sync-message-body.test.ts +375 -0
- package/src/handlers/sync-message-body.ts +337 -0
- package/src/handlers/sync-messages-deleted-account.test.ts +141 -0
- package/src/handlers/sync-messages.test.ts +204 -0
- package/src/handlers/sync-messages.ts +412 -0
- package/src/handlers/sync-reserved-host.test.ts +97 -0
- package/src/index.test.ts +22 -0
- package/src/index.ts +70 -0
- package/src/poller.ts +49 -0
- package/src/processor.test.ts +58 -0
- package/src/processor.ts +66 -0
- package/src/scheduler/config.test.ts +40 -0
- package/src/scheduler/config.ts +52 -0
- package/src/scheduler/decide-due.test.ts +44 -0
- package/src/scheduler/decide-due.ts +26 -0
- package/src/scheduler/handler.ts +52 -0
- package/src/scheduler/local-runner.ts +76 -0
- package/src/scheduler/run-tick.test.ts +248 -0
- package/src/scheduler/run-tick.ts +141 -0
- package/src/with-oauth-lifecycle-deps.ts +62 -0
- package/src/with-oauth-lifecycle.test.ts +227 -0
- package/src/with-oauth-lifecycle.ts +125 -0
- package/tsconfig.json +8 -0
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A SYNC_MESSAGES trigger can outlive its account: deletion does not purge the
|
|
3
|
+
* already-queued triggers in lockstep, so a trigger can reference an account
|
|
4
|
+
* whose DDB row is gone. The lookup then raises NotFoundError. That error can
|
|
5
|
+
* never succeed on retry, so it would retry to maxReceiveCount and poison the
|
|
6
|
+
* messages DLQ forever (issue #911).
|
|
7
|
+
*
|
|
8
|
+
* Contract under test:
|
|
9
|
+
* - missing account (get throws NotFoundError) -> acked + WARN, never thrown
|
|
10
|
+
* - soft-deleted account (deletedAt set) -> acked, never thrown
|
|
11
|
+
* - healthy account -> proceeds past the gate (the
|
|
12
|
+
* handler does real work and fails without a live IMAP server) — proving a
|
|
13
|
+
* healthy trigger is NOT silently dropped
|
|
14
|
+
* - transient error (a non-NotFound error) -> propagates, so SQS retries
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import assert from "node:assert/strict";
|
|
18
|
+
import { afterEach, describe, it, mock } from "node:test";
|
|
19
|
+
import {
|
|
20
|
+
_resetForTest,
|
|
21
|
+
_setClientForTest,
|
|
22
|
+
type RemitClient,
|
|
23
|
+
} from "@remit/backend/client";
|
|
24
|
+
import type { AccountItem } from "@remit/data-ports";
|
|
25
|
+
import type { Logger } from "@remit/logger-lambda";
|
|
26
|
+
import type { SyncMessagesEvent } from "../events.js";
|
|
27
|
+
import { syncMessages } from "./sync-messages.js";
|
|
28
|
+
|
|
29
|
+
const notFoundError = (message: string): Error => {
|
|
30
|
+
const error = new Error(message);
|
|
31
|
+
error.name = "NotFoundError";
|
|
32
|
+
return error;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const setAccountGet = (get: () => Promise<AccountItem>): void => {
|
|
36
|
+
_setClientForTest({ account: { get } } as unknown as RemitClient);
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
interface WarnRecord {
|
|
40
|
+
fields: Record<string, unknown>;
|
|
41
|
+
message: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const recordingLogger = (warns: WarnRecord[]): Logger => {
|
|
45
|
+
const noop = () => {};
|
|
46
|
+
const log = {
|
|
47
|
+
info: noop,
|
|
48
|
+
warn: (fields: Record<string, unknown>, message: string) => {
|
|
49
|
+
warns.push({ fields, message });
|
|
50
|
+
},
|
|
51
|
+
error: noop,
|
|
52
|
+
debug: noop,
|
|
53
|
+
fatal: noop,
|
|
54
|
+
trace: noop,
|
|
55
|
+
child: () => log,
|
|
56
|
+
} as unknown as Logger;
|
|
57
|
+
return log;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const healthyAccount = (): AccountItem =>
|
|
61
|
+
({
|
|
62
|
+
accountId: "acct-live",
|
|
63
|
+
accountConfigId: "acfg-live",
|
|
64
|
+
connectionState: "authenticated",
|
|
65
|
+
username: "alice@imap.example.com",
|
|
66
|
+
imapHost: "imap.example.com",
|
|
67
|
+
imapPort: 993,
|
|
68
|
+
imapTls: true,
|
|
69
|
+
}) as unknown as AccountItem;
|
|
70
|
+
|
|
71
|
+
const deletedAccount = (): AccountItem =>
|
|
72
|
+
({
|
|
73
|
+
...healthyAccount(),
|
|
74
|
+
accountId: "acct-deleted",
|
|
75
|
+
deletedAt: Date.now(),
|
|
76
|
+
}) as unknown as AccountItem;
|
|
77
|
+
|
|
78
|
+
const event = (accountId: string): SyncMessagesEvent =>
|
|
79
|
+
({
|
|
80
|
+
type: "SYNC_MESSAGES",
|
|
81
|
+
accountId,
|
|
82
|
+
mailboxId: "mbox-1",
|
|
83
|
+
eventId: "evt-1",
|
|
84
|
+
timestamp: 0,
|
|
85
|
+
}) as SyncMessagesEvent;
|
|
86
|
+
|
|
87
|
+
afterEach(() => {
|
|
88
|
+
mock.restoreAll();
|
|
89
|
+
_resetForTest();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe("syncMessages deleted/missing account drop (#911)", () => {
|
|
93
|
+
it("acks and warns when the account no longer exists", async () => {
|
|
94
|
+
setAccountGet(async () => {
|
|
95
|
+
throw notFoundError("Account not found: acct-gone");
|
|
96
|
+
});
|
|
97
|
+
const warns: WarnRecord[] = [];
|
|
98
|
+
|
|
99
|
+
await assert.doesNotReject(() =>
|
|
100
|
+
syncMessages(event("acct-gone"), recordingLogger(warns)),
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
const warn = warns.find((w) =>
|
|
104
|
+
w.message.includes("account no longer exists"),
|
|
105
|
+
);
|
|
106
|
+
assert.ok(warn, "expected a WARN for the missing account");
|
|
107
|
+
assert.equal(warn.fields.accountId, "acct-gone");
|
|
108
|
+
assert.equal(warn.fields.mailboxId, "mbox-1");
|
|
109
|
+
assert.equal(warn.fields.eventId, "evt-1");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("acks a soft-deleted account without throwing", async () => {
|
|
113
|
+
setAccountGet(async () => deletedAccount());
|
|
114
|
+
|
|
115
|
+
await assert.doesNotReject(() =>
|
|
116
|
+
syncMessages(event("acct-deleted"), recordingLogger([])),
|
|
117
|
+
);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("proceeds past the gate for a healthy account (not silently dropped)", async () => {
|
|
121
|
+
setAccountGet(async () => healthyAccount());
|
|
122
|
+
|
|
123
|
+
// No live IMAP server, so a healthy account must reach real work and fail
|
|
124
|
+
// there — the not-found gate must not swallow it as a clean ack.
|
|
125
|
+
await assert.rejects(() =>
|
|
126
|
+
syncMessages(event("acct-live"), recordingLogger([])),
|
|
127
|
+
);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("propagates a transient error so SQS retries", async () => {
|
|
131
|
+
const transient = new Error("ProvisionedThroughputExceededException");
|
|
132
|
+
setAccountGet(async () => {
|
|
133
|
+
throw transient;
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
await assert.rejects(
|
|
137
|
+
() => syncMessages(event("acct-live"), recordingLogger([])),
|
|
138
|
+
transient,
|
|
139
|
+
);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import type {
|
|
4
|
+
AccountItem,
|
|
5
|
+
IMessageFlagPushRepository,
|
|
6
|
+
} from "@remit/data-ports";
|
|
7
|
+
import type { Logger } from "@remit/logger-lambda";
|
|
8
|
+
import { drainPendingFlagPushes } from "./sync-messages.js";
|
|
9
|
+
|
|
10
|
+
const buildLogger = (): {
|
|
11
|
+
log: Logger;
|
|
12
|
+
infos: Array<{ fields: Record<string, unknown>; msg: string }>;
|
|
13
|
+
errors: Array<{ fields: Record<string, unknown>; msg: string }>;
|
|
14
|
+
} => {
|
|
15
|
+
const infos: Array<{ fields: Record<string, unknown>; msg: string }> = [];
|
|
16
|
+
const errors: Array<{ fields: Record<string, unknown>; msg: string }> = [];
|
|
17
|
+
const log = {
|
|
18
|
+
info: (fields: Record<string, unknown>, msg: string) => {
|
|
19
|
+
infos.push({ fields, msg });
|
|
20
|
+
},
|
|
21
|
+
error: (fields: Record<string, unknown>, msg: string) => {
|
|
22
|
+
errors.push({ fields, msg });
|
|
23
|
+
},
|
|
24
|
+
warn: () => {},
|
|
25
|
+
debug: () => {},
|
|
26
|
+
fatal: () => {},
|
|
27
|
+
trace: () => {},
|
|
28
|
+
child: () => log,
|
|
29
|
+
} as unknown as Logger;
|
|
30
|
+
return { log, infos, errors };
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const account = {
|
|
34
|
+
accountId: "acc-1",
|
|
35
|
+
accountConfigId: "acc-cfg-1",
|
|
36
|
+
} as unknown as AccountItem;
|
|
37
|
+
|
|
38
|
+
const marker = (overrides: Partial<Record<string, unknown>> = {}) => ({
|
|
39
|
+
messageId: "msg-1",
|
|
40
|
+
flagName: "\\Seen",
|
|
41
|
+
accountId: "acc-1",
|
|
42
|
+
accountConfigId: "acc-cfg-1",
|
|
43
|
+
mailboxId: "mbx-1",
|
|
44
|
+
operation: "add",
|
|
45
|
+
state: "pending",
|
|
46
|
+
createdAt: 1,
|
|
47
|
+
updatedAt: 1,
|
|
48
|
+
...overrides,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe("drainPendingFlagPushes — periodic per-mailbox re-arm (issue #1273)", () => {
|
|
52
|
+
it("re-emits FLAG_PUSH for every marker stuck in `pending` (crash between local write and enqueue)", async () => {
|
|
53
|
+
const markerService = {
|
|
54
|
+
listByMailboxId: async () => [marker()],
|
|
55
|
+
} as unknown as IMessageFlagPushRepository;
|
|
56
|
+
|
|
57
|
+
const emitted: unknown[] = [];
|
|
58
|
+
const { log } = buildLogger();
|
|
59
|
+
|
|
60
|
+
await drainPendingFlagPushes(
|
|
61
|
+
markerService,
|
|
62
|
+
account,
|
|
63
|
+
"mbx-1",
|
|
64
|
+
log,
|
|
65
|
+
async (event) => {
|
|
66
|
+
emitted.push(event);
|
|
67
|
+
},
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
assert.equal(emitted.length, 1);
|
|
71
|
+
assert.deepEqual(emitted[0], {
|
|
72
|
+
type: "FLAG_PUSH",
|
|
73
|
+
accountId: "acc-1",
|
|
74
|
+
accountConfigId: "acc-cfg-1",
|
|
75
|
+
messageId: "msg-1",
|
|
76
|
+
flagName: "\\Seen",
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("does NOT re-arm markers already queued or processing — a live driver already owns them", async () => {
|
|
81
|
+
const markerService = {
|
|
82
|
+
listByMailboxId: async () => [
|
|
83
|
+
marker({ messageId: "queued-msg", state: "queued" }),
|
|
84
|
+
marker({ messageId: "processing-msg", state: "processing" }),
|
|
85
|
+
],
|
|
86
|
+
} as unknown as IMessageFlagPushRepository;
|
|
87
|
+
|
|
88
|
+
const emitted: unknown[] = [];
|
|
89
|
+
const { log } = buildLogger();
|
|
90
|
+
|
|
91
|
+
await drainPendingFlagPushes(
|
|
92
|
+
markerService,
|
|
93
|
+
account,
|
|
94
|
+
"mbx-1",
|
|
95
|
+
log,
|
|
96
|
+
async (event) => {
|
|
97
|
+
emitted.push(event);
|
|
98
|
+
},
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
assert.equal(emitted.length, 0);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("is a no-op when no markers exist for the mailbox", async () => {
|
|
105
|
+
const markerService = {
|
|
106
|
+
listByMailboxId: async () => [],
|
|
107
|
+
} as unknown as IMessageFlagPushRepository;
|
|
108
|
+
|
|
109
|
+
const emitted: unknown[] = [];
|
|
110
|
+
const { log, infos } = buildLogger();
|
|
111
|
+
|
|
112
|
+
await drainPendingFlagPushes(
|
|
113
|
+
markerService,
|
|
114
|
+
account,
|
|
115
|
+
"mbx-1",
|
|
116
|
+
log,
|
|
117
|
+
async (event) => {
|
|
118
|
+
emitted.push(event);
|
|
119
|
+
},
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
assert.equal(emitted.length, 0);
|
|
123
|
+
assert.equal(infos.length, 0);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("re-arms multiple stuck markers for the same mailbox (per-field, independent)", async () => {
|
|
127
|
+
const markerService = {
|
|
128
|
+
listByMailboxId: async () => [
|
|
129
|
+
marker({ flagName: "\\Seen" }),
|
|
130
|
+
marker({ flagName: "\\Flagged" }),
|
|
131
|
+
],
|
|
132
|
+
} as unknown as IMessageFlagPushRepository;
|
|
133
|
+
|
|
134
|
+
const emitted: unknown[] = [];
|
|
135
|
+
const { log } = buildLogger();
|
|
136
|
+
|
|
137
|
+
await drainPendingFlagPushes(
|
|
138
|
+
markerService,
|
|
139
|
+
account,
|
|
140
|
+
"mbx-1",
|
|
141
|
+
log,
|
|
142
|
+
async (event) => {
|
|
143
|
+
emitted.push(event);
|
|
144
|
+
},
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
assert.equal(emitted.length, 2);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("a re-arm (SQS) failure is caught per-marker and logged loudly — never thrown", async () => {
|
|
151
|
+
const markerService = {
|
|
152
|
+
listByMailboxId: async () => [marker()],
|
|
153
|
+
} as unknown as IMessageFlagPushRepository;
|
|
154
|
+
|
|
155
|
+
const { log, errors } = buildLogger();
|
|
156
|
+
|
|
157
|
+
await assert.doesNotReject(
|
|
158
|
+
drainPendingFlagPushes(markerService, account, "mbx-1", log, async () => {
|
|
159
|
+
throw Object.assign(new Error("queue down"), { code: "ECONNREFUSED" });
|
|
160
|
+
}),
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
const alerted = errors.find(
|
|
164
|
+
(e) => e.fields.alert === "flag_push_drain_rearm_failed",
|
|
165
|
+
);
|
|
166
|
+
assert.ok(
|
|
167
|
+
alerted,
|
|
168
|
+
"expected an alertable flag_push_drain_rearm_failed log",
|
|
169
|
+
);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("a re-arm failure for one marker does not stop the others from being re-armed", async () => {
|
|
173
|
+
const markerService = {
|
|
174
|
+
listByMailboxId: async () => [
|
|
175
|
+
marker({ messageId: "will-fail", flagName: "\\Seen" }),
|
|
176
|
+
marker({ messageId: "will-succeed", flagName: "\\Flagged" }),
|
|
177
|
+
],
|
|
178
|
+
} as unknown as IMessageFlagPushRepository;
|
|
179
|
+
|
|
180
|
+
const emitted: unknown[] = [];
|
|
181
|
+
const { log } = buildLogger();
|
|
182
|
+
|
|
183
|
+
await drainPendingFlagPushes(
|
|
184
|
+
markerService,
|
|
185
|
+
account,
|
|
186
|
+
"mbx-1",
|
|
187
|
+
log,
|
|
188
|
+
async (event) => {
|
|
189
|
+
if (
|
|
190
|
+
(event as unknown as { messageId: string }).messageId === "will-fail"
|
|
191
|
+
) {
|
|
192
|
+
throw new Error("queue down");
|
|
193
|
+
}
|
|
194
|
+
emitted.push(event);
|
|
195
|
+
},
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
assert.equal(emitted.length, 1);
|
|
199
|
+
assert.equal(
|
|
200
|
+
(emitted[0] as { messageId: string }).messageId,
|
|
201
|
+
"will-succeed",
|
|
202
|
+
);
|
|
203
|
+
});
|
|
204
|
+
});
|