mirror of
https://github.com/juherr/kill-the-news.git
synced 2026-06-20 22:03:48 +00:00
feat(feed): optional per-feed sender-in-title toggle
Add a per-feed senderInTitle flag (domain FeedState.senderInTitle ↔ FeedConfig.sender_in_title). When set, the feed generator prefixes each entry title with [Sender] (display name, falling back to the address). Exposed as an admin edit-form checkbox and across the REST API create/update/response schemas. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -22,6 +22,7 @@ kill-the-news keeps the same workflow while avoiding shared domains and shared d
|
|||||||
- Cloudflare Email Workers ingestion (no third-party service)
|
- Cloudflare Email Workers ingestion (no third-party service)
|
||||||
- ForwardEmail webhook ingestion with source-IP verification (optional alternative)
|
- ForwardEmail webhook ingestion with source-IP verification (optional alternative)
|
||||||
- Optional per-feed sender allowlist (`email@domain.com` or `domain.com`)
|
- Optional per-feed sender allowlist (`email@domain.com` or `domain.com`)
|
||||||
|
- Optional per-feed "sender in title" toggle — renders each entry as `[Sender] Subject` for at-a-glance scanning in your reader
|
||||||
- RSS generation on demand (`/rss/:feedId`)
|
- RSS generation on demand (`/rss/:feedId`)
|
||||||
- Atom feed at `/atom/:feedId`
|
- Atom feed at `/atom/:feedId`
|
||||||
- JSON Feed at `/json/:feedId` (natively consumed by NetNewsWire, Reeder, NewsBlur, Feedly)
|
- JSON Feed at `/json/:feedId` (natively consumed by NetNewsWire, Reeder, NewsBlur, Feedly)
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ Gaps found by reading every open/closed issue + PR on [kill-the-newsletter](http
|
|||||||
|
|
||||||
- [ ] `P2·S` **Strip-styles / plaintext rendering option** ([#74](https://github.com/leafac/kill-the-newsletter/issues/74), [#119](https://github.com/leafac/kill-the-newsletter/issues/119)). Some readers render newsletter HTML/CSS poorly. Offer an opt-in to strip `<style>` + inline styles (keeping links), or to prefer the `text/plain` part. Per-feed setting + `src/infrastructure/html-processor.ts`.
|
- [ ] `P2·S` **Strip-styles / plaintext rendering option** ([#74](https://github.com/leafac/kill-the-newsletter/issues/74), [#119](https://github.com/leafac/kill-the-newsletter/issues/119)). Some readers render newsletter HTML/CSS poorly. Offer an opt-in to strip `<style>` + inline styles (keeping links), or to prefer the `text/plain` part. Per-feed setting + `src/infrastructure/html-processor.ts`.
|
||||||
|
|
||||||
- [ ] `P2·S` **Optional sender in entry title** ([#123 — open PR upstream](https://github.com/leafac/kill-the-newsletter/pull/123), [#124](https://github.com/leafac/kill-the-newsletter/issues/124)). We already emit `<author>`, but some users want `[Sender] Subject` as the entry title for at-a-glance scanning in the reader. Per-feed toggle + `src/infrastructure/feed-generator.ts`.
|
- [x] `P2·S` **Optional sender in entry title** ([#123 — open PR upstream](https://github.com/leafac/kill-the-newsletter/pull/123), [#124](https://github.com/leafac/kill-the-newsletter/issues/124)). We already emit `<author>`, but some users want `[Sender] Subject` as the entry title for at-a-glance scanning in the reader. Per-feed toggle + `src/infrastructure/feed-generator.ts`. — **Shipped:** per-feed `senderInTitle` flag (domain `FeedState.senderInTitle` ↔ `FeedConfig.sender_in_title`); when set, `buildFeed` prefixes each entry title with `[Sender]` (display name, falling back to the email address). Toggle exposed as an admin edit-form checkbox and on the REST API (`FeedCreate`/`FeedUpdate`/`Feed` schemas).
|
||||||
|
|
||||||
- [ ] `P2·S` **Detect a newsletter's native Atom/RSS feed** — _top item on upstream's own [TODO](https://github.com/leafac/kill-the-newsletter/blob/main/TODO.md), not yet built there_. When an incoming email's HTML contains `<link rel="alternate" type="application/atom+xml">` (or `application/rss+xml`), surface it: "this newsletter already publishes a feed — subscribe to it directly instead." We already parse HTML with linkedom in `src/infrastructure/html-processor.ts`, so detection is cheap; store the discovered URL on the feed and show it in the admin UI / a feed entry. A genuine differentiator — we'd ship it before upstream.
|
- [ ] `P2·S` **Detect a newsletter's native Atom/RSS feed** — _top item on upstream's own [TODO](https://github.com/leafac/kill-the-newsletter/blob/main/TODO.md), not yet built there_. When an incoming email's HTML contains `<link rel="alternate" type="application/atom+xml">` (or `application/rss+xml`), surface it: "this newsletter already publishes a feed — subscribe to it directly instead." We already parse HTML with linkedom in `src/infrastructure/html-processor.ts`, so detection is cheap; store the discovered URL on the feed and show it in the admin UI / a feed entry. A genuine differentiator — we'd ship it before upstream.
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ export interface FeedState {
|
|||||||
* is `mailboxId@domain`. Decoupled from the feed's `FeedId` (the read id). */
|
* is `mailboxId@domain`. Decoupled from the feed's `FeedId` (the read id). */
|
||||||
mailboxId: string;
|
mailboxId: string;
|
||||||
author?: string;
|
author?: string;
|
||||||
|
/** When true, entry titles in the feed output are rendered as `[Sender] Subject`. */
|
||||||
|
senderInTitle?: boolean;
|
||||||
allowedSenders: string[];
|
allowedSenders: string[];
|
||||||
blockedSenders: string[];
|
blockedSenders: string[];
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ export interface CreateFeedInput {
|
|||||||
language: string;
|
language: string;
|
||||||
allowedSenders: string[];
|
allowedSenders: string[];
|
||||||
blockedSenders: string[];
|
blockedSenders: string[];
|
||||||
|
/** When true, render entry titles as `[Sender] Subject` in the feed output. */
|
||||||
|
senderInTitle?: boolean;
|
||||||
/** Raw client-requested lifetime; the application resolves it into a `Lifetime`. */
|
/** Raw client-requested lifetime; the application resolves it into a `Lifetime`. */
|
||||||
lifetimeHours?: number;
|
lifetimeHours?: number;
|
||||||
}
|
}
|
||||||
@@ -23,6 +25,7 @@ export interface UpdateFeedInput {
|
|||||||
language?: string;
|
language?: string;
|
||||||
allowedSenders?: string[];
|
allowedSenders?: string[];
|
||||||
blockedSenders?: string[];
|
blockedSenders?: string[];
|
||||||
|
senderInTitle?: boolean;
|
||||||
lifetimeHours?: number;
|
lifetimeHours?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,6 +98,7 @@ export class Feed {
|
|||||||
description: input.description,
|
description: input.description,
|
||||||
language: input.language,
|
language: input.language,
|
||||||
mailboxId: deps.mailboxId.value,
|
mailboxId: deps.mailboxId.value,
|
||||||
|
senderInTitle: input.senderInTitle,
|
||||||
allowedSenders: input.allowedSenders,
|
allowedSenders: input.allowedSenders,
|
||||||
blockedSenders: input.blockedSenders,
|
blockedSenders: input.blockedSenders,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
@@ -134,6 +138,11 @@ export class Feed {
|
|||||||
return this._state.language;
|
return this._state.language;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Whether entry titles render as `[Sender] Subject` in the feed output. */
|
||||||
|
get senderInTitle(): boolean {
|
||||||
|
return this._state.senderInTitle ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
/** The inbound mailbox (`noun.noun.NN`) — the feed's email address is `mailboxId@domain`. */
|
/** The inbound mailbox (`noun.noun.NN`) — the feed's email address is `mailboxId@domain`. */
|
||||||
get mailboxId(): MailboxId {
|
get mailboxId(): MailboxId {
|
||||||
return MailboxId.unchecked(this._state.mailboxId);
|
return MailboxId.unchecked(this._state.mailboxId);
|
||||||
@@ -341,6 +350,9 @@ export class Feed {
|
|||||||
this._state.description = patch.description;
|
this._state.description = patch.description;
|
||||||
}
|
}
|
||||||
if (patch.language !== undefined) this._state.language = patch.language;
|
if (patch.language !== undefined) this._state.language = patch.language;
|
||||||
|
if (patch.senderInTitle !== undefined) {
|
||||||
|
this._state.senderInTitle = patch.senderInTitle;
|
||||||
|
}
|
||||||
if (patch.allowedSenders !== undefined) {
|
if (patch.allowedSenders !== undefined) {
|
||||||
this._state.allowedSenders = patch.allowedSenders;
|
this._state.allowedSenders = patch.allowedSenders;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -147,6 +147,37 @@ describe("generateRssFeed", () => {
|
|||||||
expect(result).not.toContain("<item>");
|
expect(result).not.toContain("<item>");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("leaves the item title unprefixed by default", () => {
|
||||||
|
const result = generateRssFeed(
|
||||||
|
mockFeedConfig,
|
||||||
|
mockEmails,
|
||||||
|
BASE_URL,
|
||||||
|
FEED_ID,
|
||||||
|
);
|
||||||
|
expect(result).toContain("Hello World");
|
||||||
|
expect(result).not.toContain("[Alice]");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefixes the item title with the sender when sender_in_title is set", () => {
|
||||||
|
const result = generateRssFeed(
|
||||||
|
{ ...mockFeedConfig, sender_in_title: true },
|
||||||
|
mockEmails,
|
||||||
|
BASE_URL,
|
||||||
|
FEED_ID,
|
||||||
|
);
|
||||||
|
expect(result).toContain("[Alice] Hello World");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the email address when the sender has no display name", () => {
|
||||||
|
const result = generateRssFeed(
|
||||||
|
{ ...mockFeedConfig, sender_in_title: true },
|
||||||
|
[{ ...mockEmails[0], from: "bob@example.com" }],
|
||||||
|
BASE_URL,
|
||||||
|
FEED_ID,
|
||||||
|
);
|
||||||
|
expect(result).toContain("[bob@example.com] Hello World");
|
||||||
|
});
|
||||||
|
|
||||||
it("feed link points to the public read URL, never an admin path", () => {
|
it("feed link points to the public read URL, never an admin path", () => {
|
||||||
const result = generateRssFeed(
|
const result = generateRssFeed(
|
||||||
mockFeedConfig,
|
mockFeedConfig,
|
||||||
|
|||||||
@@ -74,8 +74,12 @@ function buildFeed(
|
|||||||
baseUrl,
|
baseUrl,
|
||||||
EmailAddress.parse(email.from)?.siteBaseUrl() ?? "",
|
EmailAddress.parse(email.from)?.siteBaseUrl() ?? "",
|
||||||
);
|
);
|
||||||
|
const subject = htmlToText(email.subject);
|
||||||
|
const title = feedConfig.sender_in_title
|
||||||
|
? `[${parseFromAddress(email.from).name ?? email.from}] ${subject}`
|
||||||
|
: subject;
|
||||||
feed.addItem({
|
feed.addItem({
|
||||||
title: htmlToText(email.subject),
|
title,
|
||||||
id: entryUrl,
|
id: entryUrl,
|
||||||
link: entryUrl,
|
link: entryUrl,
|
||||||
description: bodyContent,
|
description: bodyContent,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export function fromConfigDTO(dto: FeedConfig): FeedState {
|
|||||||
language: dto.language,
|
language: dto.language,
|
||||||
mailboxId: dto.mailbox_id,
|
mailboxId: dto.mailbox_id,
|
||||||
author: dto.author,
|
author: dto.author,
|
||||||
|
senderInTitle: dto.sender_in_title,
|
||||||
allowedSenders: dto.allowed_senders ?? [],
|
allowedSenders: dto.allowed_senders ?? [],
|
||||||
blockedSenders: dto.blocked_senders ?? [],
|
blockedSenders: dto.blocked_senders ?? [],
|
||||||
createdAt: dto.created_at,
|
createdAt: dto.created_at,
|
||||||
@@ -34,6 +35,7 @@ export function toConfigDTO(state: FeedState): FeedConfig {
|
|||||||
language: state.language,
|
language: state.language,
|
||||||
mailbox_id: state.mailboxId,
|
mailbox_id: state.mailboxId,
|
||||||
author: state.author,
|
author: state.author,
|
||||||
|
sender_in_title: state.senderInTitle,
|
||||||
allowed_senders: state.allowedSenders,
|
allowed_senders: state.allowedSenders,
|
||||||
blocked_senders: state.blockedSenders,
|
blocked_senders: state.blockedSenders,
|
||||||
created_at: state.createdAt,
|
created_at: state.createdAt,
|
||||||
|
|||||||
@@ -1472,4 +1472,104 @@ describe("Admin Routes", () => {
|
|||||||
expect(body).not.toContain("validator.w3.org/feed/images");
|
expect(body).not.toContain("validator.w3.org/feed/images");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Sender-in-title toggle", () => {
|
||||||
|
it("edit form renders the checkbox, unchecked by default", async () => {
|
||||||
|
const authCookie = await loginAndGetCookie();
|
||||||
|
const repo = FeedRepository.from(mockEnv as unknown as Env);
|
||||||
|
const feedId = FeedId.generate();
|
||||||
|
const feed = Feed.create(
|
||||||
|
feedId,
|
||||||
|
{
|
||||||
|
title: "Title Toggle Feed",
|
||||||
|
language: "en",
|
||||||
|
allowedSenders: [],
|
||||||
|
blockedSenders: [],
|
||||||
|
},
|
||||||
|
{ mailboxId: MailboxId.unchecked("title.toggle.01") },
|
||||||
|
);
|
||||||
|
await repo.save(feed);
|
||||||
|
|
||||||
|
const res = await request(`/admin/feeds/${feedId.value}/edit`, {
|
||||||
|
headers: { Cookie: authCookie },
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.text();
|
||||||
|
expect(body).toContain('name="sender_in_title"');
|
||||||
|
expect(body).toContain("Show sender in entry titles");
|
||||||
|
expect(body).not.toContain("checked");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists the toggle through edit and reflects it back as checked", async () => {
|
||||||
|
const authCookie = await loginAndGetCookie();
|
||||||
|
const repo = FeedRepository.from(mockEnv as unknown as Env);
|
||||||
|
const feedId = FeedId.generate();
|
||||||
|
const feed = Feed.create(
|
||||||
|
feedId,
|
||||||
|
{
|
||||||
|
title: "Title Toggle Feed",
|
||||||
|
language: "en",
|
||||||
|
allowedSenders: [],
|
||||||
|
blockedSenders: [],
|
||||||
|
},
|
||||||
|
{ mailboxId: MailboxId.unchecked("title.toggle.02") },
|
||||||
|
);
|
||||||
|
await repo.save(feed);
|
||||||
|
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("title", "Title Toggle Feed");
|
||||||
|
form.append("sender_in_title", "true");
|
||||||
|
const post = await request(`/admin/feeds/${feedId.value}/edit`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Cookie: authCookie,
|
||||||
|
Origin: "https://test.getmynews.app",
|
||||||
|
},
|
||||||
|
body: form,
|
||||||
|
});
|
||||||
|
expect(post.status).toBe(302);
|
||||||
|
|
||||||
|
const cfg = await repo.getConfig(feedId);
|
||||||
|
expect(cfg?.sender_in_title).toBe(true);
|
||||||
|
|
||||||
|
const editPage = await request(`/admin/feeds/${feedId.value}/edit`, {
|
||||||
|
headers: { Cookie: authCookie },
|
||||||
|
});
|
||||||
|
expect(await editPage.text()).toContain("checked");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears the toggle when the checkbox is omitted (unchecked)", async () => {
|
||||||
|
const authCookie = await loginAndGetCookie();
|
||||||
|
const repo = FeedRepository.from(mockEnv as unknown as Env);
|
||||||
|
const feedId = FeedId.generate();
|
||||||
|
const feed = Feed.create(
|
||||||
|
feedId,
|
||||||
|
{
|
||||||
|
title: "Title Toggle Feed",
|
||||||
|
language: "en",
|
||||||
|
senderInTitle: true,
|
||||||
|
allowedSenders: [],
|
||||||
|
blockedSenders: [],
|
||||||
|
},
|
||||||
|
{ mailboxId: MailboxId.unchecked("title.toggle.03") },
|
||||||
|
);
|
||||||
|
await repo.save(feed);
|
||||||
|
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("title", "Title Toggle Feed");
|
||||||
|
// No sender_in_title field ⇒ unchecked.
|
||||||
|
const post = await request(`/admin/feeds/${feedId.value}/edit`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Cookie: authCookie,
|
||||||
|
Origin: "https://test.getmynews.app",
|
||||||
|
},
|
||||||
|
body: form,
|
||||||
|
});
|
||||||
|
expect(post.status).toBe(302);
|
||||||
|
|
||||||
|
const cfg = await repo.getConfig(feedId);
|
||||||
|
expect(cfg?.sender_in_title).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -269,6 +269,23 @@ feedsRouter.get("/:feedId/edit", async (c) => {
|
|||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="sender_in_title"
|
||||||
|
value="true"
|
||||||
|
checked={feedConfig.sender_in_title ?? false}
|
||||||
|
disabled={isExpired}
|
||||||
|
/>
|
||||||
|
Show sender in entry titles
|
||||||
|
</label>
|
||||||
|
<small>
|
||||||
|
Render each entry's title as <code>[Sender] Subject</code> for
|
||||||
|
at-a-glance scanning in your reader.
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="lifetime_hours">Lifetime (hours)</label>
|
<label for="lifetime_hours">Lifetime (hours)</label>
|
||||||
<input
|
<input
|
||||||
@@ -322,6 +339,7 @@ feedsRouter.post("/:feedId/edit", async (c) => {
|
|||||||
const blockedSenders = parseAllowedSenders(
|
const blockedSenders = parseAllowedSenders(
|
||||||
formData.get("blocked_senders")?.toString() || "",
|
formData.get("blocked_senders")?.toString() || "",
|
||||||
);
|
);
|
||||||
|
const senderInTitle = formData.get("sender_in_title") === "true";
|
||||||
const lifetimeHoursRaw = formData.get("lifetime_hours")?.toString();
|
const lifetimeHoursRaw = formData.get("lifetime_hours")?.toString();
|
||||||
|
|
||||||
const parsedData = updateFeedSchema.parse({
|
const parsedData = updateFeedSchema.parse({
|
||||||
@@ -338,6 +356,7 @@ feedsRouter.post("/:feedId/edit", async (c) => {
|
|||||||
language: parsedData.language,
|
language: parsedData.language,
|
||||||
allowedSenders: parsedData.allowedSenders,
|
allowedSenders: parsedData.allowedSenders,
|
||||||
blockedSenders: parsedData.blockedSenders,
|
blockedSenders: parsedData.blockedSenders,
|
||||||
|
senderInTitle,
|
||||||
lifetimeHours: lifetimeHoursRaw
|
lifetimeHours: lifetimeHoursRaw
|
||||||
? parseInt(lifetimeHoursRaw, 10)
|
? parseInt(lifetimeHoursRaw, 10)
|
||||||
: undefined,
|
: undefined,
|
||||||
|
|||||||
@@ -143,6 +143,40 @@ describe("REST API (/api/v1)", () => {
|
|||||||
expect(afterList.feeds.map((f) => f.id)).not.toContain(created.id);
|
expect(afterList.feeds.map((f) => f.id)).not.toContain(created.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("defaults senderInTitle to false and lets it be set on create and update", async () => {
|
||||||
|
const createRes = await request("/api/v1/feeds", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { ...authHeaders, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ title: "Title Feed" }),
|
||||||
|
});
|
||||||
|
const created = (await createRes.json()) as {
|
||||||
|
id: string;
|
||||||
|
senderInTitle: boolean;
|
||||||
|
};
|
||||||
|
expect(created.senderInTitle).toBe(false);
|
||||||
|
|
||||||
|
const setRes = await request("/api/v1/feeds", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { ...authHeaders, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ title: "Prefixed Feed", senderInTitle: true }),
|
||||||
|
});
|
||||||
|
const set = (await setRes.json()) as {
|
||||||
|
id: string;
|
||||||
|
senderInTitle: boolean;
|
||||||
|
};
|
||||||
|
expect(set.senderInTitle).toBe(true);
|
||||||
|
|
||||||
|
const patchRes = await request(`/api/v1/feeds/${set.id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { ...authHeaders, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ senderInTitle: false }),
|
||||||
|
});
|
||||||
|
expect(patchRes.status).toBe(200);
|
||||||
|
expect(
|
||||||
|
(await patchRes.json()) as { senderInTitle: boolean },
|
||||||
|
).toMatchObject({ senderInTitle: false });
|
||||||
|
});
|
||||||
|
|
||||||
it("returns 400 for an invalid create body", async () => {
|
it("returns 400 for an invalid create body", async () => {
|
||||||
const res = await request("/api/v1/feeds", {
|
const res = await request("/api/v1/feeds", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ function toFeed(
|
|||||||
language: config.language,
|
language: config.language,
|
||||||
allowedSenders: config.allowed_senders ?? [],
|
allowedSenders: config.allowed_senders ?? [],
|
||||||
blockedSenders: config.blocked_senders ?? [],
|
blockedSenders: config.blocked_senders ?? [],
|
||||||
|
senderInTitle: config.sender_in_title ?? false,
|
||||||
createdAt: config.created_at,
|
createdAt: config.created_at,
|
||||||
updatedAt: config.updated_at,
|
updatedAt: config.updated_at,
|
||||||
expiresAt: config.expires_at,
|
expiresAt: config.expires_at,
|
||||||
@@ -152,6 +153,7 @@ apiApp.openapi(
|
|||||||
language: body.language,
|
language: body.language,
|
||||||
allowedSenders: normalizeSenders(body.allowedSenders) ?? [],
|
allowedSenders: normalizeSenders(body.allowedSenders) ?? [],
|
||||||
blockedSenders: normalizeSenders(body.blockedSenders) ?? [],
|
blockedSenders: normalizeSenders(body.blockedSenders) ?? [],
|
||||||
|
senderInTitle: body.senderInTitle,
|
||||||
lifetimeHours: body.lifetimeHours,
|
lifetimeHours: body.lifetimeHours,
|
||||||
});
|
});
|
||||||
return c.json(toFeed(feedId, config, 0, env), 201);
|
return c.json(toFeed(feedId, config, 0, env), 201);
|
||||||
@@ -217,6 +219,7 @@ apiApp.openapi(
|
|||||||
language: body.language,
|
language: body.language,
|
||||||
allowedSenders: normalizeSenders(body.allowedSenders),
|
allowedSenders: normalizeSenders(body.allowedSenders),
|
||||||
blockedSenders: normalizeSenders(body.blockedSenders),
|
blockedSenders: normalizeSenders(body.blockedSenders),
|
||||||
|
senderInTitle: body.senderInTitle,
|
||||||
lifetimeHours: body.lifetimeHours,
|
lifetimeHours: body.lifetimeHours,
|
||||||
});
|
});
|
||||||
if (result.status === "not_found")
|
if (result.status === "not_found")
|
||||||
|
|||||||
@@ -39,6 +39,10 @@ export const FeedCreateSchema = z
|
|||||||
language: z.string().optional().default("en"),
|
language: z.string().optional().default("en"),
|
||||||
allowedSenders: z.array(z.string()).optional().default([]),
|
allowedSenders: z.array(z.string()).optional().default([]),
|
||||||
blockedSenders: z.array(z.string()).optional().default([]),
|
blockedSenders: z.array(z.string()).optional().default([]),
|
||||||
|
senderInTitle: z.boolean().optional().openapi({
|
||||||
|
description:
|
||||||
|
"Render entry titles as `[Sender] Subject` in the feed output.",
|
||||||
|
}),
|
||||||
lifetimeHours: z.number().int().positive().optional().openapi({
|
lifetimeHours: z.number().int().positive().optional().openapi({
|
||||||
description:
|
description:
|
||||||
"Hours until the feed expires. Ignored when the server enforces a fixed FEED_TTL_HOURS.",
|
"Hours until the feed expires. Ignored when the server enforces a fixed FEED_TTL_HOURS.",
|
||||||
@@ -53,6 +57,10 @@ export const FeedUpdateSchema = z
|
|||||||
language: z.string().optional(),
|
language: z.string().optional(),
|
||||||
allowedSenders: z.array(z.string()).optional(),
|
allowedSenders: z.array(z.string()).optional(),
|
||||||
blockedSenders: z.array(z.string()).optional(),
|
blockedSenders: z.array(z.string()).optional(),
|
||||||
|
senderInTitle: z.boolean().optional().openapi({
|
||||||
|
description:
|
||||||
|
"Render entry titles as `[Sender] Subject` in the feed output.",
|
||||||
|
}),
|
||||||
lifetimeHours: z.number().int().positive().optional().openapi({
|
lifetimeHours: z.number().int().positive().optional().openapi({
|
||||||
description: "Reset the feed's lifetime to this many hours from now.",
|
description: "Reset the feed's lifetime to this many hours from now.",
|
||||||
}),
|
}),
|
||||||
@@ -83,6 +91,7 @@ export const FeedSchema = z
|
|||||||
language: z.string(),
|
language: z.string(),
|
||||||
allowedSenders: z.array(z.string()),
|
allowedSenders: z.array(z.string()),
|
||||||
blockedSenders: z.array(z.string()),
|
blockedSenders: z.array(z.string()),
|
||||||
|
senderInTitle: z.boolean(),
|
||||||
createdAt: z.number(),
|
createdAt: z.number(),
|
||||||
updatedAt: z.number().optional(),
|
updatedAt: z.number().optional(),
|
||||||
expiresAt: z.number().optional(),
|
expiresAt: z.number().optional(),
|
||||||
|
|||||||
@@ -295,6 +295,19 @@ label {
|
|||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.checkbox-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-label input[type="checkbox"] {
|
||||||
|
width: auto;
|
||||||
|
margin: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
input[type="text"],
|
input[type="text"],
|
||||||
input[type="email"],
|
input[type="email"],
|
||||||
input[type="search"],
|
input[type="search"],
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ export interface FeedConfig {
|
|||||||
blocked_senders?: string[];
|
blocked_senders?: string[];
|
||||||
language: string;
|
language: string;
|
||||||
author?: string;
|
author?: string;
|
||||||
|
// When true, entry titles in the feed output are rendered as `[Sender] Subject`.
|
||||||
|
sender_in_title?: boolean;
|
||||||
created_at: number;
|
created_at: number;
|
||||||
updated_at?: number;
|
updated_at?: number;
|
||||||
expires_at?: number; // Unix timestamp ms — present when a TTL is configured
|
expires_at?: number; // Unix timestamp ms — present when a TTL is configured
|
||||||
|
|||||||
Reference in New Issue
Block a user