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.
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.
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.
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.
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.
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.
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.
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.
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.
A sweep of lib/ and store/ for user-facing English, after the views were done.
The pattern here is the one #248 found: a module returns an English sentence
and the view renders it without asking for a translation.
Scheduled send was entirely untranslated. The four presets -- Later today,
Tomorrow morning, Tomorrow afternoon, Monday morning -- were rendered raw, and
scheduleError() returned three English sentences straight to the picker.
describeSpan() built "30 days" by appending an "s" unless the count was one,
which is English grammar written into the code: it produces the right German
only by the two languages happening to agree, and Russian needs three forms.
It is plural() now. scheduleError translates in place rather than returning a
key, because it composes a sentence around that span.
Read receipts: refusalText() returns five explanations, all rendered raw in
MessageView, and the offered case was a template literal -- "Requested, to
x@y. Never sent automatically." -- with the address concatenated in. It takes
a placeholder now, so the sentence can be reordered.
Compose: the sending toast and its Undo, the Open draft action, the two
attachment failures, and three thrown errors that surface to the reader as
toasts.
Two swipe labels, Add star and Remove star, are rendered through
t(desc.label) and have never been in any catalogue -- the coverage scan that
found the other 176 only looked at t("literal") sites, so labels reaching t()
through a variable were invisible to it. Every such table is now enumerated
and all 61 values checked: these two were the only ones missing.
17 strings and 3 plural forms are new and land with each language.
Verified: typecheck clean, 1000 tests pass.
A sweep for UI text still rendering in English, after the nine catalogues were
brought up to date. Four dialogs were building their own English:
- The delete confirmation in MailView, entirely: both titles, both messages
and the confirm label. Its counts read "message(s)", which is a
parenthesis standing in for agreement -- every language that inflects got
the wrong form. They are plural() calls now.
- Rename, in the Files tree.
- New address book, and its Name placeholder.
- New category, and its Name placeholder. The button opening that dialog was
already translated, which is how it went unnoticed: the label read right
and the dialog it opened did not.
Rename, New address book, New category, Name and Delete are already in all
nine catalogues. Delete?, Delete forever? and the two plural forms are new and
land with each language.
Verified: typecheck clean, 1000 tests pass.
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: 您 where the reader is addressed
directly and the pronoun dropped everywhere it can be, and the fixed
terminology, so 收件箱, 文件夹, 邮件, 会话, 标签, 已删除邮件, 草稿 and 设置
read the same here as everywhere above.
Fifteen strings are deliberately absent and keep falling back to English:
example.com and the other input placeholders, the product names, and the
verbatim header names List-Id and X-Spam-Status.
Verified: typecheck clean, 1000 tests pass, nothing missing from zh-Hans.ts.
Features landed after the catalogues were written and their strings were never
added, so they rendered in English. Reported against German (#247); every
language had the identical gap.
176 entries: 153 from features that shipped after the translation pass, and 23
keyboard bindings whose group and description are registered in English at the
call site and translated at render.
Follows the decisions this file already pins: ви in lowercase, the infinitive
for actions, and the terminology that keeps this a Ukrainian catalogue rather
than the Russian one with a different name on it -- Тека and not папка, Мітка
and not ярлик, which in Ukrainian means a shortcut. Вхідні, Лист, Листування,
Кошик, Чернетки and Налаштування read the same here as everywhere above.
Fifteen strings are deliberately absent and keep falling back to English:
example.com and the other input placeholders, the product names, and the
verbatim header names List-Id and X-Spam-Status.
Verified: typecheck clean, 1000 tests pass, nothing missing from uk.ts.
Features landed after the catalogues were written and their strings were never
added, so they rendered in English. Reported against German (#247); every
language had the identical gap.
176 entries: 153 from features that shipped after the translation pass, and 23
keyboard bindings whose group and description are registered in English at the
call site and translated at render.
Follows the decisions this file already pins: вы in lowercase rather than
correspondence-style «Вы», the infinitive for actions, and the fixed
terminology, so Входящие, Папка, Письмо, Цепочка, Ярлык, Корзина, Черновики
and Настройки read the same here as everywhere above.
Fifteen strings are deliberately absent and keep falling back to English:
example.com and the other input placeholders, the product names, and the
verbatim header names List-Id and X-Spam-Status.
Verified: typecheck clean, 1000 tests pass, nothing missing from ru.ts.
Features landed after the catalogues were written and their strings were never
added, so they rendered in English. Reported against German (#247); every
language had the identical gap.
176 entries: 153 from features that shipped after the translation pass, and 23
keyboard bindings whose group and description are registered in English at the
call site and translated at render.
Follows the decisions this file already pins: です・ます throughout, no あなた,
bare noun or verb stem on buttons, and the deliberate script mixing -- kanji
for the noun carrying the meaning, katakana for the loanword the reader knows,
long vowels keeping their ー. So 受信トレイ, フォルダー, メール, スレッド,
ラベル, ゴミ箱, 下書き and 設定 read the same here as everywhere above.
Fifteen strings are deliberately absent and keep falling back to English:
example.com and the other input placeholders, the product names, and the
verbatim header names List-Id and X-Spam-Status.
Verified: typecheck clean, 1000 tests pass, nothing missing from ja.ts.
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.
Features landed after the catalogues were written and their strings were never
added, so they rendered in English. Reported against German (#247); every
language had the identical gap.
176 entries: 153 from features that shipped after the translation pass, and 23
keyboard bindings whose group and description are registered in English at the
call site and translated at render.
Follows the decisions this file already pins: u throughout, and the fixed
terminology, so Postvak IN, Map, Bericht, Gesprek, Label, Prullenbak,
Concepten and Instellingen read the same here as everywhere above.
Fifteen strings are deliberately absent and keep falling back to English:
example.com and the other input placeholders, the product names, and the
verbatim header names List-Id and X-Spam-Status.
Verified: typecheck clean, 1000 tests pass, nothing missing from nl.ts.
Features landed after the catalogues were written and their strings were never
added, so they rendered in English. Reported against German (#247); every
language had the identical gap.
176 entries: 153 from features that shipped after the translation pass, and 23
keyboard bindings whose group and description are registered in English at the
call site and translated at render.
Follows the decisions this file already pins: vous throughout, guillemets for
quoted names, a plain space before ? and : rather than a narrow no-break one,
and the fixed terminology, so Boîte de réception, Dossier, Message, Libellé,
Corbeille, Brouillons and Paramètres read the same here as everywhere above.
Fifteen strings are deliberately absent and keep falling back to English:
example.com and the other input placeholders, the product names, and the
verbatim header names List-Id and X-Spam-Status.
Verified: typecheck clean, 1000 tests pass, nothing missing from fr.ts.
Features landed after the catalogues were written and their strings were never
added, so they rendered in English. Reported against German (#247); every
language had the identical gap.
176 entries: 153 from features that shipped after the translation pass, and 23
keyboard bindings whose group and description are registered in English at the
call site and translated at render.
Follows the decisions this file already pins: usted throughout, peninsular
Spanish, and the fixed terminology, so Bandeja de entrada, Carpeta, Mensaje,
Conversación, Etiqueta, Papelera, Borradores and Configuración read the same
here as everywhere above.
Fifteen strings are deliberately absent and keep falling back to English:
example.com and the other input placeholders, the product names, and the
verbatim header names List-Id and X-Spam-Status.
Verified: typecheck clean, 1000 tests pass, nothing missing from es.ts.
Features landed after the catalogues were written and their strings were never
added, so they rendered in English. A native speaker reviewing this file
reported it (#247), which is the review the language was marked Beta waiting
for.
176 entries: 153 from features that shipped after the translation pass --
privacy and safety settings, the message ordering controls, subscribed and
birthday calendars, label nesting, the file preview, the unsaved-changes
prompts -- and 23 keyboard bindings, whose group and description are
registered in English at the call site and are translated at render.
Follows the decisions this file already pins: Sie throughout, and the fixed
terminology, so Ordner, Nachricht, Konversation, Posteingang, Papierkorb,
Entwürfe and Label read the same here as everywhere above.
Fifteen strings are deliberately absent and keep falling back to English,
which is the correct rendering for them: example.com and the other input
placeholders, the product names, and the verbatim header names List-Id and
X-Spam-Status. A translator should not be invited to translate example.com.
Verified: typecheck clean, 1000 tests pass, and a coverage pass over every
t() call site reports nothing missing from de.ts.
A native speaker reviewing the German catalogue reported strings appearing in
English (#247). Three of the reported areas turned out not to be missing
translations at all: the strings were there, and the code was rendering the
English source instead of asking for one.
The Sieve rule dialog rendered HEADER_CHOICES and HEADER_OPS labels directly.
All sixteen are already in every catalogue -- "Subject" has been "Betreff" in
de.ts all along, which is exactly the inconsistency the reporter noticed
against the Out-of-office page, where it already reads Betreff. Wrapping the
two dropdowns fixes nine languages at once and adds nothing to any catalogue.
The keyboard shortcut panel rendered each binding's group and description
directly. Those are registered in English at the call sites and should stay
that way -- the binding table is data and the English is the catalogue key --
so the panel translates them at render instead. A binding added anywhere is
then translatable without its registrar knowing i18n exists.
The palette grid marks every name translate="no". That is right for ihasmail,
Dracula, Gruvbox, Rosé Pine and Tokyo Night, which are names. "Classic" is an
adjective describing the theme, not a name, so PaletteMeta gains a
`translatable` flag for the one entry that is a word. Flagging the exception
beats dropping the attribute from all six.
No catalogue changes here: the strings the first two need are already present
in all nine. "Classic" needs an entry, which lands with each language.
APP_NAME is a runtime variable and two of the three places showing the name
ignored it. The sign-in page fetched /api/config, received the name and used
only sourceUrl -- so a rebranded deployment still said "ihasmail" on the one
page a new user meets first. The top bar had it written in. Only the document
title read it, and it had been reading it from the session all along.
The rebranding guide documents both as things to patch yourself, one of them
with "if you change nothing else on this page, change this". It should not
have to.
The sign-in page takes the name from the answer it was already getting. The
top bar takes it from the session, where the title has taken it from since it
was written. Neither is a new request.
One shared default rather than the string written out at three call sites,
because three copies of a default is how two of them end up stale. It stands
if the config request fails, since a sign-in form with no name on it would be
worse than one with the wrong name -- and an empty or non-string name falls
back too, so a deployment that sets APP_NAME= does not get a nameless page.
Confirmed with APP_NAME set to something else: sign-in heading, top bar and
tab title all read it.
#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.
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.
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.
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.
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.
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.
#227 emitted TZID with the IANA name and nothing defining it, on the reasoning
that every client resolves those names and that generating a definition would
mean shipping a zone database. Both halves were wrong.
Measured, not assumed. Run an export through ical.js -- Mozilla's own
iCalendar library, the one Thunderbird's calendar uses -- and a TZID with no
VTIMEZONE beside it does not resolve: it falls back to floating time. A 09:00
in Phoenix then reads as 09:00 wherever the file is opened, seven hours out,
silently, on every timed event in every export.
as exported | zone: floating | UTC: 09:00Z
with a VTIMEZONE added | zone: America/Phoenix | UTC: 16:00Z
The database was already here, too. The browser has IANA behind Intl, and an
offset for an instant is a formatting question: format the instant into the
zone, read the clock back, and the difference is the offset. Transitions are
found by walking month by month for the ones where the answer changes and
bisecting inside them -- no rules are known, so none can be got wrong.
Each transition is its own dated sub-component rather than an RRULE. More
lines and no cleverness: a derived rule that is subtly wrong moves somebody's
meeting, while a list of dates can only be incomplete at its ends, which is
what the window is for -- the year before the earliest event to ten years past
the latest, an open-ended weekly meeting being the case that needs it.
A zone Intl does not know is left undefined rather than described from
nothing; the TZID stays on the event, which is where it was. TZNAME is dropped
where Intl offers "GMT+9", which only repeats the offset beside it.
Confirmed the same way it was found. Berlin now resolves to +0200 in September
and +0100 in December, so the transitions are being applied and not just an
offset.
Refs #216.
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.
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.
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.
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.
#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.
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.
The guard shipped with "Discard changes" as the only choice carrying a
colour -- a filled red button, against a plain outlined "Save changes" --
which made losing the work the loudest thing in a dialog whose entire
purpose is to stop that. The emphasis belongs on the safe answer.
A dialog choice can now be marked `primary`, and Save is. Discard keeps its
`danger` flag, but a danger choice is drawn the way `.menu-item.danger`
already is: a red label on the ordinary surface. In a list of answers a
filled red button is not "this one is destructive", it is "this one is the
default", which is the opposite of what it meant here.
That rendering change reaches the other choice dialog too -- the calendar's
"this occurrence or the whole series", where both answers are marked danger
because both delete something. Two filled red buttons become two red labels
and nothing is highlighted, which is right: neither answer there is the safe
one, so neither should look like it.
Checked in the browser against the mock, in both themes. Light: #dc2626 on
white, 4.8:1. Dark: the theme's own --danger, which every palette already
tunes for contrast on this surface.
Reported on #175 by the reporter's colleague, who is right that the
non-destructive action is the one that normally gets the highlight.
The same bug the calendar import had, in the three places contacts write
more than one card at once. ContactCard/set is refused whole over
maxObjectsInSet -- requestTooLarge, nothing created -- so a large enough
vCard or LDIF file imported nothing, and "select all, delete" over a large
address book deleted nothing and reported it in JMAP's words.
Nobody has hit it. It was found by looking, after #215 fixed the calendar,
and it is promised on #173.
Both imports now go through one createCards, which splits by the ceiling the
session advertises and falls back to 500. That is what the LDIF import's
comment -- "from ContactCard/set down they are the same" -- was already
claiming, and is now true of.
destroyCards splits the same way, and takes off the list the ids the server
said it destroyed rather than everything that was asked for. It removed all
of them before, which was harmless while there was one call and wrong the
moment a later batch can fail: deleted contacts must not stay on screen, and
live ones must not disappear from it.
One behaviour change beyond the batching. A vCard import the server accepted
no card of returned 0, and the view reported importing no contacts -- which
reads as an empty file rather than as a refusal. It now says why, which is
what the LDIF import has always done. A file with genuinely nothing in it
still says so, earlier and separately.
An 800 KB export imported nothing at all. Every event in the file went out
in a single CalendarEvent/set, and Stalwart refuses a method call carrying
more objects than maxObjectsInSet -- the whole call, with requestTooLarge,
creating none of it -- so the import failed at exactly the size that makes
importing worth doing. A two-event invitation was fine; a real calendar was
not.
The events now go out maxObjectsInSet at a time, which the client already
reads off the session and defaults to 500 where a server does not say. That
is the same ceiling and the same helper the mail store batches deletes and
flag changes by; nothing new had to be learned about the limit, and there is
no need to ask anyone to split an .ics by hand at an arbitrary line.
Still batches rather than a call per event: createEvent invalidates on the
way out and invalidating re-fetches every cached range, which is why the
import writes its own set calls in the first place. One invalidate, after
the last batch.
A batch that fails after earlier ones have been filed now says how many got
in -- "1000 of 1200 events were imported before this happened" -- and
re-reads the calendar so they are visible. Reporting only that the import
failed would send someone looking for events that are already there.
The mock enforced this ceiling all along, on both /get and /set; nothing had
exercised it with a file big enough to cross it.
Reported on #173.
Dragging an event across the month grid wrote the date of the cell it
landed on into the event's stored start. Those are the same date only
while the event's time zone is the reader's.
An event kept in Asia/Tokyo at 15:00 is drawn to a reader in Phoenix at
23:00 the previous evening. Dropped on the 11th, it was written as the
11th in Tokyo -- which is the 10th on screen. It went where its own
calendar said rather than where the pointer did, one day short, every
time.
Moving by the difference between the two local days instead moves it
exactly as far as the hand did, and adding whole days to a stored wall
clock leaves the time of day alone without touching the zone -- so the
frame the rest of this path is careful about is still not crossed.
Found by giving the mock an event in a zone that is not the machine's.
Every other fixture used the machine's own, which cannot tell a correct
conversion from no conversion at all: the case that works is the one the
fixtures were all testing.
A JMAP push subscription stays silent until the client echoes back a
verification code. When the code arrives with no tab open, the service
worker leaves it in the cache for the next tab to collect.
Both sides named that entry relatively, and a relative key is resolved
against the URL of whoever is asking. The worker lives at <base>/sw.js, so
it wrote under <base>/; a tab at /mail/inbox/abc looked under
/mail/inbox/. They agreed only when the open page happened to be the root,
which is why this survived: the case that works is the one people try
first.
The failure is quiet in the worst way. A subscription that never gets its
code back simply never delivers, which is indistinguishable from push not
working at all -- there is no error anywhere to notice.
Both sides now build the key from the mount: the worker from the BASE it
already derives from its own location, the page through withBase. Found
while adding BASE_PATH, where the two disagree at every route rather than
only at deep ones; left alone then because it was pre-existing and
unrelated to that change.
FEATURES has always said attach-from-Files works "however large", because a
blob the account already holds is attached by reference and nothing is
sent. The code checked every file against maxSizeUpload regardless, so the
two disagreed and the code was the one that was wrong.
maxSizeUpload is what the server will accept for a single upload (RFC
8620). It bears on a file that is about to be uploaded and on nothing
else. Applying it to a by-reference attachment refused a 60 MB message the
server was already storing, on the grounds that it could not have been
uploaded -- which it was not being. Forwarding a large message as an
attachment hit exactly that.
A file from somebody else's account is fetched and re-uploaded into this
one, because a message can only carry blobs from the account sending it.
That upload is real and the limit is real for it, so it still applies
there.
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.
The calendar could only be edited through the editor, so moving a meeting
half an hour meant opening a dialog, changing two fields and saving. Every
other surface a finger or a pointer drives already answers to a drag.
In the day and week grids an event moves by dragging it and changes length
by dragging its bottom edge, snapping to fifteen minutes. In the month
grid it moves to another day and keeps the time it had, because a month
cell is a day and nothing finer -- changing the hour as well would answer
a question nobody asked.
It goes through the same path a menu edit takes. A recurring event is
asked which dates it means, and the answer runs through runScoped, so a
date the server will only change as part of a whole series offers that
rather than failing.
Three things do not offer a drag, and the reasons are checked in one place
so no grid has to remember all three: a read-only calendar, an event with
no calendar, and a birthday -- which is derived from a contact and has
nothing on the server to move. The reserved classes the swipe gesture was
told to keep out of are exactly the ones that are draggable here, which is
what that reservation was for.
Invitations are not sent. A drag is a scheduling gesture, and mailing
every guest on each nudge of a block is not what the hand was asking for;
a change that should go out with notice goes through the editor.
The new time is computed in the event's own frame rather than through an
instant. Working it out from the reader's local hours and then
re-expressing it in the event's zone converts twice, and the two do not
cancel: caught in the browser, where an event moved two hours the first
time it was dragged in the month grid and then sat still, because after
that its stored time and the reader's agreed. Parsing the stored string
into its parts and adding minutes to those touches no zone at all, and a
resize sends only a duration, so the question does not arise there either.