Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/architecture/emails.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,14 @@ to break one message at a time:
tags for the HTML part and `stripMarkdown` flattens it for the text part
(`@releases/rendering/strip-markdown`). Escaping it verbatim shows readers
asterisks.
- **Release rows link upstream, not to `/release/`.** `/release/<id>` pages are
noindexed stubs of upstream content, so a digest's release titles and version
pills send the reader to the release's own `url` whenever it is http(s), the
same policy the web feed applies via `releaseLinkTarget()`
(`web/src/lib/release-link.ts`). The on-site permalink is the fallback:
slugged `webUrl`, then the bare-ID path, which 308s to canonical. "and N more"
in a rollup still goes to the product page — that's an org surface, not a
release stub.

## Subject lines

Expand Down
12 changes: 10 additions & 2 deletions workers/api/src/lib/digest-email.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,9 +98,17 @@ function digestDateLabel(cadence: "daily" | "weekly", referenceDate: string): st
return DIGEST_DATE_FMT.format(new Date(end.getTime() - DAY_MS));
}

/**
* Where a release row in the digest sends the reader. Mirrors the web feed policy
* in `web/src/lib/release-link.ts` (`releaseLinkTarget`): `/release/<id>` pages are
* noindexed stubs of upstream content, so the default click goes straight to the
* upstream page whenever the release has a referenceable http(s) URL. The on-site
* permalink stays the fallback — the slugged canonical (`webUrl`, populated when the
* row is mapped with a webBase), then the bare-ID path, which 308s to canonical.
*/
function releaseUrl(baseUrl: string, r: ReleaseLatestItem): string {
// Prefer the slugged canonical (`webUrl`, populated when the row is mapped
// with a webBase); fall back to the bare-ID path, which 308s to canonical.
const upstream = (r.url ?? "").trim();
if (/^https?:\/\//i.test(upstream)) return upstream;
return r.webUrl ?? `${baseUrl}/release/${r.id}`;
}

Expand Down
5 changes: 4 additions & 1 deletion workers/api/src/lib/email-samples.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,10 @@ function sampleDigestRelease(): ReleaseLatestItem {
titleGenerated: null,
titleShort: "Sample release",
publishedAt: new Date().toISOString(),
url: "https://releases.sh/release/rel_sample_1",
// Upstream source URL — the digest's default link target (see `releaseUrl` in
// digest-email.ts). Kept as a third-party-looking URL so the preview is
// representative rather than exercising the internal-permalink fallback.
url: "https://example.com/changelog#sample-release",
media: [],
source: {
slug: "changelog",
Expand Down
30 changes: 28 additions & 2 deletions workers/api/test/digest-email.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,10 @@ describe("buildDigestEmail", () => {
expect(subject).toContain("2");
expect(text).toContain("ShortHeadline"); // titleShort preferred over title, matching the feed UI
expect(text).not.toContain("RawLongTitle");
expect(text).toContain("https://releases.sh/release/rel_1");
// Release rows link upstream, not to the noindexed /release/ stub — same policy
// as the web feed's releaseLinkTarget().
expect(text).toContain("https://acme.com/changelog/1");
expect(text).not.toContain("https://releases.sh/release/rel_1");
expect(text).toContain("reld_x");
expect(html).toContain("Unsubscribe");
expect(html).toContain("https://releases.sh/following");
Expand All @@ -63,6 +66,29 @@ describe("buildDigestEmail", () => {
expect(html).toContain("max-width:600px");
});

it("falls back to the on-site permalink when a release has no upstream http(s) url", () => {
const { text: slugged } = buildDigestEmail({
recipientName: "T",
cadence: "daily",
releases: [rel({ url: null, webUrl: "https://releases.sh/release/rel_1-thing" })],
baseUrl: "https://releases.sh",
manageUrl: "https://releases.sh/following",
unsubscribeUrl: "https://api.releases.sh/v1/digest/unsubscribe/reld_x",
});
expect(slugged).toContain("https://releases.sh/release/rel_1-thing");

// No webUrl either → the bare-ID path, which 308s to canonical.
const { text: bare } = buildDigestEmail({
recipientName: "T",
cadence: "daily",
releases: [rel({ url: null })],
baseUrl: "https://releases.sh",
manageUrl: "https://releases.sh/following",
unsubscribeUrl: "https://api.releases.sh/v1/digest/unsubscribe/reld_x",
});
expect(bare).toContain("https://releases.sh/release/rel_1");
});

it("compresses a per-product version burst to the newest release + notes", () => {
const ghSource = {
slug: "sdk",
Expand Down Expand Up @@ -103,7 +129,7 @@ describe("buildDigestEmail", () => {
expect(text).toContain("Latest — Fixed a socket leak");
// "and N more" links to the product page; the pill itself links to the release.
expect(text).toContain("https://releases.sh/acme/widget");
expect(html).toContain('href="https://releases.sh/release/t1"');
expect(html).toContain('href="https://acme.com/changelog/1"');
expect(html).toContain(">4.2.1</a>");
expect(html).toContain("and 1 more");
expect(html).toContain("&middot; 2 releases");
Expand Down
Loading