@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
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { SendMessageCommand, SQSClient } from "@aws-sdk/client-sqs";
|
|
3
|
+
import { AwsQueryProtocol } from "@aws-sdk/core/protocols";
|
|
4
|
+
import { deriveAddressId, deriveMessageId } from "@remit/data-ports/id";
|
|
5
|
+
import type { ConnectionState } from "@remit/domain-enums";
|
|
6
|
+
import type { Logger } from "@remit/logger-lambda";
|
|
7
|
+
import {
|
|
8
|
+
createMailOAuthService,
|
|
9
|
+
microsoftProviderConfig,
|
|
10
|
+
} from "@remit/mail-oauth-service";
|
|
11
|
+
// Subpath import, not the package root: the root barrel re-exports the whole
|
|
12
|
+
// IMAP sync surface (mailbox/flag/message sync, snippet/heuristics text
|
|
13
|
+
// processing), which drags `natural` and friends into a bundle that only
|
|
14
|
+
// ever calls resolveConnectionCredentials.
|
|
15
|
+
import {
|
|
16
|
+
type AccountCredentialsDeps,
|
|
17
|
+
resolveConnectionCredentials,
|
|
18
|
+
} from "@remit/mailbox-service/account-credentials";
|
|
19
|
+
import {
|
|
20
|
+
createKmsDataKeyProvider,
|
|
21
|
+
createSecretsService,
|
|
22
|
+
} from "@remit/secrets-service";
|
|
23
|
+
import { sendMail } from "@remit/smtp-service";
|
|
24
|
+
import { resolveSqsCredentials } from "@remit/sqs-client";
|
|
25
|
+
import { env } from "expect-env";
|
|
26
|
+
import { buildDataPortsFromEnv } from "../data-ports.js";
|
|
27
|
+
import type { SendMessageEvent } from "../events.js";
|
|
28
|
+
import { sendMessage } from "./send-message-core.js";
|
|
29
|
+
|
|
30
|
+
const ports = await buildDataPortsFromEnv();
|
|
31
|
+
const dataKeyProvider = createKmsDataKeyProvider(env.KMS_KEY_ID);
|
|
32
|
+
const secrets = createSecretsService(dataKeyProvider);
|
|
33
|
+
|
|
34
|
+
// Lazy OAuth service (only instantiated when an OAuth account sends mail)
|
|
35
|
+
let _tokenService: ReturnType<typeof createMailOAuthService> | undefined;
|
|
36
|
+
const getTokenService = () => {
|
|
37
|
+
if (!_tokenService) {
|
|
38
|
+
_tokenService = createMailOAuthService(
|
|
39
|
+
microsoftProviderConfig({
|
|
40
|
+
clientId: process.env.MSOAUTH_CLIENT_ID ?? "",
|
|
41
|
+
clientSecret: process.env.MSOAUTH_CLIENT_SECRET ?? "",
|
|
42
|
+
overrides: process.env.MSOAUTH_TOKEN_ENDPOINT
|
|
43
|
+
? { tokenEndpoint: process.env.MSOAUTH_TOKEN_ENDPOINT }
|
|
44
|
+
: undefined,
|
|
45
|
+
}),
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
return _tokenService;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const accountService = ports.account;
|
|
52
|
+
const outboxService = ports.outboxMessage;
|
|
53
|
+
const addressService = ports.address;
|
|
54
|
+
const messageService = ports.message;
|
|
55
|
+
const envelopeService = ports.envelope;
|
|
56
|
+
|
|
57
|
+
// APPEND_SENT_MESSAGE is processed by the IMAP worker via the
|
|
58
|
+
// message-management queue (see remit-imap-worker/src/emit.ts).
|
|
59
|
+
const messageMgmtQueueUrl = env.SQS_QUEUE_URL_MESSAGE_MGMT;
|
|
60
|
+
const isLocalQueue = messageMgmtQueueUrl.startsWith("http://localhost");
|
|
61
|
+
|
|
62
|
+
const messageMgmtSqs = new SQSClient({
|
|
63
|
+
endpoint: isLocalQueue ? new URL(messageMgmtQueueUrl).origin : undefined,
|
|
64
|
+
...(isLocalQueue && { protocol: AwsQueryProtocol }),
|
|
65
|
+
credentials: resolveSqsCredentials(),
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const emitAppendSentMessage = async (
|
|
69
|
+
accountId: string,
|
|
70
|
+
outboxMessageId: string,
|
|
71
|
+
): Promise<void> => {
|
|
72
|
+
const event = {
|
|
73
|
+
type: "APPEND_SENT_MESSAGE" as const,
|
|
74
|
+
accountId,
|
|
75
|
+
outboxMessageId,
|
|
76
|
+
eventId: randomUUID(),
|
|
77
|
+
timestamp: Date.now(),
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
await messageMgmtSqs.send(
|
|
81
|
+
new SendMessageCommand({
|
|
82
|
+
QueueUrl: messageMgmtQueueUrl,
|
|
83
|
+
MessageBody: JSON.stringify(event),
|
|
84
|
+
}),
|
|
85
|
+
);
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const findMessageByHeader = async (
|
|
89
|
+
accountId: string,
|
|
90
|
+
messageIdHeader: string,
|
|
91
|
+
) => {
|
|
92
|
+
const messageId = deriveMessageId(accountId, messageIdHeader);
|
|
93
|
+
return messageService.get(messageId).catch((error: unknown) => {
|
|
94
|
+
if ((error as { name?: string })?.name === "NotFoundError") return null;
|
|
95
|
+
throw error;
|
|
96
|
+
});
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const getEnvelopeFromEmail = async (
|
|
100
|
+
messageId: string,
|
|
101
|
+
): Promise<string | null> => {
|
|
102
|
+
const data = await envelopeService
|
|
103
|
+
.getMessageData(messageId)
|
|
104
|
+
.catch((error: unknown) => {
|
|
105
|
+
if ((error as { name?: string })?.name === "NotFoundError") return null;
|
|
106
|
+
throw error;
|
|
107
|
+
});
|
|
108
|
+
if (!data) return null;
|
|
109
|
+
const fromEntry = data.envelopeAddress.find(
|
|
110
|
+
(addr) => addr.addressRole === "from",
|
|
111
|
+
);
|
|
112
|
+
return fromEntry?.normalizedEmail ?? null;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Build AccountCredentialsDeps for the SMTP send path.
|
|
117
|
+
* Uses resolveConnectionCredentials — the single authType branch in the codebase.
|
|
118
|
+
* Rotation is persisted via accountService.update so the next IMAP sync picks
|
|
119
|
+
* up the new token without needing a separate refresh.
|
|
120
|
+
*/
|
|
121
|
+
const buildCredentialDeps = (): AccountCredentialsDeps => ({
|
|
122
|
+
secrets,
|
|
123
|
+
tokenService: getTokenService(),
|
|
124
|
+
persistRotatedToken: async (accountId, encryptedHash, updatedAt) => {
|
|
125
|
+
await accountService.update(accountId, {
|
|
126
|
+
oauthRefreshTokenHash: encryptedHash,
|
|
127
|
+
oauthTokenUpdatedAt: updatedAt,
|
|
128
|
+
});
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
export const handleSendMessage = (
|
|
133
|
+
event: SendMessageEvent,
|
|
134
|
+
log: Logger,
|
|
135
|
+
): Promise<void> => {
|
|
136
|
+
const credentialDeps = buildCredentialDeps();
|
|
137
|
+
return sendMessage(event, log, {
|
|
138
|
+
getOutbox: (accountConfigId, id) => outboxService.get(accountConfigId, id),
|
|
139
|
+
getAccount: (id) => accountService.get(id),
|
|
140
|
+
updateOutbox: (accountConfigId, id, patch) =>
|
|
141
|
+
outboxService.update(accountConfigId, id, patch),
|
|
142
|
+
updateOutboxStatus: (accountConfigId, id, status) =>
|
|
143
|
+
outboxService.updateStatus(accountConfigId, id, status),
|
|
144
|
+
markOutboxSent: (accountConfigId, id, fields) =>
|
|
145
|
+
outboxService.markSent(accountConfigId, id, fields),
|
|
146
|
+
secrets,
|
|
147
|
+
resolveCredentials: (account) =>
|
|
148
|
+
resolveConnectionCredentials(account, credentialDeps),
|
|
149
|
+
updateConnectionState: async (id, state) => {
|
|
150
|
+
await accountService.update(id, {
|
|
151
|
+
connectionState:
|
|
152
|
+
state as (typeof ConnectionState)[keyof typeof ConnectionState],
|
|
153
|
+
});
|
|
154
|
+
},
|
|
155
|
+
send: sendMail,
|
|
156
|
+
emitAppendSentMessage,
|
|
157
|
+
engagement: {
|
|
158
|
+
resolveAddressId: deriveAddressId,
|
|
159
|
+
incrementOutboundCount: (accountConfigId, addressId, now) =>
|
|
160
|
+
addressService.incrementOutboundCount(accountConfigId, addressId, now),
|
|
161
|
+
incrementReplyCount: (accountConfigId, addressId, now) =>
|
|
162
|
+
addressService.incrementReplyCount(accountConfigId, addressId, now),
|
|
163
|
+
findMessageByHeader,
|
|
164
|
+
getEnvelopeFromEmail,
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
};
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { inspect } from "node:util";
|
|
2
|
+
import {
|
|
3
|
+
createLogger,
|
|
4
|
+
MetricUnit,
|
|
5
|
+
metrics,
|
|
6
|
+
withTelemetry,
|
|
7
|
+
} from "@remit/logger-lambda";
|
|
8
|
+
import type { SQSBatchResponse, SQSEvent } from "aws-lambda";
|
|
9
|
+
import type { SmtpEvent } from "./events.js";
|
|
10
|
+
import { processEvent } from "./processor.js";
|
|
11
|
+
|
|
12
|
+
const log = createLogger();
|
|
13
|
+
|
|
14
|
+
export const handler = withTelemetry(
|
|
15
|
+
async (event: SQSEvent): Promise<SQSBatchResponse> => {
|
|
16
|
+
const batchItemFailures: { itemIdentifier: string }[] = [];
|
|
17
|
+
|
|
18
|
+
for (const record of event.Records) {
|
|
19
|
+
const smtpEvent: SmtpEvent = JSON.parse(record.body);
|
|
20
|
+
log.info("Processing SMTP event", {
|
|
21
|
+
eventType: smtpEvent.type,
|
|
22
|
+
eventId: smtpEvent.eventId,
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const sendStart = Date.now();
|
|
26
|
+
const failed = await processEvent(smtpEvent, log)
|
|
27
|
+
.then(() => {
|
|
28
|
+
metrics.addMetric(
|
|
29
|
+
"smtpSendLatency",
|
|
30
|
+
MetricUnit.Milliseconds,
|
|
31
|
+
Date.now() - sendStart,
|
|
32
|
+
);
|
|
33
|
+
return false;
|
|
34
|
+
})
|
|
35
|
+
.catch((error) => {
|
|
36
|
+
log.error("SMTP event processing failed", {
|
|
37
|
+
error: inspect(error),
|
|
38
|
+
messageId: record.messageId,
|
|
39
|
+
});
|
|
40
|
+
metrics.addMetric("smtpSendFailures", MetricUnit.Count, 1);
|
|
41
|
+
return true;
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
if (failed) {
|
|
45
|
+
batchItemFailures.push({ itemIdentifier: record.messageId });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return { batchItemFailures };
|
|
50
|
+
},
|
|
51
|
+
);
|
package/src/poller.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { createLogger } from "@remit/logger-lambda";
|
|
2
|
+
import { runQueuePoller } from "@remit/sqs-client/poller";
|
|
3
|
+
import { env } from "expect-env";
|
|
4
|
+
import { handler } from "./index.js";
|
|
5
|
+
|
|
6
|
+
/** Production queue poller — the deployed form of `e2e-processor-shim.ts`. */
|
|
7
|
+
const log = createLogger();
|
|
8
|
+
|
|
9
|
+
await runQueuePoller({
|
|
10
|
+
log,
|
|
11
|
+
targets: [
|
|
12
|
+
{
|
|
13
|
+
queueUrl: env.SQS_QUEUE_URL_SMTP,
|
|
14
|
+
handler,
|
|
15
|
+
functionName: "smtp-worker",
|
|
16
|
+
},
|
|
17
|
+
],
|
|
18
|
+
});
|
package/src/processor.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Logger } from "@remit/logger-lambda";
|
|
2
|
+
import type { SmtpEvent } from "./events.js";
|
|
3
|
+
import { handleSendMessage } from "./handlers/send-message.js";
|
|
4
|
+
|
|
5
|
+
export const processEvent = async (
|
|
6
|
+
event: SmtpEvent,
|
|
7
|
+
log: Logger,
|
|
8
|
+
): Promise<void> => {
|
|
9
|
+
switch (event.type) {
|
|
10
|
+
case "SEND_MESSAGE":
|
|
11
|
+
return handleSendMessage(event, log);
|
|
12
|
+
case "PROCESS_OUTBOX":
|
|
13
|
+
// Future: batch process all queued messages
|
|
14
|
+
log.info(
|
|
15
|
+
{ accountId: event.accountId },
|
|
16
|
+
"PROCESS_OUTBOX not yet implemented",
|
|
17
|
+
);
|
|
18
|
+
return;
|
|
19
|
+
default:
|
|
20
|
+
throw new Error(`Unknown event type: ${(event as SmtpEvent).type}`);
|
|
21
|
+
}
|
|
22
|
+
};
|