Archive and Mark as read now sit on the notification, and both happen in the background. The phone stays where it is.
The premise was wrong, and that is most of this PR
Both this repo and I have said twice that the service worker has no session, so anything touching mail had to open the app — the same wall snooze runs into. That is not true, and checking it rather than repeating it is the substance 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: ihasmail header, which is not a secret and is not held in memory anywhere (client.ts sends it from a module constant). A same-origin fetch from the worker carries the cookie like any other request.
Confirmed against the mock rather than reasoned about: logging in with curl, then issuing Email/set with nothing but that cookie and the static headers, marked a message read and moved it to Archive — HTTP 200, updated. Nothing the tab holds is involved, because the API asks for none of it.
The two places that claimed otherwise are corrected in place rather than quietly deleted, including the push-renewal paragraph — renewal still needs a tab, but for a different reason than the one given: the worker wakes only for a push, and the push stops arriving the moment the subscription lapses. A renewal that can only run while renewal is unnecessary is no schedule.
Two actions, not three
Notification.maxActions is 2 on Android and anything past it is dropped silently, so these are the two worth having rather than the two that came first. Archive and Mark as read are triage — what you do to a notification you have already read all of. Reply is deliberately absent: it would have to open the app, and tapping the notification already does that.
The part the worker still cannot do
It 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 (lib/swFacts.ts), rewritten 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 condition is the same one background push already carries.
This also fixes two strings the worker had always shown in English whatever the reader's language: "New mail" and "(no subject)".
Failure
A session can be gone by the time a button is pressed — expired, signed out, or a cookie that did not outlive the browser. That comes back as a refusal and the notification says so, rather than vanishing as though it worked. It does not open the app to recover: being interrupted is what the button existed to avoid.
Translations
Two new strings in all nine catalogues: New mail and Could not do that — open ihasmail and try again. Archive, Mark as read, New message and (no subject) were already there and are reused. Unreviewed like the rest.
Verification
npm run typecheck, npm test (1150 web + 161 server, 6 new) and npm run build pass; i18n:check shows no new missing keys. The API claim above is verified end to end against the mock, which is the load-bearing part.
Not driven on a device: the worker cannot be imported by the test runner and there is no push to receive here. What a phone still has to confirm is that the buttons appear, that two is the number that survives, and that a press with a stale cookie produces the refusal notification rather than silence.
Follow-up this opens up, not done here
The worker can now ask how many messages are unread, so the badge could carry a real number instead of a dot; and a PushVerification could be answered directly instead of stashed for a tab. Both are noted in the code rather than built — whether a badge is worth a request on every push is a separate question.
Archive and Mark as read now sit on the notification, and both happen in the background. The phone stays where it is.
### The premise was wrong, and that is most of this PR
Both this repo and I have said twice that the service worker has no session, so anything touching mail had to open the app — the same wall snooze runs into. That is not true, and checking it rather than repeating it is the substance 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: ihasmail` header, which is not a secret and is not held in memory anywhere (`client.ts` sends it from a module constant). A same-origin fetch from the worker carries the cookie like any other request.
Confirmed against the mock rather than reasoned about: logging in with curl, then issuing `Email/set` with **nothing but that cookie and the static headers**, marked a message read and moved it to Archive — HTTP 200, `updated`. Nothing the tab holds is involved, because the API asks for none of it.
The two places that claimed otherwise are corrected in place rather than quietly deleted, including the push-renewal paragraph — renewal still needs a tab, but for a different reason than the one given: the worker wakes only for a push, and the push stops arriving the moment the subscription lapses. A renewal that can only run while renewal is unnecessary is no schedule.
### Two actions, not three
`Notification.maxActions` is 2 on Android and anything past it is dropped silently, so these are the two worth having rather than the two that came first. Archive and Mark as read are triage — what you do to a notification you have already read all of. **Reply is deliberately absent**: it would have to open the app, and tapping the notification already does that.
### The part the worker still cannot do
It 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 (`lib/swFacts.ts`), rewritten 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 condition is the same one background push already carries.
This also fixes two strings the worker had always shown in English whatever the reader's language: "New mail" and "(no subject)".
### Failure
A session can be gone by the time a button is pressed — expired, signed out, or a cookie that did not outlive the browser. That comes back as a refusal and the notification says so, rather than vanishing as though it worked. It does **not** open the app to recover: being interrupted is what the button existed to avoid.
### Translations
Two new strings in all nine catalogues: `New mail` and `Could not do that — open ihasmail and try again`. `Archive`, `Mark as read`, `New message` and `(no subject)` were already there and are reused. Unreviewed like the rest.
### Verification
`npm run typecheck`, `npm test` (1150 web + 161 server, 6 new) and `npm run build` pass; `i18n:check` shows no new missing keys. The API claim above is verified end to end against the mock, which is the load-bearing part.
Not driven on a device: the worker cannot be imported by the test runner and there is no push to receive here. What a phone still has to confirm is that the buttons appear, that two is the number that survives, and that a press with a stale cookie produces the refusal notification rather than silence.
### Follow-up this opens up, not done here
The worker can now ask how many messages are unread, so the badge could carry a real number instead of a dot; and a PushVerification could be answered directly instead of stashed for a tab. Both are noted in the code rather than built — whether a badge is worth a request on every push is a separate question.
**Merged** 2026-09-07 as coffey-labs/ihasmail@1592f38515b0
<sub>Rebuilt from: git history, session transcript.</sub>
This repo is archived. You cannot comment on issues.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Archive and Mark as read now sit on the notification, and both happen in the background. The phone stays where it is.
The premise was wrong, and that is most of this PR
Both this repo and I have said twice that the service worker has no session, so anything touching mail had to open the app — the same wall snooze runs into. That is not true, and checking it rather than repeating it is the substance 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: ihasmailheader, which is not a secret and is not held in memory anywhere (client.tssends it from a module constant). A same-origin fetch from the worker carries the cookie like any other request.Confirmed against the mock rather than reasoned about: logging in with curl, then issuing
Email/setwith nothing but that cookie and the static headers, marked a message read and moved it to Archive — HTTP 200,updated. Nothing the tab holds is involved, because the API asks for none of it.The two places that claimed otherwise are corrected in place rather than quietly deleted, including the push-renewal paragraph — renewal still needs a tab, but for a different reason than the one given: the worker wakes only for a push, and the push stops arriving the moment the subscription lapses. A renewal that can only run while renewal is unnecessary is no schedule.
Two actions, not three
Notification.maxActionsis 2 on Android and anything past it is dropped silently, so these are the two worth having rather than the two that came first. Archive and Mark as read are triage — what you do to a notification you have already read all of. Reply is deliberately absent: it would have to open the app, and tapping the notification already does that.The part the worker still cannot do
It 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 (
lib/swFacts.ts), rewritten 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 condition is the same one background push already carries.
This also fixes two strings the worker had always shown in English whatever the reader's language: "New mail" and "(no subject)".
Failure
A session can be gone by the time a button is pressed — expired, signed out, or a cookie that did not outlive the browser. That comes back as a refusal and the notification says so, rather than vanishing as though it worked. It does not open the app to recover: being interrupted is what the button existed to avoid.
Translations
Two new strings in all nine catalogues:
New mailandCould not do that — open ihasmail and try again.Archive,Mark as read,New messageand(no subject)were already there and are reused. Unreviewed like the rest.Verification
npm run typecheck,npm test(1150 web + 161 server, 6 new) andnpm run buildpass;i18n:checkshows no new missing keys. The API claim above is verified end to end against the mock, which is the load-bearing part.Not driven on a device: the worker cannot be imported by the test runner and there is no push to receive here. What a phone still has to confirm is that the buttons appear, that two is the number that survives, and that a press with a stale cookie produces the refusal notification rather than silence.
Follow-up this opens up, not done here
The worker can now ask how many messages are unread, so the badge could carry a real number instead of a dot; and a PushVerification could be answered directly instead of stashed for a tab. Both are noted in the code rather than built — whether a badge is worth a request on every push is a separate question.
Merged 2026-09-07 as coffey-labs/ihasmail@1592f38515
Rebuilt from: git history, session transcript.