fix(core): let the configured siteUrl steer outbound email links - #2370
fix(core): let the configured siteUrl steer outbound email links#2370danielmlr wants to merge 2 commits into
Conversation
🦋 Changeset detectedLatest commit: 835a0be The changes in this PR will be included in the next version bump. This PR includes changesets to release 17 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
There was a problem hiding this comment.
This is the right fix for a real, well-diagnosed problem. getSiteBaseUrl used to trust only the setup-wizard-written emdash:site_url option, but Admin → Settings writes site:url, so a site set up on a temporary origin would keep mailing links to that dead origin even after siteUrl was configured. Making getSiteBaseUrl consult config.siteUrl / EMDASH_SITE_URL / SITE_URL first mirrors the existing getPublicOrigin precedence and preserves the anti-spoofing property: the request URL is still the least-trusted fallback, and a configured/stored value always wins.
I checked the diff, the updated helper and all six call sites, the EmDashConfig/SiteUrlConfig structural compatibility, the setup URL-lock tests, the changeset, and the logged-out query-count impact. The implementation is correct and well-tested. The new precedence test directly reproduces the reported regression.
The only remaining issue is accuracy of the surrounding comments: four route files still say they “use stored site URL” when they now use the configured origin first (stored only as the fallback). Those comments will mislead future readers about the actual precedence and should be updated.
| @@ -53,7 +53,7 @@ export const POST: APIRoute = async ({ request, params, locals }) => { | |||
|
|
|||
| // Build config using stored site URL (not request Host header) | |||
There was a problem hiding this comment.
[suggestion] The comment is now misleading: getSiteBaseUrl checks the configured origin (config.siteUrl / env vars) before falling back to the stored setup origin. Update it to describe the actual precedence.
| // Build config using stored site URL (not request Host header) | |
| // Build config using the configured or stored site URL (not request Host header) |
| @@ -47,7 +47,7 @@ export const POST: APIRoute = async ({ request, locals }) => { | |||
| const siteName = (await options.get<string>("emdash:site_title")) || "EmDash"; | |||
|
|
|||
| // Use stored site URL to prevent Host header spoofing in invite emails | |||
There was a problem hiding this comment.
[suggestion] The comment is now misleading: getSiteBaseUrl checks the configured origin (config.siteUrl / env vars) before falling back to the stored setup origin. Update it to describe the actual precedence.
| // Use stored site URL to prevent Host header spoofing in invite emails | |
| // Use configured or stored site URL to prevent Host header spoofing in invite emails |
| @@ -59,7 +59,7 @@ export const POST: APIRoute = async ({ request, locals }) => { | |||
|
|
|||
| // Build magic link config using stored site URL (not request Host header) | |||
There was a problem hiding this comment.
[suggestion] The comment is now misleading: getSiteBaseUrl checks the configured origin (config.siteUrl / env vars) before falling back to the stored setup origin. Update it to describe the actual precedence.
| // Build magic link config using stored site URL (not request Host header) | |
| // Build magic link config using the configured or stored site URL (not request Host header) |
| @@ -69,7 +69,7 @@ export const POST: APIRoute = async ({ request, locals }) => { | |||
| const siteName = (await options.get<string>("emdash:site_title")) || "EmDash"; | |||
|
|
|||
| // Use stored site URL to prevent Host header spoofing in signup emails | |||
There was a problem hiding this comment.
[suggestion] The comment is now misleading: getSiteBaseUrl checks the configured origin (config.siteUrl / env vars) before falling back to the stored setup origin. Update it to describe the actual precedence.
| // Use stored site URL to prevent Host header spoofing in signup emails | |
| // Use configured or stored site URL to prevent Host header spoofing in signup emails |
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Overlapping PRsThis PR modifies files that are also changed by other open PRs:
This may cause merge conflicts or duplicated work. A maintainer will coordinate. |
What does this PR do?
getSiteBaseUrl— the only origin source for magic-link, invite, signup-confirmation, recovery and comment-notification emails — read only the storedemdash:site_urloption. That option is written once, by the setup wizard (setIfAbsent, deliberately, as the Host-spoofing lock), and Admin → Settings writes the different keysite:url. So a site that completed setup on a throwaway origin (a*.workers.devpreview URL, a staging host) kept mailing that origin forever: the operator setssiteUrlin the integration options as the docs prescribe ("the single option for the public origin"), every other origin-dependent feature followsgetPublicOriginto the new host — and email links still point at the dead one. The only remedy was a manualUPDATE options …against the production database. We hit exactly this on a production site.This PR gives
getSiteBaseUrlthe same precedence asgetPublicOrigin: configured origin (config.siteUrl, thenEMDASH_SITE_URL/SITE_URL) → stored setup origin → request URL (pre-setup only). The request still never overrides a configured or stored value, so the anti-spoofing property is unchanged. As a side effect, deployments with a configuredsiteUrlskip the options query on the email path entirely.Changes:
api/public-url.ts: extractgetConfiguredOrigin(config)(config → env, no request fallback);getPublicOriginnow uses it — no behavior change there.api/site-url.ts: accept an optionalconfigand apply it first.emdash.config(they already have it in scope).tests/integration/api/site-url.test.tscovering the four precedence cases, including a regression guard for the Host-spoofing lock. The first test fails onmain.An alternative fix would be re-syncing the stored option at runtime init when
config.siteUrlis set — happy to rework in that direction if you prefer it; this variant is the smaller diff and matches the documented "single option" contract.Type of change
Checklist
pnpm typecheckpasses (core package,tsc --noEmit)pnpm lintpasses (lint:json→ 0 diagnostics)pnpm testpasses (targeted:tests/integration/api/site-url.test.ts,tests/integration/astro/setup-site-url-lock.test.ts,tests/integration/auth,tests/integration/comments,tests/unit/plugins/email-pipeline.test.ts— 227 tests)pnpm formathas been runAI-generated code disclosure
Screenshots / test output
On main, the new precedence test fails with: