@remit/account-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/src/config.ts ADDED
@@ -0,0 +1,70 @@
1
+ import { SQSClient } from "@aws-sdk/client-sqs";
2
+ import { getClient } from "@remit/backend/client";
3
+ import { resolveSqsCredentials } from "@remit/sqs-client";
4
+ import type { StorageService } from "@remit/storage-service";
5
+ import { createStorageService } from "@remit/storage-service/s3";
6
+ import { env } from "expect-env";
7
+ import type { CascadeServices } from "./cascade.js";
8
+
9
+ const remitClient = await getClient();
10
+
11
+ export const sqsClient = new SQSClient({
12
+ credentials: resolveSqsCredentials(),
13
+ });
14
+
15
+ // SQS env vars are lazy-evaluated. The fanout worker needs them at handler time;
16
+ // the finalize worker imports `cascadeServices` from this module but talks to no
17
+ // queue, so its Lambda doesn't carry the env vars. Eager evaluation here would
18
+ // crash finalize at module load — getters defer the read to the fanout-only call
19
+ // sites.
20
+ export const getSearchIndexQueueUrl = (): string =>
21
+ env.SQS_QUEUE_URL_SEARCH_INDEX;
22
+ export const getImapWorkerQueueUrl = (): string =>
23
+ env.SQS_QUEUE_URL_IMAP_WORKER;
24
+ export const getAccountFinalizeQueueUrl = (): string =>
25
+ env.SQS_QUEUE_URL_ACCOUNT_FINALIZE;
26
+ export const getAccountPurgeDeleteQueueUrl = (): string =>
27
+ env.SQS_QUEUE_URL_ACCOUNT_PURGE_DELETE;
28
+
29
+ const graceSecondsRaw = process.env.ACCOUNT_DELETION_GRACE_SECONDS;
30
+ export const graceSeconds = graceSecondsRaw
31
+ ? Number.parseInt(graceSecondsRaw, 10)
32
+ : 60;
33
+
34
+ // Every cascade service is a `RemitClient` repository, so the whole enumeration
35
+ // runs on whatever backend `getClient()` selected — DynamoDB (ElectroDB),
36
+ // Postgres, or SQLite (Drizzle). Filter/FilterAnchor/Label/MessageLabel (Smart
37
+ // Organize, RFC 034) are present on all three backends via the client, so they
38
+ // need no backend-specific wiring here.
39
+ export const cascadeServices: CascadeServices = {
40
+ accountConfigService: remitClient.accountConfig,
41
+ accountService: remitClient.account,
42
+ addressService: remitClient.address,
43
+ mailboxService: remitClient.mailbox,
44
+ messageService: remitClient.message,
45
+ messageFlagService: remitClient.messageFlag,
46
+ envelopeService: remitClient.envelope,
47
+ outboxMessageService: remitClient.outboxMessage,
48
+ threadMessageService: remitClient.threadMessage,
49
+ mailboxLockService: remitClient.mailboxLock,
50
+ messagePlacementMoveService: remitClient.placementMove,
51
+ messageFlagPushService: remitClient.flagPush,
52
+ accountExportRequestService: remitClient.accountExportRequest,
53
+ accountSettingService: remitClient.accountSetting,
54
+ filterService: remitClient.filter,
55
+ filterAnchorService: remitClient.filterAnchor,
56
+ labelService: remitClient.label,
57
+ messageLabelService: remitClient.messageLabel,
58
+ };
59
+
60
+ export const accountConfigService = cascadeServices.accountConfigService;
61
+
62
+ let storageService: StorageService | null = null;
63
+ export const getStorageService = (): StorageService => {
64
+ if (!storageService) {
65
+ storageService = createStorageService();
66
+ }
67
+ return storageService;
68
+ };
69
+
70
+ export const dataBackend = process.env.DATA_BACKEND;
@@ -0,0 +1,94 @@
1
+ import type { Logger } from "@remit/logger-lambda";
2
+ import type { CascadeEntity } from "./cascade.js";
3
+
4
+ /**
5
+ * The four steps of the deletion cascade that vary by deployment. Everything
6
+ * else — enumerating the AccountConfig's rows, the FIFO purge orchestration, the
7
+ * queue sends — is backend-neutral and lives in the handlers unchanged.
8
+ *
9
+ * Two composition roots build this, selected by `DATA_BACKEND` exactly like
10
+ * `@remit/backend`'s `compose-{dynamodb,postgres,sqlite}`:
11
+ *
12
+ * - `compose-dynamodb.ts` (AWS custody stack): Cognito global sign-out,
13
+ * CloudFront invalidation, raw-S3 prefix delete, DynamoDB `BatchWriteItem`
14
+ * cascade. Byte-for-byte the pre-split behavior. Stripped from the open-core
15
+ * export and loaded through a lazy `import()` so the closed AWS SDK/ElectroDB
16
+ * graph never reaches the relational tree.
17
+ * - `compose-relational.ts` (self-host stack, RFC 035/036): no-op sign-out
18
+ * (deleting the AccountConfig severs the session's data resolution; there is
19
+ * no CDN to sign out of), no-op invalidation (Caddy proxies `/content/*`
20
+ * straight to the backend with no cache), filesystem prefix delete, and the
21
+ * Drizzle cascade over Postgres or SQLite.
22
+ */
23
+ export interface DeletionCapabilities {
24
+ /**
25
+ * Validate every precondition the destructive steps depend on, BEFORE any row
26
+ * or object is deleted. AWS checks both `CONTENT_DISTRIBUTION_ID` and
27
+ * `S3_STORAGE_BUCKET_NAME` up front so a missing var fails loud instead of
28
+ * destroying rows in the cascade and only then erroring at the storage step.
29
+ * Relational is a no-op — its steps carry no such deploy-time configuration.
30
+ */
31
+ assertReady(log: Logger): Promise<void>;
32
+ /**
33
+ * Invalidate the deleted user's active auth sessions. AWS: Cognito
34
+ * `AdminUserGlobalSignOut`. Relational: no-op.
35
+ */
36
+ signOut(userId: string, log: Logger): Promise<void>;
37
+ /**
38
+ * Purge any CDN/edge cache under a tenant/account content prefix
39
+ * (`accountConfigId` or `accountConfigId/accountId`). AWS: CloudFront
40
+ * invalidation of `/content/accounts/{prefix}/*`. Relational: no-op.
41
+ */
42
+ invalidateContent(contentPrefix: string, log: Logger): Promise<void>;
43
+ /**
44
+ * Delete every stored object under a storage key prefix
45
+ * (`accounts/{accountConfigId}/…`). AWS: raw-S3 list+delete. Relational:
46
+ * recursive filesystem removal under the local storage root.
47
+ */
48
+ deleteStoragePrefix(keyPrefix: string, log: Logger): Promise<void>;
49
+ /**
50
+ * Remove the enumerated rows in dependency order (children → parents). AWS:
51
+ * DynamoDB `BatchWriteItem`. Relational: Drizzle transaction (Postgres or
52
+ * SQLite). Excludes the AccountConfig row, which the caller deletes last
53
+ * through its repository as the cascade-in-progress marker.
54
+ */
55
+ cascadeDelete(entities: CascadeEntity[], log: Logger): Promise<void>;
56
+ }
57
+
58
+ const isRelationalBackend = (): boolean => {
59
+ const backend = process.env.DATA_BACKEND;
60
+ return backend === "postgres" || backend === "sqlite";
61
+ };
62
+
63
+ const buildDeletionCapabilitiesFromEnv =
64
+ async (): Promise<DeletionCapabilities> => {
65
+ if (isRelationalBackend()) {
66
+ const { buildRelationalDeletionCapabilities } = await import(
67
+ "./compose-relational.js"
68
+ );
69
+ return buildRelationalDeletionCapabilities();
70
+ }
71
+ // `as string` stops tsgo resolving the module in the open-core tree, which
72
+ // strips it; the named contract keeps the call site typed rather than `any`.
73
+ type ComposeDynamoDBModule = {
74
+ buildDynamoDBDeletionCapabilities: () => DeletionCapabilities;
75
+ };
76
+ const { buildDynamoDBDeletionCapabilities } = (await import(
77
+ "./compose-dynamodb.js" as string
78
+ )) as ComposeDynamoDBModule;
79
+ return buildDynamoDBDeletionCapabilities();
80
+ };
81
+
82
+ let capabilitiesPromise: Promise<DeletionCapabilities> | undefined;
83
+
84
+ /**
85
+ * The process-wide deletion capabilities, built once per backend and reused
86
+ * across invocations. Both handlers await this in their default path; tests
87
+ * inject a `DeletionCapabilities` directly.
88
+ */
89
+ export const getDeletionCapabilities = (): Promise<DeletionCapabilities> => {
90
+ if (!capabilitiesPromise) {
91
+ capabilitiesPromise = buildDeletionCapabilitiesFromEnv();
92
+ }
93
+ return capabilitiesPromise;
94
+ };
package/src/events.ts ADDED
@@ -0,0 +1,85 @@
1
+ export type AccountDeleteEvent = {
2
+ type: "AccountDelete";
3
+ accountConfigId: string;
4
+ };
5
+
6
+ /**
7
+ * Per-account hard-delete. Purges ONE mail account's data (mailboxes,
8
+ * messages, body parts, thread messages, S3 objects, search vectors) while
9
+ * keeping the AccountConfig/tenant and any sibling accounts intact. Emitted
10
+ * by the API on `deleteAccount`; consumed by the fanout worker.
11
+ */
12
+ export type AccountDataPurgeEvent = {
13
+ type: "AccountDataPurge";
14
+ accountId: string;
15
+ accountConfigId: string;
16
+ };
17
+
18
+ export type AccountExportEvent = {
19
+ type: "AccountExport";
20
+ accountConfigId: string;
21
+ accountExportRequestId: string;
22
+ };
23
+
24
+ /**
25
+ * A "all like these" back-apply job (RFC 034, #1278). Rides the same
26
+ * account-fanout queue as export: the API writes a Pending OrganizeJobRequest
27
+ * row and enqueues this; the fanout worker matches the corpus and applies the
28
+ * action, driving the row to Complete/Failed. Never creates a Filter row.
29
+ */
30
+ export type OrganizeJobEvent = {
31
+ type: "OrganizeJob";
32
+ accountConfigId: string;
33
+ organizeJobId: string;
34
+ };
35
+
36
+ export type AccountFanoutEvent =
37
+ | AccountDeleteEvent
38
+ | AccountDataPurgeEvent
39
+ | AccountExportEvent
40
+ | OrganizeJobEvent;
41
+
42
+ export type AccountDeleteFinalizeEvent = {
43
+ type: "FinalizeAccountDelete";
44
+ accountConfigId: string;
45
+ };
46
+
47
+ /** One message subtree to delete: its manifest row plus the Message it indexes. */
48
+ export type AccountDataPurgeSubtreeItem = {
49
+ threadMessageId: string;
50
+ messageId: string;
51
+ };
52
+
53
+ /**
54
+ * Destructive phase of the per-account purge. The fanout worker reads the
55
+ * account's ThreadMessage manifest and emits a stream of these onto a FIFO
56
+ * queue, single message group per account: a series of `subtrees` batches
57
+ * followed by exactly one `container` leftover. FIFO ordering guarantees the
58
+ * container runs only after every subtree delete — no fan-in counter and no
59
+ * self-re-enqueue. The finalize worker consumes them and already holds the
60
+ * S3-delete, CloudFront, and DDB batch-write grants.
61
+ *
62
+ * - `subtrees`: delete each `{ threadMessageId, messageId }` subtree (the
63
+ * Message, its 9 child entities, and the manifest row). Idempotent.
64
+ * - `container`: the last message — delete the account-keyed container rows
65
+ * (mailboxes, outbox, locks), the S3 prefix, and the CloudFront cache. The
66
+ * tenant-shared Address is never deleted.
67
+ */
68
+ export type AccountDataPurgeFinalizeEvent =
69
+ | {
70
+ type: "FinalizeAccountDataPurge";
71
+ kind: "subtrees";
72
+ accountId: string;
73
+ accountConfigId: string;
74
+ items: AccountDataPurgeSubtreeItem[];
75
+ }
76
+ | {
77
+ type: "FinalizeAccountDataPurge";
78
+ kind: "container";
79
+ accountId: string;
80
+ accountConfigId: string;
81
+ };
82
+
83
+ export type AccountFinalizeEvent =
84
+ | AccountDeleteFinalizeEvent
85
+ | AccountDataPurgeFinalizeEvent;
@@ -0,0 +1,132 @@
1
+ import { PassThrough } from "node:stream";
2
+ import type { Logger } from "@remit/logger-lambda";
3
+ import type { StorageService } from "@remit/storage-service";
4
+ import { ZipArchive } from "archiver";
5
+ import type { CascadeServices } from "../cascade.js";
6
+ import { cascadeServices, getStorageService } from "../config.js";
7
+ import type { AccountExportEvent } from "../events.js";
8
+
9
+ const THIRTY_DAYS_MILLIS = 30 * 24 * 60 * 60 * 1000;
10
+
11
+ export interface ProcessAccountExportDeps {
12
+ services: CascadeServices;
13
+ storageService: StorageService;
14
+ }
15
+
16
+ const defaultDeps = (): ProcessAccountExportDeps => ({
17
+ services: cascadeServices,
18
+ storageService: getStorageService(),
19
+ });
20
+
21
+ const appendMessageBodies = async (
22
+ archive: ZipArchive,
23
+ accountConfigId: string,
24
+ services: CascadeServices,
25
+ storageService: StorageService,
26
+ log: Logger,
27
+ ): Promise<number> => {
28
+ const { accountConfigService, accountService, messageService } = services;
29
+ let appended = 0;
30
+
31
+ const accountConfigDescription =
32
+ await accountConfigService.describe(accountConfigId);
33
+
34
+ for (const account of accountConfigDescription.account) {
35
+ const accountDescription = await accountService.describe(account.accountId);
36
+
37
+ for (const mailbox of accountDescription.mailbox) {
38
+ const messages = await messageService.listAllByMailbox(mailbox.mailboxId);
39
+
40
+ for (const message of messages) {
41
+ const body = await storageService.retrieveMessageBodyStream(
42
+ accountConfigId,
43
+ account.accountId,
44
+ message.messageId,
45
+ );
46
+
47
+ if (!body) {
48
+ log.info(
49
+ { accountConfigId, messageId: message.messageId },
50
+ "No raw body for message, skipping",
51
+ );
52
+ continue;
53
+ }
54
+
55
+ archive.append(body, {
56
+ name: `accounts/${account.accountId}/${mailbox.fullPath ?? mailbox.mailboxId}/${message.messageId}.eml`,
57
+ });
58
+ appended += 1;
59
+ }
60
+ }
61
+ }
62
+
63
+ return appended;
64
+ };
65
+
66
+ export const processAccountExport = async (
67
+ event: AccountExportEvent,
68
+ log: Logger,
69
+ deps: ProcessAccountExportDeps = defaultDeps(),
70
+ ): Promise<void> => {
71
+ const { accountConfigId, accountExportRequestId } = event;
72
+ const { services, storageService } = deps;
73
+ const { accountExportRequestService } = services;
74
+
75
+ await accountExportRequestService.get(accountExportRequestId);
76
+ await accountExportRequestService.update(accountExportRequestId, {
77
+ state: "Processing",
78
+ });
79
+ log.info(
80
+ { accountConfigId, accountExportRequestId },
81
+ "Export processing started",
82
+ );
83
+
84
+ try {
85
+ const archive = new ZipArchive({ zlib: { level: 6 } });
86
+ const passthrough = new PassThrough();
87
+ archive.pipe(passthrough);
88
+
89
+ const uploadDone = storageService.storeExportArchiveStream(
90
+ accountConfigId,
91
+ accountExportRequestId,
92
+ passthrough,
93
+ );
94
+
95
+ const archiveSettled = new Promise<void>((resolve, reject) => {
96
+ archive.on("warning", reject);
97
+ archive.on("error", reject);
98
+ archive.on("end", resolve);
99
+ });
100
+
101
+ const entryCount = await appendMessageBodies(
102
+ archive,
103
+ accountConfigId,
104
+ services,
105
+ storageService,
106
+ log,
107
+ );
108
+
109
+ await archive.finalize();
110
+ await archiveSettled;
111
+ const objectKey = await uploadDone;
112
+
113
+ const expiresAt = Date.now() + THIRTY_DAYS_MILLIS;
114
+
115
+ await accountExportRequestService.update(accountExportRequestId, {
116
+ state: "Ready",
117
+ objectKey,
118
+ expiresAt,
119
+ });
120
+
121
+ log.info(
122
+ { accountConfigId, accountExportRequestId, entryCount },
123
+ "Export ready",
124
+ );
125
+ } catch (error) {
126
+ await accountExportRequestService.update(accountExportRequestId, {
127
+ state: "Failed",
128
+ errorMessage: error instanceof Error ? error.message : String(error),
129
+ });
130
+ throw error;
131
+ }
132
+ };
@@ -0,0 +1,165 @@
1
+ import { SendMessageCommand, type SQSClient } from "@aws-sdk/client-sqs";
2
+ import { createLogger, type Logger, withTelemetry } from "@remit/logger-lambda";
3
+ import type { SQSBatchResponse, SQSEvent, SQSHandler } from "aws-lambda";
4
+ import type { CascadeServices } from "../cascade.js";
5
+ import { enumerateCascadeEntities } from "../cascade.js";
6
+ import {
7
+ cascadeServices,
8
+ getAccountFinalizeQueueUrl,
9
+ getAccountPurgeDeleteQueueUrl,
10
+ getImapWorkerQueueUrl,
11
+ sqsClient,
12
+ } from "../config.js";
13
+ import {
14
+ type DeletionCapabilities,
15
+ getDeletionCapabilities,
16
+ } from "../deletion-capabilities.js";
17
+ import type {
18
+ AccountDeleteEvent,
19
+ AccountFanoutEvent,
20
+ AccountFinalizeEvent,
21
+ } from "../events.js";
22
+ import { processAccountExport } from "./account-export.js";
23
+ import { processAccountDataPurge } from "./account-purge.js";
24
+ import { processOrganizeJob } from "./organize-job.js";
25
+
26
+ export interface ProcessAccountFanoutDeps {
27
+ services?: CascadeServices;
28
+ sqs?: SQSClient;
29
+ signOut?: DeletionCapabilities["signOut"];
30
+ imapWorkerQueueUrl?: string;
31
+ accountFinalizeQueueUrl?: string;
32
+ accountPurgeDeleteQueueUrl?: string;
33
+ }
34
+
35
+ /**
36
+ * Fanout step of the account-deletion cascade. Read-only: enumerates the
37
+ * AccountConfig's data and dispatches downstream work — search-index
38
+ * deletes, per-account imap-worker stops, a sign-out, and the finalize
39
+ * enqueue. Throws on any non-swallowable error so SQS partial batch-failure
40
+ * retries the whole record.
41
+ */
42
+ export const processAccountFanout = async (
43
+ event: AccountFanoutEvent,
44
+ log: Logger,
45
+ deps: ProcessAccountFanoutDeps = {},
46
+ ): Promise<void> => {
47
+ const services = deps.services ?? cascadeServices;
48
+ const sqs = deps.sqs ?? sqsClient;
49
+
50
+ if (event.type === "AccountDataPurge") {
51
+ await processAccountDataPurge(event, log, {
52
+ services,
53
+ sqs,
54
+ accountPurgeDeleteQueueUrl:
55
+ deps.accountPurgeDeleteQueueUrl ?? getAccountPurgeDeleteQueueUrl(),
56
+ });
57
+ return;
58
+ }
59
+
60
+ if (event.type === "AccountExport") {
61
+ await processAccountExport(event, log);
62
+ return;
63
+ }
64
+
65
+ if (event.type === "OrganizeJob") {
66
+ await processOrganizeJob(event, log);
67
+ return;
68
+ }
69
+
70
+ await processAccountDelete(event, log, services, sqs, deps);
71
+ };
72
+
73
+ const processAccountDelete = async (
74
+ event: AccountDeleteEvent,
75
+ log: Logger,
76
+ services: CascadeServices,
77
+ sqs: SQSClient,
78
+ deps: ProcessAccountFanoutDeps,
79
+ ): Promise<void> => {
80
+ const { accountConfigId } = event;
81
+ const signOut = deps.signOut ?? (await getDeletionCapabilities()).signOut;
82
+ const imapWorkerQueueUrl = deps.imapWorkerQueueUrl ?? getImapWorkerQueueUrl();
83
+ const accountFinalizeQueueUrl =
84
+ deps.accountFinalizeQueueUrl ?? getAccountFinalizeQueueUrl();
85
+
86
+ const accountConfig =
87
+ await services.accountConfigService.get(accountConfigId);
88
+ const userId = accountConfig.userId;
89
+
90
+ const { entities, messageIds: _messageIds } = await enumerateCascadeEntities(
91
+ accountConfigId,
92
+ services,
93
+ log,
94
+ );
95
+
96
+ const accountIds = entities
97
+ .filter((e) => e.entityType === "Account")
98
+ .map((e) => e.key.accountId);
99
+
100
+ for (const accountId of accountIds) {
101
+ await sqs.send(
102
+ new SendMessageCommand({
103
+ QueueUrl: imapWorkerQueueUrl,
104
+ MessageBody: JSON.stringify({
105
+ type: "IMAP_WORKER_STOP",
106
+ accountConfigId,
107
+ accountId,
108
+ }),
109
+ }),
110
+ );
111
+ }
112
+ log.info(
113
+ { accountConfigId, count: accountIds.length },
114
+ "Enqueued imap-worker stop signals",
115
+ );
116
+
117
+ await signOut(userId, log);
118
+
119
+ const finalizeEvent: AccountFinalizeEvent = {
120
+ type: "FinalizeAccountDelete",
121
+ accountConfigId,
122
+ };
123
+ await sqs.send(
124
+ new SendMessageCommand({
125
+ QueueUrl: accountFinalizeQueueUrl,
126
+ MessageBody: JSON.stringify(finalizeEvent),
127
+ }),
128
+ );
129
+ log.info({ accountConfigId }, "Enqueued finalize event");
130
+ };
131
+
132
+ const log = createLogger();
133
+
134
+ export const handler: SQSHandler = withTelemetry(
135
+ async (event: SQSEvent): Promise<SQSBatchResponse> => {
136
+ const batchItemFailures: { itemIdentifier: string }[] = [];
137
+
138
+ for (const record of event.Records) {
139
+ const fanoutEvent: AccountFanoutEvent = JSON.parse(record.body);
140
+ log.info(
141
+ {
142
+ eventType: fanoutEvent.type,
143
+ accountConfigId: fanoutEvent.accountConfigId,
144
+ },
145
+ "Processing account fanout event",
146
+ );
147
+
148
+ const failed = await processAccountFanout(fanoutEvent, log)
149
+ .then(() => false)
150
+ .catch((error) => {
151
+ log.error(
152
+ { error, messageId: record.messageId },
153
+ "Account fanout event processing failed",
154
+ );
155
+ return true;
156
+ });
157
+
158
+ if (failed) {
159
+ batchItemFailures.push({ itemIdentifier: record.messageId });
160
+ }
161
+ }
162
+
163
+ return { batchItemFailures };
164
+ },
165
+ );