İçeriğe geç

Mail Service & Magic Link Authentication — Design Spec

Derin

Date: 2026-04-01 Status: Approved Scope: Global enterprise mail service + Admin magic link login


Build a global, enterprise-grade email service for ECU Tuning Portal using React Email (component-based HTML templates) + Nodemailer (SMTP transport via Plesk). The first consumer is Admin Magic Link Authentication — a passwordless login option alongside the existing password + 2FA flow.

  • Reusable mail service that any part of the app (admin, customer, landing) can consume
  • Branded, i18n-ready email templates matching the portal’s dark/red tech aesthetic
  • Magic link login for admin users — hybrid with existing password/2FA auth
  • Enterprise-grade security: single-use tokens, TTL, rate limiting, email enumeration protection
  • Customer portal magic link (future phase)
  • Email queue/worker system (not needed at current scale)
  • Third-party email providers (Resend, SendGrid) — using existing Plesk SMTP

lib/mail/
├── transport.ts # Nodemailer SMTP connection (singleton, pooled)
├── service.ts # Main mail service — sendMail(options)
├── tokens.ts # Magic link token generate/verify (crypto + DB)
├── i18n.ts # Template translation dictionaries
└── templates/
├── base-layout.tsx # Shared layout (logo, footer, brand colors)
├── magic-link.tsx # Magic link login template
└── index.ts # Template registry (type-safe exports)
LayerFileResponsibility
Transporttransport.tsSingleton Nodemailer pooled SMTP connection (port 465, SSL)
Serviceservice.tsOrchestrator: accepts {to, template, data, locale}, renders template, sends via transport
Templatestemplates/*.tsxReact Email components — pure rendering, no side effects
i18ni18n.tsLocale-keyed string dictionaries per template
Tokenstokens.tsgenerateMagicToken(email) and verifyMagicToken(token) — DB-backed
interface SendMailOptions {
to: string;
template: 'magic-link'; // Union grows as templates are added
data: Record<string, unknown>;
locale?: string; // Defaults to 'en'
}
async function sendMail(options: SendMailOptions): Promise<{ success: boolean; messageId?: string; error?: string }>;

model MagicLinkToken {
id Int @id @default(autoincrement())
email String
token String @unique // crypto.randomBytes(32).toString('hex')
expiresAt DateTime // created + 15 minutes
usedAt DateTime? // Set on first use (single-use enforcement)
ipAddress String?
userAgent String?
createdAt DateTime @default(now())
@@index([email])
@@index([token])
}

No changes to existing models. The magic link flow creates a standard NextAuth session upon successful verification.


POST /api/auth/magic-link/send
Body: { email: string }
1. Validate email format
2. Rate limit: 5 requests/hour per email, 10 requests/hour per IP
3. Look up AdminUser by email
4. If NOT found → return same success response (email enumeration protection)
5. If found:
a. Delete any existing unused tokens for this email
b. Generate token: crypto.randomBytes(32).toString('hex')
c. Insert MagicLinkToken: { email, token, expiresAt: now + 15min, ipAddress, userAgent }
d. Render magic-link template with React Email (using admin's locale or 'en')
e. Send via mail service
6. Return: { success: true, message: "Check your email" }
GET /api/auth/magic-link/verify?token=xxx
1. Find MagicLinkToken by token
2. Validate:
a. Token exists → else 400 "Invalid or expired link"
b. expiresAt > now → else 400 "Link expired"
c. usedAt is null → else 400 "Link already used"
3. Mark token as used: UPDATE usedAt = now()
4. Find AdminUser by email
5. Create NextAuth session (programmatic signIn)
6. If admin has totpEnabled:
a. Clear twoFactorVerifiedAt (same as password login)
b. Redirect to 2FA verification page
7. If no 2FA → redirect to /admin/dashboard
MeasureImplementation
Single-use tokensusedAt column — checked on verify, set after use
15-minute TTLexpiresAt column — checked on verify
Rate limitingPer-email: 5/hour, Per-IP: 10/hour (reuse existing rateLimit())
Email enumerationSame response whether user exists or not
2FA preservedMagic link skips password only — 2FA step still required if enabled
Token entropycrypto.randomBytes(32) = 256 bits of randomness
HTTPS onlyMagic link URL uses MAGIC_LINK_BASE_URL (https://ecutuningportal.com)
CleanupDelete unused tokens for same email before creating new one

All emails wrapped in a consistent branded layout:

  • Background: #111111 (slightly lighter than #050505 for email client compatibility)
  • Content panel: #1a1a1a with subtle border
  • Accent: #ef4444 (brand-red) for buttons, links, highlights
  • Font: System font stack with Chakra Petch as preferred (fallback to sans-serif)
  • Header: CPU icon + “ECU TUNING PORTAL” text
  • Footer: Copyright, auto-generated notice, website link

Subject (en): “Sign in to ECU Tuning Portal” Subject (tr): “ECU Tuning Portal Giris”

Body:

Hello {name},
Click the button below to sign in to your account.
This link is valid for 15 minutes and can only be used once.
[ Sign In → ] ← Red CTA button (#ef4444)
If you did not request this link, you can safely ignore this email.
IP: {ipAddress}
type TemplateTranslations = Record<string, Record<string, string>>;
const translations: Record<string, TemplateTranslations> = {
'magic-link': {
en: {
subject: 'Sign in to ECU Tuning Portal',
greeting: 'Hello {name}',
body: 'Click the button below to sign in...',
button: 'Sign In',
warning: 'If you did not request this link...',
expiry: 'This link is valid for 15 minutes...',
},
tr: {
subject: 'ECU Tuning Portal Giris',
greeting: 'Merhaba {name}',
body: 'Hesabiniza giris yapmak icin asagidaki butona tiklayin...',
button: 'Giris Yap',
warning: 'Bu linki siz talep etmediyseniz...',
expiry: 'Bu link 15 dakika gecerlidir...',
},
},
};

Locale support starts with en and tr. The structure supports all 24 locales — adding a new language only requires adding a key to the translations object.


  • Email + password form
  • Redirects to 2FA page if TOTP enabled
  • Tab/toggle approach: Two sections separated by “or” divider
    1. Email + password form (existing, unchanged)
    2. “Sign in with email link” — email-only form + send button
  • After magic link sent: success message “Check your email” with countdown/resend option
  • Visual style matches existing glass-panel dark theme
┌─────────────────────────────────┐
│ ECU TUNING PORTAL │
│ Admin Panel Login │
├─────────────────────────────────┤
│ Email: [______________] │
│ Password: [______________] │
│ │
│ [ Sign In ] │
│ │
│ ─────── or ──────── │
│ │
│ [ Sign in with Email Link ] │ ← Expands to email-only form
│ │
└─────────────────────────────────┘

# Mail Service (new)
SMTP_HOST=ecutuningportal.com
SMTP_PORT=465
SMTP_SECURE=true
SMTP_USER=no-reply@ecutuningportal.com
SMTP_PASS=<from Plesk>
SMTP_FROM="ECU Tuning Portal <no-reply@ecutuningportal.com>"
# Magic Link (new)
MAGIC_LINK_BASE_URL=https://ecutuningportal.com

PackagePurpose
@react-email/componentsReact Email UI components (Html, Body, Button, etc.)
nodemailerSMTP email transport
PackagePurpose
@types/nodemailerTypeScript type definitions

FileActionDescription
lib/mail/transport.tsCreateNodemailer SMTP singleton with connection pooling
lib/mail/service.tsCreateMain sendMail() orchestrator
lib/mail/tokens.tsCreategenerateMagicToken / verifyMagicToken
lib/mail/i18n.tsCreateTemplate translation dictionaries
lib/mail/templates/base-layout.tsxCreateShared branded email layout
lib/mail/templates/magic-link.tsxCreateMagic link email template
lib/mail/templates/index.tsCreateTemplate registry
prisma/schema.prismaEditAdd MagicLinkToken model
app/api/auth/magic-link/send/route.tsCreateSend magic link API
app/api/auth/magic-link/verify/route.tsCreateVerify magic link API
app/[locale]/admin/login/page.tsxEditAdd magic link UI option
.envEditAdd SMTP + magic link env vars
package.jsonEditAdd new dependencies

This design enables future additions with minimal changes:

  • New templates: Create a new .tsx file in templates/, add translations to i18n.ts, add type to SendMailOptions.template union
  • Customer portal: Same sendMail() call, just different templates and token logic referencing Customer model instead of AdminUser
  • Landing page: Contact form confirmations, newsletter — just new templates
  • Attachments: sendMail() options can be extended to accept attachments array (Nodemailer native support)
  • Email logs: Add an EmailLog model to track all sent emails for debugging/audit