Email Unsubscribe System: Low-Level Design
An email unsubscribe system lets users opt out of specific email categories while maintaining compliance with CAN-SPAM, GDPR, and CASL regulations. The design must handle granular category preferences, one-click unsubscribe (RFC 8058), global unsubscribe, and the critical safety requirement that unsubscribes must be respected immediately and permanently — bouncing or re-subscribing a user without their consent is a compliance violation.
Core Data Model
CREATE TABLE EmailCategory (
category_id SERIAL PRIMARY KEY,
category_key VARCHAR(100) UNIQUE NOT NULL, -- 'marketing', 'product_updates', 'weekly_digest'
display_name VARCHAR(200) NOT NULL,
description TEXT,
is_transactional BOOLEAN NOT NULL DEFAULT FALSE, -- transactional emails cannot be unsubscribed
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE EmailPreference (
pref_id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
category_id INT NOT NULL REFERENCES EmailCategory(category_id),
subscribed BOOLEAN NOT NULL DEFAULT TRUE,
source VARCHAR(50) NOT NULL DEFAULT 'signup', -- signup, user_action, import
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (user_id, category_id) -- one preference per user per category
);
CREATE TABLE GlobalUnsubscribe (
user_id BIGINT PRIMARY KEY,
email VARCHAR(255) NOT NULL, -- denormalized for email-level lookups
reason VARCHAR(50), -- 'user_request', 'bounce_hard', 'complaint', 'admin'
unsubscribed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE UnsubscribeAuditLog (
log_id BIGSERIAL PRIMARY KEY,
user_id BIGINT,
email VARCHAR(255) NOT NULL,
action VARCHAR(30) NOT NULL, -- 'unsubscribe', 'resubscribe', 'global_unsubscribe'
category_key VARCHAR(100), -- NULL for global
source VARCHAR(50) NOT NULL, -- 'one_click', 'preference_page', 'bounce', 'api'
ip_address INET,
user_agent VARCHAR(500),
logged_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX ON EmailPreference(user_id);
CREATE INDEX ON GlobalUnsubscribe(email);
Checking Send Permission Before Each Email
def can_send(user_id: int, category_key: str) -> bool:
"""
Check if a user can receive an email in the given category.
Must be called before every email send — compliance requirement.
"""
# 1. Global unsubscribe — hard stop for all non-transactional email
if db.fetchone("SELECT 1 FROM GlobalUnsubscribe WHERE user_id=%s", (user_id,)):
category = db.fetchone(
"SELECT is_transactional FROM EmailCategory WHERE category_key=%s", (category_key,)
)
if not category or not category['is_transactional']:
return False
# 2. Category-level preference
pref = db.fetchone("""
SELECT ep.subscribed, ec.is_transactional
FROM EmailPreference ep
JOIN EmailCategory ec USING (category_id)
WHERE ep.user_id=%s AND ec.category_key=%s
""", (user_id, category_key))
if pref:
# Transactional emails ignore preference — always send
if pref['is_transactional']:
return True
return pref['subscribed']
# 3. No explicit preference — check default for the category
category = db.fetchone(
"SELECT is_transactional FROM EmailCategory WHERE category_key=%s", (category_key,)
)
# Default: send if no preference on file (opt-out model)
# For GDPR double-opt-in products: change default to False (opt-in model)
return True if category else False
Unsubscribe Handlers
def unsubscribe_category(user_id: int, category_key: str,
source: str = 'user_action', ip: str = None):
"""Unsubscribe from a specific category."""
category = db.fetchone(
"SELECT category_id, is_transactional FROM EmailCategory WHERE category_key=%s",
(category_key,)
)
if not category:
raise ValueError(f"Unknown category: {category_key}")
if category['is_transactional']:
raise CannotUnsubscribeTransactionalError(
"Transactional emails (receipts, security alerts) cannot be unsubscribed"
)
db.execute("""
INSERT INTO EmailPreference (user_id, category_id, subscribed, source)
VALUES (%s, %s, FALSE, %s)
ON CONFLICT (user_id, category_id) DO UPDATE
SET subscribed=FALSE, source=EXCLUDED.source, updated_at=NOW()
""", (user_id, category['category_id'], source))
_log_action(user_id, None, 'unsubscribe', category_key, source, ip)
def global_unsubscribe(user_id: int, email: str,
reason: str = 'user_request', source: str = 'user_action',
ip: str = None):
"""Opt out of all non-transactional email."""
db.execute("""
INSERT INTO GlobalUnsubscribe (user_id, email, reason)
VALUES (%s, %s, %s)
ON CONFLICT (user_id) DO UPDATE
SET email=EXCLUDED.email, reason=EXCLUDED.reason, unsubscribed_at=NOW()
""", (user_id, email, reason))
_log_action(user_id, email, 'global_unsubscribe', None, source, ip)
def one_click_unsubscribe(token: str, ip: str = None):
"""
RFC 8058 / List-Unsubscribe: POST to unsubscribe endpoint with a signed token.
Token = HMAC-SHA256(user_id:category_key:email, secret_key), base64url encoded.
"""
import hmac, hashlib, base64
try:
payload, sig = token.rsplit('.', 1)
expected = base64.urlsafe_b64encode(
hmac.new(SECRET_KEY, payload.encode(), hashlib.sha256).digest()
).rstrip(b'=').decode()
if not hmac.compare_digest(sig, expected):
raise InvalidTokenError("Invalid unsubscribe token")
user_id, category_key, email = base64.urlsafe_b64decode(
payload + '=='
).decode().split(':')
unsubscribe_category(int(user_id), category_key, source='one_click', ip=ip)
except (ValueError, KeyError) as e:
raise InvalidTokenError("Malformed unsubscribe token") from e
def _log_action(user_id, email, action, category_key, source, ip):
user_email = email
if not user_email and user_id:
row = db.fetchone("SELECT email FROM User WHERE user_id=%s", (user_id,))
user_email = row['email'] if row else None
db.execute("""
INSERT INTO UnsubscribeAuditLog
(user_id, email, action, category_key, source, ip_address)
VALUES (%s,%s,%s,%s,%s,%s)
""", (user_id, user_email, action, category_key, source, ip))
Key Design Decisions
- Global unsubscribe takes precedence: can_send() checks GlobalUnsubscribe first — one row blocks all marketing email regardless of category preferences. This is a CAN-SPAM requirement: an opt-out of all email must be honored within 10 business days (immediate in practice).
- Transactional emails bypass unsubscribe: password resets, receipts, security alerts, and account notifications are is_transactional=TRUE and are sent regardless of global unsubscribe status. Never mark marketing emails as transactional to work around unsubscribes — this violates CAN-SPAM and GDPR and destroys sender reputation.
- HMAC-signed one-click tokens: one-click unsubscribe tokens must not contain guessable or enumerable values. The HMAC signature prevents forged unsubscribe requests. The token embeds user_id, category, and email so the handler doesn’t need a database lookup to identify the subscriber.
- Audit log for compliance: every preference change is logged with source, IP, and timestamp. CAN-SPAM requires that you can demonstrate an opt-out was honored. GDPR requires a record of consent changes. The audit log provides this evidence for regulatory inquiries.
{“@context”:”https://schema.org”,”@type”:”FAQPage”,”mainEntity”:[{“@type”:”Question”,”name”:”What is the difference between CAN-SPAM, GDPR, and CASL email compliance requirements?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”CAN-SPAM (US): opt-out model — you can email anyone until they opt out. Must include a physical address and an unsubscribe mechanism in every commercial email. Must honor opt-outs within 10 business days. Penalties: $50,120 per email. GDPR (EU/UK): opt-in model — you need explicit consent before sending marketing email. Consent must be freely given, specific, informed, and documented. "Soft opt-in" exception: you can email existing customers about similar products/services. Right to erasure includes deleting marketing preferences. Penalties: 4% of annual global revenue. CASL (Canada): express or implied consent required before sending commercial messages. Implied consent lasts 2 years from last purchase. Stricter than CAN-SPAM; closer to GDPR. Penalties: $1M CAD per violation. Design implication: for a global product, build to GDPR standard (explicit opt-in, double-opt-in confirmation, documented consent) — this satisfies CAN-SPAM and CASL as well.”}},{“@type”:”Question”,”name”:”How do you handle transactional vs. marketing email from the same sending domain?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Using the same domain and IP for transactional (receipts, password resets) and marketing (newsletters, promotions) email creates a reputation risk: a marketing campaign that generates spam complaints will hurt the deliverability of transactional emails. Separation strategies: (1) Separate IPs: use a dedicated IP for transactional email (e.g., mail.example.com sending via transactional-ip-pool) and a shared or separate IP pool for marketing. IP reputation is separate. (2) Separate subdomains: send transactional from no-reply@mail.example.com and marketing from newsletter@updates.example.com. SPF, DKIM, and DMARC are configured separately per subdomain. (3) Separate sending providers: Mandrill or AWS SES for transactional; Mailchimp or Klaviyo for marketing. This is the cleanest separation — if the marketing provider gets your domain blacklisted, the transactional provider is unaffected. Always implement email_category.is_transactional properly to prevent marketing emails from being sent through the transactional path.”}},{“@type”:”Question”,”name”:”How do you re-engage users who unsubscribed without re-subscribing them?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Re-subscribing a user who unsubscribed without their consent is a compliance violation (CAN-SPAM, GDPR). You cannot re-add them to your marketing list just because they became a paid customer again. Legitimate re-engagement: (1) at signup or purchase, present a clear opt-in checkbox for marketing email — if they check it, that is new explicit consent; (2) include a tasteful in-product prompt ("You’re missing product updates — opt back in here") that requires an explicit click; (3) show the preference management page when they next log in, not as a forced interstitial, but as a visible setting. Never: automatically re-subscribe them on account reactivation, import their address back into a marketing list, or send "one last email" to let them know they can re-subscribe (this is itself a marketing email to someone who opted out).”}},{“@type”:”Question”,”name”:”How do you handle unsubscribes from forwarded emails or shared accounts?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”A user forwards your newsletter to a colleague. The colleague clicks "unsubscribe" in the forwarded email. The one-click unsubscribe token identifies the original recipient (alice@company.com), not the person who clicked. Alice gets unsubscribed even though she still wants the newsletter — a false unsubscribe. Mitigation: (1) HMAC tokens include the user_id and email together; the token is specific to alice@company.com and is not reusable by another email address; (2) add a confirmation step for email-based unsubscribes (a landing page: "You’re unsubscribing alice@company.com from marketing emails — confirm?"); (3) for List-Unsubscribe headers (email client unsubscribe buttons), RFC 8058 one-click is appropriate since it is harder to accidentally trigger; (4) offer a re-subscribe link on the unsubscribe confirmation page for recovery.”}},{“@type”:”Question”,”name”:”How do you measure and improve email deliverability after building the unsubscribe system?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Deliverability is measured by: (1) inbox placement rate (% of sent emails that land in inbox vs. spam vs. rejected) — use seed list testing (Litmus, GlockApps) to measure across major ISPs; (2) bounce rate: hard bounces (invalid address) should be <0.1%; remove hard-bounced addresses immediately and permanently; soft bounces (mailbox full) retry 3 times over 72 hours then suppress; (3) spam complaint rate: ISPs report complaints via feedback loops (FBL); complaint rates above 0.08% (Google’s published threshold) trigger deliverability penalties; (4) unsubscribe rate: >0.5% per campaign suggests misaligned audience or content. Actions to improve: ensure double-opt-in for new subscribers, remove inactive subscribers (>12 months no opens) from marketing lists, segment sends by engagement tier, and authenticate all sending domains with SPF, DKIM, and DMARC alignment.”}}]}
Email unsubscribe and notification preference system design is discussed in LinkedIn system design interview questions.
Email unsubscribe and merchant email compliance design is covered in Shopify system design interview preparation.
Email unsubscribe and customer communication compliance design is discussed in Amazon system design interview guide.