feat(campaigns): "View in browser" web fallback for failed inbox renders

GET /campaigns/:id/recipients/:i/view re-renders the campaign with the
same vars the worker used at send time — same template, same per-row
amount override, same language pick. Useful when the recipient's mail
client butchers the layout: image-blocking, antique Outlook, niche
third-party apps, accessibility tools.

Templates: Mustache section {{#view_url}}…{{/view_url}} guards a tiny
gray link above the header logo (11px, #94a3b8). The section collapses
to nothing when view_url is empty, so:
  - the /view page itself doesn't show the link (you're already there)
  - wizard previews / test-sends don't show it (no real campaign id)

worker passes view_url = HUB_PUBLIC_URL + /campaigns/<id>/recipients/<i>/view
using the existing cfg.HUB_PUBLIC_URL setting (defaults msg.gigafibre.ca).

Security: campaign-id is a 21-char nanoid (≈10²¹ space). Same level of
exposure as the Giftbit shortlink itself. X-Robots-Tag: noindex on the
response so the URLs don't end up on search engines.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-05-22 09:30:38 -04:00
parent 00f2e735c8
commit 4babb403e8
9 changed files with 101 additions and 4 deletions

View File

@ -107,6 +107,13 @@ table, td { color: #1B2E24; } #u_body a { color: #00C853; text-decoration: under
<div
aria-label="Your exclusive offer from TARGO" aria-roledescription="email" role="article" lang="und" dir="auto" style="word-spacing:normal;background-color:#F5FAF7;"
>
<!-- ════════ VIEW IN BROWSER (web fallback) ════════ -->
{{#view_url}}
<div style="margin:0px auto;max-width:600px;padding:8px 36px 4px;text-align:center;">
<a href="{{view_url}}" style="font-family:Plus Jakarta Sans, Helvetica, Arial, sans-serif;font-size:11px;color:#94a3b8;text-decoration:underline;">Trouble viewing this email? Open in browser</a>
</div>
{{/view_url}}
<!-- ════════ HEADER LOGO ════════ -->
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->

File diff suppressed because one or more lines are too long

View File

@ -107,6 +107,17 @@ table, td { color: #1B2E24; } #u_body a { color: #00C853; text-decoration: under
<div
aria-label="Une offre exclusive de TARGO" aria-roledescription="email" role="article" lang="und" dir="auto" style="word-spacing:normal;background-color:#F5FAF7;"
>
<!-- ════════ VIEW IN BROWSER (fallback web) ════════
Mustache section {{#view_url}}…{{/view_url}} keeps this row
OUT of the output when view_url is empty (e.g. when the user
is already viewing the campaign in a browser, or during a
test-send where no campaign id exists). -->
{{#view_url}}
<div style="margin:0px auto;max-width:600px;padding:8px 36px 4px;text-align:center;">
<a href="{{view_url}}" style="font-family:Plus Jakarta Sans, Helvetica, Arial, sans-serif;font-size:11px;color:#94a3b8;text-decoration:underline;">Affichage incorrect ? Voir dans le navigateur</a>
</div>
{{/view_url}}
<!-- ════════ HEADER LOGO ════════ -->
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->

File diff suppressed because one or more lines are too long

View File

@ -809,6 +809,12 @@ async function sendCampaignAsync (id) {
const lang = (r.language || 'fr').toLowerCase().split('-')[0]
const tplText = getTpl(lang)
// Web fallback ("View in browser") so recipients with rendering
// issues (image-blocking, antique Outlook, third-party mail apps)
// can open the campaign in any modern browser. The URL hits the
// /recipients/:row_index/view endpoint defined further down which
// re-renders the same template with this recipient's variables.
const viewUrl = `${cfg.HUB_PUBLIC_URL || 'https://msg.gigafibre.ca'}/campaigns/${encodeURIComponent(id)}/recipients/${i}/view`
// Per-recipient amount override. Precedence:
// 1. r.amount — explicit override typed in the manual-add dialog
// 2. r.gift_value_cents → "$X" formatted (when CSV import set this)
@ -836,6 +842,7 @@ async function sendCampaignAsync (id) {
expiry: p.expiry || '',
commitment_months: p.commitment_months || '3',
year: new Date().getFullYear(),
view_url: viewUrl,
}
const html = renderTemplate(tplText, vars)
const toName = `${r.firstname || ''} ${r.lastname || ''}`.trim()
@ -1535,6 +1542,60 @@ async function handle (req, res, method, path) {
// ── Per-campaign wildcard routes (MUST stay below the /templates and
// /webhook fixed paths above, otherwise the wildcard captures them) ─────
// GET /campaigns/:id/recipients/:i/view — re-render the email for ONE
// recipient using the same variables the worker used at send time. Linked
// from the "Voir dans le navigateur / View in browser" line at the top of
// every campaign email so recipients with rendering issues (image-blocking
// clients, antique Outlook, niche third-party mail apps) can fall back to
// a fresh browser render. No auth needed — the campaign-id is 21-char
// nanoid (≈10²¹ space) and row_index alone is enough to reconcile.
const viewMatch = path.match(/^\/campaigns\/([^/]+)\/recipients\/(\d+)\/view$/)
if (viewMatch && method === 'GET') {
const c = loadCampaign(viewMatch[1])
if (!c) return json(res, 404, { error: 'not found' })
const i = parseInt(viewMatch[2], 10)
const r = (c.recipients || [])[i]
if (!r) return json(res, 404, { error: 'recipient not found' })
const p = c.params || {}
const lang = (r.language || 'fr').toLowerCase().split('-')[0]
const tplPath = p.template_path || templateForLanguage(lang)
let tplText
try { tplText = fs.readFileSync(tplPath, 'utf8') }
catch { return json(res, 500, { error: 'template missing' }) }
let displayAmount = p.amount || '50 $'
if (r.amount) {
displayAmount = r.amount
} else if (r.gift_value_cents) {
const cents = Number(r.gift_value_cents) || 0
displayAmount = cents % 100 === 0
? `${cents / 100} $`
: `${(cents / 100).toFixed(2)} $`
}
const vars = {
firstname: r.firstname || (lang === 'en' ? 'dear customer' : 'cher client'),
lastname: r.lastname || '',
email: r.email,
description: r.civic_address || '',
gift_url: r.gift_url,
amount: displayAmount,
expiry: p.expiry || '',
commitment_months: p.commitment_months || '3',
year: new Date().getFullYear(),
// Empty so the {{#view_url}} section block in the template collapses
// — we don't want the "view in browser" line to show up when the
// user is ALREADY viewing it in a browser.
view_url: '',
}
const html = renderTemplate(tplText, vars)
res.writeHead(200, {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'no-store',
// Disallow indexing — these URLs aren't meant for search engines
'X-Robots-Tag': 'noindex, nofollow',
})
return res.end(html)
}
// GET /campaigns/:id/report.csv — per-recipient report download
// Columns chosen for operational follow-up (resend, refund, support):
// row, firstname, lastname, email, phone, language, customer_id, civic_address,

View File

@ -107,6 +107,13 @@ table, td { color: #1B2E24; } #u_body a { color: #00C853; text-decoration: under
<div
aria-label="Your exclusive offer from TARGO" aria-roledescription="email" role="article" lang="und" dir="auto" style="word-spacing:normal;background-color:#F5FAF7;"
>
<!-- ════════ VIEW IN BROWSER (web fallback) ════════ -->
{{#view_url}}
<div style="margin:0px auto;max-width:600px;padding:8px 36px 4px;text-align:center;">
<a href="{{view_url}}" style="font-family:Plus Jakarta Sans, Helvetica, Arial, sans-serif;font-size:11px;color:#94a3b8;text-decoration:underline;">Trouble viewing this email? Open in browser</a>
</div>
{{/view_url}}
<!-- ════════ HEADER LOGO ════════ -->
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->

File diff suppressed because one or more lines are too long

View File

@ -107,6 +107,17 @@ table, td { color: #1B2E24; } #u_body a { color: #00C853; text-decoration: under
<div
aria-label="Une offre exclusive de TARGO" aria-roledescription="email" role="article" lang="und" dir="auto" style="word-spacing:normal;background-color:#F5FAF7;"
>
<!-- ════════ VIEW IN BROWSER (fallback web) ════════
Mustache section {{#view_url}}…{{/view_url}} keeps this row
OUT of the output when view_url is empty (e.g. when the user
is already viewing the campaign in a browser, or during a
test-send where no campaign id exists). -->
{{#view_url}}
<div style="margin:0px auto;max-width:600px;padding:8px 36px 4px;text-align:center;">
<a href="{{view_url}}" style="font-family:Plus Jakarta Sans, Helvetica, Arial, sans-serif;font-size:11px;color:#94a3b8;text-decoration:underline;">Affichage incorrect ? Voir dans le navigateur</a>
</div>
{{/view_url}}
<!-- ════════ HEADER LOGO ════════ -->
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->

File diff suppressed because one or more lines are too long