Email address verification
Paid add-on. A form can require an address to be confirmed through a link before its effects take place: creating an account, sending a confirmation to that inbox.
This document describes what the module guarantees and what it refuses. The user guide gives the short version, under “Confirming an address”.
The submission is always recorded
That is the structuring decision. It would have been simpler to refuse the submission until confirmation; that would lose a request every time an email fails to arrive — and emails fail to arrive, regularly, for reasons that have nothing to do with the site or the visitor.
The entry is therefore created normally, active, readable. What follows from it waits. And internal alerts go out immediately: a request must not stay invisible because an address is unconfirmed.
The setting lives under email_verification:
{
"email_verification": {
"enabled": true,
"field": "email_1"
}
}
One setting. The link’s lifetime — twenty-four hours — and the resend ceiling — three a day — are not adjustable: they protect the owner of the inbox, who is neither the visitor typing the address nor the administrator configuring the form.
Only an email field can be verified. Verifying a text field would mean sending a link to whatever somebody typed there, and the form would become a relay for sending to anything.
The token
The link carries a row and a secret — 12.a3f…. The database keeps only a
HMAC-SHA256 of the secret, keyed on the site salt.
A table of plaintext tokens would be a table of keys: whoever read it — a backup, an SQL injection, a misplaced export — could validate any address without ever reaching the matching inbox. A fingerprint allows none of that.
The comparison goes through hash_equals: an ordinary string comparison stops at
the first differing byte, and that time is measurable — on a token, it amounts to
reconstructing it byte by byte.
The link’s shape is checked before any query: digits, a dot, sixty-four hexadecimal characters. A forged payload does not so much as reach the database.
The token encodes neither the address, nor the submission, nor its expiry date: all of that lives in the row. A token carrying its own conditions would be verifiable without the database, and therefore replayable after a revocation — which exists only there.
One use, and one only
The verification is written by a condition carried in the query:
verified_at IS NULL. Two simultaneous openings of the same link — a mail client
prefetching, an antivirus following links — trigger the effects only once: the
first UPDATE touches one row, the second none, and it is the row count that
decides.
A resend replaces
entry_id is unique: a resend writes over the previous token, which stops being
valid at that very moment. Two valid links for one address would mean a revoked
link stays usable through its twin, and revocation would revoke nothing.
A send that fails consumes its attempt all the same. The token is made before it goes into the link, and the link before it goes into the message: by the time the send fails, the previous token is already replaced. The attempt is lost, and that is the price of a guarantee that matters more.
Public answers are neutral
Invalid link, expired, already used, revoked: all lead to the same refusal, which does not say which. Telling “expired” from “never existed” would tell anyone trying links at random what exists — and what has existed.
The real state is read in the submission detail, by whoever already has the right to read it.
The return leaves nothing behind
The link is followed, then the visitor is redirected immediately to the page
from which they sent the form. Rendering a page at the link’s address would leave
the secret on display there: in the history, in the address bar, in the server’s
access log and in the Referer header of everything the page then loaded.
The return address comes from the database — it was recorded at submission time —
and is therefore treated as untrusted: wp_validate_redirect() checks it against
the allowed hosts, and falls back to the home page otherwise. Without that, a
value written into the database would turn the verification link into an open
redirect, that is, a link on the site that leads elsewhere.
The email contains no answers
It goes to an address that has precisely not been confirmed yet. Copying the answers into it would send somebody’s input to an inbox that nothing says belongs to them.
Which effects wait
The rule is the address, not the role. A notification is held back if one of
its recipients — to, cc or bcc, after tokens are resolved — is the address
being verified.
We could have held back those whose recipient is a field token and let through
those aimed at a hard-coded address. That is an approximation: an internal alert
can be aimed at {field:manager}, and a confirmation can be aimed at a
hard-coded address. The exact question is “does this message go to the address we
are verifying?”, and it gives for free what the plan asks for.
On release, the same comparison is taken the other way round: only the notifications that were held back go out, failing which the alerts already sent would be sent a second time.
Account creation consults the same guard. The filter
solis_forms_pro_entry_effects_allowed is true when nobody answers: a site
without verification behaves exactly as before. That is what makes the dependency
genuinely optional — the accounts module knows nothing of verification, and
verification knows nothing of accounts; they share only a vocabulary,
DeferredEffects.
Replayed provisioning is idempotent, so a release arriving twice does not create two accounts.
Purge
Verifications that expired without a follow-up are erased a week after their expiry. Those that succeeded are kept: they say that an address was confirmed, and that is what an administrator looks up months later.
Deleting a submission erases its verification.
What the module does not do
No SMS, no domain validation, no marketing double opt-in, no address change on a signed-in account. And it does not verify slot bookings: that module did not exist yet, and the guard will wait for it without changing anything about how it works when it arrives.
