mirror of
https://github.com/juherr/kill-the-news.git
synced 2026-06-20 22:03:48 +00:00
style: fix Prettier formatting on 11 files
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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: "a@b.com", content: bigContent, receivedAt: 111, headers: {} });
|
const email1 = JSON.stringify({
|
||||||
const email2 = JSON.stringify({ subject: "Old2", from: "a@b.com", content: bigContent, receivedAt: 222, headers: {} });
|
subject: "Old1",
|
||||||
|
from: "a@b.com",
|
||||||
|
content: bigContent,
|
||||||
|
receivedAt: 111,
|
||||||
|
headers: {},
|
||||||
|
});
|
||||||
|
const email2 = JSON.stringify({
|
||||||
|
subject: "Old2",
|
||||||
|
from: "a@b.com",
|
||||||
|
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
|
||||||
|
|||||||
@@ -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()!;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
@@ -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
@@ -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>`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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 {
|
||||||
|
|||||||
@@ -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("sender@example.com");
|
expect(result.from).toBe("sender@example.com");
|
||||||
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: "x@y.com" } });
|
const result = EmailParser.parseForwardEmailPayload({
|
||||||
|
from: { text: "x@y.com" },
|
||||||
|
});
|
||||||
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: "x@y.com" } });
|
const result = EmailParser.parseForwardEmailPayload({
|
||||||
|
from: { text: "x@y.com" },
|
||||||
|
});
|
||||||
expect(result.subject).toBe("No Subject");
|
expect(result.subject).toBe("No Subject");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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"');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user