@remit/backend 0.0.122 → 0.0.124

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/backend",
3
- "version": "0.0.122",
3
+ "version": "0.0.124",
4
4
  "description": "Remit Mail Inspector API backend",
5
5
  "license": "MIT",
6
6
  "author": "",
@@ -0,0 +1,89 @@
1
+ import { existsSync } from "node:fs";
2
+ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
3
+ import { dirname } from "node:path";
4
+ import { logger } from "@remit/logger-lambda";
5
+ import {
6
+ backfillClassifications,
7
+ type ClassificationBackfillCheckpoint,
8
+ type ClassificationBackfillCheckpointStore,
9
+ } from "@remit/mailbox-service";
10
+ import { getClient } from "../src/service/data-client.js";
11
+
12
+ /**
13
+ * One-time, full-corpus classification backfill over the stored-body cohort
14
+ * the classifier never examined (issue #1197): rows whose bodies were synced
15
+ * before the classifier reached them, which carry `NotExamined` on
16
+ * `Message.classificationState` and so stay `uncategorized` in the list. The
17
+ * same alternate-entrypoint shape `backfill-list-id.mjs` uses: baked into the
18
+ * backend image, never wired into a compose one-shot, run by hand once per
19
+ * install:
20
+ *
21
+ * docker compose -f docker-compose.sqlite.yml run --rm backend \
22
+ * node backfill-classification.mjs
23
+ *
24
+ * Safe to interrupt: progress is checkpointed to disk after every page and
25
+ * picked back up on the next run, every already-examined row reads as
26
+ * `alreadyExamined` on a rerun, and a row whose category was already decided
27
+ * is recorded without re-deriving it, so a partial pass resumes rather than
28
+ * repeats.
29
+ */
30
+
31
+ const CHECKPOINT_PATH =
32
+ process.env.CLASSIFICATION_BACKFILL_CHECKPOINT_PATH ??
33
+ "/data/sqlite/classification-backfill-checkpoint.json";
34
+
35
+ const isCheckpoint = (
36
+ value: unknown,
37
+ ): value is ClassificationBackfillCheckpoint => {
38
+ if (typeof value !== "object" || value === null) return false;
39
+ if (!("accountConfigId" in value)) return false;
40
+ const candidate = value as Record<string, unknown>;
41
+ return (
42
+ typeof candidate.accountConfigId === "string" &&
43
+ (candidate.continuationToken === undefined ||
44
+ typeof candidate.continuationToken === "string")
45
+ );
46
+ };
47
+
48
+ const fileCheckpointStore: ClassificationBackfillCheckpointStore = {
49
+ load: async () => {
50
+ if (!existsSync(CHECKPOINT_PATH)) return undefined;
51
+ const raw = await readFile(CHECKPOINT_PATH, "utf8");
52
+ const parsed: unknown = JSON.parse(raw);
53
+ return isCheckpoint(parsed) ? parsed : undefined;
54
+ },
55
+ save: async (checkpoint) => {
56
+ await mkdir(dirname(CHECKPOINT_PATH), { recursive: true });
57
+ const staging = `${CHECKPOINT_PATH}.writing`;
58
+ await writeFile(staging, JSON.stringify(checkpoint));
59
+ await rename(staging, CHECKPOINT_PATH);
60
+ },
61
+ clear: async () => {
62
+ await rm(CHECKPOINT_PATH, { force: true });
63
+ },
64
+ };
65
+
66
+ const run = async (): Promise<void> => {
67
+ const client = await getClient();
68
+
69
+ const result = await backfillClassifications(
70
+ {
71
+ accountConfigService: client.accountConfig,
72
+ addressService: client.address,
73
+ threadMessageService: client.threadMessage,
74
+ messageService: client.message,
75
+ storageService: client.storage,
76
+ },
77
+ { checkpointStore: fileCheckpointStore, logger },
78
+ );
79
+
80
+ // biome-ignore lint/plugin/no-logger-info: a completed full-corpus backfill is an audit-grade signal
81
+ logger.info({ result }, "backfill done");
82
+ };
83
+
84
+ run()
85
+ .then(() => process.exit(0))
86
+ .catch((error: unknown) => {
87
+ logger.error({ error }, "backfill failed");
88
+ process.exit(1);
89
+ });
@@ -1,10 +1,11 @@
1
1
  import type {
2
2
  AccountResponse,
3
+ AccountService as AccountServiceName,
3
4
  FolderAppointment,
4
5
  } from "@remit/api-openapi-types";
5
6
  import type { AccountItem } from "@remit/data-ports";
6
7
  import { BadRequestError, ConflictError } from "@remit/data-ports/errors";
7
- import { AccountAuthType } from "@remit/domain-enums";
8
+ import { AccountAuthType, AccountService } from "@remit/domain-enums";
8
9
  import type { AccountOverrides } from "./account-overrides.js";
9
10
  import type { AccountSignature } from "./account-signature.js";
10
11
 
@@ -84,6 +85,41 @@ export const assertPasswordProvided = (
84
85
  }
85
86
  };
86
87
 
88
+ const MICROSOFT_GRANT_SERVICES: readonly AccountServiceName[] = [
89
+ AccountService.Mail,
90
+ ];
91
+
92
+ export const resolveSyncedServices = (
93
+ authType: string,
94
+ requested: readonly AccountServiceName[],
95
+ ): AccountServiceName[] => {
96
+ const services = Object.values(AccountService).filter((service) =>
97
+ requested.includes(service),
98
+ );
99
+ if (services.length === 0) {
100
+ throw new BadRequestError(
101
+ "syncedServices must name at least one service. To stop syncing an account entirely, delete it with DELETE /accounts/{accountId}.",
102
+ );
103
+ }
104
+ if (authType !== AccountAuthType.OauthMicrosoft) {
105
+ if (services.some((service) => service !== AccountService.Mail)) {
106
+ throw new BadRequestError(
107
+ `A password account syncs mail only, so syncedServices must be ["${AccountService.Mail}"].`,
108
+ );
109
+ }
110
+ return services;
111
+ }
112
+ const uncovered = services.filter(
113
+ (service) => !MICROSOFT_GRANT_SERVICES.includes(service),
114
+ );
115
+ if (uncovered.length > 0) {
116
+ throw new BadRequestError(
117
+ `This account's Microsoft consent does not cover ${uncovered.join(", ")}. Grant it through POST /accounts/oauth/microsoft/start.`,
118
+ );
119
+ }
120
+ return services;
121
+ };
122
+
87
123
  // SECURITY: passwordHash, oauthRefreshTokenHash, and smtpPasswordHash are
88
124
  // intentionally omitted — never expose token material in API responses.
89
125
  // Display name, mute flag, and signatures live in per-account AccountSetting
@@ -103,6 +139,7 @@ export const toAccountResponse = (
103
139
  username: account.username,
104
140
  email: account.email,
105
141
  authType: account.authType ?? AccountAuthType.Password,
142
+ syncedServices: account.syncedServices,
106
143
  imapHost: account.imapHost,
107
144
  imapPort: account.imapPort,
108
145
  imapTls: account.imapTls,
@@ -0,0 +1,217 @@
1
+ import assert from "node:assert/strict";
2
+ import { afterEach, beforeEach, describe, it } from "node:test";
3
+ import type { AccountResponse } from "@remit/api-openapi-types";
4
+ import type { AccountItem } from "@remit/data-ports";
5
+ import {
6
+ AccountAuthType,
7
+ AccountService,
8
+ ConnectionState,
9
+ } from "@remit/domain-enums";
10
+ import {
11
+ AccountConfigRepo,
12
+ AccountRepo,
13
+ AccountSettingRepo,
14
+ ConfigImportRepo,
15
+ MailboxRepo,
16
+ } from "@remit/drizzle-service";
17
+ import { createShippedSqliteDb } from "@remit/drizzle-service/test-sqlite";
18
+ import type { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda";
19
+ import type { Context } from "openapi-backend";
20
+ import { deriveAccountConfigId } from "../auth.js";
21
+ import { handleError } from "../error.js";
22
+ import { formatResponse } from "../response.js";
23
+ import {
24
+ _resetForTest,
25
+ type RemitClient,
26
+ setClient,
27
+ } from "../service/data-client.js";
28
+ import { AccountDetailOperations, AccountOperations } from "./account.js";
29
+ import { ConfigOperations } from "./config.js";
30
+
31
+ const SUB = "cognito-sub-1180";
32
+ const ACCOUNT_CONFIG_ID = deriveAccountConfigId(SUB);
33
+
34
+ type Handler = (
35
+ context: Context,
36
+ event: APIGatewayProxyEvent,
37
+ ) => Promise<unknown>;
38
+
39
+ const getConfig = ConfigOperations.ConfigOperations_getConfig as Handler;
40
+ const createAccount =
41
+ AccountOperations.AccountOperations_createAccount as Handler;
42
+ const updateAccount =
43
+ AccountDetailOperations.AccountDetailOperations_updateAccount as Handler;
44
+
45
+ const event = (body?: unknown): APIGatewayProxyEvent =>
46
+ ({
47
+ body: body === undefined ? null : JSON.stringify(body),
48
+ requestContext: { authorizer: { claims: { sub: SUB } } },
49
+ }) as unknown as APIGatewayProxyEvent;
50
+
51
+ const context = (params: Record<string, string> = {}): Context =>
52
+ ({ request: { params } }) as unknown as Context;
53
+
54
+ const respond = async (
55
+ run: () => Promise<unknown>,
56
+ ): Promise<APIGatewayProxyResult> =>
57
+ run().then(
58
+ (body) => formatResponse(body as Record<string, unknown>),
59
+ (error: unknown) => handleError(error),
60
+ );
61
+
62
+ const bodyOf = <T>(response: APIGatewayProxyResult): T =>
63
+ JSON.parse(response.body) as T;
64
+
65
+ let accounts: AccountRepo;
66
+ let close: () => void;
67
+
68
+ const seedAccount = (authType: AccountItem["authType"]) =>
69
+ accounts.create({
70
+ accountConfigId: ACCOUNT_CONFIG_ID,
71
+ username: `${authType}@example.com`,
72
+ email: `${authType}@example.com`,
73
+ authType,
74
+ imapHost: "imap.example.com",
75
+ imapPort: 993,
76
+ imapTls: true,
77
+ imapStartTls: false,
78
+ isActive: true,
79
+ connectionState: ConnectionState.NotAuthenticated,
80
+ });
81
+
82
+ const readServices = async (accountId: string): Promise<string[]> => {
83
+ const config = bodyOf<{ accounts: AccountResponse[] }>(
84
+ await respond(() => getConfig(context(), event())),
85
+ );
86
+ const account = config.accounts.find((item) => item.accountId === accountId);
87
+ assert.ok(account, `GET /config lists ${accountId}`);
88
+ return account.syncedServices;
89
+ };
90
+
91
+ const patchServices = (accountId: string, syncedServices: string[]) =>
92
+ respond(() =>
93
+ updateAccount(context({ accountId }), event({ syncedServices })),
94
+ );
95
+
96
+ beforeEach(async () => {
97
+ const store = createShippedSqliteDb();
98
+ close = store.close;
99
+ accounts = new AccountRepo(store.db);
100
+ const accountConfig = new AccountConfigRepo(store.db);
101
+ await accountConfig.create({
102
+ accountConfigId: ACCOUNT_CONFIG_ID,
103
+ userId: SUB,
104
+ });
105
+ setClient({
106
+ accountConfig,
107
+ account: accounts,
108
+ accountSetting: new AccountSettingRepo(store.db),
109
+ mailbox: new MailboxRepo(store.db),
110
+ configImport: new ConfigImportRepo(store.db),
111
+ } as unknown as RemitClient);
112
+ });
113
+
114
+ afterEach(() => {
115
+ _resetForTest();
116
+ close();
117
+ });
118
+
119
+ describe("the services an account syncs", () => {
120
+ it("reads mail for an account that never chose", async () => {
121
+ const account = await seedAccount(AccountAuthType.Password);
122
+
123
+ assert.deepEqual(await readServices(account.accountId), [
124
+ AccountService.Mail,
125
+ ]);
126
+ });
127
+
128
+ it("writes a selection the account can serve and reads it back", async () => {
129
+ const account = await seedAccount(AccountAuthType.OauthMicrosoft);
130
+
131
+ const response = await patchServices(account.accountId, [
132
+ AccountService.Mail,
133
+ AccountService.Mail,
134
+ ]);
135
+
136
+ assert.equal(response.statusCode, 200);
137
+ assert.deepEqual(bodyOf<AccountResponse>(response).syncedServices, [
138
+ AccountService.Mail,
139
+ ]);
140
+ assert.deepEqual(await readServices(account.accountId), [
141
+ AccountService.Mail,
142
+ ]);
143
+ });
144
+
145
+ it("refuses an empty selection and points at deleting the account", async () => {
146
+ const account = await seedAccount(AccountAuthType.OauthMicrosoft);
147
+
148
+ const response = await patchServices(account.accountId, []);
149
+
150
+ assert.equal(response.statusCode, 400);
151
+ assert.match(
152
+ bodyOf<{ message: string }>(response).message,
153
+ /DELETE \/accounts\/\{accountId\}/,
154
+ );
155
+ assert.deepEqual(await readServices(account.accountId), [
156
+ AccountService.Mail,
157
+ ]);
158
+ });
159
+
160
+ it("refuses calendar on a password account", async () => {
161
+ const account = await seedAccount(AccountAuthType.Password);
162
+
163
+ const response = await patchServices(account.accountId, [
164
+ AccountService.Mail,
165
+ AccountService.Calendar,
166
+ ]);
167
+
168
+ assert.equal(response.statusCode, 400);
169
+ assert.match(
170
+ bodyOf<{ message: string }>(response).message,
171
+ /password account syncs mail only/,
172
+ );
173
+ assert.deepEqual(await readServices(account.accountId), [
174
+ AccountService.Mail,
175
+ ]);
176
+ });
177
+
178
+ it("refuses calendar the Microsoft consent never covered and names the consent route", async () => {
179
+ const account = await seedAccount(AccountAuthType.OauthMicrosoft);
180
+
181
+ const response = await patchServices(account.accountId, [
182
+ AccountService.Calendar,
183
+ ]);
184
+
185
+ assert.equal(response.statusCode, 400);
186
+ assert.match(
187
+ bodyOf<{ message: string }>(response).message,
188
+ /POST \/accounts\/oauth\/microsoft\/start/,
189
+ );
190
+ assert.deepEqual(await readServices(account.accountId), [
191
+ AccountService.Mail,
192
+ ]);
193
+ });
194
+
195
+ it("refuses to create a password account that syncs calendar", async () => {
196
+ const response = await respond(() =>
197
+ createAccount(
198
+ context(),
199
+ event({
200
+ email: "person@example.com",
201
+ password: "hunter2",
202
+ imapHost: "imap.example.com",
203
+ imapPort: 993,
204
+ imapTls: true,
205
+ imapStartTls: false,
206
+ syncedServices: [AccountService.Calendar],
207
+ }),
208
+ ),
209
+ );
210
+
211
+ assert.equal(response.statusCode, 400);
212
+ assert.deepEqual(
213
+ await accounts.listAllByAccountConfig(ACCOUNT_CONFIG_ID),
214
+ [],
215
+ );
216
+ });
217
+ });
@@ -8,7 +8,11 @@ import type {
8
8
  UpdateAccountInput,
9
9
  } from "@remit/api-openapi-types";
10
10
  import type { AccountItem, IAccountRepository } from "@remit/data-ports";
11
- import { AccountAuthType, ConnectionState } from "@remit/domain-enums";
11
+ import {
12
+ AccountAuthType,
13
+ AccountService,
14
+ ConnectionState,
15
+ } from "@remit/domain-enums";
12
16
  import { logger } from "@remit/logger-lambda";
13
17
  import {
14
18
  createMailOAuthService,
@@ -42,6 +46,7 @@ import {
42
46
  assertNoDuplicateMailbox,
43
47
  assertNotOAuthCreate,
44
48
  assertPasswordProvided,
49
+ resolveSyncedServices,
45
50
  toAccountResponse,
46
51
  } from "./account-guards.js";
47
52
  import {
@@ -203,6 +208,11 @@ export const AccountOperations: Record<
203
208
  // Password accounts require a non-empty password
204
209
  assertPasswordProvided(input.authType, input.password);
205
210
 
211
+ const syncedServices = resolveSyncedServices(
212
+ AccountAuthType.Password,
213
+ input.syncedServices ?? [AccountService.Mail],
214
+ );
215
+
206
216
  const { account, accountConfig, accountSetting, mailbox, secrets } =
207
217
  await getClient();
208
218
 
@@ -235,6 +245,7 @@ export const AccountOperations: Record<
235
245
  email: input.email,
236
246
  username: input.username ?? input.email,
237
247
  authType: AccountAuthType.Password,
248
+ syncedServices,
238
249
  passwordHash,
239
250
  imapHost: input.imapHost,
240
251
  imapPort: input.imapPort,
@@ -405,6 +416,13 @@ export const AccountDetailOperations: Record<
405
416
  );
406
417
  }
407
418
 
419
+ if (input.syncedServices !== undefined) {
420
+ updates.syncedServices = resolveSyncedServices(
421
+ existing.authType,
422
+ input.syncedServices,
423
+ );
424
+ }
425
+
408
426
  // Copy non-password fields
409
427
  const fields = [
410
428
  "imapHost",