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
+17 -12
View File
@@ -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
+65 -13
View File
@@ -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
+11 -2
View File
@@ -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()!;
+8 -6
View File
@@ -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;
+6 -2
View File
@@ -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());
+5 -1
View File
@@ -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
View File
@@ -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>`,
);
}
+2 -1
View File
@@ -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 -4
View File
@@ -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 {
+16 -12
View File
@@ -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");
});
+109 -19
View File
@@ -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"');
});
});