@remit/smtp-worker 0.0.26 → 0.0.28
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 +4 -2
- package/src/data-ports.ts +17 -21
- package/src/e2e-processor-shim.ts +4 -1
- package/src/failure-kind.ts +1 -1
- package/src/handlers/attachment-storage.test.ts +106 -0
- package/src/handlers/attachment-storage.ts +52 -0
- package/src/handlers/engagement-counters.ts +6 -6
- package/src/handlers/send-message-core.ts +48 -2
- package/src/handlers/send-message.test.ts +75 -1
- package/src/handlers/send-message.ts +12 -1
- package/src/index.ts +5 -3
- package/src/poller.ts +5 -1
- package/src/processor.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/smtp-worker",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.28",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"exports": {
|
|
@@ -16,17 +16,19 @@
|
|
|
16
16
|
"scripts": {
|
|
17
17
|
"bundle": "node build.mjs",
|
|
18
18
|
"test:typecheck": "tsgo --noEmit",
|
|
19
|
-
"test:run": "node $NODE_TEST_FLAGS --env-file=../../localhost-test-unit.env --experimental-test-module-mocks --experimental-test-coverage --test-coverage-include='src/**' --test-coverage-exclude='src/**/*.test.ts' --test-coverage-exclude='src/**/*.test.tsx' --test-coverage-lines=83 --test 'src/**/*.test.ts'",
|
|
19
|
+
"test:run": "node $NODE_TEST_FLAGS --env-file=../../localhost-test-unit.env --experimental-test-module-mocks --experimental-test-coverage --test-coverage-include='src/**' --test-coverage-exclude='src/**/*.test.ts' --test-coverage-exclude='src/**/*.test.tsx' --test-coverage-exclude='src/handlers/send-message.ts' --test-coverage-lines=83 --test 'src/**/*.test.ts'",
|
|
20
20
|
"test": "npm run test:typecheck && npm run test:run",
|
|
21
21
|
"dev": "node --import tsx src/e2e-processor-shim.ts"
|
|
22
22
|
},
|
|
23
23
|
"dependencies": {
|
|
24
|
+
"@remit/logger": "*",
|
|
24
25
|
"@remit/data-ports": "*",
|
|
25
26
|
"@remit/drizzle-service": "*",
|
|
26
27
|
"@remit/logger-lambda": "*",
|
|
27
28
|
"@remit/sqs-client": "*",
|
|
28
29
|
"@remit/secrets-service": "*",
|
|
29
30
|
"@remit/smtp-service": "*",
|
|
31
|
+
"@remit/storage-service": "*",
|
|
30
32
|
"@remit/domain-enums": "*",
|
|
31
33
|
"@remit/mail-oauth-service": "*",
|
|
32
34
|
"@remit/mailbox-service": "*",
|
package/src/data-ports.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type {
|
|
|
3
3
|
IAddressRepository,
|
|
4
4
|
IEnvelopeRepository,
|
|
5
5
|
IMessageRepository,
|
|
6
|
+
IOutboxAttachmentRepository,
|
|
6
7
|
IOutboxMessageRepository,
|
|
7
8
|
} from "@remit/data-ports";
|
|
8
9
|
|
|
@@ -13,6 +14,7 @@ import type {
|
|
|
13
14
|
export interface SmtpDataPorts {
|
|
14
15
|
account: IAccountRepository;
|
|
15
16
|
outboxMessage: IOutboxMessageRepository;
|
|
17
|
+
outboxAttachment: IOutboxAttachmentRepository;
|
|
16
18
|
address: IAddressRepository;
|
|
17
19
|
message: IMessageRepository;
|
|
18
20
|
envelope: IEnvelopeRepository;
|
|
@@ -24,31 +26,25 @@ export interface SmtpDataPorts {
|
|
|
24
26
|
// marked `external` for the Lambda esbuild build (LAMBDA_ESBUILD_OPTIONS), so
|
|
25
27
|
// esbuild leaves this `import()` unresolved in the bundle. It resolves only
|
|
26
28
|
// where the relational composition actually runs, which has the package
|
|
27
|
-
// installed.
|
|
29
|
+
// installed.
|
|
28
30
|
const buildSqliteDataPorts = async (): Promise<SmtpDataPorts> => {
|
|
29
|
-
const sqliteDbPath = process.env.SQLITE_DB_PATH;
|
|
30
|
-
if (!sqliteDbPath) throw new Error("SQLITE_DB_PATH is required");
|
|
31
|
-
|
|
32
31
|
const {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
DrizzleEnvelopeRepository,
|
|
37
|
-
DrizzleMessageRepository,
|
|
38
|
-
messageDataSchema,
|
|
39
|
-
OutboxMessageRepo,
|
|
32
|
+
createSqliteRepositories,
|
|
33
|
+
openSqliteDatabases,
|
|
34
|
+
sqliteDatabasePathsFromEnv,
|
|
40
35
|
} = await import("@remit/drizzle-service");
|
|
41
36
|
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
37
|
+
const repositories = createSqliteRepositories(
|
|
38
|
+
await openSqliteDatabases(sqliteDatabasePathsFromEnv()),
|
|
39
|
+
);
|
|
45
40
|
|
|
46
41
|
return {
|
|
47
|
-
account:
|
|
48
|
-
outboxMessage:
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
42
|
+
account: repositories.account,
|
|
43
|
+
outboxMessage: repositories.outboxMessage,
|
|
44
|
+
outboxAttachment: repositories.outboxAttachment,
|
|
45
|
+
address: repositories.address,
|
|
46
|
+
message: repositories.message,
|
|
47
|
+
envelope: repositories.envelope,
|
|
52
48
|
};
|
|
53
49
|
};
|
|
54
50
|
|
|
@@ -67,12 +63,12 @@ export const setSmtpDataPorts = (ports: SmtpDataPorts): void => {
|
|
|
67
63
|
/**
|
|
68
64
|
* The SMTP data ports for this process: the ones a composition root registered,
|
|
69
65
|
* or — for a process that registered none — the SQLite composition this build
|
|
70
|
-
* contains. `
|
|
66
|
+
* contains. `SQLITE_APP_DB_PATH` is that composition's precondition, not a backend
|
|
71
67
|
* selection.
|
|
72
68
|
*/
|
|
73
69
|
export const buildDataPortsFromEnv = async (): Promise<SmtpDataPorts> => {
|
|
74
70
|
if (injectedDataPorts) return injectedDataPorts;
|
|
75
|
-
if (process.env.
|
|
71
|
+
if (process.env.SQLITE_APP_DB_PATH) return buildSqliteDataPorts();
|
|
76
72
|
throw new Error(
|
|
77
73
|
"no SMTP data ports registered — register them with setSmtpDataPorts() from your composition root",
|
|
78
74
|
);
|
|
@@ -4,12 +4,15 @@ import {
|
|
|
4
4
|
DeleteMessageCommand,
|
|
5
5
|
ReceiveMessageCommand,
|
|
6
6
|
} from "@aws-sdk/client-sqs";
|
|
7
|
-
import { createLogger } from "@remit/logger
|
|
7
|
+
import { createLogger } from "@remit/logger";
|
|
8
|
+
import { installNodeLogger } from "@remit/logger-lambda/node";
|
|
8
9
|
import { createQueueProducer } from "@remit/sqs-client/producer";
|
|
9
10
|
import type { Context, SQSBatchResponse, SQSEvent } from "aws-lambda";
|
|
10
11
|
import { env } from "expect-env";
|
|
11
12
|
import { handler } from "./index.js";
|
|
12
13
|
|
|
14
|
+
installNodeLogger();
|
|
15
|
+
|
|
13
16
|
/**
|
|
14
17
|
* E2E-only queue drainer.
|
|
15
18
|
*
|
package/src/failure-kind.ts
CHANGED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import type { OutboxAttachmentItem } from "@remit/data-ports";
|
|
4
|
+
import { createMockStorageService } from "@remit/storage-service";
|
|
5
|
+
import {
|
|
6
|
+
createAttachmentLoader,
|
|
7
|
+
createAttachmentReader,
|
|
8
|
+
} from "./attachment-storage.js";
|
|
9
|
+
|
|
10
|
+
describe("createAttachmentReader", () => {
|
|
11
|
+
it("refuses to read on a Lambda with no bucket, naming the missing setting", () => {
|
|
12
|
+
let created = 0;
|
|
13
|
+
const reader = createAttachmentReader(
|
|
14
|
+
{ AWS_LAMBDA_FUNCTION_NAME: "smtp-worker" },
|
|
15
|
+
() => {
|
|
16
|
+
created += 1;
|
|
17
|
+
return createMockStorageService();
|
|
18
|
+
},
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
assert.throws(
|
|
22
|
+
() => reader.retrieveOutboxAttachment("cfg", "acc", "ob", "att"),
|
|
23
|
+
/S3_BUCKET_NAME is not set on this worker/,
|
|
24
|
+
);
|
|
25
|
+
assert.equal(created, 0, "no filesystem fallback was built");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("reads through storage built once, on first use", async () => {
|
|
29
|
+
const storage = createMockStorageService();
|
|
30
|
+
await storage.storeOutboxAttachment({
|
|
31
|
+
accountConfigId: "cfg",
|
|
32
|
+
accountId: "acc",
|
|
33
|
+
outboxMessageId: "ob",
|
|
34
|
+
outboxAttachmentId: "att",
|
|
35
|
+
content: Buffer.from("bytes"),
|
|
36
|
+
});
|
|
37
|
+
let created = 0;
|
|
38
|
+
const reader = createAttachmentReader(
|
|
39
|
+
{ AWS_LAMBDA_FUNCTION_NAME: "smtp-worker", S3_BUCKET_NAME: "bucket" },
|
|
40
|
+
() => {
|
|
41
|
+
created += 1;
|
|
42
|
+
return storage;
|
|
43
|
+
},
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
assert.equal(
|
|
47
|
+
String(await reader.retrieveOutboxAttachment("cfg", "acc", "ob", "att")),
|
|
48
|
+
"bytes",
|
|
49
|
+
);
|
|
50
|
+
await reader.retrieveOutboxAttachment("cfg", "acc", "ob", "att");
|
|
51
|
+
assert.equal(created, 1);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe("createAttachmentLoader", () => {
|
|
56
|
+
it("reads the draft's stored files for the tenant it is given", async () => {
|
|
57
|
+
const storage = createMockStorageService();
|
|
58
|
+
await storage.storeOutboxAttachment({
|
|
59
|
+
accountConfigId: "cfg",
|
|
60
|
+
accountId: "acc",
|
|
61
|
+
outboxMessageId: "ob",
|
|
62
|
+
outboxAttachmentId: "att",
|
|
63
|
+
content: Buffer.from("%PDF"),
|
|
64
|
+
});
|
|
65
|
+
const row: OutboxAttachmentItem = {
|
|
66
|
+
outboxAttachmentId: "att",
|
|
67
|
+
outboxMessageId: "ob",
|
|
68
|
+
accountId: "acc",
|
|
69
|
+
accountConfigId: "cfg",
|
|
70
|
+
filename: "report.pdf",
|
|
71
|
+
contentType: "application/pdf",
|
|
72
|
+
sizeBytes: 4,
|
|
73
|
+
state: "Stored",
|
|
74
|
+
storageKey: "k",
|
|
75
|
+
reservationExpiresAt: 0,
|
|
76
|
+
createdAt: 0,
|
|
77
|
+
updatedAt: 0,
|
|
78
|
+
};
|
|
79
|
+
const asked: string[][] = [];
|
|
80
|
+
const load = createAttachmentLoader(
|
|
81
|
+
storage,
|
|
82
|
+
async () => ({
|
|
83
|
+
listByOutboxMessage: async (accountConfigId, outboxMessageId) => {
|
|
84
|
+
asked.push([accountConfigId, outboxMessageId]);
|
|
85
|
+
return [row];
|
|
86
|
+
},
|
|
87
|
+
}),
|
|
88
|
+
() => 1_000_000,
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
const files = await load(
|
|
92
|
+
{ accountConfigId: "cfg", accountId: "acc" },
|
|
93
|
+
"ob",
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
assert.deepEqual(asked, [["cfg", "ob"]]);
|
|
97
|
+
assert.deepEqual(
|
|
98
|
+
files.map((file) => [
|
|
99
|
+
file.filename,
|
|
100
|
+
file.contentType,
|
|
101
|
+
String(file.content),
|
|
102
|
+
]),
|
|
103
|
+
[["report.pdf", "application/pdf", "%PDF"]],
|
|
104
|
+
);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { IOutboxAttachmentRepository } from "@remit/data-ports";
|
|
2
|
+
import { loadOutboxAttachmentContents } from "@remit/mailbox-service/outbox-attachment-content";
|
|
3
|
+
import type { MailAttachment } from "@remit/smtp-service";
|
|
4
|
+
import type { StorageService } from "@remit/storage-service";
|
|
5
|
+
import type { SendTenant } from "./send-message-core.js";
|
|
6
|
+
|
|
7
|
+
export type AttachmentReader = Pick<StorageService, "retrieveOutboxAttachment">;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Where this worker reads a message's files from, built on the first read.
|
|
11
|
+
*
|
|
12
|
+
* Without a bucket the storage factory falls back to the local filesystem,
|
|
13
|
+
* which a Lambda does not have: every read would come back empty and blame the
|
|
14
|
+
* file. The check runs on the first read rather than at start, so a deployment
|
|
15
|
+
* not yet given the bucket still sends every message that carries no files.
|
|
16
|
+
*/
|
|
17
|
+
export const createAttachmentReader = (
|
|
18
|
+
processEnv: NodeJS.ProcessEnv,
|
|
19
|
+
createStorage: () => StorageService,
|
|
20
|
+
): AttachmentReader => {
|
|
21
|
+
let storage: StorageService | null = null;
|
|
22
|
+
return {
|
|
23
|
+
retrieveOutboxAttachment: (...args) => {
|
|
24
|
+
if (processEnv.AWS_LAMBDA_FUNCTION_NAME && !processEnv.S3_BUCKET_NAME) {
|
|
25
|
+
throw new Error(
|
|
26
|
+
"S3_BUCKET_NAME is not set on this worker, so it has no attachment storage to read from",
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
if (!storage) storage = createStorage();
|
|
30
|
+
return storage.retrieveOutboxAttachment(...args);
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** The files a message goes out with, read from this worker's ports and storage. */
|
|
36
|
+
export const createAttachmentLoader =
|
|
37
|
+
(
|
|
38
|
+
reader: AttachmentReader,
|
|
39
|
+
getRepository: () => Promise<
|
|
40
|
+
Pick<IOutboxAttachmentRepository, "listByOutboxMessage">
|
|
41
|
+
>,
|
|
42
|
+
now: () => number = Date.now,
|
|
43
|
+
) =>
|
|
44
|
+
async (
|
|
45
|
+
tenant: SendTenant,
|
|
46
|
+
outboxMessageId: string,
|
|
47
|
+
): Promise<MailAttachment[]> =>
|
|
48
|
+
loadOutboxAttachmentContents(
|
|
49
|
+
{ attachments: await getRepository(), storage: reader },
|
|
50
|
+
{ ...tenant, outboxMessageId },
|
|
51
|
+
Math.floor(now() / 1000),
|
|
52
|
+
);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { OutboxMessageItem } from "@remit/data-ports";
|
|
2
|
-
import
|
|
2
|
+
import { bareMessageId, wireMessageId } from "@remit/data-ports/id";
|
|
3
|
+
import type { Logger } from "@remit/logger";
|
|
3
4
|
import type { EngagementCounterDeps, SendTenant } from "./send-message-core.js";
|
|
4
5
|
|
|
5
6
|
/**
|
|
@@ -119,8 +120,7 @@ const collectReferenceHeaders = (outbox: OutboxMessageItem): string[] => {
|
|
|
119
120
|
return result;
|
|
120
121
|
};
|
|
121
122
|
|
|
122
|
-
const headerVariants = (header: string): string[] =>
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
};
|
|
123
|
+
const headerVariants = (header: string): string[] => [
|
|
124
|
+
wireMessageId(header),
|
|
125
|
+
bareMessageId(header),
|
|
126
|
+
];
|
|
@@ -5,12 +5,15 @@ import type {
|
|
|
5
5
|
UpdateOutboxMessageInput,
|
|
6
6
|
} from "@remit/data-ports";
|
|
7
7
|
import { AccountAuthType, OutboxMessageStatus } from "@remit/domain-enums";
|
|
8
|
-
import {
|
|
8
|
+
import type { Logger } from "@remit/logger";
|
|
9
|
+
import { recordSmtpFailure } from "@remit/logger-lambda/metrics";
|
|
9
10
|
import { RefreshTokenError } from "@remit/mail-oauth-service";
|
|
10
11
|
import type { CredentialResolution } from "@remit/mailbox-service/account-credentials";
|
|
12
|
+
import { OutboxAttachmentUnavailableError } from "@remit/mailbox-service/outbox-attachment-content";
|
|
11
13
|
import type { SecretsService } from "@remit/secrets-service";
|
|
12
14
|
import {
|
|
13
15
|
buildMailMessage,
|
|
16
|
+
type MailAttachment,
|
|
14
17
|
type SendResult,
|
|
15
18
|
SmtpConnectionError,
|
|
16
19
|
type sendMail,
|
|
@@ -100,6 +103,10 @@ export interface SendMessageDeps {
|
|
|
100
103
|
lastError?: string,
|
|
101
104
|
) => Promise<void>;
|
|
102
105
|
send: typeof sendMail;
|
|
106
|
+
loadAttachments: (
|
|
107
|
+
tenant: SendTenant,
|
|
108
|
+
outboxMessageId: string,
|
|
109
|
+
) => Promise<MailAttachment[]>;
|
|
103
110
|
emitAppendSentMessage: (
|
|
104
111
|
accountId: string,
|
|
105
112
|
outboxMessageId: string,
|
|
@@ -156,6 +163,16 @@ const SENDABLE_STATUSES: ReadonlySet<OutboxMessageItem["status"]> = new Set([
|
|
|
156
163
|
OutboxMessageStatus.sending,
|
|
157
164
|
]);
|
|
158
165
|
|
|
166
|
+
/**
|
|
167
|
+
* Why a message stopped at its files, in words the Outbox can show. A `failed`
|
|
168
|
+
* row can be edited, sent again and discarded (#933), so the sentence names
|
|
169
|
+
* the file and what to do about it.
|
|
170
|
+
*/
|
|
171
|
+
const attachmentFailureReason = (error: unknown): string =>
|
|
172
|
+
error instanceof OutboxAttachmentUnavailableError
|
|
173
|
+
? `${error.message}, so this message was not sent. Remove "${error.filename}", attach it again, and send.`
|
|
174
|
+
: `The files on this message could not be read (${error instanceof Error ? error.message : String(error)}), so it was not sent. Send it again.`;
|
|
175
|
+
|
|
159
176
|
export const getSendMessageMaxAttempts = (
|
|
160
177
|
processEnv: NodeJS.ProcessEnv = process.env,
|
|
161
178
|
): number => attemptBudget("SEND_MESSAGE_MAX_ATTEMPTS", 3, processEnv);
|
|
@@ -301,9 +318,38 @@ export const sendMessage = async (
|
|
|
301
318
|
}
|
|
302
319
|
const smtpConfig = resolved.config;
|
|
303
320
|
|
|
321
|
+
// Below the budget a storage hiccup is retried like any other; at it the
|
|
322
|
+
// record would dead-letter with the row at `queued`, which can be neither
|
|
323
|
+
// sent again nor discarded (#845.8), so the row settles `failed` instead.
|
|
324
|
+
const attachments = await deps
|
|
325
|
+
.loadAttachments(
|
|
326
|
+
{ accountConfigId, accountId: account.accountId },
|
|
327
|
+
outboxMessageId,
|
|
328
|
+
)
|
|
329
|
+
.catch(async (error: unknown) => {
|
|
330
|
+
if (receiveCount < SEND_MESSAGE_MAX_ATTEMPTS) throw error;
|
|
331
|
+
log.error(
|
|
332
|
+
{ outboxMessageId, receiveCount, error: String(error) },
|
|
333
|
+
"Attachments could not be loaded; settling the row as failed",
|
|
334
|
+
);
|
|
335
|
+
if (outbox.status !== OutboxMessageStatus.draft) {
|
|
336
|
+
await deps.updateOutboxIfStatus(
|
|
337
|
+
accountConfigId,
|
|
338
|
+
outboxMessageId,
|
|
339
|
+
outbox.status,
|
|
340
|
+
{
|
|
341
|
+
status: OutboxMessageStatus.failed,
|
|
342
|
+
lastError: attachmentFailureReason(error),
|
|
343
|
+
},
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
return null;
|
|
347
|
+
});
|
|
348
|
+
if (attachments === null) return;
|
|
349
|
+
|
|
304
350
|
await deps.updateOutboxStatus(accountConfigId, outboxMessageId, "sending");
|
|
305
351
|
|
|
306
|
-
const message = buildMailMessage(outbox);
|
|
352
|
+
const message = buildMailMessage(outbox, attachments);
|
|
307
353
|
|
|
308
354
|
log.info(
|
|
309
355
|
{ outboxMessageId, to: outbox.toAddresses, subject: outbox.subject },
|
|
@@ -7,11 +7,17 @@ import type {
|
|
|
7
7
|
} from "@remit/data-ports";
|
|
8
8
|
import { AccountAuthType } from "@remit/domain-enums";
|
|
9
9
|
import { RefreshTokenError } from "@remit/mail-oauth-service";
|
|
10
|
+
import { OutboxAttachmentUnavailableError } from "@remit/mailbox-service/outbox-attachment-content";
|
|
10
11
|
import {
|
|
11
12
|
type EncryptedPayload,
|
|
12
13
|
serializeEncryptedPayload,
|
|
13
14
|
} from "@remit/secrets-service";
|
|
14
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
type MailAttachment,
|
|
17
|
+
type MailMessage,
|
|
18
|
+
type SendResult,
|
|
19
|
+
SmtpConnectionError,
|
|
20
|
+
} from "@remit/smtp-service";
|
|
15
21
|
import type { SendMessageEvent } from "../events.js";
|
|
16
22
|
import {
|
|
17
23
|
getSendMessageMaxAttempts,
|
|
@@ -104,6 +110,8 @@ const buildDeps = (
|
|
|
104
110
|
appendThrows?: Error;
|
|
105
111
|
resolveCredentials?: SendMessageDeps["resolveCredentials"];
|
|
106
112
|
send?: SendMessageDeps["send"];
|
|
113
|
+
attachments?: MailAttachment[];
|
|
114
|
+
attachmentsFail?: Error;
|
|
107
115
|
} = {},
|
|
108
116
|
): { deps: SendMessageDeps; recorded: Recorded } => {
|
|
109
117
|
const recorded: Recorded = {
|
|
@@ -194,6 +202,13 @@ const buildDeps = (
|
|
|
194
202
|
}
|
|
195
203
|
);
|
|
196
204
|
}),
|
|
205
|
+
loadAttachments: async (tenant, outboxMessageId) => {
|
|
206
|
+
assert.equal(tenant.accountConfigId, account.accountConfigId);
|
|
207
|
+
assert.equal(tenant.accountId, account.accountId);
|
|
208
|
+
assert.equal(outboxMessageId, outbox.outboxMessageId);
|
|
209
|
+
if (options.attachmentsFail) throw options.attachmentsFail;
|
|
210
|
+
return options.attachments ?? [];
|
|
211
|
+
},
|
|
197
212
|
emitAppendSentMessage: async (accountId, outboxMessageId) => {
|
|
198
213
|
recorded.appendCalls.push({ accountId, outboxMessageId });
|
|
199
214
|
if (options.appendThrows) throw options.appendThrows;
|
|
@@ -225,6 +240,65 @@ const event: SendMessageEvent = {
|
|
|
225
240
|
};
|
|
226
241
|
|
|
227
242
|
describe("sendMessage handler", () => {
|
|
243
|
+
it("retries a message whose files could not be read, below the budget", async () => {
|
|
244
|
+
const { deps, recorded } = buildDeps({
|
|
245
|
+
outbox: buildOutbox({ status: "queued" }),
|
|
246
|
+
attachmentsFail: new OutboxAttachmentUnavailableError(
|
|
247
|
+
"payroll.xlsx",
|
|
248
|
+
"nothing is stored at key/payroll",
|
|
249
|
+
),
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
await assert.rejects(() => sendMessage(event, silentLogger, deps, 1));
|
|
253
|
+
|
|
254
|
+
assert.equal(recorded.sendCalls, 0);
|
|
255
|
+
assert.deepEqual(recorded.updates, []);
|
|
256
|
+
assert.deepEqual(recorded.statuses, [], "never moved to sending");
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("settles a message whose files cannot be read as failed, naming the file, at the budget", async () => {
|
|
260
|
+
const { deps, recorded } = buildDeps({
|
|
261
|
+
outbox: buildOutbox({ status: "queued" }),
|
|
262
|
+
attachmentsFail: new OutboxAttachmentUnavailableError(
|
|
263
|
+
"payroll.xlsx",
|
|
264
|
+
"nothing is stored at key/payroll",
|
|
265
|
+
),
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
await sendMessage(event, silentLogger, deps, SEND_MESSAGE_MAX_ATTEMPTS);
|
|
269
|
+
|
|
270
|
+
assert.equal(recorded.sendCalls, 0);
|
|
271
|
+
assert.equal(recorded.updates.length, 1);
|
|
272
|
+
assert.equal(recorded.updates[0].patch.status, "failed");
|
|
273
|
+
assert.equal(
|
|
274
|
+
recorded.updates[0].patch.lastError,
|
|
275
|
+
'"payroll.xlsx" could not be read: nothing is stored at key/payroll, so this message was not sent. Remove "payroll.xlsx", attach it again, and send.',
|
|
276
|
+
);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it("sends every file the draft carries as a part of the message", async () => {
|
|
280
|
+
const attachments: MailAttachment[] = [
|
|
281
|
+
{
|
|
282
|
+
filename: "report.pdf",
|
|
283
|
+
contentType: "application/pdf",
|
|
284
|
+
content: Buffer.from("%PDF-1.4"),
|
|
285
|
+
},
|
|
286
|
+
];
|
|
287
|
+
const sent: MailMessage[] = [];
|
|
288
|
+
const { deps } = buildDeps({
|
|
289
|
+
attachments,
|
|
290
|
+
send: async (_config, message) => {
|
|
291
|
+
sent.push(message);
|
|
292
|
+
return { success: true, messageId: "smtp-mid-1", isTransient: false };
|
|
293
|
+
},
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
await sendMessage(event, silentLogger, deps);
|
|
297
|
+
|
|
298
|
+
assert.equal(sent.length, 1);
|
|
299
|
+
assert.deepEqual(sent[0].attachments, attachments);
|
|
300
|
+
});
|
|
301
|
+
|
|
228
302
|
it("marks status `blocked` when SMTP is disabled — never `sent`", async () => {
|
|
229
303
|
const { deps, recorded } = buildDeps({
|
|
230
304
|
account: buildAccount({ smtpEnabled: false }),
|
|
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
import { SendMessageCommand } from "@aws-sdk/client-sqs";
|
|
3
3
|
import { deriveAddressId, deriveMessageId } from "@remit/data-ports/id";
|
|
4
4
|
import type { ConnectionState } from "@remit/domain-enums";
|
|
5
|
-
import type { Logger } from "@remit/logger
|
|
5
|
+
import type { Logger } from "@remit/logger";
|
|
6
6
|
import {
|
|
7
7
|
createMailOAuthService,
|
|
8
8
|
microsoftProviderConfig,
|
|
@@ -21,9 +21,14 @@ import {
|
|
|
21
21
|
} from "@remit/secrets-service";
|
|
22
22
|
import { sendMail } from "@remit/smtp-service";
|
|
23
23
|
import { createQueueProducer } from "@remit/sqs-client/producer";
|
|
24
|
+
import { createStorageService } from "@remit/storage-service/s3";
|
|
24
25
|
import { env } from "expect-env";
|
|
25
26
|
import { buildDataPortsFromEnv, type SmtpDataPorts } from "../data-ports.js";
|
|
26
27
|
import type { SendMessageEvent } from "../events.js";
|
|
28
|
+
import {
|
|
29
|
+
createAttachmentLoader,
|
|
30
|
+
createAttachmentReader,
|
|
31
|
+
} from "./attachment-storage.js";
|
|
27
32
|
import { sendMessage } from "./send-message-core.js";
|
|
28
33
|
|
|
29
34
|
// The data ports are resolved lazily and cached, not at module load: the
|
|
@@ -35,6 +40,11 @@ const getPorts = (): Promise<SmtpDataPorts> => {
|
|
|
35
40
|
return portsPromise;
|
|
36
41
|
};
|
|
37
42
|
|
|
43
|
+
const loadAttachments = createAttachmentLoader(
|
|
44
|
+
createAttachmentReader(process.env, createStorageService),
|
|
45
|
+
async () => (await getPorts()).outboxAttachment,
|
|
46
|
+
);
|
|
47
|
+
|
|
38
48
|
const dataKeyProvider = createKmsDataKeyProvider(env.KMS_KEY_ID);
|
|
39
49
|
const secrets = createSecretsService(dataKeyProvider);
|
|
40
50
|
|
|
@@ -177,6 +187,7 @@ export const handleSendMessage = (
|
|
|
177
187
|
);
|
|
178
188
|
},
|
|
179
189
|
send: sendMail,
|
|
190
|
+
loadAttachments,
|
|
180
191
|
emitAppendSentMessage,
|
|
181
192
|
engagement: {
|
|
182
193
|
resolveAddressId: deriveAddressId,
|
package/src/index.ts
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
import { inspect } from "node:util";
|
|
2
|
+
import { createLogger } from "@remit/logger";
|
|
2
3
|
import {
|
|
3
|
-
createLogger,
|
|
4
4
|
queueNameFromEventSource,
|
|
5
5
|
recordQueueEvent,
|
|
6
6
|
recordSmtpFailure,
|
|
7
|
-
|
|
8
|
-
} from "@remit/logger-lambda";
|
|
7
|
+
} from "@remit/logger-lambda/metrics";
|
|
8
|
+
import { installNodeLogger, withTelemetry } from "@remit/logger-lambda/node";
|
|
9
9
|
import type { SQSBatchResponse, SQSEvent } from "aws-lambda";
|
|
10
10
|
import type { SmtpEvent } from "./events.js";
|
|
11
11
|
import { smtpFailureKind } from "./failure-kind.js";
|
|
12
12
|
import { processEvent } from "./processor.js";
|
|
13
13
|
|
|
14
|
+
installNodeLogger();
|
|
15
|
+
|
|
14
16
|
const log = createLogger();
|
|
15
17
|
|
|
16
18
|
/**
|
package/src/poller.ts
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
|
-
import { createLogger
|
|
1
|
+
import { createLogger } from "@remit/logger";
|
|
2
|
+
import { startMetricsServer } from "@remit/logger-lambda/metrics";
|
|
3
|
+
import { installNodeLogger } from "@remit/logger-lambda/node";
|
|
2
4
|
import { runQueuePoller } from "@remit/sqs-client/poller";
|
|
3
5
|
import { env } from "expect-env";
|
|
4
6
|
import { handler } from "./index.js";
|
|
5
7
|
|
|
8
|
+
installNodeLogger();
|
|
9
|
+
|
|
6
10
|
/** Production queue poller — the deployed form of `e2e-processor-shim.ts`. */
|
|
7
11
|
const log = createLogger();
|
|
8
12
|
|
package/src/processor.ts
CHANGED