@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 +17 -0
- package/package.json +40 -0
- package/src/data-ports.ts +126 -0
- package/src/e2e-processor-shim.ts +162 -0
- package/src/events.ts +17 -0
- package/src/handlers/engagement-counters.ts +126 -0
- package/src/handlers/resolve-smtp-config.test.ts +221 -0
- package/src/handlers/resolve-smtp-config.ts +105 -0
- package/src/handlers/send-message-core.ts +270 -0
- package/src/handlers/send-message.test.ts +624 -0
- package/src/handlers/send-message.ts +167 -0
- package/src/index.ts +51 -0
- package/src/poller.ts +18 -0
- package/src/processor.ts +22 -0
- package/tsconfig.json +8 -0
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
|
+
};
|