@remit/smtp-service 0.0.8 → 0.0.10

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,11 +1,21 @@
1
1
  {
2
2
  "name": "@remit/smtp-service",
3
- "version": "0.0.8",
3
+ "version": "0.0.10",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
+ "exports": {
7
+ ".": {
8
+ "types": "./src/index.ts",
9
+ "default": "./src/index.ts"
10
+ },
11
+ "./message-builder": {
12
+ "types": "./src/message-builder.ts",
13
+ "default": "./src/message-builder.ts"
14
+ }
15
+ },
6
16
  "scripts": {
7
17
  "test:typecheck": "tsgo --noEmit",
8
- "test:run": "node --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=75 --test 'src/**/*.test.ts'",
18
+ "test:run": "node --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'",
9
19
  "test": "npm run test:typecheck && npm run test:run",
10
20
  "test:integ": "RUN_INTEG_TESTS=true node --env-file=../../localhost-dev-aws.env --import tsx --test --test-force-exit 'src/**/*.integ.test.ts'"
11
21
  },
@@ -1,7 +1,11 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { describe, it } from "node:test";
3
3
  import type { OutboxMessageItem } from "@remit/data-ports";
4
- import { buildMailMessage, type MailAttachment } from "./message-builder.js";
4
+ import {
5
+ buildMailMessage,
6
+ type MailAttachment,
7
+ renderRawMessage,
8
+ } from "./message-builder.js";
5
9
 
6
10
  const baseOutbox = (
7
11
  overrides: Partial<OutboxMessageItem> = {},
@@ -22,6 +26,16 @@ const baseOutbox = (
22
26
  });
23
27
 
24
28
  describe("buildMailMessage", () => {
29
+ it("stamps the Sent copy with the moment the send was recorded", () => {
30
+ const message = buildMailMessage(baseOutbox({ sentAt: 1_700_000_000_000 }));
31
+ assert.deepEqual(message.date, new Date(1_700_000_000_000));
32
+ });
33
+
34
+ it("leaves the date to the transport while the message is still going out", () => {
35
+ const message = buildMailMessage(baseOutbox({ sentAt: undefined }));
36
+ assert.equal(message.date, undefined);
37
+ });
38
+
25
39
  it("formats From as a quoted display name when fromName is set", () => {
26
40
  const message = buildMailMessage(
27
41
  baseOutbox({
@@ -32,6 +46,16 @@ describe("buildMailMessage", () => {
32
46
  assert.equal(message.from, '"Alice Sender" <alice@example.com>');
33
47
  });
34
48
 
49
+ it("escapes a display name that carries a quote", () => {
50
+ const message = buildMailMessage(
51
+ baseOutbox({
52
+ fromName: 'Al "Big" Sender',
53
+ fromAddress: "alice@example.com",
54
+ }),
55
+ );
56
+ assert.equal(message.from, '"Al \\"Big\\" Sender" <alice@example.com>');
57
+ });
58
+
35
59
  it("uses the bare address as From when fromName is absent", () => {
36
60
  const message = buildMailMessage(
37
61
  baseOutbox({ fromName: undefined, fromAddress: "alice@example.com" }),
@@ -115,3 +139,172 @@ describe("buildMailMessage", () => {
115
139
  assert.equal(message.attachments, undefined);
116
140
  });
117
141
  });
142
+
143
+ /**
144
+ * `send-mime.spec.ts` compares both copies off a real server, which covers every
145
+ * shape the API can currently produce. What it cannot reach is what the compose
146
+ * path never sets — a display name, an attachment — so those are asserted here.
147
+ */
148
+ describe("renderRawMessage", () => {
149
+ const headerOf = (raw: string, name: string): string | undefined => {
150
+ const head = raw.split(/\r?\n\r?\n/, 1)[0].replace(/\r?\n[ \t]+/g, " ");
151
+ return head
152
+ .split(/\r?\n/)
153
+ .find((line) => line.toLowerCase().startsWith(`${name.toLowerCase()}:`))
154
+ ?.slice(name.length + 1)
155
+ .trim();
156
+ };
157
+
158
+ it("carries the display name into the rendered From", async () => {
159
+ const raw = String(
160
+ await renderRawMessage(
161
+ buildMailMessage(
162
+ baseOutbox({
163
+ fromName: "Alice Sender",
164
+ fromAddress: "alice@example.com",
165
+ }),
166
+ ),
167
+ ),
168
+ );
169
+ assert.equal(headerOf(raw, "From"), "Alice Sender <alice@example.com>");
170
+ });
171
+
172
+ it("keeps a display name with a comma from splitting into two addresses", async () => {
173
+ const raw = String(
174
+ await renderRawMessage(
175
+ buildMailMessage(
176
+ baseOutbox({ fromName: "Doe, John", fromAddress: "j@example.com" }),
177
+ ),
178
+ ),
179
+ );
180
+ assert.equal(headerOf(raw, "From"), '"Doe, John" <j@example.com>');
181
+ });
182
+
183
+ it("keeps the quotes inside a display name", async () => {
184
+ const raw = String(
185
+ await renderRawMessage(
186
+ buildMailMessage(
187
+ baseOutbox({
188
+ fromName: 'Al "Big" Sender',
189
+ fromAddress: "a@example.com",
190
+ }),
191
+ ),
192
+ ),
193
+ );
194
+ assert.equal(
195
+ headerOf(raw, "From"),
196
+ '"Al \\"Big\\" Sender" <a@example.com>',
197
+ );
198
+ });
199
+
200
+ it("renders text and HTML bodies as a multipart/alternative", async () => {
201
+ const raw = String(
202
+ await renderRawMessage(
203
+ buildMailMessage(
204
+ baseOutbox({ textBody: "plain text", htmlBody: "<p>html</p>" }),
205
+ ),
206
+ ),
207
+ );
208
+ assert.match(
209
+ headerOf(raw, "Content-Type") ?? "",
210
+ /^multipart\/alternative/,
211
+ );
212
+ assert.match(raw, /Content-Type: text\/plain/);
213
+ assert.match(raw, /Content-Type: text\/html/);
214
+ });
215
+
216
+ it("renders a text-only body without an HTML alternative", async () => {
217
+ const raw = String(
218
+ await renderRawMessage(
219
+ buildMailMessage(
220
+ baseOutbox({ textBody: "plain text", htmlBody: undefined }),
221
+ ),
222
+ ),
223
+ );
224
+ assert.match(headerOf(raw, "Content-Type") ?? "", /^text\/plain/);
225
+ assert.ok(!raw.includes("text/html"));
226
+ });
227
+
228
+ it("renders each attachment as its own part", async () => {
229
+ const attachments: MailAttachment[] = [
230
+ {
231
+ filename: "invoice.pdf",
232
+ content: Buffer.from("pdf-bytes"),
233
+ contentType: "application/pdf",
234
+ },
235
+ {
236
+ filename: "logo.png",
237
+ content: Buffer.from("png-bytes"),
238
+ contentType: "image/png",
239
+ cid: "logo-cid",
240
+ contentDisposition: "inline",
241
+ },
242
+ ];
243
+ const raw = String(
244
+ await renderRawMessage(
245
+ buildMailMessage(baseOutbox({ textBody: "body" }), attachments),
246
+ ),
247
+ );
248
+
249
+ assert.match(headerOf(raw, "Content-Type") ?? "", /^multipart\/mixed/);
250
+ assert.match(raw, /Content-Type: application\/pdf; name=invoice\.pdf/);
251
+ assert.match(raw, /Content-Disposition: attachment; filename=invoice\.pdf/);
252
+ assert.match(raw, /Content-Type: image\/png; name=logo\.png/);
253
+ assert.match(raw, /Content-Disposition: inline; filename=logo\.png/);
254
+ assert.match(raw, /Content-ID: <logo-cid>/);
255
+ assert.ok(raw.includes(Buffer.from("pdf-bytes").toString("base64")));
256
+ assert.ok(raw.includes(Buffer.from("png-bytes").toString("base64")));
257
+ });
258
+
259
+ it("dates the rendered copy from the moment the send was recorded", async () => {
260
+ const raw = String(
261
+ await renderRawMessage(
262
+ buildMailMessage(baseOutbox({ sentAt: 1_700_000_000_000 })),
263
+ ),
264
+ );
265
+ assert.equal(
266
+ new Date(headerOf(raw, "Date") ?? "").getTime(),
267
+ 1_700_000_000_000,
268
+ );
269
+ });
270
+
271
+ it("carries addressing and identity headers through unchanged", async () => {
272
+ const raw = String(
273
+ await renderRawMessage(
274
+ buildMailMessage(
275
+ baseOutbox({
276
+ toAddresses: ["a@example.com", "b@example.com"],
277
+ ccAddresses: ["c@example.com"],
278
+ replyToAddress: "reply@example.com",
279
+ subject: "Quarterly numbers",
280
+ messageIdValue: "abc.123@example.com",
281
+ inReplyTo: "parent@example.com",
282
+ references: ["one@example.com", "two@example.com"],
283
+ }),
284
+ ),
285
+ ),
286
+ );
287
+
288
+ assert.equal(headerOf(raw, "To"), "a@example.com, b@example.com");
289
+ assert.equal(headerOf(raw, "Cc"), "c@example.com");
290
+ assert.equal(headerOf(raw, "Reply-To"), "reply@example.com");
291
+ assert.equal(headerOf(raw, "Subject"), "Quarterly numbers");
292
+ assert.equal(headerOf(raw, "Message-ID"), "<abc.123@example.com>");
293
+ assert.equal(headerOf(raw, "In-Reply-To"), "<parent@example.com>");
294
+ assert.equal(
295
+ headerOf(raw, "References"),
296
+ "<one@example.com> <two@example.com>",
297
+ );
298
+ });
299
+
300
+ // Only the sender ever reads this render, so the header stays. An SMTP
301
+ // transport strips it from the copy that goes to a recipient.
302
+ it("keeps Bcc on the copy filed in Sent", async () => {
303
+ const raw = String(
304
+ await renderRawMessage(
305
+ buildMailMessage(baseOutbox({ bccAddresses: ["hidden@example.com"] })),
306
+ ),
307
+ );
308
+ assert.equal(headerOf(raw, "Bcc"), "hidden@example.com");
309
+ });
310
+ });
@@ -1,4 +1,5 @@
1
1
  import type { OutboxMessageItem } from "@remit/data-ports";
2
+ import nodemailer, { type SendMailOptions } from "nodemailer";
2
3
 
3
4
  export interface MailMessage {
4
5
  from: string;
@@ -12,6 +13,7 @@ export interface MailMessage {
12
13
  messageId: string;
13
14
  inReplyTo?: string;
14
15
  references?: string;
16
+ date?: Date;
15
17
  attachments?: MailAttachment[];
16
18
  }
17
19
 
@@ -23,29 +25,66 @@ export interface MailAttachment {
23
25
  contentDisposition?: "attachment" | "inline";
24
26
  }
25
27
 
26
- /**
27
- * Build Nodemailer message options from OutboxMessage entity
28
- */
28
+ // Nodemailer parses the From string back into a name and an address, so a
29
+ // display name carrying a quote or a backslash has to survive that round trip.
30
+ const formatFrom = (address: string, name: string | undefined): string =>
31
+ name ? `"${name.replace(/(["\\])/g, "\\$1")}" <${address}>` : address;
32
+
33
+ /** The one description of an outgoing message: the wire copy and the Sent copy. */
29
34
  export const buildMailMessage = (
30
35
  outbox: OutboxMessageItem,
31
36
  attachments?: MailAttachment[],
32
- ): MailMessage => {
33
- const from = outbox.fromName
34
- ? `"${outbox.fromName}" <${outbox.fromAddress}>`
35
- : outbox.fromAddress;
37
+ ): MailMessage => ({
38
+ from: formatFrom(outbox.fromAddress, outbox.fromName),
39
+ to: outbox.toAddresses,
40
+ cc: outbox.ccAddresses,
41
+ bcc: outbox.bccAddresses,
42
+ replyTo: outbox.replyToAddress,
43
+ subject: outbox.subject,
44
+ text: outbox.textBody,
45
+ html: outbox.htmlBody,
46
+ messageId: `<${outbox.messageIdValue}>`,
47
+ inReplyTo: outbox.inReplyTo ? `<${outbox.inReplyTo}>` : undefined,
48
+ references: outbox.references?.map((r) => `<${r}>`).join(" "),
49
+ // Only the Sent copy has one to carry: the row is stamped after the
50
+ // submission is accepted, so on the wire path this is always absent and
51
+ // nodemailer dates the message as it goes out.
52
+ date: outbox.sentAt ? new Date(outbox.sentAt) : undefined,
53
+ attachments,
54
+ });
55
+
56
+ export const toNodemailerOptions = (message: MailMessage): SendMailOptions => ({
57
+ from: message.from,
58
+ to: message.to,
59
+ cc: message.cc,
60
+ bcc: message.bcc,
61
+ replyTo: message.replyTo,
62
+ subject: message.subject,
63
+ text: message.text,
64
+ html: message.html,
65
+ messageId: message.messageId,
66
+ inReplyTo: message.inReplyTo,
67
+ references: message.references,
68
+ date: message.date,
69
+ attachments: message.attachments?.map((a) => ({
70
+ filename: a.filename,
71
+ content: a.content,
72
+ contentType: a.contentType,
73
+ cid: a.cid,
74
+ contentDisposition: a.contentDisposition,
75
+ })),
76
+ });
77
+
78
+ /** The same message as RFC822 bytes, for an IMAP APPEND. */
79
+ export const renderRawMessage = async (
80
+ message: MailMessage,
81
+ ): Promise<Buffer> => {
82
+ const transport = nodemailer.createTransport({ streamTransport: true });
83
+ const info = await transport.sendMail(toNodemailerOptions(message));
36
84
 
37
- return {
38
- from,
39
- to: outbox.toAddresses,
40
- cc: outbox.ccAddresses,
41
- bcc: outbox.bccAddresses,
42
- replyTo: outbox.replyToAddress,
43
- subject: outbox.subject,
44
- text: outbox.textBody,
45
- html: outbox.htmlBody,
46
- messageId: `<${outbox.messageIdValue}>`,
47
- inReplyTo: outbox.inReplyTo ? `<${outbox.inReplyTo}>` : undefined,
48
- references: outbox.references?.map((r) => `<${r}>`).join(" "),
49
- attachments,
50
- };
85
+ const chunks: Buffer[] = [];
86
+ for await (const chunk of info.message as AsyncIterable<Buffer>) {
87
+ chunks.push(chunk);
88
+ }
89
+ return Buffer.concat(chunks);
51
90
  };
@@ -1,5 +1,5 @@
1
1
  import nodemailer from "nodemailer";
2
- import type { MailMessage } from "./message-builder.js";
2
+ import { type MailMessage, toNodemailerOptions } from "./message-builder.js";
3
3
 
4
4
  /**
5
5
  * Discriminated union of SMTP authentication credentials.
@@ -114,26 +114,7 @@ export const sendMail = async (
114
114
  });
115
115
 
116
116
  return transporter
117
- .sendMail({
118
- from: message.from,
119
- to: message.to,
120
- cc: message.cc,
121
- bcc: message.bcc,
122
- replyTo: message.replyTo,
123
- subject: message.subject,
124
- text: message.text,
125
- html: message.html,
126
- messageId: message.messageId,
127
- inReplyTo: message.inReplyTo,
128
- references: message.references,
129
- attachments: message.attachments?.map((a) => ({
130
- filename: a.filename,
131
- content: a.content as Buffer,
132
- contentType: a.contentType,
133
- cid: a.cid,
134
- contentDisposition: a.contentDisposition,
135
- })),
136
- })
117
+ .sendMail(toNodemailerOptions(message))
137
118
  .then((info) => ({
138
119
  success: true,
139
120
  messageId: info.messageId,