Browser-local release gate
Start at account lookup when the email already arrived
No address, activation link, token, message, subject, SMTP credential, provider secret or production log is requested or transmitted.
Email arrived, but the code says the account or email cannot be found? Trace the identity shared by signup and verification before retrying or changing DNS.
- Release gate
- Truthful API claim
- Boundary
- Next action
- Exit condition
- Safety
A 200 or 202 response is not a delivery receipt
HTTP status describes how the server handled the HTTP request. It does not automatically prove that a background job ran, SMTP credentials existed, a provider accepted the message, a receiving server accepted it, or a user could see it.
- HTTP 200 or 202
- The endpoint completed according to its code path. Read the response contract before calling the email sent.
- Queue or job ID
- The application accepted work. The worker can still fail, skip, time out, or use the wrong environment.
- Provider message ID
- The provider accepted a specific request. Suppression, delay, bounce, and receiver acceptance still follow.
- Delivered event
- A receiving mail system normally accepted the SMTP transaction. Inbox visibility is still a separate boundary.
Map the incident locally. Use evidence categories only; do not enter a recipient, message, code, link, token, or credential.
Read the signed public Nostr incident path for the complete application-to-provider workflow and separate operations-kit handoff.
The first ten minutes
- Freeze repeated sends and choose one attempt with an exact UTC time and random internal correlation ID.
- Read the handler result and logs. Confirm whether it returned after validation, enqueue, worker completion, or provider acceptance.
- Follow the correlation ID into the queue. Record enqueue, start, retry, terminal failure, and dead-letter evidence.
- At worker start, verify that required configuration is present and points at the intended environment without printing any secret value.
- Require either a provider message ID or an explicit failure class. If neither exists, stop at the application-to-provider boundary.
Common false-success paths
- A mail helper returns early when an SMTP username, password, API key, sender identity, or template is missing, while its caller ignores the result.
- A broad exception handler logs a warning and lets the endpoint return the same success payload used for a real handoff.
- The endpoint returns before a background task executes, but the UI changes the wording from requested or queued to sent.
- The worker runs in a different environment, region, account, or secret namespace than the API process.
- A provider sandbox, unverified sender, suppression entry, quota, or rate limit blocks the message before SMTP delivery.
A recent public bug report documented the exact first pattern: missing SMTP credentials caused the send helper to return without sending, while registration still returned 200 and told the user a code was sent. The useful evidence was the early return, not another inbox search.
Never log a live OTP, verification URL, password-reset token, SMTP password, provider key, or recipient address to make this path observable. Log bounded status, random correlation IDs, configuration presence, and provider identifiers instead.
Nodemailer and Ethereal can produce a real false success
Ethereal is a fake SMTP service for development and testing. It accepts a normal SMTP submission and stores a preview, but it never delivers that message to the real recipient. A production fallback that calls createTestAccount() when SMTP credentials are absent can therefore make sendMail() resolve while every user waits for an email that will never arrive.
Make transport selection explicit and fail production readiness before the registration or reset endpoint starts accepting work:
const required = ["SMTP_HOST", "SMTP_PORT", "SMTP_USER", "SMTP_PASS", "CLIENT_URL"];
const missing = required.filter((name) => !process.env[name]);
if (process.env.NODE_ENV === "production" && missing.length) {
throw new Error(`Mail readiness failed: ${missing.join(", ")}`);
}
if (process.env.NODE_ENV === "production" &&
process.env.SMTP_HOST === "smtp.ethereal.email") {
throw new Error("Ethereal is test-only");
}
const transporter = nodemailer.createTransport(productionSmtpOptions);
await transporter.verify();
- Keep Ethereal behind an explicit development or test mode. Do not select it merely because production secrets are missing.
- Require the public
CLIENT_URLin production so verification and reset links cannot silently point atlocalhost. - Treat
verify()as connection, TLS, and authentication readiness. It does not prove that the server will accept a specific sender or that a receiver will accept the message. - For one controlled send, retain a redacted correlation ID, transport identity,
messageId, and bounded accepted or rejected outcome. Do not log the recipient, link, token, subject, body, or credential.
The current GravityX issue demonstrates this exact branch: its production manifest does not declare SMTP or CLIENT_URL values, while the mail helper falls back to Ethereal and the endpoint still returns success after the test server accepts the message.
A pending account needs a recoverable handoff
A common registration sequence persists an inactive account and verification token, publishes an event, schedules best-effort work, and then returns HTTP 202. The account commit is real even when the event subscriber, scheduler, template, channel, process, SMTP connection, or final retry later fails. Returning a generic registration error after that commit encourages a duplicate request, while silently dropping the side effect leaves a username that is already taken and can never activate.
Keep the account result and notification result separate. After the inactive account exists, the truthful response is still pending_verification. Its notification field may be durably_queued, best_effort_scheduled, or handoff_failed; none of those states makes the account disappear.
- Prefer an outbox or durable job written with the pending-account state. A process restart between commit and SMTP must replay the same logical notification, not create another account.
- Validate the configured verification channel and its template at startup. If configuration names
smtpbut the plugin is absent, do not accept registrations and then drop every message. - Provide a rate-limited resend or reconciliation path for an existing pending account. Rotate a token generation deliberately, invalidate older links according to policy, and keep the operation idempotent.
- Record bounded handoff states and retry classes. Do not put an address, subject, body, token, link, password, or provider secret in logs.
If the current system intentionally remains best-effort, say so in the API and UI and make recovery available. A retry loop inside one worker process does not survive a restart and is not a durable queue.
The email arrived, but the code says account not found
That symptom crosses the delivery boundary: the application generated a code and the message reached the mailbox. The next failure is account-state consistency, not SPF, DKIM, DMARC, PTR, spam placement, or another send attempt.
A current public report includes the exact sequence: signup sends the verification code, but entering it returns “cannot find my account with my email address.” The issue remains open, has no linked fix, and also differs between the desktop and website paths. That makes client API origin, environment, tenant, database, email normalization, pending-account persistence, and verification-generation ownership the first evidence to compare.
- Return one opaque registration ID from signup and attach the verification generation to that internal account ID, not to a second free-form email lookup.
- Record which API origin, environment, tenant, region, and database served desktop signup, website signup, and code verification.
- Prove exactly one pending row exists before sending. If the commit fails, do not create a code or claim signup succeeded.
- Verify the newest generation once, then prove expired or reused codes fail without deleting the pending account or inviting duplicate signup.
Use the browser-local account lookup trace above. The demand evidence is the still-open FinceptTerminal account creation issue; do not post an address, code, screenshot, token, or private log to reproduce it.
Keycloak disabled-account linking stops before email
Keycloak 26.4.2 makes the no-email result deterministic for this branch. IdpEmailVerificationAuthenticator.authenticateImpl() resolves the existing user before it calls sendVerifyEmail(). The shared getExistingUser() helper throws AuthenticationFlowError.USER_DISABLED when that local account is disabled, so no identity-provider link email should be attempted.
The silent user experience comes from a second boundary. The default First Broker Login documentation marks “Verify Existing Account By Email” as an alternative method. In DefaultAuthenticationFlow.processFlow(), an AuthenticationFlowException from an alternative execution is collected, the execution is marked ATTEMPTED, and the flow moves to another alternative. That behavior preserves fallback authentication, but it also means the disabled-user exception itself does not create a user-facing challenge.
- Intended security decision
- A disabled local account is not eligible for account linking. Do not send the email, auto-enable the user or silently link the identity.
- Observed UI defect
- The eligibility exception is consumed inside the alternative-method loop, leaving no terminal page that explains the failed boundary.
- Bounded fix
- Resolve disabled-user eligibility in the required account-link gate and return one terminal challenge before entering the alternative email and re-authentication methods.
- Disclosure boundary
- Keep the exact
USER_DISABLEDreason in server evidence; use a specific or generic user message according to the realm's anti-enumeration policy.
Generate the exact release gate and regression proof without leaving this section or entering realm, account, IdP, message or log data.
Do not patch SMTP, resend the request or remove kc_idp_hint to make this case pass. Test the flow state directly:
- Disabled existing user: one terminal response, one bounded
USER_DISABLEDevent, noSEND_IDENTITY_PROVIDER_LINKevent and zero email-provider calls. - Enabled existing user: the email alternative still creates the link action token, emits the send event and renders the email-sent challenge.
- Email and re-authentication alternatives enabled: the disabled case cannot fall through, loop to the hinted IdP or remain on the confirmation page.
- Direct
kc_idp_hintentry: the same terminal outcome appears without relying on the skipped username form to render an error.
The generated release brief stays in this browser. It contains bounded states and regression evidence only; it does not collect a realm, username, email, IdP alias, token or log.
Make the response contract truthful
Model the account and notification as two measurable state machines. A minimal notification path is requested, durably_queued, worker_started, provider_accepted, receiver_accepted, and failed. Do not collapse those states into a boolean named sent, and do not rewrite an already committed account as missing.
account = createPendingAccount(request)
handoff = enqueueVerification(account.id, generation=1)
return 202, {
"accountStatus": "pending_verification",
"notificationStatus": handoff.persisted ? "durably_queued" : "handoff_failed",
"retryable": !handoff.persisted,
"trackingId": handoff.publicTrackingId
}
If account creation and notification handoff share no transaction, preserve enough state to reconcile the gap. A 503 can be truthful before any account is committed; after commit it must not invite blind registration replay. The broader invariant is simpler: user-facing wording, API fields, logs, alerts, and tests must not claim a stronger boundary than the evidence supports.
Keep the SMTP plugin boundary explicit
For a MailKit channel, make transport security an explicit configuration value. Port 465 normally uses SslOnConnect; port 587 should require StartTls when policy requires encryption. MailKit's Auto mode can choose StartTlsWhenAvailable on non-465 ports, which permits a clear-text session when the server does not advertise STARTTLS. A verification channel should not silently weaken its configured requirement.
Allow only known template content types such as text/plain and text/html. Map them to the message body's text or HTML part; do not copy an arbitrary front-matter value into a MIME header. Reject CR or LF in rendered subjects, parse sender and recipient addresses with the mail library, use cancellation-aware async calls, and never write the SMTP password to a repository template, startup log, exception payload, or protocol transcript.
Add failure tests before the next incident
- Missing credentials: the mail helper returns or throws an explicit configuration failure and no endpoint says sent.
- Queue unavailable: the request exposes a retryable queue failure and does not create an orphaned verification secret.
- Provider rejects: retain the provider error class without leaking credentials or a message body.
- Provider accepts: persist one provider message ID against the correlation ID before reporting that boundary.
- Retry: enforce idempotency, rate limits, and clear invalidation rules for older links or codes.
- Configured channel missing: fail startup readiness or disable registration instead of accepting and dropping the message.
- Process exits after account commit: replay one durable notification for the same account and generation.
- Resend: reuse the pending account, rotate or retain the token by explicit policy, and never create a second account.
- MailKit security: reject an unavailable required STARTTLS path and reject unrecognized content types or subject line breaks.
Only then investigate sender and receiver delivery
Once a real provider message ID exists, follow provider events through suppression, processing, delay, bounce, or receiver acceptance. Preserve complete SMTP diagnostics. After acceptance, inspect quarantine, rules, forwarding, mailbox views, and a trusted original header.
For the actual production sending path, verify SPF authorization, the observed DKIM selector, DMARC alignment, sending-IP PTR, consent, complaints, bounce handling, and volume changes. Public DNS evidence can explain a documented rejection; it cannot prove that the application generated a message.
Use the SMTP error decoder when a complete reply exists, or the local header analyzer after a controlled copy arrives. If SMTP first returns 250 and a later DSN changes the outcome, follow the per-recipient asynchronous status model.
Only after provider acceptance
Check the actual sending domain
Use this only when the worker produced a provider message ID or a complete SMTP reply. The free check covers public SPF, DKIM, DMARC, MX, PTR, MTA-STS, TLS-RPT, and CAA evidence; a complete report remains optional after results.
Retry without weakening authentication
Fix the first unproven boundary, then send one controlled replacement through the same production path. Keep rate limits, abuse controls, expiration, and single-use behavior active. Make the current request visible and invalidate older secrets according to the product's security model.
Primary references
- RFC 9110: HTTP 200 OK semantics
- RFC 5321: Simple Mail Transfer Protocol
- OWASP: Logging Cheat Sheet
- Public bug report: SMTP credentials missing behind a false success response
- Moongate issue #40: SMTP notification channel and account verification routing
- Moongate notification worker at the reviewed revision
- Moongate pending-account commit and event publication at the reviewed revision
- MailKit: SmtpClient ConnectAsync security modes
- Nodemailer: testing with Ethereal and its no-real-delivery boundary
- Nodemailer: SMTP transport and
transporter.verify() - GravityX issue #8: password-reset endpoint reports success but no message arrives
- GravityX mail transport selection at the reviewed revision
- Keycloak issue #51106: disabled local account leaves IdP linking without an email or visible error
- Keycloak 26.4.2: email-link authenticator resolves the existing user before sending
- Keycloak 26.4.2: disabled existing users raise
USER_DISABLED - Keycloak 26.4.2: alternative execution exceptions are consumed and marked attempted
- Keycloak server administration: default First Broker Login alternatives