Recipe-Inhalt ist auf Englisch. Englisches Original lesen →
← Alle 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 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.

Schritt 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.

Schritt 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:[email protected]"

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.

Schritt 3: nodemailer transport in your server

// 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" <[email protected]>',  // 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:[email protected]>',
      '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: "[email protected]", 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.

Schritt 4: Token table + endpoint

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:

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:

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.

Schritt 5: The +tag email trap

Plus-tagged addresses ([email protected], [email protected]) 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 ([email protected][email protected]), 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.

Schritt 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.

Schritt 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: [email protected], [email protected], [email protected], [email protected], 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 · auf Deinem Rechner ausführen
ls -la src/saas/oauth.ts 2>/dev/null && grep -c "/authorize\|/token\|/register" src/saas/oauth.ts 2>/dev/null
Erwartet: oauth.ts exists and contains at least one of /authorize, /token, /register.
Falls hängen geblieben: Lift the OAuth pattern from mcp-crew or aiguide — magic-link + PKCE in ~400 lines, drop-in replicable.
Stripe Checkout, subscription Stripe webhooks, idempotent, s