The Developer's Complete Guide to Email Types, Spam Laws, and Building Email Systems That Actually Work
Most developers treat email as a solved problem.
You pick a provider, paste in an API key, call resend.emails.send(), and move on. It works in development. It works in staging. Then you ship to production, send your first real campaign to 5,000 users, and half of them never see it because or worse, your domain gets flagged and your password reset emails start landing in spam too.
Email is deceptively simple on the surface and surprisingly complex underneath. The complexity is not in the sending code. It is in understanding what you are sending, to whom, when, and how you are sending it and respecting a set of rules that inbox providers, regulators, and end users have every right to enforce.
This guide covers everything you need to know before email becomes a serious part of your product. Not just the API calls, but the architecture, the law, the deliverability mechanics, and the mistakes that are completely avoidable if you know about them first.
The Three Categories of Email (And Why They Cannot Share Infrastructure)
Most developers think of email in two buckets: "the email my app sends" and "the newsletter." The real breakdown is more nuanced, and getting it wrong has infrastructure consequences.
1. Transactional Email
Triggered by a specific user action. One recipient. Expected and wanted by that user right now.
Examples:
- Password reset link
- Email address verification / OTP
- Order confirmation and receipt
- Shipping and delivery notification
- Account security alert (new device login, password changed)
- Invoice and billing receipt
- Welcome email immediately after signup
- Booking or appointment confirmation
Characteristics:
- Time-sensitive — a delayed OTP is a broken OTP
- High expected open rate (60-80%+)
- No opt-in required (legitimate interest basis)
- Not subject to unsubscribe requirements under most laws
- Must be on a dedicated sending infrastructure that its reputation cannot be contaminated by marketing traffic
2. Marketing / Promotional Email
Sent to a list with the goal of driving engagement, awareness, or revenue.
Examples:
- Weekly newsletter
- Product announcement or launch email
- Flash sale or discount campaign
- Re-engagement campaign to inactive users
- Feature highlight email
- Case study or social proof broadcast
Characteristics:
- Requires explicit opt-in consent (under GDPR, CASL, and increasingly under CAN-SPAM enforcement)
- Must include working unsubscribe mechanism
- Engagement rate varies widely (20-40% open rate is good)
- Reputation-sensitive - one bad campaign can drag down your sender score
3. Lifecycle / Behavioral Email
Somewhere between transactional and marketing. Triggered by user behavior, not a direct action of the user and aimed at moving the user toward a goal.
Examples:
- Onboarding sequence (triggered by signup date + days elapsed)
- Trial expiration warning (triggered by plan status)
- Inactivity nudge (triggered by last login date)
- Upgrade prompt after hitting a usage limit
- Win-back email to churned users
- Product usage digest (weekly summary of activity)
Characteristics:
- Triggered by system events, often via cron jobs
- Straddles the line between transactional and marketing
- Generally requires opt-in, especially for EU users
- Works best when genuinely personalized to user behavior
How Spam Filters Actually Work (The Parts Developers Skip)
Understanding spam filtering is not about gaming the system. It is about understanding why legitimate emails fail to deliver and how to prevent it.
The Reputation Stack
Inbox providers (Gmail, Outlook, Apple Mail, Yahoo) evaluate your mail on multiple layers simultaneously:
IP Reputation: The sending IP's history of complaints, bounces, and spam trap hits. A fresh IP has zero reputation. A shared IP inherits the reputation of every other sender on it.
Domain Reputation: The reputation of your sending domain (the part after the @) and your tracking domain. Separate from IP reputation, you can move IPs and keep your domain reputation, or lose your domain reputation while keeping a clean IP.
Content Scoring: The email's content is analyzed for spam signals: excessive capitalization, suspicious link patterns, known spam phrases, image-to-text ratio, missing plain-text version.
Engagement Signals: Gmail and Outlook heavily weight recipient behavior. If recipients open, click, and reply, your reputation improves. If they delete without reading, mark as spam, or unsubscribe immediately, your reputation degrades.
Authentication: Whether your email passes SPF, DKIM, and DMARC. Failing authentication is not just a deliverability risk, Gmail and Yahoo now reject unauthenticated bulk mail outright.
The Three Authentication Records Every Developer Must Set Up
These are DNS records. You set them once. Skipping them is the most common deliverability mistake teams make.
SPF (Sender Policy Framework)
Declares which mail servers are authorized to send email for your domain. Inbox providers check this to verify the sending server is allowed.
yourdomain.com TXT "v=spf1 include:sendgrid.net include:resend.com ~all"The ~all means "soft fail": emails from unauthorized servers are accepted but flagged. Use -all (hard fail) for stricter enforcement once you are sure all your sending sources are listed.
DKIM (DomainKeys Identified Mail)
Adds a cryptographic signature to every outgoing email. The recipient's mail server verifies the signature against your DNS record, confirming the email was not tampered with in transit.
resend._domainkey.yourdomain.com CNAME resend.domainkey.your-selector.resend.comYour email provider generates the key pair. You publish the public key in DNS. They sign with the private key. You never handle the cryptographic operations yourself.
DMARC (Domain-based Message Authentication)
Tells inbox providers what to do when SPF or DKIM fails to authenticate an email and importantly, sends you reports about failures so you can detect spoofing or misconfiguration.
_dmarc.yourdomain.com TXT "v=DMARC1; p=quarantine; rua=mailto:dmarc-reports@yourdomain.com; pct=100"Start with p=none (monitoring only) to see what is failing before you enforce. Move to p=quarantine once you are confident, then p=reject for maximum protection.
IP Warm-Up: The Part That Breaks New Senders
If you are sending from a new domain or a dedicated IP, you cannot start at full volume. Every inbox provider tracks new sending sources and throttles them until they prove legitimate behavior.
Sending 50,000 emails on day one from a fresh domain is the single most reliable way to permanently damage your sender reputation before your product has users.
The Warm-Up Schedule
Warm up gradually over 4-6 weeks, increasing volume only when engagement rates remain healthy:
| Week | Daily Volume | Notes |
|---|---|---|
| 1 | 200-500 | Best engaged users only (recent signups, active users) |
| 2 | 1,000-2,000 | Expand to verified, active contacts |
| 3 | 5,000-10,000 | Continue with engaged segment |
| 4 | 20,000-30,000 | Broader audience, monitor bounce rate |
| 5-6 | Full volume | If complaint rate stays below 0.1% |
Send to your most engaged users first. High open rates during warm-up signal legitimacy. If you start with cold or unengaged addresses, the low engagement signals spam behavior to inbox providers.
Email List Hygiene: The Ongoing Maintenance Developers Forget
Your email list degrades over time. People change jobs, abandon inboxes, and mark emails as spam. Sending to a dirty list damages deliverability for your entire domain.
Hard Bounces vs Soft Bounces
Hard bounce: The email address permanently does not exist. The recipient server rejected delivery. Remove this address from your list immediately and permanently. A hard bounce rate above 2% triggers ISP-level penalties.
Soft bounce: Temporary delivery failure (full inbox, server temporarily down). Retry 2-3 times over 24-48 hours, then treat as a hard bounce if it persists.
Processing Bounces in Code
Every major email provider sends bounce events via webhook. Process them in real time:
// app/api/webhooks/email/route.tsimport { db } from "@/lib/db";import { NextResponse } from "next/server";export async function POST(req: Request) { const payload = await req.json(); // Resend webhook event format if (payload.type === "email.bounced") { const email = payload.data.to[0]; const bounceType = payload.data.bounce?.type; // "hard" or "soft" if (bounceType === "hard") { // Permanently suppress this address await db.emailSuppression.upsert({ where: { email }, update: { reason: "hard_bounce", updatedAt: new Date() }, create: { email, reason: "hard_bounce" }, }); } } if (payload.type === "email.complained") { // Spam complaint — suppress immediately const email = payload.data.to[0]; await db.emailSuppression.upsert({ where: { email }, update: { reason: "spam_complaint", updatedAt: new Date() }, create: { email, reason: "spam_complaint" }, }); } return NextResponse.json({ received: true });}Always check your suppression list before sending:
async function isSuppressed(email: string): Promise<boolean> { const record = await db.emailSuppression.findUnique({ where: { email } }); return !!record;}Spam Complaint Rate
Your complaint rate is the ratio of spam reports to emails delivered. Keep it below 0.1% (1 complaint per 1,000 emails). Above 0.3% and inbox providers begin throttling your mail. Above 0.5% and you are looking at blocks.
A single campaign with a high complaint rate can take weeks to recover from. Monitor this in your provider dashboard after every send.
Cron-Based Email: Scheduling, Timing, and the Rules
A significant portion of product email is not triggered by real-time user actions. Instead, it is scheduled. Trial expiration warnings, weekly digests, re-engagement campaigns, renewal reminders. All of these run on crons.
The Cron Email Stack
// cron/send-trial-expiry-warnings.tsimport { db } from "@/lib/db";import { resend } from "@/lib/resend";import { render } from "react-email";import { TrialExpiryEmail } from "@/emails/trial-expiry";export async function sendTrialExpiryWarnings() { // Find users whose trial expires in exactly 3 days const threeDaysFromNow = new Date(); threeDaysFromNow.setDate(threeDaysFromNow.getDate() + 3); const expiringUsers = await db.user.findMany({ where: { plan: "trial", trialEndsAt: { gte: new Date(threeDaysFromNow.setHours(0, 0, 0, 0)), lte: new Date(threeDaysFromNow.setHours(23, 59, 59, 999)), }, // Check suppression list NOT: { email: { in: await db.emailSuppression .findMany({ select: { email: true } }) .then((rows) => rows.map((r) => r.email)), }, }, }, }); for (const user of expiringUsers) { const html = await render(<TrialExpiryEmail name={user.name} daysLeft={3} />); await resend.emails.send({ from: "team@yourapp.com", to: user.email, subject: "Your trial ends in 3 days", html, }); // Record the send to prevent duplicates await db.emailLog.create({ data: { userId: user.id, type: "trial_expiry_warning", sentAt: new Date(), }, }); }}Idempotency: The Non-Negotiable Rule for Cron Emails
A cron job that runs twice (due to a deployment overlap, a server retry, or a scheduler bug) must not send the same email twice. Always write cron email jobs to be idempotent.
// Check before sending - idempotent guardconst alreadySent = await db.emailLog.findFirst({ where: { userId: user.id, type: "trial_expiry_warning", sentAt: { gte: new Date(Date.now() - 24 * 60 * 60 * 1000), // last 24h }, },});if (alreadySent) { console.log("Skipping duplicate send for", user.email); continue;}Without this guard, a cron job running twice during a deployment sends every user the email twice. This generates immediate spam complaints and unsubscribes.
Timing Rules for Scheduled Email
- Send in the recipient's timezone when possible - a "good morning" email at 2am local time trains users to ignore you
- Avoid Monday mornings and Friday afternoons for marketing campaigns - these have the worst engagement windows for most B2B audiences
- Stagger large sends to do not blast 50,000 emails in one second; spread over 1-2 hours to avoid triggering rate limits and to get better inbox placement
- Do not send during major holidays unless the content is directly holiday-relevant
// Stagger sends to avoid rate limit spikesfor (let i = 0; i < recipients.length; i++) { await sendEmail(recipients[i]); // Add small delay every 100 sends if (i > 0 && i % 100 === 0) { await new Promise((resolve) => setTimeout(resolve, 1000)); }}What Legally Gets You in Trouble
Spam law is not hypothetical. Fines are real. Here is what developers need to know.
CAN-SPAM (United States)
Applies to any commercial email sent to US recipients.
Requirements:
- Accurate From, Reply-To, and routing information - no spoofed sender
- Non-deceptive subject line - cannot mislead about content
- Identify the message as advertising (unless it is clearly a transactional email)
- Include your physical mailing address in every email
- Provide a working unsubscribe mechanism
- Honor unsubscribes within 10 business days
Penalty: Up to $53,088 per email in violation.
What developers get wrong: CAN-SPAM requires unsubscribes to be processed within 10 days, not immediately. But email systems that delay processing unsubscribes sometimes send one more email before the suppression kicks in and which is technically compliant but creates complaints. Build real-time unsubscribe processing.
GDPR (European Union / UK)
Applies to any email sent to EU or UK residents, regardless of where your company is based.
Requirements:
- Explicit, documented opt-in consent — pre-ticked boxes do not count
- Clear purpose at time of collection ("I agree to receive marketing emails from X")
- Right to erasure — honor deletion requests within 30 days
- Data processing disclosure in privacy policy
- No re-use of data for different purposes than originally consented
Penalty: Up to 4% of global annual revenue or €20 million, whichever is higher.
What developers get wrong: Using an email collected for one purpose (e.g., account signup) to send marketing campaigns without separate marketing consent. These are two different consent requirements under GDPR.
CASL (Canada)
Stricter than CAN-SPAM. Requires express consent for most commercial emails. Implied consent (from being an existing customer) has a 2-year expiration.
The Unsubscribe System: Build It Right Once
An unsubscribe link that does not work, delays processing, or requires a login to complete generates spam complaints. Here is the correct implementation:
// Generate a signed unsubscribe token (no login required)import { createHmac } from "crypto";export function generateUnsubscribeToken(email: string): string { const hmac = createHmac("sha256", process.env.UNSUBSCRIBE_SECRET!); hmac.update(email); return hmac.digest("hex");}export function verifyUnsubscribeToken(email: string, token: string): boolean { const expected = generateUnsubscribeToken(email); return expected === token;}// In your email templateconst token = generateUnsubscribeToken(user.email);const unsubscribeUrl = `https://yourapp.com/unsubscribe?email=${encodeURIComponent(user.email)}&token=${token}`;// app/unsubscribe/page.tsxexport default async function UnsubscribePage({ searchParams }) { const { email, token } = searchParams; if (!verifyUnsubscribeToken(email, token)) { return <p>Invalid unsubscribe link.</p>; } // Process immediately — no login, no confirmation step required await db.emailSuppression.upsert({ where: { email }, update: { reason: "unsubscribed", updatedAt: new Date() }, create: { email, reason: "unsubscribed" }, }); return <p>You have been unsubscribed. You will no longer receive marketing emails from us.</p>;}Rules:
- One click to unsubscribe - do not require login
- Process immediately - do not queue it for later
- Confirm with a clear message - no dark patterns
- Keep the user subscribed to transactional email - unsubscribe from marketing only
- Add List-Unsubscribe header to every marketing email
// Add List-Unsubscribe header (required by Gmail for bulk senders)await resend.emails.send({ from: "team@yourapp.com", to: user.email, subject: "Your weekly digest", html: emailHtml, headers: { "List-Unsubscribe": `<${unsubscribeUrl}>, <mailto:unsubscribe@yourapp.com?subject=unsubscribe>`, "List-Unsubscribe-Post": "List-Unsubscribe=One-Click", },});The List-Unsubscribe-Post header enables one-click unsubscribe directly from Gmail's interface without a confirmation step. It is a requirement for high-volume senders and a significant contributor to lower complaint rates.
The Email Event Logging System
Track everything. You cannot diagnose deliverability problems or prove compliance without records.
// Minimum email event schemamodel EmailEvent { id String @id @default(cuid()) userId String? email String type String // sent, delivered, bounced, complained, opened, clicked, unsubscribed campaignId String? messageId String? // Provider's message ID for correlation metadata Json? createdAt DateTime @default(now()) @@index([email]) @@index([userId]) @@index([campaignId]) @@index([type, createdAt])}Log every outbound email and every inbound webhook event. At early scale, a PostgreSQL table is completely sufficient. Move to ClickHouse or TimescaleDB only when you are querying hundreds of millions of events and query performance becomes a problem.
Email Development Best Practices - Frequently Asked Questions
The Pre-Launch Email Checklist
Before you send a single production email, verify every item on this list:
- SPF record published and validated
- DKIM configured and signing confirmed
- DMARC in monitoring mode (
p=none) with reports going to a real inbox - Sending domain separate from your main domain (use a subdomain)
- Transactional and marketing on separate sending domains and IPs
- Bounce webhook endpoint live and processing hard bounces to suppression list
- Complaint webhook endpoint live and processing to suppression list
- Unsubscribe endpoint live and processing immediately (no login required)
- List-Unsubscribe header in all marketing emails
- Physical mailing address in footer of all marketing emails (CAN-SPAM)
- Email log table in database
- Idempotency guard on all cron-based email jobs
- Email sending tested in Gmail, Outlook, and Apple Mail
- Plain-text fallback populated for all emails
- Spam score checked (use mail-tester.com or your provider's tools)
Final Thoughts
Email is one of those systems that works well when you respect how it works and fails silently when you ignore it.
The good news: everything in this guide is a one-time setup cost. Set up authentication correctly, build your suppression list processing, make your unsubscribes work, and warm up your sending domain. These decisions compound positively — a clean, well-authenticated sending domain gets better inbox placement over time, which improves open rates, which further strengthens reputation.
The alternative — treating email as a fire-and-forget utility, skipping authentication, not processing bounces, sending to purchased lists — compounds negatively. Deliverability problems are slow to develop and slow to recover from.
Build it right at the start. Your users will receive the emails your product sends them. That is the whole job.
Need help designing a compliant, deliverable email system for your product? Websyro Agency builds email infrastructure for SaaS teams and startups. We help from authentication setup to suppression lists to sending architecture. Talk to us for the first consultation is free.
