From a8f65e67f44a0bfd552af6ef6d1dae2851d4e75d Mon Sep 17 00:00:00 2001 From: Kiryll Kulakowski Date: Thu, 30 Jul 2026 12:00:25 -0400 Subject: [PATCH] feat(email-sdk): add SMTP attachment support --- .changeset/calm-mails-smile.md | 5 + apps/fumadocs/content/docs/adapters/smtp.mdx | 26 ++-- apps/fumadocs/src/lib/adapter-support.ts | 6 +- .../src/lib/field-support.generated.json | 3 +- packages/email-sdk/README.md | 2 +- packages/email-sdk/src/cli.test.ts | 29 ++++ packages/email-sdk/src/smtp.test.ts | 133 +++++++++++++++++- packages/email-sdk/src/smtp.ts | 52 +++++-- packages/email-sdk/src/utils.ts | 44 +++++- scripts/check-adapter-support-docs.ts | 2 +- 10 files changed, 272 insertions(+), 30 deletions(-) create mode 100644 .changeset/calm-mails-smile.md diff --git a/.changeset/calm-mails-smile.md b/.changeset/calm-mails-smile.md new file mode 100644 index 0000000..7c2423e --- /dev/null +++ b/.changeset/calm-mails-smile.md @@ -0,0 +1,5 @@ +--- +"@opencoredev/email-sdk": minor +--- + +Add attachment support to the built-in SMTP adapter. diff --git a/apps/fumadocs/content/docs/adapters/smtp.mdx b/apps/fumadocs/content/docs/adapters/smtp.mdx index 28695d6..0050763 100644 --- a/apps/fumadocs/content/docs/adapters/smtp.mdx +++ b/apps/fumadocs/content/docs/adapters/smtp.mdx @@ -89,7 +89,8 @@ export const email = createEmailClient({ default: '"localhost"', }, timeoutMs: { - description: "Timeout for the connection and each SMTP command.", + description: + "Timeout for the connection and each SMTP command. Large attachments on slow links may need a higher value.", type: "number", default: "15000", }, @@ -102,9 +103,11 @@ With `secure: true` the whole session is TLS. Otherwise the adapter connects in ## Send -SMTP maps `cc`, `bcc`, `replyTo`, and `headers`. When a message has both `html` and `text`, the adapter builds a `multipart/alternative` MIME body. The generated `Message-ID` header is `<{idempotencyKey}@email-sdk.local>` when send options include an `idempotencyKey`, and a random UUID otherwise. An explicit `Message-ID` in `headers` overrides it; set one yourself if you need a specific domain for threading or deduplication. +SMTP maps `cc`, `bcc`, `replyTo`, `headers`, and `attachments`. It sends attachments as Base64 MIME parts inside `multipart/mixed`. If the message also has text and HTML bodies, those bodies stay together in a nested `multipart/alternative` part. -```ts +The generated `Message-ID` header is `<{idempotencyKey}@email-sdk.local>` when send options include an `idempotencyKey`, and a random UUID otherwise. An explicit `Message-ID` in `headers` overrides it; set one yourself if you need a specific domain for threading or deduplication. + +```ts title="src/send-alert.ts" const result = await email.send({ from: "Acme ", to: "ops@example.com", @@ -113,6 +116,13 @@ const result = await email.send({ text: "db-1 is at 92% disk usage.", html: "

db-1 is at 92% disk usage.

", headers: [{ name: "X-Alert-ID", value: "alert_481" }], + attachments: [ + { + filename: "disk-report.txt", + content: "db-1: 92%", + contentType: "text/plain", + }, + ], }); console.log(result.accepted); // every recipient the server accepted @@ -121,10 +131,9 @@ console.log(result.id); // parsed from the server's "queued as ..." reply, else `rejected` is always empty, because a refused recipient fails the SMTP conversation instead. Retryable transport failures surface as `EmailAdapterError`, so SMTP plays well with [retries and fallbacks](/docs/concepts/fallbacks-and-retries). - - The built-in transport does not build attachment MIME parts, and SMTP has no tag or metadata - concept. Any of these fields throws an `EmailValidationError` before connecting. See the - [field support matrix](/docs/adapters/field-support). + + SMTP has no provider-side tag or metadata concept. Either field throws an `EmailValidationError` + before connecting. See the [field support matrix](/docs/adapters/field-support). ## Multiple SMTP routes @@ -170,7 +179,8 @@ npx email-sdk send \ --to user@example.com \ --subject "SMTP smoke test" \ --text "It works" \ + --attachment ./report.txt:text/plain \ --dry-run ``` -Each flag falls back to its env var: `--host`/`SMTP_HOST`, `--port`/`SMTP_PORT` (default 587), `--secure`/`SMTP_SECURE`, `--require-tls`/`SMTP_REQUIRE_TLS`, `--allow-insecure-auth`/`SMTP_ALLOW_INSECURE_AUTH`, `--user`/`SMTP_USER`, `--pass`/`SMTP_PASS`. Drop `--dry-run` for one real send to prove the host, TLS mode, and credentials actually deliver. +Each flag falls back to its env var: `--host`/`SMTP_HOST`, `--port`/`SMTP_PORT` (default 587), `--secure`/`SMTP_SECURE`, `--require-tls`/`SMTP_REQUIRE_TLS`, `--allow-insecure-auth`/`SMTP_ALLOW_INSECURE_AUTH`, `--user`/`SMTP_USER`, `--pass`/`SMTP_PASS`. A dry run includes the attachment path and MIME metadata without reading or sending the file. Drop `--dry-run` for one real send to prove the host, TLS mode, credentials, and attachment delivery. diff --git a/apps/fumadocs/src/lib/adapter-support.ts b/apps/fumadocs/src/lib/adapter-support.ts index 822e635..4bcff19 100644 --- a/apps/fumadocs/src/lib/adapter-support.ts +++ b/apps/fumadocs/src/lib/adapter-support.ts @@ -204,9 +204,11 @@ export const ADAPTER_SUPPORT_ENTRIES = [ id: "smtp", label: "SMTP", setupHref: "/docs/adapters/smtp", - fields: { cc: true, bcc: true, replyTo: true, headers: true }, + fields: { cc: true, bcc: true, replyTo: true, headers: true, attachments: true }, capabilities: { repeatedHeaders: true, idempotency: "message_id", scheduling: false, personalized: "expanded" }, - limits: ["Validates ASCII envelope addresses and header names before opening a connection."], + limits: [ + "Validates ASCII envelope addresses, header names, and attachment MIME fields before opening a connection.", + ], }, ] as const satisfies readonly AdapterSupportEntry[]; diff --git a/apps/fumadocs/src/lib/field-support.generated.json b/apps/fumadocs/src/lib/field-support.generated.json index bd41a0f..7cc1ac1 100644 --- a/apps/fumadocs/src/lib/field-support.generated.json +++ b/apps/fumadocs/src/lib/field-support.generated.json @@ -164,6 +164,7 @@ "cc": true, "bcc": true, "replyTo": true, - "headers": true + "headers": true, + "attachments": true } } diff --git a/packages/email-sdk/README.md b/packages/email-sdk/README.md index b8f4a54..da02121 100644 --- a/packages/email-sdk/README.md +++ b/packages/email-sdk/README.md @@ -46,7 +46,7 @@ console.log(result.adapter, result.id); - Capability validation for headers, attachments, tags, metadata, scheduling, and personalization. - Retries inside one adapter, then fallback only when your fallback policy allows it. - Sequential `sendMany` for independent sends and `sendPersonalized` for recipient variables. -- Built-in SMTP transport with no Nodemailer dependency. +- Built-in SMTP transport with attachment support and no Nodemailer dependency. - Hooks and middleware for logs, metrics, traces, defaults, and capture stores. - Test adapters that never call a real provider. - A bundled CLI for adapter discovery, setup checks, and dry-run validation. diff --git a/packages/email-sdk/src/cli.test.ts b/packages/email-sdk/src/cli.test.ts index df7d16f..9b71d7f 100644 --- a/packages/email-sdk/src/cli.test.ts +++ b/packages/email-sdk/src/cli.test.ts @@ -70,6 +70,35 @@ describe("email-sdk CLI", () => { expect(stderr).toContain("resend does not support these EmailMessage fields: metadata"); }); + test("accepts SMTP attachments during dry run", async () => { + const { stdout, stderr, exitCode } = await runCli([ + "send", + "--adapter", + "smtp", + "--from", + "hello@example.com", + "--to", + "user@example.com", + "--subject", + "Hello", + "--text", + "It works", + "--attachment", + "receipt.txt:text/plain", + "--dry-run", + ]); + + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + expect(JSON.parse(stdout)).toMatchObject({ + ok: true, + adapter: "smtp", + message: { + attachments: [{ filename: "receipt.txt", path: "receipt.txt", contentType: "text/plain" }], + }, + }); + }); + test("doctor accepts provider credentials from flags", async () => { const { stdout, stderr, exitCode } = await runCli([ "doctor", diff --git a/packages/email-sdk/src/smtp.test.ts b/packages/email-sdk/src/smtp.test.ts index 6659091..f75e8a3 100644 --- a/packages/email-sdk/src/smtp.test.ts +++ b/packages/email-sdk/src/smtp.test.ts @@ -1,5 +1,8 @@ import { describe, expect, test } from "bun:test"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; import net from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { EmailValidationError } from "./errors.js"; import { smtp } from "./smtp.js"; @@ -17,6 +20,10 @@ function send(message: EmailMessage) { return smtp({ host: "127.0.0.1", port: 1 }).send(message, { attempt: 1 }); } +function base64(value: string) { + return Buffer.from(value).toString("base64"); +} + // Runs a minimal in-process SMTP server, sends the message through the adapter, // and returns the raw DATA payload the client transmitted. async function captureSmtpData(message: EmailMessage) { @@ -70,6 +77,103 @@ async function captureSmtpData(message: EmailMessage) { return { commands, data: captured }; } +describe("smtp attachments", () => { + test("sends text messages with attachment MIME parts", async () => { + const transmitted = await captureSmtpData({ + ...baseMessage, + attachments: [{ filename: "hello.txt", content: "hello", contentType: "text/plain" }], + }); + + expect(transmitted.data).toContain('Content-Type: multipart/mixed; boundary="email-sdk-mixed-'); + expect(transmitted.data).toContain("Content-Type: text/plain; charset=utf-8"); + expect(transmitted.data).toContain('Content-Type: text/plain; name="hello.txt"'); + expect(transmitted.data).toContain("Content-Transfer-Encoding: base64"); + expect(transmitted.data).toContain('Content-Disposition: attachment; filename="hello.txt"'); + expect(transmitted.data).toContain(base64("hello")); + }); + + test("sends html-only messages with attachment MIME parts", async () => { + const transmitted = await captureSmtpData({ + ...baseMessage, + text: undefined, + html: "

Hi there

", + attachments: [{ filename: "hello.txt", content: "hello" }], + }); + + expect(transmitted.data).toContain('Content-Type: multipart/mixed; boundary="email-sdk-mixed-'); + expect(transmitted.data).toContain("Content-Type: text/html; charset=utf-8"); + expect(transmitted.data).toContain('Content-Type: application/octet-stream; name="hello.txt"'); + expect(transmitted.data).not.toContain("multipart/alternative"); + }); + + test("nests alternative bodies inside mixed messages", async () => { + const transmitted = await captureSmtpData({ + ...baseMessage, + html: "

Hi there

", + attachments: [{ filename: "hello.txt", content: "hello" }], + }); + + expect(transmitted.data).toContain("Content-Type: multipart/mixed"); + expect(transmitted.data).toContain("Content-Type: multipart/alternative"); + expect(transmitted.data).toContain("Content-Type: text/plain; charset=utf-8"); + expect(transmitted.data).toContain("Content-Type: text/html; charset=utf-8"); + }); + + test("encodes raw, base64, byte, and file attachments", async () => { + const dir = await mkdtemp(join(tmpdir(), "email-sdk-smtp-")); + const path = join(dir, "from-file.txt"); + + try { + await writeFile(path, "from file"); + const alreadyEncoded = base64("already encoded"); + const transmitted = await captureSmtpData({ + ...baseMessage, + attachments: [ + { filename: "raw.txt", content: "raw text" }, + { filename: "base64.txt", content: alreadyEncoded, contentEncoding: "base64" }, + { filename: "bytes.bin", content: new Uint8Array([1, 2, 3]) }, + { filename: "from-file.txt", path, contentType: "text/plain" }, + ], + }); + + expect(transmitted.data).toContain(base64("raw text")); + expect(transmitted.data).toContain(alreadyEncoded); + expect(transmitted.data).toContain(Buffer.from(new Uint8Array([1, 2, 3])).toString("base64")); + expect(transmitted.data).toContain(base64("from file")); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + test("reads file attachments before connecting", async () => { + await expect( + send({ + ...baseMessage, + attachments: [{ filename: "missing.txt", path: join(tmpdir(), "missing-email-sdk.txt") }], + }), + ).rejects.toHaveProperty("code", "ENOENT"); + }); + + test("sends inline content IDs", async () => { + const transmitted = await captureSmtpData({ + ...baseMessage, + html: 'Logo', + attachments: [ + { + filename: "logo.png", + content: new Uint8Array([137, 80, 78, 71]), + contentType: "image/png", + contentId: "logo", + disposition: "inline", + }, + ], + }); + + expect(transmitted.data).toContain('Content-Disposition: inline; filename="logo.png"'); + expect(transmitted.data).toContain("Content-ID: "); + }); +}); + describe("smtp injection guards", () => { test("rejects CRLF injected into the envelope address", async () => { await expect( @@ -159,13 +263,28 @@ describe("smtp injection guards", () => { expect(dataHeaderLines.some((line) => line.toLowerCase().startsWith("bcc:"))).toBe(false); }); - test("rejects attachments before connecting", async () => { - await expect( - send({ - ...baseMessage, - attachments: [{ filename: "hello.txt", content: "hello" }], - }), - ).rejects.toBeInstanceOf(EmailValidationError); + test.each([ + ["filename", { filename: 'bad"name.txt', content: "hello" }, "SMTP attachment filename"], + ["filename type", { filename: 123, content: "hello" }, "SMTP attachment filename"], + [ + "content type", + { filename: "hello.txt", content: "hello", contentType: "text/plain; charset=utf-8" }, + "SMTP attachment content type", + ], + [ + "content ID", + { filename: "hello.txt", content: "hello", contentId: "bad id" }, + "SMTP attachment content ID", + ], + [ + "disposition", + { filename: "hello.txt", content: "hello", disposition: "inline\r\nBcc: bad" }, + "SMTP attachment disposition", + ], + ])("rejects unsafe attachment %s", async (_, attachment, message) => { + await expect(send({ ...baseMessage, attachments: [attachment as never] })).rejects.toThrow( + message, + ); }); test("accepts addresses with hyphens and plus signs", async () => { diff --git a/packages/email-sdk/src/smtp.ts b/packages/email-sdk/src/smtp.ts index ead2353..74297d0 100644 --- a/packages/email-sdk/src/smtp.ts +++ b/packages/email-sdk/src/smtp.ts @@ -3,9 +3,10 @@ import net from "node:net"; import tls from "node:tls"; import { EmailAdapterError } from "./errors.js"; -import type { EmailAddress, EmailMessage, EmailAdapter } from "./types.js"; +import type { EmailAddress, EmailAttachment, EmailMessage, EmailAdapter } from "./types.js"; import { BUILT_IN_ADAPTER_CAPABILITIES, + attachmentToBytes, formatAddress, formatAddresses, headersToArray, @@ -52,10 +53,11 @@ export function smtp( }, async send(message, context) { validateBuiltInAdapter("smtp", message); + const raw = await buildMimeMessage(message, options.defaults, context.idempotencyKey); const client = new SmtpClient(options, port, context.idempotencyKey); try { - const response = await client.send(message); + const response = await client.send(message, raw); return { adapter: name, @@ -88,7 +90,7 @@ class SmtpClient { private readonly idempotencyKey?: string, ) {} - async send(message: EmailMessage) { + async send(message: EmailMessage, raw: string) { await this.connect(); await this.expect([220]); await this.command(`EHLO ${this.options.heloName ?? "localhost"}`, [250]); @@ -133,7 +135,6 @@ class SmtpClient { } await this.command("DATA", [354]); - const raw = buildMimeMessage(message, this.options.defaults, this.idempotencyKey); const response = await this.command(`${escapeData(raw)}\r\n.`, [250]); await this.command("QUIT", [221]).catch(() => undefined); @@ -281,7 +282,7 @@ class SmtpClient { } } -function buildMimeMessage( +async function buildMimeMessage( message: EmailMessage, defaults: SmtpAdapterOptions["defaults"], idempotencyKey?: string, @@ -307,20 +308,51 @@ function buildMimeMessage( const headerText = headers .filter(([, value]) => value) .map(([key, value]) => `${key}: ${foldHeader(value)}`) + .join("\r\n"); + + const bodyPart = buildBodyPart(message); + + if (!message.attachments?.length) { + return `${headerText}\r\n${bodyPart}`; + } + + const boundary = `email-sdk-mixed-${randomUUID()}`; + const attachmentParts = await Promise.all(message.attachments.map(buildAttachmentPart)); + const parts = [bodyPart, ...attachmentParts] + .map((part) => `--${boundary}\r\n${part}`) .join("\r\n"); + return `${headerText}\r\nContent-Type: multipart/mixed; boundary="${boundary}"\r\n\r\n${parts}\r\n--${boundary}--`; +} + +function buildBodyPart(message: EmailMessage) { if (message.html && message.text) { - const boundary = `email-sdk-${randomUUID()}`; + const boundary = `email-sdk-alt-${randomUUID()}`; - return `${headerText}\r\nContent-Type: multipart/alternative; boundary="${boundary}"\r\n\r\n--${boundary}\r\nContent-Type: text/plain; charset=utf-8\r\n\r\n${message.text}\r\n--${boundary}\r\nContent-Type: text/html; charset=utf-8\r\n\r\n${message.html}\r\n--${boundary}--`; + return `Content-Type: multipart/alternative; boundary="${boundary}"\r\n\r\n--${boundary}\r\nContent-Type: text/plain; charset=utf-8\r\n\r\n${message.text}\r\n--${boundary}\r\nContent-Type: text/html; charset=utf-8\r\n\r\n${message.html}\r\n--${boundary}--`; } const contentType = message.html ? "text/html" : "text/plain"; const body = message.html ?? message.text ?? ""; - return `${headerText}\r\nContent-Type: ${contentType}; charset=utf-8\r\n\r\n${body}`; + return `Content-Type: ${contentType}; charset=utf-8\r\n\r\n${body}`; } +async function buildAttachmentPart(attachment: EmailAttachment) { + const headers = [ + `Content-Type: ${attachment.contentType ?? "application/octet-stream"}; name="${attachment.filename}"`, + "Content-Transfer-Encoding: base64", + `Content-Disposition: ${attachment.disposition ?? "attachment"}; filename="${attachment.filename}"`, + ]; + + if (attachment.contentId) { + headers.push(`Content-ID: <${attachment.contentId}>`); + } + + const encoded = (await attachmentToBytes(attachment)).toString("base64"); + + return `${headers.join("\r\n")}\r\n\r\n${wrapBase64(encoded)}`; +} function envelopeAddress(address: EmailAddress) { return parseEmailAddress(formatAddress(address)); } @@ -338,6 +370,10 @@ function foldHeader(value: string) { return value.replace(/\r\n|[\r\n]/g, " "); } +function wrapBase64(value: string) { + return value.match(/.{1,76}/g)?.join("\r\n") ?? ""; +} + function extractSmtpMessageId(response: string) { const match = response.match(/(?:queued as|id)\s+\s]+)>?/i); return match?.[1]; diff --git a/packages/email-sdk/src/utils.ts b/packages/email-sdk/src/utils.ts index 6b19616..ee4b0b1 100644 --- a/packages/email-sdk/src/utils.ts +++ b/packages/email-sdk/src/utils.ts @@ -482,7 +482,7 @@ export const SUPPORTED_MESSAGE_FIELDS = { scaleway: { cc: true, bcc: true, replyTo: true, headers: true, attachments: true }, zeptomail: { cc: true, bcc: true, replyTo: true, attachments: true }, mailpace: { cc: true, bcc: true, replyTo: true }, - smtp: { cc: true, bcc: true, replyTo: true, headers: true }, + smtp: { cc: true, bcc: true, replyTo: true, headers: true, attachments: true }, } satisfies Record; const NATIVE_IDEMPOTENCY = new Set(["resend", "jetemail", "lettermint", "primitive"]); @@ -623,7 +623,47 @@ export function validateBuiltInAdapter( `SMTP header name ${JSON.stringify(header.name)} contains invalid characters.`, ); } - } + } + for (const attachment of message.attachments ?? []) { + if ( + typeof attachment.filename !== "string" || + !/^[\x20-\x21\x23-\x5b\x5d-\x7e]+$/.test(attachment.filename) + ) { + throw new EmailValidationError( + `SMTP attachment filename ${JSON.stringify(attachment.filename)} contains invalid characters.`, + ); + } + if ( + attachment.contentType !== undefined && + (typeof attachment.contentType !== "string" || + !/^[A-Za-z0-9!#$%&'*+.^_`{|}~-]+\/[A-Za-z0-9!#$%&'*+.^_`{|}~-]+$/.test( + attachment.contentType, + )) + ) { + throw new EmailValidationError( + `SMTP attachment content type ${JSON.stringify(attachment.contentType)} contains invalid characters.`, + ); + } + if ( + attachment.contentId !== undefined && + (typeof attachment.contentId !== "string" || + !/^[\x21-\x7e]+$/.test(attachment.contentId) || + /[\s<>]/.test(attachment.contentId)) + ) { + throw new EmailValidationError( + `SMTP attachment content ID ${JSON.stringify(attachment.contentId)} contains invalid characters.`, + ); + } + if ( + attachment.disposition !== undefined && + attachment.disposition !== "attachment" && + attachment.disposition !== "inline" + ) { + throw new EmailValidationError( + `SMTP attachment disposition ${JSON.stringify(attachment.disposition)} contains invalid characters.`, + ); + } + } } } diff --git a/scripts/check-adapter-support-docs.ts b/scripts/check-adapter-support-docs.ts index 4fe1df0..c515df3 100644 --- a/scripts/check-adapter-support-docs.ts +++ b/scripts/check-adapter-support-docs.ts @@ -99,7 +99,7 @@ requireIncludes(entry("postmark"), "one tag"); requireIncludes(entry("lettermint"), "one tag"); requireIncludes(entry("mailtrap"), "one tag"); requireIncludes(entry("scaleway"), "headers already include Reply-To"); -requireIncludes(entry("smtp"), "ASCII envelope addresses and header names"); +requireIncludes(entry("smtp"), "ASCII envelope addresses, header names, and attachment MIME fields"); requireIncludes(entry("sendgrid"), "1,000 recipients"); requireIncludes(entry("mailgun"), "1,000 recipients"); requireIncludes(entry("sendgrid"), "Tag names are discarded");