@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/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@remit/account-worker",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"bundle": "esbuild src/index.ts --sourcemap --bundle --platform=node --format=esm --outfile=dist/index.js",
|
|
8
|
+
"test:typecheck": "tsgo --noEmit",
|
|
9
|
+
"test:run": "node --env-file=../../localhost-test-unit.env --test 'src/**/*.test.ts'",
|
|
10
|
+
"test": "npm run test:typecheck && npm run test:run"
|
|
11
|
+
},
|
|
12
|
+
"devDependencies": {
|
|
13
|
+
"@aws-sdk/client-cloudfront": "*",
|
|
14
|
+
"@aws-sdk/client-cognito-identity-provider": "*",
|
|
15
|
+
"@aws-sdk/client-dynamodb": "*",
|
|
16
|
+
"@aws-sdk/client-s3": "*",
|
|
17
|
+
"@aws-sdk/client-sqs": "*",
|
|
18
|
+
"@aws-sdk/core": "*",
|
|
19
|
+
"@aws-sdk/lib-dynamodb": "*",
|
|
20
|
+
"@aws-sdk/lib-storage": "*",
|
|
21
|
+
"@aws-sdk/s3-request-presigner": "*",
|
|
22
|
+
"@remit/backend": "*",
|
|
23
|
+
"@remit/data-ports": "*",
|
|
24
|
+
"@remit/electrodb-entities": "*",
|
|
25
|
+
"@remit/drizzle-service": "*",
|
|
26
|
+
"@remit/domain-enums": "*",
|
|
27
|
+
"@remit/logger-lambda": "*",
|
|
28
|
+
"@remit/search-index-worker": "*",
|
|
29
|
+
"@remit/search-service": "*",
|
|
30
|
+
"@remit/sqs-client": "*",
|
|
31
|
+
"@remit/storage-service": "*",
|
|
32
|
+
"@types/archiver": "*",
|
|
33
|
+
"@types/aws-lambda": "*",
|
|
34
|
+
"archiver": "*",
|
|
35
|
+
"aws-sdk-client-mock": "*",
|
|
36
|
+
"electrodb": "*",
|
|
37
|
+
"esbuild": "^0.27.7",
|
|
38
|
+
"expect-env": "*",
|
|
39
|
+
"p-map": "*"
|
|
40
|
+
},
|
|
41
|
+
"license": "MIT",
|
|
42
|
+
"publishConfig": {
|
|
43
|
+
"access": "public"
|
|
44
|
+
},
|
|
45
|
+
"repository": {
|
|
46
|
+
"type": "git",
|
|
47
|
+
"url": "git+https://github.com/remit-mail/remit.git",
|
|
48
|
+
"directory": "packages/account-worker"
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import assert from "node:assert";
|
|
2
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { describe, it } from "node:test";
|
|
5
|
+
import { COVERED_ENTITY_TYPES } from "./cascade.js";
|
|
6
|
+
|
|
7
|
+
// The snapshot enumerates electrodb-service's models; that package is stripped
|
|
8
|
+
// from the open-core tree, so the check skips there and runs where it ships.
|
|
9
|
+
const modelsDir = resolve(
|
|
10
|
+
import.meta.dirname,
|
|
11
|
+
"../../remit-electrodb-service/src/models",
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
describe("cascade entity coverage snapshot", () => {
|
|
15
|
+
it(
|
|
16
|
+
"covers every entity model in remit-electrodb-service",
|
|
17
|
+
{ skip: !existsSync(modelsDir) },
|
|
18
|
+
() => {
|
|
19
|
+
const modelFiles = readdirSync(modelsDir)
|
|
20
|
+
.filter(
|
|
21
|
+
(f) =>
|
|
22
|
+
f.endsWith(".ts") && !f.endsWith(".test.ts") && f !== "index.ts",
|
|
23
|
+
)
|
|
24
|
+
.map((f) => f.replace(".ts", ""));
|
|
25
|
+
|
|
26
|
+
// Map model file names to the entity types they contain
|
|
27
|
+
const entityTypesByFile: Record<string, string[]> = {
|
|
28
|
+
"account-config": ["AccountConfig"],
|
|
29
|
+
"account-export-request": [],
|
|
30
|
+
"account-setting": ["AccountSetting"],
|
|
31
|
+
"account-setting-registry": [],
|
|
32
|
+
account: ["Account"],
|
|
33
|
+
address: ["Address", "EnvelopeAddress"],
|
|
34
|
+
envelope: [
|
|
35
|
+
"Envelope",
|
|
36
|
+
"MessageReference",
|
|
37
|
+
"BodyPart",
|
|
38
|
+
"BodyPartParameter",
|
|
39
|
+
"RawMessageStorage",
|
|
40
|
+
"BodyPartStorage",
|
|
41
|
+
"BodyPartContent",
|
|
42
|
+
],
|
|
43
|
+
filter: ["Filter"],
|
|
44
|
+
"filter-anchor": ["FilterAnchor"],
|
|
45
|
+
label: ["Label"],
|
|
46
|
+
mailbox: ["Mailbox"],
|
|
47
|
+
"mailbox-lock": ["MailboxLock"],
|
|
48
|
+
"mailbox-special-use": [],
|
|
49
|
+
message: ["Message"],
|
|
50
|
+
"message-flag": ["MessageFlag"],
|
|
51
|
+
"message-flag-push": ["MessageFlagPush"],
|
|
52
|
+
"message-label": ["MessageLabel"],
|
|
53
|
+
"message-placement-move": ["MessagePlacementMove"],
|
|
54
|
+
"organize-job-request": [],
|
|
55
|
+
"outbox-message": ["OutboxMessage"],
|
|
56
|
+
"thread-message": ["ThreadMessage"],
|
|
57
|
+
"wellknown-rule": [],
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const allEntityTypes = Object.values(entityTypesByFile).flat().sort();
|
|
61
|
+
const coveredSorted = [...COVERED_ENTITY_TYPES].sort();
|
|
62
|
+
|
|
63
|
+
assert.deepStrictEqual(
|
|
64
|
+
coveredSorted,
|
|
65
|
+
allEntityTypes,
|
|
66
|
+
`Cascade does not cover all entity types.\n` +
|
|
67
|
+
`Missing: ${allEntityTypes.filter((t) => !(coveredSorted as readonly string[]).includes(t)).join(", ")}\n` +
|
|
68
|
+
`Extra: ${(coveredSorted as readonly string[]).filter((t) => !allEntityTypes.includes(t)).join(", ")}`,
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
// Verify all model files are accounted for
|
|
72
|
+
const unmappedFiles = modelFiles.filter((f) => !(f in entityTypesByFile));
|
|
73
|
+
assert.deepStrictEqual(
|
|
74
|
+
unmappedFiles,
|
|
75
|
+
[],
|
|
76
|
+
`Model files not mapped to entity types: ${unmappedFiles.join(", ")}`,
|
|
77
|
+
);
|
|
78
|
+
},
|
|
79
|
+
);
|
|
80
|
+
});
|
package/src/cascade.ts
ADDED
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
IAccountConfigRepository,
|
|
3
|
+
IAccountExportRequestRepository,
|
|
4
|
+
IAccountRepository,
|
|
5
|
+
IAccountSettingRepository,
|
|
6
|
+
IAddressRepository,
|
|
7
|
+
IEnvelopeRepository,
|
|
8
|
+
IFilterAnchorRepository,
|
|
9
|
+
IFilterRepository,
|
|
10
|
+
ILabelRepository,
|
|
11
|
+
IMailboxLockRepository,
|
|
12
|
+
IMailboxRepository,
|
|
13
|
+
IMessageFlagPushRepository,
|
|
14
|
+
IMessageFlagRepository,
|
|
15
|
+
IMessageLabelRepository,
|
|
16
|
+
IMessagePlacementMoveRepository,
|
|
17
|
+
IMessageRepository,
|
|
18
|
+
IOutboxMessageRepository,
|
|
19
|
+
IThreadMessageRepository,
|
|
20
|
+
MessageDescription,
|
|
21
|
+
} from "@remit/data-ports";
|
|
22
|
+
import type { Logger } from "@remit/logger-lambda";
|
|
23
|
+
|
|
24
|
+
export interface CascadeEntity {
|
|
25
|
+
entityType: string;
|
|
26
|
+
key: Record<string, string>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface CascadeServices {
|
|
30
|
+
accountConfigService: IAccountConfigRepository;
|
|
31
|
+
accountService: IAccountRepository;
|
|
32
|
+
addressService: IAddressRepository;
|
|
33
|
+
mailboxService: IMailboxRepository;
|
|
34
|
+
messageService: IMessageRepository;
|
|
35
|
+
messageFlagService: IMessageFlagRepository;
|
|
36
|
+
envelopeService: IEnvelopeRepository;
|
|
37
|
+
outboxMessageService: IOutboxMessageRepository;
|
|
38
|
+
threadMessageService: IThreadMessageRepository;
|
|
39
|
+
mailboxLockService: IMailboxLockRepository;
|
|
40
|
+
messagePlacementMoveService: IMessagePlacementMoveRepository;
|
|
41
|
+
messageFlagPushService: IMessageFlagPushRepository;
|
|
42
|
+
accountExportRequestService: IAccountExportRequestRepository;
|
|
43
|
+
accountSettingService: IAccountSettingRepository;
|
|
44
|
+
filterService: IFilterRepository;
|
|
45
|
+
filterAnchorService: IFilterAnchorRepository;
|
|
46
|
+
labelService: ILabelRepository;
|
|
47
|
+
messageLabelService: IMessageLabelRepository;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface CascadeResult {
|
|
51
|
+
entities: CascadeEntity[];
|
|
52
|
+
messageIds: string[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Expand one message's 9 child entities (flags, envelope, addresses, body
|
|
57
|
+
* parts, references, raw/body storage) into the cascade plan. The Message row
|
|
58
|
+
* itself is pushed by the caller — this only collects the children that a
|
|
59
|
+
* `describe()` reveals. Shared by the tenant cascade enumerator and the
|
|
60
|
+
* per-account purge worker.
|
|
61
|
+
*/
|
|
62
|
+
export const collectMessageChildEntities = (
|
|
63
|
+
entities: CascadeEntity[],
|
|
64
|
+
messageData: MessageDescription,
|
|
65
|
+
): void => {
|
|
66
|
+
for (const flag of messageData.messageFlag) {
|
|
67
|
+
entities.push({
|
|
68
|
+
entityType: "MessageFlag",
|
|
69
|
+
key: { messageFlagId: flag.messageFlagId },
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
for (const envelope of messageData.envelope) {
|
|
73
|
+
entities.push({
|
|
74
|
+
entityType: "Envelope",
|
|
75
|
+
key: { envelopeId: envelope.envelopeId },
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
for (const ref of messageData.messageReference) {
|
|
79
|
+
entities.push({
|
|
80
|
+
entityType: "MessageReference",
|
|
81
|
+
key: { messageReferenceId: ref.messageReferenceId },
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
for (const addr of messageData.envelopeAddress) {
|
|
85
|
+
entities.push({
|
|
86
|
+
entityType: "EnvelopeAddress",
|
|
87
|
+
key: { envelopeAddressId: addr.envelopeAddressId },
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
for (const bp of messageData.bodyPart) {
|
|
91
|
+
entities.push({
|
|
92
|
+
entityType: "BodyPart",
|
|
93
|
+
key: { bodyPartId: bp.bodyPartId },
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
for (const bpp of messageData.bodyPartParameter) {
|
|
97
|
+
entities.push({
|
|
98
|
+
entityType: "BodyPartParameter",
|
|
99
|
+
key: { bodyPartParameterId: bpp.bodyPartParameterId },
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
for (const rms of messageData.rawMessageStorage) {
|
|
103
|
+
entities.push({
|
|
104
|
+
entityType: "RawMessageStorage",
|
|
105
|
+
key: { rawStorageId: rms.rawStorageId },
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
for (const bps of messageData.bodyPartStorage) {
|
|
109
|
+
entities.push({
|
|
110
|
+
entityType: "BodyPartStorage",
|
|
111
|
+
key: { bodyPartStorageId: bps.bodyPartStorageId },
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
for (const bpc of messageData.bodyPartContent) {
|
|
115
|
+
entities.push({
|
|
116
|
+
entityType: "BodyPartContent",
|
|
117
|
+
key: { bodyPartContentId: bpc.bodyPartContentId },
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
export const enumerateCascadeEntities = async (
|
|
123
|
+
accountConfigId: string,
|
|
124
|
+
services: CascadeServices,
|
|
125
|
+
log: Logger,
|
|
126
|
+
): Promise<CascadeResult> => {
|
|
127
|
+
const entities: CascadeEntity[] = [];
|
|
128
|
+
const messageIds: string[] = [];
|
|
129
|
+
|
|
130
|
+
const {
|
|
131
|
+
accountConfigService,
|
|
132
|
+
accountService,
|
|
133
|
+
messageService,
|
|
134
|
+
outboxMessageService,
|
|
135
|
+
threadMessageService,
|
|
136
|
+
mailboxLockService,
|
|
137
|
+
messagePlacementMoveService,
|
|
138
|
+
messageFlagPushService,
|
|
139
|
+
accountSettingService,
|
|
140
|
+
filterService,
|
|
141
|
+
filterAnchorService,
|
|
142
|
+
labelService,
|
|
143
|
+
messageLabelService,
|
|
144
|
+
} = services;
|
|
145
|
+
|
|
146
|
+
const description = await accountConfigService.describe(accountConfigId);
|
|
147
|
+
|
|
148
|
+
for (const account of description.account) {
|
|
149
|
+
entities.push({
|
|
150
|
+
entityType: "Account",
|
|
151
|
+
key: { accountId: account.accountId },
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
const accountDescription = await accountService.describe(account.accountId);
|
|
155
|
+
|
|
156
|
+
for (const mailbox of accountDescription.mailbox) {
|
|
157
|
+
entities.push({
|
|
158
|
+
entityType: "Mailbox",
|
|
159
|
+
key: { mailboxId: mailbox.mailboxId },
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
const messages = await messageService.listAllByMailbox(mailbox.mailboxId);
|
|
163
|
+
|
|
164
|
+
for (const message of messages) {
|
|
165
|
+
messageIds.push(message.messageId);
|
|
166
|
+
entities.push({
|
|
167
|
+
entityType: "Message",
|
|
168
|
+
key: { messageId: message.messageId },
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
const messageData = await messageService.describe(message.messageId);
|
|
172
|
+
|
|
173
|
+
for (const flag of messageData.messageFlag) {
|
|
174
|
+
entities.push({
|
|
175
|
+
entityType: "MessageFlag",
|
|
176
|
+
key: { messageFlagId: flag.messageFlagId },
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
for (const envelope of messageData.envelope) {
|
|
181
|
+
entities.push({
|
|
182
|
+
entityType: "Envelope",
|
|
183
|
+
key: { envelopeId: envelope.envelopeId },
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
for (const ref of messageData.messageReference) {
|
|
188
|
+
entities.push({
|
|
189
|
+
entityType: "MessageReference",
|
|
190
|
+
key: { messageReferenceId: ref.messageReferenceId },
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
for (const addr of messageData.envelopeAddress) {
|
|
195
|
+
entities.push({
|
|
196
|
+
entityType: "EnvelopeAddress",
|
|
197
|
+
key: { envelopeAddressId: addr.envelopeAddressId },
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
for (const bp of messageData.bodyPart) {
|
|
202
|
+
entities.push({
|
|
203
|
+
entityType: "BodyPart",
|
|
204
|
+
key: { bodyPartId: bp.bodyPartId },
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
for (const bpp of messageData.bodyPartParameter) {
|
|
209
|
+
entities.push({
|
|
210
|
+
entityType: "BodyPartParameter",
|
|
211
|
+
key: { bodyPartParameterId: bpp.bodyPartParameterId },
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
for (const rms of messageData.rawMessageStorage) {
|
|
216
|
+
entities.push({
|
|
217
|
+
entityType: "RawMessageStorage",
|
|
218
|
+
key: { rawStorageId: rms.rawStorageId },
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
for (const bps of messageData.bodyPartStorage) {
|
|
223
|
+
entities.push({
|
|
224
|
+
entityType: "BodyPartStorage",
|
|
225
|
+
key: { bodyPartStorageId: bps.bodyPartStorageId },
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
for (const bpc of messageData.bodyPartContent) {
|
|
230
|
+
entities.push({
|
|
231
|
+
entityType: "BodyPartContent",
|
|
232
|
+
key: { bodyPartContentId: bpc.bodyPartContentId },
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const outboxMessages = await outboxMessageService.listByAccount(
|
|
239
|
+
account.accountId,
|
|
240
|
+
);
|
|
241
|
+
for (const outbox of outboxMessages.items) {
|
|
242
|
+
entities.push({
|
|
243
|
+
entityType: "OutboxMessage",
|
|
244
|
+
key: { outboxMessageId: outbox.outboxMessageId },
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const locks = await mailboxLockService.listByAccount(account.accountId);
|
|
249
|
+
for (const lock of locks) {
|
|
250
|
+
entities.push({
|
|
251
|
+
entityType: "MailboxLock",
|
|
252
|
+
key: { mailboxId: lock.mailboxId, eventName: lock.eventName },
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const placementMoves = await messagePlacementMoveService.listByAccountId(
|
|
257
|
+
account.accountId,
|
|
258
|
+
);
|
|
259
|
+
for (const move of placementMoves) {
|
|
260
|
+
entities.push({
|
|
261
|
+
entityType: "MessagePlacementMove",
|
|
262
|
+
key: { messageId: move.messageId },
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const flagPushes = await messageFlagPushService.listByAccountId(
|
|
267
|
+
account.accountId,
|
|
268
|
+
);
|
|
269
|
+
for (const push of flagPushes) {
|
|
270
|
+
entities.push({
|
|
271
|
+
entityType: "MessageFlagPush",
|
|
272
|
+
key: { messageId: push.messageId, flagName: push.flagName },
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const threadMessages =
|
|
278
|
+
await threadMessageService.listAllByAccount(accountConfigId);
|
|
279
|
+
for (const tm of threadMessages) {
|
|
280
|
+
entities.push({
|
|
281
|
+
entityType: "ThreadMessage",
|
|
282
|
+
key: { accountConfigId, threadMessageId: tm.threadMessageId },
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const accountSettings =
|
|
287
|
+
await accountSettingService.listByAccountConfig(accountConfigId);
|
|
288
|
+
for (const setting of accountSettings) {
|
|
289
|
+
entities.push({
|
|
290
|
+
entityType: "AccountSetting",
|
|
291
|
+
key: { accountSettingId: setting.accountSettingId },
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Filter/FilterAnchor/Label/MessageLabel are accountConfig-scoped (RFC 034),
|
|
296
|
+
// not per sub-account, so — like AccountSetting and ThreadMessage above —
|
|
297
|
+
// they are enumerated once here rather than inside the per-account loop.
|
|
298
|
+
const filters = await filterService.listByAccountConfig(accountConfigId);
|
|
299
|
+
for (const filter of filters) {
|
|
300
|
+
entities.push({
|
|
301
|
+
entityType: "Filter",
|
|
302
|
+
key: { accountConfigId, filterId: filter.filterId },
|
|
303
|
+
});
|
|
304
|
+
if (!filter.hasAnchor) continue;
|
|
305
|
+
const anchor = await filterAnchorService.get(
|
|
306
|
+
accountConfigId,
|
|
307
|
+
filter.filterId,
|
|
308
|
+
);
|
|
309
|
+
if (!anchor) continue;
|
|
310
|
+
entities.push({
|
|
311
|
+
entityType: "FilterAnchor",
|
|
312
|
+
key: { accountConfigId, filterId: filter.filterId },
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const labels = await labelService.listByAccountConfig(accountConfigId);
|
|
317
|
+
for (const label of labels) {
|
|
318
|
+
entities.push({
|
|
319
|
+
entityType: "Label",
|
|
320
|
+
key: { accountConfigId, labelId: label.labelId },
|
|
321
|
+
});
|
|
322
|
+
const messageLabels = await messageLabelService.listByLabelId(
|
|
323
|
+
accountConfigId,
|
|
324
|
+
label.labelId,
|
|
325
|
+
);
|
|
326
|
+
for (const messageLabel of messageLabels) {
|
|
327
|
+
entities.push({
|
|
328
|
+
entityType: "MessageLabel",
|
|
329
|
+
key: { messageLabelId: messageLabel.messageLabelId },
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
for (const address of description.address) {
|
|
335
|
+
entities.push({
|
|
336
|
+
entityType: "Address",
|
|
337
|
+
key: { addressId: address.addressId },
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
entities.push({
|
|
342
|
+
entityType: "AccountConfig",
|
|
343
|
+
key: { accountConfigId },
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
log.info(
|
|
347
|
+
{
|
|
348
|
+
accountConfigId,
|
|
349
|
+
entityCount: entities.length,
|
|
350
|
+
messageCount: messageIds.length,
|
|
351
|
+
},
|
|
352
|
+
"Cascade enumeration complete",
|
|
353
|
+
);
|
|
354
|
+
|
|
355
|
+
return { entities, messageIds };
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
export const COVERED_ENTITY_TYPES = [
|
|
359
|
+
"Account",
|
|
360
|
+
"AccountConfig",
|
|
361
|
+
"AccountSetting",
|
|
362
|
+
"Address",
|
|
363
|
+
"BodyPart",
|
|
364
|
+
"BodyPartContent",
|
|
365
|
+
"BodyPartParameter",
|
|
366
|
+
"BodyPartStorage",
|
|
367
|
+
"Envelope",
|
|
368
|
+
"EnvelopeAddress",
|
|
369
|
+
"Filter",
|
|
370
|
+
"FilterAnchor",
|
|
371
|
+
"Label",
|
|
372
|
+
"Mailbox",
|
|
373
|
+
"MailboxLock",
|
|
374
|
+
"Message",
|
|
375
|
+
"MessageFlag",
|
|
376
|
+
"MessageFlagPush",
|
|
377
|
+
"MessageLabel",
|
|
378
|
+
"MessagePlacementMove",
|
|
379
|
+
"MessageReference",
|
|
380
|
+
"OutboxMessage",
|
|
381
|
+
"RawMessageStorage",
|
|
382
|
+
"ThreadMessage",
|
|
383
|
+
] as const;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdir, mkdtemp, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { afterEach, beforeEach, describe, it } from "node:test";
|
|
6
|
+
import type { Logger } from "@remit/logger-lambda";
|
|
7
|
+
import { buildRelationalDeletionCapabilities } from "./compose-relational.js";
|
|
8
|
+
|
|
9
|
+
const noopLog = {
|
|
10
|
+
info: () => {},
|
|
11
|
+
warn: () => {},
|
|
12
|
+
error: () => {},
|
|
13
|
+
debug: () => {},
|
|
14
|
+
fatal: () => {},
|
|
15
|
+
trace: () => {},
|
|
16
|
+
child: () => noopLog,
|
|
17
|
+
} as unknown as Logger;
|
|
18
|
+
|
|
19
|
+
const exists = (path: string): Promise<boolean> =>
|
|
20
|
+
stat(path)
|
|
21
|
+
.then(() => true)
|
|
22
|
+
.catch(() => false);
|
|
23
|
+
|
|
24
|
+
const writeUnder = async (base: string, key: string): Promise<string> => {
|
|
25
|
+
const full = join(base, key);
|
|
26
|
+
await mkdir(dirname(full), { recursive: true });
|
|
27
|
+
await writeFile(full, "x");
|
|
28
|
+
return full;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
describe("relational deletion capabilities — filesystem storage cleanup", () => {
|
|
32
|
+
let base: string;
|
|
33
|
+
const prevStoragePath = process.env.STORAGE_LOCAL_PATH;
|
|
34
|
+
|
|
35
|
+
beforeEach(async () => {
|
|
36
|
+
base = await mkdtemp(join(tmpdir(), "remit-relational-storage-"));
|
|
37
|
+
process.env.STORAGE_LOCAL_PATH = base;
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
afterEach(() => {
|
|
41
|
+
if (prevStoragePath === undefined) delete process.env.STORAGE_LOCAL_PATH;
|
|
42
|
+
else process.env.STORAGE_LOCAL_PATH = prevStoragePath;
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("removes every object under the account prefix, leaving siblings intact", async () => {
|
|
46
|
+
const caps = buildRelationalDeletionCapabilities();
|
|
47
|
+
|
|
48
|
+
const deleted = [
|
|
49
|
+
await writeUnder(base, "accounts/cfg-1/acc-1/messages/m1/body.eml"),
|
|
50
|
+
await writeUnder(base, "accounts/cfg-1/acc-2/messages/m2/parts/1"),
|
|
51
|
+
];
|
|
52
|
+
const kept = await writeUnder(
|
|
53
|
+
base,
|
|
54
|
+
"accounts/cfg-2/acc-9/messages/m9/body.eml",
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
await caps.deleteStoragePrefix("accounts/cfg-1/", noopLog);
|
|
58
|
+
|
|
59
|
+
for (const path of deleted) {
|
|
60
|
+
assert.equal(await exists(path), false, `${path} must be deleted`);
|
|
61
|
+
}
|
|
62
|
+
assert.equal(await exists(kept), true, "a different tenant must survive");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("scopes deletion to a single account when the prefix names one", async () => {
|
|
66
|
+
const caps = buildRelationalDeletionCapabilities();
|
|
67
|
+
|
|
68
|
+
const deleted = await writeUnder(
|
|
69
|
+
base,
|
|
70
|
+
"accounts/cfg-1/acc-1/messages/m1/body.eml",
|
|
71
|
+
);
|
|
72
|
+
const keptSibling = await writeUnder(
|
|
73
|
+
base,
|
|
74
|
+
"accounts/cfg-1/acc-2/messages/m2/body.eml",
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
await caps.deleteStoragePrefix("accounts/cfg-1/acc-1/", noopLog);
|
|
78
|
+
|
|
79
|
+
assert.equal(await exists(deleted), false);
|
|
80
|
+
assert.equal(
|
|
81
|
+
await exists(keptSibling),
|
|
82
|
+
true,
|
|
83
|
+
"a sibling account under the same tenant must survive",
|
|
84
|
+
);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("is replay-safe — deleting a missing prefix does not throw", async () => {
|
|
88
|
+
const caps = buildRelationalDeletionCapabilities();
|
|
89
|
+
await caps.deleteStoragePrefix("accounts/never-existed/", noopLog);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe("relational deletion capabilities — CDN and sign-out are no-ops", () => {
|
|
94
|
+
it("invalidateContent resolves without a CDN", async () => {
|
|
95
|
+
const caps = buildRelationalDeletionCapabilities();
|
|
96
|
+
await caps.invalidateContent("cfg-1", noopLog);
|
|
97
|
+
await caps.invalidateContent("cfg-1/acc-1", noopLog);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("signOut resolves without a federated session store", async () => {
|
|
101
|
+
const caps = buildRelationalDeletionCapabilities();
|
|
102
|
+
await caps.signOut("user-123", noopLog);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { rm } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
type CascadeDeleter,
|
|
5
|
+
createCascadeDeleter,
|
|
6
|
+
createSqliteCascadeDeleter,
|
|
7
|
+
} from "@remit/drizzle-service";
|
|
8
|
+
import type { Logger } from "@remit/logger-lambda";
|
|
9
|
+
import { env } from "expect-env";
|
|
10
|
+
import type { DeletionCapabilities } from "./deletion-capabilities.js";
|
|
11
|
+
|
|
12
|
+
// Mirrors `@remit/storage-service`'s filesystem-backend default: bodies
|
|
13
|
+
// live under `STORAGE_LOCAL_PATH` (`.remit/storage` when unset). The cascade
|
|
14
|
+
// deletes the same `accounts/{accountConfigId}/…` key prefixes the S3 backend
|
|
15
|
+
// would, translated to a recursive directory removal.
|
|
16
|
+
const storageBasePath = (): string =>
|
|
17
|
+
process.env.STORAGE_LOCAL_PATH ?? ".remit/storage";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Recursively remove every stored object under a key prefix. Replay-safe:
|
|
21
|
+
* `force: true` swallows a missing directory (ENOENT), matching the S3 backend's
|
|
22
|
+
* "delete on missing keys is a 200" idempotency.
|
|
23
|
+
*/
|
|
24
|
+
const deleteStoragePrefix = async (
|
|
25
|
+
keyPrefix: string,
|
|
26
|
+
log: Logger,
|
|
27
|
+
): Promise<void> => {
|
|
28
|
+
const target = join(storageBasePath(), keyPrefix);
|
|
29
|
+
await rm(target, { recursive: true, force: true });
|
|
30
|
+
log.info({ keyPrefix, target }, "Filesystem storage prefix cleanup complete");
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// The Drizzle cascade deleter is opened once, on first use, and reused. On
|
|
34
|
+
// Postgres it binds to `PG_CONNECTION_URL`; on SQLite it opens the shared file
|
|
35
|
+
// at `SQLITE_DB_PATH` (the native binding loads there, hence async). Built
|
|
36
|
+
// lazily so the fanout worker — which signs out but never cascade-deletes —
|
|
37
|
+
// does not open a database handle it will not use.
|
|
38
|
+
let deleterPromise: Promise<CascadeDeleter> | undefined;
|
|
39
|
+
|
|
40
|
+
const buildDeleter = async (): Promise<CascadeDeleter> => {
|
|
41
|
+
if (process.env.DATA_BACKEND === "sqlite") {
|
|
42
|
+
return createSqliteCascadeDeleter(env.SQLITE_DB_PATH);
|
|
43
|
+
}
|
|
44
|
+
return createCascadeDeleter(env.PG_CONNECTION_URL);
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const getDeleter = (): Promise<CascadeDeleter> => {
|
|
48
|
+
if (!deleterPromise) deleterPromise = buildDeleter();
|
|
49
|
+
return deleterPromise;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The self-host stack's deletion capabilities (RFC 035/036).
|
|
54
|
+
*
|
|
55
|
+
* - `signOut`: no-op. Deleting the AccountConfig row severs the session's
|
|
56
|
+
* account→data resolution, and there is no federated session store to sign
|
|
57
|
+
* out of; better-auth session rows are owned by the auth service, outside this
|
|
58
|
+
* worker's data seam.
|
|
59
|
+
* - `invalidateContent`: no-op. Caddy proxies `/content/*` straight to the
|
|
60
|
+
* backend with no response cache (`deploy/vps/caddy/routes.caddy`), so there
|
|
61
|
+
* is nothing to invalidate.
|
|
62
|
+
* - `deleteStoragePrefix`: recursive filesystem removal.
|
|
63
|
+
* - `cascadeDelete`: the Drizzle cascade over Postgres or SQLite. Message
|
|
64
|
+
* subtrees emit `message.removed` outbox rows the search-index worker relays.
|
|
65
|
+
*/
|
|
66
|
+
export const buildRelationalDeletionCapabilities =
|
|
67
|
+
(): DeletionCapabilities => ({
|
|
68
|
+
assertReady: async () => {},
|
|
69
|
+
signOut: async (userId, log) => {
|
|
70
|
+
log.info(
|
|
71
|
+
{ userId },
|
|
72
|
+
"Sign-out is a no-op on the relational backend; AccountConfig removal severs session data access",
|
|
73
|
+
);
|
|
74
|
+
},
|
|
75
|
+
invalidateContent: async (contentPrefix, log) => {
|
|
76
|
+
log.info(
|
|
77
|
+
{ contentPrefix },
|
|
78
|
+
"Content invalidation is a no-op on the relational backend; no CDN cache fronts /content",
|
|
79
|
+
);
|
|
80
|
+
},
|
|
81
|
+
deleteStoragePrefix: (keyPrefix, log) =>
|
|
82
|
+
deleteStoragePrefix(keyPrefix, log),
|
|
83
|
+
cascadeDelete: async (entities, log) => {
|
|
84
|
+
const deleter = await getDeleter();
|
|
85
|
+
await deleter(entities, log);
|
|
86
|
+
},
|
|
87
|
+
});
|