SMTP
Send over raw SMTP with the built-in TCP/TLS transport — no Nodemailer, no HTTP API.
The SMTP adapter speaks the SMTP protocol directly over Node/Bun TCP and TLS sockets — no Nodemailer, no HTTP, no dependency. Point it at PurelyMail, a self-hosted server, or any provider's SMTP endpoint, and register it more than once under different names for multiple routes.
@opencoredev/email-sdk/smtpConfigure
Get the host, port, TLS mode, and credentials from your SMTP provider.
import { createEmailClient } from "@opencoredev/email-sdk";
import { smtp } from "@opencoredev/email-sdk/smtp";
export const email = createEmailClient({
adapters: [
smtp({
host: "smtp.purelymail.com",
port: 587,
auth: {
user: process.env.SMTP_USER!,
pass: process.env.SMTP_PASS!,
},
}),
],
});Prop
Type
TLS and auth semantics
With secure: true the whole session is TLS. Otherwise the adapter connects in plaintext and upgrades with STARTTLS before authenticating whenever requireTLS is set or auth is present — credentials never travel unencrypted. Authenticating against a server that cannot do TLS requires an explicit allowInsecureAuth: true; without it the adapter throws instead of sending the password in the clear.
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 the message has an idempotencyKey (a random UUID otherwise), and an explicit Message-ID in headers overrides it — set one yourself if you need a specific domain for threading or deduplication.
const result = await email.send({
from: "Acme <alerts@acme.com>",
to: "ops@example.com",
cc: "oncall@example.com",
subject: "Disk usage above 90% on db-1",
text: "db-1 is at 92% disk usage.",
html: "<p><strong>db-1</strong> is at 92% disk usage.</p>",
headers: { "X-Alert-ID": "alert_481" },
});
console.log(result.accepted); // every recipient the server accepted
console.log(result.id); // parsed from the server's "queued as ..." reply, else the idempotency keyrejected is always empty — a refused recipient fails the SMTP conversation instead, and every transport error surfaces as a retryable EmailProviderError, so SMTP plays well with retries and fallbacks.
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.
Multiple SMTP routes
name lets you register the adapter twice and use one route as the fallback for the other.
export const email = createEmailClient({
adapters: [
smtp({
name: "purelymail",
host: "smtp.purelymail.com",
auth: { user: process.env.PURELYMAIL_USER!, pass: process.env.PURELYMAIL_PASS! },
}),
smtp({
name: "backup-smtp",
host: "smtp.backup.example",
auth: { user: process.env.BACKUP_SMTP_USER!, pass: process.env.BACKUP_SMTP_PASS! },
}),
],
defaultAdapter: "purelymail",
fallback: ["backup-smtp"],
});Verify from the CLI
doctor only checks SMTP_HOST; the other settings are optional.
SMTP_HOST=smtp.purelymail.com npx email-sdk doctor --adapter smtpnpx email-sdk send \
--adapter smtp \
--host smtp.purelymail.com \
--port 587 \
--require-tls \
--user hello@acme.com \
--pass "$SMTP_PASS" \
--from "Acme <hello@acme.com>" \
--to user@example.com \
--subject "SMTP smoke test" \
--text "It works" \
--dry-runEach 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 — the only check that proves the host, TLS mode, and credentials actually deliver.
