mirror of
https://github.com/juherr/kill-the-news.git
synced 2026-06-20 22:03:48 +00:00
refactor(domain): add FeedId, EmailAddress and Domain value objects
Encapsulate the email/domain/feed-id parsing that was scattered as ad-hoc
regexes and split("@") calls into three small immutable value objects under
src/domain/value-objects/. EmailParser.extractFeedId and generateFeedId now
delegate to FeedId; the sender policy, favicon domain extraction and the admin
SenderField parse through EmailAddress/Domain.
Left as-is on purpose: forwardemail's multi-address free-text extraction and the
admin allow/block list normaliser, which operate on mixed email-or-domain input
that the single-address value objects would reject.
Behaviour-preserving; adds unit tests for each value object.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+21
-12
@@ -1,4 +1,6 @@
|
|||||||
import { Env, FeedConfig, FeedMetadata, EmailMetadata } from "../types";
|
import { Env, FeedConfig, FeedMetadata, EmailMetadata } from "../types";
|
||||||
|
import { EmailAddress } from "./value-objects/email-address";
|
||||||
|
import { Domain } from "./value-objects/domain";
|
||||||
|
|
||||||
const HOUR_MS = 3_600_000;
|
const HOUR_MS = 3_600_000;
|
||||||
|
|
||||||
@@ -41,29 +43,36 @@ function normalizeEmail(value: string): string {
|
|||||||
|
|
||||||
type SenderMatch = "blocked" | "allowed" | "neutral";
|
type SenderMatch = "blocked" | "allowed" | "neutral";
|
||||||
|
|
||||||
|
function toDomains(entries: string[]): Domain[] {
|
||||||
|
return entries
|
||||||
|
.map((e) => Domain.parse(e))
|
||||||
|
.filter((d): d is Domain => d !== null);
|
||||||
|
}
|
||||||
|
|
||||||
function evaluateSender(
|
function evaluateSender(
|
||||||
sender: string,
|
sender: string,
|
||||||
allowedSenders: string[],
|
allowedSenders: string[],
|
||||||
blockedSenders: string[],
|
blockedSenders: string[],
|
||||||
): SenderMatch {
|
): SenderMatch {
|
||||||
const normalized = normalizeEmail(sender);
|
const parsed = EmailAddress.parse(sender);
|
||||||
const domain = normalized.split("@")[1] || "";
|
const normalized = parsed ? parsed.normalized : normalizeEmail(sender);
|
||||||
|
const senderDomain = parsed?.domain ?? null;
|
||||||
const normalizeDomain = (e: string) => (e.startsWith("@") ? e.slice(1) : e);
|
|
||||||
|
|
||||||
const exactBlocked = blockedSenders.filter((e) => e.includes("@"));
|
const exactBlocked = blockedSenders.filter((e) => e.includes("@"));
|
||||||
const exactAllowed = allowedSenders.filter((e) => e.includes("@"));
|
const exactAllowed = allowedSenders.filter((e) => e.includes("@"));
|
||||||
const domainBlocked = blockedSenders
|
const domainBlocked = toDomains(
|
||||||
.filter((e) => !e.includes("@"))
|
blockedSenders.filter((e) => !e.includes("@")),
|
||||||
.map(normalizeDomain);
|
);
|
||||||
const domainAllowed = allowedSenders
|
const domainAllowed = toDomains(
|
||||||
.filter((e) => !e.includes("@"))
|
allowedSenders.filter((e) => !e.includes("@")),
|
||||||
.map(normalizeDomain);
|
);
|
||||||
|
|
||||||
if (exactBlocked.includes(normalized)) return "blocked";
|
if (exactBlocked.includes(normalized)) return "blocked";
|
||||||
if (exactAllowed.includes(normalized)) return "allowed";
|
if (exactAllowed.includes(normalized)) return "allowed";
|
||||||
if (domain && domainBlocked.includes(domain)) return "blocked";
|
if (senderDomain && domainBlocked.some((d) => d.matches(senderDomain)))
|
||||||
if (domain && domainAllowed.includes(domain)) return "allowed";
|
return "blocked";
|
||||||
|
if (senderDomain && domainAllowed.some((d) => d.matches(senderDomain)))
|
||||||
|
return "allowed";
|
||||||
return "neutral";
|
return "neutral";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { Domain } from "./domain";
|
||||||
|
|
||||||
|
describe("Domain", () => {
|
||||||
|
it("normalises case and whitespace", () => {
|
||||||
|
expect(Domain.parse(" Example.COM ")?.value).toBe("example.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips a leading @ and trailing dots", () => {
|
||||||
|
expect(Domain.parse("@example.com")?.value).toBe("example.com");
|
||||||
|
expect(Domain.parse("example.com.")?.value).toBe("example.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for empty input", () => {
|
||||||
|
expect(Domain.parse("")).toBeNull();
|
||||||
|
expect(Domain.parse("@")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("compares by normalised value", () => {
|
||||||
|
expect(
|
||||||
|
Domain.parse("Example.com")!.matches(Domain.parse("example.com")!),
|
||||||
|
).toBe(true);
|
||||||
|
expect(Domain.parse("a.com")!.matches(Domain.parse("b.com")!)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* A normalised DNS domain (lowercased, no leading `@`, no trailing dots).
|
||||||
|
* Accepts both bare (`example.com`) and allowlist-style (`@example.com`) input.
|
||||||
|
*/
|
||||||
|
export class Domain {
|
||||||
|
private constructor(readonly value: string) {}
|
||||||
|
|
||||||
|
static parse(raw: string): Domain | null {
|
||||||
|
const normalized = raw
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/^@+/, "")
|
||||||
|
.replace(/\.+$/, "");
|
||||||
|
return normalized ? new Domain(normalized) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
matches(other: Domain): boolean {
|
||||||
|
return this.value === other.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
toString(): string {
|
||||||
|
return this.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { EmailAddress } from "./email-address";
|
||||||
|
|
||||||
|
describe("EmailAddress", () => {
|
||||||
|
it("parses a bare address and normalises it", () => {
|
||||||
|
const email = EmailAddress.parse("News@Example.COM")!;
|
||||||
|
expect(email.normalized).toBe("news@example.com");
|
||||||
|
expect(email.domain.value).toBe("example.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses a display form (Name <addr>)", () => {
|
||||||
|
const email = EmailAddress.parse("GitHub <news@GitHub.com>")!;
|
||||||
|
expect(email.normalized).toBe("news@github.com");
|
||||||
|
expect(email.domain.value).toBe("github.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips a trailing dot from the domain", () => {
|
||||||
|
expect(EmailAddress.parse("a@Example.COM.")?.domain.value).toBe(
|
||||||
|
"example.com",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when there is no address", () => {
|
||||||
|
expect(EmailAddress.parse("not an email")).toBeNull();
|
||||||
|
expect(EmailAddress.parse("")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { Domain } from "./domain";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A normalised email address. `parse` accepts a bare address (`a@b.com`) or a
|
||||||
|
* display form (`Name <a@b.com>`), lowercasing the local part and normalising
|
||||||
|
* the domain. Returns null when no plausible address can be found.
|
||||||
|
*/
|
||||||
|
export class EmailAddress {
|
||||||
|
private constructor(
|
||||||
|
readonly normalized: string,
|
||||||
|
readonly domain: Domain,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
static parse(raw: string): EmailAddress | null {
|
||||||
|
const match = raw.match(/([^\s<>@]+)@([^\s<>@]+)/);
|
||||||
|
if (!match) return null;
|
||||||
|
const domain = Domain.parse(match[2]);
|
||||||
|
if (!domain) return null;
|
||||||
|
const local = match[1].trim().toLowerCase();
|
||||||
|
return new EmailAddress(`${local}@${domain.value}`, domain);
|
||||||
|
}
|
||||||
|
|
||||||
|
toString(): string {
|
||||||
|
return this.normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { FeedId } from "./feed-id";
|
||||||
|
|
||||||
|
describe("FeedId.parse", () => {
|
||||||
|
it("extracts the feed id from an inbound address", () => {
|
||||||
|
expect(FeedId.parse("river.castle.42@example.com")?.value).toBe(
|
||||||
|
"river.castle.42",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves the original casing of the local part", () => {
|
||||||
|
expect(FeedId.parse("River.Castle.42@example.com")?.value).toBe(
|
||||||
|
"River.Castle.42",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects malformed feed ids", () => {
|
||||||
|
expect(FeedId.parse("user@example.com")).toBeNull();
|
||||||
|
expect(FeedId.parse("notanemail")).toBeNull();
|
||||||
|
expect(FeedId.parse("river.castle.4@example.com")).toBeNull();
|
||||||
|
expect(FeedId.parse("river.castle.123@example.com")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("FeedId.generate", () => {
|
||||||
|
it("produces the noun.noun.NN format", () => {
|
||||||
|
for (let i = 0; i < 50; i++) {
|
||||||
|
expect(FeedId.generate().value).toMatch(/^[a-z]+\.[a-z]+\.\d{2}$/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trips through parse from an address", () => {
|
||||||
|
const id = FeedId.generate();
|
||||||
|
expect(FeedId.parse(`${id.value}@example.com`)?.value).toBe(id.value);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { nouns } from "../../data/nouns";
|
||||||
|
|
||||||
|
// Feed IDs are noun1.noun2.XY (two lowercase nouns + a 2-digit suffix).
|
||||||
|
const FEED_ID_IN_ADDRESS = /^([a-z]+\.[a-z]+\.\d{2})@/i;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A feed identifier. `parse` pulls it from the local part of an inbound email
|
||||||
|
* address; `generate` mints a fresh one. The original casing is preserved.
|
||||||
|
*/
|
||||||
|
export class FeedId {
|
||||||
|
private constructor(readonly value: string) {}
|
||||||
|
|
||||||
|
/** Extract the feed id from an inbound address (`noun.noun.NN@domain`). */
|
||||||
|
static parse(emailAddress: string): FeedId | null {
|
||||||
|
const match = emailAddress.match(FEED_ID_IN_ADDRESS);
|
||||||
|
return match ? new FeedId(match[1]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static generate(): FeedId {
|
||||||
|
const noun1 = nouns[Math.floor(Math.random() * nouns.length)];
|
||||||
|
const noun2 = nouns[Math.floor(Math.random() * nouns.length)];
|
||||||
|
const number = Math.floor(Math.random() * 90) + 10;
|
||||||
|
return new FeedId(`${noun1}.${noun2}.${number}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
toString(): string {
|
||||||
|
return this.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
import { FeedRepository } from "../../domain/feed-repository";
|
import { FeedRepository } from "../../domain/feed-repository";
|
||||||
import { feedRssUrl, feedAtomUrl, feedEmailAddress } from "../../utils/urls";
|
import { feedRssUrl, feedAtomUrl, feedEmailAddress } from "../../utils/urls";
|
||||||
import { formatBytes } from "../../utils/format";
|
import { formatBytes } from "../../utils/format";
|
||||||
|
import { EmailAddress } from "../../domain/value-objects/email-address";
|
||||||
import { emailsPageScript } from "../../scripts/generated/emails-page";
|
import { emailsPageScript } from "../../scripts/generated/emails-page";
|
||||||
|
|
||||||
type AppEnv = { Bindings: Env };
|
type AppEnv = { Bindings: Env };
|
||||||
@@ -71,19 +72,15 @@ const CopyField = ({ label, value, display }: CopyFieldProps) => (
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
function extractSenderEmail(from: string): string {
|
|
||||||
const match = from.match(/<([^>]+@[^>]+)>/);
|
|
||||||
return match ? match[1].trim().toLowerCase() : from.trim().toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
type SenderFieldProps = {
|
type SenderFieldProps = {
|
||||||
from: string;
|
from: string;
|
||||||
feedId: string;
|
feedId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const SenderField = ({ from, feedId }: SenderFieldProps) => {
|
const SenderField = ({ from, feedId }: SenderFieldProps) => {
|
||||||
const senderEmail = extractSenderEmail(from);
|
const parsed = EmailAddress.parse(from);
|
||||||
const senderDomain = senderEmail.split("@")[1] || "";
|
const senderEmail = parsed?.normalized ?? from.trim().toLowerCase();
|
||||||
|
const senderDomain = parsed?.domain.value ?? "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="copyable">
|
<div class="copyable">
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { EmailData } from "../types";
|
import { EmailData } from "../types";
|
||||||
|
import { FeedId } from "../domain/value-objects/feed-id";
|
||||||
|
|
||||||
export class EmailParser {
|
export class EmailParser {
|
||||||
// Matches noun1.noun2.XY (the feed ID format) before the @ symbol
|
// Matches noun1.noun2.XY (the feed ID format) before the @ symbol
|
||||||
static extractFeedId(emailAddress: string): string | null {
|
static extractFeedId(emailAddress: string): string | null {
|
||||||
const match = emailAddress.match(/^([a-z]+\.[a-z]+\.\d{2})@/i);
|
return FeedId.parse(emailAddress)?.value ?? null;
|
||||||
return match ? match[1] : null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
MAX_ICON_BYTES,
|
MAX_ICON_BYTES,
|
||||||
} from "../config/constants";
|
} from "../config/constants";
|
||||||
import { FeedRepository } from "../domain/feed-repository";
|
import { FeedRepository } from "../domain/feed-repository";
|
||||||
|
import { EmailAddress } from "../domain/value-objects/email-address";
|
||||||
import { logger } from "../lib/logger";
|
import { logger } from "../lib/logger";
|
||||||
|
|
||||||
interface IconRecord {
|
interface IconRecord {
|
||||||
@@ -18,10 +19,7 @@ interface IconRecord {
|
|||||||
* no plausible address can be parsed.
|
* no plausible address can be parsed.
|
||||||
*/
|
*/
|
||||||
export function extractEmailDomain(from: string): string | null {
|
export function extractEmailDomain(from: string): string | null {
|
||||||
const match = from.match(/[^\s<>@]+@([^\s<>@]+\.[^\s<>@]+)/);
|
return EmailAddress.parse(from)?.domain.value ?? null;
|
||||||
if (!match) return null;
|
|
||||||
const domain = match[1].trim().toLowerCase().replace(/\.+$/, "");
|
|
||||||
return domain || null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function arrayBufferToBase64(buffer: ArrayBuffer): string {
|
function arrayBufferToBase64(buffer: ArrayBuffer): string {
|
||||||
|
|||||||
@@ -1,17 +1,9 @@
|
|||||||
import { nouns } from "../data/nouns";
|
import { FeedId } from "../domain/value-objects/feed-id";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates a random feed ID in the format noun1.noun2.XY
|
* Generates a random feed ID in the format noun1.noun2.XY
|
||||||
* @returns A random feed ID string
|
* @returns A random feed ID string
|
||||||
*/
|
*/
|
||||||
export function generateFeedId(): string {
|
export function generateFeedId(): string {
|
||||||
// Select two random nouns
|
return FeedId.generate().value;
|
||||||
const noun1 = nouns[Math.floor(Math.random() * nouns.length)];
|
|
||||||
const noun2 = nouns[Math.floor(Math.random() * nouns.length)];
|
|
||||||
|
|
||||||
// Generate a random 2-digit number between 10 and 99
|
|
||||||
const number = Math.floor(Math.random() * 90) + 10;
|
|
||||||
|
|
||||||
// Combine to create the ID with dots as separators
|
|
||||||
return `${noun1}.${noun2}.${number}`;
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user