Email capture is the moment a person hands you their address, at signup, at a newsletter form, or at checkout. This chapter answers how to collect addresses responsibly so you build a clean, verified, consented list instead of one full of typos, fake addresses, and unconsented contacts that bounce, generate spam complaints, and quietly destroy your sender reputation. Capture is where the reputation fight is won or lost: every address you add either strengthens or weakens you.
Quick answers to the questions this chapter covers:
- How do I validate an email address (client-side and server-side)?
- What is double opt-in and when should I use it instead of single opt-in?
- How do I design a capture form that converts without dark patterns?
- How do I collect valid marketing consent with checkboxes?
- How do I handle typos, already-registered users, and bots?
- How do I design a verification email people actually click?
Scope: this chapter is about getting the address in correctly. For the legal basis of the consent you collect here, see Compliance. For what you do with the address afterward, see Marketing Emails. For how clean capture protects your reputation, see Deliverability.
Why capture is the highest-leverage point in the whole system
Everything downstream of capture is damage control. Once a bad address is in your database, you spend money sending to it, you risk a hard bounce, you might hit a spam trap, and you carry the compliance liability of a consent record you cannot prove. The cheapest place to keep a bad address out is the form itself; the second cheapest is the verification click; everything after that is expensive.
Concretely, the cost of a single bad address compounds across the system:
| Stage where you catch it | Relative cost | What it costs you |
|---|---|---|
| Client-side (form) | ~1x | A few lines of JS, instant feedback |
| Server-side (API) | ~2x | A DNS/MX lookup, a database write you avoid |
| Double opt-in (verification) | ~5x | One transactional send that goes unconfirmed |
| First marketing send | ~50x | A hard bounce against your reputation |
| Ongoing (spam trap / complaint) | ~500x+ | Blocklisting, throttling, a deliverability incident |
The numbers are illustrative, but the shape is real and consistent: the further right you catch a bad address, the more it costs, and the cost curve is roughly exponential. A spam-trap hit or a sustained complaint rate above the Gmail/Yahoo/Microsoft threshold (0.3%, in force since February 2024) can suppress delivery for your entire list, not just the bad address. This is why capture quality is not a "nice to have" UX detail; it is the input that determines whether the rest of the pipeline works at all. See Deliverability for how reputation actually degrades.
The four properties every captured address must have
A good capture pipeline guarantees four independent properties. Validation, verification, and consent capture each cover a different one, and you need all four:
- Syntactically valid, the string is a well-formed address (RFC 5322).
- Routable, the domain exists and accepts mail (DNS + MX).
- Owned by the submitter, the person who typed it controls the mailbox (double opt-in).
- Consented, the person affirmatively agreed to what you will send, and you can prove it (consent capture + storage).
A common failure is to conflate these. An MX check proves routability, not ownership. A confirmation click proves ownership and deliverability, not that you stored a defensible consent record. A pre-ticked box "captures consent" but fails property 4 because it is not provable as freely given. Treat the four as a checklist, not a single step.
How do I validate an email address?
Validation answers a deceptively simple question: is this a real, usable email address? You need two layers and both are mandatory: client-side validation for fast feedback, and server-side validation for the real check. Client-side alone is never enough, and server-side alone gives a worse user experience.
How should client-side validation work?
Client-side validation gives the user instant feedback so they can fix mistakes before submitting. Treat it as a UX convenience, not a security or quality guarantee. Anyone can bypass it.
Start with the HTML5 input:
HTML template code
<input type="email" name="email" autocomplete="email" inputmode="email"
spellcheck="false" autocapitalize="off" required>
This alone gives you a basic format check and, on mobile, the email-optimized keyboard (with @ and . keys). The extra attributes matter more than they look:
autocomplete="email"lets the browser and password managers fill the field, which both raises completion and reduces typos (a remembered address is a correct address).inputmode="email"triggers the email keyboard even on inputs styled as something other thantype="email".spellcheck="false"andautocapitalize="off"stop mobile keyboards from "correcting"jane@acme.iointoJane@acme.ioor flagging the local part as a misspelling.
Then follow these best practices:
- Validate on blur, or with a short debounce. Do not show an angry red error while the user is still typing the first character. Wait until they leave the field, or pause briefly (roughly 300 to 500 ms) after they stop typing.
- Show clear error messages. "Please enter a valid email address", not "Invalid".
- Do not be too strict. Allow unusual but valid formats. Email addresses can contain
+, dots, and subaddressing; over-aggressive regexes reject real addresses (for exampleuser+tag@domain.com). The official RFC 5322 grammar is famously permissive, so a tight regex will always reject someone real. - Client-side validation does not equal deliverability. Passing an HTML5 check only means the string looks like an email. It says nothing about whether the domain exists or the mailbox accepts mail.
Why "don't be too strict" matters: the most common email-validation bug is a regex that rejects valid addresses. Every rejected real user is a lost signup and a support complaint. When in doubt, accept and verify (with double opt-in) rather than block.
A pragmatic client-side check
You do not need to implement RFC 5322 in JavaScript. The goal client-side is to catch obvious garbage ("no @", "two @", "ends in a dot") while never rejecting a plausible real address. A deliberately loose regex plus a "has exactly one @ and a dot after it" sanity check is enough:
Code for your engineers (TypeScript)
// Deliberately permissive: catches obvious mistakes, rejects almost no real address.
export function looksLikeEmail(input: string): boolean {
const value = input.trim();
if (value.length > 254) return false; // RFC 5321 max total length
const at = value.lastIndexOf("@");
if (at <= 0 || at === value.length - 1) return false;
const local = value.slice(0, at);
const domain = value.slice(at + 1);
if (local.length > 64) return false; // RFC 5321 max local-part length
if (!domain.includes(".")) return false; // needs a dot in the domain
if (domain.startsWith(".") || domain.endsWith(".")) return false;
if (/\s/.test(value)) return false; // no spaces
return true;
}
The two numeric limits are from RFC 5321: the local part (before the @) is capped at 64 octets and the whole address at 254 octets in practice. Enforcing these client-side is safe because no real address exceeds them.
What should server-side validation check?
Always validate server-side. Client-side checks can be bypassed via disabled JavaScript, direct API calls, or bots. The server is the authoritative gate, and it is the only place validation actually counts.
Check, in order, and stop at the first failure that you treat as fatal:
- Email format (RFC 5322): use a robust parser, not a naive regex.
- Normalize the address: lowercase the domain (the domain is case-insensitive), trim whitespace, and strip a trailing dot. Do not lowercase or rewrite the local part, per RFC 5321 the local part is technically case-sensitive, and
John.Doe@example.comandjohn.doe@example.commay be different mailboxes. Most providers treat them the same, but rewriting them is a correctness bug you do not need. - Domain exists (DNS lookup): does
domain.comresolve at all? - Domain has MX records: does it actually accept mail? A domain with no MX record cannot receive email, so the address is undeliverable. As a fallback, RFC 5321 §5.1 says a domain with an A/AAAA record but no MX may still accept mail on the A record (an "implicit MX"), so treat "no MX but has A" as deliverable, not invalid.
- Optionally, disposable email detection: flag throwaway domains (for example 10-minute-mail style) if your use case warrants it.
- Optionally, role-account detection: flag
info@,admin@,sales@,noreply@,postmaster@and similar shared addresses for marketing flows. They tend to have multiple readers, higher complaint rates, and unclear consent.
A reference MX check in Node
The MX check is the highest-value server-side check after format, because typo'd domains (@gmial.con) are the single biggest source of hard bounces. Node's dns/promises does this with no external dependency:
Code for your engineers (TypeScript)
import { promises as dns } from "node:dns";
type RoutableResult = { routable: boolean; reason?: string };
export async function isRoutableDomain(domain: string): Promise<RoutableResult> {
const host = domain.toLowerCase().replace(/\.$/, "");
try {
const mx = await dns.resolveMx(host);
// Some misconfigured domains return a single "." MX meaning "no mail accepted" (RFC 7505 null MX).
if (mx.length === 1 && mx[0].exchange === "") {
return { routable: false, reason: "null-mx" };
}
if (mx.length > 0) return { routable: true };
} catch (err: any) {
if (err.code !== "ENOTFOUND" && err.code !== "ENODATA") {
// DNS error (timeout, SERVFAIL), do NOT treat as invalid. Accept and verify later.
return { routable: true, reason: "dns-error-soft-accept" };
}
}
// No MX, fall back to implicit MX (A/AAAA record) per RFC 5321 §5.1.
try {
await dns.lookup(host);
return { routable: true, reason: "implicit-mx" };
} catch {
return { routable: false, reason: "no-mx-no-a" };
}
}
Two subtleties this code encodes and that most naive implementations get wrong:
- RFC 7505 "null MX": a domain that explicitly does not accept mail publishes a single MX record
0 .(exchange is the root). Treat that as undeliverable, not deliverable. - Soft-fail on DNS errors: a DNS timeout or SERVFAIL is your problem, not the user's. Rejecting the signup because your resolver hiccupped is a self-inflicted lost conversion. Accept the address and let double opt-in be the real deliverability test.
Recommended tools: dedicated email-verification services (such as ZeroBounce, NeverBounce, Kickbox, or Emailable) handle syntax, MX, disposable-domain checks, role-account detection, and sometimes SMTP-level mailbox probing via API. Use them when the cost of a bad address is high (cold-ish lists, paid acquisition) and you want signal before the first send. For most consumer signup flows, format + MX + double opt-in is sufficient and avoids the per-check fee and the privacy footprint of sending every address to a third party.
A note on SMTP "ping" verification. Some services confirm a mailbox exists by opening an SMTP connection and issuing
RCPT TOwithout sending. This is increasingly unreliable: major providers (Gmail, Outlook) accept-all at the edge and reject later, return greylisting deferrals, or rate-limit probers. Treat a positive SMTP probe as a weak signal and a negative as inconclusive, never as ground truth. The only definitive proof a mailbox is real and owned is a click on a verification link.
Why does each server-side check exist?
Each check stops a different kind of bad address before it can hurt you:
- Format catches structural garbage before it reaches your database.
- DNS / MX catches typo'd or fake domains (
@gmial.con) that would otherwise hard-bounce, and every hard bounce damages your sender reputation. - Disposable detection is a judgment call. Blocking throwaway addresses reduces abuse and junk signups, but can also block privacy-conscious legitimate users. Use it where abuse is a real cost (for example free-trial farming), not reflexively.
- Role-account detection is similarly a judgment call.
support@is a perfectly good destination for a B2B transactional receipt, but a poor target for a personal marketing newsletter where one recipient's complaint affects an inbox several people read.
The reputation link: invalid addresses that get through do not just sit harmlessly, they bounce. A high bounce rate tells mailbox providers you do not manage your list, and they start sending you to spam. Keep hard bounces under 2% and ideally under 1%. Validation at capture is your first line of bounce prevention. See Deliverability.
How should I keep disposable-domain detection current?
A static list of disposable domains goes stale within weeks; new throwaway domains appear constantly. Options, in increasing order of effort and accuracy:
- Open-source blocklists (community-maintained lists of thousands of disposable domains). Cheap, but always slightly behind and prone to false positives when a list over-aggressively includes a domain some real users use.
- A verification API that maintains the list for you and returns a
disposable: trueflag. - Behavioral signals instead of domain matching, block based on abuse patterns (many signups from one IP, sequential addresses) rather than the domain itself. This catches custom disposable setups a list will never know about.
Whichever you choose, fail open by default: if disposable detection is uncertain, accept the address and rely on double opt-in and engagement signals. Wrongly blocking a real user is usually worse than admitting one throwaway address that never confirms.
What are common email-validation mistakes?
- Relying on a copy-pasted regex that rejects
+, subaddressing, or new TLDs. - Validating only on the client and trusting it server-side.
- Doing an MX check but not caching results, so a slow DNS lookup blocks the form.
- Treating a DNS timeout as "invalid email" and rejecting a real user for your resolver's failure.
- Lowercasing or otherwise rewriting the local part and breaking case-sensitive mailboxes.
- Blocking disposable domains by default and silently losing real privacy-minded users.
- Treating a passed format check as proof the mailbox exists. It is not.
- Trusting a single SMTP
RCPT TOprobe as definitive when major providers accept-all.
Email validation checklist
-
type="email" requiredwithautocomplete="email"andinputmode="email"on the input - Client-side validation on blur / debounced, with clear messages
- Format validator is permissive (accepts
+, subaddressing, new TLDs) - Server-side format check (RFC 5322)
- Address normalized (domain lowercased, whitespace trimmed; local part untouched)
- DNS lookup confirms the domain resolves
- MX record check confirms the domain accepts mail (with implicit-MX and null-MX handling)
- DNS errors soft-accept rather than reject
- MX lookups cached / time-boxed so they never block the form
- Disposable-email detection considered for abuse-prone flows, failing open
- Role-account detection considered for marketing flows
What is double opt-in?
Double opt-in confirms two things at once: that the address actually belongs to the person submitting it, and that the mailbox is deliverable. The user proves both by clicking a link in a confirmation email. Until they click, the address is pending and is not on your active sending list.
What does the double opt-in process look like?
- 1
User enters their address in the form
- 2
Saved as “pending”
Not on the list yet. No marketing. - 3
Send the verification email
With a one-time token and a short TTL. - 4
Click the link → confirmation
Record the confirmation time and IP. - 5
“Confirmed” → added to the list
No click within the TTL window? The address expires and never joins the list.
- User submits email through your form.
- Send a verification email with a unique link/token.
- User clicks the link.
- Mark the address as verified.
- Allow access / add to list.
Until step 4, the address is pending, not yet on your active sending list. Never send marketing to a pending address.
How do I generate and verify the token securely?
The verification token is a bearer credential: whoever holds it can confirm the address. It must be unguessable, single-use, and time-limited. Two correct patterns:
Pattern A, opaque random token stored server-side. Generate a high-entropy random value, store only its hash, and look it up on click. This is the simplest to reason about and easy to revoke (just delete the row).
Code for your engineers (TypeScript)
import { randomBytes, createHash } from "node:crypto";
// At signup:
const rawToken = randomBytes(32).toString("base64url"); // 256 bits of entropy
const tokenHash = createHash("sha256").update(rawToken).digest("hex");
// Store tokenHash, email, createdAt, expiresAt, usedAt=null in the DB.
// Put rawToken in the verification URL: https://app.example.com/verify?t=<rawToken>
// On click:
function verify(rawTokenFromUrl: string) {
const hash = createHash("sha256").update(rawTokenFromUrl).digest("hex");
// SELECT ... WHERE tokenHash = hash AND usedAt IS NULL AND expiresAt > now()
// If found: set usedAt = now(), mark address verified. Single-use enforced by usedAt.
}
Store the hash, not the raw token, so a database leak does not hand an attacker every pending verification link. The raw token lives only in the email.
Pattern B, signed stateless token (HMAC). Encode the email and an expiry into a token signed with a server secret. No storage needed for the token itself, but you still need a server-side record to enforce single use (a token you cannot revoke can be replayed until it expires).
Code for your engineers (TypeScript)
import { createHmac, timingSafeEqual } from "node:crypto";
const SECRET = process.env.VERIFY_SIGNING_KEY!; // 32+ random bytes, never in the repo
function sign(payload: string): string {
const mac = createHmac("sha256", SECRET).update(payload).digest("base64url");
return `${Buffer.from(payload).toString("base64url")}.${mac}`;
}
function verifySignature(token: string): { ok: boolean; payload?: string } {
const [body, mac] = token.split(".");
if (!body || !mac) return { ok: false };
const payload = Buffer.from(body, "base64url").toString();
const expected = createHmac("sha256", SECRET).update(payload).digest("base64url");
const a = Buffer.from(mac);
const b = Buffer.from(expected);
// Constant-time compare avoids leaking validity through timing.
if (a.length !== b.length || !timingSafeEqual(a, b)) return { ok: false };
return { ok: true, payload }; // payload = `${email}|${expiresAtMs}`, still check expiry + single-use
}
Whichever pattern you use, the non-negotiable properties are: ≥128 bits of entropy (Pattern A) or HMAC-SHA256 with a secret never exposed to the client (Pattern B); constant-time comparison so you do not leak validity through response timing; single-use enforcement in your own store; and expiry.
Never put the verification action behind a GET that mutates state unsafely, but do expect GET. Email clients and security scanners pre-fetch links. A link that confirms the address on a bare
GETcan be auto-confirmed by a corporate link scanner before the human ever clicks, a false positive. Mitigate by making the click land on a confirmation page (GET, idempotent, shows a "Confirm" button) and performing the state change on the button's POST, or by accepting that scanner pre-fetch happens and treating the address as confirmed only after a real interaction where the stakes require it. For low-stakes newsletters, GET-confirm is usually acceptable; for account activation, prefer the interstitial page.
What timing rules should the verification step follow?
Send the verification email immediately, include an expiration (24 to 48 hours), allow resend after 60 seconds, and limit resend attempts (3 per hour). These mirror the transactional-email rules. See Transactional Emails for the design details.
When should I use single opt-in instead of double opt-in?
Use double opt-in for all marketing emails. Use single opt-in only when the list is a byproduct of a transaction the user clearly initiated (creating an account, placing an order), where a separate confirmation step would be redundant friction. The table below compares them.
| Single Opt-In | Double Opt-In | |
|---|---|---|
| Process | Add to list immediately | Require email confirmation first |
| Pros | Lower friction, faster growth | Verified addresses, better engagement, meets GDPR/CASL |
| Cons | Higher invalid rate, lower engagement | Some users don't confirm |
| Use for | Account creation, transactional | Marketing lists, newsletters |
Recommendation: use double opt-in for all marketing emails.
A decision table for which to use
| Scenario | Recommended flow | Why |
|---|---|---|
| Newsletter / blog subscription | Double opt-in | Pure marketing consent; needs auditable proof |
| Lead magnet (gated PDF, webinar) | Double opt-in | High fake-address rate; people enter junk to grab the asset |
| Account signup with email/password | Single opt-in for the account + verification of the address; separate marketing consent | The account is the initiated transaction; marketing is a distinct purpose |
| Checkout / order placed | Single opt-in for transactional receipts; double opt-in (or explicit unchecked box) for marketing | The order initiates transactional mail; marketing needs its own consent |
| Imported / purchased list | Do not send. Re-permission with a one-time confirmation if you must | No consent exists; sending is both a deliverability and a legal risk |
| Contest / giveaway entry | Double opt-in | Extremely high junk-address rate; consent is murky |
| B2B contact form ("contact sales") | Single opt-in for the reply only | A reply to an inbound request is not marketing |
A repeated theme: the address being deliverable (verification) is a separate question from whether the person consented to marketing (consent capture). You can verify an address at account signup without that person ever consenting to your newsletter. Keep the two decisions independent. See Compliance and Email Types.
Why is double opt-in worth the friction?
The obvious objection is "we'll lose the people who don't confirm." That is true, but the people who do not confirm are disproportionately the ones who gave a fake address, typo'd it, or were not really interested. Expect roughly 10 to 30% of submissions to never confirm; filtering them out improves your list:
- Cleaner list: every confirmed address is real and deliverable, so bounce rates stay low.
- Better engagement: confirmed subscribers actually want your email, so open/click rates rise, which mailbox providers reward with better inbox placement.
- Legal cover: the confirmation click is auditable proof of consent, which directly supports GDPR and CASL requirements (see Compliance).
- Anti-abuse: stops bots and malicious actors from signing other people up to your list using their addresses (list-bombing / subscription-bombing).
Single opt-in is the right choice when the list is a byproduct of a transaction the user clearly initiated, creating an account or placing an order, where a separate confirmation step would be redundant friction.
What happens to addresses that never confirm?
Keep them in the pending state, do not send to them, and purge them on a schedule (for example after 30 days). Never silently activate a pending address. If you want one reminder, send a single follow-up before the token expires, then stop. Repeated nags to an unconfirmed address are themselves a spam risk.
A pending address is a liability if mishandled: it is an address you have not confirmed you may contact. Sending it more than the original verification email plus one reminder edges toward sending unconsented mail. The data-retention angle also matters under GDPR, holding unconfirmed addresses indefinitely is data you have no lawful basis to keep. A scheduled purge of unconfirmed pending records (commonly 30 days) is both a deliverability hygiene practice and a data-minimization practice. See Compliance and List Management.
How do I defend against subscription-bombing through my form?
Attackers use open signup forms to flood a victim's inbox: they submit the victim's address to thousands of newsletters at once, burying a real confirmation email (a bank fraud alert, say) under noise. Your form must not become a weapon:
- One pending record per address, if an address already has an unexpired pending token, do not generate a new email on resubmission; resend the existing one under the resend rate limit.
- Rate-limit per submitted address, not just per IP. The attacker varies the IP; the constant is the victim's address.
- Rate-limit per IP / session / fingerprint to slow the attack source.
- CAPTCHA or proof-of-work only when you detect a burst, never on every legitimate signup.
- Make the verification email itself defensible with an "I didn't sign up, ignore this" message, so a victim who does see it knows it is safe to discard.
Double opt-in checklist
- New marketing signups start in a pending state
- Verification email sent immediately
- Token has ≥128 bits of entropy, or is HMAC-signed with a server-only secret
- Only the token hash is stored server-side (opaque-token pattern)
- Token comparison is constant-time
- Link/token is single-use (enforced in your own store)
- Token expires in 24 to 48 hours
- Resend allowed after 60s, capped at 3/hour, reusing the existing pending record
- Link-scanner pre-fetch accounted for (interstitial page for high-stakes flows)
- Address activated only after the click
- Confirmation timestamp + IP + form URL recorded as consent proof
- Unconfirmed pending records purged on a schedule
How do I design a capture form that converts?
The form is where capture succeeds or fails. The goal is to make it effortless for a genuine person to give you a correct address, while collecting honest consent. Keep it short, make one action obvious, and never trick anyone into consent.
How should the email input field behave?
- Use
type="email"so mobile devices show the email keyboard (with@and.). - Add
autocomplete="email"so password managers and browsers fill it, autofilled addresses are correct addresses. - Include a placeholder ("you@example.com") to model the expected format, but never as a replacement for a visible label.
- Show clear error messages, "Please enter a valid email address", not "Invalid".
- Disable autocapitalize and spellcheck (
autocapitalize="off" spellcheck="false") so the mobile keyboard does not "fix" the address.
How do I collect valid marketing consent with checkboxes?
For marketing signups, consent must be explicit and informed. The checkbox must require a deliberate action.
- Unchecked by default (required). Pre-checked consent is not valid consent under GDPR, and is a dark pattern everywhere.
- Specific language about what they are signing up for.
- Separate checkboxes for different email types. Do not bundle "newsletter" and "promotions" into one tick.
- Link to your privacy policy.
HTML template code
<fieldset>
<legend>Choose what you'd like to receive (optional)</legend>
<label>
<input type="checkbox" name="consent_newsletter" value="yes">
Subscribe to our weekly newsletter with product updates
</label>
<label>
<input type="checkbox" name="consent_promotions" value="yes">
Send me promotional offers and deals
</label>
<p>
We process your data per our
<a href="/privacy">Privacy Policy</a>. Unsubscribe anytime.
</p>
</fieldset>
Don't: pre-check boxes, use vague language, or hide consent in the terms of service.
Why unchecked-by-default is non-negotiable: under GDPR, consent must be a freely given, specific, informed, and unambiguous affirmative action. A pre-ticked box is none of those, the user did nothing. Beyond the legal requirement, consent you tricked someone into is worthless: those subscribers do not engage, they mark you as spam, and they drag down your reputation. Honest consent is also better consent. See Compliance.
What dark patterns must a consent form avoid?
"Dark-pattern-free" is a hard requirement, not a style preference. The following are all consent-invalidating in the EU and corrosive everywhere; avoid every one:
| Dark pattern | What it looks like | Why it's a problem |
|---|---|---|
| Pre-ticked box | Consent checkbox checked on load | Not an affirmative action; invalid consent under GDPR |
| Forced bundling | One tick covers newsletter + promotions + partners | Not "specific"; user can't consent to one and not another |
| Consent-by-continuing | "By signing up you agree to receive marketing" with no opt-out | Marketing consent must be separable from the transaction |
| Confirmshaming | Decline option reads "No, I don't want to save money" | Manipulative; coerces the choice |
| Asymmetric prominence | Giant "Subscribe", greyed-out tiny "No thanks" | Choice is not freely given |
| Buried consent | Consent text only in linked Terms of Service | Not "informed"; user never saw it |
| Roach motel | Easy to subscribe, multi-step to leave | Violates one-click-unsubscribe expectations (see below) |
The last row connects forward: since February 2024, Gmail, Yahoo, and Microsoft require bulk senders to honor one-click unsubscribe (RFC 8058: a List-Unsubscribe header plus List-Unsubscribe-Post: List-Unsubscribe=One-Click). A capture flow that makes leaving hard is not just a dark pattern, it sets you up to violate sender requirements downstream. Design the exit at the same time you design the entrance. See Marketing Emails and Compliance.
What consent metadata must I capture and store?
Capturing consent is not enough; you must be able to prove it later, potentially years afterward in response to a regulator or a complaint. Store an immutable consent record at the moment of the affirmative action:
| Field | Why you need it |
|---|---|
| Email address (normalized) | The subject of the consent |
| Consent purpose(s) | Which specific lists/types they agreed to |
| Timestamp (UTC, ISO 8601) | When consent was given |
| Source / form URL | Where it happened (which form, which campaign) |
| IP address | Corroborating evidence of who and where |
| User agent | Further corroboration |
| Exact consent wording shown | What they actually agreed to, store the literal text or a version id |
| Consent method | "double opt-in click", "checkbox", etc. |
| Double opt-in confirmation timestamp + IP | Proof the address was also verified |
A consent record should be append-only: when someone changes preferences or withdraws, write a new record, do not overwrite the old one. The audit trail is the whole point. Storing the exact wording (or a version identifier pointing to it) matters because "what did the user agree to" is the question regulators actually ask, and "the current text on our form" is not an answer if the text has changed since.
Code for your engineers (TypeScript)
type ConsentRecord = {
email: string; // normalized
purposes: string[]; // ["newsletter", "promotions"]
grantedAt: string; // ISO 8601 UTC
source: string; // e.g. "https://example.com/blog/post-x#footer-form"
ip: string;
userAgent: string;
wordingVersion: string; // e.g. "consent-copy-2026-03"
method: "checkbox" | "double-opt-in" | "import-reconsent";
doubleOptInConfirmedAt?: string;
doubleOptInConfirmIp?: string;
};
How should the form be laid out?
- Keep it simple and focused. Every extra field lowers completion, so ask only for what you need.
- One primary action: a single, obvious submit button.
- Clear value proposition: tell the user what they get and how often.
- Mobile-friendly: large tap targets (≥44×44 px), no tiny fields.
- Accessible: real
<label>s associated with inputs, ARIA where needed, keyboard navigable, and errors announced to screen readers (aria-live,aria-describedbylinking the field to its error text).
How do I stop bots without hurting real users?
Bots inflate your list with garbage and can trigger subscription-bombing. Defend in layers, cheapest and least intrusive first:
- Honeypot field, a hidden input real users never fill; any submission that fills it is a bot. Zero friction, catches naive bots.
- Time-to-submit check, a form submitted in under ~1 second was almost certainly scripted.
- Origin / token check, require a server-issued form token (also CSRF protection) so blind POSTs to your endpoint fail.
- Invisible / risk-based CAPTCHA, only escalate to a visible challenge when the prior signals look suspicious.
HTML template code
<!-- Honeypot: visually hidden, off-screen, aria-hidden. Real users never see or fill it. -->
<div aria-hidden="true" style="position:absolute; left:-9999px;">
<label>Leave this field blank
<input type="text" name="website" tabindex="-1" autocomplete="off">
</label>
</div>
Reserve visible CAPTCHA for observed abuse. A visible challenge on every signup measurably lowers conversion and creates accessibility barriers (it can be a hard blocker for users with visual or motor impairments), so it should be the last resort, not the first.
What are common form-design mistakes?
- Asking for name, company, and phone when you only need the email.
- A pre-checked or bundled consent box that fails GDPR and breeds complaints.
- Any of the dark patterns in the table above (confirmshaming, asymmetric buttons, consent-by-continuing).
- A vague value proposition ("Join our list") with no frequency or content promise.
- Tiny tap targets or a submit button hidden below the fold on mobile.
- Placeholder text used as the only label, which breaks screen readers and disappears on focus.
- Capturing consent but storing no metadata, leaving you unable to prove it later.
- A visible CAPTCHA on every signup, suppressing conversion and excluding disabled users.
Form design checklist
-
type="email"withautocomplete="email",inputmode="email", and a realistic placeholder - Visible
<label>for the field (placeholder is not a label) - One focused form, one primary action
- Value proposition stated ("weekly", "product updates")
- Marketing consent checkboxes unchecked by default
- Separate checkboxes per email type
- Privacy policy linked near the consent
- No pre-checked boxes, no vague or buried consent, no confirmshaming, no asymmetric buttons
- Full consent metadata captured (timestamp, IP, UA, source, wording version)
- Honeypot + time-to-submit + CSRF token; CAPTCHA only on observed abuse
- Labels associated with inputs; errors announced via
aria-live; keyboard- and screen-reader-accessible - Tap targets ≥44×44 px
How do I handle capture errors and abuse?
Capture forms meet a lot of edge cases: typos, people who are already on the list, and bots. Handling each well improves both conversion and security, so treat error handling as part of the product, not an afterthought.
What should I do when the email is invalid?
- Show a clear error message at the field.
- Suggest corrections for common typos:
@gmial.combecomes@gmail.com,@hotmial.combecomes@hotmail.com,@gmail.conbecomes@gmail.com. A "Did you mean...?" prompt recovers users who would otherwise bounce or give up. - Allow the user to fix and resubmit without re-entering everything.
Why typo suggestions pay off: a large share of hard bounces are simple typos in popular domains. Catching them at the form, before they ever enter your list, prevents the bounce entirely and saves the signup. It is one of the highest-ROI bits of capture UX.
How typo suggestion works
The standard approach computes the edit distance (Levenshtein / Damerau-Levenshtein) between the entered domain and a list of popular domains and TLDs, and suggests the nearest match within a small threshold. Open-source libraries like mailcheck implement exactly this. The key rules:
- Suggest, never auto-correct. Replacing
@gmail.conwith@gmail.comsilently is wrong if the user really meant an obscure.con-adjacent domain (rare, but auto-correct removes their ability to override). Show "Did you mean you@gmail.com?" as a one-tap accept. - Cover both the domain and the TLD,
gmial.com(domain typo) andgmail.con(TLD typo) are different error classes. - Keep the popular-domain list short and high-traffic (gmail.com, yahoo.com, outlook.com, hotmail.com, icloud.com, plus your region's big providers). A long list increases false suggestions.
Code for your engineers (TypeScript)
// Sketch: suggest a correction only when an entered domain is one edit away from a known one.
// Use Damerau-Levenshtein so a transposition (gmial.com -> gmail.com) counts as a single edit;
// plain Levenshtein scores that swap as distance 2 and would miss it.
const POPULAR = ["gmail.com", "yahoo.com", "outlook.com", "hotmail.com", "icloud.com"];
function suggestDomain(domain: string): string | null {
const d = domain.toLowerCase();
if (POPULAR.includes(d)) return null; // already correct
for (const candidate of POPULAR) {
if (damerauLevenshtein(d, candidate) === 1) return candidate; // one typo/transposition away
}
return null;
}
What should I show when the email is already registered?
- Accounts: "This email is already registered. [Sign in]"
- Marketing: "You're already subscribed! [Manage preferences]"
- Do not reveal whether an account exists for security-sensitive flows.
The security tension: telling an attacker "this email is already registered" confirms an account exists (account enumeration). For login/signup flows, prefer a neutral response ("If that email isn't already registered, we've sent a confirmation") and deliver the specific message by email instead. For a low-stakes newsletter, a friendly "you're already subscribed" is usually fine. Match the strictness to the sensitivity.
Resubscribe and the re-consent case
A subtle case: someone who previously unsubscribed (or was suppressed for bouncing) resubmits the form. Do not silently re-add them.
- If they unsubscribed, treat the new submission as a fresh, deliberate opt-in, run them through double opt-in again, and write a new consent record. Their prior unsubscribe is a withdrawal you must honor until they re-consent explicitly.
- If they are on a suppression list for hard-bouncing, a fresh valid-looking submission of the same address can clear the suppression only after a successful verification click proves the mailbox is now deliverable. Never auto-remove an address from suppression just because someone typed it again.
This matters for both deliverability and compliance: re-mailing a previously-unsubscribed address without new consent is a complaint magnet and, depending on jurisdiction, unlawful. See List Management and Compliance.
How do I rate-limit the form to stop abuse?
- Limit verification emails: 3 per hour per email address. Stops attackers from using your form to spam a third party (and protects your reputation).
- Rate limit form submissions generally (per IP, per session, per fingerprint).
- Use CAPTCHA sparingly: only when you actually see abuse; it hurts conversion and accessibility.
- Monitor for abuse patterns: bursts of signups, sequential addresses, single-IP floods, signups from datacenter IP ranges.
A layered rate-limit design
Effective limiting uses several keys at once, because each defends against a different attack:
| Limit key | Defends against | Suggested ceiling |
|---|---|---|
| Per submitted address | Subscription-bombing a victim | 3 verification emails / hour |
| Per IP | A single source spamming the form | e.g. 10 submissions / hour |
| Per session / device | Scripted multi-address abuse from one client | e.g. 20 / hour |
| Global form rate | A distributed flood | Alert above a baseline you observe |
Implement these with a sliding-window or token-bucket counter in a fast store (e.g. Redis). When a limit trips, prefer a soft response, "Please try again shortly" or a CAPTCHA, over a hard ban, since false positives hit real users. Log every trip so you can tune ceilings against real traffic rather than guesses.
How do I distinguish a real bounce from an abuse signal after capture?
Some bad addresses only reveal themselves after the verification send. Connect your bounce/complaint webhooks back to capture so the form learns:
- A verification email that hard-bounces means the validation passed but the mailbox is dead, feed that domain pattern back into your monitoring.
- A spike in complaints on verification emails specifically suggests subscription-bombing (victims marking the unwanted confirmation as spam), tighten per-address limits.
- Repeated soft bounces then a hard bounce on the same address is a normal lifecycle, not abuse.
Wiring these signals requires consuming your provider's webhook events. See Webhooks and Events for the event model and verification, and Sending Reliability for retry and bounce handling.
Error-handling checklist
- Field-level error messages, human-readable, announced to screen readers
- "Did you mean...?" typo suggestions for common domains and TLDs (suggest, don't auto-correct)
- Resubmit without re-entering all fields
- Neutral, non-enumerating responses on sensitive flows
- Previously-unsubscribed addresses run through fresh double opt-in, not silently re-added
- Suppressed (bounced) addresses cleared only after a successful verification click
- Verification emails capped at 3/hour per address
- Form submissions rate-limited per IP / session / fingerprint
- CAPTCHA only when abuse is observed
- Signup patterns monitored for abuse; bounce/complaint webhooks fed back to capture
How do I design a verification email people actually click?
The verification email is the email half of double opt-in. Because the entire signup depends on the user clicking it, it has to be clear, fast, and trustworthy. Send it instantly, from a trusted sender, with one obvious action.
What should the verification email contain?
- Clear purpose: "Verify your email address".
- Prominent verification button.
- Expiration time: state when the link stops working.
- Plain-text fallback link under the button, in case the button does not render.
- Resend option.
- "I didn't request this" notice so someone signed up against their will can opt out (and so a subscription-bombing victim knows the message is safe to ignore).
- Do not include OTP/2FA codes in the subject line or preview text. It discourages opens (the user feels done without opening) and exposes the code in notification previews.
How should the verification email be designed?
- Mobile-friendly: most verification clicks happen on a phone.
- Large, tappable button (≥44 px tall).
- Clear call-to-action.
See Transactional Emails for detailed email design guidance. Verification emails follow exactly the same design rules (subject lines, pre-header, mobile-first layout, code/link display, error handling).
What authentication must the verification email pass?
A verification email that lands in spam defeats the entire capture flow, the user already decided to join and you lose them at the last step. Because it is the highest-stakes message in the funnel, it must pass authentication cleanly:
- SPF (RFC 7208), your sending IP is authorized for the envelope-from domain.
- DKIM (RFC 6376), the message is cryptographically signed and unmodified in transit.
- DMARC (RFC 7489), published policy aligns SPF/DKIM with the visible
From:domain, and tells receivers what to do on failure.
For high-volume senders the February 2024 Gmail/Yahoo/Microsoft requirements make this non-optional: bulk senders must authenticate with SPF and DKIM, publish DMARC, keep the spam-complaint rate under 0.3%, and support one-click unsubscribe on marketing mail (RFC 8058). A verification email is transactional and not subject to the unsubscribe requirement, but it must still pass SPF/DKIM/DMARC to reach the inbox. Adjacent standards worth having in place as your program matures: ARC (RFC 8617) to preserve auth results across forwarders, MTA-STS (RFC 8461) and TLS-RPT (RFC 8460) to enforce and report on TLS, and BIMI to display your logo once DMARC is at enforcement. The full authentication setup, with copy-paste DNS records, lives in Deliverability.
Minimal DNS records, as illustration (your provider supplies the exact selector and values):
; SPF, authorize your provider's sending infrastructure (RFC 7208)
example.com. IN TXT "v=spf1 include:_spf.your-provider.example -all"
; DKIM, public key your provider signs against (RFC 6376); selector varies per provider
prov1._domainkey.example.com. IN TXT "v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEB..."
; DMARC, start at p=none to monitor, then move to quarantine/reject (RFC 7489)
_dmarc.example.com. IN TXT "v=DMARC1; p=none; rua=mailto:dmarc@example.com; adkim=s; aspf=s"
# Verify each record resolves before trusting your verification send:
dig +short TXT example.com # expect the v=spf1 line
dig +short TXT prov1._domainkey.example.com # expect the v=DKIM1 key
dig +short TXT _dmarc.example.com # expect the v=DMARC1 policy
Why do verification emails deserve extra care?
This is the email the entire signup hinges on. If it lands in spam, looks like phishing, or its button is hard to tap, the user never confirms, and you lose them after they had already decided to join. Treat it as a high-priority transactional email: send instantly, from a trusted sender, with one obvious action.
How should I send the verification email provider-neutrally?
Keep the send behind a thin abstraction so the capture logic never depends on a specific ESP. Any of Amazon SES, Postmark, SendGrid, Mailgun, Resend, Brevo, SparkPost, or Mandrill can sit behind the same interface; swapping providers should not touch your capture code.
Code for your engineers (TypeScript)
// Provider-neutral interface, implement once per provider, call everywhere.
interface EmailClient {
send(message: {
to: string;
subject: string;
html: string;
text: string; // always include a plain-text part
headers?: Record<string, string>;
}): Promise<{ id: string }>;
}
async function sendVerificationEmail(
emailClient: EmailClient,
to: string,
verifyUrl: string,
): Promise<void> {
await emailClient.send({
to,
subject: "Verify your email address",
html: `<p>Confirm your address to finish signing up.</p>
<p><a href="${verifyUrl}">Verify email</a></p>
<p>Or paste this link: ${verifyUrl}</p>
<p>This link expires in 24 hours. If you didn't sign up, ignore this email.</p>`,
text: `Confirm your address to finish signing up.\n\n${verifyUrl}\n\n` +
`This link expires in 24 hours. If you didn't sign up, ignore this email.`,
});
}
The EmailClient interface is the seam: the SES implementation, the Postmark implementation, and so on all satisfy it, and your capture code only ever sees the interface. This is the same pattern used throughout this guide for sends and for Webhooks and Events, depend on a standard contract (raw SMTP, the Standard Webhooks scheme, Node crypto), never on a vendor SDK's surface.
Verification email checklist
- Subject clearly states "Verify your email" (no codes in it)
- One prominent verification button, above the fold on mobile, ≥44 px tall
- Expiration stated next to the button
- Plain-text fallback link under the button
- Plain-text alternative part included in the message
- Resend option available
- "I didn't request this" notice with a way to opt out
- Sent immediately from a trusted, consistent sender
- Passes SPF (RFC 7208), DKIM (RFC 6376), and DMARC (RFC 7489) alignment
- Sent through a provider-neutral abstraction, not a vendor-locked SDK
- Renders well on mobile and in dark mode
Common mistakes across the whole capture flow
A consolidated list of the failure modes this chapter warns against, for quick review:
- Trusting client-side validation. It is UX only; the server is the gate.
- Over-strict regexes that reject
+, subaddressing, or new TLDs and silently lose real users. - Rejecting on DNS errors instead of soft-accepting and letting verification decide.
- No double opt-in on marketing, so the list fills with typos, fakes, and unconsented contacts.
- Storing raw verification tokens instead of their hashes, turning a DB leak into mass account takeover.
- Non-constant-time token comparison, leaking validity through timing.
- Pre-ticked, bundled, or buried consent that is legally invalid and operationally worthless.
- Capturing consent without storing the metadata to prove it later.
- Forms that enable subscription-bombing because they rate-limit by IP but not by submitted address.
- Silently re-adding unsubscribed or suppressed addresses on resubmission.
- A verification email that fails authentication and lands in spam, losing users at the final step.
- Coupling capture code to one ESP's SDK instead of a provider-neutral interface.
What should I read next?
- Transactional Emails: design rules for verification emails (subject, layout, codes, errors)
- Compliance: legal requirements for consent (GDPR, CASL) and consent-record storage
- Marketing Emails: what happens after capture, including one-click unsubscribe (RFC 8058)
- Deliverability: SPF/DKIM/DMARC setup and how validation and double opt-in improve sender reputation
- List Management: keeping the captured list clean, suppression, and resubscribe handling
- Sending Reliability: retries, bounce handling, and queueing the verification send
- Webhooks and Events: consuming bounce/complaint events and feeding them back to capture
- Email Types: Transactional vs Marketing: which capture flow needs which consent
- Index
