Commit Graph
133 Commits
Author SHA1 Message Date
jcoffey 07b39eb9b6 Call the app by its name in every sentence that names it (#406)
APP_NAME renames an instance, but only the sign-in page, the title bar and
a few headings used it. Two dozen sentences wrote "ihasmail" into
themselves, so a renamed instance still told people to keep an ihasmail
tab open and offered to open mail links "in ihasmail".

Those sentences now take the name as {app}, which also lets a translator
put it where their language wants it. brand.ts grew useAppName() for
components and currentAppName() for the few places that build strings
outside React.

Left as they are: the Files folder "ihasmail", the Sieve script
"ihasmail" and ihasmail.org. Those name things a person can go and look
at, and renaming them would rename real data.

All nine catalogues keep their translations: the name inside each one
became the placeholder. Three of the strings had no translation before
and still fall back to English.

A test walks the sources and the catalogues so a new sentence can't
hard-code the name again.
2026-09-19 14:27:34 -07:00
jcoffeyandJoe Esteves c118184975 Match Shift+letter shortcuts (Shift+I, Shift+U) (#399)
comboOf() let a shifted letter encode Shift in its case, so Shift+I
produced "I" and never matched the "shift+i" / "shift+u" bindings for
mark as read / unread. Shifted letters now yield "shift+<letter>";
symbols such as "#" and "!" still carry Shift in the character.

Fixes #398

Co-authored-by: Joe Esteves <[email protected]>
2026-09-18 08:22:13 -07:00
jcoffey-dev 191c4e7e68 Ask before opening a shared item in a message
The share address takes a plain form POST, which any website can make,
and the app opened whatever arrived straight into a composer. It now
shows what was shared -- the title, the start of the text and link, and
the file names -- and opens a message only when the reader chooses to.
Discarding drops it.

Confirm dialogs now put a message that is not plain text in a div, since
the summary has blocks of its own.

Three new strings, translated in all nine catalogs.
2026-09-16 12:23:27 -07:00
jcoffey-dev d38dee7eb9 Save contact photos inline, and load cards so avatars show
Stalwart refuses a blobId in a card's media ("blobIds in media is not
supported"), so adding or changing a photo always failed. The editor now
saves the photo as a data: URI, which Stalwart accepts and returns
unchanged, and leaves the card's other media as it was. Checked live on
0.16.22; the mock now refuses a blobId the same way.

Avatars in the mail list come from the address book's cards, and nothing
loaded those at sign-in, so a photo showed only after Contacts had been
opened. The cards now load in the background at start, the avatar uses
whatever cards are held, and a shared card's photo is fetched from the
account it belongs to.

Fixes #376.
2026-09-16 11:27:47 -07:00
jcoffey-dev dfe885a921 Close the smaller gaps from the security review
Ask for the account password before minting an app password, and keep
sessions the proxy checks from writing the account's own registry objects,
so a session left open on someone else's machine cannot take a credential
away from it. The password is compared with what the session holds; Stalwart
is asked only when 2FA moved the session onto an app password.

Serve attachments and proxied images with no-store on a device that is not
the person's own. Give files from a winmail.dat only the types the server
would show inline. Strip direction controls from sender and attachment
names and from saved filenames.

On signing out, send what is inside its undo window, then close every
composer, so the next person to sign in does not find the last one's draft.

Group sessions by the account Stalwart names and its server, so "sign out
other sessions" also reaches a session opened as a bare or differently
cased username.
2026-09-16 08:47:23 -07:00
jcoffey-dev bd6a605d61 Group six more clusters out of web/src/lib
Takes the flat module count from 66 to 42, continuing what admin/ and
calendar/ started.

  lib/mailbox/  archiveDate, emptyFolder, folderMove, labelTree,
                mailboxName, mailboxRoute
  lib/sieve/    sieve, sieveApply, sieveFolders
  lib/input/    keyboard, swipe, touch, listSelection, dropUpload
  lib/notify/   notify, webpush, webpushEnable
  lib/sw/       swCache, swFacts, staleBuild
  lib/text/     html, markdown, text, emlName

FOUR THINGS THE FILENAMES GET WRONG, each checked by reading the file
rather than trusting what it is called:

  - appFolder is not a mailbox. It is the `ihasmail` folder in JMAP
    *Files*, where the client keeps signature images and synced settings.
    It stays flat.
  - format holds no formatting of text. It re-exports the date and clock
    formatters, so it belongs with dates/datetime, not with text/.
  - preview is the file viewer deciding what it can show without
    downloading, and source is where to point someone asking for this
    instance's AGPL source. Neither is about text.
  - notify is not Web Push. It is the tab title, the favicon badge and
    the new-mail sound -- in-app notification, which is why it sits with
    webpush rather than under sw/ with the service worker's own concerns.

threadScroll stays flat too: it decides where a conversation opens, which
is view state rather than a gesture, and input/ is honest only if
everything in it interprets something the reader did.

No behavior change. Almost every reference was on the @/ alias; eight
relative imports in files that did not move, or that moved away from a
sibling, needed rewriting by hand.
2026-09-15 23:17:50 -07:00
jcoffey-dev f7712b1c1e Group the admin and calendar modules, and stop calling screenshots docs
web/src/lib had grown to 85 flat modules -- 42% of the web source, about
12,800 lines -- with one subdirectory (smime/) to its name. The tell was
that a naming prefix had taken over a directory's job: eight adminX.ts
files sat adjacent because alphabetical order put them there, not because
anything said they belonged together.

  lib/admin/     adminAccess, adminDashboard, adminDirectory, adminDomains,
                 adminGroups, adminLists, adminRoles, adminTenants
  lib/calendar/  appointment, availabilityWindow, eventDrag, ics, recurrence

Tests move with their modules into lib/admin/__tests__ and
lib/calendar/__tests__, which is what views/ already does. describeRules
stays in lib/__tests__: it checks that sieve's describeRule and
recurrence's agree, so it belongs to neither.

recurrence.ts joins the calendar group and archiveDate.ts does not, which
is the opposite of the first guess from the filenames. archiveDate picks
the Archive/2026/09 mailbox for a message -- mail, not calendar --
while recurrence reads JSCalendarRecurrenceRule. schedule.ts is scheduled
*send*, so it stays put too. birthdays.ts is left alone deliberately: it
is read off the contact cards and only rendered by the calendar, so it
belongs to whichever of the two you ask.

docs/ held no documentation. It held ten JPEGs and the two scripts that
capture them, while the actual documentation is a separate site in the
ihasmail.org repository -- so anyone opening docs/ expecting prose found
a headless-Chrome driver. The images are now screenshots/, and the two
capture scripts join the other .mjs tooling in scripts/, which is where a
generator belongs. Renaming docs/ to screenshots/ wholesale would have
produced screenshots/screenshots/inbox-dark.jpg.

No behavior changes: every import was already on the @/ alias, so this is
path rewrites and nothing else.
2026-09-15 22:44:53 -07:00
jcoffey-dev d1731efdb9 Use American English spelling throughout 2026-09-15 11:45:58 -07:00
jcoffey-dev 40df0f658b Follow what the live server does with tenants and domains
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.
2026-09-15 09:37:00 -07:00
jcoffey-dev fd104a1f34 Add Tenants to Administration, and let an account be put in one
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.
2026-09-15 09:28:39 -07:00
jcoffey-dev 7e46ddeb7d Record the roles run on the live server, and drop the mock's made-up permission
A throwaway role on the production server confirmed the create shape,
one-pointer changes to permissions, bases and name together, the grant
refusal, and the in-use refusal when another role builds on it. It also
showed that a permission name Stalwart does not know fails the whole
update -- which is how jmapEmailSet, carried by the mock since Accounts
was built, turned out not to exist. The mock uses jmapEmailUpdate and now
refuses unknown names against the 0.16.22 snapshot.

Some permissions an administrator holds are never listed by /api/account
(sysLogCreate was granted without complaint), so the picker locks their
Allow; KNOWN-ISSUES says so.
2026-09-15 09:16:49 -07:00
jcoffey-dev a00d07b430 Add Roles to Administration, with Stalwart's permissions in every language
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.
2026-09-15 09:13:05 -07:00
jcoffey-dev 627422d794 Add Mailing lists to Administration
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.
2026-09-15 08:47:04 -07:00
jcoffey-dev e2a531b615 Add Groups to Administration
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.
2026-09-15 08:31:36 -07:00
jcoffey-dev 0054b8a3ce Open Administration on a dashboard of what the role can read
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.
2026-09-15 08:06:59 -07:00
jcoffey bb8d6eb92d Merge pull request #359 from Coffey-Labs/fix/keyboard-keyless-keydown
Ignore a keydown that carries no key
2026-09-15 07:29:33 -07:00
jcoffey-dev 8e9ca0498a Ignore a keydown that carries no key
Picking a saved login from Chrome's password autofill dispatches a plain
Event named "keydown", with no key on it. The shortcut listener passed it to
comboOf, which read the key's length and threw -- an uncaught TypeError in
the console on every sign-in. Harmless, since nothing was bound to it, but
it was noise that looks like a real fault.

comboOf now returns null for an event with no key, as it already does for a
bare modifier, so the listener stops there.
2026-09-15 07:27:12 -07:00
jcoffey-dev 95395200a3 Let the contact list be resized, and give the contact the rest of the page
The contacts grid still had three columns from when the address books sat
inside the view: 220px for them, 280-360px for the list, the rest for the
contact. The books moved to the app's left pane in 350f4f4 and the grid
never followed, so the list was squeezed into the books' 220px, truncating
names, the contact was held to 360px, and the remaining width sat empty.

It is two columns now, with the same splitter as the message list between
them: drag between 240px and the width that leaves 360px for the contact,
arrow keys move it, double-click puts it back to 320px. The width is a
device setting beside the mail list's, so it is kept by a device marked as
your own and never synced. The splitter is hidden where contacts show one
pane at a time. The unused .contacts-books rules are gone.

"Resize contact list" is in all nine catalogues.
2026-09-15 07:24:56 -07:00
jcoffey-dev 0859936f27 Move a folder from its menu, with the same picker as moving mail
A folder could only be moved by dragging it, which is slow in a long list
and not offered at all on a touch screen. Its menu now has "Move to…",
which opens the searchable folder picker that moving messages already
uses, with a "Top level" row above the folders.

The picker lists only legal destinations: the same rules as a drop -- not
into itself, its own subtree or the parent it already has -- plus the
rights a picker has to check up front because it shows every folder at
once: mayRename on the folder being moved, which RFC 8621 uses for
reparenting, and mayCreateChild on the destination. Both only say no on
shared mail. The move itself goes through the same path as a drop, so the
toast and the expanded destination are unchanged.

"Move “{name}” to…" and "Top level" are in all nine catalogues.

Closes #355
2026-09-15 07:00:12 -07:00
jcoffey-dev abb07acc80 Let the sidebar be resized by dragging its edge
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
2026-09-14 07:54:56 -07:00
jcoffey-dev 15b1838e21 Say every Administration refusal in the reader's language
Stalwart explains a refused change in English, and several of its words
reached the page as they were: "Invalid domain name" for a reserved TLD,
"Invalid email address" for a catch-all, a grant refusal, and ihasmail's own
proxy messages. Every registry error type now has its own message, and a
value one of the registry's string validators refused is recognised by the
validator's wording and explained again. A domain clash or a missing domain
is worded for a domain rather than an account.

The one exception is kept on purpose: a password policy's reason follows a
translated sentence, because the rule is the server's and dropping it would
leave no way to find out why.

The mock now refuses a reserved TLD and a catch-all without a domain the way
the live server did. KNOWN-ISSUES records the fix, and that the last two live
cases -- an administrator-set password and the outranking guard -- held.

15 new strings in all nine catalogues, 3 retired; strings falling back to
English stay at 16.
2026-09-13 17:18:15 -07:00
jcoffey-dev 430fc2673c Filter accounts on @type, the name Stalwart uses
The Accounts list sent x:Account/query with {"type": "User"}, and a live
0.16 server refuses it: "unsupportedFilter - type". The registry keys a
filter by the property's name on the object, which for the discriminator is
@type, so the whole list failed to load. {"@type": "User"} is accepted.

The mock took the wrong name without complaint, which is how it shipped. It
now refuses any filter name the real server does not index for that object,
answering the way Stalwart does.
2026-09-13 15:56:16 -07:00
jcoffey-dev 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.
2026-09-13 15:39:16 -07:00
jcoffey-dev 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.
2026-09-13 15:11:55 -07:00
jcoffey-dev d599e7404f Let conversation view off mean off
The setting reached only as far as the query. It set `collapseThreads`, so the
list correctly showed individual messages -- and then everything downstream
carried on working in threads. Opening one message highlighted every row of its
thread and filled the reading pane with the whole conversation, which is the
grouping the setting was turned off to avoid. The empty pane went on offering
"62 conversations" either way.

Three places had to learn about it, and the two rules behind them now live
together in lib/openMessage.ts:

- the row highlight matched on threadId, so siblings lit up
- ThreadView rendered every message the thread held
- the empty state named conversations regardless

The thread id stays in the path and loading is unchanged; the opened message
rides in `m`. Keeping it in the URL rather than in memory is what makes a
reload or a shared link come back to the same message, and an id that names
nothing in the thread falls back to the conversation -- which is what a link
from somebody with the setting on looks like, and what a stale parameter looks
like after switching back. Better a conversation than an empty pane.

Nine catalogues gain "No message selected" and "Select a message to read it
here"; "{n} messages" was already there, plural forms and all.
2026-09-10 14:15:50 -07:00
jcoffey-dev 73a1bad29f Render plain-text mail with its line breaks
`htmlBody` is a derived list, not a filter: RFC 8621 §4.1.4 gives a message
with no HTML alternative one anyway, holding the text/plain part. Testing
`Boolean(htmlRaw)` therefore answered "this is HTML" for every plain-text
mail, sending it to HtmlBody and `.ihm-email-root`, which is
`white-space: normal` and collapses every line break. Hard-wrapped mail
arrived as a single paragraph with the signature and the quoted reply run
into the prose.

Confirmed live against Stalwart 0.16.21 (2026-09-10): a plain-text message
comes back with `htmlBody` and `textBody` naming the same part, typed
text/plain, while a real multipart/alternative names two different parts.
`type` was already in BODY_PROPS; nothing looked at it.

TextBody was written for exactly these messages and was simply unreachable,
so this also restores what it does -- pre-wrap, quote-depth colouring and the
collapsible quoted block, none of which had ever fired on plain-text mail.
2026-09-10 13:32:37 -07:00
jcoffey-dev 520de85d12 Take vitest to 4.1.11, and stop two suites leaking their spies
GHSA-82fw-gwwq-j7x9 -- arbitrary file read through @vitest/mocker's
redirect mock -- has no fix in the 3.x line. The patched versions are
4.1.11 and 5.0.0-rc.2, so clearing it means the major. vite stays at
6.4.3: vitest 4 accepts ^6, and nothing outside devDependencies moves.

The bump surfaced a bug of ours rather than one of vitest's. vi.spyOn
now hands back the spy already installed on a method instead of wrapping
it in a fresh one, so a spy installed in beforeEach keeps its call count
across tests. compose-from-share expected two uploads and saw three: its
own two, plus the one from the test before it. The assertion was only
ever passing because each test happened to get a new spy.

Both suites now restore between tests, which is what the other five
spying suites already do. webpush had the same leak with no assertion
close enough to catch it.
2026-09-10 06:41:19 -07:00
jcoffey-dev 6f3aba06a6 Neutralise light panels nested inside dark painted cards
Closes #310.

A dark campaign rendered with beige cards inside it. markKeptSurfaces
marks any element whose declared background is below the luminance
threshold, with no area cap, so a 600px layout card is marked exactly
like a button. The CSS then exempted the marked element and its whole
subtree via [data-ihm-keep] *, so a light table nested in that card was
never touched. In the reported specimen 14 of 21 light panels survived.

The rule now is that being inside a painted surface is not inherited past
a sheet. The walk tracks that state and emits a second mark,
data-ihm-in-keep, for elements sitting on paint with no background of
their own; the CSS exempts those explicitly instead of exempting every
descendant. A nested light sheet ends the protection, and paint resumes
below it, so a button inside such a sheet is still kept whole.

The alternatives in the report were not taken. Dropping the descendant
half of the selector outright puts back what #294 fixed: a nested label
on a coloured cell loses its colour. An area threshold is a magic number
that misfires on both a legitimate hero banner and a small dark panel
with a light chip in it.

The tests assert against the neutraliser selector lifted out of
EMAIL_BASE_CSS rather than against the marks. The first draft of them
checked which attributes were set and passed against the unfixed code,
which proved nothing: the bug was in the rule that reads the marks, not
in the marking. All four fail without this change.
2026-09-08 19:32:09 -07:00
jcoffey-dev acc50f009c Archive and mark read from the notification itself
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.
2026-09-07 22:55:55 -07:00
jcoffey-dev 82470e8db0 Be somewhere a phone can share to
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.
2026-09-07 22:40:44 -07:00
jcoffey-dev 4e61adfe80 Badge the installed icon, and share to the phone rather than to Downloads
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.
2026-09-07 22:28:17 -07:00
jcoffey-dev befe1dbf53 Six more palettes, taking the picker to twelve
Catppuccin, Solarized, Ayu, Kanagawa, Everforest and Primer, each with the
light and dark variant its own project publishes: Latte and Mocha, Lotus
and Wave, and so on. Values were fetched from each project's own repository
and recorded in .palette-sources/palettes-upstream.md, with the two tiers
no project publishes marked derived rather than passed off as upstream.

Four candidates were rejected rather than adapted. Nord and Synthwave '84
publish no light variant, and inventing one is not porting a theme.
Monokai is proprietary and its licence forbids redistribution. Material
Theme has become a commercial product whose repository no longer publishes
a palette at all.

Body text is now lifted for contrast like every other text tone rather
than exempted and merely checked. Most of these palettes target their own
~4.5:1 for body text where ihasmail asks 7:1, so the old rule would have
rejected five of the six on a bar their designers never aimed at. Nudging
the published colour along its own hue is what the script already does for
muted text, links and accents, and every shift is printed in the generated
CSS: Solarized light moves 4.13 to 7.07, Primer needed nothing at all.

Primer is named for the design system, not for GitHub. The colour values
are MIT; the name and the logo are trademarks, and NOTICE says plainly
that nothing here is endorsed.

The picker grid already wrapped on its own, so twelve cards needed no
layout change.
2026-09-06 16:31:38 -07:00
jcoffey-dev 2464c9655f Let the theme be forced onto mail that styles itself
Appearance gained "Apply the theme to messages too" some time ago, and it
themes an HTML message only when the message brings no colours of its own.
That predicate is the right default and it almost never passes: one
`color:#FFFFFF` on one button label opts a whole message out, so in real
mail — receipts, shipping notices, anything from a template — the switch
did nothing at all and the reader kept a bright white card on a dark UI.

A second switch, off by default and only meaningful with the first on,
forces the palette over the sender's colours. It cannot be done perfectly,
which is why it is a separate, explicit choice: the same bargain a
dark-reader extension makes.

What it does is tell two kinds of colour apart. A *sheet* the design sits
on — the white 600px wrapper — is neutralised, and a *painted surface* —
a call to action, a footer banner — is kept whole so its label stays
legible on it. Relative luminance decides, at 0.5: white wrappers sit at
1.0, a blue button near 0.09. Only the painted ones are marked, with
data-ihm-keep, and one rule in EMAIL_BASE_CSS neutralises everything else.

Nothing the sender wrote is removed, so the switch is reversible, colours
arriving from a <style> block are covered as well as inline ones, and
print still pins the tokens to ink on white.

The mock grew the message this is about: an outer wrapper on
bgcolor="#ffffff", a <style> block, a coloured button, a grey footer.
Without one, neither the bug nor the fix could be seen.

Verified in a browser against the mock: with only the first switch on the
card is still white; with both, the wrapper computes to transparent, body
text follows the theme, and the button keeps white-on-blue. Two surfaces
marked, which are the two the message paints.

Closes #290
2026-09-06 15:58:25 -07:00
jcoffey-dev b4248a6661 Match an LDIF re-import on the entry's dn
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.
2026-09-04 07:50:49 -07:00
Coffey Labs 2a9e18f04c Merge pull request #271 from Coffey-Labs/fix/shortcuts-after-checkbox
Keep shortcuts working after a checkbox is clicked
2026-09-03 14:41:00 -07:00
jcoffey-dev 95e5c69e8f Keep shortcuts working after a checkbox is clicked
Ticking "select all" disabled every keyboard shortcut until the reader clicked
somewhere else (#260). Same for the per-message checkboxes, so selecting a few
messages and pressing e to archive them did nothing.

The guard that stops "a" archiving while you are typing into the search box
tested `tagName === "INPUT"`. That is also true of a checkbox, and a checkbox
keeps focus after a click -- correctly, since space should toggle it again.
So the guard was suppressing shortcuts for an element that swallows no
keystroke: space is handled by the browser before this listener runs.

The question is not "is this an input" but "does this input take text", which
is what isTextEntry now asks. A <select> counts, in the sense that matters
here: typing a letter jumps to the option starting with it, and a shortcut
would steal that.

Thirteen checkboxes and seven file inputs across the app were affected, not
just the one reported.

The regression test was checked against the old guard first: it fails there
and passes here, which is the only thing that makes it a regression test.
2026-09-03 14:38:15 -07:00
jcoffey-dev 1a842d8d14 Build the two rule descriptions as sentences, not fragments
Both describeRule functions assembled their output by concatenation, which no
catalogue could fix. A translator handed " and " or " on " in isolation cannot
move it: German puts the verb last, Japanese does not separate list items with
a word at all, and the fragments arrive in an order the English sentence chose.
Reported by a native speaker reviewing the German catalogue (#247), whose "the
summaries" item is the Sieve one.

Every branch is now one whole sentence with placeholders, so a translator
rewrites the sentence including its word order. Joining is Intl.ListFormat,
which gives "A, B und C" for an allof rule and the language's own disjunction
for anyof, rather than a hardcoded " and " that would be wrong twice over.

The recurrence tail no longer appends: ", 5 times" and ", until 2026-05-03"
wrap the sentence they qualify, so a language that puts the limit first can.

Ordinals become words. The old suffix table -- st, nd, rd, th, picked by
arithmetic -- is English spelling rules in code, and no catalogue can reach a
suffix chosen that way. German writes "1.", Japanese "第1". nthOfPeriod is 1-5
or -1 in practice, so five words and "last" cover it.

WEEKDAYS is gone. Its long names could have been catalogue entries but its
short ones never could: "T" is Tuesday and Thursday, "S" is Saturday and
Sunday, and a catalogue cannot hold two translations under one key. That was
bad data rather than missing translation, and Intl has every name in every
locale in three widths. lib/datetime.ts gains weekdayName, weekdayNames and
formatList; recurrence.ts keeps WEEKDAY_KEYS for the ordering, which is not a
language question.

Adds the first tests either function has had. Neither had any, and no test
would have caught what was wrong with them, since the English output was
correct -- so these pin the two properties that actually matter: fragments go
through the catalogue, and the joining is Intl's.

32 strings and 9 plural forms are new and land with each language.

Verified: typecheck clean, 1009 tests pass.
2026-09-03 14:20:50 -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
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
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
jcoffey-dev 1a6158aa70 Export a calendar as an iCAL file
The mirror of the import from #173, and the last thing contacts had that
calendars did not -- an address book could always be exported, a calendar
never could.

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

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

Three decisions worth stating rather than leaving to be found:

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

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

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

Closes #216.
2026-09-02 09:21:16 -07:00
jcoffey-dev 4a99b77bc3 Highlight saving, not discarding, on the unsaved-changes guard
The guard shipped with "Discard changes" as the only choice carrying a
colour -- a filled red button, against a plain outlined "Save changes" --
which made losing the work the loudest thing in a dialog whose entire
purpose is to stop that. The emphasis belongs on the safe answer.

A dialog choice can now be marked `primary`, and Save is. Discard keeps its
`danger` flag, but a danger choice is drawn the way `.menu-item.danger`
already is: a red label on the ordinary surface. In a list of answers a
filled red button is not "this one is destructive", it is "this one is the
default", which is the opposite of what it meant here.

That rendering change reaches the other choice dialog too -- the calendar's
"this occurrence or the whole series", where both answers are marked danger
because both delete something. Two filled red buttons become two red labels
and nothing is highlighted, which is right: neither answer there is the safe
one, so neither should look like it.

Checked in the browser against the mock, in both themes. Light: #dc2626 on
white, 4.8:1. Dark: the theme's own --danger, which every palette already
tunes for contrast on this surface.

Reported on #175 by the reporter's colleague, who is right that the
non-destructive action is the one that normally gets the highlight.
2026-09-02 07:05:27 -07:00
Coffey Labs b0edce1d9b Merge pull request #214 from Coffey-Labs/fix/drag-day-across-timezones
Move an event by the days the hand moved it, not to the date dropped on
2026-09-02 01:11:53 -07:00
jcoffey-dev 7b3069e41b Move an event by the days the hand moved it, not to the date dropped on
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.
2026-09-02 01:07:07 -07:00
jcoffey-dev 44b676c55d Name the push verification entry absolutely, from both sides
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.
2026-09-02 01:01:11 -07:00
Coffey Labs 13a6bcd66b Merge pull request #211 from Coffey-Labs/feat/ical-subscriptions
Subscribe to a calendar published at a URL
2026-09-02 00:54:55 -07:00
jcoffey-dev 4d18d94b63 Subscribe to a calendar published at a URL
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.
2026-09-02 00:52:06 -07:00
jcoffey-dev a30f96f76b Drag an event to move it, and its edge to resize 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.
2026-09-02 00:40:03 -07:00
jcoffey-dev c838569638 Merge branch 'main' into feat/palettes
# Conflicts:
#	web/src/store/settings.ts
2026-09-02 00:19:22 -07:00
jcoffey-dev 9aa0eda0d5 Six palettes, each with a light half and a dark one
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.
2026-09-02 00:14:54 -07:00