A Wraps plugin for better-auth: auth email through your own SES

better-auth gets an enormous amount right, and then it hands you a list of TODOs shaped like function signatures.

emailAndPassword: {
  sendResetPassword: async ({ user, url }) => { /* ...your problem */ },
},
emailVerification: {
  sendVerificationEmail: async ({ user, url }) => { /* ...your problem */ },
},

That's the correct design — an auth library has no business picking your email provider. But it means every better-auth app starts the same way: pick a provider, write six nearly-identical HTML templates you'll never look at again, and try to remember which of magicLink, emailOTP, and organization puts its sender on the plugin instead of the root config.

And then there's the other half nobody puts in the quickstart: a new user signed up, and something downstream needs to know. The welcome sequence, the onboarding workflow, the contact record.

So I built @wraps.dev/better-auth — two things, both opt-in, either usable without the other.

pnpm add @wraps.dev/better-auth
import { betterAuth } from 'better-auth';
import { wraps } from '@wraps.dev/better-auth';
 
export const auth = betterAuth({
  emailAndPassword: { enabled: true },
  plugins: [
    wraps({
      // Sync half — omit to disable
      apiKey: process.env.WRAPS_API_KEY,
 
      // Email half — omit to disable
      email: {
        from: 'Acme <auth@acme.com>',
        appName: 'Acme',
        brand: { logoUrl: '...', primaryColor: '#4f46e5', supportEmail: 'help@acme.com' },
      },
    }),
  ],
});

sendVerificationEmail, sendResetPassword, and onPasswordReset are now wired, delivered through your own AWS SES account. The email half needs no Wraps account at all — it's @wraps.dev/email and your AWS credentials. If all you want is "stop writing auth email templates," that's the whole install.


The senders a plugin can't reach

Better Auth's own plugins own their sender options, so nothing can inject into them from outside. Build the senders once and pass them where they go:

import { wrapsAuthEmails } from '@wraps.dev/better-auth';
import { emailOTP, magicLink, organization } from 'better-auth/plugins';
 
const emails = wrapsAuthEmails({
  from: 'Acme <auth@acme.com>',
  appName: 'Acme',
  appUrl: 'https://app.acme.com',
});
 
betterAuth({
  plugins: [
    magicLink({ sendMagicLink: emails.magicLink }),
    emailOTP({ sendVerificationOTP: emails.otp }),
    organization({ sendInvitationEmail: emails.invitation }),
  ],
});

Six senders total: verification, resetPassword, passwordChanged, magicLink, otp, invitation. The templates are plain HTML with no React dependency and no Wraps branding anywhere in them — they read as coming from your app, because they are. Override any one of them with a function that returns { subject, html, text }.

And the plugin never overrides your config. Better Auth merges plugin options under your own, so if you already have a sendVerificationEmail, yours wins and the plugin's is never called. The senders it supplies fill gaps; they don't take over. It also never sets emailAndPassword.enabled — configuring the email half will not quietly turn on password auth in an app that only does OAuth.


The hook that catches OAuth signups

This is the part I'd want to read if someone else had written this package.

The obvious way to detect a signup in a better-auth plugin is a response-level after hook matched to the sign-up path. It works. You test it with email/password, you see the contact appear, you ship.

Then a week later you notice your Google signups aren't in the CRM.

Better Auth skips after hooks on OAuth redirect responses. The callback doesn't return JSON to a client — it returns a redirect — and a plugin matching /callback/* silently misses every Google and GitHub signup you'll ever get. Silently is the important word: nothing errors, nothing logs, the contact just isn't there.

So the plugin hangs off the database layer instead:

databaseHooks: {
  user: {
    create: {
      after: async (user, context) => { await run(sync.onUserCreated(user, context)); },
    },
  },
}

A user row got created. That's the actual event we care about, and it fires for every path that can produce one — email/password, OAuth, passkey, magic link, OTP, admin-created, SCIM-provisioned. Plugin databaseHooks are additive, so your own still run.

Two more correctness notes that came out of running this on Lambda:

Sync work is awaited, not fire-and-forget. On Lambda the runtime freezes the instant the handler returns, so a floating promise you didn't await simply never happens — and it never happens inconsistently, which is worse, because it works fine on your laptop. If your platform has a real background primitive, hand it over:

wraps({ waitUntil: (promise) => ctx.waitUntil(promise) })   // Vercel / Cloudflare

Auth never fails because of us. Every contact write and every send is wrapped. Failures go to onError with a stage and stop there. A Wraps outage or an SES throttle cannot break a signup — the worst case is a missing contact and a log line, never a user who couldn't create an account.


Defaults that assume you'd rather be asked

Two of the choices in here are about consent, and both default to doing less.

New contacts are subscribed to no topics. A signup is a transactional relationship, not marketing permission. Someone creating an account has agreed to hear about their account — not to your product newsletter. You opt in explicitly, and only when your signup form actually asked:

wraps({ apiKey: process.env.WRAPS_API_KEY, topicSlugs: ['product-updates'] });

Attribution is off by default. Knowing which campaign produced a signup is genuinely useful, and the information only exists at the moment of the request — by the time you query the contact, it's gone. Turn it on with one line and the plugin reads it off the signup request and stores it on both the contact and the user.signed_up event, so a workflow can branch on it:

wraps({ apiKey: process.env.WRAPS_API_KEY, attribution: true });

It's off by default because it writes browser-supplied data to your contact records, and that should be something you decided rather than something that started happening when you bumped a version.

Which leads to the part that took the most care. Attribution comes from a wraps_attribution cookie, and cookies are browser-writable — anyone can put anything in one. So:

  • The field list is an allowlist, not a suggestion. The UTM set, ref, referrer, landing_page, gclid, fbclid, msclkid. Unknown keys are dropped, not stored.
  • Values are flattened to strings and capped at 512 characters. No nested objects, no unbounded payload.
  • Nothing from the cookie can shadow method, provider, or source. Those are what the plugin reports about how the signup happened, and a stray method key in a cookie must not be able to lie about it.

Extend the allowlist deliberately, or replace the whole thing with your own code:

import { DEFAULT_ATTRIBUTION_FIELDS } from '@wraps.dev/better-auth';
 
attribution: {
  fields: [...DEFAULT_ATTRIBUTION_FIELDS, 'partner_id'],
  parse: (context) => ({ affiliate: context?.headers?.get('x-affiliate') }),
}

parse skips the allowlist entirely — it's your code, so it's your call. If it throws, you get an onError with stage: 'attribution' and the signup carries on without attribution attached.


The conversion case

One small thing worth calling out, because it's the bug you'd hit in month two.

Someone subscribes to your newsletter in March. In June they create an account with the same address. The naive sync POSTs a new contact, hits a uniqueness conflict, and errors — so the signup that mattered most is the one that doesn't sync. The plugin patches the existing contact instead, keeping their history and their existing subscriptions intact while attaching the new externalId.

The user.signed_up event still fires, with properties.method recording how they signed up (email, oauth, passkey, magic-link, otp) and provider set for OAuth. Which is exactly the branch your onboarding workflow wants: a newsletter subscriber who finally converted is a different email than a cold signup.


Try it

npm install @wraps.dev/better-auth

MIT licensed, at github.com/wraps-team/better-auth-wraps. Needs better-auth 1.6+; @wraps.dev/email is an optional peer dependency, only for the email half.

If you're on better-auth and tired of writing password-reset HTML, install it for the email half and ignore the rest. That's a supported way to use this.


I'm building Wraps — email infrastructure that runs in your own AWS account. This plugin exists because I got tired of writing the same six auth templates in every project, and because the OAuth-signups-missing-from-the-CRM bug happened to me first.