Commit Graph
611 Commits
Author SHA1 Message Date
jcoffey-dev 2280df1ea9 Catch the Ukrainian catalogue up with what shipped after it
Features landed after the catalogues were written and their strings were never
added, so they rendered in English. Reported against German (#247); every
language had the identical gap.

176 entries: 153 from features that shipped after the translation pass, and 23
keyboard bindings whose group and description are registered in English at the
call site and translated at render.

Follows the decisions this file already pins: ви in lowercase, the infinitive
for actions, and the terminology that keeps this a Ukrainian catalogue rather
than the Russian one with a different name on it -- Тека and not папка, Мітка
and not ярлик, which in Ukrainian means a shortcut. Вхідні, Лист, Листування,
Кошик, Чернетки and Налаштування read the same here as everywhere above.

Fifteen strings are deliberately absent and keep falling back to English:
example.com and the other input placeholders, the product names, and the
verbatim header names List-Id and X-Spam-Status.

Verified: typecheck clean, 1000 tests pass, nothing missing from uk.ts.
2026-09-03 11:52:41 -07:00
jcoffey-dev 083039b27e Catch the Russian catalogue up with what shipped after it
Features landed after the catalogues were written and their strings were never
added, so they rendered in English. Reported against German (#247); every
language had the identical gap.

176 entries: 153 from features that shipped after the translation pass, and 23
keyboard bindings whose group and description are registered in English at the
call site and translated at render.

Follows the decisions this file already pins: вы in lowercase rather than
correspondence-style «Вы», the infinitive for actions, and the fixed
terminology, so Входящие, Папка, Письмо, Цепочка, Ярлык, Корзина, Черновики
and Настройки read the same here as everywhere above.

Fifteen strings are deliberately absent and keep falling back to English:
example.com and the other input placeholders, the product names, and the
verbatim header names List-Id and X-Spam-Status.

Verified: typecheck clean, 1000 tests pass, nothing missing from ru.ts.
2026-09-03 11:50:29 -07:00
jcoffey-dev d525b18f6d Catch the Japanese catalogue up with what shipped after it
Features landed after the catalogues were written and their strings were never
added, so they rendered in English. Reported against German (#247); every
language had the identical gap.

176 entries: 153 from features that shipped after the translation pass, and 23
keyboard bindings whose group and description are registered in English at the
call site and translated at render.

Follows the decisions this file already pins: です・ます throughout, no あなた,
bare noun or verb stem on buttons, and the deliberate script mixing -- kanji
for the noun carrying the meaning, katakana for the loanword the reader knows,
long vowels keeping their ー. So 受信トレイ, フォルダー, メール, スレッド,
ラベル, ゴミ箱, 下書き and 設定 read the same here as everywhere above.

Fifteen strings are deliberately absent and keep falling back to English:
example.com and the other input placeholders, the product names, and the
verbatim header names List-Id and X-Spam-Status.

Verified: typecheck clean, 1000 tests pass, nothing missing from ja.ts.
2026-09-03 11:48:32 -07:00
jcoffey-dev f5ba09ef77 Catch the Brazilian Portuguese catalogue up with what shipped after it
Features landed after the catalogues were written and their strings were never
added, so they rendered in English. Reported against German (#247); every
language had the identical gap.

176 entries: 153 from features that shipped after the translation pass, and 23
keyboard bindings whose group and description are registered in English at the
call site and translated at render.

Follows the decisions this file already pins: você rather than the formal
address the other Phase 1 languages took, and the fixed terminology, so Caixa
de entrada, Pasta, Mensagem, Conversa, Marcador, Lixeira, Rascunhos and
Configurações read the same here as everywhere above.

Fifteen strings are deliberately absent and keep falling back to English:
example.com and the other input placeholders, the product names, and the
verbatim header names List-Id and X-Spam-Status.

Verified: typecheck clean, 1000 tests pass, nothing missing from pt-BR.ts.
2026-09-03 11:46:28 -07:00
jcoffey-dev 3b77fb85fd Catch the Dutch catalogue up with what shipped after it
Features landed after the catalogues were written and their strings were never
added, so they rendered in English. Reported against German (#247); every
language had the identical gap.

176 entries: 153 from features that shipped after the translation pass, and 23
keyboard bindings whose group and description are registered in English at the
call site and translated at render.

Follows the decisions this file already pins: u throughout, and the fixed
terminology, so Postvak IN, Map, Bericht, Gesprek, Label, Prullenbak,
Concepten and Instellingen read the same here as everywhere above.

Fifteen strings are deliberately absent and keep falling back to English:
example.com and the other input placeholders, the product names, and the
verbatim header names List-Id and X-Spam-Status.

Verified: typecheck clean, 1000 tests pass, nothing missing from nl.ts.
2026-09-03 11:44:27 -07:00
jcoffey-dev b05cd178e6 Catch the French catalogue up with what shipped after it
Features landed after the catalogues were written and their strings were never
added, so they rendered in English. Reported against German (#247); every
language had the identical gap.

176 entries: 153 from features that shipped after the translation pass, and 23
keyboard bindings whose group and description are registered in English at the
call site and translated at render.

Follows the decisions this file already pins: vous throughout, guillemets for
quoted names, a plain space before ? and : rather than a narrow no-break one,
and the fixed terminology, so Boîte de réception, Dossier, Message, Libellé,
Corbeille, Brouillons and Paramètres read the same here as everywhere above.

Fifteen strings are deliberately absent and keep falling back to English:
example.com and the other input placeholders, the product names, and the
verbatim header names List-Id and X-Spam-Status.

Verified: typecheck clean, 1000 tests pass, nothing missing from fr.ts.
2026-09-03 11:42:30 -07:00
jcoffey-dev f2437b6904 Catch the Spanish catalogue up with what shipped after it
Features landed after the catalogues were written and their strings were never
added, so they rendered in English. Reported against German (#247); every
language had the identical gap.

176 entries: 153 from features that shipped after the translation pass, and 23
keyboard bindings whose group and description are registered in English at the
call site and translated at render.

Follows the decisions this file already pins: usted throughout, peninsular
Spanish, and the fixed terminology, so Bandeja de entrada, Carpeta, Mensaje,
Conversación, Etiqueta, Papelera, Borradores and Configuración read the same
here as everywhere above.

Fifteen strings are deliberately absent and keep falling back to English:
example.com and the other input placeholders, the product names, and the
verbatim header names List-Id and X-Spam-Status.

Verified: typecheck clean, 1000 tests pass, nothing missing from es.ts.
2026-09-03 11:40:36 -07:00
jcoffey-dev ba16067529 Catch the German catalogue up with what shipped after it
Features landed after the catalogues were written and their strings were never
added, so they rendered in English. A native speaker reviewing this file
reported it (#247), which is the review the language was marked Beta waiting
for.

176 entries: 153 from features that shipped after the translation pass --
privacy and safety settings, the message ordering controls, subscribed and
birthday calendars, label nesting, the file preview, the unsaved-changes
prompts -- and 23 keyboard bindings, whose group and description are
registered in English at the call site and are translated at render.

Follows the decisions this file already pins: Sie throughout, and the fixed
terminology, so Ordner, Nachricht, Konversation, Posteingang, Papierkorb,
Entwürfe and Label read the same here as everywhere above.

Fifteen strings are deliberately absent and keep falling back to English,
which is the correct rendering for them: example.com and the other input
placeholders, the product names, and the verbatim header names List-Id and
X-Spam-Status. A translator should not be invited to translate example.com.

Verified: typecheck clean, 1000 tests pass, and a coverage pass over every
t() call site reports nothing missing from de.ts.
2026-09-03 11:37:56 -07:00
jcoffey-dev b10149fd54 Translate three lists the code was rendering raw
A native speaker reviewing the German catalogue reported strings appearing in
English (#247). Three of the reported areas turned out not to be missing
translations at all: the strings were there, and the code was rendering the
English source instead of asking for one.

The Sieve rule dialog rendered HEADER_CHOICES and HEADER_OPS labels directly.
All sixteen are already in every catalogue -- "Subject" has been "Betreff" in
de.ts all along, which is exactly the inconsistency the reporter noticed
against the Out-of-office page, where it already reads Betreff. Wrapping the
two dropdowns fixes nine languages at once and adds nothing to any catalogue.

The keyboard shortcut panel rendered each binding's group and description
directly. Those are registered in English at the call sites and should stay
that way -- the binding table is data and the English is the catalogue key --
so the panel translates them at render instead. A binding added anywhere is
then translatable without its registrar knowing i18n exists.

The palette grid marks every name translate="no". That is right for ihasmail,
Dracula, Gruvbox, Rosé Pine and Tokyo Night, which are names. "Classic" is an
adjective describing the theme, not a name, so PaletteMeta gains a
`translatable` flag for the one entry that is a word. Flagging the exception
beats dropping the attribute from all six.

No catalogue changes here: the strings the first two need are already present
in all nine. "Classic" needs an entry, which lands with each language.
2026-09-03 11:37:05 -07:00
Coffey Labs 3ffee1224f Merge pull request #246 from Coffey-Labs/ci/prune-old-images
Prune old images, and keep every release
2026-09-02 23:50:13 -07:00
jcoffey-dev 4b2c97df4e Prune old images, and keep every release
Two artefacts, opposite answers.

Releases stay. They carry no assets -- the image lives in GHCR -- so one costs
a tag, a title and generated notes, and with no CHANGELOG in this repository
those notes are the only changelog there is. Deleting one destroys history
that cannot be reconstructed, and saves nothing.

Images accumulate: a multi-architecture build a week, and the by-digest push
leaves two untagged per-architecture manifests behind each time on top of the
tagged index. Ten tagged versions are kept, which is roughly a quarter of
releases and far more than anything anyone rolls back to.

The obvious tool for this is a trap. delete-package-versions with
`delete-only-untagged-versions` will delete the per-architecture manifests
that a multi-arch tag points at, because they are untagged by design, and
nothing appears to break: the tag still resolves and pulls simply start
failing for one architecture. This action understands manifest lists and
leaves a retained index's children alone, `validate` re-checks every
multi-arch manifest against the registry afterwards, and `latest` is excluded
from consideration entirely.

It is pinned to a commit rather than a major tag. It holds `packages: write`
and its whole purpose is deletion, so a tag repointed upstream is not a risk
worth carrying for the convenience.

Kept in its own file and dispatchable, so a dry run can show exactly what
would go without rebuilding and re-pushing an image to find out.
2026-09-02 23:47:22 -07:00
Coffey Labs 60a4647a43 Merge pull request #245 from Coffey-Labs/ci/scheduled-releases
Cut a release once a week, and only when there is something in it
2026-09-02 23:45:41 -07:00
jcoffey-dev 5cb0b2f3ea Cut a release once a week, and only when there is something in it
Publishing on release is the right trigger only if releases happen. They had
not: main ran 184 commits ahead of the last one, so `:latest` described a
build that neither the demo, nor production, nor anyone building from source
was running. This is the part that makes the trigger true without anyone
having to remember.

Mondays at 09:00 UTC. A run with no commits since the last release does
nothing at all -- an empty release moves `:latest` to an identical build,
spends a version number and mails every watcher about nothing. The decision is
written to the run summary either way, so a quiet week reads as a decision
rather than as a workflow that failed silently.

The awkward part is that a release created with GITHUB_TOKEN raises no
`release` event: GitHub refuses to let a token trigger another workflow, to
stop a workflow looping on its own output. A scheduled job that cut a release
and left publish.yml to notice would tag the commit and never build an image,
which is the kind of failure that looks like success. So publish.yml gains a
`workflow_call` trigger and this calls it directly. The alternative was a
personal access token kept as a secret; this needs no credential.

Two smaller decisions. Drafts are excluded when looking for the last release,
because an unpublished draft is not a release anybody has and counting from it
would hide commits that never shipped. And if the tag a release names has
gone, the count falls back to the whole history -- over-counting cuts a
release that was due anyway, where under-counting skips one that was not.
2026-09-02 23:43:12 -07:00
Coffey Labs e1c85cf82e Merge pull request #244 from Coffey-Labs/ci/publish-container-image
Publish the image the docs have been telling people to pull
v2026.9.2-pr244
2026-09-02 23:30:07 -07:00
jcoffey-dev 1734ed0439 Publish the image the docs have been telling people to pull
README has said `docker run ... ghcr.io/coffey-labs/ihasmail:latest` since the
Docker instructions were written, and the docs site repeats it in four places.
Nothing ever pushed that image. `docker pull` answers `denied`, because the
package does not exist: .github/workflows held ci.yml and nothing else, and
there is no reference to ghcr.io, docker/build-push or docker push anywhere in
this repo. The instructions have been wrong the whole time.

Adds the workflow that makes them true. It fires on a published release, and
by hand for a ref -- the same dispatch trigger ci.yml carries, and the only
way to build an image for the tags that predate this file.

Two architectures on native runners rather than one build under QEMU.
Emulated arm64 runs `npm ci` and the Vite build through instruction
translation, which takes tens of minutes and sometimes exhausts memory;
ubuntu-24.04-arm is free for public repositories and does it at native speed.
The cost is pushing by digest and joining the two into one manifest at the
end, which is what the third job does.

`latest` moves only for a real release. A prerelease that moved it would hand
every `:latest` deployment an unfinished build, and a dispatch run has to ask
for it deliberately.

Also documents the images in README: which tags exist, that the dated tag is
the one to pin, and that building it yourself is still fully supported --
`docker compose up --build` is unchanged and the image is a convenience, not a
new requirement.

Worth knowing before the first run: GHCR creates a new package **private**,
even for a public repository, so an anonymous pull will still be refused until
the visibility is changed by hand. That is written at the top of the workflow,
because it is the failure that looks like success.
2026-09-02 23:22:21 -07:00
Coffey Labs 93856de143 Merge pull request #243 from Coffey-Labs/fix/login-app-name
Call the instance what it calls itself, on the page that matters most
2026-09-02 15:27:20 -07:00
jcoffey-dev cfcaf5f573 Call the instance what it calls itself, on the page that matters most
APP_NAME is a runtime variable and two of the three places showing the name
ignored it. The sign-in page fetched /api/config, received the name and used
only sourceUrl -- so a rebranded deployment still said "ihasmail" on the one
page a new user meets first. The top bar had it written in. Only the document
title read it, and it had been reading it from the session all along.

The rebranding guide documents both as things to patch yourself, one of them
with "if you change nothing else on this page, change this". It should not
have to.

The sign-in page takes the name from the answer it was already getting. The
top bar takes it from the session, where the title has taken it from since it
was written. Neither is a new request.

One shared default rather than the string written out at three call sites,
because three copies of a default is how two of them end up stale. It stands
if the config request fails, since a sign-in form with no name on it would be
worse than one with the wrong name -- and an empty or non-string name falls
back too, so a deployment that sets APP_NAME= does not get a nameless page.

Confirmed with APP_NAME set to something else: sign-in heading, top bar and
tab title all read it.
2026-09-02 15:24:36 -07:00
Coffey Labs 3582ad116e Merge pull request #242 from Coffey-Labs/feat/contacts-update-on-reimport
Update a contact on re-import rather than skipping it
2026-09-02 15:17:27 -07:00
jcoffey-dev 9f4bd65fd5 Update a contact on re-import rather than skipping it
#228 skipped a vCard whose UID the book already held. The reporter asked for
the opposite on #174 and he is right: the reason to import a file a second
time is usually that the first one was not right, so skipping means a
corrected export corrects nothing.

A merge, not a replacement. Properties the file carries overwrite what is
here; properties it does not mention are left alone, so a phone number added
in ihasmail after the first import survives a re-import of the original file.
The cost is that a field genuinely deleted at the source stays here, which is
the better way to be wrong -- the other way round loses work nobody asked to
lose. Worth confirming with him rather than assuming.

`addressBookIds` is left off the patch. The card is already in this book, so
saying it again says nothing, and saying it on a card that is also in another
book would move it.

Creates and updates now share one batch budget. Stalwart counts every object
in a /set together, so batching the halves separately would send 300 new and
300 changed as two calls of 300 and be refused for a limit of 500 that neither
half exceeds.

LDIF is untouched and still reports look-alikes without acting on them, since
what it should match on is the question still open on #223. Both imports keep
one answer shape so a caller need not know which it called; LDIF's `updated`
is always 0, which is the honest number rather than a missing field.

The message a vCard attached to a message shows changes with it: the newer
copy now wins instead of being dropped, so it says the contact was brought up
to date rather than that nothing was added.

Refs #223.
2026-09-02 15:06:04 -07:00
Coffey Labs af0e8b2be2 Merge pull request #241 from Coffey-Labs/feat/domain-server-mapping
Choose the Stalwart by the domain somebody signs in with
2026-09-02 14:33:14 -07:00
jcoffey-dev 171c11fc92 Choose the Stalwart by the domain somebody signs in with
One ihasmail in front of several Stalwarts, from #238. STALWART_URL stays
required and stays the default, so an installation that sets nothing behaves
exactly as it always has -- the mapping only adds domains that go elsewhere.

An unlisted domain goes to the default. So does a bare username, which
Stalwart accepts and which has no domain to map at all.

A listed domain never falls back. If its server is unreachable that sign-in
fails rather than retrying against the default, because falling back would
authenticate somebody against a server their domain was deliberately routed
away from -- and if the same account name existed there, they would land in
another tenant's mailbox. The fallback is a decision about unmapped domains,
taken before any network call, not a recovery path.

Smaller than it sounds because only four places read config.stalwartUrl, all
in upstream.ts. The upstream session now records which server issued it, since
the relative URLs inside it only mean anything against that server, and every
route already holding a session gets the right upstream without a second
lookup. The client is untouched: it talks to one proxy and never learns there
is more than one server behind it, which is exactly why this is small and
several-servers-at-once is not.

The upstream is derived from the username rather than stored on the session,
so a mapping change takes effect on restart instead of being frozen into
sessions that outlive it.

Validated at boot the way the settings policy is: malformed JSON, a duplicate
domain once normalised, a missing file or a value that is not an http(s) URL
all stop the server. Domains are lower-cased and stripped of a trailing dot,
because that is how one arrives off a username and comparing them any other
way means a mapping that silently never matches. The servers themselves are
not contacted -- a mapping is a routing table, not a health check, and one
customer's outage must not stop ihasmail starting for the other four.

Eight tests on the routing, two on the shipped example, and the four refusals
checked by hand against a real config load.
2026-09-02 14:18:50 -07:00
Coffey Labs 2a41a31bb9 Merge pull request #240 from Coffey-Labs/fix/upstream-outage-lockout
Do not spend login attempts on an outage nobody caused
2026-09-02 14:14:24 -07:00
jcoffey-dev 607afeb4ad Do not spend login attempts on an outage nobody caused
ihasmail runs in its own container, usually on its own host, so Stalwart being
briefly unreachable is an ordinary Tuesday. Sign-in handled it almost right:
a 401 is invalid_credentials, a timeout is 504 and anything else is 502, none
of which reads as a rejected password.

What it got wrong was the counting. RateLimiter.check() consumes an attempt
when it is called, and it is called before the upstream is contacted; reset()
only runs on success. So every try against an unreachable server burned a
credential attempt, and after ten of them the person was locked out for the
rest of the fifteen-minute window -- including after the server came back. A
thirty-second blip became a quarter-hour lockout, and the second failure was
entirely ihasmail's own doing.

A 401 is a judgement about the password and stays counted. A 502 or 504 is the
upstream failing to answer, says nothing about the credentials, and is now
refunded -- one attempt back, not the key cleared, so a run of real failures
with an outage in the middle still adds up. The old-server refusal refunds too:
those credentials were accepted.

Both guessing keys are refunded, not just the username one. Refunding only
that would not have fixed it -- ten retries still spend the per-address budget,
and behind one office NAT that budget belongs to the whole building, so a
company-wide outage would lock out the company.

Which needs a backstop, because "not counted" must not mean "unlimited": each
attempt still costs an outbound connection that may sit there until
UPSTREAM_TIMEOUT, and an outage is the one moment the endpoint is cheapest to
abuse. So there is a second ceiling per address, twenty times looser and never
refunded. A person retrying will not come near it; something hammering will.

Both messages now say the quiet part -- "This is not a problem with your
password" -- for somebody already worried they have forgotten it.

Closes #239.
2026-09-02 14:08:23 -07:00
Coffey Labs f6f6ce0b5e Merge pull request #237 from Coffey-Labs/feat/headers-show-original
Make "Show original" in the headers dialog the action, not a description
2026-09-02 13:23:01 -07:00
jcoffey-dev b10ce2f9dd Make "Show original" in the headers dialog the action, not a description
The hint at the foot of Message headers named an action and left you to go
find it. Requested in #236, and the reporter is right that it is the shape of
the thing rather than the size: telling somebody a feature exists is half a
job when the other half is one element away.

Clicking it now closes the headers dialog and opens the original, so it reads
as going deeper rather than as opening a second window.

`tNode` rather than a sentence chopped either side of a button: the sentence
stays whole for whoever translates it, and a language that puts the verb
somewhere else can move the hole rather than being handed two fragments.

The link style needed unscoping to work, which turned out to be a bug of its
own. `.link-btn` was written for the composer's To and Cc labels and scoped to
`.composer-field label`, so the two callers outside it -- the trusted-domain
list in Privacy settings, and now this -- rendered as default button chrome in
the middle of a sentence. The rule is now unscoped and Privacy is fixed by the
same change.

Checked in a browser: the hint reads as a sentence with a dotted-underlined
link in it, clicking swaps one dialog for the other, and the raw message is
there.

Closes #236.
2026-09-02 13:17:10 -07:00
Coffey Labs 45f1309d06 Merge pull request #235 from Coffey-Labs/feat/go-to-folder
Go to a folder by name, with g then o
2026-09-02 13:07:52 -07:00
jcoffey-dev 483aac849a Go to a folder by name, with g then o
Requested in #233. The `g` shortcuts cover the handful of folders every
account has -- inbox, sent, drafts -- and nothing reaches the dozens a Sieve
rule fills, which is where somebody with a real folder tree spends their time.
`g o` opens the picker, you type part of a name, and you are there.

The picker is the one the move action already uses, with one difference that
only shows up on shared mail: it selected folders by `mayAddItems`, which is
right for a destination and wrong for a place to go. A shared folder you may
read but not file into is somewhere you can visit. The right is now a
parameter, named for what it is asking rather than for which caller wants it.

Hosted in AppShell rather than in the mail view, because the `g` shortcuts are
global and the mail view is not mounted to hear about it -- pressing this from
the calendar should still take you to a folder, and now does.

`o` on its own opens a conversation and does not clash: a pending prefix is
tried before a bare key. That was already true and nothing said so, so there
are now five tests for the sequence machinery -- including that an abandoned
prefix costs the prefix and not the keystroke after it, which is the nicer
behaviour of the two and was undocumented.

Checked in a browser against the mock: opened from the calendar, filtered to a
nested folder, landed on it, and `o` still opened a conversation afterwards.

Closes #233.
2026-09-02 13:03:55 -07:00
Coffey Labs a8cf8ce3d7 Merge pull request #234 from Coffey-Labs/feat/ldif-duplicate-report
Say how much an LDIF re-import duplicated, without acting on it
2026-09-02 12:51:10 -07:00
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
Coffey Labs 0d5975d427 Merge pull request #232 from Coffey-Labs/docs/policy-example
Ship an example settings policy, and name the variables in .env.example
2026-09-02 11:23:15 -07:00
jcoffey-dev a24b4c5538 Ship an example settings policy, and name the variables in .env.example
#231 added the policy but nothing to copy. The repo already answers this the
same way four times over -- Caddyfile.example, deploy.example.sh,
nginx.example.conf, .env.example -- and the new feature was the one thing
configurable here with no example beside it.

settings-policy.example.json carries all three sections with the reasoning in
it, including the part worth being deliberate about: a `changes` entry
overrides a decision a reader has already made, and if you want it to stay put
regardless that is `enforced` instead. JSON has no comments, so the commentary
is in `_`-prefixed keys, which is safe because the server reads three names and
ignores everything else.

A test asserts the shipped example stays valid against the rules the parser
enforces -- unique versions, settings objects, no comment key colliding with a
real section. An example that has drifted is worse than none: somebody copies
it, the server refuses to start, and the first experience of the feature is a
crash loop.

.env.example gains the four variables, commented out, with the file form and
the inline form and the note that the file wins over the variables.

Confirmed against the real image on the deploy host rather than reasoned
about: an immutable container -- --read-only, IMMUTABLE=1, SESSION_FILE=
empty -- starts and serves the policy both with a read-only file mount and
with the environment variables alone. The feature costs nothing in
immutability, because the only thing it writes is the applied-changes stamp,
and that goes in the reader's own settings file on Stalwart like every other
setting.
2026-09-02 11:20:34 -07:00
Coffey Labs cff7f6c11b Merge pull request #231 from Coffey-Labs/feat/policy-change-once
Apply installation policy changes once each, per account
2026-09-02 11:03:50 -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
Coffey Labs 6821d6a93f Merge pull request #230 from Coffey-Labs/feat/installation-defaults
Let an installation seed and lock user settings
2026-09-02 10:52:58 -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
Coffey Labs a01e1874d8 Merge pull request #229 from Coffey-Labs/fix/export-vtimezone
Define the zones an export names, instead of only naming them
2026-09-02 10:29:45 -07:00
jcoffey-dev 0e58b886b5 Define the zones an export names, instead of only naming them
#227 emitted TZID with the IANA name and nothing defining it, on the reasoning
that every client resolves those names and that generating a definition would
mean shipping a zone database. Both halves were wrong.

Measured, not assumed. Run an export through ical.js -- Mozilla's own
iCalendar library, the one Thunderbird's calendar uses -- and a TZID with no
VTIMEZONE beside it does not resolve: it falls back to floating time. A 09:00
in Phoenix then reads as 09:00 wherever the file is opened, seven hours out,
silently, on every timed event in every export.

    as exported            | zone: floating        | UTC: 09:00Z
    with a VTIMEZONE added | zone: America/Phoenix | UTC: 16:00Z

The database was already here, too. The browser has IANA behind Intl, and an
offset for an instant is a formatting question: format the instant into the
zone, read the clock back, and the difference is the offset. Transitions are
found by walking month by month for the ones where the answer changes and
bisecting inside them -- no rules are known, so none can be got wrong.

Each transition is its own dated sub-component rather than an RRULE. More
lines and no cleverness: a derived rule that is subtly wrong moves somebody's
meeting, while a list of dates can only be incomplete at its ends, which is
what the window is for -- the year before the earliest event to ten years past
the latest, an open-ended weekly meeting being the case that needs it.

A zone Intl does not know is left undefined rather than described from
nothing; the TZID stays on the event, which is where it was. TZNAME is dropped
where Intl offers "GMT+9", which only repeats the offset beside it.

Confirmed the same way it was found. Berlin now resolves to +0200 in September
and +0100 in December, so the transitions are being applied and not just an
offset.

Refs #216.
2026-09-02 10:26:43 -07:00
Coffey Labs 5f35428ddb Merge pull request #228 from Coffey-Labs/feat/vcard-uid-dedupe
Skip vCards on re-import that the address book already has
2026-09-02 09:36:35 -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
Coffey Labs 85289b0c34 Merge pull request #227 from Coffey-Labs/feat/calendar-ics-export
Export a calendar as an iCAL file
2026-09-02 09:24:00 -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
Coffey Labs cb69115be1 Merge pull request #226 from Coffey-Labs/feat/contacts-menu-parity
Give the address books the menus the calendars have
2026-09-02 09:08:40 -07:00
jcoffey-dev 8badf48c4a Give the address books the menus the calendars have
Two remarks from the reporter's colleague, both the same underlying thing:
contacts and calendar grew their menus at different times and it shows.

The dots button on hover. The calendar has offered its per-item menu two ways
since it was written -- the button and right-click -- and contacts only had
right-click, which is undiscoverable and unavailable on touch. The rows are
already .nav-item, which has carried the hover-reveal rule for mail folders
all along, so this is the button and no CSS.

Import and export move into those menus. As a pair of buttons at the foot of
the sidebar they did not say which address book they acted on -- they meant
"whatever is selected", which is not something a button can tell you. The
calendar settled this already: its iCAL import lives in the calendar's own
menu, because that is where "which one?" is answered by where you clicked.
The events they dispatch now name the book instead of meaning the selection.

Exporting a book now exports that book, rather than the list on screen. The
old one handed you whatever was showing, so a search box with something in it
quietly narrowed the export -- fine while the button sat under that list,
wrong from a menu in the sidebar.

Two things that would otherwise have been lost with the buttons. "All
contacts" gets the same menu, so exporting everything still has a home; and
a book somebody shared gets a menu rather than the bare X, since it can be
exported too and losing that would have been a regression dressed as a
tidy-up. The X moves inside as "Remove from my contacts".

Closes #224.
2026-09-02 09:06:00 -07:00
Coffey Labs a61fe28523 Merge pull request #225 from Coffey-Labs/feat/skip-duplicate-uids-on-import
Skip events on re-import that the calendar already has
2026-09-02 08:57:36 -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
Coffey Labs f1a8f162cf Merge pull request #221 from Coffey-Labs/feat/set-folder-role
Set the Archive role from ihasmail, rather than describing it
2026-09-02 08:41:09 -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
Coffey Labs 8e9259638d Merge pull request #220 from Coffey-Labs/fix/archive-role-message
Say the Archive folder needs the role, not the name
2026-09-02 08:10:10 -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
Coffey Labs 3f33b61369 Merge pull request #219 from Coffey-Labs/fix/unsaved-dialog-emphasis
Highlight saving, not discarding, on the unsaved-changes guard
2026-09-02 07:50:05 -07:00