---
title: "Ring Mailer & RingdomX Mail"
description: "First-party Auth SMTP (OTP, magic link, password reset) via lib/mailer.ts — separate from CRM channel SMTP; founder RingdomX Mail (hosted MX or BYO)"
locale: "en"
---
# Ring Mailer & RingdomX Mail

> **Info**
> Use **Founder** / **Developer** tabs in the docs sidebar to filter this page. Shared sections apply to both audiences.

Ring sends **login mail** through **Ring Mailer** — Nodemailer in `lib/mailer.ts` (`SMTP_*` / `EMAIL_MODE`). Passwordless sign-in uses **OTP** or **magic links** (`/verify#token=…`) with hashed tokens in Postgres (`email_login_tokens`). There is **no Resend** provider. **CRM replies** use a **different** plane: per-channel SMTP via `EmailSenderService` + `ring-config.emailCrm.channels` — see [Email AI-CRM](/docs/features/email-ai-crm.md).

  
- **[Authentication feature](/docs/features/authentication.md)** — Auth.js session model, OAuth, wallets, vitals onboarding.

  
- **[Auth architecture](/docs/architecture/authentication.md)** — Providers in `auth.ts`, adapters, JWT sessions.

  
- **[Email AI-CRM](/docs/features/email-ai-crm.md)** — Public inbox — CRM channel SMTP, not Auth SMTP_*.

  
- **[Environment](/docs/deployment/environment.md)** — `SMTP_*`, `EMAIL_MODE`, `OTP_HMAC_SECRET` reference.

## What ships

| Surface | Role |
|---------|------|
| **Auth transport** | `lib/mailer.ts` — pooled Nodemailer; `sendMail()` |
| **CRM outbound** | `features/email-crm/pipeline/smtp/email-sender.ts` — **per-channel** SMTP (not Auth pool) |
| **Auth actions** | `app/_actions/auth-email-actions.ts` — OTP, magic link, forgot/reset |
| **Credentials** | Auth.js `email-otp`, `email-magic`, `credentials` (password) in `auth.ts` |
| **UI** | Login email form; `/verify`, `/forgot-password`, `/reset-password` |
| **Templates** | `emails/OTPEmail.tsx`, `emails/MagicLinkEmail.tsx` (`@react-email`) |
| **Tokens** | `lib/auth/email-tokens.ts` + `features/auth/services/email-login-tokens.ts` |
| **Cleanup cron** | `POST/GET /api/cron/cleanup-email-tokens` — **fail-closed** `CRON_SECRET` |
| **Migration** | `data/migrations/038_email_login_tokens.sql` |

Auth.js still owns sessions (`auth()`, `useSession`, `/api/auth/*`). Ring Mailer only delivers **auth** mail and feeds Credentials providers.

Do **not** point Auth `SMTP_*` at the CRM `info@` mailbox for OTP if you can avoid it. ring-platform.org prod Auth uses `mail.subiworx.com` / `noreply@ring-platform.org`; CRM primary uses `mail.ringdom.org` / `info@ringdom.org`. See `RING-MAILER-OPS.md` and `EMAIL-CRM-OPS.md`.

```mermaid
sequenceDiagram
    participant U as User
    participant A as auth-email-actions
    participant M as lib/mailer sendMail
    participant DB as email_login_tokens
    participant Auth as Auth.js Credentials

    U->>A: requestLoginCode / magic link
    A->>DB: insert hashed token
    A->>M: SMTP or Ethereal
    M-->>U: OTP email or /verify#token
    U->>Auth: signIn email-otp or email-magic
    Auth->>DB: consume token
    Auth-->>U: JWT session cookie
```

### For founders

## Why own auth mail instead of a SaaS key

Third-party magic-link SaaS ties every clone to an external API key and monthly cost. Ring Mailer keeps **login mail on your domain** (or Ringdom-hosted MX) so members see `noreply@your-domain`, SPF/DKIM stay under your control, and white-label clones do not share a marketplace Resend account.

### RingdomX Mail (calculator)

In the project calculator, external add-on **`mail`** is labeled **RingdomX Mail**:

| Mode | What you get |
|------|----------------|
| **Hosted (`MAIL_MODE=ringdom`)** | Clone MX/SPF/DKIM toward Ringdom mail (`mail.ringdom.org` path). Credentials live in satellite owner secrets. |
| **Bring your own (`MAIL_MODE=byo`)** | You set Auth `SMTP_*` in Owner Secrets. Configure CRM channels separately in `emailCrm` / `CRM_CHANNEL_*`. |

Pricing and preset ids: `features/calculator/presets/project.ts` external id `mail`; copy in `locales/*/calculator.json`.

  
  
  

### Operator checklist

Decide **RingdomX hosted** vs **BYO SMTP** for **Auth**. Provision CRM channels independently (see [Email AI-CRM](/docs/features/email-ai-crm.md)).

Ensure migration `038_email_login_tokens.sql` is applied on the clone Postgres.

Smoke-test: request an OTP from `/login`, confirm delivery (or Ethereal preview in logs), complete sign-in. Approve one CRM draft separately to confirm channel SMTP.

### For developers

## Local development (Auth SMTP or Ethereal)

No Resend account. Pick one for **login mail**:

| Mode | Env | Behavior |
|------|-----|----------|
| **Ethereal (default-friendly)** | `EMAIL_MODE=ethereal` | Nodemailer test account; preview URL logged as `[RingMailer] Ethereal preview` |
| **Real SMTP** | `SMTP_HOST`, `SMTP_USER`, `SMTP_PASSWORD` (or `SMTP_PASS`), optional `SMTP_PORT` / `SMTP_SECURE` / `SMTP_FROM` | Auth sends through this server |

Also set `OTP_HMAC_SECRET` (≥32-byte secret) or rely on `AUTH_SECRET` (min 16 chars) for token hashing.

{`# EMAIL_MODE=ethereal
# OTP_HMAC_SECRET=generate-a-32-byte-hex-secret

# SMTP_HOST=mail.example.com
# SMTP_PORT=587
# SMTP_SECURE=false
# SMTP_USER=noreply@example.com
# SMTP_PASSWORD=
# SMTP_FROM=Ring Platform <noreply@example.com>
# Alias accepted: SMTP_PASS=`}

`isRingMailerConfigured()` is true when `EMAIL_MODE=ethereal`, Ethereal user/pass are set, or host+user+password are present.

### Token cleanup cron

`/api/cron/cleanup-email-tokens` deletes expired `email_login_tokens` (plus grace). **Fail-closed**: missing or mismatched `CRON_SECRET` → `401` (aligned with `email-processor`).

### Migration

{`psql "$DATABASE_URL" -f data/migrations/038_email_login_tokens.sql`}

Ops notes: `data/migrations/RING-MAILER-OPS.md` (Auth host = `mail.subiworx.com` for ring-platform.org prod).

### Auth flows (verified)

| Flow | Action / route | Credentials id |
|------|----------------|----------------|
| OTP sign-in | `requestLoginCode` → UI → `signIn('email-otp')` | `email-otp` |
| Magic link | `requestMagicLink` → `/verify#token=…` → `signIn('email-magic')` | `email-magic` |
| Email verify | same magic consume with `email_verify` flow | `email-magic` |
| Forgot password | `requestPasswordReset` → `/reset-password#token=…` | consume + set password |
| Password login | `signIn('credentials')` | `credentials` |

Pages: `app/[locale]/verify/page.tsx`, `forgot-password`, `reset-password`. UI: `features/auth/components/email-login-form.tsx`. Password **register** UI is demoted — `/register` redirects to `/login`.

### Deprecated — do not use

| Removed | Replacement |
|---------|-------------|
| `AUTH_RESEND_KEY` / `AUTH_RESEND_FROM` | `SMTP_*` / `EMAIL_MODE` / `SMTP_FROM` |
| `next-auth/providers/resend` | Credentials `email-otp` / `email-magic` + `lib/mailer.ts` |
| `signIn('resend')` | `requestLoginCode` / magic link → Credentials |
| CRM replies via `lib/mailer.ts` | `EmailSenderService` + `CRM_CHANNEL_*` |

### Code map

| Path | Role |
|------|------|
| `lib/mailer.ts` | Auth transport SSOT |
| `app/_actions/auth-email-actions.ts` | Server Actions for request/send |
| `lib/auth/email-tokens.ts` | OTP / HMAC / magic URL helpers |
| `features/auth/services/email-login-tokens.ts` | Postgres token CRUD + rate limit |
| `app/api/cron/cleanup-email-tokens/route.ts` | Fail-closed token cleanup |
| `auth.ts` | Credentials providers |
| `emails/*.tsx` | React Email templates |

## Related documentation

  
- [features/authentication](/docs/features/authentication.md) — Next-step: full Auth.js provider surface, vitals gate, and /register → /login.

  
- [features/email-ai-crm](/docs/features/email-ai-crm.md) — Depends-on: CRM replies use channel SMTP — keep Auth SMTP_* separate.

  
- [examples/authentication](/docs/examples/authentication.md) — Same-workflow: integrator patterns for OAuth + Ring Mailer.

  
- [architecture/authentication](/docs/architecture/authentication.md) — Deep-dive: Auth.js file split and JWT callbacks.

  
- [deployment/environment](/docs/deployment/environment.md) — See-also: full SMTP and EMAIL_MODE env reference.
