95395200a3ff4b130628de3de151f1b0a04550c6
17
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
1dafb4bc79 |
Add Domains to Administration
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. |
||
|
|
82e217155b |
Add Administration, starting with accounts
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. |
||
|
|
c84f190f76 |
Check S/MIME signatures, and remember who signed
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.
|
||
|
|
45c8929697 |
Withdraw the key manager, and keep what probing it established
A Settings section for public keys is furniture, not a feature. Nothing in ihasmail signs, encrypts, decrypts or verifies with a key, so the page could only ever tell the reader in its own footnote that adding one does nothing. It is withdrawn on that reasoning -- the same reasoning that closed PR #67, reached again with the code in front of us. So this reverts every user-visible part of it: the section, the lib, the mock handlers, the component and the 261 catalogue strings. Nothing in web/ or server/ differs from main now. What stays is the part that was expensive and is true regardless. The x:PublicKey registry was probed against a live 0.16.20 on 2026-09-05, and the findings are now in KNOWN-ISSUES rather than in a closed pull request -- which is where they sat for the nine days between #67 and this branch, and why the work was done twice. Consolidated into one entry, framed as what Stalwart does rather than what ihasmail offers: - an ordinary user may read and write their own keys, whatever the permissions table says - the registry takes S/MIME certificates as well as OpenPGP keys, and parses both -- confirmed with a real self-signed X.509 certificate, and a malformed one gets its own BER decoding error - a key can parse and still be refused, with different words. A sign-and-certify key -- what `gpg --quick-generate-key` makes -- gets "Could not find any suitable keys", which is not a paste error and must not be shown as one - emailAddresses comes back as {} when empty, an object where a list property should be an array. It type-checks, then throws in join() - a create answers with the id alone; patching `key` is allowed - expiresAt is the registry's field and is not derived from the key ROADMAP now says plainly that key management has been built and withdrawn twice, that the registry is not the obstacle, and that verifying a signature -- which needs only public keys -- is the shortest route to a key being worth having. Encryption at rest moves from "not offered yet" to refused: it is a one-way door, since turning it off does not decrypt what is already there, and that is not a switch to hand an ordinary user however easy it would be to add. |
||
|
|
e93d42d27e |
Manage public keys, over Stalwart's x:PublicKey registry
A new Settings section, next to Identities & signatures: list, add, rename and remove the OpenPGP public keys and S/MIME certificates published on this account. Only public material -- no private key is stored, requested or sent by any of this. This is PR #67 revived. That branch was built against 0.16.19, closed unmerged on 2026-08-26, and shares no ancestry with main after the email scrub, so it is ported rather than rebased: the four files it added are carried over, the three it edited are applied by hand, and everything it claimed was re-probed against the live 0.16.20 on 2026-09-05. The i18n work is new -- nine catalogues landed on 2026-08-31, after that branch was written. What the re-probe confirmed, unchanged from 0.16.19: - An ordinary user may read *and* write their own keys, though the permissions table lists every sysPublicKey* permission as administrative. get and query both answered for a normal account, and a malformed create came back invalidProperties naming `key` rather than forbidden -- a rejection of the key, not of the person. - The server parses the key and says precisely what is wrong. So ihasmail does not validate key material; the server's message is shown verbatim, as password-policy rejections already are. - urn:stalwart:jmap is still absent from the session's top-level capabilities and present per-account, so the check that reads all three places is still the one that works. What it added, none of which was known before: - A key can parse perfectly and still be refused, with different words: a sign-and-certify key with no encryption subkey -- what `gpg --quick-generate-key` produces -- gets "Could not find any suitable keys in OpenPGP public key". That is the rejection somebody exporting from GnuPG will actually meet, and it is not a paste error, so collapsing both to "invalid key" would send them back to the clipboard for a problem that is in the key. - emailAddresses comes back as {} when empty -- an object where a JMAP list property should be an array. It type-checks, then throws in join() while the list renders. normalize() checked the shape already; there is now a test saying why, and the mock answers {} the same way, because one that helpfully returned [] would let that crash ship. - A create answers with the id alone, no createdAt, so adding a key reloads rather than believing the response. - destroy works and leaves the registry empty. PR #67 shipped that path untested -- its live probe was refused before anything was created, so there was nothing to destroy. - Patching `key` is allowed by the server. The mock still refuses it, now deliberately rather than for want of evidence: ihasmail replaces a key by adding one and removing the old, which keeps createdAt meaning what it says. x:EncryptionAtRest still does not exist on 0.16.20 -- asking for it is an unknownMethod. encryptionAtRest is a field on x:AccountSettings, and its value is a typed object ({"@type":"Disabled"}) rather than the bare string ROADMAP described. Nothing here writes it. An empty description is now sent as empty rather than filled in with "Key". The description is stored on the server, so a default invented in the client would be whichever language the adder happened to be using; the list labels a blank one at render time instead. Verified in a browser against the mock, not only in tests: both rejections reach the toast in the server's own words with the form still filled in, a good key renders its card, the kind is labelled from the armour header, renaming persists, removing asks first and empties the list, and the whole section reads correctly in German. |
||
|
|
0df62e6b2f |
Say why S/MIME rather than OpenPGP, and why neither is urgent
The entry recorded what the probing established and what the design caveat is, and said nothing about why this is the encryption worth building or why it sits on this page rather than in the tracker. Somebody reading it -- including me in six months -- could reasonably conclude the choice was arbitrary. End-to-end encrypted mail never reached the mainstream, and the reasons are structural rather than a tooling problem: everyone in a thread has to take part, key discovery was never solved and the keyservers got weaponised, there is no forward secrecy, the metadata stays in the clear, a lost key loses the mail, and it breaks search and spam filtering. EFAIL showed the clients were exploitable too. The privacy win that actually landed was STARTTLS, MTA-STS and DANE, which needed nothing from users. S/MIME wins between the two because it is more deployed where software gets paid for -- native in Outlook and Apple Mail, routine in defence, healthcare, finance and government -- since a CA issues and revokes certificates an IT department can administer, which the web of trust never managed. The last paragraph is the one that will matter in practice: a self-hosted webmail for Stalwart draws the densest concentration of PGP users left, so this will be asked for far more often than it would be used. That is the argument for keeping it here and honest rather than building it on the strength of the requests. Docs only. No strings added, no catalogues touched. |
||
|
|
1f8c12e29e |
Write the S/MIME position down (#273)
It was backlogged in conversation on 2026-08-27 and recorded nowhere in the repository -- not in ROADMAP, FEATURES, KNOWN-ISSUES, the README or the docs. That is the state a plan is in just before it is forgotten, and it is also the state that lets the same probing get done twice. The entry carries what the earlier work established against a live 0.16.19, including the two findings that contradict the documentation: encryptionAtRest is a field on x:AccountSettings rather than an object of its own, and ordinary users can write their own x:PublicKey entries despite the permissions table listing every sysPublicKey permission as admin-only. Dated, and marked not re-run since the 0.16.20 upgrade, the way KNOWN-ISSUES dates its entries. PR #67 is named as the starting point: a working public-key manager, closed unmerged, none of which is in the tree today. The caveat that matters most is last, because it is the one a user cannot undo: turning encryption-at-rest off does not decrypt what is already encrypted. |
||
|
|
0871d3291b |
Write down what the last week's features actually do
FEATURES.md had nothing on three of them. The scheduling panel and what it will not show, the filter editors no longer discarding work in silence, and where compose-as-new can be reached from — including the reply strip, which is the one a thumb finds. KNOWN-ISSUES.md gains the free/busy finding, which is the sort of thing that page exists for: it was checked against the live server rather than assumed, free/busy between accounts turns out to need no sharing set up, and a principal offers no route to its calendars at all. Also what the check did *not* settle, and which way ihasmail errs in the meantime. ROADMAP.md gains the two things left deliberately unbuilt: a scheduling view you can visit with no event in hand, and per-message actions from the message list on a touchscreen, where the gesture that would open them already means "select". |
||
|
|
ded2f4dc1b |
Ten languages, not nine: correct the count everywhere
The picker offers English plus nine translations. I wrote it up as nine in total with eight unread, which is off by one in the direction that undercounts the work and, worse, misstates how many catalogues are waiting for a speaker to read them. Nine of the ten are machine-made and unread — all of them, not all but one — so the sentence that matters reads more sharply than the wrong version did, not less. |
||
|
|
c26ca90e01 |
Document the nine languages, and what Beta means on them
The translations shipped today and the docs still said "no languages but English". They also need to say the harder thing, which is that eight of the nine have never been read by anybody who speaks them. - README gains the language list, with the Beta caveat in the same line rather than a footnote. - FEATURES.md gains an Interface language section: the list, why it is a separate setting from the date locale, and the two design properties that follow — a missing entry renders English, and plurals are asked of Intl.PluralRules rather than assumed, which is why Russian carries three forms and Japanese one. - ROADMAP.md no longer lists translations as "not yet". What replaces it is the half that is genuinely not done: a translation anybody has checked. RTL is split out as its own entry, because holding Arabic, Hebrew and Persian back is a layout decision and not a queue position. - KNOWN-ISSUES.md gains two entries. The unread catalogues, which is the one thing on that page that cannot be closed by testing. And the coverage number that read 100% while two hundred strings rendered English in every language — recorded as a general lesson rather than an i18n one, since a coverage number measures what it can see and the rest is exactly what nobody is checking. |
||
|
|
95dcb96086 |
Start extraction: an i18n core, and a way to see how far it has got
The groundwork in #145 gave the app a language to serve. This gives it something to serve, and a way to measure the distance to the languages actually planned. The English text is the key. `t("Archive")` looks "Archive" up and returns the English when it is not there, which buys three things worth more than tidy symbolic keys: no English catalogue to keep in step with the code, a missing translation that degrades to readable English rather than to `mail.list.archive`, and an extraction step that is wrapping a string rather than inventing a name for it. Names are where extraction stalls, and 55 components is a lot of small naming arguments. The cost is that editing English copy orphans its translations, which is the right way round: the copy is the product, and a stale German sentence should fall back to the new English. `plural()` takes forms rather than (one, other), because two forms is an English assumption that does not survive phase two of the plan. Russian and Ukrainian need three, and choosing between them is not a question about the number 1. Intl.PluralRules knows the rule for every language the browser knows, so the catalogue supplies the forms and the runtime picks; a category the catalogue does not carry falls back to `other` rather than rendering undefined. Interpolation is named rather than positional for the same reason -- German moves the parts of a sentence around and means the same thing. Catalogues are dynamically imported, so a reader who never leaves English never downloads one, and English needs no fetch at all. `applyLang` sets the lang attribute before kicking the load, deliberately: lang is what stops Chrome offering to translate and should not wait on a network request to say something it already knows. `t()` is a plain function, not a hook, so the tree is keyed on a language version at the root and thrown away when the catalogue changes. Making every call site a subscriber would turn extracting a string from "wrap it" into "wrap it and add a hook", for an event that happens about once per account. NotificationsSettings is extracted end to end as the reference -- it covers all four shapes, being JSX text, translated attributes, a toast, and a sentence with a value interpolated into it. scripts/i18n-coverage.mjs counts what is left, because ~1,000 strings across 56 files is too many to eyeball in review or carry in anyone's head. It reports 20 wrapped and 925 remaining, and it deliberately does not count punctuation and separators as untranslated -- a floor no amount of work could reach would make the number useless. A progress report rather than a gate: --check exits non-zero, for once the number is low enough for that to mean something. ROADMAP.md said translations were "English-only for now" on a page whose stated purpose is things the answer is "no" to. It now says what is actually happening, carries the phase order, and says why Arabic, Hebrew and Persian are on neither list: RTL is a layout and bidi problem rather than a longer catalogue, and shipping it as though it were the same kind of work is how an RTL build ends up unusable with nobody saying so. |
||
|
|
95f640b24c |
Point at the Coffey-Labs organisation
The repositories moved off LINUXexpert-org. The old URLs redirect, so nothing was broken, but a redirect is not a correct address to publish. The SOURCE_URL defaults matter most: the AGPL asks whoever runs a modified version to offer that version's source, and the sign-in page and About screen show this link. It is in four places that have to agree -- the compose file, .env.example, the server default and the web fallback. The rest is documentation and issue links. |
||
|
|
1c678fabed |
Say where the 2FA entry came from, not that something is tracking it
The roadmap's preamble promised that anything with an issue number was tracked in the issue tracker, and the two-factor entry ended in a bare "Reported as #75". That issue was closed as completed on 2026-08-26, so the one entry the promise applied to was the one it was wrong about: a reader following the link finds a closed ticket and has to guess whether the work went with it. It did not. #75 reported a sign-in refused with nothing but "Invalid credentials", and that bug was fixed -- the message now says what is happening and points at app passwords. The OAuth work the report uncovered stayed behind on this page, which is exactly the case the preamble had no room for. So the preamble now says an issue number records where an entry came from rather than where it is tracked, and the entry says plainly that #75 is closed, what closing it fixed, and that there is no ticket to watch for the rest. |
||
|
|
350f4f4197 |
Put address books in the left pane, other people's included
Address book sharing was withdrawn a few hours ago on a report that it behaved like mail folder sharing. That was wrong -- it works -- and it is back, built the way Files is rather than the way it was. Three things it inherits from Files. Shared books are listed in the app's own left pane instead of behind an account switch in the profile menu. The reader's books and other people's sit under separate headings, since a book belonging to somebody else behaves differently and a single merged list would be quiet about whose contacts you are reading. And opening Contacts re-reads the session, so a book shared while the tab was open turns up without signing out and in again. The books pane the view kept to itself is gone, and with it the last module that ignored the sidebar it was given. The one thing Files does not need: shared contacts have to answer when somebody types a name into a To field, so they are loaded up front rather than when a book is opened, and they are offered by `suggest` and found by `lookupByEmail` alongside the reader's own. Their own cards win a tie, since a card someone wrote themselves should beat a colleague's copy of the same person. That is the difference between a shared book you can look at and one you can use. Cards from a shared account are held apart from the reader's rather than merged in, and keyed by account as well as id. Ids are only unique within an account -- two accounts each having a book `ab1` is ordinary -- and a flat map would have had one silently replace the other. The mock grew an address book in its shared account, with contacts in it, because none of this could be exercised otherwise. KNOWN-ISSUES records the withdrawal as the mistake it was rather than leaving it in the history looking like a finding. Mail folder sharing stays withdrawn: that one really is broken. |
||
|
|
1e2db95577 |
Stop offering to share mail folders, and let a share be removed
Sharing a mail folder does nothing. `Mailbox/set` takes the `shareWith` map, `Mailbox/get` reads it back, and the folder never appears for the account it was shared with -- confirmed on the live 0.16.19 with a folder shared read-only to another account on the same server, which never saw it. Stalwart's sharing documentation lists calendars, address books and file storage; mail folders are not among them. Nothing anywhere reports a failure, so a client that trusts what it reads back shows the share as live for ever, which is what happened. The entry point is withdrawn. Address book sharing goes with it on a report that it behaved the same way -- not reproduced, and contradicted by Stalwart's own docs, so that one is expected back; it is out because offering a share nobody can verify was worse than the gap. Files and calendars are untouched. Removing a share was impossible, for a reason worth writing down. The dialog rendered the list of who a thing was shared with *inside* the branch that runs when the directory has principals to offer. A server with `allowDirectoryQueries` off returns none -- that is the default, and it is how these shares came to be made in the first place -- so the dialog showed one line of hint and nothing else. The share was there, and there was no way to see it, let alone remove it. The list is now rendered whatever the directory says; only the control for adding somebody new depends on having somebody to add. So the withdrawn entry points do not strand what they created: a folder or book already shared still offers "Stop sharing", which is the one thing you want when the share is invisible everywhere else. The API was never the problem, which is worth recording since it was the first guess: `shareWith: null` is accepted and clears the map, tested against the live server on the stuck folder, which is now unshared. |
||
|
|
d15f64ada6 |
docs: put the Docker quick start back in the README
Slimming it left no way to try ihasmail without leaving GitHub. Restored in short form -- the four commands, the 2FA app-password note, and links to the install and configure guides for TLS and the full environment. ROADMAP.md's 2FA entry points back at that section again, rather than at the install docs it was redirected to when the section was gone. |
||
|
|
9618a0278c |
docs: slim the README, split out known issues and roadmap
The README had grown to 330 lines and was carrying three audiences at once: installing, using, and working on ihasmail. docs.ihasmail.org covers the first two now, and ihasmail.org covers the feature tour, so the README links there instead of restating them. - Known issues / pending QA → KNOWN-ISSUES.md, verbatim - Roadmap / not yet → ROADMAP.md, verbatim (its "see Quick start" pointer now aims at the install docs, since that section is gone) - Dropped the env-var table (docs.ihasmail.org/configure/), the shortcut list (/shortcuts/), the Docker quick start (/install/) and the long feature list (ihasmail.org/#features), leaving a nav table at the top and a six-line summary of what's in it - Kept and tightened what is only true of this tree: architecture, dev commands, the mock, version numbers, deploying - Version examples refreshed from 2.16.57 to the current 2.16.84 |