diff --git a/src/domain/feed.ts b/src/domain/feed.ts index 513d955..044a941 100644 --- a/src/domain/feed.ts +++ b/src/domain/feed.ts @@ -1,4 +1,6 @@ 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; @@ -41,29 +43,36 @@ function normalizeEmail(value: string): string { 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( sender: string, allowedSenders: string[], blockedSenders: string[], ): SenderMatch { - const normalized = normalizeEmail(sender); - const domain = normalized.split("@")[1] || ""; - - const normalizeDomain = (e: string) => (e.startsWith("@") ? e.slice(1) : e); + const parsed = EmailAddress.parse(sender); + const normalized = parsed ? parsed.normalized : normalizeEmail(sender); + const senderDomain = parsed?.domain ?? null; const exactBlocked = blockedSenders.filter((e) => e.includes("@")); const exactAllowed = allowedSenders.filter((e) => e.includes("@")); - const domainBlocked = blockedSenders - .filter((e) => !e.includes("@")) - .map(normalizeDomain); - const domainAllowed = allowedSenders - .filter((e) => !e.includes("@")) - .map(normalizeDomain); + const domainBlocked = toDomains( + blockedSenders.filter((e) => !e.includes("@")), + ); + const domainAllowed = toDomains( + allowedSenders.filter((e) => !e.includes("@")), + ); if (exactBlocked.includes(normalized)) return "blocked"; if (exactAllowed.includes(normalized)) return "allowed"; - if (domain && domainBlocked.includes(domain)) return "blocked"; - if (domain && domainAllowed.includes(domain)) return "allowed"; + if (senderDomain && domainBlocked.some((d) => d.matches(senderDomain))) + return "blocked"; + if (senderDomain && domainAllowed.some((d) => d.matches(senderDomain))) + return "allowed"; return "neutral"; } diff --git a/src/domain/value-objects/domain.test.ts b/src/domain/value-objects/domain.test.ts new file mode 100644 index 0000000..894c04e --- /dev/null +++ b/src/domain/value-objects/domain.test.ts @@ -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); + }); +}); diff --git a/src/domain/value-objects/domain.ts b/src/domain/value-objects/domain.ts new file mode 100644 index 0000000..fe278a4 --- /dev/null +++ b/src/domain/value-objects/domain.ts @@ -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; + } +} diff --git a/src/domain/value-objects/email-address.test.ts b/src/domain/value-objects/email-address.test.ts new file mode 100644 index 0000000..a5b84d2 --- /dev/null +++ b/src/domain/value-objects/email-address.test.ts @@ -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 )", () => { + const email = EmailAddress.parse("GitHub ")!; + 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(); + }); +}); diff --git a/src/domain/value-objects/email-address.ts b/src/domain/value-objects/email-address.ts new file mode 100644 index 0000000..779d6de --- /dev/null +++ b/src/domain/value-objects/email-address.ts @@ -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 `), 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; + } +} diff --git a/src/domain/value-objects/feed-id.test.ts b/src/domain/value-objects/feed-id.test.ts new file mode 100644 index 0000000..a7079b5 --- /dev/null +++ b/src/domain/value-objects/feed-id.test.ts @@ -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); + }); +}); diff --git a/src/domain/value-objects/feed-id.ts b/src/domain/value-objects/feed-id.ts new file mode 100644 index 0000000..17f59bc --- /dev/null +++ b/src/domain/value-objects/feed-id.ts @@ -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; + } +} diff --git a/src/routes/admin/emails.tsx b/src/routes/admin/emails.tsx index d977b5b..c30093d 100644 --- a/src/routes/admin/emails.tsx +++ b/src/routes/admin/emails.tsx @@ -9,6 +9,7 @@ import { import { FeedRepository } from "../../domain/feed-repository"; import { feedRssUrl, feedAtomUrl, feedEmailAddress } from "../../utils/urls"; import { formatBytes } from "../../utils/format"; +import { EmailAddress } from "../../domain/value-objects/email-address"; import { emailsPageScript } from "../../scripts/generated/emails-page"; type AppEnv = { Bindings: Env }; @@ -71,19 +72,15 @@ const CopyField = ({ label, value, display }: CopyFieldProps) => ( ); -function extractSenderEmail(from: string): string { - const match = from.match(/<([^>]+@[^>]+)>/); - return match ? match[1].trim().toLowerCase() : from.trim().toLowerCase(); -} - type SenderFieldProps = { from: string; feedId: string; }; const SenderField = ({ from, feedId }: SenderFieldProps) => { - const senderEmail = extractSenderEmail(from); - const senderDomain = senderEmail.split("@")[1] || ""; + const parsed = EmailAddress.parse(from); + const senderEmail = parsed?.normalized ?? from.trim().toLowerCase(); + const senderDomain = parsed?.domain.value ?? ""; return (
diff --git a/src/utils/email-parser.ts b/src/utils/email-parser.ts index 0781495..52ecd75 100644 --- a/src/utils/email-parser.ts +++ b/src/utils/email-parser.ts @@ -1,10 +1,10 @@ import { EmailData } from "../types"; +import { FeedId } from "../domain/value-objects/feed-id"; export class EmailParser { // Matches noun1.noun2.XY (the feed ID format) before the @ symbol static extractFeedId(emailAddress: string): string | null { - const match = emailAddress.match(/^([a-z]+\.[a-z]+\.\d{2})@/i); - return match ? match[1] : null; + return FeedId.parse(emailAddress)?.value ?? null; } // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/src/utils/favicon-fetcher.ts b/src/utils/favicon-fetcher.ts index 9cb1e62..3fd891f 100644 --- a/src/utils/favicon-fetcher.ts +++ b/src/utils/favicon-fetcher.ts @@ -5,6 +5,7 @@ import { MAX_ICON_BYTES, } from "../config/constants"; import { FeedRepository } from "../domain/feed-repository"; +import { EmailAddress } from "../domain/value-objects/email-address"; import { logger } from "../lib/logger"; interface IconRecord { @@ -18,10 +19,7 @@ interface IconRecord { * no plausible address can be parsed. */ export function extractEmailDomain(from: string): string | null { - const match = from.match(/[^\s<>@]+@([^\s<>@]+\.[^\s<>@]+)/); - if (!match) return null; - const domain = match[1].trim().toLowerCase().replace(/\.+$/, ""); - return domain || null; + return EmailAddress.parse(from)?.domain.value ?? null; } function arrayBufferToBase64(buffer: ArrayBuffer): string { diff --git a/src/utils/id-generator.ts b/src/utils/id-generator.ts index a850c59..a52b78f 100644 --- a/src/utils/id-generator.ts +++ b/src/utils/id-generator.ts @@ -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 * @returns A random feed ID string */ export function generateFeedId(): string { - // Select two random nouns - 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}`; + return FeedId.generate().value; }