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:
parent
00f2e735c8
commit
4babb403e8
|
|
@ -107,6 +107,13 @@ table, td { color: #1B2E24; } #u_body a { color: #00C853; text-decoration: under
|
||||||
<div
|
<div
|
||||||
aria-label="Your exclusive offer from TARGO" aria-roledescription="email" role="article" lang="und" dir="auto" style="word-spacing:normal;background-color:#F5FAF7;"
|
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 ════════ -->
|
<!-- ════════ 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]-->
|
<!--[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
|
|
@ -107,6 +107,17 @@ table, td { color: #1B2E24; } #u_body a { color: #00C853; text-decoration: under
|
||||||
<div
|
<div
|
||||||
aria-label="Une offre exclusive de TARGO" aria-roledescription="email" role="article" lang="und" dir="auto" style="word-spacing:normal;background-color:#F5FAF7;"
|
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 ════════ -->
|
<!-- ════════ 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]-->
|
<!--[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
|
|
@ -809,6 +809,12 @@ async function sendCampaignAsync (id) {
|
||||||
|
|
||||||
const lang = (r.language || 'fr').toLowerCase().split('-')[0]
|
const lang = (r.language || 'fr').toLowerCase().split('-')[0]
|
||||||
const tplText = getTpl(lang)
|
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:
|
// Per-recipient amount override. Precedence:
|
||||||
// 1. r.amount — explicit override typed in the manual-add dialog
|
// 1. r.amount — explicit override typed in the manual-add dialog
|
||||||
// 2. r.gift_value_cents → "$X" formatted (when CSV import set this)
|
// 2. r.gift_value_cents → "$X" formatted (when CSV import set this)
|
||||||
|
|
@ -836,6 +842,7 @@ async function sendCampaignAsync (id) {
|
||||||
expiry: p.expiry || '',
|
expiry: p.expiry || '',
|
||||||
commitment_months: p.commitment_months || '3',
|
commitment_months: p.commitment_months || '3',
|
||||||
year: new Date().getFullYear(),
|
year: new Date().getFullYear(),
|
||||||
|
view_url: viewUrl,
|
||||||
}
|
}
|
||||||
const html = renderTemplate(tplText, vars)
|
const html = renderTemplate(tplText, vars)
|
||||||
const toName = `${r.firstname || ''} ${r.lastname || ''}`.trim()
|
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
|
// ── Per-campaign wildcard routes (MUST stay below the /templates and
|
||||||
// /webhook fixed paths above, otherwise the wildcard captures them) ─────
|
// /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
|
// GET /campaigns/:id/report.csv — per-recipient report download
|
||||||
// Columns chosen for operational follow-up (resend, refund, support):
|
// Columns chosen for operational follow-up (resend, refund, support):
|
||||||
// row, firstname, lastname, email, phone, language, customer_id, civic_address,
|
// row, firstname, lastname, email, phone, language, customer_id, civic_address,
|
||||||
|
|
|
||||||
|
|
@ -107,6 +107,13 @@ table, td { color: #1B2E24; } #u_body a { color: #00C853; text-decoration: under
|
||||||
<div
|
<div
|
||||||
aria-label="Your exclusive offer from TARGO" aria-roledescription="email" role="article" lang="und" dir="auto" style="word-spacing:normal;background-color:#F5FAF7;"
|
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 ════════ -->
|
<!-- ════════ 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]-->
|
<!--[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
|
|
@ -107,6 +107,17 @@ table, td { color: #1B2E24; } #u_body a { color: #00C853; text-decoration: under
|
||||||
<div
|
<div
|
||||||
aria-label="Une offre exclusive de TARGO" aria-roledescription="email" role="article" lang="und" dir="auto" style="word-spacing:normal;background-color:#F5FAF7;"
|
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 ════════ -->
|
<!-- ════════ 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]-->
|
<!--[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
Loading…
Reference in New Issue
Block a user