Compare commits

..
Author SHA1 Message Date
Coffey Labs 806e63d071 Merge pull request #138 from Coffey-Labs/mock-moved-occurrence
Mock: let an override move an occurrence, as the server does
2026-08-30 21:55:10 -07:00
jcoffey-dev 06943fd473 Mock: let an override move an occurrence, as the server does
Confirmed live on 0.16.20 (2026-08-31): one occurrence of a weekly 09:00
series moved to 14:00 comes back with `start` at 14:00 and
`recurrenceId` still at 09:00. The slot the rule made stays put; only the
clock time moves.

The mock set `start` from the slot after merging the override, so it
clobbered any `start` the override carried and a moved occurrence did not
move. Per-occurrence *time* editing - one of the main things the feature
is for - therefore looked broken against the mock and correct against the
server, which is the wrong way round for a mock to be wrong.

It also confirms the choice of handle: `recurrenceId` is the one name for
an instance that survives both a renumbering and a move, which is why the
store re-resolves from it rather than from `start` or a cached id.
2026-08-30 21:49:53 -07:00
Coffey Labs 362bd8b282 Merge pull request #137 from Coffey-Labs/calendar-per-occurrence
Calendar: edit and delete a single occurrence
2026-08-30 21:45:15 -07:00
jcoffey-dev 91481965bc Calendar: never mutate an occurrence by an id we are holding
Verified against the live 0.16.20 instance, which found two things the
mock had guessed wrong about.

A synthetic id encodes a position in the expanded series, and writing a
`recurrenceOverrides` entry renumbers it. A five-week series came back as
`e i m q u` over 03-01..03-29; after one override was written to 03-08
the same five ids addressed 03-01, 03-15, 03-29, 03-08 and 03-22. Nothing
was rejected. A stale id is not invalid, it is wrong - a confident answer
about the wrong day - so a delete meant for one occurrence removes
another.

`recurrenceId` is the stable name for a slot in a series, because it is
the date. `updateEvent` and `destroyEvent` now look the current id up by
it immediately before acting, and refuse outright when the date has left
the series rather than falling back to the id in hand.

The mock had this exactly backwards: it kept ids stable on purpose, which
agreed with the belief that is wrong. It now renumbers too - a different
permutation to Stalwart's, with the property that matters - and a test
holds an id across a write and watches it change meaning.

Second finding: the inherited properties are dropped *after* the server
has decided to write an override, so a patch made only of them still
writes one, carrying the server-filled start and duration and nothing
else. `{"privacy":"private"}` on one occurrence answered "updated", left
privacy untouched, and left that date with no title at all. Sending
nothing when narrowing empties a patch was written as a principle - a
request whose response could only be a meaningless "updated" is worse
than no request - and it turns out to prevent real data loss.

Both recorded in KNOWN-ISSUES with the dates they were confirmed on.
2026-08-30 21:39:30 -07:00
jcoffey-dev 5ced44ec13 Calendar: drop the per-event colour picker, which categories replaced
A category carries a colour. Offering a separate colour picker beside it
made two ways to say the same thing, and they could disagree: an explicit
colour wins over the category's in `eventColor`, so an event could be
filed under Work and drawn in the Travel colour with nothing on the menu
explaining why.

Categories are the one that carries meaning, so the swatch grid goes and
picking a category is how an event gets a colour.

Clearing an explicit colour stays, and only appears when there is one to
clear. An event that already has one - set before this, or by another
client, or by CalDAV - would otherwise ignore its category for ever with
no way to fix it from here. Same reasoning as leaving "Stop sharing" on a
folder whose share nobody can see: the escape hatch is worth most exactly
when the thing it undoes is invisible.

Nothing reads differently for events without an explicit colour, and
`CALENDAR_COLORS` is untouched - categories, labels and mailboxes all
still pick from it.
2026-08-30 21:35:01 -07:00
jcoffey-dev dd8998f178 Calendar: edit and delete a single occurrence
Closes #132.

Stalwart 0.16.20 accepts a synthetic id on `CalendarEvent/set`, writing a
`recurrenceOverrides` entry rather than touching the series, so editing
one date of a recurring event is now something the server does and this
does too.

Editing asks the scope *before* the form opens, because it decides which
event the form is even about: a form populated from the master shows the
series' start date, so editing Wednesday's standup would have offered to
move Monday's. Deleting asks in place of the old confirm.

The patch is narrowed rather than posted hopefully. 0.16.20 sorts
per-occurrence properties into three groups and only one is honest: ten
are refused with `invalidProperties`, twelve more are dropped from the
patch while the response still reports success, and the rest are applied.
That silent middle group is how #26 reached a live server - a successful
response is not evidence anything was written - so `occurrencePatch`
throws on the first group, reports the second to the caller, and the
editor leaves out the five it always sends. A patch that would be
entirely dropped is not sent at all.

The refusal for an occurrence of a this-and-future change offers the
series instead of a bare error toast. Nothing here writes one of those,
but an event synced from another client can carry one.

Two things the scope prompt cost, both worth knowing. A dialog is queued
in a store the moment it is asked for, so it outlives the effect that
asked: without a ref guard a remount queues a second prompt the first
answer cannot retract. And gating the *answer* on the effect's cleanup
flag is worse - StrictMode runs mount, cleanup, mount, so the flag is
already set by the time anyone clicks and the editor never opens.

The mock expands recurrences for the first time, which is what makes any
of this developable. It hands out synthetic ids for everything including
one-offs, gives occurrences a `recurrenceId` and no rule, and reproduces
the refusals - including the silent drops, since a mock that applied them
would let a client that sends them look correct everywhere but a real
server.
2026-08-30 21:35:01 -07:00
Coffey Labs 6ec2304fc2 Merge pull request #135 from Coffey-Labs/calendar-event-scope
Resolve the base event id in the calendar store, not at the call sites
2026-08-30 21:29:59 -07:00
Coffey Labs d6f9ef1243 Merge pull request #136 from Coffey-Labs/version-tense
Stalwart has not reached 1.0 yet
2026-08-30 21:28:08 -07:00
jcoffey-dev b0cb73a924 Stalwart has not reached 1.0 yet
Both the versioning comment and the README described 1.0 in the past
tense, which reads as though it has shipped and the numbering was
changed in response. It has not. The old `2.16.x` scheme was dropped
over where it would end up, not where it ended up, and the surrounding
conditionals are simplified to match: "would sort", "would read", rather
than "would have".

The badge quoted in the comment also said 0.16.19; it says 0.16.20 now.

Comments and prose only -- no behaviour changes.
2026-08-30 21:22:07 -07:00
jcoffey-dev 373822c2ae Resolve the base event id in the calendar store, not at the call sites
Closes #133.

`updateEvent`, `destroyEvent` and `rsvp` took an id and sent it. The
`baseEventId ?? id` that made them hit the series lived at four call
sites instead, and every one of them happened to be right.

That was backstopped by the server until now. Through 0.16.19 a synthetic
id reaching `destroy` came back as "Deleting synthetic ids is not yet
supported" and the user saw a toast. 0.16.20 accepts it and removes one
date instead, reporting success under a dialog that said "Delete all
occurrences?" - so a forgotten `??` became silent data loss rather than
an error.

The three methods now take the event and a required `scope`, and there is
exactly one place that turns an event into an id. A caller that wants the
series cannot get an occurrence by forgetting anything; a caller that
wants one occurrence has to say so.

`rsvp` takes the event rather than an id for the same reason, and no
longer looks it up: its patch is `participants/{key}/participationStatus`,
which is one of the pointers 0.16.20 *allows* on an occurrence, so aimed
at an instance it would quietly mean "only that day".

`findByUid` says in a comment that its query omits `expandRecurrences` on
purpose, since InviteCard hands the result straight to `destroyEvent`.
2026-08-30 21:06:56 -07:00
Coffey Labs 12157f47bf Merge pull request #134 from Coffey-Labs/stalwart-0.16.20
Stalwart 0.16.20 on the live instance
2026-08-30 21:02:22 -07:00
jcoffey-dev e41742a26c Stalwart 0.16.20 on the live instance
INBUXA moved 0.16.19 -> 0.16.20 on 2026-08-31 with eight seconds of
downtime. A 0.16.x -> 0.16.x upgrade is a binary replacement: no data
migration, no config change.

Nothing ihasmail depends on changed. The session capabilities, blob,
quota, submission and registry paths are untouched by the release, and
`urn:stalwart:jmap` is still absent at session level, so the three-place
lookup that sign-in turns on remains both correct and necessary. The
Locale enum did move from POSIX names to BCP-47 (`en_US` -> `en-US`,
`POSIX` dropped), which `normalizeLocale` already handled.

The recurrence entry is rewritten rather than deleted. 0.16.20 added
`CalendarEvent/set` support for synthetic ids, so per-occurrence editing
is a thing the server allows and ihasmail does not do yet (#132) - and
the refusal that used to catch a synthetic id reaching `destroy` is gone,
which is why the base-id resolution wants moving into the store (#133).

Dates on the existing entries are left at 0.16.19 on purpose: they record
what was actually run, and the upgrade was read from the diff, not re-run.
2026-08-30 20:58:52 -07:00
Coffey Labs 1e01b34384 Merge pull request #131 from Coffey-Labs/github-org-coffey-labs
Point at the Coffey-Labs organisation
2026-08-30 15:26:58 -07:00
jcoffey-dev 95f640b24c Point at the Coffey-Labs organisation
The repositories moved off LINUXexpert-org. The old URLs redirect, so
nothing was broken, but a redirect is not a correct address to publish.

The SOURCE_URL defaults matter most: the AGPL asks whoever runs a
modified version to offer that version's source, and the sign-in page and
About screen show this link. It is in four places that have to agree --
the compose file, .env.example, the server default and the web fallback.

The rest is documentation and issue links.
2026-08-30 15:17:43 -07:00
Coffey Labs 826a13ab39 Merge pull request #130 from LINUXexpert-org/calver-decouple-from-stalwart
Stop borrowing Stalwart's version number
2026-08-30 14:42:02 -07:00
jcoffey-dev 2f55b1e3e1 Stop borrowing Stalwart's version number
The middle field was the Stalwart generation a build targeted -- 16 for
0.16 -- which leaves nowhere to go when Stalwart reaches 1.0. There is no
honest value for it: 2.1 sorts below the 2.16 already deployed, so every
image and About screen would have read as a downgrade. Tying our
numbering to somebody else's was the mistake, and which Stalwart a build
needs is said properly in the README badge and KNOWN-ISSUES, where it can
be precise rather than one digit.

The version is now the date of the commit it was built from, and the pull
request moves after the + as build metadata. It is provenance rather than
a rank: at the rate they merge here it climbs without bound and says
nothing about how new a build is. Everything after the + is ignored when
versions are compared, which reads correctly -- two builds from the same
day differ in where they came from, not in age -- and nothing depends on
that comparison anyway, since images are pruned by creation time and a
rollback names a git ref.

The date is the commit's own, so rebuilding an old commit gives the
version it had the first time. package.json is no longer the source of
anything and sits at 0.0.0, which is what an unversioned build reports
and is meant to look wrong.

The formatting is a pure function now, so the rules have tests. They had
none while the version was the thing naming every image we ship.
2026-08-30 14:38:07 -07:00
Coffey Labs d75e9bc769 Merge pull request #129 from LINUXexpert-org/link-project-site-v2
Link the project site from inside the app
2026-08-30 14:19:21 -07:00
jcoffey-dev 08fd08e6fe Link the project site from inside the app
ihasmail.org was linked only from the login screen footer -- a page a signed-in
user sees once and then never again. From inside the app there was no way back
to the project site at all; Documentation went to docs.ihasmail.org and that
was the whole of it.

"About ihasmail" now sits under Documentation in the account menu, where
somebody looking for what this thing is would actually go.
2026-08-30 14:17:02 -07:00
Coffey Labs ecc030aa3f Merge pull request #128 from LINUXexpert-org/deploy-dry-run-needs-no-terminal
Let a dry run answer without a terminal
2026-08-30 14:13:14 -07:00
jcoffey-dev 8844fc9836 Let a dry run answer without a terminal
The confirmation ran before the dry-run check, so a dry run over SSH was
refused for having no terminal to confirm on -- and the refusal came out
instead of the report it was asked for. Nothing was going to be deployed
either way: it was asking whether to go ahead with something that was not
going to happen.

Print the report, stop there for a dry run, and gate only the real thing
on the confirmation. The hold list still refuses a held commit under
--dry-run, since that is an answer a dry run should give.

--help printed a fixed line range, which the header edit above would have
clipped. Print the leading comment block itself instead.
2026-08-30 14:11:26 -07:00
Coffey Labs ef7823d6de Merge pull request #127 from LINUXexpert-org/blob-download-compressed-length
Send the length of the bytes we are actually sending
2026-08-30 13:58:24 -07:00
jcoffey-dev 8d475e2b07 Refuse to save a script we only partly read
The transport fix stops the truncation that caused #76, but the save path
had no answer for a baseline that arrives incomplete. It is neither
unknown nor empty, so every existing guard passes it through: it parses
into a shorter rule list that looks exactly like a script with fewer
rules, and saving writes that back over the real one.

Check the script against the shape the generator emits instead. Every
rule comment parses, every enabled rule has an if and a closed body under
it, every block ends with a blank line. Structural rather than a
re-serialize-and-compare, so a script written by an older version whose
serializer differed is still editable.

The rule editor reports a short script as unreadable rather than showing
the rules that happened to parse, since a list that looks complete over a
script that is not is the most dangerous thing it could offer.

A cut at the end of a complete rule block is still a valid shorter script
and cannot be told apart from one; that residual is the proxy's to cover.
2026-08-30 13:56:12 -07:00
jcoffey-dev 0277b5b6a8 Send the length of the bytes we are actually sending
A gzip response is decompressed before the blob proxy sees the body, but
its content-length still describes the compressed bytes. Copying that
header onto the longer body made the browser stop reading that many bytes
in and call the download complete, so files arrived truncated with nothing
reporting a failure.

It took a hop that compresses to show up, and one that only compresses
above a threshold to look like a race: a Sieve script stayed intact for two
rules and came back cut off mid-rule once the third pushed it past 1 KiB.

Ask upstream for identity, and forward no length at all rather than one
that describes different bytes.
2026-08-30 13:46:05 -07:00
LINUXexpert.org 22c8eb4af6 Merge pull request #126 from LINUXexpert-org/copyright-coffey-labs
Change the copyright holder to Coffey Labs
2026-08-30 01:31:15 -07:00
jcoffey-dev bd3c4bf964 Change the copyright holder to Coffey Labs
Two lines in the README: the licence statement, and the "by" badge in the
header.

The badge mattered as much as the copyright line. It pointed at
linuxexpert.org, which is now retired -- it 301s its articles to jcoffey.dev
and answers 410 for everything else -- so "by LINUXexpert.org" sent a reader to
a site that no longer claims this work. It now reads "by Coffey Labs" and
points at coffeylabs.org.

Everything else that says LINUXexpert-org is a github.com URL: the source link
baked into .env.example, docker-compose.yml, web/src/lib/source.ts and
server/src/config.ts, plus issue and release links in the docs. Those are the
repository's real path and are unchanged -- the AGPL source offer in the app
depends on that URL resolving.

LICENSE untouched. Its only copyright is the FSF's on the AGPL text itself.
2026-08-30 01:29:17 -07:00
LINUXexpert.org 7eca17665a Merge pull request #124 from LINUXexpert-org/work/coc-contact
Say where to report a code of conduct violation
2026-08-29 00:29:33 -07:00
jcoffey-dev b6327ffb98 Say where to report a code of conduct violation
The Contributor Covenant ships its enforcement section with a placeholder
for the contact address, and this copy never filled it in. The sentence
whose entire job is to tell someone where to report harassment read:

    reported to the community leaders responsible for enforcement at
    .

So the document existed, scored on GitHub's community profile, and
answered the question it was there to answer with a full stop. Anyone who
needed it would have had to go looking somewhere else, at the moment they
were least inclined to.

Uses the same obfuscated address as SECURITY.md and CONTRIBUTING.md, so
there is one contact for the project rather than a second one to keep in
sync. Found while porting this file to cairnobs, which is about to go
public and would have inherited the same gap.
2026-08-29 00:26:17 -07:00
jcoffey-dev d8fc47d765 Clean up contributor metadata 2026-08-28 15:18:05 -07:00
LINUXexpert.org ddd1bbf9b3 Merge pull request #123 from LINUXexpert-org/untrusted-device-mode
Ask whose computer this is, and believe the answer
2026-08-28 14:20:40 -07:00
jcoffey-dev 0b01956535 Ask whose computer this is, and believe the answer
Sign-out never cleared local storage. It stopped push, flushed settings and
removed the subscription -- that last one reasoned explicitly that a browser
left holding someone's mail becomes somebody else's next -- and then left the
settings cache and the recently-addressed list on disk. That list is other
people's addresses, and nothing ever removed it.

Clearing it on sign-out is now unconditional, because lending a laptop is the
same exposure as a public machine, only quieter. The keep-list is short and
deliberate: lastUser, which only a trusted device writes; the trust flag; and
the random push device id. Everything else goes, so a key added later is
forgotten by default rather than by nobody having thought about it.

"Keep me signed in on this device" defaulted to true, which assumed the answer
most costly to get wrong -- someone on a library machine got a thirty-day
cookie unless they noticed a ticked box. It now asks whose computer this is,
defaults to not yours, and says what each answer does. Untrusted means a
session cookie, nothing written locally, no push subscription, and a five
minute idle sign-out.

The idle timer is there because the alternative does not work: custom
beforeunload text was removed from browsers years ago, and no event fires at
all for walking away from a signed-in screen, which is the case that matters.
A timer needs nobody's cooperation.

Reads are gated as well as writes, since a machine trusted once still has the
residue; an untrusted sign-in purges it outright. The wire keeps calling this
`remember` -- it is persisted in SESSION_FILE, and renaming it would invalidate
every session file on upgrade for a change of vocabulary.

Verified in a browser against the mock, not only in tests: untrusted sign-in
leaves localStorage empty through a full session including folder expansion;
trusted writes settings, recent and lastUser as before; sign-out clears recent
and settings while keeping lastUser; an untrusted sign-in afterwards clears
even that.
2026-08-28 14:15:15 -07:00
LINUXexpert.org 045dda109b Merge pull request #122 from LINUXexpert-org/roadmap-2fa-issue-closed
Say where the 2FA entry came from, not that something is tracking it
2026-08-28 12:02:42 -07:00
jcoffey-dev 1c678fabed Say where the 2FA entry came from, not that something is tracking it
The roadmap's preamble promised that anything with an issue number was tracked
in the issue tracker, and the two-factor entry ended in a bare "Reported as
#75". That issue was closed as completed on 2026-08-26, so the one entry the
promise applied to was the one it was wrong about: a reader following the link
finds a closed ticket and has to guess whether the work went with it.

It did not. #75 reported a sign-in refused with nothing but "Invalid
credentials", and that bug was fixed -- the message now says what is happening
and points at app passwords. The OAuth work the report uncovered stayed behind
on this page, which is exactly the case the preamble had no room for.

So the preamble now says an issue number records where an entry came from
rather than where it is tracked, and the entry says plainly that #75 is closed,
what closing it fixed, and that there is no ticket to watch for the rest.
2026-08-28 12:00:38 -07:00
49 changed files with 2258 additions and 202 deletions
+1 -1
View File
@@ -56,4 +56,4 @@ APP_NAME=ihasmail
# asks whoever runs a modified version to offer *that* version's source -- so if
# you have patched it, point this at your own tree. Shown on the sign-in page
# and in Settings > About.
SOURCE_URL=https://github.com/LINUXexpert-org/ihasmail
SOURCE_URL=https://github.com/Coffey-Labs/ihasmail
+1 -1
View File
@@ -60,7 +60,7 @@ representative at an online or offline event.
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
.
**johnellisATlinuxDOTcom**.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
+1 -1
View File
@@ -16,7 +16,7 @@ By participating in this project, you agree to treat other contributors with res
### Reporting Bugs
Before opening a new issue, please search [existing issues](https://github.com/LINUXexpert-org/ihasmail/issues) to see if it's already been reported. When filing a bug report, include:
Before opening a new issue, please search [existing issues](https://github.com/Coffey-Labs/ihasmail/issues) to see if it's already been reported. When filing a bug report, include:
- A clear, descriptive title
- Steps to reproduce the issue
+23 -9
View File
@@ -4,11 +4,17 @@ What was checked, against which server, and when. For a failure you are hitting
right now, start with [Troubleshooting](https://docs.ihasmail.org/troubleshooting/);
for what is not built yet, see [ROADMAP.md](ROADMAP.md).
The live instance runs **0.16.19**, and as of **2026-08-26 there is nothing
left pending**: every entry below has been exercised against it. What remains
here is not a list of unknowns but of things worth knowing — where Stalwart
departs from a spec, where a setting has to be turned on for a feature to work,
and what ihasmail deliberately does not do.
The live instance runs **0.16.20**, upgraded from 0.16.19 on 2026-08-31 with
eight seconds of downtime, and as of **2026-08-26 there is nothing left
pending**. Every entry below was exercised against 0.16.19 on the date it
names, and the dates still say so: the upgrade was read against the
0.16.19→0.16.20 diff rather than re-run, and nothing in it touches the session
capabilities, blob, quota, submission or registry paths these entries describe.
The calendar entries below carrying a 2026-08-31 date are the exception: those
were exercised against the live 0.16.20 directly.
What remains here is not a list of unknowns but of things worth knowing — where
Stalwart departs from a spec, where a setting has to be turned on for a feature
to work, and what ihasmail deliberately does not do.
Entries keep saying what was checked and when, because this section has been
wrong before: the 0.16 registry path was once recorded as verified live when a
@@ -19,7 +25,9 @@ upgraded on 2026-08-25. They are kept where the finding is about ihasmail
rather than about 0.15 — a byte cap that still applies, a flow that still
works the same way — and dropped where 0.15 was the whole subject. Support for
0.15 was removed on 2026-08-26; the last release that runs on it is tagged
[`stalwart-0.15-support`](https://github.com/LINUXexpert-org/ihasmail/releases/tag/stalwart-0.15-support).
[`stalwart-0.15-support`](https://github.com/Coffey-Labs/ihasmail/releases/tag/stalwart-0.15-support).
- **A compressing hop in front of Stalwart truncated every blob download, and nothing said so.** Node decompresses a gzip response before the code ever sees the body, but leaves the `content-length` header describing the *compressed* bytes. The blob proxy copied that header onto the longer body it forwarded, so the browser stopped reading exactly that many bytes in and called the download complete. Reported on [#76](https://github.com/Coffey-Labs/ihasmail/issues/76) against a Coolify deployment, where Traefik's compress middleware only engages above 1 KiB: filter rules one and two were fine and the third pushed the script past the threshold, after which it came back cut off mid-rule — 384 bytes of a 1.3 KB script. The size threshold is what made it look like a race. This is the *second* cause behind that issue, and the first fix did not touch it: a truncated script is neither unknown nor empty, so the "refuse to save from a baseline we could not read" guard never fired — the script parsed, just with rules missing, and the next save wrote the short version back over the real one. Every blob download shared the fault, not just Sieve: message source, vCards, signature HTML, attachments being forwarded, and the `settings.json` sync. Settings degraded honestly by luck rather than design — a truncated file fails `JSON.parse`, which is caught and leaves the local cache in charge — so it stopped syncing between devices instead of being overwritten. The proxy now asks upstream for `identity` and, for a hop that compresses anyway, forwards no length at all rather than one describing different bytes. The image proxy is unaffected: it uses `node:http` directly, sends no `accept-encoding`, and never decompresses. The save path no longer trusts the transport either: a script is now checked for completeness against the shape the generator emits — every `# rule:` comment parses, every enabled rule has an `if` and a closed body below it, every block ends with a blank line — and saving refuses on anything short, as does the rule editor, which reports the script as unreadable rather than showing the rules that happened to parse. The check is structural rather than a re-serialize-and-compare, so a script written by an older version with a different serializer is still editable; refusing over a changed byte would be the worse bug. It catches a cut at every offset except the end of a complete rule block, which is a legitimately shorter script and indistinguishable from one in the bytes alone — that residual is what the proxy fix covers.
- **Delete all spam destroys, and does not pass through Deleted Items** — this is the point of the feature and the thing worth checking on a real server, since a folder that empties into another folder has solved nothing. `Email/set destroy`, walked a page at a time so it survives `maxObjectsInSet` the way emptying Deleted Items already had to. **Confirmed live on 0.16.19 (2026-08-26)**: Junk Mail emptied and Deleted Items stayed empty afterwards. There is no undo, which is why all three entry points share one dialog that says so. Only Deleted Items and Junk Mail can be emptied this way, enforced in the store rather than only hidden in the menus.
- **Sharing a mail folder is accepted and does nothing.** `Mailbox/set` with a `shareWith` map is applied, `Mailbox/get` reads it back, and the folder never appears for the account it was shared with — **confirmed live on 0.16.19 (2026-08-27)** with a folder shared read-only to another account on the same server, which never saw it. Stalwart's own sharing documentation lists calendars, address books and file storage; mail folders are not among them. Nothing reports a failure at any point, which is the whole problem: the share is stored, so a client that trusts what it reads back shows it as live for ever. The entry point is withdrawn. A folder that is *already* shared still offers **Stop sharing**, because a share nobody can see is exactly the one you want to be able to clear, and there is no other way to. File sharing is unaffected and works end to end.
@@ -29,11 +37,17 @@ works the same way — and dropped where 0.15 was the whole subject. Support for
- **Read receipts are built here, not by the server** — JMAP has an extension for them, [RFC 9007](https://www.rfc-editor.org/rfc/rfc9007.html)'s `MDN/send`, and Stalwart does not implement it: `urn:ietf:params:jmap:mdn` is not among its capabilities. So ihasmail assembles the `multipart/report` itself and sends it the long way round — raw MIME uploaded as a blob, `Email/import`, then `EmailSubmission` — which is also why the receipt lands in Sent, where it honestly belongs. Non-ASCII parts are base64 rather than `8bit`, so nothing depends on 8BITMIME surviving every hop. There is deliberately no "always send" setting: a receipt confirms to whoever asked that the address is live and when it was read, to an address of the sender's choosing, so each one is a decision. Verified against the mock end to end (upload, import, submit, `$mdnsent`), and **confirmed live on 0.16.19 (2026-08-26)**: a receipt asked for by a real sender was assembled, uploaded, imported and submitted, landed in Sent, and set `$mdnsent` so a second look does not offer to send another.
- **Where 0.16 advertises `urn:stalwart:jmap`** — not where a JMAP client would look, and this now decides whether a sign-in is allowed at all. Stalwart builds the session-level `capabilities` from a fixed list (`Session::new`, plus WebSocket) that has never contained this capability, in any 0.16.x from 0.16.0 to 0.16.19. It hands it out per-account instead, so it appears in `primaryAccounts` and in each account's `accountCapabilities`. ihasmail tested for it in `capabilities` alone, which made every real 0.16 server read as older than 0.16 — and that one check drove three things: self-service credentials fell back to `POST /api/account/auth`, which 0.16 removed, so password changes, 2FA and app passwords all failed with "this mail server does not offer self-service credential management"; About reported the wrong generation; and Files took the older code path. It now looks in all three places, and is covered by tests on each. Worth restating plainly, because the stakes went up when 0.15 support was dropped: there is no longer a fallback path for this check to be wrong *into*. Getting it wrong now refuses every sign-in against a perfectly good server — a loud failure rather than a quiet misrouting, which is the trade the removal was making.
- **HTML signatures** — Stalwart caps a signature at 2047 **bytes** (`value.len() < 2048` on a Rust string, so UTF-8 bytes, not characters). ihasmail compacts pasted HTML, moves images to Files and, if still too large, keeps the full signature in Files behind a short marker; other clients see a text fallback. Confirmed live on 0.15.5 (2026-08-24): oversized, non-ASCII and inline-image signatures all save, and a test message arrived intact at Gmail with the logo inline.
- **Settings live in the account's Files, not the browser** — every preference used to sit in `localStorage`, so none of them followed anyone between devices. The sharpest edge was the default identity: with none set the address that sorts first wins, so someone who set it at work found it unset at home and mail went out from an address the recipient might not recognise ([#54](https://github.com/LINUXexpert-org/ihasmail/issues/54)). They are now a `settings.json` in the `ihasmail` folder in JMAP Files, beside the signature images already kept there — which keeps ihasmail itself stateless: no volume, no database, nothing to back up separately, and the settings are covered by whatever backs up the mail store. `x:AccountSettings` was the other candidate and does not fit; its schema is `locale`/`timeZone`/`description` with no free-form field, and writing it needs `sysAccountSettingsSet`, where the built-in user role carries only the `…Get` half. `localStorage` stays on as a *cache* rather than the source of truth, so the first frame paints from it and the file corrects it a moment later; a browser with no cache shows defaults for that one frame, which is the trade for not gating the whole app on a round trip. Settings that describe *this* screen or browser deliberately stay local — list-pane sizes, density, font size, sidebar state, and the notification toggles, which track a permission the browser grants per-device and would be a claim about somewhere else it cannot make. That split is written as a list of exceptions, so a setting added later syncs by default. Writes are coalesced behind a three-second debounce, since `update()` fires on every frame of a splitter drag, and a tab going away or a sign-out flushes first. The `ihasmail` folder is now hidden from the Files view, contents and all: hiding the folder alone would be worse than showing it, because the tree attaches a node whose parent is missing to the root, so the signature images — visible there since signatures shipped — would have spilled into the top level. **Confirmed live on 0.16.19 (2026-08-26)**: settings set in Chrome came back on a fresh login in Firefox and in an incognito session, both of which start with an empty cache, so each read the account's file rather than anything local. Confirmed again on the deployed instance rather than only a pre-deployment build. Requires 0.16, which ihasmail now requires everywhere — `FileNode/query` cannot see directories before that, and sign-in refuses an older server outright. Two limits worth knowing: conflicts are last-write-wins, and a change made on one device does not reach another that already has ihasmail open until it signs in again.
- **Settings live in the account's Files, not the browser** — every preference used to sit in `localStorage`, so none of them followed anyone between devices. The sharpest edge was the default identity: with none set the address that sorts first wins, so someone who set it at work found it unset at home and mail went out from an address the recipient might not recognise ([#54](https://github.com/Coffey-Labs/ihasmail/issues/54)). They are now a `settings.json` in the `ihasmail` folder in JMAP Files, beside the signature images already kept there — which keeps ihasmail itself stateless: no volume, no database, nothing to back up separately, and the settings are covered by whatever backs up the mail store. `x:AccountSettings` was the other candidate and does not fit; its schema is `locale`/`timeZone`/`description` with no free-form field, and writing it needs `sysAccountSettingsSet`, where the built-in user role carries only the `…Get` half. `localStorage` stays on as a *cache* rather than the source of truth, so the first frame paints from it and the file corrects it a moment later; a browser with no cache shows defaults for that one frame, which is the trade for not gating the whole app on a round trip. Settings that describe *this* screen or browser deliberately stay local — list-pane sizes, density, font size, sidebar state, and the notification toggles, which track a permission the browser grants per-device and would be a claim about somewhere else it cannot make. That split is written as a list of exceptions, so a setting added later syncs by default. Writes are coalesced behind a three-second debounce, since `update()` fires on every frame of a splitter drag, and a tab going away or a sign-out flushes first. The `ihasmail` folder is now hidden from the Files view, contents and all: hiding the folder alone would be worse than showing it, because the tree attaches a node whose parent is missing to the root, so the signature images — visible there since signatures shipped — would have spilled into the top level. **Confirmed live on 0.16.19 (2026-08-26)**: settings set in Chrome came back on a fresh login in Firefox and in an incognito session, both of which start with an empty cache, so each read the account's file rather than anything local. Confirmed again on the deployed instance rather than only a pre-deployment build. Requires 0.16, which ihasmail now requires everywhere — `FileNode/query` cannot see directories before that, and sign-in refuses an older server outright. Two limits worth knowing: conflicts are last-write-wins, and a change made on one device does not reach another that already has ihasmail open until it signs in again.
- **Files on 0.16** — the pre-0.16 quirks this entry used to describe are gone with the support for them: `FileNode/query` masking directories out of its own results, `nodeType` not existing, and rights being a single `mayWrite`. What is left is what has actually been exercised on 0.16.19. Finding and creating a folder, creating a node with `nodeType`, uploading and downloading its blob, and pointing an existing node at a new one all ran live on 2026-08-26, as a side effect of the settings file. Rename, move and delete are **confirmed live on 0.16.19 (2026-08-26)** as well, which closes this out: what had been confirmed on 0.15.5 (2026-08-24) was the older code path, and that path no longer exists. Two fallbacks went with the removal and are worth knowing about: `ensureFolder` and `findInFolder` now filter on `parentId`/`isTopLevel` alone and match names client-side, since `name` is not a filter Stalwart is known to implement and one it does not know fails the whole query; and a refused filter or sort no longer drops the view into fetching every node in the account, which would have hidden a real fault behind a performance cliff nobody would notice.
- **Self-service credentials** — the registry path is **confirmed live** against Stalwart 0.16.19 (2026-08-25): app passwords created and revoked, password changed, 2FA enabled and disabled, with the browser session surviving the switch to an app password. The 0.15 REST path was confirmed live too, on 0.15.5 (2026-08-24), and has since been removed along with the rest of 0.15 support. The mock enforces the same rules the real server does (current password required, password policy, a TOTP code on every request once 2FA is on, app passwords exempt from it). Password changes are refused by Stalwart for accounts backed by an external directory (LDAP/SQL/OIDC); the server's own message is shown when that happens.
- **Scheduled send needs one setting turned on, and says nothing when it is off.** Stalwart advertises the delay in the account's `urn:ietf:params:jmap:submission` capability — `maxDelayedSend: 2592000` (30 days) and `FUTURERELEASE` among its `submissionExtensions`, and note it is the *account* capability, not the session-level one, which is empty. But the MTA only honours a hold when `futureRelease` is set under the session's MTA extensions, and [that setting defaults to `false`](https://stalw.art/docs/ref/object/mta-extensions/). With it off, Stalwart takes the `HOLDUNTIL` parameter, skips the hold and sends the message immediately **without an error** — the capability still says thirty days. So set `futureRelease` (to the longest hold you want to allow) before relying on this; a value shorter than 30 days is fine, and a request past it is refused honestly, with a `forbiddenMailFrom` naming the limit. `npm run dev:mock:no-future-release` reproduces the silent-drop case. ihasmail asks for the delay the way JMAP requires — a `HOLDUNTIL` parameter on the envelope's `mailFrom`, since RFC 8621 makes `sendAt` read-only and server-derived — and files the held message in a **Scheduled** folder, because `onSuccessUpdateEmail` would otherwise drop it in Sent the moment the submission is created. Nothing moves it out when the hold expires, so ihasmail reconciles the folder on the way in: released messages to Sent, cancelled ones back to Drafts. Three fixes this depends on landed in **0.16.17**, below the live instance's 0.16.19: `HOLDUNTIL` taking RFC 3339 date-times again (0.16.16 had it wanting Unix timestamps), `EmailSubmission/query` on `undoStatus` agreeing with `/get` about held submissions, and `EmailSubmission/get` without `ids` iterating the right index. The hold itself is now **confirmed against the live 0.16.19** (2026-08-25), once `futureRelease` was set to `30d` there: a submission carrying a `HOLDUNTIL` ten minutes out came back `pending`, with `sendAt` equal to the time asked for and a `250 2.1.5 Queued` from the MTA, rather than going out at once. Worth repeating that the capability is no evidence either way — it advertised `maxDelayedSend: 2592000` and `FUTURERELEASE` while the setting was still off. Only a submission tells you. The rest of the journey is **confirmed live too (2026-08-26)**: a hold expired and was delivered, and the **Scheduled** folder reconciled on the way in — a released message moved to Sent, a cancelled one back to Drafts. Nothing in Stalwart does that moving, so if ihasmail is never opened again the message still goes out; it is only the folder that waits to be tidied.
- **Stalwart 0.16 and RFC 8984 disagree about the calendar vocabulary, and the server only says so half the time.** A participant's address lives in `calendarAddress`, not RFC 8984's `sendTo`/`email`; the organizer is `organizerCalendarAddress`, not `replyTo`; and a recurrence is a single `recurrenceRule`, not a `recurrenceRules` array. Addressed the RFC's way, `CalendarEvent/set` **keeps the event and discards the whole participant map without an error** — guests disappeared on save and no invitation was ever sent, which is what [#26](https://github.com/LINUXexpert-org/ihasmail/issues/26) reported. The array form of the rule is refused honestly, with `invalidProperties`, so recurring events could not be created at all and existing ones showed no repeat ([#30](https://github.com/LINUXexpert-org/ihasmail/issues/30)). ihasmail now writes Stalwart's names and reads either, and the mock refuses what the real server refuses, since advertising the RFC spelling is precisely how this got as far as a live server. Verified against 0.16.19 on 2026-08-25, end to end: participants, organizer and rule all survive a create, an update and a re-read; an invitation to an external Gmail address arrived as an invite card, and the decline came back and was applied to the event (`needs-action``declined`, sequence 1). Cancelling the event notified the guest too. Adding guests to an event that had none, and clearing them again with `null`, both work on the update path, as does RSVP — which patches `participants/{key}/participationStatus` (and `participationComment`) rather than sending the whole map. That patch has to be aimed at the base event: `CalendarEvent/set` refuses a synthetic id with *"Updating synthetic ids is not yet supported"*, which is why RSVP resolves `baseEventId` first. Adding a *new* participant by patch is refused as well (`Patch operation failed`), so a changed guest list is written as the whole `participants` property. One more thing to know when reading this code: an expanded occurrence carries a `recurrenceId` but *no* rule of its own, and `baseEventId` is set on everything an expanded query returns — a one-off included, whose own id differs from its base — so neither is a test for recurrence.
- Recurring events: colour/category/edit/delete apply to the whole series (per-occurrence overrides aren't supported by the server yet).
- **Stalwart 0.16 and RFC 8984 disagree about the calendar vocabulary, and the server only says so half the time.** A participant's address lives in `calendarAddress`, not RFC 8984's `sendTo`/`email`; the organizer is `organizerCalendarAddress`, not `replyTo`; and a recurrence is a single `recurrenceRule`, not a `recurrenceRules` array. Addressed the RFC's way, `CalendarEvent/set` **keeps the event and discards the whole participant map without an error** — guests disappeared on save and no invitation was ever sent, which is what [#26](https://github.com/Coffey-Labs/ihasmail/issues/26) reported. The array form of the rule is refused honestly, with `invalidProperties`, so recurring events could not be created at all and existing ones showed no repeat ([#30](https://github.com/Coffey-Labs/ihasmail/issues/30)). ihasmail now writes Stalwart's names and reads either, and the mock refuses what the real server refuses, since advertising the RFC spelling is precisely how this got as far as a live server. Verified against 0.16.19 on 2026-08-25, end to end: participants, organizer and rule all survive a create, an update and a re-read; an invitation to an external Gmail address arrived as an invite card, and the decline came back and was applied to the event (`needs-action``declined`, sequence 1). Cancelling the event notified the guest too. Adding guests to an event that had none, and clearing them again with `null`, both work on the update path, as does RSVP — which patches `participants/{key}/participationStatus` (and `participationComment`) rather than sending the whole map. That patch had to be aimed at the base event: through 0.16.19 `CalendarEvent/set` refused a synthetic id with *"Updating synthetic ids is not yet supported"*, which is why RSVP resolves `baseEventId` first. 0.16.20 accepts one, so that resolution is now a choice rather than the only option — an RSVP aimed at an occurrence would answer for that date alone. It still resolves the base, which is the answer people mean. Adding a *new* participant by patch is refused as well (`Patch operation failed`), so a changed guest list is written as the whole `participants` property. One more thing to know when reading this code: an expanded occurrence carries a `recurrenceId` but *no* rule of its own, and `baseEventId` is set on everything an expanded query returns — a one-off included, whose own id differs from its base — so neither is a test for recurrence.
- **An override can move an occurrence, and then `start` and `recurrenceId` mean two different times.** The slot stays where the rule put it and only the clock time moves. **Confirmed live on 0.16.20 (2026-08-31)**: one occurrence of a weekly 09:00 series moved to 14:00 came back `start: 2027-06-14T14:00:00` with `recurrenceId` still `2027-06-14T09:00:00`. This is the right behaviour and it is the reason `recurrenceId` is the handle ihasmail holds: it is the one name for an instance that survives *both* a renumbering and a move, so a mutation can always be re-resolved from it. Worth recording because the mock got it wrong in the other direction — it overwrote an override's `start` with the slot time, so a moved occurrence did not move, and per-occurrence *time* editing looked broken against the mock and correct against the server. Found by asking a real server rather than by reading the mock, which is the only way this kind of disagreement ever surfaces.
- **A synthetic id is only true until the next write, and a stale one is wrong rather than invalid.** Stalwart's expanded-occurrence ids encode a position in the series, and writing a `recurrenceOverrides` entry adds a component that renumbers it. **Confirmed live on 0.16.20 (2026-08-31)**: a five-week series came back as `e i m q u` over 03-01 … 03-29; one override written to 03-08 left the *same five ids* addressing 03-01, 03-15, 03-29, 03-08 and 03-22. Nothing was rejected and nothing reported a change — `i` simply meant a week later than it had a moment earlier. So an id cached across a write silently points at another date, and a delete meant for one occurrence removes a different one. This is the second time the same shape of problem has cost a live debugging session, and it is worth saying plainly why it is dangerous: the failure is not a `notFound` a client would notice, it is a confident answer about the wrong day. ihasmail therefore never mutates an occurrence by an id it is holding. `recurrenceId` is the stable name for a slot in a series — it is the date — so `updateEvent` and `destroyEvent` look the current id up by it immediately before they act, and refuse outright if the date is no longer in the series rather than falling back to the id in hand. The mock renumbers too, by a different permutation to the real server's but with the property that matters, since a mock that kept ids stable would agree with precisely the belief that is wrong.
- **A per-occurrence patch made only of inherited properties creates an override that loses the title.** The twelve properties 0.16.20 drops from a per-occurrence patch are dropped *after* it has decided to write an override, so a patch consisting only of them still writes one — and that override carries the `start` and `duration` the server fills in and nothing else. **Confirmed live on 0.16.20 (2026-08-31)**: `{"privacy": "private"}` aimed at one occurrence answered `updated`, left `privacy` untouched on the series, and left that date with no title at all. A successful response, a silently discarded change, and real data loss on a third property nobody mentioned. ihasmail narrows a per-occurrence patch before sending it and sends nothing when narrowing empties it, which was written as a point of principle — a request whose response could only be a meaningless "updated" is worse than no request — and turns out to prevent this. Worth remembering as the argument for the principle.
- **Recurring events can be edited and deleted one date at a time, since 0.16.20.** A write aimed at a synthetic id was refused outright through 0.16.19; 0.16.20 turns it into a `recurrenceOverrides` entry instead, so "this occurrence" and "the whole series" are now two different things ihasmail asks about before acting. **Confirmed live on 0.16.20 (2026-08-31)** end to end against a five-week series: a legal patch landed on the override with `start` and `duration` filled in by the server; `useDefaultAlerts` was refused with *"This property cannot be modified on a single occurrence."*; a destroy removed one date and left the series; and a base event and one of its instances in the same request were refused together, both ids, with *"A base event and its instances cannot be modified in the same request."* The scope is chosen before the editor opens rather than on save, because it decides which event the form is about — one populated from the master shows the *series'* start date, so editing Wednesday would have offered to move Monday. Two entries below are the sharp edges this turned up.
- Editable date boxes are always Gregorian and in Latin digits, even for locales whose *display* uses another calendar or numbering system (`fa-IR`, `th-TH`, `ar-EG`) — they keep the locale's field order and separator, but a Buddhist-era year in a text box does not round-trip against the Gregorian calendar grid. Non-Gregorian calendar support is not implemented.
- The account locale is read from `x:AccountSettings/get`, whose permission the built-in user role has, falling back to `x:Account/get` (which needs the admin-only `sysAccountGet`). Both are Stalwart 0.16 methods: **on older servers neither is reachable** — they do not implement the registry and reject a request that so much as names the `urn:stalwart:jmap` capability — so there the locale still falls back to the browser's and can be chosen by hand. Confirmed live on 0.16.19 (2026-08-25), once the capability was looked for where Stalwart advertises it; a locale request that is merely refused no longer downgrades the detected generation.
+33 -15
View File
@@ -4,9 +4,9 @@
<p align="center">
<a href="LICENSE"><img alt="Licence: AGPL-3.0-or-later" src="https://img.shields.io/badge/licence-AGPL--3.0--or--later-2dd4bf?style=flat-square"></a>
<a href="https://stalw.art" target="_blank" rel="noreferrer"><img alt="Requires Stalwart 0.16 or newer; tested against 0.16.19" src="https://img.shields.io/badge/Stalwart-0.16.19-6366f1?style=flat-square"></a>
<a href="https://stalw.art" target="_blank" rel="noreferrer"><img alt="Requires Stalwart 0.16 or newer; tested against 0.16.20" src="https://img.shields.io/badge/Stalwart-0.16.20-6366f1?style=flat-square"></a>
<a href="https://docs.ihasmail.org" target="_blank" rel="noreferrer"><img alt="Documentation: docs.ihasmail.org" src="https://img.shields.io/badge/docs-docs.ihasmail.org-0ea5e9?style=flat-square"></a>
<a href="https://linuxexpert.org" target="_blank" rel="noreferrer"><img alt="by LINUXexpert.org" src="https://img.shields.io/badge/by-LINUXexpert.org-0f766e?style=flat-square"></a>
<a href="https://coffeylabs.org" target="_blank" rel="noreferrer"><img alt="by Coffey Labs" src="https://img.shields.io/badge/by-Coffey%20Labs-0f766e?style=flat-square"></a>
</p>
# ihasmail
@@ -64,8 +64,8 @@ wrong guess had somewhere to fall back to, so it failed *quietly* — and that
reached production. With one supported generation a wrong guess is a loud error
on the first call.
- Still on 0.15? The last release that runs on it is tagged [`stalwart-0.15-support`](https://github.com/LINUXexpert-org/ihasmail/releases/tag/stalwart-0.15-support).
- Upgrading? [stalwart-migrator](https://github.com/LINUXexpert-org/stalwart-migrator) does it in place, checkpointing every phase and validating afterwards. The live instance moved 0.15.5 → 0.16.19 with eight seconds of downtime and nothing lost.
- Still on 0.15? The last release that runs on it is tagged [`stalwart-0.15-support`](https://github.com/Coffey-Labs/ihasmail/releases/tag/stalwart-0.15-support).
- Upgrading? [stalwart-migrator](https://github.com/Coffey-Labs/stalwart-migrator) does it in place, checkpointing every phase and validating afterwards. The live instance moved 0.15.5 → 0.16.19 with eight seconds of downtime and nothing lost.
## Quick start (Docker)
@@ -161,21 +161,39 @@ the sign-in refusal can be tested.
### Version numbers
`ihasmail v2.16.84``2` is ihasmail's own major, `16` the Stalwart generation
this build targets, `84` the pull request the commit came from. The first two
live in the root `package.json`; the third comes from git at build time, since
it does not exist until the PR has merged. A commit that did not arrive through
a PR carries the last number plus its short SHA — `2.16.84+g1fa6578`.
`ihasmail v2026.8.30+pr129` — the date of the commit this was built from, and
the pull request that commit arrived through. A commit that did not arrive
through one carries its short SHA instead: `2026.8.30+g1fa6578`. It all comes
from git at build time; nothing writes a version into the tree, and
`package.json` sits at `0.0.0` because it is no longer the source of anything.
The date is the commit's own rather than today's, so rebuilding an old commit
gives the version it had the first time.
```bash
node scripts/version.mjs # the version for the current checkout
docker build --build-arg IHASMAIL_VERSION="$(node scripts/version.mjs)" -t ihasmail:2.16 .
docker build --build-arg IHASMAIL_VERSION="$(node scripts/version.mjs)" -t ihasmail:2026.8.30 .
```
`.dockerignore` excludes `.git` deliberately, so an image build cannot work this
out for itself — pass it in. Left out, the build falls back to the base version
from `package.json`, so a version with no PR number means whoever built the
image did not pass one.
out for itself — pass it in. Left out, the build reports `0.0.0`, which is meant
to look wrong: a version with no `+pr` or `+g` means whoever built the image did
not pass one.
The version says nothing about Stalwart, deliberately. It used to: `2.16.x` had
`16` for the 0.16 generation it targeted, which leaves nowhere to go once
Stalwart reaches 1.0 — `2.1` sorts *below* the `2.16` already deployed, so every
image and About screen would read as a downgrade. Which Stalwart a build needs is
stated where it can be precise, in the badge at the top of this file and in
[KNOWN-ISSUES.md](KNOWN-ISSUES.md), rather than compressed into one digit.
The pull request lives after the `+`, as build metadata, because it is
provenance rather than a rank: at the rate they merge here it climbs without
bound and says nothing about how new a build is. Everything after the `+` is
ignored when versions are compared, which is the right reading — two builds from
the same day differ in where they came from, not in age. Nothing here depends on
that comparison: images are pruned oldest-first by creation time, and a rollback
names a git ref.
### Deploying
@@ -188,7 +206,7 @@ container, waits for healthy, then prunes all but the newest
```bash
./deploy.sh # origin/main, asks before shipping new commits
./deploy.sh --dry-run # run the guards and stop
./deploy.sh v2.16.84 --yes # a named ref, no prompt (there is no tty over ssh)
./deploy.sh v2026.8.30 --yes # a named ref, no prompt (there is no tty over ssh)
```
`--yes` does not override a hold; clearing one means deleting its line.
@@ -200,7 +218,7 @@ container, waits for healthy, then prunes all but the newest
## License
Copyright (C) 2026 LINUXexpert.org — AGPL-3.0-or-later. See
Copyright (C) 2026 Coffey Labs — AGPL-3.0-or-later. See
[LICENSE](LICENSE).
ihasmail was relicensed from GPL-3.0 to AGPL-3.0 on 2026-08-25: webmail is
+6 -4
View File
@@ -1,12 +1,14 @@
# Roadmap / not yet
Things ihasmail does not do, and why. Anything with an issue number is tracked
in [the issue tracker](https://github.com/LINUXexpert-org/ihasmail/issues); the
rest is here because the answer is "no", not "not yet".
Things ihasmail does not do, and why. An issue number here says where the entry
came from, not that it is tracked elsewhere — a report can be closed because the
bug in it was fixed while the larger thing it asked for stays on this page. What
is genuinely open lives in [the issue tracker](https://github.com/Coffey-Labs/ihasmail/issues);
the rest is here because the answer is "no", not "not yet".
See [KNOWN-ISSUES.md](KNOWN-ISSUES.md) for what is built but worth knowing about.
- **Sharing a mail folder.** Stalwart stores the share and never delivers it; see [KNOWN-ISSUES.md](KNOWN-ISSUES.md). Withdrawn until the server does something with it. Sharing files, calendars and address books is unaffected and works.
- Snooze (nothing in JMAP or Stalwart supports it, and ihasmail never stores a password, so nothing could act on a mailbox while you are away)
- Translations (strings are English-only for now)
- **Two-factor sign-in.** Today an account with 2FA must use an app password (see [Quick start](README.md#quick-start-docker)), and Settings Security offers no way to switch 2FA *on* — only off, for an account that already has it. Supporting a TOTP code directly means implementing OAuth: Stalwart offers the authorization-code and device flows and no password grant, so ihasmail would hand sign-in to Stalwart's own login and come back with a token. That is a better security posture than the sealed password it holds now — a refresh token rather than a credential — but it replaces ihasmail's own sign-in page for those users and may need an OAuth client registered. Reported as [#75](https://github.com/LINUXexpert-org/ihasmail/issues/75)
- **Two-factor sign-in.** Today an account with 2FA must use an app password (see [Quick start](README.md#quick-start-docker)), and Settings Security offers no way to switch 2FA *on* — only off, for an account that already has it. Supporting a TOTP code directly means implementing OAuth: Stalwart offers the authorization-code and device flows and no password grant, so ihasmail would hand sign-in to Stalwart's own login and come back with a token. That is a better security posture than the sealed password it holds now — a refresh token rather than a credential — but it replaces ihasmail's own sign-in page for those users and may need an OAuth client registered. Came out of [#75](https://github.com/Coffey-Labs/ihasmail/issues/75), which is closed: what was reported there was a sign-in refused with nothing but "Invalid credentials", and that was fixed by saying what is actually happening and pointing at app passwords. The OAuth work it uncovered is tracked here rather than as an open issue, so there is no ticket to watch for it.
+28 -16
View File
@@ -20,8 +20,12 @@
# whatever main happens to have picked up since the last
# release.
#
# --dry-run runs both guards, says what it would deploy, and stops before
# building or touching the container.
# --dry-run checks the hold list, says what it would deploy, and stops
# before building or touching the container. It does not ask
# for confirmation: there is nothing to agree to when nothing
# changes, and needing a terminal would make it useless over
# SSH -- which is where wanting to look before leaping is most
# likely.
#
# The container is replaced rather than restarted, because the image is rebuilt
# from the new checkout. Data lives in a named volume and survives that; the
@@ -97,7 +101,7 @@ for arg in "$@"; do
case "$arg" in
-y|--yes) ASSUME_YES=1 ;;
-n|--dry-run) DRY_RUN=1 ;;
-h|--help) sed -n '2,28p' "$0"; exit 0 ;;
-h|--help) awk 'NR > 1 { if (/^#/) print; else exit }' "$0"; exit 0 ;;
-*) echo "unknown option: $arg" >&2; exit 2 ;;
*)
if [ -n "$REF" ]; then echo "give at most one git-ref (got '$REF' and '$arg')" >&2; exit 2; fi
@@ -145,7 +149,22 @@ if [ -n "$NEW" ]; then
echo "==> $(git log --oneline -1 "$CURRENT") -> $(git log --oneline -1 "$TARGET")"
echo "==> introduces:"
printf '%s\n' "$NEW" | sed 's/^/ /'
if [ "$ASSUME_YES" -ne 1 ]; then
else
echo "==> already at $(git log --oneline -1 "$TARGET"); rebuilding"
fi
# A dry run has now said everything it has to say, so it stops here -- before
# the confirmation rather than after it. Asking whether to go ahead with
# something that is not going to happen is noise at a terminal; over SSH it was
# worse, because the refusal came out *instead of* the report above and a dry
# run could not be used from another machine at all. Which is the machine you
# are most likely to be on when you want one.
if [ "$DRY_RUN" -eq 1 ]; then
echo "==> dry run: would deploy $(git log --oneline -1 "$TARGET"); nothing was changed"
exit 0
fi
if [ -n "$NEW" ] && [ "$ASSUME_YES" -ne 1 ]; then
if [ -t 0 ]; then
read -r -p "deploy these to production? [y/N] " reply
case "$reply" in
@@ -157,14 +176,6 @@ if [ -n "$NEW" ]; then
echo " re-run with --yes if that is what you mean, or name the ref you want." >&2
exit 1
fi
fi
else
echo "==> already at $(git log --oneline -1 "$TARGET"); rebuilding"
fi
if [ "$DRY_RUN" -eq 1 ]; then
echo "==> dry run: would deploy $(git log --oneline -1 "$TARGET"); nothing was changed"
exit 0
fi
git reset --hard --quiet "$TARGET"
@@ -197,10 +208,11 @@ prune_old_images() {
}
VERSION="$(node scripts/version.mjs)"
# A Docker tag may not contain "+", which a version for a commit that did not
# come through a pull request does: 2.16.57+g1fa6578. The image is tagged with
# the "+" turned into "-"; what the build is *told* it is keeps the real form,
# so About and /api/health still report it correctly.
# A Docker tag may not contain "+", and every version has one now:
# 2026.8.30+pr129, or +g1fa6578 for a commit that did not come through a pull
# request. The image is tagged with the "+" turned into "-"; what the build is
# *told* it is keeps the real form, so About and /api/health still report it
# correctly.
TAG="${VERSION//+/-}"
echo "==> building $(git log --oneline -1) as v$VERSION"
docker build \
+1 -1
View File
@@ -9,7 +9,7 @@ services:
STALWART_URL: ${STALWART_URL:?set STALWART_URL in .env}
APP_SECRET: ${APP_SECRET:?set APP_SECRET in .env (openssl rand -base64 48)}
APP_NAME: ${APP_NAME:-ihasmail}
SOURCE_URL: ${SOURCE_URL:-https://github.com/LINUXexpert-org/ihasmail}
SOURCE_URL: ${SOURCE_URL:-https://github.com/Coffey-Labs/ihasmail}
TRUST_PROXY: "1"
IMAGE_PROXY: "1"
volumes:
+1 -1
View File
@@ -13,7 +13,7 @@ const chrome = spawn("google-chrome-stable", [
"--headless=new", `--remote-debugging-port=${PORT}`, "--hide-scrollbars",
"--no-first-run", "--no-default-browser-check",
"--window-size=1420,790", "--force-device-scale-factor=1",
"--user-data-dir=/tmp/claude-light-profile", "about:blank",
"--user-data-dir=/tmp/ihasmail-light-profile", "about:blank",
], { stdio: "ignore" });
const json = async (p) => { for (let i = 0; i < 60; i++) { try { return await (await fetch(`http://127.0.0.1:${PORT}${p}`)).json(); } catch { await sleep(250); } } throw new Error("no chrome"); };
+1 -1
View File
@@ -48,7 +48,7 @@ const PORT = 9333;
const chrome = spawn("google-chrome-stable", [
"--headless=new", `--remote-debugging-port=${PORT}`, "--hide-scrollbars",
"--no-first-run", "--no-default-browser-check", "--disable-gpu",
`--user-data-dir=/tmp/claude-shots-profile`, "about:blank",
`--user-data-dir=/tmp/ihasmail-shots-profile`, "about:blank",
], { stdio: "ignore" });
const json = async (path) => {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ihasmail",
"version": "2.16.0",
"version": "0.0.0",
"private": true,
"description": "ihasmail \u2014 a fast, modern JMAP webmail for Stalwart Mail Server",
"license": "AGPL-3.0-or-later",
+2 -1
View File
@@ -1,4 +1,5 @@
/** Types for `version.mjs`, which is plain JS so the Dockerfile and shell can run it directly. */
export function baseVersion(): string;
export const UNVERSIONED: string;
export function formatVersion(commit: { date: string; subject?: string; sha: string }): string;
export function versionFromGit(): string | null;
export function resolveVersion(): string;
+63 -38
View File
@@ -1,38 +1,51 @@
/**
* Work out this build's version: `2.16.57`.
* Work out this build's version: `2026.8.30+pr129`.
*
* 2 ihasmail's own major
* 16 the Stalwart major this build targets — 0.16, the oldest it supports
* 57 the pull request the checked-out commit came from
* 2026.8.30 the date of the commit this was built from
* +pr129 the pull request it arrived through
*
* The first two are the `version` in the root package.json, so there is one
* place to bump them; the third is read from git, because it does not exist
* until the pull request has actually merged. Nothing writes a version back
* into the tree: a committed one would always be describing a merge that had
* not happened yet, and every branch would collide on the same line.
* The date leads because ihasmail's version used to be `2.16.<pr>`, where `16`
* was the Stalwart generation it targeted -- and Stalwart 1.0 will leave that
* with nowhere to go. `2.1` would sort *below* the `2.16` already deployed, so
* every image and About screen would read as a downgrade. Tying our
* numbering to somebody else's was the mistake; which Stalwart a build needs is
* said properly in the README badge and KNOWN-ISSUES, where it can be precise
* ("0.16 or newer; tested against 0.16.20") rather than one digit.
*
* A commit that did not arrive through a pull request has no number of its
* own, so it carries the last one plus its own short SHA — `2.16.57+g1fa6578`
* — which is honest about being past that PR rather than silently claiming to
* be it.
* The pull request moved into build metadata, after the `+`, because it is
* provenance rather than a position in a sequence: at a hundred merges a week
* it climbs without bound and says nothing about how new a build is. SemVer
* ignores everything after the `+` when comparing versions, which is the right
* reading -- two builds from the same day differ in where they came from, not
* in rank. Nothing here relies on that comparison anyway: images are pruned
* oldest-first by creation time and rollbacks name a git ref.
*
* A commit that did not arrive through a pull request carries its short SHA
* instead -- `2026.8.30+g1fa6578` -- which is honest about being some commit on
* that day rather than claiming a pull request it was only built after.
*
* The date is the commit's own, not today's, so rebuilding an old commit gives
* the same answer it gave the first time. It comes from the commit object,
* timezone included, so two machines agree.
*
* Nothing writes a version back into the tree: a committed one would always be
* describing a merge that had not happened yet, and every branch would collide
* on the same line. `package.json` no longer carries it either -- npm wants the
* field, so it stays at `0.0.0`, which is what an unversioned build reports and
* is meant to look wrong.
*
* `.dockerignore` excludes `.git`, so an image build cannot run any of this.
* It takes the answer through `--build-arg IHASMAIL_VERSION=...` instead, and
* whoever builds is responsible for computing it see ihasmail-deploy.sh.
* whoever builds is responsible for computing it -- see ihasmail-deploy.sh.
*/
import { execFileSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
/** "2.16" — ihasmail major and the Stalwart major this build is built for. */
export function baseVersion() {
const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
const [major, minor] = String(pkg.version).split(".");
return `${major}.${minor}`;
}
/** What a build with nothing to go on reports, and it should look wrong. */
export const UNVERSIONED = "0.0.0";
function git(...args) {
return execFileSync("git", args, { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
@@ -40,41 +53,53 @@ function git(...args) {
const PR_SUBJECT = /^Merge pull request #(\d+)\b/;
/**
* The version for a commit, from the three things about it that decide one.
* Pure, so the rules can be exercised without a repository staged to produce
* them: `{ date: "2026-08-30", subject: "Merge pull request #129 from ...",
* sha: "1fa6578" }` gives `2026.8.30+pr129`.
*
* Leading zeros are stripped because a version field may not carry them, so
* September is `9` rather than `09`.
*/
export function formatVersion({ date, subject = "", sha }) {
const [y, m, d] = date.split("-");
const calendar = `${Number(y)}.${Number(m)}.${Number(d)}`;
const pr = PR_SUBJECT.exec(subject)?.[1];
return pr ? `${calendar}+pr${pr}` : `${calendar}+g${sha}`;
}
/**
* The version for the commit checked out here, or null when there is no git to
* ask an unpacked tarball, or the Docker build context.
* ask -- an unpacked tarball, or the Docker build context.
*/
export function versionFromGit() {
let head;
let date;
try {
head = git("rev-parse", "--short", "HEAD");
// %cs is the committer date in the commit's own timezone, which is stored
// in the commit -- so this does not depend on the clock or zone of whoever
// is building.
date = git("show", "-s", "--format=%cs", "HEAD");
} catch {
return null;
}
const base = baseVersion();
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) return null;
let subject = "";
try {
// Walk back over first parents: a merge commit's subject names its PR, and
// anything after the newest one is work that has not been through one.
const log = git("log", "--first-parent", "--format=%H%x00%s", "-n", "200");
const commits = log ? log.split("\n").map((l) => l.split("\0")) : [];
for (const [sha, subject = ""] of commits) {
const pr = PR_SUBJECT.exec(subject)?.[1];
if (!pr) continue;
// The PR's own merge commit is the version; anything above it is past it.
const exact = sha.startsWith(git("rev-parse", "HEAD"));
return exact ? `${base}.${pr}` : `${base}.${pr}+g${head}`;
}
subject = git("show", "-s", "--format=%s", "HEAD");
} catch {
/* a shallow clone, or no history to read */
/* no subject to read; fall through to the SHA */
}
return `${base}.0+g${head}`;
return formatVersion({ date, subject, sha: head });
}
/** Whatever the environment was told, else git, else just the base. */
/** Whatever the environment was told, else git, else an answer that looks wrong. */
export function resolveVersion() {
const fromEnv = process.env.IHASMAIL_VERSION?.trim();
if (fromEnv) return fromEnv;
return versionFromGit() ?? `${baseVersion()}.0`;
return versionFromGit() ?? UNVERSIONED;
}
// `node scripts/version.mjs` prints it, for shell scripts and CI.
+53
View File
@@ -35,3 +35,56 @@ test("image proxy refuses private targets", async () => {
const res = await app.request("/api/image?url=http://127.0.0.1/x");
assert.equal(res.status, 401);
});
test("a compressed upstream blob is not forwarded with the compressed length", async () => {
const { forwardedContentLength } = await import("./app.js");
// gzip: the body we forward has already been decompressed, so the length on
// the wire describes different bytes and must not be copied (issue #76).
const gz = new Headers({ "content-encoding": "gzip", "content-length": "384" });
assert.equal(forwardedContentLength(gz), null);
// identity, spelled out or absent: the length describes the body we send.
assert.equal(forwardedContentLength(new Headers({ "content-encoding": "identity", "content-length": "1157" })), "1157");
assert.equal(forwardedContentLength(new Headers({ "content-length": "1157" })), "1157");
assert.equal(forwardedContentLength(new Headers({ "content-encoding": "BR", "content-length": "384" })), null);
// Nothing to forward is not an error.
assert.equal(forwardedContentLength(new Headers()), null);
});
test("a Sieve script larger than a compressing hop's threshold survives the proxy", async () => {
const http = await import("node:http");
const zlib = await import("node:zlib");
const { forwardedContentLength } = await import("./app.js");
const script =
"# ihasmail filters v1 - edit with care; rules are stored in the `# rule:` comments\nrequire [\"fileinto\"];\n\n" +
["a", "b", "c"]
.map(
(k) =>
`# rule:{"id":"r${k}","name":"From ${k}@example.com","enabled":true,"join":"allof","tests":[{"type":"header","header":"from","op":"contains","value":"${k}@example.com"}],"actions":[{"type":"fileinto","mailbox":"INBOX/${k}"}]}\n` +
`if header :contains "from" "${k}@example.com"\n{\n fileinto "INBOX/${k}";\n}\n\n`,
)
.join("");
const gz = zlib.gzipSync(Buffer.from(script));
assert.ok(gz.length < Buffer.byteLength(script), "the script has to compress for this test to mean anything");
// A hop that compresses regardless of what we asked for.
const origin = http.createServer((_req, res) => {
res.writeHead(200, { "content-type": "application/sieve", "content-encoding": "gzip", "content-length": String(gz.length) });
res.end(gz);
});
await new Promise<void>((r) => origin.listen(0, () => r()));
const port = (origin.address() as { port: number }).port;
try {
const up = await fetch(`http://127.0.0.1:${port}/`);
// What the blob route forwards.
const headers = new Headers({ "content-type": "application/sieve; charset=utf-8" });
const cl = forwardedContentLength(up.headers);
if (cl) headers.set("Content-Length", cl);
const out = new Response(await up.arrayBuffer(), { status: 200, headers });
assert.equal(out.headers.get("content-length"), null);
assert.equal(await out.text(), script);
} finally {
origin.close();
}
});
+31 -2
View File
@@ -519,14 +519,17 @@ export function createApp(): Hono<Env> {
const upstream = await getUpstreamSession(session.id, session.authorization);
const url = absoluteUpstream(expandTemplate(upstream.downloadUrl, { accountId, blobId, name, type: accept }));
const res = await fetch(url, {
headers: { authorization: session.authorization },
// Ask for the bytes as they are. undici would otherwise negotiate gzip
// on our behalf and hand back a decompressed body whose content-length
// header still describes the compressed one -- see forwardedContentLength.
headers: { authorization: session.authorization, "accept-encoding": "identity" },
signal: AbortSignal.timeout(Math.max(config.upstreamTimeout, 5 * 60_000)),
});
if (!res.ok) return c.json({ error: "not_found" }, res.status === 404 ? 404 : 502);
const headers = new Headers();
const type = sanitizeContentType(res.headers.get("content-type") ?? accept);
headers.set("Content-Type", type);
const cl = res.headers.get("content-length");
const cl = forwardedContentLength(res.headers);
if (cl) headers.set("Content-Length", cl);
const safeInline = inline && isInlineSafe(type);
headers.set(
@@ -651,6 +654,32 @@ function passthrough(res: Response): Response {
return new Response(res.body, { status: res.status, headers });
}
/**
* The upstream content-length, but only when it describes the bytes we are
* about to forward.
*
* A compressed response is decompressed for us before we ever see the body --
* undici does it transparently -- while the content-length header is left
* describing the *compressed* length. Copying it onto the longer body we then
* send makes the browser stop reading exactly that many bytes in and call the
* download complete, so the file arrives silently truncated.
*
* That is the second half of issue #76. A hop in front of Stalwart compressed
* responses over 1 KiB, so a Sieve script stayed intact until the third rule
* pushed it past the threshold and it came back cut off mid-rule. Nothing
* reported an error: the script parsed, just with rules missing, and saving
* wrote that shortened version back over the real one.
*
* We ask for `identity` above so the usual case still carries a length the
* browser can show progress against; this is the guard for a hop that
* compresses anyway.
*/
export function forwardedContentLength(headers: Headers): string | null {
const encoding = headers.get("content-encoding")?.trim().toLowerCase();
if (encoding && encoding !== "identity") return null;
return headers.get("content-length");
}
function sanitizeContentType(ct: string): string {
const lower = ct.split(";")[0]!.trim().toLowerCase();
// Never let the browser render HTML/SVG/XML/JS served from the blob endpoint.
+1 -1
View File
@@ -127,7 +127,7 @@ export const config = {
* source, not the one it was forked from -- so anyone deploying a patched
* ihasmail should point this at their own tree.
*/
sourceUrl: env("SOURCE_URL", "https://github.com/LINUXexpert-org/ihasmail"),
sourceUrl: env("SOURCE_URL", "https://github.com/Coffey-Labs/ihasmail"),
host: env("HOST", "0.0.0.0"),
port: int("PORT", 8080),
stalwartUrl,
+172 -9
View File
@@ -5,6 +5,7 @@
*/
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { randomUUID } from "node:crypto";
import { expandOccurrences, occurrenceAt, occurrenceView, parseSyntheticId, slotOfOccurrence, splitOccurrencePatch, syntheticId, type Occurrence } from "./recurrence.js";
import { parseOtpauthUrl, verifyTotp } from "../totp.js";
import { holdUntilOf, undoStatusOf } from "./futurerelease.js";
@@ -399,6 +400,24 @@ function genericGet(list: Obj[]) {
return { accountId: ACCOUNT, state: String(state.n), list: found.map((x) => pick(x, a.properties as string[] | null)), notFound: ids ? ids.filter((id) => !list.some((x) => x.id === id)) : [] };
};
}
/**
* An id, as either a stored event or one occurrence of one.
*
* A synthetic id whose base is gone, or whose index falls outside the series
* (deleted, or past a `count`), resolves to nothing — `notFound`, the way the
* server answers for an occurrence that is not there any more.
*/
function resolveEvent(list: Obj[], id: string): { base: Obj; occ?: Occurrence } | null {
const direct = list.find((x) => x.id === id);
if (direct) return { base: direct };
const parsed = parseSyntheticId(id);
if (!parsed) return null;
const base = list.find((x) => x.id === parsed.baseId);
if (!base) return null;
const occ = occurrenceAt(base, parsed.slot);
return occ ? { base, occ } : null;
}
/** Thrown from an onCreate hook to refuse a create the way a real server would. */
class SetError extends Error {
constructor(readonly type: string, readonly description: string, readonly properties?: string[]) { super(description); }
@@ -436,6 +455,125 @@ function genericSet(list: Obj[], prefix: string, onCreate?: (o: Obj) => void) {
};
}
/* ---------- calendar events ---------- */
/**
* `CalendarEvent/set`, including the synthetic-id handling 0.16.20 added.
*
* An update or destroy aimed at an occurrence does not touch the series: it
* writes a `recurrenceOverrides` entry keyed by that date, exactly as Stalwart
* does — `{ excluded: true }` for a destroy, the patch merged in for an update.
*
* The refusals are the point of reproducing this at all:
*
* - a base event and one of its instances in the same request is refused, both
* ids at once, because the server cannot apply them in a defined order;
* - the same id twice is "Duplicate event id.";
* - the ten event-level properties are refused with `invalidProperties`;
* - and the twelve inherited ones are dropped in silence, with the response
* still saying the update succeeded. A mock that applied them would let a
* client that sends them look correct everywhere except a real server.
*/
function calendarEventSet(a: Obj) {
const created: Obj = {};
const updated: Obj = {};
const destroyed: string[] = [];
const notCreated: Obj = {};
const notUpdated: Obj = {};
const notDestroyed: Obj = {};
for (const [cid, obj] of Object.entries((a.create as Obj) ?? {})) {
const o: Obj = { ...(obj as Obj), id: `ev${randomUUID().slice(0, 6)}` };
// Stalwart 0.16 rejects the RFC 8984 array outright and silently discards
// participants addressed the RFC 8984 way. The mock did neither, which is
// how #26 and #30 reached a live server unnoticed — so it does both.
if (o.recurrenceRules) { notCreated[cid] = new SetError("invalidProperties", "Invalid property.", ["recurrenceRules"]).toJSON(); continue; }
const parts = o.participants as Record<string, Obj> | undefined;
if (parts && Object.values(parts).some((p) => !p.calendarAddress)) delete o.participants;
if (o.replyTo && !o.organizerCalendarAddress) delete o.replyTo;
o.uid = o.uid ?? randomUUID();
events.push(o);
created[cid] = { id: o.id };
}
const updates = Object.entries((a.update as Obj) ?? {});
const destroys = ((a.destroy as string[]) ?? []).slice();
const seen = new Set<string>();
/* A base and one of its instances cannot be settled in the same request. */
const baseOf = (id: string): string | null => {
const r = resolveEvent(events, id);
return r ? (r.base.id as string) : null;
};
const touched = new Map<string, { base: string[]; instance: string[] }>();
for (const id of [...updates.map(([id]) => id), ...destroys]) {
const b = baseOf(id);
if (!b) continue;
const entry = touched.get(b) ?? { base: [], instance: [] };
(parseSyntheticId(id) ? entry.instance : entry.base).push(id);
touched.set(b, entry);
}
const conflicted = new Set<string>();
for (const [, e] of touched) {
if (e.base.length && e.instance.length) for (const id of [...e.base, ...e.instance]) conflicted.add(id);
}
const conflict = () => new SetError("invalidProperties", "A base event and its instances cannot be modified in the same request.", ["id"]).toJSON();
for (const [id, patch] of updates) {
if (conflicted.has(id)) { notUpdated[id] = conflict(); continue; }
if (seen.has(id)) { notUpdated[id] = new SetError("invalidProperties", "Duplicate event id.", ["id"]).toJSON(); continue; }
seen.add(id);
const resolved = resolveEvent(events, id);
if (!resolved) { notUpdated[id] = { type: "notFound" }; continue; }
if (!resolved.occ) { applyPatch(resolved.base, patch as Obj); updated[id] = null; continue; }
const { rejected, applied } = splitOccurrencePatch(patch as Obj);
if (rejected) { notUpdated[id] = new SetError("invalidProperties", "This property cannot be modified on a single occurrence.", [rejected]).toJSON(); continue; }
writeOverride(resolved.base, resolved.occ, applied);
updated[id] = null;
}
for (const id of destroys) {
if (conflicted.has(id)) { notDestroyed[id] = conflict(); continue; }
const resolved = resolveEvent(events, id);
if (!resolved) { notDestroyed[id] = { type: "notFound" }; continue; }
if (resolved.occ) {
// One date off a series, which is an override rather than a deletion.
writeOverride(resolved.base, resolved.occ, { excluded: true }, true);
destroyed.push(id);
continue;
}
const i = events.findIndex((x) => x.id === id);
if (i >= 0) { events.splice(i, 1); destroyed.push(id); }
}
return setResp({
created, updated, destroyed,
...(Object.keys(notCreated).length ? { notCreated } : {}),
...(Object.keys(notUpdated).length ? { notUpdated } : {}),
...(Object.keys(notDestroyed).length ? { notDestroyed } : {}),
});
}
/**
* Merge a patch into the override for one date.
*
* Stalwart fills `start` and `duration` in when the patch leaves them out, so
* an override always carries its own timing; the mock does the same, or a
* client could depend on inheriting them and be right only here.
*/
function writeOverride(base: Obj, occ: Occurrence, patch: Obj, replace = false) {
const overrides = (base.recurrenceOverrides as Record<string, Obj> | undefined) ?? {};
const existing = replace ? {} : (overrides[occ.recurrenceId] ?? {});
const next: Obj = { ...existing };
if (!replace) {
if (!("start" in next)) next.start = occ.start;
if (!("duration" in next) && base.duration) next.duration = base.duration;
}
applyPatch(next, patch);
overrides[occ.recurrenceId] = next;
base.recurrenceOverrides = overrides;
}
/* ---------- submissions ---------- */
/**
* Held messages, the way Stalwart models them: `sendAt` is derived from the
@@ -774,18 +912,43 @@ const handlers: Record<string, Handler> = {
"SieveScript/validate": () => ({ accountId: ACCOUNT, error: null }),
"Calendar/get": (a) => hideShareWithUnlessAsked(a, genericGet(calendarsFor(a.accountId))(a) as { list: Obj[] }) as never,
"Calendar/set": (a) => genericSet(calendarsFor(a.accountId), "c", (o) => Object.assign(o, { color: "#0f766e", isSubscribed: true, isVisible: true, isDefault: false, includeInAvailability: "all", timeZone: null, shareWith: null, myRights: rightsCal(), description: null, sortOrder: 0, ...o }))(a),
"CalendarEvent/query": (a) => { const list = eventsFor(a.accountId); return { accountId: a.accountId ?? ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: list.filter((e) => !(a.filter as Obj)?.uid || e.uid === (a.filter as Obj).uid).map((e) => e.id), total: list.length }; },
"CalendarEvent/get": (a) => genericGet(eventsFor(a.accountId))(a),
/*
* With `expandRecurrences` every id that comes back is synthetic — a one-off
* included, which is what a live 0.16.19 does and what makes `baseEventId`
* useless as a test for a series. Without it (the `findByUid` path) the
* stored ids come back untouched, because callers hand those straight to a
* destroy and mean the whole event.
*/
"CalendarEvent/query": (a) => {
const list = eventsFor(a.accountId);
const filter = (a.filter as Obj) ?? {};
const matching = list.filter((e) => !filter.uid || e.uid === filter.uid);
if (!a.expandRecurrences) {
return { accountId: a.accountId ?? ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: matching.map((e) => e.id), total: matching.length };
}
const from = filter.after ? new Date(filter.after as string) : new Date(-8640000000000);
const to = filter.before ? new Date(filter.before as string) : new Date(8640000000000);
const ids: string[] = [];
for (const e of matching) for (const occ of expandOccurrences(e, from, to)) ids.push(syntheticId(e.id as string, slotOfOccurrence(e, occ)));
return { accountId: a.accountId ?? ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids, total: ids.length };
},
"CalendarEvent/get": (a) => {
const list = eventsFor(a.accountId);
const ids = a.ids as string[] | null | undefined;
if (!ids) return genericGet(list)(a);
const found: Obj[] = [];
const notFound: string[] = [];
for (const id of ids) {
const resolved = resolveEvent(list, id);
if (!resolved) { notFound.push(id); continue; }
found.push(resolved.occ ? occurrenceView(resolved.base, resolved.occ) : resolved.base);
}
return { accountId: ACCOUNT, state: String(state.n), list: found.map((x) => pick(x, a.properties as string[] | null)), notFound };
},
// Stalwart 0.16 rejects the RFC 8984 array outright and silently discards
// participants addressed the RFC 8984 way. The mock did neither, which is how
// #26 and #30 reached a live server unnoticed — so it now does both.
"CalendarEvent/set": genericSet(events, "ev", (o) => {
if (o.recurrenceRules) throw new SetError("invalidProperties", "Invalid property.", ["recurrenceRules"]);
const parts = o.participants as Record<string, Obj> | undefined;
if (parts && Object.values(parts).some((p) => !p.calendarAddress)) delete o.participants;
if (o.replyTo && !o.organizerCalendarAddress) delete o.replyTo;
return Object.assign(o, { uid: o.uid ?? randomUUID() });
}),
"CalendarEvent/set": (a) => calendarEventSet(a),
"CalendarEvent/parse": (a) => { const parsed: Obj = {}; for (const b of a.blobIds as string[]) { const blob = blobs.get(b); if (!blob) continue; const t = blob.data.toString(); const g = (k: string) => new RegExp(`^${k}[^:]*:(.*)$`, "m").exec(t)?.[1]?.trim(); const ds = g("DTSTART") ?? "20260101T000000Z"; const de = g("DTEND") ?? ds; const toLocal = (s: string) => `${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6, 8)}T${s.slice(9, 11)}:${s.slice(11, 13)}:00`; const start = new Date(`${toLocal(ds)}Z`); const end = new Date(`${toLocal(de)}Z`); parsed[b] = { "@type": "Event", uid: g("UID"), title: g("SUMMARY"), start: toLocal(ds), timeZone: "Etc/UTC", duration: `PT${Math.round((end.getTime() - start.getTime()) / 60000)}M`, method: g("METHOD"), locations: g("LOCATION") ? { l: { name: g("LOCATION") } } : undefined, participants: { org: { name: "Ada Lovelace", calendarAddress: "mailto:[email protected]", roles: { owner: true } }, me: { name: "Demo User", calendarAddress: `mailto:${USER}`, roles: { attendee: true, required: true }, participationStatus: "needs-action" } } }; } return { accountId: ACCOUNT, parsed, notParsable: [] }; },
"ParticipantIdentity/get": genericGet(participantIdentities),
"Principal/query": () => ({ accountId: ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: principals.map((p) => p.id) }),
+210
View File
@@ -0,0 +1,210 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { expandOccurrences, occurrenceAt, occurrenceView, parseSyntheticId, slotOfOccurrence, splitOccurrencePatch, syntheticId } from "./recurrence.js";
/**
* The mock expands recurrences so that per-occurrence editing can be developed
* against something. What it has to get right is not the expansion — that is
* the easy half — but the three things a live server does that a client will
* otherwise be written against wrongly:
*
* - every expanded id is synthetic, one-offs included;
* - an occurrence carries a `recurrenceId` and no rule;
* - a per-occurrence patch loses some properties in silence.
*/
const WEEKDAYS = { "@type": "RecurrenceRule", frequency: "weekly", byDay: [{ day: "mo" }, { day: "tu" }, { day: "we" }, { day: "th" }, { day: "fr" }] };
/** A standup at 09:00 every weekday, starting Monday 2026-09-07. */
const series = () => ({ id: "ev1", "@type": "Event", uid: "u1", title: "Standup", start: "2026-09-07T09:00:00", duration: "PT30M", recurrenceRule: WEEKDAYS } as Record<string, unknown>);
const oneOff = () => ({ id: "ev2", "@type": "Event", uid: "u2", title: "Lunch", start: "2026-09-08T12:00:00", duration: "PT1H" } as Record<string, unknown>);
const week = (from: string, to: string) => [new Date(from), new Date(to)] as const;
describe("expandOccurrences", () => {
it("gives a weekday rule five dates in a week and skips the weekend", () => {
const [a, b] = week("2026-09-07T00:00:00", "2026-09-14T00:00:00");
const out = expandOccurrences(series(), a, b);
assert.deepEqual(out.map((o) => o.start), [
"2026-09-07T09:00:00", "2026-09-08T09:00:00", "2026-09-09T09:00:00",
"2026-09-10T09:00:00", "2026-09-11T09:00:00",
]);
});
it("gives a one-off exactly one occurrence, at index 0", () => {
const [a, b] = week("2026-09-01T00:00:00", "2026-10-01T00:00:00");
const out = expandOccurrences(oneOff(), a, b);
assert.equal(out.length, 1);
assert.equal(out[0]!.index, 0);
});
it("honours count", () => {
const ev = { ...series(), recurrenceRule: { ...WEEKDAYS, count: 3 } };
const [a, b] = week("2026-09-07T00:00:00", "2026-10-01T00:00:00");
assert.equal(expandOccurrences(ev, a, b).length, 3);
});
it("drops an excluded date from the expansion, keeping the series positions", () => {
const ev = { ...series(), recurrenceOverrides: { "2026-09-08T09:00:00": { excluded: true } } };
const [a, b] = week("2026-09-07T00:00:00", "2026-09-14T00:00:00");
const out = expandOccurrences(ev, a, b);
assert.deepEqual(out.map((o) => o.start), [
"2026-09-07T09:00:00", "2026-09-09T09:00:00", "2026-09-10T09:00:00", "2026-09-11T09:00:00",
]);
// The position within the series is unchanged — Wednesday is still the
// third date the rule produces, whatever happened to Tuesday. It is the
// *id* built on top of that which moves, and only after a write.
assert.equal(out[1]!.index, 2);
});
it("carries an override onto the occurrence it keys", () => {
const ev = { ...series(), recurrenceOverrides: { "2026-09-09T09:00:00": { title: "Standup (long)" } } };
const [a, b] = week("2026-09-07T00:00:00", "2026-09-14T00:00:00");
const out = expandOccurrences(ev, a, b);
assert.deepEqual(out.find((o) => o.start === "2026-09-09T09:00:00")!.override, { title: "Standup (long)" });
});
});
describe("occurrenceView", () => {
it("strips the rule, sets recurrenceId, and points baseEventId at the master", () => {
const base = series();
const occ = occurrenceAt(base, 1)!;
const view = occurrenceView(base, occ);
assert.equal(view.id, syntheticId("ev1", 1));
assert.equal(view.baseEventId, "ev1");
assert.equal(view.recurrenceId, "2026-09-08T09:00:00");
assert.equal(view.recurrenceRule, undefined);
assert.equal(view.recurrenceOverrides, undefined);
});
it("gives a one-off a synthetic id over a different base, and no recurrenceId", () => {
// Both halves matter. The id is why `baseEventId` proves nothing about a
// series; the absent `recurrenceId` is why a one-off does not read as one.
const base = oneOff();
const view = occurrenceView(base, occurrenceAt(base, 0)!);
assert.equal(view.id, "ev2-o0");
assert.equal(view.baseEventId, "ev2");
assert.notEqual(view.id, view.baseEventId);
assert.equal(view.recurrenceId, undefined);
});
it("lets an override win over the series", () => {
const base = { ...series(), recurrenceOverrides: { "2026-09-08T09:00:00": { title: "Moved" } } };
// Slot 2, not 1: one override has already shifted the numbering. Reaching
// for the id this occurrence had *before* the write is the bug below.
const view = occurrenceView(base, occurrenceAt(base, 2)!);
assert.equal(view.start, "2026-09-08T09:00:00");
assert.equal(view.title, "Moved");
});
});
describe("parseSyntheticId", () => {
it("round-trips", () => {
assert.deepEqual(parseSyntheticId(syntheticId("ev1", 12)), { baseId: "ev1", slot: 12 });
});
it("does not claim a stored id", () => {
assert.equal(parseSyntheticId("ev1"), null);
});
});
describe("splitOccurrencePatch", () => {
it("applies what an occurrence takes", () => {
const { rejected, applied } = splitOccurrencePatch({ title: "Just today", color: "#f00" });
assert.equal(rejected, undefined);
assert.deepEqual(applied, { title: "Just today", color: "#f00" });
});
it("refuses an event-level property by name", () => {
assert.equal(splitOccurrencePatch({ calendarIds: { c2: true } }).rejected, "calendarIds");
assert.equal(splitOccurrencePatch({ hideAttendees: true }).rejected, "hideAttendees");
});
it("drops an inherited property in silence, which is the dangerous half", () => {
// No `rejected`, nothing applied, and a real server would still answer
// "updated". Anything that trusts the response believes this landed.
const { rejected, applied } = splitOccurrencePatch({ privacy: "private", recurrenceRule: null });
assert.equal(rejected, undefined);
assert.deepEqual(applied, {});
});
it("judges a pointer patch on its first token", () => {
assert.deepEqual(splitOccurrencePatch({ "participants/me/participationStatus": "accepted" }).applied,
{ "participants/me/participationStatus": "accepted" });
assert.deepEqual(splitOccurrencePatch({ "participants/me/calendarAddress": "mailto:x@y" }).applied, {});
});
});
describe("synthetic ids are only true until the next write", () => {
/*
* Confirmed live on 0.16.20 (2026-08-31): writing one `recurrenceOverrides`
* entry renumbered a five-week series so that the *same* ids addressed
* different dates. Nothing was rejected. The mock reproduces the shape of
* that rather than the exact permutation, because the property that bites is
* not which date an id moves to but that it moves at all, silently.
*/
it("makes a cached id address a different date after an override is written", () => {
const before = series();
const held = syntheticId("ev1", slotOfOccurrence(before, occurrenceAt(before, 3)!));
const dateBefore = occurrenceAt(before, parseSyntheticId(held)!.slot)!.start;
const after = { ...before, recurrenceOverrides: { "2026-09-07T09:00:00": { title: "changed" } } };
const dateAfter = occurrenceAt(after, parseSyntheticId(held)!.slot)!.start;
assert.notEqual(dateAfter, dateBefore);
// And crucially it still resolves — a stale id is wrong, not invalid, so a
// client that trusts it gets a confident answer about the wrong day.
assert.ok(dateAfter);
});
it("keeps recurrenceId meaning the same date across a write, which is why it is the handle", () => {
const before = series();
const occ = occurrenceAt(before, 3)!;
const after = { ...before, recurrenceOverrides: { "2026-09-07T09:00:00": { title: "changed" } } };
const same = expandOccurrences(after, new Date("2026-09-01T00:00:00"), new Date("2026-10-01T00:00:00"))
.find((o) => o.recurrenceId === occ.recurrenceId);
assert.equal(same!.start, occ.start);
});
});
describe("an override that moves an occurrence", () => {
/*
* Confirmed live on 0.16.20 (2026-08-31): one occurrence of a weekly 09:00
* series moved to 14:00 comes back with `start` at 14:00 and `recurrenceId`
* still at 09:00 — the slot the rule made, which the move does not touch.
*
* The mock used to clobber the override's `start` with the slot time, so a
* moved occurrence did not move. That made per-occurrence *time* editing —
* one of the main things the feature is for — look broken against the mock
* and fine against the server.
*/
const moved = () => ({
...series(),
recurrenceOverrides: { "2026-09-08T09:00:00": { start: "2026-09-08T14:00:00" } },
});
it("moves the occurrence and leaves its recurrenceId on the original slot", () => {
const [a, b] = week("2026-09-07T00:00:00", "2026-09-14T00:00:00");
const occ = expandOccurrences(moved(), a, b).find((o) => o.recurrenceId === "2026-09-08T09:00:00")!;
assert.equal(occ.start, "2026-09-08T14:00:00");
assert.equal(occ.recurrenceId, "2026-09-08T09:00:00");
});
it("shows the moved time on the occurrence a get returns", () => {
const base = moved();
const occ = expandOccurrences(base, new Date("2026-09-07T00:00:00"), new Date("2026-09-14T00:00:00"))
.find((o) => o.recurrenceId === "2026-09-08T09:00:00")!;
const view = occurrenceView(base, occ);
assert.equal(view.start, "2026-09-08T14:00:00");
assert.equal(view.recurrenceId, "2026-09-08T09:00:00");
});
it("keeps the occurrence findable by recurrenceId after the move", () => {
// This is the property the store depends on: `recurrenceId` survives both
// a renumbering and a move, so it is the handle a mutation resolves from.
const base = moved();
const all = expandOccurrences(base, new Date("2026-09-01T00:00:00"), new Date("2026-10-01T00:00:00"));
assert.equal(all.filter((o) => o.recurrenceId === "2026-09-08T09:00:00").length, 1);
});
});
+238
View File
@@ -0,0 +1,238 @@
/**
* Enough recurrence expansion for the mock to behave like Stalwart 0.16.20.
*
* The mock used to hand a recurring event back once, as its stored self. Three
* things that only a live server showed were therefore impossible to develop
* against, and all three had already cost a debugging session:
*
* - an expanded query gives *everything* a synthetic id over a `baseEventId`,
* a one-off included, so `baseEventId` is no evidence of a series;
* - an occurrence carries a `recurrenceId` and no rule of its own;
* - 0.16.20 takes a write aimed at a synthetic id and turns it into a
* `recurrenceOverrides` entry rather than touching the series.
*
* A mock that agrees with the client rather than with the server is how #26 and
* #30 reached a live instance, so the refusals matter as much as the successes:
* what Stalwart rejects is rejected here, and what it drops in silence is
* dropped here, in silence, on purpose.
*/
export type Obj = Record<string, unknown>;
/** How far the expander will walk before giving up on a rule. */
const MAX_ITERATIONS = 750;
const DAYS = ["su", "mo", "tu", "we", "th", "fr", "sa"];
/**
* The id an occurrence is addressed by, which is only true until the next write.
*
* Stalwart's are opaque; the mock's are parseable because it has to resolve
* them, and nothing in ihasmail may read either.
*
* They are also deliberately **unstable**, because the real ones are.
* **Confirmed live on 0.16.20 (2026-08-31):** a synthetic id encodes a position
* in the expanded series, and writing a `recurrenceOverrides` entry adds a
* component that renumbers it. A five-week series held `e i m q u` over
* 03-01…03-29; after one override was written to 03-08 the same ids addressed
* 03-01, 03-15, 03-29, 03-08, 03-22. Nothing was rejected — they just meant
* different dates.
*
* That is the hazard worth reproducing, and note which way round it goes: a
* stale id is not *invalid*, it is *wrong*. A mock that expired them instead
* would hand back a loud `notFound` and let a client that caches ids look
* careful. So the numbering is shifted by the number of overrides — an
* arbitrary stand-in for Stalwart's renumbering, with the one property that
* matters: hold an id across a write and it silently addresses another date.
*/
export const syntheticId = (baseId: string, slot: number): string => `${baseId}-o${slot}`;
export function parseSyntheticId(id: string): { baseId: string; slot: number } | null {
const m = /^(.+)-o(\d+)$/.exec(id);
return m ? { baseId: m[1]!, slot: Number(m[2]) } : null;
}
/** How far the id numbering has been rotated away from the series order. */
function rotation(base: Obj): number {
return Object.keys((base.recurrenceOverrides as Record<string, Obj> | undefined) ?? {}).length;
}
/** The id slot this occurrence currently answers to. */
export function slotOfOccurrence(base: Obj, occ: Occurrence): number {
return occ.index + rotation(base);
}
/** `2026-08-31T09:00:00` — the naive local form the mock stores `start` in. */
export function localDateTime(d: Date): string {
const p = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
}
const parseLocal = (s: string): Date => new Date(s);
export interface Occurrence {
index: number;
/** The slot in the series this instance fills, which keys any override. */
recurrenceId: string;
start: string;
/** Set when a `recurrenceOverrides` entry applies to this date. */
override?: Obj;
}
interface Rule {
frequency?: string;
interval?: number;
count?: number;
until?: string;
byDay?: { day: string }[];
}
/**
* Every occurrence of `base` between `from` and `to`, in series order.
*
* An event with no rule has exactly one, at index 0 — which is what gives a
* one-off the synthetic id a real server would give it.
*/
export function expandOccurrences(base: Obj, from: Date, to: Date): Occurrence[] {
const overrides = (base.recurrenceOverrides as Record<string, Obj> | undefined) ?? {};
const startStr = base.start as string;
if (!startStr) return [];
const first = parseLocal(startStr);
const rule = base.recurrenceRule as Rule | undefined;
const out: Occurrence[] = [];
const emit = (index: number, at: Date): boolean => {
const recurrenceId = localDateTime(at);
const override = overrides[recurrenceId];
// An excluded date is simply gone from the expansion. Its slot is not
// reserved -- see `syntheticId` for why nothing here pretends otherwise.
if (override?.excluded === true) return true;
/*
* An override may move the occurrence, and then `start` and `recurrenceId`
* are two different times: the slot it fills stays where the rule put it,
* and only the clock time moves. **Confirmed live on 0.16.20
* (2026-08-31)**: one occurrence of a weekly 09:00 series moved to 14:00
* came back `start: 2027-06-14T14:00:00` with `recurrenceId` still
* `2027-06-14T09:00:00`.
*
* Which is exactly why `recurrenceId` is what a client holds on to. It is
* the one name for this instance that neither a renumbering nor a move
* changes.
*/
const start = (typeof override?.start === "string" ? override.start : null) ?? recurrenceId;
const shown = parseLocal(start);
if (shown >= from && shown < to) {
out.push({ index, recurrenceId, start, ...(override ? { override } : {}) });
}
return at < to;
};
if (!rule?.frequency) {
emit(0, first);
return out;
}
const interval = Math.max(1, rule.interval ?? 1);
const until = rule.until ? parseLocal(rule.until) : null;
const byDay = rule.byDay?.length ? new Set(rule.byDay.map((d) => d.day.toLowerCase())) : null;
let index = 0;
let emitted = 0;
const cursor = new Date(first);
for (let step = 0; step < MAX_ITERATIONS; step++) {
if (until && cursor > until) break;
if (rule.count != null && emitted >= rule.count) break;
const matches = !byDay || byDay.has(DAYS[cursor.getDay()]!);
if (matches) {
emitted++;
const keepGoing = emit(index, new Date(cursor));
index++;
if (!keepGoing) break;
}
// A rule with byDay walks day by day and keeps the days it names; without
// one it steps by its own frequency.
if (byDay) cursor.setDate(cursor.getDate() + 1);
else if (rule.frequency === "daily") cursor.setDate(cursor.getDate() + interval);
else if (rule.frequency === "weekly") cursor.setDate(cursor.getDate() + 7 * interval);
else if (rule.frequency === "monthly") cursor.setMonth(cursor.getMonth() + interval);
else if (rule.frequency === "yearly") cursor.setFullYear(cursor.getFullYear() + interval);
else break;
}
return out;
}
/** Fields that describe the series and never travel down to one instance. */
const SERIES_ONLY = ["recurrenceRule", "recurrenceRules", "excludedRecurrenceRules", "recurrenceOverrides"];
/**
* The object a `CalendarEvent/get` returns for one occurrence.
*
* The rule is stripped, `recurrenceId` is set, and `baseEventId` points at the
* master — so an occurrence is recognisable by its `recurrenceId` and by
* nothing else, which is the shape `isRecurring` was written against.
*/
export function occurrenceView(base: Obj, occ: Occurrence): Obj {
const view: Obj = { ...base };
for (const k of SERIES_ONLY) delete view[k];
Object.assign(view, occ.override ?? {});
view.id = syntheticId(base.id as string, slotOfOccurrence(base, occ));
view.baseEventId = base.id;
view.start = occ.start;
// Only a genuine instance of a series carries one. A one-off expanded into
// its single occurrence does not, or every one-off would look recurring.
if (base.recurrenceRule) view.recurrenceId = occ.recurrenceId;
delete view.excluded;
return view;
}
/* ---------- what a single occurrence will not take ---------- */
/** Refused outright, with `invalidProperties`. */
export const OCCURRENCE_REJECTED = new Set([
"baseEventId", "calendarIds", "isDraft", "isOrigin", "utcStart", "utcEnd",
"useDefaultAlerts", "mayInviteSelf", "mayInviteOthers", "hideAttendees",
]);
/**
* Dropped from the patch, with the response still reporting success.
*
* This is the half that has to be reproduced most carefully. A mock that
* *applied* these would agree with a client that sends them, and the belief
* would ship — which is exactly the road #26 took to a live server.
*/
export const OCCURRENCE_INHERITED = new Set([
"@type", "method", "organizerCalendarAddress", "privacy", "prodId",
"recurrenceId", "recurrenceIdTimeZone", "sentBy", "uid",
"recurrenceOverrides", "recurrenceRule", "relatedTo",
]);
/**
* Split a per-occurrence patch the way the server's validator does.
*
* `rejected` is the first property that would be refused, if any; `applied` is
* what actually lands on the override. Everything else vanishes without a word.
*/
export function splitOccurrencePatch(patch: Obj): { rejected?: string; applied: Obj } {
const applied: Obj = {};
for (const [key, value] of Object.entries(patch)) {
const [head, , third] = key.split("/");
const root = head ?? key;
if (OCCURRENCE_REJECTED.has(root)) return { rejected: root, applied };
if (OCCURRENCE_INHERITED.has(root)) continue;
if (root === "participants" && third === "calendarAddress") continue;
if (root === "id") continue;
applied[key] = value;
}
return { applied };
}
/** The occurrence a slot currently addresses — which is not a fixed thing. */
export function occurrenceAt(base: Obj, slot: number): Occurrence | null {
const index = slot - rotation(base);
if (index < 0) return null;
const all = expandOccurrences(base, new Date(-8640000000000), new Date(8640000000000));
return all.find((o) => o.index === index) ?? null;
}
+63
View File
@@ -0,0 +1,63 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { formatVersion, resolveVersion, UNVERSIONED, versionFromGit } from "../../scripts/version.mjs";
/**
* The version is this build's public identity: it names the image, and it is
* what About and /api/health report. It had no tests while it was
* `2.16.<pr>`; it has them now that the rules moved.
*/
test("a pull request merge is named by its number", () => {
assert.equal(
formatVersion({ date: "2026-08-30", subject: "Merge pull request #129 from Coffey-Labs/link-project-site-v2", sha: "1fa6578" }),
"2026.8.30+pr129",
);
});
test("a commit that did not come through a pull request carries its SHA", () => {
// Claiming the last PR would say it *is* that PR rather than something after it.
assert.equal(formatVersion({ date: "2026-08-30", subject: "Fix a thing directly on main", sha: "1fa6578" }), "2026.8.30+g1fa6578");
});
test("leading zeros are stripped, since a version field may not carry them", () => {
assert.equal(formatVersion({ date: "2026-09-05", subject: "Merge pull request #7 from x/y", sha: "abc1234" }), "2026.9.5+pr7");
assert.equal(formatVersion({ date: "2027-01-01", subject: "", sha: "abc1234" }), "2027.1.1+gabc1234");
});
test("it sorts forward from the versions it replaces", () => {
// 2.16.129 was deployed. 2.1.x would have read as a downgrade, which is the
// whole reason the Stalwart generation left the version.
const [older, newer] = ["2.16.129", "2026.8.30"].map((v) => v.split(".").map(Number));
assert.ok(newer![0]! > older![0]!, "the leading field has to increase");
});
test("two builds from the same day differ, even though they rank the same", () => {
const a = formatVersion({ date: "2026-08-30", subject: "Merge pull request #128 from x/y", sha: "aaaaaaa" });
const b = formatVersion({ date: "2026-08-30", subject: "Merge pull request #129 from x/y", sha: "bbbbbbb" });
assert.notEqual(a, b);
assert.equal(a.split("+")[0], b.split("+")[0]);
});
test("the same commit always resolves to the same version", () => {
// Built from the commit's own date, not today's, so an old commit rebuilt
// now reports what it reported then.
const commit = { date: "2026-08-30", subject: "Merge pull request #129 from x/y", sha: "1fa6578" };
assert.equal(formatVersion(commit), formatVersion(commit));
});
test("an explicit IHASMAIL_VERSION wins, because the Docker build has no git", () => {
const before = process.env.IHASMAIL_VERSION;
process.env.IHASMAIL_VERSION = "2026.8.30+pr129";
try {
assert.equal(resolveVersion(), "2026.8.30+pr129");
} finally {
if (before === undefined) delete process.env.IHASMAIL_VERSION;
else process.env.IHASMAIL_VERSION = before;
}
});
test("a checkout with git resolves to a real version, and an unversioned build looks wrong", () => {
assert.match(versionFromGit() ?? "", /^\d{4}\.\d{1,2}\.\d{1,2}\+(pr\d+|g[0-9a-f]+)$/);
assert.equal(UNVERSIONED, "0.0.0");
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@ihasmail/web",
"version": "2.16.0",
"version": "0.0.0",
"private": true,
"license": "AGPL-3.0-or-later",
"type": "module",
+54
View File
@@ -0,0 +1,54 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { startIdleLogout, stopIdleLogout, IDLE_TIMEOUT_MS } from "@/lib/idleLogout";
describe("idle sign-out on an untrusted device", () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => {
stopIdleLogout();
vi.useRealTimers();
});
it("signs out after five minutes of nothing happening", () => {
const expire = vi.fn();
startIdleLogout(expire);
expect(IDLE_TIMEOUT_MS).toBe(5 * 60 * 1000);
vi.advanceTimersByTime(IDLE_TIMEOUT_MS - 1);
expect(expire).not.toHaveBeenCalled();
vi.advanceTimersByTime(1);
expect(expire).toHaveBeenCalledTimes(1);
});
it("starts the clock again on any sign of a person", () => {
const expire = vi.fn();
startIdleLogout(expire);
vi.advanceTimersByTime(IDLE_TIMEOUT_MS - 1000);
window.dispatchEvent(new Event("keydown"));
vi.advanceTimersByTime(IDLE_TIMEOUT_MS - 1000);
expect(expire).not.toHaveBeenCalled();
vi.advanceTimersByTime(1000);
expect(expire).toHaveBeenCalledTimes(1);
});
it("fires once, not repeatedly, and stops listening afterwards", () => {
const expire = vi.fn();
startIdleLogout(expire);
vi.advanceTimersByTime(IDLE_TIMEOUT_MS * 3);
expect(expire).toHaveBeenCalledTimes(1);
// A late event must not resurrect a timer for a session that has ended.
window.dispatchEvent(new Event("keydown"));
vi.advanceTimersByTime(IDLE_TIMEOUT_MS * 2);
expect(expire).toHaveBeenCalledTimes(1);
});
it("stops cleanly, so a trusted sign-in is never signed out", () => {
const expire = vi.fn();
startIdleLogout(expire);
stopIdleLogout();
vi.advanceTimersByTime(IDLE_TIMEOUT_MS * 2);
expect(expire).not.toHaveBeenCalled();
});
});
+105
View File
@@ -0,0 +1,105 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import {
accountKey,
clearAllData,
clearSignedInData,
isDeviceTrusted,
loadJson,
loadRaw,
saveJson,
setDeviceTrusted,
} from "@/lib/storage";
/**
* The gate is a privacy boundary rather than a convenience, so it is tested
* from both sides: that a trusted device still works exactly as it did, and
* that an untrusted one leaves nothing to find.
*/
describe("device-trusted storage", () => {
let store: Map<string, string>;
beforeEach(() => {
store = new Map();
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: {
get length() {
return store.size;
},
key: (i: number) => [...store.keys()][i] ?? null,
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => void store.set(k, v),
removeItem: (k: string) => void store.delete(k),
},
});
setDeviceTrusted(false);
});
afterEach(() => {
setDeviceTrusted(false);
Reflect.deleteProperty(globalThis, "localStorage");
});
it("writes nothing at all when the device is not trusted", () => {
saveJson("settings", { theme: "dark" });
saveJson(accountKey("acct1", "recent"), [{ email: "[email protected]" }]);
expect([...store.keys()].filter((k) => k !== "ihasmail:deviceTrusted")).toEqual([]);
});
it("does not read residue left by an earlier trusted session", () => {
setDeviceTrusted(true);
saveJson(accountKey("acct1", "recent"), [{ email: "[email protected]" }]);
setDeviceTrusted(false);
// The bytes are still on disk until a purge; the gate must not serve them.
expect(loadRaw(accountKey("acct1", "recent"), [])).toEqual([]);
});
it("round-trips normally on a trusted device", () => {
setDeviceTrusted(true);
saveJson("settings", { theme: "dark" });
expect(loadJson("settings", { theme: "light", accent: "blue" })).toEqual({ theme: "dark", accent: "blue" });
expect(isDeviceTrusted()).toBe(true);
});
it("remembers trust across a reload, so a trusted device still paints from cache", () => {
setDeviceTrusted(true);
expect(store.get("ihasmail:deviceTrusted")).toBe("1");
setDeviceTrusted(false);
expect(store.has("ihasmail:deviceTrusted")).toBe(false);
});
it("clears the account's data on sign-out but keeps the deliberate exceptions", () => {
setDeviceTrusted(true);
saveJson("settings", { theme: "dark" });
saveJson("mbx-expanded", { a: true });
saveJson(accountKey("acct1", "recent"), [{ email: "[email protected]" }]);
store.set("ihasmail:lastUser", "[email protected]");
store.set("ihasmail:pushDeviceId", "ihasmail-abc");
clearSignedInData();
expect(store.has("ihasmail:settings")).toBe(false);
expect(store.has("ihasmail:mbx-expanded")).toBe(false);
expect(store.has("ihasmail:acct1:recent")).toBe(false);
// Kept on purpose: prefills sign-in, and only a trusted device wrote it.
expect(store.get("ihasmail:lastUser")).toBe("[email protected]");
expect(store.get("ihasmail:pushDeviceId")).toBe("ihasmail-abc");
});
it("clears everything, lastUser included, for an untrusted sign-in", () => {
setDeviceTrusted(true);
saveJson("settings", { theme: "dark" });
store.set("ihasmail:lastUser", "[email protected]");
clearAllData();
expect([...store.keys()]).toEqual([]);
});
it("leaves keys belonging to anything else alone", () => {
setDeviceTrusted(true);
store.set("someone-elses-key", "keep me");
clearAllData();
expect(store.get("someone-elses-key")).toBe("keep me");
});
});
+6 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { DEFAULT_SETTINGS, DEVICE_KEYS, acceptRemote, isDarkTheme, syncedPart, toggleTarget, useSettings, type Theme } from "@/store/settings";
import { loadJson, saveJson } from "@/lib/storage";
import { loadJson, saveJson, setDeviceTrusted } from "@/lib/storage";
/**
* "ihasmail" is a dark theme wearing ihasmail.org's palette. Everything that
@@ -60,9 +60,14 @@ describe("the default theme", () => {
removeItem: (k: string) => void store.delete(k),
},
});
// Reads and writes are gated on device trust now, and the gate defaults to
// closed. These tests are about `loadJson`'s merge, so open it and put it
// back -- an untrusted device is covered by storage.test.ts instead.
setDeviceTrusted(true);
try {
fn();
} finally {
setDeviceTrusted(false);
Reflect.deleteProperty(globalThis, "localStorage");
}
};
+59
View File
@@ -0,0 +1,59 @@
/**
* Sign out an untrusted device after a few minutes of inactivity.
*
* This exists because the alternative does not work. Asking someone to
* remember to sign out relies on the person, which is the part you cannot rely
* on when the machine is not theirs — and a browser cannot help: custom
* `beforeunload` text was removed years ago, and no event fires at all for the
* case that actually matters, which is walking away from a signed-in screen.
*
* A timer needs nobody's cooperation, so that is what this is.
*
* Trusted devices are left alone entirely: the whole point of saying a machine
* is yours is not being signed out of it.
*/
const IDLE_MS = 5 * 60 * 1000;
/** Coarse enough not to fire constantly, broad enough to catch a person reading. */
const ACTIVITY = ["mousedown", "keydown", "touchstart", "scroll", "focus"] as const;
let timer: ReturnType<typeof setTimeout> | null = null;
let onExpire: (() => void) | null = null;
function arm(): void {
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
timer = null;
const fn = onExpire;
stopIdleLogout();
fn?.();
}, IDLE_MS);
}
/**
* Reading a long message is not idleness, but it produces no events either.
* Visibility is the honest signal available: a hidden tab is one nobody is
* looking at, so the clock keeps running; showing it again is activity.
*/
function onVisibility(): void {
if (document.visibilityState === "visible") arm();
}
export function startIdleLogout(expire: () => void): void {
stopIdleLogout();
onExpire = expire;
for (const ev of ACTIVITY) window.addEventListener(ev, arm, { passive: true, capture: true });
document.addEventListener("visibilitychange", onVisibility);
arm();
}
export function stopIdleLogout(): void {
if (timer) clearTimeout(timer);
timer = null;
onExpire = null;
for (const ev of ACTIVITY) window.removeEventListener(ev, arm, { capture: true });
document.removeEventListener("visibilitychange", onVisibility);
}
/** Exported for tests, which should not wait five real minutes. */
export const IDLE_TIMEOUT_MS = IDLE_MS;
+94
View File
@@ -174,6 +174,100 @@ export function rulesToSieve(rules: SieveRule[]): string {
return lines.join("\n");
}
/**
* Why this script must not be rewritten from the rules parsed out of it, or
* null when rewriting it is safe.
*
* Saving replaces the whole script with a fresh serialization of the rules read
* out of it, so whatever was not read is deleted. `sieveToRules` cannot raise
* the alarm by itself: it skips what it does not recognise, so a script cut off
* partway through parses cleanly into a shorter list and looks exactly like one
* that genuinely has fewer rules. That is the shape of the loss in #76 -- a
* truncated download, a plausible parse, and a save that wrote the short
* version back over the real one. The transport fault behind it is fixed in the
* blob proxy; this is the check that makes the save path refuse regardless of
* how the content came to be short.
*
* The tests are structural rather than an equality check against
* `rulesToSieve(sieveToRules(content))`. A script written by an older version
* whose serializer differed in some detail is intact, and refusing to let
* anyone edit their rules over a changed byte would be the worse bug.
*/
export function scriptDamage(content: string): string | null {
// Not one of ours: genuinely empty, or hand-written. Both mean something
// else and are answered elsewhere.
if (!content.includes("# rule:") && !content.includes(SCRIPT_HEADER)) {
// Unless it is one of ours cut off inside its own first line, which reads
// as a very short hand-written script -- and that reading is the one that
// offers to replace it.
const head = content.replace(/\n+$/, "");
if (head !== "" && SCRIPT_HEADER.startsWith(head)) return "breaks off inside its first line";
return null;
}
// Every generated script ends with a newline, so a body that stops mid-line
// stopped early. The rest of the walk covers the cuts that land on one.
if (!content.endsWith("\n")) return "stops in the middle of a line";
const lines = content.replace(/\r\n/g, "\n").split("\n");
let i = 0;
let hadRequire = false;
if (lines[0] === SCRIPT_HEADER) {
i = 1;
hadRequire = lines[i]?.startsWith("require ") ?? false;
if (hadRequire) i++;
if (lines[i] !== "") return "breaks off in its opening lines";
i++;
} else {
// Header edited away but the rule comments kept. Still ours to walk.
i = lines.findIndex((l) => l.startsWith("# rule:"));
}
// Walk the shape rulesToSieve emits, one rule block at a time. Deliberately
// structural: the condition and action lines are read only for their
// presence, so a serializer that words them differently is still intact.
let seen = 0;
const cut = (r: SieveRule, what: string) => `has a rule in it (“${r.name}”) ${what}`;
while (i < lines.length) {
const line = lines[i]!;
if (!line.startsWith("# rule:")) return "has a stray line where a rule should start";
let rule: SieveRule | null = null;
try {
const parsed = JSON.parse(line.slice(7)) as SieveRule;
if (parsed && typeof parsed === "object" && Array.isArray(parsed.tests) && Array.isArray(parsed.actions)) rule = parsed;
} catch {
/* reported below */
}
if (!rule) return "has a rule in it that breaks off unfinished";
seen++;
i++;
// `!r.enabled` is how rulesToSieve chooses the branch, so match it exactly
// rather than testing for `=== false`.
if (rule.enabled) {
if (!lines[i]?.startsWith("if ")) return cut(rule, "with nothing below it");
i++;
if (lines[i] !== "{") return cut(rule, "whose body never opens");
i++;
while (i < lines.length && lines[i] !== "}") {
if (lines[i] === "") return cut(rule, "whose body breaks off");
i++;
}
if (i >= lines.length) return cut(rule, "whose body never closes");
i++;
} else {
if (!lines[i]?.startsWith("# (disabled) ")) return cut(rule, "with nothing below it");
i++;
}
// Each block is followed by a blank line, the last one included: it is the
// empty final element left by the trailing newline.
if (lines[i] !== "") return cut(rule, "that runs into what follows it");
i++;
}
// A require line is written only for rules that need it, so one standing over
// no rules at all means the rules it was written for are gone.
if (hadRequire && seen === 0) return "breaks off before the first rule";
return null;
}
/** Returns rules if the script was generated by ihasmail, else null (raw script). */
export function sieveToRules(script: string): SieveRule[] | null {
if (!script.includes("# rule:")) return script.trim() === "" || script.includes(SCRIPT_HEADER) ? [] : null;
+1 -1
View File
@@ -5,4 +5,4 @@
* source. The server says where its own lives, via SOURCE_URL; this is only the
* fallback for when it has not been asked yet, or has nothing to say.
*/
export const DEFAULT_SOURCE_URL = "https://github.com/LINUXexpert-org/ihasmail";
export const DEFAULT_SOURCE_URL = "https://github.com/Coffey-Labs/ihasmail";
+89
View File
@@ -1,6 +1,93 @@
/**
* Local storage, gated on whether this device is trusted.
*
* Everything here is a *cache* or a screen preference — the real copy lives in
* the account's JMAP Files (see `settingsSync`). That makes it safe to write
* nothing at all, which is what an untrusted device does: on a shared or public
* machine the cost of a stale first frame is nothing beside leaving someone's
* address book on it.
*
* Reads are gated as well as writes. A machine that was trusted once still has
* the residue, and honouring it would let a previous session's data surface in
* a later untrusted one.
*/
const PREFIX = "ihasmail:";
/**
* Kept when a session ends. Everything else is cleared, so a key added later
* is forgotten by default rather than by nobody having thought about it.
*
* - `lastUser` is a deliberate convenience: it prefills the sign-in field, and
* it is only ever written by a trusted device in the first place.
* - `deviceTrusted` is how the next boot knows to read at all.
* - `pushDeviceId` is a random id for this browser, so re-subscribing replaces
* rather than accumulates. The subscription itself is removed on sign-out.
*/
const KEEP_ON_SIGN_OUT = ["lastUser", "deviceTrusted", "pushDeviceId"];
const TRUST_KEY = `${PREFIX}deviceTrusted`;
/**
* Read at module load rather than waiting for the session, so a trusted device
* still paints its first frame from cache. An untrusted one has nothing to
* read, so there is nothing to wait for.
*/
let trusted = (() => {
try {
return localStorage.getItem(TRUST_KEY) === "1";
} catch {
return false;
}
})();
export function isDeviceTrusted(): boolean {
return trusted;
}
/** Set from the session's `remember` flag, which is the answer given at sign-in. */
export function setDeviceTrusted(value: boolean): void {
trusted = value;
try {
if (value) localStorage.setItem(TRUST_KEY, "1");
else localStorage.removeItem(TRUST_KEY);
} catch {
/* private mode: the in-memory flag still holds for this tab */
}
}
/** Every `ihasmail:` key currently present, without the prefix. */
function ownKeys(): string[] {
const out: string[] = [];
try {
for (let i = 0; i < localStorage.length; i++) {
const k = localStorage.key(i);
if (k && k.startsWith(PREFIX)) out.push(k.slice(PREFIX.length));
}
} catch {
/* ignore */
}
return out;
}
/**
* Drop what this browser was holding for a signed-in account. Called on every
* sign-out, trusted or not: handing a laptop to someone else is the same
* exposure as a public machine, only quieter.
*/
export function clearSignedInData(): void {
for (const key of ownKeys()) {
if (KEEP_ON_SIGN_OUT.includes(key)) continue;
removeKey(key);
}
}
/** Everything, `lastUser` included — for signing in to a device we do not trust. */
export function clearAllData(): void {
for (const key of ownKeys()) removeKey(key);
}
export function loadJson<T>(key: string, fallback: T): T {
if (!trusted) return fallback;
try {
const raw = localStorage.getItem(PREFIX + key);
if (raw == null) return fallback;
@@ -11,6 +98,7 @@ export function loadJson<T>(key: string, fallback: T): T {
}
export function loadRaw<T>(key: string, fallback: T): T {
if (!trusted) return fallback;
try {
const raw = localStorage.getItem(PREFIX + key);
if (raw == null) return fallback;
@@ -21,6 +109,7 @@ export function loadRaw<T>(key: string, fallback: T): T {
}
export function saveJson(key: string, value: unknown): void {
if (!trusted) return;
try {
localStorage.setItem(PREFIX + key, JSON.stringify(value));
} catch {
+5 -3
View File
@@ -1,6 +1,8 @@
/**
* What this build calls itself: `2.16.57`, or `2.16.57+g1fa6578` for a commit
* that did not come through a pull request. Baked in by Vite; see
* `scripts/version.mjs` for where the parts come from.
* What this build calls itself: `2026.8.30+pr129` -- the date of the commit it
* was built from, and the pull request that commit arrived through. A commit
* that did not come through one carries its short SHA instead,
* `2026.8.30+g1fa6578`. Baked in by Vite; see `scripts/version.mjs` for why the
* parts are what they are.
*/
export const APP_VERSION = __IHASMAIL_VERSION__;
+5
View File
@@ -19,6 +19,7 @@
*/
import { CAP, client } from "@/jmap/client";
import type { GetResponse, Id, SetResponse } from "@/jmap/types";
import { isDeviceTrusted } from "@/lib/storage";
export const VAPID_CAP = "urn:ietf:params:jmap:webpush-vapid";
export const EMAILPUSH_CAP = "urn:ietf:params:jmap:emailpush";
@@ -93,6 +94,10 @@ export function encodeKey(buffer: ArrayBuffer | null): string {
/** A stable id for this browser, so a re-subscribe replaces rather than piles up. */
export function deviceClientId(): string {
const KEY = "ihasmail:pushDeviceId";
// An untrusted device gets a per-session id instead of a stored one. It is
// the same trade private mode already makes below: re-subscribing will not
// reuse it, which costs nothing when push is refused there anyway.
if (!isDeviceTrusted()) return `ihasmail-${crypto.randomUUID()}`;
try {
const existing = localStorage.getItem(KEY);
if (existing) return existing;
+7
View File
@@ -6,6 +6,7 @@
* permission prompt, none of which exists under a test runner.
*/
import { CAP } from "@/jmap/client";
import { isDeviceTrusted } from "@/lib/storage";
import { useSession } from "@/store/session";
import { useMail } from "@/store/mail";
import {
@@ -67,6 +68,12 @@ export async function enableWebPush(): Promise<{ ok: true } | { ok: false; reaso
if (Notification.permission === "denied") {
return { ok: false, reason: "Notifications are blocked for this site in your browser's settings." };
}
// A subscription outlives the tab and belongs to the account, not the
// session -- so on a machine the user has told us is not theirs, it would go
// on delivering their mail to it long after they had gone.
if (!isDeviceTrusted()) {
return { ok: false, reason: "Background notifications need a device you have marked as your own. Sign in again with \u201CThis is my own device\u201D ticked." };
}
const key = applicationServerKey();
if (!key) return { ok: false, reason: "This mail server does not publish a push key." };
+256
View File
@@ -0,0 +1,256 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CAP, client } from "@/jmap/client";
import { CalendarSetError, eventIdForScope, isOccurrence, isThisAndFutureRefusal, occurrencePatch, OccurrenceScopeError, useCalendar } from "@/store/calendar";
import type { CalendarEvent, JmapSession } from "@/jmap/types";
/**
* Through 0.16.19 the server caught a synthetic id for us: `CalendarEvent/set`
* refused one outright, so a mutation aimed at the wrong id of an expanded
* occurrence arrived as a toast rather than as data loss.
*
* 0.16.20 accepts it and writes a `recurrenceOverrides` entry instead — a
* destroy that meant the series removes one date and reports success, under a
* dialog that said "Delete all occurrences?". The resolution therefore lives in
* the store behind a required `scope`, and these tests are what stops it
* drifting back out to the callers.
*/
/** The shape a live 0.16.19 returns for one occurrence of a weekly series. */
const OCCURRENCE: CalendarEvent = {
id: "iaaaaas",
baseEventId: "i",
"@type": "Event",
uid: "u1",
calendarIds: { c1: true },
start: "2026-09-02T09:00:00",
duration: "PT30M",
recurrenceId: "2026-09-02T09:00:00",
participants: {
me: { "@type": "Participant", calendarAddress: "mailto:[email protected]", participationStatus: "needs-action", roles: { attendee: true } },
},
} as unknown as CalendarEvent;
/** A one-off, which an expanded query still hands back with a base of its own. */
const ONE_OFF: CalendarEvent = { ...OCCURRENCE, id: "eaaaaai", baseEventId: "i", recurrenceId: undefined } as unknown as CalendarEvent;
/** A master, fetched by id rather than expanded. */
const MASTER: CalendarEvent = { ...OCCURRENCE, id: "i", baseEventId: undefined, recurrenceId: undefined } as unknown as CalendarEvent;
interface SetCall { update?: Record<string, unknown>; destroy?: string[] }
/**
* A server that renumbers, the way 0.16.20 does.
*
* `resolvesTo` is the id the occurrence answers to *now* — deliberately not the
* id the cached object carries, because that is exactly the situation a write
* to the series leaves behind. A store that sends the id it was handed rather
* than the one it looked up will send `iaaaaas` and these tests will say so.
*/
function server(opts: { resolvesTo?: string | null } = {}) {
const calls: SetCall[] = [];
const resolved = opts.resolvesTo === undefined ? OCCURRENCE.id : opts.resolvesTo;
const fetchMock = vi.fn(async (_url: string, init: RequestInit) => {
const body = JSON.parse(init.body as string) as { methodCalls: [string, Record<string, unknown>, string][] };
const methodResponses = body.methodCalls.map(([name, args, id]) => {
if (name === "CalendarEvent/get" && id === "g") {
// The re-resolution lookup: same recurrenceId, whatever id it wears now.
const list = resolved ? [{ ...OCCURRENCE, id: resolved }] : [];
return [name, { accountId: "a1", state: "1", list, notFound: [] }, id];
}
if (name === "CalendarEvent/set") {
calls.push({ update: args.update as Record<string, unknown>, destroy: args.destroy as string[] });
return [name, {
accountId: "a1", oldState: "1", newState: "2",
updated: Object.fromEntries(Object.keys((args.update ?? {}) as object).map((k) => [k, null])),
destroyed: (args.destroy ?? []) as string[],
notUpdated: {}, notDestroyed: {},
}, id];
}
return [name, { accountId: "a1", state: "1", list: [], notFound: [], ids: [], total: 0, queryState: "q", position: 0, canCalculateChanges: false }, id];
});
return { ok: true, status: 200, json: async () => ({ methodResponses, sessionState: "1" }) } as Response;
});
vi.stubGlobal("fetch", fetchMock);
return calls;
}
beforeEach(() => {
client.session = {
capabilities: { [CAP.core]: { maxObjectsInGet: 500, maxObjectsInSet: 500 }, [CAP.calendars]: {} },
accounts: {},
primaryAccounts: {},
state: "s1",
} as unknown as JmapSession;
useCalendar.setState({
accountId: "a1",
available: true,
calendars: {},
events: { [OCCURRENCE.id]: OCCURRENCE },
ranges: {},
identities: [{ id: "id1", name: "Me", calendarAddress: "mailto:[email protected]", sendTo: {}, isDefault: true }],
});
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe("eventIdForScope", () => {
it("walks an occurrence up to its master for the series", () => {
expect(eventIdForScope(OCCURRENCE, "series")).toBe("i");
});
it("sends the instance as it came for a single occurrence", () => {
expect(eventIdForScope(OCCURRENCE, "occurrence")).toBe("iaaaaas");
});
it("resolves a master to itself under either scope", () => {
expect(eventIdForScope(MASTER, "series")).toBe("i");
expect(eventIdForScope(MASTER, "occurrence")).toBe("i");
});
it("treats a one-off's synthetic id as a series id, because its base is real", () => {
// An expanded query gives a one-off an instance id over a different base.
// Stalwart resolves a synthetic id on a component that is neither recurrent
// nor an override back to the base event, so both scopes are safe here —
// but only `series` sends the id that is unambiguously the event.
expect(eventIdForScope(ONE_OFF, "series")).toBe("i");
expect(isOccurrence(ONE_OFF)).toBe(true);
});
it("does not call a master an occurrence", () => {
expect(isOccurrence(MASTER)).toBe(false);
expect(isOccurrence({ ...MASTER, baseEventId: "i" } as CalendarEvent)).toBe(false);
});
});
describe("destroyEvent", () => {
it("sends the master id for a series, never the synthetic one", async () => {
const calls = server();
await useCalendar.getState().destroyEvent(OCCURRENCE, false, "series");
expect(calls[0]!.destroy).toEqual(["i"]);
expect(calls[0]!.destroy).not.toContain("iaaaaas");
});
it("sends the id the occurrence answers to now, not the one it was handed", async () => {
// The live finding: writing one override renumbers the series, so an id
// cached a moment ago addresses a different date. `recurrenceId` is the
// stable handle, so the store looks the current id up by it.
const calls = server({ resolvesTo: "renumbered7" });
await useCalendar.getState().destroyEvent(OCCURRENCE, false, "occurrence");
expect(calls[0]!.destroy).toEqual(["renumbered7"]);
expect(calls[0]!.destroy).not.toContain("iaaaaas");
});
it("refuses rather than guessing when the date is no longer in the series", async () => {
const calls = server({ resolvesTo: null });
await expect(useCalendar.getState().destroyEvent(OCCURRENCE, false, "occurrence"))
.rejects.toThrow(/no longer part of this series/i);
expect(calls).toEqual([]);
});
it("drops the occurrence from the cache without evicting the master", async () => {
server();
useCalendar.setState({ events: { i: MASTER, iaaaaas: OCCURRENCE } });
await useCalendar.getState().destroyEvent(OCCURRENCE, false, "occurrence");
expect(useCalendar.getState().events.iaaaaas).toBeUndefined();
expect(useCalendar.getState().events.i).toBeDefined();
});
});
describe("updateEvent", () => {
it("patches the master for a series", async () => {
const calls = server();
await useCalendar.getState().updateEvent(OCCURRENCE, { color: "#f00" }, false, "series");
expect(Object.keys(calls[0]!.update!)).toEqual(["i"]);
});
it("patches the id the occurrence answers to now", async () => {
const calls = server({ resolvesTo: "renumbered7" });
await useCalendar.getState().updateEvent(OCCURRENCE, { color: "#f00" }, false, "occurrence");
expect(Object.keys(calls[0]!.update!)).toEqual(["renumbered7"]);
});
});
describe("rsvp", () => {
it("answers for the series even when handed an occurrence", async () => {
// The patch itself survives either scope: `participationStatus` is one of
// the pointers 0.16.20 allows on an occurrence, so an RSVP aimed at an
// instance would quietly mean "only that day" and nothing would say so.
const calls = server();
await useCalendar.getState().rsvp(OCCURRENCE, "accepted");
expect(Object.keys(calls[0]!.update!)).toEqual(["i"]);
expect(calls[0]!.update!.i).toEqual({ "participants/me/participationStatus": "accepted" });
});
it("refuses when the signed-in identity is not a participant", async () => {
server();
useCalendar.setState({ identities: [{ id: "id2", name: "Someone", calendarAddress: "mailto:[email protected]", sendTo: {}, isDefault: true }] });
await expect(useCalendar.getState().rsvp(OCCURRENCE, "accepted")).rejects.toThrow(/not a participant/i);
});
});
describe("occurrencePatch", () => {
it("lets through what one date will actually take", () => {
const { patch, dropped } = occurrencePatch({ title: "Just today", color: "#f00" });
expect(patch).toEqual({ title: "Just today", color: "#f00" });
expect(dropped).toEqual([]);
});
it("throws on a property the server refuses outright", () => {
// Loud is correct here: moving one occurrence to another calendar is not
// something the user can be quietly given a different answer to.
expect(() => occurrencePatch({ calendarIds: { c2: true } })).toThrow(OccurrenceScopeError);
expect(() => occurrencePatch({ useDefaultAlerts: false })).toThrow(/whole series/i);
});
it("removes an inherited property and reports it, rather than letting it vanish", () => {
// The server would take this patch, drop `privacy`, and answer "updated".
// Anything that believes the response believes the change landed.
const { patch, dropped } = occurrencePatch({ title: "x", privacy: "private", recurrenceRule: null });
expect(patch).toEqual({ title: "x" });
expect(dropped).toEqual(["privacy", "recurrenceRule"]);
});
it("judges a pointer patch on its first token, as the server does", () => {
expect(occurrencePatch({ "participants/me/participationStatus": "accepted" }).patch)
.toEqual({ "participants/me/participationStatus": "accepted" });
expect(occurrencePatch({ "participants/me/calendarAddress": "mailto:x@y" }).dropped)
.toEqual(["participants/me/calendarAddress"]);
});
});
describe("updateEvent, per occurrence", () => {
it("narrows the patch before sending it and reports what it kept back", async () => {
const calls = server();
const dropped = await useCalendar.getState().updateEvent(OCCURRENCE, { title: "Just today", privacy: "private" }, false, "occurrence");
expect(calls[0]!.update).toEqual({ iaaaaas: { title: "Just today" } });
expect(dropped).toEqual(["privacy"]);
});
it("sends nothing at all when a patch is entirely inherited", async () => {
// A request that could only be a no-op is worse than no request: the
// response would say "updated" and mean nothing by it.
const calls = server();
const dropped = await useCalendar.getState().updateEvent(OCCURRENCE, { privacy: "private" }, false, "occurrence");
expect(calls).toEqual([]);
expect(dropped).toEqual(["privacy"]);
});
it("leaves a series patch exactly as the caller wrote it", async () => {
const calls = server();
await useCalendar.getState().updateEvent(OCCURRENCE, { privacy: "private", useDefaultAlerts: false }, false, "series");
expect(calls[0]!.update!.i).toEqual({ privacy: "private", useDefaultAlerts: false });
});
});
describe("isThisAndFutureRefusal", () => {
it("recognises the refusal worth offering the series for", () => {
expect(isThisAndFutureRefusal(new CalendarSetError({
type: "invalidProperties",
description: "Occurrences of a this-and-future change cannot be modified individually.",
}))).toBe(true);
});
it("does not claim an unrelated refusal", () => {
expect(isThisAndFutureRefusal(new CalendarSetError({ type: "forbidden", description: "Nope." }))).toBe(false);
expect(isThisAndFutureRefusal(new Error("Occurrences of a this-and-future change"))).toBe(false);
});
});
@@ -83,3 +83,91 @@ describe("reloading", () => {
expect(useSieve.getState().rules().rules).toHaveLength(3);
});
});
/**
* Issue #76, second round. The transport fault is fixed in the blob proxy, but
* the save path had no answer for a script that arrives *partly* read: it is
* neither unknown nor empty, so the guards above all pass it through. It parses
* into a shorter rule list that looks exactly like a script with fewer rules,
* and saving writes that shorter version back over the real one.
*
* These pin the third state: read, but not all of it.
*/
describe("a script that was only partly read", () => {
/** Cut at 384 bytes, the way a compressing hop cut the reporter's script. */
const truncate = (content: string, at: number) => content.slice(0, at);
const full = rulesToSieve(threeRules);
it("reports its rules as unknown rather than handing back the ones that parsed", () => {
useSieve.setState({ contents: { s1: truncate(full, 384) } });
const { rules, loaded, damage } = useSieve.getState().rules();
expect(rules).toBeNull();
expect(loaded).toBe(true);
expect(damage).toBeTruthy();
});
it("refuses to save over the part it never saw", async () => {
useSieve.setState({ contents: { s1: truncate(full, 384) } });
await expect(useSieve.getState().saveRules([newRule({ name: "New" })])).rejects.toThrow(/overwrite the rest of it/i);
});
it("catches a cut at every offset through the script, not just a lucky one", () => {
// The offsets that cannot be caught are the ends of complete rule blocks:
// each is a valid shorter script and nothing in the bytes says otherwise.
// That is the residual the proxy fix covers and this check cannot.
const safe = new Set<number>();
for (let n = 0; n <= threeRules.length; n++) safe.add(rulesToSieve(threeRules.slice(0, n)).length);
let missed = 0;
for (let at = 1; at < full.length; at++) {
useSieve.setState({ contents: { s1: truncate(full, at) } });
const { damage } = useSieve.getState().rules();
if (!damage && !safe.has(at)) missed++;
}
expect(missed).toBe(0);
});
it("leaves an intact script alone at every length it can legitimately have", () => {
for (let n = 0; n <= threeRules.length; n++) {
useSieve.setState({ contents: { s1: rulesToSieve(threeRules.slice(0, n)) } });
const { rules, damage } = useSieve.getState().rules();
expect(damage).toBeNull();
expect(rules).toHaveLength(n);
}
});
it("leaves the shapes a rule can take alone — disabled, many actions, extensions", () => {
// A false positive here costs someone the use of the rules editor, so the
// walk has to pass everything rulesToSieve can legitimately produce.
const varied = [
newRule({ name: "Disabled", enabled: false }),
newRule({ name: "Many actions", actions: [{ type: "fileinto", mailbox: "A" }, { type: "markread" }, { type: "flag" }, { type: "stop" }] }),
newRule({ name: "Two tests", join: "anyof", tests: [{ type: "body", op: "contains", value: "x" }, { type: "size", op: "over", value: 1024 }] }),
newRule({ name: "No actions at all", actions: [] }),
newRule({ name: "Quotes \" and \\ backslash" }),
];
useSieve.setState({ contents: { s1: rulesToSieve(varied) } });
const { rules, damage } = useSieve.getState().rules();
expect(damage).toBeNull();
expect(rules).toHaveLength(varied.length);
});
it("catches a cut at every offset through that script too", () => {
const varied = [newRule({ name: "Disabled", enabled: false }), newRule({ name: "Live" }), newRule({ name: "Also off", enabled: false })];
const full = rulesToSieve(varied);
const safe = new Set<number>();
for (let n = 0; n <= varied.length; n++) safe.add(rulesToSieve(varied.slice(0, n)).length);
let missed = 0;
for (let at = 1; at < full.length; at++) {
useSieve.setState({ contents: { s1: full.slice(0, at) } });
if (!useSieve.getState().rules().damage && !safe.has(at)) missed++;
}
expect(missed).toBe(0);
});
it("does not call a hand-written script damaged", () => {
useSieve.setState({ contents: { s1: 'require ["fileinto"];\nif header :contains "from" "x" { fileinto "X"; }' } });
const { rules, damage } = useSieve.getState().rules();
expect(damage).toBeNull();
expect(rules).toBeNull(); // hand-written, which is a different refusal
});
});
+203 -14
View File
@@ -46,6 +46,174 @@ export const CALENDAR_PROPS = [
"myRights",
];
/**
* Which of an event's two ids a mutation means.
*
* `CalendarEvent/query` runs with `expandRecurrences`, so an occurrence arrives
* carrying a synthetic `id` of its own *and* a `baseEventId` pointing at the
* master it was expanded from. Sending one where the other was meant is not a
* distinction the server will make for us:
*
* - Through 0.16.19 a synthetic id was refused outright — *"Updating synthetic
* ids is not yet supported"* — so a slip was loud and arrived as a toast.
* - 0.16.20 accepts it, and writes a `recurrenceOverrides` entry instead. A
* destroy that meant the series now removes one date and reports success,
* under a dialog that said "Delete all occurrences?".
*
* So the choice is named and required rather than left to each caller to
* remember a `??`. There is exactly one place that turns an event into an id,
* and it is below.
*/
export type EventScope = "series" | "occurrence";
/**
* The id to send for `scope`.
*
* `series` walks up to the master; `occurrence` sends the instance as it came.
* A one-off is safe either way — it has a synthetic id like everything an
* expanded query returns, and Stalwart resolves a synthetic id on a component
* that is neither recurrent nor an override back to the base event itself.
*/
export function eventIdForScope(event: CalendarEvent, scope: EventScope): Id {
return scope === "series" ? (event.baseEventId ?? event.id) : event.id;
}
/** Whether this object is an expanded occurrence rather than a master. */
export function isOccurrence(event: CalendarEvent): boolean {
return event.baseEventId != null && event.baseEventId !== event.id;
}
/**
* What `CalendarEvent/set` will not take on a single occurrence, and why the
* client has to know rather than letting the server sort it out.
*
* 0.16.20's per-occurrence validator sorts properties into three groups, and
* only one of them is honest about itself:
*
* - **Rejected** — `invalidProperties`, *"This property cannot be modified on a
* single occurrence."* Loud, and fine.
* - **Inherited** — dropped from the patch, and the response still says the
* update succeeded. Nothing anywhere reports it.
* - Everything else, which is applied to the override.
*
* The middle group is the whole problem. It is the same failure as [#26], where
* a participant map addressed the RFC 8984 way was discarded without an error
* and the client showed the guests as saved: a successful response is not
* evidence that anything was written. So a per-occurrence patch is checked here
* before it is sent — rejected properties throw, inherited ones are reported to
* the caller — rather than being posted hopefully and believed.
*
* [#26]: https://github.com/Coffey-Labs/ihasmail/issues/26
*/
const OCCURRENCE_REJECTED = new Set([
"baseEventId", "calendarIds", "isDraft", "isOrigin", "utcStart", "utcEnd",
"useDefaultAlerts", "mayInviteSelf", "mayInviteOthers", "hideAttendees",
]);
/** Applied to the series and never to one date; dropped in silence if sent. */
const OCCURRENCE_INHERITED = new Set([
"@type", "method", "organizerCalendarAddress", "privacy", "prodId",
"recurrenceId", "recurrenceIdTimeZone", "sentBy", "uid",
"recurrenceOverrides", "recurrenceRule", "relatedTo",
]);
/**
* A `notUpdated`/`notDestroyed` entry, kept whole rather than flattened.
*
* Some refusals are worth acting on rather than only showing: 0.16.20 will not
* edit an occurrence that belongs to a this-and-future change, and the useful
* response to that is to offer the series, which needs the reason and not just
* its text.
*/
export class CalendarSetError extends Error {
constructor(readonly setError: { type: string; description?: string; properties?: string[] }) {
super(setErrorMessage(setError));
this.name = "CalendarSetError";
}
}
/** Whether a refusal was "this occurrence belongs to a this-and-future change". */
export function isThisAndFutureRefusal(err: unknown): boolean {
return err instanceof CalendarSetError && /this-and-future/i.test(err.setError.description ?? "");
}
/**
* A synthetic id is only true until the next write, so an occurrence is
* re-resolved from its `recurrenceId` immediately before it is touched.
*
* **Confirmed live on 0.16.20 (2026-08-31.)** Stalwart's synthetic ids encode a
* position in the expanded series, and writing a `recurrenceOverrides` entry
* adds a component that renumbers it. A five-week series held ids `e i m q u`
* over 03-01…03-29; after one override was written to 03-08 the *same ids*
* addressed 03-01, 03-15, 03-29, 03-08, 03-22. Not one of them was rejected —
* `i` simply meant a week later than it had a moment before.
*
* So an id cached across a write silently points at a different date, and a
* delete aimed at one occurrence removes another. `recurrenceId` is the stable
* name for a slot in a series — it is the date itself — so that is what we hold
* and what we look the current id up by.
*/
async function currentOccurrenceId(accountId: Id, event: CalendarEvent): Promise<Id> {
const base = event.baseEventId;
const rid = event.recurrenceId;
// A one-off, or an object with nothing to re-resolve from: its own id is all
// there is, and there is no series for a write to have renumbered.
if (!base || !rid) return event.id;
const around = new Date(rid);
if (Number.isNaN(around.getTime())) return event.id;
const from = new Date(around.getTime() - DAY_MS);
const to = new Date(around.getTime() + DAY_MS);
const res = await client.chain([
["CalendarEvent/query", { accountId, filter: { after: toLocalDateTime(from), before: toLocalDateTime(to) }, expandRecurrences: true, limit: 200 }, "q"],
["CalendarEvent/get", { accountId, "#ids": { resultOf: "q", name: "CalendarEvent/query", path: "/ids" }, properties: ["id", "baseEventId", "recurrenceId"] }, "g"],
]);
const list = (res.get("g")?.[0] as unknown as GetResponse<CalendarEvent> | undefined)?.list ?? [];
const found = list.find((e) => e.baseEventId === base && e.recurrenceId === rid);
if (!found) {
// The date is gone -- already excluded, or the series no longer reaches it.
// Better to say so than to act on an id that means something else now.
throw new Error("That occurrence is no longer part of this series. Reload the calendar and try again.");
}
return found.id;
}
export class OccurrenceScopeError extends Error {
constructor(readonly property: string) {
super(`"${property}" applies to the whole series and cannot be changed for one occurrence.`);
this.name = "OccurrenceScopeError";
}
}
/**
* A patch narrowed to what one occurrence will actually accept.
*
* Throws `OccurrenceScopeError` on a property the server would refuse, and
* returns the inherited ones it removed so a caller can say what it could not
* do for this date alone instead of claiming it did.
*
* Patch *pointers* are judged on their first token, the way the server does:
* `participants/{key}/participationStatus` is allowed, and
* `participants/{key}/calendarAddress` is one of the silent drops.
*/
export function occurrencePatch(patch: Record<string, unknown>): { patch: Record<string, unknown>; dropped: string[] } {
const out: Record<string, unknown> = {};
const dropped: string[] = [];
for (const [key, value] of Object.entries(patch)) {
const [head, , third] = key.split("/");
const root = head ?? key;
if (OCCURRENCE_REJECTED.has(root)) throw new OccurrenceScopeError(root);
if (OCCURRENCE_INHERITED.has(root)) { dropped.push(root); continue; }
if (root === "participants" && third === "calendarAddress") { dropped.push(key); continue; }
// `id` is immutable; the server errors on a value that is not the event's
// own, and ignores one that is. Neither is worth sending.
if (root === "id") { dropped.push(root); continue; }
out[key] = value;
}
return { patch: out, dropped };
}
/** A calendar somebody else shared, and the account it lives in. */
export interface SharedCalendar {
accountId: Id;
@@ -85,9 +253,10 @@ interface CalendarState {
instancesIn(start: Date, end: Date): EventInstance[];
getEvent(id: Id): Promise<CalendarEvent | null>;
createEvent(event: Partial<CalendarEvent>, calendarId: Id, sendInvites: boolean): Promise<Id>;
updateEvent(id: Id, patch: Record<string, unknown>, sendInvites: boolean): Promise<void>;
destroyEvent(id: Id, sendInvites: boolean): Promise<void>;
rsvp(id: Id, status: "accepted" | "tentative" | "declined", comment?: string): Promise<void>;
/** Returns the properties that had to be left to the series, if any. */
updateEvent(event: CalendarEvent, patch: Record<string, unknown>, sendInvites: boolean, scope: EventScope): Promise<string[]>;
destroyEvent(event: CalendarEvent, sendInvites: boolean, scope: EventScope): Promise<void>;
rsvp(event: CalendarEvent, status: "accepted" | "tentative" | "declined", comment?: string): Promise<void>;
createCalendar(data: Partial<Calendar>): Promise<Id>;
updateCalendar(id: Id, patch: Partial<Calendar>): Promise<void>;
destroyCalendar(id: Id): Promise<void>;
@@ -359,39 +528,51 @@ export const useCalendar = create<CalendarState>((set, get) => ({
return res.created!.e!.id;
},
async updateEvent(id, patch, sendInvites) {
async updateEvent(event, patch, sendInvites, scope) {
const accountId = get().accountId!;
const res = await client.call<SetResponse>("CalendarEvent/set", { accountId, update: { [id]: patch }, sendSchedulingMessages: sendInvites });
const id = scope === "occurrence" ? await currentOccurrenceId(accountId, event) : eventIdForScope(event, scope);
// An occurrence takes less than the series does, and says so about only
// half of it. Narrow the patch here rather than posting it hopefully.
const { patch: body, dropped } = scope === "occurrence" ? occurrencePatch(patch) : { patch, dropped: [] as string[] };
if (!Object.keys(body).length) return dropped;
const res = await client.call<SetResponse>("CalendarEvent/set", { accountId, update: { [id]: body }, sendSchedulingMessages: sendInvites });
const err = res.notUpdated?.[id];
if (err) throw new Error(setErrorMessage(err));
if (err) throw new CalendarSetError(err);
get().invalidate();
return dropped;
},
async destroyEvent(id, sendInvites) {
async destroyEvent(event, sendInvites, scope) {
const accountId = get().accountId!;
const id = scope === "occurrence" ? await currentOccurrenceId(accountId, event) : eventIdForScope(event, scope);
const res = await client.call<SetResponse>("CalendarEvent/set", { accountId, destroy: [id], sendSchedulingMessages: sendInvites });
const err = res.notDestroyed?.[id];
if (err) throw new Error(setErrorMessage(err));
if (err) throw new CalendarSetError(err);
set((s) => {
const events = { ...s.events };
// Drop both ids: the one that was sent, and the object as the caller
// held it. An occurrence destroy leaves the master alone on purpose.
delete events[id];
if (scope === "occurrence") delete events[event.id];
return { events };
});
get().invalidate();
},
async rsvp(id, status, comment) {
const ev = get().events[id] ?? (await get().getEvent(id));
if (!ev) throw new Error("Event not found");
id = ev.baseEventId ?? id;
const mine = myParticipantKeys(ev, get().identities);
async rsvp(event, status, comment) {
const mine = myParticipantKeys(event, get().identities);
if (!mine.length) throw new Error("You are not a participant of this event");
const patch: Record<string, unknown> = {};
for (const k of mine) {
patch[`participants/${k}/participationStatus`] = status;
if (comment) patch[`participants/${k}/participationComment`] = comment;
}
await get().updateEvent(id, patch, true);
// Answering for the series, not for one date. The patch itself survives
// either scope -- `participants/{key}/participationStatus` is one of the
// pointers 0.16.20 allows on an occurrence -- so this would silently mean
// "only that day" if it were aimed at an instance. Accepting an invitation
// means accepting the series.
await get().updateEvent(event, patch, true, "series");
},
async createCalendar(data) {
@@ -436,6 +617,14 @@ export const useCalendar = create<CalendarState>((set, get) => ({
return res.list ?? [];
},
/**
* The event with this uid, as a master rather than an occurrence.
*
* The query deliberately omits `expandRecurrences`, so what comes back is the
* stored event and `id` is a real id. Callers rely on that — `InviteCard`
* removes a cancelled event by handing this straight to `destroyEvent` — so
* it is a property of this method, not an accident of the default.
*/
async findByUid(uid) {
const accountId = get().accountId;
if (!accountId) return null;
+3 -2
View File
@@ -1,4 +1,5 @@
import { create } from "zustand";
import { accountKey, loadRaw, saveJson } from "@/lib/storage";
import { CAP, client, setErrorMessage } from "@/jmap/client";
import type { AddressBook, ContactCard, EmailAddress, GetResponse, Id, Principal, QueryResponse, SetResponse } from "@/jmap/types";
import { contactDisplayName, contactEmails, sortKey } from "@/lib/contacts";
@@ -441,7 +442,7 @@ export const useContacts = create<ContactsState>((set, get) => ({
const next = [...addrs.filter((a) => a.email), ...cur.filter((r) => !addrs.some((a) => a.email.toLowerCase() === r.email.toLowerCase()))].slice(0, 200);
set({ recent: next });
try {
localStorage.setItem(`ihasmail:${get().accountId}:recent`, JSON.stringify(next));
saveJson(accountKey(get().accountId, "recent"), next);
} catch {
/* ignore */
}
@@ -466,7 +467,7 @@ useSession.subscribe((s) => {
const accountId = s.accountFor(CAP.contacts);
let recent: EmailAddress[] = [];
try {
recent = JSON.parse(localStorage.getItem(`ihasmail:${accountId}:recent`) ?? "[]") as EmailAddress[];
recent = loadRaw<EmailAddress[]>(accountKey(accountId, "recent"), []);
} catch {
/* ignore */
}
+22
View File
@@ -7,6 +7,8 @@ import { setServerLocale } from "@/lib/datetime";
import { flushSettingsPush, stopSettingsSync } from "@/lib/settingsSync";
import { reloadIfServerRebuilt } from "@/lib/staleBuild";
import { unsubscribeThisDevice } from "@/lib/webpush";
import { clearAllData, clearSignedInData, setDeviceTrusted } from "@/lib/storage";
import { startIdleLogout, stopIdleLogout } from "@/lib/idleLogout";
export type AuthStatus = "loading" | "anonymous" | "authenticated";
@@ -81,6 +83,11 @@ export const useSession = create<SessionState>((set, get) => ({
} catch {
/* ignore */
}
stopIdleLogout();
// Unconditional. The push subscription above is removed for exactly this
// reason -- that a browser left holding someone's mail is somebody else's
// problem next -- and the address book cached here is the same argument.
clearSignedInData();
client.session = null;
set({ status: "anonymous", session: null, accountId: null });
},
@@ -112,6 +119,19 @@ export const useSession = create<SessionState>((set, get) => ({
function applySession(s: JmapSession, set: (p: Partial<SessionState>) => void) {
client.session = s;
setServerLocale(s.ihasmail?.userLocale);
// `remember` is the answer to "is this device yours", given at sign-in and
// carried on the session -- so a reload arrives at the same answer without
// the client storing it, which on an untrusted device it could not do anyway.
const trusted = Boolean(s.ihasmail?.remember);
setDeviceTrusted(trusted);
if (trusted) {
stopIdleLogout();
} else {
// Residue from an earlier trusted session on this machine is exactly what
// an untrusted sign-in is asking us not to keep.
clearAllData();
startIdleLogout(() => void useSession.getState().logout());
}
const accountId = s.primaryAccounts[CAP.mail] ?? Object.keys(s.accounts)[0] ?? null;
set({ status: "authenticated", session: s, accountId, error: null });
}
@@ -119,6 +139,8 @@ function applySession(s: JmapSession, set: (p: Partial<SessionState>) => void) {
client.onUnauthenticated(() => {
push.stop();
stopSettingsSync();
stopIdleLogout();
clearSignedInData();
client.session = null;
// Ask before showing the sign-in form rather than after. A deploy is the
// usual reason to be signed out here, and reloading a form someone has
+21 -6
View File
@@ -1,7 +1,7 @@
import { create } from "zustand";
import { CAP, client, setErrorMessage } from "@/jmap/client";
import type { GetResponse, Id, SetResponse, SieveScript } from "@/jmap/types";
import { rulesToSieve, sieveToRules, type SieveRule } from "@/lib/sieve";
import { rulesToSieve, scriptDamage, sieveToRules, type SieveRule } from "@/lib/sieve";
import { useSession } from "./session";
export const IHASMAIL_SCRIPT = "ihasmail";
@@ -19,7 +19,7 @@ interface SieveState {
getContent(id: Id): Promise<string>;
/** Rules derived from the "ihasmail" script (null = the active script is hand-written). */
/** `loaded` distinguishes "this script is hand-written" from "we could not read it". */
rules(): { script: SieveScript | null; rules: SieveRule[] | null; content: string; loaded: boolean };
rules(): { script: SieveScript | null; rules: SieveRule[] | null; content: string; loaded: boolean; damage: string | null };
saveRules(rules: SieveRule[]): Promise<void>;
saveScript(id: Id | null, name: string, content: string, activate: boolean): Promise<Id>;
activate(id: Id | null): Promise<void>;
@@ -91,14 +91,19 @@ export const useSieve = create<SieveState>((set, get) => ({
rules() {
const { scripts, contents } = get();
const script = scripts.find((s) => s.name === IHASMAIL_SCRIPT) ?? scripts.find((s) => s.isActive) ?? null;
if (!script) return { script: null, rules: [], content: "", loaded: true };
if (!script) return { script: null, rules: [], content: "", loaded: true, damage: null };
const content = contents[script.id];
// Not loaded, or the fetch failed. `null` means "cannot say", which every
// caller already treats as "do not edit this script" -- as opposed to `[]`,
// which means "this script genuinely has no rules" and invites a save that
// would overwrite whatever is really in it.
if (content === undefined) return { script, rules: null, content: "", loaded: false };
return { script, rules: sieveToRules(content), content, loaded: true };
if (content === undefined) return { script, rules: null, content: "", loaded: false, damage: null };
// Read, but not all of it. Showing the rules that did parse would be the
// most dangerous thing available: a short list that looks complete, over a
// script that is not. Say "cannot say" here too.
const damage = scriptDamage(content);
if (damage) return { script, rules: null, content, loaded: true, damage };
return { script, rules: sieveToRules(content), content, loaded: true, damage: null };
},
async saveRules(rules) {
@@ -106,9 +111,19 @@ export const useSieve = create<SieveState>((set, get) => ({
// The last line of defence. Writing rules replaces the whole script, so
// doing it from a baseline we never managed to read deletes whatever was
// there. Refusing is recoverable; overwriting is not.
if (existing && get().contents[existing.id] === undefined) {
if (existing) {
const content = get().contents[existing.id];
if (content === undefined) {
throw new Error("Your filter script could not be read, so saving would overwrite it. Reload and try again.");
}
// Read in full is a separate question from read at all, and the answer
// that cost rules in #76 was "partly". A baseline missing its tail writes
// out just as confidently as one missing entirely.
const damage = scriptDamage(content);
if (damage) {
throw new Error(`Your filter script ${damage}, so saving would overwrite the rest of it. Reload and try again.`);
}
}
await get().saveScript(existing?.id ?? null, IHASMAIL_SCRIPT, rulesToSieve(rules), true);
},
+5
View File
@@ -310,6 +310,11 @@ a.menu-item:hover { color: var(--fg); }
.dialog-body { padding: 8px 20px 16px; overflow: auto; }
.dialog-foot { display: flex; align-items: center; justify-content: flex-end; gap: 8px; padding: 12px 20px 16px; border-top: 1px solid var(--border); }
.dialog-foot .left { margin-right: auto; }
/* "This occurrence or the whole series" — one button per answer, stacked, so
the destructive one is read rather than landed on by muscle memory. */
.dialog-choices { display: flex; flex-direction: column; gap: 8px; }
.dialog-choice { display: flex; flex-direction: column; align-items: flex-start; gap: 2px; width: 100%; text-align: left; padding: 10px 12px; height: auto; }
.dialog-choice small { font-weight: 400; opacity: 0.75; }
/* Toasts ----------------------------------------------------------------- */
.toast-host { position: fixed; left: 50%; bottom: 24px; transform: translateX(-50%); z-index: 3000; display: flex; flex-direction: column; gap: 8px; align-items: center; pointer-events: none; padding: 0 12px; width: 100%; max-width: 520px; }
+39 -2
View File
@@ -86,9 +86,17 @@ export function Dialog({ open, onClose, title, children, footer, size = "md", cl
/* ---------- Imperative confirm / prompt ---------- */
export interface DialogChoice {
value: string;
label: string;
/** Shown under the label, for the choice that needs the caveat. */
hint?: string;
danger?: boolean;
}
interface ConfirmRequest {
id: number;
kind: "confirm" | "prompt";
kind: "confirm" | "prompt" | "choice";
title: string;
message?: ReactNode;
confirmLabel?: string;
@@ -96,6 +104,7 @@ interface ConfirmRequest {
danger?: boolean;
defaultValue?: string;
placeholder?: string;
choices?: DialogChoice[];
resolve: (v: boolean | string | null) => void;
}
@@ -119,6 +128,18 @@ export function promptDialog(opts: { title: string; message?: ReactNode; default
});
}
/**
* A question with more than two answers, which "this one or all of them" is.
*
* Resolves to the chosen `value`, or `null` if the dialog is dismissed —
* dismissing is not one of the choices, so a caller cannot mistake it for one.
*/
export function choiceDialog(opts: { title: string; message?: ReactNode; choices: DialogChoice[]; cancelLabel?: string }): Promise<string | null> {
return new Promise((resolve) => {
useConfirmStore.getState().push({ id: reqId++, kind: "choice", ...opts, resolve: (v) => resolve(typeof v === "string" ? v : null) });
});
}
export function ConfirmHost() {
const req = useConfirmStore((s) => s.queue[0]);
const pop = useConfirmStore((s) => s.pop);
@@ -132,10 +153,15 @@ export function ConfirmHost() {
return (
<Dialog
open
onClose={() => done(req.kind === "prompt" ? null : false)}
onClose={() => done(req.kind === "confirm" ? false : null)}
title={req.title}
size="sm"
footer={
req.kind === "choice" ? (
<button className="btn" onClick={() => done(null)}>
{req.cancelLabel ?? "Cancel"}
</button>
) : (
<>
<button className="btn" onClick={() => done(req.kind === "prompt" ? null : false)}>
{req.cancelLabel ?? "Cancel"}
@@ -144,9 +170,20 @@ export function ConfirmHost() {
{req.confirmLabel ?? (req.kind === "prompt" ? "OK" : "Confirm")}
</button>
</>
)
}
>
{req.message && <p style={{ marginTop: 0 }}>{req.message}</p>}
{req.kind === "choice" && (
<div className="dialog-choices">
{req.choices?.map((c) => (
<button key={c.value} className={`btn dialog-choice ${c.danger ? "btn-danger" : ""}`} onClick={() => done(c.value)}>
<span>{c.label}</span>
{c.hint && <small>{c.hint}</small>}
</button>
))}
</div>
)}
{req.kind === "prompt" && (
<form
onSubmit={(e) => {
+5 -1
View File
@@ -1,6 +1,6 @@
import { useEffect, useState, type ReactNode } from "react";
import { Link, useLocation } from "wouter";
import { BookOpen, Calendar, ChevronsUpDown, FolderOpen, HelpCircle, LogOut, Mail, Menu as MenuIcon, Moon, PenSquare, Plus, RefreshCw, Settings, Sun, Upload, Users } from "lucide-react";
import { BookOpen, Calendar, ChevronsUpDown, FolderOpen, Globe, HelpCircle, LogOut, Mail, Menu as MenuIcon, Moon, PenSquare, Plus, RefreshCw, Settings, Sun, Upload, Users } from "lucide-react";
import { useSession } from "@/store/session";
import { toggleTarget, useEffectiveTheme, useSettings } from "@/store/settings";
import { useMail } from "@/store/mail";
@@ -102,6 +102,10 @@ export function AppShell({ children }: { children: ReactNode }) {
</div>
<MenuSep />
<MenuItem icon={<BookOpen size={16} />} label="Documentation" href="https://docs.ihasmail.org" external />
{/* The project site. It is linked from the login screen footer, which
is a page a signed-in user never sees again -- so from inside the
app there was no way back to it. */}
<MenuItem icon={<Globe size={16} />} label="About ihasmail" href="https://ihasmail.org" external />
<MenuItem icon={<Settings size={16} />} label="Settings" onClick={() => navigate("/settings")} />
<MenuItem icon={<RefreshCw size={16} />} label="Refresh" onClick={() => window.location.reload()} />
<MenuItem icon={<LogOut size={16} />} label="Sign out" onClick={() => void logout()} />
+11 -6
View File
@@ -22,7 +22,7 @@ export function LoginPage() {
const [username, setUsername] = useState(() => localStorage.getItem("ihasmail:lastUser") ?? "");
const [password, setPassword] = useState("");
const [showPw, setShowPw] = useState(false);
const [remember, setRemember] = useState(true);
const [trustDevice, setTrustDevice] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -34,8 +34,8 @@ export function LoginPage() {
try {
// No two-factor code: the field is not on this form until the flow works
// end to end, and the server treats an absent code as none given.
await login(username.trim(), password, "", remember);
localStorage.setItem("ihasmail:lastUser", username.trim());
await login(username.trim(), password, "", trustDevice);
if (trustDevice) localStorage.setItem("ihasmail:lastUser", username.trim());
} catch (err) {
if (err instanceof ApiError) {
if (err.code === "invalid_credentials") {
@@ -74,10 +74,15 @@ export function LoginPage() {
</button>
</div>
</div>
<label className="check" style={{ marginBottom: 12 }}>
<input type="checkbox" checked={remember} onChange={(e) => setRemember(e.target.checked)} />
<span>Keep me signed in on this device</span>
<label className="check" style={{ marginBottom: 4 }}>
<input type="checkbox" checked={trustDevice} onChange={(e) => setTrustDevice(e.target.checked)} />
<span>This is my own device</span>
</label>
<p className="hint" style={{ marginBottom: 12 }}>
{trustDevice
? "Stay signed in, and keep settings and recent addresses on this computer."
: "Signed out after 5 minutes of inactivity, and nothing is kept on this computer. Leave this unticked on a shared or public one."}
</p>
<button className="btn btn-primary btn-lg btn-block" type="submit" disabled={busy}>
{busy ? <span className="spinner" style={{ borderTopColor: "#fff" }} /> : <LogIn size={18} />}
{busy ? "Signing in…" : "Sign in"}
+29 -18
View File
@@ -1,13 +1,13 @@
import { Calendar as CalIcon, CalendarDays, Copy, ExternalLink, Palette, Pencil, Plus, Tag, Trash2, X } from "lucide-react";
import { useLocation } from "wouter";
import type { CalendarEvent } from "@/jmap/types";
import { useCalendar, isRecurring, type EventInstance } from "@/store/calendar";
import { useCalendar, isRecurring, isOccurrence, type EventInstance, type EventScope } from "@/store/calendar";
import { useSettings } from "@/store/settings";
import { formatDayMonth } from "@/lib/datetime";
import { MenuItem, MenuSep, MenuTitle, Popover, type Anchor } from "@/ui/popover";
import { CALENDAR_COLORS } from "@/ui/misc";
import { confirmDialog } from "@/ui/dialog";
import { toast } from "@/ui/toast";
import { askDeleteScope, askEditScope, droppedMessage, runScoped } from "./scope";
import { toLocalDateOnly } from "@/lib/dates";
import { formatTime } from "@/lib/format";
@@ -60,20 +60,23 @@ export function CalendarContextMenu({ ctx, onClose, onOpen, onEdit, onCreate }:
const { inst } = ctx;
const ev = inst.event;
const baseId = ev.baseEventId ?? ev.id;
const canEdit = inst.calendar?.myRights.mayWriteAll || inst.calendar?.myRights.mayWriteOwn || !inst.calendar;
const currentCat = categoryOf(ev, categories);
const participants = Object.keys(ev.participants ?? {}).length;
const patch = async (p: Record<string, unknown>, msg: string) => {
const scope = await askEditScope(ev);
if (!scope) return;
try {
await cal.updateEvent(baseId, p, false);
toast.success(msg);
const dropped = await runScoped(scope, (s) => cal.updateEvent(ev, p, false, s));
if (!dropped) return;
// A per-occurrence change can be accepted in part. Say which part.
toast.success(droppedMessage(dropped) ?? (scope === "occurrence" ? `${msg} for this date` : msg));
} catch (err) {
toast.error((err as Error).message);
}
};
const setColor = (color: string | null) => void patch({ color }, color ? "Colour updated" : "Colour reset");
const setColor = (color: string | null) => void patch({ color }, color ? "Colour updated" : "Custom colour removed");
const setCategory = (cat: { name: string; color: string } | null) => {
const categoriesPatch = cat ? { [cat.name]: true } : null;
void patch({ categories: categoriesPatch, color: cat ? cat.color : null }, cat ? `Categorised as ${cat.name}` : "Category cleared");
@@ -89,11 +92,16 @@ export function CalendarContextMenu({ ctx, onClose, onOpen, onEdit, onCreate }:
};
const del = async () => {
onClose();
const recurring = isRecurring(ev);
if (!(await confirmDialog({ title: recurring ? "Delete all occurrences?" : "Delete this event?", confirmLabel: "Delete", danger: true }))) return;
let scope: EventScope | null = "series";
if (isRecurring(ev) && isOccurrence(ev)) {
scope = await askDeleteScope(ev);
} else if (!(await confirmDialog({ title: "Delete this event?", confirmLabel: "Delete", danger: true }))) {
scope = null;
}
if (!scope) return;
try {
await cal.destroyEvent(baseId, participants > 1);
toast.success("Event deleted");
await runScoped(scope, (s) => cal.destroyEvent(ev, participants > 1, s));
toast.success(scope === "occurrence" ? "Occurrence deleted" : "Event deleted");
} catch (err) {
toast.error((err as Error).message);
}
@@ -113,14 +121,17 @@ export function CalendarContextMenu({ ctx, onClose, onOpen, onEdit, onCreate }:
))}
<MenuItem icon={<X size={16} />} label="No category" disabled={!currentCat} onClick={() => { onClose(); setCategory(null); }} />
<MenuItem icon={<Tag size={16} />} label="Manage categories…" onClick={() => { onClose(); navigate("/settings/calendar"); }} />
<MenuSep />
<MenuTitle><span className="row gap-4"><Palette size={12} /> Colour</span></MenuTitle>
<div className="color-grid" style={{ gridTemplateColumns: "repeat(6, 26px)", padding: "4px 10px 8px" }}>
{CALENDAR_COLORS.map((c) => (
<button key={c} type="button" style={{ background: c, width: 26, height: 26, outline: ev.color?.toLowerCase() === c ? "2px solid var(--fg)" : undefined, outlineOffset: 1 }} aria-label={c} onClick={() => { onClose(); setColor(c); }} />
))}
</div>
{ev.color && <MenuItem icon={<X size={16} />} label="Use calendar colour" onClick={() => { onClose(); setColor(null); }} />}
{/*
A colour is what a category already carries, so a second way to set
one just made two things that could disagree. Picking a category is
now the only way to colour an event here.
Clearing one stays, though, and only when there is one to clear: an
event that already has an explicit colour — set before this, or by
another client — would otherwise ignore its category for ever with
nothing on the menu to say why.
*/}
{ev.color && <MenuItem icon={<Palette size={16} />} label="Clear custom colour" onClick={() => { onClose(); setColor(null); }} />}
<MenuSep />
<MenuItem danger icon={<Trash2 size={16} />} label="Delete" onClick={() => void del()} />
</>
+80 -16
View File
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { Plus, Trash2, Users } from "lucide-react";
import type { BusyPeriod, CalendarEvent, EmailAddress, JSCalendarAlert, JSCalendarParticipant, JSCalendarRecurrenceRule, JSCalendarNDay } from "@/jmap/types";
import { useCalendar, myParticipantKeys, isRecurring, eventRule, makeParticipant, participantEmail } from "@/store/calendar";
import { useCalendar, myParticipantKeys, isRecurring, isOccurrence, eventRule, makeParticipant, participantEmail, type EventScope } from "@/store/calendar";
import { useSettings } from "@/store/settings";
import { useSession } from "@/store/session";
import { useContacts } from "@/store/contacts";
@@ -14,6 +14,7 @@ import { browserTimeZone, dateToZonedLocal, formatDuration, fromInputDateTime, l
import { formatClock, formatNumericDate, formatWeekday } from "@/lib/datetime";
import { WEEKDAYS, describeRule, presetFor, ruleFromPreset, type RecurrencePreset } from "@/lib/recurrence";
import { newKey } from "@/lib/contacts";
import { askEditScope, droppedMessage, runScoped } from "./scope";
export interface EditorInit {
event?: CalendarEvent;
@@ -24,25 +25,68 @@ export interface EditorInit {
const ALERT_OPTIONS = [0, 5, 10, 15, 30, 60, 120, 1440, 2880, 10080];
/**
* Fields this form always sends that a single occurrence will not take.
*
* `useDefaultAlerts` and `calendarIds` are refused with `invalidProperties`;
* the rest are dropped from the patch while the response still reports
* success. Both halves are reasons not to send them — the second more so,
* because nothing would say it had happened.
*/
const OCCURRENCE_OMIT = new Set(["useDefaultAlerts", "calendarIds", "recurrenceRule", "privacy", "organizerCalendarAddress"]);
export function EventEditor({ init, onClose }: { init: EditorInit; onClose: () => void }) {
const cal = useCalendar();
const settings = useSettings((s) => s.settings);
const session = useSession((s) => s.session);
const [base, setBase] = useState<CalendarEvent | null | undefined>(init.event && !init.event.baseEventId ? init.event : undefined);
const [scope, setScope] = useState<EventScope | undefined>(init.event?.baseEventId ? undefined : "series");
const editing = Boolean(init.event);
// Load base event for recurring instances
/*
* Which event this form is even about has to be settled before it opens.
*
* A form populated from the master shows the series' start date, so editing
* Wednesday's standup would offer to move Monday's — right for the series and
* wrong for one date. So the scope is asked first, and the occurrence itself
* is what the form loads when the answer is "this occurrence".
*/
/*
* Asked once per event, and deliberately not tied to the effect's lifetime.
*
* Two things make the obvious version wrong. A dialog is queued in a store
* the moment it is requested, so it outlives the effect that asked for it: a
* re-run queues a second prompt the first answer cannot retract, and the
* reader is asked the same question twice. And gating the *answer* on a
* cleanup flag is worse — React's StrictMode runs mount, cleanup, mount, so
* the flag is already set by the time anyone clicks and the editor never
* opens at all. The ref is what makes this once; the answer is applied
* whenever it arrives.
*/
const asked = useRef<string | null>(null);
useEffect(() => {
if (init.event?.baseEventId) void cal.getEvent(init.event.baseEventId).then((e) => setBase(e));
else if (!init.event) setBase(null);
const ev = init.event;
if (!ev) { setBase(null); setScope("series"); return; }
if (!ev.baseEventId) { setBase(ev); setScope("series"); return; }
if (asked.current === ev.id) return;
asked.current = ev.id;
void (async () => {
const chosen = isRecurring(ev) && isOccurrence(ev) ? await askEditScope(ev) : "series";
if (!chosen) { onClose(); return; }
setScope(chosen);
if (chosen === "occurrence") setBase(ev);
else void cal.getEvent(ev.baseEventId!).then(setBase);
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [init.event?.id]);
if (base === undefined) return null;
return <EventForm key={base?.id ?? "new"} init={init} base={base} editing={editing} onClose={onClose} settingsTz={settings.timeZone ?? browserTimeZone} defaultAlert={settings.defaultAlertMinutes} myEmail={session?.username ?? ""} />;
if (base === undefined || scope === undefined) return null;
return <EventForm key={base?.id ?? "new"} init={init} base={base} scope={scope} editing={editing} onClose={onClose} settingsTz={settings.timeZone ?? browserTimeZone} defaultAlert={settings.defaultAlertMinutes} myEmail={session?.username ?? ""} />;
}
function EventForm({ init, base, editing, onClose, settingsTz, defaultAlert, myEmail }: { init: EditorInit; base: CalendarEvent | null; editing: boolean; onClose: () => void; settingsTz: string; defaultAlert: number; myEmail: string }) {
function EventForm({ init, base, scope, editing, onClose, settingsTz, defaultAlert, myEmail }: { init: EditorInit; base: CalendarEvent | null; scope: EventScope; editing: boolean; onClose: () => void; settingsTz: string; defaultAlert: number; myEmail: string }) {
/** This form is editing one date rather than the series behind it. */
const oneDate = scope === "occurrence";
const cal = useCalendar();
const contacts = useContacts();
const ev = base;
@@ -175,11 +219,23 @@ function EventForm({ init, base, editing, onClose, settingsTz, defaultAlert, myE
};
const invites = sendInvites && attendees.length > 0;
if (ev) {
/*
* A single occurrence takes less than the series does. Four of the
* fields this form always sends are among them — `useDefaultAlerts`
* and `calendarIds` are refused outright, `recurrenceRule`,
* `privacy` and `organizerCalendarAddress` are dropped in silence —
* so they are left out here rather than sent and believed. The store
* still checks; this is what stops it having to complain.
*/
const source = oneDate
? Object.fromEntries(Object.entries(obj).filter(([k]) => !OCCURRENCE_OMIT.has(k)))
: obj;
const patch: Record<string, unknown> = {};
for (const [k, v] of Object.entries(obj)) patch[k] = v === undefined ? null : v;
if (Object.keys(ev.calendarIds)[0] !== calendarId) patch.calendarIds = { [calendarId]: true };
await cal.updateEvent(ev.id, patch, invites);
toast.success("Event updated");
for (const [k, v] of Object.entries(source)) patch[k] = v === undefined ? null : v;
if (!oneDate && Object.keys(ev.calendarIds)[0] !== calendarId) patch.calendarIds = { [calendarId]: true };
const dropped = await runScoped(scope, (s) => cal.updateEvent(ev, patch, invites, s));
if (!dropped) { setBusy(false); return; }
toast.success(droppedMessage(dropped) ?? (oneDate ? "This occurrence updated" : "Event updated"));
} else {
const clean: Record<string, unknown> = {};
for (const [k, v] of Object.entries(obj)) if (v !== undefined) clean[k] = v;
@@ -204,7 +260,13 @@ function EventForm({ init, base, editing, onClose, settingsTz, defaultAlert, myE
return (
<Dialog open onClose={onClose} title={editing ? "Edit event" : "New event"} size="lg" footer={<><button className="btn" onClick={onClose}>Cancel</button><button className="btn btn-primary" disabled={busy} onClick={() => void save()}>{busy ? "Saving…" : editing ? "Save" : attendees.length && sendInvites ? "Send invites" : "Create"}</button></>}>
<div className="event-form">
{ev && isRecurring(ev) && <div className="info-box mb-16">This is a recurring event changes apply to the whole series.</div>}
{ev && isRecurring(ev) && (
<div className="info-box mb-16">
{oneDate
? `Editing ${formatNumericDate(start)} only — the rest of the series is unchanged. Repeat, calendar and privacy belong to the series and are not shown.`
: "This is a recurring event — changes apply to the whole series."}
</div>
)}
<div className="field"><input className="input" style={{ fontSize: "1.1em", height: 44 }} placeholder="Add title" autoFocus value={title} onChange={(e) => setTitle(e.target.value)} /></div>
<div className="time-row mb-8">
{allDay ? (
@@ -229,6 +291,7 @@ function EventForm({ init, base, editing, onClose, settingsTz, defaultAlert, myE
{listTimeZones().map((t) => <option key={t} value={t}>{t}</option>)}
</select>
)}
{!oneDate && (
<select className="select" style={{ width: "auto", height: 32 }} value={preset} onChange={(e) => { const p = e.target.value as RecurrencePreset; setPreset(p); if (p === "custom") setRule(rule ?? { "@type": "RecurrenceRule", frequency: "weekly", byDay: [{ "@type": "NDay", day: WEEKDAYS[(start.getDay() + 6) % 7]!.key }] }); else setRule(ruleFromPreset(p, start)); }}>
<option value="none">Does not repeat</option>
<option value="daily">Daily</option>
@@ -238,8 +301,9 @@ function EventForm({ init, base, editing, onClose, settingsTz, defaultAlert, myE
<option value="yearly">Yearly</option>
<option value="custom">Custom</option>
</select>
)}
</div>
{preset === "custom" && (
{!oneDate && preset === "custom" && (
<div className="card" style={{ marginBottom: 12 }}>
<div className="row wrap" style={{ gap: 8 }}>
<span>Repeat every</span>
@@ -269,7 +333,7 @@ function EventForm({ init, base, editing, onClose, settingsTz, defaultAlert, myE
)}
<div className="field-row">
<div className="field"><label>Calendar</label>
<select className="select" value={calendarId} onChange={(e) => setCalendarId(e.target.value)}>
<select className="select" value={calendarId} disabled={oneDate} title={oneDate ? "An occurrence cannot be moved to another calendar on its own" : undefined} onChange={(e) => setCalendarId(e.target.value)}>
{calendars.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</div>
@@ -327,7 +391,7 @@ function EventForm({ init, base, editing, onClose, settingsTz, defaultAlert, myE
<div className="field-row">
<div className="field"><label>Status</label><select className="select" value={status} onChange={(e) => setStatus(e.target.value as typeof status)}><option value="confirmed">Confirmed</option><option value="tentative">Tentative</option><option value="cancelled">Cancelled</option></select></div>
<div className="field"><label>Show as</label><select className="select" value={freeBusy} onChange={(e) => setFreeBusy(e.target.value as typeof freeBusy)}><option value="busy">Busy</option><option value="free">Free</option></select></div>
<div className="field"><label>Visibility</label><select className="select" value={privacy} onChange={(e) => setPrivacy(e.target.value as typeof privacy)}><option value="public">Default</option><option value="private">Private</option><option value="secret">Secret</option></select></div>
{!oneDate && <div className="field"><label>Visibility</label><select className="select" value={privacy} onChange={(e) => setPrivacy(e.target.value as typeof privacy)}><option value="public">Default</option><option value="private">Private</option><option value="secret">Secret</option></select></div>}
</div>
<div className="field"><label>Category</label>
<select className="select" value={category} onChange={(e) => setCategory(e.target.value)}>
+15 -8
View File
@@ -1,9 +1,10 @@
import { useState } from "react";
import { AlignLeft, Bell, Calendar as CalIcon, Check, Clock, HelpCircle, Link2, MapPin, Pencil, Repeat, Trash2, Users, X, Mail } from "lucide-react";
import { useCalendar, myParticipantKeys, isRecurring, eventRule, participantEmail, type EventInstance } from "@/store/calendar";
import { useCalendar, myParticipantKeys, isRecurring, isOccurrence, eventRule, participantEmail, type EventInstance, type EventScope } from "@/store/calendar";
import { Popover, type Anchor } from "@/ui/popover";
import { confirmDialog } from "@/ui/dialog";
import { toast } from "@/ui/toast";
import { askDeleteScope, runScoped } from "./scope";
import { formatTimeRange, humanDuration, parseDuration } from "@/lib/dates";
import { describeRule } from "@/lib/recurrence";
import { useCompose } from "@/store/compose";
@@ -22,20 +23,26 @@ export function EventPopover({ inst, anchor, onClose, onEdit }: { inst: EventIns
const myStatus = myKeys.length ? ev.participants?.[myKeys[0]!]?.participationStatus : undefined;
const isOrganizer = ev.isOrigin !== false && (!participants.length || participants.some(([k, p]) => p.roles?.owner && myKeys.includes(k)));
const canEdit = inst.calendar?.myRights.mayWriteAll || (inst.calendar?.myRights.mayWriteOwn && isOrganizer) || !inst.calendar;
const baseId = ev.baseEventId ?? ev.id;
const location = Object.values(ev.locations ?? {})[0];
const vloc = Object.values(ev.virtualLocations ?? {})[0];
const alerts = Object.values(ev.alerts ?? {});
const openCompose = useCompose((s) => s.open);
const del = async () => {
const recurring = isRecurring(ev);
const ok = await confirmDialog({ title: recurring ? "Delete all occurrences?" : "Delete this event?", message: recurring ? "This will delete the entire series." : undefined, confirmLabel: "Delete", danger: true });
if (!ok) return;
// A series asks which; anything else is a plain confirm. `askDeleteScope`
// returns null for a dismissed dialog, which is a cancel and not a series.
let scope: EventScope | null = "series";
if (isRecurring(ev) && isOccurrence(ev)) {
scope = await askDeleteScope(ev);
} else {
const ok = await confirmDialog({ title: "Delete this event?", confirmLabel: "Delete", danger: true });
if (!ok) scope = null;
}
if (!scope) return;
setBusy(true);
try {
await cal.destroyEvent(baseId, participants.length > 1);
toast.success("Event deleted");
await runScoped(scope, (s) => cal.destroyEvent(ev, participants.length > 1, s));
toast.success(scope === "occurrence" ? "Occurrence deleted" : "Event deleted");
onClose();
} catch (err) {
toast.error((err as Error).message);
@@ -47,7 +54,7 @@ export function EventPopover({ inst, anchor, onClose, onEdit }: { inst: EventIns
const rsvp = async (status: "accepted" | "tentative" | "declined") => {
setBusy(true);
try {
await cal.rsvp(baseId, status);
await cal.rsvp(ev, status);
toast.success("Response sent");
onClose();
} catch (err) {
+89
View File
@@ -0,0 +1,89 @@
import { choiceDialog, confirmDialog } from "@/ui/dialog";
import { isOccurrence, isRecurring, isThisAndFutureRefusal, type EventScope } from "@/store/calendar";
import type { CalendarEvent } from "@/jmap/types";
/**
* Ask which of a series a change is meant for, when there is a choice.
*
* There is only a choice when the object in hand is an occurrence of a real
* series: a one-off has a synthetic id too, but its only occurrence *is* the
* event, so asking would be a question with one true answer. `null` means the
* dialog was dismissed, which is not the same as "the whole series" — every
* caller has to treat it as a cancel.
*
* Until 0.16.20 there was nothing to ask: the server refused a write aimed at
* an occurrence, so "the whole series" was the only thing that could happen.
*/
export async function askScope(
event: CalendarEvent,
opts: { title: string; occurrenceLabel: string; seriesLabel: string; danger?: boolean; occurrenceHint?: string; seriesHint?: string },
): Promise<EventScope | null> {
if (!isRecurring(event) || !isOccurrence(event)) return "series";
const answer = await choiceDialog({
title: opts.title,
choices: [
{ value: "occurrence", label: opts.occurrenceLabel, hint: opts.occurrenceHint, danger: opts.danger },
{ value: "series", label: opts.seriesLabel, hint: opts.seriesHint, danger: opts.danger },
],
});
return answer === "occurrence" || answer === "series" ? answer : null;
}
/** The scope question for deleting. */
export const askDeleteScope = (event: CalendarEvent): Promise<EventScope | null> =>
askScope(event, {
title: "Delete this event?",
occurrenceLabel: "This occurrence",
occurrenceHint: "Removes this date and leaves the rest of the series.",
seriesLabel: "All occurrences",
seriesHint: "Deletes the whole series. This cannot be undone.",
danger: true,
});
/** The scope question for editing. */
export const askEditScope = (event: CalendarEvent): Promise<EventScope | null> =>
askScope(event, {
title: "Change this event?",
occurrenceLabel: "This occurrence",
occurrenceHint: "Applies to this date only.",
seriesLabel: "All occurrences",
seriesHint: "Applies to every date in the series.",
});
/**
* What to say when the server kept some of a per-occurrence change for the
* series. `dropped` comes back from `updateEvent`; an empty list says nothing.
*/
export function droppedMessage(dropped: string[]): string | null {
if (!dropped.length) return null;
const names = dropped.map((d) => d.replace(/^@/, "")).join(", ");
return `Saved for this date. ${names} ${dropped.length === 1 ? "applies" : "apply"} to the whole series and was left unchanged.`;
}
/**
* Run a scoped change, and offer the series if the server will not do one date.
*
* Stalwart refuses an occurrence that belongs to a this-and-future override —
* *"Occurrences of a this-and-future change cannot be modified individually."*
* Nothing ihasmail writes creates one, but an event synced from another client
* can carry one, so the refusal is reachable and a bare error toast would leave
* the reader with no way forward.
*
* The series is offered rather than silently substituted: they asked for one
* date, and doing the larger thing without saying so is the failure this whole
* area exists to avoid.
*/
export async function runScoped<T>(scope: EventScope, run: (scope: EventScope) => Promise<T>): Promise<T | null> {
try {
return await run(scope);
} catch (err) {
if (scope !== "occurrence" || !isThisAndFutureRefusal(err)) throw err;
const ok = await confirmDialog({
title: "This date cannot be changed on its own",
message: "It belongs to a change that was applied to this and all later occurrences, which the server will only edit as a whole. Apply to the entire series instead?",
confirmLabel: "Apply to series",
});
return ok ? await run("series") : null;
}
}
+8 -6
View File
@@ -33,17 +33,19 @@ export function FilterFromMessageDialog({ email, mailboxId, onClose }: { email:
}
if (!ready) return <Dialog open onClose={onClose} title="Create filter" size="sm"><Spinner /></Dialog>;
const { rules, loaded } = sieve.rules();
const { rules, loaded, damage } = sieve.rules();
if (rules === null) {
return (
<Dialog open onClose={onClose} title="Create filter" size="sm" footer={<button className="btn" onClick={onClose}>Close</button>}>
{/*
Two different situations, and telling them apart matters: one is
permanent and one is a reload away. Saying "written by hand" when the
script merely failed to fetch sends someone looking for a problem
they do not have.
Three different situations, and telling them apart matters: one is
permanent and two are a reload away. Saying "written by hand" when the
script merely failed to fetch -- or arrived in part -- sends someone
looking for a problem they do not have.
*/}
{loaded ? (
{damage ? (
<p>Your filter script {damage}, so only part of it arrived. Adding a rule would write that part back over the whole thing. Reload the page and try again.</p>
) : loaded ? (
<p>Your active Sieve script was written by hand, so rules can't be added automatically. Open <b>Settings → Filters & rules</b> to edit the script or switch to managed rules.</p>
) : (
<p>Your filter script couldn't be read just now, so adding a rule would risk overwriting it. Reload the page and try again.</p>
+2 -2
View File
@@ -54,7 +54,7 @@ export function InviteCard({ email, part }: { email: Email; part: EmailBodyPart
target = await cal.getEvent(id);
}
if (!target) throw new Error("Could not add the event to your calendar");
await cal.rsvp(target.id, status);
await cal.rsvp(target, status);
setExisting(await cal.getEvent(target.id));
toast.success(status === "accepted" ? "Invitation accepted" : status === "declined" ? "Invitation declined" : "Marked as tentative");
} catch (err) {
@@ -115,7 +115,7 @@ export function InviteCard({ email, part }: { email: Email; part: EmailBodyPart
)}
{method === "CANCEL" && existing && (
<div className="rsvp">
<button className="btn btn-sm btn-danger" disabled={Boolean(busy)} onClick={async () => { try { await cal.destroyEvent(existing.id, false); setExisting(null); toast.success("Removed from calendar"); } catch (err) { toast.error((err as Error).message); } }}>Remove from calendar</button>
<button className="btn btn-sm btn-danger" disabled={Boolean(busy)} onClick={async () => { try { await cal.destroyEvent(existing, false, "series"); setExisting(null); toast.success("Removed from calendar"); } catch (err) { toast.error((err as Error).message); } }}>Remove from calendar</button>
</div>
)}
<span className="sr-only">{email.id}</span>
+1 -1
View File
@@ -30,7 +30,7 @@ export function AboutSettings() {
</tbody>
</table>
<p className="hint" style={{ marginTop: 6 }}>Stalwart does not publish its version number to mail clients, so ihasmail reports the edition where the server gives one. ihasmail requires 0.16 or newer, and sign-in refuses anything older.</p>
<p className="hint">The middle number of ihasmail's own version is the Stalwart generation it is built for: <strong>v2.16.x</strong> targets Stalwart 0.16. The last is the pull request it was built from, and a trailing <code>+g</code> and short commit means the build is past that pull request rather than exactly it.</p>
<p className="hint">ihasmail's own version is the date of the commit it was built from, followed by where that commit came from: <strong>v2026.8.30+pr129</strong> was built from a commit dated the 30th of August 2026 that arrived through pull request 129. A commit that did not come through one carries its short SHA instead <code>+g1fa6578</code>. The version deliberately says nothing about Stalwart; what this build needs from the server is the line above.</p>
<h2>Server capabilities</h2>
<div className="row wrap gap-4">
{caps.map((c) => <span key={c} className="chip mono" style={{ fontSize: ".78em" }}>{c.replace("urn:ietf:params:jmap:", "")}</span>)}
+14 -1
View File
@@ -45,7 +45,7 @@ const RULE_MIME = "application/x-ihasmail-sieve-rule";
function RulesEditor() {
const sieve = useSieve();
const { script, rules, content } = sieve.rules();
const { script, rules, content, damage } = sieve.rules();
const [local, setLocal] = useState<SieveRule[] | null>(null);
const [editing, setEditing] = useState<SieveRule | null>(null);
const [saving, setSaving] = useState(false);
@@ -80,6 +80,19 @@ function RulesEditor() {
}
};
// Before the hand-written branch: a script that arrived in part is not a
// script someone chose to write themselves, and the way out of it is a reload
// rather than the "start with rules" button below, which would write over it.
if (damage) {
return (
<div className="warn-box">
<div className="row gap-8" style={{ marginBottom: 8 }}><AlertTriangle size={18} /> <b>Only part of your filter script arrived.</b></div>
<p style={{ margin: "0 0 8px" }}>It {damage}, so the rules in it can't be shown or edited saving what did arrive would write it back over the rest. Reload the page to try again. Your rules are still on the server; nothing here has changed them.</p>
<button className="btn" onClick={() => window.location.reload()}>Reload</button>
</div>
);
}
if (rules === null) {
return (
<div className="warn-box">