@remit/smtp-service 0.0.15 → 0.0.17

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/smtp-service",
3
- "version": "0.0.15",
3
+ "version": "0.0.17",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "exports": {
@@ -16,14 +16,13 @@
16
16
  "scripts": {
17
17
  "test:typecheck": "tsgo --noEmit",
18
18
  "test:run": "node $NODE_TEST_FLAGS --env-file=../../localhost-test-unit.env --experimental-test-coverage --test-coverage-include='src/**' --test-coverage-exclude='src/**/*.test.ts' --test-coverage-exclude='src/**/*.test.tsx' --test-coverage-lines=95 --test 'src/**/*.test.ts'",
19
- "test": "npm run test:typecheck && npm run test:run",
20
- "test:integ": "RUN_INTEG_TESTS=true node --env-file=../../localhost-dev-aws.env --import tsx --test --test-force-exit 'src/**/*.integ.test.ts'"
19
+ "test": "npm run test:typecheck && npm run test:run"
21
20
  },
22
21
  "devDependencies": {
23
22
  "@types/node": "*"
24
23
  },
25
24
  "dependencies": {
26
- "nodemailer": "^9.0.3",
25
+ "nodemailer": "^10.0.6",
27
26
  "@remit/data-ports": "*",
28
27
  "@types/nodemailer": "*"
29
28
  },
@@ -99,6 +99,17 @@ describe("buildMailMessage", () => {
99
99
  assert.equal(message.inReplyTo, "<parent@example.com>");
100
100
  });
101
101
 
102
+ it("puts exactly one pair of brackets on an id stored with its own", () => {
103
+ const message = buildMailMessage(
104
+ baseOutbox({
105
+ inReplyTo: "<parent@example.com>",
106
+ references: ["<<root@example.com>>", "<parent@example.com>"],
107
+ }),
108
+ );
109
+ assert.equal(message.inReplyTo, "<parent@example.com>");
110
+ assert.equal(message.references, "<root@example.com> <parent@example.com>");
111
+ });
112
+
102
113
  it("leaves inReplyTo undefined when absent", () => {
103
114
  const message = buildMailMessage(baseOutbox({ inReplyTo: undefined }));
104
115
  assert.equal(message.inReplyTo, undefined);
@@ -132,7 +143,7 @@ describe("buildMailMessage", () => {
132
143
  },
133
144
  ];
134
145
  const message = buildMailMessage(baseOutbox(), attachments);
135
- assert.equal(message.attachments, attachments);
146
+ assert.deepEqual(message.attachments, attachments);
136
147
  });
137
148
 
138
149
  it("leaves attachments undefined when none are provided", () => {
@@ -257,6 +268,53 @@ describe("renderRawMessage", () => {
257
268
  assert.ok(raw.includes(Buffer.from("png-bytes").toString("base64")));
258
269
  });
259
270
 
271
+ it("puts a file the html body refers to inline, next to the html", async () => {
272
+ const raw = String(
273
+ await renderRawMessage(
274
+ buildMailMessage(
275
+ baseOutbox({
276
+ textBody: "see below",
277
+ htmlBody: '<p>see below</p><p><img src="cid:shot-1@remit"></p>',
278
+ }),
279
+ [
280
+ {
281
+ filename: "shot.png",
282
+ content: Buffer.from("png-bytes"),
283
+ contentType: "image/png",
284
+ cid: "shot-1@remit",
285
+ },
286
+ ],
287
+ ),
288
+ ),
289
+ );
290
+
291
+ assert.match(raw, /Content-Type: multipart\/related; type="text\/html"/);
292
+ assert.match(raw, /Content-Disposition: inline; filename=shot\.png/);
293
+ assert.match(raw, /Content-ID: <shot-1@remit>/);
294
+ });
295
+
296
+ it("sends a file the html body does not refer to as an ordinary attachment", async () => {
297
+ const raw = String(
298
+ await renderRawMessage(
299
+ buildMailMessage(
300
+ baseOutbox({ textBody: "attached", htmlBody: "<p>attached</p>" }),
301
+ [
302
+ {
303
+ filename: "photo.png",
304
+ content: Buffer.from("png-bytes"),
305
+ contentType: "image/png",
306
+ cid: "photo-1@remit",
307
+ },
308
+ ],
309
+ ),
310
+ ),
311
+ );
312
+
313
+ assert.match(raw, /Content-Disposition: attachment; filename=photo\.png/);
314
+ assert.ok(!raw.includes("Content-ID"));
315
+ assert.ok(!raw.includes("multipart/related"));
316
+ });
317
+
260
318
  it("dates the rendered copy from the moment the send was recorded", async () => {
261
319
  const raw = String(
262
320
  await renderRawMessage(
@@ -1,4 +1,5 @@
1
1
  import type { OutboxMessageItem } from "@remit/data-ports";
2
+ import { wireMessageId } from "@remit/data-ports/id";
2
3
  import nodemailer, { type SendMailOptions } from "nodemailer";
3
4
 
4
5
  export interface MailMessage {
@@ -30,6 +31,18 @@ export interface MailAttachment {
30
31
  const formatFrom = (address: string, name: string | undefined): string =>
31
32
  name ? `"${name.replace(/(["\\])/g, "\\$1")}" <${address}>` : address;
32
33
 
34
+ const placeAttachment = (
35
+ attachment: MailAttachment,
36
+ html: string | undefined,
37
+ ): MailAttachment => {
38
+ if (!attachment.cid || attachment.contentDisposition) return attachment;
39
+ if (html?.includes(`cid:${attachment.cid}`)) {
40
+ return { ...attachment, contentDisposition: "inline" };
41
+ }
42
+ const { cid: _unreferenced, ...standalone } = attachment;
43
+ return standalone;
44
+ };
45
+
33
46
  /** The one description of an outgoing message: the wire copy and the Sent copy. */
34
47
  export const buildMailMessage = (
35
48
  outbox: OutboxMessageItem,
@@ -43,14 +56,16 @@ export const buildMailMessage = (
43
56
  subject: outbox.subject,
44
57
  text: outbox.textBody,
45
58
  html: outbox.htmlBody,
46
- messageId: `<${outbox.messageIdValue}>`,
47
- inReplyTo: outbox.inReplyTo ? `<${outbox.inReplyTo}>` : undefined,
48
- references: outbox.references?.map((r) => `<${r}>`).join(" "),
59
+ messageId: wireMessageId(outbox.messageIdValue),
60
+ inReplyTo: outbox.inReplyTo ? wireMessageId(outbox.inReplyTo) : undefined,
61
+ references: outbox.references?.map(wireMessageId).join(" "),
49
62
  // Only the Sent copy has one to carry: the row is stamped after the
50
63
  // submission is accepted, so on the wire path this is always absent and
51
64
  // nodemailer dates the message as it goes out.
52
65
  date: outbox.sentAt ? new Date(outbox.sentAt) : undefined,
53
- attachments,
66
+ attachments: attachments?.map((attachment) =>
67
+ placeAttachment(attachment, outbox.htmlBody),
68
+ ),
54
69
  });
55
70
 
56
71
  export const toNodemailerOptions = (message: MailMessage): SendMailOptions => ({
@@ -1,240 +0,0 @@
1
- /**
2
- * Integration tests for SMTP client using mokapi.
3
- *
4
- * These tests require mokapi to be running:
5
- * npm run start:mokapi
6
- *
7
- * Run with:
8
- * npm run test:integ -w packages/smtp-service
9
- *
10
- * Note: mokapi SMTP server runs on port 2525 without TLS.
11
- * Sent emails are delivered to the recipient's IMAP mailbox.
12
- */
13
-
14
- import assert from "node:assert";
15
- import { describe, test } from "node:test";
16
- import type { MailMessage } from "./message-builder.js";
17
- import { generateMessageId } from "./message-id.js";
18
- import { type SmtpConfig, sendMail } from "./smtp-client.js";
19
-
20
- const MOKAPI_SMTP_CONFIG: SmtpConfig = {
21
- host: "localhost",
22
- port: 2525,
23
- secure: false,
24
- user: "alice@mokapi.io",
25
- credentials: { kind: "password", password: "alice123" },
26
- tls: {
27
- rejectUnauthorized: false, // Accept mokapi's self-signed cert
28
- },
29
- };
30
-
31
- /**
32
- * Generate a unique message ID for testing
33
- */
34
- const createTestMessageId = (): string => {
35
- return `<${generateMessageId("mokapi.io")}>`;
36
- };
37
-
38
- /**
39
- * Create a basic test message
40
- */
41
- const createTestMessage = (
42
- overrides: Partial<MailMessage> = {},
43
- ): MailMessage => {
44
- return {
45
- from: "alice@mokapi.io",
46
- to: ["bob@mokapi.io"],
47
- subject: `Test message ${Date.now()}`,
48
- text: "This is a test message body.",
49
- messageId: createTestMessageId(),
50
- ...overrides,
51
- };
52
- };
53
-
54
- describe("SMTP client integration tests", {
55
- skip: !process.env.RUN_INTEG_TESTS,
56
- }, () => {
57
- describe("sendMail", () => {
58
- test("sends a simple text message", async () => {
59
- const message = createTestMessage({
60
- subject: `Simple text test ${Date.now()}`,
61
- text: "Hello from the SMTP integration test!",
62
- });
63
-
64
- const result = await sendMail(MOKAPI_SMTP_CONFIG, message);
65
-
66
- assert.equal(result.success, true, "Send should succeed");
67
- assert.equal(result.isTransient, false, "Should not be transient");
68
- assert.ok(result.messageId, "Should return a message ID");
69
- assert.ok(result.response, "Should return an SMTP response");
70
- });
71
-
72
- test("sends an HTML message", async () => {
73
- const message = createTestMessage({
74
- subject: `HTML test ${Date.now()}`,
75
- text: "Plain text fallback",
76
- html: "<html><body><h1>Hello!</h1><p>This is an <strong>HTML</strong> message.</p></body></html>",
77
- });
78
-
79
- const result = await sendMail(MOKAPI_SMTP_CONFIG, message);
80
-
81
- assert.equal(result.success, true, "Send should succeed");
82
- assert.ok(result.messageId, "Should return a message ID");
83
- });
84
-
85
- test("sends a message with CC recipients", async () => {
86
- const message = createTestMessage({
87
- subject: `CC test ${Date.now()}`,
88
- to: ["bob@mokapi.io"],
89
- cc: ["alice@mokapi.io"],
90
- text: "Message with CC recipient",
91
- });
92
-
93
- const result = await sendMail(MOKAPI_SMTP_CONFIG, message);
94
-
95
- assert.equal(result.success, true, "Send should succeed");
96
- assert.ok(result.messageId, "Should return a message ID");
97
- });
98
-
99
- test("sends a message with BCC recipients", async () => {
100
- const message = createTestMessage({
101
- subject: `BCC test ${Date.now()}`,
102
- to: ["bob@mokapi.io"],
103
- bcc: ["alice@mokapi.io"],
104
- text: "Message with BCC recipient",
105
- });
106
-
107
- const result = await sendMail(MOKAPI_SMTP_CONFIG, message);
108
-
109
- assert.equal(result.success, true, "Send should succeed");
110
- assert.ok(result.messageId, "Should return a message ID");
111
- });
112
-
113
- test("sends a message with multiple recipients", async () => {
114
- const message = createTestMessage({
115
- subject: `Multiple recipients test ${Date.now()}`,
116
- to: ["bob@mokapi.io", "alice@mokapi.io"],
117
- text: "Message to multiple recipients",
118
- });
119
-
120
- const result = await sendMail(MOKAPI_SMTP_CONFIG, message);
121
-
122
- assert.equal(result.success, true, "Send should succeed");
123
- assert.ok(result.messageId, "Should return a message ID");
124
- });
125
-
126
- test("sends a reply with In-Reply-To header", async () => {
127
- const originalMessageId = "<original-message-123@mokapi.io>";
128
- const message = createTestMessage({
129
- subject: `Re: Original subject ${Date.now()}`,
130
- text: "This is a reply to your message.",
131
- inReplyTo: originalMessageId,
132
- });
133
-
134
- const result = await sendMail(MOKAPI_SMTP_CONFIG, message);
135
-
136
- assert.equal(result.success, true, "Send should succeed");
137
- assert.ok(result.messageId, "Should return a message ID");
138
- });
139
-
140
- test("sends a reply with References header", async () => {
141
- const references = [
142
- "<msg-1@mokapi.io>",
143
- "<msg-2@mokapi.io>",
144
- "<msg-3@mokapi.io>",
145
- ].join(" ");
146
-
147
- const message = createTestMessage({
148
- subject: `Re: Thread subject ${Date.now()}`,
149
- text: "This is a reply in a thread.",
150
- inReplyTo: "<msg-3@mokapi.io>",
151
- references,
152
- });
153
-
154
- const result = await sendMail(MOKAPI_SMTP_CONFIG, message);
155
-
156
- assert.equal(result.success, true, "Send should succeed");
157
- assert.ok(result.messageId, "Should return a message ID");
158
- });
159
-
160
- test("sends a message with Reply-To address", async () => {
161
- const message = createTestMessage({
162
- subject: `Reply-To test ${Date.now()}`,
163
- text: "Please reply to a different address.",
164
- replyTo: "noreply@mokapi.io",
165
- });
166
-
167
- const result = await sendMail(MOKAPI_SMTP_CONFIG, message);
168
-
169
- assert.equal(result.success, true, "Send should succeed");
170
- assert.ok(result.messageId, "Should return a message ID");
171
- });
172
-
173
- test("sends a message with formatted From name", async () => {
174
- const message = createTestMessage({
175
- from: '"Alice Test" <alice@mokapi.io>',
176
- subject: `From name test ${Date.now()}`,
177
- text: "Message with formatted From name",
178
- });
179
-
180
- const result = await sendMail(MOKAPI_SMTP_CONFIG, message);
181
-
182
- assert.equal(result.success, true, "Send should succeed");
183
- assert.ok(result.messageId, "Should return a message ID");
184
- });
185
-
186
- test("handles connection failure gracefully", async () => {
187
- const badConfig: SmtpConfig = {
188
- host: "localhost",
189
- port: 9999, // Non-existent port
190
- secure: false,
191
- user: "alice@mokapi.io",
192
- credentials: { kind: "password", password: "alice123" },
193
- connectionTimeout: 1000, // Short timeout for tests
194
- };
195
-
196
- const message = createTestMessage();
197
- const result = await sendMail(badConfig, message);
198
-
199
- assert.equal(result.success, false, "Send should fail");
200
- assert.ok(result.error, "Should return an error");
201
- // Connection errors are typically transient
202
- assert.equal(
203
- result.isTransient,
204
- false,
205
- "Connection errors without SMTP code are not transient",
206
- );
207
- });
208
-
209
- test("handles authentication failure", async () => {
210
- const badAuthConfig: SmtpConfig = {
211
- host: "localhost",
212
- port: 2525,
213
- secure: false,
214
- user: "alice@mokapi.io",
215
- credentials: { kind: "password", password: "wrong-password" },
216
- tls: {
217
- rejectUnauthorized: false,
218
- },
219
- connectionTimeout: 5000,
220
- };
221
-
222
- const message = createTestMessage();
223
- const result = await sendMail(badAuthConfig, message);
224
-
225
- // mokapi may or may not enforce authentication
226
- // If it does, this should fail; if not, it will succeed
227
- if (!result.success) {
228
- assert.ok(result.error, "Should return an error");
229
- // 5xx errors are permanent (not transient)
230
- if (result.smtpCode && result.smtpCode >= 500) {
231
- assert.equal(
232
- result.isTransient,
233
- false,
234
- "Auth failure should be permanent",
235
- );
236
- }
237
- }
238
- });
239
- });
240
- });