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:
@@ -30,10 +30,10 @@ Email-to-RSS keeps the same workflow while avoiding shared domains and shared da
|
||||
|
||||
Two ingestion methods are supported — pick one or use both:
|
||||
|
||||
| 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 |
|
||||
| **ForwardEmail webhook** | ForwardEmail parses the message and POSTs a JSON payload to `POST /api/inbound`; the Worker verifies the source IP before processing |
|
||||
| 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 |
|
||||
| **ForwardEmail webhook** | ForwardEmail parses the message and POSTs a JSON payload to `POST /api/inbound`; the Worker verifies the source IP before processing |
|
||||
|
||||
Common path:
|
||||
|
||||
@@ -60,7 +60,7 @@ Main routes:
|
||||
|
||||
## 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
|
||||
|
||||
@@ -70,9 +70,11 @@ If your domain is not yet on Cloudflare: in the [Cloudflare dashboard](https://d
|
||||
npx wrangler login
|
||||
```
|
||||
3. Run setup:
|
||||
|
||||
```bash
|
||||
bash setup.sh
|
||||
```
|
||||
|
||||
The script will prompt for an admin password and your domain, then:
|
||||
- install npm dependencies
|
||||
- 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.
|
||||
|
||||
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:
|
||||
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:
|
||||
- Action: **Send to Worker**
|
||||
- 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.).
|
||||
|
||||
Add these DNS records in Cloudflare (*DNS → Records*):
|
||||
Add these DNS records in Cloudflare (_DNS → Records_):
|
||||
|
||||
| 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.
|
||||
|
||||
5. Deploy:
|
||||
|
||||
```bash
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
@@ -153,7 +157,7 @@ This feature is **optional**. If no R2 bucket is bound, attachments are silently
|
||||
|
||||
**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
|
||||
npx wrangler r2 bucket create your-bucket-name
|
||||
```
|
||||
@@ -177,8 +181,8 @@ Instead of the built-in password login you can delegate admin authentication to
|
||||
|
||||
**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 |
|
||||
|
||||
**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:
|
||||
|
||||
1. `CF-Connecting-IP` is in `PROXY_TRUSTED_IPS`
|
||||
2. The `X-Auth-Proxy-Secret` header matches `PROXY_AUTH_SECRET`
|
||||
3. `Remote-User` or `X-Forwarded-User` is non-empty
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import "../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_TO = `${VALID_FEED_ID}@test.getmynews.app`;
|
||||
@@ -139,25 +143,53 @@ describe("processEmail", () => {
|
||||
const oldKey1 = `feed:${VALID_FEED_ID}:111`;
|
||||
const oldKey2 = `feed:${VALID_FEED_ID}:222`;
|
||||
const bigContent = "x".repeat(200);
|
||||
const email1 = JSON.stringify({ 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: {} });
|
||||
const email1 = JSON.stringify({
|
||||
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(oldKey2, email2);
|
||||
await env.EMAIL_STORAGE.put(
|
||||
`feed:${VALID_FEED_ID}:metadata`,
|
||||
JSON.stringify({
|
||||
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 res = await processEmail(makeInput({ subject: "New" }), tinyEnv as any);
|
||||
const res = await processEmail(
|
||||
makeInput({ subject: "New" }),
|
||||
tinyEnv as any,
|
||||
);
|
||||
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[0].subject).toBe("New");
|
||||
|
||||
@@ -175,13 +207,17 @@ describe("processEmail", () => {
|
||||
const bigEnv = { ...env, FEED_MAX_SIZE_BYTES: String(10 * 1024 * 1024) };
|
||||
await processEmail(makeInput({ subject: "First" }), 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);
|
||||
});
|
||||
});
|
||||
|
||||
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 = {
|
||||
filename: "report.pdf",
|
||||
@@ -205,7 +241,10 @@ describe("processEmail — attachments", () => {
|
||||
`feed:${VALID_FEED_ID}:metadata`,
|
||||
"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();
|
||||
});
|
||||
|
||||
@@ -226,7 +265,10 @@ describe("processEmail — attachments", () => {
|
||||
`feed:${VALID_FEED_ID}:metadata`,
|
||||
"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[0].filename).toBe("report.pdf");
|
||||
@@ -271,7 +313,14 @@ describe("processEmail — attachments", () => {
|
||||
content: bigContent,
|
||||
receivedAt: 111,
|
||||
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);
|
||||
|
||||
@@ -295,7 +344,10 @@ describe("processEmail — attachments", () => {
|
||||
|
||||
// Process with tight size budget to force trimming
|
||||
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);
|
||||
|
||||
// Old attachment should be deleted from R2
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
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 {
|
||||
filename: string;
|
||||
@@ -151,7 +157,10 @@ export async function processEmail(
|
||||
};
|
||||
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[] = [];
|
||||
while (totalSize > maxBytes && feedMetadata.emails.length > 1) {
|
||||
const dropped = feedMetadata.emails.pop()!;
|
||||
|
||||
@@ -6,10 +6,7 @@ export interface ForwardEmailAttachment {
|
||||
filename?: string;
|
||||
contentType?: string;
|
||||
size?: number;
|
||||
content?:
|
||||
| { type: "Buffer"; data: number[] }
|
||||
| ArrayBuffer
|
||||
| ArrayBufferView;
|
||||
content?: { type: "Buffer"; data: number[] } | ArrayBuffer | ArrayBufferView;
|
||||
}
|
||||
|
||||
export interface ForwardEmailPayload {
|
||||
@@ -56,8 +53,13 @@ function toArrayBuffer(
|
||||
): ArrayBuffer | null {
|
||||
if (!content) return null;
|
||||
if (content instanceof ArrayBuffer) return content;
|
||||
if (ArrayBuffer.isView(content)) return (content as ArrayBufferView).buffer as ArrayBuffer;
|
||||
if (typeof content === "object" && content.type === "Buffer" && Array.isArray(content.data)) {
|
||||
if (ArrayBuffer.isView(content))
|
||||
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 null;
|
||||
|
||||
@@ -14,7 +14,8 @@ describe("Admin Routes", () => {
|
||||
mockEnv = createMockEnv() as unknown as Env;
|
||||
testApp = new Hono();
|
||||
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 () => {
|
||||
const formData = new FormData();
|
||||
formData.append("password", "test-password");
|
||||
@@ -366,7 +367,10 @@ describe("Admin Routes", () => {
|
||||
} as unknown as Env;
|
||||
}
|
||||
|
||||
function makeProxyRequest(path: string, headers: Record<string, string> = {}) {
|
||||
function makeProxyRequest(
|
||||
path: string,
|
||||
headers: Record<string, string> = {},
|
||||
) {
|
||||
const proxyApp = new Hono();
|
||||
proxyApp.route("/admin", app);
|
||||
return proxyApp.request(path, { headers }, proxyEnv());
|
||||
|
||||
@@ -68,7 +68,11 @@ describe("Atom Feed Route", () => {
|
||||
`feed:${FEED_ID}:metadata`,
|
||||
JSON.stringify({
|
||||
emails: [
|
||||
{ key: emailKey, subject: "Atom Entry Subject", receivedAt: 1700000001000 },
|
||||
{
|
||||
key: emailKey,
|
||||
subject: "Atom Entry Subject",
|
||||
receivedAt: 1700000001000,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
+49
-23
@@ -21,7 +21,9 @@ export async function handle(c: Context): Promise<Response> {
|
||||
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) {
|
||||
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'",
|
||||
);
|
||||
|
||||
return c.html(html`<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>${emailData.subject}</title>
|
||||
<style>
|
||||
body { font-family: sans-serif; max-width: 800px; 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>
|
||||
</head>
|
||||
<body>
|
||||
<h1>${emailData.subject}</h1>
|
||||
<dl class="meta">
|
||||
<dt>From:</dt><dd>${emailData.from}</dd>
|
||||
<dt>Date:</dt><dd>${new Date(emailData.receivedAt).toUTCString()}</dd>
|
||||
</dl>
|
||||
<div class="content">${raw(emailData.content)}</div>
|
||||
</body>
|
||||
</html>`);
|
||||
return c.html(
|
||||
html`<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0"
|
||||
/>
|
||||
<title>${emailData.subject}</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: sans-serif;
|
||||
max-width: 800px;
|
||||
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>
|
||||
</head>
|
||||
<body>
|
||||
<h1>${emailData.subject}</h1>
|
||||
<dl class="meta">
|
||||
<dt>From:</dt>
|
||||
<dd>${emailData.from}</dd>
|
||||
<dt>Date:</dt>
|
||||
<dd>${new Date(emailData.receivedAt).toUTCString()}</dd>
|
||||
</dl>
|
||||
<div class="content">${raw(emailData.content)}</div>
|
||||
</body>
|
||||
</html>`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -39,7 +39,8 @@ describe("GET /files/:attachmentId/:filename", () => {
|
||||
});
|
||||
|
||||
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, {
|
||||
httpMetadata: { contentType: "application/pdf" },
|
||||
});
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { Context } from "hono";
|
||||
import { Env } from "../types";
|
||||
import {
|
||||
ForwardEmailPayload,
|
||||
handleForwardEmail,
|
||||
} from "../lib/forwardemail";
|
||||
import { ForwardEmailPayload, handleForwardEmail } from "../lib/forwardemail";
|
||||
|
||||
export async function handle(c: Context): Promise<Response> {
|
||||
try {
|
||||
|
||||
@@ -53,15 +53,15 @@ describe("EmailParser.decodeEncodedWords", () => {
|
||||
// =?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
|
||||
// =?US-ASCII?Q?Hello=20World?= → "Hello World" (=20 → space, _ → space)
|
||||
expect(
|
||||
EmailParser.decodeEncodedWords("=?US-ASCII?Q?Hello=20World?="),
|
||||
).toBe("Hello World");
|
||||
expect(EmailParser.decodeEncodedWords("=?US-ASCII?Q?Hello=20World?=")).toBe(
|
||||
"Hello World",
|
||||
);
|
||||
});
|
||||
|
||||
it("decodes underscores as spaces in QP encoding", () => {
|
||||
expect(
|
||||
EmailParser.decodeEncodedWords("=?US-ASCII?Q?Hello_World?="),
|
||||
).toBe("Hello World");
|
||||
expect(EmailParser.decodeEncodedWords("=?US-ASCII?Q?Hello_World?=")).toBe(
|
||||
"Hello World",
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves unrecognised encoded-word syntax unchanged", () => {
|
||||
@@ -79,9 +79,7 @@ describe("EmailParser.parseForwardEmailPayload", () => {
|
||||
});
|
||||
|
||||
it("throws on undefined payload", () => {
|
||||
expect(() =>
|
||||
EmailParser.parseForwardEmailPayload(undefined),
|
||||
).toThrow();
|
||||
expect(() => EmailParser.parseForwardEmailPayload(undefined)).toThrow();
|
||||
});
|
||||
|
||||
it("parses subject, from, and HTML content", () => {
|
||||
@@ -95,7 +93,9 @@ describe("EmailParser.parseForwardEmailPayload", () => {
|
||||
expect(result.subject).toBe("Test Subject");
|
||||
expect(result.from).toBe("sender@example.com");
|
||||
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", () => {
|
||||
@@ -137,14 +137,18 @@ describe("EmailParser.parseForwardEmailPayload", () => {
|
||||
|
||||
it("uses Date.now() when date field is absent", () => {
|
||||
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();
|
||||
expect(result.receivedAt).toBeGreaterThanOrEqual(before);
|
||||
expect(result.receivedAt).toBeLessThanOrEqual(after);
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
|
||||
|
||||
@@ -38,13 +38,23 @@ const FEED_ID = "abc123";
|
||||
|
||||
describe("generateRssFeed", () => {
|
||||
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("<title>Test Newsletter</title>");
|
||||
});
|
||||
|
||||
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("550e8400-e29b-41d4-a716-446655440000");
|
||||
expect(result).toContain("report.pdf");
|
||||
@@ -53,22 +63,44 @@ describe("generateRssFeed", () => {
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
|
||||
it("enclosure URL uses /files/{id}/{filename} scheme", () => {
|
||||
const result = generateRssFeed(mockFeedConfig, [mockEmailWithAttachment], BASE_URL, FEED_ID);
|
||||
expect(result).toContain(`${BASE_URL}/files/550e8400-e29b-41d4-a716-446655440000/report.pdf`);
|
||||
const result = generateRssFeed(
|
||||
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", () => {
|
||||
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}`);
|
||||
});
|
||||
|
||||
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("Hello World");
|
||||
});
|
||||
@@ -82,39 +114,74 @@ describe("generateRssFeed", () => {
|
||||
|
||||
describe("generateAtomFeed", () => {
|
||||
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"');
|
||||
});
|
||||
|
||||
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>");
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
|
||||
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("Hello World");
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
|
||||
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}`);
|
||||
});
|
||||
|
||||
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}`);
|
||||
});
|
||||
|
||||
@@ -125,26 +192,49 @@ describe("generateAtomFeed", () => {
|
||||
});
|
||||
|
||||
it("handles config without description", () => {
|
||||
const configNoDesc: FeedConfig = { ...mockFeedConfig, description: undefined };
|
||||
const result = generateAtomFeed(configNoDesc, mockEmails, BASE_URL, FEED_ID);
|
||||
const configNoDesc: FeedConfig = {
|
||||
...mockFeedConfig,
|
||||
description: undefined,
|
||||
};
|
||||
const result = generateAtomFeed(
|
||||
configNoDesc,
|
||||
mockEmails,
|
||||
BASE_URL,
|
||||
FEED_ID,
|
||||
);
|
||||
expect(result).toContain('xmlns="http://www.w3.org/2005/Atom"');
|
||||
});
|
||||
|
||||
it("handles config with author field", () => {
|
||||
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");
|
||||
});
|
||||
|
||||
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("550e8400-e29b-41d4-a716-446655440000");
|
||||
expect(result).toContain("report.pdf");
|
||||
});
|
||||
|
||||
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"');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user