@remit/backend 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.
Files changed (87) hide show
  1. package/.env.test +7 -0
  2. package/dev-server/content-auth.test.ts +113 -0
  3. package/dev-server/content-auth.ts +54 -0
  4. package/dev-server/content-handler.test.ts +105 -0
  5. package/dev-server/content-handler.ts +79 -0
  6. package/dev-server/content-path.test.ts +37 -0
  7. package/dev-server/content-path.ts +27 -0
  8. package/dev-server/cors.test.ts +48 -0
  9. package/dev-server/cors.ts +25 -0
  10. package/dev-server/lambda-helpers.ts +69 -0
  11. package/dev-server/server.ts +289 -0
  12. package/package.json +82 -0
  13. package/src/auth.ts +92 -0
  14. package/src/config/msoauth.ts +60 -0
  15. package/src/data-backend.test.ts +25 -0
  16. package/src/data-backend.ts +20 -0
  17. package/src/derive/autoMoved.ts +28 -0
  18. package/src/derive/contentSignature.test.ts +153 -0
  19. package/src/derive/contentSignature.ts +139 -0
  20. package/src/derive/contentUrl.test.ts +156 -0
  21. package/src/derive/contentUrl.ts +70 -0
  22. package/src/derive/enrichThreadRows.ts +155 -0
  23. package/src/derive/filterThreadCriteria.test.ts +119 -0
  24. package/src/derive/filterThreadCriteria.ts +60 -0
  25. package/src/derive/pendingMoveCounts.ts +90 -0
  26. package/src/derive/senderTrust.test.ts +67 -0
  27. package/src/derive/senderTrust.ts +23 -0
  28. package/src/error.ts +55 -0
  29. package/src/handlers/account-guards.ts +137 -0
  30. package/src/handlers/account-oauth.test.ts +383 -0
  31. package/src/handlers/account-oauth.ts +452 -0
  32. package/src/handlers/account-overrides.test.ts +69 -0
  33. package/src/handlers/account-overrides.ts +286 -0
  34. package/src/handlers/account-ownership.ts +25 -0
  35. package/src/handlers/account-signature.ts +129 -0
  36. package/src/handlers/account.ts +524 -0
  37. package/src/handlers/address.ts +136 -0
  38. package/src/handlers/config.test.ts +184 -0
  39. package/src/handlers/config.ts +219 -0
  40. package/src/handlers/ensure-account-config.ts +30 -0
  41. package/src/handlers/filter.ts +294 -0
  42. package/src/handlers/folder-role-appointments.test.ts +250 -0
  43. package/src/handlers/folder-role-appointments.ts +272 -0
  44. package/src/handlers/folder-role.ts +76 -0
  45. package/src/handlers/index.ts +48 -0
  46. package/src/handlers/mailbox.ts +411 -0
  47. package/src/handlers/me.ts +190 -0
  48. package/src/handlers/message.ts +886 -0
  49. package/src/handlers/organize.test.ts +43 -0
  50. package/src/handlers/organize.ts +196 -0
  51. package/src/handlers/outbox.ts +218 -0
  52. package/src/handlers/search.test.ts +182 -0
  53. package/src/handlers/search.ts +105 -0
  54. package/src/handlers/sync-progress.test.ts +158 -0
  55. package/src/handlers/sync-progress.ts +64 -0
  56. package/src/handlers/sync.test.ts +146 -0
  57. package/src/handlers/sync.ts +119 -0
  58. package/src/handlers/thread.ts +361 -0
  59. package/src/handlers/unified-threads.ts +196 -0
  60. package/src/handlers/vip-suggestions.ts +21 -0
  61. package/src/index.ts +170 -0
  62. package/src/json.ts +11 -0
  63. package/src/jwt-auth.test.ts +89 -0
  64. package/src/jwt-auth.ts +95 -0
  65. package/src/request-context.test.ts +61 -0
  66. package/src/request-context.ts +29 -0
  67. package/src/request.ts +10 -0
  68. package/src/response.test.ts +107 -0
  69. package/src/response.ts +80 -0
  70. package/src/service/compose-postgres.ts +72 -0
  71. package/src/service/compose-sqlite.ts +80 -0
  72. package/src/service/create-remit-client.ts +358 -0
  73. package/src/service/dynamodb.test.ts +100 -0
  74. package/src/service/dynamodb.ts +58 -0
  75. package/src/service/filter.ts +55 -0
  76. package/src/service/fire-and-forget.test.ts +126 -0
  77. package/src/service/fire-and-forget.ts +79 -0
  78. package/src/service/localhost-env-config.test.ts +40 -0
  79. package/src/service/organize.test.ts +384 -0
  80. package/src/service/organize.ts +354 -0
  81. package/src/service/semantic-capability.test.ts +64 -0
  82. package/src/service/semantic-capability.ts +62 -0
  83. package/src/service/sqs.ts +4 -0
  84. package/src/service/trigger-sync.test.ts +211 -0
  85. package/src/service/trigger-sync.ts +102 -0
  86. package/src/types.ts +161 -0
  87. package/tsconfig.json +7 -0
@@ -0,0 +1,119 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import type { ThreadMessageResponse } from "@remit/api-openapi-types";
4
+ import { MessageCategory, SenderTrust, StarColor } from "@remit/domain-enums";
5
+ import {
6
+ filterByOffRowCriteria,
7
+ hasOffRowCriteria,
8
+ } from "./filterThreadCriteria.js";
9
+
10
+ const row = (
11
+ overrides: Partial<ThreadMessageResponse>,
12
+ ): ThreadMessageResponse => ({
13
+ threadMessageId: "tm",
14
+ threadId: "t",
15
+ messageId: "m",
16
+ accountConfigId: "acc",
17
+ mailboxId: "mb",
18
+ subject: "s",
19
+ fromEmail: "a@example.com",
20
+ fromName: "A",
21
+ sentDate: 0,
22
+ isRead: false,
23
+ hasAttachment: false,
24
+ hasStars: false,
25
+ star: StarColor.None,
26
+ isDeleted: false,
27
+ snippet: "",
28
+ createdAt: 0,
29
+ updatedAt: 0,
30
+ senderTrust: SenderTrust.Unknown,
31
+ ...overrides,
32
+ });
33
+
34
+ describe("hasOffRowCriteria", () => {
35
+ it("is false for an empty criteria object", () => {
36
+ assert.equal(hasOffRowCriteria({}), false);
37
+ });
38
+
39
+ it("is false for empty arrays", () => {
40
+ assert.equal(hasOffRowCriteria({ senderTrust: [], category: [] }), false);
41
+ });
42
+
43
+ it("is true when any criterion is set", () => {
44
+ assert.equal(hasOffRowCriteria({ senderTrust: [SenderTrust.Vip] }), true);
45
+ assert.equal(hasOffRowCriteria({ dkimMismatch: false }), true);
46
+ });
47
+ });
48
+
49
+ describe("filterByOffRowCriteria", () => {
50
+ it("returns rows untouched when no criteria are active", () => {
51
+ const rows = [row({}), row({})];
52
+ assert.equal(filterByOffRowCriteria(rows, {}).length, 2);
53
+ });
54
+
55
+ it("filters by senderTrust any-of", () => {
56
+ const rows = [
57
+ row({ senderTrust: SenderTrust.Vip }),
58
+ row({ senderTrust: SenderTrust.Wellknown }),
59
+ row({ senderTrust: SenderTrust.Unknown }),
60
+ ];
61
+ const result = filterByOffRowCriteria(rows, {
62
+ senderTrust: [SenderTrust.Vip, SenderTrust.Wellknown],
63
+ });
64
+ assert.equal(result.length, 2);
65
+ assert.ok(result.every((r) => r.senderTrust !== SenderTrust.Unknown));
66
+ });
67
+
68
+ it("filters by category any-of and excludes rows with no category", () => {
69
+ const rows = [
70
+ row({ category: MessageCategory.newsletter }),
71
+ row({ category: MessageCategory.personal }),
72
+ row({ category: undefined }),
73
+ ];
74
+ const result = filterByOffRowCriteria(rows, {
75
+ category: [MessageCategory.newsletter],
76
+ });
77
+ assert.equal(result.length, 1);
78
+ assert.equal(result[0].category, MessageCategory.newsletter);
79
+ });
80
+
81
+ it("filters by dkimMismatch and never matches rows lacking an authenticity signal", () => {
82
+ const rows = [
83
+ row({ authenticity: { fromDomain: "x", dkimMismatch: false } }),
84
+ row({ authenticity: { fromDomain: "y", dkimMismatch: true } }),
85
+ row({ authenticity: undefined }),
86
+ ];
87
+ const aligned = filterByOffRowCriteria(rows, { dkimMismatch: false });
88
+ assert.equal(aligned.length, 1);
89
+ assert.equal(aligned[0].authenticity?.dkimMismatch, false);
90
+
91
+ const mismatched = filterByOffRowCriteria(rows, { dkimMismatch: true });
92
+ assert.equal(mismatched.length, 1);
93
+ assert.equal(mismatched[0].authenticity?.dkimMismatch, true);
94
+ });
95
+
96
+ it("ANDs criteria together (Rescue shape: trust set + dkim aligned)", () => {
97
+ const rows = [
98
+ row({
99
+ senderTrust: SenderTrust.Vip,
100
+ authenticity: { fromDomain: "x", dkimMismatch: false },
101
+ }),
102
+ row({
103
+ senderTrust: SenderTrust.Vip,
104
+ authenticity: { fromDomain: "y", dkimMismatch: true },
105
+ }),
106
+ row({
107
+ senderTrust: SenderTrust.Unknown,
108
+ authenticity: { fromDomain: "z", dkimMismatch: false },
109
+ }),
110
+ ];
111
+ const result = filterByOffRowCriteria(rows, {
112
+ senderTrust: [SenderTrust.Vip, SenderTrust.Wellknown],
113
+ dkimMismatch: false,
114
+ });
115
+ assert.equal(result.length, 1);
116
+ assert.equal(result[0].senderTrust, SenderTrust.Vip);
117
+ assert.equal(result[0].authenticity?.dkimMismatch, false);
118
+ });
119
+ });
@@ -0,0 +1,60 @@
1
+ import type {
2
+ MessageCategory,
3
+ SenderTrust,
4
+ ThreadMessageResponse,
5
+ } from "@remit/api-openapi-types";
6
+
7
+ /**
8
+ * Off-row search criteria — fields that live on the underlying Message/Address,
9
+ * not on the ThreadMessage DynamoDB item. They are resolved by enriching the
10
+ * windowed rows (see enrichThreadRows) and filtering in app code, because no
11
+ * index or FilterExpression can serve them.
12
+ */
13
+ export interface OffRowCriteria {
14
+ senderTrust?: SenderTrust[];
15
+ category?: MessageCategory[];
16
+ dkimMismatch?: boolean;
17
+ }
18
+
19
+ export const hasOffRowCriteria = (criteria: OffRowCriteria): boolean =>
20
+ Boolean(criteria.senderTrust?.length) ||
21
+ Boolean(criteria.category?.length) ||
22
+ criteria.dkimMismatch !== undefined;
23
+
24
+ /**
25
+ * Filter enriched rows by the off-row criteria. Each active criterion is an
26
+ * any-of set (AND across criteria, OR within a set). A row with no `category`
27
+ * never matches a category filter; a row with no `authenticity` signal never
28
+ * matches a `dkimMismatch` filter (absence means no signal, not a verdict).
29
+ */
30
+ export const filterByOffRowCriteria = (
31
+ rows: ThreadMessageResponse[],
32
+ criteria: OffRowCriteria,
33
+ ): ThreadMessageResponse[] => {
34
+ if (!hasOffRowCriteria(criteria)) return rows;
35
+
36
+ const trustSet = criteria.senderTrust?.length
37
+ ? new Set(criteria.senderTrust)
38
+ : undefined;
39
+ const categorySet = criteria.category?.length
40
+ ? new Set(criteria.category)
41
+ : undefined;
42
+ const { dkimMismatch } = criteria;
43
+
44
+ return rows.filter((row) => {
45
+ if (trustSet && !trustSet.has(row.senderTrust)) return false;
46
+ if (
47
+ categorySet &&
48
+ (row.category === undefined || !categorySet.has(row.category))
49
+ ) {
50
+ return false;
51
+ }
52
+ if (
53
+ dkimMismatch !== undefined &&
54
+ row.authenticity?.dkimMismatch !== dkimMismatch
55
+ ) {
56
+ return false;
57
+ }
58
+ return true;
59
+ });
60
+ };
@@ -0,0 +1,90 @@
1
+ import type { MailboxItem } from "@remit/data-ports";
2
+
3
+ /**
4
+ * The subset of a pending placement-move marker (issue #1271) this
5
+ * derivation needs — decoupled from the concrete marker type so it stays a
6
+ * pure function over plain data.
7
+ */
8
+ export interface PendingPlacementMove {
9
+ sourceMailboxId: string;
10
+ destinationMailboxId: string;
11
+ }
12
+
13
+ /**
14
+ * The subset of a pending flag-push marker (issue #1273) this derivation
15
+ * needs. Only `\Seen` markers are meaningful here — `mailboxId` is the
16
+ * marker's denormalized "last-known" mailbox, the same read-time-only hint
17
+ * `applyPendingMoveCountPrediction` already tolerates for placement moves.
18
+ */
19
+ export interface PendingUnseenFlagPush {
20
+ mailboxId: string;
21
+ operation: "add" | "remove";
22
+ }
23
+
24
+ /**
25
+ * Adjusts `messageCount` and `unseenCount` on a set of mailboxes to reflect
26
+ * every pending placement move (issue #1271) and pending `\Seen` flag push
27
+ * (issue #1273): a prediction applied at READ TIME, never written back to
28
+ * storage (epic #1281 invariant 4). Stored counts are refreshed only by
29
+ * mailbox-sync re-reading IMAP.
30
+ *
31
+ * - A pending move has already relocated the message locally
32
+ * (`ThreadMessage.mailboxId`), so its destination folder's live listing
33
+ * outnumbers the stored `messageCount` by one until the marker confirms
34
+ * and a resync catches up.
35
+ * - A pending `\Seen` push has already flipped `ThreadMessage.isRead`
36
+ * locally: `add` (marked read) means the stored `unseenCount` is one HIGH
37
+ * until the push confirms; `remove` (marked unread) means it is one LOW.
38
+ * Only `\Seen` markers carry read/unread state — a pending `\Flagged`
39
+ * (star) marker never adjusts either count, same reasoning the placement
40
+ * move prediction already applies to its own scope.
41
+ *
42
+ * Pure: takes the mailboxes and the pending markers as plain input, returns a
43
+ * new array. Never mutates its arguments.
44
+ */
45
+ export const applyPendingMoveCountPrediction = (
46
+ mailboxes: readonly MailboxItem[],
47
+ pendingMoves: readonly PendingPlacementMove[],
48
+ pendingUnseenFlagPushes: readonly PendingUnseenFlagPush[] = [],
49
+ ): MailboxItem[] => {
50
+ if (pendingMoves.length === 0 && pendingUnseenFlagPushes.length === 0) {
51
+ return [...mailboxes];
52
+ }
53
+
54
+ const messageCountDelta = new Map<string, number>();
55
+ for (const move of pendingMoves) {
56
+ if (move.sourceMailboxId === move.destinationMailboxId) continue;
57
+ messageCountDelta.set(
58
+ move.sourceMailboxId,
59
+ (messageCountDelta.get(move.sourceMailboxId) ?? 0) - 1,
60
+ );
61
+ messageCountDelta.set(
62
+ move.destinationMailboxId,
63
+ (messageCountDelta.get(move.destinationMailboxId) ?? 0) + 1,
64
+ );
65
+ }
66
+
67
+ const unseenCountDelta = new Map<string, number>();
68
+ for (const push of pendingUnseenFlagPushes) {
69
+ const adjustment = push.operation === "add" ? -1 : 1;
70
+ unseenCountDelta.set(
71
+ push.mailboxId,
72
+ (unseenCountDelta.get(push.mailboxId) ?? 0) + adjustment,
73
+ );
74
+ }
75
+
76
+ return mailboxes.map((mailbox) => {
77
+ const messageAdjustment = messageCountDelta.get(mailbox.mailboxId);
78
+ const unseenAdjustment = unseenCountDelta.get(mailbox.mailboxId);
79
+ if (!messageAdjustment && !unseenAdjustment) return mailbox;
80
+ return {
81
+ ...mailbox,
82
+ messageCount: messageAdjustment
83
+ ? Math.max(0, mailbox.messageCount + messageAdjustment)
84
+ : mailbox.messageCount,
85
+ unseenCount: unseenAdjustment
86
+ ? Math.max(0, mailbox.unseenCount + unseenAdjustment)
87
+ : mailbox.unseenCount,
88
+ };
89
+ });
90
+ };
@@ -0,0 +1,67 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import type { AddressFlags } from "@remit/api-openapi-types";
4
+ import { deriveSenderTrust } from "./senderTrust.js";
5
+
6
+ // AddressFlagBase requires setAt; deriveSenderTrust only reads `value`, so the
7
+ // timestamp value is irrelevant to these assertions.
8
+ const SET_AT = 1_700_000_000_000;
9
+
10
+ describe("deriveSenderTrust", () => {
11
+ it("returns 'unknown' when flags is undefined", () => {
12
+ assert.equal(deriveSenderTrust(undefined), "unknown");
13
+ });
14
+
15
+ it("returns 'unknown' for an empty flags object", () => {
16
+ assert.equal(deriveSenderTrust({}), "unknown");
17
+ });
18
+
19
+ it("returns 'unknown' when wellknown.value is false", () => {
20
+ const flags: AddressFlags = { wellknown: { value: false, setAt: SET_AT } };
21
+ assert.equal(deriveSenderTrust(flags), "unknown");
22
+ });
23
+
24
+ it("returns 'wellknown' when wellknown.value is true", () => {
25
+ const flags: AddressFlags = { wellknown: { value: true, setAt: SET_AT } };
26
+ assert.equal(deriveSenderTrust(flags), "wellknown");
27
+ });
28
+
29
+ it("returns 'vip' when vip.value is true", () => {
30
+ const flags: AddressFlags = { vip: { value: true, setAt: SET_AT } };
31
+ assert.equal(deriveSenderTrust(flags), "vip");
32
+ });
33
+
34
+ it("returns 'unknown' when vip.value is false", () => {
35
+ const flags: AddressFlags = { vip: { value: false, setAt: SET_AT } };
36
+ assert.equal(deriveSenderTrust(flags), "unknown");
37
+ });
38
+
39
+ it("vip wins over wellknown when both are true", () => {
40
+ const flags: AddressFlags = {
41
+ vip: { value: true, setAt: SET_AT },
42
+ wellknown: { value: true, setAt: SET_AT },
43
+ };
44
+ assert.equal(deriveSenderTrust(flags), "vip");
45
+ });
46
+
47
+ it("falls back to wellknown when vip is false but wellknown is true", () => {
48
+ const flags: AddressFlags = {
49
+ vip: { value: false, setAt: SET_AT },
50
+ wellknown: { value: true, setAt: SET_AT },
51
+ };
52
+ assert.equal(deriveSenderTrust(flags), "wellknown");
53
+ });
54
+
55
+ it("ignores trusted flag (orthogonal axis)", () => {
56
+ const flags: AddressFlags = { trusted: { value: true, setAt: SET_AT } };
57
+ assert.equal(deriveSenderTrust(flags), "unknown");
58
+ });
59
+
60
+ it("ignores trusted even when wellknown is also set", () => {
61
+ const flags: AddressFlags = {
62
+ trusted: { value: true, setAt: SET_AT },
63
+ wellknown: { value: true, setAt: SET_AT },
64
+ };
65
+ assert.equal(deriveSenderTrust(flags), "wellknown");
66
+ });
67
+ });
@@ -0,0 +1,23 @@
1
+ import type { AddressFlags } from "@remit/api-openapi-types";
2
+ import { SenderTrust } from "@remit/domain-enums";
3
+
4
+ export type SenderTrustValue = (typeof SenderTrust)[keyof typeof SenderTrust];
5
+
6
+ /**
7
+ * Derive the SenderTrust discriminator from an Address's flags map.
8
+ *
9
+ * Precedence (per EDD #232):
10
+ * flags.vip.value === true → "vip"
11
+ * flags.wellknown.value === true → "wellknown"
12
+ * otherwise → "unknown"
13
+ *
14
+ * Pure function, no I/O. Frontend never derives this — single source of
15
+ * truth for the UI badge state.
16
+ */
17
+ export const deriveSenderTrust = (
18
+ flags: AddressFlags | undefined,
19
+ ): SenderTrustValue => {
20
+ if (flags?.vip?.value === true) return SenderTrust.Vip;
21
+ if (flags?.wellknown?.value === true) return SenderTrust.Wellknown;
22
+ return SenderTrust.Unknown;
23
+ };
package/src/error.ts ADDED
@@ -0,0 +1,55 @@
1
+ import { logger } from "@remit/logger-lambda";
2
+ import type { APIGatewayProxyResult } from "aws-lambda";
3
+ import { formatResponse } from "./response.js";
4
+
5
+ export const handleError = async (
6
+ error: unknown,
7
+ ): Promise<APIGatewayProxyResult> => {
8
+ if (error instanceof Error) {
9
+ if ("statusCode" in error) {
10
+ logger.error(
11
+ {
12
+ error: error.message,
13
+ statusCode: error.statusCode,
14
+ stack: error.stack,
15
+ },
16
+ "Error with statusCode",
17
+ );
18
+ return formatResponse(
19
+ { message: error.message },
20
+ error.statusCode as number,
21
+ );
22
+ }
23
+
24
+ if (error.name === "ElectroError") {
25
+ // DynamoDB client/infrastructure errors should be 500
26
+ // These contain "aws-error" in the message or reference URL
27
+ const isInfrastructureError =
28
+ error.message.includes("aws-error") ||
29
+ error.message.includes("DynamoDB client");
30
+
31
+ const statusCode = isInfrastructureError ? 500 : 400;
32
+ const logMessage = isInfrastructureError
33
+ ? "DynamoDB infrastructure error"
34
+ : "ElectroError";
35
+
36
+ logger.error({ error: error.message, stack: error.stack }, logMessage);
37
+
38
+ // Don't expose internal details for infrastructure errors
39
+ const responseMessage = isInfrastructureError
40
+ ? "Database temporarily unavailable"
41
+ : error.message;
42
+
43
+ return formatResponse({ message: responseMessage }, statusCode);
44
+ }
45
+
46
+ logger.error(
47
+ { error: error.message, name: error.name, stack: error.stack },
48
+ "Unhandled Error",
49
+ );
50
+ return formatResponse({ message: "Internal server error" }, 500);
51
+ }
52
+
53
+ logger.error({ error: JSON.stringify(error) }, "Unknown error type");
54
+ return formatResponse({ message: "Internal server error" }, 500);
55
+ };
@@ -0,0 +1,137 @@
1
+ import type {
2
+ AccountResponse,
3
+ FolderAppointment,
4
+ } from "@remit/api-openapi-types";
5
+ import type { AccountItem } from "@remit/data-ports";
6
+ import { ConflictError } from "@remit/data-ports/errors";
7
+ import { AccountAuthType } from "@remit/domain-enums";
8
+ import type { AccountOverrides } from "./account-overrides.js";
9
+ import type { AccountSignature } from "./account-signature.js";
10
+
11
+ /**
12
+ * Natural key identifying "the same mailbox in the same place" within an
13
+ * account config. IMAP host and login username are both case-insensitive, so we
14
+ * fold to lowercase before comparing. Used as the explicit server-side
15
+ * uniqueness key now that message identity is account-scoped (entropy per
16
+ * onboard) and no longer incidentally dedups duplicate onboards (#633/#635).
17
+ */
18
+ export interface MailboxNaturalKey {
19
+ imapHost: string;
20
+ username: string;
21
+ }
22
+
23
+ const mailboxKeyOf = (key: MailboxNaturalKey): string =>
24
+ `${key.imapHost.toLowerCase()}\u0000${key.username.toLowerCase()}`;
25
+
26
+ /**
27
+ * Find an existing ACTIVE (non-deleted) account that occupies the same
28
+ * mailbox-in-the-same-place as `key`. Soft-deleted accounts are ignored so a
29
+ * re-onboard after delete still succeeds.
30
+ *
31
+ * Callers pass the accounts already scoped to one account config (the natural
32
+ * key's accountConfigId component), so this only matches on host + username.
33
+ */
34
+ export const findActiveDuplicateMailbox = (
35
+ existing: AccountItem[],
36
+ key: MailboxNaturalKey,
37
+ ): AccountItem | undefined => {
38
+ const target = mailboxKeyOf(key);
39
+ return existing.find(
40
+ (account) =>
41
+ !account.deletedAt &&
42
+ mailboxKeyOf({
43
+ imapHost: account.imapHost,
44
+ username: account.username,
45
+ }) === target,
46
+ );
47
+ };
48
+
49
+ /**
50
+ * Reject the create when an active account already onboards the same mailbox in
51
+ * the same place. Surfaces as a typed 409 ConflictError so the web-client can
52
+ * show a "already added" message instead of silently creating a duplicate.
53
+ */
54
+ export const assertNoDuplicateMailbox = (
55
+ existing: AccountItem[],
56
+ key: MailboxNaturalKey,
57
+ ): void => {
58
+ const duplicate = findActiveDuplicateMailbox(existing, key);
59
+ if (!duplicate) return;
60
+ throw new ConflictError(
61
+ `An account for ${key.username} on ${key.imapHost} already exists`,
62
+ );
63
+ };
64
+
65
+ /** Rejects OAuth accounts that must go through the dedicated connect flow. */
66
+ export const assertNotOAuthCreate = (authType: string | undefined): void => {
67
+ if (authType === AccountAuthType.OauthMicrosoft) {
68
+ throw Object.assign(new Error("Bad Request"), {
69
+ status: 400,
70
+ message:
71
+ "OAuth accounts must be created via the OAuth connect flow (POST /accounts/oauth/microsoft/start)",
72
+ });
73
+ }
74
+ };
75
+
76
+ /** Rejects password-auth account creation when no password is supplied. */
77
+ export const assertPasswordProvided = (
78
+ authType: string | undefined,
79
+ password: string | undefined,
80
+ ): void => {
81
+ const isPasswordAuth = authType === AccountAuthType.Password || !authType;
82
+ if (isPasswordAuth && !password) {
83
+ throw Object.assign(new Error("Bad Request"), {
84
+ status: 400,
85
+ message:
86
+ "password is required when authType is 'password' (or when authType is omitted)",
87
+ });
88
+ }
89
+ };
90
+
91
+ // SECURITY: passwordHash, oauthRefreshTokenHash, and smtpPasswordHash are
92
+ // intentionally omitted — never expose token material in API responses.
93
+ // Display name, mute flag, and signatures live in per-account AccountSetting
94
+ // rows (RFC 032); the caller resolves them and passes them in. Absent means the
95
+ // override is unset. `folderAppointments` (RFC 032 exclusive-folder-appointment,
96
+ // #976) is resolved the same way — persisted rows merged with server-proposed
97
+ // defaults for any unfilled role — via `resolveFolderAppointments`.
98
+ export const toAccountResponse = (
99
+ account: AccountItem,
100
+ signature: AccountSignature = {},
101
+ overrides: AccountOverrides = {},
102
+ folderAppointments: FolderAppointment[] = [],
103
+ ): AccountResponse => ({
104
+ accountId: account.accountId,
105
+ accountConfigId: account.accountConfigId,
106
+ displayName: overrides.displayName,
107
+ username: account.username,
108
+ email: account.email,
109
+ authType: account.authType ?? AccountAuthType.Password,
110
+ imapHost: account.imapHost,
111
+ imapPort: account.imapPort,
112
+ imapTls: account.imapTls,
113
+ imapStartTls: account.imapStartTls,
114
+ // RFC 032 Tier 2: SMTP config is total. ElectroDB `default` applies on write
115
+ // only, so rows written before this change still lack these attributes —
116
+ // coalesce to the schema defaults on read so the response is always complete.
117
+ smtpEnabled: account.smtpEnabled ?? false,
118
+ smtpHost: account.smtpHost ?? "",
119
+ smtpPort: account.smtpPort ?? 587,
120
+ smtpTls: account.smtpTls ?? false,
121
+ smtpStartTls: account.smtpStartTls ?? true,
122
+ smtpUsername: account.smtpUsername ?? "",
123
+ signaturePlainText: signature.plainText,
124
+ signatureHtml: signature.html,
125
+ isActive: account.isActive,
126
+ connectionState: account.connectionState,
127
+ lastConnectedAt: account.lastConnectedAt,
128
+ lastSyncAt: account.lastSyncAt,
129
+ lastError: account.lastError,
130
+ syncPhase: account.syncPhase,
131
+ mailboxCountTotal: account.mailboxCountTotal,
132
+ mailboxCountSynced: account.mailboxCountSynced,
133
+ muted: overrides.muted,
134
+ createdAt: account.createdAt,
135
+ updatedAt: account.updatedAt,
136
+ folderAppointments,
137
+ });