Skip to content

fix(core): let the configured siteUrl steer outbound email links - #2370

Open
danielmlr wants to merge 2 commits into
emdash-cms:mainfrom
danielmlr:fix/site-base-url-config-precedence
Open

fix(core): let the configured siteUrl steer outbound email links#2370
danielmlr wants to merge 2 commits into
emdash-cms:mainfrom
danielmlr:fix/site-base-url-config-precedence

Conversation

@danielmlr

Copy link
Copy Markdown

What does this PR do?

getSiteBaseUrl — the only origin source for magic-link, invite, signup-confirmation, recovery and comment-notification emails — read only the stored emdash:site_url option. That option is written once, by the setup wizard (setIfAbsent, deliberately, as the Host-spoofing lock), and Admin → Settings writes the different key site:url. So a site that completed setup on a throwaway origin (a *.workers.dev preview URL, a staging host) kept mailing that origin forever: the operator sets siteUrl in the integration options as the docs prescribe ("the single option for the public origin"), every other origin-dependent feature follows getPublicOrigin to the new host — and email links still point at the dead one. The only remedy was a manual UPDATE options … against the production database. We hit exactly this on a production site.

This PR gives getSiteBaseUrl the same precedence as getPublicOrigin: configured origin (config.siteUrl, then EMDASH_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 configured siteUrl skip the options query on the email path entirely.

Changes:

  • api/public-url.ts: extract getConfiguredOrigin(config) (config → env, no request fallback); getPublicOrigin now uses it — no behavior change there.
  • api/site-url.ts: accept an optional config and apply it first.
  • All six call sites pass emdash.config (they already have it in scope).
  • New test tests/integration/api/site-url.test.ts covering the four precedence cases, including a regression guard for the Host-spoofing lock. The first test fails on main.

An alternative fix would be re-syncing the stored option at runtime init when config.siteUrl is 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

  • Bug fix
  • Feature (requires maintainer-approved Discussion)
  • Refactor (no behavior change)
  • Translation
  • Documentation
  • Performance improvement
  • Tests
  • Chore (dependencies, CI, tooling)

Checklist

  • I have read CONTRIBUTING.md
  • pnpm typecheck passes (core package, tsc --noEmit)
  • pnpm lint passes (lint:json → 0 diagnostics)
  • pnpm test passes (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 format has been run
  • I have added/updated tests for my changes (if applicable)
  • User-visible strings in the admin UI are wrapped for translation — n/a, no admin UI change
  • I have added a changeset
  • New features link to an approved Discussion — n/a, bug fix

AI-generated code disclosure

  • This PR includes AI-generated code — model/tool: Claude Fable 5

Screenshots / test output

 Test Files  2 passed (2)
      Tests  8 passed (8)   # site-url.test.ts + setup-site-url-lock.test.ts

 Test Files  10 passed (10)
      Tests  219 passed (219)  # integration/auth, integration/comments, email-pipeline

On main, the new precedence test fails with:

AssertionError: expected 'https://my-site.workers.dev/_emdash' to be 'https://real.example/_emdash'

@changeset-bot

changeset-bot Bot commented Aug 8, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 835a0be

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 17 packages
Name Type
emdash Patch
@emdash-cms/cloudflare Patch
@emdash-cms/sandbox-workerd Patch
@emdash-cms/plugin-mcp-smoke Patch
@emdash-cms/fixture-perf-site Patch
@emdash-cms/perf-demo-site Patch
@emdash-cms/cache-demo-site Patch
@emdash-cms/do-demo-site Patch
@emdash-cms/do-solo-demo-site Patch
@emdash-cms/admin Patch
@emdash-cms/auth Patch
@emdash-cms/blocks Patch
@emdash-cms/gutenberg-to-portable-text Patch
@emdash-cms/x402 Patch
create-emdash Patch
@emdash-cms/auth-atproto Patch
@emdash-cms/plugin-embeds Patch

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

@emdashbot emdashbot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Suggested change
// 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Suggested change
// 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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Suggested change
// 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Suggested change
// 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>
@github-actions github-actions Bot added review/needs-rereview Author pushed changes since the last review overlap and removed review/needs-review No maintainer or bot review yet labels Aug 8, 2026
@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Overlapping PRs

This PR modifies files that are also changed by other open PRs:

This may cause merge conflicts or duplicated work. A maintainer will coordinate.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant