@@ -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
7477type 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