Feature/protocol handlers
* Added account selection for protocol links when multiple connected accounts are available, including mailto: links * Added support for handling mailto: links in an already-open PWA/session instead of always opening a new tab * Added webcal: protocol handling for calendar links * Added account selection for webcal: links when multiple calendar-capable accounts are connected * Added an import-or-subscribe choice for detected webcal calendars * Added protocol handler settings for registering mail and calendar handlers and choosing the open mode * Added service worker/session coordination for passing protocol requests between browser/PWA contexts * Added tests and translations for the new protocol handler flows
This commit is contained in:
committed by
Linus Rath
parent
8b0e2052cf
commit
3f444a8912
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { plainTextToComposerBody } from "../email-composer-utils";
|
||||
|
||||
describe("plainTextToComposerBody", () => {
|
||||
it("returns an empty string for empty input", () => {
|
||||
expect(plainTextToComposerBody("")).toBe("");
|
||||
});
|
||||
|
||||
it("escapes HTML before building composer paragraphs", () => {
|
||||
expect(plainTextToComposerBody("<script>alert('x') & \"q\"</script>")).toBe(
|
||||
"<p><script>alert('x') & "q"</script></p>"
|
||||
);
|
||||
});
|
||||
|
||||
it("normalizes line endings and preserves single line breaks", () => {
|
||||
expect(plainTextToComposerBody("line1\r\nline2\rline3")).toBe(
|
||||
"<p>line1<br>line2<br>line3</p>"
|
||||
);
|
||||
});
|
||||
|
||||
it("splits paragraphs on blank lines", () => {
|
||||
expect(plainTextToComposerBody("first\n\nsecond\nthird")).toBe(
|
||||
"<p>first</p><p>second<br>third</p>"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { parseMailto } from "../protocol-handlers/mailto";
|
||||
import { listenForMailtoRequests } from "../protocol-handlers/session";
|
||||
import { parseWebcal } from "../protocol-handlers/webcal";
|
||||
|
||||
const originalServiceWorkerDescriptor = Object.getOwnPropertyDescriptor(navigator, "serviceWorker");
|
||||
|
||||
function installServiceWorkerMock() {
|
||||
const listeners = new Set<(event: MessageEvent) => void>();
|
||||
const worker = { postMessage: vi.fn() };
|
||||
const serviceWorker = {
|
||||
ready: Promise.resolve({ active: worker }),
|
||||
controller: worker,
|
||||
addEventListener: vi.fn((type: string, listener: EventListener) => {
|
||||
if (type === "message") listeners.add(listener as (event: MessageEvent) => void);
|
||||
}),
|
||||
removeEventListener: vi.fn((type: string, listener: EventListener) => {
|
||||
if (type === "message") listeners.delete(listener as (event: MessageEvent) => void);
|
||||
}),
|
||||
};
|
||||
|
||||
Object.defineProperty(navigator, "serviceWorker", {
|
||||
configurable: true,
|
||||
value: serviceWorker,
|
||||
});
|
||||
|
||||
return {
|
||||
dispatch(data: unknown) {
|
||||
listeners.forEach((listener) => listener(new MessageEvent("message", { data })));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
if (originalServiceWorkerDescriptor) {
|
||||
Object.defineProperty(navigator, "serviceWorker", originalServiceWorkerDescriptor);
|
||||
return;
|
||||
}
|
||||
Reflect.deleteProperty(navigator, "serviceWorker");
|
||||
});
|
||||
|
||||
describe("protocol handlers", () => {
|
||||
describe("parseMailto", () => {
|
||||
it("parses a single path recipient", () => {
|
||||
expect(parseMailto("mailto:alice@example.com")).toEqual({
|
||||
to: ["alice@example.com"],
|
||||
cc: [],
|
||||
bcc: [],
|
||||
subject: "",
|
||||
body: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses multiple recipients with subject and body", () => {
|
||||
expect(parseMailto("mailto:alice@example.com,bob@example.com?subject=Hello&body=Hi")).toMatchObject({
|
||||
to: ["alice@example.com", "bob@example.com"],
|
||||
subject: "Hello",
|
||||
body: "Hi",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses to, cc, and bcc query recipients", () => {
|
||||
expect(parseMailto("mailto:?to=alice@example.com&cc=bob@example.com&bcc=eve@example.com")).toMatchObject({
|
||||
to: ["alice@example.com"],
|
||||
cc: ["bob@example.com"],
|
||||
bcc: ["eve@example.com"],
|
||||
});
|
||||
});
|
||||
|
||||
it("decodes subject and body values", () => {
|
||||
expect(parseMailto("mailto:alice@example.com?subject=Hello%20World&body=line1%0Aline2")).toMatchObject({
|
||||
subject: "Hello World",
|
||||
body: "line1\nline2",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves literal plus signs in query values", () => {
|
||||
expect(parseMailto("mailto:?to=user+tag@example.com&subject=C++&body=a+b")).toMatchObject({
|
||||
to: ["user+tag@example.com"],
|
||||
subject: "C++",
|
||||
body: "a+b",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects non-mailto URLs", () => {
|
||||
expect(parseMailto("https://example.com")).toBeNull();
|
||||
});
|
||||
|
||||
it("allows an empty mailto URL", () => {
|
||||
expect(parseMailto("mailto:")).toEqual({
|
||||
to: [],
|
||||
cc: [],
|
||||
bcc: [],
|
||||
subject: "",
|
||||
body: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("removes control characters and caps recipients", () => {
|
||||
const recipients = Array.from({ length: 250 }, (_, index) => `user${index}@example.com`).join(",");
|
||||
const parsed = parseMailto(`mailto:${recipients}?subject=Hi%0ABcc:evil@example.com`);
|
||||
expect(parsed?.to).toHaveLength(200);
|
||||
expect(parsed?.subject).toBe("HiBcc:evil@example.com");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseWebcal", () => {
|
||||
it("normalizes webcal to https", () => {
|
||||
expect(parseWebcal("webcal://example.com/calendar.ics")?.subscriptionUrl).toBe("https://example.com/calendar.ics");
|
||||
});
|
||||
|
||||
it("normalizes webcals to https", () => {
|
||||
expect(parseWebcal("webcals://example.com/calendar.ics")?.subscriptionUrl).toBe("https://example.com/calendar.ics");
|
||||
});
|
||||
|
||||
it("accepts https URLs", () => {
|
||||
expect(parseWebcal("https://example.com/calendar.ics")?.subscriptionUrl).toBe("https://example.com/calendar.ics");
|
||||
});
|
||||
|
||||
it("rejects unsupported protocols", () => {
|
||||
expect(parseWebcal("ftp://example.com/calendar.ics")).toBeNull();
|
||||
});
|
||||
|
||||
it("suggests a name from the path", () => {
|
||||
expect(parseWebcal("webcal://example.com/team.ics")?.suggestedName).toBe("team");
|
||||
});
|
||||
|
||||
it("falls back to hostname for suggested name", () => {
|
||||
expect(parseWebcal("webcal://example.com/")?.suggestedName).toBe("example.com");
|
||||
});
|
||||
|
||||
it("prefers a name query parameter", () => {
|
||||
expect(parseWebcal("webcal://example.com/team.ics?name=Team%20Calendar")?.suggestedName).toBe("Team Calendar");
|
||||
});
|
||||
});
|
||||
|
||||
describe("listenForMailtoRequests", () => {
|
||||
const mailtoValue = {
|
||||
to: ["alice@example.com"],
|
||||
cc: [],
|
||||
bcc: [],
|
||||
subject: "Hello",
|
||||
body: "Hi",
|
||||
};
|
||||
|
||||
it("accepts legacy service-worker mailto messages without a client id", () => {
|
||||
const serviceWorker = installServiceWorkerMock();
|
||||
const onMailto = vi.fn();
|
||||
vi.spyOn(window, "focus").mockImplementation(() => undefined);
|
||||
|
||||
const cleanup = listenForMailtoRequests(onMailto, () => ({ path: "/", standalone: false }));
|
||||
serviceWorker.dispatch({ type: "mailto-request", id: "legacy", value: mailtoValue });
|
||||
|
||||
expect(onMailto).toHaveBeenCalledWith(mailtoValue);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("ignores service-worker mailto messages for another client", () => {
|
||||
const serviceWorker = installServiceWorkerMock();
|
||||
const onMailto = vi.fn();
|
||||
|
||||
const cleanup = listenForMailtoRequests(onMailto, () => ({ path: "/", standalone: false }));
|
||||
serviceWorker.dispatch({ type: "mailto-request", id: "targeted", clientId: "other-client", value: mailtoValue });
|
||||
|
||||
expect(onMailto).not.toHaveBeenCalled();
|
||||
cleanup();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user