{"id":45019970,"url":"https://github.com/nuntly/better-email","last_synced_at":"2026-02-22T05:00:34.060Z","repository":{"id":338627191,"uuid":"1158524910","full_name":"nuntly/better-email","owner":"nuntly","description":"Better Auth emails made simple with Better Email","archived":false,"fork":false,"pushed_at":"2026-02-15T15:54:02.000Z","size":146,"stargazers_count":4,"open_issues_count":0,"forks_count":0,"subscribers_count":1,"default_branch":"main","last_synced_at":"2026-02-21T07:41:16.954Z","etag":null,"topics":["better-auth","email","mailgun","mustache","nuntly","postmark","react-email","react-mjml","resend","ses","smtp","typescript"],"latest_commit_sha":null,"homepage":"","language":"TypeScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/nuntly.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":"CONTRIBUTING.md","funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-02-15T14:19:12.000Z","updated_at":"2026-02-20T21:29:19.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/nuntly/better-email","commit_stats":null,"previous_names":["nuntly/better-email"],"tags_count":2,"template":false,"template_full_name":null,"purl":"pkg:github/nuntly/better-email","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/nuntly%2Fbetter-email","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/nuntly%2Fbetter-email/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/nuntly%2Fbetter-email/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/nuntly%2Fbetter-email/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/nuntly","download_url":"https://codeload.github.com/nuntly/better-email/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/nuntly%2Fbetter-email/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":29705523,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-02-22T03:17:42.375Z","status":"ssl_error","status_checked_at":"2026-02-22T03:17:31.622Z","response_time":110,"last_error":"SSL_read: unexpected eof while reading","robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":false,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["better-auth","email","mailgun","mustache","nuntly","postmark","react-email","react-mjml","resend","ses","smtp","typescript"],"created_at":"2026-02-19T02:05:40.319Z","updated_at":"2026-02-22T05:00:34.022Z","avatar_url":"https://github.com/nuntly.png","language":"TypeScript","readme":"# Better Email\n\nA Better Auth plugin that centralizes all email-sending callbacks through a single ESP-agnostic provider and template renderer.\n\n## Problem\n\nBetter Auth scatters email-sending across 8+ independent callback sites (4 core, 4+ plugin-level). Each must be independently wired to an email provider and template. This leads to duplicated send logic, inconsistent error handling, and templates spread across multiple locations.\n\n## Solution\n\n`better-email` decouples **what you send** from **how you send it** through two independent interfaces:\n\n- **Provider** (`EmailProvider`) handles email delivery. Swap providers (Nuntly, SES, Resend, Postmark, Mailgun, SMTP) without touching a single template.\n- **Renderer** (`EmailTemplateRenderer`) handles HTML/text generation. Switch from plain HTML to React Email or MJML without changing your provider config.\n\nThis separation means you can mix and match freely: use Postmark for delivery with React Email for templates, then later migrate to SES without rewriting any template code.\n\n`better-email` also provides:\n\n- **7 built-in providers**: Nuntly (default), SES, Resend, Postmark, Mailgun, SMTP, Console\n- **5 built-in renderers**: plain HTML (default), React Email, MJML, Mustache, React MJML\n- **Core callback defaults** injected via `init()` for `sendVerificationEmail` and `sendResetPassword`\n- **Factory wrappers** for plugin-level callbacks (magic link, email OTP, organization invitation, two-factor OTP, change email, delete account)\n- **Lifecycle hooks** (`onBeforeSend`, `onAfterSend`, `onSendError`) applied consistently across all email types\n- **Tag management** with default tags and per-type tags for analytics/tracking\n\n## Setup with Next.js 16 + Better Auth\n\n### 1. Install\n\n```bash\nnpm install @nuntly/better-email\n# or\npnpm add @nuntly/better-email\n# or\nyarn add @nuntly/better-email\n# or\nbun add @nuntly/better-email\n```\n\n### 2. Pick a provider\n\nChoose one of the built-in providers or implement the `EmailProvider` interface. The default provider is `NuntlyProvider`.\n\n### 3. Pick a template renderer\n\nChoose one of the built-in renderers or implement the `EmailTemplateRenderer` interface.\n\n### 4. Configure Better Auth\n\n```typescript\n// lib/auth.ts\nimport { betterAuth } from 'better-auth';\nimport { organization, twoFactor } from 'better-auth/plugins';\nimport { magicLink } from 'better-auth/plugins/magic-link';\nimport { emailOTP } from 'better-auth/plugins/email-otp';\nimport { betterEmail, NuntlyProvider, DefaultTemplateRenderer } from '@repo/better-email';\n\nconst email = betterEmail({\n  provider: new NuntlyProvider({\n    apiKey: process.env.NUNTLY_API_KEY!,\n    from: 'noreply@yourdomain.com',\n  }),\n  templateRenderer: new DefaultTemplateRenderer(),\n  defaultTags: [{ name: 'app', value: 'my-app' }],\n  tags: {\n    'verification-email': [{ name: 'category', value: 'auth' }],\n  },\n  onAfterSend: async (context, message) =\u003e {\n    console.log(`Email sent: ${context.type} to ${message.to}`);\n  },\n  onSendError: async (context, message, error) =\u003e {\n    console.error(`Email failed: ${context.type} to ${message.to}`, error);\n  },\n});\n\nexport const auth = betterAuth({\n  // ...your database, session, social providers config...\n  emailAndPassword: {\n    enabled: true,\n  },\n  emailVerification: {\n    sendOnSignUp: true,\n  },\n  user: {\n    changeEmail: {\n      enabled: true,\n      sendChangeEmailVerification: email.helpers.changeEmail,\n    },\n    deleteUser: {\n      enabled: true,\n      sendDeleteAccountVerification: email.helpers.deleteAccount,\n    },\n  },\n  plugins: [\n    email,\n    twoFactor({\n      sendOTP: email.helpers.twoFactor,\n    }),\n    organization({\n      sendInvitationEmail: email.helpers.invitation,\n    }),\n    magicLink({\n      sendMagicLink: email.helpers.magicLink,\n    }),\n    emailOTP({\n      sendVerificationOTP: email.helpers.otp,\n    }),\n  ],\n});\n```\n\n### 5. Export the auth handler (Next.js 16 App Router)\n\n```typescript\n// app/api/auth/[...all]/route.ts\nimport { auth } from '@/lib/auth';\nimport { toNextJsHandler } from 'better-auth/next-js';\n\nexport const { GET, POST } = toNextJsHandler(auth.handler);\n```\n\n## Providers\n\nProviders handle **delivery only**. They receive a ready-to-send message (`to`, `subject`, `html`, `text`, `tags`) and deliver it through a service. They know nothing about templates or rendering.\n\nAll providers implement the `EmailProvider` interface:\n\n```typescript\ninterface EmailProvider {\n  send(message: EmailMessage): Promise\u003cvoid\u003e;\n}\n```\n\nSwitching provider never requires changes to your templates. You can use a built-in provider or create your own by implementing the interface.\n\n### NuntlyProvider (default)\n\nSends emails via the Nuntly REST API. No external dependencies.\n\n```typescript\nimport { NuntlyProvider } from '@repo/better-email';\n\nconst provider = new NuntlyProvider({\n  apiKey: process.env.NUNTLY_API_KEY!,\n  from: 'noreply@yourdomain.com',\n  // Optional: defaults to https://api.nuntly.com\n  baseUrl: 'https://api.nuntly.com',\n});\n```\n\n| Option    | Type     | Required | Description                                         |\n| --------- | -------- | -------- | --------------------------------------------------- |\n| `apiKey`  | `string` | Yes      | Your Nuntly API key.                                |\n| `from`    | `string` | Yes      | Sender email address.                               |\n| `baseUrl` | `string` | No       | API base URL. Defaults to `https://api.nuntly.com`. |\n\n### SESProvider\n\nSends emails via AWS SES v2. Requires `@aws-sdk/client-sesv2`. The provider handles building the full SES payload internally.\n\n```typescript\nimport { SESProvider } from '@repo/better-email';\nimport { SESv2Client, SendEmailCommand } from '@aws-sdk/client-sesv2';\n\nconst provider = new SESProvider({\n  client: new SESv2Client({ region: 'us-east-1' }),\n  SendEmailCommand,\n  from: 'noreply@yourdomain.com',\n  configurationSetName: 'my-config-set', // optional\n});\n```\n\n| Option                 | Type          | Required | Description                                                |\n| ---------------------- | ------------- | -------- | ---------------------------------------------------------- |\n| `client`               | `SESv2Client` | Yes      | An `SESv2Client` instance from `@aws-sdk/client-sesv2`.    |\n| `SendEmailCommand`     | `class`       | Yes      | The `SendEmailCommand` class from `@aws-sdk/client-sesv2`. |\n| `from`                 | `string`      | Yes      | Sender email address.                                      |\n| `configurationSetName` | `string`      | No       | SES configuration set name for tracking.                   |\n\n### ResendProvider\n\nSends emails via the Resend REST API. No external dependencies.\n\n```typescript\nimport { ResendProvider } from '@repo/better-email';\n\nconst provider = new ResendProvider({\n  apiKey: process.env.RESEND_API_KEY!,\n  from: 'noreply@yourdomain.com',\n});\n```\n\n| Option    | Type     | Required | Description                                         |\n| --------- | -------- | -------- | --------------------------------------------------- |\n| `apiKey`  | `string` | Yes      | Your Resend API key.                                |\n| `from`    | `string` | Yes      | Sender email address.                               |\n| `baseUrl` | `string` | No       | API base URL. Defaults to `https://api.resend.com`. |\n\n### PostmarkProvider\n\nSends emails via the Postmark REST API. No external dependencies.\n\n```typescript\nimport { PostmarkProvider } from '@repo/better-email';\n\nconst provider = new PostmarkProvider({\n  serverToken: process.env.POSTMARK_SERVER_TOKEN!,\n  from: 'noreply@yourdomain.com',\n  messageStream: 'outbound', // optional\n});\n```\n\n| Option          | Type     | Required | Description                                              |\n| --------------- | -------- | -------- | -------------------------------------------------------- |\n| `serverToken`   | `string` | Yes      | Your Postmark server token.                              |\n| `from`          | `string` | Yes      | Sender email address.                                    |\n| `messageStream` | `string` | No       | Postmark message stream.                                 |\n| `baseUrl`       | `string` | No       | API base URL. Defaults to `https://api.postmarkapp.com`. |\n\n### MailgunProvider\n\nSends emails via the Mailgun REST API. No external dependencies.\n\n```typescript\nimport { MailgunProvider } from '@repo/better-email';\n\nconst provider = new MailgunProvider({\n  apiKey: process.env.MAILGUN_API_KEY!,\n  domain: 'mg.yourdomain.com',\n  from: 'noreply@yourdomain.com',\n  // Optional: use EU region\n  baseUrl: 'https://api.eu.mailgun.net',\n});\n```\n\n| Option    | Type     | Required | Description                                          |\n| --------- | -------- | -------- | ---------------------------------------------------- |\n| `apiKey`  | `string` | Yes      | Your Mailgun API key.                                |\n| `domain`  | `string` | Yes      | Your Mailgun sending domain.                         |\n| `from`    | `string` | Yes      | Sender email address.                                |\n| `baseUrl` | `string` | No       | API base URL. Defaults to `https://api.mailgun.net`. |\n\n### SMTPProvider\n\nSends emails via SMTP using a nodemailer transporter. Requires `nodemailer`. The provider handles message formatting internally.\n\n```typescript\nimport { SMTPProvider } from '@repo/better-email';\nimport nodemailer from 'nodemailer';\n\nconst provider = new SMTPProvider({\n  transporter: nodemailer.createTransport({\n    host: 'smtp.example.com',\n    port: 587,\n    auth: { user: 'user', pass: 'pass' },\n  }),\n  from: 'noreply@yourdomain.com',\n});\n```\n\n| Option        | Type                   | Required | Description                                       |\n| ------------- | ---------------------- | -------- | ------------------------------------------------- |\n| `transporter` | nodemailer transporter | Yes      | A pre-configured nodemailer transporter instance. |\n| `from`        | `string`               | Yes      | Sender email address.                             |\n\n### ConsoleProvider\n\nLogs emails to the console instead of sending them. Useful for development and testing.\n\n```typescript\nimport { ConsoleProvider } from '@repo/better-email';\n\nconst provider = new ConsoleProvider();\n```\n\n### Custom provider\n\nImplement the `EmailProvider` interface for any email service:\n\n```typescript\nimport type { EmailProvider, EmailMessage } from '@repo/better-email';\n\nconst customProvider: EmailProvider = {\n  async send(message: EmailMessage) {\n    await yourEmailApi.send({\n      to: message.to,\n      subject: message.subject,\n      html: message.html,\n      text: message.text,\n    });\n  },\n};\n```\n\n## Template renderers\n\nRenderers handle **HTML/text generation only**. They receive a typed context (user, url, token, etc.) and produce `{ subject, html, text }`. They know nothing about how the email is delivered.\n\nAll renderers implement the `EmailTemplateRenderer` interface:\n\n```typescript\ninterface EmailTemplateRenderer {\n  render(context: EmailContext): Promise\u003cRenderedEmail\u003e;\n}\n```\n\nSwitching renderer never requires changes to your transport config. The `render` method receives a discriminated union (`EmailContext`) where you switch on `context.type` to access type-specific fields.\n\n### DefaultTemplateRenderer\n\nRenders minimal plain HTML for all 8 email types. No dependencies required. Useful for prototyping and testing.\n\n```typescript\nimport { DefaultTemplateRenderer } from '@repo/better-email';\n\nconst renderer = new DefaultTemplateRenderer();\n```\n\n### ReactMJMLRenderer\n\nRenders templates built with [React MJML](https://github.com/Faire/mjml-react) components. Requires `react` and `@faire/mjml-react`.\n\n**Automatic plain text generation:** The renderer automatically converts the HTML output to plain text, preserving links, line breaks, and basic formatting.\n\n```typescript\nimport { ReactMJMLRenderer } from '@repo/better-email';\nimport { render } from '@faire/mjml-react';\nimport { createElement } from 'react';\nimport VerificationEmail from './emails/verification-mjml';\nimport ResetPasswordEmail from './emails/reset-password-mjml';\n\nconst renderer = new ReactMJMLRenderer({\n  render: (element) =\u003e render(element),\n  createElement,\n  templates: {\n    'verification-email': VerificationEmail,\n    'reset-password': ResetPasswordEmail,\n  },\n  subjects: {\n    'verification-email': 'Verify your email',\n    'reset-password': 'Reset your password',\n  },\n});\n```\n\nEach template component uses `@faire/mjml-react` MJML components and receives the typed context as props:\n\n```tsx\n// emails/verification-mjml.tsx\nimport { Mjml, MjmlBody, MjmlSection, MjmlColumn, MjmlText } from '@faire/mjml-react';\nimport type { EmailProps } from '@repo/better-email';\n\nexport default function VerificationEmail({ user, url }: EmailProps\u003c'verification-email'\u003e) {\n  return (\n    \u003cMjml\u003e\n      \u003cMjmlBody\u003e\n        \u003cMjmlSection\u003e\n          \u003cMjmlColumn\u003e\n            \u003cMjmlText\u003eHi {user.name},\u003c/MjmlText\u003e\n            \u003cMjmlText\u003e\n              Click \u003ca href={url}\u003ehere\u003c/a\u003e to verify your email.\n            \u003c/MjmlText\u003e\n          \u003c/MjmlColumn\u003e\n        \u003c/MjmlSection\u003e\n      \u003c/MjmlBody\u003e\n    \u003c/Mjml\u003e\n  );\n}\n```\n\n| Option          | Type                                             | Required | Description                                     |\n| --------------- | ------------------------------------------------ | -------- | ----------------------------------------------- |\n| `render`        | `(element) =\u003e { html: string; errors: any[] }`   | Yes      | The `render` function from `@faire/mjml-react`. |\n| `createElement` | `(component, props) =\u003e any`                      | Yes      | `React.createElement`.                          |\n| `templates`     | `Partial\u003cRecord\u003cEmailType, Component\u003e\u003e`          | Yes      | Map of email type to React MJML component.      |\n| `subjects`      | `Partial\u003cRecord\u003cEmailType, string \\| Function\u003e\u003e` | Yes      | Map of email type to subject line or function.  |\n| `fallback`      | `EmailTemplateRenderer`                          | No       | Fallback renderer for missing templates.        |\n\n### ReactEmailRenderer\n\nRenders templates built with [React Email](https://react.email) components. Requires `react` and `@react-email/render`.\n\n**Automatic plain text generation:** The renderer automatically generates plain text versions of emails using `{ plainText: true }` option. You can optionally provide a custom `renderPlainText` function for more control.\n\n```typescript\nimport { ReactEmailRenderer } from '@repo/better-email';\nimport { render } from '@react-email/render';\nimport { createElement } from 'react';\nimport VerificationEmail from './emails/verification';\nimport ResetPasswordEmail from './emails/reset-password';\n\nconst renderer = new ReactEmailRenderer({\n  render,\n  // Optional: Custom plain text renderer\n  // renderPlainText: (element) =\u003e render(element, { plainText: true }),\n  createElement,\n  templates: {\n    'verification-email': VerificationEmail,\n    'reset-password': ResetPasswordEmail,\n  },\n  subjects: {\n    'verification-email': 'Verify your email',\n    'reset-password': 'Reset your password',\n  },\n});\n```\n\nEach template component receives the typed context as props. Use the `EmailProps\u003cT\u003e` utility type to get the props for a given email type (strips the `type` discriminator automatically):\n\n```tsx\n// emails/verification.tsx\nimport { Html, Head, Body, Text, Link } from '@react-email/components';\nimport type { EmailProps } from '@repo/better-email';\n\nexport default function VerificationEmail({ user, url }: EmailProps\u003c'verification-email'\u003e) {\n  return (\n    \u003cHtml\u003e\n      \u003cHead /\u003e\n      \u003cBody\u003e\n        \u003cText\u003eHi {user.name},\u003c/Text\u003e\n        \u003cText\u003eClick the link below to verify your email:\u003c/Text\u003e\n        \u003cLink href={url}\u003eVerify email\u003c/Link\u003e\n      \u003c/Body\u003e\n    \u003c/Html\u003e\n  );\n}\n```\n\n| Option            | Type                                             | Required | Description                                                                     |\n| ----------------- | ------------------------------------------------ | -------- | ------------------------------------------------------------------------------- |\n| `render`          | `(element, options?) =\u003e Promise\u003cstring\u003e`         | Yes      | The `render` function from `@react-email/render`.                               |\n| `createElement`   | `(component, props) =\u003e any`                      | Yes      | `React.createElement`.                                                          |\n| `templates`       | `Partial\u003cRecord\u003cEmailType, Component\u003e\u003e`          | Yes      | Map of email type to React component.                                           |\n| `subjects`        | `Partial\u003cRecord\u003cEmailType, string \\| Function\u003e\u003e` | Yes      | Map of email type to subject line or function.                                  |\n| `renderPlainText` | `(element) =\u003e Promise\u003cstring\u003e \\| string`         | No       | Custom plain text renderer. Defaults to `render(element, { plainText: true })`. |\n| `fallback`        | `EmailTemplateRenderer`                          | No       | Fallback renderer for missing templates.                                        |\n\n### MJMLRenderer\n\nRenders templates written in [MJML](https://mjml.io) markup. Requires the `mjml` package.\n\n```typescript\nimport { MJMLRenderer } from '@repo/better-email';\nimport mjml2html from 'mjml';\n\nconst renderer = new MJMLRenderer({\n  compile: (mjmlString) =\u003e mjml2html(mjmlString).html,\n  templates: {\n    'verification-email': (ctx) =\u003e ({\n      subject: 'Verify your email',\n      mjml: `\n        \u003cmjml\u003e\n          \u003cmj-body\u003e\n            \u003cmj-section\u003e\n              \u003cmj-column\u003e\n                \u003cmj-text\u003e\n                  Click \u003ca href=\"${ctx.url}\"\u003ehere\u003c/a\u003e to verify your email.\n                \u003c/mj-text\u003e\n              \u003c/mj-column\u003e\n            \u003c/mj-section\u003e\n          \u003c/mj-body\u003e\n        \u003c/mjml\u003e\n      `,\n      text: `Verify your email: ${ctx.url}`,\n    }),\n    'reset-password': (ctx) =\u003e ({\n      subject: 'Reset your password',\n      mjml: `\n        \u003cmjml\u003e\n          \u003cmj-body\u003e\n            \u003cmj-section\u003e\n              \u003cmj-column\u003e\n                \u003cmj-text\u003e\n                  Click \u003ca href=\"${ctx.url}\"\u003ehere\u003c/a\u003e to reset your password.\n                \u003c/mj-text\u003e\n              \u003c/mj-column\u003e\n            \u003c/mj-section\u003e\n          \u003c/mj-body\u003e\n        \u003c/mjml\u003e\n      `,\n      text: `Reset your password: ${ctx.url}`,\n    }),\n  },\n});\n```\n\nEach template function receives the typed `EmailContext` and returns `{ subject, mjml, text }`.\n\n**Loading templates from files:**\n\nSince MJML templates are plain strings (not JavaScript template literals), you need a templating engine to inject dynamic values. Use Mustache syntax in your MJML files:\n\n```typescript\nimport { readFileSync } from 'fs';\nimport { join } from 'path';\nimport Mustache from 'mustache';\n\nconst loadTemplate = (filename: string) =\u003e readFileSync(join(__dirname, 'templates', filename), 'utf-8');\n\nconst renderer = new MJMLRenderer({\n  compile: (mjmlString) =\u003e mjml2html(mjmlString).html,\n  templates: {\n    'verification-email': (ctx) =\u003e {\n      // Template file uses Mustache syntax: \u003ca href=\"{{url}}\"\u003e\n      const mjmlTemplate = loadTemplate('verification.mjml');\n      const mjmlWithData = Mustache.render(mjmlTemplate, ctx);\n\n      return {\n        subject: 'Verify your email',\n        mjml: mjmlWithData,\n        text: `Verify your email: ${ctx.url}`,\n      };\n    },\n  },\n});\n```\n\n**templates/verification.mjml:**\n```xml\n\u003cmjml\u003e\n  \u003cmj-body\u003e\n    \u003cmj-section\u003e\n      \u003cmj-column\u003e\n        \u003cmj-text\u003e\n          Click \u003ca href=\"{{url}}\"\u003ehere\u003c/a\u003e to verify your email.\n        \u003c/mj-text\u003e\n      \u003c/mj-column\u003e\n    \u003c/mj-section\u003e\n  \u003c/mj-body\u003e\n\u003c/mjml\u003e\n```\n\n| Option      | Type                                   | Required | Description                                                                 |\n| ----------- | -------------------------------------- | -------- | --------------------------------------------------------------------------- |\n| `compile`   | `(mjml: string) =\u003e string`             | Yes      | Compiles MJML to HTML. Wraps `mjml2html(...).html`.                         |\n| `templates` | `Partial\u003cRecord\u003cEmailType, Function\u003e\u003e` | Yes      | Map of email type to template function returning `{ subject, mjml, text }`. |\n| `fallback`  | `EmailTemplateRenderer`                | No       | Fallback renderer for missing templates.                                    |\n\n### MustacheRenderer\n\nRenders templates using [Mustache](https://mustache.github.io/) templating syntax. Requires the `mustache` package.\n\n```typescript\nimport { MustacheRenderer } from '@repo/better-email';\nimport Mustache from 'mustache';\n\nconst renderer = new MustacheRenderer({\n  render: (template, data) =\u003e Mustache.render(template, data),\n  templates: {\n    'verification-email': (ctx) =\u003e ({\n      subject: 'Verify your email',\n      template: `\n        \u003chtml\u003e\n          \u003cbody\u003e\n            \u003cp\u003eClick \u003ca href=\"{{url}}\"\u003ehere\u003c/a\u003e to verify your email.\u003c/p\u003e\n          \u003c/body\u003e\n        \u003c/html\u003e\n      `,\n      text: `Verify your email: ${ctx.url}`,\n    }),\n    'reset-password': (ctx) =\u003e ({\n      subject: 'Reset your password',\n      template: `\n        \u003chtml\u003e\n          \u003cbody\u003e\n            \u003cp\u003eClick \u003ca href=\"{{url}}\"\u003ehere\u003c/a\u003e to reset your password.\u003c/p\u003e\n          \u003c/body\u003e\n        \u003c/html\u003e\n      `,\n      text: `Reset your password: ${ctx.url}`,\n    }),\n  },\n});\n```\n\nEach template function receives the typed `EmailContext` and returns `{ subject, template, text }`. The template string uses Mustache syntax (`{{variable}}`), and the full context is passed as data to `Mustache.render`.\n\n**Loading templates from files:**\n\n```typescript\nimport { readFileSync } from 'fs';\nimport { join } from 'path';\n\nconst loadTemplate = (filename: string) =\u003e readFileSync(join(__dirname, 'templates', filename), 'utf-8');\n\nconst renderer = new MustacheRenderer({\n  render: (template, data) =\u003e Mustache.render(template, data),\n  templates: {\n    'verification-email': (ctx) =\u003e ({\n      subject: 'Verify your email',\n      template: loadTemplate('verification.mustache'), // {{url}} will be replaced automatically\n      text: `Verify your email: ${ctx.url}`,\n    }),\n  },\n});\n```\n\n**templates/verification.mustache:**\n```html\n\u003chtml\u003e\n  \u003cbody\u003e\n    \u003cp\u003eHi {{user.name}},\u003c/p\u003e\n    \u003cp\u003eClick \u003ca href=\"{{url}}\"\u003ehere\u003c/a\u003e to verify your email.\u003c/p\u003e\n  \u003c/body\u003e\n\u003c/html\u003e\n```\n\nThe `MustacheRenderer` automatically passes the full context to `Mustache.render()`, so all fields (url, user.name, etc.) are available in your template.\n\n| Option      | Type                                                          | Required | Description                                                                     |\n| ----------- | ------------------------------------------------------------- | -------- | ------------------------------------------------------------------------------- |\n| `render`    | `(template: string, data: Record\u003cstring, unknown\u003e) =\u003e string` | Yes      | Mustache render function. Wraps `Mustache.render(template, data)`.              |\n| `templates` | `Partial\u003cRecord\u003cEmailType, Function\u003e\u003e`                        | Yes      | Map of email type to template function returning `{ subject, template, text }`. |\n| `fallback`  | `EmailTemplateRenderer`                                       | No       | Fallback renderer for missing templates.                                        |\n\n### Custom renderer\n\nImplement the `EmailTemplateRenderer` interface:\n\n```typescript\nimport type { EmailTemplateRenderer, EmailContext, RenderedEmail } from '@repo/better-email';\n\nconst customRenderer: EmailTemplateRenderer = {\n  async render(context: EmailContext): Promise\u003cRenderedEmail\u003e {\n    switch (context.type) {\n      case 'verification-email':\n        return {\n          subject: 'Verify your email',\n          html: `\u003cp\u003eVerify: \u003ca href=\"${context.url}\"\u003e${context.url}\u003c/a\u003e\u003c/p\u003e`,\n          text: `Verify: ${context.url}`,\n        };\n      // handle other types...\n      default: {\n        const _exhaustive: never = context;\n        throw new Error(`Unhandled email type: ${(_exhaustive as EmailContext).type}`);\n      }\n    }\n  },\n};\n```\n\nThe `never` check ensures TypeScript reports a compile-time error if a new email type is added to `EmailContext` without being handled in your renderer.\n\n### Combining renderers with fallback\n\nAll built-in renderers accept an optional `fallback` renderer. If a template is not found for an email type, the fallback is used instead. This lets you use React Email for your main templates while the `DefaultTemplateRenderer` covers any types you haven't customized yet:\n\n```typescript\nimport { ReactEmailRenderer, DefaultTemplateRenderer } from '@repo/better-email';\n\nconst renderer = new ReactEmailRenderer({\n  render: (element, options) =\u003e render(element, options),\n  createElement,\n  templates: {\n    'verification-email': VerificationEmail,\n    'reset-password': ResetPasswordEmail,\n  },\n  subjects: {\n    'verification-email': 'Verify your email',\n    'reset-password': 'Reset your password',\n  },\n  fallback: new DefaultTemplateRenderer(),\n});\n```\n\n## How it works\n\n### `init()` and `defu` semantics\n\nBetter Auth merges plugin `init()` options with user options via `defu(userOptions, pluginOptions)`. This means the plugin's callbacks act as **defaults**: if you provide your own `sendVerificationEmail` or `sendResetPassword`, your callback wins.\n\n### Core callbacks (via `init()`)\n\nThe plugin automatically provides defaults for:\n\n| Callback                | Better Auth option path                   |\n| ----------------------- | ----------------------------------------- |\n| `sendVerificationEmail` | `emailVerification.sendVerificationEmail` |\n| `sendResetPassword`     | `emailAndPassword.sendResetPassword`      |\n\n### Helpers (for plugin-level callbacks)\n\nThe plugin exposes pre-configured helpers via `email.helpers.*`. Each helper is a callback matching the signature its target plugin expects:\n\n| Helper                  | Plugin         | Plugin option                                   |\n| ----------------------- | -------------- | ----------------------------------------------- |\n| `helpers.changeEmail`   | core           | `user.changeEmail.sendChangeEmailVerification`  |\n| `helpers.deleteAccount` | core           | `user.deleteUser.sendDeleteAccountVerification` |\n| `helpers.magicLink`     | `magicLink`    | `sendMagicLink`                                 |\n| `helpers.otp`           | `emailOTP`     | `sendVerificationOTP`                           |\n| `helpers.invitation`    | `organization` | `sendInvitationEmail`                           |\n| `helpers.twoFactor`     | `twoFactor`    | `sendOTP`                                       |\n\nThe standalone factory functions (`betterEmailMagicLink`, `betterEmailOTP`, etc.) are still exported for cases where you need to create helpers with different options.\n\n### Email flow\n\nFor every email (both core defaults and helpers), the flow is:\n\n1. `templateRenderer.render(context)` produces `{ subject, html, text }`\n2. Tags are merged: `[...defaultTags, ...perTypeTags, { name: 'type', value: context.type }]`\n3. `onBeforeSend(context, message)` is called (return `false` to skip sending)\n4. `transport.send(message)` delivers the email\n5. `onAfterSend(context, message)` or `onSendError(context, message, error)` is called\n\n## Email types\n\nThe `EmailContext` discriminated union covers 8 email types. Each type has a corresponding exported interface:\n\n| Type                          | Context interface                  | Key fields                                       |\n| ----------------------------- | ---------------------------------- | ------------------------------------------------ |\n| `verification-email`          | `VerificationEmailContext`         | `user`, `url`, `token`                           |\n| `reset-password`              | `ResetPasswordContext`             | `user`, `url`, `token`                           |\n| `change-email-verification`   | `ChangeEmailVerificationContext`   | `user`, `newEmail`, `url`, `token`               |\n| `delete-account-verification` | `DeleteAccountVerificationContext` | `user`, `url`, `token`                           |\n| `magic-link`                  | `MagicLinkContext`                 | `email`, `url`, `token`                          |\n| `verification-otp`            | `VerificationOTPContext`           | `email`, `otp`, `otpType`                        |\n| `organization-invitation`     | `OrganizationInvitationContext`    | `email`, `organization`, `inviter`, `invitation` |\n| `two-factor-otp`              | `TwoFactorOTPContext`              | `user`, `otp`                                    |\n\n### Utility types\n\nTwo utility types simplify working with email contexts:\n\n- **`EmailContextFor\u003cT\u003e`** extracts the full context interface for a given email type from the `EmailContext` union:\n\n  ```typescript\n  type EmailContextFor\u003c'verification-email'\u003e // =\u003e VerificationEmailContext\n  ```\n\n- **`EmailProps\u003cT\u003e`** strips the `type` discriminator, giving you just the data fields. Use this to type template props and callback data:\n  ```typescript\n  type EmailProps\u003c'verification-email'\u003e // =\u003e { user: User; url: string; token: string }\n  ```\n\n## License\n\nMIT License - see [LICENSE](LICENSE) for details.\n\nCopyright (c) 2026 Nuntly\n","funding_links":[],"categories":[],"sub_categories":[],"project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fnuntly%2Fbetter-email","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fnuntly%2Fbetter-email","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fnuntly%2Fbetter-email/lists"}