On a server that is not Enterprise the Tenants page is still only the
notice. On Enterprise the notice is gone -- a real installation that has
tenants has the licence -- unless SHOW_ENTERPRISE_NOTICES=1 asks for it above
the list. The public demo will set it: it reports Enterprise so tenants can be
shown, and should not suggest they come without the licence. The setting
reaches the browser as session.ihasmail.server.enterpriseNotices.
A run on the production server with throwaway tenants, a role, lists and a
domain, all removed, found three things the source reading had not:
- Something in a tenant has to be on a domain in that tenant (a list in a
tenant on an unassigned domain is invalidForeignKey), while something in
no tenant may be on a tenant's domain. The account panel's tenant choice
offered every tenant; it offers only the domain's now, and a new account
starts in the tenant of the domain it is made on. The domain list reads
memberTenantId for it.
- A domain created in a tenant puts its DKIM keys there too, and they keep
the tenant from being deleted. They are counted with the rest, so Delete
is not offered while any remain.
- Stalwart lets a domain leave a tenant while the tenant still has accounts
on it, stranding them. The panel asks first and refuses while any are
there.
The refusal to delete a tenant that holds anything was confirmed, as were
tenant create, quota pointers, logo and rename. The mock follows the domain
rule, filters DKIM keys by tenant, and KNOWN-ISSUES records the run.
The non-Enterprise notice is now just "Tenants are a Stalwart Enterprise
feature." Two sentences were reworded and one plural added, in all nine
catalogues, and the old sentences are gone.
On a server that does not report Enterprise -- or reports no edition --
tenants hold nobody to anything beyond an ordinary user's permissions, so
the page is the notice alone: no New tenant, no search, no list, and no
tenant query is made. The mock's edition is MOCK_EDITION now (default oss,
as before), so MOCK_EDITION=enterprise brings the section back to work on.
A tenant is a separate organisation on one server: its own people,
domains and limits, and an administrator who manages only what is in it.
It gets a section under Access, gated by sysTenantQuery and sysTenantGet,
with a notice on a server that does not report Enterprise, where anyone
inside a tenant is held to an ordinary user's permissions.
The panel edits the tenant's name, logo, role and limits. The logo is an
https address, drawn through the image proxy the strict image policy
requires, or an image data URL. Limits change one quotas/<name> pointer
each, so the four ihasmail does not offer keep their values, and an empty
field is no limit. The role is the most anyone inside can be allowed.
Stalwart keeps no list on a tenant -- each account, group, domain, list and
role names its own -- so what a tenant holds is counted with memberTenantId
queries and shown against its limits. Domains are added and taken out from
the tenant's panel, one memberTenantId change each; only a domain in no
tenant can be added, and its accounts stay where they are. Delete is offered
once every count reads zero.
A tenant does nothing until someone administers it, so the account panel
gains a Tenant choice for an administrator who can read tenants: an
Administrator inside a tenant administers that tenant. Nobody moves their
own account.
The mock has a tenant holding a domain and an administrator, a spare domain
to assign, memberTenantId filters on every query, and Stalwart's rule that
only an administrator outside every tenant may move things into one. A test
of taking a domain back out found that the mock's pointer handling dropped a
top-level null instead of storing it, so nothing had ever been cleared that
way; it stores null now, as the server reads it back.
Nothing about tenants has been written on a live server: production has
none. KNOWN-ISSUES says what was read from source.
Thirty-nine new strings and one plural, in all nine catalogues.
A role is a named set of permissions given to accounts, groups and
tenants. It gets its own section under a new Access heading: every role
listed with the permissions it grants once its bases are followed, and a
panel to create, edit and delete one.
A role builds on others and has everything they grant; a denial anywhere in
the tree wins, which is how Stalwart resolves it (permissions.rs unions
enabled and disabled across the tree, then subtracts). The picker is
Stalwart's own list of permissions, under its headings, searchable and
filterable to what is granted or set here. Each permission is not set,
allowed or denied, and one that is inherited says which role it comes from.
Only permissions the viewer holds can be allowed, because Stalwart refuses
the rest, and a role carrying anything the viewer lacks opens read-only with
no delete, because Stalwart checks a grant but not a delete. Saving sends a
pointer for each permission and base role that changed.
The roles Stalwart hands out by default, read from x:Authentication, say so
before they are changed and cannot be deleted here; a role still in use is
kept by the server, and the refusal names what uses it.
The permission list is Stalwart's schema. A new route, GET
/api/admin/permissions, fetches /api/schema as the signed-in account and
returns only names and labels, behind the same two gates as the registry
methods and held in memory for an hour. Its labels are English only, so
every one of the 661 has a translation in each of the eight other
languages, in its own file keyed by permission name and loaded only when
Roles opens. A permission a later Stalwart adds shows its English label. A
test holds every language to the 0.16.22 snapshot: nothing missing, nothing
stale.
The mock answers x:Role/set with the grant check, loops and in-use
refusals, reads the defaults from x:Authentication, and serves the schema
gzipped as the real one is.
Fifty-two new strings and two plurals in all nine catalogues, and 661
permission labels with 59 headings in each of the eight translations.
A mailing list is an address that passes mail on to everyone on it. To
Stalwart it is its own object, x:MailingList, behind sysMailingList*, so
it gets its own section under Directory after Groups: search, fifty to a
page with each list's recipient count, and a panel to create, edit and
delete one.
Recipients are a property of the list, so unlike a group's members they
save with the rest of the panel. What Save sends for them is only what was
added and removed, one recipients/<address> pointer each -- the patch the
live server accepted -- so a recipient added elsewhere while the panel was
open is not taken out. They can be pasted several at a time, from a
spreadsheet column, a comma-separated line or Name <address>; anything with
an @ that is not an address stays in the box with a note. Past a dozen, a
filter narrows them.
That is all a list is in Stalwart -- no owners, moderation or posting
rules -- so that is all the panel offers.
The mock answers x:MailingList with two lists, the recipient set's live
shape, and the refusals a wrong address, a clash with an account and a
missing permission get.
Twenty-five new strings and one plural, in all nine catalogues.
A group is a shared address and mailbox and the people who share it. To
Stalwart it is an x:Account of type Group, behind the same sysAccount*
permissions as a person, so it sits under Directory beside Accounts:
search, a page of fifty with each group's member count, and a panel to
create, edit and delete one.
Membership lives on the member, not the group. Members are the users whose
memberGroupIds name it, and adding or removing one is a single
memberGroupIds/<group> pointer on that user's account -- true or null --
which leaves their other groups alone. Changes apply straight away rather
than riding on Save, so the list is always what the server has. Nobody can
add or remove themselves, the same line the account panel draws at one's
own role.
A group's role is Default or Custom, not a person's User or Admin, and it
is what the group may do: in 0.16 a user's permissions come from their own
roles only, and a group gives its members what is shared with it. Only
roles the viewer could grant are offered.
Delete takes the members out first and then deletes the group, the order
a domain's keys go before the domain, because the registry keeps anything
another object names. A role that cannot change the members' accounts is
not offered a delete it could only half finish.
The mock's groups had a person's roles, accepted a memberGroupIds filter
without applying it, and answered a linked delete with the wrong shape;
all three follow the source now, and it refuses nested groups and
memberships of things that are not groups.
Nothing about groups has been run against a live server yet: production
has none, and every operation is a write. KNOWN-ISSUES says what was read
from source.
Thirty-five new strings, two of them plurals, in all nine catalogues.
A line under the cards says where the rest is: detailed metrics, the
delivery queue, logs and server settings are in Stalwart's own
administration. It links there when the operator sets STALWART_ADMIN_URL,
and stays plain text otherwise, because STALWART_URL is how this server
reaches Stalwart and is often an address no browser can open.
Several servers: a servers file entry may now be an object,
{"url": ..., "adminUrl": ...}, and a session routed to that server gets its
adminUrl. A routed domain without one gets no link rather than the default
server's, for the same reason routing never falls back. The URL is sent
only to a session that may administer.
The shipped example file stopped the server at startup: its "_comment"
key was read as a domain and refused as not a URL, while the test that
checks the example skipped it. Keys starting with an underscore are notes
now -- no mail domain starts with one -- and the example is also loaded
through the real parser in a test, so the two cannot disagree again.
Two new strings, in all nine catalogues.
Administration used to open on its first section. It opens on a grid of
cards now: users, domains, messages waiting in the delivery queue, server
memory, and the last 24 hours' received and sent. Each card is there only
when the role holds what its number needs -- a count is a query, the
metric history a query and a get -- so a helpdesk role that reads accounts
and domains sees those two cards and nothing about the server.
What the cards count is whatever Stalwart answers for the signed-in
account, which scopes a tenant administrator's accounts, domains and queue
to the tenancy. The metric history has no tenant in it, and Stalwart's
Tenant Administrator role does not hold it, so a tenant's dashboard is
users, domains and pending.
The history is Enterprise-only and switched off by default. A server that
refuses it leaves those cards off; one that records nothing says so rather
than showing zeroes. Received and sent add up the queue counters Stalwart's
own dashboard uses, filtered with the comparison names the live server
accepts (a bare timestamp is unsupportedFilter). The column count follows
the number of cards so rows stay even, and falls back by the grid's own
width rather than the window's.
The server's test for whether an account is offered Administration matches
the client's again, now that a count is enough. The mock answers the queue
and an hourly history ending in the current hour; MOCK_METRICS=off refuses
the history as Community does, a tenant administrator gets the queue, and
helpdesk reads domains, as the demo's does.
ROADMAP and FEATURES said reporting and queues were out of scope; they say
the dashboard reads a handful of numbers and that managing queues, logs
and settings stays out. KNOWN-ISSUES records what was settled on the live
server and what was only read from source.
Fourteen new strings, in all nine catalogues.
The sidebar's right edge is now a splitter, like the one between the
message list and the reading pane: drag it between 240 and 480px, move it
with the arrow keys, double-click to put it back. The width is a device
setting, and stays null until someone drags, so a width set in the
reader's own CSS through --sidebar-w is kept until they choose otherwise.
Hidden on a phone, where the sidebar is a drawer, and while collapsed.
Arrow keys on either splitter moved the pane and never saved it: the
keyboard path called onResize without onEnd. It ends each key press now,
and both views keep the in-progress size in a ref as well as state, so
the end reads the value set in the same tick.
The message-list splitter's accessible name was an untranslated literal.
It goes through translate() now, and it and the sidebar's new name are in
all nine catalogues.
Closes#345
Both said the live instance runs 0.16.21. KNOWN-ISSUES now records that
0.16.22 was tested and lists what it changed for a client, and notes the one
entry it touches: an event read by its stored id reports a null baseEventId,
while a one-off from an expanded query still carries a base.
The shareWith entry still described calendars and address books leaving the
field out, which 0.16.21 fixed; it now says so, and that Mailbox/get alone
still omits it.
A session signed in without "This is my own device" can no longer
administer. The server withholds the account's permissions from it and the
JMAP proxy refuses registry methods beyond the account's own, the same gate
ADMINISTRATION=0 uses. A borrowed or shared machine is where nobody should
be able to reset a password or remove a domain.
An administrator in such a session still sees Administration in the account
menu, greyed out, with the reason and the fix: sign in again with the box
ticked. The server tells that session only that the account administers.
The gate now reads the body only when it could name a registry method --
"x: in the text, or a \u escape that could spell one -- so ordinary mail
traffic from an untrusted session is forwarded untouched.
1 new string, translated in all nine catalogues, quoting each language's own
label for the tickbox; strings falling back to English stay at 16.
A role that can read domains now finds a Domains section beside Accounts:
list and search with each domain's account count and whether its DNS, DKIM
and certificate are managed automatically; add a domain; edit its
description, other names, catch-all address and plus addressing; copy its
DNS records one at a time or as a zone file; see its DKIM keys and their
stage; and remove it once no accounts use it.
The records come from the zone file Stalwart computes per domain. A long
DKIM record, which the BIND serialiser splits into quoted chunks, is joined
back into the single value a DNS provider's form wants.
Removing a domain takes its DKIM keys first, in the same request, because the
server will not remove a domain its keys still name. Removal is not offered
while accounts use the domain, or when the role cannot remove the keys.
The Administration nav is now built from the sections the role can read, and
the menu appears when there is at least one. The mock gains domains, DKIM
keys and zone files.
61 new strings, translated in all nine catalogues; strings falling back to
English stay at 16.
ADMINISTRATION=0 at launch removes in-app administration for everyone. The
account's permissions are no longer sent to the browser, so the menu never
appears, and the JMAP proxy refuses Stalwart registry methods other than the
account's own (settings, password, app passwords, API keys, public keys,
masked addresses). Hiding the menu alone would have left an administrator's
browser console able to make every call the menu made.
With administration on, the request body streams through untouched as before;
only an installation that turns it off reads and checks the body, forwarding
the parsed form so the server receives exactly what was inspected.
An account whose Stalwart role manages accounts now finds Administration in
the account menu. It lists, searches, creates and edits accounts -- display
name, other addresses, role, storage limit -- sets a new password, and
deletes, each offered only when the role holds the matching permission.
The server keeps the permissions list from GET /api/account, which it
already called for the edition and threw the rest away. Everything else is
JMAP x:Account, x:Domain and x:Role calls through the existing /api/jmap
proxy, so nothing new is stored and Stalwart decides every call.
Stalwart checks a grant against the caller's permissions but not a password
change or a delete, so an account that outranks the viewer is shown
read-only. Your own password is changed in Settings, which re-seals the
session; changing it here would strand it.
The mock server gains a directory behind the same permission names, with
MOCK_ROLE choosing admin, tenant-admin, helpdesk or user.
68 new strings, translated in all nine catalogues; strings falling back to
English stay at 16.
Both happen in the background. The phone stays where it is.
This was twice described as impossible, here and in FEATURES.md: the
service worker was said to have no session, so anything touching mail had
to open the app. That is wrong, and checking it rather than repeating it
is the whole of this change. ihasmail's session is an httpOnly cookie
against its own origin and the only other thing the API asks for is a
fixed `x-requested-with` header, which is not a secret and is not held
anywhere. A same-origin fetch from the worker carries the cookie like any
other. Confirmed against the mock: logging in with curl and then issuing
`Email/set` with nothing but that cookie and the static headers marked a
message read and moved it to Archive, HTTP 200. Nothing the tab holds in
memory is involved, because the API asks for none of it.
Two actions, because `maxActions` is two on Android and anything past it
is dropped without a word. Archive and Mark as read are the two worth
having: they are what somebody does to a notification they have already
read the whole of. Reply is not among them -- it would have to open the
app, which is what tapping the notification does already.
The worker still cannot reach a catalogue. It is plain JavaScript copied
into the build, outside the bundle, with no i18n and no idea which
mailbox is the archive. So the app writes both down in the same cache it
already uses for handoffs, and rewrites them whenever the language, the
account or the folder list changes. Where there is no such note -- between
installing this worker and next opening ihasmail -- the notification
appears with no buttons at all, rather than English ones over a mailbox
guessed by name. That also fixes two strings the worker had always shown
in English regardless: "New mail" and "(no subject)".
A session can be gone by the time a button is pressed. That comes back as
a refusal and the notification says so, rather than vanishing as though
it had worked. It does not open the app to recover: being interrupted is
what the button existed to avoid.
The two claims that were wrong are corrected rather than quietly deleted,
including the one about push renewal -- which still needs a tab, but for
a different reason than the one given. The reason is when the worker
runs, not what it may do: it wakes only for a push, and the push stops
when the subscription lapses.
Two new strings, in all nine catalogues.
ihasmail could hand a file to the share sheet as of #306, and was still
not in it. Share a photo from the gallery, a link from the browser or a
document from a file manager and ihasmail was not among the places it
could go, which is the one piece of operating-system integration a mail
app is expected to have.
A share is a POST that navigates, and there is nothing on this side that
can answer one: the app is a client-side router with no endpoint at that
address, and the server behind it would need a route that understood the
composer. So the service worker intercepts it, takes the form body, puts
the files and text in its cache, and redirects to the app -- which finds
them on start and opens a draft holding them. The subject is the shared
title, the text and the link become the body, and files are attached and
begin uploading. Nothing is addressed: a share says what to send, never
who to.
The body is pushed in above the signature rather than passed to open(),
because open() only fits a signature when it is given no body at all --
the obvious version drops the signature from every message that started
as a share, and nothing about the draft looks wrong afterwards.
Collected on every start rather than when the launch URL says so. A share
to a signed-out ihasmail lands on the sign-in page, and there is no
account to attach to until it is done, so the payload has to outlive a
redirect and a login -- which the query string does not. What that costs
is a stash nobody came back for, so it carries a timestamp and expires
after ten minutes.
`accept` names wildcard families and explicit types and extensions both.
A mail client attaches anything, but wildcards are not in the
specification and operating systems differ over which form they match on,
so the explicit list is what holds if the families are ignored.
The cache name the worker and the app have to agree on now has one home
on the app side. It was written out twice, and a drift would not fail --
a push verification would simply never complete and a share would arrive
at an empty composer.
One case is deliberately left to fail loudly: an app still installed
whose worker has been cleared away POSTs to the server, which answers
405. A server route would trade a plain error for a silent nothing, and
the payload is gone in both -- it only ever existed in that request body.
Verified by test, not on a device: Android is the only place this exists
at all, and the extension driving Chrome is not connected here. The
handoff is pinned from the tab's side against a cache shaped exactly as
the worker leaves it, since the two files never see each other.
Three things an installed ihasmail did not do that a phone user expects,
and all three are about the app once it is off the browser tab.
The unread count was painted into the tab title and the favicon, neither
of which exists in `display: standalone` -- so putting ihasmail on a home
screen threw the count away entirely. It goes to the Badging API as well
now. Web Push marks the icon while the app is closed, and marks it with a
dot rather than a figure: the service worker has no session to ask how
many messages are unread, and a push carries the new mail rather than a
total, so counting the payload would badge "2" over an inbox holding
forty. The next tab to open writes the real count over it.
Sharing is new. Everything that left ihasmail left as a download, which
on a phone is close to a dead end -- the file lands in Downloads and
whoever meant to send it somewhere goes looking for it in a file manager.
The share sheet is now on the message menu, on each attachment row, and
in the file viewer, which is where an attachment is already open and
where both callers meet. A message shares as text rather than as the
.eml beside it: a share sheet is aimed at everything that is not a mail
client, and an .eml in a chat app is an attachment nobody can open.
Every control feature-detects, and sharing a file is a separate question
from sharing at all -- desktop Linux and Firefox have neither, and not
every browser with `share` takes files. Anything that fails, including
the transient activation running out while a large attachment is fetched,
falls through to the download the button sits beside, so the worst case
costs a tap rather than the file. `NotAllowedError` is reported as
unsupported for that reason: it cannot be told apart from a refusal, and
a toast about activation is not something a reader can act on.
The share strings are contextual keys rather than the existing "Share…".
That one means granting another account access, and several languages use
a different verb for it -- German had "Freigeben" where the sheet wants
"Teilen". Three new strings, in all nine catalogues.
The manifest gains `launch_handler: navigate-existing`, so a mailto:, a
shortcut or a notification tapped while ihasmail is running arrives in
the copy that is running: two windows on one inbox disagree about what
has been read. `focus-existing` would have been wrong -- it only focuses
and leaves the target URL to launchQueue, which nothing here consumes, so
it would swallow the mailto. There is deliberately still no `id`, and the
manifest now says why: it is the one member resolved against the origin
of start_url rather than against the manifest's own address, so no
relative form can name a subpath mount, and the default id already is
start_url -- writing one now would give every installed copy a new
identity and orphan it as a second app.
Verified by test rather than on a device: the extension driving Chrome
was not connected, and Chrome on Linux has no Web Share to drive anyway.
The preview dialog is covered by a component test that stubs the browser
both ways.
Contacts and calendars disagreed on a re-import: a vCard or LDIF entry
whose identity a book already held overwrote the card there (#242, #274),
while an event whose UID a calendar held was counted and thrown away
(#222). The asymmetry was never decided -- it was where each half stopped.
Decided on #279: calendars update too, with two properties held back.
`participants` carries every attendee's accepted/declined and
`recurrenceOverrides` holds every "just this Wednesday" edit made here.
Both are decisions taken after the file was written, and a file that
mentions them at all describes them as they were at export, so writing
either one over would destroy work silently and return no error. A
corrected export now fixes the time, the title and the location, and
leaves who said yes alone. `uid` is held back with them: it is what the
two were matched on, so it is already equal.
The scan returns uid -> id rather than a set of UIDs, since updating
needs something to address, and creates and updates now share one
`maxObjectsInSet` budget the way contacts' `writeCards` does -- 300 new
and 300 changed batched separately would be two calls of 300, neither
over a ceiling of 500 and both refused. Counts become created/updated,
reported as the contacts import reports them.
Still no scheduling messages, on an update as much as on a create. That
is a real cost -- an event a re-import moves is moved here and nowhere
else -- and it is the lesser one: an import is not the place to start
mailing a room full of people who never asked for it.
Driven against the mock end to end: a second file with the same UID
updated the event in place, took the file's title, start and location,
and left an accepted RSVP and a per-occurrence override untouched even
though the file carried participants of its own.
The README badge still read 0.16.20, in both the label and the shield it
links to. It is the first version number a reader sees and it was the one
place the prose update missed, because it is HTML rather than Markdown.
Two entries had gone further than stale and were wrong. FEATURES said
occurrence ids are not stable across a write, and KNOWN-ISSUES carried
that as a live hazard with the five-week series that proved it. 0.16.21
fixed exactly that: an occurrence is identified by its recurrence id now,
and holding an id across a write keeps it on its own date. Both entries
say so, keep the old behaviour and the evidence for it because the client
still supports 0.16 as a whole, and record what replaced it.
The defence in the client stays either way, and the reason is written
down: re-resolving by recurrenceId costs one lookup, a date can still
leave a series, and 0.16.20 is still a server someone may be running.
The KNOWN-ISSUES header now says the live instance runs 0.16.21 and,
unlike the upgrades before it, that this one was re-run rather than read
against the diff — with what was exercised by hand.
Themes were not in "What's in it" at all, which is odd for something a
reader sees before anything else. There is now a bullet for the twelve,
saying that palette and light-or-dark are separate choices and that a
palette which would not meet the contrast this app claims is not written.
FEATURES lists the six new palettes and what each borrows for its light
half, and records the rule that changed with them: body text used to be
checked and then accepted or rejected, which would have turned away five
of the six over a bar their designers never aimed at, so it is now lifted
along its own hue like every other text tone. Twenty-one of the twenty-two
borrowed halves need at least one lift.
The message-theming entry gained the second switch, including why the
first one alone did nothing for most real mail.
The Stalwart section records what the release is validated against rather
than only what it requires: 0.16.21, run against a real instance, with the
four client-visible JMAP changes named. The mock section gains its third
switch and says it tracks the current release, confirms each behaviour
against a real server first, and rewrites rather than deletes the test
that pinned an old behaviour.
A signed message now says whether that holds up, as it is read. This is
verification only: nothing here signs, encrypts or decrypts, and the
private-key question that blocks those is untouched. Verifying needed
none of it, because the certificate travels inside the message -- which
is why this is the half that could be built.
What it checks. For multipart/signed carrying PKCS#7, the exact bytes of
the signed part -- headers included, canonicalised to CRLF -- are hashed
against the messageDigest attribute, and the signature over the signed
attributes is verified with WebCrypto against the certificate inside the
message. RSA PKCS#1 v1.5 and ECDSA over P-256/384/521, with SHA-256, 384
or 512.
The trust model is the design, and it is deliberately small. A browser
has no system trust store, and the certificate arrives inside the
message, so anyone can self-sign as anyone: on its own a good signature
shows only that the sender held the key they attached. So the word
"verified" is never rendered, and the reassuring case is not the loud
one. What carries the weight is remembering -- the first signed message
from an address pins its fingerprint, later ones are compared, and a
signer that changed is reported with both names and told to check by
another route. Trust on first use, no certificate authority anywhere.
The pins live in the account's settings rather than the browser: one
that only a single device knew would greet the same correspondent as new
everywhere else, which is how people are trained to click past the one
warning that matters. A pin records the message that created it, so the
message that established a signer keeps saying so instead of appearing
to be corroborated by itself -- without that, the very first signed
message anybody receives reads as "the same signer as before", where
before is itself. A changed, mismatched or expired signer is never
pinned, since writing the anomaly into the baseline makes every later
message agree with it.
Three things are declined rather than attempted, and all three say
"could not check" rather than "does not check out", because ignorance
and an accusation are different claims:
- OpenPGP, by name. The signature carries no key and there is nowhere
to get the sender's: x:PublicKey is the account's OWN registry, and
a keyserver or WKD lookup would tell a third party who you
correspond with -- the leak the image proxy exists to close.
- SHA-1. Not forgeable in practice today, still not something to put a
tick beside.
- RSA-PSS, whose salt length lives in parameters this does not read.
Guessing wrong would report a good signature as bad.
Nothing validates a chain: no CA bundle is shipped and revocation is not
checked. "Issued by" reports what the certificate claims, and a
self-signed one claims itself.
The DER, CMS, X.509 and MIME readers are hand-written and deliberately
narrow -- no new dependency, and the whole verifier is a lazily imported
8.6 kB chunk that a reader of unsigned mail never downloads. The one
place this is easy to get quietly wrong has its own function and its own
test: signed attributes are signed as a SET OF, not as the [0] IMPLICIT
they arrive as, and hashing the message instead would make every
signature "pass".
Tested against real `openssl smime -sign` output rather than hand-built
fixtures -- RSA, ECDSA, a tampered copy, and a valid signature by a
certificate for somebody else -- because a signed message written by
hand only agrees with whatever its author believed the format to be.
Also driven in a browser against the mock, which now serves three real
signed messages so every branch of the banner is reachable.
Translations: 34 new strings in all nine catalogues, 306 entries.
Falling back to English is unchanged at 24 per language.
Raised on #174 as the other half of a migration -- import, notice
something is wrong, empty the book, correct the export, import again --
and tracked as #277.
The gap turned out to be wider than the ask. Contacts had no multi-select
at all: the only delete in the module was the cross on a single card's
pane, one card and one confirmation at a time. `destroyCards` has taken a
list and batched it against maxObjectsInSet since #218, and nothing in
the UI ever handed it more than one id. So "empty this address book" was
missing, and so was "delete these fourteen".
The list now has checkboxes, on hover the way the message list's are, and
always on a touchscreen where there is no hover to reveal them.
Shift-click takes the run between two rows. The search box gives way to a
selection bar rather than sitting beside it, because what the count
promises is what the search left on screen. A selection is cleared when
the book being shown changes, since carrying it across would leave a
count describing rows that are no longer there and a Delete aimed at
them.
Emptying a book is in the book's own menu, beside the import and export
that moved there in #226, and separate from Delete, which takes the book
with it. A default book cannot be deleted and can perfectly well be
emptied, which is most of the reason it is its own entry.
The part that is not a deletion, and the reason this is not one destroy
over everything in the book: a card filed in two books belongs to both,
and `ContactCard/set destroy` takes it away from both at once. Emptying
one book must not empty another, so a card with a second home is patched
out of this one and left alone. That is reported separately afterwards,
because it would otherwise look like contacts that refused to go.
`destroyCards` now answers with what the server confirmed rather than
throwing on the first refusal. A refusal that took half a selection with
it still deleted the other half, and an error saying only that it failed
sends somebody looking for contacts that are already gone. Both callers
report the count and the reason apart.
Emptying a shared book is deliberately not offered: the cards live in the
owner's account and this client has no path to write there.
One bug found by driving the built app rather than by any test, and worth
recording because of where it hid. The range a shift-click covers was
measured inside the `setPicked` updater -- which React runs when it gets
round to rendering, by which time the anchor ref has already been moved
to the row that *ended* the range. Every shift-click selected exactly one
row, and every store assertion still passed, because nothing was wrong
below the component. The anchor is read before the updater now, and the
contacts view has its first component tests: ten of them, six of which
fail if the measurement moves back inside.
Twelve new strings, in all nine catalogues, so nothing new falls back to
English.
Reported again by the submitter's colleague at LINET after #223 was
closed: duplicate checking was implemented for vCard and never for LDIF,
so re-importing an address book still leaves a second copy of everything.
That was deliberate at the time -- the matching key was an open question
I did not want to answer alone -- but the answer had already been given
on #174 and I closed the issue without acting on it.
The answer, in the submitter's words: an attribute that *can* change is
fine, because it will not have changed between two imports minutes apart.
An import is not a sync. That makes the `dn` usable -- it is the only
identity the file carries, and Mozilla's schema defines no UID -- and it
needs no guessing at all, unlike the name-plus-email fallback I had been
weighing.
So `uidFromDn` derives a namespaced, stable uid from the distinguished
name, normalised for the case and spacing two exports of one directory
differ in. A card the book already holds under that uid is updated rather
than duplicated, merged the way the vCard import merges: what the file
carries wins, what it does not mention is left alone. Reported as created
and updated, which is the pair that was asked for.
Three things worth knowing:
Matching is per address book, so two customer directories that each hold
a `cn=John Smith` stay two people as long as they are filed separately.
Imported into one book they would merge, which is the one way this can be
wrong and the reason the escape hatch is worth naming.
The look-alike count stays, and now means something narrower: entries
that `dn` matching could not catch -- one whose `dn` moved between
exports, and anything imported before there was a `dn` to match on. Those
are still only counted, never merged.
A file holding two entries under one `dn` is malformed, since a directory
cannot, and now becomes one card instead of two sharing an identity.
FEATURES gains the re-import behaviour for both formats; it documented
neither.
Dragging an event across the month grid wrote the date of the cell it
landed on into the event's stored start. Those are the same date only
while the event's time zone is the reader's.
An event kept in Asia/Tokyo at 15:00 is drawn to a reader in Phoenix at
23:00 the previous evening. Dropped on the 11th, it was written as the
11th in Tokyo -- which is the 10th on screen. It went where its own
calendar said rather than where the pointer did, one day short, every
time.
Moving by the difference between the two local days instead moves it
exactly as far as the hand did, and adding whole days to a stored wall
clock leaves the time of day alone without touching the zone -- so the
frame the rest of this path is careful about is still not crossed.
Found by giving the mock an event in a zone that is not the machine's.
Every other fixture used the machine's own, which cannot tell a correct
conversion from no conversion at all: the case that works is the one the
fixtures were all testing.
A JMAP push subscription stays silent until the client echoes back a
verification code. When the code arrives with no tab open, the service
worker leaves it in the cache for the next tab to collect.
Both sides named that entry relatively, and a relative key is resolved
against the URL of whoever is asking. The worker lives at <base>/sw.js, so
it wrote under <base>/; a tab at /mail/inbox/abc looked under
/mail/inbox/. They agreed only when the open page happened to be the root,
which is why this survived: the case that works is the one people try
first.
The failure is quiet in the worst way. A subscription that never gets its
code back simply never delivers, which is indistinguishable from push not
working at all -- there is no error anywhere to notice.
Both sides now build the key from the mount: the worker from the BASE it
already derives from its own location, the page through withBase. Found
while adding BASE_PATH, where the two disagree at every route rather than
only at deep ones; left alone then because it was pre-existing and
unrelated to that change.
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.
A timetable, a rota, a public holiday list: the calendars people are given
as a link, which ihasmail could not show at all.
Nothing is stored. The document is fetched when the calendar is opened and
parsed in the browser; the server keeps no copy, no cache and no schedule,
which is what lets an immutable container serve this. There is no timer
either -- there is nowhere to run one -- so the guarantee is that a
subscription is as current as the last time somebody looked, which is also
when it matters. That is said plainly rather than implied.
The fetch has to happen on the server: a calendar URL belongs to whoever
published it and almost none of them send CORS headers. That makes it the
second place this app knocks on a door somebody else chose, so the guard
the image proxy has always had was lifted out and both now call it. A
second SSRF implementation is how one of them ends up missing a case; this
way there is one, and the extraction is covered by the image proxy's own
tests still passing unchanged.
webcal: is understood, because that is how these are published, and it is
read as https: rather than waved past the checks -- a webcal URL pointing
at loopback is refused exactly like an http one.
Recurrence is deliberately not expanded. RRULE is a small language with a
lot of edge cases, and a subscription quietly showing the wrong dates would
be worse than one showing the first occurrence and saying so.
The parser is a subscription parser rather than an importer: a subscribed
calendar is read-only and redrawn from scratch each refresh, so nothing has
to round-trip or survive an edit, which is most of what makes a full
iCalendar implementation large. What it does have to do is never mis-state
a time -- a DATE is built in local time rather than at UTC midnight, which
would land on the day before for anyone west of Greenwich -- and never hang
on a document somebody else wrote.
Events go through instancesIn like the birthdays, so no view has to know
they are not real calendars, and the calendar they hang off reports no
write rights, so everything that asks before offering an edit declines on
its own. A subscription that cannot be read says so in the sidebar rather
than drawing an empty calendar, which looks like a calendar with nothing
in it.
The calendar could only be edited through the editor, so moving a meeting
half an hour meant opening a dialog, changing two fields and saving. Every
other surface a finger or a pointer drives already answers to a drag.
In the day and week grids an event moves by dragging it and changes length
by dragging its bottom edge, snapping to fifteen minutes. In the month
grid it moves to another day and keeps the time it had, because a month
cell is a day and nothing finer -- changing the hour as well would answer
a question nobody asked.
It goes through the same path a menu edit takes. A recurring event is
asked which dates it means, and the answer runs through runScoped, so a
date the server will only change as part of a whole series offers that
rather than failing.
Three things do not offer a drag, and the reasons are checked in one place
so no grid has to remember all three: a read-only calendar, an event with
no calendar, and a birthday -- which is derived from a contact and has
nothing on the server to move. The reserved classes the swipe gesture was
told to keep out of are exactly the ones that are draggable here, which is
what that reservation was for.
Invitations are not sent. A drag is a scheduling gesture, and mailing
every guest on each nudge of a block is not what the hand was asking for;
a change that should go out with notice goes through the editor.
The new time is computed in the event's own frame rather than through an
instant. Working it out from the reader's local hours and then
re-expressing it in the event's zone converts twice, and the two do not
cancel: caught in the browser, where an event moved two hours the first
time it was dragged in the month grid and then sat still, because after
that its stored time and the reader's agreed. Parsing the stored string
into its parts and adding minutes to those touches no zone at all, and a
resize sends only a duration, so the question does not arise there either.
The theme was one enum -- system, light, dark, ihasmail -- where one value
carried a whole palette and implied dark. That works for exactly one
palette. The two questions now come apart: which palette, and which side.
Classic is the plain light and dark this app has always had. ihasmail's
own palette gains a day version, so the background of the dark one becomes
the text of the light one and the two read as one palette from either end.
Dracula, Gruvbox, Rosé Pine and Tokyo Night are the work of their own
projects, used under the MIT licence, and taken from each project's own
repository rather than from anyone's reimplementation. What was fetched is
recorded in .palette-sources/ and credited in NOTICE.
Giving ihasmail's palette a light half removed a whole special case.
Nothing is one-sided any more, so a palette can no longer override the
mode, the toggle no longer has to set a palette aside on the way to light
and remember it, and the greyed-out control that explained all that is
gone. The old lastDarkTheme, which existed only for that, is gone with it.
The shades between the published colours are derived rather than guessed:
these projects publish twelve to twenty values and ihasmail needs about
thirty. scripts/build-palettes.py computes the tiers and then measures
every text colour against the surface it sits on -- 4.5:1 for prose, 3:1
for borders and marks -- lifting anything short towards white on a dark
ground and towards black on a light one, so a lifted tier keeps its hue.
It refuses to write a palette that would not pass.
Every one of the nine halves needed at least one lift. These palettes are
built for code editors, not for prose at this size: Dracula's comment grey
is 3.03:1 on its own background and Rosé Pine's gold is 2.7:1 on Dawn.
Shipping them as published would have quietly ended the WCAG AA claim.
Two things caught while checking rather than while writing. The generated
blocks were appended to the end of the stylesheet, which put them after
the accent variants at equal specificity -- so choosing an accent over one
of the new palettes did nothing at all. They now sit before those rules,
where the existing ihasmail block's own comment says they have to. And
that block was unqualified, so it would have shadowed the new light half;
it is now explicitly the dark one.
Settings written before this carry `theme` and no palette, and are read
through the old enum. `theme` is still written back, derived, because a
device on an older build reads it and would otherwise be stranded on a
theme nobody chose.
Newest-first was the only order, so the mail you had not read yet was
wherever it happened to fall.
Seven presets and up to three levels of your own. It covers the Inbox
alone by default: unread-first is what people want in the folder they
triage and confusing in Sent, where everything is read and the order that
matters is when it went. Search keeps newest-first whatever the setting
says, since a result list is already ordered by the question that was
asked.
The server does the sorting, over the whole folder, for the same reason
search runs there: a list sorted in the browser is sorted only as far as
the browser has loaded, which on a folder of ten thousand is the first
fifty and a lie about the rest.
Two details that are easy to get wrong and were worth pinning in tests.
hasKeyword sorts a boolean and false comes before true, so "unread first"
is $seen ASCENDING while "starred first" is $flagged DESCENDING -- the
other way round. Getting either backwards puts exactly the mail you were
looking for at the bottom. And every order ends with newest-first as a
tiebreak, because a sort whose last level is a keyword or a subject leaves
every tie undefined, and an undefined order changes between two looks at
the same folder for no reason the reader can see.
Sorting on a keyword is optional in RFC 8621, and a server that will not
do it fails the whole query rather than degrading it -- so this setting
could turn a folder into one that does not open. The refusal is caught
once, the keyword levels dropped and the query retried, and nothing is
said: the reader asked for an order and got the closest the server can
give, and a toast on every folder change would be the app complaining
about its own request.
The mock now honours the sort instead of always answering newest-first,
which had it reproducing a server that silently returns a different order
from the one asked for -- the one shape of wrongness a client cannot
detect. MOCK_NO_KEYWORD_SORT=1 reproduces a server that refuses the
keyword sorts, so the fallback can be developed against.
The dates were already on the contact cards and nothing ever showed them,
so the one thing a birthday is for -- noticing it in time -- was the one
thing the app could not do with it.
Derived, not stored. The dates stay on the cards: a second copy of the
same fact drifts the first time somebody corrects one, and keeping a
calendar of its own is exactly what ihasmail does not do. Entries are
generated when a view asks for a range and vanish when the contact does.
They go through instancesIn like everything else, so no view has to know
they are different.
Off until switched on. It is derived data, and a calendar that fills
itself with dates nobody put there is a surprise rather than a feature. It
can also be hidden from the calendar's own sidebar without being turned
off, which is the same distinction the shared calendars already draw.
They cannot be edited or deleted, and that falls out of the design rather
than being special-cased: the virtual calendar reports no write rights, so
every control that already asks before offering Edit or Delete declines on
its own. updateEvent and destroyEvent refuse a synthesised id as well, so
the store is safe whatever calls it -- including anything added later.
Two things about the dates themselves. A card that records only a day and
month is the common case rather than the exceptional one, and gets a
birthday with no age rather than no birthday. And 29 February falls on the
28th in a year that has no 29th: somebody born in February has a birthday
in February, and moving it into March is the arithmetic winning over the
fact. Both are conventions; these are the ones that keep the fact intact.
The mock now carries birthdays on most of its contacts, including one with
no year and one on 29 February, so both cases are visible without a real
address book.
Both sides added a field next to the mail store's selection: the label
counts the sidebar draws, and the flag for a selection that means the
whole query rather than the loaded page. They are independent, so the
resolution keeps both.
Outlook sending in Rich Text packs every attachment into one TNEF blob.
Every other client shows a single unopenable winmail.dat, and the files
inside it are gone as far as the reader is concerned -- which is a
decoding problem rather than a mail one.
Written from the published format: a signature, a key, then a flat run of
attributes, each one a level byte, a 32-bit id carrying its own type, a
length, the data and a checksum. Attachments are delimited by
attAttachRenddata rather than named, which is why the parse is a small
state machine.
The MAPI property stream inside attAttachment is read for two properties:
the long filename and the MIME type. attAttachTitle carries an 8.3 name,
so a file that arrived as "Quarterly Report Final.docx" is QUARTE~1.DOC
there and correct here. The stream stops at a named property (id >=
0x8000) rather than guessing past it, since those carry a GUID before
their value and nothing after one can be trusted to stay aligned.
Decoded in the browser, on request. The server never sees the contents and
has nowhere to keep a decoded copy; doing the work on sight would spend
the bandwidth whether or not anybody wanted what is inside.
A blob that goes wrong part-way through keeps what was read before that
point, whether it ran out or the checksum stopped matching. Half the
attachments beats none: the alternative is a reader who can see the file
is there and cannot have it. The original stays attached either way.
The message body is deliberately not decoded. TNEF can also carry it as
compressed RTF, which is a second format again for a body the reader
already has in plain text or HTML nine times in ten.
The mock now sends one, built by its own encoder rather than by the
parser's fixtures, so the two are independent implementations of the same
description.
A flat list is fine at five labels and unreadable at thirty, and there was
no way to keep one that matters occasionally without it holding a row for
ever.
A label can now sit under another, and each says whether it belongs in the
sidebar always, only while it has unread mail, or never.
Nesting is display only. The keywords stay flat on the message, which is
what keeps them readable by every other client: moving a label under
another rewrites nothing in the mailbox, and a client that knows nothing
about ihasmail sees exactly what it always did. Both new fields are
optional, so a settings file written before this parses unchanged and
means what it did.
Settings sync between devices, so the tree has to survive shapes that
should not exist. A label whose parent was deleted on another device comes
back to the top level rather than vanishing -- a label that disappears
because something else was deleted is one the reader cannot get back. A
cycle arriving from an older device is broken by treating the label that
closes the loop as a root, so nothing is lost and nothing hangs. The
parent picker will not offer a label's own descendants, so one cannot be
built here in the first place.
A label kept by the unread rule keeps its ancestors, whatever they were
set to. A child cannot be drawn under a parent that is not there, and
promoting it to the top level would silently rearrange the tree at the
moment the reader is least able to explain why. The parent comes back as a
container instead, and its own count still says whether it has anything of
its own.
Unread counts come from one request carrying a query per label rather than
a request each, with limit 0 so the server does not send ids that would
only be thrown away. They refresh on the same beat as the folder counts,
since the things that move them are the same things, and a failure is
swallowed: a count is decoration, and the sidebar draws the label without
one.
Also corrects the Labels page, which said names and colours are kept in
this browser. They live in the account's own Files and follow it between
devices, like every other setting that is not about this screen.
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.
Four warnings, in Privacy & safety, and all of them start switched off.
That is not timidity. A client that begins by interrupting is one people
learn to click through, and a warning clicked through without reading
costs the same attention and buys nothing. The outside-sender warning
could not be on by default in any case: it measures against the domains
that count as yours, and with nothing configured every message in the
mailbox is from outside.
Your own identity domains are always internal and are not configuration.
An account signed in as [email protected] warning that example.com is
external would be absurd, and making it be typed in first is a foot-gun
that leaves the feature useless the moment it is enabled. Configured
domains are additional, and cover their subdomains -- matched on a dot
boundary, so example.com covers mail.example.com and not notexample.com,
which is exactly the domain somebody registers on purpose.
The four:
A banner names the sender's domain on a message from outside.
Sending outside names the outside recipients and asks, rather than
refusing. "This is going outside" is a rule and not something the sender
can check; a list of addresses is. It reads the full identity list rather
than the visible one, since hiding an identity from the From menu does not
make its domain somebody else's.
Sending to a large group asks once the count crosses a threshold, which is
what catches a reply-all onto a long thread. It counts people rather than
headers, so one address in To and nine in Cc is a message to ten.
Opening a link asks when the destination is not trusted, and always when
the link's text names one domain while its destination is another -- even
where that destination is trusted, because being trusted is not the same
as being the place the text claimed. On that mismatch the offer to trust
the domain is withheld: what would be trusted is the destination, and the
destination is not the thing in question. Anything that is not http or
https is left alone, since warning about a mailto: is noise and noise is
how a warning stops being read. Both bodies are covered, because a link in
a plain-text mail is linkified by us and points wherever it likes just as
readily as one the sender marked up.
The click is cancelled and the navigation re-issued after the answer,
since there is no way to hold a real navigation open across a dialog. The
reopen runs in the continuation of the dialog's own click, which is still
the gesture a popup blocker wants to see.
`BASE_PATH=/mail` mounts the whole app under a prefix, for a host that is not
ihasmail's alone. Unset -- every deployment that exists -- is the domain root
and is byte-for-byte what it was: the canonical form of the setting is the
empty string, and `""` concatenated onto `/api/health` is `/api/health`.
That choice of canonical form is the whole design. A trailing slash would have
been the obvious alternative, and it fails quietly in exactly one place: at the
root it makes `//api/health`, which is not a path on this host but a
protocol-relative URL to a host called `api`. One call site forgetting to
branch is a request leaving the origin. So the empty string, one leading slash,
no trailing one, worked out once in `scripts/basePath.mjs` -- plain JS, next to
`version.mjs`, because the web build and the server both have to reach the same
answer and two implementations of "what does /mail/ mean" is precisely the bug
where the server serves an app whose script tags point somewhere else.
`/mail`, `mail`, `/mail/` and `//mail//` all mean the same mount; a deployment
should not fail over a trailing slash.
Unlike everything else ihasmail is told, this one cannot wait for the process
to start. The bundle writes its own asset URLs into index.html, so `BASE_PATH`
is read at build time for Vite's `base` as well as at run time for the routes,
and the Dockerfile carries one value into both. Get them out of step and the
page comes up blank with a 404 in a console nobody has open -- so the static
handler, which is reading index.html anyway, checks what it asks for and says
so in the log once per build.
Everything moves together. The API mounts at `${base}/api`; the router is
given the base once, so every `<Route path>` and `<Link href>` stays written
root-absolute and wouter does the rest; `apiFetch` adds the prefix in one place
rather than at forty call sites; the session cookie's Path narrows to the mount
so two instances on one host cannot sign each other out.
Two things need no prefix at all, and it is worth saying why they were not
given one. A manifest's members resolve against the manifest's own address, so
relative URLs there follow the mount with nothing substituted at build time --
which is also why `public/` needed no template step. The service worker is the
same trick: it is served from the mount, so `new URL("./", self.location)`
tells it where that is, and a worker that derives the value cannot disagree
with the page that registered it.
Anything outside the mount is a 404 rather than the app shell, and
`stripBasePath` does not use `startsWith` -- under `/mail` this process shares
a hostname, and answering `/mailbox` with our index would shadow a neighbour
instead of letting it 404 honestly. For the same reason the notification-click
handler now checks the path as well as the origin: `includeUncontrolled` widens
`matchAll` to the whole origin, which off the root would have navigated a
stranger's tab to our inbox.
Inline images in a draft were the one silent trap. They are matched by their
blob URL on the way out, once unanchored and once anchored, and a bare
`/api/blob/` still appears inside `/mail/api/blob/...` -- so one pattern would
have replaced the tail and left `/mail` in front of a `cid:`, and the other
would have missed and sent the message linking to the sender's own webmail.
Both patterns are built from the base now.