@remit/smtp-worker 0.0.29 → 0.0.31
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/package.json +6 -2
- package/src/data-ports.ts +2 -20
- package/src/events.ts +5 -0
- package/src/failure-kind.ts +1 -1
- package/src/handlers/attachment-storage.test.ts +1 -58
- package/src/handlers/attachment-storage.ts +0 -23
- package/src/handlers/engagement-counters.test.ts +108 -0
- package/src/handlers/engagement-counters.ts +27 -3
- package/src/handlers/resolve-smtp-config.ts +2 -2
- package/src/handlers/send-message.test.ts +538 -864
- package/src/handlers/send-message.ts +510 -174
- package/src/index.test.ts +9 -4
- package/src/index.ts +10 -1
- package/src/lambda-context.test.ts +34 -0
- package/src/lambda-context.ts +41 -0
- package/src/ports.ts +21 -0
- package/src/process-bundle.test.ts +62 -0
- package/src/process.ts +11 -0
- package/src/processor.ts +7 -3
- package/src/handlers/send-message-core.ts +0 -514
|
@@ -1,10 +1,14 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { OutboxMessageItem, QueueProducer } from "@remit/data-ports";
|
|
2
2
|
import { deriveAddressId, deriveMessageId } from "@remit/data-ports/id";
|
|
3
|
-
import type { ConnectionState } from "@remit/domain-enums";
|
|
4
|
-
import type { Logger } from "@remit/logger";
|
|
5
3
|
import {
|
|
6
|
-
|
|
7
|
-
|
|
4
|
+
AccountAuthType,
|
|
5
|
+
type ConnectionState,
|
|
6
|
+
OutboxMessageStatus,
|
|
7
|
+
} from "@remit/domain-enums";
|
|
8
|
+
import { createLogger, type MailFailureKind } from "@remit/logger";
|
|
9
|
+
import {
|
|
10
|
+
type MailOAuthService,
|
|
11
|
+
RefreshTokenError,
|
|
8
12
|
} from "@remit/mail-oauth-service";
|
|
9
13
|
// Subpath import, not the package root: the root barrel re-exports the whole
|
|
10
14
|
// IMAP sync surface (mailbox/flag/message sync, snippet/heuristics text
|
|
@@ -14,198 +18,530 @@ import {
|
|
|
14
18
|
type AccountCredentialsDeps,
|
|
15
19
|
resolveConnectionCredentials,
|
|
16
20
|
} from "@remit/mailbox-service/account-credentials";
|
|
17
|
-
import { createSecretsService } from "@remit/secrets-service";
|
|
18
|
-
import { createKmsDataKeyProvider } from "@remit/secrets-service/kms";
|
|
19
|
-
import { sendMail } from "@remit/smtp-service";
|
|
20
|
-
import { createQueueProducer } from "@remit/sqs-client/producer";
|
|
21
|
-
import { createStorageService } from "@remit/storage-service/s3";
|
|
22
|
-
import { env } from "expect-env";
|
|
23
|
-
import { buildDataPortsFromEnv, type SmtpDataPorts } from "../data-ports.js";
|
|
24
|
-
import type { SendMessageEvent } from "../events.js";
|
|
25
21
|
import {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
} from "
|
|
29
|
-
import {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
};
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
// Lazy OAuth service (only instantiated when an OAuth account sends mail)
|
|
49
|
-
let _tokenService: ReturnType<typeof createMailOAuthService> | undefined;
|
|
50
|
-
const getTokenService = () => {
|
|
51
|
-
if (!_tokenService) {
|
|
52
|
-
_tokenService = createMailOAuthService(
|
|
53
|
-
microsoftProviderConfig({
|
|
54
|
-
clientId: process.env.MSOAUTH_CLIENT_ID ?? "",
|
|
55
|
-
clientSecret: process.env.MSOAUTH_CLIENT_SECRET ?? "",
|
|
56
|
-
overrides: process.env.MSOAUTH_TOKEN_ENDPOINT
|
|
57
|
-
? { tokenEndpoint: process.env.MSOAUTH_TOKEN_ENDPOINT }
|
|
58
|
-
: undefined,
|
|
59
|
-
}),
|
|
60
|
-
);
|
|
61
|
-
}
|
|
62
|
-
return _tokenService;
|
|
63
|
-
};
|
|
22
|
+
loadOutboxAttachmentContents,
|
|
23
|
+
OutboxAttachmentUnavailableError,
|
|
24
|
+
} from "@remit/mailbox-service/outbox-attachment-content";
|
|
25
|
+
import type { SecretsService } from "@remit/secrets-service";
|
|
26
|
+
import {
|
|
27
|
+
buildMailMessage,
|
|
28
|
+
type SendResult,
|
|
29
|
+
SmtpConnectionError,
|
|
30
|
+
type SmtpGetSocket,
|
|
31
|
+
sendMail,
|
|
32
|
+
} from "@remit/smtp-service";
|
|
33
|
+
import type { AppendSentMessageEvent, SendMessageEvent } from "../events.js";
|
|
34
|
+
import type { SmtpDataPorts } from "../ports.js";
|
|
35
|
+
import type { AttachmentReader } from "./attachment-storage.js";
|
|
36
|
+
import {
|
|
37
|
+
type EngagementCounterDeps,
|
|
38
|
+
writeEngagementCounters,
|
|
39
|
+
} from "./engagement-counters.js";
|
|
40
|
+
import {
|
|
41
|
+
resolveSmtpConfig,
|
|
42
|
+
type SmtpCredentials,
|
|
43
|
+
} from "./resolve-smtp-config.js";
|
|
64
44
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
45
|
+
export interface SendMessageContext {
|
|
46
|
+
ports: SmtpDataPorts;
|
|
47
|
+
secrets: SecretsService;
|
|
48
|
+
storage: AttachmentReader;
|
|
49
|
+
messageManagement: QueueProducer<AppendSentMessageEvent>;
|
|
50
|
+
tokenService: Pick<MailOAuthService, "getAccessToken">;
|
|
51
|
+
getSocket: SmtpGetSocket;
|
|
52
|
+
maxAttempts: number;
|
|
53
|
+
recordSmtpFailure: (kind: MailFailureKind) => void;
|
|
73
54
|
}
|
|
74
55
|
|
|
75
|
-
const
|
|
76
|
-
|
|
56
|
+
const log = createLogger();
|
|
57
|
+
|
|
58
|
+
const credentialDeps = (ctx: SendMessageContext): AccountCredentialsDeps => ({
|
|
59
|
+
secrets: ctx.secrets,
|
|
60
|
+
tokenService: ctx.tokenService,
|
|
61
|
+
persistRotatedToken: async (accountId, encryptedHash, updatedAt) => {
|
|
62
|
+
await ctx.ports.account.update(accountId, {
|
|
63
|
+
oauthRefreshTokenHash: encryptedHash,
|
|
64
|
+
oauthTokenUpdatedAt: updatedAt,
|
|
65
|
+
});
|
|
66
|
+
},
|
|
77
67
|
});
|
|
78
68
|
|
|
79
|
-
|
|
69
|
+
/**
|
|
70
|
+
* Persist the account's connectionState, and with it the sentence the account
|
|
71
|
+
* card shows. `lastError` carries the server's own words when the refusal
|
|
72
|
+
* came with any; a flip with no reason of its own removes the stored one, so
|
|
73
|
+
* the card never reads back a failure the account has moved on from.
|
|
74
|
+
*/
|
|
75
|
+
const updateConnectionState = async (
|
|
76
|
+
ports: SmtpDataPorts,
|
|
80
77
|
accountId: string,
|
|
81
|
-
|
|
78
|
+
state: (typeof ConnectionState)[keyof typeof ConnectionState],
|
|
79
|
+
lastError?: string,
|
|
82
80
|
): Promise<void> => {
|
|
83
|
-
|
|
84
|
-
type: "APPEND_SENT_MESSAGE",
|
|
81
|
+
await ports.account.update(
|
|
85
82
|
accountId,
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
};
|
|
90
|
-
|
|
91
|
-
await messageMgmtQueue.send({ body: event });
|
|
83
|
+
{ connectionState: state, lastError },
|
|
84
|
+
lastError === undefined ? ["lastError"] : undefined,
|
|
85
|
+
);
|
|
92
86
|
};
|
|
93
87
|
|
|
94
|
-
const
|
|
88
|
+
const isNotFound = (error: unknown): boolean =>
|
|
89
|
+
typeof error === "object" &&
|
|
90
|
+
error !== null &&
|
|
91
|
+
"name" in error &&
|
|
92
|
+
error.name === "NotFoundError";
|
|
93
|
+
|
|
94
|
+
const engagementDeps = (ports: SmtpDataPorts): EngagementCounterDeps => ({
|
|
95
|
+
resolveAddressId: deriveAddressId,
|
|
96
|
+
incrementOutboundCount: (accountConfigId, addressId, now) =>
|
|
97
|
+
ports.address.incrementOutboundCount(accountConfigId, addressId, now),
|
|
98
|
+
incrementReplyCount: (accountConfigId, addressId, now) =>
|
|
99
|
+
ports.address.incrementReplyCount(accountConfigId, addressId, now),
|
|
100
|
+
findMessageByHeader: (accountId, messageIdHeader) =>
|
|
101
|
+
ports.message
|
|
102
|
+
.get(deriveMessageId(accountId, messageIdHeader))
|
|
103
|
+
.catch((error: unknown) => {
|
|
104
|
+
if (isNotFound(error)) return null;
|
|
105
|
+
throw error;
|
|
106
|
+
}),
|
|
107
|
+
getEnvelopeFromEmail: async (messageId) => {
|
|
108
|
+
const data = await ports.envelope
|
|
109
|
+
.getMessageData(messageId)
|
|
110
|
+
.catch((error: unknown) => {
|
|
111
|
+
if (isNotFound(error)) return null;
|
|
112
|
+
throw error;
|
|
113
|
+
});
|
|
114
|
+
if (!data) return null;
|
|
115
|
+
const fromEntry = data.envelopeAddress.find(
|
|
116
|
+
(addr) => addr.addressRole === "from",
|
|
117
|
+
);
|
|
118
|
+
return fromEntry?.normalizedEmail ?? null;
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const emitAppendSentMessage = (
|
|
123
|
+
ctx: SendMessageContext,
|
|
95
124
|
accountId: string,
|
|
96
|
-
|
|
97
|
-
) =>
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
125
|
+
outboxMessageId: string,
|
|
126
|
+
): Promise<void> =>
|
|
127
|
+
ctx.messageManagement.send({
|
|
128
|
+
body: {
|
|
129
|
+
type: "APPEND_SENT_MESSAGE",
|
|
130
|
+
accountId,
|
|
131
|
+
outboxMessageId,
|
|
132
|
+
eventId: crypto.randomUUID(),
|
|
133
|
+
timestamp: Date.now(),
|
|
134
|
+
},
|
|
103
135
|
});
|
|
104
|
-
};
|
|
105
136
|
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
): Promise<string | null> => {
|
|
109
|
-
const { envelope } = await getPorts();
|
|
110
|
-
const data = await envelope
|
|
111
|
-
.getMessageData(messageId)
|
|
112
|
-
.catch((error: unknown) => {
|
|
113
|
-
if ((error as { name?: string })?.name === "NotFoundError") return null;
|
|
114
|
-
throw error;
|
|
115
|
-
});
|
|
116
|
-
if (!data) return null;
|
|
117
|
-
const fromEntry = data.envelopeAddress.find(
|
|
118
|
-
(addr) => addr.addressRole === "from",
|
|
119
|
-
);
|
|
120
|
-
return fromEntry?.normalizedEmail ?? null;
|
|
121
|
-
};
|
|
137
|
+
const UNFILED_NOT_QUEUED =
|
|
138
|
+
"Sent, but not filed: the copy for the Sent folder could not be queued.";
|
|
122
139
|
|
|
123
140
|
/**
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
* up the new token without needing a separate refresh.
|
|
141
|
+
* Every re-auth branch settles the row with this, because the row is the only
|
|
142
|
+
* place the user learns the send stopped: `blocked` shows its reason and offers
|
|
143
|
+
* no Retry, and a retry cannot succeed until the account is reconnected.
|
|
128
144
|
*/
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
145
|
+
const REAUTH_REQUIRED =
|
|
146
|
+
"This account needs to be reconnected before it can send. Open Settings → Accounts and choose Reconnect, then send this message again.";
|
|
147
|
+
|
|
148
|
+
const UNFILED_CONNECTION_LOST =
|
|
149
|
+
"The connection to the outgoing server dropped during the send, so this message may already have been delivered. Check with the recipient before sending it again.";
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The connection failures that prove nothing was submitted: no session ever
|
|
153
|
+
* opened, so the server holds no copy and `failed` is safe — Retry sends the
|
|
154
|
+
* only copy there is.
|
|
155
|
+
*
|
|
156
|
+
* `ECONNRESET` and `ETIMEDOUT` classify as `network` too and are deliberately
|
|
157
|
+
* absent. Either can land after DATA, with the message already queued on the
|
|
158
|
+
* server, and a `failed` row invites a Retry that delivers it twice.
|
|
159
|
+
*/
|
|
160
|
+
const NEVER_SUBMITTED_CODES: ReadonlySet<string> = new Set([
|
|
161
|
+
"ECONNREFUSED",
|
|
162
|
+
"ENOTFOUND",
|
|
163
|
+
"EHOSTUNREACH",
|
|
164
|
+
]);
|
|
165
|
+
|
|
166
|
+
const errorCode = (cause: unknown): string =>
|
|
167
|
+
cause instanceof Error && "code" in cause && typeof cause.code === "string"
|
|
168
|
+
? cause.code
|
|
169
|
+
: "";
|
|
140
170
|
|
|
141
|
-
|
|
171
|
+
/**
|
|
172
|
+
* Whether the message can be re-sent without risking a second copy. An auth
|
|
173
|
+
* rejection is decided before the envelope; a connection failure carries the
|
|
174
|
+
* code that says how far it got. Anything else counts as possibly delivered,
|
|
175
|
+
* which is the answer that cannot produce a duplicate.
|
|
176
|
+
*/
|
|
177
|
+
const neverSubmitted = (err: SmtpConnectionError): boolean =>
|
|
178
|
+
err.kind === "auth" || NEVER_SUBMITTED_CODES.has(errorCode(err.cause));
|
|
179
|
+
|
|
180
|
+
const SENDABLE_STATUSES: ReadonlySet<OutboxMessageItem["status"]> = new Set([
|
|
181
|
+
OutboxMessageStatus.draft,
|
|
182
|
+
OutboxMessageStatus.queued,
|
|
183
|
+
OutboxMessageStatus.sending,
|
|
184
|
+
]);
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Why a message stopped at its files, in words the Outbox can show. A `failed`
|
|
188
|
+
* row can be edited, sent again and discarded (#933), so the sentence names
|
|
189
|
+
* the file and what to do about it.
|
|
190
|
+
*/
|
|
191
|
+
const attachmentFailureReason = (error: unknown): string =>
|
|
192
|
+
error instanceof OutboxAttachmentUnavailableError
|
|
193
|
+
? `${error.message}, so this message was not sent. Remove "${error.filename}", attach it again, and send.`
|
|
194
|
+
: `The files on this message could not be read (${error instanceof Error ? error.message : String(error)}), so it was not sent. Send it again.`;
|
|
195
|
+
|
|
196
|
+
export const handleSendMessage = async (
|
|
142
197
|
event: SendMessageEvent,
|
|
143
|
-
|
|
144
|
-
receiveCount
|
|
198
|
+
ctx: SendMessageContext,
|
|
199
|
+
receiveCount: number,
|
|
145
200
|
): Promise<void> => {
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
201
|
+
const { outboxMessageId, accountId } = event;
|
|
202
|
+
const { ports } = ctx;
|
|
203
|
+
|
|
204
|
+
log.info({ outboxMessageId, accountId }, "Processing send message event");
|
|
205
|
+
|
|
206
|
+
// Load the account first: its accountConfigId scopes every outbox lookup and
|
|
207
|
+
// update. The worker's tenant comes from the account it loads by accountId,
|
|
208
|
+
// never off the outbox row it fetches.
|
|
209
|
+
const account = await ports.account.get(accountId);
|
|
210
|
+
const { accountConfigId } = account;
|
|
211
|
+
|
|
212
|
+
const outbox = await ports.outboxMessage.get(
|
|
213
|
+
accountConfigId,
|
|
214
|
+
outboxMessageId,
|
|
215
|
+
);
|
|
216
|
+
// The fence against SQS at-least-once redelivery, stated as the states a
|
|
217
|
+
// send may proceed FROM. Every other state has already been on the wire, so
|
|
218
|
+
// naming them one by one is how a state added later silently starts sending
|
|
219
|
+
// twice.
|
|
220
|
+
if (!SENDABLE_STATUSES.has(outbox.status)) {
|
|
221
|
+
log.info(
|
|
222
|
+
{ outboxMessageId, status: outbox.status },
|
|
223
|
+
"Message already left the queue, skipping",
|
|
224
|
+
);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// `send` queues a row before it emits, so an event that finds one at `draft`
|
|
229
|
+
// found a row the user pulled back into the composer after the emit. That is
|
|
230
|
+
// not a stalled send and it keeps its own state. Every other settle is
|
|
231
|
+
// conditional on the status the send was decided against, so a row that has
|
|
232
|
+
// moved on since is not overwritten either.
|
|
233
|
+
const settleBlockedOnReauth = async (
|
|
234
|
+
expected: OutboxMessageItem["status"] = outbox.status,
|
|
235
|
+
): Promise<void> => {
|
|
236
|
+
if (expected === OutboxMessageStatus.draft) return;
|
|
237
|
+
await ports.outboxMessage.updateIfStatus(
|
|
238
|
+
accountConfigId,
|
|
239
|
+
outboxMessageId,
|
|
240
|
+
expected,
|
|
241
|
+
{
|
|
242
|
+
status: OutboxMessageStatus.blocked,
|
|
243
|
+
lastError: REAUTH_REQUIRED,
|
|
244
|
+
},
|
|
245
|
+
);
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
// Tombstone fence: drop events for deleted accounts (#228)
|
|
249
|
+
if (account.deletedAt) {
|
|
250
|
+
log.info(
|
|
251
|
+
{ accountId, deletedAt: account.deletedAt },
|
|
252
|
+
"Account deleted, dropping send event",
|
|
253
|
+
);
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Reauth fence: skip accounts that need re-authentication. No SMTP traffic
|
|
258
|
+
// until the user re-auths (mirrors the IMAP reauth/ACK contract, #472).
|
|
259
|
+
if (account.connectionState === "reauth_required") {
|
|
260
|
+
await settleBlockedOnReauth();
|
|
261
|
+
log.info(
|
|
262
|
+
{ accountId, connectionState: account.connectionState },
|
|
263
|
+
"Account requires reauth, blocking the message",
|
|
264
|
+
);
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Resolve credentials. On a terminal OAuth auth failure (token revoked),
|
|
269
|
+
// flip the account to reauth_required and ACK — do not retry. Transient /
|
|
270
|
+
// config failures rethrow for SQS retry/backoff.
|
|
271
|
+
let credentials: SmtpCredentials | undefined;
|
|
272
|
+
try {
|
|
273
|
+
const resolution = await resolveConnectionCredentials(
|
|
274
|
+
account,
|
|
275
|
+
credentialDeps(ctx),
|
|
276
|
+
);
|
|
277
|
+
if (resolution.status === "missing") {
|
|
278
|
+
// Terminal, never a retry (issue #1120). The account still sends if it
|
|
279
|
+
// carries an SMTP-specific credential of its own, so this hands
|
|
280
|
+
// resolveSmtpConfig no credential and lets it decide; where there is
|
|
281
|
+
// none either, the send settles `blocked` below.
|
|
282
|
+
log.warn(
|
|
283
|
+
{
|
|
284
|
+
accountId,
|
|
285
|
+
reason: resolution.reason,
|
|
286
|
+
connectionState: resolution.terminalState,
|
|
287
|
+
},
|
|
288
|
+
"Account stores no credential",
|
|
289
|
+
);
|
|
290
|
+
await updateConnectionState(ports, accountId, resolution.terminalState);
|
|
291
|
+
} else {
|
|
292
|
+
credentials = resolution.credentials;
|
|
293
|
+
}
|
|
294
|
+
} catch (err) {
|
|
295
|
+
if (err instanceof RefreshTokenError) {
|
|
296
|
+
if (err.error.kind === "reauth-required") {
|
|
297
|
+
log.warn(
|
|
298
|
+
{ accountId, errorKind: err.error.kind, errorCode: err.error.code },
|
|
299
|
+
"OAuth token revoked; marking account reauth_required",
|
|
186
300
|
);
|
|
301
|
+
await updateConnectionState(ports, accountId, "reauth_required");
|
|
302
|
+
await settleBlockedOnReauth();
|
|
303
|
+
return; // ACK — do not retry
|
|
304
|
+
}
|
|
305
|
+
// transient or config: let-it-crash (SQS retry / DLQ)
|
|
306
|
+
throw err;
|
|
307
|
+
}
|
|
308
|
+
if (err instanceof SmtpConnectionError && err.kind === "auth") {
|
|
309
|
+
// Only OAuth accounts have a re-auth recovery path. For password
|
|
310
|
+
// accounts, rethrow to preserve pre-PR batch-item-failure behaviour.
|
|
311
|
+
if (account.authType !== AccountAuthType.OauthMicrosoft) {
|
|
312
|
+
throw err;
|
|
313
|
+
}
|
|
314
|
+
log.warn(
|
|
315
|
+
{ accountId, errorKind: err.kind, error: err.message },
|
|
316
|
+
"SMTP auth rejected; marking account reauth_required",
|
|
317
|
+
);
|
|
318
|
+
await updateConnectionState(
|
|
319
|
+
ports,
|
|
320
|
+
accountId,
|
|
321
|
+
"reauth_required",
|
|
322
|
+
err.message,
|
|
323
|
+
);
|
|
324
|
+
await settleBlockedOnReauth();
|
|
325
|
+
return; // ACK — do not retry
|
|
326
|
+
}
|
|
327
|
+
throw err;
|
|
328
|
+
}
|
|
329
|
+
const resolved = await resolveSmtpConfig(account, ctx.secrets, credentials);
|
|
330
|
+
if (!resolved.ok) {
|
|
331
|
+
// `blocked` is distinct from `failed`: no auto-retry — the user has to
|
|
332
|
+
// reconfigure the account first (issue #192).
|
|
333
|
+
await ports.outboxMessage.update(accountConfigId, outboxMessageId, {
|
|
334
|
+
status: "blocked",
|
|
335
|
+
lastError: resolved.reason,
|
|
336
|
+
});
|
|
337
|
+
log.error({ accountId, reason: resolved.reason }, "SMTP not configured");
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
const smtpConfig = { ...resolved.config, getSocket: ctx.getSocket };
|
|
341
|
+
|
|
342
|
+
// Below the budget a storage hiccup is retried like any other; at it the
|
|
343
|
+
// record would dead-letter with the row at `queued`, which can be neither
|
|
344
|
+
// sent again nor discarded (#845.8), so the row settles `failed` instead.
|
|
345
|
+
const attachments = await loadOutboxAttachmentContents(
|
|
346
|
+
{ attachments: ports.outboxAttachment, storage: ctx.storage },
|
|
347
|
+
{ accountConfigId, accountId: account.accountId, outboxMessageId },
|
|
348
|
+
Math.floor(Date.now() / 1000),
|
|
349
|
+
).catch(async (error: unknown) => {
|
|
350
|
+
if (receiveCount < ctx.maxAttempts) throw error;
|
|
351
|
+
log.error(
|
|
352
|
+
{ outboxMessageId, receiveCount, error: String(error) },
|
|
353
|
+
"Attachments could not be loaded; settling the row as failed",
|
|
354
|
+
);
|
|
355
|
+
if (outbox.status !== OutboxMessageStatus.draft) {
|
|
356
|
+
await ports.outboxMessage.updateIfStatus(
|
|
357
|
+
accountConfigId,
|
|
358
|
+
outboxMessageId,
|
|
359
|
+
outbox.status,
|
|
360
|
+
{
|
|
361
|
+
status: OutboxMessageStatus.failed,
|
|
362
|
+
lastError: attachmentFailureReason(error),
|
|
363
|
+
},
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
return null;
|
|
367
|
+
});
|
|
368
|
+
if (attachments === null) return;
|
|
369
|
+
|
|
370
|
+
await ports.outboxMessage.updateStatus(
|
|
371
|
+
accountConfigId,
|
|
372
|
+
outboxMessageId,
|
|
373
|
+
"sending",
|
|
374
|
+
);
|
|
375
|
+
|
|
376
|
+
const message = buildMailMessage(outbox, attachments);
|
|
377
|
+
|
|
378
|
+
log.info(
|
|
379
|
+
{ outboxMessageId, to: outbox.toAddresses, subject: outbox.subject },
|
|
380
|
+
"Sending message via SMTP",
|
|
381
|
+
);
|
|
382
|
+
let result: SendResult;
|
|
383
|
+
try {
|
|
384
|
+
result = await sendMail(smtpConfig, message);
|
|
385
|
+
} catch (err) {
|
|
386
|
+
// A terminal SMTP auth rejection (e.g. expired OAuth token surfaced at
|
|
387
|
+
// connect time) flips the account to reauth_required and ACKs.
|
|
388
|
+
// Only OAuth accounts have a re-auth recovery path.
|
|
389
|
+
if (
|
|
390
|
+
err instanceof SmtpConnectionError &&
|
|
391
|
+
err.kind === "auth" &&
|
|
392
|
+
account.authType === AccountAuthType.OauthMicrosoft
|
|
393
|
+
) {
|
|
394
|
+
log.warn(
|
|
395
|
+
{ accountId, errorKind: err.kind, error: err.message },
|
|
396
|
+
"SMTP auth rejected during send; marking account reauth_required",
|
|
397
|
+
);
|
|
398
|
+
await updateConnectionState(
|
|
399
|
+
ports,
|
|
400
|
+
accountId,
|
|
401
|
+
"reauth_required",
|
|
402
|
+
err.message,
|
|
403
|
+
);
|
|
404
|
+
await settleBlockedOnReauth(OutboxMessageStatus.sending);
|
|
405
|
+
return; // ACK — do not retry
|
|
406
|
+
}
|
|
407
|
+
// A password account's auth failure and every network failure retry on
|
|
408
|
+
// SQS redelivery until the queue's own budget runs out, then settle
|
|
409
|
+
// here instead of dead-lettering with the row stuck at `sending`
|
|
410
|
+
// (issue #951). Where it settles is the double-send question:
|
|
411
|
+
// `neverSubmitted` says the message cannot be on the server, so
|
|
412
|
+
// `failed` offers the Retry the user needs; anything else settles
|
|
413
|
+
// `unfiled`, the state that says a copy may be out there and which
|
|
414
|
+
// Retry is not offered on. Wait-or-reconcile
|
|
415
|
+
// (docs/architecture/imap-mutations.md R2): neither applies — a
|
|
416
|
+
// submission leaves no server-side handle to reconcile against, so the
|
|
417
|
+
// row settles on what the failure itself proves.
|
|
418
|
+
if (err instanceof SmtpConnectionError) {
|
|
419
|
+
if (receiveCount < ctx.maxAttempts) {
|
|
420
|
+
throw err;
|
|
421
|
+
}
|
|
422
|
+
const settled = neverSubmitted(err)
|
|
423
|
+
? { status: OutboxMessageStatus.failed, lastError: err.message }
|
|
424
|
+
: {
|
|
425
|
+
status: OutboxMessageStatus.unfiled,
|
|
426
|
+
lastError: `${UNFILED_CONNECTION_LOST} (${err.message})`,
|
|
427
|
+
};
|
|
428
|
+
await ports.outboxMessage.update(
|
|
429
|
+
accountConfigId,
|
|
430
|
+
outboxMessageId,
|
|
431
|
+
settled,
|
|
432
|
+
);
|
|
433
|
+
// Terminal and never re-thrown, so the handler-outcome series
|
|
434
|
+
// records this record as a success. Counted here or it is invisible.
|
|
435
|
+
ctx.recordSmtpFailure(err.kind);
|
|
436
|
+
log.error(
|
|
437
|
+
{
|
|
438
|
+
outboxMessageId,
|
|
439
|
+
errorKind: err.kind,
|
|
440
|
+
receiveCount,
|
|
441
|
+
status: settled.status,
|
|
442
|
+
},
|
|
443
|
+
"SMTP send retry exhausted; settling the row",
|
|
444
|
+
);
|
|
445
|
+
return; // ACK — settled terminal, no more retries
|
|
446
|
+
}
|
|
447
|
+
throw err;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
if (result.success) {
|
|
451
|
+
await ports.outboxMessage.markSent(accountConfigId, outboxMessageId, {
|
|
452
|
+
sentAt: Date.now(),
|
|
453
|
+
smtpMessageId: result.messageId,
|
|
454
|
+
});
|
|
455
|
+
log.info(
|
|
456
|
+
{ outboxMessageId, smtpMessageId: result.messageId },
|
|
457
|
+
"Message sent successfully",
|
|
458
|
+
);
|
|
459
|
+
|
|
460
|
+
await writeEngagementCounters(
|
|
461
|
+
outbox,
|
|
462
|
+
{ accountConfigId, accountId: account.accountId },
|
|
463
|
+
engagementDeps(ports),
|
|
464
|
+
log,
|
|
465
|
+
).catch((error: unknown) => {
|
|
466
|
+
log.warn(
|
|
467
|
+
{ outboxMessageId, error: String(error) },
|
|
468
|
+
"Failed to write engagement counters (best-effort)",
|
|
469
|
+
);
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
// The filing is what deletes this row, so an enqueue that never lands
|
|
473
|
+
// leaves it at `sent` — a delivered message the Outbox hides and no Sent
|
|
474
|
+
// folder holds. Settle it here instead: the send itself is done, so
|
|
475
|
+
// throwing would only redeliver an event that cannot re-send.
|
|
476
|
+
await emitAppendSentMessage(ctx, accountId, outboxMessageId).catch(
|
|
477
|
+
async (error: unknown) => {
|
|
478
|
+
log.error(
|
|
479
|
+
{ outboxMessageId, error: String(error) },
|
|
480
|
+
"Failed to enqueue APPEND_SENT_MESSAGE; settling the row as unfiled",
|
|
481
|
+
);
|
|
482
|
+
await ports.outboxMessage.update(accountConfigId, outboxMessageId, {
|
|
483
|
+
status: OutboxMessageStatus.unfiled,
|
|
484
|
+
lastError: UNFILED_NOT_QUEUED,
|
|
485
|
+
});
|
|
187
486
|
},
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
487
|
+
);
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
if (result.isTransient) {
|
|
492
|
+
// A 4xx from the server is retried on SQS redelivery the same way a
|
|
493
|
+
// connection failure is above; once the queue's own budget runs out
|
|
494
|
+
// this settles the row at `failed` rather than leaving it at `queued`
|
|
495
|
+
// forever once the record dead-letters (issue #951).
|
|
496
|
+
if (receiveCount < ctx.maxAttempts) {
|
|
497
|
+
log.warn(
|
|
498
|
+
{
|
|
499
|
+
outboxMessageId,
|
|
500
|
+
smtpCode: result.smtpCode,
|
|
501
|
+
error: result.error?.message,
|
|
502
|
+
},
|
|
503
|
+
"Transient failure, will retry",
|
|
504
|
+
);
|
|
505
|
+
await ports.outboxMessage.updateStatus(
|
|
506
|
+
accountConfigId,
|
|
507
|
+
outboxMessageId,
|
|
508
|
+
"queued",
|
|
509
|
+
);
|
|
510
|
+
throw new Error(`SMTP transient error: ${result.error?.message}`);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
await ports.outboxMessage.update(accountConfigId, outboxMessageId, {
|
|
514
|
+
status: "failed",
|
|
515
|
+
lastError: result.error?.message,
|
|
516
|
+
lastSmtpCode: result.smtpCode,
|
|
517
|
+
});
|
|
518
|
+
// Terminal and never re-thrown, so the handler-outcome series records
|
|
519
|
+
// this record as a success. Counted here or it is invisible.
|
|
520
|
+
ctx.recordSmtpFailure("other");
|
|
521
|
+
log.error(
|
|
522
|
+
{
|
|
523
|
+
outboxMessageId,
|
|
524
|
+
smtpCode: result.smtpCode,
|
|
525
|
+
error: result.error?.message,
|
|
526
|
+
receiveCount,
|
|
207
527
|
},
|
|
528
|
+
"Transient failure retry exhausted; settling as failed",
|
|
529
|
+
);
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// Permanent failure - mark as failed, don't throw (no retry)
|
|
534
|
+
await ports.outboxMessage.update(accountConfigId, outboxMessageId, {
|
|
535
|
+
status: "failed",
|
|
536
|
+
lastError: result.error?.message,
|
|
537
|
+
lastSmtpCode: result.smtpCode,
|
|
538
|
+
});
|
|
539
|
+
log.error(
|
|
540
|
+
{
|
|
541
|
+
outboxMessageId,
|
|
542
|
+
smtpCode: result.smtpCode,
|
|
543
|
+
error: result.error?.message,
|
|
208
544
|
},
|
|
209
|
-
|
|
545
|
+
"Permanent failure",
|
|
210
546
|
);
|
|
211
547
|
};
|