---
title: "Magic-link mail. Brevo SMTP, sender reputation, the +tag trap | Recipe — StudioMeyer Academy"
description: "Passwordless auth via one-time email links. The DKIM/SPF/DMARC setup that prevents Yahoo bounces, the token TTL pattern, the +tag email gotcha, and the deliverability checklist."
canonical: "https://studiomeyer.academy/en/recipes/10.2-magic-link-mail"
language: en
markdown_versions: ["https://studiomeyer.academy/en/recipes/10.2-magic-link-mail.md"]
publisher: "StudioMeyer Academy, https://studiomeyer.academy (llms.txt: https://studiomeyer.academy/llms.txt)"
---

[← Alle Recipes](https://studiomeyer.academy/recipes)

Phase 10 · Multi-Tenant SaaS·7 steps

# Magic-link mail. Brevo SMTP, sender reputation, the +tag trap

Passwordless auth via one-time email links. The DKIM/SPF/DMARC setup that prevents Yahoo bounces, the token TTL pattern, the +tag email gotcha, and the deliverability checklist.

7 steps0%

Du liest ohne Account. Mit [Login](https://studiomeyer.academy/login?next=/recipes/10.2-magic-link-mail) speichern wir Step-Fortschritt + Notes.

# Magic-link mail. Brevo SMTP, sender reputation, the +tag trap

Magic-link auth is the cheapest production-grade auth: no password storage, no password-reset flows, no breach exposure. The catch is email, one bad sender setup and your delivery rate drops to 60%, half your signups silently fail. This recipe is the production setup that survives Yahoo, Outlook, and GMX.

### Step 1: Pick an SMTP provider with a free tier

Four options that work for solo SaaS:

| Provider | Free tier | DKIM/DMARC | Notes | |---|---|---|---| | **Brevo** (ex-Sendinblue) | 300/day forever | Yes | EU-based, good for DACH compliance. Shared-IP pool, deliverability varies. | | **Resend** | 3k/mo, 100/day | Yes | Modern DX, React Email templates, good docs. US-based. | | **Postmark** | 14-day trial only ($15/mo for 10k after) | Yes | Best transactional deliverability in the industry. Transactional-only, they refuse marketing mail. | | **Mailgun** | Pay-as-you-go ($35/50k) | Yes | Mature deliverability, less-friendly DX. |

**Pragmatic default for a solo SaaS:**

- **Brevo** if you're in the EU and price-sensitive, €0/month for 300 emails/day is hard to beat.
- **Resend** if you're shipping fast and want better DX (typed SDK, React Email).
- **Postmark** if you've already had deliverability problems and need to fix them, their inbox rate consistently beats the alternatives. The €15/mo is cheap insurance for a SaaS with paying users.

If you start with Brevo and notice your magic-links bouncing more than 1% (check the Brevo dashboard weekly), the upgrade path is Resend → Postmark. The DNS setup in Step 2 transfers across all three.

### Step 2: DNS setup, the only step that determines deliverability

Yahoo, Gmail, Outlook, and most large providers require DKIM + SPF + DMARC. Without them, your magic-links bounce silently. With them, your delivery rate stays above 95%.

For Brevo, you'll add three DNS records to your domain (e.g., `studiomeyer.io`):

```
Type   Name                            Value
TXT    studiomeyer.io                  "v=spf1 include:spf.brevo.com ~all"
TXT    mail._domainkey.studiomeyer.io  "k=rsa; p=MIGfMA0...<long key from Brevo>"
TXT    _dmarc.studiomeyer.io           "v=DMARC1; p=quarantine; rua=mailto:dmarc@studiomeyer.io"
```

**Important about the SPF record:** Use `~all` (soft-fail), not `-all` (hard-fail), as the default. Reason: most domains already send mail through other providers (Google Workspace, Microsoft 365, your CRM, your invoicing tool), a hard-fail SPF will silently kill all of those. If you're certain Brevo is the only sender for this domain AND you've added every other sender's `include:` directive, then `-all` is correct. Otherwise stick with `~all`.

If you already have an SPF record on the domain, **append** `include:spf.brevo.com` to it instead of creating a second one, multiple SPF records are an RFC violation and most receivers reject the domain entirely.

Get the exact DKIM value from **Brevo dashboard → Senders & IP → Authenticate your domain**. Wait 30-60 minutes for DNS propagation, then verify in the Brevo UI. Don't skip the verify step, you find typos that way.

### Step 3: nodemailer transport in your server

```typescript
// src/lib/email-magic.ts
import nodemailer from 'nodemailer';
import { logger } from './logger.js';

const transporter = nodemailer.createTransport({
  host: process.env.SMTP_HOST!,             // smtp-relay.brevo.com
  port: Number(process.env.SMTP_PORT ?? 587),
  secure: false,                            // STARTTLS on port 587
  auth: {
    user: process.env.SMTP_USER!,           // your Brevo login
    pass: process.env.SMTP_PASS!,           // your Brevo SMTP key (NOT your account password)
  },
});

export async function sendMagicLink(to: string, link: string): Promise<void> {
  // Reject the +tag trap upfront (Step 5 explains why)
  if (to.includes('+')) throw new Error('PLUS_TAG_NOT_ALLOWED');

  const html = `
    <p>Hi,</p>
    <p>Click this link to sign in. The link expires in 15 minutes and can only be used once.</p>
    <p><a href="${link}">${link}</a></p>
    <p>If you didn't request this, ignore this email.</p>
    <hr>
    <p style="color: #666; font-size: 12px;">
      Sent by My SaaS · <a href="https://mysaas.io/unsubscribe">Unsubscribe</a>
    </p>
  `;

  await transporter.sendMail({
    from: '"My SaaS" <hello@mysaas.io>',  // matches your verified DKIM domain
    to,
    subject: 'Sign in to My SaaS',
    text: `Sign in: ${link}\n\nThis link expires in 15 minutes.`,
    html,
    headers: {
      'List-Unsubscribe': '<mailto:unsubscribe@mysaas.io>',
      'X-Entity-Ref-ID': `magic-${Date.now()}`,  // dedupe header for some providers
    },
  });

  logger.info('magic link sent', { to, host: to.split('@')[1] });
}
```

Three rules baked into this:

- **`from` matches the DKIM-verified domain.** If your DKIM is for `studiomeyer.io` and you send `from: "x@example.com"`, every provider rejects you.
- **Plain-text version included.** Spam filters score HTML-only emails ~30% worse.
- **`List-Unsubscribe` header.** Required by Yahoo + Gmail bulk-sender policy as of Feb 2024. Even one transactional email per user counts.

### Step 4: Token table + endpoint

```sql
CREATE TABLE oauth_magic_links (
  token        TEXT PRIMARY KEY,
  email        TEXT NOT NULL,
  tenant_id    UUID,                  -- nullable: filled on first verify if user is new
  expires_at   TIMESTAMPTZ NOT NULL,
  used         BOOLEAN NOT NULL DEFAULT FALSE,
  created_at   TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX ON oauth_magic_links (email);
CREATE INDEX ON oauth_magic_links (expires_at);
```

The `/authorize` POST handler:

```typescript
import { randomBytes } from 'node:crypto';

app.post('/authorize', async (req, res) => {
  const email = String(req.body.email ?? '').trim().toLowerCase();
  if (!email.match(/^[^@\s]+@[^@\s]+\.[^@\s]+$/)) {
    return res.status(400).send('Invalid email.');
  }
  if (email.includes('+')) {
    return res.status(400).send('Plus-addressed emails are not supported. Use a regular address.');
  }

  const token = randomBytes(32).toString('base64url');
  const expiresAt = new Date(Date.now() + 15 * 60 * 1000); // 15 minutes

  await db.query(
    `INSERT INTO oauth_magic_links (token, email, expires_at)
     VALUES ($1, $2, $3)`,
    [token, email, expiresAt],
  );

  const link = `${process.env.BASE_URL}/authorize/verify?token=${token}`;
  try {
    await sendMagicLink(email, link);
  } catch (err) {
    logger.error('magic link mail failed', { email, err: String(err) });
    return res.status(500).send('Could not send the email. Try again or contact support.');
  }

  res.send('Check your email, the link expires in 15 minutes.');
});
```

The verify endpoint:

```typescript
app.get('/authorize/verify', async (req, res) => {
  const token = String(req.query.token ?? '');
  const r = await db.query(
    `UPDATE oauth_magic_links
     SET used = TRUE
     WHERE token = $1 AND used = FALSE AND expires_at > NOW()
     RETURNING email`,
    [token],
  );
  if (r.rows.length === 0) {
    return res.status(401).send('Invalid or expired link. Request a new one.');
  }

  // Find or create tenant
  const email = r.rows[0].email as string;
  const tenant = await findOrCreateTenantByEmail(email);

  // Issue session, set cookie, redirect to dashboard
  const sessionToken = await issueSession(tenant.id);
  res.cookie('session', sessionToken, { httpOnly: true, secure: true, sameSite: 'lax', maxAge: 30 * 24 * 3600 * 1000 });
  res.redirect('/dashboard');
});
```

The `UPDATE ... WHERE used = FALSE AND expires_at > NOW() RETURNING` pattern is atomic, no race condition where two parallel clicks both succeed.

### Step 5: The +tag email trap

Plus-tagged addresses (`info+test@studiomeyer.io`, `user+filter@gmail.com`) are how power users filter their inbox. Most SaaS treat them as valid. The catch is that not every receiving server does:

- **Gmail:** accepts `+tags` reliably (this is where the convention comes from).
- **Outlook / Hotmail / Live:** accepts `+tags` for personal accounts, but Office 365 tenants can disable plus-addressing per organization, and many corporate setups do.
- **Yahoo:** historically used `-` instead of `+` and many Yahoo MX setups still reject `+tagged` addresses outright.
- **GMX, t-online, AOL:** mixed, some accept, some reject.
- **Self-hosted / corporate MX servers:** roughly 1 in 5 rejects `+tags` by default. Postfix and Exim both ship with plus-addressing disabled in some distros.

The practical reality: across a SaaS user base, between 2% and 15% of `+tagged` addresses bounce hard. That's enough to damage your sender reputation if you don't catch it.

Three options:

- **Strip the +tag before storing** (`x+y@example.com` → `x@example.com`), controversial, breaks the user's stated intent (they want their filter to work).
- **Reject upfront with a clear message**, what we do (Step 4 has the check). Pragmatic default for transactional auth.
- **Accept-and-pray**, what most SaaS do, and what causes the 2-15% mystery bounce ticket.

We've shipped both "accept" and "reject" policies in production. After multiple sender-reputation incidents and dozens of hard bounces traced to plus-addressing, we settled on "reject upfront with a clear message" for magic-link auth. The reasoning: magic-link is a one-time, time-sensitive signal. A bounced magic-link is a failed signup. A user who reads "plus-addressing isn't supported, please use your normal address" gets in within 30 seconds. A user who waits 15 minutes for a magic-link that bounced silently never comes back.

### Step 6: Verify

Run `academy_validate_step`. The validator checks the OAuth surface (`/authorize`, `/token`, `/.well-known/oauth-authorization-server`) is reachable. Magic-link is one of the auth methods that gets you there.

### Step 7: Pre-flight email checklist before going live

Run through this before your first real signup:

- DKIM signed: `dig +short TXT mail._domainkey.yourdomain.io` returns the key
- SPF correct: `dig +short TXT yourdomain.io` includes `include:spf.brevo.com`
- DMARC set: `dig +short TXT _dmarc.yourdomain.io` returns the policy
- Send a test email to: yourself@gmail.com, yourself@yahoo.com, yourself@outlook.com, yourself@gmx.de, all four arrive in the inbox, not spam
- View source on the received email, confirms `dkim=pass`, `spf=pass`, `dmarc=pass`
- Click the magic link, confirm the redirect works
- Send to a `+tagged` address, confirm your endpoint rejects it cleanly
- Wait 24h, check Brevo dashboard for bounces, if any, fix before launch

Skipping any of these steps means you find the bug after a paying customer can't sign in.

## Common traps

- **`from` mismatch with DKIM domain**, silent reject by Gmail, hard bounce on Yahoo.
- **No plain-text version**, 30% spam-score penalty. Always provide both.
- **Long token TTL**, 15 minutes is the sweet spot. 1 hour increases attack window for stolen emails. 5 minutes makes users miss the window.
- **Reusing tokens**, magic links should be one-shot. The `WHERE used = FALSE` UPDATE handles it.
- **Plus-tag emails accepted blindly**, covered in Step 5.
- **Re-using your password as SMTP\_PASS**. Brevo gives you a separate SMTP key in their dashboard. Use that, not your account password.

## What good looks like

A user enters their email on `/authorize`, clicks "Send link", checks inbox within 5 seconds, clicks the link, lands on the dashboard logged in. Failure modes are rare (typo'd email, expired link, +tag rejection) and have clear error messages. Bounce rate stays under 1%.

If you skip the DNS setup, you'll spend the next month debugging why your conversions are 50% of what they should be. If you skip the +tag rejection, you'll spend the next month explaining to angry Yahoo users why they can't sign in. Both are avoidable in advance.

Client check · run on your machine

ls -la src/saas/oauth.ts 2>/dev/null && grep -c "/authorize\\|/token\\|/register" src/saas/oauth.ts 2>/dev/null

**Expect:** oauth.ts exists and contains at least one of /authorize, /token, /register.

**If stuck:** Lift the OAuth pattern from mcp-crew or aiguide — magic-link + PKCE in ~400 lines, drop-in replicable.

[← Stripe Checkout, subscription](https://studiomeyer.academy/recipes/10.1-stripe-checkout) [Stripe webhooks, idempotent, s →](https://studiomeyer.academy/recipes/10.5-stripe-webhooks)

Phase 10

Stripe billing, magic-link mail, Supabase basics

[Alle Phasen →](https://studiomeyer.academy/recipes)

Concepts

[Magic-link auth](https://studiomeyer.academy/concepts/magic_link) [Multi-tenant SaaS](https://studiomeyer.academy/concepts/multi_tenant)

## Sitemap

Public pages of this site as Markdown: [sitemap](https://studiomeyer.academy/sitemap.md). Curated entry point: [llms.txt](https://studiomeyer.academy/llms.txt), full text: [llms-full.txt](https://studiomeyer.academy/llms-full.txt).
