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. There is no Nodemailer and no HTTP API in the path. 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, so credentials do not 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, 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.
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, because a refused recipient fails the SMTP conversation instead. 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 to prove the host, TLS mode, and credentials actually deliver.
