DEV Community

Cover image for BYO in KickJS v5 — Building Your Own Mailer (and Why That's a Good Thing)
Orinda Felix Ochieng
Orinda Felix Ochieng

Posted on

BYO in KickJS v5 — Building Your Own Mailer (and Why That's a Good Thing)

If you went looking for @forinda/kickjs-mailer on npm and found a deprecation banner instead of a v5 release, here's the punchline: the package isn't gone. It's just... yours. The framework still has everything you need to wire a mailer into the DI container — defineAdapter, createToken, lifecycle hooks — and the email surface turns out to be small enough that owning it in your own repo is the better trade. This post walks through what that pattern looks like, why it matters, and how to lay out the four pieces every BYO mailer collapses to.

What got deprecated and why

The README on the abandoned package is unusually direct. Quoting verbatim:

Deprecated — going private in v4.1.2. Replaced by a BYO recipe using defineAdapter/definePlugin from @forinda/kickjs directly.

That sentence captures a philosophical shift in how the framework treats peripheral concerns. KickJS v5 still publishes opinionated packages for the things you don't want to reinvent — auth strategies, queue drivers, OpenTelemetry wiring, ORM adapters. But for surfaces that are essentially "an interface, a service, and a default implementation," shipping a package starts to cost more than it saves. You inherit a versioning pact ("does mailer 2.x work with kickjs 5.x?"), a peer-dep graph, and a public API that has to evolve in lockstep with three transports nobody on the maintainer's machine actually uses.

The recipe approach inverts that. The framework guarantees the primitivesdefineAdapter, the DI container, the request lifecycle. Recipes documenting how to assemble those primitives into common shapes live in the docs. Your project owns the assembled artifact. When the primitive's signature is stable across the major, your recipe-based mailer is stable across the major. No transitive churn.

Recipe, not package

A package ships a finished thing — code, types, a published version, a public API contract. A recipe ships a shape: "here's how to compose four primitives into a mailer; copy it, adjust it, own it." The framework promises that the primitives are stable. The recipe lives in your repo and serves only your app, so it's sized to your app — only the providers you actually use, only the options you actually configure. Fewer lines of code under your control instead of more lines of code under someone else's release schedule.

The four pieces

Every BYO mailer collapses to the same four pieces. They don't change much between projects, which is exactly why the framework stopped trying to ship them as a unit:

  1. TypesMailMessage, MailResult, MailRecipient. The shape of what callers send and what they get back. Plain interfaces, no decorators, no DI.
  2. Provider interfaceMailProvider with one method: send(message): Promise<MailResult>. This is the seam tests stub, the seam ops swaps per environment, and the only contract a transport implementation has to satisfy.
  3. ServiceMailerService. The class injected into use-cases. It wraps a provider and adds the small amount of cross-cutting logic every call site would otherwise repeat — typically a default from address, sometimes a logger, sometimes a tag.
  4. Adapter factory — the defineAdapter call. Produces a typed factory that registers the service against an injection token in the DI container during beforeStart. Composition lives at the edge: pass it whichever provider this environment needs.

Each piece is one screen of code. None depends on the framework beyond defineAdapter and createToken. That's the recipe.

What the types look like

The types are deliberately boring — interfaces, not classes, no inheritance:

export interface MailMessage {
  readonly from?: MailRecipient
  readonly to: MailRecipient | MailRecipient[]
  readonly subject: string
  readonly text?: string
  readonly html?: string
}

export interface MailResult {
  readonly messageId: string
  readonly accepted: boolean
}

export interface MailProvider {
  readonly name: string
  send(message: MailMessage): Promise<MailResult>
}

export const MAILER = createToken<MailerService>('MailerService')
Enter fullscreen mode Exit fullscreen mode

A useful trick when migrating off a deprecated package: mirror the old shape on purpose. If your callers already say mailer.send({ to, subject, html }), keep that exact signature. BYO doesn't have to mean "redesign your email API" — it usually means "lift the same shape, drop the dep." The injection token plays the same role: it's the contract between callers and the adapter, so use-cases reference MAILER and never know which provider is actually wired underneath.

The service stays small on purpose

The service itself is small, and that's a feature:

export class MailerService {
  constructor(private readonly opts: { provider: MailProvider; defaultFrom: MailRecipient }) {}

  async send(message: MailMessage): Promise<MailResult> {
    return this.opts.provider.send({
      ...message,
      from: message.from ?? this.opts.defaultFrom,
    })
  }
}
Enter fullscreen mode Exit fullscreen mode

Notice what's not in there: no template engine, no retry policy, no rate limiting, no queue handoff. Those are real concerns for some apps — they're just not concerns for the mailer service itself. Templating belongs in a render layer above; retries belong in a queue layer below. Keeping the service to "stamp from, delegate" means it's still recognisably the same class six months from now when those layers do show up.

If you find yourself reaching for an additional concern, ask whether it belongs inside send or around it. "Inside" is almost always the wrong answer. A 300-line mailer with hand-rolled exponential backoff is a code smell; pushing the message onto a queue and letting the queue owner handle retries is the shape that scales.

A console provider for dev

The most useful default provider in early development is one that logs to the console:

export class ConsoleMailProvider implements MailProvider {
  readonly name = 'console'
  private counter = 0

  async send(message: MailMessage): Promise<MailResult> {
    this.counter += 1
    const id = `console-${this.counter}`
    console.info(`mail[${id}] -> ${message.to} :: ${message.subject}`)
    return { messageId: id, accepted: true }
  }
}
Enter fullscreen mode Exit fullscreen mode

Zero dependencies. Zero network calls. You can run the whole bootstrap, exercise password-reset, signup confirmation, anything that emits mail, and watch it scroll past in your dev terminal. When you're ready to send for real, you swap one constructor.

The adapter factory

The piece that ties it to the DI container:

const mailerAdapterFactory = defineAdapter<MailerAdapterConfig>({
  name: 'MailerAdapter',
  build(config) {
    return {
      beforeStart({ container }) {
        container.registerInstance(
          MAILER,
          new MailerService({ provider: config.provider, defaultFrom: config.defaultFrom }),
        )
      },
    }
  },
})

export function createMailerAdapter() {
  return mailerAdapterFactory({
    provider: new ConsoleMailProvider(),
    defaultFrom: { name: 'App', address: 'noreply@example.com' },
  })
}

export { mailerAdapterFactory }
Enter fullscreen mode Exit fullscreen mode

Two exports, two purposes. createMailerAdapter() is the production ergonomics — call it from your bootstrap, get the dev-defaulted mailer registered. mailerAdapterFactory is the raw factory exposed for tests and per-environment composition: pass any MailProvider, get an adapter that registers a service wrapping it. That's the entire surface.

The test seam

Because MailProvider is a one-method interface, the test double is genuinely trivial:

export class CapturingMailProvider implements MailProvider {
  name = 'capturing-test'
  sent: MailMessage[] = []

  async send(message: MailMessage): Promise<MailResult> {
    this.sent.push(message)
    return { messageId: `test-${this.sent.length}`, accepted: true }
  }

  reset() { this.sent.length = 0 }
}
Enter fullscreen mode Exit fullscreen mode

Each integration test instantiates a CapturingMailProvider, threads it into the test container's mailer registration via the raw factory, and asserts on subject / html / to after exercising the endpoint. If a flow embeds an OTP or a magic link in the email body, the test scrapes it back out of the captured payload — a thin helper on the test provider keeps that readable.

Compare what stubbing the old package would have looked like: mock the export, stub send, hope the mock signature matches whatever version of the package you happened to pull. Owning the interface means the stub is just a class.

Dev to prod is one constructor swap

This is where the recipe actually saves money. Today, dev runs on ConsoleMailProvider. When transactional mail lands, swapping to a real transport is one constructor swap inside createMailerAdapter():

// before
provider: new ConsoleMailProvider(),

// after
provider: new ResendMailProvider({ apiKey: env.RESEND_KEY }),
// or
provider: new SmtpMailProvider({ host, port, user, pass }),
Enter fullscreen mode Exit fullscreen mode

The service doesn't change. The injection token doesn't change. Every mailer.send({...}) call site doesn't change. There's no peer-dep version chase, no "does the new mailer package work with kickjs 5.7?", no transitive nodemailer upgrade landing in your lockfile because the package author bumped a dep. You wrote ~30 lines of ResendMailProvider, you own those 30 lines, and that's the whole change.

The same composition pattern means staging on SMTP, prod on a transactional API, e2e on the capturing provider — same adapter factory, three different provider: values. No package matrix to maintain.

When you should still ship a package

Recipes win for surfaces that are basically "an interface plus a default." They lose when the default implementation has significant shared logic (token rotation, signature verification, multi-region routing) every caller would otherwise duplicate, when the contract has to evolve in coordination with an external spec, or when the thing genuinely benefits from being battle-tested across many consumers. Mailers fail all three. Once you notice the pattern, you start spotting it elsewhere: feature flags, audit logs, idempotency keys. Recipe-shaped surfaces all the way down.

Why this is better

Three concrete wins from doing it this way:

  • Smaller dep tree. No mailer package means no nodemailer (or whatever the package would have pulled in) until you actually want it. A console provider has zero deps. CI installs are faster, supply-chain surface is smaller, and you don't get CVE pings for transports you aren't using.
  • No upstream churn. When kickjs ships v5.8, your mailer doesn't need a coordinated release. defineAdapter is the API contract, and that's part of the framework core. As long as the core's stable, your recipe is stable.
  • Trivial per-environment swaps. Same adapter factory, different provider: values per env. No package matrix, no dual-publishing dance, no "is the test provider on the same major as prod?"

That's the trade KickJS v5 is pushing you toward across the small-surface concerns: own the recipe, lean on the primitives. The mailer is just the most readable example because everyone has written one.

References

Top comments (0)