{"record":{"id":"f2089e1fc57a200c","repo":"TryGhost/Ghost","slug":"failed-to-send-email-address-verification-email","errorCode":null,"errorMessage":"Failed to send email address verification email","messagePattern":"Failed to send email address verification email","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"apps/portal/src/utils/api.js","lineNumber":493,"sourceCode":"            const body = {\n                email,\n                identity\n            };\n\n            return makeRequest({\n                url,\n                method: 'POST',\n                headers: {\n                    'Content-Type': 'application/json'\n                },\n                body: JSON.stringify(body)\n            }).then(async function (res) {\n                if (res.ok) {\n                    return 'Success';\n                } else {\n                    const errData = await res.json();\n                    const errMssg = errData?.errors?.[0]?.message || 'Failed to send email address verification email';\n                    throw new Error(errMssg);\n                }\n            });\n        },\n\n        async checkoutPlan({plan, tierId, cadence, cancelUrl, successUrl, email: customerEmail, name, offerId, newsletters, metadata = {}} = {}) {\n            const siteUrlObj = new URL(siteUrl);\n            const identity = await api.member.identity();\n            const url = endpointFor({type: 'members', resource: 'create-stripe-checkout-session'});\n\n            if (!successUrl) {\n                const checkoutSuccessUrl = window.location.href.startsWith(siteUrlObj.href) ? new URL(window.location.href) : new URL(siteUrl);\n                checkoutSuccessUrl.searchParams.set('stripe', 'success');\n                successUrl = checkoutSuccessUrl.href;\n            }\n            if (!cancelUrl) {\n                const checkoutCancelUrl = window.location.href.startsWith(siteUrlObj.href) ? new URL(window.location.href) : new URL(siteUrl);\n                checkoutCancelUrl.searchParams.set('stripe', 'cancel');\n                cancelUrl = checkoutCancelUrl.href;","sourceCodeStart":475,"sourceCodeEnd":511,"githubUrl":"https://github.com/TryGhost/Ghost/blob/47d8b0e2ad2fd4757d3bc45f46c3ac165ff8a1fe/apps/portal/src/utils/api.js#L475-L511","documentation":"Thrown by Portal's updateEmailAddress() when the POST to the members 'member/email' endpoint returns a non-2xx status. The Portal API client tries to surface the backend's structured error (errors[0].message) and falls back to this generic string only when the backend sent no parseable error body. It is a client-side Error propagated to the Portal UI, which typically renders it as a toast/alert to the member.","triggerScenarios":"A signed-in member submits a new email address in Portal account settings; the Ghost backend rejects the change. Common backend rejections: email already registered to another member, malformed email, rate-limited verification sends, or the member's identity token is stale/expired.","commonSituations":"Members portal loaded with an expired session cookie so the identity claim fails; duplicate email across members; email validation regex mismatch; SMTP/transactional email service down so the backend refuses to queue the verification. Also seen in local dev when siteUrl/identity mismatch makes the endpoint 401.","solutions":["Inspect the actual thrown message — if it differs from the fallback, the backend's errors[0].message names the real cause; act on that.","Confirm the member is still signed in: re-derive identity via api.member.identity() and retry; a stale identity token is the most frequent trigger.","Verify the new email is well-formed and not already used by another member on the site.","Check Ghost server logs (members API + email service) for the corresponding 4xx and the backend's reason.","If using a proxy/CDN, ensure it isn't returning an HTML error page that defeats res.json() parsing."],"exampleFix":"// before: stale identity cached long before the call\nconst identity = await api.member.identity();\nawait api.member.updateEmailAddress({email});\n\n// after: refresh identity at call time and surface the real error\ntry {\n    await api.member.updateEmailAddress({email});\n} catch (err) {\n    showToast(err.message || 'Could not update your email. Please refresh and try again.');\n}","handlingStrategy":"try-catch","validationCode":"// Validate the email shape and identity before calling\nfunction isValidEmail(email) {\n    return typeof email === 'string' && /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(email);\n}\n\nasync function canUpdateEmail(api) {\n    try {\n        await api.member.identity(); // throws if session is stale\n        return true;\n    } catch {\n        return false;\n    }\n}\n\nif (isValidEmail(email) && await canUpdateEmail(api)) {\n    await api.member.updateEmailAddress({email});\n}","typeGuard":"function isMembersApiErrorBody(x) {\n    return typeof x === 'object' && x !== null && Array.isArray(x.errors) && typeof x.errors[0]?.message === 'string';\n}","tryCatchPattern":"try {\n    await api.member.updateEmailAddress({email});\n} catch (err) {\n    // err.message is the backend reason, or the generic fallback\n    showToast(err.message);\n    if (/session|identity|unauthor/i.test(err.message)) {\n        // re-authenticate and let the member retry\n    }\n}","preventionTips":["Refresh the member identity at the point of action, not at page load.","Pre-validate the email client-side before posting.","Treat any updateEmailAddress rejection as user-facing and surface the backend message verbatim."],"tags":["portal","members-api","email-verification","client-side","network"],"backgroundTag":null,"analyzedSha":"47d8b0e2ad2fd4757d3bc45f46c3ac165ff8a1fe","analyzedAt":"2026-08-13T01:25:26.651Z","schemaVersion":2},"datasetVersion":"2026-08-13T04:17:16.726Z"}