@remit/backend 0.0.25 → 0.0.27

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.25",
3
+ "version": "0.0.27",
4
4
  "description": "Remit Mail Inspector API backend",
5
5
  "license": "MIT",
6
6
  "author": "",
@@ -0,0 +1,104 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import type { MessageItem } from "@remit/data-ports";
4
+ import { PlacementAction, PlacementConfidence } from "@remit/domain-enums";
5
+ import { deriveAutoMoved } from "./autoMoved.js";
6
+
7
+ type AutoMovedInput = Pick<
8
+ MessageItem,
9
+ "movedByRemit" | "placementVerdict" | "filterMove"
10
+ >;
11
+
12
+ const confidentVerdict = {
13
+ action: PlacementAction.MoveToInbox,
14
+ confidence: PlacementConfidence.Confident,
15
+ fromPlacement: "junk",
16
+ reasons: ["provider=spam", "dmarc=pass"],
17
+ dryRun: false,
18
+ decidedAt: 1_700_000_000_000,
19
+ };
20
+
21
+ const filterMove = {
22
+ filterId: "flt-1",
23
+ sourceMailboxId: "mb-inbox",
24
+ destinationMailboxId: "mb-travel",
25
+ decidedAt: 1_700_000_000_000,
26
+ };
27
+
28
+ describe("deriveAutoMoved", () => {
29
+ it("returns undefined when the message was not moved by Remit", () => {
30
+ const message: AutoMovedInput = {
31
+ movedByRemit: false,
32
+ placementVerdict: confidentVerdict,
33
+ };
34
+ assert.equal(deriveAutoMoved(message), undefined);
35
+ });
36
+
37
+ it("projects a standing-filter move to its mailbox ids and filter id", () => {
38
+ const message: AutoMovedInput = { movedByRemit: true, filterMove };
39
+ assert.deepEqual(deriveAutoMoved(message), {
40
+ fromMailboxId: "mb-inbox",
41
+ destinationMailboxId: "mb-travel",
42
+ filterId: "flt-1",
43
+ });
44
+ });
45
+
46
+ it("never leaks the classifier action/fromPlacement for a filter move", () => {
47
+ const result = deriveAutoMoved({ movedByRemit: true, filterMove });
48
+ assert.equal(result?.action, undefined);
49
+ assert.equal(result?.fromPlacement, undefined);
50
+ });
51
+
52
+ it("a filter move outranks a co-present placement verdict", () => {
53
+ // Both a filter move and a classifier verdict can be recorded; the filter's
54
+ // exclusive move is what ultimately placed the message (RFC 034 Dec. 3.1).
55
+ const message: AutoMovedInput = {
56
+ movedByRemit: true,
57
+ filterMove,
58
+ placementVerdict: confidentVerdict,
59
+ };
60
+ assert.deepEqual(deriveAutoMoved(message), {
61
+ fromMailboxId: "mb-inbox",
62
+ destinationMailboxId: "mb-travel",
63
+ filterId: "flt-1",
64
+ });
65
+ });
66
+
67
+ it("projects a confident classifier move to action + fromPlacement", () => {
68
+ const message: AutoMovedInput = {
69
+ movedByRemit: true,
70
+ placementVerdict: confidentVerdict,
71
+ };
72
+ assert.deepEqual(deriveAutoMoved(message), {
73
+ action: PlacementAction.MoveToInbox,
74
+ fromPlacement: "junk",
75
+ });
76
+ });
77
+
78
+ it("drops an unsure classifier verdict", () => {
79
+ const message: AutoMovedInput = {
80
+ movedByRemit: true,
81
+ placementVerdict: {
82
+ ...confidentVerdict,
83
+ confidence: PlacementConfidence.Unsure,
84
+ },
85
+ };
86
+ assert.equal(deriveAutoMoved(message), undefined);
87
+ });
88
+
89
+ it("drops a dry-run classifier verdict", () => {
90
+ const message: AutoMovedInput = {
91
+ movedByRemit: true,
92
+ placementVerdict: { ...confidentVerdict, dryRun: true },
93
+ };
94
+ assert.equal(deriveAutoMoved(message), undefined);
95
+ });
96
+
97
+ it("does not crash on legacy verdict-less, filter-less moved data", () => {
98
+ // A message moved before either the verdict or the filter marker existed:
99
+ // movedByRemit is set but nothing is derivable. Must return undefined, not
100
+ // throw (issue #223 back-compat).
101
+ const message: AutoMovedInput = { movedByRemit: true };
102
+ assert.equal(deriveAutoMoved(message), undefined);
103
+ });
104
+ });
@@ -3,19 +3,47 @@ import type { MessageItem } from "@remit/data-ports";
3
3
  import { PlacementConfidence } from "@remit/domain-enums";
4
4
 
5
5
  /**
6
- * Project a Message's internal placement verdict to the public `AutoMovedInfo`
7
- * shape. Present only for a real, in-effect auto-move `movedByRemit` is
8
- * true, the verdict is `Confident`, and it was not a dry run. Confidence,
9
- * dryRun, decidedAt and reasons are internal diagnostics and never cross to
10
- * the wire; the client derives everything else (whether the move is still in
11
- * effect, the undo target) from `action`/`fromPlacement` plus current
12
- * placement.
6
+ * Project a Message's internal auto-move state to the public `AutoMovedInfo`
7
+ * shape. Present only for a real, in-effect auto-move; absent otherwise, so a
8
+ * message synced before either mechanism existed (no `filterMove`, no
9
+ * `placementVerdict`) projects nothing and renders no badge.
10
+ *
11
+ * Two mechanisms move mail and both raise `movedByRemit`:
12
+ *
13
+ * 1. A standing filter (RFC 034) records a `filterMove` marker naming the
14
+ * source folder, the destination folder, and the filter. It has no
15
+ * classifier direction/role, so the projection carries mailbox ids
16
+ * (`fromMailboxId`/`destinationMailboxId`) and the `filterId` for the
17
+ * Settings link — an arbitrary destination the Inbox/Junk-shaped `action`
18
+ * cannot name.
19
+ * 2. The Tier 0 classifier records a `placementVerdict`. Its `action`
20
+ * (MoveToInbox/MoveToJunk) and `fromPlacement` (inbox/junk/other) are the
21
+ * only fields that cross to the wire; confidence, dryRun, decidedAt and
22
+ * reasons stay internal.
23
+ *
24
+ * The filter marker outranks the verdict check: a matched filter's move is
25
+ * exclusive and outranks the classifier's placement move at index time (RFC 034
26
+ * Decision 3.1), so a message carrying both was ultimately moved by the filter.
27
+ * The client derives everything else (whether the move is still in effect, the
28
+ * undo target) from these fields plus current placement.
13
29
  */
14
30
  export const deriveAutoMoved = (
15
- message: Pick<MessageItem, "movedByRemit" | "placementVerdict">,
31
+ message: Pick<
32
+ MessageItem,
33
+ "movedByRemit" | "placementVerdict" | "filterMove"
34
+ >,
16
35
  ): AutoMovedInfo | undefined => {
17
36
  if (message.movedByRemit !== true) return undefined;
18
37
 
38
+ const filterMove = message.filterMove;
39
+ if (filterMove) {
40
+ return {
41
+ fromMailboxId: filterMove.sourceMailboxId,
42
+ destinationMailboxId: filterMove.destinationMailboxId,
43
+ filterId: filterMove.filterId,
44
+ };
45
+ }
46
+
19
47
  const verdict = message.placementVerdict;
20
48
  if (!verdict) return undefined;
21
49
  if (verdict.confidence !== PlacementConfidence.Confident) return undefined;
@@ -16,6 +16,7 @@ import { OrganizeJobDetailOperations, OrganizeOperations } from "./organize.js";
16
16
  import { OutboxDetailOperations, OutboxOperations } from "./outbox.js";
17
17
  import { SemanticSearchOperations } from "./search.js";
18
18
  import { SyncOperations } from "./sync.js";
19
+ import { SystemOperations } from "./system-update.js";
19
20
  import { ThreadDetailOperations, ThreadOperations } from "./thread.js";
20
21
  import { UnifiedThreadOperations } from "./unified-threads.js";
21
22
 
@@ -23,6 +24,7 @@ import { UnifiedThreadOperations } from "./unified-threads.js";
23
24
  export const handlers: Record<OperationIds, OperationHandler<any>> = {
24
25
  ...MeOperations,
25
26
  ...ConfigOperations,
27
+ ...SystemOperations,
26
28
  ...AccountOperations,
27
29
  ...AccountDetailOperations,
28
30
  ...MicrosoftOAuthOperations,
@@ -0,0 +1,286 @@
1
+ import assert from "node:assert/strict";
2
+ import {
3
+ mkdirSync,
4
+ mkdtempSync,
5
+ readFileSync,
6
+ rmSync,
7
+ writeFileSync,
8
+ } from "node:fs";
9
+ import { join } from "node:path";
10
+ import { after, afterEach, before, beforeEach, describe, it } from "node:test";
11
+ import { fileURLToPath } from "node:url";
12
+ import { createInstanceOwnerStore } from "@remit/auth-service";
13
+ import type { APIGatewayProxyEvent } from "aws-lambda";
14
+ import Database from "better-sqlite3";
15
+ import type { Context } from "openapi-backend";
16
+ import { SystemOperations } from "./system-update.js";
17
+
18
+ const tmpRoot = join(
19
+ fileURLToPath(new URL(".", import.meta.url)),
20
+ "..",
21
+ "..",
22
+ ".tmp",
23
+ "system-update",
24
+ );
25
+
26
+ const readMigrationSql = (relativePath: string): string =>
27
+ readFileSync(fileURLToPath(new URL(relativePath, import.meta.url)), "utf8");
28
+
29
+ const authMigrationSql = readMigrationSql(
30
+ "../../../../deploy/vps/migrations-sqlite/auth/0000_rainy_iron_monger.sql",
31
+ );
32
+ const metaMigrationSql = readMigrationSql(
33
+ "../../../../deploy/vps/migrations-sqlite/meta/0000_demonic_lilith.sql",
34
+ );
35
+
36
+ const OWNER = "the-owner";
37
+ const MANIFEST_URL = "https://updates.example.com/stable.json";
38
+
39
+ let dbPath: string;
40
+ let controlDir: string;
41
+
42
+ const okState = {
43
+ currentVersion: "v1.4.1",
44
+ check: {
45
+ status: "ok",
46
+ lastCheckedAt: "2026-07-20T08:00:00Z",
47
+ latestVersion: "v1.5.0",
48
+ publishedAt: "2026-07-18T09:00:00Z",
49
+ summary: "Faster search.",
50
+ releaseNotesUrl: "https://example.com/notes",
51
+ updateAvailable: true,
52
+ },
53
+ run: null,
54
+ };
55
+
56
+ const writeState = (state: unknown): void => {
57
+ writeFileSync(join(controlDir, "state.json"), JSON.stringify(state));
58
+ };
59
+
60
+ before(async () => {
61
+ mkdirSync(tmpRoot, { recursive: true });
62
+ dbPath = join(mkdtempSync(join(tmpRoot, "db-")), "app.db");
63
+ const sqlite = new Database(dbPath);
64
+ for (const sql of [authMigrationSql, metaMigrationSql]) {
65
+ for (const statement of sql.split("--> statement-breakpoint")) {
66
+ sqlite.exec(statement);
67
+ }
68
+ }
69
+ sqlite
70
+ .prepare(
71
+ "INSERT INTO auth_user (id, name, email, email_verified, created_at, updated_at) VALUES (?, ?, ?, 0, ?, ?)",
72
+ )
73
+ .run(OWNER, OWNER, "owner@example.com", 1000, 1000);
74
+ sqlite.close();
75
+ process.env.DATA_BACKEND = "sqlite";
76
+ process.env.SQLITE_DB_PATH = dbPath;
77
+ const store = await createInstanceOwnerStore({
78
+ provider: "sqlite",
79
+ connectionString: dbPath,
80
+ });
81
+ await store.claimIfUnclaimed(OWNER);
82
+ await store.close();
83
+ });
84
+
85
+ after(() => {
86
+ rmSync(tmpRoot, { recursive: true, force: true });
87
+ });
88
+
89
+ beforeEach(() => {
90
+ controlDir = mkdtempSync(join(tmpRoot, "control-"));
91
+ process.env.REMIT_UPDATE_CONTROL_DIR = controlDir;
92
+ process.env.REMIT_UPDATE_MANIFEST_URL = MANIFEST_URL;
93
+ process.env.REMIT_TAG = "v1.4.1";
94
+ });
95
+
96
+ afterEach(() => {
97
+ delete process.env.REMIT_UPDATE_MANIFEST_URL;
98
+ delete process.env.REMIT_UPDATE_CONTROL_DIR;
99
+ delete process.env.REMIT_TAG;
100
+ });
101
+
102
+ const buildEvent = (sub?: string): APIGatewayProxyEvent =>
103
+ ({
104
+ headers: {},
105
+ requestContext: sub ? { authorizer: { claims: { sub } } } : {},
106
+ }) as unknown as APIGatewayProxyEvent;
107
+
108
+ const postContext = (targetVersion: string): Context =>
109
+ ({ request: { requestBody: { targetVersion } } }) as unknown as Context;
110
+
111
+ const getSystemUpdate =
112
+ SystemOperations.SystemOperations_getSystemUpdate as unknown as (
113
+ context: Context,
114
+ event: APIGatewayProxyEvent,
115
+ ) => Promise<unknown>;
116
+
117
+ const applySystemUpdate =
118
+ SystemOperations.SystemOperations_applySystemUpdate as unknown as (
119
+ context: Context,
120
+ event: APIGatewayProxyEvent,
121
+ ) => Promise<unknown>;
122
+
123
+ const getUpdate = (event: APIGatewayProxyEvent) =>
124
+ getSystemUpdate({} as unknown as Context, event);
125
+
126
+ const applyUpdate = (targetVersion: string, event: APIGatewayProxyEvent) =>
127
+ applySystemUpdate(postContext(targetVersion), event);
128
+
129
+ const hasStatus = (value: unknown, code: number): boolean =>
130
+ typeof value === "object" &&
131
+ value !== null &&
132
+ (value as { statusCode?: number }).statusCode === code;
133
+
134
+ describe("GET /system/update", () => {
135
+ it("returns 404 when no manifest URL is configured", async () => {
136
+ delete process.env.REMIT_UPDATE_MANIFEST_URL;
137
+
138
+ const result = await getUpdate(buildEvent(OWNER));
139
+
140
+ assert.ok(hasStatus(result, 404));
141
+ });
142
+
143
+ it("returns 403 for a non-owner", async () => {
144
+ const result = await getUpdate(buildEvent("someone-else"));
145
+
146
+ assert.ok(hasStatus(result, 403));
147
+ });
148
+
149
+ it("returns 200 with the state file verbatim", async () => {
150
+ writeState(okState);
151
+
152
+ const result = await getUpdate(buildEvent(OWNER));
153
+
154
+ assert.deepEqual(result, okState);
155
+ });
156
+
157
+ it("returns a disabled, empty resource when no state file exists", async () => {
158
+ const result = await getUpdate(buildEvent(OWNER));
159
+
160
+ assert.deepEqual(result, {
161
+ currentVersion: "v1.4.1",
162
+ check: { status: "disabled" },
163
+ run: null,
164
+ });
165
+ });
166
+
167
+ it("returns 200 with the run block untouched when the check has failed", async () => {
168
+ const run = {
169
+ runId: "run-1",
170
+ fromVersion: "v1.3.0",
171
+ targetVersion: "v1.4.1",
172
+ phase: "committing",
173
+ outcome: "succeeded",
174
+ startedAt: "2026-07-19T08:00:00Z",
175
+ updatedAt: "2026-07-19T08:05:00Z",
176
+ message: "Update installed.",
177
+ logCommand: "remit logs backend",
178
+ };
179
+ writeState({
180
+ currentVersion: "v1.4.1",
181
+ check: {
182
+ status: "failed",
183
+ lastCheckedAt: "2026-07-20T08:00:00Z",
184
+ error: "The update manifest could not be fetched.",
185
+ },
186
+ run,
187
+ });
188
+
189
+ const result = (await getUpdate(buildEvent(OWNER))) as {
190
+ check: { status: string };
191
+ run: unknown;
192
+ };
193
+
194
+ assert.equal(result.check.status, "failed");
195
+ assert.deepEqual(result.run, run);
196
+ });
197
+ });
198
+
199
+ describe("POST /system/update", () => {
200
+ it("returns 404 when no manifest URL is configured", async () => {
201
+ delete process.env.REMIT_UPDATE_MANIFEST_URL;
202
+
203
+ const result = await applyUpdate("v1.5.0", buildEvent(OWNER));
204
+
205
+ assert.ok(hasStatus(result, 404));
206
+ });
207
+
208
+ it("returns 403 for a non-owner", async () => {
209
+ writeState(okState);
210
+
211
+ const result = await applyUpdate("v1.5.0", buildEvent("someone-else"));
212
+
213
+ assert.ok(hasStatus(result, 403));
214
+ });
215
+
216
+ it("returns 409 when a run is already in flight", async () => {
217
+ writeState({
218
+ ...okState,
219
+ run: {
220
+ runId: "run-9",
221
+ fromVersion: "v1.4.1",
222
+ targetVersion: "v1.5.0",
223
+ phase: "verifying",
224
+ outcome: null,
225
+ startedAt: "2026-07-20T08:01:00Z",
226
+ updatedAt: "2026-07-20T08:04:00Z",
227
+ message: "Waiting for the new version to answer.",
228
+ logCommand: "remit logs backend",
229
+ },
230
+ });
231
+
232
+ await assert.rejects(applyUpdate("v1.5.0", buildEvent(OWNER)), {
233
+ statusCode: 409,
234
+ });
235
+ });
236
+
237
+ it("returns 422 when the target is not the checked version", async () => {
238
+ writeState(okState);
239
+
240
+ await assert.rejects(applyUpdate("v9.9.9", buildEvent(OWNER)), {
241
+ statusCode: 422,
242
+ });
243
+ });
244
+
245
+ it("returns 422 when no check has ever succeeded", async () => {
246
+ await assert.rejects(applyUpdate("v1.5.0", buildEvent(OWNER)), {
247
+ statusCode: 422,
248
+ });
249
+ });
250
+
251
+ it("returns 202 with a run block carrying a fresh runId", async () => {
252
+ writeState(okState);
253
+
254
+ const result = (await applyUpdate("v1.5.0", buildEvent(OWNER))) as {
255
+ statusCode: number;
256
+ body: { run: { runId: string; targetVersion: string; outcome: null } };
257
+ };
258
+
259
+ assert.equal(result.statusCode, 202);
260
+ assert.equal(result.body.run.targetVersion, "v1.5.0");
261
+ assert.equal(result.body.run.outcome, null);
262
+ assert.ok(result.body.run.runId.length > 0);
263
+ });
264
+
265
+ it("writes request.json with exactly the four seam fields and nothing else", async () => {
266
+ writeState(okState);
267
+
268
+ await applyUpdate("v1.5.0", buildEvent(OWNER));
269
+
270
+ const request = JSON.parse(
271
+ readFileSync(join(controlDir, "request.json"), "utf8"),
272
+ );
273
+
274
+ assert.deepEqual(Object.keys(request).sort(), [
275
+ "requestedAt",
276
+ "requestedBy",
277
+ "runId",
278
+ "targetVersion",
279
+ ]);
280
+ assert.equal(request.targetVersion, "v1.5.0");
281
+ assert.equal(request.requestedBy, OWNER);
282
+ for (const forbidden of ["registry", "image", "digest"]) {
283
+ assert.ok(!(forbidden in request));
284
+ }
285
+ });
286
+ });
@@ -0,0 +1,189 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { readFileSync, renameSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import type {
5
+ SystemUpdateResponse,
6
+ SystemUpdateRun,
7
+ } from "@remit/api-openapi-types";
8
+ import { ConflictError, HTTPError } from "@remit/data-ports/errors";
9
+ import type { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda";
10
+ import type { Context } from "openapi-backend";
11
+ import { getSubFromEvent } from "../auth.js";
12
+ import type { OperationHandler, SystemOperationIds } from "../types.js";
13
+ import { requireInstanceOwner } from "./owner-guard.js";
14
+
15
+ class TargetVersionMismatchError extends HTTPError {
16
+ name = "TargetVersionMismatchError";
17
+ statusCode = 422;
18
+ }
19
+
20
+ const DEFAULT_CONTROL_DIR = "/data/control";
21
+
22
+ const controlDir = (): string =>
23
+ process.env.REMIT_UPDATE_CONTROL_DIR ?? DEFAULT_CONTROL_DIR;
24
+
25
+ const manifestUrl = (): string | undefined =>
26
+ process.env.REMIT_UPDATE_MANIFEST_URL;
27
+
28
+ const currentVersion = (): string => process.env.REMIT_TAG ?? "unknown";
29
+
30
+ const notFound = (): APIGatewayProxyResult => ({
31
+ statusCode: 404,
32
+ headers: { "Content-Type": "application/json" },
33
+ body: JSON.stringify({ message: "Not found" }),
34
+ });
35
+
36
+ /**
37
+ * The self-update seam is off unless a manifest URL is configured (RFC 037 D8),
38
+ * which the hosted deployment leaves unset. Both endpoints answer 404 in that
39
+ * case — before the owner guard, so a probe cannot tell an off surface from a
40
+ * forbidden one.
41
+ */
42
+ const guardManifestConfigured = (): APIGatewayProxyResult | null =>
43
+ manifestUrl() ? null : notFound();
44
+
45
+ /**
46
+ * Read `state.json` off the control volume. A missing file is an instance that
47
+ * has never checked or updated (RFC 037 D8), not an error, so it reads as
48
+ * `null`; the caller renders the disabled/empty resource. Any other read or
49
+ * parse failure propagates.
50
+ */
51
+ const readState = (): SystemUpdateResponse | null => {
52
+ let raw: string;
53
+ try {
54
+ raw = readFileSync(join(controlDir(), "state.json"), "utf8");
55
+ } catch (error) {
56
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
57
+ throw error;
58
+ }
59
+ return JSON.parse(raw) as SystemUpdateResponse;
60
+ };
61
+
62
+ const emptyResource = (): SystemUpdateResponse => ({
63
+ currentVersion: currentVersion(),
64
+ check: { status: "disabled" },
65
+ run: null,
66
+ });
67
+
68
+ const isRunInFlight = (run: SystemUpdateRun | null | undefined): boolean =>
69
+ run != null && run.outcome === null;
70
+
71
+ /**
72
+ * The version the caller may install is the one the last check reported. It
73
+ * exists only on a successful check; a disabled or failed check offers nothing
74
+ * to match, so any target is a mismatch.
75
+ */
76
+ const checkedVersion = (
77
+ state: SystemUpdateResponse | null,
78
+ ): string | undefined =>
79
+ state?.check.status === "ok" ? state.check.latestVersion : undefined;
80
+
81
+ /**
82
+ * Write `request.json` atomically with exactly the four fields the seam accepts
83
+ * (#133). The updater resolves the registry and every image reference from the
84
+ * manifest it fetches itself; a request naming any of those is rejected whole,
85
+ * so the backend never writes one. The temp file sits in the same directory as
86
+ * the target for the rename to be atomic on one filesystem.
87
+ */
88
+ const writeRequest = (request: {
89
+ runId: string;
90
+ targetVersion: string;
91
+ requestedAt: string;
92
+ requestedBy: string;
93
+ }): void => {
94
+ const dir = controlDir();
95
+ const tmp = join(dir, `.request.json.${request.runId}.tmp`);
96
+ writeFileSync(tmp, JSON.stringify(request), { mode: 0o644 });
97
+ renameSync(tmp, join(dir, "request.json"));
98
+ };
99
+
100
+ /**
101
+ * The resource returned by the POST. The updater has not yet written the
102
+ * authoritative run — it polls the seam — so this bootstraps the run block with
103
+ * the id just requested and the first phase, giving the client a `runId` to
104
+ * persist and poll (RFC 037 D9). The updater's own `state.json` supersedes it on
105
+ * the next read.
106
+ */
107
+ const requestedResource = (
108
+ state: SystemUpdateResponse | null,
109
+ runId: string,
110
+ targetVersion: string,
111
+ requestedAt: string,
112
+ ): SystemUpdateResponse => {
113
+ const from = state?.currentVersion ?? currentVersion();
114
+ return {
115
+ currentVersion: from,
116
+ check: state?.check ?? { status: "disabled" },
117
+ run: {
118
+ runId,
119
+ fromVersion: from,
120
+ targetVersion,
121
+ phase: "checking",
122
+ outcome: null,
123
+ startedAt: requestedAt,
124
+ updatedAt: requestedAt,
125
+ message: "The update has been requested.",
126
+ logCommand: "remit logs updater",
127
+ },
128
+ };
129
+ };
130
+
131
+ export const SystemOperations: Record<
132
+ SystemOperationIds,
133
+ OperationHandler<SystemOperationIds>
134
+ > = {
135
+ SystemOperations_getSystemUpdate: async (
136
+ _context: Context,
137
+ ...args: unknown[]
138
+ ): Promise<SystemUpdateResponse | APIGatewayProxyResult> => {
139
+ const offSurface = guardManifestConfigured();
140
+ if (offSurface) return offSurface;
141
+
142
+ const event = args[0] as APIGatewayProxyEvent;
143
+ const forbidden = await requireInstanceOwner(event);
144
+ if (forbidden) return forbidden;
145
+
146
+ return readState() ?? emptyResource();
147
+ },
148
+
149
+ SystemOperations_applySystemUpdate: async (
150
+ context: Context,
151
+ ...args: unknown[]
152
+ ): Promise<SystemUpdateResponse | APIGatewayProxyResult> => {
153
+ const offSurface = guardManifestConfigured();
154
+ if (offSurface) return offSurface;
155
+
156
+ const event = args[0] as APIGatewayProxyEvent;
157
+ const forbidden = await requireInstanceOwner(event);
158
+ if (forbidden) return forbidden;
159
+
160
+ const { targetVersion } = context.request.requestBody as {
161
+ targetVersion: string;
162
+ };
163
+ const state = readState();
164
+
165
+ if (isRunInFlight(state?.run)) {
166
+ throw new ConflictError("An update is already in progress.");
167
+ }
168
+
169
+ if (targetVersion !== checkedVersion(state)) {
170
+ throw new TargetVersionMismatchError(
171
+ "targetVersion does not match the latest checked release.",
172
+ );
173
+ }
174
+
175
+ const runId = randomUUID();
176
+ const requestedAt = new Date().toISOString();
177
+ writeRequest({
178
+ runId,
179
+ targetVersion,
180
+ requestedAt,
181
+ requestedBy: getSubFromEvent(event) ?? "",
182
+ });
183
+
184
+ return {
185
+ statusCode: 202,
186
+ body: requestedResource(state, runId, targetVersion, requestedAt),
187
+ } as unknown as APIGatewayProxyResult;
188
+ },
189
+ };
@@ -33,6 +33,7 @@ import {
33
33
  MailboxQueueService,
34
34
  MessageMoveService,
35
35
  OutboxQueueService,
36
+ PlacementMoveService,
36
37
  } from "@remit/mailbox-service";
37
38
  import { createSearchService, type SearchService } from "@remit/search-service";
38
39
  import {
@@ -287,6 +288,34 @@ export const createRemitClient = (deps: RemitClientDeps): RemitClient => {
287
288
  bodySyncQueue,
288
289
  } = deps;
289
290
 
291
+ // The read-path body backfill (describeMessage / getRawMessage →
292
+ // fetchAndGetBody) materializes a body that was never sync-synced, and must
293
+ // run the account's standing filters the same way the imap-worker's sync path
294
+ // does. Without this, a message a user opens before background body-sync is
295
+ // classified but never filter-evaluated — and once its body is stored the
296
+ // filter-capable sync path skips it, so an explicit standing filter silently
297
+ // never fires (issue #223). Only the explicit user-rule half is wired here;
298
+ // heuristic classifier placement stays on the sync path. Wired like
299
+ // `sync-message-body.ts`, over the message-management queue's local-first
300
+ // PlacementMoveService; filters stay off when that queue is unset.
301
+ const placementMoveQueueUrl = process.env.SQS_QUEUE_URL_MESSAGE_MGMT;
302
+ const placementMoveService = placementMoveQueueUrl
303
+ ? new PlacementMoveService({
304
+ messageService: repositories.message,
305
+ threadMessageService: repositories.threadMessage,
306
+ markerService: repositories.placementMove,
307
+ sqsQueueUrl: placementMoveQueueUrl,
308
+ })
309
+ : undefined;
310
+ const filterConfig = placementMoveService
311
+ ? {
312
+ filterService: repositories.filter,
313
+ filterAnchorService: repositories.filterAnchor,
314
+ messageLabelService: repositories.messageLabel,
315
+ placementMoveService,
316
+ }
317
+ : undefined;
318
+
290
319
  const bodySync = new BodySyncService(
291
320
  repositories.message,
292
321
  storage,
@@ -294,6 +323,8 @@ export const createRemitClient = (deps: RemitClientDeps): RemitClient => {
294
323
  repositories.address,
295
324
  repositories.envelope,
296
325
  logger,
326
+ undefined,
327
+ filterConfig,
297
328
  );
298
329
 
299
330
  const flagPushService = new FlagPushService({
package/src/types.ts CHANGED
@@ -12,6 +12,8 @@ export type OperationIds =
12
12
  | "MeOperations_getExport"
13
13
  | "MeOperations_listQuarantine"
14
14
  | "ConfigOperations_getConfig"
15
+ | "SystemOperations_getSystemUpdate"
16
+ | "SystemOperations_applySystemUpdate"
15
17
  | "AccountOperations_createAccount"
16
18
  | "AccountOperations_testConnection"
17
19
  | "AccountDetailOperations_updateAccount"
@@ -60,6 +62,8 @@ export type MeOperationIds = MatchPrefix<"MeOperations_", OperationIds>;
60
62
 
61
63
  export type ConfigOperationIds = MatchPrefix<"ConfigOperations_", OperationIds>;
62
64
 
65
+ export type SystemOperationIds = MatchPrefix<"SystemOperations_", OperationIds>;
66
+
63
67
  export type AccountOperationIds = MatchPrefix<
64
68
  "AccountOperations_",
65
69
  OperationIds