@remit/smtp-worker 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/build.mjs ADDED
@@ -0,0 +1,17 @@
1
+ import * as esbuild from "esbuild";
2
+
3
+ await esbuild.build({
4
+ entryPoints: ["src/index.ts"],
5
+ bundle: true,
6
+ platform: "node",
7
+ target: "node20",
8
+ format: "esm",
9
+ outdir: "dist",
10
+ sourcemap: true,
11
+ external: [
12
+ "@aws-sdk/*", // Use Lambda-provided SDK
13
+ ],
14
+ banner: {
15
+ js: "import { createRequire } from 'module'; const require = createRequire(import.meta.url);",
16
+ },
17
+ });
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@remit/smtp-worker",
3
+ "version": "0.0.1",
4
+ "type": "module",
5
+ "main": "dist/index.js",
6
+ "scripts": {
7
+ "bundle": "node build.mjs",
8
+ "test:typecheck": "tsgo --noEmit",
9
+ "test:run": "node --env-file=../../localhost-test-unit.env --test 'src/**/*.test.ts'",
10
+ "test": "npm run test:typecheck && npm run test:run"
11
+ },
12
+ "devDependencies": {
13
+ "@aws-sdk/client-sqs": "*",
14
+ "@aws-sdk/core": "*",
15
+ "@remit/data-ports": "*",
16
+ "@remit/drizzle-service": "*",
17
+ "@remit/domain-enums": "*",
18
+ "@remit/logger-lambda": "*",
19
+ "@remit/mail-oauth-service": "*",
20
+ "@remit/mailbox-service": "*",
21
+ "@remit/secrets-service": "*",
22
+ "@remit/smtp-service": "*",
23
+ "@remit/sqs-client": "*",
24
+ "@remit/storage-service": "*",
25
+ "@types/aws-lambda": "*",
26
+ "@types/pg": "^8.0.0",
27
+ "esbuild": "^0.27.7",
28
+ "expect-env": "*",
29
+ "p-map": "*"
30
+ },
31
+ "license": "MIT",
32
+ "publishConfig": {
33
+ "access": "public"
34
+ },
35
+ "repository": {
36
+ "type": "git",
37
+ "url": "git+https://github.com/remit-mail/remit.git",
38
+ "directory": "packages/smtp-worker"
39
+ }
40
+ }
@@ -0,0 +1,126 @@
1
+ import type {
2
+ IAccountRepository,
3
+ IAddressRepository,
4
+ IEnvelopeRepository,
5
+ IMessageRepository,
6
+ IOutboxMessageRepository,
7
+ } from "@remit/data-ports";
8
+ // Type-only: erased at build, so it carries no runtime dependency on
9
+ // drizzle-orm. The value import lives inside the relational builders as a
10
+ // dynamic `import()` — see the comment there for why.
11
+ import type { NodePgDatabase } from "drizzle-orm/node-postgres";
12
+
13
+ /**
14
+ * The repositories the SMTP send path reads and writes. One neutral seam over
15
+ * the three backends (DynamoDB / Postgres / SQLite), selected by
16
+ * `buildDataPortsFromEnv` so the same handler serves every stack.
17
+ */
18
+ export interface SmtpDataPorts {
19
+ account: IAccountRepository;
20
+ outboxMessage: IOutboxMessageRepository;
21
+ address: IAddressRepository;
22
+ message: IMessageRepository;
23
+ envelope: IEnvelopeRepository;
24
+ }
25
+
26
+ // `@remit/drizzle-service` and `drizzle-orm/node-postgres` are loaded
27
+ // lazily, inside these functions, instead of as static top-level imports. The
28
+ // relational branches only ever run when `DATA_BACKEND` selects them — never on
29
+ // the deployed Lambda — but a static import is bundled (and evaluated at module
30
+ // load) regardless of whether its branch runs. Both packages are marked
31
+ // `external` for the Lambda esbuild build (LAMBDA_ESBUILD_OPTIONS), so esbuild
32
+ // leaves this `import()` unresolved in the bundle; it resolves only on the
33
+ // relational path, which runs via `tsx`/the container bundle with both packages
34
+ // installed. Mirrors the search-index-worker data-ports seam.
35
+ const buildPostgresDataPorts = async (): Promise<SmtpDataPorts> => {
36
+ const pgConnectionUrl = process.env.PG_CONNECTION_URL;
37
+ if (!pgConnectionUrl) throw new Error("PG_CONNECTION_URL is required");
38
+
39
+ const {
40
+ AccountRepo,
41
+ AddressRepo,
42
+ DrizzleEnvelopeRepository,
43
+ DrizzleMessageRepository,
44
+ messageDataSchema,
45
+ OutboxMessageRepo,
46
+ } = await import("@remit/drizzle-service");
47
+ const { drizzle } = await import("drizzle-orm/node-postgres");
48
+
49
+ const db = drizzle(pgConnectionUrl, { schema: messageDataSchema });
50
+ const genericDb = db as unknown as NodePgDatabase<Record<string, unknown>>;
51
+ const messageDataDb = db as unknown as NodePgDatabase<
52
+ typeof messageDataSchema
53
+ >;
54
+
55
+ return {
56
+ account: new AccountRepo(genericDb),
57
+ outboxMessage: new OutboxMessageRepo(genericDb),
58
+ address: new AddressRepo(genericDb),
59
+ message: new DrizzleMessageRepository(messageDataDb),
60
+ envelope: new DrizzleEnvelopeRepository(messageDataDb),
61
+ };
62
+ };
63
+
64
+ // The SQLite twin of `buildPostgresDataPorts` (RFC 036): the same Drizzle repos
65
+ // over the one shared SQLite file. `createSqliteDatabase` (and better-sqlite3
66
+ // behind it) is kept external from the DynamoDB Lambda bundle by the same
67
+ // dynamic-import treatment.
68
+ const buildSqliteDataPorts = async (): Promise<SmtpDataPorts> => {
69
+ const sqliteDbPath = process.env.SQLITE_DB_PATH;
70
+ if (!sqliteDbPath) throw new Error("SQLITE_DB_PATH is required");
71
+
72
+ const {
73
+ AccountRepo,
74
+ AddressRepo,
75
+ createSqliteDatabase,
76
+ DrizzleEnvelopeRepository,
77
+ DrizzleMessageRepository,
78
+ messageDataSchema,
79
+ OutboxMessageRepo,
80
+ } = await import("@remit/drizzle-service");
81
+
82
+ const { db } = await createSqliteDatabase(messageDataSchema, {
83
+ filename: sqliteDbPath,
84
+ });
85
+ const genericDb = db as unknown as NodePgDatabase<Record<string, unknown>>;
86
+ const messageDataDb = db as unknown as NodePgDatabase<
87
+ typeof messageDataSchema
88
+ >;
89
+
90
+ return {
91
+ account: new AccountRepo(genericDb),
92
+ outboxMessage: new OutboxMessageRepo(genericDb),
93
+ address: new AddressRepo(genericDb),
94
+ message: new DrizzleMessageRepository(messageDataDb),
95
+ envelope: new DrizzleEnvelopeRepository(messageDataDb),
96
+ };
97
+ };
98
+
99
+ /**
100
+ * Select the SMTP data ports from the environment, mirroring the other workers'
101
+ * `DATA_BACKEND` selection:
102
+ *
103
+ * - `DATA_BACKEND=postgres` → Drizzle repos over `PG_CONNECTION_URL`.
104
+ * - `DATA_BACKEND=sqlite` → Drizzle repos over the shared `SQLITE_DB_PATH` file.
105
+ * - otherwise → ElectroDB services over `DYNAMODB_TABLE_NAME` (the AWS path,
106
+ * unchanged from the pre-convergence worker).
107
+ *
108
+ * The DynamoDB branch loads its composition through a dynamic `import()`, so the
109
+ * sole importer of the closed `@remit/remit-electrodb-service`
110
+ * (`./compose-dynamodb.js`) is never loaded on the relational (open-core) tree,
111
+ * where that module is not shipped. esbuild still bundles it into the Lambda
112
+ * because the specifier is a relative import, not an `external`.
113
+ */
114
+ export const buildDataPortsFromEnv = async (): Promise<SmtpDataPorts> => {
115
+ if (process.env.DATA_BACKEND === "postgres") return buildPostgresDataPorts();
116
+ if (process.env.DATA_BACKEND === "sqlite") return buildSqliteDataPorts();
117
+ // `as string` stops tsgo resolving the module in the open-core tree, which
118
+ // strips it; the named contract keeps the call site typed rather than `any`.
119
+ type ComposeDynamoDBModule = {
120
+ buildDynamoDBDataPorts: () => SmtpDataPorts;
121
+ };
122
+ const { buildDynamoDBDataPorts } = (await import(
123
+ "./compose-dynamodb.js" as string
124
+ )) as ComposeDynamoDBModule;
125
+ return buildDynamoDBDataPorts();
126
+ };
@@ -0,0 +1,162 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ DeleteMessageCommand,
4
+ ReceiveMessageCommand,
5
+ SQSClient,
6
+ } from "@aws-sdk/client-sqs";
7
+ import { AwsQueryProtocol } from "@aws-sdk/core/protocols";
8
+ import { createLogger } from "@remit/logger-lambda";
9
+ import { resolveSqsCredentials } from "@remit/sqs-client";
10
+ import type { Context, SQSBatchResponse, SQSEvent } from "aws-lambda";
11
+ import { env } from "expect-env";
12
+ import { handler } from "./index.js";
13
+
14
+ /**
15
+ * E2E-only queue drainer.
16
+ *
17
+ * Production binds the Lambda `handler` (`src/index.ts`) to the SMTP queue via
18
+ * an SQS event-source mapping. The e2e/CI stack runs on ElasticMQ, which has no
19
+ * event-source mapping, so this process supplies that missing piece: it
20
+ * long-polls the queue, wraps every received batch in an `SQSEvent`, and invokes
21
+ * the exact production `handler`. It then honours the returned
22
+ * `batchItemFailures` the way the SQS service would — deleting the messages
23
+ * that succeeded and leaving the failures un-deleted so their visibility
24
+ * timeout lapses and SQS redelivers them. No processing or failure logic lives
25
+ * here; the prod handler owns all of it.
26
+ *
27
+ * This is a test harness, not production code. If it crashes the e2e suite
28
+ * fails loudly, which is the desired signal — there is no crash net.
29
+ */
30
+
31
+ const queueUrl = env.SQS_QUEUE_URL_SMTP;
32
+ const queueName = new URL(queueUrl).pathname.split("/").pop();
33
+ const log = createLogger().child({ queue: queueName });
34
+
35
+ const isLocal = queueUrl.startsWith("http://localhost");
36
+ const sqs = new SQSClient({
37
+ endpoint: isLocal ? new URL(queueUrl).origin : undefined,
38
+ ...(isLocal && { protocol: AwsQueryProtocol }),
39
+ credentials: resolveSqsCredentials(),
40
+ });
41
+
42
+ const maxMessages = 10;
43
+
44
+ let isShuttingDown = false;
45
+
46
+ process.on("SIGINT", () => {
47
+ log.info("Worker received SIGINT, shutting down...");
48
+ isShuttingDown = true;
49
+ });
50
+
51
+ process.on("SIGTERM", () => {
52
+ log.info("Worker received SIGTERM, shutting down...");
53
+ isShuttingDown = true;
54
+ });
55
+
56
+ // Minimal Lambda Context: `withTelemetry` only reads `functionName` and adds it
57
+ // to the logger; the prod handler never touches the rest.
58
+ const lambdaContext = {
59
+ functionName: `e2e-smtp-worker-${queueName}`,
60
+ } as Context;
61
+
62
+ const pollQueue = async (): Promise<void> => {
63
+ log.info("Worker started, polling...", { maxMessages });
64
+
65
+ let consecutiveEmptyPolls = 0;
66
+
67
+ while (!isShuttingDown) {
68
+ const waitTime = consecutiveEmptyPolls > 0 ? 20 : 0;
69
+
70
+ const response = await sqs.send(
71
+ new ReceiveMessageCommand({
72
+ QueueUrl: queueUrl,
73
+ MaxNumberOfMessages: maxMessages,
74
+ WaitTimeSeconds: waitTime,
75
+ VisibilityTimeout: 300,
76
+ MessageSystemAttributeNames: ["ApproximateReceiveCount"],
77
+ }),
78
+ );
79
+
80
+ if (!response.Messages || response.Messages.length === 0) {
81
+ consecutiveEmptyPolls++;
82
+ continue;
83
+ }
84
+
85
+ consecutiveEmptyPolls = 0;
86
+
87
+ const messages = response.Messages.flatMap((m) =>
88
+ m.Body && m.ReceiptHandle && m.MessageId
89
+ ? [
90
+ {
91
+ messageId: m.MessageId,
92
+ receiptHandle: m.ReceiptHandle,
93
+ body: m.Body,
94
+ receiveCount: m.Attributes?.ApproximateReceiveCount ?? "1",
95
+ },
96
+ ]
97
+ : [],
98
+ );
99
+
100
+ if (messages.length === 0) {
101
+ continue;
102
+ }
103
+
104
+ log.info("Invoking handler for batch", { count: messages.length });
105
+
106
+ const event: SQSEvent = {
107
+ Records: messages.map((m) => ({
108
+ messageId: m.messageId,
109
+ receiptHandle: m.receiptHandle,
110
+ body: m.body,
111
+ attributes: {
112
+ ApproximateReceiveCount: m.receiveCount,
113
+ SentTimestamp: "0",
114
+ SenderId: "e2e",
115
+ ApproximateFirstReceiveTimestamp: "0",
116
+ },
117
+ messageAttributes: {},
118
+ md5OfBody: "",
119
+ eventSource: "aws:sqs",
120
+ eventSourceARN: queueUrl,
121
+ awsRegion: "local",
122
+ })),
123
+ };
124
+
125
+ const result = (await handler(event, lambdaContext)) as
126
+ | SQSBatchResponse
127
+ | undefined;
128
+
129
+ const failedIds = new Set(
130
+ (result?.batchItemFailures ?? []).map((f) => f.itemIdentifier),
131
+ );
132
+
133
+ // Mirror SQS partial-batch-failure semantics: delete the messages the
134
+ // handler reported as succeeded; leave failures un-deleted so their
135
+ // visibility timeout lapses and SQS redelivers them (and eventually
136
+ // dead-letters once maxReceiveCount is hit).
137
+ const succeeded = messages.filter((m) => !failedIds.has(m.messageId));
138
+
139
+ for (const message of succeeded) {
140
+ await sqs.send(
141
+ new DeleteMessageCommand({
142
+ QueueUrl: queueUrl,
143
+ ReceiptHandle: message.receiptHandle,
144
+ }),
145
+ );
146
+ }
147
+
148
+ log.info("Batch processed", {
149
+ deleted: succeeded.length,
150
+ leftForRedelivery: failedIds.size,
151
+ });
152
+ }
153
+
154
+ log.info("Worker polling stopped");
155
+ };
156
+
157
+ pollQueue()
158
+ .then(() => process.exit(0))
159
+ .catch((error) => {
160
+ log.error("Worker error", { error });
161
+ process.exit(1);
162
+ });
package/src/events.ts ADDED
@@ -0,0 +1,17 @@
1
+ export interface BaseEvent {
2
+ accountId: string;
3
+ eventId: string; // Idempotency key
4
+ timestamp: number;
5
+ }
6
+
7
+ export interface SendMessageEvent extends BaseEvent {
8
+ type: "SEND_MESSAGE";
9
+ outboxMessageId: string;
10
+ }
11
+
12
+ export interface ProcessOutboxEvent extends BaseEvent {
13
+ type: "PROCESS_OUTBOX";
14
+ // Processes all queued messages for the account
15
+ }
16
+
17
+ export type SmtpEvent = SendMessageEvent | ProcessOutboxEvent;
@@ -0,0 +1,126 @@
1
+ import type { OutboxMessageItem } from "@remit/data-ports";
2
+ import type { Logger } from "@remit/logger-lambda";
3
+ import type { EngagementCounterDeps, SendTenant } from "./send-message-core.js";
4
+
5
+ /**
6
+ * Engagement counters for an outbound send.
7
+ *
8
+ * - Per recipient (To/Cc/Bcc): increment `outboundCount`, set `lastOutboundAt`
9
+ * - Per recipient that also appears as the original sender of a stored
10
+ * message referenced by `In-Reply-To` or `References`: also increment
11
+ * `replyCount`, set `lastReplyAt`
12
+ *
13
+ * Counters use atomic `ADD`. Drift under at-least-once SQS is accepted
14
+ * residual per EDD #232.
15
+ */
16
+ export const writeEngagementCounters = async (
17
+ outbox: OutboxMessageItem,
18
+ tenant: SendTenant,
19
+ deps: EngagementCounterDeps,
20
+ log: Logger,
21
+ ): Promise<void> => {
22
+ const now = Date.now();
23
+ const recipients = collectRecipients(outbox);
24
+ if (recipients.length === 0) return;
25
+
26
+ const recipientAddressIds = new Map<string, string>();
27
+ for (const email of recipients) {
28
+ const addressId = deps.resolveAddressId(tenant.accountConfigId, email);
29
+ recipientAddressIds.set(email, addressId);
30
+ }
31
+
32
+ for (const addressId of recipientAddressIds.values()) {
33
+ await deps.incrementOutboundCount(tenant.accountConfigId, addressId, now);
34
+ }
35
+
36
+ const replyTargets = await resolveReplyTargets(outbox, tenant, deps, log);
37
+ for (const email of replyTargets) {
38
+ const addressId = recipientAddressIds.get(email);
39
+ if (!addressId) continue;
40
+ await deps.incrementReplyCount(tenant.accountConfigId, addressId, now);
41
+ }
42
+ };
43
+
44
+ const collectRecipients = (outbox: OutboxMessageItem): string[] => {
45
+ const all = [
46
+ ...(outbox.toAddresses ?? []),
47
+ ...(outbox.ccAddresses ?? []),
48
+ ...(outbox.bccAddresses ?? []),
49
+ ];
50
+ const seen = new Set<string>();
51
+ const result: string[] = [];
52
+ for (const raw of all) {
53
+ const email = normalizeEmail(raw);
54
+ if (!email) continue;
55
+ if (seen.has(email)) continue;
56
+ seen.add(email);
57
+ result.push(email);
58
+ }
59
+ return result;
60
+ };
61
+
62
+ const normalizeEmail = (raw: string): string | null => {
63
+ if (!raw) return null;
64
+ const trimmed = raw.trim();
65
+ if (trimmed === "") return null;
66
+ const angle = trimmed.match(/<([^>]+)>/);
67
+ const candidate = angle ? angle[1] : trimmed;
68
+ if (!candidate.includes("@")) return null;
69
+ return candidate.toLowerCase();
70
+ };
71
+
72
+ const resolveReplyTargets = async (
73
+ outbox: OutboxMessageItem,
74
+ tenant: SendTenant,
75
+ deps: EngagementCounterDeps,
76
+ log: Logger,
77
+ ): Promise<Set<string>> => {
78
+ const headerCandidates = collectReferenceHeaders(outbox);
79
+ if (headerCandidates.length === 0) return new Set();
80
+
81
+ const recipientSet = new Set(collectRecipients(outbox));
82
+
83
+ const matched = new Set<string>();
84
+ for (const header of headerCandidates) {
85
+ for (const variant of headerVariants(header)) {
86
+ const message = await deps.findMessageByHeader(tenant.accountId, variant);
87
+ if (!message) continue;
88
+ const fromEmail = await deps.getEnvelopeFromEmail(message.messageId);
89
+ if (!fromEmail) continue;
90
+ const normalized = fromEmail.toLowerCase();
91
+ if (recipientSet.has(normalized)) {
92
+ matched.add(normalized);
93
+ }
94
+ break;
95
+ }
96
+ }
97
+
98
+ if (matched.size === 0) {
99
+ log.debug?.(
100
+ { outboxMessageId: outbox.outboxMessageId },
101
+ "No reply targets resolved from In-Reply-To/References",
102
+ );
103
+ }
104
+ return matched;
105
+ };
106
+
107
+ const collectReferenceHeaders = (outbox: OutboxMessageItem): string[] => {
108
+ const result: string[] = [];
109
+ const seen = new Set<string>();
110
+ const push = (raw: string | undefined): void => {
111
+ if (!raw) return;
112
+ const trimmed = raw.trim();
113
+ if (trimmed === "" || seen.has(trimmed)) return;
114
+ seen.add(trimmed);
115
+ result.push(trimmed);
116
+ };
117
+ push(outbox.inReplyTo);
118
+ for (const ref of outbox.references ?? []) push(ref);
119
+ return result;
120
+ };
121
+
122
+ const headerVariants = (header: string): string[] => {
123
+ const stripped = header.replace(/^<|>$/g, "");
124
+ if (stripped === header) return [`<${header}>`, header];
125
+ return [header, stripped];
126
+ };