style: fix Prettier formatting on 11 files

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Julien Herr
2026-05-21 11:35:37 +02:00
parent 3aea41f862
commit b24ee969d1
11 changed files with 289 additions and 95 deletions
+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
+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: "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
+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());
+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,
},
], ],
}), }),
); );
+42 -16
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> html`<!DOCTYPE html>
<head> <html>
<meta charset="UTF-8"> <head>
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta charset="UTF-8" />
<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 {
+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("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");
}); });
+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"');
}); });
}); });