Commit Graph
39 Commits
Author SHA1 Message Date
jcoffey-dev 9622875659 Say how much an LDIF re-import duplicated, without acting on it
The half of #223 that can move while the matching question is still open.

Mozilla's schema defines no UID, so the import invents one and a re-import
duplicates everything. Whether to guess an identity from a name and an address
instead is the reporter's call and he has not made it -- but the harm that was
actually reported was confusion rather than duplication: somebody imports a
file twice and cannot tell what happened.

So the import now counts how many of the entries look like contacts the book
already held, and says so in a second message. Every card is still imported.
Nothing is skipped and nothing is merged, which is the point: counting is a
different act from matching, and it takes no decision away from the person who
still owes us one.

The likeness key is name plus one address, and it is wrong in both directions
by design -- two colleagues sharing a name and an alias collapse, somebody
whose address changed since the last export looks like a stranger. That is
tolerable for a number on a toast and would not be tolerable for a merge,
which is exactly why the number is all it does.

The scan the vCard import already makes for UIDs now collects names and
addresses on the same request, so this costs no extra round trip. It is read
before anything is created, so a file that repeats a person twice counts as
two new cards rather than as a duplicate of itself.

If the answer comes back "match on name and email", the matching is written
and becomes a skip instead of a count.

Refs #223.
2026-09-02 12:45:30 -07:00
jcoffey-dev c31a653a04 Apply installation policy changes once each, per account
The last third of #207, and the only part that remembers anything.

An admin turns a setting on for people who are already here -- which a default
cannot do, since a default only seeds an account that has none -- and readers
may still turn it back off afterwards, which enforcement does not allow. The
difference between the two is entirely in the remembering.

Each change carries its own version, and an account stores the ones it has had
in its own settings file. Ids rather than a high-water mark, so a change dated
earlier than one already applied is not silently skipped -- the reporter's
analogy is a schema migration, and this is that shape.

Per account rather than per device, because ihasmail's settings are not
browser-local: they live in a file in the reader's own JMAP Files, with the
browser holding a cache. Signing in on a phone does not apply everything a
second time.

A change reaches somebody who had already decided otherwise. That is intended
and confirmed on the issue: the point is to reach everybody who is already
here. It is applied once, and their next decision sticks.

One `update` for however many are pending, since each would otherwise push a
settings file of its own. Enforced values still win, being applied after. A
change whose settings this build does not have at all is dropped rather than
recorded, or it would never run on the ihasmail that does have them.

The reader is told. A setting moving under somebody without a word is the part
of this worth being uneasy about, so the count is toasted with a way into
Settings.

README gains the Docker half the user asked for: a mounted policy file, the
same thing as environment variables for a deployment with no volume, a compose
fragment, and the fact that a policy is read once at startup so editing it
means a restart.

Closes #207.
2026-09-02 11:00:48 -07:00
jcoffey-dev 457ea53ca3 Let an installation seed and lock user settings
The first two thirds of #207. A school wanting "warn about outside senders"
on for three thousand pupils cannot ask three thousand pupils, and the
reporter is right that this is a company policy rather than a preference.

Two powers, and the difference between them is the whole request. `defaults`
seed an account that has never had settings of its own and can be changed
afterwards like anything else -- a starting point, not a rule. `enforced` are
reapplied on every load and cannot be changed at all.

Enforced controls stay visible and go dead, with a line saying why. The issue
asked for that by name: a control that is simply missing reads as a bug to
somebody who has used ihasmail without a policy.

The lock is in the settings store rather than only on the controls. There is
one door -- `update` -- and putting it there means an imported settings file,
a settings file synced from a device that predates the policy, and a control
somebody adds later and forgets to check are all covered by construction.
Reset goes back to the installation's answer rather than to ihasmail's, so it
cannot be a way around a policy either.

Configured by environment variable or by a file, because ihasmail's own
production runs read-only with no volume: an installation that cannot mount a
file can still set a variable. Keys this build does not have are dropped, the
same rule an imported settings file already gets -- a policy written against a
newer ihasmail must not put a setting nothing reads into everybody's synced
settings file. Malformed JSON stops the server rather than quietly doing
nothing, since a policy that silently did not apply is indistinguishable from
the feature not working.

Tier three -- enforcing a setting once while still letting readers change it
afterwards -- is not here. It needs a decision the reporter and I have not
made yet, and it is the only part that stores anything new.

Refs #207.
2026-09-02 10:49:55 -07:00
jcoffey-dev a1fe4fea1a Skip vCards on re-import that the address book already has
The contacts half of the rule that shipped for events, and only the half that
can be decided. A vCard carries a UID its author meant, so a card whose UID
this book already holds is that card, and re-importing an export left a second
copy of every one of them. Reported on #174 by the reporter's colleague, and
decided on #173: skip on a UID that is already here, import what arrives
without one, since nothing can be matched on an identity that is not there.

LDIF is deliberately untouched and now says so in the type. Mozilla's schema
defines no UID and the dn is not an identity outside the directory it came
from, so the import invents a UID that can never match one already present.
Guessing instead from a name and an address is the open question on #223, and
a guess that merges two people who share a name is worse than a duplicate
somebody can see and delete.

Both imports answer with the same shape, so a caller does not have to know
which one it called. LDIF's skipped is always 0, which is the honest number
rather than a missing field.

The UIDs are asked of the server rather than read from the cards in the store.
The store's copy is complete once the view has loaded, and importing does not
wait for a view.

Two callers, two messages. The contacts import reports both counts, as the
calendar import does: "Imported 3 contacts" over a file of two hundred reads
as a failure when the rest were already here. And a vCard attached to a
message -- usually one you have been sent before -- now says it is already in
your contacts rather than reporting that it added none.

Refs #223; the LDIF half stays open.
2026-09-02 09:33:53 -07:00
jcoffey-dev 1a6158aa70 Export a calendar as an iCAL file
The mirror of the import from #173, and the last thing contacts had that
calendars did not -- an address book could always be exported, a calendar
never could.

It is written here rather than asked for. The import hands parsing to the
server because Stalwart has a CalendarEvent/parse and reimplementing an .ics
reader in a browser would be foolish; there is no method the other way, in
Stalwart or in the JMAP calendar drafts, so the file is built from the RFC
8984 objects the server already returns. Most of that is renaming: 8984 was
written as a restatement of 5545, and the comments say which way it went
wherever the two disagree.

The masters, not the occurrences. The query runs without expandRecurrences,
so a weekly meeting leaves as one VEVENT carrying its RRULE rather than as a
year of identical ones -- an export that had flattened the rule would import
somewhere else as a pile nobody can maintain. A changed occurrence goes out
as its own VEVENT with the same UID and a RECURRENCE-ID, which is how
iCalendar has always said it; a cancelled one becomes an EXDATE.

Three decisions worth stating rather than leaving to be found:

No VTIMEZONE components. A TZID names the IANA zone the server holds and
nothing defines it beside it, because defining it means shipping a zone
database to describe rules the reader's own system already knows. Every
client that matters resolves IANA names. The alternative -- converting to
UTC -- would be worse than a validator's complaint: a weekly 09:00 that
becomes 08:00 for half the year is a wrong calendar.

UNTIL follows DTSTART's kind, a date for an all-day series and a UTC instant
otherwise. Sending a local time there is the usual way to make a series stop
a day early in another timezone.

Overrides are applied at the top level only. A recurrence override is a JSON
patch, and one addressing locations/x/name is not something this flattens.

Closes #216.
2026-09-02 09:21:16 -07:00
jcoffey-dev 74f6d1d0aa Skip events on re-import that the calendar already has
Importing an export twice left second copies of everything. The import has
kept the file's own UID since it was written -- inventing one only where an
event arrives without -- so what was needed to recognise an event that is
already here was there all along, and nothing looked at it.

Asked for on #173 after the reporter's colleague hit the duplication in
testing, and decided there: skip on a UID the calendar already holds, import
what arrives without one. An event with no UID is not one anything can match
to, and a softer match -- title and time, say -- guesses in both directions.

The UIDs are read once per import rather than once per event. CalendarEvent/
query does take a uid filter, which is what findByUid uses, but a file of two
thousand events would be two thousand queries. Read without expandRecurrences
so a weekly series is one event with one UID rather than one per occurrence,
and narrowed to the target calendar from calendarIds rather than through an
inCalendar filter this client has not confirmed the server supports.

Matching is per calendar. A UID is what makes an event the same event across
calendars, so the same event being in two of them is not a duplicate and the
second calendar still gets its copy.

importIcs now answers with both counts. "Imported 40 events" over a file of
240 reads as a failure when 200 of them were simply already there, and a
re-import of an unchanged file would otherwise report importing nothing at
all rather than saying everything was already here.

The three import toasts are translated in all nine catalogues while the
messages were being written -- the plural for the existing one had never been
added and was falling back to English.

Closes #222.
2026-09-02 08:52:33 -07:00
jcoffey-dev 5e6e049eef Set the Archive role from ihasmail, rather than describing it
#220 corrected the message and left it useless: it told you a folder needs
the Archive role on the server, which was true, and gave you nothing to do
about it here. Roles were shown in Folders settings and never settable.

Mailbox/set takes `role`. Confirmed live against 0.16.20 on 2026-09-02, as an
ordinary user through the proxy, with no admin API: setting role "archive" on
a folder that had none returned updated and the folder began working as the
Archive immediately. Stalwart parses the role names in SpecialUse::parse,
"archive" among them, refuses a second holder of a role, and refuses to move
the role of Inbox, Junk or Trash.

So the toast now carries the fix. "No Archive folder is set yet." with a
Create one that makes the folder and then completes the archiving that could
not happen -- rather than leaving someone to select the same messages again.

A folder already named Archive and carrying no role is adopted rather than
duplicated. That is the state #217 was reported from, and a second Archive
beside the first would be its own confusion. One named Archive that is really
the Sent folder is left alone: taking its role to fix archiving would break
sending.

Folders settings gains a Role column. Archive, Drafts and Sent are offered,
being the roles this client's behaviour depends on and the server will move;
Inbox, Junk and Trash show theirs and cannot change it, because 0.16.20
refuses. A role another folder holds is left out of the list rather than
offered and refused, so freeing it is a deliberate two steps.

The folder is created with the server's own name, never the localised one,
for the reason renaming already writes back the server's: a German session
must not create "Archiv" that an English one cannot find.

Closes #217 properly.
2026-09-02 08:38:08 -07:00
jcoffey-dev 1611ae6918 Say the Archive folder needs the role, not the name
Archiving looks the folder up by its special-use role and by nothing else --
roleId("archive"), falling back to roleId("all") -- and then, finding
neither, told you to create a folder named "Archive". Naming a folder does
not give it a role, and ihasmail has no way to assign one: Folders settings
shows the role beside a folder and offers no way to set it. So the advice
sent someone round a loop that could not end. They make the folder, it still
does not work, and the message says the same thing again.

It now says what is actually required and where it lives: a folder needs the
Archive role on the server, and naming it "Archive" is not enough.

All nine catalogues carry the correction rather than falling back to English,
and they need the same native review the rest of them do.

The existing test asserted only that archiving complained. It now checks what
the complaint says, since the words were the whole bug.

Closes #217.
2026-09-02 08:00:46 -07:00
jcoffey-dev 4121f9263b Set contact cards in batches the server will take
The same bug the calendar import had, in the three places contacts write
more than one card at once. ContactCard/set is refused whole over
maxObjectsInSet -- requestTooLarge, nothing created -- so a large enough
vCard or LDIF file imported nothing, and "select all, delete" over a large
address book deleted nothing and reported it in JMAP's words.

Nobody has hit it. It was found by looking, after #215 fixed the calendar,
and it is promised on #173.

Both imports now go through one createCards, which splits by the ceiling the
session advertises and falls back to 500. That is what the LDIF import's
comment -- "from ContactCard/set down they are the same" -- was already
claiming, and is now true of.

destroyCards splits the same way, and takes off the list the ids the server
said it destroyed rather than everything that was asked for. It removed all
of them before, which was harmless while there was one call and wrong the
moment a later batch can fail: deleted contacts must not stay on screen, and
live ones must not disappear from it.

One behaviour change beyond the batching. A vCard import the server accepted
no card of returned 0, and the view reported importing no contacts -- which
reads as an empty file rather than as a refusal. It now says why, which is
what the LDIF import has always done. A file with genuinely nothing in it
still says so, earlier and separately.
2026-09-02 06:54:24 -07:00
jcoffey-dev 17bd548524 Import an iCal file in batches the server will take
An 800 KB export imported nothing at all. Every event in the file went out
in a single CalendarEvent/set, and Stalwart refuses a method call carrying
more objects than maxObjectsInSet -- the whole call, with requestTooLarge,
creating none of it -- so the import failed at exactly the size that makes
importing worth doing. A two-event invitation was fine; a real calendar was
not.

The events now go out maxObjectsInSet at a time, which the client already
reads off the session and defaults to 500 where a server does not say. That
is the same ceiling and the same helper the mail store batches deletes and
flag changes by; nothing new had to be learned about the limit, and there is
no need to ask anyone to split an .ics by hand at an arbitrary line.

Still batches rather than a call per event: createEvent invalidates on the
way out and invalidating re-fetches every cached range, which is why the
import writes its own set calls in the first place. One invalidate, after
the last batch.

A batch that fails after earlier ones have been filed now says how many got
in -- "1000 of 1200 events were imported before this happened" -- and
re-reads the calendar so they are visible. Reporting only that the import
failed would send someone looking for events that are already there.

The mock enforced this ceiling all along, on both /get and /set; nothing had
exercised it with a file big enough to cross it.

Reported on #173.
2026-09-02 06:29:49 -07:00
jcoffey-dev 1b4788c0a7 Apply the upload limit only where something is uploaded
FEATURES has always said attach-from-Files works "however large", because a
blob the account already holds is attached by reference and nothing is
sent. The code checked every file against maxSizeUpload regardless, so the
two disagreed and the code was the one that was wrong.

maxSizeUpload is what the server will accept for a single upload (RFC
8620). It bears on a file that is about to be uploaded and on nothing
else. Applying it to a by-reference attachment refused a 60 MB message the
server was already storing, on the grounds that it could not have been
uploaded -- which it was not being. Forwarding a large message as an
attachment hit exactly that.

A file from somebody else's account is fetched and re-uploaded into this
one, because a message can only carry blobs from the account sending it.
That upload is real and the limit is real for it, so it still applies
there.
2026-09-02 00:58:37 -07:00
jcoffey-dev 8bee0eb3c8 Select a whole folder, not just the rows that are loaded
The header checkbox selected the loaded page. On a folder of ten thousand
that is fifty of them, and the only way to act on the rest was to scroll
until they loaded and tick again.

A line now offers the rest by name once the page is selected, and taking
it is a separate press. A checkbox that silently meant ten thousand when
the screen shows fifty would be the worst of both, so each option says
what it actually covers.

The wider selection is a query rather than a list of ids. What it reaches
is resolved from the server when an action runs, walked a page at a time,
because a folder holds far more than one call returns and Email/set
refuses more ids than maxObjectsInSet in one go -- which setEmails already
chunks for. It resolves uncollapsed, unlike the list: "everything in this
folder" means every message rather than one per thread, and expanding
threads the way a click does is impossible here anyway, since that walks
loaded Email objects and these are the ones that were never loaded.

Two things this exposed.

Undo is now withheld once a move reaches messages that were never loaded.
It restores the folders each message was in, taken from what the browser
holds, and for an unloaded message that is nothing -- so the undo would
have written an empty mailboxIds and left the message in no folder at all,
which is worse than the move it was undoing. move() and archiveByDate()
both did this; both now drop the offer rather than restore something
wrong.

And an action consumes the wider selection. The optimistic paths cleared
the selected ids but not the flag, so the next action would have silently
reached the whole folder again.
2026-09-01 22:56:14 -07:00
jcoffey-dev 790213cc17 Merge branch 'main' into feat/forward-as-eml
# Conflicts:
#	web/src/store/compose.ts
#	web/src/views/mail/MessageView.tsx
2026-09-01 22:31:42 -07:00
jcoffey-dev 785570a41d Archive into a dated folder
Archiving put everything in one folder, so an Archive that has been
collecting for years is a single flat list with no way to narrow it except
search.

Archive by year and Archive by month file into Archive/2026 and
Archive/2026/09, creating the folders as needed and reusing them after
that, including folders made by hand or by another client.

The names are numeric and zero-padded rather than month names, because
these are real server-side mailboxes rather than anything of ihasmail's.
Every other client sees them: a folder created as "September" by someone
reading in English stays "September" for the same account read in
Japanese, since the name is stored and not translated. And 09 sorts
between 08 and 10 where a name does not.

The date is read in the reader's own timezone rather than UTC so it agrees
with the date shown against the message in the list. A message that
arrived at 00:30 UTC on 1 September is dated 31 August in New York, and
filing it under 09 while the list says August would be the app
disagreeing with itself. A message whose date cannot be read goes to
Archive itself rather than to a folder named after a guess.

A selection spanning two months is two destinations, not one. The moves
are made silently and one toast names where everything went -- the folder
where there is a single answer, the count where there is not -- because
each group raising its own toast with its own Undo would mean undoing a
third of a move. One Undo restores the whole selection to wherever each
message came from, captured before anything moved.

The menu labels name the destination where there is one, so it reads
"Archive to 2026/09" rather than describing the rule, and falls back to
"Archive by month" for a selection with no single answer.
2026-09-01 22:06:33 -07:00
jcoffey-dev 0db795371e Forward a message as an attachment
Forwarding quoted the original into a new message, which is the right
thing for passing on something to be read and the wrong thing for passing
on something to be looked at. Quoting rewrites the body, drops the
headers, and re-parents the attachments, so a bounce, a phishing report or
anything else where the message itself is the evidence arrived altered.

Forward as attachment sends the message whole, as a message/rfc822 part.
It costs no upload at all: a message's own blobId is its RFC822 blob and
already lives in the account, so this goes through the same by-reference
path as attach-from-Files and a 40 MB message attaches as fast as a small
one.

It is in the message's own menu, the list's right-click menu, and the
overflow on the reply strip at the foot of a thread, which is the one a
thumb finds on a phone.

Two things fixed on the way, both exposed rather than introduced by this.

The filename rule was subject.replace(/[^\w.-]+/g, "_"), and \w without
the u flag is ASCII: every character of a Russian, Japanese or Chinese
subject failed the class, so those messages downloaded as a row of
underscores. What is actually unsafe in a filename is much shorter than
"not ASCII" -- path separators, the names Windows reserves, the control
range -- so the rule now keeps letters from any script and drops only
those. It lives in one place and the .eml download uses it too.

And the composer's attachment chip set overflow/text-overflow on a span,
where neither does anything, so the name never truncated and the size ran
on after it on the same line. Only long names showed it, which is every
.eml named from a subject.
2026-09-01 21:57:11 -07:00
jcoffey-dev 00b580bad8 Select more than one file at a time
Moving or deleting five files meant doing it five times, each with its own
confirm. Rows now select the way they do in a file manager: a plain click
replaces the selection, ctrl or cmd adds and removes one, shift takes the
run from the last row clicked, clicking past the last row or pressing
Escape clears it. Two or more selected raises a bar with Move to... and
Delete, and the row menu offers the same for the whole selection.

The move is one `FileNode/set` rather than a loop, and not only for the
round trip: a loop would apply half the moves and then throw, leaving a
selection split across two folders with nothing saying which half went.
One call is one answer, and `notUpdated` names whatever the server
refused.

Right-clicking inside the selection acts on all of it; right-clicking
outside means you meant that row, so the selection follows the pointer
rather than the menu quietly applying to something off-screen. A drag
carries the whole selection the same way, which is why the payload is now
a list -- and why a drop is refused unless every file in it can land,
since a drag that moves four of five and skips the fifth is worse than
one that will not start.

A selection belongs to the folder it was made in, so changing folder or
account drops it: rows left selected off-screen make the delete two
folders later a surprise.
2026-09-01 21:11:36 -07:00
jcoffey-dev fc0e2b2b3e Import an address book in LDIF
Somebody arriving from SOGo, Thunderbird or an LDAP directory has their
contacts in LDIF, and until now the only way in was vCard.

Nothing on the server reads LDIF, so this reads it here, in two pieces
that are two different problems. `ldif.ts` is RFC 2849 and nothing else:
folded lines, base64 values, case-insensitive attribute names, options,
comments, `version:` headers, change records. It knows no attribute by
name. `mozillaAb.ts` knows the attributes and no syntax -- Mozilla's
address book schema, which is what Thunderbird and SOGo write and what the
issue asks for by name. LDIF says nothing about what any attribute means,
so a file is only readable against a schema, and keeping the two apart is
what would let a second schema be added without touching the reader.

Work and home addresses, which the schema keeps in two separate sets of
attributes, come across as two addresses. So do every phone kind, the
second email, the organisation and its units, job title, nickname, web
pages and the AIM handle. The four custom fields have no equivalent in
JSContact and are appended to the note, labelled as Thunderbird labels
them: keeping something somebody chose to write down is worth more than
the tidiness of dropping it.

An entry with neither a name nor an address is skipped rather than
imported as a blank row that is impossible to identify and tedious to find
again to delete. The distinguished name is not used as the contact's uid:
it says where an entry sat in somebody else's directory.

One import control takes either format and decides by what is in the file
rather than by what it is called, because an address book exported as LDIF
arrives as .ldif, .ldi, .txt or with no extension at all.

Closes #174
2026-09-01 09:27:07 -07:00
jcoffey-dev 02c0332d8f Send a message again as a new one
A mail the far end rejected, or one that went to an address with a typo in
it, is a mail you want to send again -- not forward, and not reply to.
Doing it by hand meant a new message and copying five fields across.

"Compose as new" sits with Reply and Forward, in the message menu and in
the list's right-click menu. It opens a composer holding the recipients
the message had, its Reply-To, its subject with nothing prefixed to it,
its body with nothing wrapped around it, and its attachments.

What makes it a new mail is what it leaves behind. `draftId` stays null,
or sending would destroy the message it was made from. `inReplyTo`,
`references`, `relatedEmailId` and `relatedKeyword` stay null, so it hangs
off no thread and sending it marks the original neither answered nor
forwarded. The Message-ID is the server's and the date is stamped at
build time, so both are new without anything asking for them -- the send
path needed no changes at all.

A message you sent is composed as the identity you sent it as. One
somebody else sent has no identity of yours to match, and guessing from
whom it was addressed to would put the resend behind an alias that was
only ever the receiving end, so that case takes the account's default.

No signature is added. The body is the one that was sent, which already
ends in whatever signature went with it, and appending the identity's
would give it two.

Closes #176
2026-09-01 08:58:15 -07:00
jcoffey-dev 6431ec87f5 Import an iCal file into a calendar
An .ics reaches you by ways that are not your mailbox -- a ticketing
system a customer invited, a colleague's export, a booking confirmation
forwarded on -- and until now the only events ihasmail could take were the
ones attached to a message it had received.

The calendar's own menu now offers "Import iCAL file…", which files
everything in the file into that calendar. No global button: the issue is
right that this is not a frequent enough thing to earn one.

The parsing is the server's, through the same CalendarEvent/parse an
emailed invitation already goes through. An .ics is not a format worth
reimplementing in a browser, and Stalwart's reader handles what a
hand-rolled one would not.

Every event goes out in a single CalendarEvent/set. The round trips are
the smaller half of the reason: createEvent invalidates on the way out and
invalidating refetches every cached range, so a year of events imported
one at a time would refetch the calendar a few hundred times.

Nothing is mailed to anyone named in the file. Importing is filing
something you already have, and scheduling messages would be a surprise to
its participants.

The mock's parser read the whole file with one regex and returned one
event, which is all an invitation ever needed. It now reads per VEVENT, so
a multi-event file can be tested against it, and it invents an organiser
and an attendee only for events that carry a METHOD -- a plain export is
not addressed to anyone.

Closes #173
2026-09-01 08:38:06 -07:00
jcoffey-dev 53c38ed3c3 French, on the same terms as German
Second of Phase 1. Generated by AI, unreviewed, marked Beta, with the report
link in Settings doing the job a native speaker would otherwise do. 781 of 796
strings; the fifteen left are product names, bare URLs and example addresses,
which should stay English.

Register is "vous", following the "Sie" decision and for the same reason: a
mail client a workplace deployed has no business addressing anybody as "tu".

One terminology decision goes the opposite way to German, deliberately.
"Label" stays English in German because no German client translates it, and
becomes "Libellé" in French because Gmail did and a French reader will meet it
there. The rule being followed is "use what the reader will find elsewhere",
not "always translate" or "never" -- which only looks inconsistent if the rule
is mistaken for the outcome.

French typography: the narrow no-break space before ? ! and : is the rule and
is deliberately not used. It is invisible in a diff, trivially lost in an
editor, and no French webmail actually ships it. Guillemets are used, because
those are visible and do read as wrong when missing.

Two things worth recording from doing this a second time.

The folder-context separator arrived as a raw U+0004 byte rather than the
\\u0004 escape the German file uses. It would have worked -- TypeScript accepts
it -- and it is invisible in an editor and in a diff, which is exactly why the
German catalogue writes it as an escape. Converted, so both files say the same
thing in the same way.

And the language tests named a real language as their example of one that is
not shipped, so shipping German broke them, and shipping French broke them
again. Both times the failure was the test being out of date rather than
anything wrong. They derive an unshipped tag now, and assert over every shipped
language rather than a hardcoded pair, so the third and fourth languages will
not repeat it.
2026-08-31 11:44:40 -07:00
jcoffey-dev 87383440bb German, generated by AI and marked Beta until somebody signs it off
The first language, and the first one where the honest thing to say is not
flattering: no native speaker has read it. That is stated in the app rather
than in a commit nobody reads, because it is the fact a reader needs to judge
what they are looking at. Somebody told a translation is unchecked forgives an
odd sentence and reports it; somebody told it was reviewed reasonably concludes
the product is sloppy. The setting carries a link straight to a report, which
is the whole review process here.

`beta` is a property of the language, not of the catalogue's completeness. A
file can be word-for-word finished and still read like a machine wrote it, and
that is what the flag marks. Removing it is a person's decision.

Register is "Sie", consistently, and written down in the file so the next
language and the next contributor inherit the decision rather than re-take it.
Thunderbird and Outlook use it; ihasmail is as often a company's mail as
somebody's own, where "du" from software the workplace deployed reads as
presumptuous. Where a string can dodge the question it does, which is ordinary
good German UI. The glossary at the top of the file fixes the vocabulary once
-- Posteingang, Papierkorb, Entwürfe, archivieren -- because inconsistency
reads as amateur far more than an imperfect word choice does. "Label" and
"Spam" stay English, since translating them would name things no German mail
client calls that.

766 of 781 strings. The fifteen left are product names, bare URLs and example
addresses, which should stay English and now do.

Two things this turned up that the earlier work had hidden:

Labels defined as module-level constants -- the entire settings navigation,
the theme cards, the swipe choices, the date formats, the sharing permissions
-- are evaluated once, before any catalogue loads, so they could only ever be
English. Nothing failed; the German build simply had an English sidebar. They
are translated where they render now, which keeps the constant as data and
makes its English text the key.

And the codemod's narrowed rule, which let it take 73 more strings last time,
was too broad after all: text stranded after an inline <a> or <strong> came
through as sentence fragments -- ", and what a new account starts on." Eight
of them, rebuilt with tNode so the sentence stays whole and the element is a
named hole a translator can move.

scripts/i18n-catalog-check.mjs is new and earned itself immediately: it found
three keys invented that the code never asks for, which is the silent failure
in a catalogue -- a translation that looks right, is never looked up, and
renders English for ever. It also had to be taught about t(variable), because
it cried wolf 33 times over the constants above, and a check that cries wolf
gets switched off.

Verified in the browser rather than only in tests, which is where the settings
sidebar being English was visible and nowhere else.
2026-08-31 11:06:54 -07:00
jcoffey-dev be1d787b5f Defend against Chrome rewriting the DOM, and add the language setting
Groundwork for un-shelving translations. Chrome's translator rewrites the
rendered DOM directly, wrapping text nodes in <font> elements React has never
heard of, and the next update can then call removeChild against a parent whose
children have moved (facebook/react#11538). This is the structural defence
against that, plus the setting the served language will read from.

The language setting is `uiLanguage`, and it is deliberately not the `locale`
field that already exists. That one is a formatting choice -- what calendar,
clock and numerals to use -- and folding the two together would silently
rewrite everybody's date format the first time they picked a language. German
dates with an English interface is a real preference, and so is the reverse.
It defaults to English when absent, which covers both a new account and every
settings file written before this, and Accept-Language is not consulted: a
served locale should be something the reader chose rather than something
guessed and then written down as though they had. Only languages with strings
shipped are offered, which today means English alone -- a picker entry without
a catalogue behind it would leave the page claiming a language it is not in,
which stops a reader translating a page they cannot read.

`<html lang>` is set where applyTheme is set: at store module load, from the
localStorage cache, before createRoot() has rendered anything. Not in an
effect -- a lang that is briefly wrong is enough to raise the translate prompt
on a page that needed none. There is no server-rendered alternative to reach
for here: ihasmail serves a static shell and holds no account state, and the
settings file lives in the reader's own JMAP Files, so reading it before the
page existed would mean authenticating to Stalwart on every page load. The
static lang="en" in index.html covers the first bytes; the store only ever
corrects a reader who chose otherwise. Both halves are tested.

translate="no" and class="notranslate" go on the narrow boundaries only:
rendered email bodies, raw message source, attachment text, the generated and
hand-edited Sieve, the brand and the login name. Not on <body> -- someone
whose language ihasmail does not speak yet should still be able to translate
the parts that are ours. Email bodies turn out to live in a shadow root, so
React never reconciles them and they were never a crash risk; the marker there
is about not rewriting what a sender actually wrote.

Twenty-four fragile interpolation points were found with the TypeScript
parser rather than grep, and fifteen refactored. Pluralisation and
"count + label" pairs are collapsed into a single expression so the text is a
lone child React updates with textContent, rather than a text node with
conditional siblings to insert around. One of them -- InviteCard's
{method === "REPLY" && organizer ? "" : ""} -- rendered an empty string either
way and is simply gone.

The boundary is scoped to the main content, so the header, folder tree and any
open composer sit outside it and survive independently. It recovers by
remounting the subtree, which costs nothing because everything inside
re-derives from the stores, and it logs at info rather than error: a reader
translating a page is expected and recovered from, and filing it as an error
would put an entry in every console-reading reporter for behaviour that
worked. It re-raises anything that is not a DOM mutation error, so a real bug
still surfaces as one, and it gives up after three attempts rather than
looping invisibly.

Worth recording: the crash could not be reproduced on React 19.2.8. Wrapping
207-249 React-managed text nodes in <font>, exactly as the translator does,
then driving in-place conditional toggles and navigations, left the app intact
with the boundary never firing. The original issue is from React 16 and the
reconciler has changed a great deal since. So this lands as defence whose
premise is weaker than assumed rather than as a fix for something observed
here, and the boundary is insurance rather than a load-bearing part. The
notranslate markers and the collapsed interpolations stand on their own merits
either way.
2026-08-31 09:14:41 -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 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
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
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 a2337f6ad8 Use an example address, and the right name, in the test fixtures
Two things, one of them not what it looked like.

An organizer fixture was built from a real, routable address. Every other
fixture in the codebase uses example.org or example.com, and this
repository is public, so that one was a personal address sitting in
public source for no reason -- the test asserts roles and participation
status and never reads either value. It is [email protected] now.

The names were wrong in the other direction. Three fixtures across two
files said "John Ellis", which is not the maintainer's name; it is
John Coffey. Being a name rather than a routable address, it leaked
nothing, but it was simply incorrect, and incorrect in the sort of place
nobody rereads.

The address and the name are separate questions and got separate answers:
the address is fictional because it is an address, and the name is real
because it is right. A message from [email protected] signed John Coffey
is exactly what these tests mean.

Found while checking, at the maintainer's prompting, whether the repo
leaked anything about the host it runs on. It does not -- the nginx and
deploy files here are the generic examples they claim to be, and the real
ones live in a private repository.
2026-08-27 14:15:00 -07:00
jcoffey-dev e4b6413f46 Use an example address in the participants fixture
One test built its organizer from a real, routable address and a real
name. Every other fixture in the codebase uses example.org or
example.com, and this repository is public, so the odd one out was a
personal address sitting in public source for no reason -- the test
asserts roles and participation status and never looks at either value.

Now [email protected], matching what the rest of the tests already use.

Found while checking, at the maintainer's prompting, whether the repo
leaked anything about the host it runs on. It does not: the nginx and
deploy files here are the generic examples they claim to be, and the real
ones live in a private repository. This was the only thing the search
turned up that was worth changing.
2026-08-27 14:09:12 -07:00
jcoffey-dev 2360e40733 Show the folder tree in a shared account
Switching to an account somebody had shared showed an empty folder tree.
Their files listed perfectly well; the sidebar beside them was blank,
with nothing to say why.

Switching accounts cleared `nodes` and `children` and stopped there. So
`treeLoaded` stayed true from the account before -- the sidebar only asks
for folders when it is false, and it never asked again -- while `dirIds`
still named the previous account's folders, which no longer resolved
against the cleared `nodes`. An empty tree either way, and no error,
because nothing had failed.

The fields that belong to one account are now named in one place,
`emptyForAccount`, and the test asserts the whole set rather than the
ones that come to mind. The bug was not bad logic, it was a field nobody
remembered when two more were added a commit earlier, and asserting the
set is the only guard that survives the next two.

Found by the person it was built for, on a real share between two
accounts, which is where it was always going to show up: the tree is
built from a query that had already run for their own account, so it
only breaks on the switch.
2026-08-27 09:51:41 -07:00
jcoffey-dev 9c37af7b07 Never overwrite filters we could not read
Adding a filter from a message reported success while the script on the
server never held more than two rules (#76). Rules were being destroyed,
and the confirmation was a lie.

Three links, each defensible alone:

  1. load() recorded a *failed* blob fetch as `contents[id] = ""`.
  2. sieveToRules("") returns [] -- "this script has no rules", which is
     indistinguishable from "we could not read this script".
  3. Saving rewrites the whole script from that baseline, so every rule
     already in it was deleted. The write itself succeeded, which is why
     the UI said so.

No fetch failure was even required: rules() did `contents[id] ?? ""`, so
a script whose content had not loaded yet read as empty too. And
saveScript cached the content it had just written and then called
load(), which replaced the whole map -- discarding it if the refetch
came back short.

The fix is to keep "unknown" and "empty" apart at every step:

  - a failed fetch leaves the key absent rather than storing ""
  - load() merges rather than replacing, so a reload cannot throw away
    what saveScript just wrote
  - rules() returns null for content it does not have, which every
    caller already treats as "do not touch this script"
  - saveRules refuses outright when the baseline is unknown. Refusing is
    recoverable; overwriting is not.

rules() now also reports whether the script was read, because "written
by hand" and "could not be read" want different advice -- one is
permanent, the other is a reload away, and telling someone the wrong one
sends them hunting for a problem they do not have.

Ruled out on the way: the rule codec round-trips fine, eight rules in
and eight out. sieveToRules reads the `# rule:` JSON comments rather
than parsing Sieve, so the generated script's shape was never the issue.
2026-08-26 15:03:53 -07:00
jcoffey-dev 18e493bcd1 Delete all spam, and call folders what the server calls them
Junk Mail can now be emptied in one action, the way every other mail
client offers it: a banner across the top of the folder, and an item in
both the folder's right-click menu and the list's own menu.

The messages are destroyed rather than moved to Deleted Items. Routing
spam through the bin on its way out leaves you with the same problem in
a different folder, and "delete all spam" means gone everywhere else. So
the dialog says it before you commit, and there is no undo.

emptyMailbox already did the hard part -- walking a folder a page at a
time so it survives maxObjectsInSet, which a Deleted Items of 5192 once
did not. All that changed is which folders it will accept. The guard
stays in the store rather than living only in the menus, so a fourth
caller cannot empty the Inbox by asking nicely.

The three entry points share one helper, because three dialogs warning
about a permanent deletion in three slightly different ways is how one
of them ends up not warning at all. A folder with nothing in it offers
the item greyed out rather than hiding it, so it is where you expect it
to be next time.

Folder naming is fixed in the same commit because it changed the same
file, and because testing this is what surfaced it. Two problems, one
cause:

  - The mock called its folders "Trash" and "Sent". Stalwart's defaults
    follow the Exchange convention -- "Deleted Items", "Sent Items" --
    so anything built from a folder's name read differently against the
    mock than against a real server, and every screenshot in the README
    showed a folder list no user has.

  - Worse, and in shipping code: the undo toast took a hardcoded label
    in preference to the folder's actual name, so deleting a message
    announced "moved to Trash" on a server whose folder is called
    "Deleted Items", and reporting spam said "moved to Spam" where it is
    "Junk Mail". The one message whose job is saying where mail went was
    naming somewhere that does not exist. It now prefers the mailbox's
    own name and keeps the hardcoded word only as a fallback.

Verified against the mock: the banner appears only in Junk and only with
something to delete, the dialog counts and pluralises, the messages are
destroyed and Deleted Items stays empty afterwards, the banner
disappears once the folder is, the item greys out when empty, Archive is
offered neither, and Trash still says "Empty Deleted Items".
2026-08-26 12:00:42 -07:00
jcoffey-dev 0a9218f622 Keep settings with the account, not the browser
Every setting lived in localStorage, so none of them travelled between
devices. The sharpest edge is the default identity: with none set the
address that sorts first wins, so mail goes out from an address the
recipient may not recognise -- and someone who sets it at work finds it
unset at home, with nothing to say so. Reported in #54.

They now live in a settings.json in the account's own JMAP Files, beside
the signature images already kept there. ihasmail itself stays stateless:
no volume, no database, nothing to back up separately, and the settings
are covered by whatever backs up the mail store.

localStorage stays as a cache rather than the source of truth, so the
first frame is painted from it and the file corrects it a moment later.
A private window has no cache and shows defaults for that one frame,
which is the trade for not gating the whole app on a network round trip.

Not everything should follow the account. A list-pane width picked on a
27" monitor is wrong on a laptop, and the notification toggles track a
permission the browser grants per-device, so claiming it elsewhere would
be a lie. Those stay local, written as a list of exceptions so that a
setting added later syncs by default -- which is what adding one almost
always means.

Writes are coalesced: update() fires on every frame of a splitter drag,
so a change waits 3s and the newest value wins. A tab going away flushes
first, as does signing out, so a setting changed seconds before either
is not lost.

The ihasmail folder is now hidden from the Files view, contents and all.
Hiding the folder alone would have been worse than showing it: the tree
attaches a node whose parent is missing to the root, so the signature
images would have spilled into the top level as if the user had put them
there. Those images have been visible since signatures shipped.

Requires 0.16 -- FileNode/query cannot see directories before that. On
0.15 settings stay local exactly as they were.

Verified against the mock end to end: folder create, blob upload, node
create, read back, update, re-read. Not yet exercised against the live
0.16.19.
2026-08-26 08:19:57 -07:00
jcoffey-dev c1ef19849e Say it in the words Stalwart 0.16 answers to
Guests added to an event vanished on save and no invitation was ever
sent. Not a guard in the editor, and nothing the server complained
about: ihasmail addresses a participant the way RFC 8984 does, with
sendTo and email, and Stalwart 0.16 keeps that address under
calendarAddress. Handed the RFC's spelling it stores the event, drops
the entire participant map, and reports success. Six shapes were tried
against a live 0.16.19, down to sendTo and roles alone; all six were
dropped, and patching a participant onto an existing event fails
outright with "Patch operation failed".

The same disagreement runs through two more properties. The organizer is
organizerCalendarAddress, not replyTo. A recurrence is a single
recurrenceRule, not a recurrenceRules array — and that one Stalwart
refuses honestly, with invalidProperties, so no recurring event could be
created at all and existing ones showed no repeat.

So writes now use Stalwart's names and reads accept either, since a
mailbox may hold events written by other clients. The mock now refuses
what the real server refuses and drops what it drops: advertising the
RFC spelling is exactly how this reached a live server unnoticed, the
same way the capability-placement bug did.

Verified against 0.16.19: participants, organizer and rule all survive a
create, an update and a re-read, with the roles kept as sent.

Fixes #26
Fixes #30
2026-08-25 08:26:34 -07:00
jcoffey-dev 458eb118b4 Ask the recurrenceId, which is the part that survives expansion
Rules alone were still wrong, in the other direction. A live 0.16.19 was
asked to expand a real weekly series: the occurrences come back carrying
no rule at all — only the master has one — and Stalwart spells that
master's rule "recurrenceRule", singular, not the RFC 8984 array ihasmail
looks for. So a genuine occurrence would have read as a one-off, and the
delete dialog would have offered to delete "this event" while deleting
the series.

What an occurrence does carry is a recurrenceId, which a one-off never
has. Master by its rule under either name, occurrence by its
recurrenceId. The tests carry the shapes the live server returned.
2026-08-25 08:18:38 -07:00
jcoffey-dev 330cecfb04 Ask only the recurrence rules, the live server settles it
A probe against the live 0.16.19 says a one-off event comes back from an
expanded query as id "eaaaaai" with baseEventId "i" — an instance id of
its own, and a base that is a different event. The clause that treated a
differing base as an occurrence of a series would therefore have gone on
calling every event recurring, which was the bug.

So recurrence rules alone decide it. What that gives up is an expanded
instance that arrives without its rules attached; whether Stalwart does
that is still to be checked against a real series.
2026-08-25 08:12:06 -07:00
jcoffey-dev 8b22ea9aab Stop calling every event a series
A one-time event opened for editing said "this is a recurring event —
changes apply to the whole series", and deleting one offered to delete
all occurrences of an event that has exactly one.

Three places asked whether an event had a baseEventId and took that for
recurrence. It isn't: the calendar loads its range with expandRecurrences,
and Stalwart puts a baseEventId on everything it returns that way, a
one-off pointing at itself included. The mock never sets the field at
all, which is why this only showed up against a real server.

They now share isRecurring(), which asks about recurrence rules, and
treats a base that is some other event as an occurrence of a series too
— so an expanded instance that travels without its rules is still
described honestly on the way to being deleted.

Fixes #25
2026-08-25 07:01:23 -07:00
jcoffey-dev e720623895 Hold a message in the server's queue until the time you asked for
Scheduled send, which the README listed as needing server support that
Stalwart has had all along. The delay cannot be asked for directly --
RFC 8621 makes `sendAt` read-only and server-derived -- so it goes on the
envelope as an RFC 4865 `HOLDUNTIL` parameter, and the server reports back
the time it settled on.

Stalwart advertises this in the *account* capability, not the session-level
one (which is empty): `maxDelayedSend` of thirty days and `FUTURERELEASE`
among its `submissionExtensions`. The composer offers scheduling only when
both are there, and never offers a time the server would refuse.

A held message goes to a Scheduled folder rather than Sent, because
`onSuccessUpdateEmail` would otherwise file it as sent the moment the
submission is created, and it has not been sent. Nothing moves it out when
the hold expires, so the folder is reconciled on the way in: released
messages to Sent, cancelled ones back to Drafts. Cancelling uses a separate
`Email/set` rather than `onSuccessUpdateEmail`, whose key Stalwart reads as
an Email id and not, as the RFC says, a submission id.

The mock grows the whole lifecycle, and learns to resolve creation
references while it is there -- it had been quietly declining to create any
submission at all, since sending names its message as `#m`. Because
Stalwart's own `futureRelease` setting defaults to off and then drops the
hold in silence, `npm run dev:mock:no-future-release` reproduces that.

Verified end to end against the mock; not yet against the live server.
2026-08-24 22:07:29 -07:00
jcoffey-dev 7095968282 Empty a folder in batches the server will accept
Emptying Deleted Items back-referenced one Email/query straight into one
Email/set, so every id in the folder arrived in a single call. Stalwart
refuses the whole call over maxObjectsInSet with requestTooLarge, which
left a folder of 5192 messages impossible to empty at all.

Walk the folder a page at a time instead: the filter re-runs each pass,
so the next page is whatever is still there. A pass that destroys nothing
stops the loop and reports the server's error rather than spinning.

The same defect sat in two neighbours. Delete-forever on a large
selection sent every id in one Email/set, and mark-all-read fed up to
5000 ids into an Email/get that only echoed them back - past
maxObjectsInGet, and for no gain, since the query already returned them.
Both now page through the same ceiling, read from the session rather than
hardcoded.

The mock advertised maxObjectsInSet but never enforced it, so none of
this could fail in a test. It now rejects oversized get and set calls the
way Stalwart does.

While here, restrict emptying to Deleted Items. It was offered on Junk
too, where a permanent one-shot clear is harder to justify; Junk is now
select-all plus Delete, which takes the batched path.
2026-08-24 12:55:23 -07:00
jcoffey-dev 5575d540b8 Fix sending: never send null for an empty header property
Every message ihasmail sent set cc, bcc and replyTo to null when unused, and
inReplyTo/references likewise on a new message. Stalwart parses those
properties with try_into_address_list, which returns None for null, and the
create is rejected outright:

    if let Some(addresses) = value.try_into_address_list() { ... }
    else { response.invalid_property_create(id, header); continue 'create; }

So every send failed with "Invalid property or value.", new messages and
replies alike, regardless of attachments or signature. The mock server
accepts anything, which is why this only showed up against a real server.

Empty header properties are now omitted. On a create there is no previous
value to clear, so null was never needed - only the properties actually being
set belong in the object.

buildEmailObject is exported so the shape can be tested directly, with a
regression test that no property is ever null.
2026-08-23 13:59:33 -07:00