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 <[email protected]>
This commit is contained in:
1 parent
8f036cf223
commit
c65aabe7f4
11 files changed
+198
-35
No files matched your search
@@ -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("[email protected]")!;
|
||||
expect(email.normalized).toBe("[email protected]");
|
||||
expect(email.domain.value).toBe("example.com");
|
||||
});
|
||||
|
||||
it("parses a display form (Name <addr>)", () => {
|
||||
const email = EmailAddress.parse("GitHub <[email protected]>")!;
|
||||
expect(email.normalized).toBe("[email protected]");
|
||||
expect(email.domain.value).toBe("github.com");
|
||||
});
|
||||
|
||||
it("strips a trailing dot from the domain", () => {
|
||||
expect(EmailAddress.parse("[email protected].")?.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 (`[email protected]`) or a
|
||||
* display form (`Name <[email protected]>`), 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("[email protected]")?.value).toBe(
|
||||
"river.castle.42",
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves the original casing of the local part", () => {
|
||||
expect(FeedId.parse("[email protected]")?.value).toBe(
|
||||
"River.Castle.42",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects malformed feed ids", () => {
|
||||
expect(FeedId.parse("[email protected]")).toBeNull();
|
||||
expect(FeedId.parse("notanemail")).toBeNull();
|
||||
expect(FeedId.parse("[email protected]")).toBeNull();
|
||||
expect(FeedId.parse("[email protected]")).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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user