@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.
@@ -0,0 +1,221 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import type { AccountItem } from "@remit/data-ports";
4
+ import {
5
+ type EncryptedPayload,
6
+ serializeEncryptedPayload,
7
+ } from "@remit/secrets-service";
8
+ import { resolveSmtpConfig } from "./resolve-smtp-config.js";
9
+
10
+ const buildAccount = (overrides: Partial<AccountItem> = {}): AccountItem =>
11
+ ({
12
+ accountId: "acct-1",
13
+ accountConfigId: "cfg-1",
14
+ username: "alice@example.com",
15
+ email: "alice@example.com",
16
+ passwordHash: JSON.stringify(
17
+ serializeEncryptedPayload({
18
+ encryptedDek: Buffer.from("dek-imap"),
19
+ encryptedData: Buffer.from("data-imap"),
20
+ iv: Buffer.from("iv-imap"),
21
+ authTag: Buffer.from("tag-imap"),
22
+ }),
23
+ ),
24
+ imapHost: "imap.example.com",
25
+ imapPort: 993,
26
+ imapTls: true,
27
+ imapStartTls: false,
28
+ smtpEnabled: true,
29
+ smtpHost: "smtp.example.com",
30
+ smtpPort: 587,
31
+ smtpTls: false,
32
+ smtpStartTls: true,
33
+ smtpUsername: "",
34
+ isActive: true,
35
+ connectionState: "not_authenticated",
36
+ createdAt: 0,
37
+ updatedAt: 0,
38
+ ...overrides,
39
+ }) as unknown as AccountItem;
40
+
41
+ const stubSecrets = (calls: EncryptedPayload[]) => ({
42
+ decrypt: async (payload: EncryptedPayload): Promise<string> => {
43
+ calls.push(payload);
44
+ return payload.encryptedDek.toString();
45
+ },
46
+ });
47
+
48
+ describe("resolveSmtpConfig", () => {
49
+ it("returns missing when smtpEnabled is false", async () => {
50
+ const result = await resolveSmtpConfig(
51
+ buildAccount({ smtpEnabled: false, smtpHost: "smtp.example.com" }),
52
+ stubSecrets([]),
53
+ );
54
+ assert.equal(result.ok, false);
55
+ if (!result.ok) assert.match(result.reason, /SMTP not configured/);
56
+ });
57
+
58
+ it("returns missing when smtpEnabled is absent (legacy row not yet backfilled)", async () => {
59
+ const result = await resolveSmtpConfig(
60
+ buildAccount({ smtpEnabled: undefined, smtpHost: "smtp.example.com" }),
61
+ stubSecrets([]),
62
+ );
63
+ assert.equal(result.ok, false);
64
+ });
65
+
66
+ it("falls back to IMAP password when smtpPasswordHash is absent (issue #163)", async () => {
67
+ // This is the regression: the web form does not send smtpPassword
68
+ // unless "use different credentials for SMTP" is checked, so accounts
69
+ // that share credentials between IMAP and SMTP have no
70
+ // smtpPasswordHash. Without the fallback, every send fails with
71
+ // "SMTP not configured".
72
+ const calls: EncryptedPayload[] = [];
73
+ const account = buildAccount({
74
+ smtpHost: "smtp.example.com",
75
+ smtpPort: 587,
76
+ smtpPasswordHash: undefined,
77
+ });
78
+ const result = await resolveSmtpConfig(account, stubSecrets(calls));
79
+
80
+ assert.equal(result.ok, true);
81
+ if (!result.ok) return;
82
+ assert.equal(calls.length, 1);
83
+ assert.equal(
84
+ calls[0].encryptedDek.toString(),
85
+ "dek-imap",
86
+ "should decrypt the IMAP passwordHash",
87
+ );
88
+ assert.equal(result.config.host, "smtp.example.com");
89
+ assert.equal(result.config.port, 587);
90
+ assert.equal(result.config.user, "alice@example.com");
91
+ assert.equal(result.config.credentials.kind, "password");
92
+ if (result.config.credentials.kind === "password") {
93
+ assert.equal(result.config.credentials.password, "dek-imap");
94
+ }
95
+ });
96
+
97
+ it("uses smtpPasswordHash when present, not the IMAP passwordHash", async () => {
98
+ const calls: EncryptedPayload[] = [];
99
+ const smtpPasswordHash = JSON.stringify(
100
+ serializeEncryptedPayload({
101
+ encryptedDek: Buffer.from("dek-smtp"),
102
+ encryptedData: Buffer.from("data-smtp"),
103
+ iv: Buffer.from("iv-smtp"),
104
+ authTag: Buffer.from("tag-smtp"),
105
+ }),
106
+ );
107
+ const account = buildAccount({
108
+ smtpHost: "smtp.example.com",
109
+ smtpPort: 587,
110
+ smtpUsername: "alice-smtp@example.com",
111
+ smtpPasswordHash,
112
+ });
113
+ const result = await resolveSmtpConfig(account, stubSecrets(calls));
114
+
115
+ assert.equal(result.ok, true);
116
+ if (!result.ok) return;
117
+ assert.equal(calls.length, 1);
118
+ assert.equal(calls[0].encryptedDek.toString(), "dek-smtp");
119
+ assert.equal(result.config.user, "alice-smtp@example.com");
120
+ assert.equal(result.config.credentials.kind, "password");
121
+ if (result.config.credentials.kind === "password") {
122
+ assert.equal(result.config.credentials.password, "dek-smtp");
123
+ }
124
+ });
125
+
126
+ it("uses smtpTls flag for the secure setting", async () => {
127
+ const result = await resolveSmtpConfig(
128
+ buildAccount({
129
+ smtpHost: "smtp.example.com",
130
+ smtpPort: 465,
131
+ smtpTls: true,
132
+ }),
133
+ stubSecrets([]),
134
+ );
135
+ assert.equal(result.ok, true);
136
+ if (!result.ok) return;
137
+ assert.equal(result.config.secure, true);
138
+ });
139
+
140
+ it("defaults secure to false when smtpTls is absent", async () => {
141
+ const result = await resolveSmtpConfig(
142
+ buildAccount({
143
+ smtpHost: "smtp.example.com",
144
+ smtpPort: 587,
145
+ smtpTls: undefined,
146
+ }),
147
+ stubSecrets([]),
148
+ );
149
+ assert.equal(result.ok, true);
150
+ if (!result.ok) return;
151
+ assert.equal(result.config.secure, false);
152
+ });
153
+
154
+ describe("OAuth accounts — pre-resolved accessToken credential", () => {
155
+ // OAuth accounts: the caller (send-message-core) already resolved
156
+ // credentials via resolveConnectionCredentials (the single authType branch).
157
+ // resolveSmtpConfig receives the access token as SmtpCredentials.
158
+
159
+ it("builds OAUTH2 SmtpConfig from pre-resolved accessToken credential", async () => {
160
+ const account = buildAccount({
161
+ authType: "oauthMicrosoft",
162
+ smtpHost: "smtp.office365.com",
163
+ smtpPort: 587,
164
+ });
165
+
166
+ const result = await resolveSmtpConfig(account, stubSecrets([]), {
167
+ kind: "accessToken",
168
+ accessToken: "my-access-token",
169
+ });
170
+
171
+ assert.equal(result.ok, true);
172
+ if (!result.ok) return;
173
+ assert.equal(result.config.credentials.kind, "accessToken");
174
+ if (result.config.credentials.kind === "accessToken") {
175
+ assert.equal(result.config.credentials.accessToken, "my-access-token");
176
+ }
177
+ assert.equal(result.config.user, "alice@example.com");
178
+ assert.equal(result.config.host, "smtp.office365.com");
179
+ assert.equal(result.config.port, 587);
180
+ });
181
+
182
+ it("ignores the password credential and uses the SMTP-specific hash (issue #163)", async () => {
183
+ // The upstream resolver returns the IMAP password as a password
184
+ // credential. For SMTP we must honour the account's distinct
185
+ // smtpPasswordHash rather than the IMAP password — so the credential
186
+ // argument is ignored and the stored SMTP hash is decrypted.
187
+ const calls: EncryptedPayload[] = [];
188
+ const smtpPasswordHash = JSON.stringify(
189
+ serializeEncryptedPayload({
190
+ encryptedDek: Buffer.from("dek-smtp"),
191
+ encryptedData: Buffer.from("data-smtp"),
192
+ iv: Buffer.from("iv-smtp"),
193
+ authTag: Buffer.from("tag-smtp"),
194
+ }),
195
+ );
196
+ const account = buildAccount({
197
+ smtpHost: "smtp.example.com",
198
+ smtpPort: 587,
199
+ smtpPasswordHash,
200
+ });
201
+
202
+ const result = await resolveSmtpConfig(account, stubSecrets(calls), {
203
+ kind: "password",
204
+ password: "imap-password-should-be-ignored",
205
+ });
206
+
207
+ assert.equal(result.ok, true);
208
+ if (!result.ok) return;
209
+ assert.equal(calls.length, 1, "must decrypt the SMTP-specific hash");
210
+ assert.equal(calls[0].encryptedDek.toString(), "dek-smtp");
211
+ assert.equal(result.config.credentials.kind, "password");
212
+ if (result.config.credentials.kind === "password") {
213
+ assert.equal(
214
+ result.config.credentials.password,
215
+ "dek-smtp",
216
+ "must use the decrypted SMTP password, not the IMAP credential",
217
+ );
218
+ }
219
+ });
220
+ });
221
+ });
@@ -0,0 +1,105 @@
1
+ import type { AccountItem } from "@remit/data-ports";
2
+ import {
3
+ deserializeEncryptedPayload,
4
+ type SecretsService,
5
+ } from "@remit/secrets-service";
6
+ import type { SmtpConfig } from "@remit/smtp-service";
7
+
8
+ export interface SmtpConfigMissing {
9
+ ok: false;
10
+ reason: string;
11
+ }
12
+
13
+ export interface SmtpConfigResolved {
14
+ ok: true;
15
+ config: SmtpConfig;
16
+ }
17
+
18
+ export type ResolvedSmtpConfig = SmtpConfigResolved | SmtpConfigMissing;
19
+
20
+ /**
21
+ * Resolved SMTP credentials — either a password or an OAuth2 access token.
22
+ * Callers should obtain this via resolveConnectionCredentials from
23
+ * @remit/mailbox-service (the single authType branch in the codebase).
24
+ */
25
+ export type SmtpCredentials =
26
+ | { kind: "password"; password: string }
27
+ | { kind: "accessToken"; accessToken: string };
28
+
29
+ /**
30
+ * Resolve a SmtpConfig from a stored account.
31
+ *
32
+ * Credential resolution (authType branching) is handled upstream by
33
+ * resolveConnectionCredentials in @remit/mailbox-service. For OAuth
34
+ * accounts the resolved access token is passed in via `credentials` and used
35
+ * directly. For password accounts the credential is IGNORED here: SMTP must
36
+ * honour the account's SMTP-specific password (`smtpPasswordHash`) when present,
37
+ * which is distinct from the IMAP password the upstream resolver returns.
38
+ *
39
+ * The web form treats "use different credentials for SMTP" as opt-in: when
40
+ * disabled (the default), the IMAP password is reused for SMTP and no
41
+ * smtpPasswordHash is persisted. The SMTP worker must mirror that, otherwise
42
+ * sends fail with "SMTP not configured" for every account that uses the same
43
+ * credentials for IMAP and SMTP (issue #163).
44
+ *
45
+ * - `smtpEnabled` false => account isn't configured for sending (RFC 032 Tier 2:
46
+ * the explicit marker, not inferred from `smtpHost` presence).
47
+ * - Missing smtpPasswordHash => fall back to passwordHash (the IMAP secret).
48
+ * - Empty smtpUsername => fall back to username (the IMAP login).
49
+ *
50
+ * @param credentials - Pre-resolved credentials from resolveConnectionCredentials.
51
+ * Only the `accessToken` kind is consumed (OAuth accounts). For password
52
+ * accounts the SMTP-specific hash path always runs so per-SMTP passwords work.
53
+ */
54
+ export const resolveSmtpConfig = async (
55
+ account: AccountItem,
56
+ secrets: Pick<SecretsService, "decrypt">,
57
+ credentials?: SmtpCredentials,
58
+ ): Promise<ResolvedSmtpConfig> => {
59
+ if (!account.smtpEnabled) {
60
+ return { ok: false, reason: "SMTP not configured for this account" };
61
+ }
62
+
63
+ const smtpHost = account.smtpHost ?? "";
64
+ const smtpPort = account.smtpPort ?? 587;
65
+ const smtpUser = account.smtpUsername || account.username;
66
+
67
+ // OAuth accounts: use the pre-minted access token directly.
68
+ if (credentials?.kind === "accessToken") {
69
+ return {
70
+ ok: true,
71
+ config: {
72
+ host: smtpHost,
73
+ port: smtpPort,
74
+ secure: account.smtpTls ?? false,
75
+ user: smtpUser,
76
+ credentials: {
77
+ kind: "accessToken",
78
+ accessToken: credentials.accessToken,
79
+ },
80
+ },
81
+ };
82
+ }
83
+
84
+ // Password accounts: prefer the SMTP-specific secret, falling back to the
85
+ // IMAP password hash (issue #163). The upstream password credential is the
86
+ // IMAP password and must NOT override an account's separate SMTP password.
87
+ const passwordHash = account.smtpPasswordHash ?? account.passwordHash;
88
+ if (!passwordHash) {
89
+ return { ok: false, reason: "No password configured for this account" };
90
+ }
91
+ const smtpPassword = await secrets.decrypt(
92
+ deserializeEncryptedPayload(JSON.parse(passwordHash)),
93
+ );
94
+
95
+ return {
96
+ ok: true,
97
+ config: {
98
+ host: smtpHost,
99
+ port: smtpPort,
100
+ secure: account.smtpTls ?? false,
101
+ user: smtpUser,
102
+ credentials: { kind: "password", password: smtpPassword },
103
+ },
104
+ };
105
+ };
@@ -0,0 +1,270 @@
1
+ import type {
2
+ AccountItem,
3
+ MessageItem,
4
+ OutboxMessageItem,
5
+ UpdateOutboxMessageInput,
6
+ } from "@remit/data-ports";
7
+ import { AccountAuthType } from "@remit/domain-enums";
8
+ import type { Logger } from "@remit/logger-lambda";
9
+ import { RefreshTokenError } from "@remit/mail-oauth-service";
10
+ import type { SecretsService } from "@remit/secrets-service";
11
+ import {
12
+ buildMailMessage,
13
+ type SendResult,
14
+ SmtpConnectionError,
15
+ type sendMail,
16
+ } from "@remit/smtp-service";
17
+ import type { SendMessageEvent } from "../events.js";
18
+ import { writeEngagementCounters } from "./engagement-counters.js";
19
+ import {
20
+ resolveSmtpConfig,
21
+ type SmtpCredentials,
22
+ } from "./resolve-smtp-config.js";
23
+
24
+ /** Tenant scope carried from the loaded account, never read off a looked-up row. */
25
+ export interface SendTenant {
26
+ accountConfigId: string;
27
+ accountId: string;
28
+ }
29
+
30
+ export interface EngagementCounterDeps {
31
+ resolveAddressId: (accountConfigId: string, email: string) => string;
32
+ incrementOutboundCount: (
33
+ accountConfigId: string,
34
+ addressId: string,
35
+ now: number,
36
+ ) => Promise<void>;
37
+ incrementReplyCount: (
38
+ accountConfigId: string,
39
+ addressId: string,
40
+ now: number,
41
+ ) => Promise<void>;
42
+ findMessageByHeader: (
43
+ accountId: string,
44
+ messageIdHeader: string,
45
+ ) => Promise<MessageItem | null>;
46
+ getEnvelopeFromEmail: (messageId: string) => Promise<string | null>;
47
+ }
48
+
49
+ export interface SendMessageDeps {
50
+ getOutbox: (
51
+ accountConfigId: string,
52
+ id: string,
53
+ ) => Promise<OutboxMessageItem>;
54
+ getAccount: (id: string) => Promise<AccountItem>;
55
+ updateOutbox: (
56
+ accountConfigId: string,
57
+ id: string,
58
+ patch: UpdateOutboxMessageInput,
59
+ ) => Promise<unknown>;
60
+ updateOutboxStatus: (
61
+ accountConfigId: string,
62
+ id: string,
63
+ status: OutboxMessageItem["status"],
64
+ ) => Promise<unknown>;
65
+ markOutboxSent: (
66
+ accountConfigId: string,
67
+ id: string,
68
+ fields: { sentAt: number; smtpMessageId?: string },
69
+ ) => Promise<unknown>;
70
+ secrets: Pick<SecretsService, "decrypt">;
71
+ /**
72
+ * Resolve credentials for the account. Called after fetching the account.
73
+ * For password accounts this may resolve immediately from the stored hash.
74
+ * For OAuth accounts this mints an access token via the token service.
75
+ * Throws RefreshTokenError on OAuth failures — callers should not need to
76
+ * handle this here; the caller of sendMessage handles it.
77
+ */
78
+ resolveCredentials: (account: AccountItem) => Promise<SmtpCredentials>;
79
+ /**
80
+ * Persist the account's connectionState. Called when a terminal OAuth/SMTP
81
+ * auth failure is detected so the account is fenced off until the user
82
+ * re-auths (mirrors the IMAP withOAuthLifecycle contract).
83
+ */
84
+ updateConnectionState: (accountId: string, state: string) => Promise<void>;
85
+ send: typeof sendMail;
86
+ emitAppendSentMessage: (
87
+ accountId: string,
88
+ outboxMessageId: string,
89
+ ) => Promise<void>;
90
+ engagement: EngagementCounterDeps;
91
+ }
92
+
93
+ export const sendMessage = async (
94
+ event: SendMessageEvent,
95
+ log: Logger,
96
+ deps: SendMessageDeps,
97
+ ): Promise<void> => {
98
+ const { outboxMessageId, accountId } = event;
99
+
100
+ log.info({ outboxMessageId, accountId }, "Processing send message event");
101
+
102
+ // Load the account first: its accountConfigId scopes every outbox lookup and
103
+ // update. The worker's tenant comes from the account it loads by accountId,
104
+ // never off the outbox row it fetches.
105
+ const account = await deps.getAccount(accountId);
106
+ const { accountConfigId } = account;
107
+
108
+ const outbox = await deps.getOutbox(accountConfigId, outboxMessageId);
109
+ if (outbox.status === "sent") {
110
+ log.info({ outboxMessageId }, "Message already sent, skipping");
111
+ return;
112
+ }
113
+
114
+ // Tombstone fence: drop events for deleted accounts (#228)
115
+ if (account.deletedAt) {
116
+ log.info(
117
+ { accountId, deletedAt: account.deletedAt },
118
+ "Account deleted, dropping send event",
119
+ );
120
+ return;
121
+ }
122
+
123
+ // Reauth fence: skip accounts that need re-authentication. No SMTP traffic
124
+ // until the user re-auths (mirrors the IMAP reauth/ACK contract, #472).
125
+ if (account.connectionState === "reauth_required") {
126
+ log.info(
127
+ { accountId, connectionState: account.connectionState },
128
+ "Account requires reauth, dropping send event",
129
+ );
130
+ return;
131
+ }
132
+
133
+ // Resolve credentials. On a terminal OAuth auth failure (token revoked),
134
+ // flip the account to reauth_required and ACK — do not retry. Transient /
135
+ // config failures rethrow for SQS retry/backoff.
136
+ let credentials: SmtpCredentials;
137
+ try {
138
+ credentials = await deps.resolveCredentials(account);
139
+ } catch (err) {
140
+ if (err instanceof RefreshTokenError) {
141
+ if (err.error.kind === "reauth-required") {
142
+ log.warn(
143
+ { accountId, errorKind: err.error.kind, errorCode: err.error.code },
144
+ "OAuth token revoked; marking account reauth_required",
145
+ );
146
+ await deps.updateConnectionState(accountId, "reauth_required");
147
+ return; // ACK — do not retry
148
+ }
149
+ // transient or config: let-it-crash (SQS retry / DLQ)
150
+ throw err;
151
+ }
152
+ if (err instanceof SmtpConnectionError && err.kind === "auth") {
153
+ // Only OAuth accounts have a re-auth recovery path. For password
154
+ // accounts, rethrow to preserve pre-PR batch-item-failure behaviour.
155
+ if (account.authType !== AccountAuthType.OauthMicrosoft) {
156
+ throw err;
157
+ }
158
+ log.warn(
159
+ { accountId, errorKind: err.kind },
160
+ "SMTP auth rejected; marking account reauth_required",
161
+ );
162
+ await deps.updateConnectionState(accountId, "reauth_required");
163
+ return; // ACK — do not retry
164
+ }
165
+ throw err;
166
+ }
167
+ const resolved = await resolveSmtpConfig(account, deps.secrets, credentials);
168
+ if (!resolved.ok) {
169
+ // `blocked` is distinct from `failed`: no auto-retry — the user has to
170
+ // reconfigure the account first (issue #192).
171
+ await deps.updateOutbox(accountConfigId, outboxMessageId, {
172
+ status: "blocked",
173
+ lastError: resolved.reason,
174
+ });
175
+ log.error({ accountId, reason: resolved.reason }, "SMTP not configured");
176
+ return;
177
+ }
178
+ const smtpConfig = resolved.config;
179
+
180
+ await deps.updateOutboxStatus(accountConfigId, outboxMessageId, "sending");
181
+
182
+ const message = buildMailMessage(outbox);
183
+
184
+ log.info(
185
+ { outboxMessageId, to: outbox.toAddresses, subject: outbox.subject },
186
+ "Sending message via SMTP",
187
+ );
188
+ let result: SendResult;
189
+ try {
190
+ result = await deps.send(smtpConfig, message);
191
+ } catch (err) {
192
+ // A terminal SMTP auth rejection (e.g. expired OAuth token surfaced at
193
+ // connect time) flips the account to reauth_required and ACKs.
194
+ // Only OAuth accounts have a re-auth recovery path. For password
195
+ // accounts, rethrow to preserve pre-PR batch-item-failure behaviour.
196
+ if (err instanceof SmtpConnectionError && err.kind === "auth") {
197
+ if (account.authType !== AccountAuthType.OauthMicrosoft) {
198
+ throw err;
199
+ }
200
+ log.warn(
201
+ { accountId, errorKind: err.kind },
202
+ "SMTP auth rejected during send; marking account reauth_required",
203
+ );
204
+ await deps.updateConnectionState(accountId, "reauth_required");
205
+ return; // ACK — do not retry
206
+ }
207
+ throw err;
208
+ }
209
+
210
+ if (result.success) {
211
+ await deps.markOutboxSent(accountConfigId, outboxMessageId, {
212
+ sentAt: Date.now(),
213
+ smtpMessageId: result.messageId,
214
+ });
215
+ log.info(
216
+ { outboxMessageId, smtpMessageId: result.messageId },
217
+ "Message sent successfully",
218
+ );
219
+
220
+ await writeEngagementCounters(
221
+ outbox,
222
+ { accountConfigId, accountId: account.accountId },
223
+ deps.engagement,
224
+ log,
225
+ ).catch((error: unknown) => {
226
+ log.warn(
227
+ { outboxMessageId, error: String(error) },
228
+ "Failed to write engagement counters (best-effort)",
229
+ );
230
+ });
231
+
232
+ await deps
233
+ .emitAppendSentMessage(accountId, outboxMessageId)
234
+ .catch((error: unknown) => {
235
+ log.warn(
236
+ { outboxMessageId, error: String(error) },
237
+ "Failed to enqueue APPEND_SENT_MESSAGE (best-effort)",
238
+ );
239
+ });
240
+ return;
241
+ }
242
+
243
+ if (result.isTransient) {
244
+ log.warn(
245
+ {
246
+ outboxMessageId,
247
+ smtpCode: result.smtpCode,
248
+ error: result.error?.message,
249
+ },
250
+ "Transient failure, will retry",
251
+ );
252
+ await deps.updateOutboxStatus(accountConfigId, outboxMessageId, "queued");
253
+ throw new Error(`SMTP transient error: ${result.error?.message}`);
254
+ }
255
+
256
+ // Permanent failure - mark as failed, don't throw (no retry)
257
+ await deps.updateOutbox(accountConfigId, outboxMessageId, {
258
+ status: "failed",
259
+ lastError: result.error?.message,
260
+ lastSmtpCode: result.smtpCode,
261
+ });
262
+ log.error(
263
+ {
264
+ outboxMessageId,
265
+ smtpCode: result.smtpCode,
266
+ error: result.error?.message,
267
+ },
268
+ "Permanent failure",
269
+ );
270
+ };