fecae2acd3e640e49029d44daf043ac50c1f0ba9
15
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
fecae2acd3 |
Translate English built from template literals in attributes
i18n-literals now flags a template literal in a UI attribute or prop when
there are words between its values: `Remove ${email}`, `${name} — shared by
${owner}`. It cannot be a catalogue key as written, so neither the key
exemption nor the sentence-shape test applies. A template that is only
punctuation around values, like `${name} (${size})`, is left alone.
The twelve it found are now keys with placeholders: the quota bar title,
the address menu's label, a folder's subfolder unread count, a recipient
chip's remove button, the contact editor's title, shared and available
calendars and address books, the date and time fields' labels, the
attachment title's fallback name, and the free/busy bar. The bar showed
the raw JMAP busyStatus ("confirmed") and now says Busy, Tentative or
Unavailable.
11 new keys in all nine catalogues. Remove {address}, Date, Time, Busy and
Tentative already existed.
|
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
c9ab203b76 |
Show birthdays from the address book as a calendar
The dates were already on the contact cards and nothing ever showed them, so the one thing a birthday is for -- noticing it in time -- was the one thing the app could not do with it. Derived, not stored. The dates stay on the cards: a second copy of the same fact drifts the first time somebody corrects one, and keeping a calendar of its own is exactly what ihasmail does not do. Entries are generated when a view asks for a range and vanish when the contact does. They go through instancesIn like everything else, so no view has to know they are different. Off until switched on. It is derived data, and a calendar that fills itself with dates nobody put there is a surprise rather than a feature. It can also be hidden from the calendar's own sidebar without being turned off, which is the same distinction the shared calendars already draw. They cannot be edited or deleted, and that falls out of the design rather than being special-cased: the virtual calendar reports no write rights, so every control that already asks before offering Edit or Delete declines on its own. updateEvent and destroyEvent refuse a synthesised id as well, so the store is safe whatever calls it -- including anything added later. Two things about the dates themselves. A card that records only a day and month is the common case rather than the exceptional one, and gets a birthday with no age rather than no birthday. And 29 February falls on the 28th in a year that has no 29th: somebody born in February has a birthday in February, and moving it into March is the arithmetic winning over the fact. Both are conventions; these are the ones that keep the fact intact. The mock now carries birthdays on most of its contacts, including one with no year and one on 29 February, so both cases are visible without a real address book. |
||
|
|
6431ec87f5 |
Import an iCal file into a calendar
An .ics reaches you by ways that are not your mailbox -- a ticketing system a customer invited, a colleague's export, a booking confirmation forwarded on -- and until now the only events ihasmail could take were the ones attached to a message it had received. The calendar's own menu now offers "Import iCAL file…", which files everything in the file into that calendar. No global button: the issue is right that this is not a frequent enough thing to earn one. The parsing is the server's, through the same CalendarEvent/parse an emailed invitation already goes through. An .ics is not a format worth reimplementing in a browser, and Stalwart's reader handles what a hand-rolled one would not. Every event goes out in a single CalendarEvent/set. The round trips are the smaller half of the reason: createEvent invalidates on the way out and invalidating refetches every cached range, so a year of events imported one at a time would refetch the calendar a few hundred times. Nothing is mailed to anyone named in the file. Importing is filing something you already have, and scheduling messages would be a surprise to its participants. The mock's parser read the whole file with one regex and returned one event, which is all an invitation ever needed. It now reads per VEVENT, so a multi-event file can be tested against it, and it invents an organiser and an attendee only for events that carry a METHOD -- a plain export is not addressed to anyone. Closes #173 |
||
|
|
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.
|
||
|
|
8ea611f7f7 |
Extract 515 strings by codemod, and the two bugs only a screenshot caught
Wrapping ~1,000 strings by hand is a thousand chances to mistype the copy
itself, and a parser does not get bored. scripts/i18n-extract.mjs does the
mechanical part -- JSX text and the attributes a person actually reads -- and
refuses the rest rather than guessing. 78% now: 515 wrapped, 143 left.
What it refuses matters as much as what it does. Text split around an
interpolation arrives as separate fragments, and wrapping each on its own
produces "Move " and " messages", which no translator can do anything with;
those are listed for a person to rebuild as sentences. So is anything
containing a double quote, which would end the literal.
Three things it had to be taught, each found by running it:
- <code>, <kbd> and <pre> are not prose. The first run wrapped `label:name`
inside <code> -- a search operator, where translating it breaks the thing it
documents. Subtrees marked translate="no" are skipped for the same reason.
- `t` is a natural name for a callback parameter and several files already use
it, so an import called `t` is shadowed inside those callbacks -- silently,
wherever the local happens to be callable. The name is checked per file now
and aliased to `translate` where it is taken.
- JSX decodes HTML entities and a JS string literal does not, so
`Language & region` moved into t("...") and rendered the entity on screen.
That last one is the one worth remembering. Typecheck passed, 443 tests
passed, and the page said "Language & region" in plain sight. It took
looking at a screenshot, and then a sweep of ten views to find the second
occurrence in a sentence I had written by hand earlier the same day. Nothing
in the toolchain was ever going to catch it: it is valid TypeScript rendering
valid text that happens to be wrong.
The codemod decodes entities now, and checks for a quote after decoding rather
than before.
|
||
|
|
9544fa5f12 |
Let the owner stop sharing a calendar or an address book
Revoking a share meant opening the share dialog, removing each person from it in turn, and saving. That is the right tool for changing who has access and the wrong one for withdrawing it altogether, which is the more urgent of the two and the one someone is likely to want in a hurry. Both now offer "Stop sharing" in the context menu, which clears the lot after a confirmation saying how many people lose access. It appears only when there is something to revoke, so the menu says whether a thing is shared as well as offering to change it. A calendar also says it is shared now. Address books have carried that badge since they gained sharing; calendars never did, so the only way to find out was to open the dialog and look -- which for the owner of a dozen calendars means opening a dozen dialogs. Both go through the existing update paths, so a server that refuses is reported rather than swallowed. Verified against the mock, both kinds: sharing one shows the badge and adds the entry, confirming clears `shareWith`, the badge goes, and the entry disappears with it since there is no longer anything to stop. |
||
|
|
5e5bec31b7 |
Remember an added address book when the server will not
"You are not allowed to modify this address book." That is Stalwart's answer to a sharee subscribing to a book shared read-only, and it is a fair one: `isSubscribed` lives on the collection rather than on the reader, so adding one is a write to the *owner's* account. The identical write on a shared calendar is accepted. The difference is the server's. So the flag is still asked for first -- a preference the server holds is one every client agrees about -- and when it is refused the answer goes in the reader's own synced settings instead, as `addedShares`, keyed by account and collection. Either record counts as added, and the rule has a test of its own because three components ask the question and they must not drift apart. Two things about how this hid. The refusal arrives as a *successful* response with the id in `notUpdated`, so the version that ignored it saw nothing wrong and the button simply did nothing -- fixed a commit ago, and it is what turned "the + does nothing in Firefox" into a sentence from the server. And it cannot be seen from the owner's account at all, where the write succeeds: it took two browsers signed in as two accounts to find, which is why it survived every check made from one. The mock refuses the same write for the same reason. One that accepted it would have gone on agreeing with the belief that shipped. Verified against it: adding the shared book is refused by the server, recorded in settings, and the book moves to "Shared with me" with its contacts reaching the To field; removing undoes all three; and it survives a full page reload, which is the point of putting it where the settings live rather than in this tab. |
||
|
|
0215255280 |
Add a shared calendar or address book, rather than being given it
An account linked for its files also offered its calendar and its address
book, and neither had been shared. That was not ihasmail inventing them:
asked about the other account, the live 0.16.19 returns every calendar
and every book it holds, each with full rights -- read, write, share,
delete, all true. There is nothing in the rights to tell "shared with me"
from "reachable at all", because the server does not distinguish them.
`isSubscribed` does, and it is the field JMAP has for exactly this: it
came back false on all of them. So a shared calendar or book is listed
under "Shared with me" once the reader has added it, and under "Available
to add" until then, with one button either way.
Nothing unsubscribed contributes anything. A calendar that has not been
added draws no events, and a book that has not been added lends no cards
to the To field -- which is the one that mattered most, since it is the
difference between offering a colleague's contacts and offering a
stranger's without anyone having asked.
The mock's shared calendar and address book now arrive unsubscribed, the
way the real server hands them over, so the adding is exercised rather
than skipped; and its `Calendar/set` and `AddressBook/set` route by
account, since subscribing to somebody else's is a write to their
account and the mock had nowhere to put it.
Verified against the mock: the shared calendar sits under "Available to
add" with no events in the grid, adding it moves it to "Shared with me"
and its events appear, removing it undoes both; and `suggest("katherine")`
finds nothing until the shared book is added, then finds her.
|
||
|
|
270fb3d32c |
Shared calendars in the calendar, and no more account switcher
Three things from using it on two real accounts. A calendar shared with you never appeared. Nothing was wrong with the share -- the calendar had nowhere to be shown. Calendars loaded from one account and one only, so the sharer's were reachable solely by switching the whole app to their account, which is the door being closed below. They now sit under "Shared with me" beside the reader's own, in their own colour, with their events in the grid and a click to hide them like any other calendar. Their events go through `instancesIn`, the one funnel every view already reads, so month, week, day and agenda got them without being touched. Events and calendars from another account are keyed by account as well as id, and hiding one is remembered under the same key: an id means nothing outside the account holding it, and two accounts sharing an id is ordinary rather than unlucky. An account that shared nothing was listed in Files as though it had. Every non-personal account was offered on the reasoning that its folders could speak for themselves -- but an account whose *calendar* was shared has no folders to speak with, and appeared as an invitation to open an empty pane. Each is now asked for one file before being listed, and silence is taken for an answer. And the account switcher is gone from the profile menu. It existed to reach what other people shared and was the wrong door: it moved the whole app to somebody else's account, and since Stalwart advertises every capability on a shared account, mail, calendar and contacts went with it and were refused. Everything it was for is now in the module the share belongs to, found without anyone needing to know an account was involved. What this does not prove is that Stalwart delivers a calendar share at all. The mock says the client handles one, which is the half that was missing; whether the server behaves like address books, which work, or like mail folders, which do not, needs the two accounts again. |
||
|
|
d82ff15921 |
Configurable date and time formats, defaulting to the Stalwart locale
Every user-visible date now goes through web/src/lib/datetime.ts, driven by three settings (Settings > General > Locale): - Language & region: automatic, or any of the 618 locales CLDR has data for, each named in its own language and script (web/src/lib/locales.ts, generated by probing Intl over the subtag space). - Date format: automatic (locale order), 22.11.2025, 22/11/2025, 11/22/2025, or ISO 8601 2025-11-22. - Time format: automatic (locale), 24-hour, or 12-hour. Automatic takes the locale Stalwart has for the account, read best-effort at login via x:Account/get (urn:stalwart:jmap) and passed to the client in the session; servers without the capability, or that deny sysAccountGet to a regular user, fall back to the browser locale. POSIX forms are normalised (de_DE.UTF-8 -> de-DE) and script modifiers kept (sr_RS@latin -> sr-Latn-RS, uz_UZ@cyrillic -> uz-Cyrl-UZ), while dialect/variant/currency modifiers are dropped and a script the locale already implies is not appended. Numerals follow the locale (22.11.2025 renders as Arabic-Indic digits under ar-EG); ISO 8601 is the exception and pins date and clock to Latin digits so one line never mixes digit systems. Rewired: message list and headers, quoted reply headers, calendar (titles, weekday and hour gutters, mini calendar, agenda, popovers, invite cards, free/busy), contacts, files, sessions. No raw toLocale*String date calls are left in web/src. Native <input type="datetime-local"> pickers always follow the browser locale and cannot be restyled by a page, so the out-of-office fields echo the entered instant in the chosen format underneath. Also: month-grid day labels no longer wrap when they hold a date, and the mock server serves x:Account/get (MOCK_LOCALE, default en_US). Closes #1 |
||
|
|
645b8b510f |
ihasmail 2.0: rebuild as Stalwart-first JMAP webmail
Replace the FastAPI/HTMX prototype with a Node/Hono session proxy and a React 19/Vite SPA. Mail (conversation view, search operators, labels, sanitised HTML, privacy image proxy, invites, undo send, templates), calendar (month/week/day/agenda, invites, free/busy, categories, context menus), contacts (JSContact, groups, vCard), files, Sieve filter builder (incl. filter-from-message with retroactive apply), vacation, identities with default + Reply-To, PWA/mobile layout, push via SSE, in-memory mock Stalwart for dev, Docker + CI. |