Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/calm-mails-smile.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@opencoredev/email-sdk": minor
---

Add attachment support to the built-in SMTP adapter.
26 changes: 18 additions & 8 deletions apps/fumadocs/content/docs/adapters/smtp.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
Expand All @@ -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 <alerts@acme.com>",
to: "ops@example.com",
Expand All @@ -113,6 +116,13 @@ const result = await email.send({
text: "db-1 is at 92% disk usage.",
html: "<p><strong>db-1</strong> is at 92% disk usage.</p>",
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
Expand All @@ -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).

<Callout type="warn" title="No attachments, tags, or metadata">
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).
<Callout type="warn" title="No tags or metadata">
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).
</Callout>

## Multiple SMTP routes
Expand Down Expand Up @@ -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.
6 changes: 4 additions & 2 deletions apps/fumadocs/src/lib/adapter-support.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];

Expand Down
3 changes: 2 additions & 1 deletion apps/fumadocs/src/lib/field-support.generated.json
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,7 @@
"cc": true,
"bcc": true,
"replyTo": true,
"headers": true
"headers": true,
"attachments": true
}
}
2 changes: 1 addition & 1 deletion packages/email-sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
29 changes: 29 additions & 0 deletions packages/email-sdk/src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
133 changes: 126 additions & 7 deletions packages/email-sdk/src/smtp.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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) {
Expand Down Expand Up @@ -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: "<p>Hi there</p>",
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: "<p>Hi there</p>",
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: '<img src="cid:logo" alt="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: <logo>");
});
});

describe("smtp injection guards", () => {
test("rejects CRLF injected into the envelope address", async () => {
await expect(
Expand Down Expand Up @@ -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 () => {
Expand Down
Loading