Skip to content

Commit e0a2cd6

Browse files
authored
Merge pull request #90 from goposta/dev
feat(email): add hosted web view and reserved {{ posta_* }} template …
2 parents 2152196 + 64ad733 commit e0a2cd6

12 files changed

Lines changed: 588 additions & 28 deletions

File tree

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
---
2+
sidebar_position: 3
3+
title: System Variables
4+
description: Built-in {{ posta_* }} template variables for unsubscribe and web-view links
5+
---
6+
7+
# System Variables
8+
9+
Posta reserves the `{{ posta_* }}` variable namespace for values it injects into
10+
every templated email. You don't pass these in `template_data` — Posta fills them
11+
in per message, after the message identity is known. Using them is opt-in: a
12+
variable only appears if your template references it.
13+
14+
> The `posta_` prefix is reserved. Any key you supply in `template_data` that starts
15+
> with `posta_` is ignored in favour of the system value.
16+
17+
## Available variables
18+
19+
| Variable | Description |
20+
|---|---|
21+
| `{{ posta_web_view_url }}` | Signed link to read this email on the web ("view in browser"). |
22+
| `{{ posta_mail_web_link }}` | Alias for `posta_web_view_url`. |
23+
| `{{ posta_unsubscribe_url }}` | One-click unsubscribe link for this message. |
24+
25+
## View in browser
26+
27+
```html
28+
<a href="{{ posta_web_view_url }}">View this email in your browser</a>
29+
```
30+
31+
Works in the text part too:
32+
33+
```
34+
View online: {{ posta_web_view_url }}
35+
```
36+
37+
The link is an HMAC-signed, **expiring** capability bound to the message's opaque
38+
ID. The hosted page renders the exact HTML that was sent, on Posta's public web
39+
origin (`POSTA_WEB_URL`), with a restrictive content-security policy, `noindex`, and
40+
no cookies. Opening it does **not** count as an email open.
41+
42+
Notes:
43+
- Links expire after 90 days by default.
44+
- `cid:` inline-attachment images don't resolve on the web — use `https`/`data:` image URLs if you rely on the web view.
45+
46+
## Unsubscribe
47+
48+
```html
49+
<a href="{{ posta_unsubscribe_url }}">Unsubscribe</a>
50+
```
51+
52+
For campaign sends this unsubscribes the recipient from that campaign's list. For
53+
transactional sends it adds the recipient to your suppression list. The link is the
54+
RFC 8058 one-click endpoint, so it also works from the mailbox-provider
55+
"Unsubscribe" button.
56+
57+
## Behaviour in previews
58+
59+
In the template editor / preview there is no real message yet, so each system
60+
variable renders as **its own name** (e.g. `{{ posta_web_view_url }}`
61+
`posta_web_view_url`) rather than a generated link. This lets a preview of a
62+
template that uses system variables render without errors, and shows the author
63+
which variable sits where. The real links are only generated when the message is
64+
actually sent.

internal/handlers/template_handler.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,10 @@ func (h *TemplateHandler) Preview(c *okapi.Context, req *PreviewTemplateRequest)
199199
if data == nil {
200200
data = map[string]any{}
201201
}
202+
// Expose reserved {{ posta_* }} variables by name so previews of templates that
203+
// use them render without missing-key errors. No real message exists here, so
204+
// they resolve to their own names rather than generated links.
205+
data = email.WithSystemVarNames(data)
202206

203207
input := &email.RenderInput{
204208
SubjectTemplate: req.Body.SubjectTemplate,

internal/handlers/tracking_handler.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121
"fmt"
2222
"html"
2323
"net/http"
24+
"regexp"
2425
"strings"
2526
"time"
2627

@@ -30,6 +31,11 @@ import (
3031
"github.com/jkaninda/okapi"
3132
)
3233

34+
// openPixelRe matches the open-tracking pixel injected by tracking.ProcessHTML so
35+
// it can be stripped from the hosted "view in browser" page — rendering a stored
36+
// campaign body in a browser would otherwise re-fire the open and inflate metrics.
37+
var openPixelRe = regexp.MustCompile(`(?i)<img[^>]+src=["'][^"']*/t/o/[^"']*["'][^>]*>`)
38+
3339
type TrackingHandler struct {
3440
trackingRepo *repositories.TrackingRepository
3541
messageRepo *repositories.CampaignMessageRepository
@@ -78,6 +84,10 @@ type TrackingUnsubscribeRequest struct {
7884
Token string `param:"token"`
7985
}
8086

87+
type TrackingWebViewRequest struct {
88+
Token string `param:"token"`
89+
}
90+
8191
// OpenPixel serves a 1x1 transparent GIF and records the open event.
8292
// Signature is mandatory; unsigned or bad-sig requests get 404.
8393
func (h *TrackingHandler) OpenPixel(c *okapi.Context, req *TrackingOpenRequest) error {
@@ -216,6 +226,42 @@ h1{font-size:20px;color:#16a34a}p{color:#6b7280;font-size:14px}</style></head><b
216226
return c.HTMLView(http.StatusOK, confirmHTML, okapi.M{})
217227
}
218228

229+
// WebView renders a hosted copy of a sent email ("view in browser"). The token is
230+
// an HMAC-signed, expiring capability bound to the email's opaque UUID. Because the
231+
// page renders arbitrary customer HTML, it is served with a restrictive CSP, no
232+
// cookies, and noindex, and the open-tracking pixel is stripped so a web view does
233+
// not inflate open metrics.
234+
func (h *TrackingHandler) WebView(c *okapi.Context, req *TrackingWebViewRequest) error {
235+
emailUUID, err := h.trackingService.VerifyWebViewToken(req.Token)
236+
if err != nil {
237+
return c.HTMLView(http.StatusNotFound, "This link is invalid or has expired.", okapi.M{})
238+
}
239+
em, err := h.emailRepo.FindByUUID(emailUUID)
240+
if err != nil || em == nil {
241+
return c.HTMLView(http.StatusNotFound, "Message not found", okapi.M{})
242+
}
243+
244+
body := em.HTMLBody
245+
if strings.TrimSpace(body) == "" {
246+
body = "<pre style=\"white-space:pre-wrap;font-family:sans-serif\">" + html.EscapeString(em.TextBody) + "</pre>"
247+
}
248+
body = openPixelRe.ReplaceAllString(body, "")
249+
250+
hdr := c.ResponseWriter().Header()
251+
hdr.Set("Content-Security-Policy", "default-src 'none'; img-src https: http: data:; style-src 'unsafe-inline' https:; font-src https: data:; base-uri 'none'; form-action 'none'")
252+
hdr.Set("X-Robots-Tag", "noindex, nofollow")
253+
hdr.Set("Referrer-Policy", "no-referrer")
254+
255+
page := fmt.Sprintf(`<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">
256+
<meta name="viewport" content="width=device-width, initial-scale=1"><meta name="robots" content="noindex, nofollow">
257+
<title>%s</title>
258+
<style>body{margin:0;background:#f3f4f6}.posta-wv-bar{font-family:sans-serif;font-size:12px;color:#6b7280;text-align:center;padding:10px;background:#fff;border-bottom:1px solid #e5e7eb}.posta-wv-body{max-width:680px;margin:0 auto;padding:16px}</style>
259+
</head><body><div class="posta-wv-bar">You are viewing a copy of an email.</div><div class="posta-wv-body">%s</div></body></html>`,
260+
html.EscapeString(em.Subject), body)
261+
262+
return c.HTMLView(http.StatusOK, page, okapi.M{})
263+
}
264+
219265
// UnsubscribeConfirm processes the unsubscribe action.
220266
func (h *TrackingHandler) UnsubscribeConfirm(c *okapi.Context, req *TrackingUnsubscribeRequest) error {
221267
messageID, err := h.trackingService.VerifyUnsubscribeToken(req.Token)

internal/routes/routes.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -325,10 +325,10 @@ func InitRoutes(app *okapi.Okapi, db *gorm.DB, redisClient *redis.Client, cfg *c
325325
trackingService := tracking.NewService(trackingRepo, cfg.AppWebURL, []byte(cfg.JWTSecret))
326326
r.h.tracking = handlers.NewTrackingHandler(trackingRepo, campaignMessageRepo, campaignRepo, subscriberRepo, subscriberListRepo, emailRepo, suppressionRepo, trackingService)
327327

328-
// Auto-attach RFC 8058 one-click unsubscribe URLs on transactional sends
329-
// that target a subscriber list (so Gmail/Yahoo bulk-sender requirements
330-
// are met without the caller having to build the header themselves).
331-
emailService.SetTxUnsubscribeGenerator(trackingService)
328+
// Wire the generator for Posta-injected public links (one-click unsubscribe +
329+
// hosted "view in browser"), used to resolve reserved {{ posta_* }} template
330+
// variables once an email's identity is known.
331+
emailService.SetLinkGenerator(trackingService)
332332

333333
// Bounce webhook
334334
r.h.bounceWebhook = handlers.NewBounceWebhookHandler(subscriberRepo, emailRepo, campaignMessageRepo)

internal/routes/tracking_routes.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,14 @@ func (r *Router) trackingRoutes() []okapi.RouteDefinition {
7575
Summary: "Transactional one-click unsubscribe (RFC 8058)",
7676
Options: []okapi.RouteOption{okapi.DocHide()},
7777
},
78+
{
79+
Method: http.MethodGet,
80+
Path: "/t/v/{token}",
81+
Handler: okapi.H(r.h.tracking.WebView),
82+
Tags: []string{"Tracking"},
83+
Summary: "View email in browser",
84+
Options: []okapi.RouteOption{okapi.DocHide()},
85+
},
7886
}
7987
}
8088

internal/services/email/service.go

Lines changed: 48 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import (
2828
"strings"
2929
"time"
3030

31+
"github.com/google/uuid"
3132
"github.com/goposta/posta/internal/models"
3233
"github.com/goposta/posta/internal/services/ratelimit"
3334
"github.com/goposta/posta/internal/services/settings"
@@ -64,11 +65,13 @@ type PlanLimits struct {
6465
MaxBatchSize int
6566
}
6667

67-
// TxUnsubscribeGenerator produces a signed one-click (RFC 8058) unsubscribe
68-
// URL for a transactional email. Kept as a narrow interface so the email
69-
// service does not depend directly on the tracking package.
70-
type TxUnsubscribeGenerator interface {
68+
// LinkGenerator produces the signed public links Posta injects into messages:
69+
// the one-click (RFC 8058) unsubscribe URL and the hosted "view in browser" URL.
70+
// Kept as a narrow interface so the email service does not depend directly on the
71+
// tracking package.
72+
type LinkGenerator interface {
7173
TxUnsubscribeURL(emailID uint) string
74+
WebViewURL(emailUUID string) string
7275
}
7376

7477
type Service struct {
@@ -89,7 +92,7 @@ type Service struct {
8992
settings *settings.Provider
9093
blobStore blob.Store
9194
planLimits PlanLimitsProvider
92-
txUnsubGen TxUnsubscribeGenerator
95+
linkGen LinkGenerator
9396
devMode bool
9497
onSent func()
9598
onFailed func()
@@ -151,11 +154,11 @@ func (s *Service) SetEnqueuer(eq EmailEnqueuer) {
151154
s.enqueuer = eq
152155
}
153156

154-
// SetTxUnsubscribeGenerator wires the one-click unsubscribe URL generator.
155-
// When set, transactional sends that use a subscriber list but don't supply an
156-
// explicit list_unsubscribe_url will get an auto-generated RFC 8058 URL.
157-
func (s *Service) SetTxUnsubscribeGenerator(gen TxUnsubscribeGenerator) {
158-
s.txUnsubGen = gen
157+
// SetLinkGenerator wires the generator for Posta-injected public links — the
158+
// one-click unsubscribe URL and the hosted "view in browser" URL used to resolve
159+
// reserved {{ posta_* }} template variables.
160+
func (s *Service) SetLinkGenerator(gen LinkGenerator) {
161+
s.linkGen = gen
159162
}
160163

161164
// SetBlobStore sets the blob storage backend for persisting email attachments.
@@ -602,6 +605,10 @@ func (s *Service) Send(ctx context.Context, userID, apiKeyID uint, workspaceID *
602605
}
603606

604607
em := &models.Email{
608+
// Assign the UUID up front so reserved {{ posta_* }} links (which key the
609+
// web view off the UUID) can be built without depending on the DB reading
610+
// the gen_random_uuid() default back after insert.
611+
UUID: uuid.NewString(),
605612
UserID: userID,
606613
WorkspaceID: workspaceID,
607614
APIKeyID: apiKeyPtr,
@@ -623,6 +630,23 @@ func (s *Service) Send(ctx context.Context, userID, apiKeyID uint, workspaceID *
623630
return nil, fmt.Errorf("failed to store email: %w", err)
624631
}
625632

633+
// Resolve reserved {{ posta_* }} system links now that the email identity is
634+
// known. Rendering left sentinels in the body; replace them with the real
635+
// per-message URLs. Only touches the DB when the template actually used one.
636+
if s.linkGen != nil && (HasSystemSentinels(em.HTMLBody) || HasSystemSentinels(em.TextBody) || HasSystemSentinels(em.Subject)) {
637+
webViewURL := s.linkGen.WebViewURL(em.UUID)
638+
unsubscribeURL := s.linkGen.TxUnsubscribeURL(em.ID)
639+
em.HTMLBody = SubstituteSystemLinks(em.HTMLBody, webViewURL, unsubscribeURL)
640+
em.TextBody = SubstituteSystemLinks(em.TextBody, webViewURL, unsubscribeURL)
641+
em.Subject = SubstituteSystemLinks(em.Subject, webViewURL, unsubscribeURL)
642+
// Keep req in sync so the synchronous fallback (sendSync) sends the
643+
// resolved body/subject too.
644+
req.HTML = em.HTMLBody
645+
req.Text = em.TextBody
646+
req.Subject = em.Subject
647+
_ = s.emailRepo.Update(em)
648+
}
649+
626650
if s.devMode {
627651
em.Status = models.EmailStatusSent
628652
now := time.Now()
@@ -1003,7 +1027,17 @@ func (s *Service) RenderTemplate(userID uint, workspaceID *uint, templateID *uin
10031027
if err != nil {
10041028
return nil, fmt.Errorf("template not found: %w", err)
10051029
}
1006-
return s.resolveAndRender(tmpl, language, data)
1030+
rendered, err := s.resolveAndRender(tmpl, language, data)
1031+
if err != nil {
1032+
return nil, err
1033+
}
1034+
// This is a preview (editor / dry-run): there is no real message yet, so show
1035+
// the reserved {{ posta_* }} variables by name rather than leaking the internal
1036+
// sentinels or generating throwaway links.
1037+
rendered.HTML = SubstituteSystemLinks(rendered.HTML, VarWebView, VarUnsubscribe)
1038+
rendered.Text = SubstituteSystemLinks(rendered.Text, VarWebView, VarUnsubscribe)
1039+
rendered.Subject = SubstituteSystemLinks(rendered.Subject, VarWebView, VarUnsubscribe)
1040+
return rendered, nil
10071041
}
10081042

10091043
// findTemplate looks up a template by ID or name. When templateID is provided
@@ -1067,7 +1101,9 @@ func (s *Service) resolveAndRender(tmpl *models.Template, language string, data
10671101
TextTemplate: l.TextTemplate,
10681102
CSS: css,
10691103
}
1070-
return s.renderer.Render(input, data)
1104+
// Inject reserved {{ posta_* }} variables as sentinels; the real per-message
1105+
// URLs are substituted later in Send once the email identity is known.
1106+
return s.renderer.Render(input, WithSystemVars(data))
10711107
}
10721108

10731109
// resolveLocalization implements the language fallback strategy:

0 commit comments

Comments
 (0)