724ff0b077542237aadb4a58ad78ccfa9ecd9b16
9
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
acc50f009c |
Archive and mark read from the notification itself
Both happen in the background. The phone stays where it is. This was twice described as impossible, here and in FEATURES.md: the service worker was said to have no session, so anything touching mail had to open the app. That is wrong, and checking it rather than repeating it is the whole of this change. ihasmail's session is an httpOnly cookie against its own origin and the only other thing the API asks for is a fixed `x-requested-with` header, which is not a secret and is not held anywhere. A same-origin fetch from the worker carries the cookie like any other. Confirmed against the mock: logging in with curl and then issuing `Email/set` with nothing but that cookie and the static headers marked a message read and moved it to Archive, HTTP 200. Nothing the tab holds in memory is involved, because the API asks for none of it. Two actions, because `maxActions` is two on Android and anything past it is dropped without a word. Archive and Mark as read are the two worth having: they are what somebody does to a notification they have already read the whole of. Reply is not among them -- it would have to open the app, which is what tapping the notification does already. The worker still cannot reach a catalogue. It is plain JavaScript copied into the build, outside the bundle, with no i18n and no idea which mailbox is the archive. So the app writes both down in the same cache it already uses for handoffs, and rewrites them whenever the language, the account or the folder list changes. Where there is no such note -- between installing this worker and next opening ihasmail -- the notification appears with no buttons at all, rather than English ones over a mailbox guessed by name. That also fixes two strings the worker had always shown in English regardless: "New mail" and "(no subject)". A session can be gone by the time a button is pressed. That comes back as a refusal and the notification says so, rather than vanishing as though it had worked. It does not open the app to recover: being interrupted is what the button existed to avoid. The two claims that were wrong are corrected rather than quietly deleted, including the one about push renewal -- which still needs a tab, but for a different reason than the one given. The reason is when the worker runs, not what it may do: it wakes only for a push, and the push stops when the subscription lapses. Two new strings, in all nine catalogues. |
||
|
|
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. |
||
|
|
44b676c55d |
Name the push verification entry absolutely, from both sides
A JMAP push subscription stays silent until the client echoes back a verification code. When the code arrives with no tab open, the service worker leaves it in the cache for the next tab to collect. Both sides named that entry relatively, and a relative key is resolved against the URL of whoever is asking. The worker lives at <base>/sw.js, so it wrote under <base>/; a tab at /mail/inbox/abc looked under /mail/inbox/. They agreed only when the open page happened to be the root, which is why this survived: the case that works is the one people try first. The failure is quiet in the worst way. A subscription that never gets its code back simply never delivers, which is indistinguishable from push not working at all -- there is no error anywhere to notice. Both sides now build the key from the mount: the worker from the BASE it already derives from its own location, the page through withBase. Found while adding BASE_PATH, where the two disagree at every route rather than only at deep ones; left alone then because it was pre-existing and unrelated to that change. |
||
|
|
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.
|
||
|
|
96bc7b53d7 |
Notifications that arrive when ihasmail is closed
ihasmail's notifications came from EventSource, which lives exactly as
long as a tab does -- so "desktop notifications" has always quietly
meant "while you are looking". That switch is now labelled as much, and
a second one does the thing people assumed the first one did.
Stalwart 0.16 signs Web Push with VAPID (RFC 9749) and can put the
message itself in the payload (draft-ietf-jmap-emailpush). The server
pushes straight to the browser's own push service: ihasmail's server is
not in the delivery path, there is no relay to run, and nothing beyond
the browser vendor's endpoint that Web Push requires of everyone.
Checked against the live 0.16.19 before any of this was written, because
an advertised capability is not a configured one:
- the session publishes a real applicationServerKey, so no key
generation or server configuration is needed
- PushSubscription/get answers an ordinary user rather than refusing
- emailpush is advertised, and its draft defines a filter, an ordered
properties list and an urgency -- so the payload can carry sender and
subject, and the server drops properties from the end when it will
not fit rather than failing the notification
Three things this gets right that are easy to get wrong:
- The verification handshake. A JMAP subscription delivers nothing
until the client echoes back a code the server pushed, and the
service worker cannot answer it -- no credentials in that context.
It forwards the code to a tab, or leaves it in the cache when no tab
was open to forward it to.
- Key encoding. The W3C Push API produces unpadded base64url and
Stalwart 0.16 was fixed to accept exactly that, so nothing here pads
on the way out. The VAPID key needs padding on the way *in* for
atob; getting that backwards fails at subscribe() with an opaque
error, so it lives in one named function with tests.
- Sign-out. A subscription belongs to the account, not the session.
Without tearing it down, a shared machine keeps notifying for a
mailbox nobody is signed into -- which is somebody else's mail.
The mock models the JMAP half, including refusing padded keys and
non-https endpoints, and creating subscriptions *unverified*. Delivery
cannot be mocked -- it runs through the browser vendor's real push
service -- but a mock that marked a subscription verified on creation
would let a client ship without the handshake, and the symptom in
production is "registered, and silent".
Not verified end to end: an actual notification arriving. That needs a
real browser, a real push service and real delivery, so it is live
testing or nothing.
|
||
|
|
c81e4f1aa9 |
Drop the .com wordmark from the logo
The logo baked "ihasmail.com" into the artwork, which is the wrong identity for a project that is not the hosted instance -- and at the 34px the topbar renders it at, the wordmark was an illegible smudge under a squashed cat. logo.png, icon-512.png, icon-192.png and apple-touch-icon.png are now the mark alone. favicon-64.png, icon-maskable.png and favicon.ico already were, and are untouched. Cropping needed a threshold: both source files carry a band of near-invisible pixels (alpha 1-10) roughly 40px wide down the left side, so a plain getbbox() crop leaves the mark sitting off-centre. The bounding box is taken at alpha > 8 instead. The login page had no name of its own -- it relied on the wordmark -- so it gets one as text, styled like the topbar's. Its screenshot is retaken; the rest still show the old mark in the topbar. |
||
|
|
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. |