45f1309d062b2ee9d50aefb09e93a392e7d16cbc
16
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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.
|
||
|
|
7ba749148f |
Make an event out of a message
Asked for in #167: a right-click on a mail that turns it into a calendar entry, the way a bill or a task becomes a reminder. Nothing clever, and deliberately so -- the subject becomes the title, the body becomes the description, and the reader supplies the one thing the message cannot. A due date is exactly that thing. "Due on the 14th" in an invoice is not a date a parser could be trusted with, and a wrong guess quietly scheduled is worse than no guess at all, so the editor opens on the next half hour for an hour and the reader fixes it. Forward rather than now, because a start time that has already passed by the time they press Create is one more thing to correct. The body is capped at 5000 characters. A newsletter is a message too, and its whole body would be stored on the event, synced to every device, and shown in a three-row textarea; what is worth keeping -- the amount, the account, the address -- is near the top. The cut is marked, so a truncated bill is not read as the whole of it. One message only. The list menu acts on the selection everywhere else, but there is no sensible event to make out of five mails, and the mobile entry appears only when exactly one row is held. The editor lives inside CalendarView and the reader is in the mail view when they ask, so the draft waits in the calendar store until that view mounts and takes it -- once, or it would reopen on every later visit. It seeds a form rather than an event: the dialog still says New event and still has to be pressed. Called *Create event…* rather than "appointment", which is the word the issue used: it opens the New event dialog, and each catalogue already has its own settled noun for that -- Termin, événement, 日程. Reachable three ways, since a phone has no right-click: the row context menu, a message's ⋮, and the ⋮ of a held row on mobile. Hidden entirely where the account has no calendar. |
||
|
|
94639e8420 |
Hang every folder off one edge, and give the drawer a way out
Two things the drill-down got wrong, both found on a phone-width window. The folders did not line up. The rule that drops the twisty's 30px gutter was hung on the rows offering a drill, so only folders with children lost it -- they sat 18px left of every folder without any, and the column of icons came apart. Whether a folder has children is not a reason to hang it somewhere else. The class moves to the list, which is what the indent is a property of; icons now share one column and labels another, at every level and on the back row too. There was no obvious way back out of the drawer. It covers the top bar -- it is taller than it -- so the hamburger that opened it is underneath, and pressing the same place again did nothing at all, since that handler only ever set the drawer open. The dimmed strip beside the drawer was the only exit, and nothing says so. There is now a close where the hamburger was, moved by the same rule so it lands on exactly the same pixels, and the hamburger itself toggles rather than only opening. Escape closes it too, for a tablet with a keyboard. Raising the top bar over the drawer instead would have been the smaller change and is not available: the drawer is at 950 and a full-screen composer at 800, so a top bar above the first is also above the second. |
||
|
|
6c958b8609 |
Folders one level at a time on a phone, and targets a thumb can hit
Three things the mobile interface got wrong, all of them measurable. The folder tree spent its width on depth. Four levels down, the 16px indent steps and the 18px twisty left a folder 85px of a 300px drawer to print its name in, and the twisty had walked far enough right that hitting it was luck -- a miss landed on the row, which is a link, so the wrong tap also cost a navigation. Under 768px the tree is now a drill-down: one level at a time, no indent, a back row above it, and a chevron at the right edge that is the same size in the same place on every row. Tapping the row still opens the folder; only the chevron changes what the list shows. The tree is untouched above 768px, where a wide sidebar can afford the indent and where dragging a folder onto another folder -- still the only way to reparent one -- needs both of them on screen at once. Every control in the top bar was under the 44px a fingertip covers: the icons at 36, the search filter at 30, the row menu at 24, and the hamburger 6px from the bezel in the corner a thumb is worst at. They keep the size they draw at and gain a transparent hit area, since growing the boxes would reflow a bar with no room to give; rows grow for real, because a 44px target inside a 36px row reaches into its neighbours. The one exception is the row menu, held to 36px wide: at a full 44 it overlapped the drill chevron by 4px, so its right edge silently drilled instead. Pinch was dead on the message list. `.msg-row` sets `touch-action: pan-y` to feed the swipe gesture the horizontal movement the browser is not using -- but naming any value drops every gesture not named, zoom included. It worked on an open message and died on the list, which reads as the zoom being broken at random rather than as a rule about rows. `pan-y pinch-zoom` keeps the swipe and gives the zoom back. |
||
|
|
a6863e98cc |
Second extraction pass: the strings the codemod could not see
`i18n:coverage` reported 100% while a hundred-odd strings rendered
English in every language. It was not wrong about what it measured: it
reads JSX text, and none of these were JSX text. They were toast
arguments, `confirmDialog({ title, confirmLabel })` props, `title=` and
`aria-label=` attributes, and template literals — every one built from an
expression the codemod cannot read.
176 source strings and 15 plural sets now go through t() and plural(),
translated into all nine languages. Where English put a word in a slot,
the sentence is spelled out per branch instead: `Filter ${verb}` became
"Filter saved" and "Filter created", because which word agrees with what,
and where it sits, is not a property English gets to decide for everyone.
Counts that were `${n} message${n === 1 ? "" : "s"}` are plural() calls,
so Russian and Ukrainian get three forms and Japanese and Chinese get the
one they actually have.
Two of the catalogue's own conventions were worth learning the hard way.
Plural entries are keyed on the English *other* form, not `one` — `one`
is a form English happens to have and Japanese does not. And a constant
table holding English that is translated at the render site is fine: the
literal is a key, not a leak.
Which is what the new check encodes. `scripts/i18n-literals.mjs` accepts
a string that is wrapped where it is written or is a catalogue key
somewhere, and refuses one that is neither — a string no catalogue can
translate, however many languages ship. It found twenty more than my own
sweep had, including the stale-folder toast seen in production. It runs
as part of `npm run i18n:check`.
Also fixed: the catalogue is now awaited before the first paint. The
tree is rebuilt when a catalogue lands, so components recover on their
own, but a string computed in an effect does not — a toast fired in that
gap is emitted in English and stays English. The wait costs nothing
visible, since the session bootstrap already shows a spinner and English
resolves immediately.
And the Japanese agenda title loses a space Japanese does not use:
"{date} からの予定" was written with the English habit of spacing around
a placeholder.
|
||
|
|
f94cc2ce51 |
Translate the labels the extractor could not see
Three places built user-visible English out of expressions rather than
writing it as JSX text, so the extraction codemod never found them and
they stayed English in all nine languages — including the five that have
been in production for weeks.
The calendar view switcher was the worst of them: it spelled its labels
as `v[0].toUpperCase() + v.slice(1)`, which is correct English and
untranslatable anywhere else. Day, Week, Month and Agenda were already in
every catalogue, sitting unused, because the buttons never asked for
them. They now come from a Record<View, () => string>, so TypeScript
makes the map exhaustive and adding a view forces adding its label. The
labels are functions rather than values: a module-level object would
capture whichever language happened to load first and keep it.
The other two needed new source strings, added to all nine catalogues:
the composer's title for an untitled draft ("New message"), its status
line ("Sending…", "Saving…", "Error", "Saved {when}", "Unsaved"), and the
agenda view's own title ("Agenda from {date}").
This is not the whole of it. A sweep for the same shape — template
literals, toast arguments, and dialog props rather than JSX text — turns
up roughly a hundred more strings, mostly toasts and confirmation
dialogs. Those are a second extraction pass rather than a fix, and are
left for one.
|
||
|
|
fc5c8dd4fa |
Portuguese (Brazil), completing Phase 1
781 of 796 strings; the fifteen left are product names, bare URLs and example addresses. Generated by AI, unreviewed, marked Beta. This is Brazilian Portuguese specifically, and the tag says so rather than claiming "Portuguese". It is not a stand-in for European Portuguese: the vocabulary diverges in exactly the places a mail client lives -- arquivo against ficheiro, tela against ecrã -- and offering one variety as though it were the other is worse than offering English, because the reader cannot tell it was not meant for them. A pt-PT catalogue would be a separate file. Register is "você", and this is the one place Phase 1 deliberately breaks its own rule. The other four all took the formal address; Brazilian Portuguese has no comfortable equivalent. "O senhor" is deferential rather than merely polite and reads as stiff or sarcastic in software, while "você" is the neutral default Gmail, Outlook and every Brazilian bank use, carrying none of the familiarity "du" or "tu" would elsewhere. Following the rule here would have produced a worse translation by obeying a decision made about other languages. The rule was always "address the reader the way the language does it", and these five are what that looks like rather than five copies of one answer. "Marcador" for label, which is Gmail's word in Brazil: a fourth different outcome from the same rule about using what the reader will meet elsewhere, after English, Libellé and Etiqueta. Phase 1 is complete: de, fr, nl, es, pt-BR, each unreviewed and each marked Beta until a speaker signs it off. That review is the part nobody has done and the part that decides whether any of this was worth shipping. |