@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/package.json +50 -0
- package/src/cascade.test.ts +80 -0
- package/src/cascade.ts +383 -0
- package/src/compose-relational.test.ts +104 -0
- package/src/compose-relational.ts +87 -0
- package/src/config.ts +70 -0
- package/src/deletion-capabilities.ts +94 -0
- package/src/events.ts +85 -0
- package/src/handlers/account-export.ts +132 -0
- package/src/handlers/account-fanout.ts +165 -0
- package/src/handlers/account-finalize.ts +311 -0
- package/src/handlers/account-purge.ts +226 -0
- package/src/handlers/organize-job.ts +78 -0
- package/src/index.ts +11 -0
- package/src/poller.ts +39 -0
- package/tsconfig.json +8 -0
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
|
+
);
|