mirror of
https://github.com/juherr/kill-the-news.git
synced 2026-06-20 22:03:48 +00:00
feat(api): add versioned REST API with OpenAPI 3.1 spec
Expose /api/v1/* for feed and email management (feeds CRUD, email list/get/delete, stats) so the service can be automated without scraping the admin UI. Built on @hono/zod-openapi; the OpenAPI 3.1 spec is served at /api/openapi.json with a Scalar reference at /api/docs. Auth is token-based (Authorization: Bearer <ADMIN_PASSWORD>) plus the existing reverse-proxy headers — no cookie, no CSRF. Extracted the auth primitives into src/lib/auth.ts and the feed create/update/delete orchestration into src/lib/feed-service.ts so the admin UI and the REST API share a single source of truth. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+14
-68
@@ -2,12 +2,14 @@ import { Context, Hono } from "hono";
|
||||
import { deleteCookie, getSignedCookie, setSignedCookie } from "hono/cookie";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod";
|
||||
import { Env, FeedConfig } from "../types";
|
||||
import { Env } from "../types";
|
||||
import { csrf } from "hono/csrf";
|
||||
import { ADMIN_COOKIE_MAX_AGE } from "../config/constants";
|
||||
import { logger } from "../lib/logger";
|
||||
import { timingSafeEqual, checkProxyAuth } from "../lib/auth";
|
||||
import { Layout, clampText } from "./admin/ui";
|
||||
import { listAllFeeds, updateFeedInList } from "./admin/helpers";
|
||||
import { listAllFeeds } from "./admin/helpers";
|
||||
import { updateFeedRecord } from "../lib/feed-service";
|
||||
import { feedRssUrl, feedAtomUrl, feedEmailAddress } from "../utils/urls";
|
||||
import { feedsRouter } from "./admin/feeds";
|
||||
import { emailsRouter } from "./admin/emails";
|
||||
@@ -37,27 +39,6 @@ app.use("*", async (c, next) => {
|
||||
await next();
|
||||
});
|
||||
|
||||
function timingSafeEqual(a: string, b: string): boolean {
|
||||
const enc = new TextEncoder();
|
||||
const aBytes = enc.encode(a);
|
||||
const bBytes = enc.encode(b);
|
||||
// Try native timing-safe implementation first (Cloudflare Workers runtime)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const subtle = crypto.subtle as any;
|
||||
if (typeof subtle.timingSafeEqual === "function") {
|
||||
if (aBytes.length !== bBytes.length) return false;
|
||||
return subtle.timingSafeEqual(aBytes, bBytes);
|
||||
}
|
||||
// Constant-time fallback for Node (test environment): encode length
|
||||
// mismatch into `diff` so the loop always runs over the full length.
|
||||
const len = Math.max(aBytes.length, bBytes.length);
|
||||
let diff = aBytes.length ^ bBytes.length;
|
||||
for (let i = 0; i < len; i++) {
|
||||
diff |= (aBytes[i] ?? 0) ^ (bBytes[i] ?? 0);
|
||||
}
|
||||
return diff === 0;
|
||||
}
|
||||
|
||||
// Authentication middleware for admin routes
|
||||
async function authMiddleware(c: Context, next: () => Promise<void>) {
|
||||
const env = c.env;
|
||||
@@ -69,22 +50,8 @@ async function authMiddleware(c: Context, next: () => Promise<void>) {
|
||||
}
|
||||
|
||||
// Proxy auth: only active when both env vars are present
|
||||
if (env.PROXY_AUTH_SECRET && env.PROXY_TRUSTED_IPS) {
|
||||
const trustedIps = env.PROXY_TRUSTED_IPS.split(",")
|
||||
.map((s: string) => s.trim())
|
||||
.filter(Boolean);
|
||||
const clientIp = c.req.header("CF-Connecting-IP") ?? "";
|
||||
const providedSecret = c.req.header("X-Auth-Proxy-Secret") ?? "";
|
||||
const remoteUser =
|
||||
c.req.header("Remote-User") || c.req.header("X-Forwarded-User") || "";
|
||||
|
||||
if (
|
||||
trustedIps.includes(clientIp) &&
|
||||
timingSafeEqual(providedSecret, env.PROXY_AUTH_SECRET) &&
|
||||
remoteUser.length > 0
|
||||
) {
|
||||
return next();
|
||||
}
|
||||
if (checkProxyAuth(c, env)) {
|
||||
return next();
|
||||
}
|
||||
|
||||
// Fallback: signed cookie
|
||||
@@ -1020,45 +987,24 @@ app.post(
|
||||
},
|
||||
),
|
||||
async (c) => {
|
||||
// Type assertion for environment variables
|
||||
const env = c.env;
|
||||
const emailStorage = env.EMAIL_STORAGE;
|
||||
const feedId = c.req.param("feedId");
|
||||
|
||||
try {
|
||||
const { title, description } = c.req.valid("json");
|
||||
const parsedData = { title, description, language: "en" as const };
|
||||
|
||||
// Get existing feed config
|
||||
const feedConfigKey = `feed:${feedId}:config`;
|
||||
const existingConfig = (await emailStorage.get(feedConfigKey, {
|
||||
type: "json",
|
||||
})) as FeedConfig | null;
|
||||
// In-place edit: only title/description, expiry untouched.
|
||||
const result = await updateFeedRecord(
|
||||
env,
|
||||
feedId,
|
||||
{ title, description },
|
||||
{ inPlace: true },
|
||||
);
|
||||
|
||||
if (!existingConfig) {
|
||||
if (result.status === "not_found") {
|
||||
return c.json({ error: "Feed not found" }, 404);
|
||||
}
|
||||
|
||||
// Update feed configuration
|
||||
await emailStorage.put(
|
||||
feedConfigKey,
|
||||
JSON.stringify({
|
||||
...existingConfig,
|
||||
title: parsedData.title,
|
||||
description: parsedData.description,
|
||||
updated_at: Date.now(),
|
||||
}),
|
||||
);
|
||||
|
||||
// Update feed in the list of all feeds
|
||||
await updateFeedInList(
|
||||
emailStorage,
|
||||
feedId,
|
||||
parsedData.title,
|
||||
parsedData.description,
|
||||
);
|
||||
|
||||
// Return success response
|
||||
return c.json({ success: true });
|
||||
} catch (error) {
|
||||
logger.error("Error updating feed via API", { error: String(error) });
|
||||
|
||||
+28
-160
@@ -1,7 +1,6 @@
|
||||
import { Hono } from "hono";
|
||||
import { z } from "zod";
|
||||
import { Env, FeedConfig, FeedMetadata } from "../../types";
|
||||
import { generateFeedId } from "../../utils/id-generator";
|
||||
import { Env, FeedConfig } from "../../types";
|
||||
import { bumpCounters } from "../../utils/stats";
|
||||
import { waitUntilSafe } from "../../utils/worker";
|
||||
import { feedRssUrl, feedEmailAddress } from "../../utils/urls";
|
||||
@@ -10,13 +9,16 @@ import { sendUnsubscribes } from "../../utils/unsubscribe";
|
||||
import { getAttachmentBucket } from "../../utils/attachments";
|
||||
import { Layout } from "./ui";
|
||||
import {
|
||||
addFeedToList,
|
||||
updateFeedInList,
|
||||
removeFeedFromList,
|
||||
removeFeedsFromListBulk,
|
||||
purgeFeedKeysStep,
|
||||
collectUnsubscribeUrls,
|
||||
} from "./helpers";
|
||||
import {
|
||||
createFeedRecord,
|
||||
updateFeedRecord,
|
||||
deleteFeedRecord,
|
||||
deleteFeedFastDetailed,
|
||||
} from "../../lib/feed-service";
|
||||
|
||||
type AppEnv = { Bindings: Env };
|
||||
|
||||
@@ -56,56 +58,10 @@ const senderFilterSchema = z.object({
|
||||
value: z.string().min(1),
|
||||
});
|
||||
|
||||
// ── Delete helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
type DeleteFeedFastResult = {
|
||||
ok: boolean;
|
||||
configDeleted: boolean;
|
||||
metadataDeleted: boolean;
|
||||
errors: string[];
|
||||
};
|
||||
|
||||
async function deleteFeedFastDetailed(
|
||||
emailStorage: KVNamespace,
|
||||
feedId: string,
|
||||
): Promise<DeleteFeedFastResult> {
|
||||
const feedConfigKey = `feed:${feedId}:config`;
|
||||
const feedMetadataKey = `feed:${feedId}:metadata`;
|
||||
|
||||
const errors: string[] = [];
|
||||
let configDeleted = false;
|
||||
let metadataDeleted = false;
|
||||
|
||||
try {
|
||||
await emailStorage.delete(feedConfigKey);
|
||||
configDeleted = true;
|
||||
} catch (error) {
|
||||
errors.push(`config delete failed: ${String(error)}`);
|
||||
}
|
||||
|
||||
try {
|
||||
await emailStorage.delete(feedMetadataKey);
|
||||
metadataDeleted = true;
|
||||
} catch (error) {
|
||||
errors.push(`metadata delete failed: ${String(error)}`);
|
||||
}
|
||||
|
||||
return { ok: configDeleted, configDeleted, metadataDeleted, errors };
|
||||
}
|
||||
|
||||
async function deleteFeedFast(
|
||||
emailStorage: KVNamespace,
|
||||
feedId: string,
|
||||
): Promise<boolean> {
|
||||
const result = await deleteFeedFastDetailed(emailStorage, feedId);
|
||||
return result.ok;
|
||||
}
|
||||
|
||||
// ── Routes ────────────────────────────────────────────────────────────────────
|
||||
|
||||
feedsRouter.post("/create", async (c) => {
|
||||
const env = c.env;
|
||||
const emailStorage = env.EMAIL_STORAGE;
|
||||
const isJson =
|
||||
c.req.header("Content-Type")?.includes("application/json") ?? false;
|
||||
|
||||
@@ -160,48 +116,17 @@ feedsRouter.post("/create", async (c) => {
|
||||
blockedSenders,
|
||||
});
|
||||
|
||||
// FEED_TTL_HOURS overrides any client-submitted value
|
||||
const resolvedHours = env.FEED_TTL_HOURS
|
||||
? parseInt(env.FEED_TTL_HOURS, 10)
|
||||
: lifetimeHoursRaw
|
||||
? parseInt(lifetimeHoursRaw, 10)
|
||||
: NaN;
|
||||
const expiresAt =
|
||||
Number.isFinite(resolvedHours) && resolvedHours > 0
|
||||
? Date.now() + resolvedHours * 3_600_000
|
||||
: undefined;
|
||||
const lifetimeHours = lifetimeHoursRaw
|
||||
? parseInt(lifetimeHoursRaw, 10)
|
||||
: undefined;
|
||||
|
||||
const feedId = generateFeedId();
|
||||
|
||||
const feedConfig: FeedConfig = {
|
||||
const { feedId } = await createFeedRecord(env, {
|
||||
title: parsedData.title,
|
||||
description: parsedData.description,
|
||||
language: parsedData.language,
|
||||
allowed_senders: parsedData.allowedSenders,
|
||||
blocked_senders: parsedData.blockedSenders,
|
||||
created_at: Date.now(),
|
||||
updated_at: Date.now(),
|
||||
...(expiresAt !== undefined ? { expires_at: expiresAt } : {}),
|
||||
};
|
||||
|
||||
const feedMetadata: FeedMetadata = { emails: [] };
|
||||
|
||||
await Promise.all([
|
||||
emailStorage.put(`feed:${feedId}:config`, JSON.stringify(feedConfig)),
|
||||
emailStorage.put(`feed:${feedId}:metadata`, JSON.stringify(feedMetadata)),
|
||||
]);
|
||||
|
||||
await addFeedToList(
|
||||
emailStorage,
|
||||
feedId,
|
||||
parsedData.title,
|
||||
parsedData.description,
|
||||
expiresAt,
|
||||
);
|
||||
|
||||
await bumpCounters(emailStorage, {
|
||||
feeds_created: 1,
|
||||
last_feed_created_at: new Date().toISOString(),
|
||||
allowedSenders: parsedData.allowedSenders,
|
||||
blockedSenders: parsedData.blockedSenders,
|
||||
lifetimeHours,
|
||||
});
|
||||
|
||||
if (isJson) {
|
||||
@@ -387,7 +312,6 @@ feedsRouter.get("/:feedId/edit", async (c) => {
|
||||
|
||||
feedsRouter.post("/:feedId/edit", async (c) => {
|
||||
const env = c.env;
|
||||
const emailStorage = env.EMAIL_STORAGE;
|
||||
const feedId = c.req.param("feedId");
|
||||
|
||||
try {
|
||||
@@ -411,60 +335,23 @@ feedsRouter.post("/:feedId/edit", async (c) => {
|
||||
blockedSenders,
|
||||
});
|
||||
|
||||
const feedConfigKey = `feed:${feedId}:config`;
|
||||
const existingConfig = (await emailStorage.get(feedConfigKey, {
|
||||
type: "json",
|
||||
})) as FeedConfig | null;
|
||||
|
||||
if (!existingConfig) {
|
||||
return c.text("Feed not found", 404);
|
||||
}
|
||||
|
||||
// Expired feeds cannot be edited
|
||||
if (
|
||||
existingConfig.expires_at !== undefined &&
|
||||
existingConfig.expires_at <= Date.now()
|
||||
) {
|
||||
return c.text("Feed has expired and cannot be modified.", 403);
|
||||
}
|
||||
|
||||
// Resolve new expires_at:
|
||||
// - FEED_TTL_HOURS set: always recompute from env (reset TTL from now)
|
||||
// - Field submitted: set new expiry from now
|
||||
// - Field empty: preserve existing expires_at (no silent removal)
|
||||
let newExpiresAt: number | undefined;
|
||||
if (env.FEED_TTL_HOURS) {
|
||||
const h = parseInt(env.FEED_TTL_HOURS, 10);
|
||||
newExpiresAt =
|
||||
Number.isFinite(h) && h > 0 ? Date.now() + h * 3_600_000 : undefined;
|
||||
} else if (lifetimeHoursRaw) {
|
||||
const h = parseInt(lifetimeHoursRaw, 10);
|
||||
newExpiresAt =
|
||||
Number.isFinite(h) && h > 0 ? Date.now() + h * 3_600_000 : undefined;
|
||||
} else {
|
||||
newExpiresAt = existingConfig.expires_at;
|
||||
}
|
||||
|
||||
const updatedConfig: FeedConfig = {
|
||||
...existingConfig,
|
||||
const result = await updateFeedRecord(env, feedId, {
|
||||
title: parsedData.title,
|
||||
description: parsedData.description,
|
||||
language: parsedData.language,
|
||||
allowed_senders: parsedData.allowedSenders,
|
||||
blocked_senders: parsedData.blockedSenders,
|
||||
updated_at: Date.now(),
|
||||
expires_at: newExpiresAt,
|
||||
};
|
||||
allowedSenders: parsedData.allowedSenders,
|
||||
blockedSenders: parsedData.blockedSenders,
|
||||
lifetimeHours: lifetimeHoursRaw
|
||||
? parseInt(lifetimeHoursRaw, 10)
|
||||
: undefined,
|
||||
});
|
||||
|
||||
await emailStorage.put(feedConfigKey, JSON.stringify(updatedConfig));
|
||||
|
||||
await updateFeedInList(
|
||||
emailStorage,
|
||||
feedId,
|
||||
parsedData.title,
|
||||
parsedData.description,
|
||||
newExpiresAt,
|
||||
);
|
||||
if (result.status === "not_found") {
|
||||
return c.text("Feed not found", 404);
|
||||
}
|
||||
if (result.status === "expired") {
|
||||
return c.text("Feed has expired and cannot be modified.", 403);
|
||||
}
|
||||
|
||||
return c.redirect("/admin");
|
||||
} catch (error) {
|
||||
@@ -534,31 +421,12 @@ feedsRouter.post("/:feedId/sender-filter", async (c) => {
|
||||
|
||||
feedsRouter.post("/:feedId/delete", async (c) => {
|
||||
const env = c.env;
|
||||
const emailStorage = env.EMAIL_STORAGE;
|
||||
const feedId = c.req.param("feedId");
|
||||
const view = c.req.query("view") === "table" ? "table" : "list";
|
||||
const wantsJson = (c.req.header("Accept") || "").includes("application/json");
|
||||
|
||||
try {
|
||||
// Read unsubscribe URLs before the metadata is deleted below.
|
||||
const unsubscribeUrls = await collectUnsubscribeUrls(emailStorage, feedId);
|
||||
|
||||
await deleteFeedFast(emailStorage, feedId);
|
||||
const removed = await removeFeedFromList(emailStorage, feedId);
|
||||
if (removed) {
|
||||
await bumpCounters(emailStorage, { feeds_deleted: 1 });
|
||||
}
|
||||
|
||||
if (unsubscribeUrls.length > 0) {
|
||||
waitUntilSafe(c, sendUnsubscribes(unsubscribeUrls, env));
|
||||
}
|
||||
|
||||
waitUntilSafe(
|
||||
c,
|
||||
purgeFeedKeysStep(emailStorage, feedId, {
|
||||
bucket: getAttachmentBucket(env),
|
||||
}),
|
||||
);
|
||||
await deleteFeedRecord(c, env, feedId);
|
||||
|
||||
if (wantsJson) {
|
||||
return c.json({ ok: true, feedId });
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
import { apiApp } from "./index";
|
||||
import { createMockEnv } from "../../test/setup";
|
||||
import { Env } from "../../types";
|
||||
|
||||
const PASSWORD = "test-password";
|
||||
const authHeaders = { Authorization: `Bearer ${PASSWORD}` };
|
||||
|
||||
describe("REST API (/api/v1)", () => {
|
||||
let testApp: Hono;
|
||||
let mockEnv: Env;
|
||||
let request: (path: string, init?: RequestInit) => Promise<Response>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockEnv = createMockEnv() as unknown as Env;
|
||||
testApp = new Hono();
|
||||
testApp.route("/api", apiApp);
|
||||
request = (path, init = {}) =>
|
||||
Promise.resolve(testApp.request(path, init, mockEnv));
|
||||
});
|
||||
|
||||
async function createFeed(title = "Test Feed"): Promise<string> {
|
||||
const res = await request("/api/v1/feeds", {
|
||||
method: "POST",
|
||||
headers: { ...authHeaders, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ title }),
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
const body = (await res.json()) as { id: string };
|
||||
return body.id;
|
||||
}
|
||||
|
||||
describe("Authentication", () => {
|
||||
it("rejects requests without a token", async () => {
|
||||
const res = await request("/api/v1/feeds");
|
||||
expect(res.status).toBe(401);
|
||||
expect((await res.json()) as { error: string }).toEqual({
|
||||
error: "Unauthorized",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects requests with a wrong token", async () => {
|
||||
const res = await request("/api/v1/feeds", {
|
||||
headers: { Authorization: "Bearer nope" },
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("accepts a valid Bearer token", async () => {
|
||||
const res = await request("/api/v1/feeds", { headers: authHeaders });
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("accepts proxy auth headers", async () => {
|
||||
const proxyApp = new Hono();
|
||||
proxyApp.route("/api", apiApp);
|
||||
const proxyEnv = {
|
||||
...createMockEnv(),
|
||||
PROXY_TRUSTED_IPS: "10.0.0.1",
|
||||
PROXY_AUTH_SECRET: "proxy-secret",
|
||||
} as unknown as Env;
|
||||
const res = await proxyApp.request(
|
||||
"/api/v1/feeds",
|
||||
{
|
||||
headers: {
|
||||
"CF-Connecting-IP": "10.0.0.1",
|
||||
"X-Auth-Proxy-Secret": "proxy-secret",
|
||||
"Remote-User": "alice",
|
||||
},
|
||||
},
|
||||
proxyEnv,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Feeds CRUD", () => {
|
||||
it("creates, reads, lists, updates and deletes a feed", async () => {
|
||||
// Create
|
||||
const createRes = await request("/api/v1/feeds", {
|
||||
method: "POST",
|
||||
headers: { ...authHeaders, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
title: "Daily Digest",
|
||||
description: "news",
|
||||
allowedSenders: ["News@Example.com"],
|
||||
}),
|
||||
});
|
||||
expect(createRes.status).toBe(201);
|
||||
const created = (await createRes.json()) as {
|
||||
id: string;
|
||||
title: string;
|
||||
allowedSenders: string[];
|
||||
emailAddress: string;
|
||||
rssUrl: string;
|
||||
atomUrl: string;
|
||||
emailCount: number;
|
||||
};
|
||||
expect(created.title).toBe("Daily Digest");
|
||||
// senders are normalized to lowercase
|
||||
expect(created.allowedSenders).toEqual(["news@example.com"]);
|
||||
expect(created.emailCount).toBe(0);
|
||||
expect(created.rssUrl).toContain(`/rss/${created.id}`);
|
||||
|
||||
// Get
|
||||
const getRes = await request(`/api/v1/feeds/${created.id}`, {
|
||||
headers: authHeaders,
|
||||
});
|
||||
expect(getRes.status).toBe(200);
|
||||
expect((await getRes.json()) as { id: string }).toMatchObject({
|
||||
id: created.id,
|
||||
title: "Daily Digest",
|
||||
});
|
||||
|
||||
// List
|
||||
const listRes = await request("/api/v1/feeds", { headers: authHeaders });
|
||||
const list = (await listRes.json()) as { feeds: { id: string }[] };
|
||||
expect(list.feeds.map((f) => f.id)).toContain(created.id);
|
||||
|
||||
// Update
|
||||
const patchRes = await request(`/api/v1/feeds/${created.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { ...authHeaders, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ title: "Renamed" }),
|
||||
});
|
||||
expect(patchRes.status).toBe(200);
|
||||
expect((await patchRes.json()) as { title: string }).toMatchObject({
|
||||
title: "Renamed",
|
||||
});
|
||||
|
||||
// Delete
|
||||
const delRes = await request(`/api/v1/feeds/${created.id}`, {
|
||||
method: "DELETE",
|
||||
headers: authHeaders,
|
||||
});
|
||||
expect(delRes.status).toBe(200);
|
||||
expect((await delRes.json()) as { ok: boolean }).toEqual({ ok: true });
|
||||
|
||||
// Gone from the list
|
||||
const after = await request("/api/v1/feeds", { headers: authHeaders });
|
||||
const afterList = (await after.json()) as { feeds: { id: string }[] };
|
||||
expect(afterList.feeds.map((f) => f.id)).not.toContain(created.id);
|
||||
});
|
||||
|
||||
it("returns 400 for an invalid create body", async () => {
|
||||
const res = await request("/api/v1/feeds", {
|
||||
method: "POST",
|
||||
headers: { ...authHeaders, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ title: "" }),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
expect((await res.json()) as { error: string }).toHaveProperty("error");
|
||||
});
|
||||
|
||||
it("returns 404 when getting a missing feed", async () => {
|
||||
const res = await request("/api/v1/feeds/does-not-exist", {
|
||||
headers: authHeaders,
|
||||
});
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 404 when deleting a missing feed", async () => {
|
||||
const res = await request("/api/v1/feeds/does-not-exist", {
|
||||
method: "DELETE",
|
||||
headers: authHeaders,
|
||||
});
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 404 when updating a missing feed", async () => {
|
||||
const res = await request("/api/v1/feeds/does-not-exist", {
|
||||
method: "PATCH",
|
||||
headers: { ...authHeaders, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ title: "x" }),
|
||||
});
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Emails", () => {
|
||||
it("lists, reads and deletes an email", async () => {
|
||||
const feedId = await createFeed();
|
||||
|
||||
// Seed an email directly into KV (mirrors storeEmail's key shape).
|
||||
const receivedAt = 1737000000000;
|
||||
const key = `feed:${feedId}:email:${receivedAt}`;
|
||||
await mockEnv.EMAIL_STORAGE.put(
|
||||
key,
|
||||
JSON.stringify({
|
||||
subject: "Hello",
|
||||
from: "news@example.com",
|
||||
content: "<p>hi</p>",
|
||||
receivedAt,
|
||||
headers: {},
|
||||
}),
|
||||
);
|
||||
await mockEnv.EMAIL_STORAGE.put(
|
||||
`feed:${feedId}:metadata`,
|
||||
JSON.stringify({
|
||||
emails: [{ key, subject: "Hello", receivedAt }],
|
||||
}),
|
||||
);
|
||||
|
||||
// List
|
||||
const listRes = await request(`/api/v1/feeds/${feedId}/emails`, {
|
||||
headers: authHeaders,
|
||||
});
|
||||
expect(listRes.status).toBe(200);
|
||||
const list = (await listRes.json()) as {
|
||||
emails: { entryId: number; subject: string }[];
|
||||
};
|
||||
expect(list.emails).toHaveLength(1);
|
||||
expect(list.emails[0]).toMatchObject({
|
||||
entryId: receivedAt,
|
||||
subject: "Hello",
|
||||
});
|
||||
|
||||
// Get single
|
||||
const getRes = await request(
|
||||
`/api/v1/feeds/${feedId}/emails/${receivedAt}`,
|
||||
{ headers: authHeaders },
|
||||
);
|
||||
expect(getRes.status).toBe(200);
|
||||
expect((await getRes.json()) as { content: string }).toMatchObject({
|
||||
from: "news@example.com",
|
||||
content: "<p>hi</p>",
|
||||
});
|
||||
|
||||
// Delete
|
||||
const delRes = await request(
|
||||
`/api/v1/feeds/${feedId}/emails/${receivedAt}`,
|
||||
{ method: "DELETE", headers: authHeaders },
|
||||
);
|
||||
expect(delRes.status).toBe(200);
|
||||
expect(await mockEnv.EMAIL_STORAGE.get(key)).toBeNull();
|
||||
|
||||
// Gone
|
||||
const after = await request(
|
||||
`/api/v1/feeds/${feedId}/emails/${receivedAt}`,
|
||||
{ headers: authHeaders },
|
||||
);
|
||||
expect(after.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 404 listing emails for a missing feed", async () => {
|
||||
const res = await request("/api/v1/feeds/missing/emails", {
|
||||
headers: authHeaders,
|
||||
});
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Stats", () => {
|
||||
it("returns monitoring counters", async () => {
|
||||
await createFeed();
|
||||
const res = await request("/api/v1/stats", { headers: authHeaders });
|
||||
expect(res.status).toBe(200);
|
||||
const stats = (await res.json()) as {
|
||||
feeds_created: number;
|
||||
active_feeds: number;
|
||||
attachments_enabled: boolean;
|
||||
};
|
||||
expect(stats.feeds_created).toBeGreaterThanOrEqual(1);
|
||||
expect(stats.active_feeds).toBeGreaterThanOrEqual(1);
|
||||
expect(typeof stats.attachments_enabled).toBe("boolean");
|
||||
});
|
||||
});
|
||||
|
||||
describe("OpenAPI document", () => {
|
||||
it("serves a public OpenAPI 3.1 spec", async () => {
|
||||
const res = await request("/api/openapi.json");
|
||||
expect(res.status).toBe(200);
|
||||
const doc = (await res.json()) as {
|
||||
openapi: string;
|
||||
paths: Record<string, unknown>;
|
||||
};
|
||||
expect(doc.openapi).toBe("3.1.0");
|
||||
expect(doc.paths).toHaveProperty("/v1/feeds");
|
||||
expect(doc.paths).toHaveProperty("/v1/feeds/{feedId}");
|
||||
expect(doc.paths).toHaveProperty("/v1/stats");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,397 @@
|
||||
import { OpenAPIHono, createRoute, z } from "@hono/zod-openapi";
|
||||
import { Scalar } from "@scalar/hono-api-reference";
|
||||
import { Env, FeedConfig } from "../../types";
|
||||
import { apiAuthMiddleware } from "../../lib/auth";
|
||||
import {
|
||||
createFeedRecord,
|
||||
updateFeedRecord,
|
||||
deleteFeedRecord,
|
||||
} from "../../lib/feed-service";
|
||||
import { listAllFeeds, deleteAttachmentsForEmails } from "../admin/helpers";
|
||||
import {
|
||||
getFeedConfig,
|
||||
getFeedMetadata,
|
||||
getEmailData,
|
||||
} from "../../utils/storage";
|
||||
import { getStats } from "../../utils/stats";
|
||||
import { feedEmailAddress, feedRssUrl, feedAtomUrl } from "../../utils/urls";
|
||||
import {
|
||||
ErrorSchema,
|
||||
FeedIdParam,
|
||||
EntryIdParam,
|
||||
FeedCreateSchema,
|
||||
FeedUpdateSchema,
|
||||
FeedSchema,
|
||||
FeedListSchema,
|
||||
EmailListSchema,
|
||||
EmailSchema,
|
||||
StatsSchema,
|
||||
} from "./schemas";
|
||||
|
||||
type AppEnv = { Bindings: Env };
|
||||
|
||||
const OkSchema = z.object({ ok: z.boolean() }).openapi("Ok");
|
||||
|
||||
const jsonContent = <T>(schema: T, description: string) => ({
|
||||
content: { "application/json": { schema } },
|
||||
description,
|
||||
});
|
||||
|
||||
const bearer = [{ bearerAuth: [] }];
|
||||
|
||||
function normalizeSenders(senders?: string[]): string[] | undefined {
|
||||
return senders?.map((s) => s.trim().toLowerCase()).filter(Boolean);
|
||||
}
|
||||
|
||||
function toFeed(
|
||||
id: string,
|
||||
config: FeedConfig,
|
||||
emailCount: number,
|
||||
env: Env,
|
||||
): z.infer<typeof FeedSchema> {
|
||||
return {
|
||||
id,
|
||||
title: config.title,
|
||||
description: config.description,
|
||||
language: config.language,
|
||||
allowedSenders: config.allowed_senders ?? [],
|
||||
blockedSenders: config.blocked_senders ?? [],
|
||||
createdAt: config.created_at,
|
||||
updatedAt: config.updated_at,
|
||||
expiresAt: config.expires_at,
|
||||
emailCount,
|
||||
emailAddress: feedEmailAddress(id, env),
|
||||
rssUrl: feedRssUrl(id, env),
|
||||
atomUrl: feedAtomUrl(id, env),
|
||||
};
|
||||
}
|
||||
|
||||
export const apiApp = new OpenAPIHono<AppEnv>({
|
||||
defaultHook: (result, c) => {
|
||||
if (!result.success) {
|
||||
const message = result.error.issues
|
||||
.map((i) => `${i.path.join(".")}: ${i.message}`)
|
||||
.join("; ");
|
||||
return c.json({ error: `Validation failed: ${message}` }, 400);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Token auth on every /v1 route. The spec + docs stay public.
|
||||
apiApp.use("/v1/*", apiAuthMiddleware);
|
||||
|
||||
apiApp.openAPIRegistry.registerComponent("securitySchemes", "bearerAuth", {
|
||||
type: "http",
|
||||
scheme: "bearer",
|
||||
description: "Use the admin password as the bearer token.",
|
||||
});
|
||||
|
||||
// ── Feeds ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
apiApp.openapi(
|
||||
createRoute({
|
||||
method: "get",
|
||||
path: "/v1/feeds",
|
||||
tags: ["Feeds"],
|
||||
summary: "List all feeds",
|
||||
security: bearer,
|
||||
responses: {
|
||||
200: jsonContent(FeedListSchema, "The list of feeds"),
|
||||
401: jsonContent(ErrorSchema, "Unauthorized"),
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const env = c.env;
|
||||
const feeds = await listAllFeeds(env.EMAIL_STORAGE);
|
||||
return c.json(
|
||||
{
|
||||
feeds: feeds.map((f) => ({
|
||||
id: f.id,
|
||||
title: f.title,
|
||||
description: f.description,
|
||||
expiresAt: f.expires_at,
|
||||
emailAddress: feedEmailAddress(f.id, env),
|
||||
rssUrl: feedRssUrl(f.id, env),
|
||||
atomUrl: feedAtomUrl(f.id, env),
|
||||
})),
|
||||
},
|
||||
200,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
apiApp.openapi(
|
||||
createRoute({
|
||||
method: "post",
|
||||
path: "/v1/feeds",
|
||||
tags: ["Feeds"],
|
||||
summary: "Create a feed",
|
||||
security: bearer,
|
||||
request: {
|
||||
body: { content: { "application/json": { schema: FeedCreateSchema } } },
|
||||
},
|
||||
responses: {
|
||||
201: jsonContent(FeedSchema, "The created feed"),
|
||||
400: jsonContent(ErrorSchema, "Invalid request body"),
|
||||
401: jsonContent(ErrorSchema, "Unauthorized"),
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const env = c.env;
|
||||
const body = c.req.valid("json");
|
||||
const { feedId, config } = await createFeedRecord(env, {
|
||||
title: body.title,
|
||||
description: body.description,
|
||||
language: body.language,
|
||||
allowedSenders: normalizeSenders(body.allowedSenders) ?? [],
|
||||
blockedSenders: normalizeSenders(body.blockedSenders) ?? [],
|
||||
lifetimeHours: body.lifetimeHours,
|
||||
});
|
||||
return c.json(toFeed(feedId, config, 0, env), 201);
|
||||
},
|
||||
);
|
||||
|
||||
apiApp.openapi(
|
||||
createRoute({
|
||||
method: "get",
|
||||
path: "/v1/feeds/{feedId}",
|
||||
tags: ["Feeds"],
|
||||
summary: "Get a feed",
|
||||
security: bearer,
|
||||
request: { params: FeedIdParam },
|
||||
responses: {
|
||||
200: jsonContent(FeedSchema, "The feed"),
|
||||
401: jsonContent(ErrorSchema, "Unauthorized"),
|
||||
404: jsonContent(ErrorSchema, "Feed not found"),
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const env = c.env;
|
||||
const { feedId } = c.req.valid("param");
|
||||
const config = await getFeedConfig(env.EMAIL_STORAGE, feedId);
|
||||
if (!config) return c.json({ error: "Feed not found" }, 404);
|
||||
const metadata = await getFeedMetadata(env.EMAIL_STORAGE, feedId);
|
||||
return c.json(
|
||||
toFeed(feedId, config, metadata?.emails.length ?? 0, env),
|
||||
200,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
apiApp.openapi(
|
||||
createRoute({
|
||||
method: "patch",
|
||||
path: "/v1/feeds/{feedId}",
|
||||
tags: ["Feeds"],
|
||||
summary: "Update a feed",
|
||||
security: bearer,
|
||||
request: {
|
||||
params: FeedIdParam,
|
||||
body: { content: { "application/json": { schema: FeedUpdateSchema } } },
|
||||
},
|
||||
responses: {
|
||||
200: jsonContent(FeedSchema, "The updated feed"),
|
||||
400: jsonContent(ErrorSchema, "Invalid request body"),
|
||||
401: jsonContent(ErrorSchema, "Unauthorized"),
|
||||
404: jsonContent(ErrorSchema, "Feed not found"),
|
||||
409: jsonContent(ErrorSchema, "Feed has expired"),
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const env = c.env;
|
||||
const { feedId } = c.req.valid("param");
|
||||
const body = c.req.valid("json");
|
||||
const result = await updateFeedRecord(env, feedId, {
|
||||
title: body.title,
|
||||
description: body.description,
|
||||
language: body.language,
|
||||
allowedSenders: normalizeSenders(body.allowedSenders),
|
||||
blockedSenders: normalizeSenders(body.blockedSenders),
|
||||
lifetimeHours: body.lifetimeHours,
|
||||
});
|
||||
if (result.status === "not_found")
|
||||
return c.json({ error: "Feed not found" }, 404);
|
||||
if (result.status === "expired")
|
||||
return c.json({ error: "Feed has expired and cannot be modified" }, 409);
|
||||
const metadata = await getFeedMetadata(env.EMAIL_STORAGE, feedId);
|
||||
return c.json(
|
||||
toFeed(feedId, result.config, metadata?.emails.length ?? 0, env),
|
||||
200,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
apiApp.openapi(
|
||||
createRoute({
|
||||
method: "delete",
|
||||
path: "/v1/feeds/{feedId}",
|
||||
tags: ["Feeds"],
|
||||
summary: "Delete a feed",
|
||||
security: bearer,
|
||||
request: { params: FeedIdParam },
|
||||
responses: {
|
||||
200: jsonContent(OkSchema, "The feed was deleted"),
|
||||
401: jsonContent(ErrorSchema, "Unauthorized"),
|
||||
404: jsonContent(ErrorSchema, "Feed not found"),
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const env = c.env;
|
||||
const { feedId } = c.req.valid("param");
|
||||
const removed = await deleteFeedRecord(c, env, feedId);
|
||||
if (!removed) return c.json({ error: "Feed not found" }, 404);
|
||||
return c.json({ ok: true }, 200);
|
||||
},
|
||||
);
|
||||
|
||||
// ── Emails ──────────────────────────────────────────────────────────────────
|
||||
|
||||
apiApp.openapi(
|
||||
createRoute({
|
||||
method: "get",
|
||||
path: "/v1/feeds/{feedId}/emails",
|
||||
tags: ["Emails"],
|
||||
summary: "List a feed's emails",
|
||||
security: bearer,
|
||||
request: { params: FeedIdParam },
|
||||
responses: {
|
||||
200: jsonContent(EmailListSchema, "The feed's emails"),
|
||||
401: jsonContent(ErrorSchema, "Unauthorized"),
|
||||
404: jsonContent(ErrorSchema, "Feed not found"),
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const env = c.env;
|
||||
const { feedId } = c.req.valid("param");
|
||||
const metadata = await getFeedMetadata(env.EMAIL_STORAGE, feedId);
|
||||
if (!metadata) return c.json({ error: "Feed not found" }, 404);
|
||||
return c.json(
|
||||
{
|
||||
emails: metadata.emails.map((e) => ({
|
||||
entryId: e.receivedAt,
|
||||
subject: e.subject,
|
||||
receivedAt: e.receivedAt,
|
||||
size: e.size,
|
||||
attachmentIds: e.attachmentIds,
|
||||
})),
|
||||
},
|
||||
200,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
apiApp.openapi(
|
||||
createRoute({
|
||||
method: "get",
|
||||
path: "/v1/feeds/{feedId}/emails/{entryId}",
|
||||
tags: ["Emails"],
|
||||
summary: "Get a single email",
|
||||
security: bearer,
|
||||
request: { params: EntryIdParam },
|
||||
responses: {
|
||||
200: jsonContent(EmailSchema, "The email"),
|
||||
401: jsonContent(ErrorSchema, "Unauthorized"),
|
||||
404: jsonContent(ErrorSchema, "Email not found"),
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const env = c.env;
|
||||
const { feedId, entryId } = c.req.valid("param");
|
||||
const receivedAt = parseInt(entryId, 10);
|
||||
const metadata = await getFeedMetadata(env.EMAIL_STORAGE, feedId);
|
||||
const metaEntry = metadata?.emails.find((e) => e.receivedAt === receivedAt);
|
||||
if (!metaEntry) return c.json({ error: "Email not found" }, 404);
|
||||
const data = await getEmailData(env.EMAIL_STORAGE, metaEntry.key);
|
||||
if (!data) return c.json({ error: "Email not found" }, 404);
|
||||
return c.json(
|
||||
{
|
||||
entryId: receivedAt,
|
||||
subject: data.subject,
|
||||
from: data.from,
|
||||
receivedAt: data.receivedAt,
|
||||
content: data.content,
|
||||
attachments: (data.attachments ?? []).map((a) => ({
|
||||
id: a.id,
|
||||
filename: a.filename,
|
||||
contentType: a.contentType,
|
||||
size: a.size,
|
||||
url: `/files/${a.id}/${encodeURIComponent(a.filename)}`,
|
||||
})),
|
||||
},
|
||||
200,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
apiApp.openapi(
|
||||
createRoute({
|
||||
method: "delete",
|
||||
path: "/v1/feeds/{feedId}/emails/{entryId}",
|
||||
tags: ["Emails"],
|
||||
summary: "Delete a single email",
|
||||
security: bearer,
|
||||
request: { params: EntryIdParam },
|
||||
responses: {
|
||||
200: jsonContent(OkSchema, "The email was deleted"),
|
||||
401: jsonContent(ErrorSchema, "Unauthorized"),
|
||||
404: jsonContent(ErrorSchema, "Email not found"),
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const env = c.env;
|
||||
const emailStorage = env.EMAIL_STORAGE;
|
||||
const { feedId, entryId } = c.req.valid("param");
|
||||
const receivedAt = parseInt(entryId, 10);
|
||||
const metadata = await getFeedMetadata(emailStorage, feedId);
|
||||
const metaEntry = metadata?.emails.find((e) => e.receivedAt === receivedAt);
|
||||
if (!metadata || !metaEntry)
|
||||
return c.json({ error: "Email not found" }, 404);
|
||||
|
||||
await emailStorage.delete(metaEntry.key);
|
||||
await deleteAttachmentsForEmails(env, metadata.emails, [metaEntry.key]);
|
||||
metadata.emails = metadata.emails.filter((e) => e.key !== metaEntry.key);
|
||||
await emailStorage.put(`feed:${feedId}:metadata`, JSON.stringify(metadata));
|
||||
|
||||
return c.json({ ok: true }, 200);
|
||||
},
|
||||
);
|
||||
|
||||
// ── Stats ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
apiApp.openapi(
|
||||
createRoute({
|
||||
method: "get",
|
||||
path: "/v1/stats",
|
||||
tags: ["Stats"],
|
||||
summary: "Read monitoring counters",
|
||||
security: bearer,
|
||||
responses: {
|
||||
200: jsonContent(StatsSchema, "Monitoring counters"),
|
||||
401: jsonContent(ErrorSchema, "Unauthorized"),
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
return c.json(await getStats(c.env), 200);
|
||||
},
|
||||
);
|
||||
|
||||
// ── OpenAPI document + docs (public) ───────────────────────────────────────────
|
||||
|
||||
apiApp.doc31("/openapi.json", {
|
||||
openapi: "3.1.0",
|
||||
info: {
|
||||
title: "kill-the-news API",
|
||||
version: "1.0.0",
|
||||
description:
|
||||
"REST API for managing email-to-RSS feeds, their emails, and monitoring counters.",
|
||||
},
|
||||
servers: [{ url: "/api" }],
|
||||
});
|
||||
|
||||
apiApp.get(
|
||||
"/docs",
|
||||
Scalar({
|
||||
url: "/api/openapi.json",
|
||||
pageTitle: "kill-the-news API",
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,153 @@
|
||||
import { z } from "@hono/zod-openapi";
|
||||
|
||||
// ── Shared ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export const ErrorSchema = z
|
||||
.object({
|
||||
error: z.string().openapi({ example: "Feed not found" }),
|
||||
})
|
||||
.openapi("Error");
|
||||
|
||||
export const FeedIdParam = z.object({
|
||||
feedId: z
|
||||
.string()
|
||||
.min(1)
|
||||
.openapi({
|
||||
param: { name: "feedId", in: "path" },
|
||||
example: "happy-otter-1234",
|
||||
}),
|
||||
});
|
||||
|
||||
export const EntryIdParam = FeedIdParam.extend({
|
||||
entryId: z
|
||||
.string()
|
||||
.regex(/^\d+$/, "entryId must be the email's receivedAt timestamp")
|
||||
.openapi({
|
||||
param: { name: "entryId", in: "path" },
|
||||
example: "1737000000000",
|
||||
}),
|
||||
});
|
||||
|
||||
// ── Feeds ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export const FeedCreateSchema = z
|
||||
.object({
|
||||
title: z.string().min(1).openapi({ example: "Daily Tech Digest" }),
|
||||
description: z.string().optional(),
|
||||
language: z.string().optional().default("en"),
|
||||
allowedSenders: z.array(z.string()).optional().default([]),
|
||||
blockedSenders: z.array(z.string()).optional().default([]),
|
||||
lifetimeHours: z.number().int().positive().optional().openapi({
|
||||
description:
|
||||
"Hours until the feed expires. Ignored when the server enforces a fixed FEED_TTL_HOURS.",
|
||||
}),
|
||||
})
|
||||
.openapi("FeedCreate");
|
||||
|
||||
export const FeedUpdateSchema = z
|
||||
.object({
|
||||
title: z.string().min(1).optional(),
|
||||
description: z.string().optional(),
|
||||
language: z.string().optional(),
|
||||
allowedSenders: z.array(z.string()).optional(),
|
||||
blockedSenders: z.array(z.string()).optional(),
|
||||
lifetimeHours: z.number().int().positive().optional().openapi({
|
||||
description: "Reset the feed's lifetime to this many hours from now.",
|
||||
}),
|
||||
})
|
||||
.openapi("FeedUpdate");
|
||||
|
||||
export const FeedSummarySchema = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
title: z.string(),
|
||||
description: z.string().optional(),
|
||||
expiresAt: z.number().optional(),
|
||||
emailAddress: z.string(),
|
||||
rssUrl: z.string(),
|
||||
atomUrl: z.string(),
|
||||
})
|
||||
.openapi("FeedSummary");
|
||||
|
||||
export const FeedListSchema = z
|
||||
.object({ feeds: z.array(FeedSummarySchema) })
|
||||
.openapi("FeedList");
|
||||
|
||||
export const FeedSchema = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
title: z.string(),
|
||||
description: z.string().optional(),
|
||||
language: z.string(),
|
||||
allowedSenders: z.array(z.string()),
|
||||
blockedSenders: z.array(z.string()),
|
||||
createdAt: z.number(),
|
||||
updatedAt: z.number().optional(),
|
||||
expiresAt: z.number().optional(),
|
||||
emailCount: z.number(),
|
||||
emailAddress: z.string(),
|
||||
rssUrl: z.string(),
|
||||
atomUrl: z.string(),
|
||||
})
|
||||
.openapi("Feed");
|
||||
|
||||
// ── Emails ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export const EmailSummarySchema = z
|
||||
.object({
|
||||
entryId: z.number().openapi({
|
||||
description: "Email receivedAt timestamp; used as the path id.",
|
||||
}),
|
||||
subject: z.string(),
|
||||
receivedAt: z.number(),
|
||||
size: z.number().optional(),
|
||||
attachmentIds: z.array(z.string()).optional(),
|
||||
})
|
||||
.openapi("EmailSummary");
|
||||
|
||||
export const EmailListSchema = z
|
||||
.object({ emails: z.array(EmailSummarySchema) })
|
||||
.openapi("EmailList");
|
||||
|
||||
export const AttachmentSchema = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
filename: z.string(),
|
||||
contentType: z.string(),
|
||||
size: z.number(),
|
||||
url: z.string(),
|
||||
})
|
||||
.openapi("Attachment");
|
||||
|
||||
export const EmailSchema = z
|
||||
.object({
|
||||
entryId: z.number(),
|
||||
subject: z.string(),
|
||||
from: z.string(),
|
||||
receivedAt: z.number(),
|
||||
content: z.string(),
|
||||
attachments: z.array(AttachmentSchema),
|
||||
})
|
||||
.openapi("Email");
|
||||
|
||||
// ── Stats ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export const StatsSchema = z
|
||||
.object({
|
||||
feeds_created: z.number(),
|
||||
feeds_deleted: z.number(),
|
||||
emails_received: z.number(),
|
||||
emails_rejected: z.number(),
|
||||
unsubscribes_sent: z.number(),
|
||||
active_feeds: z.number(),
|
||||
websub_subscriptions_active: z.number(),
|
||||
attachments_enabled: z.boolean(),
|
||||
last_email_at: z.string().optional(),
|
||||
last_feed_created_at: z.string().optional(),
|
||||
first_seen: z.string().optional(),
|
||||
attachments_bytes: z.number().optional(),
|
||||
attachments_count: z.number().optional(),
|
||||
kv_bytes_estimated: z.number().optional(),
|
||||
storage_scanned_at: z.string().optional(),
|
||||
})
|
||||
.openapi("Stats");
|
||||
Reference in New Issue
Block a user