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
+2090
-127
No files matched your search
@@ -21,7 +21,10 @@ jobs:
|
||||
- run: npm ci
|
||||
|
||||
- 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
|
||||
run: npm run typecheck
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
#!/bin/sh
|
||||
npx lint-staged && npm run typecheck && npm test
|
||||
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
"build": "wrangler deploy --dry-run --outdir=dist",
|
||||
"format": "prettier --write '**/*.{js,ts,css,json,md}'",
|
||||
"format:check": "prettier --check '**/*.{js,ts,css,json,md}'",
|
||||
"lint": "eslint src",
|
||||
"dev": "wrangler dev",
|
||||
"deploy": "wrangler deploy --env production",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"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": "",
|
||||
"license": "MIT",
|
||||
@@ -20,10 +32,15 @@
|
||||
"@types/mailparser": "^3.4.6",
|
||||
"@types/rss": "^0.0.32",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"eslint": "^10.0.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"happy-dom": "^20.5.0",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^17.0.5",
|
||||
"msw": "^2.12.8",
|
||||
"prettier": "^3.8.1",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.59.4",
|
||||
"vitest": "^4.0.18",
|
||||
"wrangler": "^4.63.0"
|
||||
},
|
||||
|
||||
@@ -5,7 +5,7 @@ import { processEmail, RawAttachment } from "./email-processor";
|
||||
export async function handleCloudflareEmail(
|
||||
message: ForwardableEmailMessage,
|
||||
env: Env,
|
||||
ctx: ExecutionContext,
|
||||
_ctx: ExecutionContext,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const email = await PostalMime.parse(message.raw);
|
||||
|
||||
@@ -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: "[email protected]", content: bigContent, receivedAt: 111, headers: {} });
|
||||
const email2 = JSON.stringify({ subject: "Old2", from: "[email protected]", content: bigContent, receivedAt: 222, headers: {} });
|
||||
const email1 = JSON.stringify({
|
||||
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(oldKey2, email2);
|
||||
await env.EMAIL_STORAGE.put(
|
||||
`feed:${VALID_FEED_ID}:metadata`,
|
||||
JSON.stringify({
|
||||
emails: [
|
||||
{ key: oldKey2, subject: "Old2", receivedAt: 222, size: email2.length },
|
||||
{ key: oldKey1, subject: "Old1", receivedAt: 111, size: email1.length },
|
||||
{
|
||||
key: oldKey2,
|
||||
subject: "Old2",
|
||||
receivedAt: 222,
|
||||
size: email2.length,
|
||||
},
|
||||
{
|
||||
key: oldKey1,
|
||||
subject: "Old1",
|
||||
receivedAt: 111,
|
||||
size: email1.length,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const tinyEnv = { ...env, FEED_MAX_SIZE_BYTES: "50" };
|
||||
const res = await processEmail(makeInput({ subject: "New" }), tinyEnv as any);
|
||||
const res = await processEmail(
|
||||
makeInput({ subject: "New" }),
|
||||
tinyEnv as any,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const metadata = await env.EMAIL_STORAGE.get(`feed:${VALID_FEED_ID}:metadata`, "json");
|
||||
const metadata = await env.EMAIL_STORAGE.get(
|
||||
`feed:${VALID_FEED_ID}:metadata`,
|
||||
"json",
|
||||
);
|
||||
expect(metadata.emails).toHaveLength(1);
|
||||
expect(metadata.emails[0].subject).toBe("New");
|
||||
|
||||
@@ -175,13 +207,17 @@ describe("processEmail", () => {
|
||||
const bigEnv = { ...env, FEED_MAX_SIZE_BYTES: String(10 * 1024 * 1024) };
|
||||
await processEmail(makeInput({ subject: "First" }), bigEnv as any);
|
||||
await processEmail(makeInput({ subject: "Second" }), bigEnv as any);
|
||||
const metadata = await env.EMAIL_STORAGE.get(`feed:${VALID_FEED_ID}:metadata`, "json");
|
||||
const metadata = await env.EMAIL_STORAGE.get(
|
||||
`feed:${VALID_FEED_ID}:metadata`,
|
||||
"json",
|
||||
);
|
||||
expect(metadata.emails).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("processEmail — attachments", () => {
|
||||
const pdfContent = new TextEncoder().encode("PDF bytes").buffer as ArrayBuffer;
|
||||
const pdfContent = new TextEncoder().encode("PDF bytes")
|
||||
.buffer as ArrayBuffer;
|
||||
|
||||
const pdfAttachment: RawAttachment = {
|
||||
filename: "report.pdf",
|
||||
@@ -205,7 +241,10 @@ describe("processEmail — attachments", () => {
|
||||
`feed:${VALID_FEED_ID}:metadata`,
|
||||
"json",
|
||||
);
|
||||
const emailData = await env.EMAIL_STORAGE.get(metadata.emails[0].key, "json");
|
||||
const emailData = await env.EMAIL_STORAGE.get(
|
||||
metadata.emails[0].key,
|
||||
"json",
|
||||
);
|
||||
expect(emailData.attachments).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -226,7 +265,10 @@ describe("processEmail — attachments", () => {
|
||||
`feed:${VALID_FEED_ID}:metadata`,
|
||||
"json",
|
||||
);
|
||||
const emailData = await env.EMAIL_STORAGE.get(metadata.emails[0].key, "json");
|
||||
const emailData = await env.EMAIL_STORAGE.get(
|
||||
metadata.emails[0].key,
|
||||
"json",
|
||||
);
|
||||
|
||||
expect(emailData.attachments).toHaveLength(1);
|
||||
expect(emailData.attachments[0].filename).toBe("report.pdf");
|
||||
@@ -271,7 +313,14 @@ describe("processEmail — attachments", () => {
|
||||
content: bigContent,
|
||||
receivedAt: 111,
|
||||
headers: {},
|
||||
attachments: [{ id: oldAttachmentId, filename: "old.pdf", contentType: "application/pdf", size: 100 }],
|
||||
attachments: [
|
||||
{
|
||||
id: oldAttachmentId,
|
||||
filename: "old.pdf",
|
||||
contentType: "application/pdf",
|
||||
size: 100,
|
||||
},
|
||||
],
|
||||
});
|
||||
await env.EMAIL_STORAGE.put(oldKey, oldEmail);
|
||||
|
||||
@@ -295,7 +344,10 @@ describe("processEmail — attachments", () => {
|
||||
|
||||
// Process with tight size budget to force trimming
|
||||
const tinyEnv = { ...env, FEED_MAX_SIZE_BYTES: "50" };
|
||||
const res = await processEmail(makeInput({ subject: "New" }), tinyEnv as any);
|
||||
const res = await processEmail(
|
||||
makeInput({ subject: "New" }),
|
||||
tinyEnv as any,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
// Old attachment should be deleted from R2
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { EmailParser } from "../utils/email-parser";
|
||||
import { AttachmentData, EmailMetadata, Env, FeedConfig, FeedMetadata } from "../types";
|
||||
import {
|
||||
AttachmentData,
|
||||
EmailMetadata,
|
||||
Env,
|
||||
FeedConfig,
|
||||
FeedMetadata,
|
||||
} from "../types";
|
||||
|
||||
export interface RawAttachment {
|
||||
filename: string;
|
||||
@@ -151,7 +157,10 @@ export async function processEmail(
|
||||
};
|
||||
feedMetadata.emails.unshift(newEntry);
|
||||
|
||||
let totalSize = feedMetadata.emails.reduce((sum, e) => sum + (e.size ?? 0), 0);
|
||||
let totalSize = feedMetadata.emails.reduce(
|
||||
(sum, e) => sum + (e.size ?? 0),
|
||||
0,
|
||||
);
|
||||
const toDelete: EmailMetadata[] = [];
|
||||
while (totalSize > maxBytes && feedMetadata.emails.length > 1) {
|
||||
const dropped = feedMetadata.emails.pop()!;
|
||||
|
||||
@@ -6,10 +6,7 @@ export interface ForwardEmailAttachment {
|
||||
filename?: string;
|
||||
contentType?: string;
|
||||
size?: number;
|
||||
content?:
|
||||
| { type: "Buffer"; data: number[] }
|
||||
| ArrayBuffer
|
||||
| ArrayBufferView;
|
||||
content?: { type: "Buffer"; data: number[] } | ArrayBuffer | ArrayBufferView;
|
||||
}
|
||||
|
||||
export interface ForwardEmailPayload {
|
||||
@@ -56,8 +53,13 @@ function toArrayBuffer(
|
||||
): ArrayBuffer | null {
|
||||
if (!content) return null;
|
||||
if (content instanceof ArrayBuffer) return content;
|
||||
if (ArrayBuffer.isView(content)) return (content as ArrayBufferView).buffer as ArrayBuffer;
|
||||
if (typeof content === "object" && content.type === "Buffer" && Array.isArray(content.data)) {
|
||||
if (ArrayBuffer.isView(content))
|
||||
return (content as ArrayBufferView).buffer as ArrayBuffer;
|
||||
if (
|
||||
typeof content === "object" &&
|
||||
content.type === "Buffer" &&
|
||||
Array.isArray(content.data)
|
||||
) {
|
||||
return Uint8Array.from(content.data).buffer as ArrayBuffer;
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -14,7 +14,8 @@ describe("Admin Routes", () => {
|
||||
mockEnv = createMockEnv() as unknown as Env;
|
||||
testApp = new Hono();
|
||||
testApp.route("/admin", app);
|
||||
request = (path, init = {}) => Promise.resolve(testApp.request(path, init, mockEnv));
|
||||
request = (path, init = {}) =>
|
||||
Promise.resolve(testApp.request(path, init, mockEnv));
|
||||
loginAndGetCookie = async () => {
|
||||
const formData = new FormData();
|
||||
formData.append("password", "test-password");
|
||||
@@ -366,7 +367,10 @@ describe("Admin Routes", () => {
|
||||
} as unknown as Env;
|
||||
}
|
||||
|
||||
function makeProxyRequest(path: string, headers: Record<string, string> = {}) {
|
||||
function makeProxyRequest(
|
||||
path: string,
|
||||
headers: Record<string, string> = {},
|
||||
) {
|
||||
const proxyApp = new Hono();
|
||||
proxyApp.route("/admin", app);
|
||||
return proxyApp.request(path, { headers }, proxyEnv());
|
||||
|
||||
+27
-9
@@ -71,9 +71,11 @@ function timingSafeEqual(a: string, b: string): boolean {
|
||||
const aBytes = enc.encode(a);
|
||||
const bBytes = enc.encode(b);
|
||||
// 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;
|
||||
return (crypto.subtle as any).timingSafeEqual(aBytes, bBytes);
|
||||
return subtle.timingSafeEqual(aBytes, bBytes);
|
||||
}
|
||||
// Constant-time fallback for Node (test environment): encode 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
|
||||
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 providedSecret = c.req.header("X-Auth-Proxy-Secret") ?? "";
|
||||
const remoteUser =
|
||||
@@ -113,7 +117,11 @@ async function authMiddleware(c: Context, next: () => Promise<void>) {
|
||||
}
|
||||
|
||||
// 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") {
|
||||
return c.redirect("/admin/login");
|
||||
}
|
||||
@@ -146,6 +154,7 @@ const authSchema = z.object({
|
||||
});
|
||||
|
||||
// Base HTML layout with design system
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const layout = (title: string, content: any) => {
|
||||
return html`<!DOCTYPE html>
|
||||
<html>
|
||||
@@ -1610,11 +1619,14 @@ app.post("/feeds/create", async (c) => {
|
||||
if (isJson) {
|
||||
const body = await c.req.json<Record<string, unknown>>();
|
||||
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");
|
||||
view = "list";
|
||||
allowedSenders = Array.isArray(body.allowedSenders)
|
||||
? normalizeAllowedSenders((body.allowedSenders as unknown[]).map(String))
|
||||
? normalizeAllowedSenders(
|
||||
(body.allowedSenders as unknown[]).map(String),
|
||||
)
|
||||
: [];
|
||||
} else {
|
||||
const formData = await c.req.formData();
|
||||
@@ -1930,8 +1942,9 @@ async function purgeFeedKeysStep(
|
||||
});
|
||||
if (emailKeys.length > 0) {
|
||||
const emailDataResults = await Promise.allSettled(
|
||||
emailKeys.map((k) =>
|
||||
emailStorage.get(k, { type: "json" }) as Promise<EmailData | null>,
|
||||
emailKeys.map(
|
||||
(k) =>
|
||||
emailStorage.get(k, { type: "json" }) as Promise<EmailData | null>,
|
||||
),
|
||||
);
|
||||
const attachmentIds = emailDataResults
|
||||
@@ -1976,7 +1989,12 @@ app.post("/feeds/:feedId/delete", async (c) => {
|
||||
|
||||
// Best-effort cleanup in the background so the request stays fast.
|
||||
// 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) {
|
||||
return c.json({ ok: true, feedId });
|
||||
}
|
||||
|
||||
@@ -68,7 +68,11 @@ describe("Atom Feed Route", () => {
|
||||
`feed:${FEED_ID}:metadata`,
|
||||
JSON.stringify({
|
||||
emails: [
|
||||
{ key: emailKey, subject: "Atom Entry Subject", receivedAt: 1700000001000 },
|
||||
{
|
||||
key: emailKey,
|
||||
subject: "Atom Entry Subject",
|
||||
receivedAt: 1700000001000,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
+49
-23
@@ -21,7 +21,9 @@ export async function handle(c: Context): Promise<Response> {
|
||||
return new Response("Feed not found", { status: 404 });
|
||||
}
|
||||
|
||||
const metaEntry = feedMetadata.emails.find((e) => e.receivedAt === receivedAt);
|
||||
const metaEntry = feedMetadata.emails.find(
|
||||
(e) => e.receivedAt === receivedAt,
|
||||
);
|
||||
if (!metaEntry) {
|
||||
return new Response("Entry not found", { status: 404 });
|
||||
}
|
||||
@@ -39,26 +41,50 @@ export async function handle(c: Context): Promise<Response> {
|
||||
"default-src 'none'; style-src 'unsafe-inline'; img-src *; frame-src 'none'",
|
||||
);
|
||||
|
||||
return c.html(html`<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>${emailData.subject}</title>
|
||||
<style>
|
||||
body { font-family: sans-serif; max-width: 800px; margin: 0 auto; padding: 1rem; }
|
||||
.meta { color: #666; font-size: 0.875rem; margin-bottom: 1.5rem; border-bottom: 1px solid #eee; padding-bottom: 0.75rem; }
|
||||
.meta dt { display: inline; font-weight: bold; }
|
||||
.meta dd { display: inline; margin: 0 1rem 0 0.25rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>${emailData.subject}</h1>
|
||||
<dl class="meta">
|
||||
<dt>From:</dt><dd>${emailData.from}</dd>
|
||||
<dt>Date:</dt><dd>${new Date(emailData.receivedAt).toUTCString()}</dd>
|
||||
</dl>
|
||||
<div class="content">${raw(emailData.content)}</div>
|
||||
</body>
|
||||
</html>`);
|
||||
return c.html(
|
||||
html`<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0"
|
||||
/>
|
||||
<title>${emailData.subject}</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: sans-serif;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
.meta {
|
||||
color: #666;
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 1.5rem;
|
||||
border-bottom: 1px solid #eee;
|
||||
padding-bottom: 0.75rem;
|
||||
}
|
||||
.meta dt {
|
||||
display: inline;
|
||||
font-weight: bold;
|
||||
}
|
||||
.meta dd {
|
||||
display: inline;
|
||||
margin: 0 1rem 0 0.25rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>${emailData.subject}</h1>
|
||||
<dl class="meta">
|
||||
<dt>From:</dt>
|
||||
<dd>${emailData.from}</dd>
|
||||
<dt>Date:</dt>
|
||||
<dd>${new Date(emailData.receivedAt).toUTCString()}</dd>
|
||||
</dl>
|
||||
<div class="content">${raw(emailData.content)}</div>
|
||||
</body>
|
||||
</html>`,
|
||||
);
|
||||
}
|
||||
@@ -39,7 +39,8 @@ describe("GET /files/:attachmentId/:filename", () => {
|
||||
});
|
||||
|
||||
it("returns 200 with stored content when attachment exists", async () => {
|
||||
const content = new TextEncoder().encode("PDF content").buffer as ArrayBuffer;
|
||||
const content = new TextEncoder().encode("PDF content")
|
||||
.buffer as ArrayBuffer;
|
||||
await mockR2.put("test-uuid", content, {
|
||||
httpMetadata: { contentType: "application/pdf" },
|
||||
});
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { Context } from "hono";
|
||||
import { Env } from "../types";
|
||||
import {
|
||||
ForwardEmailPayload,
|
||||
handleForwardEmail,
|
||||
} from "../lib/forwardemail";
|
||||
import { ForwardEmailPayload, handleForwardEmail } from "../lib/forwardemail";
|
||||
|
||||
export async function handle(c: Context): Promise<Response> {
|
||||
try {
|
||||
|
||||
+13
-10
@@ -71,7 +71,7 @@ class MockCache implements Cache {
|
||||
|
||||
async match(
|
||||
request: RequestInfo,
|
||||
options?: CacheQueryOptions,
|
||||
_options?: CacheQueryOptions,
|
||||
): Promise<Response | undefined> {
|
||||
const key = request instanceof Request ? request.url : request;
|
||||
const response = this.store.get(key);
|
||||
@@ -80,7 +80,7 @@ class MockCache implements Cache {
|
||||
|
||||
async delete(
|
||||
request: RequestInfo,
|
||||
options?: CacheQueryOptions,
|
||||
_options?: CacheQueryOptions,
|
||||
): Promise<boolean> {
|
||||
const key = request instanceof Request ? request.url : request;
|
||||
return this.store.delete(key);
|
||||
@@ -107,7 +107,6 @@ beforeAll(() => {
|
||||
server.listen({ onUnhandledRequest: "error" });
|
||||
|
||||
// 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;
|
||||
|
||||
// Mock Cloudflare Workers runtime globals
|
||||
@@ -118,31 +117,26 @@ beforeAll(() => {
|
||||
|
||||
// Mock crypto for generating random values
|
||||
if (!g.crypto) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
g.crypto = require("crypto").webcrypto;
|
||||
}
|
||||
|
||||
// Ensure other required globals are available
|
||||
if (!g.FormData) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { FormData } = require("undici");
|
||||
g.FormData = FormData;
|
||||
}
|
||||
|
||||
if (!g.Headers) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { Headers } = require("undici");
|
||||
g.Headers = Headers;
|
||||
}
|
||||
|
||||
if (!g.Request) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { Request } = require("undici");
|
||||
g.Request = Request;
|
||||
}
|
||||
|
||||
if (!g.Response) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { Response } = require("undici");
|
||||
g.Response = Response;
|
||||
}
|
||||
@@ -173,7 +167,13 @@ export class MockR2 {
|
||||
|
||||
async put(
|
||||
key: string,
|
||||
value: ReadableStream | ArrayBuffer | ArrayBufferView | string | null | Blob,
|
||||
value:
|
||||
| ReadableStream
|
||||
| ArrayBuffer
|
||||
| ArrayBufferView
|
||||
| string
|
||||
| null
|
||||
| Blob,
|
||||
options?: {
|
||||
httpMetadata?: { contentType?: string; contentDisposition?: string };
|
||||
},
|
||||
@@ -216,7 +216,10 @@ export class MockR2 {
|
||||
headers.set("Content-Type", entry.httpMetadata.contentType);
|
||||
}
|
||||
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
|
||||
interface KVNamespace {
|
||||
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(
|
||||
key: string,
|
||||
options: { type: "arrayBuffer" },
|
||||
|
||||
Vendored
+2
-2
@@ -15,7 +15,7 @@ declare module "rss" {
|
||||
generator?: string;
|
||||
categories?: string[];
|
||||
custom_namespaces?: Record<string, string>;
|
||||
custom_elements?: any[];
|
||||
custom_elements?: unknown[];
|
||||
}
|
||||
|
||||
interface RSSItemOptions {
|
||||
@@ -34,7 +34,7 @@ declare module "rss" {
|
||||
size?: number;
|
||||
type?: string;
|
||||
};
|
||||
custom_elements?: any[];
|
||||
custom_elements?: unknown[];
|
||||
}
|
||||
|
||||
interface RSSXMLOptions {
|
||||
|
||||
@@ -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("[email protected]");
|
||||
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: "[email protected]" } });
|
||||
const result = EmailParser.parseForwardEmailPayload({
|
||||
from: { text: "[email protected]" },
|
||||
});
|
||||
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: "[email protected]" } });
|
||||
const result = EmailParser.parseForwardEmailPayload({
|
||||
from: { text: "[email protected]" },
|
||||
});
|
||||
expect(result.subject).toBe("No Subject");
|
||||
});
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ export class EmailParser {
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
static parseForwardEmailPayload(payload: any): EmailData {
|
||||
if (!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> {
|
||||
const headers: Record<string, string> = {};
|
||||
|
||||
@@ -62,7 +64,7 @@ export class EmailParser {
|
||||
if (encoding.toUpperCase() === "B") {
|
||||
try {
|
||||
return atob(text);
|
||||
} catch (e) {
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
} else if (encoding.toUpperCase() === "Q") {
|
||||
|
||||
@@ -38,13 +38,23 @@ const FEED_ID = "abc123";
|
||||
|
||||
describe("generateRssFeed", () => {
|
||||
it("returns RSS 2.0 with channel element", () => {
|
||||
const result = generateRssFeed(mockFeedConfig, mockEmails, BASE_URL, FEED_ID);
|
||||
const result = generateRssFeed(
|
||||
mockFeedConfig,
|
||||
mockEmails,
|
||||
BASE_URL,
|
||||
FEED_ID,
|
||||
);
|
||||
expect(result).toContain("<channel>");
|
||||
expect(result).toContain("<title>Test Newsletter</title>");
|
||||
});
|
||||
|
||||
it("includes <enclosure> element for email with attachment", () => {
|
||||
const result = generateRssFeed(mockFeedConfig, [mockEmailWithAttachment], BASE_URL, FEED_ID);
|
||||
const result = generateRssFeed(
|
||||
mockFeedConfig,
|
||||
[mockEmailWithAttachment],
|
||||
BASE_URL,
|
||||
FEED_ID,
|
||||
);
|
||||
expect(result).toContain("<enclosure");
|
||||
expect(result).toContain("550e8400-e29b-41d4-a716-446655440000");
|
||||
expect(result).toContain("report.pdf");
|
||||
@@ -53,22 +63,44 @@ describe("generateRssFeed", () => {
|
||||
});
|
||||
|
||||
it("does not include <enclosure> for email without attachments", () => {
|
||||
const result = generateRssFeed(mockFeedConfig, mockEmails, BASE_URL, FEED_ID);
|
||||
const result = generateRssFeed(
|
||||
mockFeedConfig,
|
||||
mockEmails,
|
||||
BASE_URL,
|
||||
FEED_ID,
|
||||
);
|
||||
expect(result).not.toContain("<enclosure");
|
||||
});
|
||||
|
||||
it("enclosure URL uses /files/{id}/{filename} scheme", () => {
|
||||
const result = generateRssFeed(mockFeedConfig, [mockEmailWithAttachment], BASE_URL, FEED_ID);
|
||||
expect(result).toContain(`${BASE_URL}/files/550e8400-e29b-41d4-a716-446655440000/report.pdf`);
|
||||
const result = generateRssFeed(
|
||||
mockFeedConfig,
|
||||
[mockEmailWithAttachment],
|
||||
BASE_URL,
|
||||
FEED_ID,
|
||||
);
|
||||
expect(result).toContain(
|
||||
`${BASE_URL}/files/550e8400-e29b-41d4-a716-446655440000/report.pdf`,
|
||||
);
|
||||
});
|
||||
|
||||
it("includes rss self-link in RSS output", () => {
|
||||
const result = generateRssFeed(mockFeedConfig, mockEmails, BASE_URL, FEED_ID);
|
||||
const result = generateRssFeed(
|
||||
mockFeedConfig,
|
||||
mockEmails,
|
||||
BASE_URL,
|
||||
FEED_ID,
|
||||
);
|
||||
expect(result).toContain(`${BASE_URL}/rss/${FEED_ID}`);
|
||||
});
|
||||
|
||||
it("includes email entries as <item> elements", () => {
|
||||
const result = generateRssFeed(mockFeedConfig, mockEmails, BASE_URL, FEED_ID);
|
||||
const result = generateRssFeed(
|
||||
mockFeedConfig,
|
||||
mockEmails,
|
||||
BASE_URL,
|
||||
FEED_ID,
|
||||
);
|
||||
expect(result).toContain("<item>");
|
||||
expect(result).toContain("Hello World");
|
||||
});
|
||||
@@ -82,39 +114,74 @@ describe("generateRssFeed", () => {
|
||||
|
||||
describe("generateAtomFeed", () => {
|
||||
it("returns Atom 1.0 namespace", () => {
|
||||
const result = generateAtomFeed(mockFeedConfig, mockEmails, BASE_URL, FEED_ID);
|
||||
const result = generateAtomFeed(
|
||||
mockFeedConfig,
|
||||
mockEmails,
|
||||
BASE_URL,
|
||||
FEED_ID,
|
||||
);
|
||||
expect(result).toContain('xmlns="http://www.w3.org/2005/Atom"');
|
||||
});
|
||||
|
||||
it("contains <feed> root element", () => {
|
||||
const result = generateAtomFeed(mockFeedConfig, mockEmails, BASE_URL, FEED_ID);
|
||||
const result = generateAtomFeed(
|
||||
mockFeedConfig,
|
||||
mockEmails,
|
||||
BASE_URL,
|
||||
FEED_ID,
|
||||
);
|
||||
expect(result).toContain("<feed");
|
||||
expect(result).toContain("</feed>");
|
||||
});
|
||||
|
||||
it("includes feed title", () => {
|
||||
const result = generateAtomFeed(mockFeedConfig, mockEmails, BASE_URL, FEED_ID);
|
||||
const result = generateAtomFeed(
|
||||
mockFeedConfig,
|
||||
mockEmails,
|
||||
BASE_URL,
|
||||
FEED_ID,
|
||||
);
|
||||
expect(result).toContain("Test Newsletter");
|
||||
});
|
||||
|
||||
it("includes <entry> elements for each email", () => {
|
||||
const result = generateAtomFeed(mockFeedConfig, mockEmails, BASE_URL, FEED_ID);
|
||||
const result = generateAtomFeed(
|
||||
mockFeedConfig,
|
||||
mockEmails,
|
||||
BASE_URL,
|
||||
FEED_ID,
|
||||
);
|
||||
expect(result).toContain("<entry>");
|
||||
expect(result).toContain("Hello World");
|
||||
});
|
||||
|
||||
it("includes author information", () => {
|
||||
const result = generateAtomFeed(mockFeedConfig, mockEmails, BASE_URL, FEED_ID);
|
||||
const result = generateAtomFeed(
|
||||
mockFeedConfig,
|
||||
mockEmails,
|
||||
BASE_URL,
|
||||
FEED_ID,
|
||||
);
|
||||
expect(result).toContain("Alice");
|
||||
});
|
||||
|
||||
it("self-link points to atom URL", () => {
|
||||
const result = generateAtomFeed(mockFeedConfig, mockEmails, BASE_URL, FEED_ID);
|
||||
const result = generateAtomFeed(
|
||||
mockFeedConfig,
|
||||
mockEmails,
|
||||
BASE_URL,
|
||||
FEED_ID,
|
||||
);
|
||||
expect(result).toContain(`${BASE_URL}/atom/${FEED_ID}`);
|
||||
});
|
||||
|
||||
it("includes rss alternate link", () => {
|
||||
const result = generateAtomFeed(mockFeedConfig, mockEmails, BASE_URL, FEED_ID);
|
||||
const result = generateAtomFeed(
|
||||
mockFeedConfig,
|
||||
mockEmails,
|
||||
BASE_URL,
|
||||
FEED_ID,
|
||||
);
|
||||
expect(result).toContain(`${BASE_URL}/rss/${FEED_ID}`);
|
||||
});
|
||||
|
||||
@@ -125,26 +192,49 @@ describe("generateAtomFeed", () => {
|
||||
});
|
||||
|
||||
it("handles config without description", () => {
|
||||
const configNoDesc: FeedConfig = { ...mockFeedConfig, description: undefined };
|
||||
const result = generateAtomFeed(configNoDesc, mockEmails, BASE_URL, FEED_ID);
|
||||
const configNoDesc: FeedConfig = {
|
||||
...mockFeedConfig,
|
||||
description: undefined,
|
||||
};
|
||||
const result = generateAtomFeed(
|
||||
configNoDesc,
|
||||
mockEmails,
|
||||
BASE_URL,
|
||||
FEED_ID,
|
||||
);
|
||||
expect(result).toContain('xmlns="http://www.w3.org/2005/Atom"');
|
||||
});
|
||||
|
||||
it("handles config with author field", () => {
|
||||
const configWithAuthor: FeedConfig = { ...mockFeedConfig, author: "Bob" };
|
||||
const result = generateAtomFeed(configWithAuthor, mockEmails, BASE_URL, FEED_ID);
|
||||
const result = generateAtomFeed(
|
||||
configWithAuthor,
|
||||
mockEmails,
|
||||
BASE_URL,
|
||||
FEED_ID,
|
||||
);
|
||||
expect(result).toContain("Bob");
|
||||
});
|
||||
|
||||
it("includes enclosure link for email with attachment in Atom feed", () => {
|
||||
const result = generateAtomFeed(mockFeedConfig, [mockEmailWithAttachment], BASE_URL, FEED_ID);
|
||||
const result = generateAtomFeed(
|
||||
mockFeedConfig,
|
||||
[mockEmailWithAttachment],
|
||||
BASE_URL,
|
||||
FEED_ID,
|
||||
);
|
||||
expect(result).toContain('rel="enclosure"');
|
||||
expect(result).toContain("550e8400-e29b-41d4-a716-446655440000");
|
||||
expect(result).toContain("report.pdf");
|
||||
});
|
||||
|
||||
it("does not include enclosure link for email without attachments in Atom feed", () => {
|
||||
const result = generateAtomFeed(mockFeedConfig, mockEmails, BASE_URL, FEED_ID);
|
||||
const result = generateAtomFeed(
|
||||
mockFeedConfig,
|
||||
mockEmails,
|
||||
BASE_URL,
|
||||
FEED_ID,
|
||||
);
|
||||
expect(result).not.toContain('rel="enclosure"');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user