@cosmicdrift/kumiko-bundled-features 0.141.0 → 0.142.0
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 +13 -7
- package/src/inbound-mail-foundation/__tests__/feature.test.ts +169 -0
- package/src/inbound-mail-foundation/__tests__/inbound-mail-foundation.integration.test.ts +437 -0
- package/src/inbound-mail-foundation/aggregate-id.ts +40 -0
- package/src/inbound-mail-foundation/connect-routes.ts +230 -0
- package/src/inbound-mail-foundation/constants.ts +80 -0
- package/src/inbound-mail-foundation/db/queries/inbound-projections.ts +38 -0
- package/src/inbound-mail-foundation/entities.ts +168 -0
- package/src/inbound-mail-foundation/events.ts +147 -0
- package/src/inbound-mail-foundation/feature.ts +185 -0
- package/src/inbound-mail-foundation/handlers/account-state.ts +23 -0
- package/src/inbound-mail-foundation/handlers/connect-account.write.ts +112 -0
- package/src/inbound-mail-foundation/handlers/disconnect-account.write.ts +69 -0
- package/src/inbound-mail-foundation/handlers/ingest-message.write.ts +279 -0
- package/src/inbound-mail-foundation/handlers/list-accounts.query.ts +44 -0
- package/src/inbound-mail-foundation/handlers/list-messages.query.ts +58 -0
- package/src/inbound-mail-foundation/handlers/scope-visibility.ts +14 -0
- package/src/inbound-mail-foundation/handlers/update-account.write.ts +78 -0
- package/src/inbound-mail-foundation/index.ts +97 -0
- package/src/inbound-mail-foundation/oauth-state.ts +103 -0
- package/src/inbound-mail-foundation/projection.ts +152 -0
- package/src/inbound-mail-foundation/provider-factory.ts +53 -0
- package/src/inbound-mail-foundation/tenant-destroy-hook.ts +93 -0
- package/src/inbound-mail-foundation/types.ts +250 -0
- package/src/inbound-mail-foundation/watch-supervisor.ts +482 -0
- package/src/inbound-provider-imap/__tests__/feature.test.ts +115 -0
- package/src/inbound-provider-imap/__tests__/imap-live.integration.test.ts +194 -0
- package/src/inbound-provider-imap/credential-document.ts +51 -0
- package/src/inbound-provider-imap/feature.ts +287 -0
- package/src/inbound-provider-imap/imap-client.ts +158 -0
- package/src/inbound-provider-imap/index.ts +17 -0
- package/src/inbound-provider-inmemory/feature.ts +154 -0
- package/src/inbound-provider-inmemory/index.ts +12 -0
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
// createInboundMailSupervisor — der langlebige Sync-Prozess der
|
|
2
|
+
// Foundation: startet pro aktivem Account `plugin.watch()` (IMAP IDLE,
|
|
3
|
+
// Push in Sekunden) mit Reconnect-Backoff und fährt zusätzlich den
|
|
4
|
+
// periodischen Reconciliation-Poll (Default 5 min) über `plugin.fetch()`.
|
|
5
|
+
// Dedup im ingest-Handler macht Watch/Poll-Überschneidung idempotent —
|
|
6
|
+
// der Poll ist Korrektheits-Anker, watch nur Latenz-Optimierung.
|
|
7
|
+
//
|
|
8
|
+
// **Plan-Abweichung (dokumentiert):** Der Plan sah den Poll als
|
|
9
|
+
// `r.job`-Cron vor. JobContext hat aber KEINEN Dispatcher (verifiziert:
|
|
10
|
+
// run-export-jobs.ts "Worker-AppContext hat kein queryAs/write") — der
|
|
11
|
+
// ingest MUSS durch den Standard-Write-Handler (ES-Executor, PII,
|
|
12
|
+
// Idempotency). Deshalb ist der Supervisor eine app-verdrahtete
|
|
13
|
+
// Komponente mit `dispatchSystemWrite` in den Deps, exakt wie
|
|
14
|
+
// billing-foundation's webhook-handler. Der App-Owner startet ihn in
|
|
15
|
+
// bin/server.ts:
|
|
16
|
+
//
|
|
17
|
+
// const supervisor = createInboundMailSupervisor({
|
|
18
|
+
// providerCtx: { registry: deps.registry, secrets },
|
|
19
|
+
// db,
|
|
20
|
+
// dispatchWrite: ({ handlerQn, payload, tenantId }) =>
|
|
21
|
+
// deps.dispatchSystemWrite({ handlerQn, payload, tenantId: tenantId as TenantId }),
|
|
22
|
+
// });
|
|
23
|
+
// await supervisor.start();
|
|
24
|
+
// // shutdown-hook: await supervisor.stop();
|
|
25
|
+
//
|
|
26
|
+
// **Betriebsrisiko IDLE (Plan §7.3):** langlebige Sockets im Prozess.
|
|
27
|
+
// Mitigation hier: Backoff-Restart via onError, sauberes stop() aller
|
|
28
|
+
// Watcher beim Shutdown, Poll fängt jede Lücke.
|
|
29
|
+
|
|
30
|
+
import { fetchOne, insertOne, selectMany, updateMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
31
|
+
import {
|
|
32
|
+
configuredPiiSubjectKms,
|
|
33
|
+
decryptPiiFieldValues,
|
|
34
|
+
} from "@cosmicdrift/kumiko-framework/crypto";
|
|
35
|
+
import type { DbConnection, EntityTableMeta } from "@cosmicdrift/kumiko-framework/db";
|
|
36
|
+
import { Temporal } from "temporal-polyfill";
|
|
37
|
+
import { InboundMailAccountStatuses, InboundMailFoundationHandlers } from "./constants";
|
|
38
|
+
import { MAIL_ACCOUNT_PII_FIELDS, syncCursorTable } from "./entities";
|
|
39
|
+
import { mailAccountsProjectionTable } from "./projection";
|
|
40
|
+
import { resolveInboundProviderForAccount } from "./provider-factory";
|
|
41
|
+
import {
|
|
42
|
+
type InboundMailContext,
|
|
43
|
+
type InboundMailProviderPlugin,
|
|
44
|
+
isInboundAuthError,
|
|
45
|
+
isInboundCursorInvalidError,
|
|
46
|
+
isInboundRateLimitError,
|
|
47
|
+
type MailAccountRecord,
|
|
48
|
+
type RawInboundMessage,
|
|
49
|
+
type SyncCursorPayload,
|
|
50
|
+
} from "./types";
|
|
51
|
+
|
|
52
|
+
const DEFAULT_POLL_INTERVAL_MS = 5 * 60 * 1000;
|
|
53
|
+
const DEFAULT_BACKFILL_WINDOW_DAYS = 30;
|
|
54
|
+
const DEFAULT_MAX_MESSAGES_PER_POLL = 200;
|
|
55
|
+
const DEFAULT_WATCH_BACKOFF_INITIAL_MS = 5_000;
|
|
56
|
+
const DEFAULT_WATCH_BACKOFF_MAX_MS = 5 * 60 * 1000;
|
|
57
|
+
/** V1: ein Cursor pro Account (eine Mailbox-Inbox). Multi-Folder später
|
|
58
|
+
* über weitere scopes ohne Schema-Änderung. */
|
|
59
|
+
const CURSOR_SCOPE = "default";
|
|
60
|
+
|
|
61
|
+
export type InboundMailSupervisorDeps = {
|
|
62
|
+
/** Slim-Context für Provider-Calls (registry Pflicht, secrets für
|
|
63
|
+
* Credential-Reads der Provider). */
|
|
64
|
+
readonly providerCtx: InboundMailContext;
|
|
65
|
+
/** App-DB — direct reads auf read_mail_accounts (Account-Snapshot)
|
|
66
|
+
* + direct writes auf read_mail_sync_cursors (unmanaged store). */
|
|
67
|
+
readonly db: DbConnection;
|
|
68
|
+
/** Standard-Dispatcher mit SystemUser — trägt ingest-message +
|
|
69
|
+
* update-account. */
|
|
70
|
+
readonly dispatchWrite: (args: {
|
|
71
|
+
readonly handlerQn: string;
|
|
72
|
+
readonly payload: unknown;
|
|
73
|
+
readonly tenantId: string;
|
|
74
|
+
}) => Promise<{
|
|
75
|
+
readonly isSuccess: boolean;
|
|
76
|
+
readonly data?: unknown;
|
|
77
|
+
readonly error?: unknown;
|
|
78
|
+
}>;
|
|
79
|
+
/** Persisted den raw MIME-Body (file-foundation) und liefert den
|
|
80
|
+
* bodyRef fürs Event. Fehlt der Hook, läuft snippet-only-Mode
|
|
81
|
+
* (bodyRef = ""). */
|
|
82
|
+
readonly storeBody?: (account: MailAccountRecord, msg: RawInboundMessage) => Promise<string>;
|
|
83
|
+
readonly pollIntervalMs?: number;
|
|
84
|
+
readonly backfillWindowDays?: number;
|
|
85
|
+
readonly maxMessagesPerPoll?: number;
|
|
86
|
+
readonly watchBackoffInitialMs?: number;
|
|
87
|
+
readonly watchBackoffMaxMs?: number;
|
|
88
|
+
readonly log?: (line: string) => void;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
type WatcherState = {
|
|
92
|
+
stop: (() => Promise<void>) | null;
|
|
93
|
+
backoffMs: number;
|
|
94
|
+
restartTimer: ReturnType<typeof setTimeout> | null;
|
|
95
|
+
/** Bump beim stop() — verhindert dass ein nachzügelnder Restart einen
|
|
96
|
+
* bereits gestoppten Watcher wiederbelebt. */
|
|
97
|
+
generation: number;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
export type InboundMailSupervisor = {
|
|
101
|
+
readonly start: () => Promise<void>;
|
|
102
|
+
/** Ein Reconciliation-Durchlauf über alle aktiven Accounts — auch
|
|
103
|
+
* standalone nutzbar (Tests, manueller Ops-Trigger). */
|
|
104
|
+
readonly pollOnce: () => Promise<void>;
|
|
105
|
+
readonly stop: () => Promise<void>;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
export function createInboundMailSupervisor(
|
|
109
|
+
deps: InboundMailSupervisorDeps,
|
|
110
|
+
): InboundMailSupervisor {
|
|
111
|
+
const pollIntervalMs = deps.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
112
|
+
const backfillWindowDays = deps.backfillWindowDays ?? DEFAULT_BACKFILL_WINDOW_DAYS;
|
|
113
|
+
const maxMessagesPerPoll = deps.maxMessagesPerPoll ?? DEFAULT_MAX_MESSAGES_PER_POLL;
|
|
114
|
+
const backoffInitialMs = deps.watchBackoffInitialMs ?? DEFAULT_WATCH_BACKOFF_INITIAL_MS;
|
|
115
|
+
const backoffMaxMs = deps.watchBackoffMaxMs ?? DEFAULT_WATCH_BACKOFF_MAX_MS;
|
|
116
|
+
const log = deps.log ?? (() => {});
|
|
117
|
+
|
|
118
|
+
let running = false;
|
|
119
|
+
let pollTimer: ReturnType<typeof setTimeout> | null = null;
|
|
120
|
+
let pollInFlight: Promise<void> | null = null;
|
|
121
|
+
const watchers = new Map<string, WatcherState>();
|
|
122
|
+
|
|
123
|
+
// ---------------------------------------------------------------
|
|
124
|
+
// Account-Snapshot: aktive Accounts aller Tenants, address decrypted
|
|
125
|
+
// (Provider arbeiten nie mit Ciphertext).
|
|
126
|
+
// ---------------------------------------------------------------
|
|
127
|
+
async function listActiveAccounts(): Promise<readonly MailAccountRecord[]> {
|
|
128
|
+
const rows = await selectMany(deps.db, mailAccountsProjectionTable, {
|
|
129
|
+
status: InboundMailAccountStatuses.active,
|
|
130
|
+
});
|
|
131
|
+
const piiKms = configuredPiiSubjectKms();
|
|
132
|
+
const records: MailAccountRecord[] = [];
|
|
133
|
+
for (const raw of rows) {
|
|
134
|
+
const row = piiKms
|
|
135
|
+
? await decryptPiiFieldValues(
|
|
136
|
+
raw as Record<string, unknown>,
|
|
137
|
+
MAIL_ACCOUNT_PII_FIELDS,
|
|
138
|
+
piiKms,
|
|
139
|
+
{ requestId: "inbound-mail-foundation:supervisor:list-accounts" },
|
|
140
|
+
)
|
|
141
|
+
: (raw as Record<string, unknown>);
|
|
142
|
+
records.push({
|
|
143
|
+
id: row["id"] as string,
|
|
144
|
+
tenantId: row["tenantId"] as string,
|
|
145
|
+
provider: row["provider"] as string,
|
|
146
|
+
authMethod: row["authMethod"] as string,
|
|
147
|
+
ownerUserId: (row["ownerUserId"] as string | null | undefined) ?? null,
|
|
148
|
+
address: row["address"] as string,
|
|
149
|
+
displayName: (row["displayName"] as string | undefined) ?? "",
|
|
150
|
+
status: InboundMailAccountStatuses.active,
|
|
151
|
+
watchState: (row["watchState"] as string | undefined) ?? "idle",
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
return records;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ---------------------------------------------------------------
|
|
158
|
+
// Cursor-Persistenz (unmanaged direct-write store).
|
|
159
|
+
// ---------------------------------------------------------------
|
|
160
|
+
async function loadCursor(accountId: string): Promise<SyncCursorPayload | null> {
|
|
161
|
+
const row = await fetchOne<{ cursor: string }>(deps.db, syncCursorTable as EntityTableMeta, {
|
|
162
|
+
accountId,
|
|
163
|
+
scope: CURSOR_SCOPE,
|
|
164
|
+
});
|
|
165
|
+
if (!row) return null;
|
|
166
|
+
try {
|
|
167
|
+
return JSON.parse(row.cursor) as SyncCursorPayload; // @cast-boundary eigene JSON-Persistenz
|
|
168
|
+
} catch {
|
|
169
|
+
return null; // korrupter Cursor = wie kein Cursor → Backfill, Dedup fängt Dubletten
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function saveCursor(accountId: string, cursor: SyncCursorPayload): Promise<void> {
|
|
174
|
+
const now = Temporal.Now.instant().toString();
|
|
175
|
+
const serialized = JSON.stringify(cursor);
|
|
176
|
+
const existing = await fetchOne<{ id: string }>(deps.db, syncCursorTable as EntityTableMeta, {
|
|
177
|
+
accountId,
|
|
178
|
+
scope: CURSOR_SCOPE,
|
|
179
|
+
});
|
|
180
|
+
if (existing) {
|
|
181
|
+
await updateMany(
|
|
182
|
+
deps.db,
|
|
183
|
+
syncCursorTable as EntityTableMeta,
|
|
184
|
+
{ cursor: serialized, updatedAt: now },
|
|
185
|
+
{ accountId, scope: CURSOR_SCOPE },
|
|
186
|
+
);
|
|
187
|
+
// skip: update-Pfad fertig — nicht in den insert-Zweig durchfallen.
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
await insertOne(deps.db, syncCursorTable as EntityTableMeta, {
|
|
191
|
+
id: crypto.randomUUID(),
|
|
192
|
+
accountId,
|
|
193
|
+
scope: CURSOR_SCOPE,
|
|
194
|
+
cursor: serialized,
|
|
195
|
+
updatedAt: now,
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function resetCursor(accountId: string): Promise<void> {
|
|
200
|
+
// Kein deleteMany-Import nötig: leerer Cursor-String parsed zu null
|
|
201
|
+
// → nächster fetch läuft als Backfill.
|
|
202
|
+
await updateMany(
|
|
203
|
+
deps.db,
|
|
204
|
+
syncCursorTable as EntityTableMeta,
|
|
205
|
+
{ cursor: "", updatedAt: Temporal.Now.instant().toString() },
|
|
206
|
+
{ accountId, scope: CURSOR_SCOPE },
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ---------------------------------------------------------------
|
|
211
|
+
// Ingest — jede Message durch den Standard-Write-Handler.
|
|
212
|
+
// ---------------------------------------------------------------
|
|
213
|
+
async function ingestBatch(
|
|
214
|
+
account: MailAccountRecord,
|
|
215
|
+
msgs: readonly RawInboundMessage[],
|
|
216
|
+
cursorSnapshot: string,
|
|
217
|
+
): Promise<void> {
|
|
218
|
+
for (const msg of msgs) {
|
|
219
|
+
const bodyRef = deps.storeBody && msg.rawMime ? await deps.storeBody(account, msg) : "";
|
|
220
|
+
const result = await deps.dispatchWrite({
|
|
221
|
+
handlerQn: InboundMailFoundationHandlers.ingestMessage,
|
|
222
|
+
tenantId: account.tenantId,
|
|
223
|
+
payload: {
|
|
224
|
+
accountId: account.id,
|
|
225
|
+
ownerUserId: account.ownerUserId,
|
|
226
|
+
providerName: account.provider,
|
|
227
|
+
providerMessageId: msg.providerMessageId,
|
|
228
|
+
messageIdHeader: msg.messageIdHeader,
|
|
229
|
+
providerThreadId: msg.providerThreadId,
|
|
230
|
+
references: msg.references,
|
|
231
|
+
from: msg.from,
|
|
232
|
+
to: msg.to,
|
|
233
|
+
cc: msg.cc,
|
|
234
|
+
subject: msg.subject,
|
|
235
|
+
snippet: msg.snippet,
|
|
236
|
+
receivedAtIso: msg.receivedAtIso,
|
|
237
|
+
bodyRef,
|
|
238
|
+
scope: msg.scope,
|
|
239
|
+
providerCursor: cursorSnapshot,
|
|
240
|
+
},
|
|
241
|
+
});
|
|
242
|
+
if (!result.isSuccess) {
|
|
243
|
+
// Einzel-Message-Fehler bricht den Batch: Cursor wird NICHT
|
|
244
|
+
// persistiert → nächster Tick re-fetcht ab altem Cursor, Dedup
|
|
245
|
+
// überspringt die bereits verarbeiteten.
|
|
246
|
+
throw new Error(
|
|
247
|
+
`ingest-message failed for account ${account.id}: ${JSON.stringify(result.error ?? {})}`,
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async function markAccount(
|
|
254
|
+
account: MailAccountRecord,
|
|
255
|
+
fields: { status?: string; watchState?: string },
|
|
256
|
+
reason: string,
|
|
257
|
+
): Promise<void> {
|
|
258
|
+
await deps.dispatchWrite({
|
|
259
|
+
handlerQn: InboundMailFoundationHandlers.updateAccount,
|
|
260
|
+
tenantId: account.tenantId,
|
|
261
|
+
payload: { accountId: account.id, ...fields, reason },
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ---------------------------------------------------------------
|
|
266
|
+
// Fehler-Semantik (Plan §2-Tabelle) — geteilt von Poll und Watch.
|
|
267
|
+
// Liefert true wenn der Account weiterlaufen darf.
|
|
268
|
+
// ---------------------------------------------------------------
|
|
269
|
+
async function handleSyncError(account: MailAccountRecord, err: unknown): Promise<boolean> {
|
|
270
|
+
if (isInboundAuthError(err)) {
|
|
271
|
+
log(`inbound-mail: account ${account.id} auth error — needs re-connect`);
|
|
272
|
+
await stopWatcher(account.id);
|
|
273
|
+
await markAccount(
|
|
274
|
+
account,
|
|
275
|
+
{ status: InboundMailAccountStatuses.authError, watchState: "idle" },
|
|
276
|
+
"watch_supervisor",
|
|
277
|
+
);
|
|
278
|
+
return false;
|
|
279
|
+
}
|
|
280
|
+
if (isInboundRateLimitError(err)) {
|
|
281
|
+
log(`inbound-mail: account ${account.id} rate-limited (retryAfter ${err.retryAfterMs}ms)`);
|
|
282
|
+
return true; // nächster Tick versucht es erneut
|
|
283
|
+
}
|
|
284
|
+
if (isInboundCursorInvalidError(err)) {
|
|
285
|
+
log(`inbound-mail: account ${account.id} cursor invalid — full resync in backfill window`);
|
|
286
|
+
await resetCursor(account.id);
|
|
287
|
+
return true;
|
|
288
|
+
}
|
|
289
|
+
// Transient/unbekannt: loggen, nächster Tick retried.
|
|
290
|
+
log(
|
|
291
|
+
`inbound-mail: account ${account.id} sync error: ${err instanceof Error ? err.message : String(err)}`,
|
|
292
|
+
);
|
|
293
|
+
return true;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// ---------------------------------------------------------------
|
|
297
|
+
// Poll (Reconciliation).
|
|
298
|
+
// ---------------------------------------------------------------
|
|
299
|
+
async function pollAccount(
|
|
300
|
+
account: MailAccountRecord,
|
|
301
|
+
plugin: InboundMailProviderPlugin,
|
|
302
|
+
): Promise<void> {
|
|
303
|
+
let cursor = await loadCursor(account.id);
|
|
304
|
+
let budget = maxMessagesPerPoll;
|
|
305
|
+
try {
|
|
306
|
+
// hasMore-Schleife: Pagination innerhalb eines Ticks bis Budget.
|
|
307
|
+
for (;;) {
|
|
308
|
+
const result = await plugin.fetch(deps.providerCtx, account, cursor, {
|
|
309
|
+
backfillWindowDays,
|
|
310
|
+
maxMessages: budget,
|
|
311
|
+
});
|
|
312
|
+
await ingestBatch(account, result.messages, JSON.stringify(result.nextCursor));
|
|
313
|
+
await saveCursor(account.id, result.nextCursor);
|
|
314
|
+
cursor = result.nextCursor;
|
|
315
|
+
budget -= result.messages.length;
|
|
316
|
+
if (!result.hasMore || budget <= 0) break;
|
|
317
|
+
}
|
|
318
|
+
} catch (err) {
|
|
319
|
+
await handleSyncError(account, err);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async function pollOnce(): Promise<void> {
|
|
324
|
+
const accounts = await listActiveAccounts();
|
|
325
|
+
for (const account of accounts) {
|
|
326
|
+
let plugin: InboundMailProviderPlugin;
|
|
327
|
+
try {
|
|
328
|
+
plugin = resolveInboundProviderForAccount(deps.providerCtx, account);
|
|
329
|
+
} catch (err) {
|
|
330
|
+
log(`inbound-mail: ${err instanceof Error ? err.message : String(err)}`);
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
await pollAccount(account, plugin);
|
|
334
|
+
if (running) await ensureWatcher(account, plugin);
|
|
335
|
+
}
|
|
336
|
+
// Accounts die nicht mehr aktiv sind: Watcher abbauen.
|
|
337
|
+
const activeIds = new Set(accounts.map((a) => a.id));
|
|
338
|
+
for (const accountId of watchers.keys()) {
|
|
339
|
+
if (!activeIds.has(accountId)) await stopWatcher(accountId);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// ---------------------------------------------------------------
|
|
344
|
+
// Watch-Lifecycle mit Backoff-Restart.
|
|
345
|
+
// ---------------------------------------------------------------
|
|
346
|
+
async function ensureWatcher(
|
|
347
|
+
account: MailAccountRecord,
|
|
348
|
+
plugin: InboundMailProviderPlugin,
|
|
349
|
+
): Promise<void> {
|
|
350
|
+
// skip: Provider ohne Live-Push — der Reconciliation-Poll deckt den Account ab.
|
|
351
|
+
if (!plugin.watch) return;
|
|
352
|
+
const existing = watchers.get(account.id);
|
|
353
|
+
// skip: Watcher läuft bereits bzw. Restart ist geplant — nichts zu tun.
|
|
354
|
+
if (existing?.stop || existing?.restartTimer) return;
|
|
355
|
+
|
|
356
|
+
const state: WatcherState = existing ?? {
|
|
357
|
+
stop: null,
|
|
358
|
+
backoffMs: backoffInitialMs,
|
|
359
|
+
restartTimer: null,
|
|
360
|
+
generation: 0,
|
|
361
|
+
};
|
|
362
|
+
watchers.set(account.id, state);
|
|
363
|
+
const generation = state.generation;
|
|
364
|
+
|
|
365
|
+
const scheduleRestart = (err: unknown) => {
|
|
366
|
+
// skip: Supervisor gestoppt oder Watcher-Generation gewechselt — Restart wäre ein Zombie.
|
|
367
|
+
if (!running || state.generation !== generation) return;
|
|
368
|
+
state.stop = null;
|
|
369
|
+
const delay = state.backoffMs;
|
|
370
|
+
state.backoffMs = Math.min(state.backoffMs * 2, backoffMaxMs);
|
|
371
|
+
log(
|
|
372
|
+
`inbound-mail: watch for account ${account.id} died (${err instanceof Error ? err.message : String(err)}) — restart in ${delay}ms`,
|
|
373
|
+
);
|
|
374
|
+
void markAccount(account, { watchState: `backoff:${delay}ms` }, "watch_supervisor");
|
|
375
|
+
state.restartTimer = setTimeout(() => {
|
|
376
|
+
state.restartTimer = null;
|
|
377
|
+
void ensureWatcher(account, plugin);
|
|
378
|
+
}, delay);
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
try {
|
|
382
|
+
const stop = await plugin.watch(deps.providerCtx, account, {
|
|
383
|
+
onMessages: async (msgs) => {
|
|
384
|
+
try {
|
|
385
|
+
await ingestBatch(account, msgs, "watch");
|
|
386
|
+
} catch (err) {
|
|
387
|
+
// Ingest-Fehler killt den Watcher nicht — der Poll holt die
|
|
388
|
+
// Messages beim nächsten Tick (Dedup macht's idempotent).
|
|
389
|
+
log(
|
|
390
|
+
`inbound-mail: watch-ingest for account ${account.id} failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
},
|
|
394
|
+
onError: (err) => {
|
|
395
|
+
void (async () => {
|
|
396
|
+
const keepRunning = await handleSyncError(account, err);
|
|
397
|
+
// auth_error → stopWatcher hat die generation gebumpt,
|
|
398
|
+
// scheduleRestart no-op't; für alle anderen: Backoff-Restart.
|
|
399
|
+
if (keepRunning) scheduleRestart(err);
|
|
400
|
+
})();
|
|
401
|
+
},
|
|
402
|
+
});
|
|
403
|
+
if (state.generation !== generation || !running) {
|
|
404
|
+
await stop();
|
|
405
|
+
// skip: stop() kam während des Connects — Watcher wurde sofort wieder abgebaut.
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
state.stop = stop;
|
|
409
|
+
state.backoffMs = backoffInitialMs;
|
|
410
|
+
void markAccount(account, { watchState: "watching" }, "watch_supervisor");
|
|
411
|
+
} catch (err) {
|
|
412
|
+
const keepRunning = await handleSyncError(account, err);
|
|
413
|
+
if (keepRunning) scheduleRestart(err);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
async function stopWatcher(accountId: string): Promise<void> {
|
|
418
|
+
const state = watchers.get(accountId);
|
|
419
|
+
// skip: kein Watcher-State für diesen Account — bereits abgebaut.
|
|
420
|
+
if (!state) return;
|
|
421
|
+
state.generation += 1;
|
|
422
|
+
if (state.restartTimer) {
|
|
423
|
+
clearTimeout(state.restartTimer);
|
|
424
|
+
state.restartTimer = null;
|
|
425
|
+
}
|
|
426
|
+
const stop = state.stop;
|
|
427
|
+
state.stop = null;
|
|
428
|
+
watchers.delete(accountId);
|
|
429
|
+
if (stop) {
|
|
430
|
+
try {
|
|
431
|
+
await stop();
|
|
432
|
+
} catch (err) {
|
|
433
|
+
log(
|
|
434
|
+
`inbound-mail: stop watcher ${accountId} failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// ---------------------------------------------------------------
|
|
441
|
+
// Lifecycle.
|
|
442
|
+
// ---------------------------------------------------------------
|
|
443
|
+
function scheduleNextPoll(): void {
|
|
444
|
+
// skip: Supervisor gestoppt — keinen weiteren Poll-Tick planen.
|
|
445
|
+
if (!running) return;
|
|
446
|
+
pollTimer = setTimeout(() => {
|
|
447
|
+
pollTimer = null;
|
|
448
|
+
pollInFlight = pollOnce()
|
|
449
|
+
.catch((err) =>
|
|
450
|
+
log(
|
|
451
|
+
`inbound-mail: poll tick failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
452
|
+
),
|
|
453
|
+
)
|
|
454
|
+
.finally(() => {
|
|
455
|
+
pollInFlight = null;
|
|
456
|
+
scheduleNextPoll();
|
|
457
|
+
});
|
|
458
|
+
}, pollIntervalMs);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
return {
|
|
462
|
+
async start() {
|
|
463
|
+
// skip: bereits gestartet — start() ist idempotent.
|
|
464
|
+
if (running) return;
|
|
465
|
+
running = true;
|
|
466
|
+
await pollOnce();
|
|
467
|
+
scheduleNextPoll();
|
|
468
|
+
},
|
|
469
|
+
pollOnce,
|
|
470
|
+
async stop() {
|
|
471
|
+
running = false;
|
|
472
|
+
if (pollTimer) {
|
|
473
|
+
clearTimeout(pollTimer);
|
|
474
|
+
pollTimer = null;
|
|
475
|
+
}
|
|
476
|
+
if (pollInFlight) await pollInFlight;
|
|
477
|
+
for (const accountId of [...watchers.keys()]) {
|
|
478
|
+
await stopWatcher(accountId);
|
|
479
|
+
}
|
|
480
|
+
},
|
|
481
|
+
};
|
|
482
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// Contract- und Pure-Logic-Tests für inbound-provider-imap.
|
|
2
|
+
// Netz-lose Tests: Cursor-Semantik, References-Normalisierung,
|
|
3
|
+
// Credential-Dokument, Fehler-Mapping. Der echte IMAP-Roundtrip
|
|
4
|
+
// (greenmail/dovecot) ist opt-in, siehe imap-live.integration.test.ts.
|
|
5
|
+
|
|
6
|
+
import { describe, expect, test } from "bun:test";
|
|
7
|
+
import {
|
|
8
|
+
isInboundAuthError,
|
|
9
|
+
isInboundCursorInvalidError,
|
|
10
|
+
isInboundTransientError,
|
|
11
|
+
} from "../../inbound-mail-foundation";
|
|
12
|
+
import { parseImapCredentialDocument } from "../credential-document";
|
|
13
|
+
import { inboundProviderImapFeature } from "../feature";
|
|
14
|
+
import {
|
|
15
|
+
assertUidValidity,
|
|
16
|
+
buildProviderMessageId,
|
|
17
|
+
mapImapError,
|
|
18
|
+
normalizeReferences,
|
|
19
|
+
parseImapCursor,
|
|
20
|
+
} from "../imap-client";
|
|
21
|
+
|
|
22
|
+
describe("inboundProviderImapFeature — shape", () => {
|
|
23
|
+
test("has the expected name + requirements", () => {
|
|
24
|
+
expect(inboundProviderImapFeature.name).toBe("inbound-provider-imap");
|
|
25
|
+
expect(inboundProviderImapFeature.requires).toContain("inbound-mail-foundation");
|
|
26
|
+
expect(inboundProviderImapFeature.requires).toContain("secrets");
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("cursor — UIDVALIDITY:lastUid", () => {
|
|
31
|
+
test("parse roundtrip + malformed → null", () => {
|
|
32
|
+
expect(parseImapCursor({ uidValidity: "17", lastUid: 42 })).toEqual({
|
|
33
|
+
uidValidity: "17",
|
|
34
|
+
lastUid: 42,
|
|
35
|
+
});
|
|
36
|
+
expect(parseImapCursor(null)).toBeNull();
|
|
37
|
+
expect(parseImapCursor({ deltaLink: "graph" })).toBeNull();
|
|
38
|
+
expect(parseImapCursor({ uidValidity: 17, lastUid: "42" })).toBeNull();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("UIDVALIDITY-Wechsel → InboundCursorInvalidError (Voll-Resync)", () => {
|
|
42
|
+
expect(() => assertUidValidity({ uidValidity: "17", lastUid: 42 }, "18")).toThrow();
|
|
43
|
+
try {
|
|
44
|
+
assertUidValidity({ uidValidity: "17", lastUid: 42 }, "18");
|
|
45
|
+
} catch (e) {
|
|
46
|
+
expect(isInboundCursorInvalidError(e)).toBe(true);
|
|
47
|
+
}
|
|
48
|
+
// Unverändert bzw. kein Cursor: kein Throw.
|
|
49
|
+
assertUidValidity({ uidValidity: "17", lastUid: 42 }, "17");
|
|
50
|
+
assertUidValidity(null, "18");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("providerMessageId bindet UIDVALIDITY (UID allein ist nicht stabil)", () => {
|
|
54
|
+
expect(buildProviderMessageId("17", 42)).toBe("17:42");
|
|
55
|
+
expect(buildProviderMessageId("18", 42)).not.toBe(buildProviderMessageId("17", 42));
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("normalizeReferences", () => {
|
|
60
|
+
test("string[]-Form, <>-stripping", () => {
|
|
61
|
+
expect(normalizeReferences({ references: ["<a@x>", "<b@x>"] })).toEqual(["a@x", "b@x"]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("whitespace-getrennter string wird gesplittet", () => {
|
|
65
|
+
expect(normalizeReferences({ references: "<a@x> <b@x>" })).toEqual(["a@x", "b@x"]);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("ohne References fällt In-Reply-To als Thread-Anker ein", () => {
|
|
69
|
+
expect(normalizeReferences({ inReplyTo: "<parent@x>" })).toEqual(["parent@x"]);
|
|
70
|
+
expect(normalizeReferences({})).toEqual([]);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe("credential document", () => {
|
|
75
|
+
test("password-Dokument parsed mit Defaults (port 993, secure)", () => {
|
|
76
|
+
const r = parseImapCredentialDocument(
|
|
77
|
+
JSON.stringify({ host: "imap.example.com", user: "u@example.com", password: "pw" }),
|
|
78
|
+
);
|
|
79
|
+
expect(r.ok).toBe(true);
|
|
80
|
+
if (!r.ok) return;
|
|
81
|
+
expect(r.doc.port).toBe(993);
|
|
82
|
+
expect(r.doc.secure).toBe(true);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("weder password noch accessToken → abgelehnt", () => {
|
|
86
|
+
const r = parseImapCredentialDocument(
|
|
87
|
+
JSON.stringify({ host: "imap.example.com", user: "u@example.com" }),
|
|
88
|
+
);
|
|
89
|
+
expect(r.ok).toBe(false);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("kein JSON → abgelehnt", () => {
|
|
93
|
+
expect(parseImapCredentialDocument("not-json").ok).toBe(false);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe("mapImapError — typisierte Fehlerklassen (Plan §2)", () => {
|
|
98
|
+
test("Auth-Fehler → InboundAuthError (kein Retry, needs re-connect)", () => {
|
|
99
|
+
expect(isInboundAuthError(mapImapError(new Error("Authentication failed"), "h"))).toBe(true);
|
|
100
|
+
expect(isInboundAuthError(mapImapError(new Error("Invalid credentials (Failure)"), "h"))).toBe(
|
|
101
|
+
true,
|
|
102
|
+
);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("Netz-Fehler → InboundTransientError (Job-Retry)", () => {
|
|
106
|
+
expect(isInboundTransientError(mapImapError(new Error("ECONNREFUSED"), "h"))).toBe(true);
|
|
107
|
+
expect(isInboundTransientError(mapImapError(new Error("getaddrinfo ENOTFOUND x"), "h"))).toBe(
|
|
108
|
+
true,
|
|
109
|
+
);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("Unbekanntes → transient (retry schadet nicht)", () => {
|
|
113
|
+
expect(isInboundTransientError(mapImapError(new Error("weird server burp"), "h"))).toBe(true);
|
|
114
|
+
});
|
|
115
|
+
});
|