diff --git a/src/routes/admin.ts b/src/routes/admin.ts index 66681d8..ce51192 100644 --- a/src/routes/admin.ts +++ b/src/routes/admin.ts @@ -1349,10 +1349,15 @@ app.get("/", async (c) => { body: JSON.stringify({ feedIds: batch }), }); - const data = await res.json().catch(() => ({})); - if (!res.ok) { - const message = data && data.error ? String(data.error) : ('Request failed (' + res.status + ')'); - throw new Error(message); + let data = {}; + if (window.parseJsonResponseOrThrow) { + data = await window.parseJsonResponseOrThrow(res, { prefix: 'Bulk feed delete failed' }); + } else { + data = await res.json().catch(() => ({})); + if (!res.ok) { + const message = data && data.error ? String(data.error) : ('Bulk feed delete failed (HTTP ' + res.status + ')'); + throw new Error(message); + } } const deletedIds = Array.isArray(data.deletedFeedIds) ? data.deletedFeedIds : batch; @@ -1376,7 +1381,7 @@ app.get("/", async (c) => { if (failed.length > 0) { if (window.showToast) { window.showToast( - 'Deleted ' + deletedTotal + ' feed(s). ' + failed.length + ' failed (still visible).', + 'Deleted ' + deletedTotal + ' feed(s). ' + failed.length + ' failed (still visible). Try again; Cloudflare limits can cause temporary failures.', { type: 'error', duration: 6500 }, ); } @@ -1386,7 +1391,7 @@ app.get("/", async (c) => { } catch (error) { if (toast && toast.dismiss) toast.dismiss(); if (window.showToast) { - window.showToast('Bulk delete failed: ' + (error && error.message ? error.message : 'Unknown error'), { type: 'error', duration: 7000 }); + window.showToast((error && error.message) ? error.message : 'Bulk feed delete failed.', { type: 'error', duration: 7500 }); } } finally { FEED_BULK_DELETE_IN_PROGRESS = false; @@ -1757,16 +1762,15 @@ app.post("/feeds/:feedId/purge", async (c) => { app.post("/feeds/bulk-delete", async (c) => { const env = c.env as unknown as Env; const emailStorage = env.EMAIL_STORAGE; + const contentType = c.req.header("Content-Type") || ""; + const wantsJson = + contentType.includes("application/json") || + (c.req.header("Accept") || "").includes("application/json"); - try { - const contentType = c.req.header("Content-Type") || ""; - const wantsJson = - contentType.includes("application/json") || - (c.req.header("Accept") || "").includes("application/json"); - - if (wantsJson) { - const body = (await c.req.json().catch(() => null)) as { - feedIds?: unknown; + try { + if (wantsJson) { + const body = (await c.req.json().catch(() => null)) as { + feedIds?: unknown; } | null; const rawIds = Array.isArray(body?.feedIds) ? body?.feedIds : []; @@ -1868,7 +1872,16 @@ app.post("/feeds/bulk-delete", async (c) => { ); } catch (error) { console.error("Error bulk deleting feeds:", error); - return c.text("Error bulk deleting feeds. Please try again.", 400); + return wantsJson + ? c.json( + { + ok: false, + error: + "Bulk feed delete failed. This can happen if Cloudflare is rate-limiting requests or if the Worker hit a plan quota. Please try again.", + }, + 500, + ) + : c.text("Error bulk deleting feeds. Please try again.", 500); } }); @@ -2674,10 +2687,15 @@ app.get("/feeds/:feedId/emails", async (c) => { body: JSON.stringify({ emailKeys: batch }), }); - const data = await res.json().catch(() => ({})); - if (!res.ok) { - const message = data && data.error ? String(data.error) : ('Request failed (' + res.status + ')'); - throw new Error(message); + let data = {}; + if (window.parseJsonResponseOrThrow) { + data = await window.parseJsonResponseOrThrow(res, { prefix: 'Bulk email delete failed' }); + } else { + data = await res.json().catch(() => ({})); + if (!res.ok) { + const message = data && data.error ? String(data.error) : ('Bulk email delete failed (HTTP ' + res.status + ')'); + throw new Error(message); + } } const deletedKeys = Array.isArray(data.deletedEmailKeys) ? data.deletedEmailKeys : batch; @@ -2700,7 +2718,7 @@ app.get("/feeds/:feedId/emails", async (c) => { if (failed.length > 0) { if (window.showToast) { window.showToast( - 'Deleted ' + deletedTotal + ' email(s). ' + failed.length + ' failed (still visible).', + 'Deleted ' + deletedTotal + ' email(s). ' + failed.length + ' failed (still visible). Try again; Cloudflare limits can cause temporary failures.', { type: 'error', duration: 6500 }, ); } @@ -2710,7 +2728,7 @@ app.get("/feeds/:feedId/emails", async (c) => { } catch (error) { if (toast && toast.dismiss) toast.dismiss(); if (window.showToast) { - window.showToast('Bulk delete failed: ' + (error && error.message ? error.message : 'Unknown error'), { type: 'error', duration: 7000 }); + window.showToast((error && error.message) ? error.message : 'Bulk email delete failed.', { type: 'error', duration: 7500 }); } } finally { EMAIL_BULK_DELETE_IN_PROGRESS = false; @@ -3146,13 +3164,12 @@ app.post("/feeds/:feedId/emails/bulk-delete", async (c) => { const env = c.env as unknown as Env; const emailStorage = env.EMAIL_STORAGE; const feedId = c.req.param("feedId"); + const contentType = c.req.header("Content-Type") || ""; + const wantsJson = + contentType.includes("application/json") || + (c.req.header("Accept") || "").includes("application/json"); try { - const contentType = c.req.header("Content-Type") || ""; - const wantsJson = - contentType.includes("application/json") || - (c.req.header("Accept") || "").includes("application/json"); - const feedMetadataKey = `feed:${feedId}:metadata`; const feedMetadata = (await emailStorage.get(feedMetadataKey, { type: "json", @@ -3236,7 +3253,16 @@ app.post("/feeds/:feedId/emails/bulk-delete", async (c) => { ); } catch (error) { console.error("Error bulk deleting emails:", error); - return c.text("Error bulk deleting emails. Please try again.", 400); + return wantsJson + ? c.json( + { + ok: false, + error: + "Bulk email delete failed. This can happen if Cloudflare is rate-limiting requests or if the Worker hit a plan quota. Please try again.", + }, + 500, + ) + : c.text("Error bulk deleting emails. Please try again.", 500); } }); diff --git a/src/scripts/httpErrors.ts b/src/scripts/httpErrors.ts new file mode 100644 index 0000000..f40806b --- /dev/null +++ b/src/scripts/httpErrors.ts @@ -0,0 +1,136 @@ +// Helpers for turning failed fetch() responses into actionable, user-friendly +// error messages (especially for Cloudflare quota / rate-limit pages). + +export const httpErrorScripts = ` + (function () { + function extractRayIdFromText(text) { + const match = String(text || '').match(/ray id\\s*[:#]?\\s*([a-z0-9-]+)/i); + return match ? match[1] : ''; + } + + function compact(text, maxLen) { + const str = String(text || ''); + if (str.length <= maxLen) return str; + return str.slice(0, Math.max(0, maxLen - 3)) + '...'; + } + + function classifyCloudflareError(status, text) { + const lower = String(text || '').toLowerCase(); + const looksLikeCloudflare = + lower.includes('cloudflare') || + lower.includes('ray id') || + lower.includes('cf-ray'); + + if (!looksLikeCloudflare) return null; + + const isRateLimited = + status === 429 || + lower.includes('error 1015') || + lower.includes('rate limited') || + lower.includes('you are being rate limited'); + + const isQuota = + lower.includes('worker') && + (lower.includes('exceeded') || + lower.includes('quota') || + lower.includes('limit') || + lower.includes('requests')); + + const isBlocked = + status === 403 && + (lower.includes('access denied') || lower.includes('forbidden')); + + if (isRateLimited) return { kind: 'rate_limit', label: 'Cloudflare rate limit' }; + if (isQuota) return { kind: 'quota', label: 'Cloudflare plan limit' }; + if (isBlocked) return { kind: 'blocked', label: 'Cloudflare security block' }; + + return { kind: 'cloudflare', label: 'Cloudflare error page' }; + } + + function buildHelpfulErrorMessage(prefix, status, headers, text, json) { + const safePrefix = prefix ? String(prefix) : 'Request failed'; + const cfRayHeader = headers && headers.get ? (headers.get('cf-ray') || '') : ''; + const retryAfter = headers && headers.get ? (headers.get('retry-after') || '') : ''; + + // Prefer our own API's structured error first. + let apiError = ''; + if (json && typeof json === 'object') { + if (typeof json.error === 'string') apiError = json.error; + else if (json.error && typeof json.error.message === 'string') apiError = json.error.message; + } + + if (apiError) { + const parts = [safePrefix + ': ' + apiError, '(HTTP ' + status + ')']; + if (cfRayHeader) parts.push('cf-ray ' + cfRayHeader); + return parts.join(' '); + } + + const cf = classifyCloudflareError(status, text); + if (cf) { + const ray = cfRayHeader || extractRayIdFromText(text); + const base = safePrefix + ': ' + cf.label + ' (HTTP ' + status + ').'; + let hint = ''; + if (cf.kind === 'rate_limit') { + hint = 'Try again in a bit, or delete smaller batches.'; + } else if (cf.kind === 'quota') { + hint = 'It looks like you hit a plan quota/limit. Try again later or check Cloudflare usage.'; + } else if (cf.kind === 'blocked') { + hint = 'Cloudflare blocked the request. Check WAF/rules and logs.'; + } else { + hint = 'Please try again; if it persists, check Cloudflare logs/usage.'; + } + + const extras = []; + if (retryAfter) extras.push('retry-after ' + retryAfter + 's'); + if (ray) extras.push('cf-ray ' + ray); + const extra = extras.length ? ' (' + extras.join(', ') + ')' : ''; + return base + ' ' + hint + extra; + } + + const snippet = compact(String(text || '').replace(/\\s+/g, ' ').trim(), 140); + const fallbackParts = [safePrefix + ' (HTTP ' + status + ')']; + if (snippet) fallbackParts.push('- ' + snippet); + if (cfRayHeader) fallbackParts.push('(cf-ray ' + cfRayHeader + ')'); + return fallbackParts.join(' '); + } + + async function parseJsonResponseOrThrow(res, opts) { + const options = opts || {}; + const prefix = options.prefix ? String(options.prefix) : 'Request failed'; + + const contentType = String(res.headers.get('content-type') || '').toLowerCase(); + let text = ''; + try { + text = await res.text(); + } catch (e) { + text = ''; + } + + const trimmed = String(text || '').trim(); + let json = null; + if ( + trimmed && + (contentType.includes('application/json') || + trimmed.startsWith('{') || + trimmed.startsWith('[')) + ) { + try { + json = JSON.parse(trimmed); + } catch (e) { + json = null; + } + } + + if (!res.ok) { + throw new Error(buildHelpfulErrorMessage(prefix, res.status, res.headers, text, json)); + } + + if (json !== null) return json; + if (options && options.allowText) return { text: text }; + throw new Error(prefix + ': Unexpected response format (HTTP ' + res.status + ').'); + } + + // Expose globally for inline route scripts. + window.parseJsonResponseOrThrow = parseJsonResponseOrThrow; + })(); +`; diff --git a/src/scripts/index.ts b/src/scripts/index.ts index 6792347..27d833a 100644 --- a/src/scripts/index.ts +++ b/src/scripts/index.ts @@ -5,10 +5,12 @@ import { modalScripts, emailViewScripts, initScripts } from "./interactions"; import { clipboardScripts } from "./clipboard"; import { authHelpers } from "./auth"; import { toastScripts } from "./toast"; +import { httpErrorScripts } from "./httpErrors"; // Combine all scripts into a single JavaScript string export const interactiveScripts = ` ${toastScripts} + ${httpErrorScripts} ${modalScripts} ${emailViewScripts} ${clipboardScripts} @@ -23,4 +25,5 @@ export { clipboardScripts, authHelpers, toastScripts, + httpErrorScripts, };