Merge pull request #1 from juherr/feat/add-eslint-lint-staged

feat: add ESLint, lint-staged, and update pre-commit hook + CI
This commit is contained in:
Julien Herr authored and GitHub committed 2026-05-21 20:41:15 +02:00
commit aede0c9d16
22 files changed
+2073 -110

No files matched your search

+4 -1
View File
@@ -21,7 +21,10 @@ jobs:
- run: npm ci - run: npm ci
- name: Format check - name: Format check
run: npx prettier --check '**/*.{js,ts,css,json,md}' run: npm run format:check
- name: Lint
run: npm run lint
- name: Type check - name: Type check
run: npm run typecheck run: npm run typecheck
+2
View File
@@ -0,0 +1,2 @@
#!/bin/sh
npx lint-staged && npm run typecheck && npm test
+13 -8
View File
@@ -31,7 +31,7 @@ Email-to-RSS keeps the same workflow while avoiding shared domains and shared da
Two ingestion methods are supported — pick one or use both: Two ingestion methods are supported — pick one or use both:
| Method | How it works | | Method | How it works |
| ---------------------- | ------------------------------------------------------------------ | | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| **Cloudflare Email Workers** | Cloudflare Email Routing delivers the raw message directly to the Worker via the `email()` handler — no outbound webhook needed | | **Cloudflare Email Workers** | Cloudflare Email Routing delivers the raw message directly to the Worker via the `email()` handler — no outbound webhook needed |
| **ForwardEmail webhook** | ForwardEmail parses the message and POSTs a JSON payload to `POST /api/inbound`; the Worker verifies the source IP before processing | | **ForwardEmail webhook** | ForwardEmail parses the message and POSTs a JSON payload to `POST /api/inbound`; the Worker verifies the source IP before processing |
@@ -60,7 +60,7 @@ Main routes:
## Cloudflare setup ## Cloudflare setup
If your domain is not yet on Cloudflare: in the [Cloudflare dashboard](https://dash.cloudflare.com/), go to *Add a site*, enter your domain, choose the Free plan, and follow the instructions to update your nameservers at your registrar. Wait for the zone to become active (usually a few minutes). If your domain is not yet on Cloudflare: in the [Cloudflare dashboard](https://dash.cloudflare.com/), go to _Add a site_, enter your domain, choose the Free plan, and follow the instructions to update your nameservers at your registrar. Wait for the zone to become active (usually a few minutes).
## Setup ## Setup
@@ -70,9 +70,11 @@ If your domain is not yet on Cloudflare: in the [Cloudflare dashboard](https://d
npx wrangler login npx wrangler login
``` ```
3. Run setup: 3. Run setup:
```bash ```bash
bash setup.sh bash setup.sh
``` ```
The script will prompt for an admin password and your domain, then: The script will prompt for an admin password and your domain, then:
- install npm dependencies - install npm dependencies
- verify Cloudflare auth (`wrangler whoami`) - verify Cloudflare auth (`wrangler whoami`)
@@ -86,8 +88,8 @@ If your domain is not yet on Cloudflare: in the [Cloudflare dashboard](https://d
No third-party service required. Cloudflare receives the email and hands it directly to the Worker. No third-party service required. Cloudflare receives the email and hands it directly to the Worker.
1. In the Cloudflare dashboard, go to *Email → Email Routing* for your zone and click **Enable Email Routing**. Cloudflare will prompt you to add MX and SPF records — accept and it adds them automatically. 1. In the Cloudflare dashboard, go to _Email → Email Routing_ for your zone and click **Enable Email Routing**. Cloudflare will prompt you to add MX and SPF records — accept and it adds them automatically.
2. Under *Email Routing → Routing Rules*, add a **Catch-all** rule: 2. Under _Email Routing → Routing Rules_, add a **Catch-all** rule:
- Action: **Send to Worker** - Action: **Send to Worker**
- Worker: `email-to-rss` (the name from `wrangler.toml`) - Worker: `email-to-rss` (the name from `wrangler.toml`)
@@ -97,7 +99,7 @@ That's it. No webhook configuration is needed.
Use this if you prefer ForwardEmail's additional features (sender filtering, open-tracking, etc.). Use this if you prefer ForwardEmail's additional features (sender filtering, open-tracking, etc.).
Add these DNS records in Cloudflare (*DNS → Records*): Add these DNS records in Cloudflare (_DNS → Records_):
| Type | Name | Content | Notes | | Type | Name | Content | Notes |
| ---- | ---- | ---------------------------------------------------- | ----------------------- | | ---- | ---- | ---------------------------------------------------- | ----------------------- |
@@ -111,14 +113,16 @@ Replace `yourdomain.com` with your actual domain.
The Worker verifies each webhook request against ForwardEmail's published MX IP list before processing it. The Worker verifies each webhook request against ForwardEmail's published MX IP list before processing it.
5. Deploy: 5. Deploy:
```bash ```bash
npm run deploy npm run deploy
``` ```
Wrangler will create the Worker and register `yourdomain.com` (and `www.yourdomain.com`) as custom domains pointing to it. Cloudflare handles TLS automatically. Wrangler will create the Worker and register `yourdomain.com` (and `www.yourdomain.com`) as custom domains pointing to it. Cloudflare handles TLS automatically.
6. Open `https://yourdomain.com/admin` and sign in. 6. Open `https://yourdomain.com/admin` and sign in.
> **Tip:** To verify the Worker is running, check *Workers & Pages → email-to-rss* in the Cloudflare dashboard. The *Custom Domains* tab should list your domain once the deploy succeeds. > **Tip:** To verify the Worker is running, check _Workers & Pages → email-to-rss_ in the Cloudflare dashboard. The _Custom Domains_ tab should list your domain once the deploy succeeds.
## Development ## Development
@@ -153,7 +157,7 @@ This feature is **optional**. If no R2 bucket is bound, attachments are silently
**Setup:** **Setup:**
1. Create an R2 bucket in the Cloudflare dashboard (*R2 Object Storage → Create bucket*), or with Wrangler: 1. Create an R2 bucket in the Cloudflare dashboard (_R2 Object Storage → Create bucket_), or with Wrangler:
```bash ```bash
npx wrangler r2 bucket create your-bucket-name npx wrangler r2 bucket create your-bucket-name
``` ```
@@ -178,7 +182,7 @@ Instead of the built-in password login you can delegate admin authentication to
**Required Worker secrets** (set with `wrangler secret put`, never in `[vars]`): **Required Worker secrets** (set with `wrangler secret put`, never in `[vars]`):
| Secret | Description | | Secret | Description |
|---|---| | ------------------- | ---------------------------------------------- |
| `PROXY_AUTH_SECRET` | Shared secret between the proxy and the Worker | | `PROXY_AUTH_SECRET` | Shared secret between the proxy and the Worker |
**Required `[vars]`** in `wrangler.toml`: **Required `[vars]`** in `wrangler.toml`:
@@ -188,6 +192,7 @@ PROXY_TRUSTED_IPS = "10.0.0.1" # comma-separated IPs of your reverse proxy
``` ```
When both are configured, the Worker authenticates a request if: When both are configured, the Worker authenticates a request if:
1. `CF-Connecting-IP` is in `PROXY_TRUSTED_IPS` 1. `CF-Connecting-IP` is in `PROXY_TRUSTED_IPS`
2. The `X-Auth-Proxy-Secret` header matches `PROXY_AUTH_SECRET` 2. The `X-Auth-Proxy-Secret` header matches `PROXY_AUTH_SECRET`
3. `Remote-User` or `X-Forwarded-User` is non-empty 3. `Remote-User` or `X-Forwarded-User` is non-empty
+22
View File
@@ -0,0 +1,22 @@
import prettier from "eslint-config-prettier";
import tseslint from "typescript-eslint";
export default tseslint.config(
{ ignores: ["dist/", "coverage/"] },
...tseslint.configs.recommended,
prettier,
{
rules: {
"@typescript-eslint/no-unused-vars": [
"error",
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
],
},
},
{
files: ["src/**/*.test.ts", "src/test/**/*.ts"],
rules: {
"@typescript-eslint/no-explicit-any": "off",
},
},
);
+1708 -6
View File
File diff suppressed because it is too large. Load diff
+18 -1
View File
@@ -6,12 +6,24 @@
"scripts": { "scripts": {
"build": "wrangler deploy --dry-run --outdir=dist", "build": "wrangler deploy --dry-run --outdir=dist",
"format": "prettier --write '**/*.{js,ts,css,json,md}'", "format": "prettier --write '**/*.{js,ts,css,json,md}'",
"format:check": "prettier --check '**/*.{js,ts,css,json,md}'",
"lint": "eslint src",
"dev": "wrangler dev", "dev": "wrangler dev",
"deploy": "wrangler deploy --env production", "deploy": "wrangler deploy --env production",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"test:coverage": "vitest run --coverage", "test:coverage": "vitest run --coverage",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit",
"prepare": "husky"
},
"lint-staged": {
"*.{ts,js}": [
"eslint --fix",
"prettier --write"
],
"*.{json,md,css}": [
"prettier --write"
]
}, },
"author": "", "author": "",
"license": "MIT", "license": "MIT",
@@ -20,10 +32,15 @@
"@types/mailparser": "^3.4.6", "@types/mailparser": "^3.4.6",
"@types/rss": "^0.0.32", "@types/rss": "^0.0.32",
"@vitest/coverage-v8": "^4.0.18", "@vitest/coverage-v8": "^4.0.18",
"eslint": "^10.0.0",
"eslint-config-prettier": "^10.1.8",
"happy-dom": "^20.5.0", "happy-dom": "^20.5.0",
"husky": "^9.1.7",
"lint-staged": "^17.0.5",
"msw": "^2.12.8", "msw": "^2.12.8",
"prettier": "^3.8.1", "prettier": "^3.8.1",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"typescript-eslint": "^8.59.4",
"vitest": "^4.0.18", "vitest": "^4.0.18",
"wrangler": "^4.63.0" "wrangler": "^4.63.0"
}, },
+1 -1
View File
@@ -5,7 +5,7 @@ import { processEmail, RawAttachment } from "./email-processor";
export async function handleCloudflareEmail( export async function handleCloudflareEmail(
message: ForwardableEmailMessage, message: ForwardableEmailMessage,
env: Env, env: Env,
ctx: ExecutionContext, _ctx: ExecutionContext,
): Promise<void> { ): Promise<void> {
try { try {
const email = await PostalMime.parse(message.raw); const email = await PostalMime.parse(message.raw);
+65 -13
View File
@@ -1,7 +1,11 @@
import { describe, it, expect, beforeEach } from "vitest"; import { describe, it, expect, beforeEach } from "vitest";
import "../test/setup"; import "../test/setup";
import { createMockEnv, MockR2 } from "../test/setup"; import { createMockEnv, MockR2 } from "../test/setup";
import { processEmail, ProcessEmailInput, RawAttachment } from "./email-processor"; import {
processEmail,
ProcessEmailInput,
RawAttachment,
} from "./email-processor";
const VALID_FEED_ID = "apple.mountain.42"; const VALID_FEED_ID = "apple.mountain.42";
const VALID_TO = `${VALID_FEED_ID}@test.getmynews.app`; const VALID_TO = `${VALID_FEED_ID}@test.getmynews.app`;
@@ -139,25 +143,53 @@ describe("processEmail", () => {
const oldKey1 = `feed:${VALID_FEED_ID}:111`; const oldKey1 = `feed:${VALID_FEED_ID}:111`;
const oldKey2 = `feed:${VALID_FEED_ID}:222`; const oldKey2 = `feed:${VALID_FEED_ID}:222`;
const bigContent = "x".repeat(200); const bigContent = "x".repeat(200);
const email1 = JSON.stringify({ subject: "Old1", from: "[email protected]", content: bigContent, receivedAt: 111, headers: {} }); const email1 = JSON.stringify({
const email2 = JSON.stringify({ subject: "Old2", from: "[email protected]", content: bigContent, receivedAt: 222, headers: {} }); subject: "Old1",
from: "[email protected]",
content: bigContent,
receivedAt: 111,
headers: {},
});
const email2 = JSON.stringify({
subject: "Old2",
from: "[email protected]",
content: bigContent,
receivedAt: 222,
headers: {},
});
await env.EMAIL_STORAGE.put(oldKey1, email1); await env.EMAIL_STORAGE.put(oldKey1, email1);
await env.EMAIL_STORAGE.put(oldKey2, email2); await env.EMAIL_STORAGE.put(oldKey2, email2);
await env.EMAIL_STORAGE.put( await env.EMAIL_STORAGE.put(
`feed:${VALID_FEED_ID}:metadata`, `feed:${VALID_FEED_ID}:metadata`,
JSON.stringify({ JSON.stringify({
emails: [ emails: [
{ key: oldKey2, subject: "Old2", receivedAt: 222, size: email2.length }, {
{ key: oldKey1, subject: "Old1", receivedAt: 111, size: email1.length }, key: oldKey2,
subject: "Old2",
receivedAt: 222,
size: email2.length,
},
{
key: oldKey1,
subject: "Old1",
receivedAt: 111,
size: email1.length,
},
], ],
}), }),
); );
const tinyEnv = { ...env, FEED_MAX_SIZE_BYTES: "50" }; const tinyEnv = { ...env, FEED_MAX_SIZE_BYTES: "50" };
const res = await processEmail(makeInput({ subject: "New" }), tinyEnv as any); const res = await processEmail(
makeInput({ subject: "New" }),
tinyEnv as any,
);
expect(res.status).toBe(200); expect(res.status).toBe(200);
const metadata = await env.EMAIL_STORAGE.get(`feed:${VALID_FEED_ID}:metadata`, "json"); const metadata = await env.EMAIL_STORAGE.get(
`feed:${VALID_FEED_ID}:metadata`,
"json",
);
expect(metadata.emails).toHaveLength(1); expect(metadata.emails).toHaveLength(1);
expect(metadata.emails[0].subject).toBe("New"); expect(metadata.emails[0].subject).toBe("New");
@@ -175,13 +207,17 @@ describe("processEmail", () => {
const bigEnv = { ...env, FEED_MAX_SIZE_BYTES: String(10 * 1024 * 1024) }; const bigEnv = { ...env, FEED_MAX_SIZE_BYTES: String(10 * 1024 * 1024) };
await processEmail(makeInput({ subject: "First" }), bigEnv as any); await processEmail(makeInput({ subject: "First" }), bigEnv as any);
await processEmail(makeInput({ subject: "Second" }), bigEnv as any); await processEmail(makeInput({ subject: "Second" }), bigEnv as any);
const metadata = await env.EMAIL_STORAGE.get(`feed:${VALID_FEED_ID}:metadata`, "json"); const metadata = await env.EMAIL_STORAGE.get(
`feed:${VALID_FEED_ID}:metadata`,
"json",
);
expect(metadata.emails).toHaveLength(2); expect(metadata.emails).toHaveLength(2);
}); });
}); });
describe("processEmail — attachments", () => { describe("processEmail — attachments", () => {
const pdfContent = new TextEncoder().encode("PDF bytes").buffer as ArrayBuffer; const pdfContent = new TextEncoder().encode("PDF bytes")
.buffer as ArrayBuffer;
const pdfAttachment: RawAttachment = { const pdfAttachment: RawAttachment = {
filename: "report.pdf", filename: "report.pdf",
@@ -205,7 +241,10 @@ describe("processEmail — attachments", () => {
`feed:${VALID_FEED_ID}:metadata`, `feed:${VALID_FEED_ID}:metadata`,
"json", "json",
); );
const emailData = await env.EMAIL_STORAGE.get(metadata.emails[0].key, "json"); const emailData = await env.EMAIL_STORAGE.get(
metadata.emails[0].key,
"json",
);
expect(emailData.attachments).toBeUndefined(); expect(emailData.attachments).toBeUndefined();
}); });
@@ -226,7 +265,10 @@ describe("processEmail — attachments", () => {
`feed:${VALID_FEED_ID}:metadata`, `feed:${VALID_FEED_ID}:metadata`,
"json", "json",
); );
const emailData = await env.EMAIL_STORAGE.get(metadata.emails[0].key, "json"); const emailData = await env.EMAIL_STORAGE.get(
metadata.emails[0].key,
"json",
);
expect(emailData.attachments).toHaveLength(1); expect(emailData.attachments).toHaveLength(1);
expect(emailData.attachments[0].filename).toBe("report.pdf"); expect(emailData.attachments[0].filename).toBe("report.pdf");
@@ -271,7 +313,14 @@ describe("processEmail — attachments", () => {
content: bigContent, content: bigContent,
receivedAt: 111, receivedAt: 111,
headers: {}, headers: {},
attachments: [{ id: oldAttachmentId, filename: "old.pdf", contentType: "application/pdf", size: 100 }], attachments: [
{
id: oldAttachmentId,
filename: "old.pdf",
contentType: "application/pdf",
size: 100,
},
],
}); });
await env.EMAIL_STORAGE.put(oldKey, oldEmail); await env.EMAIL_STORAGE.put(oldKey, oldEmail);
@@ -295,7 +344,10 @@ describe("processEmail — attachments", () => {
// Process with tight size budget to force trimming // Process with tight size budget to force trimming
const tinyEnv = { ...env, FEED_MAX_SIZE_BYTES: "50" }; const tinyEnv = { ...env, FEED_MAX_SIZE_BYTES: "50" };
const res = await processEmail(makeInput({ subject: "New" }), tinyEnv as any); const res = await processEmail(
makeInput({ subject: "New" }),
tinyEnv as any,
);
expect(res.status).toBe(200); expect(res.status).toBe(200);
// Old attachment should be deleted from R2 // Old attachment should be deleted from R2
+11 -2
View File
@@ -1,5 +1,11 @@
import { EmailParser } from "../utils/email-parser"; import { EmailParser } from "../utils/email-parser";
import { AttachmentData, EmailMetadata, Env, FeedConfig, FeedMetadata } from "../types"; import {
AttachmentData,
EmailMetadata,
Env,
FeedConfig,
FeedMetadata,
} from "../types";
export interface RawAttachment { export interface RawAttachment {
filename: string; filename: string;
@@ -151,7 +157,10 @@ export async function processEmail(
}; };
feedMetadata.emails.unshift(newEntry); feedMetadata.emails.unshift(newEntry);
let totalSize = feedMetadata.emails.reduce((sum, e) => sum + (e.size ?? 0), 0); let totalSize = feedMetadata.emails.reduce(
(sum, e) => sum + (e.size ?? 0),
0,
);
const toDelete: EmailMetadata[] = []; const toDelete: EmailMetadata[] = [];
while (totalSize > maxBytes && feedMetadata.emails.length > 1) { while (totalSize > maxBytes && feedMetadata.emails.length > 1) {
const dropped = feedMetadata.emails.pop()!; const dropped = feedMetadata.emails.pop()!;
+8 -6
View File
@@ -6,10 +6,7 @@ export interface ForwardEmailAttachment {
filename?: string; filename?: string;
contentType?: string; contentType?: string;
size?: number; size?: number;
content?: content?: { type: "Buffer"; data: number[] } | ArrayBuffer | ArrayBufferView;
| { type: "Buffer"; data: number[] }
| ArrayBuffer
| ArrayBufferView;
} }
export interface ForwardEmailPayload { export interface ForwardEmailPayload {
@@ -56,8 +53,13 @@ function toArrayBuffer(
): ArrayBuffer | null { ): ArrayBuffer | null {
if (!content) return null; if (!content) return null;
if (content instanceof ArrayBuffer) return content; if (content instanceof ArrayBuffer) return content;
if (ArrayBuffer.isView(content)) return (content as ArrayBufferView).buffer as ArrayBuffer; if (ArrayBuffer.isView(content))
if (typeof content === "object" && content.type === "Buffer" && Array.isArray(content.data)) { return (content as ArrayBufferView).buffer as ArrayBuffer;
if (
typeof content === "object" &&
content.type === "Buffer" &&
Array.isArray(content.data)
) {
return Uint8Array.from(content.data).buffer as ArrayBuffer; return Uint8Array.from(content.data).buffer as ArrayBuffer;
} }
return null; return null;
+6 -2
View File
@@ -14,7 +14,8 @@ describe("Admin Routes", () => {
mockEnv = createMockEnv() as unknown as Env; mockEnv = createMockEnv() as unknown as Env;
testApp = new Hono(); testApp = new Hono();
testApp.route("/admin", app); testApp.route("/admin", app);
request = (path, init = {}) => Promise.resolve(testApp.request(path, init, mockEnv)); request = (path, init = {}) =>
Promise.resolve(testApp.request(path, init, mockEnv));
loginAndGetCookie = async () => { loginAndGetCookie = async () => {
const formData = new FormData(); const formData = new FormData();
formData.append("password", "test-password"); formData.append("password", "test-password");
@@ -366,7 +367,10 @@ describe("Admin Routes", () => {
} as unknown as Env; } as unknown as Env;
} }
function makeProxyRequest(path: string, headers: Record<string, string> = {}) { function makeProxyRequest(
path: string,
headers: Record<string, string> = {},
) {
const proxyApp = new Hono(); const proxyApp = new Hono();
proxyApp.route("/admin", app); proxyApp.route("/admin", app);
return proxyApp.request(path, { headers }, proxyEnv()); return proxyApp.request(path, { headers }, proxyEnv());
+26 -8
View File
@@ -71,9 +71,11 @@ function timingSafeEqual(a: string, b: string): boolean {
const aBytes = enc.encode(a); const aBytes = enc.encode(a);
const bBytes = enc.encode(b); const bBytes = enc.encode(b);
// Try native timing-safe implementation first (Cloudflare Workers runtime) // Try native timing-safe implementation first (Cloudflare Workers runtime)
if (typeof (crypto.subtle as any).timingSafeEqual === "function") { // 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; if (aBytes.length !== bBytes.length) return false;
return (crypto.subtle as any).timingSafeEqual(aBytes, bBytes); return subtle.timingSafeEqual(aBytes, bBytes);
} }
// Constant-time fallback for Node (test environment): encode length // Constant-time fallback for Node (test environment): encode length
// mismatch into `diff` so the loop always runs over the full length. // mismatch into `diff` so the loop always runs over the full length.
@@ -97,7 +99,9 @@ async function authMiddleware(c: Context, next: () => Promise<void>) {
// Proxy auth: only active when both env vars are present // Proxy auth: only active when both env vars are present
if (env.PROXY_AUTH_SECRET && env.PROXY_TRUSTED_IPS) { if (env.PROXY_AUTH_SECRET && env.PROXY_TRUSTED_IPS) {
const trustedIps = env.PROXY_TRUSTED_IPS.split(",").map((s) => s.trim()).filter(Boolean); const trustedIps = env.PROXY_TRUSTED_IPS.split(",")
.map((s) => s.trim())
.filter(Boolean);
const clientIp = c.req.header("CF-Connecting-IP") ?? ""; const clientIp = c.req.header("CF-Connecting-IP") ?? "";
const providedSecret = c.req.header("X-Auth-Proxy-Secret") ?? ""; const providedSecret = c.req.header("X-Auth-Proxy-Secret") ?? "";
const remoteUser = const remoteUser =
@@ -113,7 +117,11 @@ async function authMiddleware(c: Context, next: () => Promise<void>) {
} }
// Fallback: signed cookie // Fallback: signed cookie
const authCookie = await getSignedCookie(c, env.ADMIN_PASSWORD, ADMIN_COOKIE_NAME); const authCookie = await getSignedCookie(
c,
env.ADMIN_PASSWORD,
ADMIN_COOKIE_NAME,
);
if (authCookie !== "1") { if (authCookie !== "1") {
return c.redirect("/admin/login"); return c.redirect("/admin/login");
} }
@@ -146,6 +154,7 @@ const authSchema = z.object({
}); });
// Base HTML layout with design system // Base HTML layout with design system
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const layout = (title: string, content: any) => { const layout = (title: string, content: any) => {
return html`<!DOCTYPE html> return html`<!DOCTYPE html>
<html> <html>
@@ -1610,11 +1619,14 @@ app.post("/feeds/create", async (c) => {
if (isJson) { if (isJson) {
const body = await c.req.json<Record<string, unknown>>(); const body = await c.req.json<Record<string, unknown>>();
title = String(body.title ?? ""); title = String(body.title ?? "");
description = body.description != null ? String(body.description) : undefined; description =
body.description != null ? String(body.description) : undefined;
language = String(body.language ?? "en"); language = String(body.language ?? "en");
view = "list"; view = "list";
allowedSenders = Array.isArray(body.allowedSenders) allowedSenders = Array.isArray(body.allowedSenders)
? normalizeAllowedSenders((body.allowedSenders as unknown[]).map(String)) ? normalizeAllowedSenders(
(body.allowedSenders as unknown[]).map(String),
)
: []; : [];
} else { } else {
const formData = await c.req.formData(); const formData = await c.req.formData();
@@ -1930,7 +1942,8 @@ async function purgeFeedKeysStep(
}); });
if (emailKeys.length > 0) { if (emailKeys.length > 0) {
const emailDataResults = await Promise.allSettled( const emailDataResults = await Promise.allSettled(
emailKeys.map((k) => emailKeys.map(
(k) =>
emailStorage.get(k, { type: "json" }) as Promise<EmailData | null>, emailStorage.get(k, { type: "json" }) as Promise<EmailData | null>,
), ),
); );
@@ -1976,7 +1989,12 @@ app.post("/feeds/:feedId/delete", async (c) => {
// Best-effort cleanup in the background so the request stays fast. // Best-effort cleanup in the background so the request stays fast.
// Use the UI purge endpoint for full, user-visible progress. // Use the UI purge endpoint for full, user-visible progress.
waitUntilSafe(c, purgeFeedKeysStep(emailStorage, feedId, { bucket: env.ATTACHMENT_BUCKET })); waitUntilSafe(
c,
purgeFeedKeysStep(emailStorage, feedId, {
bucket: env.ATTACHMENT_BUCKET,
}),
);
if (wantsJson) { if (wantsJson) {
return c.json({ ok: true, feedId }); return c.json({ ok: true, feedId });
} }
+5 -1
View File
@@ -68,7 +68,11 @@ describe("Atom Feed Route", () => {
`feed:${FEED_ID}:metadata`, `feed:${FEED_ID}:metadata`,
JSON.stringify({ JSON.stringify({
emails: [ emails: [
{ key: emailKey, subject: "Atom Entry Subject", receivedAt: 1700000001000 }, {
key: emailKey,
subject: "Atom Entry Subject",
receivedAt: 1700000001000,
},
], ],
}), }),
); );
+37 -11
View File
@@ -21,7 +21,9 @@ export async function handle(c: Context): Promise<Response> {
return new Response("Feed not found", { status: 404 }); return new Response("Feed not found", { status: 404 });
} }
const metaEntry = feedMetadata.emails.find((e) => e.receivedAt === receivedAt); const metaEntry = feedMetadata.emails.find(
(e) => e.receivedAt === receivedAt,
);
if (!metaEntry) { if (!metaEntry) {
return new Response("Entry not found", { status: 404 }); return new Response("Entry not found", { status: 404 });
} }
@@ -39,26 +41,50 @@ export async function handle(c: Context): Promise<Response> {
"default-src 'none'; style-src 'unsafe-inline'; img-src *; frame-src 'none'", "default-src 'none'; style-src 'unsafe-inline'; img-src *; frame-src 'none'",
); );
return c.html(html`<!DOCTYPE html> return c.html(
html`<!DOCTYPE html>
<html> <html>
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta
name="viewport"
content="width=device-width, initial-scale=1.0"
/>
<title>${emailData.subject}</title> <title>${emailData.subject}</title>
<style> <style>
body { font-family: sans-serif; max-width: 800px; margin: 0 auto; padding: 1rem; } body {
.meta { color: #666; font-size: 0.875rem; margin-bottom: 1.5rem; border-bottom: 1px solid #eee; padding-bottom: 0.75rem; } font-family: sans-serif;
.meta dt { display: inline; font-weight: bold; } max-width: 800px;
.meta dd { display: inline; margin: 0 1rem 0 0.25rem; } margin: 0 auto;
padding: 1rem;
}
.meta {
color: #666;
font-size: 0.875rem;
margin-bottom: 1.5rem;
border-bottom: 1px solid #eee;
padding-bottom: 0.75rem;
}
.meta dt {
display: inline;
font-weight: bold;
}
.meta dd {
display: inline;
margin: 0 1rem 0 0.25rem;
}
</style> </style>
</head> </head>
<body> <body>
<h1>${emailData.subject}</h1> <h1>${emailData.subject}</h1>
<dl class="meta"> <dl class="meta">
<dt>From:</dt><dd>${emailData.from}</dd> <dt>From:</dt>
<dt>Date:</dt><dd>${new Date(emailData.receivedAt).toUTCString()}</dd> <dd>${emailData.from}</dd>
<dt>Date:</dt>
<dd>${new Date(emailData.receivedAt).toUTCString()}</dd>
</dl> </dl>
<div class="content">${raw(emailData.content)}</div> <div class="content">${raw(emailData.content)}</div>
</body> </body>
</html>`); </html>`,
);
} }
+2 -1
View File
@@ -39,7 +39,8 @@ describe("GET /files/:attachmentId/:filename", () => {
}); });
it("returns 200 with stored content when attachment exists", async () => { it("returns 200 with stored content when attachment exists", async () => {
const content = new TextEncoder().encode("PDF content").buffer as ArrayBuffer; const content = new TextEncoder().encode("PDF content")
.buffer as ArrayBuffer;
await mockR2.put("test-uuid", content, { await mockR2.put("test-uuid", content, {
httpMetadata: { contentType: "application/pdf" }, httpMetadata: { contentType: "application/pdf" },
}); });
+1 -4
View File
@@ -1,9 +1,6 @@
import { Context } from "hono"; import { Context } from "hono";
import { Env } from "../types"; import { Env } from "../types";
import { import { ForwardEmailPayload, handleForwardEmail } from "../lib/forwardemail";
ForwardEmailPayload,
handleForwardEmail,
} from "../lib/forwardemail";
export async function handle(c: Context): Promise<Response> { export async function handle(c: Context): Promise<Response> {
try { try {
+13 -10
View File
@@ -71,7 +71,7 @@ class MockCache implements Cache {
async match( async match(
request: RequestInfo, request: RequestInfo,
options?: CacheQueryOptions, _options?: CacheQueryOptions,
): Promise<Response | undefined> { ): Promise<Response | undefined> {
const key = request instanceof Request ? request.url : request; const key = request instanceof Request ? request.url : request;
const response = this.store.get(key); const response = this.store.get(key);
@@ -80,7 +80,7 @@ class MockCache implements Cache {
async delete( async delete(
request: RequestInfo, request: RequestInfo,
options?: CacheQueryOptions, _options?: CacheQueryOptions,
): Promise<boolean> { ): Promise<boolean> {
const key = request instanceof Request ? request.url : request; const key = request instanceof Request ? request.url : request;
return this.store.delete(key); return this.store.delete(key);
@@ -107,7 +107,6 @@ beforeAll(() => {
server.listen({ onUnhandledRequest: "error" }); server.listen({ onUnhandledRequest: "error" });
// Type-safe access to Node's global object for setting Workers-like globals in tests // Type-safe access to Node's global object for setting Workers-like globals in tests
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const g = globalThis as any; const g = globalThis as any;
// Mock Cloudflare Workers runtime globals // Mock Cloudflare Workers runtime globals
@@ -118,31 +117,26 @@ beforeAll(() => {
// Mock crypto for generating random values // Mock crypto for generating random values
if (!g.crypto) { if (!g.crypto) {
// eslint-disable-next-line @typescript-eslint/no-require-imports
g.crypto = require("crypto").webcrypto; g.crypto = require("crypto").webcrypto;
} }
// Ensure other required globals are available // Ensure other required globals are available
if (!g.FormData) { if (!g.FormData) {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { FormData } = require("undici"); const { FormData } = require("undici");
g.FormData = FormData; g.FormData = FormData;
} }
if (!g.Headers) { if (!g.Headers) {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { Headers } = require("undici"); const { Headers } = require("undici");
g.Headers = Headers; g.Headers = Headers;
} }
if (!g.Request) { if (!g.Request) {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { Request } = require("undici"); const { Request } = require("undici");
g.Request = Request; g.Request = Request;
} }
if (!g.Response) { if (!g.Response) {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { Response } = require("undici"); const { Response } = require("undici");
g.Response = Response; g.Response = Response;
} }
@@ -173,7 +167,13 @@ export class MockR2 {
async put( async put(
key: string, key: string,
value: ReadableStream | ArrayBuffer | ArrayBufferView | string | null | Blob, value:
| ReadableStream
| ArrayBuffer
| ArrayBufferView
| string
| null
| Blob,
options?: { options?: {
httpMetadata?: { contentType?: string; contentDisposition?: string }; httpMetadata?: { contentType?: string; contentDisposition?: string };
}, },
@@ -216,7 +216,10 @@ export class MockR2 {
headers.set("Content-Type", entry.httpMetadata.contentType); headers.set("Content-Type", entry.httpMetadata.contentType);
} }
if (entry.httpMetadata?.contentDisposition) { if (entry.httpMetadata?.contentDisposition) {
headers.set("Content-Disposition", entry.httpMetadata.contentDisposition); headers.set(
"Content-Disposition",
entry.httpMetadata.contentDisposition,
);
} }
}, },
}; };
+1 -1
View File
@@ -71,7 +71,7 @@ declare global {
// This is not an ideal solution but works for our example // This is not an ideal solution but works for our example
interface KVNamespace { interface KVNamespace {
get(key: string, options?: { type: "text" }): Promise<string | null>; get(key: string, options?: { type: "text" }): Promise<string | null>;
get(key: string, options: { type: "json" }): Promise<any | null>; get(key: string, options: { type: "json" }): Promise<unknown | null>;
get( get(
key: string, key: string,
options: { type: "arrayBuffer" }, options: { type: "arrayBuffer" },
+2 -2
View File
@@ -15,7 +15,7 @@ declare module "rss" {
generator?: string; generator?: string;
categories?: string[]; categories?: string[];
custom_namespaces?: Record<string, string>; custom_namespaces?: Record<string, string>;
custom_elements?: any[]; custom_elements?: unknown[];
} }
interface RSSItemOptions { interface RSSItemOptions {
@@ -34,7 +34,7 @@ declare module "rss" {
size?: number; size?: number;
type?: string; type?: string;
}; };
custom_elements?: any[]; custom_elements?: unknown[];
} }
interface RSSXMLOptions { interface RSSXMLOptions {
+16 -12
View File
@@ -53,15 +53,15 @@ describe("EmailParser.decodeEncodedWords", () => {
// =?UTF-8?Q?caf=C3=A9?= → "café" (but decodeQuotedPrintable works byte-by-byte) // =?UTF-8?Q?caf=C3=A9?= → "café" (but decodeQuotedPrintable works byte-by-byte)
// Use a simple ASCII QP sequence to stay charset-agnostic in tests // Use a simple ASCII QP sequence to stay charset-agnostic in tests
// =?US-ASCII?Q?Hello=20World?= → "Hello World" (=20 → space, _ → space) // =?US-ASCII?Q?Hello=20World?= → "Hello World" (=20 → space, _ → space)
expect( expect(EmailParser.decodeEncodedWords("=?US-ASCII?Q?Hello=20World?=")).toBe(
EmailParser.decodeEncodedWords("=?US-ASCII?Q?Hello=20World?="), "Hello World",
).toBe("Hello World"); );
}); });
it("decodes underscores as spaces in QP encoding", () => { it("decodes underscores as spaces in QP encoding", () => {
expect( expect(EmailParser.decodeEncodedWords("=?US-ASCII?Q?Hello_World?=")).toBe(
EmailParser.decodeEncodedWords("=?US-ASCII?Q?Hello_World?="), "Hello World",
).toBe("Hello World"); );
}); });
it("leaves unrecognised encoded-word syntax unchanged", () => { it("leaves unrecognised encoded-word syntax unchanged", () => {
@@ -79,9 +79,7 @@ describe("EmailParser.parseForwardEmailPayload", () => {
}); });
it("throws on undefined payload", () => { it("throws on undefined payload", () => {
expect(() => expect(() => EmailParser.parseForwardEmailPayload(undefined)).toThrow();
EmailParser.parseForwardEmailPayload(undefined),
).toThrow();
}); });
it("parses subject, from, and HTML content", () => { it("parses subject, from, and HTML content", () => {
@@ -95,7 +93,9 @@ describe("EmailParser.parseForwardEmailPayload", () => {
expect(result.subject).toBe("Test Subject"); expect(result.subject).toBe("Test Subject");
expect(result.from).toBe("[email protected]"); expect(result.from).toBe("[email protected]");
expect(result.content).toBe("<p>Hello</p>"); expect(result.content).toBe("<p>Hello</p>");
expect(result.receivedAt).toBe(new Date("2024-01-15T10:00:00.000Z").getTime()); expect(result.receivedAt).toBe(
new Date("2024-01-15T10:00:00.000Z").getTime(),
);
}); });
it("prefers HTML content over plain text", () => { it("prefers HTML content over plain text", () => {
@@ -137,14 +137,18 @@ describe("EmailParser.parseForwardEmailPayload", () => {
it("uses Date.now() when date field is absent", () => { it("uses Date.now() when date field is absent", () => {
const before = Date.now(); const before = Date.now();
const result = EmailParser.parseForwardEmailPayload({ from: { text: "[email protected]" } }); const result = EmailParser.parseForwardEmailPayload({
from: { text: "[email protected]" },
});
const after = Date.now(); const after = Date.now();
expect(result.receivedAt).toBeGreaterThanOrEqual(before); expect(result.receivedAt).toBeGreaterThanOrEqual(before);
expect(result.receivedAt).toBeLessThanOrEqual(after); expect(result.receivedAt).toBeLessThanOrEqual(after);
}); });
it("defaults subject to 'No Subject' when absent", () => { it("defaults subject to 'No Subject' when absent", () => {
const result = EmailParser.parseForwardEmailPayload({ from: { text: "[email protected]" } }); const result = EmailParser.parseForwardEmailPayload({
from: { text: "[email protected]" },
});
expect(result.subject).toBe("No Subject"); expect(result.subject).toBe("No Subject");
}); });
+3 -1
View File
@@ -7,6 +7,7 @@ export class EmailParser {
return match ? match[1] : null; return match ? match[1] : null;
} }
// eslint-disable-next-line @typescript-eslint/no-explicit-any
static parseForwardEmailPayload(payload: any): EmailData { static parseForwardEmailPayload(payload: any): EmailData {
if (!payload) { if (!payload) {
throw new Error("Missing or invalid webhook payload"); throw new Error("Missing or invalid webhook payload");
@@ -30,6 +31,7 @@ export class EmailParser {
}; };
} }
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private static extractHeaders(payload: any): Record<string, string> { private static extractHeaders(payload: any): Record<string, string> {
const headers: Record<string, string> = {}; const headers: Record<string, string> = {};
@@ -62,7 +64,7 @@ export class EmailParser {
if (encoding.toUpperCase() === "B") { if (encoding.toUpperCase() === "B") {
try { try {
return atob(text); return atob(text);
} catch (e) { } catch {
return text; return text;
} }
} else if (encoding.toUpperCase() === "Q") { } else if (encoding.toUpperCase() === "Q") {
+109 -19
View File
@@ -38,13 +38,23 @@ const FEED_ID = "abc123";
describe("generateRssFeed", () => { describe("generateRssFeed", () => {
it("returns RSS 2.0 with channel element", () => { it("returns RSS 2.0 with channel element", () => {
const result = generateRssFeed(mockFeedConfig, mockEmails, BASE_URL, FEED_ID); const result = generateRssFeed(
mockFeedConfig,
mockEmails,
BASE_URL,
FEED_ID,
);
expect(result).toContain("<channel>"); expect(result).toContain("<channel>");
expect(result).toContain("<title>Test Newsletter</title>"); expect(result).toContain("<title>Test Newsletter</title>");
}); });
it("includes <enclosure> element for email with attachment", () => { it("includes <enclosure> element for email with attachment", () => {
const result = generateRssFeed(mockFeedConfig, [mockEmailWithAttachment], BASE_URL, FEED_ID); const result = generateRssFeed(
mockFeedConfig,
[mockEmailWithAttachment],
BASE_URL,
FEED_ID,
);
expect(result).toContain("<enclosure"); expect(result).toContain("<enclosure");
expect(result).toContain("550e8400-e29b-41d4-a716-446655440000"); expect(result).toContain("550e8400-e29b-41d4-a716-446655440000");
expect(result).toContain("report.pdf"); expect(result).toContain("report.pdf");
@@ -53,22 +63,44 @@ describe("generateRssFeed", () => {
}); });
it("does not include <enclosure> for email without attachments", () => { it("does not include <enclosure> for email without attachments", () => {
const result = generateRssFeed(mockFeedConfig, mockEmails, BASE_URL, FEED_ID); const result = generateRssFeed(
mockFeedConfig,
mockEmails,
BASE_URL,
FEED_ID,
);
expect(result).not.toContain("<enclosure"); expect(result).not.toContain("<enclosure");
}); });
it("enclosure URL uses /files/{id}/{filename} scheme", () => { it("enclosure URL uses /files/{id}/{filename} scheme", () => {
const result = generateRssFeed(mockFeedConfig, [mockEmailWithAttachment], BASE_URL, FEED_ID); const result = generateRssFeed(
expect(result).toContain(`${BASE_URL}/files/550e8400-e29b-41d4-a716-446655440000/report.pdf`); mockFeedConfig,
[mockEmailWithAttachment],
BASE_URL,
FEED_ID,
);
expect(result).toContain(
`${BASE_URL}/files/550e8400-e29b-41d4-a716-446655440000/report.pdf`,
);
}); });
it("includes rss self-link in RSS output", () => { it("includes rss self-link in RSS output", () => {
const result = generateRssFeed(mockFeedConfig, mockEmails, BASE_URL, FEED_ID); const result = generateRssFeed(
mockFeedConfig,
mockEmails,
BASE_URL,
FEED_ID,
);
expect(result).toContain(`${BASE_URL}/rss/${FEED_ID}`); expect(result).toContain(`${BASE_URL}/rss/${FEED_ID}`);
}); });
it("includes email entries as <item> elements", () => { it("includes email entries as <item> elements", () => {
const result = generateRssFeed(mockFeedConfig, mockEmails, BASE_URL, FEED_ID); const result = generateRssFeed(
mockFeedConfig,
mockEmails,
BASE_URL,
FEED_ID,
);
expect(result).toContain("<item>"); expect(result).toContain("<item>");
expect(result).toContain("Hello World"); expect(result).toContain("Hello World");
}); });
@@ -82,39 +114,74 @@ describe("generateRssFeed", () => {
describe("generateAtomFeed", () => { describe("generateAtomFeed", () => {
it("returns Atom 1.0 namespace", () => { it("returns Atom 1.0 namespace", () => {
const result = generateAtomFeed(mockFeedConfig, mockEmails, BASE_URL, FEED_ID); const result = generateAtomFeed(
mockFeedConfig,
mockEmails,
BASE_URL,
FEED_ID,
);
expect(result).toContain('xmlns="http://www.w3.org/2005/Atom"'); expect(result).toContain('xmlns="http://www.w3.org/2005/Atom"');
}); });
it("contains <feed> root element", () => { it("contains <feed> root element", () => {
const result = generateAtomFeed(mockFeedConfig, mockEmails, BASE_URL, FEED_ID); const result = generateAtomFeed(
mockFeedConfig,
mockEmails,
BASE_URL,
FEED_ID,
);
expect(result).toContain("<feed"); expect(result).toContain("<feed");
expect(result).toContain("</feed>"); expect(result).toContain("</feed>");
}); });
it("includes feed title", () => { it("includes feed title", () => {
const result = generateAtomFeed(mockFeedConfig, mockEmails, BASE_URL, FEED_ID); const result = generateAtomFeed(
mockFeedConfig,
mockEmails,
BASE_URL,
FEED_ID,
);
expect(result).toContain("Test Newsletter"); expect(result).toContain("Test Newsletter");
}); });
it("includes <entry> elements for each email", () => { it("includes <entry> elements for each email", () => {
const result = generateAtomFeed(mockFeedConfig, mockEmails, BASE_URL, FEED_ID); const result = generateAtomFeed(
mockFeedConfig,
mockEmails,
BASE_URL,
FEED_ID,
);
expect(result).toContain("<entry>"); expect(result).toContain("<entry>");
expect(result).toContain("Hello World"); expect(result).toContain("Hello World");
}); });
it("includes author information", () => { it("includes author information", () => {
const result = generateAtomFeed(mockFeedConfig, mockEmails, BASE_URL, FEED_ID); const result = generateAtomFeed(
mockFeedConfig,
mockEmails,
BASE_URL,
FEED_ID,
);
expect(result).toContain("Alice"); expect(result).toContain("Alice");
}); });
it("self-link points to atom URL", () => { it("self-link points to atom URL", () => {
const result = generateAtomFeed(mockFeedConfig, mockEmails, BASE_URL, FEED_ID); const result = generateAtomFeed(
mockFeedConfig,
mockEmails,
BASE_URL,
FEED_ID,
);
expect(result).toContain(`${BASE_URL}/atom/${FEED_ID}`); expect(result).toContain(`${BASE_URL}/atom/${FEED_ID}`);
}); });
it("includes rss alternate link", () => { it("includes rss alternate link", () => {
const result = generateAtomFeed(mockFeedConfig, mockEmails, BASE_URL, FEED_ID); const result = generateAtomFeed(
mockFeedConfig,
mockEmails,
BASE_URL,
FEED_ID,
);
expect(result).toContain(`${BASE_URL}/rss/${FEED_ID}`); expect(result).toContain(`${BASE_URL}/rss/${FEED_ID}`);
}); });
@@ -125,26 +192,49 @@ describe("generateAtomFeed", () => {
}); });
it("handles config without description", () => { it("handles config without description", () => {
const configNoDesc: FeedConfig = { ...mockFeedConfig, description: undefined }; const configNoDesc: FeedConfig = {
const result = generateAtomFeed(configNoDesc, mockEmails, BASE_URL, FEED_ID); ...mockFeedConfig,
description: undefined,
};
const result = generateAtomFeed(
configNoDesc,
mockEmails,
BASE_URL,
FEED_ID,
);
expect(result).toContain('xmlns="http://www.w3.org/2005/Atom"'); expect(result).toContain('xmlns="http://www.w3.org/2005/Atom"');
}); });
it("handles config with author field", () => { it("handles config with author field", () => {
const configWithAuthor: FeedConfig = { ...mockFeedConfig, author: "Bob" }; const configWithAuthor: FeedConfig = { ...mockFeedConfig, author: "Bob" };
const result = generateAtomFeed(configWithAuthor, mockEmails, BASE_URL, FEED_ID); const result = generateAtomFeed(
configWithAuthor,
mockEmails,
BASE_URL,
FEED_ID,
);
expect(result).toContain("Bob"); expect(result).toContain("Bob");
}); });
it("includes enclosure link for email with attachment in Atom feed", () => { it("includes enclosure link for email with attachment in Atom feed", () => {
const result = generateAtomFeed(mockFeedConfig, [mockEmailWithAttachment], BASE_URL, FEED_ID); const result = generateAtomFeed(
mockFeedConfig,
[mockEmailWithAttachment],
BASE_URL,
FEED_ID,
);
expect(result).toContain('rel="enclosure"'); expect(result).toContain('rel="enclosure"');
expect(result).toContain("550e8400-e29b-41d4-a716-446655440000"); expect(result).toContain("550e8400-e29b-41d4-a716-446655440000");
expect(result).toContain("report.pdf"); expect(result).toContain("report.pdf");
}); });
it("does not include enclosure link for email without attachments in Atom feed", () => { it("does not include enclosure link for email without attachments in Atom feed", () => {
const result = generateAtomFeed(mockFeedConfig, mockEmails, BASE_URL, FEED_ID); const result = generateAtomFeed(
mockFeedConfig,
mockEmails,
BASE_URL,
FEED_ID,
);
expect(result).not.toContain('rel="enclosure"'); expect(result).not.toContain('rel="enclosure"');
}); });
}); });