From 7d58b874b857ab35aa55bc57cfcf11767a079d51 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Wed, 9 Sep 2026 06:34:43 -0700 Subject: [PATCH 1/5] feat: keep every recipient-facing and self-host-facing address on the deployment's own domain: mint unsubscribe links on a workspace's verified tracking domain (served by the tracking service, proxied to the backend that owns the pages), attach RFC 8058 one-click only over https, resolve all branding through config.Brand() gated on SelfHosted() so a self-host's email footer, sign-in links, stats card, API example and public form badge name nobody else, drop the app.warmbly.com fallback from AppBaseURL, blank TRACKING_DOMAIN and FORMS_DOMAIN on core-only installs, and have install.sh offer to configure a fresh interactive install instead of silently defaulting to localhost --- .env.example | 19 +- admin/src/app/dashboard/OutreachPage.tsx | 6 +- cmd/backend/boot.go | 6 +- docs/content/docs/api/endpoints.mdx | 2 + .../docs/development/configuration.mdx | 14 +- docs/content/docs/development/install.mdx | 6 +- docs/content/docs/guides/mailboxes.mdx | 2 + docs/content/docs/guides/unsubscribe.mdx | 10 + forms/src/FormPage.tsx | 12 +- forms/src/api.ts | 7 + internal/api/handler/auth_config.go | 38 ++++ internal/api/handler/internal_form.go | 13 ++ internal/api/handler/organization.go | 2 +- internal/app/dangerzone/service.go | 5 +- internal/app/email/handler.go | 4 +- internal/app/instancecheck/checks_urls.go | 30 ++- internal/app/notification/email.go | 7 +- internal/app/referral/service.go | 4 +- internal/app/unsublink/signer.go | 23 +- internal/app/unsublink/signer_test.go | 20 ++ internal/app/worker/wmail/send.go | 10 +- internal/config/brand.go | 74 +++++++ internal/config/endpoints.go | 42 +++- internal/formwire/formwire.go | 10 + internal/notify/templates/base.go | 141 ++++++------ internal/notify/templates/digest.go | 2 +- internal/notify/templates/templates_test.go | 123 ++++++++--- internal/notify/templates/trial_expired.go | 2 +- internal/notify/templates/welcome.go | 4 +- internal/tasks/campaign_task.go | 5 +- internal/tasks/optout.go | 10 +- internal/tasks/optout_origin_test.go | 90 ++++++++ internal/tasks/test_email.go | 2 +- internal/tasks/tracking_host.go | 36 ++- site/public/install.sh | 70 +++++- site/public/install.sh.sha256 | 2 +- tracking/src/handlers.rs | 83 +++++++ tracking/src/main.rs | 19 +- tracking/src/unsubscribe.rs | 207 ++++++++++++++++++ web/src/app/app/api-keys/page.tsx | 6 +- web/src/app/app/settings/limits/page.tsx | 23 +- web/src/app/auth/layout.tsx | 36 +-- web/src/app/auth/login/page.tsx | 24 +- web/src/app/cli/page.tsx | 8 +- web/src/app/connect/page.tsx | 8 +- .../app/analytics/StatsShareCard.tsx | 25 ++- .../app/automations/ExpressionReference.tsx | 3 +- .../campaigns/sequences/RichTextEditor.tsx | 3 +- .../components/app/emails/InboxDetails.tsx | 2 +- .../app/emails/WarmupCoverageNotice.tsx | 2 +- web/src/components/app/forms/FormPreview.tsx | 16 +- web/src/components/layout/UpgradeDialog.tsx | 23 +- web/src/components/shared/BrandMark.tsx | 27 +++ web/src/hooks/useBrand.ts | 29 +++ web/src/lib/api/hooks/auth/useAuthConfig.ts | 4 + web/src/lib/api/models/auth/AuthConfig.ts | 19 ++ web/src/lib/information.ts | 1 - 57 files changed, 1187 insertions(+), 234 deletions(-) create mode 100644 internal/config/brand.go create mode 100644 internal/tasks/optout_origin_test.go create mode 100644 tracking/src/unsubscribe.rs create mode 100644 web/src/components/shared/BrandMark.tsx create mode 100644 web/src/hooks/useBrand.ts diff --git a/.env.example b/.env.example index 18f20ebe..cd17b8da 100644 --- a/.env.example +++ b/.env.example @@ -115,8 +115,10 @@ DEPLOYMENT_MODE=self_hosted # === Addresses ================================================================ # # APP_URL is the source of every emailed link: password resets, invitations and -# the first-run claim link. Leave it unset and those links are built against the -# hosted service, which sends a live reset token off your deployment. +# the first-run claim link. Leave it unset and a self-host guesses the origin +# from CORS_ALLOW_ORIGINS or PUBLIC_HOST; it never falls back to the hosted +# service, because a working link to somebody else's dashboard would carry a +# live reset token off your deployment. Set it and stop guessing. # With the shipped docker-compose.yml, set PUBLIC_HOST to your LAN IP or domain # and APP_URL / API_PUBLIC_URL / CORS_ALLOW_ORIGINS / WEBSOCKET_URL / @@ -143,8 +145,12 @@ DEPLOYMENT_MODE=self_hosted # Host serving open pixels and click links, and the CNAME value customers point # their own tracking subdomain at. Use a separate, neutral domain in production -# and proxy it to the tracking service on :3000. Unset means mail goes out with -# no pixel and unwrapped links, and no custom tracking domain can verify. +# and proxy it to the tracking service on :3000. A workspace that verifies its +# own domain against this one also serves its recipients' unsubscribe link +# there; every other opt-out is served from API_PUBLIC_URL. Unset means mail +# goes out with no pixel and unwrapped links, and no custom tracking domain can +# verify. Do not set it without running the tracking service: a host nothing +# answers on breaks every link in the mail. # TRACKING_DOMAIN=localhost:3000 # Host port the tracking service is published on. 3000 is a very common default @@ -340,7 +346,10 @@ EMAIL_ADDRESS=noreply@example.com # === Transactional email branding ============================================= -# A self-hosted install should not send mail attributed to another company. +# A self-hosted install should not send mail attributed to another company, so +# these have no defaults here: unset renders no footer link row and no company +# identification line, rather than ours. EMAIL_BRAND_NAME is the exception; it +# is the software's name and defaults to Warmbly. # EMAIL_BRAND_NAME=Acme # EMAIL_BRAND_LEGAL_ENTITY=Acme Ltd # EMAIL_BRAND_COMPANY_NUMBER= diff --git a/admin/src/app/dashboard/OutreachPage.tsx b/admin/src/app/dashboard/OutreachPage.tsx index 7664a2d9..3025e0eb 100644 --- a/admin/src/app/dashboard/OutreachPage.tsx +++ b/admin/src/app/dashboard/OutreachPage.tsx @@ -56,7 +56,9 @@ export default function OutreachPage() { const qc = useQueryClient(); const [mode, setMode] = useState("email"); const [target, setTarget] = useState(""); - const [replyTo, setReplyTo] = useState("team@warmbly.com"); + // Blank, not our support address: prefilling it on somebody else's + // instance addresses their customers' replies to us. + const [replyTo, setReplyTo] = useState(""); const [subject, setSubject] = useState(""); const [body, setBody] = useState(""); @@ -298,7 +300,7 @@ export default function OutreachPage() { id="reply_to" value={replyTo} onChange={(e) => setReplyTo(e.target.value)} - placeholder="team@warmbly.com (replies will land here)" + placeholder="support@yourdomain.com (replies will land here)" className="font-mono text-sm" />

diff --git a/cmd/backend/boot.go b/cmd/backend/boot.go index e503cbc9..1cd6bf7c 100644 --- a/cmd/backend/boot.go +++ b/cmd/backend/boot.go @@ -190,7 +190,11 @@ func mailTransportKind(t *notify.Transport) string { // only as a failure in the browser. func warnDeploymentURLs(ctx context.Context, appURL string) { if appURL == "" { - log.Printf("Warning: APP_URL is not set. Password reset and team invitation emails will link to %s, which is almost certainly not this deployment.", config.AppBaseURL()) + if guess := config.AppBaseURL(); guess != "" { + log.Printf("Warning: APP_URL is not set. Password reset and team invitation emails will link to %s, guessed from CORS_ALLOW_ORIGINS or PUBLIC_HOST. Set APP_URL if that is not where the dashboard is served.", guess) + return + } + log.Printf("Warning: APP_URL is not set and nothing else names this deployment's dashboard, so password reset and team invitation emails carry links with no host and nobody can open them. Set APP_URL.") return } if !passkeysUsableFor(appURL) { diff --git a/docs/content/docs/api/endpoints.mdx b/docs/content/docs/api/endpoints.mdx index 28949516..2d1c8ca7 100644 --- a/docs/content/docs/api/endpoints.mdx +++ b/docs/content/docs/api/endpoints.mdx @@ -349,6 +349,8 @@ These never accept an API key. They depend on a human-bound session: billing flo `GET /auth/config` also carries `websocket_url` and `app_url` (both strings, each omitted when the instance has none). They are the realtime gateway a developer client connects to and the dashboard origin a client sends someone to. Both are served here because on a self-hosted instance the host layout is whatever the operator chose, and there is no other way to discover it: the [CLI](/api/cli/) reads them for `warmbly events tail` and `warmbly browse`. +Alongside them, `api_url` is this API's own public base (for a copyable example that names the right server), and `brand` is who the deployment says it is: `name`, and `website_url`, `website_label`, `terms_url`, `privacy_url` and `support_email`, each omitted when unset. On a self-hosted instance that configured no `EMAIL_BRAND_*` only `name` is present, and a client should render no link at all rather than substituting one of its own. See [configuration](/development/configuration/). + `GET /auth/config` also carries `billing_enabled` (boolean). It is `false` when the deployment runs with `BILLING_PROVIDER=none`, which is the self-host default: every feature is unlocked server-side, so the dashboard shows the workspace as self-hosted instead of on a free trial and hides the billing and referral pages. `self_hosted` alone does not imply this, because a self-hosted install may still run Stripe. - `POST /auth/setup` (first-run claim: exchanges the one-time token printed at boot for the owner account. Refused once any account exists) - `GET /auth/providers`, `POST /auth/apple`, `POST /auth/google` (native-app social sign-in) diff --git a/docs/content/docs/development/configuration.mdx b/docs/content/docs/development/configuration.mdx index 9c08d6a1..bf0e4f02 100644 --- a/docs/content/docs/development/configuration.mdx +++ b/docs/content/docs/development/configuration.mdx @@ -97,11 +97,11 @@ The secret check runs in the backend. The consumer and the workers start happily ## Addresses -Every emailed link (password reset, invitation, the first-run claim link) is built from `APP_URL`. Leave it unset and those links are built against the hosted service, which means a live reset token leaves your deployment. +Every emailed link (password reset, invitation, the first-run claim link) is built from `APP_URL`. Leave it unset and a self-hosted instance guesses the origin from `CORS_ALLOW_ORIGINS` or `PUBLIC_HOST` instead, and never falls back to the hosted service: a link nobody can open is a support ticket, while a working link to somebody else's dashboard carries a live reset token out of your deployment. Set it. | Variable | What it does | Default | Restart needed | |---|---|---|---| -| `APP_URL` | The dashboard origin. The source of every emailed link | `https://app.warmbly.com` | no (read per request) | +| `APP_URL` | The dashboard origin. The source of every emailed link | guessed from `CORS_ALLOW_ORIGINS`, then `PUBLIC_HOST` | no (read per request) | | `FRONTEND_BASE_URL` | Alternative name for the same value, read when `APP_URL` is unset | unset | no | | `API_PUBLIC_URL` | The backend's public base. Frontends, blob URLs and the OIDC redirect derive from it | derived from `PUBLIC_HOST` under compose | yes | | `BACKEND_PUBLIC_URL` | The backend base used in generated worker configuration | falls back to `API_PUBLIC_URL` | yes | @@ -111,7 +111,7 @@ Every emailed link (password reset, invitation, the first-run claim link) is bui | `CORS_ALLOW_ORIGINS` | Comma separated origins allowed to call the API. Anything not listed gets `403` on preflight | derived from `PUBLIC_HOST` under compose | yes | | `WEBSOCKET_URL` | The websocket URL the dashboard connects to | derived under compose | yes (container start) | | `PHX_HOST` | The realtime service's own hostname | `localhost` | yes | -| `TRACKING_DOMAIN` | The host that serves open pixels and click links, and the `CNAME` value customers point their own tracking subdomain at. Use a separate, neutral domain in production. Unset means campaign mail ships with no pixel and unwrapped links, and no custom tracking domain can verify | `localhost:3000` under compose, otherwise unset | no | +| `TRACKING_DOMAIN` | The host that serves open pixels, click links and the unsubscribe pages for workspaces that verified their own domain against it, and the `CNAME` value those customers point their tracking subdomain at. Use a separate, neutral domain in production. Unset means campaign mail ships with no pixel and unwrapped links, no custom tracking domain can verify, and every opt-out is served from `API_PUBLIC_URL` | `localhost:3000` under compose, otherwise unset | no | | `TRACKING_SERVICE_URL` | Where the backend reaches the tracking service internally | unset | yes | | `FORMS_DOMAIN` | The host hosted form pages and embeds are served on (`forms.example.com`, routed to the forms service). The backend builds share links and embed codes from it; unset leaves forms without a public URL | unset | no (read per request) | @@ -196,10 +196,16 @@ Platform mail is the product's own outbound: registration codes, password resets | `SMTP_AUTH` | `auto`, `plain`, `login`, `cram-md5` or `none` | `auto` | yes | | `SMTP_EHLO_NAME` | EHLO name presented to the relay | the sender domain | yes | | `SMTP_TLS_INSECURE_SKIP_VERIFY` | Skips certificate verification. Only for a relay with a private certificate authority | `false` | yes | -| `EMAIL_BRAND_NAME` and the other `EMAIL_BRAND_*` values | Name, legal entity, address and links in the transactional footer | Warmbly's own | yes | +| `EMAIL_BRAND_NAME` | The product name in transactional subjects and the header | `Warmbly` | yes | +| `EMAIL_BRAND_LEGAL_ENTITY`, `EMAIL_BRAND_COMPANY_NUMBER`, `EMAIL_BRAND_PLACE_OF_REG`, `EMAIL_BRAND_ADDRESS` | The identification line in the transactional footer | unset on a self-host, which renders no line | yes | +| `EMAIL_BRAND_WEBSITE_URL`, `EMAIL_BRAND_TERMS_URL`, `EMAIL_BRAND_PRIVACY_URL`, `EMAIL_BRAND_SUPPORT_EMAIL` | The footer's links and support address | unset on a self-host, which renders no link row | yes | | `NOTIFICATION_EMAIL_DAILY_CAP` | Notification emails per user per day. `0` means uncapped | `25` | yes | | `NOTIFICATION_PUSH_WINDOW` | How long a notification waits before it is also pushed | `5h` | yes | +A self-hosted instance never fills those in with ours. Warmbly's own company details and website are the hosted service's, not yours, so an unset `EMAIL_BRAND_*` renders nothing rather than sending your users a footer naming a company they have no relationship with. Set them to your own if your jurisdiction wants an identification line on business email. + +Despite the prefix, these are not only about email. `GET /v1/auth/config` serves the public half of them (name, website, terms, privacy, support address) and every surface that used to hardcode `warmbly.com` reads it: the sign-in screen's wordmark and its Terms and Privacy links, the copyable API example on the API keys page, a shared stats card, and the "powered by" line at the foot of a [hosted form page](/guides/forms/). Each renders nothing when the value is unset, so a stranger filling in one of your forms is never sent to a website with no relationship to it. + `log` is a real transport, not a broken one: it writes every message to the backend log and delivers nothing. It exists so a fresh install can complete its first sign-in with no relay. What it costs you is password resets, invitation delivery and digests, all of which have a workaround described on [accounts and access](/development/accounts-and-access/#without-a-mail-relay). Read a code out of the log: diff --git a/docs/content/docs/development/install.mdx b/docs/content/docs/development/install.mdx index ef0db55d..01fca901 100644 --- a/docs/content/docs/development/install.mdx +++ b/docs/content/docs/development/install.mdx @@ -15,6 +15,8 @@ Add `--wizard` and it asks the questions a config reference cannot ask for you. curl -fsSL https://warmbly.com/install.sh | sh -s -- --wizard ``` +You do not have to remember the flag. A fresh install with a terminal in front of it and no hostname given asks whether to configure itself before it writes anything, because the alternative is an instance on `localhost`: a dashboard, a tracking host and an unsubscribe address that work from that machine and nowhere else, all of which end up inside campaign mail. Answer no and it says so plainly and carries on. `--assume-yes`, `--host`, `WARMBLY_HOST` and a re-run over an existing install all skip the question, because each of them already answered it. + ## What it installs | | | @@ -197,7 +199,9 @@ The first owner, either as a printed claim link or unattended from `WARMBLY_BOOT ### Footprint -Everything, or core only (no tracking pixel, no websockets, no hosted forms). Ports are checked for collisions before anything is written. Last, the update check, which is one outbound call to the GitHub releases API and the only outbound call this instance makes on its own. There is no telemetry in Warmbly. +Everything, or core only (no tracking pixel, no websockets, no hosted forms). Core only leaves `TRACKING_DOMAIN` and `FORMS_DOMAIN` unset, because naming a host nothing answers on is worse than naming none: campaign mail would ship links and an opt-out address pointing at a name that does not resolve. Sending still works, without open and click tracking, and every [unsubscribe link](/guides/unsubscribe/) is served from `API_PUBLIC_URL`. + +Ports are checked for collisions before anything is written. Last, the update check, which is one outbound call to the GitHub releases API and the only outbound call this instance makes on its own. There is no telemetry in Warmbly. diff --git a/docs/content/docs/guides/mailboxes.mdx b/docs/content/docs/guides/mailboxes.mdx index 515c96dd..aecc6aa1 100644 --- a/docs/content/docs/guides/mailboxes.mdx +++ b/docs/content/docs/guides/mailboxes.mdx @@ -193,6 +193,8 @@ Press **Check again** any time to re-resolve without changing the value. Warmbly Until it verifies, tracking falls back to the shared host, so an unverified domain never breaks sending: links keep working, they just are not on your name yet. Links already sent keep working after a change; new sends pick up the new domain. +A verified domain also serves the [unsubscribe link](/guides/unsubscribe/#which-address-the-link-points-at) in that mailbox's campaign mail, so the opt-out a recipient reads sits on your name like every other link in the message. An unverified one changes nothing: the opt-out stays on the instance's API address, which always serves it. + ## Warmup New mailboxes should warm up before carrying campaign volume. Defaults are `10`/day starting volume, `+1`/day ramp, and a `40`/day ceiling. diff --git a/docs/content/docs/guides/unsubscribe.mdx b/docs/content/docs/guides/unsubscribe.mdx index 1f98aba6..02962f83 100644 --- a/docs/content/docs/guides/unsubscribe.mdx +++ b/docs/content/docs/guides/unsubscribe.mdx @@ -21,6 +21,12 @@ The wording of the sentence and of the link text is yours to change. **Unsubscribe link** is the right choice when your list skews toward consumers, when your legal team asks for a link, or when your volume is high enough that provider bulk-sender rules apply. The link is unique to the recipient and campaign, signed so it cannot be guessed or altered, and valid for a year after the send. Clicking it opens a plain confirmation page with one button. Nothing happens until the button is pressed, because link scanners and preview fetchers follow every link in an email. The page then offers a way back for anyone who unsubscribed by mistake. +### Which address the link points at + +A mailbox or campaign with a verified [custom tracking domain](/guides/mailboxes/#custom-tracking-domain) serves its unsubscribe link there, so a recipient reads `https://t.yourdomain.com/unsubscribe/...` rather than an address on the platform. The campaign's own domain wins over the mailbox's, and only a verified one is used. The page itself is the same page, named after you: it carries no logo, no product name and no scripts, because the email came from your mailbox and not from a platform. + +Without a custom domain the link stays on the instance's own API address. Either way it is the sender's infrastructure, not a third party's, and the recipient's opt-out reaches the same suppression list. + You can also place the link inside your own copy instead of the footer: insert the **Unsubscribe link** variable from the variable menu, or type `{{.UnsubscribeLink}}`. Dropped into your copy on its own, the variable becomes a real link in the HTML message, labelled with the same **Link text** the footer uses ("Unsubscribe" by default). The recipient reads a word, not the signed address. To choose the wording yourself, select the text first and then pick **Unsubscribe link** from the variable menu, or press the link button and use the **Unsubscribe** shortcut next to the address field: the selected text becomes the link and Warmbly fills in the address at send time. @@ -78,3 +84,7 @@ The suppression list is available over the API as `GET /suppressions`, `POST /su ## Self-hosting Unsubscribe links are served by the API process on the origin in `API_PUBLIC_URL`, so that variable must be set to the address recipients can reach. Without it Warmbly cannot mint links: the header is left off, and the link mode falls back to the reply-to-opt-out sentence. Links are signed under `AUTH_SECRET`; rotating it invalidates links in emails already sent. + +Nothing in a campaign email points at warmbly.com. The opt-out address, the tracking pixel and every wrapped link are built from your own `API_PUBLIC_URL` and `TRACKING_DOMAIN`, and a workspace on your instance that verifies its own tracking domain gets the opt-out on that domain instead. A workspace's verified domain is a CNAME to your `TRACKING_DOMAIN`, so the tracking service serves the unsubscribe routes as well as the pixel and click tickets: an install running [core only](/development/install/) has no tracking service, keeps `TRACKING_DOMAIN` unset, and serves every opt-out from the API address. + +One-click (`List-Unsubscribe-Post`) is attached only when the link is `https`. An instance reachable over plain HTTP still sends the plain `List-Unsubscribe` header, because a provider POSTing an opt-out to an `http` address either refuses it or sends the token in the clear. diff --git a/forms/src/FormPage.tsx b/forms/src/FormPage.tsx index 97803ebd..98d42362 100644 --- a/forms/src/FormPage.tsx +++ b/forms/src/FormPage.tsx @@ -104,11 +104,13 @@ export function FormPage() { {design.layout !== "split" && !design.logoOnPage && bodyLogo} -

- - Powered by Warmbly - -
+ {form.brand?.url && ( +
+ + Powered by {form.brand.name} + +
+ )} diff --git a/forms/src/api.ts b/forms/src/api.ts index ab6cca96..95a289c2 100644 --- a/forms/src/api.ts +++ b/forms/src/api.ts @@ -71,11 +71,18 @@ export interface PublicForm { cover_url?: string; background_url?: string; captcha_site_key?: string; + /** The "powered by" attribution. Absent when the deployment configured none. */ + brand?: FormBrand; /** Present only when a valid personalized ?t= link opened the page. */ prefill?: Record; link_token?: string; } +export interface FormBrand { + name: string; + url?: string; +} + export interface SubmitPayload { answers: Record; /** Honeypot value; a human never fills it. */ diff --git a/internal/api/handler/auth_config.go b/internal/api/handler/auth_config.go index 888afa53..e8a936cf 100644 --- a/internal/api/handler/auth_config.go +++ b/internal/api/handler/auth_config.go @@ -84,6 +84,29 @@ type DeploymentAuthConfig struct { // a chat integration) cannot derive it: on a self-hosted instance the host // layout is whatever the operator chose. AppURL string `json:"app_url,omitempty"` + + // APIURL is this API's own public base, taken from the request rather than + // guessed. The dashboard shows it in copyable API examples, which used to + // name api.warmbly.com on every install that was not ours. + APIURL string `json:"api_url,omitempty"` + + // Brand is what this deployment calls itself and where it points people. + // Every field is empty on a self-host that configured no EMAIL_BRAND_*, + // and every surface reading it renders nothing rather than sending that + // operator's users to a website with no relationship to their instance. + Brand DeploymentBrand `json:"brand"` +} + +// DeploymentBrand is the public half of config.Brand: what the sign-in screen, +// a shared stats card and a public form page may show. The registered-company +// details stay out of it; only the email footer is their place. +type DeploymentBrand struct { + Name string `json:"name"` + WebsiteURL string `json:"website_url,omitempty"` + WebsiteLabel string `json:"website_label,omitempty"` + TermsURL string `json:"terms_url,omitempty"` + PrivacyURL string `json:"privacy_url,omitempty"` + SupportEmail string `json:"support_email,omitempty"` } // accountsDocsURL is the page every registration refusal points at. @@ -118,5 +141,20 @@ func (h *Handler) AuthConfig(c *gin.Context) { DocsURL: accountsDocsURL, WebsocketURL: config.WebsocketURL(), AppURL: config.AppBaseURL(), + APIURL: publicAPIBaseURL(c), + Brand: deploymentBrand(), }) } + +// deploymentBrand is the public subset of this deployment's branding. +func deploymentBrand() DeploymentBrand { + b := config.Brand() + return DeploymentBrand{ + Name: b.Name, + WebsiteURL: b.WebsiteURL, + WebsiteLabel: b.WebsiteLabel(), + TermsURL: b.TermsURL, + PrivacyURL: b.PrivacyURL, + SupportEmail: b.SupportEmail, + } +} diff --git a/internal/api/handler/internal_form.go b/internal/api/handler/internal_form.go index 1c6e2b55..4f4bb913 100644 --- a/internal/api/handler/internal_form.go +++ b/internal/api/handler/internal_form.go @@ -44,6 +44,7 @@ func (h *Handler) InternalGetPublicForm(c *gin.Context) { BackgroundURL: f.BackgroundURL, AllowedDomains: f.AllowedDomains, CaptchaSiteKey: formCaptchaSiteKey(f), + Brand: formBrand(), } if token := c.Query("t"); token != "" { if link, prefill := h.FormService.ResolveLink(c.Request.Context(), f, token); link != nil { @@ -54,6 +55,18 @@ func (h *Handler) InternalGetPublicForm(c *gin.Context) { c.JSON(http.StatusOK, out) } +// formBrand is the "powered by" line a public form page carries, or nil when +// this deployment set none. A self-host that configured no EMAIL_BRAND_* shows +// no attribution at all: the page is the operator's, seen by the operator's +// leads, and the platform has no claim on that footer. +func formBrand() *formwire.Brand { + b := config.Brand() + if b.WebsiteURL == "" { + return nil + } + return &formwire.Brand{Name: b.Name, URL: b.WebsiteURL} +} + // InternalRecordFormEvent stores one funnel event; the forms service already // deduped views, filtered prefetches and budgeted the source. func (h *Handler) InternalRecordFormEvent(c *gin.Context) { diff --git a/internal/api/handler/organization.go b/internal/api/handler/organization.go index e02359e9..0ebd0296 100644 --- a/internal/api/handler/organization.go +++ b/internal/api/handler/organization.go @@ -225,7 +225,7 @@ func (h *Handler) InviteMember(c *gin.Context) { // Send invitation email if h.EmailNotificationService != nil { - subject := fmt.Sprintf("You've been invited to join %s on %s", orgName, templates.CompanyName) + subject := fmt.Sprintf("You've been invited to join %s on %s", orgName, templates.CompanyName()) acceptURL := config.GetInviteURL(inv.Token) // GenerateInvitationHTML reports its own render errors to Sentry. if body, gerr := templates.GenerateInvitationHTML(inviterName, orgName, acceptURL); gerr == nil { diff --git a/internal/app/dangerzone/service.go b/internal/app/dangerzone/service.go index d5455a66..dba7f515 100644 --- a/internal/app/dangerzone/service.go +++ b/internal/app/dangerzone/service.go @@ -16,6 +16,7 @@ import ( "time" "github.com/google/uuid" + "github.com/warmbly/warmbly/internal/config" "github.com/warmbly/warmbly/internal/observability/errs" "github.com/warmbly/warmbly/internal/errx" @@ -57,7 +58,7 @@ type service struct { notifier notify.EmailNotificationService // frontendBaseURL is used when building cancellation links in emails. - // Falls back to "https://app.warmbly.com" if empty. + // Falls back to this deployment's own dashboard origin if empty. frontendBaseURL string } @@ -70,7 +71,7 @@ func NewService( frontendBaseURL string, ) Service { if frontendBaseURL == "" { - frontendBaseURL = "https://app.warmbly.com" + frontendBaseURL = config.AppBaseURL() } return &service{ repo: repo, diff --git a/internal/app/email/handler.go b/internal/app/email/handler.go index 8a2cef95..663ddae3 100644 --- a/internal/app/email/handler.go +++ b/internal/app/email/handler.go @@ -196,13 +196,13 @@ func (s *emailService) GetTrackingDomain(ctx context.Context, orgID, emailAccoun switch { case account.TrackingDomain == "": status.Status = trackdns.CodeUnset - status.Message = "No custom tracking domain is set, so opens and clicks go through the shared tracking host." + status.Message = "No custom tracking domain is set, so opens and clicks go through the shared tracking host and the unsubscribe link stays on this install's API address." case target == "": status.Status = trackdns.CodeNoTarget status.Message = "This Warmbly install has no tracking host configured, so there is nothing to point a CNAME at yet. Ask your administrator to set TRACKING_DOMAIN." case account.TrackingDomainVerified: status.Status = trackdns.CodeVerified - status.Message = fmt.Sprintf("%s points at %s.", account.TrackingDomain, target) + status.Message = fmt.Sprintf("%s points at %s. Opens, clicks and the unsubscribe link in this mailbox's campaign mail are served there.", account.TrackingDomain, target) default: status.Status = trackingStatusPending status.Message = fmt.Sprintf("%s has not verified yet. Check it again to see what DNS returns for it right now.", account.TrackingDomain) diff --git a/internal/app/instancecheck/checks_urls.go b/internal/app/instancecheck/checks_urls.go index 27306dc0..23ac571b 100644 --- a/internal/app/instancecheck/checks_urls.go +++ b/internal/app/instancecheck/checks_urls.go @@ -25,6 +25,7 @@ func urlChecks() []check { {id: "api_public_url_unset_oidc", run: checkAPIPublicURLUnsetOIDC}, {id: "oidc_discovery_failed", run: checkOIDCDiscoveryFailed}, {id: "websocket_unreachable", run: checkWebsocketUnreachable}, + {id: "tracking_domain_unset", run: checkTrackingDomainUnset}, {id: "tracking_domain_unreachable", run: checkTrackingDomainUnreachable}, {id: "app_origin_wildcard", run: checkAppOriginWildcard}, } @@ -34,10 +35,17 @@ func checkAppURLUnset(ctx context.Context, d Deps, in Input) *Finding { if appURLConfigured() { return nil } + guess := config.AppBaseURL() + if guess == "" { + return result(CategoryURLs, SeverityError, "APP_URL is not set", + "APP_URL is not set and nothing else names this instance's dashboard, so password reset, invitation and setup links "+ + "are being mailed as paths with no host in front of them and nobody can open one. Set APP_URL to your dashboard origin.", + docsAddresses) + } return result(CategoryURLs, SeverityError, "APP_URL is not set", - "APP_URL is not set, so password reset, invitation and setup links are being built against https://app.warmbly.com. "+ - "Those links go to the hosted service, not to your instance, and a reset token in one of them leaves your deployment. "+ - "Set APP_URL to your dashboard origin.", + fmt.Sprintf("APP_URL is not set, so password reset, invitation and setup links are being built against %s, guessed from "+ + "CORS_ALLOW_ORIGINS or PUBLIC_HOST. If that is not where your dashboard is served, every one of those links is dead. "+ + "Set APP_URL to your dashboard origin.", guess), docsAddresses) } @@ -151,6 +159,22 @@ func checkWebsocketUnreachable(ctx context.Context, d Deps, in Input) *Finding { docsRealtime) } +// An install that never configured a tracking host is a working install, and +// this is the only place it is ever said out loud: nothing else fails, mail +// still sends, and the operator finds out months later that no campaign ever +// recorded an open. An installer run that skipped the wizard lands here. +func checkTrackingDomainUnset(ctx context.Context, d Deps, in Input) *Finding { + if env("TRACKING_DOMAIN") != "" { + return nil + } + return result(CategoryURLs, SeverityWarning, "No tracking domain is set", + "TRACKING_DOMAIN is not set, so campaign mail goes out with no open pixel and unwrapped links, no workspace "+ + "can verify a tracking domain of its own, and every recipient's unsubscribe link is served from your API "+ + "address instead of the sender's domain. Sending itself is unaffected. Set TRACKING_DOMAIN to a host "+ + "routed to the tracking service, or leave it unset deliberately if you run no tracking service.", + docsDelivery) +} + func checkTrackingDomainUnreachable(ctx context.Context, d Deps, in Input) *Finding { domain := env("TRACKING_DOMAIN") if domain == "" { diff --git a/internal/app/notification/email.go b/internal/app/notification/email.go index af4a2069..dd5a5acd 100644 --- a/internal/app/notification/email.go +++ b/internal/app/notification/email.go @@ -6,7 +6,6 @@ import ( "os" "sort" "strconv" - "strings" "time" "github.com/google/uuid" @@ -256,9 +255,5 @@ func absoluteLink(link string) string { if link == "" || link[0] != '/' { return link } - base := strings.TrimRight(os.Getenv("APP_URL"), "/") - if base == "" { - base = "https://app.warmbly.com" - } - return base + link + return config.AppBaseURL() + link } diff --git a/internal/app/referral/service.go b/internal/app/referral/service.go index 798655dd..b6ab5a99 100644 --- a/internal/app/referral/service.go +++ b/internal/app/referral/service.go @@ -20,6 +20,7 @@ import ( "time" "github.com/google/uuid" + "github.com/warmbly/warmbly/internal/config" "github.com/warmbly/warmbly/internal/errx" "github.com/warmbly/warmbly/internal/models" "github.com/warmbly/warmbly/internal/observability/errs" @@ -33,7 +34,6 @@ const ( DefaultCurrency = "usd" // reward ledger currency MonthlyRewardCap = 50 // max rewarded conversions per referrer per 30 days clawbackWindow = 30 * 24 * time.Hour - defaultShareBase = "https://app.warmbly.com" codeAlphabet = "ABCDEFGHJKMNPQRSTVWXYZ23456789" // no ambiguous chars codeLength = 8 ) @@ -114,7 +114,7 @@ func NewService( shareBase string, ) Service { if strings.TrimSpace(shareBase) == "" { - shareBase = defaultShareBase + shareBase = config.AppBaseURL() } return &service{ repo: repo, diff --git a/internal/app/unsublink/signer.go b/internal/app/unsublink/signer.go index 65faec92..e4190d3f 100644 --- a/internal/app/unsublink/signer.go +++ b/internal/app/unsublink/signer.go @@ -40,6 +40,11 @@ type Claims struct { ExpiresAt time.Time } +// Path is the path every minted link carries, on whichever origin it is +// served from. Click tracking recognises opt-out links by this segment, so it +// is the same on the API origin and on a workspace's own tracking domain. +const Path = "/unsubscribe/" + // Signer mints links under a key derived from the instance auth secret. The // key is scoped with a purpose string so an unsubscribe token can never be // replayed as any other signed artefact that shares the secret. @@ -73,12 +78,26 @@ func (s *Signer) Token(orgID, campaignID, contactID uuid.UUID, now time.Time) st return base64.RawURLEncoding.EncodeToString(raw) } -// URL mints the full link for the given recipient, or "" when disabled. +// URL mints the full link for the given recipient on the API origin, or "" +// when disabled. func (s *Signer) URL(orgID, campaignID, contactID uuid.UUID, now time.Time) string { + return s.URLOn("", orgID, campaignID, contactID, now) +} + +// URLOn mints the link on a specific origin: the workspace's own verified +// tracking domain, so the address a recipient reads sits on the sender's +// domain rather than the platform's. An empty origin (no custom domain, or a +// deployment that serves opt-outs from the API only) falls back to the API +// origin, which always serves the same routes. +func (s *Signer) URLOn(origin string, orgID, campaignID, contactID uuid.UUID, now time.Time) string { if !s.Enabled() { return "" } - return s.baseURL + "/unsubscribe/" + s.Token(orgID, campaignID, contactID, now) + base := strings.TrimRight(strings.TrimSpace(origin), "/") + if base == "" { + base = s.baseURL + } + return base + Path + s.Token(orgID, campaignID, contactID, now) } // Verify checks the token's signature and expiry and returns its claims. diff --git a/internal/app/unsublink/signer_test.go b/internal/app/unsublink/signer_test.go index 97926e57..f1f11f98 100644 --- a/internal/app/unsublink/signer_test.go +++ b/internal/app/unsublink/signer_test.go @@ -59,3 +59,23 @@ func TestDisabledWithoutBase(t *testing.T) { t.Fatal("expected empty url") } } + +// A workspace with its own verified tracking domain mints the link there, so +// the address the recipient reads is on the sender's domain. The token is the +// same one the API origin verifies, because the host is not signed. +func TestURLOnCustomOrigin(t *testing.T) { + s := New("secret", "https://api.example.com") + org, camp, contact := uuid.New(), uuid.New(), uuid.New() + now := time.Now() + + u := s.URLOn("https://t.acme.com/", org, camp, contact, now) + if !strings.HasPrefix(u, "https://t.acme.com/unsubscribe/") { + t.Fatalf("unexpected url %q", u) + } + if _, err := s.Verify(strings.TrimPrefix(u, "https://t.acme.com/unsubscribe/"), now); err != nil { + t.Fatalf("verify: %v", err) + } + if got := s.URLOn("", org, camp, contact, now); !strings.HasPrefix(got, "https://api.example.com/unsubscribe/") { + t.Fatalf("empty origin should fall back to the API origin, got %q", got) + } +} diff --git a/internal/app/worker/wmail/send.go b/internal/app/worker/wmail/send.go index 5cad8902..d188249e 100644 --- a/internal/app/worker/wmail/send.go +++ b/internal/app/worker/wmail/send.go @@ -86,10 +86,14 @@ func buildSendHeaders(req *SendRequest) map[string]string { h[config.WarmupVerifyHeader] = req.WarmupToken } if req.UnsubscribeURL != "" { - // RFC 8058: the HTTPS URI in List-Unsubscribe plus the one-click marker - // tells Gmail/Yahoo/Microsoft to POST List-Unsubscribe=One-Click here. h["List-Unsubscribe"] = "<" + req.UnsubscribeURL + ">" - h["List-Unsubscribe-Post"] = "List-Unsubscribe=One-Click" + // RFC 8058 one-click requires the URI to be https: a provider that + // POSTs an http address either refuses or leaks the token in clear, so + // an http link (a LAN or dev install) ships as a plain RFC 2369 header + // the recipient clicks instead of a one-click button that will not work. + if strings.HasPrefix(strings.ToLower(req.UnsubscribeURL), "https://") { + h["List-Unsubscribe-Post"] = "List-Unsubscribe=One-Click" + } } if len(h) == 0 { return nil diff --git a/internal/config/brand.go b/internal/config/brand.go new file mode 100644 index 00000000..9cb999ad --- /dev/null +++ b/internal/config/brand.go @@ -0,0 +1,74 @@ +package config + +import ( + "os" + "strings" +) + +// Brand is the deployment's own identity in everything it shows the outside +// world: the transactional email footer, the sign-in screen, public form pages, +// a shared stats card. +// +// The hosted defaults are hosted-only. A self-host that set no EMAIL_BRAND_* +// gets empty strings, and every surface that reads them renders nothing rather +// than another company's name, terms and website. Those are not a sensible +// fallback for somebody else's server: they send that operator's users and +// leads to a site with no relationship to the mail or the form they just saw. +// +// EMAIL_BRAND_* is the historic prefix (the footer was the first surface) and +// stays the name of the setting, because renaming it would silently un-brand +// every install that already sets it. +type BrandConfig struct { + // Name is the product's name, which is true of a self-host too, so unlike + // everything else here it keeps its default. + Name string + + // The Companies Act 2006 identification line, hosted-only. + LegalEntity string + CompanyNumber string + PlaceOfReg string + Address string + + // Public links and the support address, hosted-only. + WebsiteURL string + TermsURL string + PrivacyURL string + SupportEmail string +} + +// Brand resolves the deployment's branding from the environment. +func Brand() BrandConfig { + return BrandConfig{ + Name: brandEnv("EMAIL_BRAND_NAME", "Warmbly"), + LegalEntity: brandEnv("EMAIL_BRAND_LEGAL_ENTITY", hostedOnly("Mindroot Ltd")), + CompanyNumber: brandEnv("EMAIL_BRAND_COMPANY_NUMBER", hostedOnly("16543299")), + PlaceOfReg: brandEnv("EMAIL_BRAND_PLACE_OF_REG", hostedOnly("England and Wales")), + Address: brandEnv("EMAIL_BRAND_ADDRESS", hostedOnly("71-75 Shelton Street, London, England, WC2H 9JQ")), + WebsiteURL: brandEnv("EMAIL_BRAND_WEBSITE_URL", hostedOnly("https://warmbly.com")), + TermsURL: brandEnv("EMAIL_BRAND_TERMS_URL", hostedOnly("https://warmbly.com/terms")), + PrivacyURL: brandEnv("EMAIL_BRAND_PRIVACY_URL", hostedOnly("https://warmbly.com/privacy")), + SupportEmail: brandEnv("EMAIL_BRAND_SUPPORT_EMAIL", hostedOnly("team@warmbly.com")), + } +} + +// WebsiteLabel is the display text for a link to WebsiteURL, derived from the +// URL so a rebranded install never renders "warmbly.com" pointing elsewhere. +func (b BrandConfig) WebsiteLabel() string { + label := strings.TrimPrefix(strings.TrimPrefix(b.WebsiteURL, "https://"), "http://") + return strings.TrimSuffix(label, "/") +} + +func brandEnv(key, def string) string { + if v := strings.TrimSpace(os.Getenv(key)); v != "" { + return v + } + return def +} + +// hostedOnly is a default that only applies to the hosted service. +func hostedOnly(def string) string { + if SelfHosted() { + return "" + } + return def +} diff --git a/internal/config/endpoints.go b/internal/config/endpoints.go index c5a2e9a5..a2a06946 100644 --- a/internal/config/endpoints.go +++ b/internal/config/endpoints.go @@ -14,15 +14,47 @@ import ( // else's dashboard, carrying a live reset token signed with the self-host's own // AUTH_SECRET. APP_URL is the documented variable; FRONTEND_BASE_URL is the // older name and stays supported so existing deployments keep working. +// +// An install that set neither is answered from what it did configure, and a +// self-host is never answered with the hosted dashboard: a link nobody can +// open is a support ticket, while a working link to someone else's app is that +// deployment's tokens walking out of it. func AppBaseURL() string { for _, key := range []string{"APP_URL", "FRONTEND_BASE_URL"} { if v := strings.TrimRight(strings.TrimSpace(os.Getenv(key)), "/"); v != "" { return v } } + if v := inferredAppBaseURL(); v != "" { + return v + } + if SelfHosted() { + return "" + } return "https://app.warmbly.com" } +// inferredAppBaseURL reconstructs the dashboard origin from the rest of the +// deployment's own configuration. CORS_ALLOW_ORIGINS is exact when it is set +// (the dashboard is the first origin the browser calls the API from); +// PUBLIC_HOST is the installer's one hostname everything else derives from. +func inferredAppBaseURL() string { + for _, origin := range strings.Split(os.Getenv("CORS_ALLOW_ORIGINS"), ",") { + origin = strings.TrimRight(strings.TrimSpace(origin), "/") + if origin == "" || origin == "*" { + continue + } + if u, err := url.Parse(origin); err == nil && u.Scheme != "" && u.Host != "" { + return u.Scheme + "://" + u.Host + } + } + host := NormalizeTrackingHost(os.Getenv("PUBLIC_HOST")) + if host == "" { + return "" + } + return publicScheme(host) + "://" + host +} + // WebsocketURL is the realtime gateway clients connect to. It is deployment // configuration rather than a secret, which is why GET /v1/auth/config serves // it: a CLI or a developer client cannot otherwise find the socket on a @@ -67,14 +99,14 @@ func FormsBaseURL() string { if host == "" { return "" } - return formsScheme(host) + "://" + host + return publicScheme(host) + "://" + host } -// formsScheme is https except where TLS cannot be terminated: a form page on a -// loopback or private-network host is a development or LAN install. The port is +// publicScheme is https except where TLS cannot be terminated: a loopback or +// private-network host is a development or LAN install. The port is // deliberately not a signal, because an install can terminate TLS on any port // and inferring http from one handed an https deployment http:// share links. -func formsScheme(host string) string { +func publicScheme(host string) string { name := hostWithoutPort(NormalizeTrackingHost(host)) if name == "localhost" || strings.HasSuffix(name, ".localhost") { return "http" @@ -107,7 +139,7 @@ func FormURLOn(host, publicID string) string { if host == "" { return GetFormURL(publicID) } - return formsScheme(host) + "://" + host + "/f/" + url.PathEscape(publicID) + return publicScheme(host) + "://" + host + "/f/" + url.PathEscape(publicID) } // GetFormURL is the hosted page for one form; empty when no base is known. diff --git a/internal/formwire/formwire.go b/internal/formwire/formwire.go index 0638a9d7..374a0a0d 100644 --- a/internal/formwire/formwire.go +++ b/internal/formwire/formwire.go @@ -18,6 +18,10 @@ type PublicForm struct { BackgroundURL string `json:"background_url,omitempty"` AllowedDomains []string `json:"allowed_domains,omitempty"` CaptchaSiteKey string `json:"captcha_site_key,omitempty"` + // Brand is the "powered by" attribution at the foot of the page. Absent on + // a self-host that configured none, because a stranger filling in an + // operator's form has no business being sent to the platform's website. + Brand *Brand `json:"brand,omitempty"` // Prefill and LinkToken appear only when a valid personalized ?t= ticket // accompanied the fetch: values for the mapped fields, and the token // echoed for submit/event attribution. @@ -25,6 +29,12 @@ type PublicForm struct { LinkToken string `json:"link_token,omitempty"` } +// Brand is the deployment's public attribution on a form page. +type Brand struct { + Name string `json:"name"` + URL string `json:"url,omitempty"` +} + // SubmitRequest carries a visitor's answers plus the abuse signals only the // public-facing process can observe. type SubmitRequest struct { diff --git a/internal/notify/templates/base.go b/internal/notify/templates/base.go index cf0a238e..92fbd624 100644 --- a/internal/notify/templates/base.go +++ b/internal/notify/templates/base.go @@ -3,86 +3,92 @@ package templates import ( "bytes" "html/template" - "os" "strings" + "github.com/warmbly/warmbly/internal/config" "github.com/warmbly/warmbly/internal/observability/errs" ) // ─── Centralized Business Details ──────────────────────────────── -// Branding and legal info for every email template. These are variables, not -// constants, because a self-hosted install must not send mail attributed to -// Mindroot Ltd with links to someone else's dashboard: AppURL derives from -// APP_URL and the rest are overridable with EMAIL_BRAND_*. -var ( - CompanyName = brandEnv("EMAIL_BRAND_NAME", "Warmbly") - LegalEntity = brandEnv("EMAIL_BRAND_LEGAL_ENTITY", "Mindroot Ltd") - CompanyNumber = brandEnv("EMAIL_BRAND_COMPANY_NUMBER", "16543299") - PlaceOfReg = brandEnv("EMAIL_BRAND_PLACE_OF_REG", "England and Wales") - RegisteredAddr = brandEnv("EMAIL_BRAND_ADDRESS", "71-75 Shelton Street, London, England, WC2H 9JQ") - WebsiteURL = brandEnv("EMAIL_BRAND_WEBSITE_URL", "https://warmbly.com") - AppURL = appURL() - SupportEmail = brandEnv("EMAIL_BRAND_SUPPORT_EMAIL", "team@warmbly.com") - TermsURL = brandEnv("EMAIL_BRAND_TERMS_URL", "https://warmbly.com/terms") - PrivacyURL = brandEnv("EMAIL_BRAND_PRIVACY_URL", "https://warmbly.com/privacy") -) +// Branding and legal info for every email template, resolved from +// config.Brand() so the footer, the sign-in screen and a public form page all +// say the same thing about who this deployment is. +// +// These are functions, not constants, because a self-hosted install must not +// send mail attributed to Mindroot Ltd with links to someone else's dashboard. +// On a self-host the hosted defaults are not a fallback at all: they are +// another company's registered details and another company's website. Unset +// means unset there, and the footer drops the row rather than filling it with +// ours. -func brandEnv(key, def string) string { - if v := strings.TrimSpace(os.Getenv(key)); v != "" { - return v - } - return def -} +// CompanyName is the product name in subjects and the header. Unlike the legal +// details it is true of a self-host too: it is the software's name. +func CompanyName() string { return config.Brand().Name } -// appURL is the dashboard base every emailed link is built from. Reading it -// here rather than hardcoding it is what makes password reset and team invites -// work on a self-hosted install. -func appURL() string { - for _, key := range []string{"APP_URL", "FRONTEND_BASE_URL"} { - if v := strings.TrimRight(strings.TrimSpace(os.Getenv(key)), "/"); v != "" { - return v - } - } - return "https://app.warmbly.com" -} +// AppURL is the dashboard base every emailed link is built from. +func AppURL() string { return config.AppBaseURL() } -// WebsiteLabel is the display text for the footer website link, derived from -// WebsiteURL so a rebranded install does not render "warmbly.com" pointing -// somewhere else. -func WebsiteLabel() string { - label := strings.TrimPrefix(strings.TrimPrefix(WebsiteURL, "https://"), "http://") - return strings.TrimSuffix(label, "/") +// footerLink is one entry in the footer's link row. It is a list rather than +// three fixed slots so an install that configured none of them renders no row +// instead of three links to nowhere. +type footerLink struct { + Label string + URL string } type baseData struct { Subject string Content template.HTML CompanyName string - LegalEntity string - CompanyNumber string - PlaceOfReg string + FooterLinks []footerLink + LegalLine string RegisteredAddr string - WebsiteURL string - WebsiteLabel string - TermsURL string - PrivacyURL string +} + +// footerLinks is the configured subset of privacy, terms and website. +func footerLinks(b config.BrandConfig) []footerLink { + var links []footerLink + for _, l := range []footerLink{ + {Label: "Privacy", URL: b.PrivacyURL}, + {Label: "Terms", URL: b.TermsURL}, + {Label: b.WebsiteLabel(), URL: b.WebsiteURL}, + } { + if l.URL != "" && l.Label != "" { + links = append(links, l) + } + } + return links +} + +// legalLine is the Companies Act 2006 identification line, which only the +// hosted service is the subject of. A self-host renders no line rather than +// naming a company that has nothing to do with the mail it just sent. +func legalLine(b config.BrandConfig) string { + parts := []string{} + for _, p := range []string{b.LegalEntity, b.CompanyNumber, b.PlaceOfReg} { + if p = strings.TrimSpace(p); p != "" { + parts = append(parts, p) + } + } + if len(parts) == 0 { + return "" + } + return "\u00a9 " + strings.Join(parts, " \u00b7 ") } var baseTmpl = template.Must(template.New("base").Parse(baseHTML)) func renderEmail(subject, content string) (string, error) { + // One read of the environment per render, so the header, the links and + // the legal line can never disagree with each other. + brand := config.Brand() data := baseData{ Subject: subject, Content: template.HTML(content), - CompanyName: CompanyName, - LegalEntity: LegalEntity, - CompanyNumber: CompanyNumber, - PlaceOfReg: PlaceOfReg, - RegisteredAddr: RegisteredAddr, - WebsiteURL: WebsiteURL, - WebsiteLabel: WebsiteLabel(), - TermsURL: TermsURL, - PrivacyURL: PrivacyURL, + CompanyName: brand.Name, + FooterLinks: footerLinks(brand), + LegalLine: legalLine(brand), + RegisteredAddr: brand.Address, } var buf bytes.Buffer if err := baseTmpl.Execute(&buf, data); err != nil { @@ -145,25 +151,22 @@ const baseHTML = ` - +{{if .FooterLinks}} - - +{{range $i, $link := .FooterLinks}}{{if $i}} ·  +{{end}}{{$link.Label}} +{{end}} +{{end}} +{{if .LegalLine}} - - +{{end}} +{{if .RegisteredAddr}} - +{{end}}
-Privacy - ·  -Terms - ·  -{{.WebsiteLabel}} -
-© {{.LegalEntity}} · {{.CompanyNumber}} · {{.PlaceOfReg}} +{{.LegalLine}}
{{.RegisteredAddr}}
diff --git a/internal/notify/templates/digest.go b/internal/notify/templates/digest.go index 1de5b37f..2ae441e2 100644 --- a/internal/notify/templates/digest.go +++ b/internal/notify/templates/digest.go @@ -64,7 +64,7 @@ func GenerateDigestHTML(count int, items []DigestItem) (string, error) { Count int Items []DigestItem AppURL string - }{Count: count, Items: items, AppURL: AppURL} + }{Count: count, Items: items, AppURL: AppURL()} var buf bytes.Buffer if err := digestTmpl.Execute(&buf, data); err != nil { errs.CaptureException(err) diff --git a/internal/notify/templates/templates_test.go b/internal/notify/templates/templates_test.go index e8023b4a..b140057e 100644 --- a/internal/notify/templates/templates_test.go +++ b/internal/notify/templates/templates_test.go @@ -1,6 +1,7 @@ package templates import ( + "github.com/warmbly/warmbly/internal/config" "os" "path/filepath" "strings" @@ -42,6 +43,8 @@ func TestBaseTemplate_Structure(t *testing.T) { } func TestBaseTemplate_BusinessDetails(t *testing.T) { + t.Setenv("DEPLOYMENT_MODE", "cloud") + html, err := GenerateLoginCodeHTML("000000") if err != nil { t.Fatalf("unexpected error: %v", err) @@ -49,17 +52,17 @@ func TestBaseTemplate_BusinessDetails(t *testing.T) { checks := []string{ // Branding - CompanyName, + CompanyName(), "warmbly.com", "Privacy", "Terms", - TermsURL, - PrivacyURL, + config.Brand().TermsURL, + config.Brand().PrivacyURL, // Companies Act 2006 required details - LegalEntity, - CompanyNumber, - PlaceOfReg, - RegisteredAddr, + config.Brand().LegalEntity, + config.Brand().CompanyNumber, + config.Brand().PlaceOfReg, + config.Brand().Address, } for _, s := range checks { @@ -69,6 +72,46 @@ func TestBaseTemplate_BusinessDetails(t *testing.T) { } } +// A self-host sends its own users mail. Naming our company in the footer, or +// linking our website and terms from it, is wrong on every count: it is not +// their legal entity, and it hands their recipients to us. +func TestBaseTemplate_SelfHostFooterNamesNobodyElse(t *testing.T) { + t.Setenv("DEPLOYMENT_MODE", "self_hosted") + t.Setenv("APP_URL", "https://app.acme.example") + + // The welcome mail is the one that both carries the footer and links the + // dashboard, so it covers the leak and the replacement in one render. + html, err := GenerateWelcomeHTML("Jane") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + for _, unwanted := range []string{"warmbly.com", "Mindroot", "16543299", "Shelton Street"} { + if strings.Contains(html, unwanted) { + t.Errorf("self-host footer leaks %q", unwanted) + } + } + if !strings.Contains(html, "app.acme.example") { + t.Error("self-host email should link the deployment's own dashboard") + } +} + +// The same install with EMAIL_BRAND_* set renders its own details. +func TestBaseTemplate_SelfHostBranding(t *testing.T) { + t.Setenv("DEPLOYMENT_MODE", "self_hosted") + t.Setenv("EMAIL_BRAND_LEGAL_ENTITY", "Acme GmbH") + t.Setenv("EMAIL_BRAND_WEBSITE_URL", "https://acme.example") + + html, err := GenerateLoginCodeHTML("000000") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + for _, want := range []string{"Acme GmbH", "acme.example"} { + if !strings.Contains(html, want) { + t.Errorf("expected footer to contain %q", want) + } + } +} + // ─── Login Code ────────────────────────────────────────────────── func TestGenerateLoginCodeHTML(t *testing.T) { @@ -276,19 +319,19 @@ func TestPreview(t *testing.T) { }}, {"trial-expired.html", func() (string, error) { return GenerateTrialExpiredHTML() }}, {"invitation.html", func() (string, error) { - return GenerateInvitationHTML("Jane Doe", "Acme Inc", AppURL+"/invite?token=abc123") + return GenerateInvitationHTML("Jane Doe", "Acme Inc", AppURL()+"/invite?token=abc123") }}, {"notification.html", func() (string, error) { - return GenerateNotificationHTML("New sign-in to your account", "Signed in from Chrome on macOS (London, GB).", AppURL+"/app/settings/security", "") + return GenerateNotificationHTML("New sign-in to your account", "Signed in from Chrome on macOS (London, GB).", AppURL()+"/app/settings/security", "") }}, {"deletion-org-scheduled.html", func() (string, error) { - return GenerateOrgDeletionScheduledHTML("Acme Inc", previewTime, 30, AppURL+"/organization/settings/danger-zone") + return GenerateOrgDeletionScheduledHTML("Acme Inc", previewTime, 30, AppURL()+"/organization/settings/danger-zone") }}, {"deletion-user-scheduled.html", func() (string, error) { - return GenerateUserDeletionScheduledHTML("Jane", previewTime, 30, AppURL+"/account/danger-zone") + return GenerateUserDeletionScheduledHTML("Jane", previewTime, 30, AppURL()+"/account/danger-zone") }}, {"deletion-reminder.html", func() (string, error) { - return GenerateDeletionReminderHTML("Acme Inc", previewTime, AppURL+"/organization/settings/danger-zone") + return GenerateDeletionReminderHTML("Acme Inc", previewTime, AppURL()+"/organization/settings/danger-zone") }}, {"deletion-completed.html", func() (string, error) { return GenerateDeletionCompletedHTML(previewTime, previewTime) @@ -322,7 +365,7 @@ func TestGenerateTrialExpiredHTML(t *testing.T) { "#f5f6f8", // branded cream wrapper "Your free trial has ended", "Choose a plan", - AppURL + "/settings/billing", // billing CTA href + AppURL() + "/settings/billing", // billing CTA href "Your Warmbly trial has ended", } for _, s := range checks { @@ -335,7 +378,7 @@ func TestGenerateTrialExpiredHTML(t *testing.T) { // ─── Invitation ───────────────────────────────────────────────── func TestGenerateInvitationHTML(t *testing.T) { - url := AppURL + "/invite?token=abc123" + url := AppURL() + "/invite?token=abc123" html, err := GenerateInvitationHTML("Jane Doe", "Acme Inc", url) if err != nil { t.Fatalf("GenerateInvitationHTML returned error: %v", err) @@ -356,7 +399,7 @@ func TestGenerateInvitationHTML(t *testing.T) { } func TestGenerateInvitationHTML_EscapesNames(t *testing.T) { - html, err := GenerateInvitationHTML("", "Acme & Co", AppURL+"/invite?token=x") + html, err := GenerateInvitationHTML("", "Acme & Co", AppURL()+"/invite?token=x") if err != nil { t.Fatalf("GenerateInvitationHTML returned error: %v", err) } @@ -371,7 +414,7 @@ func TestGenerateInvitationHTML_EscapesNames(t *testing.T) { // ─── Notification ─────────────────────────────────────────────── func TestGenerateNotificationHTML_WithCTA(t *testing.T) { - html, err := GenerateNotificationHTML("New sign-in", "Signed in from Chrome.", AppURL+"/app/settings/security", "") + html, err := GenerateNotificationHTML("New sign-in", "Signed in from Chrome.", AppURL()+"/app/settings/security", "") if err != nil { t.Fatalf("GenerateNotificationHTML returned error: %v", err) } @@ -380,7 +423,7 @@ func TestGenerateNotificationHTML_WithCTA(t *testing.T) { "New sign-in", "Signed in from Chrome.", "Open in Warmbly", // default CTA label - AppURL + "/app/settings/security", + AppURL() + "/app/settings/security", "New sign-in", } for _, s := range checks { @@ -403,7 +446,7 @@ func TestGenerateNotificationHTML_NoCTA(t *testing.T) { // ─── Deletion (danger zone) ───────────────────────────────────── func TestGenerateDeletionEmails(t *testing.T) { - cancel := AppURL + "/account/danger-zone" + cancel := AppURL() + "/account/danger-zone" cases := []struct { name string gen func() (string, error) @@ -460,31 +503,57 @@ func TestGenerateDeletionEmails(t *testing.T) { // ─── Constants ────────────────────────────────────────────────── func TestBusinessConstants(t *testing.T) { - if CompanyName == "" { + t.Setenv("DEPLOYMENT_MODE", "cloud") + + if CompanyName() == "" { t.Error("CompanyName should not be empty") } - if LegalEntity == "" { + if config.Brand().LegalEntity == "" { t.Error("LegalEntity should not be empty") } - if CompanyNumber == "" { + if config.Brand().CompanyNumber == "" { t.Error("CompanyNumber should not be empty") } - if PlaceOfReg == "" { + if config.Brand().PlaceOfReg == "" { t.Error("PlaceOfReg should not be empty") } - if RegisteredAddr == "" { + if config.Brand().Address == "" { t.Error("RegisteredAddr should not be empty") } - if WebsiteURL == "" { + if config.Brand().WebsiteURL == "" { t.Error("WebsiteURL should not be empty") } - if !strings.HasPrefix(WebsiteURL, "https://") { + if !strings.HasPrefix(config.Brand().WebsiteURL, "https://") { t.Error("WebsiteURL should start with https://") } - if !strings.HasPrefix(TermsURL, "https://") { + if !strings.HasPrefix(config.Brand().TermsURL, "https://") { t.Error("TermsURL should start with https://") } - if !strings.HasPrefix(PrivacyURL, "https://") { + if !strings.HasPrefix(config.Brand().PrivacyURL, "https://") { t.Error("PrivacyURL should start with https://") } } + +// The product name is the software's, so it survives on a self-host; the legal +// details and the links do not. +func TestBusinessConstantsOnSelfHost(t *testing.T) { + t.Setenv("DEPLOYMENT_MODE", "self_hosted") + + if CompanyName() == "" { + t.Error("CompanyName should not be empty") + } + for name, got := range map[string]string{ + "LegalEntity": config.Brand().LegalEntity, + "CompanyNumber": config.Brand().CompanyNumber, + "PlaceOfReg": config.Brand().PlaceOfReg, + "RegisteredAddr": config.Brand().Address, + "WebsiteURL": config.Brand().WebsiteURL, + "TermsURL": config.Brand().TermsURL, + "PrivacyURL": config.Brand().PrivacyURL, + "SupportEmail": config.Brand().SupportEmail, + } { + if got != "" { + t.Errorf("%s defaults to %q on a self-host; it must be unset", name, got) + } + } +} diff --git a/internal/notify/templates/trial_expired.go b/internal/notify/templates/trial_expired.go index 82082be7..56f0bc96 100644 --- a/internal/notify/templates/trial_expired.go +++ b/internal/notify/templates/trial_expired.go @@ -58,7 +58,7 @@ var trialExpiredTmpl = template.Must(template.New("trial_expired_content").Parse // GenerateTrialExpiredHTML renders the trial-ended notice through the // shared base shell. The billing CTA points at the app's billing page. func GenerateTrialExpiredHTML() (string, error) { - data := struct{ BillingURL string }{BillingURL: AppURL + "/settings/billing"} + data := struct{ BillingURL string }{BillingURL: AppURL() + "/settings/billing"} var buf bytes.Buffer if err := trialExpiredTmpl.Execute(&buf, data); err != nil { errs.CaptureException(err) diff --git a/internal/notify/templates/welcome.go b/internal/notify/templates/welcome.go index ebf6d651..cf310f01 100644 --- a/internal/notify/templates/welcome.go +++ b/internal/notify/templates/welcome.go @@ -61,13 +61,13 @@ func GenerateWelcomeHTML(firstName string) (string, error) { data := struct { FirstName string AppURL string - }{FirstName: firstName, AppURL: AppURL} + }{FirstName: firstName, AppURL: AppURL()} var buf bytes.Buffer if err := welcomeTmpl.Execute(&buf, data); err != nil { errs.CaptureException(err) return "", err } - return renderEmail("Welcome to "+CompanyName, buf.String()) + return renderEmail("Welcome to "+CompanyName(), buf.String()) } // WelcomeTemplate / WelcomeHTMLTMPL retained as deprecated exports so diff --git a/internal/tasks/campaign_task.go b/internal/tasks/campaign_task.go index f47d2e85..5b2e3882 100644 --- a/internal/tasks/campaign_task.go +++ b/internal/tasks/campaign_task.go @@ -465,7 +465,10 @@ func (s *tasksService) HandleCampaignTask(task *proto.ProcessTask) *errx.Error { optOut := s.resolveOptOut(ctx, orgID, campaign) var unsubscribeURL string if s.unsubLinks != nil && s.unsubLinks.Enabled() { - unsubscribeURL = s.unsubLinks.URL(orgID, campaign.ID, contact.ID, time.Now()) + // On the workspace's own verified tracking domain when it has one, so + // the opt-out address sits on the sender's domain like every other link + // in the email rather than naming the platform. + unsubscribeURL = s.unsubLinks.URLOn(resolveOptOutOrigin(account, campaign), orgID, campaign.ID, contact.ID, time.Now()) } extra := map[string]string{UnsubscribeLinkVar: unsubscribeURL} diff --git a/internal/tasks/optout.go b/internal/tasks/optout.go index 2434bdb3..8864b68c 100644 --- a/internal/tasks/optout.go +++ b/internal/tasks/optout.go @@ -4,6 +4,7 @@ import ( "html" "strings" + "github.com/warmbly/warmbly/internal/app/unsublink" "github.com/warmbly/warmbly/internal/models" ) @@ -11,10 +12,11 @@ import ( // ({{.UnsubscribeLink}}); it resolves to the recipient's own signed link. const UnsubscribeLinkVar = "UnsubscribeLink" -// unsubscribePathMarker is the path segment every minted link carries. Click -// tracking leaves such links alone so an opt-out is never counted as a click -// or bounced through a redirect. -const unsubscribePathMarker = "/unsubscribe/" +// unsubscribePathMarker is the path segment every minted link carries, on the +// API origin and on a workspace's own tracking domain alike. Click tracking +// leaves such links alone so an opt-out is never counted as a click or bounced +// through a redirect. +const unsubscribePathMarker = unsublink.Path // optOutFooter renders the in-body opt-out for one recipient, as HTML and as // plain text, or empty strings when the effective mode is off. Link mode with diff --git a/internal/tasks/optout_origin_test.go b/internal/tasks/optout_origin_test.go new file mode 100644 index 00000000..e760672c --- /dev/null +++ b/internal/tasks/optout_origin_test.go @@ -0,0 +1,90 @@ +package tasks + +import ( + "testing" + + "github.com/google/uuid" + "github.com/warmbly/warmbly/internal/models" +) + +func TestResolveOptOutOrigin(t *testing.T) { + verifiedMailbox := &models.Email{TrackingDomain: "t.acme.com", TrackingDomainVerified: true} + pendingMailbox := &models.Email{TrackingDomain: "t.acme.com"} + + cases := []struct { + name string + instance string + account *models.Email + campaign *models.Campaign + want string + }{ + {name: "no custom domain stays on the API origin", instance: "track.warmbly.com", want: ""}, + {name: "verified mailbox domain", instance: "track.warmbly.com", account: verifiedMailbox, want: "https://t.acme.com"}, + { + name: "verified campaign override wins", + instance: "track.warmbly.com", + account: verifiedMailbox, + campaign: &models.Campaign{TrackingDomain: "t.promo.acme.com", TrackingDomainVerified: true}, + want: "https://t.promo.acme.com", + }, + { + name: "unverified campaign override falls back to the mailbox", + instance: "track.warmbly.com", + account: verifiedMailbox, + campaign: &models.Campaign{TrackingDomain: "t.promo.acme.com"}, + want: "https://t.acme.com", + }, + {name: "unverified mailbox domain is never used", instance: "track.warmbly.com", account: pendingMailbox, want: ""}, + { + // One-click needs https, and the API origin is the honest place + // for a link on a host that cannot terminate TLS. + name: "a ported dev host stays on the API origin", + account: &models.Email{TrackingDomain: "localhost:3000", TrackingDomainVerified: true}, + want: "", + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + t.Setenv("TRACKING_DOMAIN", c.instance) + if got := resolveOptOutOrigin(c.account, c.campaign); got != c.want { + t.Errorf("resolveOptOutOrigin = %q, want %q", got, c.want) + } + }) + } +} + +// The click wrapper recognises an opt-out by its path, so a link minted on a +// workspace's own tracking domain must still be left alone: wrapping it would +// count the opt-out as a click and bounce the recipient through a redirect. +func TestOptOutOnTrackingDomainIsNotWrapped(t *testing.T) { + const host = "t.acme.com" + link := "https://" + host + "/unsubscribe/abc123" + body := `

pricing unsubscribe

` + + out, minted := TrackLinks(body, LinkTracking{ + TaskID: uuid.New(), + CampaignID: uuid.New(), + TrackingDomain: host, + Wrap: true, + }) + if !contains(out, `href="`+link+`"`) { + t.Fatalf("opt-out link was rewritten: %s", out) + } + for _, m := range minted { + if m.Destination == link { + t.Fatal("a click ticket was minted for the opt-out link") + } + } +} + +func contains(haystack, needle string) bool { + return len(haystack) >= len(needle) && (func() bool { + for i := 0; i+len(needle) <= len(haystack); i++ { + if haystack[i:i+len(needle)] == needle { + return true + } + } + return false + })() +} diff --git a/internal/tasks/test_email.go b/internal/tasks/test_email.go index fa7556e8..027c5f9e 100644 --- a/internal/tasks/test_email.go +++ b/internal/tasks/test_email.go @@ -51,7 +51,7 @@ func (s *tasksService) SendTestEmail(ctx context.Context, orgID uuid.UUID, accou optOut := s.resolveOptOut(ctx, orgID, campaign) var unsubscribeURL string if s.unsubLinks != nil && s.unsubLinks.Enabled() { - unsubscribeURL = s.unsubLinks.URL(orgID, campaign.ID, uuid.Nil, time.Now()) + unsubscribeURL = s.unsubLinks.URLOn(resolveOptOutOrigin(account, campaign), orgID, campaign.ID, uuid.Nil, time.Now()) } rendered := previewTemplatesWith(sequence.Subject, sequence.BodyHTML, sequence.BodyPlain, renderFor, unsubscribeURL) diff --git a/internal/tasks/tracking_host.go b/internal/tasks/tracking_host.go index f306f433..a890ebdf 100644 --- a/internal/tasks/tracking_host.go +++ b/internal/tasks/tracking_host.go @@ -1,6 +1,11 @@ package tasks -import "github.com/warmbly/warmbly/internal/models" +import ( + "strings" + + "github.com/warmbly/warmbly/internal/config" + "github.com/warmbly/warmbly/internal/models" +) // trackingOverrideIgnored is a tracking domain that was configured but not // used, with the sentence the campaign log records for it. @@ -49,3 +54,32 @@ func resolveTrackingHost(defaultHost string, account *models.Email, campaign *mo return host, ignored } + +// resolveOptOutOrigin is the absolute origin a recipient's unsubscribe link is +// minted on: the workspace's OWN verified tracking domain (campaign override +// first), or "" to leave it on the API origin. +// +// Deliberately narrower than resolveTrackingHost, which falls back to the +// install's shared tracking host. An opt-out has to reach something serving: +// a verified domain is a CNAME this install resolved to its own tracking +// service, so it provably does, while the shared host is only configuration +// and is absent from a core-only install. A dead click link costs a click; a +// dead opt-out costs a spam complaint. +func resolveOptOutOrigin(account *models.Email, campaign *models.Campaign) string { + host := "" + if account != nil && account.TrackingDomainVerified && account.TrackingDomain != "" { + host = account.TrackingDomain + } + if campaign != nil && campaign.TrackingDomainVerified && campaign.TrackingDomain != "" { + host = campaign.TrackingDomain + } + // http is what TrackingURL picks for a loopback or ported host, and an + // opt-out address a recipient reads (and a provider fetches for one-click) + // has to be https. Such a host is a dev or LAN install, where the API + // origin is the honest place for the link. + origin := strings.TrimSuffix(config.TrackingURL(host, ""), "/") + if !strings.HasPrefix(origin, "https://") { + return "" + } + return origin +} diff --git a/site/public/install.sh b/site/public/install.sh index f1748897..78ba4e85 100644 --- a/site/public/install.sh +++ b/site/public/install.sh @@ -726,6 +726,49 @@ press_enter() { return 0 } +# offer_wizard asks a fresh install whether to configure itself, when nothing +# already answered that. +# +# `curl ... | sh` with no flags used to go straight to defaults, which means +# localhost URLs and a tracking host on localhost: an instance that installs, +# starts, and sends campaign mail whose links and opt-out address nobody +# outside that machine can open. It is a terminal in front of a person, so it +# can just ask. Declining is a real answer and says what it costs. +# +# Skipped whenever something else already decided: --wizard, --assume-yes, a +# non-interactive shell, a re-run over an existing install (its .env is +# adopted), and any run that writes nothing. +offer_wizard() { + [ "$WIZARD" = 0 ] || return 0 + [ "$INTERACTIVE" = 1 ] || return 0 + [ "$ASSUME_YES" = 0 ] || return 0 + [ "$EXISTING" = 0 ] || return 0 + [ "$DEMO" = 0 ] || return 0 + [ "$DRY_RUN" = 0 ] || return 0 + [ "$PRINT_ENV" = 0 ] || return 0 + # An operator who named a host has already answered the question that + # matters, by flag or by WARMBLY_HOST. + [ -z "${WARMBLY_HOST:-}" ] || return 0 + [ -z "${HOST_SET:-}" ] || return 0 + + say "" + note "Nothing has told this install where it will be reached, so it would use" + note "localhost: a dashboard, a tracking host and an unsubscribe address that" + note "only work from this machine. Campaign mail carries those addresses." + say "" + if confirm "Answer a few questions to set it up properly?" yes; then + WIZARD=1 + return 0 + fi + say "" + warn "Going with the defaults. Every address this instance builds is on" + note "localhost, so campaign mail would carry a pixel, links and an opt-out" + note "no recipient can open. Fine to look around; edit .env or re-run with" + note "--wizard before you send anything real." + say "" + return 0 +} + # confirm -> 0 when yes confirm() { _q=$1; _def=${2:-yes} @@ -1025,9 +1068,13 @@ derive() { REDIS_URL="redis://redis:6379"; BUNDLED_REDIS=1 fi - # The component set decides which services exist at all. + # The component set decides which services exist at all. A core install + # has no tracking and no forms service, so naming a host for either is + # worse than leaving it unset: campaign mail would ship links, an opt-out + # address and share URLs pointing at a name nothing answers on. if [ "$COMPONENTS" = "core" ]; then WANT_TRACKING=0; WANT_REALTIME=0; WANT_FORMS=0 + TRACKING_DOMAIN=""; FORMS_DOMAIN="" else WANT_TRACKING=1; WANT_REALTIME=1; WANT_FORMS=1 fi @@ -1067,7 +1114,9 @@ CORS_ALLOW_ORIGINS=$CORS WEBSOCKET_URL=$URL_WS PHX_HOST=$PHX_HOST CHECK_ORIGIN=$CHECK_ORIGIN -# Unset means campaign mail ships with no open pixel and unwrapped links. +# Unset means campaign mail ships with no open pixel and unwrapped links. A +# workspace that verifies its own domain against this one also serves its +# recipients' unsubscribe link there; otherwise it stays on API_PUBLIC_URL. TRACKING_DOMAIN=$TRACKING_DOMAIN FORMS_DOMAIN=$FORMS_DOMAIN # CIDRs allowed to set X-Forwarded-For. Empty trusts nothing, which is correct @@ -1129,6 +1178,21 @@ $(render_env_bootstrap_owner) # ── Platform mail (login codes, resets, invitations) ───────────────────── $(render_env_mail) +# ── Whose instance this is ─────────────────────────────────────────────── +# Unset, this instance claims nothing: the sign-in screen shows no Terms or +# Privacy link, transactional email carries no company line, and public form +# pages carry no "powered by". Nothing here points at warmbly.com. Fill these +# in to put your own name on those surfaces. +# EMAIL_BRAND_NAME=Acme +# EMAIL_BRAND_WEBSITE_URL=https://acme.example +# EMAIL_BRAND_TERMS_URL=https://acme.example/terms +# EMAIL_BRAND_PRIVACY_URL=https://acme.example/privacy +# EMAIL_BRAND_SUPPORT_EMAIL=support@acme.example +# EMAIL_BRAND_LEGAL_ENTITY=Acme Ltd +# EMAIL_BRAND_COMPANY_NUMBER= +# EMAIL_BRAND_PLACE_OF_REG= +# EMAIL_BRAND_ADDRESS= + # ── Updates ────────────────────────────────────────────────────────────── # The check is an outbound call to the GitHub releases API and nothing else. # There is no telemetry in Warmbly; set UPDATE_CHECK_ENABLED=false and this @@ -3050,6 +3114,8 @@ main() { ensure_secrets + offer_wizard + if [ "$WIZARD" = 1 ] && [ "$INTERACTIVE" = 1 ]; then # The first step clears the screen, so nothing above it survives being # read unless the operator says when. diff --git a/site/public/install.sh.sha256 b/site/public/install.sh.sha256 index 8919ec26..fda2ea7e 100644 --- a/site/public/install.sh.sha256 +++ b/site/public/install.sh.sha256 @@ -1 +1 @@ -76dea57a84aa5ee84126dc657d2df87725fba90998eb4224ed861ccb71b0b08e install.sh +92b8608cb15cf03a71e3c6ac7b9f132e36edbeddf3d5f7adae08ede93a2beb86 install.sh diff --git a/tracking/src/handlers.rs b/tracking/src/handlers.rs index 6e68b0d2..00f72192 100644 --- a/tracking/src/handlers.rs +++ b/tracking/src/handlers.rs @@ -17,6 +17,7 @@ use crate::events::TrackingEvent; use crate::hits::{ForwardedHit, HitForwarder, HitPayload, Outcome}; use crate::links::{LinkResolver, Resolution}; use crate::producer::Producer; +use crate::unsubscribe::{body_content_type, invalid_token, valid_token, UnsubscribeProxy}; // 1x1 transparent GIF (43 bytes) const TRANSPARENT_GIF: &[u8] = &[ @@ -48,6 +49,8 @@ pub struct AppState { pub client_ip_header: Arc, /// Key for the source-address token (see `hash_ip`) pub ip_hash_key: Arc, + /// Recipient opt-out, proxied to the backend that owns the pages + pub unsubscribe: Arc, } impl AppState { @@ -78,6 +81,7 @@ impl AppState { trusted_proxies: Arc::new(config.trusted_proxies.clone()), client_ip_header: Arc::new(config.client_ip_header.clone()), ip_hash_key: Arc::new(config.ip_hash_key.clone()), + unsubscribe: Arc::new(UnsubscribeProxy::new(config.backend_internal_url.clone())), } } @@ -273,6 +277,85 @@ pub async fn track_click( Redirect::temporary(&target).into_response() } +/// Recipient opt-out, served here because a workspace's verified tracking +/// domain is the host its campaign mail carries. The backend owns the pages +/// and the suppression; these three only shape-check the token, spend the +/// source's budget and hand the request on. +/// +/// GET /unsubscribe/{token} +pub async fn unsubscribe_page( + State(state): State, + ConnectInfo(peer): ConnectInfo, + Path(token): Path, + headers: HeaderMap, +) -> Response { + if !valid_token(&token) { + return invalid_token(); + } + if let Some(limited) = spend_unsubscribe_budget(&state, peer, &headers).await { + return limited; + } + state.unsubscribe.get(&token).await +} + +/// POST /unsubscribe/{token} — the confirm button, or a provider's RFC 8058 +/// one-click. Never rate limited: a provider POSTing an opt-out is the one +/// request that must not be refused, and a refusal here is a spam complaint. +pub async fn unsubscribe_submit( + State(state): State, + Path(token): Path, + headers: HeaderMap, + body: Bytes, +) -> Response { + if !valid_token(&token) { + return invalid_token(); + } + let content_type = body_content_type(&headers); + state.unsubscribe.post(&token, body, content_type).await +} + +/// POST /unsubscribe/{token}/resubscribe — the "unsubscribed by mistake" button. +pub async fn unsubscribe_undo( + State(state): State, + ConnectInfo(peer): ConnectInfo, + Path(token): Path, + headers: HeaderMap, + body: Bytes, +) -> Response { + if !valid_token(&token) { + return invalid_token(); + } + if let Some(limited) = spend_unsubscribe_budget(&state, peer, &headers).await { + return limited; + } + let content_type = body_content_type(&headers); + state + .unsubscribe + .resubscribe(&token, body, content_type) + .await +} + +/// Charges one request against the source's budget, returning the refusal when +/// it is spent. A recipient opts out once; a source burning the pixel budget on +/// opt-out pages is spraying tokens. +async fn spend_unsubscribe_budget( + state: &AppState, + peer: SocketAddr, + headers: &HeaderMap, +) -> Option { + let ip = client_ip( + peer, + headers, + &state.trusted_proxies, + &state.client_ip_header, + ); + let source = hash_ip(&state.ip_hash_key, &ip); + if state.rate_limiter.allow(&source).await { + return None; + } + Some((StatusCode::TOO_MANY_REQUESTS, "Slow down").into_response()) +} + /// Query parameter the click redirect appends and the snippet strips. const IDENTIFY_PARAM: &str = "wbly_t"; diff --git a/tracking/src/main.rs b/tracking/src/main.rs index 4d85546f..15bb1b15 100644 --- a/tracking/src/main.rs +++ b/tracking/src/main.rs @@ -10,6 +10,7 @@ mod links; mod nats; mod observability; mod producer; +mod unsubscribe; use axum::{ extract::DefaultBodyLimit, @@ -26,7 +27,10 @@ use tracing::{info, warn}; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; use crate::config::Config; -use crate::handlers::{health, track_click, track_open, track_page_hit, tracking_js, AppState}; +use crate::handlers::{ + health, track_click, track_open, track_page_hit, tracking_js, unsubscribe_page, + unsubscribe_submit, unsubscribe_undo, AppState, +}; use crate::observability::report_error; use crate::producer::Producer; @@ -111,6 +115,19 @@ async fn main() { "/p", post(track_page_hit).layer(DefaultBodyLimit::max(hits::MAX_BODY_BYTES)), ) + // Recipient opt-out. A workspace's verified tracking domain is the + // host its campaign mail carries, so the unsubscribe address in that + // mail resolves here; the backend owns the pages behind it. + .route( + "/unsubscribe/:token", + get(unsubscribe_page) + .post(unsubscribe_submit) + .layer(DefaultBodyLimit::max(unsubscribe::MAX_BODY_BYTES)), + ) + .route( + "/unsubscribe/:token/resubscribe", + post(unsubscribe_undo).layer(DefaultBodyLimit::max(unsubscribe::MAX_BODY_BYTES)), + ) .layer( CorsLayer::new() .allow_origin(Any) diff --git a/tracking/src/unsubscribe.rs b/tracking/src/unsubscribe.rs new file mode 100644 index 00000000..a34f3658 --- /dev/null +++ b/tracking/src/unsubscribe.rs @@ -0,0 +1,207 @@ +//! Recipient opt-out served on the workspace's own tracking domain. +//! +//! The confirm page, the RFC 8058 one-click POST and the suppression itself +//! all live in the backend. This is the thin pass-through that lets +//! `https://t.customer.com/unsubscribe/` answer them, so the opt-out +//! address in a campaign email sits on the sender's domain like every other +//! link in the message instead of naming the platform. +//! +//! Nothing is decided here. The token is opaque, the backend verifies it, and +//! the response goes back byte for byte. The path is fixed and the token is +//! shape-checked before any request leaves, so this is a proxy for exactly one +//! backend route and never a general one. + +// reqwest and axum are on different `http` majors, so header values cross the +// proxy boundary as strings rather than as the two incompatible HeaderValue +// types. +use axum::{ + body::Bytes, + http::{header, HeaderMap, HeaderValue, StatusCode}, + response::{IntoResponse, Response}, +}; +use std::time::Duration; +use tracing::warn; + +/// Longest token accepted before the backend is asked. The signed token is 96 +/// base64url characters today; the ceiling only keeps a megabyte of junk in a +/// path from becoming a backend request. +const MAX_TOKEN_LEN: usize = 512; +/// Shortest plausible token. Anything below this cannot carry a signature. +const MIN_TOKEN_LEN: usize = 32; +/// Cap on the form body of a confirm or one-click POST, which is a few bytes. +pub const MAX_BODY_BYTES: usize = 16 * 1024; + +/// What a recipient sees when the backend cannot be reached. Neutral, like the +/// backend's own pages: the email came from the customer's mailbox, so no +/// brand is named, and replying is a route to the same outcome because reply +/// opt-outs are detected and suppressed too. +const UNAVAILABLE_HTML: &str = r#" + +Try again shortly + +

Try again shortly

We could not reach the sender's server just now. Open this link again in a few minutes, or reply to the email and the sender will stop.

"#; + +pub struct UnsubscribeProxy { + http: reqwest::Client, + backend_url: String, +} + +impl UnsubscribeProxy { + pub fn new(backend_url: String) -> Self { + Self { + http: reqwest::Client::builder() + .timeout(Duration::from_secs(5)) + // A redirect would take the recipient off this host; the + // backend's own pages are same-path forms, so there is + // nothing legitimate to follow. + .redirect(reqwest::redirect::Policy::none()) + .build() + .expect("reqwest client"), + backend_url: backend_url.trim_end_matches('/').to_string(), + } + } + + /// Proxies one opt-out request. `suffix` is the fixed path after the token + /// ("" or "/resubscribe"); `body` is None for GET. + async fn forward(&self, token: &str, suffix: &str, body: Option<(Bytes, String)>) -> Response { + let url = format!("{}/unsubscribe/{}{}", self.backend_url, token, suffix); + let request = match body { + Some((bytes, content_type)) => self + .http + .post(&url) + .header(reqwest::header::CONTENT_TYPE, content_type) + .body(bytes), + None => self.http.get(&url), + }; + + let response = match request.send().await { + Ok(response) => response, + Err(e) => { + warn!("unsubscribe proxy: backend unreachable: {}", e); + return unavailable(); + } + }; + + let status = + StatusCode::from_u16(response.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY); + let content_type = response + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .and_then(|v| HeaderValue::from_str(v).ok()) + .unwrap_or_else(|| HeaderValue::from_static("text/html; charset=utf-8")); + let body = match response.bytes().await { + Ok(body) => body, + Err(e) => { + warn!("unsubscribe proxy: truncated backend response: {}", e); + return unavailable(); + } + }; + + // Only the headers a recipient-facing page needs travel back: a + // Set-Cookie or auth header from the backend has no business on the + // customer's domain. + ( + status, + [ + (header::CONTENT_TYPE, content_type), + (header::CACHE_CONTROL, HeaderValue::from_static("no-store")), + ( + header::HeaderName::from_static("x-robots-tag"), + HeaderValue::from_static("noindex"), + ), + ], + Bytes::from(body.to_vec()), + ) + .into_response() + } +} + +fn unavailable() -> Response { + ( + StatusCode::SERVICE_UNAVAILABLE, + [ + ( + header::CONTENT_TYPE, + HeaderValue::from_static("text/html; charset=utf-8"), + ), + (header::CACHE_CONTROL, HeaderValue::from_static("no-store")), + ], + UNAVAILABLE_HTML, + ) + .into_response() +} + +/// Tokens are base64url without padding. Rejecting anything else here keeps a +/// path-traversal attempt or a spray of junk away from the backend entirely. +pub fn valid_token(token: &str) -> bool { + (MIN_TOKEN_LEN..=MAX_TOKEN_LEN).contains(&token.len()) + && token + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') +} + +/// The form content-type a browser or a provider sends, or the default when +/// the request carried none. +pub fn body_content_type(headers: &HeaderMap) -> String { + headers + .get(header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or("application/x-www-form-urlencoded") + .to_string() +} + +/// What an unknown-shaped token gets: the backend's own wording for an invalid +/// link, so a probe cannot tell the two apart. +pub fn invalid_token() -> Response { + ( + StatusCode::BAD_REQUEST, + [ + ( + header::CONTENT_TYPE, + HeaderValue::from_static("text/html; charset=utf-8"), + ), + (header::CACHE_CONTROL, HeaderValue::from_static("no-store")), + ], + r#" + +This unsubscribe link is invalid + +

This unsubscribe link is invalid

Reply to the email instead and the sender will stop.

"#, + ) + .into_response() +} + +impl UnsubscribeProxy { + pub async fn get(&self, token: &str) -> Response { + self.forward(token, "", None).await + } + + pub async fn post(&self, token: &str, body: Bytes, content_type: String) -> Response { + self.forward(token, "", Some((body, content_type))).await + } + + pub async fn resubscribe(&self, token: &str, body: Bytes, content_type: String) -> Response { + self.forward(token, "/resubscribe", Some((body, content_type))) + .await + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn token_shape_is_checked_before_the_backend_is_asked() { + assert!(valid_token(&"a".repeat(96))); + assert!(valid_token("abcABC012-_abcABC012-_abcABC012-_")); + assert!(!valid_token("short")); + assert!(!valid_token(&"a".repeat(MAX_TOKEN_LEN + 1))); + // Traversal and separators never reach the proxied path. + assert!(!valid_token(&format!("{}/../admin", "a".repeat(40)))); + assert!(!valid_token(&format!("{}?x=1", "a".repeat(40)))); + assert!(!valid_token(&format!("{}%2f", "a".repeat(40)))); + } +} diff --git a/web/src/app/app/api-keys/page.tsx b/web/src/app/app/api-keys/page.tsx index 759a4d41..14117c88 100644 --- a/web/src/app/app/api-keys/page.tsx +++ b/web/src/app/app/api-keys/page.tsx @@ -51,6 +51,7 @@ import type APIKey from "@/lib/api/models/app/apikeys/APIKey"; import CreateKeyModal from "./_components/CreateKeyModal"; import KeyDetailDrawer from "./_components/KeyDetailDrawer"; import { StackedBars } from "./_components/Sparkline"; +import useBrand from "@/hooks/useBrand"; export default function APIKeysPage() { const canManage = usePermission("MANAGE_API_KEYS"); @@ -256,7 +257,10 @@ function StatusPill({ status }: { status: APIKey["status"] }) { } function CodeSnippet({ prefix }: { prefix: string }) { - const snippet = `curl https://api.warmbly.com/v1/campaigns \\ + // This instance's own API base, not the hosted one: a self-hoster copying + // the example was being handed a curl aimed at somebody else's server. + const { apiURL } = useBrand(); + const snippet = `curl ${apiURL}/v1/campaigns \\ -H "Authorization: Bearer ${prefix}…" \\ -H "Content-Type: application/json"`; const [copied, setCopied] = React.useState(false); diff --git a/web/src/app/app/settings/limits/page.tsx b/web/src/app/app/settings/limits/page.tsx index 57c554e6..91efdfca 100644 --- a/web/src/app/app/settings/limits/page.tsx +++ b/web/src/app/app/settings/limits/page.tsx @@ -13,6 +13,7 @@ import getCurrentOrganization from "@/lib/api/client/app/organizations/getCurren import listLimitRequests from "@/lib/api/client/app/organizations/listLimitRequests"; import submitLimitRequest from "@/lib/api/client/app/organizations/submitLimitRequest"; import cancelLimitRequest from "@/lib/api/client/app/organizations/cancelLimitRequest"; +import useBrand from "@/hooks/useBrand"; import type { LimitField, LimitRequestStatus, @@ -36,6 +37,7 @@ const STATUS_TONE: Record = { export default function LimitsSettingsPage() { const qc = useQueryClient(); + const brand = useBrand(); const orgQuery = useQuery({ queryKey: ["app", "organizations", "current"], @@ -157,16 +159,17 @@ export default function LimitsSettingsPage() { {submit.isPending ? "Submitting…" : "Submit request"}

- Subject to review per our{" "} - - terms of service - - . + {brand.terms_url ? ( + <> + Subject to review per our{" "} + + terms of service + + . + + ) : ( + "Subject to review." + )}

diff --git a/web/src/app/auth/layout.tsx b/web/src/app/auth/layout.tsx index 778cebbc..2a5ae929 100644 --- a/web/src/app/auth/layout.tsx +++ b/web/src/app/auth/layout.tsx @@ -1,9 +1,10 @@ import React from "react"; import { Navigate, useLocation, useNavigate, useOutlet } from "react-router-dom"; import { AnimatePresence, motion } from "motion/react"; -import { APP_URL, WEBSITE_URL } from "@/lib/information"; +import { APP_URL } from "@/lib/information"; +import useBrand from "@/hooks/useBrand"; +import BrandMark from "@/components/shared/BrandMark"; import getToken from "@/lib/helper/getToken"; -import { Logo } from "@/components/svg"; import AuthShowcase from "./_components/AuthShowcase"; /* ═══════════════════════════════════════════ @@ -22,6 +23,7 @@ export default function AuthLayout({ const navigate = useNavigate(); const location = useLocation(); const outlet = useOutlet(); + const brand = useBrand(); React.useEffect(() => { const receiveMessage = (event: MessageEvent) => { @@ -49,20 +51,14 @@ export default function AuthLayout({
{/* Mobile logo — white, on the sky, above the card */} - - - Warmbly - + {/* Card */}
{/* Showcase — desktop only */} {/* Form column */} @@ -87,9 +83,9 @@ export default function AuthLayout({ {/* Footer — desktop, inside the card */}
- Terms - Privacy - © {new Date().getFullYear()} Warmbly + {brand.terms_url && Terms} + {brand.privacy_url && Privacy} + © {new Date().getFullYear()} {brand.name}
@@ -97,11 +93,15 @@ export default function AuthLayout({ {/* Footer — mobile, on the sky below the card */}
- Terms - · - Privacy - · - © {new Date().getFullYear()} Warmbly + {brand.terms_url && <> + Terms + · + } + {brand.privacy_url && <> + Privacy + · + } + © {new Date().getFullYear()} {brand.name}
diff --git a/web/src/app/auth/login/page.tsx b/web/src/app/auth/login/page.tsx index 4b328765..ecd8b208 100644 --- a/web/src/app/auth/login/page.tsx +++ b/web/src/app/auth/login/page.tsx @@ -21,7 +21,8 @@ import useRegister from "@/lib/api/hooks/auth/useRegister"; import useRegisterConfirm from "@/lib/api/hooks/auth/useRegisterConfirm"; import { saveTokens } from "@/lib/auth"; import getUser from "@/lib/api/client/auth/getUser"; -import { WEBSITE_URL, TURNSTILE_KEY, API_URL } from "@/lib/information"; +import { TURNSTILE_KEY, API_URL } from "@/lib/information"; +import useBrand from "@/hooks/useBrand"; import useAuthConfig from "@/lib/api/hooks/auth/useAuthConfig"; import type Session from "@/lib/api/models/auth/Session"; import beginSSO from "@/lib/api/client/auth/beginSSO"; @@ -1174,6 +1175,7 @@ function SignUpStep({ }); const pw = watch("password"); const termsChecked = watch("acceptTerms"); + const brand = useBrand(); const { evaluate } = usePasswordStrength(); const [strength, setStrength] = useState<{ score: 0 | 1 | 2 | 3 | 4; warning: string }>({ score: 0, warning: "" }); @@ -1234,13 +1236,21 @@ function SignUpStep({ I agree to the{" "} - - Terms of Service - + {brand.terms_url ? ( + + Terms of Service + + ) : ( + "Terms of Service" + )} {" "}and{" "} - - Privacy Policy - + {brand.privacy_url ? ( + + Privacy Policy + + ) : ( + "Privacy Policy" + )} diff --git a/web/src/app/cli/page.tsx b/web/src/app/cli/page.tsx index d5757d91..861d1193 100644 --- a/web/src/app/cli/page.tsx +++ b/web/src/app/cli/page.tsx @@ -21,9 +21,8 @@ import { TerminalIcon, XIcon, } from "lucide-react"; -import { Logo } from "@/components/svg"; import { InputOTP, InputOTPGroup, InputOTPSlot } from "@/components/ui/input-otp"; -import { WEBSITE_URL } from "@/lib/information"; +import BrandMark from "@/components/shared/BrandMark"; import getToken from "@/lib/helper/getToken"; import type { AppError } from "@/lib/api/client/normalizeError"; import buildError from "@/lib/helper/buildError"; @@ -113,10 +112,7 @@ function CLIAuthInner() {
- - - Warmbly - +
- - - Warmbly - + ( function StatsShareCard({ data, aspect = "1:1" }, ref) { + // A shared card is a public image. On a self-host it must not carry + // the platform's website and tagline: the numbers on it are the + // operator's, and so is whoever sees them. + const brand = useBrand(); const { width, height } = DIMENSIONS[aspect]; const metrics = data.metrics.slice(0, 4); const landscape = aspect !== "1:1"; @@ -179,7 +184,7 @@ const StatsShareCard = React.forwardRef - Warmbly + {brand.name}
@@ -228,14 +233,16 @@ const StatsShareCard = React.forwardRef {/* footer on the sky */} -
- - warmbly.com - - - Cold email, warmed up. - -
+ {brand.website_label && ( +
+ + {brand.website_label} + + + Cold email, warmed up. + +
+ )}
); diff --git a/web/src/components/app/automations/ExpressionReference.tsx b/web/src/components/app/automations/ExpressionReference.tsx index e5f32e94..c2c1de40 100644 --- a/web/src/components/app/automations/ExpressionReference.tsx +++ b/web/src/components/app/automations/ExpressionReference.tsx @@ -6,7 +6,6 @@ import toast from "react-hot-toast"; import { CircleHelpIcon, CopyIcon, ExternalLinkIcon } from "lucide-react"; import { PopoverMenu, PopoverMenuTrigger, PopoverMenuContent } from "@/components/ui/popover-menu"; -import { WEBSITE_URL } from "@/lib/information"; interface Entry { code: string; @@ -167,7 +166,7 @@ export function ExpressionReference({ label = "Reference" }: { label?: string }) e.preventDefault()} diff --git a/web/src/components/app/emails/InboxDetails.tsx b/web/src/components/app/emails/InboxDetails.tsx index 16df885e..1706e915 100644 --- a/web/src/components/app/emails/InboxDetails.tsx +++ b/web/src/components/app/emails/InboxDetails.tsx @@ -1368,7 +1368,7 @@ function TrackingDomainCard({ mailbox }: { mailbox: Inbox }) { )} - + diff --git a/web/src/components/app/emails/WarmupCoverageNotice.tsx b/web/src/components/app/emails/WarmupCoverageNotice.tsx index 2534edfa..2549b6c6 100644 --- a/web/src/components/app/emails/WarmupCoverageNotice.tsx +++ b/web/src/components/app/emails/WarmupCoverageNotice.tsx @@ -71,7 +71,7 @@ export default function WarmupCoverageNotice({ ) : ( resolveDesign(design), [design]); React.useEffect(() => ensureFont(r), [r]); + // Same rule as the served page: no attribution when this deployment + // configured none, so the preview is what a visitor will actually see. + const brand = useBrand(); const [page, setPage] = React.useState(0); const screens = React.useMemo( @@ -490,11 +494,13 @@ export default function FormPreview({ {r.layout !== "split" && !r.logoOnPage && bodyLogo} {previewPaging ? pagedPreview : buildList} - + {brand.website_url && ( + + )} diff --git a/web/src/components/layout/UpgradeDialog.tsx b/web/src/components/layout/UpgradeDialog.tsx index 4b05e536..63ae6b42 100644 --- a/web/src/components/layout/UpgradeDialog.tsx +++ b/web/src/components/layout/UpgradeDialog.tsx @@ -29,7 +29,7 @@ import useValidateDiscountCode from "@/lib/api/hooks/app/subscription/useValidat import type DiscountPreview from "@/lib/api/models/app/subscription/DiscountPreview"; import type { AppError } from "@/lib/api/client/normalizeError"; import buildError from "@/lib/helper/buildError"; -import { WEBSITE_URL } from "@/lib/information"; +import useBrand from "@/hooks/useBrand"; import { PAID_PLANS, getPlan, isAtLeast, planOrder, type PlanID } from "@/lib/plans"; import { describeDiscount, type BillingInterval } from "@/lib/pricing"; import { TextInput } from "@/components/ui/field"; @@ -49,6 +49,7 @@ export default function UpgradeDialog({ onClose: () => void; }) { const access = useFeatureAccess(); + const brand = useBrand(); const flow = useUpgradeFlow(); const validateCode = useValidateDiscountCode(); const reduced = useReducedMotion(); @@ -364,15 +365,17 @@ export default function UpgradeDialog({ )} - - Compare every feature - - + {brand.website_url && ( + + Compare every feature + + + )}