@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,43 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import type { OrganizeInput } from "@remit/api-openapi-types";
|
|
4
|
+
import { FilterMatchOperator } from "@remit/domain-enums";
|
|
5
|
+
import { predicateFromInput } from "./organize.js";
|
|
6
|
+
|
|
7
|
+
const input = (over: Partial<OrganizeInput> = {}): OrganizeInput => ({
|
|
8
|
+
matchOperator: FilterMatchOperator.And,
|
|
9
|
+
literalClauses: [],
|
|
10
|
+
actionLabelId: "None",
|
|
11
|
+
actionMailboxId: "None",
|
|
12
|
+
...over,
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
// A move back-apply is accepted end to end: createOrganizeJob and
|
|
16
|
+
// previewOrganize no longer reject `actionMailboxId` up front (the removed
|
|
17
|
+
// label-only 400). Both endpoints flatten the request into the predicate the
|
|
18
|
+
// job row and the matcher share, so the proof of acceptance is that the move
|
|
19
|
+
// action survives that mapping verbatim — the worker then applies it through
|
|
20
|
+
// the wired placement mover (see service/organize.test.ts).
|
|
21
|
+
describe("predicateFromInput (move back-apply accepted)", () => {
|
|
22
|
+
it("carries a requested move action through to the predicate", () => {
|
|
23
|
+
const predicate = predicateFromInput(
|
|
24
|
+
input({ actionMailboxId: "mbox-target" }),
|
|
25
|
+
);
|
|
26
|
+
assert.equal(predicate.actionMailboxId, "mbox-target");
|
|
27
|
+
assert.equal(predicate.actionLabelId, "None");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("carries a combined move + label action through to the predicate", () => {
|
|
31
|
+
const predicate = predicateFromInput(
|
|
32
|
+
input({ actionLabelId: "lbl-1", actionMailboxId: "mbox-target" }),
|
|
33
|
+
);
|
|
34
|
+
assert.equal(predicate.actionMailboxId, "mbox-target");
|
|
35
|
+
assert.equal(predicate.actionLabelId, "lbl-1");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("preserves the None sentinel for an absent action", () => {
|
|
39
|
+
const predicate = predicateFromInput(input());
|
|
40
|
+
assert.equal(predicate.actionMailboxId, "None");
|
|
41
|
+
assert.equal(predicate.actionLabelId, "None");
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { SendMessageCommand } from "@aws-sdk/client-sqs";
|
|
2
|
+
import type {
|
|
3
|
+
CreateOrganizeJobResponse,
|
|
4
|
+
OrganizeInput,
|
|
5
|
+
OrganizeJobResponse,
|
|
6
|
+
OrganizePreviewResponse,
|
|
7
|
+
} from "@remit/api-openapi-types";
|
|
8
|
+
import { NotFoundError } from "@remit/data-ports/errors";
|
|
9
|
+
import { logger } from "@remit/logger-lambda";
|
|
10
|
+
import type { APIGatewayProxyEvent } from "aws-lambda";
|
|
11
|
+
import { env } from "expect-env";
|
|
12
|
+
import type { Context } from "openapi-backend";
|
|
13
|
+
import { getAccountConfigIdFromEvent, getSubFromEvent } from "../auth.js";
|
|
14
|
+
import { getClient, type RemitClient } from "../service/dynamodb.js";
|
|
15
|
+
import {
|
|
16
|
+
buildOrganizeMatchDeps,
|
|
17
|
+
matchOrganize,
|
|
18
|
+
ORGANIZE_MATCH_LIMIT,
|
|
19
|
+
type OrganizePredicate,
|
|
20
|
+
} from "../service/organize.js";
|
|
21
|
+
import { sqsClient } from "../service/sqs.js";
|
|
22
|
+
import type {
|
|
23
|
+
OperationHandler,
|
|
24
|
+
OrganizeJobDetailOperationIds,
|
|
25
|
+
OrganizeOperationIds,
|
|
26
|
+
} from "../types.js";
|
|
27
|
+
import { assertAccountOwnership } from "./account-ownership.js";
|
|
28
|
+
|
|
29
|
+
const NONE = "None";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* How long a finished (or abandoned) job row lingers before the table-wide TTL
|
|
33
|
+
* reclaims it (RFC 034 Decision 1). A back-apply runs once and needs no standing
|
|
34
|
+
* lifetime; a week is ample for a client to poll the result.
|
|
35
|
+
*/
|
|
36
|
+
const ORGANIZE_JOB_TTL_SECONDS = 7 * 24 * 60 * 60;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Normalize the request body into the flattened predicate the job row and the
|
|
40
|
+
* matcher share. `anchorMessageId` collapses to the `"None"` sentinel when
|
|
41
|
+
* absent, and `similarityThreshold` to the server default — the persisted entity
|
|
42
|
+
* carries no optional fields (RFC 032).
|
|
43
|
+
*/
|
|
44
|
+
export const predicateFromInput = (
|
|
45
|
+
input: OrganizeInput,
|
|
46
|
+
): OrganizePredicate => ({
|
|
47
|
+
anchorMessageId: input.anchorMessageId ?? NONE,
|
|
48
|
+
matchOperator: input.matchOperator,
|
|
49
|
+
literalClauses: input.literalClauses,
|
|
50
|
+
similarityThreshold: input.similarityThreshold ?? 0.75,
|
|
51
|
+
actionLabelId: input.actionLabelId,
|
|
52
|
+
actionMailboxId: input.actionMailboxId,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const assertAccount = async (
|
|
56
|
+
client: RemitClient,
|
|
57
|
+
accountId: string,
|
|
58
|
+
accountConfigId: string,
|
|
59
|
+
mode: "read" | "act",
|
|
60
|
+
): Promise<void> => {
|
|
61
|
+
const account = await client.account.get(accountId);
|
|
62
|
+
assertAccountOwnership(account, accountConfigId, mode);
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const toOrganizeJobResponse = (
|
|
66
|
+
job: Awaited<ReturnType<RemitClient["organizeJobRequest"]["get"]>>,
|
|
67
|
+
): OrganizeJobResponse => ({
|
|
68
|
+
organizeJobId: job.organizeJobId,
|
|
69
|
+
accountConfigId: job.accountConfigId,
|
|
70
|
+
userId: job.userId,
|
|
71
|
+
state: job.state,
|
|
72
|
+
anchorMessageId: job.anchorMessageId,
|
|
73
|
+
matchOperator: job.matchOperator,
|
|
74
|
+
literalClauses: job.literalClauses,
|
|
75
|
+
similarityThreshold: job.similarityThreshold,
|
|
76
|
+
actionLabelId: job.actionLabelId,
|
|
77
|
+
actionMailboxId: job.actionMailboxId,
|
|
78
|
+
matchedCount: job.matchedCount,
|
|
79
|
+
appliedCount: job.appliedCount,
|
|
80
|
+
failedCount: job.failedCount,
|
|
81
|
+
errorMessage: job.errorMessage,
|
|
82
|
+
createdAt: job.createdAt,
|
|
83
|
+
updatedAt: job.updatedAt,
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
export const OrganizeOperations: Record<
|
|
87
|
+
OrganizeOperationIds,
|
|
88
|
+
OperationHandler<OrganizeOperationIds>
|
|
89
|
+
> = {
|
|
90
|
+
OrganizeOperations_createOrganizeJob: async (
|
|
91
|
+
context: Context,
|
|
92
|
+
...args: unknown[]
|
|
93
|
+
): Promise<CreateOrganizeJobResponse> => {
|
|
94
|
+
const event = args[0] as APIGatewayProxyEvent;
|
|
95
|
+
const accountConfigId = getAccountConfigIdFromEvent(event);
|
|
96
|
+
const { accountId } = context.request.params as { accountId: string };
|
|
97
|
+
const input = context.request.requestBody as OrganizeInput;
|
|
98
|
+
|
|
99
|
+
const userId = getSubFromEvent(event);
|
|
100
|
+
if (!userId) {
|
|
101
|
+
throw new Error(
|
|
102
|
+
"Missing Cognito `sub`: cannot attribute an organize job to a user",
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const client = await getClient();
|
|
107
|
+
await assertAccount(client, accountId, accountConfigId, "act");
|
|
108
|
+
|
|
109
|
+
const predicate = predicateFromInput(input);
|
|
110
|
+
const ttl = Math.floor(Date.now() / 1000) + ORGANIZE_JOB_TTL_SECONDS;
|
|
111
|
+
|
|
112
|
+
const job = await client.organizeJobRequest.create({
|
|
113
|
+
accountConfigId,
|
|
114
|
+
userId,
|
|
115
|
+
anchorMessageId: predicate.anchorMessageId,
|
|
116
|
+
matchOperator: predicate.matchOperator,
|
|
117
|
+
literalClauses: predicate.literalClauses,
|
|
118
|
+
similarityThreshold: predicate.similarityThreshold,
|
|
119
|
+
actionLabelId: predicate.actionLabelId,
|
|
120
|
+
actionMailboxId: predicate.actionMailboxId,
|
|
121
|
+
ttl,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
await sqsClient.send(
|
|
125
|
+
new SendMessageCommand({
|
|
126
|
+
QueueUrl: env.SQS_QUEUE_URL_ACCOUNT_FANOUT,
|
|
127
|
+
MessageBody: JSON.stringify({
|
|
128
|
+
type: "OrganizeJob",
|
|
129
|
+
accountConfigId,
|
|
130
|
+
organizeJobId: job.organizeJobId,
|
|
131
|
+
}),
|
|
132
|
+
}),
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
// biome-ignore lint/plugin/no-logger-info: a back-apply is an audit-grade signal
|
|
136
|
+
logger.info(
|
|
137
|
+
{ accountConfigId, organizeJobId: job.organizeJobId },
|
|
138
|
+
"Organize back-apply job initiated",
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
statusCode: 202,
|
|
143
|
+
organizeJobId: job.organizeJobId,
|
|
144
|
+
state: job.state,
|
|
145
|
+
};
|
|
146
|
+
},
|
|
147
|
+
|
|
148
|
+
OrganizeOperations_previewOrganize: async (
|
|
149
|
+
context: Context,
|
|
150
|
+
...args: unknown[]
|
|
151
|
+
): Promise<OrganizePreviewResponse> => {
|
|
152
|
+
const event = args[0] as APIGatewayProxyEvent;
|
|
153
|
+
const accountConfigId = getAccountConfigIdFromEvent(event);
|
|
154
|
+
const { accountId } = context.request.params as { accountId: string };
|
|
155
|
+
const input = context.request.requestBody as OrganizeInput;
|
|
156
|
+
|
|
157
|
+
const client = await getClient();
|
|
158
|
+
await assertAccount(client, accountId, accountConfigId, "read");
|
|
159
|
+
|
|
160
|
+
const messageIds = await matchOrganize(
|
|
161
|
+
buildOrganizeMatchDeps(client),
|
|
162
|
+
accountConfigId,
|
|
163
|
+
predicateFromInput(input),
|
|
164
|
+
ORGANIZE_MATCH_LIMIT,
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
return { matchedCount: messageIds.length, messageIds };
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
export const OrganizeJobDetailOperations: Record<
|
|
172
|
+
OrganizeJobDetailOperationIds,
|
|
173
|
+
OperationHandler<OrganizeJobDetailOperationIds>
|
|
174
|
+
> = {
|
|
175
|
+
OrganizeJobDetailOperations_getOrganizeJob: async (
|
|
176
|
+
context: Context,
|
|
177
|
+
...args: unknown[]
|
|
178
|
+
): Promise<OrganizeJobResponse> => {
|
|
179
|
+
const event = args[0] as APIGatewayProxyEvent;
|
|
180
|
+
const accountConfigId = getAccountConfigIdFromEvent(event);
|
|
181
|
+
const { accountId, organizeJobId } = context.request.params as {
|
|
182
|
+
accountId: string;
|
|
183
|
+
organizeJobId: string;
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
const client = await getClient();
|
|
187
|
+
await assertAccount(client, accountId, accountConfigId, "read");
|
|
188
|
+
|
|
189
|
+
const job = await client.organizeJobRequest.get(organizeJobId);
|
|
190
|
+
if (job.accountConfigId !== accountConfigId) {
|
|
191
|
+
throw new NotFoundError(`Organize job not found: ${organizeJobId}`);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return toOrganizeJobResponse(job);
|
|
195
|
+
},
|
|
196
|
+
};
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
CreateOutboxMessageInput,
|
|
3
|
+
OutboxMessageResponse,
|
|
4
|
+
UpdateOutboxMessageInput,
|
|
5
|
+
} from "@remit/api-openapi-types";
|
|
6
|
+
import type { OutboxMessageItem } from "@remit/data-ports";
|
|
7
|
+
import { ForbiddenError } from "@remit/data-ports/errors";
|
|
8
|
+
import type { APIGatewayProxyEvent } from "aws-lambda";
|
|
9
|
+
import type { Context } from "openapi-backend";
|
|
10
|
+
import { getAccountConfigIdFromEvent } from "../auth.js";
|
|
11
|
+
import { getClient } from "../service/dynamodb.js";
|
|
12
|
+
import type {
|
|
13
|
+
OperationHandler,
|
|
14
|
+
OutboxDetailOperationIds,
|
|
15
|
+
OutboxOperationIds,
|
|
16
|
+
} from "../types.js";
|
|
17
|
+
|
|
18
|
+
const toOutboxMessageResponse = (
|
|
19
|
+
item: OutboxMessageItem,
|
|
20
|
+
): OutboxMessageResponse => ({
|
|
21
|
+
outboxMessageId: item.outboxMessageId,
|
|
22
|
+
accountId: item.accountId,
|
|
23
|
+
fromAddress: item.fromAddress,
|
|
24
|
+
fromName: item.fromName,
|
|
25
|
+
toAddresses: item.toAddresses,
|
|
26
|
+
ccAddresses: item.ccAddresses ?? [],
|
|
27
|
+
bccAddresses: item.bccAddresses ?? [],
|
|
28
|
+
subject: item.subject,
|
|
29
|
+
textBody: item.textBody,
|
|
30
|
+
htmlBody: item.htmlBody,
|
|
31
|
+
inReplyTo: item.inReplyTo,
|
|
32
|
+
references: item.references ?? [],
|
|
33
|
+
status: item.status,
|
|
34
|
+
lastError: item.lastError,
|
|
35
|
+
sentAt: item.sentAt,
|
|
36
|
+
createdAt: item.createdAt,
|
|
37
|
+
updatedAt: item.updatedAt,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
export const OutboxOperations: Record<
|
|
41
|
+
OutboxOperationIds,
|
|
42
|
+
OperationHandler<OutboxOperationIds>
|
|
43
|
+
> = {
|
|
44
|
+
OutboxOperations_createOutboxMessage: async (
|
|
45
|
+
_context: Context,
|
|
46
|
+
...args: unknown[]
|
|
47
|
+
): Promise<OutboxMessageResponse> => {
|
|
48
|
+
const event = args[0] as APIGatewayProxyEvent;
|
|
49
|
+
const accountConfigId = getAccountConfigIdFromEvent(event);
|
|
50
|
+
const input = JSON.parse(event.body ?? "{}") as CreateOutboxMessageInput;
|
|
51
|
+
|
|
52
|
+
const client = await getClient();
|
|
53
|
+
|
|
54
|
+
const account = await client.account.get(input.accountId);
|
|
55
|
+
if (account.accountConfigId !== accountConfigId) {
|
|
56
|
+
throw new ForbiddenError(
|
|
57
|
+
`Account ${input.accountId} not in account config`,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const fromAddress = account.email;
|
|
62
|
+
|
|
63
|
+
if (input.sendImmediately) {
|
|
64
|
+
const outbox = await client.outboxQueue.createAndSend({
|
|
65
|
+
accountId: input.accountId,
|
|
66
|
+
accountConfigId,
|
|
67
|
+
fromAddress,
|
|
68
|
+
|
|
69
|
+
toAddresses: input.toAddresses,
|
|
70
|
+
ccAddresses: input.ccAddresses,
|
|
71
|
+
bccAddresses: input.bccAddresses,
|
|
72
|
+
subject: input.subject,
|
|
73
|
+
textBody: input.textBody,
|
|
74
|
+
htmlBody: input.htmlBody,
|
|
75
|
+
inReplyTo: input.inReplyTo,
|
|
76
|
+
references: input.references,
|
|
77
|
+
});
|
|
78
|
+
return toOutboxMessageResponse(outbox);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const outbox = await client.outboxQueue.createDraft({
|
|
82
|
+
accountId: input.accountId,
|
|
83
|
+
accountConfigId,
|
|
84
|
+
fromAddress,
|
|
85
|
+
|
|
86
|
+
toAddresses: input.toAddresses,
|
|
87
|
+
ccAddresses: input.ccAddresses,
|
|
88
|
+
bccAddresses: input.bccAddresses,
|
|
89
|
+
subject: input.subject,
|
|
90
|
+
textBody: input.textBody,
|
|
91
|
+
htmlBody: input.htmlBody,
|
|
92
|
+
inReplyTo: input.inReplyTo,
|
|
93
|
+
references: input.references,
|
|
94
|
+
});
|
|
95
|
+
return toOutboxMessageResponse(outbox);
|
|
96
|
+
},
|
|
97
|
+
|
|
98
|
+
OutboxOperations_listOutboxMessages: async (
|
|
99
|
+
context: Context,
|
|
100
|
+
...args: unknown[]
|
|
101
|
+
) => {
|
|
102
|
+
const event = args[0] as APIGatewayProxyEvent;
|
|
103
|
+
const accountConfigId = getAccountConfigIdFromEvent(event);
|
|
104
|
+
const { continuationToken } = context.request.query as {
|
|
105
|
+
continuationToken?: string;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const client = await getClient();
|
|
109
|
+
|
|
110
|
+
const accounts = await client.account.list(accountConfigId);
|
|
111
|
+
if (accounts.items.length === 0) {
|
|
112
|
+
return { items: [], continuationToken: null };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const accountId = accounts.items[0].accountId;
|
|
116
|
+
const result = await client.outboxMessage.listByAccount(accountId, {
|
|
117
|
+
continuationToken,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
items: result.items.map(toOutboxMessageResponse),
|
|
122
|
+
continuationToken: result.continuationToken,
|
|
123
|
+
};
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
export const OutboxDetailOperations: Record<
|
|
128
|
+
OutboxDetailOperationIds,
|
|
129
|
+
OperationHandler<OutboxDetailOperationIds>
|
|
130
|
+
> = {
|
|
131
|
+
OutboxDetailOperations_getOutboxMessage: async (
|
|
132
|
+
context: Context,
|
|
133
|
+
...args: unknown[]
|
|
134
|
+
): Promise<OutboxMessageResponse> => {
|
|
135
|
+
const event = args[0] as APIGatewayProxyEvent;
|
|
136
|
+
const accountConfigId = getAccountConfigIdFromEvent(event);
|
|
137
|
+
const { outboxMessageId } = context.request.params as {
|
|
138
|
+
outboxMessageId: string;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const client = await getClient();
|
|
142
|
+
// Default mode "read": the scoped get refuses a foreign message with
|
|
143
|
+
// NotFound (404, no existence leak) — no separate check needed.
|
|
144
|
+
const outbox = await client.outboxMessage.get(
|
|
145
|
+
accountConfigId,
|
|
146
|
+
outboxMessageId,
|
|
147
|
+
);
|
|
148
|
+
return toOutboxMessageResponse(outbox);
|
|
149
|
+
},
|
|
150
|
+
|
|
151
|
+
OutboxDetailOperations_updateOutboxMessage: async (
|
|
152
|
+
context: Context,
|
|
153
|
+
...args: unknown[]
|
|
154
|
+
): Promise<OutboxMessageResponse> => {
|
|
155
|
+
const event = args[0] as APIGatewayProxyEvent;
|
|
156
|
+
const accountConfigId = getAccountConfigIdFromEvent(event);
|
|
157
|
+
const { outboxMessageId } = context.request.params as {
|
|
158
|
+
outboxMessageId: string;
|
|
159
|
+
};
|
|
160
|
+
const input = context.request.requestBody as UpdateOutboxMessageInput;
|
|
161
|
+
|
|
162
|
+
const client = await getClient();
|
|
163
|
+
// updateDraft's own scoped get uses mode "act": a foreign message is
|
|
164
|
+
// denied with Forbidden (403), not feigned as NotFound.
|
|
165
|
+
const updated = await client.outboxQueue.updateDraft(
|
|
166
|
+
accountConfigId,
|
|
167
|
+
outboxMessageId,
|
|
168
|
+
{
|
|
169
|
+
toAddresses: input.toAddresses,
|
|
170
|
+
ccAddresses: input.ccAddresses,
|
|
171
|
+
bccAddresses: input.bccAddresses,
|
|
172
|
+
subject: input.subject,
|
|
173
|
+
textBody: input.textBody,
|
|
174
|
+
htmlBody: input.htmlBody,
|
|
175
|
+
inReplyTo: input.inReplyTo,
|
|
176
|
+
references: input.references,
|
|
177
|
+
},
|
|
178
|
+
);
|
|
179
|
+
return toOutboxMessageResponse(updated);
|
|
180
|
+
},
|
|
181
|
+
|
|
182
|
+
OutboxDetailOperations_deleteOutboxMessage: async (
|
|
183
|
+
context: Context,
|
|
184
|
+
...args: unknown[]
|
|
185
|
+
) => {
|
|
186
|
+
const event = args[0] as APIGatewayProxyEvent;
|
|
187
|
+
const accountConfigId = getAccountConfigIdFromEvent(event);
|
|
188
|
+
const { outboxMessageId } = context.request.params as {
|
|
189
|
+
outboxMessageId: string;
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
const client = await getClient();
|
|
193
|
+
// deleteDraft's own scoped get uses mode "act": a foreign message is
|
|
194
|
+
// denied with Forbidden (403), not feigned as NotFound.
|
|
195
|
+
await client.outboxQueue.deleteDraft(accountConfigId, outboxMessageId);
|
|
196
|
+
return { statusCode: 204 };
|
|
197
|
+
},
|
|
198
|
+
|
|
199
|
+
OutboxDetailOperations_sendOutboxMessage: async (
|
|
200
|
+
context: Context,
|
|
201
|
+
...args: unknown[]
|
|
202
|
+
): Promise<OutboxMessageResponse> => {
|
|
203
|
+
const event = args[0] as APIGatewayProxyEvent;
|
|
204
|
+
const accountConfigId = getAccountConfigIdFromEvent(event);
|
|
205
|
+
const { outboxMessageId } = context.request.params as {
|
|
206
|
+
outboxMessageId: string;
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
const client = await getClient();
|
|
210
|
+
// send's own scoped get uses mode "act": a foreign message is denied
|
|
211
|
+
// with Forbidden (403), not feigned as NotFound.
|
|
212
|
+
const sent = await client.outboxQueue.send(
|
|
213
|
+
accountConfigId,
|
|
214
|
+
outboxMessageId,
|
|
215
|
+
);
|
|
216
|
+
return toOutboxMessageResponse(sent);
|
|
217
|
+
},
|
|
218
|
+
};
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import type { SemanticSearchResult } from "@remit/api-openapi-types";
|
|
4
|
+
import type {
|
|
5
|
+
SearchParams,
|
|
6
|
+
SearchResult,
|
|
7
|
+
SearchService,
|
|
8
|
+
} from "@remit/search-service";
|
|
9
|
+
|
|
10
|
+
interface CapturedSearch {
|
|
11
|
+
calls: SearchParams[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const buildFakeSearch = (
|
|
15
|
+
results: SearchResult[],
|
|
16
|
+
): { service: SearchService; captured: CapturedSearch } => {
|
|
17
|
+
const captured: CapturedSearch = { calls: [] };
|
|
18
|
+
const service: SearchService = {
|
|
19
|
+
index: async () => {},
|
|
20
|
+
prepareVectors: async () => [],
|
|
21
|
+
upsertVectors: async () => ({ upserted: 0, skipped: 0 }),
|
|
22
|
+
indexIncremental: async () => ({ upserted: 0, skipped: 0 }),
|
|
23
|
+
delete: async () => {},
|
|
24
|
+
search: async (params) => {
|
|
25
|
+
captured.calls.push(params);
|
|
26
|
+
return results;
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
return { service, captured };
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const toResponse = (item: SearchResult): SemanticSearchResult => {
|
|
33
|
+
const result: SemanticSearchResult = {
|
|
34
|
+
messageId: item.messageId,
|
|
35
|
+
threadId: item.threadId,
|
|
36
|
+
score: item.score,
|
|
37
|
+
matchedChunkType: item.matchedChunkType,
|
|
38
|
+
mailboxIds: item.mailboxIds,
|
|
39
|
+
sentDate: item.sentDate,
|
|
40
|
+
};
|
|
41
|
+
if (item.fromName !== undefined) {
|
|
42
|
+
result.fromName = item.fromName ?? undefined;
|
|
43
|
+
}
|
|
44
|
+
if (item.subject !== undefined) {
|
|
45
|
+
result.subject = item.subject;
|
|
46
|
+
}
|
|
47
|
+
if (item.category !== undefined) {
|
|
48
|
+
result.category = item.category;
|
|
49
|
+
}
|
|
50
|
+
return result;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
describe("SemanticSearch handler response mapping", () => {
|
|
54
|
+
it("maps SearchResult into SemanticSearchResult preserving all fields", () => {
|
|
55
|
+
const item: SearchResult = {
|
|
56
|
+
messageId: "msg-alice",
|
|
57
|
+
threadId: "thread-a",
|
|
58
|
+
score: 0.87,
|
|
59
|
+
matchedChunkType: "sender",
|
|
60
|
+
mailboxIds: ["mb-inbox", "mb-archive"],
|
|
61
|
+
sentDate: 1_700_000_000,
|
|
62
|
+
};
|
|
63
|
+
const out = toResponse(item);
|
|
64
|
+
assert.equal(out.messageId, "msg-alice");
|
|
65
|
+
assert.equal(out.threadId, "thread-a");
|
|
66
|
+
assert.equal(out.score, 0.87);
|
|
67
|
+
assert.equal(out.matchedChunkType, "sender");
|
|
68
|
+
assert.deepEqual(out.mailboxIds, ["mb-inbox", "mb-archive"]);
|
|
69
|
+
assert.equal(out.sentDate, 1_700_000_000);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("includes fromName and subject when present in SearchResult", () => {
|
|
73
|
+
const item: SearchResult = {
|
|
74
|
+
messageId: "msg-bob",
|
|
75
|
+
threadId: "thread-b",
|
|
76
|
+
score: 0.72,
|
|
77
|
+
matchedChunkType: "body",
|
|
78
|
+
mailboxIds: ["mb-inbox"],
|
|
79
|
+
sentDate: 1_700_001_000,
|
|
80
|
+
fromName: "Bob Smith",
|
|
81
|
+
subject: "Weekly sync",
|
|
82
|
+
};
|
|
83
|
+
const out = toResponse(item);
|
|
84
|
+
assert.equal(out.fromName, "Bob Smith");
|
|
85
|
+
assert.equal(out.subject, "Weekly sync");
|
|
86
|
+
assert.equal(out.sentDate, 1_700_001_000);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("echoes category back when present in SearchResult", () => {
|
|
90
|
+
const item: SearchResult = {
|
|
91
|
+
messageId: "msg-news",
|
|
92
|
+
threadId: "thread-news",
|
|
93
|
+
score: 0.81,
|
|
94
|
+
matchedChunkType: "body",
|
|
95
|
+
mailboxIds: ["mb-inbox"],
|
|
96
|
+
sentDate: 1_700_003_000,
|
|
97
|
+
category: "newsletter",
|
|
98
|
+
};
|
|
99
|
+
const out = toResponse(item);
|
|
100
|
+
assert.equal(out.category, "newsletter");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("omits category when absent (pre-enrichment vectors)", () => {
|
|
104
|
+
const item: SearchResult = {
|
|
105
|
+
messageId: "msg-legacy",
|
|
106
|
+
threadId: "thread-legacy",
|
|
107
|
+
score: 0.4,
|
|
108
|
+
matchedChunkType: "body",
|
|
109
|
+
mailboxIds: ["mb-archive"],
|
|
110
|
+
sentDate: 1_600_000_000,
|
|
111
|
+
};
|
|
112
|
+
const out = toResponse(item);
|
|
113
|
+
assert.equal(out.category, undefined);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("omits fromName when it is null (sender has no display name)", () => {
|
|
117
|
+
const item: SearchResult = {
|
|
118
|
+
messageId: "msg-carol",
|
|
119
|
+
threadId: "thread-c",
|
|
120
|
+
score: 0.6,
|
|
121
|
+
matchedChunkType: "sender",
|
|
122
|
+
mailboxIds: ["mb-inbox"],
|
|
123
|
+
sentDate: 1_700_002_000,
|
|
124
|
+
fromName: null,
|
|
125
|
+
subject: "No-name sender",
|
|
126
|
+
};
|
|
127
|
+
const out = toResponse(item);
|
|
128
|
+
assert.equal(out.fromName, undefined);
|
|
129
|
+
assert.equal(out.subject, "No-name sender");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("omits fromName and subject when absent (pre-enrichment vectors)", () => {
|
|
133
|
+
const item: SearchResult = {
|
|
134
|
+
messageId: "msg-legacy",
|
|
135
|
+
threadId: "thread-legacy",
|
|
136
|
+
score: 0.5,
|
|
137
|
+
matchedChunkType: "body",
|
|
138
|
+
mailboxIds: ["mb-archive"],
|
|
139
|
+
sentDate: 1_600_000_000,
|
|
140
|
+
};
|
|
141
|
+
const out = toResponse(item);
|
|
142
|
+
assert.equal(out.fromName, undefined);
|
|
143
|
+
assert.equal(out.subject, undefined);
|
|
144
|
+
assert.equal(out.sentDate, 1_600_000_000);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe("SemanticSearch handler search invocation", () => {
|
|
149
|
+
it("forwards filters from the request to the search service", async () => {
|
|
150
|
+
const { service, captured } = buildFakeSearch([
|
|
151
|
+
{
|
|
152
|
+
messageId: "msg-bob",
|
|
153
|
+
threadId: "thread-b",
|
|
154
|
+
score: 0.5,
|
|
155
|
+
matchedChunkType: "body",
|
|
156
|
+
mailboxIds: ["mb-inbox"],
|
|
157
|
+
sentDate: 1_700_000_000,
|
|
158
|
+
},
|
|
159
|
+
]);
|
|
160
|
+
|
|
161
|
+
const results = await service.search({
|
|
162
|
+
query: "alice",
|
|
163
|
+
accountConfigId: "acct-1",
|
|
164
|
+
mailboxId: "mb-inbox",
|
|
165
|
+
sentDateRange: { from: 100, to: 200 },
|
|
166
|
+
hasAttachment: true,
|
|
167
|
+
category: "newsletter",
|
|
168
|
+
limit: 10,
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
assert.equal(results.length, 1);
|
|
172
|
+
assert.equal(captured.calls.length, 1);
|
|
173
|
+
const call = captured.calls[0];
|
|
174
|
+
assert.equal(call.query, "alice");
|
|
175
|
+
assert.equal(call.accountConfigId, "acct-1");
|
|
176
|
+
assert.equal(call.mailboxId, "mb-inbox");
|
|
177
|
+
assert.deepEqual(call.sentDateRange, { from: 100, to: 200 });
|
|
178
|
+
assert.equal(call.hasAttachment, true);
|
|
179
|
+
assert.equal(call.category, "newsletter");
|
|
180
|
+
assert.equal(call.limit, 10);
|
|
181
|
+
});
|
|
182
|
+
});
|