Email Validation Error Handling: Building User-Friendly Error Messages
“Invalid Email” Is the Laziest Error Message on the Internet
Your signup form rejects an email and shows “Invalid email address.” The user stares at the field. What’s wrong? Typo? Missing character? Domain issue? They have no idea. So they leave.
Baymard Institute found that 18.75% of users abandon forms after encountering a validation error they don’t understand. Nearly one in five. And email fields sit right at the top of every signup, checkout, and contact form on the web.
Here’s what usually happens: the developer calls an email validation API, gets back a rich response with status codes, sub-statuses, domain data, and disposable flags, then collapses all of that into one generic string. All that signal, thrown away at the last mile.
This tutorial shows you how to map each validation result to a specific, helpful error message. The kind that actually helps users fix their input.
What the API Gives You vs. What the User Needs
A validation API like MailCop returns structured data. The response tells you exactly what went wrong: syntax failure, dead domain, nonexistent mailbox, disposable provider, catch-all server. Eight or more distinct result types.
Your user doesn’t care about any of that. They care about one thing: what do I do now?
The gap between machine-readable statuses and human-readable guidance is where most implementations fall apart. Let’s close it.
Mapping API Responses to Human Messages
Here’s a response classifier that turns each validation result into a specific message and UX action. This is the core pattern.
// validation-messages.js
const ERROR_MAP = {
invalid_syntax: {
message: "Check for typos in your email address.",
severity: "error",
blocking: true,
},
invalid_domain: {
message: "We couldn't find that email provider.",
severity: "error",
blocking: true,
suggest: true,
},
invalid_mailbox: {
message: "That address doesn't seem to exist. Double-check and try again.",
severity: "error",
blocking: true,
},
disposable: {
message: "Please use a permanent email address.",
severity: "error",
blocking: true,
},
catch_all: {
message: null,
severity: "info",
blocking: false,
flagForReview: true,
},
unknown: {
message: null,
severity: "none",
blocking: false,
retryAsync: true,
},
deliverable: {
message: null,
severity: "none",
blocking: false,
},
};
function getValidationFeedback(apiResponse) {
const type = classifyResult(apiResponse);
const feedback = ERROR_MAP[type];
if (feedback.suggest && apiResponse.suggestion) {
feedback.message = `We couldn't find that email provider. Did you mean ${apiResponse.suggestion}?`;
}
return feedback;
}
Notice what’s not in there: the word “invalid.” Every message tells the user what went wrong and what to do about it. That’s the whole trick.
Syntax Errors: Be Specific, Not Pedantic
Syntax validation catches formatting problems before any network call happens. Missing @ sign, illegal characters, spaces in the address. The temptation is to say “Invalid email format.” Resist it.
// syntax-feedback.js
function getSyntaxFeedback(email) {
if (!email.includes("@")) {
return "Your email needs an @ symbol.";
}
const [local, domain] = email.split("@");
if (!local) {
return "Add your username before the @ symbol.";
}
if (!domain || !domain.includes(".")) {
return "Check the domain after the @ symbol.";
}
if (email.includes(" ")) {
return "Email addresses can't contain spaces.";
}
if (local.length > 64) {
return "The part before @ is too long.";
}
return null;
}
Granular syntax messages run client-side with zero latency. The user gets instant, specific feedback before your app ever touches the email validation API. According to a Google UX study, inline validation that explains the specific error reduces form completion time by 22%.
Domain Errors: Fuzzy Matching Changes Everything
A user types [email protected]. Your API returns invalid_domain because gmial.com has no MX records. The default message: “Invalid email.” The useful message: “Did you mean gmail.com?”
Domain suggestion is the single highest-impact improvement you can make to email validation UX. Levenshtein distance handles the matching.
# domain_suggest.py
COMMON_DOMAINS = [
"gmail.com", "yahoo.com", "hotmail.com", "outlook.com",
"icloud.com", "aol.com", "protonmail.com", "zoho.com",
"mail.com", "fastmail.com", "hey.com", "live.com",
]
def suggest_domain(bad_domain):
best_match = None
best_distance = float("inf")
for domain in COMMON_DOMAINS:
d = levenshtein(bad_domain.lower(), domain)
if d < best_distance and d <= 2:
best_distance = d
best_match = domain
return best_match
def get_domain_error(email, api_response):
domain = email.split("@")[1]
suggestion = api_response.get("suggestion") or suggest_domain(domain)
if suggestion:
return f"We couldn't find that email provider. Did you mean {suggestion}?"
return "We couldn't find that email provider. Check the part after @."
MailCop’s API already returns a suggestion field for common typo domains. Use it before falling back to client-side matching. The API catches more variations because it checks against actual MX record data, not just a static domain list.
Around 8% of email validation failures are domain typos, according to Mailgun’s deliverability data. That’s a lot of users you can save with one if statement.
Mailbox Errors: Direct and Honest
The domain exists. The MX records resolve. But the specific mailbox doesn’t. The SMTP server returned a 550. That address isn’t real.
Don’t say “Email not found.” Say “That address doesn’t seem to exist. Double-check and try again.”
Why “doesn’t seem to”? Because SMTP verification isn’t 100% certain. Some servers return false negatives under load. The qualification gives the user confidence to retry without feeling accused of lying.
Keep the message simple. One sentence. No technical jargon.
# app/helpers/validation_helper.rb
def mailbox_error_message(result)
if result.role_account?
"That looks like a shared inbox. Use a personal email for your account."
else
"That address doesn't seem to exist. Double-check and try again."
end
end
Disposable Emails: Firm but Fair
Disposable email services like Guerrilla Mail and Temp Mail are tools, not crimes. But if your app needs a real contact channel (account recovery, order confirmations, anything transactional), you’re right to block them.
The message matters here. “Invalid email” insults the user. “Please use a permanent email address” explains the requirement.
Some teams go further: “We’ll send account recovery and order updates to this address. Temporary emails won’t receive them.” Context turns a rejection into a reason.
The canonical open-source disposable domain blocklist tracks about 4,000 domains. MailCop checks against over 180,000 and updates the list continuously. Static blocklists go stale within weeks as new disposable services launch daily.
Catch-All Domains: Don’t Block, Flag
Catch-all domains accept mail for any address, real or fake. About 15-28% of B2B domains use catch-all configurations. That’s a huge chunk of your user base. Blocking them means rejecting legitimate signups from companies that happen to use catch-all.
The right approach: accept the signup, flag the address for async review, and validate through a confirmation email.
// catch-all-handler.js
async function handleCatchAll(email, userId) {
await db.users.update(userId, {
emailStatus: "pending_confirmation",
catchAll: true,
});
await sendConfirmationEmail(email, userId);
return {
message: null,
action: "accepted",
note: "Confirmation email sent for catch-all domain",
};
}
No error message shown to the user. They don’t need to know their domain is catch-all. They just need the confirmation email. For deeper strategies on catch-all handling, see the testing email validation post where the classifier pattern covers this case.
Unknown and Timeout Results: Never Block on Failure
The validation API timed out. The SMTP server didn’t respond. The result is unknown. What do you show the user?
Nothing. Accept the submission and validate asynchronously.
Blocking a user because your third-party API had a slow moment is a terrible trade. Stripe’s engineering blog documented this principle: never let a non-critical dependency degrade the critical path. Email validation is important. It’s not more important than letting a real user sign up.
// timeout-handler.js
async function handleUnknown(email, userId) {
await db.users.create({
email,
id: userId,
emailStatus: "unverified",
});
await validationQueue.add({ email, userId, priority: "high" });
return { blocking: false, message: null };
}
Queue the validation. Retry in the background. If it comes back invalid, send a re-engagement email or prompt the user to update their address on next login. Your rate limiting setup handles the retry queue without hammering the API.
Making It Accessible: ARIA Attributes for Inline Errors
Showing a helpful message is half the job. Screen readers need to find it. According to the WebAIM Million report, 96.3% of home pages have detectable WCAG failures, and form error handling is one of the most common gaps.
<div class="form-group">
<label for="email">Email address</label>
<input
type="email"
id="email"
aria-describedby="email-error"
aria-invalid="true"
/>
<div id="email-error" role="alert" class="error-message">
Check for typos in your email address.
</div>
</div>
Three attributes matter. aria-invalid="true" tells the screen reader this field has a problem. aria-describedby links the input to its error message. role="alert" announces the message when it shows up, so the user doesn’t have to tab around to find it.
Set aria-invalid dynamically. It should be false (or absent) until validation actually fails. Marking a field invalid before the user finishes typing is aggressive and annoying.
Internationalization: Your Error Messages Need Translation
If your app serves users in multiple languages, hard-coded English strings won’t cut it. Extract your error messages into a translation layer from day one.
// i18n/validation-messages.js
const messages = {
en: {
syntax_error: "Check for typos in your email address.",
domain_not_found: "We couldn't find that email provider.",
domain_suggestion: "We couldn't find that email provider. Did you mean {{suggestion}}?",
mailbox_not_found: "That address doesn't seem to exist. Double-check and try again.",
disposable_blocked: "Please use a permanent email address.",
},
es: {
syntax_error: "Revisa si hay errores en tu correo.",
domain_not_found: "No encontramos ese proveedor de correo.",
domain_suggestion: "No encontramos ese proveedor. ¿Quisiste decir {{suggestion}}?",
mailbox_not_found: "Esa dirección no existe. Verifica e intenta de nuevo.",
disposable_blocked: "Usa una dirección de correo permanente.",
},
};
function getLocalizedError(type, locale, vars = {}) {
const template = messages[locale]?.[type] || messages.en[type];
return template.replace(/\{\{(\w+)\}\}/g, (_, key) => vars[key] || "");
}
The pattern works with any i18n library (react-intl, i18next, rails-i18n). The point is the same: don’t bake English into your validation logic. For React Native apps serving global audiences, this is even more critical since app store listings in 20+ locales mean your error messages need to match.
Putting It All Together: The Complete Handler
Here’s the full pattern. API response comes in, classifier maps it to a result type, error handler produces the user-facing feedback.
// validation-handler.js
function classifyResult(apiResponse) {
if (apiResponse.status === "deliverable" && !apiResponse.disposable) {
return apiResponse.catch_all ? "catch_all" : "deliverable";
}
if (apiResponse.disposable) return "disposable";
if (apiResponse.status === "unknown") return "unknown";
if (!apiResponse.mx_found) return "invalid_domain";
if (apiResponse.sub_status === "mailbox_not_found") return "invalid_mailbox";
return "invalid_syntax";
}
async function handleValidation(email, apiResponse, locale) {
const type = classifyResult(apiResponse);
const feedback = ERROR_MAP[type];
if (feedback.suggest && apiResponse.suggestion) {
feedback.message = getLocalizedError("domain_suggestion", locale, {
suggestion: apiResponse.suggestion,
});
} else if (feedback.message) {
feedback.message = getLocalizedError(type, locale);
}
return {
valid: !feedback.blocking,
message: feedback.message,
severity: feedback.severity,
ariaInvalid: feedback.blocking,
};
}
Seven result types. Seven specific messages. Zero instances of “Invalid email.”
That’s the whole implementation. The API does the hard work of checking syntax, DNS, MX records, and SMTP responses. Your job is translating that signal into something a human can act on. Get this right and you’ll see fewer form abandonments, cleaner data, and users who actually fix their typos instead of bouncing.
A 2024 Formstack report found that forms with specific, actionable error messages convert 47% better than forms with generic errors. The investment is small: a lookup table and a few localized strings. The payoff is measurable in your signup funnel.
Every error message is a conversation. Make yours count.