Merge pull request #1 from juherr/feat/add-eslint-lint-staged
feat: add ESLint, lint-staged, and update pre-commit hook + CI
This commit is contained in:
22 files changed
+2078
-115
No files matched your search
@@ -21,7 +21,10 @@ jobs:
|
|||||||
- run: npm ci
|
- run: npm ci
|
||||||
|
|
||||||
- name: Format check
|
- name: Format check
|
||||||
run: npx prettier --check '**/*.{js,ts,css,json,md}'
|
run: npm run format:check
|
||||||
|
|
||||||
|
- name: Lint
|
||||||
|
run: npm run lint
|
||||||
|
|
||||||
- name: Type check
|
- name: Type check
|
||||||
run: npm run typecheck
|
run: npm run typecheck
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
npx lint-staged && npm run typecheck && npm test
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import prettier from "eslint-config-prettier";
|
||||||
|
import tseslint from "typescript-eslint";
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{ ignores: ["dist/", "coverage/"] },
|
||||||
|
...tseslint.configs.recommended,
|
||||||
|
prettier,
|
||||||
|
{
|
||||||
|
rules: {
|
||||||
|
"@typescript-eslint/no-unused-vars": [
|
||||||
|
"error",
|
||||||
|
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ["src/**/*.test.ts", "src/test/**/*.ts"],
|
||||||
|
rules: {
|
||||||
|
"@typescript-eslint/no-explicit-any": "off",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
Generated
+1708
-6
File diff suppressed because it is too large.
Load diff
+18
-1
@@ -6,12 +6,24 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "wrangler deploy --dry-run --outdir=dist",
|
"build": "wrangler deploy --dry-run --outdir=dist",
|
||||||
"format": "prettier --write '**/*.{js,ts,css,json,md}'",
|
"format": "prettier --write '**/*.{js,ts,css,json,md}'",
|
||||||
|
"format:check": "prettier --check '**/*.{js,ts,css,json,md}'",
|
||||||
|
"lint": "eslint src",
|
||||||
"dev": "wrangler dev",
|
"dev": "wrangler dev",
|
||||||
"deploy": "wrangler deploy --env production",
|
"deploy": "wrangler deploy --env production",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"test:coverage": "vitest run --coverage",
|
"test:coverage": "vitest run --coverage",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit",
|
||||||
|
"prepare": "husky"
|
||||||
|
},
|
||||||
|
"lint-staged": {
|
||||||
|
"*.{ts,js}": [
|
||||||
|
"eslint --fix",
|
||||||
|
"prettier --write"
|
||||||
|
],
|
||||||
|
"*.{json,md,css}": [
|
||||||
|
"prettier --write"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"author": "",
|
"author": "",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -20,10 +32,15 @@
|
|||||||
"@types/mailparser": "^3.4.6",
|
"@types/mailparser": "^3.4.6",
|
||||||
"@types/rss": "^0.0.32",
|
"@types/rss": "^0.0.32",
|
||||||
"@vitest/coverage-v8": "^4.0.18",
|
"@vitest/coverage-v8": "^4.0.18",
|
||||||
|
"eslint": "^10.0.0",
|
||||||
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"happy-dom": "^20.5.0",
|
"happy-dom": "^20.5.0",
|
||||||
|
"husky": "^9.1.7",
|
||||||
|
"lint-staged": "^17.0.5",
|
||||||
"msw": "^2.12.8",
|
"msw": "^2.12.8",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
|
"typescript-eslint": "^8.59.4",
|
||||||
"vitest": "^4.0.18",
|
"vitest": "^4.0.18",
|
||||||
"wrangler": "^4.63.0"
|
"wrangler": "^4.63.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { processEmail, RawAttachment } from "./email-processor";
|
|||||||
export async function handleCloudflareEmail(
|
export async function handleCloudflareEmail(
|
||||||
message: ForwardableEmailMessage,
|
message: ForwardableEmailMessage,
|
||||||
env: Env,
|
env: Env,
|
||||||
ctx: ExecutionContext,
|
_ctx: ExecutionContext,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const email = await PostalMime.parse(message.raw);
|
const email = await PostalMime.parse(message.raw);
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import { describe, it, expect, beforeEach } from "vitest";
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
import "../test/setup";
|
import "../test/setup";
|
||||||
import { createMockEnv, MockR2 } from "../test/setup";
|
import { createMockEnv, MockR2 } from "../test/setup";
|
||||||
import { processEmail, ProcessEmailInput, RawAttachment } from "./email-processor";
|
import {
|
||||||
|
processEmail,
|
||||||
|
ProcessEmailInput,
|
||||||
|
RawAttachment,
|
||||||
|
} from "./email-processor";
|
||||||
|
|
||||||
const VALID_FEED_ID = "apple.mountain.42";
|
const VALID_FEED_ID = "apple.mountain.42";
|
||||||
const VALID_TO = `${VALID_FEED_ID}@test.getmynews.app`;
|
const VALID_TO = `${VALID_FEED_ID}@test.getmynews.app`;
|
||||||
@@ -139,25 +143,53 @@ describe("processEmail", () => {
|
|||||||
const oldKey1 = `feed:${VALID_FEED_ID}:111`;
|
const oldKey1 = `feed:${VALID_FEED_ID}:111`;
|
||||||
const oldKey2 = `feed:${VALID_FEED_ID}:222`;
|
const oldKey2 = `feed:${VALID_FEED_ID}:222`;
|
||||||
const bigContent = "x".repeat(200);
|
const bigContent = "x".repeat(200);
|
||||||
const email1 = JSON.stringify({ subject: "Old1", from: "[email protected]", content: bigContent, receivedAt: 111, headers: {} });
|
const email1 = JSON.stringify({
|
||||||
const email2 = JSON.stringify({ subject: "Old2", from: "[email protected]", content: bigContent, receivedAt: 222, headers: {} });
|
subject: "Old1",
|
||||||
|
from: "[email protected]",
|
||||||
|
content: bigContent,
|
||||||
|
receivedAt: 111,
|
||||||
|
headers: {},
|
||||||
|
});
|
||||||
|
const email2 = JSON.stringify({
|
||||||
|
subject: "Old2",
|
||||||
|
from: "[email protected]",
|
||||||
|
content: bigContent,
|
||||||
|
receivedAt: 222,
|
||||||
|
headers: {},
|
||||||
|
});
|
||||||
await env.EMAIL_STORAGE.put(oldKey1, email1);
|
await env.EMAIL_STORAGE.put(oldKey1, email1);
|
||||||
await env.EMAIL_STORAGE.put(oldKey2, email2);
|
await env.EMAIL_STORAGE.put(oldKey2, email2);
|
||||||
await env.EMAIL_STORAGE.put(
|
await env.EMAIL_STORAGE.put(
|
||||||
`feed:${VALID_FEED_ID}:metadata`,
|
`feed:${VALID_FEED_ID}:metadata`,
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
emails: [
|
emails: [
|
||||||
{ key: oldKey2, subject: "Old2", receivedAt: 222, size: email2.length },
|
{
|
||||||
{ key: oldKey1, subject: "Old1", receivedAt: 111, size: email1.length },
|
key: oldKey2,
|
||||||
|
subject: "Old2",
|
||||||
|
receivedAt: 222,
|
||||||
|
size: email2.length,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: oldKey1,
|
||||||
|
subject: "Old1",
|
||||||
|
receivedAt: 111,
|
||||||
|
size: email1.length,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const tinyEnv = { ...env, FEED_MAX_SIZE_BYTES: "50" };
|
const tinyEnv = { ...env, FEED_MAX_SIZE_BYTES: "50" };
|
||||||
const res = await processEmail(makeInput({ subject: "New" }), tinyEnv as any);
|
const res = await processEmail(
|
||||||
|
makeInput({ subject: "New" }),
|
||||||
|
tinyEnv as any,
|
||||||
|
);
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
const metadata = await env.EMAIL_STORAGE.get(`feed:${VALID_FEED_ID}:metadata`, "json");
|
const metadata = await env.EMAIL_STORAGE.get(
|
||||||
|
`feed:${VALID_FEED_ID}:metadata`,
|
||||||
|
"json",
|
||||||
|
);
|
||||||
expect(metadata.emails).toHaveLength(1);
|
expect(metadata.emails).toHaveLength(1);
|
||||||
expect(metadata.emails[0].subject).toBe("New");
|
expect(metadata.emails[0].subject).toBe("New");
|
||||||
|
|
||||||
@@ -175,13 +207,17 @@ describe("processEmail", () => {
|
|||||||
const bigEnv = { ...env, FEED_MAX_SIZE_BYTES: String(10 * 1024 * 1024) };
|
const bigEnv = { ...env, FEED_MAX_SIZE_BYTES: String(10 * 1024 * 1024) };
|
||||||
await processEmail(makeInput({ subject: "First" }), bigEnv as any);
|
await processEmail(makeInput({ subject: "First" }), bigEnv as any);
|
||||||
await processEmail(makeInput({ subject: "Second" }), bigEnv as any);
|
await processEmail(makeInput({ subject: "Second" }), bigEnv as any);
|
||||||
const metadata = await env.EMAIL_STORAGE.get(`feed:${VALID_FEED_ID}:metadata`, "json");
|
const metadata = await env.EMAIL_STORAGE.get(
|
||||||
|
`feed:${VALID_FEED_ID}:metadata`,
|
||||||
|
"json",
|
||||||
|
);
|
||||||
expect(metadata.emails).toHaveLength(2);
|
expect(metadata.emails).toHaveLength(2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("processEmail — attachments", () => {
|
describe("processEmail — attachments", () => {
|
||||||
const pdfContent = new TextEncoder().encode("PDF bytes").buffer as ArrayBuffer;
|
const pdfContent = new TextEncoder().encode("PDF bytes")
|
||||||
|
.buffer as ArrayBuffer;
|
||||||
|
|
||||||
const pdfAttachment: RawAttachment = {
|
const pdfAttachment: RawAttachment = {
|
||||||
filename: "report.pdf",
|
filename: "report.pdf",
|
||||||
@@ -205,7 +241,10 @@ describe("processEmail — attachments", () => {
|
|||||||
`feed:${VALID_FEED_ID}:metadata`,
|
`feed:${VALID_FEED_ID}:metadata`,
|
||||||
"json",
|
"json",
|
||||||
);
|
);
|
||||||
const emailData = await env.EMAIL_STORAGE.get(metadata.emails[0].key, "json");
|
const emailData = await env.EMAIL_STORAGE.get(
|
||||||
|
metadata.emails[0].key,
|
||||||
|
"json",
|
||||||
|
);
|
||||||
expect(emailData.attachments).toBeUndefined();
|
expect(emailData.attachments).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -226,7 +265,10 @@ describe("processEmail — attachments", () => {
|
|||||||
`feed:${VALID_FEED_ID}:metadata`,
|
`feed:${VALID_FEED_ID}:metadata`,
|
||||||
"json",
|
"json",
|
||||||
);
|
);
|
||||||
const emailData = await env.EMAIL_STORAGE.get(metadata.emails[0].key, "json");
|
const emailData = await env.EMAIL_STORAGE.get(
|
||||||
|
metadata.emails[0].key,
|
||||||
|
"json",
|
||||||
|
);
|
||||||
|
|
||||||
expect(emailData.attachments).toHaveLength(1);
|
expect(emailData.attachments).toHaveLength(1);
|
||||||
expect(emailData.attachments[0].filename).toBe("report.pdf");
|
expect(emailData.attachments[0].filename).toBe("report.pdf");
|
||||||
@@ -271,7 +313,14 @@ describe("processEmail — attachments", () => {
|
|||||||
content: bigContent,
|
content: bigContent,
|
||||||
receivedAt: 111,
|
receivedAt: 111,
|
||||||
headers: {},
|
headers: {},
|
||||||
attachments: [{ id: oldAttachmentId, filename: "old.pdf", contentType: "application/pdf", size: 100 }],
|
attachments: [
|
||||||
|
{
|
||||||
|
id: oldAttachmentId,
|
||||||
|
filename: "old.pdf",
|
||||||
|
contentType: "application/pdf",
|
||||||
|
size: 100,
|
||||||
|
},
|
||||||
|
],
|
||||||
});
|
});
|
||||||
await env.EMAIL_STORAGE.put(oldKey, oldEmail);
|
await env.EMAIL_STORAGE.put(oldKey, oldEmail);
|
||||||
|
|
||||||
@@ -295,7 +344,10 @@ describe("processEmail — attachments", () => {
|
|||||||
|
|
||||||
// Process with tight size budget to force trimming
|
// Process with tight size budget to force trimming
|
||||||
const tinyEnv = { ...env, FEED_MAX_SIZE_BYTES: "50" };
|
const tinyEnv = { ...env, FEED_MAX_SIZE_BYTES: "50" };
|
||||||
const res = await processEmail(makeInput({ subject: "New" }), tinyEnv as any);
|
const res = await processEmail(
|
||||||
|
makeInput({ subject: "New" }),
|
||||||
|
tinyEnv as any,
|
||||||
|
);
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
// Old attachment should be deleted from R2
|
// Old attachment should be deleted from R2
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
+26
-8
@@ -71,9 +71,11 @@ function timingSafeEqual(a: string, b: string): boolean {
|
|||||||
const aBytes = enc.encode(a);
|
const aBytes = enc.encode(a);
|
||||||
const bBytes = enc.encode(b);
|
const bBytes = enc.encode(b);
|
||||||
// Try native timing-safe implementation first (Cloudflare Workers runtime)
|
// Try native timing-safe implementation first (Cloudflare Workers runtime)
|
||||||
if (typeof (crypto.subtle as any).timingSafeEqual === "function") {
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const subtle = crypto.subtle as any;
|
||||||
|
if (typeof subtle.timingSafeEqual === "function") {
|
||||||
if (aBytes.length !== bBytes.length) return false;
|
if (aBytes.length !== bBytes.length) return false;
|
||||||
return (crypto.subtle as any).timingSafeEqual(aBytes, bBytes);
|
return subtle.timingSafeEqual(aBytes, bBytes);
|
||||||
}
|
}
|
||||||
// Constant-time fallback for Node (test environment): encode length
|
// Constant-time fallback for Node (test environment): encode length
|
||||||
// mismatch into `diff` so the loop always runs over the full length.
|
// mismatch into `diff` so the loop always runs over the full length.
|
||||||
@@ -97,7 +99,9 @@ async function authMiddleware(c: Context, next: () => Promise<void>) {
|
|||||||
|
|
||||||
// Proxy auth: only active when both env vars are present
|
// Proxy auth: only active when both env vars are present
|
||||||
if (env.PROXY_AUTH_SECRET && env.PROXY_TRUSTED_IPS) {
|
if (env.PROXY_AUTH_SECRET && env.PROXY_TRUSTED_IPS) {
|
||||||
const trustedIps = env.PROXY_TRUSTED_IPS.split(",").map((s) => s.trim()).filter(Boolean);
|
const trustedIps = env.PROXY_TRUSTED_IPS.split(",")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
const clientIp = c.req.header("CF-Connecting-IP") ?? "";
|
const clientIp = c.req.header("CF-Connecting-IP") ?? "";
|
||||||
const providedSecret = c.req.header("X-Auth-Proxy-Secret") ?? "";
|
const providedSecret = c.req.header("X-Auth-Proxy-Secret") ?? "";
|
||||||
const remoteUser =
|
const remoteUser =
|
||||||
@@ -113,7 +117,11 @@ async function authMiddleware(c: Context, next: () => Promise<void>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: signed cookie
|
// Fallback: signed cookie
|
||||||
const authCookie = await getSignedCookie(c, env.ADMIN_PASSWORD, ADMIN_COOKIE_NAME);
|
const authCookie = await getSignedCookie(
|
||||||
|
c,
|
||||||
|
env.ADMIN_PASSWORD,
|
||||||
|
ADMIN_COOKIE_NAME,
|
||||||
|
);
|
||||||
if (authCookie !== "1") {
|
if (authCookie !== "1") {
|
||||||
return c.redirect("/admin/login");
|
return c.redirect("/admin/login");
|
||||||
}
|
}
|
||||||
@@ -146,6 +154,7 @@ const authSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Base HTML layout with design system
|
// Base HTML layout with design system
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const layout = (title: string, content: any) => {
|
const layout = (title: string, content: any) => {
|
||||||
return html`<!DOCTYPE html>
|
return html`<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
@@ -1610,11 +1619,14 @@ app.post("/feeds/create", async (c) => {
|
|||||||
if (isJson) {
|
if (isJson) {
|
||||||
const body = await c.req.json<Record<string, unknown>>();
|
const body = await c.req.json<Record<string, unknown>>();
|
||||||
title = String(body.title ?? "");
|
title = String(body.title ?? "");
|
||||||
description = body.description != null ? String(body.description) : undefined;
|
description =
|
||||||
|
body.description != null ? String(body.description) : undefined;
|
||||||
language = String(body.language ?? "en");
|
language = String(body.language ?? "en");
|
||||||
view = "list";
|
view = "list";
|
||||||
allowedSenders = Array.isArray(body.allowedSenders)
|
allowedSenders = Array.isArray(body.allowedSenders)
|
||||||
? normalizeAllowedSenders((body.allowedSenders as unknown[]).map(String))
|
? normalizeAllowedSenders(
|
||||||
|
(body.allowedSenders as unknown[]).map(String),
|
||||||
|
)
|
||||||
: [];
|
: [];
|
||||||
} else {
|
} else {
|
||||||
const formData = await c.req.formData();
|
const formData = await c.req.formData();
|
||||||
@@ -1930,7 +1942,8 @@ async function purgeFeedKeysStep(
|
|||||||
});
|
});
|
||||||
if (emailKeys.length > 0) {
|
if (emailKeys.length > 0) {
|
||||||
const emailDataResults = await Promise.allSettled(
|
const emailDataResults = await Promise.allSettled(
|
||||||
emailKeys.map((k) =>
|
emailKeys.map(
|
||||||
|
(k) =>
|
||||||
emailStorage.get(k, { type: "json" }) as Promise<EmailData | null>,
|
emailStorage.get(k, { type: "json" }) as Promise<EmailData | null>,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -1976,7 +1989,12 @@ app.post("/feeds/:feedId/delete", async (c) => {
|
|||||||
|
|
||||||
// Best-effort cleanup in the background so the request stays fast.
|
// Best-effort cleanup in the background so the request stays fast.
|
||||||
// Use the UI purge endpoint for full, user-visible progress.
|
// Use the UI purge endpoint for full, user-visible progress.
|
||||||
waitUntilSafe(c, purgeFeedKeysStep(emailStorage, feedId, { bucket: env.ATTACHMENT_BUCKET }));
|
waitUntilSafe(
|
||||||
|
c,
|
||||||
|
purgeFeedKeysStep(emailStorage, feedId, {
|
||||||
|
bucket: env.ATTACHMENT_BUCKET,
|
||||||
|
}),
|
||||||
|
);
|
||||||
if (wantsJson) {
|
if (wantsJson) {
|
||||||
return c.json({ ok: true, feedId });
|
return c.json({ ok: true, feedId });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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>`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
+13
-10
@@ -71,7 +71,7 @@ class MockCache implements Cache {
|
|||||||
|
|
||||||
async match(
|
async match(
|
||||||
request: RequestInfo,
|
request: RequestInfo,
|
||||||
options?: CacheQueryOptions,
|
_options?: CacheQueryOptions,
|
||||||
): Promise<Response | undefined> {
|
): Promise<Response | undefined> {
|
||||||
const key = request instanceof Request ? request.url : request;
|
const key = request instanceof Request ? request.url : request;
|
||||||
const response = this.store.get(key);
|
const response = this.store.get(key);
|
||||||
@@ -80,7 +80,7 @@ class MockCache implements Cache {
|
|||||||
|
|
||||||
async delete(
|
async delete(
|
||||||
request: RequestInfo,
|
request: RequestInfo,
|
||||||
options?: CacheQueryOptions,
|
_options?: CacheQueryOptions,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const key = request instanceof Request ? request.url : request;
|
const key = request instanceof Request ? request.url : request;
|
||||||
return this.store.delete(key);
|
return this.store.delete(key);
|
||||||
@@ -107,7 +107,6 @@ beforeAll(() => {
|
|||||||
server.listen({ onUnhandledRequest: "error" });
|
server.listen({ onUnhandledRequest: "error" });
|
||||||
|
|
||||||
// Type-safe access to Node's global object for setting Workers-like globals in tests
|
// Type-safe access to Node's global object for setting Workers-like globals in tests
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
const g = globalThis as any;
|
const g = globalThis as any;
|
||||||
|
|
||||||
// Mock Cloudflare Workers runtime globals
|
// Mock Cloudflare Workers runtime globals
|
||||||
@@ -118,31 +117,26 @@ beforeAll(() => {
|
|||||||
|
|
||||||
// Mock crypto for generating random values
|
// Mock crypto for generating random values
|
||||||
if (!g.crypto) {
|
if (!g.crypto) {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
||||||
g.crypto = require("crypto").webcrypto;
|
g.crypto = require("crypto").webcrypto;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure other required globals are available
|
// Ensure other required globals are available
|
||||||
if (!g.FormData) {
|
if (!g.FormData) {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
||||||
const { FormData } = require("undici");
|
const { FormData } = require("undici");
|
||||||
g.FormData = FormData;
|
g.FormData = FormData;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!g.Headers) {
|
if (!g.Headers) {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
||||||
const { Headers } = require("undici");
|
const { Headers } = require("undici");
|
||||||
g.Headers = Headers;
|
g.Headers = Headers;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!g.Request) {
|
if (!g.Request) {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
||||||
const { Request } = require("undici");
|
const { Request } = require("undici");
|
||||||
g.Request = Request;
|
g.Request = Request;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!g.Response) {
|
if (!g.Response) {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
||||||
const { Response } = require("undici");
|
const { Response } = require("undici");
|
||||||
g.Response = Response;
|
g.Response = Response;
|
||||||
}
|
}
|
||||||
@@ -173,7 +167,13 @@ export class MockR2 {
|
|||||||
|
|
||||||
async put(
|
async put(
|
||||||
key: string,
|
key: string,
|
||||||
value: ReadableStream | ArrayBuffer | ArrayBufferView | string | null | Blob,
|
value:
|
||||||
|
| ReadableStream
|
||||||
|
| ArrayBuffer
|
||||||
|
| ArrayBufferView
|
||||||
|
| string
|
||||||
|
| null
|
||||||
|
| Blob,
|
||||||
options?: {
|
options?: {
|
||||||
httpMetadata?: { contentType?: string; contentDisposition?: string };
|
httpMetadata?: { contentType?: string; contentDisposition?: string };
|
||||||
},
|
},
|
||||||
@@ -216,7 +216,10 @@ export class MockR2 {
|
|||||||
headers.set("Content-Type", entry.httpMetadata.contentType);
|
headers.set("Content-Type", entry.httpMetadata.contentType);
|
||||||
}
|
}
|
||||||
if (entry.httpMetadata?.contentDisposition) {
|
if (entry.httpMetadata?.contentDisposition) {
|
||||||
headers.set("Content-Disposition", entry.httpMetadata.contentDisposition);
|
headers.set(
|
||||||
|
"Content-Disposition",
|
||||||
|
entry.httpMetadata.contentDisposition,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
+1
-1
@@ -71,7 +71,7 @@ declare global {
|
|||||||
// This is not an ideal solution but works for our example
|
// This is not an ideal solution but works for our example
|
||||||
interface KVNamespace {
|
interface KVNamespace {
|
||||||
get(key: string, options?: { type: "text" }): Promise<string | null>;
|
get(key: string, options?: { type: "text" }): Promise<string | null>;
|
||||||
get(key: string, options: { type: "json" }): Promise<any | null>;
|
get(key: string, options: { type: "json" }): Promise<unknown | null>;
|
||||||
get(
|
get(
|
||||||
key: string,
|
key: string,
|
||||||
options: { type: "arrayBuffer" },
|
options: { type: "arrayBuffer" },
|
||||||
|
|||||||
Vendored
+2
-2
@@ -15,7 +15,7 @@ declare module "rss" {
|
|||||||
generator?: string;
|
generator?: string;
|
||||||
categories?: string[];
|
categories?: string[];
|
||||||
custom_namespaces?: Record<string, string>;
|
custom_namespaces?: Record<string, string>;
|
||||||
custom_elements?: any[];
|
custom_elements?: unknown[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RSSItemOptions {
|
interface RSSItemOptions {
|
||||||
@@ -34,7 +34,7 @@ declare module "rss" {
|
|||||||
size?: number;
|
size?: number;
|
||||||
type?: string;
|
type?: string;
|
||||||
};
|
};
|
||||||
custom_elements?: any[];
|
custom_elements?: unknown[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RSSXMLOptions {
|
interface RSSXMLOptions {
|
||||||
|
|||||||
@@ -53,15 +53,15 @@ describe("EmailParser.decodeEncodedWords", () => {
|
|||||||
// =?UTF-8?Q?caf=C3=A9?= → "café" (but decodeQuotedPrintable works byte-by-byte)
|
// =?UTF-8?Q?caf=C3=A9?= → "café" (but decodeQuotedPrintable works byte-by-byte)
|
||||||
// Use a simple ASCII QP sequence to stay charset-agnostic in tests
|
// Use a simple ASCII QP sequence to stay charset-agnostic in tests
|
||||||
// =?US-ASCII?Q?Hello=20World?= → "Hello World" (=20 → space, _ → space)
|
// =?US-ASCII?Q?Hello=20World?= → "Hello World" (=20 → space, _ → space)
|
||||||
expect(
|
expect(EmailParser.decodeEncodedWords("=?US-ASCII?Q?Hello=20World?=")).toBe(
|
||||||
EmailParser.decodeEncodedWords("=?US-ASCII?Q?Hello=20World?="),
|
"Hello World",
|
||||||
).toBe("Hello World");
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("decodes underscores as spaces in QP encoding", () => {
|
it("decodes underscores as spaces in QP encoding", () => {
|
||||||
expect(
|
expect(EmailParser.decodeEncodedWords("=?US-ASCII?Q?Hello_World?=")).toBe(
|
||||||
EmailParser.decodeEncodedWords("=?US-ASCII?Q?Hello_World?="),
|
"Hello World",
|
||||||
).toBe("Hello World");
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("leaves unrecognised encoded-word syntax unchanged", () => {
|
it("leaves unrecognised encoded-word syntax unchanged", () => {
|
||||||
@@ -79,9 +79,7 @@ describe("EmailParser.parseForwardEmailPayload", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("throws on undefined payload", () => {
|
it("throws on undefined payload", () => {
|
||||||
expect(() =>
|
expect(() => EmailParser.parseForwardEmailPayload(undefined)).toThrow();
|
||||||
EmailParser.parseForwardEmailPayload(undefined),
|
|
||||||
).toThrow();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("parses subject, from, and HTML content", () => {
|
it("parses subject, from, and HTML content", () => {
|
||||||
@@ -95,7 +93,9 @@ describe("EmailParser.parseForwardEmailPayload", () => {
|
|||||||
expect(result.subject).toBe("Test Subject");
|
expect(result.subject).toBe("Test Subject");
|
||||||
expect(result.from).toBe("[email protected]");
|
expect(result.from).toBe("[email protected]");
|
||||||
expect(result.content).toBe("<p>Hello</p>");
|
expect(result.content).toBe("<p>Hello</p>");
|
||||||
expect(result.receivedAt).toBe(new Date("2024-01-15T10:00:00.000Z").getTime());
|
expect(result.receivedAt).toBe(
|
||||||
|
new Date("2024-01-15T10:00:00.000Z").getTime(),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("prefers HTML content over plain text", () => {
|
it("prefers HTML content over plain text", () => {
|
||||||
@@ -137,14 +137,18 @@ describe("EmailParser.parseForwardEmailPayload", () => {
|
|||||||
|
|
||||||
it("uses Date.now() when date field is absent", () => {
|
it("uses Date.now() when date field is absent", () => {
|
||||||
const before = Date.now();
|
const before = Date.now();
|
||||||
const result = EmailParser.parseForwardEmailPayload({ from: { text: "[email protected]" } });
|
const result = EmailParser.parseForwardEmailPayload({
|
||||||
|
from: { text: "[email protected]" },
|
||||||
|
});
|
||||||
const after = Date.now();
|
const after = Date.now();
|
||||||
expect(result.receivedAt).toBeGreaterThanOrEqual(before);
|
expect(result.receivedAt).toBeGreaterThanOrEqual(before);
|
||||||
expect(result.receivedAt).toBeLessThanOrEqual(after);
|
expect(result.receivedAt).toBeLessThanOrEqual(after);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("defaults subject to 'No Subject' when absent", () => {
|
it("defaults subject to 'No Subject' when absent", () => {
|
||||||
const result = EmailParser.parseForwardEmailPayload({ from: { text: "[email protected]" } });
|
const result = EmailParser.parseForwardEmailPayload({
|
||||||
|
from: { text: "[email protected]" },
|
||||||
|
});
|
||||||
expect(result.subject).toBe("No Subject");
|
expect(result.subject).toBe("No Subject");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export class EmailParser {
|
|||||||
return match ? match[1] : null;
|
return match ? match[1] : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
static parseForwardEmailPayload(payload: any): EmailData {
|
static parseForwardEmailPayload(payload: any): EmailData {
|
||||||
if (!payload) {
|
if (!payload) {
|
||||||
throw new Error("Missing or invalid webhook payload");
|
throw new Error("Missing or invalid webhook payload");
|
||||||
@@ -30,6 +31,7 @@ export class EmailParser {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
private static extractHeaders(payload: any): Record<string, string> {
|
private static extractHeaders(payload: any): Record<string, string> {
|
||||||
const headers: Record<string, string> = {};
|
const headers: Record<string, string> = {};
|
||||||
|
|
||||||
@@ -62,7 +64,7 @@ export class EmailParser {
|
|||||||
if (encoding.toUpperCase() === "B") {
|
if (encoding.toUpperCase() === "B") {
|
||||||
try {
|
try {
|
||||||
return atob(text);
|
return atob(text);
|
||||||
} catch (e) {
|
} catch {
|
||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
} else if (encoding.toUpperCase() === "Q") {
|
} else if (encoding.toUpperCase() === "Q") {
|
||||||
|
|||||||
@@ -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