Mail

Send transactional email through SMTP, Sevk, Resend, Mailgun, or Amazon SES with a fluent, chainable API.

Introduction

@tekir/mail ships a Mail class with a fluent builder API. Access it via #services:

services.ts
import { service } from '@tekir/core'
import type { Mail } from '@tekir/mail'

export const mail = service<Mail>('mail')

Every transport speaks the same Transport interface, so you can swap backends in config without touching application code. The log transport is always available as a zero-config fallback that prints to the console.

Configuration

Export a config object from config/mail.ts. Define one or more transports and set default to choose which one mail.to() uses automatically. Only configure the transports you need.

config/mail.ts
import env from '#env'
import type { MailConfig } from '@tekir/mail'

export default {
  default: 'smtp',
  from: 'tekir App <[email protected]>',
  transports: {
    smtp: {
      host: env.SMTP_HOST,
      port: 587,
      secure: false,
      auth: {
        user: env.SMTP_USER,
        pass: env.SMTP_PASS
      }
    },
    sevk: {
      apiKey: env.SEVK_API_KEY
    },
    resend: {
      apiKey: env.RESEND_API_KEY
    },
    mailgun: {
      apiKey: env.MAILGUN_API_KEY,
      domain: env.MAILGUN_DOMAIN,
      region: 'us'
    },
    ses: {
      accessKeyId: env.AWS_ACCESS_KEY_ID,
      secretAccessKey: env.AWS_SECRET_ACCESS_KEY,
      region: 'us-east-1'
    }
  }
} satisfies MailConfig

Register MailProvider in your kernel:

start/kernel.ts
import type { TekirApp } from '@tekir/core'
import { MailProvider } from '@tekir/mail'

export default function({ app }: TekirApp) {
  app.registerAll([MailProvider])
}

Transports

SMTP

The SMTP transport uses nodemailer as an optional peer dependency. Run bun add nodemailer to enable it. Supports TLS, auth, and all standard SMTP settings.

// nodemailer is required for SMTP, install it separately:
// bun add nodemailer

import { mail } from '#services'

await mail
  .to('[email protected]')
  .subject('Hello via SMTP')
  .html('<p>Sent through your SMTP server.</p>')
  .send()

Sevk

Communicates with the sevk.io HTTP API using native fetch. No extra packages required, only an API key.

import { mail } from '#services'

// Sevk uses the sevk.io HTTP API: only an API key is needed.
await mail
  .use('sevk')
  .to('[email protected]')
  .subject('Welcome')
  .html('<h1>Thanks for signing up!</h1>')
  .send()

Resend

Communicates with the Resend HTTP API using native fetch. No extra packages required, only an API key.

import { mail } from '#services'

// Explicitly pick the Resend transport for this message
await mail
  .use('resend')
  .to('[email protected]')
  .subject('Welcome')
  .html('<h1>Thanks for signing up!</h1>')
  .send()

Mailgun

Posts to the Mailgun Messages API over HTTPS. Supports both the US (api.mailgun.net) and EU (api.eu.mailgun.net) regions via the optional region field.

import { mail } from '#services'

await mail
  .use('mailgun')
  .to(['[email protected]', '[email protected]'])
  .subject('Team Update')
  .text('Plain-text fallback for email clients that do not render HTML.')
  .html('<p>Rich content here.</p>')
  .send()

Amazon SES

Signs requests with AWS Signature v4 using the Web Crypto API, no AWS SDK or extra dependencies needed. Provide your accessKeyId, secretAccessKey, and region.

import { mail } from '#services'

// SES uses AWS Signature v4: no extra dependencies required.
await mail
  .use('ses')
  .to('[email protected]')
  .subject('Your invoice is ready')
  .html('<a href="https://...">Download Invoice</a>')
  .send()

Log (Dev)

The log transport prints a formatted representation of every message to stdout. It is registered automatically and requires no configuration. Use it in development or CI where you want to inspect emails without an outbox.

// The log transport is always registered, even without config.
// It prints the message to the console: useful in development.
import { mail } from '#services'

await mail
  .use('log')
  .to('dev@localhost')
  .subject('Test email')
  .text('This appears in your terminal, nothing is actually sent.')
  .send()

Fluent API

Every call on mail returns a MailBuilder instance. Methods are chainable; call .send() at the end to dispatch. A recipient (to) and a subject are the only required fields.

import { mail } from '#services'

await mail
  .to('[email protected]')           // required
  .cc('[email protected]')
  .bcc('[email protected]')
  .replyTo('[email protected]')
  .from('tekir <[email protected]>')   // overrides config default
  .subject('Your order has shipped') // required
  .html('<h1>It is on the way!</h1>')
  .text('Your order has shipped.')   // plain-text fallback
  .header('X-Order-Id', '9001')
  .send()

Attachments

Chain .attach() one or more times. Each attachment requires a filename and content (a Buffer, ArrayBuffer, or a Base64 string).

import { mail } from '#services'

await mail
  .to('[email protected]')
  .subject('Your receipt')
  .html('<p>Please find your receipt attached.</p>')
  .attach({
    filename: 'receipt.pdf',
    content: await Bun.file('./storage/receipts/9001.pdf').arrayBuffer(),
    contentType: 'application/pdf'
  })
  .attach({
    filename: 'logo.png',
    content: await Bun.file('./public/logo.png').arrayBuffer(),
    contentType: 'image/png'
  })
  .send()

Templates

.template(fn, data) calls a plain function that returns an HTML string and sets it as the email body. There is no special template engine, use any string interpolation, JSX renderer, or templating library you prefer.

import { mail } from '#services'

// A template is just a function that accepts data and returns an HTML string.
function welcomeTemplate(data: { name: string; link: string }): string {
  return `
    <h1>Welcome, ${data.name}!</h1>
    <p><a href="${data.link}">Confirm your email</a></p>
  `
}

await mail
  .to('[email protected]')
  .subject('Confirm your email')
  .template(welcomeTemplate, { name: 'Alice', link: 'https://myapp.com/confirm/abc' })
  .send()

mail.use()

mail.use(transportName) returns a MailBuilder pre-bound to the named transport. Use this when you want to send a single message through a transport that is not the configured default.

import { mail } from '#services'

// mail.use(transportName) returns a MailBuilder pre-bound to that transport.
// All subsequent chained calls apply to this transport.
await mail
  .use('resend')
  .to('[email protected]')
  .subject('Sent via Resend')
  .html('<p>Hello!</p>')
  .send()

BaseMail Class

For reusable, class-based emails, extend BaseMail and implement prepare(): MailBuilder. This keeps email-building logic co-located with the data it needs and makes individual email classes independently testable.

import { BaseMail } from '@tekir/mail'
import type { MailBuilder } from '@tekir/mail'

// Extend BaseMail to create a reusable, self-contained email class.
class WelcomeMail extends BaseMail {
  constructor(private user: { email: string; name: string }) {
    super()
  }

  prepare(): MailBuilder {
    return this.builder
      .to(this.user.email)
      .subject(`Welcome, ${this.user.name}!`)
      .html(`<h1>Hi ${this.user.name}, thanks for joining!</h1>`)
  }
}

// Dispatch it from anywhere
await new WelcomeMail({ email: '[email protected]', name: 'Alice' }).send()

Testing

Call mail.fake() before your test. In fake mode every .send() call records the message in memory instead of delivering it. Use the assertion helpers to verify your application sent the right emails.

import { mail } from '#services'

// Switch to fake mode: nothing is actually delivered.
mail.fake()

await mail
  .to('[email protected]')
  .subject('Test subject')
  .html('<p>Hello</p>')
  .send()

// Assert at least one email was sent
mail.assertSent()

// Assert with a predicate
mail.assertSent((m) => m.to === '[email protected]' && m.subject === 'Test subject')

// Assert exact count
mail.assertSentCount(1)

// Assert nothing was sent (useful as a negative assertion)
// mail.assertNotSent()

// Inspect raw sent records
console.log(mail.sent) // SentMail[]

// Clear the sent log between tests
mail.clearSent()

// Restore real transport behaviour
mail.restore()
  • mail.assertSent(predicate?): throws if no email (or no matching email) was sent.
  • mail.assertNotSent(predicate?): throws if an email (matching the predicate) was sent.
  • mail.assertSentCount(n): throws if the number of sent emails does not equal n.
  • mail.sent: the raw SentMail[] array for custom assertions.
  • mail.clearSent(): resets the sent log without leaving fake mode.
  • mail.restore(): exits fake mode and re-enables real transports.