The visitor picks what they need and a prepared email types itself out; one press opens it in their own mail app with subject and body filled in. The mailto link is built by a tested pure function (src/domain/email.ts, spec first) and only in the browser, so the address still never appears in the page source. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
172 lines
5.5 KiB
TypeScript
172 lines
5.5 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import {
|
|
assembleEmail,
|
|
mailtoHref,
|
|
parseEmailParts,
|
|
spellOutEmail,
|
|
} from "./email";
|
|
|
|
const parts = { user: "hello", domain: "mintel", tld: "me" } as const;
|
|
|
|
describe("parseEmailParts", () => {
|
|
it("accepts a plain user, domain and top-level domain", () => {
|
|
expect(parseEmailParts(parts)).toEqual({ ok: true, value: parts });
|
|
});
|
|
|
|
it("accepts dots, dashes, plus signs and digits in the user part", () => {
|
|
const raw = { user: "marc.m-1+web", domain: "mintel", tld: "me" };
|
|
expect(parseEmailParts(raw)).toEqual({ ok: true, value: raw });
|
|
});
|
|
|
|
it("accepts a domain with a subdomain and dashes", () => {
|
|
const raw = { user: "hello", domain: "mail.my-site", tld: "co" };
|
|
expect(parseEmailParts(raw)).toEqual({ ok: true, value: raw });
|
|
});
|
|
|
|
it.each([
|
|
["an empty user", ""],
|
|
["an at sign in the user", "he@llo"],
|
|
["a space in the user", "he llo"],
|
|
["a leading dot", ".hello"],
|
|
["a trailing dot", "hello."],
|
|
["a missing user", undefined],
|
|
["a number instead of text", 42],
|
|
])("rejects %s as invalid-user", (_label, user) => {
|
|
expect(parseEmailParts({ user, domain: "mintel", tld: "me" })).toEqual({
|
|
ok: false,
|
|
error: "invalid-user",
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
["an empty domain", ""],
|
|
["an at sign in the domain", "min@tel"],
|
|
["a space in the domain", "min tel"],
|
|
["a leading dash", "-mintel"],
|
|
["a trailing dash", "mintel-"],
|
|
["a leading dot", ".mintel"],
|
|
["a trailing dot", "mintel."],
|
|
["an underscore", "min_tel"],
|
|
["a missing domain", undefined],
|
|
])("rejects %s as invalid-domain", (_label, domain) => {
|
|
expect(parseEmailParts({ user: "hello", domain, tld: "me" })).toEqual({
|
|
ok: false,
|
|
error: "invalid-domain",
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
["an empty top-level domain", ""],
|
|
["a single letter", "m"],
|
|
["digits", "m3"],
|
|
["a dot inside", "co.uk"],
|
|
["a leading dot", ".me"],
|
|
["a missing top-level domain", undefined],
|
|
])("rejects %s as invalid-tld", (_label, tld) => {
|
|
expect(parseEmailParts({ user: "hello", domain: "mintel", tld })).toEqual({
|
|
ok: false,
|
|
error: "invalid-tld",
|
|
});
|
|
});
|
|
|
|
it("accepts a two-letter and a longer top-level domain", () => {
|
|
expect(parseEmailParts({ ...parts, tld: "me" }).ok).toBe(true);
|
|
expect(parseEmailParts({ ...parts, tld: "digital" }).ok).toBe(true);
|
|
});
|
|
|
|
it("rejects input that is not an object as invalid-user", () => {
|
|
expect(parseEmailParts(null)).toEqual({ ok: false, error: "invalid-user" });
|
|
expect(parseEmailParts("hello@mintel.me")).toEqual({
|
|
ok: false,
|
|
error: "invalid-user",
|
|
});
|
|
});
|
|
|
|
it("reports the user first when several parts are wrong", () => {
|
|
expect(parseEmailParts({ user: "", domain: "", tld: "" })).toEqual({
|
|
ok: false,
|
|
error: "invalid-user",
|
|
});
|
|
});
|
|
|
|
it("reports the domain before the top-level domain", () => {
|
|
expect(parseEmailParts({ user: "hello", domain: "", tld: "" })).toEqual({
|
|
ok: false,
|
|
error: "invalid-domain",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("assembleEmail", () => {
|
|
it("joins the parts into the real address", () => {
|
|
expect(assembleEmail(parts)).toBe("hello@mintel.me");
|
|
});
|
|
|
|
it("keeps subdomains", () => {
|
|
expect(
|
|
assembleEmail({ user: "a.b", domain: "mail.site", tld: "org" }),
|
|
).toBe("a.b@mail.site.org");
|
|
});
|
|
});
|
|
|
|
describe("spellOutEmail", () => {
|
|
it("writes the address without an at sign or a dot before the ending", () => {
|
|
expect(spellOutEmail(parts)).toBe("hello [at] mintel [dot] me");
|
|
});
|
|
|
|
it("never contains the real address", () => {
|
|
expect(spellOutEmail(parts)).not.toContain("@");
|
|
expect(spellOutEmail(parts)).not.toContain(assembleEmail(parts));
|
|
});
|
|
|
|
it("spells out dots inside the domain as well", () => {
|
|
expect(
|
|
spellOutEmail({ user: "hello", domain: "mail.site", tld: "org" }),
|
|
).toBe("hello [at] mail [dot] site [dot] org");
|
|
});
|
|
});
|
|
|
|
describe("mailtoHref", () => {
|
|
it("opens a message to the address with subject and body", () => {
|
|
expect(mailtoHref(parts, { subject: "Hello", body: "Hi" })).toBe(
|
|
"mailto:hello@mintel.me?subject=Hello&body=Hi",
|
|
);
|
|
});
|
|
|
|
it("encodes spaces as %20, not as plus signs", () => {
|
|
expect(mailtoHref(parts, { subject: "A website", body: "Hi Marc" })).toBe(
|
|
"mailto:hello@mintel.me?subject=A%20website&body=Hi%20Marc",
|
|
);
|
|
});
|
|
|
|
it("encodes line breaks so the mail app keeps the paragraphs", () => {
|
|
expect(mailtoHref(parts, { subject: "S", body: "one\n\ntwo" })).toBe(
|
|
"mailto:hello@mintel.me?subject=S&body=one%0D%0A%0D%0Atwo",
|
|
);
|
|
});
|
|
|
|
it("encodes characters that would break the link", () => {
|
|
expect(mailtoHref(parts, { subject: "Q&A: 50%?", body: "a=b #1" })).toBe(
|
|
"mailto:hello@mintel.me?subject=Q%26A%3A%2050%25%3F&body=a%3Db%20%231",
|
|
);
|
|
});
|
|
|
|
it("keeps umlauts readable for the mail app", () => {
|
|
expect(mailtoHref(parts, { subject: "Grüße", body: "ä" })).toBe(
|
|
"mailto:hello@mintel.me?subject=Gr%C3%BC%C3%9Fe&body=%C3%A4",
|
|
);
|
|
});
|
|
|
|
it("leaves out an empty subject or body instead of sending empty fields", () => {
|
|
expect(mailtoHref(parts, { subject: "", body: "" })).toBe(
|
|
"mailto:hello@mintel.me",
|
|
);
|
|
expect(mailtoHref(parts, { subject: "Only subject", body: "" })).toBe(
|
|
"mailto:hello@mintel.me?subject=Only%20subject",
|
|
);
|
|
expect(mailtoHref(parts, { subject: "", body: "Only body" })).toBe(
|
|
"mailto:hello@mintel.me?body=Only%20body",
|
|
);
|
|
});
|
|
});
|