Files
ihasmail-inbuxa/web/src/store/__tests__/compose-from-share.test.ts
T
jcoffey-dev 82470e8db0 Be somewhere a phone can share to
ihasmail could hand a file to the share sheet as of #306, and was still
not in it. Share a photo from the gallery, a link from the browser or a
document from a file manager and ihasmail was not among the places it
could go, which is the one piece of operating-system integration a mail
app is expected to have.

A share is a POST that navigates, and there is nothing on this side that
can answer one: the app is a client-side router with no endpoint at that
address, and the server behind it would need a route that understood the
composer. So the service worker intercepts it, takes the form body, puts
the files and text in its cache, and redirects to the app -- which finds
them on start and opens a draft holding them. The subject is the shared
title, the text and the link become the body, and files are attached and
begin uploading. Nothing is addressed: a share says what to send, never
who to.

The body is pushed in above the signature rather than passed to open(),
because open() only fits a signature when it is given no body at all --
the obvious version drops the signature from every message that started
as a share, and nothing about the draft looks wrong afterwards.

Collected on every start rather than when the launch URL says so. A share
to a signed-out ihasmail lands on the sign-in page, and there is no
account to attach to until it is done, so the payload has to outlive a
redirect and a login -- which the query string does not. What that costs
is a stash nobody came back for, so it carries a timestamp and expires
after ten minutes.

`accept` names wildcard families and explicit types and extensions both.
A mail client attaches anything, but wildcards are not in the
specification and operating systems differ over which form they match on,
so the explicit list is what holds if the families are ignored.

The cache name the worker and the app have to agree on now has one home
on the app side. It was written out twice, and a drift would not fail --
a push verification would simply never complete and a share would arrive
at an empty composer.

One case is deliberately left to fail loudly: an app still installed
whose worker has been cleared away POSTs to the server, which answers
405. A server route would trade a plain error for a silent nothing, and
the payload is gone in both -- it only ever existed in that request body.

Verified by test, not on a device: Android is the only place this exists
at all, and the extension driving Chrome is not connected here. The
handoff is pinned from the tab's side against a cache shaped exactly as
the worker leaves it, since the two files never see each other.
2026-09-07 22:40:44 -07:00

88 lines
3.6 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from "vitest";
import { useCompose } from "@/store/compose";
import { useMail } from "@/store/mail";
import { client } from "@/jmap/client";
import type { SharedContent } from "@/lib/shareTarget";
/**
* What a share becomes once it reaches the composer.
*
* The signature is the part worth a test. `open()` only fits one when it is
* given no body at all, so the obvious implementation -- pass the shared text
* straight to `open()` -- silently drops the signature from every message that
* started as a share, and nothing about the draft looks wrong.
*/
const IDENTITY = {
id: "i1",
name: "John",
email: "[email protected]",
replyTo: null,
htmlSignature: "<p>-- <br>John</p>",
textSignature: "-- \nJohn",
};
function share(over: Partial<SharedContent> = {}): SharedContent {
return { title: "", text: "", url: "", files: [], ...over };
}
beforeEach(() => {
useCompose.setState({ drafts: [], activeKey: null, pendingSends: {} });
useMail.setState({ accountId: "a1", identities: [IDENTITY] as never });
// addFiles uploads as it goes; nothing here is testing the upload, and a
// real one would reach for the network.
vi.spyOn(client, "upload").mockResolvedValue({ blobId: "b1", type: "image/png", size: 6 } as never);
});
const draftFor = (key: string) => useCompose.getState().drafts.find((d) => d.key === key)!;
describe("opening a share as a draft", () => {
it("makes the shared title the subject and addresses nothing", () => {
// A share says what to send, never who to. Anything else would be putting
// a recipient in a field the sharer never filled in.
const d = draftFor(useCompose.getState().openFromShare(share({ title: "Holiday plans" })));
expect(d.subject).toBe("Holiday plans");
expect(d.to).toEqual([]);
expect(d.cc).toEqual([]);
});
it("puts the shared text above the signature, not instead of it", () => {
const d = draftFor(useCompose.getState().openFromShare(share({ text: "Look at this" })));
expect(d.text).toContain("Look at this");
expect(d.text).toContain("John");
expect(d.html).toContain("Look at this");
expect(d.html).toContain("-- ");
// Above, not below: the reply goes where the caret lands.
expect(d.html.indexOf("Look at this")).toBeLessThan(d.html.indexOf("-- "));
});
it("carries a shared link into the body", () => {
const d = draftFor(useCompose.getState().openFromShare(share({ text: "worth reading", url: "https://example.com/a" })));
expect(d.text).toContain("https://example.com/a");
});
it("keeps the signature when a share carried nothing but files", () => {
const key = useCompose.getState().openFromShare(share({ files: [new File(["pixels"], "beach.png", { type: "image/png" })] }));
const d = draftFor(key);
expect(d.html).toContain("John");
expect(d.attachments.map((a) => [a.name, a.type])).toEqual([["beach.png", "image/png"]]);
});
it("attaches every shared file, and starts each one uploading", () => {
const files = [
new File(["a"], "one.png", { type: "image/png" }),
new File(["b"], "two.pdf", { type: "application/pdf" }),
];
const d = draftFor(useCompose.getState().openFromShare(share({ title: "Two things", files })));
expect(d.attachments).toHaveLength(2);
expect(d.attachments.every((a) => a.error === null)).toBe(true);
expect(client.upload).toHaveBeenCalledTimes(2);
});
it("opens a plain draft for a share that carried only a subject", () => {
const d = draftFor(useCompose.getState().openFromShare(share({ title: "Just this" })));
expect(d.subject).toBe("Just this");
expect(d.attachments).toEqual([]);
});
});