mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-12 08:04:41 +00:00
Merge remote-tracking branch 'origin/main' into fix/issue-401
This commit is contained in:
+14
-5
@@ -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=
|
||||
|
||||
@@ -56,7 +56,9 @@ export default function OutreachPage() {
|
||||
const qc = useQueryClient();
|
||||
const [mode, setMode] = useState<Mode>("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"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground mt-0.5">
|
||||
|
||||
+12
-1
@@ -190,7 +190,18 @@ 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 base := config.AppBaseURL(); base != "" {
|
||||
// FRONTEND_BASE_URL is the older name for the same setting, so a
|
||||
// deployment using it is configured, not guessed at, and must not
|
||||
// be told to go looking at CORS_ALLOW_ORIGINS.
|
||||
if os.Getenv("FRONTEND_BASE_URL") != "" {
|
||||
log.Printf("Warning: APP_URL is not set, so emailed links are built from FRONTEND_BASE_URL (%s), its older name. Rename it to APP_URL.", base)
|
||||
return
|
||||
}
|
||||
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.", base)
|
||||
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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
</Step>
|
||||
|
||||
|
||||
@@ -197,6 +197,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.
|
||||
|
||||
@@ -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.
|
||||
@@ -77,4 +83,8 @@ 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.
|
||||
Unsubscribe links are served by the API process on the origin in `API_PUBLIC_URL`, so that variable must be set to an address recipients can reach. Leaving it unset does not disable the link: the backend falls back to its own listen address, `API_HOST`, whose default wildcard bind resolves to `http://localhost:8080`. Every opt-out in every campaign email then points at an address only the backend's own machine can open. 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.
|
||||
|
||||
@@ -104,11 +104,13 @@ export function FormPage() {
|
||||
{design.layout !== "split" && !design.logoOnPage && bodyLogo}
|
||||
<FormRenderer form={form} design={design} tracker={tracker} />
|
||||
</div>
|
||||
<div className="brand">
|
||||
<a href="https://warmbly.com" target="_blank" rel="noopener noreferrer">
|
||||
Powered by Warmbly
|
||||
</a>
|
||||
</div>
|
||||
{form.brand?.url && (
|
||||
<div className="brand">
|
||||
<a href={form.brand.url} target="_blank" rel="noopener noreferrer">
|
||||
Powered by {form.brand.name}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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<string, string>;
|
||||
link_token?: string;
|
||||
}
|
||||
|
||||
export interface FormBrand {
|
||||
name: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export interface SubmitPayload {
|
||||
answers: Record<string, string[]>;
|
||||
/** Honeypot value; a human never fills it. */
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,25 +35,38 @@ 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)
|
||||
}
|
||||
|
||||
// Checks the URL links are actually built from, not only the one that was
|
||||
// configured. An install that set no APP_URL still mails reset and invitation
|
||||
// tokens, against a base inferred from CORS_ALLOW_ORIGINS or PUBLIC_HOST, and
|
||||
// that base is as capable of being plain http as a configured one.
|
||||
func checkAppURLInsecure(ctx context.Context, d Deps, in Input) *Finding {
|
||||
raw := appURL()
|
||||
if !appURLConfigured() {
|
||||
return nil
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.Scheme != "http" || isLoopbackHost(u.Hostname()) {
|
||||
return nil
|
||||
}
|
||||
source := "APP_URL is"
|
||||
if !appURLConfigured() {
|
||||
source = "APP_URL is not set, so emailed links are being built against"
|
||||
}
|
||||
return result(CategoryURLs, SeverityWarning, "The dashboard is not behind HTTPS",
|
||||
fmt.Sprintf("APP_URL is %s. Browsers refuse WebAuthn outside a secure context, so passkeys are disabled, "+
|
||||
"and session cookies are sent in the clear. Put the dashboard behind HTTPS.", raw),
|
||||
fmt.Sprintf("%s %s. Browsers refuse WebAuthn outside a secure context, so passkeys are disabled, and session "+
|
||||
"cookies and the reset and invitation tokens in emailed links all travel in the clear. Put the dashboard "+
|
||||
"behind HTTPS.", source, raw),
|
||||
docsAddresses)
|
||||
}
|
||||
|
||||
@@ -151,6 +165,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 == "" {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 = `<!DOCTYPE html>
|
||||
<tr>
|
||||
<td align="center" style="padding:32px 24px 48px;">
|
||||
<table cellpadding="0" cellspacing="0" border="0" role="presentation" style="max-width:520px;width:100%;">
|
||||
<tr>
|
||||
{{if .FooterLinks}}<tr>
|
||||
<td align="center" style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;font-size:11px;line-height:18px;padding-bottom:8px;">
|
||||
<a href="{{.PrivacyURL}}" style="color:#64748b;text-decoration:none;">Privacy</a>
|
||||
<span style="color:#cbd5e1;"> · </span>
|
||||
<a href="{{.TermsURL}}" style="color:#64748b;text-decoration:none;">Terms</a>
|
||||
<span style="color:#cbd5e1;"> · </span>
|
||||
<a href="{{.WebsiteURL}}" style="color:#64748b;text-decoration:none;">{{.WebsiteLabel}}</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
{{range $i, $link := .FooterLinks}}{{if $i}}<span style="color:#cbd5e1;"> · </span>
|
||||
{{end}}<a href="{{$link.URL}}" style="color:#64748b;text-decoration:none;">{{$link.Label}}</a>
|
||||
{{end}}</td>
|
||||
</tr>{{end}}
|
||||
{{if .LegalLine}}<tr>
|
||||
<td align="center" style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;font-size:10px;line-height:16px;color:#94a3b8;">
|
||||
© {{.LegalEntity}} · {{.CompanyNumber}} · {{.PlaceOfReg}}
|
||||
{{.LegalLine}}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
</tr>{{end}}
|
||||
{{if .RegisteredAddr}}<tr>
|
||||
<td align="center" style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;font-size:10px;line-height:16px;color:#94a3b8;padding-top:4px;">
|
||||
{{.RegisteredAddr}}
|
||||
</td>
|
||||
</tr>
|
||||
</tr>{{end}}
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -62,7 +62,7 @@ Scheduled deletion
|
||||
Account scheduled for deletion
|
||||
</h2>
|
||||
<p style="margin:0 0 16px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;font-size:13px;color:#475569;line-height:20px;">
|
||||
Hi {{.Name}}, your Warmbly account has been scheduled for permanent deletion.
|
||||
Hi {{.Name}}, your {{.Brand}} account has been scheduled for permanent deletion.
|
||||
</p>
|
||||
` + deletionDetailBlock + `
|
||||
<p style="margin:0 0 8px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;font-size:11px;color:#94a3b8;letter-spacing:0.08em;text-transform:uppercase;font-weight:500;">
|
||||
@@ -182,12 +182,14 @@ func GenerateOrgDeletionScheduledHTML(orgName string, executeAfter time.Time, gr
|
||||
// GenerateUserDeletionScheduledHTML renders the account "scheduled for
|
||||
// deletion" notice. firstName falls back to the email upstream.
|
||||
func GenerateUserDeletionScheduledHTML(firstName string, executeAfter time.Time, graceDays int, cancelURL string) (string, error) {
|
||||
return renderDeletion(userDeletionScheduledTmpl, "Your Warmbly account is scheduled for deletion", struct {
|
||||
brand := CompanyName()
|
||||
return renderDeletion(userDeletionScheduledTmpl, "Your "+brand+" account is scheduled for deletion", struct {
|
||||
Name string
|
||||
DeleteOn string
|
||||
GraceDays int
|
||||
CancelURL string
|
||||
}{firstName, formatDeletionTime(executeAfter), graceDays, cancelURL})
|
||||
Brand string
|
||||
}{firstName, formatDeletionTime(executeAfter), graceDays, cancelURL, brand})
|
||||
}
|
||||
|
||||
// GenerateOrgDeletionCancelledHTML renders the org deletion-cancelled
|
||||
|
||||
@@ -28,7 +28,7 @@ Digest
|
||||
While you were away
|
||||
</h2>
|
||||
<p style="margin:0 0 20px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;font-size:13px;color:#475569;line-height:20px;">
|
||||
{{.Count}} updates in your Warmbly workspace, bundled into one email.
|
||||
{{.Count}} updates in your {{.Brand}} workspace, bundled into one email.
|
||||
</p>
|
||||
{{range .Items}}
|
||||
<div style="margin:0 0 14px;padding:12px 14px;border:1px solid #e2e8f0;border-radius:8px;">
|
||||
@@ -45,7 +45,7 @@ While you were away
|
||||
<table cellpadding="0" cellspacing="0" border="0" align="center" role="presentation" style="margin:8px 0 24px;">
|
||||
<tr>
|
||||
<td align="center" style="border-radius:6px;background:#0f172a;">
|
||||
<a href="{{.AppURL}}" target="_blank" style="display:inline-block;padding:10px 22px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;font-size:13px;font-weight:600;color:#ffffff;text-decoration:none;letter-spacing:0.01em;">Open Warmbly</a>
|
||||
<a href="{{.AppURL}}" target="_blank" style="display:inline-block;padding:10px 22px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;font-size:13px;font-weight:600;color:#ffffff;text-decoration:none;letter-spacing:0.01em;">Open {{.Brand}}</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -60,15 +60,17 @@ var digestTmpl = template.Must(template.New("digest_content").Parse(digestConten
|
||||
|
||||
// GenerateDigestHTML renders the bundled-notifications email.
|
||||
func GenerateDigestHTML(count int, items []DigestItem) (string, error) {
|
||||
brand := CompanyName()
|
||||
data := struct {
|
||||
Count int
|
||||
Items []DigestItem
|
||||
AppURL string
|
||||
}{Count: count, Items: items, AppURL: AppURL}
|
||||
Brand string
|
||||
}{Count: count, Items: items, AppURL: AppURL(), Brand: brand}
|
||||
var buf bytes.Buffer
|
||||
if err := digestTmpl.Execute(&buf, data); err != nil {
|
||||
errs.CaptureException(err)
|
||||
return "", err
|
||||
}
|
||||
return renderEmail(fmt.Sprintf("%d updates in your Warmbly workspace", count), buf.String())
|
||||
return renderEmail(fmt.Sprintf("%d updates in your %s workspace", count, brand), buf.String())
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ Invitation
|
||||
You've been invited to {{.OrgName}}
|
||||
</h2>
|
||||
<p style="margin:0 0 20px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;font-size:13px;color:#475569;line-height:20px;">
|
||||
{{.InviterName}} has invited you to join <strong style="color:#0f172a;">{{.OrgName}}</strong> on Warmbly. Accept the invitation to start collaborating on mailboxes, warmup, and campaigns.
|
||||
{{.InviterName}} has invited you to join <strong style="color:#0f172a;">{{.OrgName}}</strong> on {{.Brand}}. Accept the invitation to start collaborating on mailboxes, warmup, and campaigns.
|
||||
</p>
|
||||
|
||||
<table cellpadding="0" cellspacing="0" border="0" align="center" role="presentation" style="margin:0 0 24px;">
|
||||
@@ -42,15 +42,17 @@ var invitationTmpl = template.Must(template.New("invitation_content").Parse(invi
|
||||
// GenerateInvitationHTML renders a team-invitation email. acceptURL is
|
||||
// the full invite-accept link (already carrying the token).
|
||||
func GenerateInvitationHTML(inviterName, orgName, acceptURL string) (string, error) {
|
||||
brand := CompanyName()
|
||||
data := struct {
|
||||
InviterName string
|
||||
OrgName string
|
||||
AcceptURL string
|
||||
}{InviterName: inviterName, OrgName: orgName, AcceptURL: acceptURL}
|
||||
Brand string
|
||||
}{InviterName: inviterName, OrgName: orgName, AcceptURL: acceptURL, Brand: brand}
|
||||
var buf bytes.Buffer
|
||||
if err := invitationTmpl.Execute(&buf, data); err != nil {
|
||||
errs.CaptureException(err)
|
||||
return "", err
|
||||
}
|
||||
return renderEmail("You've been invited to Warmbly", buf.String())
|
||||
return renderEmail("You've been invited to "+brand, buf.String())
|
||||
}
|
||||
|
||||
@@ -42,10 +42,11 @@ var notificationTmpl = template.Must(template.New("notification_content").Parse(
|
||||
|
||||
// GenerateNotificationHTML renders a generic notification email. ctaURL
|
||||
// is optional (empty drops the button); ctaLabel defaults to "Open in
|
||||
// Warmbly" when a URL is present but no label is supplied.
|
||||
// <product name>" when a URL is present but no label is supplied.
|
||||
func GenerateNotificationHTML(title, body, ctaURL, ctaLabel string) (string, error) {
|
||||
brand := CompanyName()
|
||||
if ctaURL != "" && ctaLabel == "" {
|
||||
ctaLabel = "Open in Warmbly"
|
||||
ctaLabel = "Open in " + brand
|
||||
}
|
||||
data := struct {
|
||||
Title string
|
||||
@@ -60,7 +61,7 @@ func GenerateNotificationHTML(title, body, ctaURL, ctaLabel string) (string, err
|
||||
}
|
||||
subject := title
|
||||
if subject == "" {
|
||||
subject = "Notification from Warmbly"
|
||||
subject = "Notification from " + brand
|
||||
}
|
||||
return renderEmail(subject, buf.String())
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ const registrationCodeContent = `
|
||||
Verify
|
||||
</p>
|
||||
<h2 style="margin:0 0 8px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;font-weight:600;font-size:18px;color:#0f172a;letter-spacing:-0.01em;">
|
||||
Welcome to Warmbly
|
||||
Welcome to {{.Brand}}
|
||||
</h2>
|
||||
<p style="margin:0 0 24px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;font-size:13px;color:#475569;line-height:20px;">
|
||||
Enter this code to finish creating your account.
|
||||
@@ -34,7 +34,10 @@ Expires in 15 minutes
|
||||
var registrationCodeTmpl = template.Must(template.New("registration_code_content").Parse(registrationCodeContent))
|
||||
|
||||
func GenerateRegistrationCodeHTML(code string) (string, error) {
|
||||
data := struct{ Code string }{Code: code}
|
||||
data := struct {
|
||||
Code string
|
||||
Brand string
|
||||
}{Code: code, Brand: CompanyName()}
|
||||
var buf bytes.Buffer
|
||||
if err := registrationCodeTmpl.Execute(&buf, data); err != nil {
|
||||
errs.CaptureException(err)
|
||||
|
||||
@@ -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</a>",
|
||||
AppURL + "/settings/billing", // billing CTA href
|
||||
AppURL() + "/settings/billing", // billing CTA href
|
||||
"<title>Your Warmbly trial has ended</title>",
|
||||
}
|
||||
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("<script>evil()</script>", "Acme & Co", AppURL+"/invite?token=x")
|
||||
html, err := GenerateInvitationHTML("<script>evil()</script>", "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</a>", // default CTA label
|
||||
AppURL + "/app/settings/security",
|
||||
AppURL() + "/app/settings/security",
|
||||
"<title>New sign-in</title>",
|
||||
}
|
||||
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,92 @@ 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A rebranded install must not leak the product's own name into copy a
|
||||
// recipient reads. Every template that names the product reads it from
|
||||
// EMAIL_BRAND_NAME, so a half-rebrand (an Acme header over Warmbly prose) is
|
||||
// the failure this covers.
|
||||
func TestTemplatesUseTheConfiguredName(t *testing.T) {
|
||||
t.Setenv("DEPLOYMENT_MODE", "self_hosted")
|
||||
t.Setenv("EMAIL_BRAND_NAME", "Acme")
|
||||
t.Setenv("APP_URL", "https://app.acme.example")
|
||||
|
||||
cases := map[string]func() (string, error){
|
||||
"welcome": func() (string, error) { return GenerateWelcomeHTML("Jane") },
|
||||
"digest": func() (string, error) { return GenerateDigestHTML(3, []DigestItem{{Title: "One"}}) },
|
||||
"trial expired": func() (string, error) { return GenerateTrialExpiredHTML() },
|
||||
"invitation": func() (string, error) { return GenerateInvitationHTML("Jane", "Acme Inc", "https://x/invite") },
|
||||
"notification": func() (string, error) { return GenerateNotificationHTML("Hi", "Body", "https://x", "") },
|
||||
"registration code": func() (string, error) { return GenerateRegistrationCodeHTML("123456") },
|
||||
"user deletion": func() (string, error) { return GenerateUserDeletionScheduledHTML("Jane", previewTime, 30, "https://x") },
|
||||
}
|
||||
|
||||
for name, gen := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
html, err := gen()
|
||||
if err != nil {
|
||||
t.Fatalf("render: %v", err)
|
||||
}
|
||||
if strings.Contains(html, "Warmbly") {
|
||||
t.Error("rebranded install still renders the product's own name")
|
||||
}
|
||||
if !strings.Contains(html, "Acme") {
|
||||
t.Error("expected the configured name in the copy")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ Subscription
|
||||
Your free trial has ended
|
||||
</h2>
|
||||
<p style="margin:0 0 16px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;font-size:13px;color:#475569;line-height:20px;">
|
||||
Thanks for trying Warmbly. Your trial has now ended, so we've paused your campaigns and turned off warmup to avoid any unexpected sending.
|
||||
Thanks for trying {{.Brand}}. Your trial has now ended, so we've paused your campaigns and turned off warmup to avoid any unexpected sending.
|
||||
</p>
|
||||
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:0 0 20px;width:100%;">
|
||||
@@ -58,11 +58,15 @@ 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"}
|
||||
brand := CompanyName()
|
||||
data := struct {
|
||||
BillingURL string
|
||||
Brand string
|
||||
}{BillingURL: AppURL() + "/settings/billing", Brand: brand}
|
||||
var buf bytes.Buffer
|
||||
if err := trialExpiredTmpl.Execute(&buf, data); err != nil {
|
||||
errs.CaptureException(err)
|
||||
return "", err
|
||||
}
|
||||
return renderEmail("Your Warmbly trial has ended", buf.String())
|
||||
return renderEmail("Your "+brand+" trial has ended", buf.String())
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ const welcomeContent = `
|
||||
Welcome
|
||||
</p>
|
||||
<h2 style="margin:0 0 12px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;font-weight:600;font-size:20px;color:#0f172a;letter-spacing:-0.01em;">
|
||||
{{if .FirstName}}Hi {{.FirstName}}, welcome to Warmbly{{else}}Welcome to Warmbly{{end}}
|
||||
{{if .FirstName}}Hi {{.FirstName}}, welcome to {{.Brand}}{{else}}Welcome to {{.Brand}}{{end}}
|
||||
</h2>
|
||||
<p style="margin:0 0 16px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;font-size:13px;color:#475569;line-height:20px;">
|
||||
Your account is ready. From here you can connect mailboxes, warm them up, and start outbound campaigns, all in one place.
|
||||
@@ -58,16 +58,18 @@ var welcomeTmpl = template.Must(template.New("welcome_content").Parse(welcomeCon
|
||||
// base shell so styling stays consistent with the rest of the
|
||||
// transactional mail.
|
||||
func GenerateWelcomeHTML(firstName string) (string, error) {
|
||||
brand := CompanyName()
|
||||
data := struct {
|
||||
FirstName string
|
||||
AppURL string
|
||||
}{FirstName: firstName, AppURL: AppURL}
|
||||
Brand string
|
||||
}{FirstName: firstName, AppURL: AppURL(), Brand: brand}
|
||||
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 "+brand, buf.String())
|
||||
}
|
||||
|
||||
// WelcomeTemplate / WelcomeHTMLTMPL retained as deprecated exports so
|
||||
|
||||
@@ -488,7 +488,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}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"html"
|
||||
"strings"
|
||||
|
||||
"github.com/warmbly/warmbly/internal/app/unsublink"
|
||||
"github.com/warmbly/warmbly/internal/models"
|
||||
"github.com/warmbly/warmbly/internal/pkg/mailhtml"
|
||||
)
|
||||
@@ -12,10 +13,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
|
||||
|
||||
@@ -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 := `<p><a href="https://acme.com/pricing">pricing</a> <a href="` + link + `">unsubscribe</a></p>`
|
||||
|
||||
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
|
||||
})()
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+68
-2
@@ -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 <question> <default yes|no> -> 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.
|
||||
|
||||
@@ -1 +1 @@
|
||||
76dea57a84aa5ee84126dc657d2df87725fba90998eb4224ed861ccb71b0b08e install.sh
|
||||
92b8608cb15cf03a71e3c6ac7b9f132e36edbeddf3d5f7adae08ede93a2beb86 install.sh
|
||||
|
||||
@@ -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] = &[
|
||||
@@ -43,11 +44,20 @@ pub struct AppState {
|
||||
pub hits: Arc<HitForwarder>,
|
||||
/// Tighter per-source budget for page views than for pixels
|
||||
pub hit_rate_limiter: Arc<RateLimiter>,
|
||||
/// Opt-out pages get a budget of their own rather than sharing the pixel
|
||||
/// and click one. A single NAT or mail gateway can spend that shared
|
||||
/// counter on tracking alone, and the recipient behind it who then opens
|
||||
/// the unsubscribe link would be refused: the one request that must never
|
||||
/// be turned away. Same size, separate bucket; a source loading opt-out
|
||||
/// pages this fast is probing, not opting out.
|
||||
pub unsubscribe_rate_limiter: Arc<RateLimiter>,
|
||||
/// Proxies whose forwarded-IP header is believed, and which header
|
||||
pub trusted_proxies: Arc<Vec<ipnet::IpNet>>,
|
||||
pub client_ip_header: Arc<String>,
|
||||
/// Key for the source-address token (see `hash_ip`)
|
||||
pub ip_hash_key: Arc<String>,
|
||||
/// Recipient opt-out, proxied to the backend that owns the pages
|
||||
pub unsubscribe: Arc<UnsubscribeProxy>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
@@ -75,9 +85,11 @@ impl AppState {
|
||||
config.internal_api_token.clone(),
|
||||
)),
|
||||
hit_rate_limiter: Arc::new(RateLimiter::new(config.pagehit_rate_limit_per_min)),
|
||||
unsubscribe_rate_limiter: Arc::new(RateLimiter::new(config.rate_limit_per_min)),
|
||||
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 +285,86 @@ 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<AppState>,
|
||||
ConnectInfo(peer): ConnectInfo<SocketAddr>,
|
||||
Path(token): Path<String>,
|
||||
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<AppState>,
|
||||
Path(token): Path<String>,
|
||||
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<AppState>,
|
||||
ConnectInfo(peer): ConnectInfo<SocketAddr>,
|
||||
Path(token): Path<String>,
|
||||
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 opt-out budget, returning the
|
||||
/// refusal when it is spent. A recipient opts out once, so this budget is
|
||||
/// never reached by real traffic; it exists to cap token spraying, and it is
|
||||
/// deliberately not the counter that pixels and clicks spend.
|
||||
async fn spend_unsubscribe_budget(
|
||||
state: &AppState,
|
||||
peer: SocketAddr,
|
||||
headers: &HeaderMap,
|
||||
) -> Option<Response> {
|
||||
let ip = client_ip(
|
||||
peer,
|
||||
headers,
|
||||
&state.trusted_proxies,
|
||||
&state.client_ip_header,
|
||||
);
|
||||
let source = hash_ip(&state.ip_hash_key, &ip);
|
||||
if state.unsubscribe_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";
|
||||
|
||||
|
||||
+18
-1
@@ -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)
|
||||
|
||||
@@ -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/<token>` 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#"<!doctype html><html lang="en"><head><meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1"><meta name="robots" content="noindex">
|
||||
<title>Try again shortly</title>
|
||||
<style>body{font-family:system-ui,-apple-system,"Segoe UI",sans-serif;max-width:32rem;margin:4rem auto;padding:0 1.25rem;color:#0f172a;line-height:1.5}
|
||||
h1{font-size:1.25rem;margin:0 0 .5rem}p{color:#475569;margin:0 0 1.25rem}</style></head>
|
||||
<body><h1>Try again shortly</h1><p>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.</p></body></html>"#;
|
||||
|
||||
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#"<!doctype html><html lang="en"><head><meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1"><meta name="robots" content="noindex">
|
||||
<title>This unsubscribe link is invalid</title>
|
||||
<style>body{font-family:system-ui,-apple-system,"Segoe UI",sans-serif;max-width:32rem;margin:4rem auto;padding:0 1.25rem;color:#0f172a;line-height:1.5}
|
||||
h1{font-size:1.25rem;margin:0 0 .5rem}p{color:#475569;margin:0 0 1.25rem}</style></head>
|
||||
<body><h1>This unsubscribe link is invalid</h1><p>Reply to the email instead and the sender will stop.</p></body></html>"#,
|
||||
)
|
||||
.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))));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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<LimitRequestStatus, string> = {
|
||||
|
||||
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"}
|
||||
</button>
|
||||
<p className="text-[11px] text-slate-500">
|
||||
Subject to review per our{" "}
|
||||
<a
|
||||
href="https://warmbly.com/terms"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
terms of service
|
||||
</a>
|
||||
.
|
||||
{brand.terms_url ? (
|
||||
<>
|
||||
Subject to review per our{" "}
|
||||
<a href={brand.terms_url} target="_blank" rel="noreferrer" className="underline">
|
||||
terms of service
|
||||
</a>
|
||||
.
|
||||
</>
|
||||
) : (
|
||||
"Subject to review."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
+18
-18
@@ -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({
|
||||
|
||||
<div className="relative z-10 w-full max-w-[400px] lg:max-w-[900px]">
|
||||
{/* Mobile logo — white, on the sky, above the card */}
|
||||
<a href={WEBSITE_URL} className="mb-5 flex w-fit items-center gap-2.5 mx-auto lg:hidden">
|
||||
<Logo className="w-7 text-white" />
|
||||
<span className="font-extrabold text-[18px] tracking-tight text-white">Warmbly</span>
|
||||
</a>
|
||||
<BrandMark className="mb-5 flex w-fit items-center gap-2.5 mx-auto lg:hidden" />
|
||||
|
||||
{/* Card */}
|
||||
<div className="animate-card-float grid overflow-hidden rounded-3xl border border-slate-200 bg-white shadow-[0_1px_2px_rgba(15,23,42,0.04),0_30px_70px_-32px_rgba(15,23,42,0.32)] lg:grid-cols-2 lg:min-h-[580px]">
|
||||
{/* Showcase — desktop only */}
|
||||
<div className="relative hidden lg:block lg:border-r lg:border-slate-200">
|
||||
<AuthShowcase />
|
||||
<a href={WEBSITE_URL} className="absolute left-8 top-8 z-20 flex items-center gap-2.5">
|
||||
<Logo className="w-7 text-white" />
|
||||
<span className="font-extrabold text-[18px] tracking-tight text-white">Warmbly</span>
|
||||
</a>
|
||||
<BrandMark className="absolute left-8 top-8 z-20 flex items-center gap-2.5" />
|
||||
</div>
|
||||
|
||||
{/* Form column */}
|
||||
@@ -87,9 +83,9 @@ export default function AuthLayout({
|
||||
|
||||
{/* Footer — desktop, inside the card */}
|
||||
<div className="hidden items-center gap-3 pt-9 text-[12px] text-slate-400 lg:flex">
|
||||
<a href={`${WEBSITE_URL}/terms`} target="_blank" rel="noopener noreferrer" className="hover:text-slate-700 transition-colors">Terms</a>
|
||||
<a href={`${WEBSITE_URL}/privacy`} target="_blank" rel="noopener noreferrer" className="hover:text-slate-700 transition-colors">Privacy</a>
|
||||
<span className="ml-auto">© {new Date().getFullYear()} Warmbly</span>
|
||||
{brand.terms_url && <a href={brand.terms_url} target="_blank" rel="noopener noreferrer" className="hover:text-slate-700 transition-colors">Terms</a>}
|
||||
{brand.privacy_url && <a href={brand.privacy_url} target="_blank" rel="noopener noreferrer" className="hover:text-slate-700 transition-colors">Privacy</a>}
|
||||
<span className="ml-auto">© {new Date().getFullYear()} {brand.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -97,11 +93,15 @@ export default function AuthLayout({
|
||||
|
||||
{/* Footer — mobile, on the sky below the card */}
|
||||
<div className="mt-5 flex items-center justify-center gap-3 text-[12px] text-white/70 lg:hidden">
|
||||
<a href={`${WEBSITE_URL}/terms`} target="_blank" rel="noopener noreferrer" className="hover:text-white transition-colors">Terms</a>
|
||||
<span className="text-white/40">·</span>
|
||||
<a href={`${WEBSITE_URL}/privacy`} target="_blank" rel="noopener noreferrer" className="hover:text-white transition-colors">Privacy</a>
|
||||
<span className="text-white/40">·</span>
|
||||
<span>© {new Date().getFullYear()} Warmbly</span>
|
||||
{brand.terms_url && <>
|
||||
<a href={brand.terms_url} target="_blank" rel="noopener noreferrer" className="hover:text-white transition-colors">Terms</a>
|
||||
<span className="text-white/40">·</span>
|
||||
</>}
|
||||
{brand.privacy_url && <>
|
||||
<a href={brand.privacy_url} target="_blank" rel="noopener noreferrer" className="hover:text-white transition-colors">Privacy</a>
|
||||
<span className="text-white/40">·</span>
|
||||
</>}
|
||||
<span>© {new Date().getFullYear()} {brand.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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({
|
||||
</div>
|
||||
<span className="text-[13px] text-slate-400 leading-relaxed">
|
||||
I agree to the{" "}
|
||||
<a href={`${WEBSITE_URL}/terms`} target="_blank" rel="noopener noreferrer" className="text-sky-500 hover:text-sky-600 font-medium transition-colors">
|
||||
Terms of Service
|
||||
</a>
|
||||
{brand.terms_url ? (
|
||||
<a href={brand.terms_url} target="_blank" rel="noopener noreferrer" className="text-sky-500 hover:text-sky-600 font-medium transition-colors">
|
||||
Terms of Service
|
||||
</a>
|
||||
) : (
|
||||
"Terms of Service"
|
||||
)}
|
||||
{" "}and{" "}
|
||||
<a href={`${WEBSITE_URL}/privacy`} target="_blank" rel="noopener noreferrer" className="text-sky-500 hover:text-sky-600 font-medium transition-colors">
|
||||
Privacy Policy
|
||||
</a>
|
||||
{brand.privacy_url ? (
|
||||
<a href={brand.privacy_url} target="_blank" rel="noopener noreferrer" className="text-sky-500 hover:text-sky-600 font-medium transition-colors">
|
||||
Privacy Policy
|
||||
</a>
|
||||
) : (
|
||||
"Privacy Policy"
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
<FieldError message={errors.acceptTerms?.message} />
|
||||
|
||||
@@ -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() {
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 w-full max-w-[560px]">
|
||||
<a href={WEBSITE_URL} className="mb-5 flex w-fit items-center gap-2.5 mx-auto">
|
||||
<Logo className="w-7 text-white" />
|
||||
<span className="font-extrabold text-[18px] tracking-tight text-white">Warmbly</span>
|
||||
</a>
|
||||
<BrandMark className="mb-5 flex w-fit items-center gap-2.5 mx-auto" />
|
||||
|
||||
<motion.div
|
||||
initial={{ y: 14, opacity: 0 }}
|
||||
|
||||
@@ -20,9 +20,8 @@ import {
|
||||
ServerIcon,
|
||||
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";
|
||||
@@ -111,10 +110,7 @@ function ConnectInner() {
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 w-full max-w-[560px]">
|
||||
<a href={WEBSITE_URL} className="mb-5 flex w-fit items-center gap-2.5 mx-auto">
|
||||
<Logo className="w-7 text-white" />
|
||||
<span className="font-extrabold text-[18px] tracking-tight text-white">Warmbly</span>
|
||||
</a>
|
||||
<BrandMark className="mb-5 flex w-fit items-center gap-2.5 mx-auto" />
|
||||
|
||||
<motion.div
|
||||
initial={{ y: 14, opacity: 0 }}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from "react";
|
||||
import { Logo } from "@/components/svg";
|
||||
import { type ChartPoint } from "@/components/ui/charts";
|
||||
import useBrand from "@/hooks/useBrand";
|
||||
|
||||
// A branded analytics card rasterized to a shareable PNG (see useExportCard).
|
||||
// Design: a flat branded sky background, the Warmbly logo white on the sky, and
|
||||
@@ -153,6 +154,10 @@ function ShareAreaChart({ points }: { points: ChartPoint[] }) {
|
||||
|
||||
const StatsShareCard = React.forwardRef<HTMLDivElement, { data: ShareCardData; aspect?: ShareAspect }>(
|
||||
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<HTMLDivElement, { data: ShareCardData; a
|
||||
className="text-white font-extrabold tracking-tight"
|
||||
style={{ fontFamily: "var(--font-display)", fontSize: wordmarkSize }}
|
||||
>
|
||||
Warmbly
|
||||
{brand.name}
|
||||
</span>
|
||||
</div>
|
||||
<span className="font-mono text-[15px] tabular-nums text-white/85">
|
||||
@@ -228,14 +233,16 @@ const StatsShareCard = React.forwardRef<HTMLDivElement, { data: ShareCardData; a
|
||||
</div>
|
||||
|
||||
{/* footer on the sky */}
|
||||
<div className="mt-5 flex items-center justify-between text-[16px]">
|
||||
<span className="text-white font-semibold">
|
||||
warmbly.com
|
||||
</span>
|
||||
<span className="text-white/80">
|
||||
Cold email, warmed up.
|
||||
</span>
|
||||
</div>
|
||||
{brand.website_label && (
|
||||
<div className="mt-5 flex items-center justify-between text-[16px]">
|
||||
<span className="text-white font-semibold">
|
||||
{brand.website_label}
|
||||
</span>
|
||||
<span className="text-white/80">
|
||||
Cold email, warmed up.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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 })
|
||||
</div>
|
||||
|
||||
<a
|
||||
href={`${WEBSITE_URL}/learn/personalization`}
|
||||
href="https://docs.warmbly.com/learn/personalization/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-3 inline-flex items-center gap-1 border-t border-slate-100 pt-2.5 text-[11px] font-medium text-sky-600 hover:text-sky-700"
|
||||
|
||||
@@ -61,7 +61,6 @@ import { useConfirm } from "@/hooks/context/confirm";
|
||||
import RichTextAIEdit from "@/components/app/ai/RichTextAIEdit";
|
||||
import RichTextAICaret from "@/components/app/ai/RichTextAICaret";
|
||||
import { useForms } from "@/lib/api/hooks/app/forms";
|
||||
import { WEBSITE_URL } from "@/lib/information";
|
||||
import { EmailImage } from "./nodes/EmailImageNode";
|
||||
import { ImageBubble, ImageMenu } from "./ImageControls";
|
||||
import { AlignMenu, ColorMenu, TableMenu, TypeMenu } from "./DesignControls";
|
||||
@@ -851,7 +850,7 @@ export function VariableMenu({
|
||||
</div>
|
||||
|
||||
<a
|
||||
href={`${WEBSITE_URL}/learn/personalization`}
|
||||
href="https://docs.warmbly.com/learn/personalization/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
|
||||
@@ -1368,7 +1368,7 @@ function TrackingDomainCard({ mailbox }: { mailbox: Inbox }) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<FieldShell label="Custom tracking domain" hint="Track opens & clicks through your own subdomain instead of the shared host. Improves deliverability.">
|
||||
<FieldShell label="Custom tracking domain" hint="Track opens & clicks through your own subdomain instead of the shared host, and serve the unsubscribe link there too. Improves deliverability.">
|
||||
<TextInput value={domain} placeholder="track.yourdomain.com" onChange={setDomain} className="w-full h-9" />
|
||||
</FieldShell>
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ export default function WarmupCoverageNotice({
|
||||
</button>
|
||||
) : (
|
||||
<a
|
||||
href="https://warmbly.com"
|
||||
href="https://docs.warmbly.com/guides/warmbly-cloud/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline underline-offset-2 hover:text-amber-950"
|
||||
|
||||
@@ -13,6 +13,7 @@ import type { FormDesign, FormField } from "@/lib/api/models/app/forms/Form";
|
||||
|
||||
import "./form-theme.css";
|
||||
import { designVars, ensureFont, focusSteps, resolveDesign, splitPages } from "./designCore";
|
||||
import useBrand from "@/hooks/useBrand";
|
||||
|
||||
// FieldBody renders one block with the shared .wf classes. Inputs are real
|
||||
// but inert (readOnly/disabled, tabIndex -1) so form-theme.css styles them
|
||||
@@ -322,6 +323,9 @@ export default function FormPreview({
|
||||
}) {
|
||||
const r = React.useMemo(() => 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}
|
||||
</div>
|
||||
<div className="brand">
|
||||
<a href="https://warmbly.com" target="_blank" rel="noopener noreferrer" onClick={(e) => e.preventDefault()}>
|
||||
Powered by Warmbly
|
||||
</a>
|
||||
</div>
|
||||
{brand.website_url && (
|
||||
<div className="brand">
|
||||
<a href={brand.website_url} target="_blank" rel="noopener noreferrer" onClick={(e) => e.preventDefault()}>
|
||||
Powered by {brand.name}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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({
|
||||
<ArrowRightIcon className="w-3 h-3" />
|
||||
</Link>
|
||||
)}
|
||||
<a
|
||||
href={`${WEBSITE_URL}/pricing`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 font-medium text-slate-600 hover:text-slate-900 transition-colors"
|
||||
>
|
||||
Compare every feature
|
||||
<ArrowUpRightIcon className="w-3 h-3" />
|
||||
</a>
|
||||
{brand.website_url && (
|
||||
<a
|
||||
href={`${brand.website_url}/pricing`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 font-medium text-slate-600 hover:text-slate-900 transition-colors"
|
||||
>
|
||||
Compare every feature
|
||||
<ArrowUpRightIcon className="w-3 h-3" />
|
||||
</a>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={requestClose}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Logo } from "@/components/svg";
|
||||
import useBrand from "@/hooks/useBrand";
|
||||
|
||||
/**
|
||||
* The wordmark on the sky: sign-in, the CLI authorization page, the cloud
|
||||
* connect page.
|
||||
*
|
||||
* It links to the deployment's website only when there is one. A self-hosted
|
||||
* instance that configured no EMAIL_BRAND_WEBSITE_URL renders plain text
|
||||
* instead, because the alternative was sending that operator's users to the
|
||||
* platform's marketing site from their own login screen.
|
||||
*/
|
||||
export default function BrandMark({ className }: { className: string }) {
|
||||
const brand = useBrand();
|
||||
const inner = (
|
||||
<>
|
||||
<Logo className="w-7 text-white" />
|
||||
<span className="font-extrabold text-[18px] tracking-tight text-white">{brand.name}</span>
|
||||
</>
|
||||
);
|
||||
if (!brand.website_url) return <div className={className}>{inner}</div>;
|
||||
return (
|
||||
<a href={brand.website_url} className={className}>
|
||||
{inner}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import useAuthConfig from "@/lib/api/hooks/auth/useAuthConfig";
|
||||
import { API_URL } from "@/lib/information";
|
||||
import type { DeploymentBrand } from "@/lib/api/models/auth/AuthConfig";
|
||||
|
||||
/**
|
||||
* Who this deployment says it is, for every surface that used to hardcode
|
||||
* warmbly.com: the sign-in screen's footer links, a shared stats card, the
|
||||
* form preview's attribution, copyable API examples.
|
||||
*
|
||||
* A self-hosted instance that configured no EMAIL_BRAND_* has no website, no
|
||||
* terms and no privacy URL, and the caller must render nothing rather than a
|
||||
* link to a site with no relationship to that instance. Only `name` is always
|
||||
* present: it is the software's name, which is true of a self-host too.
|
||||
*/
|
||||
export default function useBrand(): DeploymentBrand & { apiURL: string } {
|
||||
const { config } = useAuthConfig();
|
||||
return {
|
||||
name: config.brand?.name || "Warmbly",
|
||||
website_url: config.brand?.website_url,
|
||||
website_label: config.brand?.website_label,
|
||||
terms_url: config.brand?.terms_url,
|
||||
privacy_url: config.brand?.privacy_url,
|
||||
support_email: config.brand?.support_email,
|
||||
// The backend's own answer wins; a backend that predates the field
|
||||
// leaves the dashboard's configured API origin, which is the same
|
||||
// address for every deployment that is not behind a split proxy.
|
||||
apiURL: config.api_url || API_URL,
|
||||
};
|
||||
}
|
||||
@@ -30,6 +30,10 @@ export const AUTH_CONFIG_FALLBACK: AuthConfig = {
|
||||
billing_enabled: true,
|
||||
setup_required: false,
|
||||
docs_url: "https://docs.warmbly.com/development/accounts-and-access/",
|
||||
// An unreachable backend cannot tell us whose instance this is, and the
|
||||
// fallback fails self-host-safe everywhere else, so it claims no branding
|
||||
// either: no website, no terms, no privacy link.
|
||||
brand: { name: "Warmbly" },
|
||||
};
|
||||
|
||||
export default function useAuthConfig() {
|
||||
|
||||
@@ -30,4 +30,23 @@ export default interface AuthConfig {
|
||||
invites_required: boolean;
|
||||
/** Where to send someone a deployment policy refused, not their mistake. */
|
||||
docs_url: string;
|
||||
/** The dashboard origin this deployment builds its emailed links from. */
|
||||
app_url?: string;
|
||||
/** This API's own public base, for copyable API examples. Absent on a
|
||||
* backend that predates it, in which case examples fall back to the API
|
||||
* origin the dashboard itself is configured with. */
|
||||
api_url?: string;
|
||||
/** Who this deployment says it is. Every field but the name is absent on a
|
||||
* self-host that configured no EMAIL_BRAND_*, and the UI then renders no
|
||||
* link at all rather than sending the operator's users to warmbly.com. */
|
||||
brand?: DeploymentBrand;
|
||||
}
|
||||
|
||||
export interface DeploymentBrand {
|
||||
name: string;
|
||||
website_url?: string;
|
||||
website_label?: string;
|
||||
terms_url?: string;
|
||||
privacy_url?: string;
|
||||
support_email?: string;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { runtimeEnv } from "./runtimeConfig";
|
||||
|
||||
export const WEBSITE_URL = "https://warmbly.com";
|
||||
// These read the container-injected runtime config first (so one built image
|
||||
// works for any deployment), then the value Vite baked at build time.
|
||||
export const APP_URL = runtimeEnv("APP_URL", import.meta.env.VITE_APP_URL);
|
||||
|
||||
Reference in New Issue
Block a user