# SMTP (/docs/v/0.6.1/adapters/smtp)



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.

<ProviderBadge adapter="smtp" />

## Configure [#configure]

Get the host, port, TLS mode, and credentials from your SMTP provider.

```ts title="lib/email.ts"
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!,
      },
    }),
  ],
});
```

<TypeTable
  type="{
  host: {
    description: &#x22;SMTP server hostname.&#x22;,
    type: &#x22;string&#x22;,
    required: true,
  },
  port: {
    description: &#x22;SMTP server port.&#x22;,
    type: &#x22;number&#x22;,
    default: &#x22;465 when secure, else 587&#x22;,
  },
  secure: {
    description: &#x22;Connect with implicit TLS from the first byte (typically port 465).&#x22;,
    type: &#x22;boolean&#x22;,
    default: &#x22;false&#x22;,
  },
  auth: {
    description:
      'SMTP credentials. method picks the AUTH mechanism; the default is &#x22;plain&#x22; (AUTH PLAIN), set &#x22;login&#x22; for AUTH LOGIN.',
    type: '{ user: string; pass: string; method?: &#x22;plain&#x22; | &#x22;login&#x22; }',
  },
  requireTLS: {
    description: &#x22;Force a STARTTLS upgrade even when no auth is configured.&#x22;,
    type: &#x22;boolean&#x22;,
    default: &#x22;false&#x22;,
  },
  allowInsecureAuth: {
    description: &#x22;Permit AUTH over a plaintext connection. Only for trusted local servers.&#x22;,
    type: &#x22;boolean&#x22;,
    default: &#x22;false&#x22;,
  },
  tls: {
    description: &#x22;Extra options merged into the TLS connection (e.g. ca, rejectUnauthorized).&#x22;,
    type: &#x22;tls.ConnectionOptions&#x22;,
  },
  defaults: {
    description: &#x22;Default Reply-To header applied when a message has no replyTo.&#x22;,
    type: &#x22;{ replyTo?: string }&#x22;,
  },
  name: {
    description: &#x22;Adapter name override — register several SMTP servers side by side.&#x22;,
    type: &#x22;string&#x22;,
    default: '&#x22;smtp&#x22;',
  },
  heloName: {
    description: &#x22;Hostname announced in the EHLO command.&#x22;,
    type: &#x22;string&#x22;,
    default: '&#x22;localhost&#x22;',
  },
  timeoutMs: {
    description: &#x22;Timeout for the connection and each SMTP command.&#x22;,
    type: &#x22;number&#x22;,
    default: &#x22;15000&#x22;,
  },
}"
/>

### TLS and auth semantics [#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 [#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.

```ts
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 key
```

`rejected` 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](/docs/v/0.6.1/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/v/0.6.1/adapters/field-support).
</Callout>

## Multiple SMTP routes [#multiple-smtp-routes]

`name` lets you register the adapter twice and use one route as the [fallback](/docs/v/0.6.1/concepts/fallbacks-and-retries) for the other.

```ts
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 [#verify-from-the-cli]

`doctor` only checks `SMTP_HOST`; the other settings are optional.

```bash
SMTP_HOST=smtp.purelymail.com npx email-sdk doctor --adapter smtp
```

```bash
npx 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-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 — the only check that proves the host, TLS mode, and credentials actually deliver.
