3c4f6a9f8ee4ae7bae81f0b8651f4e2f0a08fcbe
5
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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. |
||
|
|
4e61adfe80 |
Badge the installed icon, and share to the phone rather than to Downloads
Three things an installed ihasmail did not do that a phone user expects, and all three are about the app once it is off the browser tab. The unread count was painted into the tab title and the favicon, neither of which exists in `display: standalone` -- so putting ihasmail on a home screen threw the count away entirely. It goes to the Badging API as well now. Web Push marks the icon while the app is closed, and marks it with a dot rather than a figure: the service worker has no session to ask how many messages are unread, and a push carries the new mail rather than a total, so counting the payload would badge "2" over an inbox holding forty. The next tab to open writes the real count over it. Sharing is new. Everything that left ihasmail left as a download, which on a phone is close to a dead end -- the file lands in Downloads and whoever meant to send it somewhere goes looking for it in a file manager. The share sheet is now on the message menu, on each attachment row, and in the file viewer, which is where an attachment is already open and where both callers meet. A message shares as text rather than as the .eml beside it: a share sheet is aimed at everything that is not a mail client, and an .eml in a chat app is an attachment nobody can open. Every control feature-detects, and sharing a file is a separate question from sharing at all -- desktop Linux and Firefox have neither, and not every browser with `share` takes files. Anything that fails, including the transient activation running out while a large attachment is fetched, falls through to the download the button sits beside, so the worst case costs a tap rather than the file. `NotAllowedError` is reported as unsupported for that reason: it cannot be told apart from a refusal, and a toast about activation is not something a reader can act on. The share strings are contextual keys rather than the existing "Share…". That one means granting another account access, and several languages use a different verb for it -- German had "Freigeben" where the sheet wants "Teilen". Three new strings, in all nine catalogues. The manifest gains `launch_handler: navigate-existing`, so a mailto:, a shortcut or a notification tapped while ihasmail is running arrives in the copy that is running: two windows on one inbox disagree about what has been read. `focus-existing` would have been wrong -- it only focuses and leaves the target URL to launchQueue, which nothing here consumes, so it would swallow the mailto. There is deliberately still no `id`, and the manifest now says why: it is the one member resolved against the origin of start_url rather than against the manifest's own address, so no relative form can name a subpath mount, and the default id already is start_url -- writing one now would give every installed copy a new identity and orphan it as a second app. Verified by test rather than on a device: the extension driving Chrome was not connected, and Chrome on Linux has no Web Share to drive anyway. The preview dialog is covered by a component test that stubs the browser both ways. |
||
|
|
93d0a32af2 |
Serve ihasmail from a subpath
`BASE_PATH=/mail` mounts the whole app under a prefix, for a host that is not
ihasmail's alone. Unset -- every deployment that exists -- is the domain root
and is byte-for-byte what it was: the canonical form of the setting is the
empty string, and `""` concatenated onto `/api/health` is `/api/health`.
That choice of canonical form is the whole design. A trailing slash would have
been the obvious alternative, and it fails quietly in exactly one place: at the
root it makes `//api/health`, which is not a path on this host but a
protocol-relative URL to a host called `api`. One call site forgetting to
branch is a request leaving the origin. So the empty string, one leading slash,
no trailing one, worked out once in `scripts/basePath.mjs` -- plain JS, next to
`version.mjs`, because the web build and the server both have to reach the same
answer and two implementations of "what does /mail/ mean" is precisely the bug
where the server serves an app whose script tags point somewhere else.
`/mail`, `mail`, `/mail/` and `//mail//` all mean the same mount; a deployment
should not fail over a trailing slash.
Unlike everything else ihasmail is told, this one cannot wait for the process
to start. The bundle writes its own asset URLs into index.html, so `BASE_PATH`
is read at build time for Vite's `base` as well as at run time for the routes,
and the Dockerfile carries one value into both. Get them out of step and the
page comes up blank with a 404 in a console nobody has open -- so the static
handler, which is reading index.html anyway, checks what it asks for and says
so in the log once per build.
Everything moves together. The API mounts at `${base}/api`; the router is
given the base once, so every `<Route path>` and `<Link href>` stays written
root-absolute and wouter does the rest; `apiFetch` adds the prefix in one place
rather than at forty call sites; the session cookie's Path narrows to the mount
so two instances on one host cannot sign each other out.
Two things need no prefix at all, and it is worth saying why they were not
given one. A manifest's members resolve against the manifest's own address, so
relative URLs there follow the mount with nothing substituted at build time --
which is also why `public/` needed no template step. The service worker is the
same trick: it is served from the mount, so `new URL("./", self.location)`
tells it where that is, and a worker that derives the value cannot disagree
with the page that registered it.
Anything outside the mount is a 404 rather than the app shell, and
`stripBasePath` does not use `startsWith` -- under `/mail` this process shares
a hostname, and answering `/mailbox` with our index would shadow a neighbour
instead of letting it 404 honestly. For the same reason the notification-click
handler now checks the path as well as the origin: `includeUncontrolled` widens
`matchAll` to the whole origin, which off the root would have navigated a
stranger's tab to our inbox.
Inline images in a draft were the one silent trap. They are matched by their
blob URL on the way out, once unanchored and once anchored, and a bare
`/api/blob/` still appears inside `/mail/api/blob/...` -- so one pattern would
have replaced the tail and left `/mail` in front of a `cid:`, and the other
would have missed and sent the message linking to the sender's own webmail.
Both patterns are built from the base now.
|
||
|
|
2c23ea980b |
Offer ihasmail as the browser's mailto: handler
Settings > General gains a "Default mail app" section that calls registerProtocolHandler so mail links anywhere in the browser open ihasmail. The browser owns the decision and there is no API to read it back, so the UI says what it can: it records that we asked, offers "Ask again", shows a Remove button where unregisterProtocolHandler exists, and points at the browser's own settings. Unsupported browsers (Safari) and insecure contexts get an explanation instead of a dead button. The manifest now declares protocol_handlers for mailto, which is the route by which an *installed* app can be offered by the operating system itself; the UI says so and links the two ideas rather than promising a system-wide default the page cannot grant. Mailto parsing is now one function (parseMailto in lib/address.ts) instead of three hand-rolled copies in AppShell and MessageView. It follows RFC 6068: recipients from the path, the to= header or both, case-insensitive headers, "+" as space, and tolerant of malformed escapes. That fixes Cc and Bcc being silently dropped, and draftFromMailto escapes the body so a mailto: URL from an untrusted page reaches the composer as text rather than markup. |
||
|
|
645b8b510f |
ihasmail 2.0: rebuild as Stalwart-first JMAP webmail
Replace the FastAPI/HTMX prototype with a Node/Hono session proxy and a React 19/Vite SPA. Mail (conversation view, search operators, labels, sanitised HTML, privacy image proxy, invites, undo send, templates), calendar (month/week/day/agenda, invites, free/busy, categories, context menus), contacts (JSContact, groups, vCard), files, Sieve filter builder (incl. filter-from-message with retroactive apply), vacation, identities with default + Reply-To, PWA/mobile layout, push via SSE, in-memory mock Stalwart for dev, Docker + CI. |