# Email Unsubscribe System Low-Level Design: Category Preferences, One-Click RFC 8058, and CAN-SPAM Compliance

Source: https://www.techinterview.org/post/3233468619/lld-email-unsubscribe/
Updated: 2026-04-17 · techinterview.org

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

Email unsubscribe and notification preference system design is discussed in [LinkedIn system design interview questions](https://www.techinterview.org/post/3233460278/linkedin-interview-guide-2026-social-graph-engineering-feed-ranking-and-professional-network-scale/).

Email unsubscribe and merchant email compliance design is covered in [Shopify system design interview preparation](https://www.techinterview.org/post/3233459924/shopify-interview/).

Email unsubscribe and customer communication compliance design is discussed in [Amazon system design interview guide](https://www.techinterview.org/post/3233460275/scale-ai-interview-guide-2026-data-infrastructure-rlhf-pipelines-and-ml-engineering/).
