@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.
- package/.env.test +7 -0
- package/dev-server/content-auth.test.ts +113 -0
- package/dev-server/content-auth.ts +54 -0
- package/dev-server/content-handler.test.ts +105 -0
- package/dev-server/content-handler.ts +79 -0
- package/dev-server/content-path.test.ts +37 -0
- package/dev-server/content-path.ts +27 -0
- package/dev-server/cors.test.ts +48 -0
- package/dev-server/cors.ts +25 -0
- package/dev-server/lambda-helpers.ts +69 -0
- package/dev-server/server.ts +289 -0
- package/package.json +82 -0
- package/src/auth.ts +92 -0
- package/src/config/msoauth.ts +60 -0
- package/src/data-backend.test.ts +25 -0
- package/src/data-backend.ts +20 -0
- package/src/derive/autoMoved.ts +28 -0
- package/src/derive/contentSignature.test.ts +153 -0
- package/src/derive/contentSignature.ts +139 -0
- package/src/derive/contentUrl.test.ts +156 -0
- package/src/derive/contentUrl.ts +70 -0
- package/src/derive/enrichThreadRows.ts +155 -0
- package/src/derive/filterThreadCriteria.test.ts +119 -0
- package/src/derive/filterThreadCriteria.ts +60 -0
- package/src/derive/pendingMoveCounts.ts +90 -0
- package/src/derive/senderTrust.test.ts +67 -0
- package/src/derive/senderTrust.ts +23 -0
- package/src/error.ts +55 -0
- package/src/handlers/account-guards.ts +137 -0
- package/src/handlers/account-oauth.test.ts +383 -0
- package/src/handlers/account-oauth.ts +452 -0
- package/src/handlers/account-overrides.test.ts +69 -0
- package/src/handlers/account-overrides.ts +286 -0
- package/src/handlers/account-ownership.ts +25 -0
- package/src/handlers/account-signature.ts +129 -0
- package/src/handlers/account.ts +524 -0
- package/src/handlers/address.ts +136 -0
- package/src/handlers/config.test.ts +184 -0
- package/src/handlers/config.ts +219 -0
- package/src/handlers/ensure-account-config.ts +30 -0
- package/src/handlers/filter.ts +294 -0
- package/src/handlers/folder-role-appointments.test.ts +250 -0
- package/src/handlers/folder-role-appointments.ts +272 -0
- package/src/handlers/folder-role.ts +76 -0
- package/src/handlers/index.ts +48 -0
- package/src/handlers/mailbox.ts +411 -0
- package/src/handlers/me.ts +190 -0
- package/src/handlers/message.ts +886 -0
- package/src/handlers/organize.test.ts +43 -0
- package/src/handlers/organize.ts +196 -0
- package/src/handlers/outbox.ts +218 -0
- package/src/handlers/search.test.ts +182 -0
- package/src/handlers/search.ts +105 -0
- package/src/handlers/sync-progress.test.ts +158 -0
- package/src/handlers/sync-progress.ts +64 -0
- package/src/handlers/sync.test.ts +146 -0
- package/src/handlers/sync.ts +119 -0
- package/src/handlers/thread.ts +361 -0
- package/src/handlers/unified-threads.ts +196 -0
- package/src/handlers/vip-suggestions.ts +21 -0
- package/src/index.ts +170 -0
- package/src/json.ts +11 -0
- package/src/jwt-auth.test.ts +89 -0
- package/src/jwt-auth.ts +95 -0
- package/src/request-context.test.ts +61 -0
- package/src/request-context.ts +29 -0
- package/src/request.ts +10 -0
- package/src/response.test.ts +107 -0
- package/src/response.ts +80 -0
- package/src/service/compose-postgres.ts +72 -0
- package/src/service/compose-sqlite.ts +80 -0
- package/src/service/create-remit-client.ts +358 -0
- package/src/service/dynamodb.test.ts +100 -0
- package/src/service/dynamodb.ts +58 -0
- package/src/service/filter.ts +55 -0
- package/src/service/fire-and-forget.test.ts +126 -0
- package/src/service/fire-and-forget.ts +79 -0
- package/src/service/localhost-env-config.test.ts +40 -0
- package/src/service/organize.test.ts +384 -0
- package/src/service/organize.ts +354 -0
- package/src/service/semantic-capability.test.ts +64 -0
- package/src/service/semantic-capability.ts +62 -0
- package/src/service/sqs.ts +4 -0
- package/src/service/trigger-sync.test.ts +211 -0
- package/src/service/trigger-sync.ts +102 -0
- package/src/types.ts +161 -0
- 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
|
+
});
|