d599e7404f70001a2f5b6ba9ddde6a372405472a
32
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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.
|
||
|
|
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. |
||
|
|
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. |
||
|
|
8173e22ccb |
Update a re-imported event rather than skipping it
Contacts and calendars disagreed on a re-import: a vCard or LDIF entry whose identity a book already held overwrote the card there (#242, #274), while an event whose UID a calendar held was counted and thrown away (#222). The asymmetry was never decided -- it was where each half stopped. Decided on #279: calendars update too, with two properties held back. `participants` carries every attendee's accepted/declined and `recurrenceOverrides` holds every "just this Wednesday" edit made here. Both are decisions taken after the file was written, and a file that mentions them at all describes them as they were at export, so writing either one over would destroy work silently and return no error. A corrected export now fixes the time, the title and the location, and leaves who said yes alone. `uid` is held back with them: it is what the two were matched on, so it is already equal. The scan returns uid -> id rather than a set of UIDs, since updating needs something to address, and creates and updates now share one `maxObjectsInSet` budget the way contacts' `writeCards` does -- 300 new and 300 changed batched separately would be two calls of 300, neither over a ceiling of 500 and both refused. Counts become created/updated, reported as the contacts import reports them. Still no scheduling messages, on an update as much as on a create. That is a real cost -- an event a re-import moves is moved here and nowhere else -- and it is the lesser one: an import is not the place to start mailing a room full of people who never asked for it. Driven against the mock end to end: a second file with the same UID updated the event in place, took the file's title, start and location, and left an accepted RSVP and a per-occurrence override untouched even though the file carried participants of its own. |
||
|
|
fdfb83b254 |
Merge pull request #297 from Coffey-Labs/i18n-fill-gaps
Translate the eight strings that were still falling back |
||
|
|
9e7723ca66 |
Translate the eight strings that were still falling back
Every catalogue was at 1,255 of 1,279 with 24 strings rendering English. Eight of those are real UI text and are now translated in all nine languages: the five sort options that had no entry while their opposites did (Read first beside Unread first, Unstarred first beside Starred first, Smallest first beside Largest first, and the two alphabetical directions), and the three sentences behind the link and external-sender warnings. Each follows the phrasing its own catalogue already used for the sibling it sits next to. The remaining sixteen are left in English deliberately, because translating them would be wrong: product and project names, the sample addresses in placeholder text, bare URL prefixes, the ellipsis used as a masked value, and two mail header names. Per locale: 1,263 of 1,279, up from 1,255. **The stale list is not touched, and should not be cleaned blindly.** The checker reports 41 keys as translated-but-never-looked-up, and some of them are live. "Classic" is the clearest: the palette picker renders it through translate(p.name) from a constant, so the extractor sees no literal, while the German "Klassisch" it would delete is the exact fix issue #247 asked for. "Add star" and "Remove star" are the same shape, reached through a ternary in a JSX label. Teaching the extractor those two call sites is the prerequisite for trusting that list. |
||
|
|
276ecfccff |
Stop naming every palette in the credit line
The hint under the theme picker listed the third-party palettes by name. That sentence is translated into nine languages, so every palette added meant rewriting it, retranslating it nine times, and leaving the previous version behind as a stale key nothing looks up. It now describes the rule instead of enumerating the cases: a palette named after another project is that project's work, used under its own licence. True of the four here, true of the next one, and true without saying "MIT" for a palette that might not be. The names are already in Settings beside each swatch and in NOTICE with their copyright lines, which is where a credit belongs. Swapped rather than added in all nine catalogues, so the old key is gone rather than left stale: 1,255 of 1,279 translated per locale, unchanged, and the 41 pre-existing stale keys are neither added to nor cleaned up here. |
||
|
|
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 |
||
|
|
c84f190f76 |
Check S/MIME signatures, and remember who signed
A signed message now says whether that holds up, as it is read. This is
verification only: nothing here signs, encrypts or decrypts, and the
private-key question that blocks those is untouched. Verifying needed
none of it, because the certificate travels inside the message -- which
is why this is the half that could be built.
What it checks. For multipart/signed carrying PKCS#7, the exact bytes of
the signed part -- headers included, canonicalised to CRLF -- are hashed
against the messageDigest attribute, and the signature over the signed
attributes is verified with WebCrypto against the certificate inside the
message. RSA PKCS#1 v1.5 and ECDSA over P-256/384/521, with SHA-256, 384
or 512.
The trust model is the design, and it is deliberately small. A browser
has no system trust store, and the certificate arrives inside the
message, so anyone can self-sign as anyone: on its own a good signature
shows only that the sender held the key they attached. So the word
"verified" is never rendered, and the reassuring case is not the loud
one. What carries the weight is remembering -- the first signed message
from an address pins its fingerprint, later ones are compared, and a
signer that changed is reported with both names and told to check by
another route. Trust on first use, no certificate authority anywhere.
The pins live in the account's settings rather than the browser: one
that only a single device knew would greet the same correspondent as new
everywhere else, which is how people are trained to click past the one
warning that matters. A pin records the message that created it, so the
message that established a signer keeps saying so instead of appearing
to be corroborated by itself -- without that, the very first signed
message anybody receives reads as "the same signer as before", where
before is itself. A changed, mismatched or expired signer is never
pinned, since writing the anomaly into the baseline makes every later
message agree with it.
Three things are declined rather than attempted, and all three say
"could not check" rather than "does not check out", because ignorance
and an accusation are different claims:
- OpenPGP, by name. The signature carries no key and there is nowhere
to get the sender's: x:PublicKey is the account's OWN registry, and
a keyserver or WKD lookup would tell a third party who you
correspond with -- the leak the image proxy exists to close.
- SHA-1. Not forgeable in practice today, still not something to put a
tick beside.
- RSA-PSS, whose salt length lives in parameters this does not read.
Guessing wrong would report a good signature as bad.
Nothing validates a chain: no CA bundle is shipped and revocation is not
checked. "Issued by" reports what the certificate claims, and a
self-signed one claims itself.
The DER, CMS, X.509 and MIME readers are hand-written and deliberately
narrow -- no new dependency, and the whole verifier is a lazily imported
8.6 kB chunk that a reader of unsigned mail never downloads. The one
place this is easy to get quietly wrong has its own function and its own
test: signed attributes are signed as a SET OF, not as the [0] IMPLICIT
they arrive as, and hashing the message instead would make every
signature "pass".
Tested against real `openssl smime -sign` output rather than hand-built
fixtures -- RSA, ECDSA, a tampered copy, and a valid signature by a
certificate for somebody else -- because a signed message written by
hand only agrees with whatever its author believed the format to be.
Also driven in a browser against the mock, which now serves three real
signed messages so every branch of the banner is reachable.
Translations: 34 new strings in all nine catalogues, 306 entries.
Falling back to English is unchanged at 24 per language.
|
||
|
|
cee107d948 |
Select contacts, and empty an address book
Raised on #174 as the other half of a migration -- import, notice something is wrong, empty the book, correct the export, import again -- and tracked as #277. The gap turned out to be wider than the ask. Contacts had no multi-select at all: the only delete in the module was the cross on a single card's pane, one card and one confirmation at a time. `destroyCards` has taken a list and batched it against maxObjectsInSet since #218, and nothing in the UI ever handed it more than one id. So "empty this address book" was missing, and so was "delete these fourteen". The list now has checkboxes, on hover the way the message list's are, and always on a touchscreen where there is no hover to reveal them. Shift-click takes the run between two rows. The search box gives way to a selection bar rather than sitting beside it, because what the count promises is what the search left on screen. A selection is cleared when the book being shown changes, since carrying it across would leave a count describing rows that are no longer there and a Delete aimed at them. Emptying a book is in the book's own menu, beside the import and export that moved there in #226, and separate from Delete, which takes the book with it. A default book cannot be deleted and can perfectly well be emptied, which is most of the reason it is its own entry. The part that is not a deletion, and the reason this is not one destroy over everything in the book: a card filed in two books belongs to both, and `ContactCard/set destroy` takes it away from both at once. Emptying one book must not empty another, so a card with a second home is patched out of this one and left alone. That is reported separately afterwards, because it would otherwise look like contacts that refused to go. `destroyCards` now answers with what the server confirmed rather than throwing on the first refusal. A refusal that took half a selection with it still deleted the other half, and an error saying only that it failed sends somebody looking for contacts that are already gone. Both callers report the count and the reason apart. Emptying a shared book is deliberately not offered: the cards live in the owner's account and this client has no path to write there. One bug found by driving the built app rather than by any test, and worth recording because of where it hid. The range a shift-click covers was measured inside the `setPicked` updater -- which React runs when it gets round to rendering, by which time the anchor ref has already been moved to the row that *ended* the range. Every shift-click selected exactly one row, and every store assertion still passed, because nothing was wrong below the component. The anchor is read before the updater now, and the contacts view has its first component tests: ten of them, six of which fail if the measurement moves back inside. Twelve new strings, in all nine catalogues, so nothing new falls back to English. |
||
|
|
1070ee13bc |
Add seven plural forms no catalogue ever had
Found by widening the coverage check to plural() forms in every file rather
than the two being worked on. Seven counted strings in the Files view and the
event editor had never been in any of the nine catalogues, so they rendered in
English whatever language was chosen.
Not a regression from the recent work -- they have been missing since the
features landed, and every earlier scan looked at t("literal") sites and the
plurals of whichever file was in hand.
All nine languages, one commit rather than nine: this is a single gap in a
check rather than a translation pass, and splitting it per language would
suggest nine decisions where there is one.
|
||
|
|
104e3c7ba0 |
Translate the rule sentences into Brazilian Portuguese
#261 rebuilt the Sieve rule summaries and the recurrence descriptions as whole sentences with placeholders, so that a translator can move the parts rather than being handed " and " on its own. This is the Brazilian Portuguese half of that. 32 strings and 9 plural forms. The ordinals are words -- there is no suffix to append here, which was the point -- and the day and item lists are joined by Intl.ListFormat rather than a translated separator. |
||
|
|
e5c8594901 |
Add the third-pass strings to Brazilian Portuguese
The sweep in #259 found user-facing English in lib/ and store/ -- scheduled send, the read-receipt explanations, compose toasts and thrown errors -- plus two swipe labels that reach t() through a variable and so were invisible to a scan for t("literal") call sites. 17 strings and 3 plural forms. "{n} days" and "{n} hours" replace a suffix appended in the code, which is English grammar the catalogue could not reach. |
||
|
|
f5ba09ef77 |
Catch the Brazilian Portuguese catalogue up with what shipped after it
Features landed after the catalogues were written and their strings were never added, so they rendered in English. Reported against German (#247); every language had the identical gap. 176 entries: 153 from features that shipped after the translation pass, and 23 keyboard bindings whose group and description are registered in English at the call site and translated at render. Follows the decisions this file already pins: você rather than the formal address the other Phase 1 languages took, and the fixed terminology, so Caixa de entrada, Pasta, Mensagem, Conversa, Marcador, Lixeira, Rascunhos and Configurações read the same here as everywhere above. Fifteen strings are deliberately absent and keep falling back to English: example.com and the other input placeholders, the product names, and the verbatim header names List-Id and X-Spam-Status. Verified: typecheck clean, 1000 tests pass, nothing missing from pt-BR.ts. |
||
|
|
9f4bd65fd5 |
Update a contact on re-import rather than skipping it
#228 skipped a vCard whose UID the book already held. The reporter asked for the opposite on #174 and he is right: the reason to import a file a second time is usually that the first one was not right, so skipping means a corrected export corrects nothing. A merge, not a replacement. Properties the file carries overwrite what is here; properties it does not mention are left alone, so a phone number added in ihasmail after the first import survives a re-import of the original file. The cost is that a field genuinely deleted at the source stays here, which is the better way to be wrong -- the other way round loses work nobody asked to lose. Worth confirming with him rather than assuming. `addressBookIds` is left off the patch. The card is already in this book, so saying it again says nothing, and saying it on a card that is also in another book would move it. Creates and updates now share one batch budget. Stalwart counts every object in a /set together, so batching the halves separately would send 300 new and 300 changed as two calls of 300 and be refused for a limit of 500 that neither half exceeds. LDIF is untouched and still reports look-alikes without acting on them, since what it should match on is the question still open on #223. Both imports keep one answer shape so a caller need not know which it called; LDIF's `updated` is always 0, which is the honest number rather than a missing field. The message a vCard attached to a message shows changes with it: the newer copy now wins instead of being dropped, so it says the contact was brought up to date rather than that nothing was added. Refs #223. |
||
|
|
b10ce2f9dd |
Make "Show original" in the headers dialog the action, not a description
The hint at the foot of Message headers named an action and left you to go find it. Requested in #236, and the reporter is right that it is the shape of the thing rather than the size: telling somebody a feature exists is half a job when the other half is one element away. Clicking it now closes the headers dialog and opens the original, so it reads as going deeper rather than as opening a second window. `tNode` rather than a sentence chopped either side of a button: the sentence stays whole for whoever translates it, and a language that puts the verb somewhere else can move the hole rather than being handed two fragments. The link style needed unscoping to work, which turned out to be a bug of its own. `.link-btn` was written for the composer's To and Cc labels and scoped to `.composer-field label`, so the two callers outside it -- the trusted-domain list in Privacy settings, and now this -- rendered as default button chrome in the middle of a sentence. The rule is now unscoped and Privacy is fixed by the same change. Checked in a browser: the hint reads as a sentence with a dotted-underlined link in it, clicking swaps one dialog for the other, and the raw message is there. Closes #236. |
||
|
|
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. |