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
|
||||
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
|
|
@ -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
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
Loading…
Reference in New Issue
Block a user