diff --git a/src/domain/value-objects/email-address.test.ts b/src/domain/value-objects/email-address.test.ts index 52fd4ca..d00ff70 100644 --- a/src/domain/value-objects/email-address.test.ts +++ b/src/domain/value-objects/email-address.test.ts @@ -30,4 +30,20 @@ describe("EmailAddress", () => { "https://example.com/", ); }); + + it("captures the display name verbatim from a display form", () => { + const email = EmailAddress.parse("Alice B ")!; + expect(email.displayName).toBe("Alice B"); + expect(email.label()).toBe("Alice B"); + }); + + it("has no display name for a bare address and labels by the address", () => { + const email = EmailAddress.parse("Bob@Example.com")!; + expect(email.displayName).toBeUndefined(); + expect(email.label()).toBe("bob@example.com"); + }); + + it("falls back to the address as the label when the display name is empty", () => { + expect(EmailAddress.parse("")?.label()).toBe("a@b.com"); + }); }); diff --git a/src/domain/value-objects/email-address.ts b/src/domain/value-objects/email-address.ts index db028e8..84cca0b 100644 --- a/src/domain/value-objects/email-address.ts +++ b/src/domain/value-objects/email-address.ts @@ -3,12 +3,16 @@ 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. + * the domain. When the input carries a display name it is captured (verbatim, + * not normalised — names are case-sensitive). Returns null when no plausible + * address can be found. */ export class EmailAddress { private constructor( readonly normalized: string, readonly domain: Domain, + /** The sender's display name from a `Name ` input, if any. */ + readonly displayName?: string, ) {} static parse(raw: string): EmailAddress | null { @@ -17,7 +21,17 @@ export class EmailAddress { const domain = Domain.parse(match[2]); if (!domain) return null; const local = match[1].trim().toLowerCase(); - return new EmailAddress(`${local}@${domain.value}`, domain); + const displayName = + raw.match(/^\s*(.+?)\s*<[^>]+>\s*$/)?.[1].trim() || undefined; + return new EmailAddress(`${local}@${domain.value}`, domain, displayName); + } + + /** + * The best human-readable label for this sender: the display name when the + * address came in `Name ` form, else the normalised address. + */ + label(): string { + return this.displayName ?? this.normalized; } /** diff --git a/src/infrastructure/feed-generator.ts b/src/infrastructure/feed-generator.ts index 0957daa..5575578 100644 --- a/src/infrastructure/feed-generator.ts +++ b/src/infrastructure/feed-generator.ts @@ -14,18 +14,6 @@ function stripInvalidXmlChars(xml: string): string { return xml.replace(/[^\x09\x0A\x0D\x20-퟿-�\u{10000}-\u{10FFFF}]/gu, ""); } -function parseFromAddress(from: string): { name: string; email?: string } { - const match = from.match(/^(.*?)\s*<([^>]+)>\s*$/); - if (match) { - return { name: match[1].trim() || match[2], email: match[2].trim() }; - } - const emailOnly = from.match(/^[^\s@]+@[^\s@]+\.[^\s@]+$/); - if (emailOnly) { - return { email: from.trim(), name: from.trim() }; - } - return { name: from.trim() }; -} - function buildFeed( feedConfig: FeedConfig, emails: EmailData[], @@ -68,23 +56,28 @@ function buildFeed( const entryUrl = `${baseUrl}${entryPath(feedId, email.receivedAt)}`; // Inline images are rendered in the body, not surfaced as an enclosure. const firstAttachment = email.attachments?.find((a) => !a.inline); + const sender = EmailAddress.parse(email.from); + const senderLabel = sender?.label() ?? email.from.trim(); const bodyContent = processEmailContent( email.content, email.attachments, baseUrl, - EmailAddress.parse(email.from)?.siteBaseUrl() ?? "", + sender?.siteBaseUrl() ?? "", ); - const sender = parseFromAddress(email.from); const subject = htmlToText(email.subject); feed.addItem({ title: feedConfig.sender_in_title - ? `[${sender.name}] ${subject}` + ? `[${senderLabel}] ${subject}` : subject, id: entryUrl, link: entryUrl, description: bodyContent, content: bodyContent, - author: [sender], + author: [ + sender + ? { name: senderLabel, email: sender.normalized } + : { name: senderLabel }, + ], date: new Date(email.receivedAt), enclosure: firstAttachment ? {