Compare commits

...
Author SHA1 Message Date
Coffey Labs 17d98748c4 Merge pull request #272 from Coffey-Labs/i18n-missing-plurals
Add seven plural forms no catalogue ever had
2026-09-03 14:47:00 -07:00
jcoffey-dev 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.
2026-09-03 14:44:23 -07:00
Coffey Labs 71827a2d04 Merge pull request #270 from Coffey-Labs/i18n-ukrainian-rule-sentences
Translate the rule sentences into Ukrainian
2026-09-03 14:41:45 -07:00
Coffey Labs 5dd0a56732 Merge pull request #269 from Coffey-Labs/i18n-russian-rule-sentences
Translate the rule sentences into Russian
2026-09-03 14:41:39 -07:00
Coffey Labs b55c8b13bc Merge pull request #268 from Coffey-Labs/i18n-chinese-rule-sentences
Translate the rule sentences into Simplified Chinese
2026-09-03 14:41:35 -07:00
Coffey Labs 0cf9b81444 Merge pull request #267 from Coffey-Labs/i18n-japanese-rule-sentences
Translate the rule sentences into Japanese
2026-09-03 14:41:31 -07:00
Coffey Labs 8386444ac7 Merge pull request #266 from Coffey-Labs/i18n-portuguese-rule-sentences
Translate the rule sentences into Brazilian Portuguese
2026-09-03 14:41:26 -07:00
Coffey Labs e1ae97139c Merge pull request #265 from Coffey-Labs/i18n-dutch-rule-sentences
Translate the rule sentences into Dutch
2026-09-03 14:41:21 -07:00
Coffey Labs 503eaf17ec Merge pull request #264 from Coffey-Labs/i18n-french-rule-sentences
Translate the rule sentences into French
2026-09-03 14:41:16 -07:00
Coffey Labs 1e02d9ebba Merge pull request #263 from Coffey-Labs/i18n-spanish-rule-sentences
Translate the rule sentences into Spanish
2026-09-03 14:41:11 -07:00
Coffey Labs d40dbf04b8 Merge pull request #262 from Coffey-Labs/i18n-german-rule-sentences
Translate the rule sentences into German
2026-09-03 14:41:06 -07:00
Coffey Labs 2a9e18f04c Merge pull request #271 from Coffey-Labs/fix/shortcuts-after-checkbox
Keep shortcuts working after a checkbox is clicked
2026-09-03 14:41:00 -07:00
Coffey Labs 4d89f5e672 Merge pull request #261 from Coffey-Labs/i18n-describe-rules
Build the two rule descriptions as sentences, not fragments
2026-09-03 14:40:55 -07:00
jcoffey-dev 95e5c69e8f Keep shortcuts working after a checkbox is clicked
Ticking "select all" disabled every keyboard shortcut until the reader clicked
somewhere else (#260). Same for the per-message checkboxes, so selecting a few
messages and pressing e to archive them did nothing.

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

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

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

The regression test was checked against the old guard first: it fails there
and passes here, which is the only thing that makes it a regression test.
2026-09-03 14:38:15 -07:00
jcoffey-dev 50d08a18e4 Translate the rule sentences into Ukrainian
#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 Ukrainian 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.
2026-09-03 14:24:12 -07:00
jcoffey-dev 9a634311b2 Translate the rule sentences into Russian
#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 Russian 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.
2026-09-03 14:24:08 -07:00
jcoffey-dev b811c84b12 Translate the rule sentences into Simplified Chinese
#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 Simplified Chinese 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.
2026-09-03 14:23:32 -07:00
jcoffey-dev b9b01ce02c Translate the rule sentences into Japanese
#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 Japanese 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.
2026-09-03 14:23:28 -07:00
jcoffey-dev 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.
2026-09-03 14:23:01 -07:00
jcoffey-dev 0a03c64ff3 Translate the rule sentences into Dutch
#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 Dutch 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.
2026-09-03 14:22:58 -07:00
jcoffey-dev 4c430ea995 Translate the rule sentences into French
#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 French 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.
2026-09-03 14:22:28 -07:00
jcoffey-dev 112b3ea52f Translate the rule sentences into Spanish
#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 Spanish 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.
2026-09-03 14:22:24 -07:00
jcoffey-dev 31edf33839 Translate the rule sentences into German
#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 German 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.
2026-09-03 14:21:53 -07:00
jcoffey-dev 1a842d8d14 Build the two rule descriptions as sentences, not fragments
Both describeRule functions assembled their output by concatenation, which no
catalogue could fix. A translator handed " and " or " on " in isolation cannot
move it: German puts the verb last, Japanese does not separate list items with
a word at all, and the fragments arrive in an order the English sentence chose.
Reported by a native speaker reviewing the German catalogue (#247), whose "the
summaries" item is the Sieve one.

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

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

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

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

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

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

Verified: typecheck clean, 1009 tests pass.
2026-09-03 14:20:50 -07:00
Coffey Labs 22f39a4507 Merge pull request #259 from Coffey-Labs/i18n-third-pass
Third pass: the strings libraries build and views render raw
2026-09-03 14:01:53 -07:00
Coffey Labs bf60fe6157 Merge pull request #258 from Coffey-Labs/i18n-second-pass
Second pass: four dialogs that were never wrapped
2026-09-03 14:01:49 -07:00
Coffey Labs 8d4063e921 Merge pull request #257 from Coffey-Labs/i18n-chinese-catch-up
Catch the Simplified Chinese catalogue up with what shipped after it
2026-09-03 14:01:44 -07:00
Coffey Labs ea6c098057 Merge pull request #256 from Coffey-Labs/i18n-ukrainian-catch-up
Catch the Ukrainian catalogue up with what shipped after it
2026-09-03 14:01:38 -07:00
Coffey Labs f7cbdb2e7a Merge pull request #255 from Coffey-Labs/i18n-russian-catch-up
Catch the Russian catalogue up with what shipped after it
2026-09-03 14:01:33 -07:00
Coffey Labs 379623614a Merge pull request #254 from Coffey-Labs/i18n-japanese-catch-up
Catch the Japanese catalogue up with what shipped after it
2026-09-03 14:01:27 -07:00
Coffey Labs 662385c6da Merge pull request #253 from Coffey-Labs/i18n-portuguese-catch-up
Catch the Brazilian Portuguese catalogue up with what shipped after it
2026-09-03 14:01:21 -07:00
Coffey Labs 0fb4d1e964 Merge pull request #252 from Coffey-Labs/i18n-dutch-catch-up
Catch the Dutch catalogue up with what shipped after it
2026-09-03 14:01:16 -07:00
Coffey Labs 898815eaca Merge pull request #251 from Coffey-Labs/i18n-french-catch-up
Catch the French catalogue up with what shipped after it
2026-09-03 14:01:09 -07:00
Coffey Labs 6ac5ee45dd Merge pull request #250 from Coffey-Labs/i18n-spanish-catch-up
Catch the Spanish catalogue up with what shipped after it
2026-09-03 14:01:04 -07:00
Coffey Labs f500261697 Merge pull request #249 from Coffey-Labs/i18n-german-catch-up
Catch the German catalogue up with what shipped after it
2026-09-03 14:00:59 -07:00
Coffey Labs 7a67a2c1c3 Merge pull request #248 from Coffey-Labs/i18n-render-untranslated-lists
Translate three lists the code was rendering raw
2026-09-03 14:00:54 -07:00
jcoffey-dev 9245fc5b1e Add the third-pass strings to Ukrainian
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.
2026-09-03 13:56:25 -07:00
jcoffey-dev 9ed64bea88 Add the third-pass strings to Russian
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.
2026-09-03 13:56:05 -07:00
jcoffey-dev 21d0320765 Add the third-pass strings to Simplified Chinese
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.
2026-09-03 13:55:45 -07:00
jcoffey-dev 2a5c6a5c18 Add the third-pass strings to Japanese
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.
2026-09-03 13:55:43 -07:00
jcoffey-dev 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.
2026-09-03 13:55:19 -07:00
jcoffey-dev d844786e14 Add the third-pass strings to Dutch
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.
2026-09-03 13:55:17 -07:00
jcoffey-dev 1b75580da2 Add the third-pass strings to French
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.
2026-09-03 13:54:53 -07:00
jcoffey-dev c0faed2c2a Add the third-pass strings to Spanish
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.
2026-09-03 13:54:41 -07:00
jcoffey-dev bb5a26c343 Add the third-pass strings to German
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.
2026-09-03 13:53:49 -07:00
jcoffey-dev be088ed78d Third pass: the strings libraries build and views render raw
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.
2026-09-03 13:52:24 -07:00
jcoffey-dev 20b6475f18 Second pass: four dialogs that were never wrapped
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.
2026-09-03 11:58:17 -07:00
jcoffey-dev 31feb4114a Catch the Simplified Chinese 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: 您 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.
2026-09-03 11:54:32 -07:00
jcoffey-dev 2280df1ea9 Catch the Ukrainian 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: ви 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.
2026-09-03 11:52:41 -07:00
jcoffey-dev 083039b27e Catch the Russian 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: вы 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.
2026-09-03 11:50:29 -07:00
jcoffey-dev d525b18f6d Catch the Japanese 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: です・ます 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.
2026-09-03 11:48:32 -07:00
jcoffey-dev 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.
2026-09-03 11:46:28 -07:00
jcoffey-dev 3b77fb85fd Catch the Dutch 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: 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.
2026-09-03 11:44:27 -07:00
jcoffey-dev b05cd178e6 Catch the French 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: 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.
2026-09-03 11:42:30 -07:00
jcoffey-dev f2437b6904 Catch the Spanish 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: 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.
2026-09-03 11:40:36 -07:00
jcoffey-dev ba16067529 Catch the German 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. 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.
2026-09-03 11:37:56 -07:00
jcoffey-dev b10149fd54 Translate three lists the code was rendering raw
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.
2026-09-03 11:37:05 -07:00
Coffey Labs 3ffee1224f Merge pull request #246 from Coffey-Labs/ci/prune-old-images
Prune old images, and keep every release
2026-09-02 23:50:13 -07:00
jcoffey-dev 4b2c97df4e Prune old images, and keep every release
Two artefacts, opposite answers.

Releases stay. They carry no assets -- the image lives in GHCR -- so one costs
a tag, a title and generated notes, and with no CHANGELOG in this repository
those notes are the only changelog there is. Deleting one destroys history
that cannot be reconstructed, and saves nothing.

Images accumulate: a multi-architecture build a week, and the by-digest push
leaves two untagged per-architecture manifests behind each time on top of the
tagged index. Ten tagged versions are kept, which is roughly a quarter of
releases and far more than anything anyone rolls back to.

The obvious tool for this is a trap. delete-package-versions with
`delete-only-untagged-versions` will delete the per-architecture manifests
that a multi-arch tag points at, because they are untagged by design, and
nothing appears to break: the tag still resolves and pulls simply start
failing for one architecture. This action understands manifest lists and
leaves a retained index's children alone, `validate` re-checks every
multi-arch manifest against the registry afterwards, and `latest` is excluded
from consideration entirely.

It is pinned to a commit rather than a major tag. It holds `packages: write`
and its whole purpose is deletion, so a tag repointed upstream is not a risk
worth carrying for the convenience.

Kept in its own file and dispatchable, so a dry run can show exactly what
would go without rebuilding and re-pushing an image to find out.
2026-09-02 23:47:22 -07:00
Coffey Labs 60a4647a43 Merge pull request #245 from Coffey-Labs/ci/scheduled-releases
Cut a release once a week, and only when there is something in it
2026-09-02 23:45:41 -07:00
jcoffey-dev 5cb0b2f3ea Cut a release once a week, and only when there is something in it
Publishing on release is the right trigger only if releases happen. They had
not: main ran 184 commits ahead of the last one, so `:latest` described a
build that neither the demo, nor production, nor anyone building from source
was running. This is the part that makes the trigger true without anyone
having to remember.

Mondays at 09:00 UTC. A run with no commits since the last release does
nothing at all -- an empty release moves `:latest` to an identical build,
spends a version number and mails every watcher about nothing. The decision is
written to the run summary either way, so a quiet week reads as a decision
rather than as a workflow that failed silently.

The awkward part is that a release created with GITHUB_TOKEN raises no
`release` event: GitHub refuses to let a token trigger another workflow, to
stop a workflow looping on its own output. A scheduled job that cut a release
and left publish.yml to notice would tag the commit and never build an image,
which is the kind of failure that looks like success. So publish.yml gains a
`workflow_call` trigger and this calls it directly. The alternative was a
personal access token kept as a secret; this needs no credential.

Two smaller decisions. Drafts are excluded when looking for the last release,
because an unpublished draft is not a release anybody has and counting from it
would hide commits that never shipped. And if the tag a release names has
gone, the count falls back to the whole history -- over-counting cuts a
release that was due anyway, where under-counting skips one that was not.
2026-09-02 23:43:12 -07:00
Coffey Labs e1c85cf82e Merge pull request #244 from Coffey-Labs/ci/publish-container-image
Publish the image the docs have been telling people to pull
2026-09-02 23:30:07 -07:00
jcoffey-dev 1734ed0439 Publish the image the docs have been telling people to pull
README has said `docker run ... ghcr.io/coffey-labs/ihasmail:latest` since the
Docker instructions were written, and the docs site repeats it in four places.
Nothing ever pushed that image. `docker pull` answers `denied`, because the
package does not exist: .github/workflows held ci.yml and nothing else, and
there is no reference to ghcr.io, docker/build-push or docker push anywhere in
this repo. The instructions have been wrong the whole time.

Adds the workflow that makes them true. It fires on a published release, and
by hand for a ref -- the same dispatch trigger ci.yml carries, and the only
way to build an image for the tags that predate this file.

Two architectures on native runners rather than one build under QEMU.
Emulated arm64 runs `npm ci` and the Vite build through instruction
translation, which takes tens of minutes and sometimes exhausts memory;
ubuntu-24.04-arm is free for public repositories and does it at native speed.
The cost is pushing by digest and joining the two into one manifest at the
end, which is what the third job does.

`latest` moves only for a real release. A prerelease that moved it would hand
every `:latest` deployment an unfinished build, and a dispatch run has to ask
for it deliberately.

Also documents the images in README: which tags exist, that the dated tag is
the one to pin, and that building it yourself is still fully supported --
`docker compose up --build` is unchanged and the image is a convenience, not a
new requirement.

Worth knowing before the first run: GHCR creates a new package **private**,
even for a public repository, so an anonymous pull will still be refused until
the visibility is changed by hand. That is written at the top of the workflow,
because it is the failure that looks like success.
2026-09-02 23:22:21 -07:00
Coffey Labs 93856de143 Merge pull request #243 from Coffey-Labs/fix/login-app-name
Call the instance what it calls itself, on the page that matters most
2026-09-02 15:27:20 -07:00
jcoffey-dev cfcaf5f573 Call the instance what it calls itself, on the page that matters most
APP_NAME is a runtime variable and two of the three places showing the name
ignored it. The sign-in page fetched /api/config, received the name and used
only sourceUrl -- so a rebranded deployment still said "ihasmail" on the one
page a new user meets first. The top bar had it written in. Only the document
title read it, and it had been reading it from the session all along.

The rebranding guide documents both as things to patch yourself, one of them
with "if you change nothing else on this page, change this". It should not
have to.

The sign-in page takes the name from the answer it was already getting. The
top bar takes it from the session, where the title has taken it from since it
was written. Neither is a new request.

One shared default rather than the string written out at three call sites,
because three copies of a default is how two of them end up stale. It stands
if the config request fails, since a sign-in form with no name on it would be
worse than one with the wrong name -- and an empty or non-string name falls
back too, so a deployment that sets APP_NAME= does not get a nameless page.

Confirmed with APP_NAME set to something else: sign-in heading, top bar and
tab title all read it.
2026-09-02 15:24:36 -07:00
Coffey Labs 3582ad116e Merge pull request #242 from Coffey-Labs/feat/contacts-update-on-reimport
Update a contact on re-import rather than skipping it
2026-09-02 15:17:27 -07:00
jcoffey-dev 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.
2026-09-02 15:06:04 -07:00
Coffey Labs af0e8b2be2 Merge pull request #241 from Coffey-Labs/feat/domain-server-mapping
Choose the Stalwart by the domain somebody signs in with
2026-09-02 14:33:14 -07:00
jcoffey-dev 171c11fc92 Choose the Stalwart by the domain somebody signs in with
One ihasmail in front of several Stalwarts, from #238. STALWART_URL stays
required and stays the default, so an installation that sets nothing behaves
exactly as it always has -- the mapping only adds domains that go elsewhere.

An unlisted domain goes to the default. So does a bare username, which
Stalwart accepts and which has no domain to map at all.

A listed domain never falls back. If its server is unreachable that sign-in
fails rather than retrying against the default, because falling back would
authenticate somebody against a server their domain was deliberately routed
away from -- and if the same account name existed there, they would land in
another tenant's mailbox. The fallback is a decision about unmapped domains,
taken before any network call, not a recovery path.

Smaller than it sounds because only four places read config.stalwartUrl, all
in upstream.ts. The upstream session now records which server issued it, since
the relative URLs inside it only mean anything against that server, and every
route already holding a session gets the right upstream without a second
lookup. The client is untouched: it talks to one proxy and never learns there
is more than one server behind it, which is exactly why this is small and
several-servers-at-once is not.

The upstream is derived from the username rather than stored on the session,
so a mapping change takes effect on restart instead of being frozen into
sessions that outlive it.

Validated at boot the way the settings policy is: malformed JSON, a duplicate
domain once normalised, a missing file or a value that is not an http(s) URL
all stop the server. Domains are lower-cased and stripped of a trailing dot,
because that is how one arrives off a username and comparing them any other
way means a mapping that silently never matches. The servers themselves are
not contacted -- a mapping is a routing table, not a health check, and one
customer's outage must not stop ihasmail starting for the other four.

Eight tests on the routing, two on the shipped example, and the four refusals
checked by hand against a real config load.
2026-09-02 14:18:50 -07:00
Coffey Labs 2a41a31bb9 Merge pull request #240 from Coffey-Labs/fix/upstream-outage-lockout
Do not spend login attempts on an outage nobody caused
2026-09-02 14:14:24 -07:00
jcoffey-dev 607afeb4ad Do not spend login attempts on an outage nobody caused
ihasmail runs in its own container, usually on its own host, so Stalwart being
briefly unreachable is an ordinary Tuesday. Sign-in handled it almost right:
a 401 is invalid_credentials, a timeout is 504 and anything else is 502, none
of which reads as a rejected password.

What it got wrong was the counting. RateLimiter.check() consumes an attempt
when it is called, and it is called before the upstream is contacted; reset()
only runs on success. So every try against an unreachable server burned a
credential attempt, and after ten of them the person was locked out for the
rest of the fifteen-minute window -- including after the server came back. A
thirty-second blip became a quarter-hour lockout, and the second failure was
entirely ihasmail's own doing.

A 401 is a judgement about the password and stays counted. A 502 or 504 is the
upstream failing to answer, says nothing about the credentials, and is now
refunded -- one attempt back, not the key cleared, so a run of real failures
with an outage in the middle still adds up. The old-server refusal refunds too:
those credentials were accepted.

Both guessing keys are refunded, not just the username one. Refunding only
that would not have fixed it -- ten retries still spend the per-address budget,
and behind one office NAT that budget belongs to the whole building, so a
company-wide outage would lock out the company.

Which needs a backstop, because "not counted" must not mean "unlimited": each
attempt still costs an outbound connection that may sit there until
UPSTREAM_TIMEOUT, and an outage is the one moment the endpoint is cheapest to
abuse. So there is a second ceiling per address, twenty times looser and never
refunded. A person retrying will not come near it; something hammering will.

Both messages now say the quiet part -- "This is not a problem with your
password" -- for somebody already worried they have forgotten it.

Closes #239.
2026-09-02 14:08:23 -07:00
Coffey Labs f6f6ce0b5e Merge pull request #237 from Coffey-Labs/feat/headers-show-original
Make "Show original" in the headers dialog the action, not a description
2026-09-02 13:23:01 -07:00
jcoffey-dev 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.
2026-09-02 13:17:10 -07:00
Coffey Labs 45f1309d06 Merge pull request #235 from Coffey-Labs/feat/go-to-folder
Go to a folder by name, with g then o
2026-09-02 13:07:52 -07:00
jcoffey-dev 483aac849a Go to a folder by name, with g then o
Requested in #233. The `g` shortcuts cover the handful of folders every
account has -- inbox, sent, drafts -- and nothing reaches the dozens a Sieve
rule fills, which is where somebody with a real folder tree spends their time.
`g o` opens the picker, you type part of a name, and you are there.

The picker is the one the move action already uses, with one difference that
only shows up on shared mail: it selected folders by `mayAddItems`, which is
right for a destination and wrong for a place to go. A shared folder you may
read but not file into is somewhere you can visit. The right is now a
parameter, named for what it is asking rather than for which caller wants it.

Hosted in AppShell rather than in the mail view, because the `g` shortcuts are
global and the mail view is not mounted to hear about it -- pressing this from
the calendar should still take you to a folder, and now does.

`o` on its own opens a conversation and does not clash: a pending prefix is
tried before a bare key. That was already true and nothing said so, so there
are now five tests for the sequence machinery -- including that an abandoned
prefix costs the prefix and not the keystroke after it, which is the nicer
behaviour of the two and was undocumented.

Checked in a browser against the mock: opened from the calendar, filtered to a
nested folder, landed on it, and `o` still opened a conversation afterwards.

Closes #233.
2026-09-02 13:03:55 -07:00
Coffey Labs a8cf8ce3d7 Merge pull request #234 from Coffey-Labs/feat/ldif-duplicate-report
Say how much an LDIF re-import duplicated, without acting on it
2026-09-02 12:51:10 -07:00
jcoffey-dev 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.
2026-09-02 12:45:30 -07:00
Coffey Labs 0d5975d427 Merge pull request #232 from Coffey-Labs/docs/policy-example
Ship an example settings policy, and name the variables in .env.example
2026-09-02 11:23:15 -07:00
jcoffey-dev a24b4c5538 Ship an example settings policy, and name the variables in .env.example
#231 added the policy but nothing to copy. The repo already answers this the
same way four times over -- Caddyfile.example, deploy.example.sh,
nginx.example.conf, .env.example -- and the new feature was the one thing
configurable here with no example beside it.

settings-policy.example.json carries all three sections with the reasoning in
it, including the part worth being deliberate about: a `changes` entry
overrides a decision a reader has already made, and if you want it to stay put
regardless that is `enforced` instead. JSON has no comments, so the commentary
is in `_`-prefixed keys, which is safe because the server reads three names and
ignores everything else.

A test asserts the shipped example stays valid against the rules the parser
enforces -- unique versions, settings objects, no comment key colliding with a
real section. An example that has drifted is worse than none: somebody copies
it, the server refuses to start, and the first experience of the feature is a
crash loop.

.env.example gains the four variables, commented out, with the file form and
the inline form and the note that the file wins over the variables.

Confirmed against the real image on the deploy host rather than reasoned
about: an immutable container -- --read-only, IMMUTABLE=1, SESSION_FILE=
empty -- starts and serves the policy both with a read-only file mount and
with the environment variables alone. The feature costs nothing in
immutability, because the only thing it writes is the applied-changes stamp,
and that goes in the reader's own settings file on Stalwart like every other
setting.
2026-09-02 11:20:34 -07:00
Coffey Labs cff7f6c11b Merge pull request #231 from Coffey-Labs/feat/policy-change-once
Apply installation policy changes once each, per account
2026-09-02 11:03:50 -07:00
jcoffey-dev 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.
2026-09-02 11:00:48 -07:00
Coffey Labs 6821d6a93f Merge pull request #230 from Coffey-Labs/feat/installation-defaults
Let an installation seed and lock user settings
2026-09-02 10:52:58 -07:00
jcoffey-dev 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.
2026-09-02 10:49:55 -07:00
Coffey Labs a01e1874d8 Merge pull request #229 from Coffey-Labs/fix/export-vtimezone
Define the zones an export names, instead of only naming them
2026-09-02 10:29:45 -07:00
jcoffey-dev 0e58b886b5 Define the zones an export names, instead of only naming them
#227 emitted TZID with the IANA name and nothing defining it, on the reasoning
that every client resolves those names and that generating a definition would
mean shipping a zone database. Both halves were wrong.

Measured, not assumed. Run an export through ical.js -- Mozilla's own
iCalendar library, the one Thunderbird's calendar uses -- and a TZID with no
VTIMEZONE beside it does not resolve: it falls back to floating time. A 09:00
in Phoenix then reads as 09:00 wherever the file is opened, seven hours out,
silently, on every timed event in every export.

    as exported            | zone: floating        | UTC: 09:00Z
    with a VTIMEZONE added | zone: America/Phoenix | UTC: 16:00Z

The database was already here, too. The browser has IANA behind Intl, and an
offset for an instant is a formatting question: format the instant into the
zone, read the clock back, and the difference is the offset. Transitions are
found by walking month by month for the ones where the answer changes and
bisecting inside them -- no rules are known, so none can be got wrong.

Each transition is its own dated sub-component rather than an RRULE. More
lines and no cleverness: a derived rule that is subtly wrong moves somebody's
meeting, while a list of dates can only be incomplete at its ends, which is
what the window is for -- the year before the earliest event to ten years past
the latest, an open-ended weekly meeting being the case that needs it.

A zone Intl does not know is left undefined rather than described from
nothing; the TZID stays on the event, which is where it was. TZNAME is dropped
where Intl offers "GMT+9", which only repeats the offset beside it.

Confirmed the same way it was found. Berlin now resolves to +0200 in September
and +0100 in December, so the transitions are being applied and not just an
offset.

Refs #216.
2026-09-02 10:26:43 -07:00
Coffey Labs 5f35428ddb Merge pull request #228 from Coffey-Labs/feat/vcard-uid-dedupe
Skip vCards on re-import that the address book already has
2026-09-02 09:36:35 -07:00
jcoffey-dev 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.
2026-09-02 09:33:53 -07:00
Coffey Labs 85289b0c34 Merge pull request #227 from Coffey-Labs/feat/calendar-ics-export
Export a calendar as an iCAL file
2026-09-02 09:24:00 -07:00
jcoffey-dev 1a6158aa70 Export a calendar as an iCAL file
The mirror of the import from #173, and the last thing contacts had that
calendars did not -- an address book could always be exported, a calendar
never could.

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

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

Three decisions worth stating rather than leaving to be found:

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

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

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

Closes #216.
2026-09-02 09:21:16 -07:00
Coffey Labs cb69115be1 Merge pull request #226 from Coffey-Labs/feat/contacts-menu-parity
Give the address books the menus the calendars have
2026-09-02 09:08:40 -07:00
jcoffey-dev 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.
2026-09-02 09:06:00 -07:00
Coffey Labs a61fe28523 Merge pull request #225 from Coffey-Labs/feat/skip-duplicate-uids-on-import
Skip events on re-import that the calendar already has
2026-09-02 08:57:36 -07:00
jcoffey-dev 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.
2026-09-02 08:52:33 -07:00
Coffey Labs f1a8f162cf Merge pull request #221 from Coffey-Labs/feat/set-folder-role
Set the Archive role from ihasmail, rather than describing it
2026-09-02 08:41:09 -07:00
jcoffey-dev 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.
2026-09-02 08:38:08 -07:00
Coffey Labs 8e9259638d Merge pull request #220 from Coffey-Labs/fix/archive-role-message
Say the Archive folder needs the role, not the name
2026-09-02 08:10:10 -07:00
jcoffey-dev 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.
2026-09-02 08:00:46 -07:00
Coffey Labs 3f33b61369 Merge pull request #219 from Coffey-Labs/fix/unsaved-dialog-emphasis
Highlight saving, not discarding, on the unsaved-changes guard
2026-09-02 07:50:05 -07:00
Coffey Labs 6e1ac42d5a Merge pull request #218 from Coffey-Labs/fix/contact-import-batching
Set contact cards in batches the server will take
2026-09-02 07:50:00 -07:00
jcoffey-dev 4a99b77bc3 Highlight saving, not discarding, on the unsaved-changes guard
The guard shipped with "Discard changes" as the only choice carrying a
colour -- a filled red button, against a plain outlined "Save changes" --
which made losing the work the loudest thing in a dialog whose entire
purpose is to stop that. The emphasis belongs on the safe answer.

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

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

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

Reported on #175 by the reporter's colleague, who is right that the
non-destructive action is the one that normally gets the highlight.
2026-09-02 07:05:27 -07:00
jcoffey-dev 4121f9263b Set contact cards in batches the server will take
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.
2026-09-02 06:54:24 -07:00
Coffey Labs 6b8ac393c9 Merge pull request #215 from Coffey-Labs/fix/ical-import-batching
Import an iCal file in batches the server will take
2026-09-02 06:43:20 -07:00
jcoffey-dev 17bd548524 Import an iCal file in batches the server will take
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.
2026-09-02 06:29:49 -07:00
Coffey Labs b0edce1d9b Merge pull request #214 from Coffey-Labs/fix/drag-day-across-timezones
Move an event by the days the hand moved it, not to the date dropped on
2026-09-02 01:11:53 -07:00
Coffey Labs 4570df6eab Merge pull request #213 from Coffey-Labs/fix/push-verification-cache-key
Name the push verification entry absolutely, from both sides
2026-09-02 01:11:32 -07:00
Coffey Labs 8962065782 Merge pull request #212 from Coffey-Labs/fix/attach-size-limits
Apply the upload limit only where something is uploaded
2026-09-02 01:11:11 -07:00
jcoffey-dev 7b3069e41b Move an event by the days the hand moved it, not to the date dropped on
Dragging an event across the month grid wrote the date of the cell it
landed on into the event's stored start. Those are the same date only
while the event's time zone is the reader's.

An event kept in Asia/Tokyo at 15:00 is drawn to a reader in Phoenix at
23:00 the previous evening. Dropped on the 11th, it was written as the
11th in Tokyo -- which is the 10th on screen. It went where its own
calendar said rather than where the pointer did, one day short, every
time.

Moving by the difference between the two local days instead moves it
exactly as far as the hand did, and adding whole days to a stored wall
clock leaves the time of day alone without touching the zone -- so the
frame the rest of this path is careful about is still not crossed.

Found by giving the mock an event in a zone that is not the machine's.
Every other fixture used the machine's own, which cannot tell a correct
conversion from no conversion at all: the case that works is the one the
fixtures were all testing.
2026-09-02 01:07:07 -07:00
jcoffey-dev 44b676c55d Name the push verification entry absolutely, from both sides
A JMAP push subscription stays silent until the client echoes back a
verification code. When the code arrives with no tab open, the service
worker leaves it in the cache for the next tab to collect.

Both sides named that entry relatively, and a relative key is resolved
against the URL of whoever is asking. The worker lives at <base>/sw.js, so
it wrote under <base>/; a tab at /mail/inbox/abc looked under
/mail/inbox/. They agreed only when the open page happened to be the root,
which is why this survived: the case that works is the one people try
first.

The failure is quiet in the worst way. A subscription that never gets its
code back simply never delivers, which is indistinguishable from push not
working at all -- there is no error anywhere to notice.

Both sides now build the key from the mount: the worker from the BASE it
already derives from its own location, the page through withBase. Found
while adding BASE_PATH, where the two disagree at every route rather than
only at deep ones; left alone then because it was pre-existing and
unrelated to that change.
2026-09-02 01:01:11 -07:00
jcoffey-dev 1b4788c0a7 Apply the upload limit only where something is uploaded
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.
2026-09-02 00:58:37 -07:00
Coffey Labs 13a6bcd66b Merge pull request #211 from Coffey-Labs/feat/ical-subscriptions
Subscribe to a calendar published at a URL
2026-09-02 00:54:55 -07:00
jcoffey-dev 4d18d94b63 Subscribe to a calendar published at a URL
A timetable, a rota, a public holiday list: the calendars people are given
as a link, which ihasmail could not show at all.

Nothing is stored. The document is fetched when the calendar is opened and
parsed in the browser; the server keeps no copy, no cache and no schedule,
which is what lets an immutable container serve this. There is no timer
either -- there is nowhere to run one -- so the guarantee is that a
subscription is as current as the last time somebody looked, which is also
when it matters. That is said plainly rather than implied.

The fetch has to happen on the server: a calendar URL belongs to whoever
published it and almost none of them send CORS headers. That makes it the
second place this app knocks on a door somebody else chose, so the guard
the image proxy has always had was lifted out and both now call it. A
second SSRF implementation is how one of them ends up missing a case; this
way there is one, and the extraction is covered by the image proxy's own
tests still passing unchanged.

webcal: is understood, because that is how these are published, and it is
read as https: rather than waved past the checks -- a webcal URL pointing
at loopback is refused exactly like an http one.

Recurrence is deliberately not expanded. RRULE is a small language with a
lot of edge cases, and a subscription quietly showing the wrong dates would
be worse than one showing the first occurrence and saying so.

The parser is a subscription parser rather than an importer: a subscribed
calendar is read-only and redrawn from scratch each refresh, so nothing has
to round-trip or survive an edit, which is most of what makes a full
iCalendar implementation large. What it does have to do is never mis-state
a time -- a DATE is built in local time rather than at UTC midnight, which
would land on the day before for anyone west of Greenwich -- and never hang
on a document somebody else wrote.

Events go through instancesIn like the birthdays, so no view has to know
they are not real calendars, and the calendar they hang off reports no
write rights, so everything that asks before offering an edit declines on
its own. A subscription that cannot be read says so in the sidebar rather
than drawing an empty calendar, which looks like a calendar with nothing
in it.
2026-09-02 00:52:06 -07:00
Coffey Labs b4ee545856 Merge pull request #210 from Coffey-Labs/feat/drag-reschedule
Drag an event to move it, and its edge to resize it
2026-09-02 00:42:47 -07:00
jcoffey-dev a30f96f76b Drag an event to move it, and its edge to resize it
The calendar could only be edited through the editor, so moving a meeting
half an hour meant opening a dialog, changing two fields and saving. Every
other surface a finger or a pointer drives already answers to a drag.

In the day and week grids an event moves by dragging it and changes length
by dragging its bottom edge, snapping to fifteen minutes. In the month
grid it moves to another day and keeps the time it had, because a month
cell is a day and nothing finer -- changing the hour as well would answer
a question nobody asked.

It goes through the same path a menu edit takes. A recurring event is
asked which dates it means, and the answer runs through runScoped, so a
date the server will only change as part of a whole series offers that
rather than failing.

Three things do not offer a drag, and the reasons are checked in one place
so no grid has to remember all three: a read-only calendar, an event with
no calendar, and a birthday -- which is derived from a contact and has
nothing on the server to move. The reserved classes the swipe gesture was
told to keep out of are exactly the ones that are draggable here, which is
what that reservation was for.

Invitations are not sent. A drag is a scheduling gesture, and mailing
every guest on each nudge of a block is not what the hand was asking for;
a change that should go out with notice goes through the editor.

The new time is computed in the event's own frame rather than through an
instant. Working it out from the reader's local hours and then
re-expressing it in the event's zone converts twice, and the two do not
cancel: caught in the browser, where an event moved two hours the first
time it was dragged in the month grid and then sat still, because after
that its stored time and the reader's agreed. Parsing the stored string
into its parts and adding minutes to those touches no zone at all, and a
resize sends only a duration, so the question does not arise there either.
2026-09-02 00:40:03 -07:00
Coffey Labs 0ac5f78789 Merge pull request #209 from Coffey-Labs/feat/palettes
Six palettes, each with a light half and a dark one
2026-09-02 00:23:34 -07:00
jcoffey-dev c838569638 Merge branch 'main' into feat/palettes
# Conflicts:
#	web/src/store/settings.ts
2026-09-02 00:19:22 -07:00
Coffey Labs 72742ef30a Merge pull request #208 from Coffey-Labs/feat/list-sort-order
Let the message list be sorted by something other than the date
2026-09-02 00:18:27 -07:00
jcoffey-dev 9aa0eda0d5 Six palettes, each with a light half and a dark one
The theme was one enum -- system, light, dark, ihasmail -- where one value
carried a whole palette and implied dark. That works for exactly one
palette. The two questions now come apart: which palette, and which side.

Classic is the plain light and dark this app has always had. ihasmail's
own palette gains a day version, so the background of the dark one becomes
the text of the light one and the two read as one palette from either end.
Dracula, Gruvbox, Rosé Pine and Tokyo Night are the work of their own
projects, used under the MIT licence, and taken from each project's own
repository rather than from anyone's reimplementation. What was fetched is
recorded in .palette-sources/ and credited in NOTICE.

Giving ihasmail's palette a light half removed a whole special case.
Nothing is one-sided any more, so a palette can no longer override the
mode, the toggle no longer has to set a palette aside on the way to light
and remember it, and the greyed-out control that explained all that is
gone. The old lastDarkTheme, which existed only for that, is gone with it.

The shades between the published colours are derived rather than guessed:
these projects publish twelve to twenty values and ihasmail needs about
thirty. scripts/build-palettes.py computes the tiers and then measures
every text colour against the surface it sits on -- 4.5:1 for prose, 3:1
for borders and marks -- lifting anything short towards white on a dark
ground and towards black on a light one, so a lifted tier keeps its hue.
It refuses to write a palette that would not pass.

Every one of the nine halves needed at least one lift. These palettes are
built for code editors, not for prose at this size: Dracula's comment grey
is 3.03:1 on its own background and Rosé Pine's gold is 2.7:1 on Dawn.
Shipping them as published would have quietly ended the WCAG AA claim.

Two things caught while checking rather than while writing. The generated
blocks were appended to the end of the stylesheet, which put them after
the accent variants at equal specificity -- so choosing an accent over one
of the new palettes did nothing at all. They now sit before those rules,
where the existing ihasmail block's own comment says they have to. And
that block was unqualified, so it would have shadowed the new light half;
it is now explicitly the dark one.

Settings written before this carry `theme` and no palette, and are read
through the old enum. `theme` is still written back, derived, because a
device on an older build reads it and would otherwise be stranded on a
theme nobody chose.
2026-09-02 00:14:54 -07:00
jcoffey-dev 34fc5ab81f Let the message list be sorted by something other than the date
Newest-first was the only order, so the mail you had not read yet was
wherever it happened to fall.

Seven presets and up to three levels of your own. It covers the Inbox
alone by default: unread-first is what people want in the folder they
triage and confusing in Sent, where everything is read and the order that
matters is when it went. Search keeps newest-first whatever the setting
says, since a result list is already ordered by the question that was
asked.

The server does the sorting, over the whole folder, for the same reason
search runs there: a list sorted in the browser is sorted only as far as
the browser has loaded, which on a folder of ten thousand is the first
fifty and a lie about the rest.

Two details that are easy to get wrong and were worth pinning in tests.
hasKeyword sorts a boolean and false comes before true, so "unread first"
is $seen ASCENDING while "starred first" is $flagged DESCENDING -- the
other way round. Getting either backwards puts exactly the mail you were
looking for at the bottom. And every order ends with newest-first as a
tiebreak, because a sort whose last level is a keyword or a subject leaves
every tie undefined, and an undefined order changes between two looks at
the same folder for no reason the reader can see.

Sorting on a keyword is optional in RFC 8621, and a server that will not
do it fails the whole query rather than degrading it -- so this setting
could turn a folder into one that does not open. The refusal is caught
once, the keyword levels dropped and the query retried, and nothing is
said: the reader asked for an order and got the closest the server can
give, and a toast on every folder change would be the app complaining
about its own request.

The mock now honours the sort instead of always answering newest-first,
which had it reproducing a server that silently returns a different order
from the one asked for -- the one shape of wrongness a client cannot
detect. MOCK_NO_KEYWORD_SORT=1 reproduces a server that refuses the
keyword sorts, so the fallback can be developed against.
2026-09-01 23:51:56 -07:00
Coffey Labs 6c7c6d19b3 Merge pull request #206 from Coffey-Labs/feat/birthday-calendar
Show birthdays from the address book as a calendar
2026-09-01 23:35:46 -07:00
Coffey Labs c5be74fe57 Merge pull request #205 from Coffey-Labs/feat/tnef
Open winmail.dat
2026-09-01 23:35:26 -07:00
Coffey Labs 60d275f38b Merge pull request #204 from Coffey-Labs/feat/nested-labels
Nest labels, and let each one say how prominent it is
2026-09-01 23:35:04 -07:00
jcoffey-dev 1ed8531764 Line up selectedAll with the rest of the object it sits in
It came in at two spaces inside a six-space set({ ... }), which reads as
if it belonged to an outer scope. Whitespace only.
2026-09-01 23:31:28 -07:00
jcoffey-dev 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.
2026-09-01 23:28:21 -07:00
jcoffey-dev be8b89f5ab Merge branch 'main' into feat/nested-labels
Both sides added a field next to the mail store's selection: the label
counts the sidebar draws, and the flag for a selection that means the
whole query rather than the loaded page. They are independent, so the
resolution keeps both.
2026-09-01 23:27:52 -07:00
Coffey Labs 9cace7c90c Merge pull request #203 from Coffey-Labs/feat/select-all-in-folder
Select a whole folder, not just the rows that are loaded
2026-09-01 23:24:14 -07:00
jcoffey-dev 9a474ef2c8 Open winmail.dat
Outlook sending in Rich Text packs every attachment into one TNEF blob.
Every other client shows a single unopenable winmail.dat, and the files
inside it are gone as far as the reader is concerned -- which is a
decoding problem rather than a mail one.

Written from the published format: a signature, a key, then a flat run of
attributes, each one a level byte, a 32-bit id carrying its own type, a
length, the data and a checksum. Attachments are delimited by
attAttachRenddata rather than named, which is why the parse is a small
state machine.

The MAPI property stream inside attAttachment is read for two properties:
the long filename and the MIME type. attAttachTitle carries an 8.3 name,
so a file that arrived as "Quarterly Report Final.docx" is QUARTE~1.DOC
there and correct here. The stream stops at a named property (id >=
0x8000) rather than guessing past it, since those carry a GUID before
their value and nothing after one can be trusted to stay aligned.

Decoded in the browser, on request. The server never sees the contents and
has nowhere to keep a decoded copy; doing the work on sight would spend
the bandwidth whether or not anybody wanted what is inside.

A blob that goes wrong part-way through keeps what was read before that
point, whether it ran out or the checksum stopped matching. Half the
attachments beats none: the alternative is a reader who can see the file
is there and cannot have it. The original stays attached either way.

The message body is deliberately not decoded. TNEF can also carry it as
compressed RTF, which is a second format again for a body the reader
already has in plain text or HTML nine times in ten.

The mock now sends one, built by its own encoder rather than by the
parser's fixtures, so the two are independent implementations of the same
description.
2026-09-01 23:16:22 -07:00
jcoffey-dev f7ef886b45 Nest labels, and let each one say how prominent it is
A flat list is fine at five labels and unreadable at thirty, and there was
no way to keep one that matters occasionally without it holding a row for
ever.

A label can now sit under another, and each says whether it belongs in the
sidebar always, only while it has unread mail, or never.

Nesting is display only. The keywords stay flat on the message, which is
what keeps them readable by every other client: moving a label under
another rewrites nothing in the mailbox, and a client that knows nothing
about ihasmail sees exactly what it always did. Both new fields are
optional, so a settings file written before this parses unchanged and
means what it did.

Settings sync between devices, so the tree has to survive shapes that
should not exist. A label whose parent was deleted on another device comes
back to the top level rather than vanishing -- a label that disappears
because something else was deleted is one the reader cannot get back. A
cycle arriving from an older device is broken by treating the label that
closes the loop as a root, so nothing is lost and nothing hangs. The
parent picker will not offer a label's own descendants, so one cannot be
built here in the first place.

A label kept by the unread rule keeps its ancestors, whatever they were
set to. A child cannot be drawn under a parent that is not there, and
promoting it to the top level would silently rearrange the tree at the
moment the reader is least able to explain why. The parent comes back as a
container instead, and its own count still says whether it has anything of
its own.

Unread counts come from one request carrying a query per label rather than
a request each, with limit 0 so the server does not send ids that would
only be thrown away. They refresh on the same beat as the folder counts,
since the things that move them are the same things, and a failure is
swallowed: a count is decoration, and the sidebar draws the label without
one.

Also corrects the Labels page, which said names and colours are kept in
this browser. They live in the account's own Files and follow it between
devices, like every other setting that is not about this screen.
2026-09-01 23:09:21 -07:00
jcoffey-dev 8bee0eb3c8 Select a whole folder, not just the rows that are loaded
The header checkbox selected the loaded page. On a folder of ten thousand
that is fifty of them, and the only way to act on the rest was to scroll
until they loaded and tick again.

A line now offers the rest by name once the page is selected, and taking
it is a separate press. A checkbox that silently meant ten thousand when
the screen shows fifty would be the worst of both, so each option says
what it actually covers.

The wider selection is a query rather than a list of ids. What it reaches
is resolved from the server when an action runs, walked a page at a time,
because a folder holds far more than one call returns and Email/set
refuses more ids than maxObjectsInSet in one go -- which setEmails already
chunks for. It resolves uncollapsed, unlike the list: "everything in this
folder" means every message rather than one per thread, and expanding
threads the way a click does is impossible here anyway, since that walks
loaded Email objects and these are the ones that were never loaded.

Two things this exposed.

Undo is now withheld once a move reaches messages that were never loaded.
It restores the folders each message was in, taken from what the browser
holds, and for an unloaded message that is nothing -- so the undo would
have written an empty mailboxIds and left the message in no folder at all,
which is worse than the move it was undoing. move() and archiveByDate()
both did this; both now drop the offer rather than restore something
wrong.

And an action consumes the wider selection. The optimistic paths cleared
the selected ids but not the flag, so the next action would have silently
reached the whole folder again.
2026-09-01 22:56:14 -07:00
Coffey Labs d300107be0 Merge pull request #202 from Coffey-Labs/feat/sender-link-warnings
Warn about outside senders, large sends and links that mislead
2026-09-01 22:48:12 -07:00
Coffey Labs 255616341b Merge pull request #201 from Coffey-Labs/feat/base-path
Serve ihasmail from a subpath
2026-09-01 22:47:53 -07:00
jcoffey-dev 5b18f1d5d7 Warn about outside senders, large sends and links that mislead
Four warnings, in Privacy & safety, and all of them start switched off.

That is not timidity. A client that begins by interrupting is one people
learn to click through, and a warning clicked through without reading
costs the same attention and buys nothing. The outside-sender warning
could not be on by default in any case: it measures against the domains
that count as yours, and with nothing configured every message in the
mailbox is from outside.

Your own identity domains are always internal and are not configuration.
An account signed in as [email protected] warning that example.com is
external would be absurd, and making it be typed in first is a foot-gun
that leaves the feature useless the moment it is enabled. Configured
domains are additional, and cover their subdomains -- matched on a dot
boundary, so example.com covers mail.example.com and not notexample.com,
which is exactly the domain somebody registers on purpose.

The four:

A banner names the sender's domain on a message from outside.

Sending outside names the outside recipients and asks, rather than
refusing. "This is going outside" is a rule and not something the sender
can check; a list of addresses is. It reads the full identity list rather
than the visible one, since hiding an identity from the From menu does not
make its domain somebody else's.

Sending to a large group asks once the count crosses a threshold, which is
what catches a reply-all onto a long thread. It counts people rather than
headers, so one address in To and nine in Cc is a message to ten.

Opening a link asks when the destination is not trusted, and always when
the link's text names one domain while its destination is another -- even
where that destination is trusted, because being trusted is not the same
as being the place the text claimed. On that mismatch the offer to trust
the domain is withheld: what would be trusted is the destination, and the
destination is not the thing in question. Anything that is not http or
https is left alone, since warning about a mailto: is noise and noise is
how a warning stops being read. Both bodies are covered, because a link in
a plain-text mail is linkified by us and points wherever it likes just as
readily as one the sender marked up.

The click is cancelled and the navigation re-issued after the answer,
since there is no way to hold a real navigation open across a dialog. The
reopen runs in the continuation of the dialog's own click, which is still
the gesture a popup blocker wants to see.
2026-09-01 22:45:22 -07:00
jcoffey-dev 93d0a32af2 Serve ihasmail from a subpath
`BASE_PATH=/mail` mounts the whole app under a prefix, for a host that is not
ihasmail's alone. Unset -- every deployment that exists -- is the domain root
and is byte-for-byte what it was: the canonical form of the setting is the
empty string, and `""` concatenated onto `/api/health` is `/api/health`.

That choice of canonical form is the whole design. A trailing slash would have
been the obvious alternative, and it fails quietly in exactly one place: at the
root it makes `//api/health`, which is not a path on this host but a
protocol-relative URL to a host called `api`. One call site forgetting to
branch is a request leaving the origin. So the empty string, one leading slash,
no trailing one, worked out once in `scripts/basePath.mjs` -- plain JS, next to
`version.mjs`, because the web build and the server both have to reach the same
answer and two implementations of "what does /mail/ mean" is precisely the bug
where the server serves an app whose script tags point somewhere else.
`/mail`, `mail`, `/mail/` and `//mail//` all mean the same mount; a deployment
should not fail over a trailing slash.

Unlike everything else ihasmail is told, this one cannot wait for the process
to start. The bundle writes its own asset URLs into index.html, so `BASE_PATH`
is read at build time for Vite's `base` as well as at run time for the routes,
and the Dockerfile carries one value into both. Get them out of step and the
page comes up blank with a 404 in a console nobody has open -- so the static
handler, which is reading index.html anyway, checks what it asks for and says
so in the log once per build.

Everything moves together. The API mounts at `${base}/api`; the router is
given the base once, so every `<Route path>` and `<Link href>` stays written
root-absolute and wouter does the rest; `apiFetch` adds the prefix in one place
rather than at forty call sites; the session cookie's Path narrows to the mount
so two instances on one host cannot sign each other out.

Two things need no prefix at all, and it is worth saying why they were not
given one. A manifest's members resolve against the manifest's own address, so
relative URLs there follow the mount with nothing substituted at build time --
which is also why `public/` needed no template step. The service worker is the
same trick: it is served from the mount, so `new URL("./", self.location)`
tells it where that is, and a worker that derives the value cannot disagree
with the page that registered it.

Anything outside the mount is a 404 rather than the app shell, and
`stripBasePath` does not use `startsWith` -- under `/mail` this process shares
a hostname, and answering `/mailbox` with our index would shadow a neighbour
instead of letting it 404 honestly. For the same reason the notification-click
handler now checks the path as well as the origin: `includeUncontrolled` widens
`matchAll` to the whole origin, which off the root would have navigated a
stranger's tab to our inbox.

Inline images in a draft were the one silent trap. They are matched by their
blob URL on the way out, once unanchored and once anchored, and a bare
`/api/blob/` still appears inside `/mail/api/blob/...` -- so one pattern would
have replaced the tail and left `/mail` in front of a `cid:`, and the other
would have missed and sent the message linking to the sender's own webmail.
Both patterns are built from the base now.
2026-09-01 22:41:18 -07:00
Coffey Labs 4c54eb74f9 Merge pull request #200 from Coffey-Labs/feat/privacy-safety-settings
Gather the privacy settings into a section of their own
2026-09-01 22:36:58 -07:00
jcoffey-dev e4b82783c2 Merge branch 'main' into feat/privacy-safety-settings
# Conflicts:
#	web/src/styles/app.css
2026-09-01 22:34:36 -07:00
Coffey Labs ccc9f73a71 Merge pull request #197 from Coffey-Labs/feat/forward-as-eml
Forward a message as an attachment
2026-09-01 22:34:02 -07:00
jcoffey-dev 790213cc17 Merge branch 'main' into feat/forward-as-eml
# Conflicts:
#	web/src/store/compose.ts
#	web/src/views/mail/MessageView.tsx
2026-09-01 22:31:42 -07:00
Coffey Labs 1ea813940b Merge pull request #196 from Coffey-Labs/feat/spam-score-panel
Show what the spam filter said, in the message details
2026-09-01 22:31:07 -07:00
jcoffey-dev 97f8b34e8b Merge branch 'main' into feat/spam-score-panel
# Conflicts:
#	web/src/store/mail.ts
#	web/src/styles/app.css
2026-09-01 22:28:52 -07:00
Coffey Labs ec8e2ad2d0 Merge pull request #199 from Coffey-Labs/feat/calendar-swipe
Swipe the calendar sideways to step a day or a month
2026-09-01 22:27:51 -07:00
Coffey Labs 3c3ffbd425 Merge pull request #198 from Coffey-Labs/feat/archive-by-date
Archive into a dated folder
2026-09-01 22:27:32 -07:00
Coffey Labs 132d0b5205 Merge pull request #195 from Coffey-Labs/feat/template-placeholders
Fill placeholders when a template is inserted
2026-09-01 22:27:04 -07:00
jcoffey-dev b83de657d7 Gather the privacy settings into a section of their own
General had grown five unrelated headings and was where anything without
an obvious home ended up. Remote images were filed under "Reading", the
read-receipt policy under "Composing", the undo-send window beside the
default message format. They are the same kind of decision -- what reaches
a sender, and what asks before something happens -- and they were the
hardest settings in the app to find.

Privacy & safety now holds all six, in three groups: remote content, read
receipts, and the things that ask before it is too late. General keeps
what it is actually about and is thirty lines shorter.

The line against Security & sessions is worth stating, because two similar
words next to each other in a nav is how a menu becomes something people
hunt through. Security & sessions is credentials and access: password,
two-factor state, app passwords, live sessions. Privacy & safety is how
the app behaves towards the reader and towards senders.

Nothing moved in storage. Settings are a flat object in settings.json and
sections are only how they are grouped on screen, so this is a UI change
with no migration and no key renames.

Two things beyond the move.

The senders trusted with remote images are now listed and can be withdrawn
one at a time. A sender was added from a message and could then only be
removed by finding another message from that same sender, which is not a
way to review a list you cannot see.

And General's lead said settings are stored in this browser, which is only
true when the server has no FileNode support. They normally live in the
account's own Files and follow it between devices, so the sentence
contradicted the feature it sat above.
2026-09-01 22:24:23 -07:00
jcoffey-dev 7d9a01cb93 Swipe the calendar sideways to step a day or a month
The calendar had next and previous as toolbar buttons and n/p, and nothing
for a thumb. Every other surface in the app that a finger drives already
answers to a swipe.

Day and month only. Those are the two views where a period is a page;
week and agenda scroll through a range rather than turning to the next
one, so there is nothing a sideways flick would obviously mean. Dragging
left pulls the next period in from the right, the way paper and every
phone do it.

Three things it deliberately does not do.

It draws nothing while the finger moves. The row swipe slides the row open
because the strip underneath has to name which of six actions is about to
happen; stepping a calendar has two outcomes and the direction of the
finger already says which. Translating the grid would also break the
sticky day header, since a transform makes a containing block. The
threshold is reported by the vibration motor instead, which is what the
haptics are for.

It does not start on an event. Which gesture was meant is decidable at the
moment the finger lands and only then, so dragging an event stays
available to be built later without having to be untangled from this
first.

And it asks for a longer drag than a row swipe -- not because the
consequence is bigger, since stepping back undoes it while a swiped row
has already been archived, but because this gesture has no way to change
its mind. A row reveals what it will do and can be let go early, and
offers Undo after. This shows nothing and offers nothing, so the distance
is the only chance to not mean it.

The axis lock is the shared one, keeping its bias towards the vertical:
the day grid scrolls through the hours, and a scroll misread as a swipe
throws the reader into another day. The toolbar buttons and n/p stay,
because a gesture with no visible control is one only the people who
already know about it can use.
2026-09-01 22:13:32 -07:00
jcoffey-dev 785570a41d Archive into a dated folder
Archiving put everything in one folder, so an Archive that has been
collecting for years is a single flat list with no way to narrow it except
search.

Archive by year and Archive by month file into Archive/2026 and
Archive/2026/09, creating the folders as needed and reusing them after
that, including folders made by hand or by another client.

The names are numeric and zero-padded rather than month names, because
these are real server-side mailboxes rather than anything of ihasmail's.
Every other client sees them: a folder created as "September" by someone
reading in English stays "September" for the same account read in
Japanese, since the name is stored and not translated. And 09 sorts
between 08 and 10 where a name does not.

The date is read in the reader's own timezone rather than UTC so it agrees
with the date shown against the message in the list. A message that
arrived at 00:30 UTC on 1 September is dated 31 August in New York, and
filing it under 09 while the list says August would be the app
disagreeing with itself. A message whose date cannot be read goes to
Archive itself rather than to a folder named after a guess.

A selection spanning two months is two destinations, not one. The moves
are made silently and one toast names where everything went -- the folder
where there is a single answer, the count where there is not -- because
each group raising its own toast with its own Undo would mean undoing a
third of a move. One Undo restores the whole selection to wherever each
message came from, captured before anything moved.

The menu labels name the destination where there is one, so it reads
"Archive to 2026/09" rather than describing the rule, and falls back to
"Archive by month" for a selection with no single answer.
2026-09-01 22:06:33 -07:00
jcoffey-dev 0db795371e Forward a message as an attachment
Forwarding quoted the original into a new message, which is the right
thing for passing on something to be read and the wrong thing for passing
on something to be looked at. Quoting rewrites the body, drops the
headers, and re-parents the attachments, so a bounce, a phishing report or
anything else where the message itself is the evidence arrived altered.

Forward as attachment sends the message whole, as a message/rfc822 part.
It costs no upload at all: a message's own blobId is its RFC822 blob and
already lives in the account, so this goes through the same by-reference
path as attach-from-Files and a 40 MB message attaches as fast as a small
one.

It is in the message's own menu, the list's right-click menu, and the
overflow on the reply strip at the foot of a thread, which is the one a
thumb finds on a phone.

Two things fixed on the way, both exposed rather than introduced by this.

The filename rule was subject.replace(/[^\w.-]+/g, "_"), and \w without
the u flag is ASCII: every character of a Russian, Japanese or Chinese
subject failed the class, so those messages downloaded as a row of
underscores. What is actually unsafe in a filename is much shorter than
"not ASCII" -- path separators, the names Windows reserves, the control
range -- so the rule now keeps letters from any script and drops only
those. It lives in one place and the .eml download uses it too.

And the composer's attachment chip set overflow/text-overflow on a span,
where neither does anything, so the name never truncated and the size ran
on after it on the same line. Only long names showed it, which is every
.eml named from a subject.
2026-09-01 21:57:11 -07:00
jcoffey-dev 63c2839602 Show what the spam filter said, in the message details
The filter in front of the mailbox scores every delivered message and
writes its working into headers, and none of it was being read. A message
in Junk gave no reason for being there.

Nothing here scores anything. The headers are parsed and shown, so this
cannot disagree with the filter that actually made the decision.

Two formats cover what sits in front of a Stalwart mailbox in practice:
the SpamAssassin-shaped X-Spam-* set, which Stalwart's own filter writes,
and Rspamd's X-Spamd-Result. A header in neither shape is left unread
rather than guessed at, since a misparsed score shown confidently is worse
than no panel at all. Mail that arrived without any of them shows nothing.

Rules are listed largest mover first and signed, because which way a rule
pushed is the point, and the biggest contributor is the answer to why the
message scored what it did.

Two things it deliberately will not do. A score is always given the
threshold it was measured against, because 6.7 is damning against 5 and
unremarkable against 15 -- the number alone is not something a reader can
act on; where no threshold was stated, it says so rather than implying
one. And where the filter recorded no verdict, none is derived from score
against threshold: the filter applies policy we cannot see, and putting a
verdict in its mouth would be inventing one.

The mock writes the same headers at delivery -- spam in Junk, clean in the
Inbox, nothing on mail this account wrote -- so the panel can be developed
and demoed against it.
2026-09-01 21:45:22 -07:00
jcoffey-dev c85525f3ed Fill placeholders when a template is inserted
Templates were a fixed subject and body, so anything that changed per
message -- who it is going to, today's date -- had to be typed over
afterwards.

Eight names are recognised: recipientName, recipientFirstName,
recipientEmail, myName, myEmail, subject, date and time. Dates and times
go through datetime.ts rather than toLocaleDateString, so a template
follows the date order and clock the app was already told to use.

Filling happens on insert rather than on send. What a placeholder came to
is then visible in the composer and can be edited, instead of the message
changing between writing it and sending it.

Two things are deliberately left alone. A placeholder that cannot be
answered yet -- a recipient's name on a draft nobody has addressed -- stays
in the body as written, because substituting an empty string produces
"Hi ,", which is wrong rather than visibly unfinished; leaving the name
says which word is still missing and can be typed over. And a name that is
not a placeholder is left as written too, since a body that quietly ate an
unrecognised token would be worse than one that shows it.

Values are escaped on the way into HTML: a display name comes from a
contact card or a typed address and is not trusted markup.
2026-09-01 21:30:18 -07:00
Coffey Labs 34578ba426 Merge pull request #194 from Coffey-Labs/feat/files-multiselect
Select more than one file at a time
2026-09-01 21:14:20 -07:00
jcoffey-dev 00b580bad8 Select more than one file at a time
Moving or deleting five files meant doing it five times, each with its own
confirm. Rows now select the way they do in a file manager: a plain click
replaces the selection, ctrl or cmd adds and removes one, shift takes the
run from the last row clicked, clicking past the last row or pressing
Escape clears it. Two or more selected raises a bar with Move to... and
Delete, and the row menu offers the same for the whole selection.

The move is one `FileNode/set` rather than a loop, and not only for the
round trip: a loop would apply half the moves and then throw, leaving a
selection split across two folders with nothing saying which half went.
One call is one answer, and `notUpdated` names whatever the server
refused.

Right-clicking inside the selection acts on all of it; right-clicking
outside means you meant that row, so the selection follows the pointer
rather than the menu quietly applying to something off-screen. A drag
carries the whole selection the same way, which is why the payload is now
a list -- and why a drop is refused unless every file in it can land,
since a drag that moves four of five and skips the fifth is worse than
one that will not start.

A selection belongs to the folder it was made in, so changing folder or
account drops it: rows left selected off-screen make the delete two
folders later a surprise.
2026-09-01 21:11:36 -07:00
Coffey Labs b5ca0021ef Merge pull request #193 from Coffey-Labs/feat/edit-text-files
Edit a text file where you are already reading it
2026-09-01 20:54:14 -07:00
jcoffey-dev a4c0e04ab9 Edit a text file where you are already reading it
v2 of the viewer: Edit, on text and Markdown, in the dialog and on the
row menu. Save is explicit -- every save mints a new blob, so autosave
would burn quota and multiply the conflicts it cannot see.

Two people editing one file is the case worth getting right. `saveText`
re-reads the node and compares the blob the editor started from: if
somebody else saved in the meantime it refuses, says so, and leaves the
work in the box to copy out. `ifInState` is the obvious tool and the
wrong one -- it is the state of every FileNode in the account, so an
unrelated upload in another folder would fail the save, and a warning
that cries wolf is a warning people click through.

Editing is not offered where saving would lose something: a file
truncated for display would have its tail written away, and one that did
not decode as UTF-8 would have mojibake written over whatever encoding it
really is. Both open read-only and say which. Nor is it offered without
mayModifyContent -- a read-only share just has no Edit.

Closing or cancelling with unsaved changes asks first, Ctrl+S saves, and
mail attachments are unaffected: they pass no onSave, because a message
part is not a thing that can be written back.
2026-09-01 20:51:51 -07:00
Coffey Labs 8d562628ff Merge pull request #192 from Coffey-Labs/feat/markdown-render
Read a Markdown file as the document it is
2026-09-01 20:36:40 -07:00
jcoffey-dev 15f2c3d357 Read a Markdown file as the document it is
A .md previewed as its own source, which is reading the punctuation
rather than the notes. It now opens rendered, with Rendered | Source in
the dialog footer for anyone who wants what the file actually says.
Markdown only; a .txt has nothing to toggle between.

Rendering is `marked`, sanitised by DOMPurify -- the one the app already
carries for mail. Markdown is not a safe subset of anything: raw HTML
passes through it by design, so a <script> in a file somebody uploaded or
shared into the account is a script tag unless something takes it out.

Images become links rather than pictures. An image in a Markdown file is
either a relative path, which has no base to resolve against here, or a
URL somewhere else, which fetches on open and tells that server the file
was read -- the tracking pixel this app blocks in mail. The link keeps
the alt text and the address, so nothing vanishes silently.

Fixes the PDF preview while here, which never worked: securityHeaders
put X-Frame-Options: DENY on every response including the blob route, so
the iframe showed Chrome's "refused to connect" where the file should
have been -- in Files today and in mail attachments long before that.
The middleware now leaves a header the route has set, and a PDF served
inline says SAMEORIGIN. Nothing else on the server is framable.
2026-09-01 20:32:59 -07:00
Coffey Labs c170b8554c Merge pull request #191 from Coffey-Labs/feat/file-preview
Look at a file without downloading it first
2026-09-01 20:21:48 -07:00
jcoffey-dev 984f0474e3 Look at a file without downloading it first
Files could only hand you the bytes: double-clicking a picture put it on
disk and left you to find it. The viewer for this already existed --
images, PDFs and text, in the attachment preview in MessageView -- it was
just wired to one screen. It is now a component both screens use.

Two things it needed before it was any use on Files. The type detection
falls back to the file name: an upload carries whatever the browser
guessed, which for anything unusual is application/octet-stream, so the
old exact-type check saw nothing to show in a .md that had just been
uploaded. And text is read with fetch, which ignores Content-Disposition,
so Markdown previews even though the server will not serve it inline.

Whether we can show a file and whether the server will serve it inline
are separate questions, and lib/preview.ts answers them separately:
`openableInTab` mirrors the isInlineSafe allowlist in the blob route,
because navigating to a blob the server will not inline just starts a
download. SVG is left out of both -- it carries script, and how to show
one safely is its own question, not a detail of a file lister.

Printing goes with it. A picture or a text file prints from the dialog
with everything else dropped; a PDF prints itself from its own iframe,
since the page around it cannot paginate someone else's document. Hiding
`.app` alone was not enough there -- `#root` kept its height and printed a
blank first page, the same trap as the message card.
2026-09-01 20:18:51 -07:00
Coffey Labs 49f9a06009 Merge pull request #190 from Coffey-Labs/fix/print-single-message
Print the message you asked to print
2026-09-01 19:49:13 -07:00
jcoffey-dev a33260966c Print the message you asked to print
The Print in a message's own menu called window.print() bare, so it
printed the whole conversation -- every message on the page -- which is
what the toolbar's "Print conversation" is already for. Opening one
message's menu and asking to print it is not a request for the other
eleven.

It now marks the card it was opened from and the print stylesheet drops
the siblings for the duration. The subject heading stays: a printed
message with no subject on it is a page nobody can file. The "3 messages"
count beside it goes, since only one of them is on the paper.

The label is left as "Print" rather than made more specific -- it is
translated in all nine shipped catalogues, and the toolbar entry beside
it already says "conversation".
2026-09-01 19:46:44 -07:00
Coffey Labs a16faa1789 Merge pull request #189 from Coffey-Labs/fix/print-light-and-first-page
Print on white, and start on page one
2026-09-01 19:39:49 -07:00
jcoffey-dev 0fb7fa09f6 Print on white, and start on page one
Printing carried whatever theme was on screen: a dark reader printed a
dark sheet, message body included -- that renders in a shadow root, and
follows the app palette through inherited custom properties, so no rule
in this stylesheet could reach it. The print block now pins the palette
tokens themselves to a light, unpainted set, which reaches the body the
same way the theme does. Backgrounds go white rather than the light
theme's greys; a printer should not lay ink over the whole page.

Page one was also blank apart from the subject. `break-inside: avoid` on
the message card cannot be honoured by a message taller than a sheet,
and Chrome answers by moving the card to a fresh page and breaking it
there anyway. Only the header is indivisible now, kept with the body
that follows it.
2026-09-01 19:37:23 -07:00
Coffey Labs 4912ea8ef4 Merge pull request #188 from Coffey-Labs/fix/ctrl-click-selection
Take the message you were reading into the selection
2026-09-01 13:44:27 -07:00
jcoffey-dev f25b559cb5 Take the message you were reading into the selection
Ctrl-clicking a second message selected only the second. The first stayed
highlighted, because it was the one open -- which is a different state
wearing a similar colour -- and was never actually selected. So both looked
picked, one was, and every action that followed applied to half of what the
screen showed, silently.

The cause is visible once the two rules sit together: shift-click took the
whole run *including* the row it started from, and ctrl-click took only the
row clicked. Two branches of one handler that had drifted apart, with
nothing asserting they agreed.

So they are one function now, and tested. Ctrl-click brings the current row
with it while nothing is selected yet; once there is a selection it toggles
exactly one row, which is what it is for. Shift-click is unchanged, and
keeps its anchor where it is so extending a range twice grows it from the
same place rather than from wherever it last reached.

The last test asserts the property that failed rather than the branches:
whichever modifier begins a selection, the anchor is in it.
2026-09-01 13:41:04 -07:00
Coffey Labs fa55636fb0 Merge pull request #187 from Coffey-Labs/docs/demo-link
Point at the demo from the top of the README
2026-09-01 13:04:17 -07:00
jcoffey-dev 8116cd0393 Point at the demo from the top of the README
Somebody deciding whether to spend an evening on a mail client wants to
see it before they read about it. The link sits under the logo, above the
badges, because a demo answers the question the badges are only evidence
for.

The line under it says what the demo is -- invented mail, nothing kept --
so nobody arrives expecting to sign in with an address of their own.
2026-09-01 13:00:43 -07:00
Coffey Labs bbdceb2697 Merge pull request #185 from Coffey-Labs/docs/scheduling-and-filters
Write down what the last week's features actually do
2026-09-01 11:03:25 -07:00
jcoffey-dev 0871d3291b Write down what the last week's features actually do
FEATURES.md had nothing on three of them. The scheduling panel and what it
will not show, the filter editors no longer discarding work in silence,
and where compose-as-new can be reached from — including the reply strip,
which is the one a thumb finds.

KNOWN-ISSUES.md gains the free/busy finding, which is the sort of thing
that page exists for: it was checked against the live server rather than
assumed, free/busy between accounts turns out to need no sharing set up,
and a principal offers no route to its calendars at all. Also what the
check did *not* settle, and which way ihasmail errs in the meantime.

ROADMAP.md gains the two things left deliberately unbuilt: a scheduling
view you can visit with no event in hand, and per-message actions from the
message list on a touchscreen, where the gesture that would open them
already means "select".
2026-09-01 11:00:04 -07:00
Coffey Labs 2beedc0f3c Merge pull request #184 from Coffey-Labs/fix/compose-as-new-on-mobile
Put "compose as new" where a thumb can find it
2026-09-01 10:56:32 -07:00
jcoffey-dev e380e168cd Put "compose as new" where a thumb can find it
It was never absent on a phone: it sits in the menu behind the ⋮ at the
top of a message card. But that is not where anybody looks. On a phone you
act on a thread from the strip at the bottom -- Reply, Reply all, Forward
-- and what is not there is, for practical purposes, not there.

The strip gets an overflow of its own, with compose-as-new in it. A fourth
labelled button does not fit; this does, and it says what it is once
opened.

Measuring to place it turned up something else. Three labelled buttons
want about 390px, and with the overflow rather more: enough for a 430px
phone and not for a 390, 360 or 320 one. The strip was already over that
line on the smaller ones before today, and simply overflowed. It now
wraps, and the spacer that would push the overflow onto a line of its own
is dropped on narrow screens so the buttons wrap as a group.

Closes #181
2026-09-01 10:53:28 -07:00
Coffey Labs 0c1820e2bb Merge pull request #183 from Coffey-Labs/feat/scheduling-panel
Schedule against the whole guest list, and say who cannot be read
2026-09-01 10:46:57 -07:00
jcoffey-dev e1402e472f Schedule against the whole guest list, and say who cannot be read
The availability bar showed the guests it had free/busy for and quietly
left out the ones it did not. In one row that is survivable. As a grid it
would be a lie: a row with nothing in it reads as a diary with nothing in
it, and "we cannot see this person's calendar" is the one thing that must
not look like "this person is free".

So everyone the event concerns now gets a row, and the ones with no
free/busy to read are drawn hatched rather than empty, with a line under
the grid saying how many and why. You get a row too, first. Scheduling
around the other people and not around yourself is how two things end up
at the same time, and the organiser was the one calendar the panel never
showed.

Your own row is never unknown. Where the directory does not list you under
the address your identity sends from -- an alias, a login that differs
from the address -- the account is still yours to read, and Stalwart
answers for it under the account's own id.

The window steps backwards and forwards a screenful at a time without
touching the event, which is the "movable forwards & backwards" the report
asks for, and offers its way back when you have wandered off.

And the bars are somewhere to put the event rather than only something to
read: the pointer shows the half hour it is over, and a click moves the
event there keeping its length. Clicking while stepped away moves the
event to where you clicked and returns the view to it, so it lands where
you were looking instead of jumping.

Free/busy is answered per principal, and only the server's own accounts
are principals. Somebody at another domain has none to read -- which is
not a gap to be closed, it is what the protocol can see -- so the grid
says so rather than drawing them blank.

Closes #172
2026-09-01 10:05:50 -07:00
Coffey Labs fb1ad3c5c8 Merge pull request #182 from Coffey-Labs/feat/availability-labels-multiday
Say what the availability bar is showing, and show all of it
2026-09-01 09:58:25 -07:00
jcoffey-dev c4649e0084 Say what the availability bar is showing, and show all of it
The bar was a day wide whatever it was drawing. It began at midnight on
the event's start day and stopped 24 hours later, so an event running over
two days showed availability for the first of them and gave no sign there
was more. And it carried no marks at all, which left "is this the whole
day or only working hours" unanswerable without dragging the event about
to see where its own outline moved.

It now covers whole days from the day the event starts to the day it ends,
and the free/busy lookup asks for the same range it draws. Above the bars
is an axis: hours every three across a single day, every six across two,
day names beyond that. The marks are drawn down the bars too, so a busy
block can be read against the hour it starts at rather than guessed at.

Whole days, always. A bar starting at the event's own start time would
move under the reader every time they adjusted it, and "busy from about a
third of the way along" is not a time anybody can read.

A week is as far as it goes. Something running longer is not an event
anybody is hunting a free slot in, and a month at eight pixels a day would
say nothing; it says how many days it left out instead.

The span is measured between two real midnights rather than counted in
24-hour days, because twice a year they differ, and every position on the
bar is a fraction of it.

The mock answered with a single busy block on the first day whatever range
it was asked for -- all a day-wide bar could show -- which would have left
a multi-day bar looking like everyone was free from the second day on. It
now answers across the range.

This is parts 1 and 2 of #172. The separate multi-day scheduling view it
also asks for is still open, and needs an answer first on what to show for
participants who have no free/busy to read.
2026-09-01 09:43:41 -07:00
Coffey Labs df9349935d Merge pull request #180 from Coffey-Labs/feat/ldif-import
Import an address book in LDIF
2026-09-01 09:31:18 -07:00
jcoffey-dev fc0e2b2b3e Import an address book in LDIF
Somebody arriving from SOGo, Thunderbird or an LDAP directory has their
contacts in LDIF, and until now the only way in was vCard.

Nothing on the server reads LDIF, so this reads it here, in two pieces
that are two different problems. `ldif.ts` is RFC 2849 and nothing else:
folded lines, base64 values, case-insensitive attribute names, options,
comments, `version:` headers, change records. It knows no attribute by
name. `mozillaAb.ts` knows the attributes and no syntax -- Mozilla's
address book schema, which is what Thunderbird and SOGo write and what the
issue asks for by name. LDIF says nothing about what any attribute means,
so a file is only readable against a schema, and keeping the two apart is
what would let a second schema be added without touching the reader.

Work and home addresses, which the schema keeps in two separate sets of
attributes, come across as two addresses. So do every phone kind, the
second email, the organisation and its units, job title, nickname, web
pages and the AIM handle. The four custom fields have no equivalent in
JSContact and are appended to the note, labelled as Thunderbird labels
them: keeping something somebody chose to write down is worth more than
the tidiness of dropping it.

An entry with neither a name nor an address is skipped rather than
imported as a blank row that is impossible to identify and tedious to find
again to delete. The distinguished name is not used as the contact's uid:
it says where an entry sat in somebody else's directory.

One import control takes either format and decides by what is in the file
rather than by what it is called, because an address book exported as LDIF
arrives as .ldif, .ldi, .txt or with no extension at all.

Closes #174
2026-09-01 09:27:07 -07:00
Coffey Labs 5a7acb7306 Merge pull request #179 from Coffey-Labs/feat/compose-as-new
Send a message again as a new one
2026-09-01 09:10:42 -07:00
jcoffey-dev 02c0332d8f Send a message again as a new one
A mail the far end rejected, or one that went to an address with a typo in
it, is a mail you want to send again -- not forward, and not reply to.
Doing it by hand meant a new message and copying five fields across.

"Compose as new" sits with Reply and Forward, in the message menu and in
the list's right-click menu. It opens a composer holding the recipients
the message had, its Reply-To, its subject with nothing prefixed to it,
its body with nothing wrapped around it, and its attachments.

What makes it a new mail is what it leaves behind. `draftId` stays null,
or sending would destroy the message it was made from. `inReplyTo`,
`references`, `relatedEmailId` and `relatedKeyword` stay null, so it hangs
off no thread and sending it marks the original neither answered nor
forwarded. The Message-ID is the server's and the date is stamped at
build time, so both are new without anything asking for them -- the send
path needed no changes at all.

A message you sent is composed as the identity you sent it as. One
somebody else sent has no identity of yours to match, and guessing from
whom it was addressed to would put the resend behind an alias that was
only ever the receiving end, so that case takes the account's default.

No signature is added. The body is the one that was sent, which already
ends in whatever signature went with it, and appending the identity's
would give it two.

Closes #176
2026-09-01 08:58:15 -07:00
Coffey Labs 53513e6744 Merge pull request #178 from Coffey-Labs/feat/ical-import
Import an iCal file into a calendar
2026-09-01 08:43:37 -07:00
jcoffey-dev 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
2026-09-01 08:38:06 -07:00
Coffey Labs 1a29784848 Merge pull request #177 from Coffey-Labs/fix/sieve-unsaved-changes
Ask before the filter editors lose your changes
2026-09-01 08:13:26 -07:00
jcoffey-dev d3e173c9c1 Ask before the filter editors lose your changes
Both editors on the Filters & rules page kept their edits in component
state, so every way out of the page threw them away without a word: a
settings link, the app rail, even the Rules/Scripts switch. The only sign
there had been anything to lose was a Save button that a screenful of
rules had already pushed below the fold.

Editors now register what they have pending, and every in-app navigation
asks first -- offering to save, rather than making "leave without saving"
the easy answer and saving the one you have to back out and find. Wouter
routes links, redirects and navigate() through one place, so the guard
holds for the app rail and the settings nav without either knowing an
editor exists. The Rules/Scripts switch asks for itself, since it never
reaches the router. Reload and tab close get the browser's own prompt.

The save bar is pinned to the foot of the pane, so "Unsaved changes" is
on screen whether or not the rules fit in the window.

Two things fixed on the way past, both in the raw script editor: saving
cleared only the selection, which left the editor open with the name
unlocked so a second save created a duplicate script instead of updating
the one just written; and the pair of identical nested conditions that
decided whether the editor was open at all is now the one question it was
asking twice.

Fixes #175
2026-09-01 07:05:17 -07:00
Coffey Labs b63f53c55b Merge pull request #170 from Coffey-Labs/docs/create-event
Say in the README that a message can become an event
2026-08-31 23:19:30 -07:00
jcoffey-dev 1e9d7bb596 Say in the README that a message can become an event
The mail bullet in "What's in it" listed everything a message can turn
into -- a reply, a filter, a receipt -- except the newest one. It now
names the event too, and says the guests come with it, which is the half
that is not obvious from the feature's name.
2026-08-31 23:17:03 -07:00
Coffey Labs 2dd0203ae1 Merge pull request #169 from Coffey-Labs/feat/event-guests-from-message
Invite the people the message was already between
2026-08-31 23:09:38 -07:00
jcoffey-dev b862f61cde Invite the people the message was already between
Follow-up to #167: an event made from a mail now opens with the sender
and everyone it was addressed to already in the guest list, so a thread
becomes a meeting without retyping the room.

Two things are deliberately left out. The reader's own addresses, since
they are the organiser and an organiser among their own guests is an
invitation to your own appointment. And a blind copy, on a message the
reader sent: a guest list is visible to every guest, so promoting a Bcc
to a guest would tell the room about a copy the sender chose to hide.
That is not something a menu item may do quietly.

*Send invitation emails to guests* now starts off when the guests were
inherited rather than typed, and on everywhere else -- which is every
event whose guests somebody chose one at a time. The reason is the case
the issue opened with: a reminder made out of a bill carries the biller
and everyone else on the mail. Left on, the primary button reads Send
invites and the first press mails all of them an invitation to what was
meant as a note to self. The switch sits right there under the list and
says what it does, so inviting them is one deliberate click. Un-sending
is not.
2026-08-31 23:07:18 -07:00
Coffey Labs 16308431b8 Merge pull request #168 from Coffey-Labs/feat/create-appointment-from-email
Make an event out of a message
2026-08-31 22:57:04 -07:00
jcoffey-dev 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.
2026-08-31 22:52:59 -07:00
Coffey Labs 3517d48a90 Merge pull request #166 from Coffey-Labs/mobile-drawer-alignment-and-close
Hang every folder off one edge, and give the drawer a way out
2026-08-31 22:10:33 -07:00
jcoffey-dev 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.
2026-08-31 22:04:25 -07:00
Coffey Labs 20929ddc2c Merge pull request #165 from Coffey-Labs/mobile-touch-targets-drilldown
Folders one level at a time on a phone, and targets a thumb can hit
2026-08-31 21:51:15 -07:00
jcoffey-dev 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.
2026-08-31 21:47:30 -07:00
Coffey Labs 100f07b580 Merge pull request #164 from Coffey-Labs/fix-language-count
Ten languages, not nine: correct the count everywhere
2026-08-31 15:38:34 -07:00
jcoffey-dev ded2f4dc1b Ten languages, not nine: correct the count everywhere
The picker offers English plus nine translations. I wrote it up as nine
in total with eight unread, which is off by one in the direction that
undercounts the work and, worse, misstates how many catalogues are
waiting for a speaker to read them.

Nine of the ten are machine-made and unread — all of them, not all but
one — so the sentence that matters reads more sharply than the wrong
version did, not less.
2026-08-31 15:36:04 -07:00
Coffey Labs 874887eb36 Merge pull request #163 from Coffey-Labs/docs-i18n-2026-08-31
Document the nine languages, and what Beta means on them
2026-08-31 15:20:07 -07:00
jcoffey-dev c26ca90e01 Document the nine languages, and what Beta means on them
The translations shipped today and the docs still said "no languages but
English". They also need to say the harder thing, which is that eight of
the nine have never been read by anybody who speaks them.

- README gains the language list, with the Beta caveat in the same line
  rather than a footnote.
- FEATURES.md gains an Interface language section: the list, why it is a
  separate setting from the date locale, and the two design properties
  that follow — a missing entry renders English, and plurals are asked of
  Intl.PluralRules rather than assumed, which is why Russian carries
  three forms and Japanese one.
- ROADMAP.md no longer lists translations as "not yet". What replaces it
  is the half that is genuinely not done: a translation anybody has
  checked. RTL is split out as its own entry, because holding Arabic,
  Hebrew and Persian back is a layout decision and not a queue position.
- KNOWN-ISSUES.md gains two entries. The unread catalogues, which is the
  one thing on that page that cannot be closed by testing. And the
  coverage number that read 100% while two hundred strings rendered
  English in every language — recorded as a general lesson rather than an
  i18n one, since a coverage number measures what it can see and the rest
  is exactly what nobody is checking.
2026-08-31 15:18:01 -07:00
Coffey Labs 2380ed282e Merge pull request #162 from Coffey-Labs/i18n-first-paint-language
Keep a settings change made before the first read, and wait for it
2026-08-31 15:11:45 -07:00
jcoffey-dev 635c4c7e52 Keep a settings change made before the first read, and wait for it
Two defects on the path that decides what language the app starts in.

**A change made before the settings file came back was thrown away.**
`queueSettingsPush` returned early while unarmed, dropping the value
instead of holding it, so a language picked in the second or so after a
page load was never written up: it survived until the next reload and no
further. That is a better account of "sometimes it takes several clicks"
than the remount race fixed in #160 — the click that stuck was one made
after the read had finished. Keeping it is safe because hydrate already
refuses to overwrite a key that is still queued.

Proof it was real: before this, no `ihasmail` folder was ever created in
the account's files, because the seed write never fired. After it, the
folder appears.

**Without a cached settings object the tree painted too early.** The
cache is not read on an untrusted device, and it is cleared by the
sign-out that every deploy causes, so in both cases the first frame is
the defaults — and the defaults are English. Anything computed in that
window is computed in the wrong language. The interface recovers, since
it is rebuilt when the catalogue lands, but a string emitted once does
not: this is why the stale-folder toast came out in English on an
otherwise German screen.

So without a cache the authenticated tree now waits for the account's
settings and their catalogue, which costs nothing — there was nothing
worth painting yet. With a cache it does not wait, and the first frame is
as quick as it was.

Neither fix makes the toast German yet: the account settings file is
neither written nor read successfully in the mock, and both failures are
swallowed. That is a third problem, and this commit does not touch it.
2026-08-31 15:03:40 -07:00
Coffey Labs 67f5a88ff6 Merge pull request #161 from Coffey-Labs/i18n-second-extraction
Second extraction pass: the strings the codemod could not see
2026-08-31 14:46:36 -07:00
jcoffey-dev ba4d2105a4 Merge main: the list subject stays notranslate and gains its translated fallback
Both sides of the conflict belong. The span holds a subject, which is the
sender's words and not ours to machine-translate; the text shown when
there is no subject is ours, and should follow the interface language.
2026-08-31 14:44:30 -07:00
Coffey Labs 9b7f66a2c0 Merge pull request #160 from Coffey-Labs/i18n-switch-and-translate-prompt
Stop a language change undoing itself, and stop the translate prompt
2026-08-31 14:43:13 -07:00
jcoffey-dev 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.
2026-08-31 14:14:30 -07:00
jcoffey-dev 51dabd9cab Stop a language change undoing itself, and stop the translate prompt
Two reports, both about the language setting.

**Picking a language sometimes took several clicks.** The subtree that
reads the account's settings file is keyed on the language version, so
choosing a language deliberately throws it away and builds it again. The
remount re-read the settings file — which still held the old language,
because the write is debounced by three seconds — and applied it, putting
the old language back. The click that appeared to work was the one made
after the previous write had landed, which is exactly the "sometimes"
in the report.

Worse than it looked: the queued push survived the remount, so the file
was eventually written with the new language while the screen showed the
old one. A reload then changed the language on its own.

Fixed twice over, because either alone leaves a race. The file is read
once per account per page load rather than once per mount, and hydrate
now holds back any key with a change still queued — a change that has
not been written up is newer than the file by definition. That rule is
`mergeRemote`, pulled out as a pure function so it could be tested
without a JMAP client.

**Both browsers kept offering to translate an English page.** They were
right to: `<html lang>` said English while the visible text was 6,289
message rows of marketing copy and brand names in whatever language the
sender wrote in. The list is most of the text on the screen, so that is
what the detector was reading.

Sender, subject and preview in the list, and the thread subject and
sender name in the reader, are now marked as what they are — content,
not interface. Message bodies were already marked, so this is the same
line drawn in the places the earlier pass missed rather than a new one.
Whether it silences the prompt is Chrome's call and cannot be checked
from inside the page; the marking is right either way.
2026-08-31 13:45:18 -07:00
Coffey Labs a4f7d386a6 Merge pull request #159 from Coffey-Labs/i18n-untranslated-labels
Translate the labels the extractor could not see
2026-08-31 13:24:45 -07:00
jcoffey-dev 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.
2026-08-31 13:20:39 -07:00
Coffey Labs a440e54922 Merge pull request #158 from Coffey-Labs/i18n-japanese
Add a Japanese interface catalogue
2026-08-31 13:17:27 -07:00
jcoffey-dev 3ab2b02ad9 Merge main: keep all four Phase 2 languages in the list 2026-08-31 13:13:47 -07:00
Coffey Labs 94e387267b Merge pull request #157 from Coffey-Labs/i18n-chinese
Add a Simplified Chinese interface catalogue
2026-08-31 13:13:16 -07:00
jcoffey-dev 2626b7a333 Merge main: keep ru, uk and zh-Hans in the language list 2026-08-31 13:11:09 -07:00
Coffey Labs 5ee9b1f1b1 Merge pull request #156 from Coffey-Labs/i18n-ukrainian
Ukrainian, and not the Russian one with a different name on it
2026-08-31 13:10:56 -07:00
jcoffey-dev a2a339ce32 Merge main: keep both ru and uk in the language list 2026-08-31 13:08:46 -07:00
Coffey Labs 1eb31450a4 Merge pull request #155 from Coffey-Labs/i18n-russian
Russian, and the first real use of the plural machinery
2026-08-31 13:07:44 -07:00
jcoffey-dev 12f08acd32 Add a Japanese interface catalogue
781 strings, machine-made and marked Beta, on the same terms as the
languages before it: the report link stands in for the native speaker we
do not have, and a missing entry falls back to English.

Plurals: there are none. Intl.PluralRules("ja") returns `other` for every
number, so each counted string carries one form. Counters do the work a
plural would — 通 for messages, 件 for conversations and items — which is
why "{n} messages" and "{n} conversations" are separate entries rather
than one pattern. The number alone does not decide the word after it.

Register is です・ます throughout, with the pronoun dropped: where English
says "your mailbox" this file usually just says メールボックス. Buttons
are the bare noun or verb stem — 送信, 返信, 削除 — not a sentence, which
is what every other mail client the reader has used does.

Script mixing is deliberate. Kanji for the noun carrying the meaning,
katakana for the loanword the reader already knows (メール, フォルダー,
アーカイブ), hiragana between them. Long vowels keep their ー.

Verified against the mock server: role folders localise and custom ones
are untouched, dates follow the language, and "{used} of {total}" comes
out reordered as "2.0 GB 中 700 MB" rather than word-for-word.
2026-08-31 13:05:33 -07:00
jcoffey-dev 988e741b79 Add a Simplified Chinese interface catalogue
781 strings, machine-made and marked Beta, on the same terms as the five
Phase 1 languages: the report link stands in for the native speaker we
do not have, and a missing entry falls back to English, so deleting a bad
line is a valid fix.

Two things differ in kind from the European catalogues, and the file
header records both so a later editor does not undo them.

Plurals: there are none. Intl.PluralRules("zh-Hans") returns `other` for
every number, so each counted string carries one form. Supplying `one`,
`few` or `many` would be filling in a distinction the language does not
draw, and none of them would ever be selected.

Script: this is Simplified, and the tag says so. A Traditional catalogue
would be a separate file rather than a character conversion of this one —
the vocabulary differs as much as the script does (软件/軟體, 文件/檔案),
and converting characters alone produces text that is readable and
obviously foreign.

Verified against the mock server: role folders localise and custom ones
are left alone, dates and the calendar follow the language, and the
catalogue code-splits into its own 41 kB chunk.
2026-08-31 12:58:29 -07:00
jcoffey-dev 5737362621 Ukrainian, and not the Russian one with a different name on it
781 of 796 strings. Generated by AI, unreviewed, marked Beta.

The thing this catalogue had to avoid is the reason it took the work it did.
Ukrainian and Russian share a script and share a plural rule -- one, few,
many, with 11 counting as many and 21 counting as one -- and share almost
nothing else that matters in a mail client. «Вхідні» is not «Входящие»,
«Кошик» is not «Корзина», «Листування» is not «Цепочка». A Ukrainian
catalogue produced by adapting the Russian one would pass every structural
check in this repo and still be the wrong language, and a Ukrainian reader
would notice in the first sentence and would be right to resent it. The
vocabulary here was chosen against what Ukrainian software says, not against
the neighbouring file.

Two words worth naming: «тека» rather than «папка» for folder, which is the
form Ukrainian software settled on; and «мітка» for label rather than
Russian's «ярлык», which in Ukrainian means a shortcut and would be a small
false friend on every screen.

Plurals tested against the shipped catalogue at 1, 2, 4, 5, 11, 21 and 0, plus
the check that every counted string carries all four categories. A missing
"few" falls back to "other" silently and is grammatical often enough to go
unnoticed.

The cross-check that Ukrainian and Russian are actually different files is
worth having but cannot live here: Russian is still an open pull request, so
ru.ts does not exist on this branch. It belongs in a follow-up once both have
landed.
2026-08-31 12:44:53 -07:00
jcoffey-dev f9d521a412 Russian, and the first real use of the plural machinery
781 of 796 strings. Generated by AI, unreviewed, marked Beta.

This is the catalogue plural() was designed for. Russian needs three forms
where English has two, and the choice is not a question about the number 1:
1 is "one", 2-4 are "few", 5-20 are "many", 11-14 are "many" despite ending in
1-4, and 21 is "one" again. Intl.PluralRules knows all of that; a two-form
assumption would have shipped "5 письмо" and read as machine output however
good the vocabulary was.

Tested against the shipped catalogue rather than a fixture -- 1, 2, 3, 5, 11,
21, 22, 25 and 0 -- plus a check that every counted string carries all four
categories, because a missing "few" falls back to "other" silently and is
grammatical often enough to go unnoticed.

"Выбрано: {n}" for the selection count rather than an agreeing form: the
impersonal construction sidesteps agreement entirely and is what Russian
interfaces actually do there.

Register is "вы", lowercase. Capitalised «Вы» is correspondence style and
reads as a letter rather than as software, so it would be a small constant
wrongness on every screen. Most of the interface avoids the question anyway,
because Russian UI convention is the infinitive for actions.

«Письмо» rather than «сообщение» for a mail message, which is what Russian
mail clients call one; «сообщение» reads as a chat message. «Ярлык» for label,
Gmail's word in Russian -- a fifth answer to the same rule about using what the
reader will meet elsewhere.

A stray CJK character got typed into one Russian sentence during drafting and
was caught by sweeping the file for anything outside the expected scripts. Not
a mistake a spellcheck would find, and not one a reader would forgive.
2026-08-31 12:38:51 -07:00
Coffey Labs 8270843c03 Merge pull request #154 from Coffey-Labs/i18n-portuguese-br
Portuguese (Brazil), completing Phase 1
2026-08-31 12:21:07 -07:00
jcoffey-dev ce28e014d3 Merge main: keep both language entries 2026-08-31 12:21:02 -07:00
Coffey Labs 461f803f4c Merge pull request #153 from Coffey-Labs/i18n-spanish
Spanish, fourth of Phase 1
2026-08-31 12:20:30 -07:00
jcoffey-dev 70af64b126 Merge main: keep both language entries 2026-08-31 12:18:34 -07:00
Coffey Labs d75618401a Merge pull request #152 from Coffey-Labs/i18n-dutch
Dutch, third of Phase 1
2026-08-31 12:18:01 -07:00
jcoffey-dev fcbe268715 Merge main: keep both language entries 2026-08-31 12:16:11 -07:00
Coffey Labs 49e703b8bb Merge pull request #151 from Coffey-Labs/i18n-french
French, on the same terms as German
2026-08-31 12:14:42 -07:00
jcoffey-dev 4ecfbd25a5 Register Dutch, and catch a catalogue nobody can select
nl.ts shipped without an entry in UI_LANGUAGES, so the language was never
offered: the catalogue built, every test passed, the coverage check reported
98%, and the picker did not list it. The entry was added by a text replacement
anchored on the French line, which does not exist on a branch cut from main, so
the replacement was a silent no-op.

A catalogue and a picker entry are two halves of one thing and either half
alone is dead weight, so the checker now verifies both directions -- a
catalogue with no entry, and an entry with no catalogue. Reverting the
one-line fix makes it fail, which is the only way to know a check works.
2026-08-31 12:12:39 -07:00
jcoffey-dev 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.
2026-08-31 12:00:00 -07:00
jcoffey-dev 8d19108498 Spanish, fourth of Phase 1
781 of 796 strings; the fifteen left are product names, bare URLs and example
addresses. Generated by AI, unreviewed, marked Beta.

Register is "usted", following the other three. Spanish makes the decision
cheaper than they did: most of an interface is infinitives and nouns --
"Eliminar", "Configuración" -- where the question never arises. It only shows
in the sentences that address the reader directly, and those agree.

This is peninsular Spanish where the varieties diverge, chosen deliberately
rather than blended, because a blend reads worse than either. The file names
the differences that actually matter in a mail client -- "correo" over
"email", "ordenador" over "computadora" -- and notes that a Latin American
catalogue would be a copy of this one with those changed, not a fresh
translation. Worth writing down while the reasoning is fresh rather than
rediscovering it if es-419 is ever asked for.

"Etiqueta" for label, as in French: Gmail established it and a reader will
find it there. That is the third application of the same rule -- use what the
reader will meet elsewhere -- and the third different outcome, which is what
a rule looks like when it is doing work.
2026-08-31 11:55:12 -07:00
jcoffey-dev e9009f7aaf Dutch, third of Phase 1
781 of 796 strings; the fifteen left are product names, bare URLs and example
addresses. Generated by AI, unreviewed, marked Beta, with the report link doing
the job a native speaker would.

Register is "u", following "Sie" and "vous". This is the one of the three most
likely to be overturned, and the file says so: Dutch leans informal further and
faster than German or French, and "je" is what most consumer software now uses.
It is written down as a single consistent choice precisely so that changing it
is a find-and-replace rather than an argument. Where a string can dodge the
question it does, which is ordinary good Dutch UI.

"Postvak IN" rather than "Inbox", because that is what Outlook and Thunderbird
call it in Dutch. "Label" stays English as in German -- no Dutch client
translates it -- which is the same rule that made French use "Libellé": use
what the reader will meet elsewhere, rather than always translating or never.

The folder-context separator needed inserting by script again, and in the
opposite way to French: there it arrived as a raw control byte and had to be
escaped, here it did not arrive at all. Both are the same underlying awkwardness
-- U+0004 does not survive being written into a file by hand -- and the
catalogue checker caught it both times by reporting the keys as stale, which is
exactly the silent failure it exists for.
2026-08-31 11:50:45 -07:00
jcoffey-dev 53c38ed3c3 French, on the same terms as German
Second of Phase 1. Generated by AI, unreviewed, marked Beta, with the report
link in Settings doing the job a native speaker would otherwise do. 781 of 796
strings; the fifteen left are product names, bare URLs and example addresses,
which should stay English.

Register is "vous", following the "Sie" decision and for the same reason: a
mail client a workplace deployed has no business addressing anybody as "tu".

One terminology decision goes the opposite way to German, deliberately.
"Label" stays English in German because no German client translates it, and
becomes "Libellé" in French because Gmail did and a French reader will meet it
there. The rule being followed is "use what the reader will find elsewhere",
not "always translate" or "never" -- which only looks inconsistent if the rule
is mistaken for the outcome.

French typography: the narrow no-break space before ? ! and : is the rule and
is deliberately not used. It is invisible in a diff, trivially lost in an
editor, and no French webmail actually ships it. Guillemets are used, because
those are visible and do read as wrong when missing.

Two things worth recording from doing this a second time.

The folder-context separator arrived as a raw U+0004 byte rather than the
\\u0004 escape the German file uses. It would have worked -- TypeScript accepts
it -- and it is invisible in an editor and in a diff, which is exactly why the
German catalogue writes it as an escape. Converted, so both files say the same
thing in the same way.

And the language tests named a real language as their example of one that is
not shipped, so shipping German broke them, and shipping French broke them
again. Both times the failure was the test being out of date rather than
anything wrong. They derive an unshipped tag now, and assert over every shipped
language rather than a hardcoded pair, so the third and fourth languages will
not repeat it.
2026-08-31 11:44:40 -07:00
Coffey Labs 8cbc04e730 Merge pull request #150 from Coffey-Labs/i18n-folder-names
Folder names follow the language, and three bugs that found
2026-08-31 11:27:29 -07:00
jcoffey-dev 71dd2e108f Folder names follow the language, and three bugs that found
Answering "can we ask Stalwart to serve German folder names": no, and it would
not help if we could. The account locale exists in `x:AccountSettings`, and
ihasmail already reads it -- that is what "Your mail server reports German"
comes from -- but writing it needs `sysAccountSettingsSet`, which the built-in
user role does not carry; only an admin could. And even then it would change
nothing, because folder names are stored data written once when the account is
provisioned. No server renames them afterwards; every other client has them
mapped.

The role is the way through. JMAP tags the standard folders and ihasmail
already trusts the role over the name everywhere it matters, so the *displayed*
name can follow the interface language with nothing written to the server. A
folder somebody made and called "Newsletters" keeps that name: those are their
words, and translating them would name a folder they never created.

The cost is real and worth stating: Thunderbird on the same account still shows
"Deleted Items", because that is what the folder is called. Inside ihasmail it
stays consistent -- everything that names a folder goes through one function,
including the "moved to …" toast, which exists precisely so that message does
not name somewhere the reader cannot find. Renaming still edits the server's
own name, never the localised one.

Three things fell out of it.

The message list refreshed for ever after a language change, which is the one
somebody noticed. The root keys its tree on the language version, so a publish
remounts everything; remounting re-runs the effect that loads the account's
settings, which calls applyLang, which called setCatalog again -- with an
identical tag and an identical catalogue -- and publishing that non-change went
round again. setCatalog now returns early when nothing changed. Measured
rather than assumed: three consecutive five-second windows with no JMAP calls
at all, against a pre-change count that never settled.

Calendar months and weekdays stayed English, because formatting locale and
interface language are separate settings and only the first feeds Intl.
Keeping them separate is right -- German dates with an English interface is a
real preference -- but somebody who picks German and is shown "September" has
not got what they asked for. A chosen interface language now joins the
*automatic* chain ahead of the server and the browser. Setting a formatting
locale explicitly still wins, and English is not counted, so an English
interface on a German browser keeps German dates exactly as before.

And the Archive folder read "Archivieren", which is the verb. English uses one
word for the button and the folder; German does not, and neither does
"Important", which is also a priority tag. tc(context, source) keys the
catalogue on both and falls back to the plain English, which was right in
English all along -- the gettext approach, including the control character as
separator so no real string can collide.

The catalogue checker needed teaching about tc() twice: first it reported the
eight contextual entries as stale, then it asked for the plain fallbacks as
though they were a second obligation. A check that reports work which does not
exist gets switched off, which is worse than not having one.
2026-08-31 11:24:24 -07:00
jcoffey-dev 87383440bb German, generated by AI and marked Beta until somebody signs it off
The first language, and the first one where the honest thing to say is not
flattering: no native speaker has read it. That is stated in the app rather
than in a commit nobody reads, because it is the fact a reader needs to judge
what they are looking at. Somebody told a translation is unchecked forgives an
odd sentence and reports it; somebody told it was reviewed reasonably concludes
the product is sloppy. The setting carries a link straight to a report, which
is the whole review process here.

`beta` is a property of the language, not of the catalogue's completeness. A
file can be word-for-word finished and still read like a machine wrote it, and
that is what the flag marks. Removing it is a person's decision.

Register is "Sie", consistently, and written down in the file so the next
language and the next contributor inherit the decision rather than re-take it.
Thunderbird and Outlook use it; ihasmail is as often a company's mail as
somebody's own, where "du" from software the workplace deployed reads as
presumptuous. Where a string can dodge the question it does, which is ordinary
good German UI. The glossary at the top of the file fixes the vocabulary once
-- Posteingang, Papierkorb, Entwürfe, archivieren -- because inconsistency
reads as amateur far more than an imperfect word choice does. "Label" and
"Spam" stay English, since translating them would name things no German mail
client calls that.

766 of 781 strings. The fifteen left are product names, bare URLs and example
addresses, which should stay English and now do.

Two things this turned up that the earlier work had hidden:

Labels defined as module-level constants -- the entire settings navigation,
the theme cards, the swipe choices, the date formats, the sharing permissions
-- are evaluated once, before any catalogue loads, so they could only ever be
English. Nothing failed; the German build simply had an English sidebar. They
are translated where they render now, which keeps the constant as data and
makes its English text the key.

And the codemod's narrowed rule, which let it take 73 more strings last time,
was too broad after all: text stranded after an inline <a> or <strong> came
through as sentence fragments -- ", and what a new account starts on." Eight
of them, rebuilt with tNode so the sentence stays whole and the element is a
named hole a translator can move.

scripts/i18n-catalog-check.mjs is new and earned itself immediately: it found
three keys invented that the code never asks for, which is the silent failure
in a catalogue -- a translation that looks right, is never looked up, and
renders English for ever. It also had to be taught about t(variable), because
it cried wolf 33 times over the constants above, and a check that cries wolf
gets switched off.

Verified in the browser rather than only in tests, which is where the settings
sidebar being English was visible and nowhere else.
2026-08-31 11:06:54 -07:00
Coffey Labs d0cbfc7870 Merge pull request #148 from Coffey-Labs/i18n-extract-batch2
Finish extraction: 100%, and a coverage number worth believing
2026-08-31 10:45:19 -07:00
jcoffey-dev 3f4b33cb51 Finish extraction: 100%, and a coverage number worth believing
The 143 the codemod refused turned out to be two different things, and only
one of them needed a person.

A third were phrases sitting next to an icon -- `<Plus /> New rule`. The
refusal rule was "has siblings", which is broader than the danger: what breaks
a translation is a sibling that renders *text*, splitting a sentence into
fragments no one can reorder. An element beside a phrase does not. Narrowing
the rule to text-producing siblings let the codemod take 73 more.

The rest were real sentences with values in the middle, rebuilt by hand as
named placeholders -- "Your active script “{name}” was written by hand",
"Waiting on the server — goes out {when}." Named rather than positional
because a translator moves the parts around; counted things go through
plural() so Russian and Ukrainian get their three forms rather than English's
two.

Sentences with an element inside them needed something new. `Open <code>mailto:
</code> links in ihasmail` has two obvious treatments and both are wrong:
splitting it into two t() calls hands over fragments that cannot be reordered,
and dropping the <code> keeps the sentence whole but loses the monospace that
said "this is a literal". tNode() keeps the sentence whole and makes the
element a named hole in it, so a translator sees one sentence and can put the
hole where their language wants it. The German test asserts exactly that: the
same call renders the code first when the catalogue says so.

The coverage number was also lying, and it is worth saying how. It counted
text inside <code> and inside translate="no" as untranslated work, and
placeholders like "123456" and "+1 555 0100" -- a one-time code and a phone
format. None of those will ever be translated, so the report sat at 21 with 6
real items left. A number with an unreachable floor is something to argue with
rather than act on, so the tool now applies the same rules the codemod does.

596 wrapped, nothing remaining. Verified in the browser across 15 views, which
is where the last bulk pass hid a bug the tests could not see: no entities, no
unfilled placeholders, no raw t( in rendered text, and the toggle switches that
looked like emptied labels are text-free by design.
2026-08-31 10:41:24 -07:00
Coffey Labs 46c1dc28e3 Merge pull request #147 from Coffey-Labs/i18n-extract-batch1
Extract 515 strings by codemod
2026-08-31 10:02:15 -07:00
jcoffey-dev 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 &amp; 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 &amp; 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.
2026-08-31 09:58:33 -07:00
jcoffey-dev 95dcb96086 Start extraction: an i18n core, and a way to see how far it has got
The groundwork in #145 gave the app a language to serve. This gives it
something to serve, and a way to measure the distance to the languages
actually planned.

The English text is the key. `t("Archive")` looks "Archive" up and returns the
English when it is not there, which buys three things worth more than tidy
symbolic keys: no English catalogue to keep in step with the code, a missing
translation that degrades to readable English rather than to
`mail.list.archive`, and an extraction step that is wrapping a string rather
than inventing a name for it. Names are where extraction stalls, and 55
components is a lot of small naming arguments. The cost is that editing English
copy orphans its translations, which is the right way round: the copy is the
product, and a stale German sentence should fall back to the new English.

`plural()` takes forms rather than (one, other), because two forms is an
English assumption that does not survive phase two of the plan. Russian and
Ukrainian need three, and choosing between them is not a question about the
number 1. Intl.PluralRules knows the rule for every language the browser knows,
so the catalogue supplies the forms and the runtime picks; a category the
catalogue does not carry falls back to `other` rather than rendering undefined.
Interpolation is named rather than positional for the same reason -- German
moves the parts of a sentence around and means the same thing.

Catalogues are dynamically imported, so a reader who never leaves English never
downloads one, and English needs no fetch at all. `applyLang` sets the lang
attribute before kicking the load, deliberately: lang is what stops Chrome
offering to translate and should not wait on a network request to say something
it already knows.

`t()` is a plain function, not a hook, so the tree is keyed on a language
version at the root and thrown away when the catalogue changes. Making every
call site a subscriber would turn extracting a string from "wrap it" into "wrap
it and add a hook", for an event that happens about once per account.

NotificationsSettings is extracted end to end as the reference -- it covers all
four shapes, being JSX text, translated attributes, a toast, and a sentence
with a value interpolated into it.

scripts/i18n-coverage.mjs counts what is left, because ~1,000 strings across 56
files is too many to eyeball in review or carry in anyone's head. It reports 20
wrapped and 925 remaining, and it deliberately does not count punctuation and
separators as untranslated -- a floor no amount of work could reach would make
the number useless. A progress report rather than a gate: --check exits
non-zero, for once the number is low enough for that to mean something.

ROADMAP.md said translations were "English-only for now" on a page whose stated
purpose is things the answer is "no" to. It now says what is actually happening,
carries the phase order, and says why Arabic, Hebrew and Persian are on neither
list: RTL is a layout and bidi problem rather than a longer catalogue, and
shipping it as though it were the same kind of work is how an RTL build ends up
unusable with nobody saying so.
2026-08-31 09:44:49 -07:00
Coffey Labs 90ed579876 Merge pull request #145 from Coffey-Labs/translate-resilience
Defend against Chrome rewriting the DOM, and add the language setting
2026-08-31 09:29:30 -07:00
jcoffey-dev be1d787b5f Defend against Chrome rewriting the DOM, and add the language setting
Groundwork for un-shelving translations. Chrome's translator rewrites the
rendered DOM directly, wrapping text nodes in <font> elements React has never
heard of, and the next update can then call removeChild against a parent whose
children have moved (facebook/react#11538). This is the structural defence
against that, plus the setting the served language will read from.

The language setting is `uiLanguage`, and it is deliberately not the `locale`
field that already exists. That one is a formatting choice -- what calendar,
clock and numerals to use -- and folding the two together would silently
rewrite everybody's date format the first time they picked a language. German
dates with an English interface is a real preference, and so is the reverse.
It defaults to English when absent, which covers both a new account and every
settings file written before this, and Accept-Language is not consulted: a
served locale should be something the reader chose rather than something
guessed and then written down as though they had. Only languages with strings
shipped are offered, which today means English alone -- a picker entry without
a catalogue behind it would leave the page claiming a language it is not in,
which stops a reader translating a page they cannot read.

`<html lang>` is set where applyTheme is set: at store module load, from the
localStorage cache, before createRoot() has rendered anything. Not in an
effect -- a lang that is briefly wrong is enough to raise the translate prompt
on a page that needed none. There is no server-rendered alternative to reach
for here: ihasmail serves a static shell and holds no account state, and the
settings file lives in the reader's own JMAP Files, so reading it before the
page existed would mean authenticating to Stalwart on every page load. The
static lang="en" in index.html covers the first bytes; the store only ever
corrects a reader who chose otherwise. Both halves are tested.

translate="no" and class="notranslate" go on the narrow boundaries only:
rendered email bodies, raw message source, attachment text, the generated and
hand-edited Sieve, the brand and the login name. Not on <body> -- someone
whose language ihasmail does not speak yet should still be able to translate
the parts that are ours. Email bodies turn out to live in a shadow root, so
React never reconciles them and they were never a crash risk; the marker there
is about not rewriting what a sender actually wrote.

Twenty-four fragile interpolation points were found with the TypeScript
parser rather than grep, and fifteen refactored. Pluralisation and
"count + label" pairs are collapsed into a single expression so the text is a
lone child React updates with textContent, rather than a text node with
conditional siblings to insert around. One of them -- InviteCard's
{method === "REPLY" && organizer ? "" : ""} -- rendered an empty string either
way and is simply gone.

The boundary is scoped to the main content, so the header, folder tree and any
open composer sit outside it and survive independently. It recovers by
remounting the subtree, which costs nothing because everything inside
re-derives from the stores, and it logs at info rather than error: a reader
translating a page is expected and recovered from, and filing it as an error
would put an entry in every console-reading reporter for behaviour that
worked. It re-raises anything that is not a DOM mutation error, so a real bug
still surfaces as one, and it gives up after three attempts rather than
looping invisibly.

Worth recording: the crash could not be reproduced on React 19.2.8. Wrapping
207-249 React-managed text nodes in <font>, exactly as the translator does,
then driving in-place conditional toggles and navigations, left the app intact
with the boundary never firing. The original issue is from React 16 and the
reconciler has changed a great deal since. So this lands as defence whose
premise is weaker than assumed rather than as a fix for something observed
here, and the boundary is insurance rather than a load-bearing part. The
notranslate markers and the collapsed interpolations stand on their own merits
either way.
2026-08-31 09:14:41 -07:00
Coffey Labs 35c6060ceb Merge pull request #144 from Coffey-Labs/push-survive-deploy
Keep push alive across a deploy, not just across a week
2026-08-31 08:24:25 -07:00
jcoffey-dev d255c20215 Keep push alive across a deploy, not just across a week
#143 added a device-local flag recording that background notifications were
switched on in this browser, and made the renewal on app start key off it. It
is not in KEEP_ON_SIGN_OUT, and that is the whole bug: clearSignedInData()
runs on two different endings and only one of them is a sign-out.

The other is a session expiring, which is what a deploy does to every signed-in
browser at once. That path deliberately does not remove the push subscription
-- there is no session left to remove it with -- so the subscription stays
registered at Stalwart and the browser keeps its own. Losing the flag there
left nothing to renew them: push would have gone quiet a week after every
deploy, with the switch in Settings still reading as on because both ends of
the subscription still existed. That is the exact failure #143 was written to
prevent, reintroduced through a different door, and the first deploy carrying
#143 would have been the thing that triggered it.

Signing out for real still forgets it. That happens directly in
unsubscribeThisDevice, next to destroying the subscription, and it happens even
when the server cannot be reached -- a browser that goes on believing it has
push would have renewal resurrect it on the next sign-in.

Both halves are tested now, because they are one invariant seen from two sides:
storage.test.ts covers the flag surviving an expiry, webpush.test.ts covers a
sign-out clearing it with the server unreachable.
2026-08-31 08:20:31 -07:00
Coffey Labs 2bf3137484 Merge pull request #143 from Coffey-Labs/push-renewal
Renew the push subscription, so it does not lapse in a week
2026-08-31 08:14:32 -07:00
jcoffey-dev 562cee82ce Renew the push subscription, so it does not lapse in a week
Background notifications were built, verified against a live server, and then
went quiet a few days later on every device that had them. A JMAP push
subscription expires -- seven days is the ceiling -- and re-registering before
it lapses is the client's job. Nothing did: enableWebPush() was reachable only
from the switch in Settings, so the subscription was registered once, expired,
and stayed expired. Nobody reports that as a bug. They report that push does
not really work.

It is renewed on every app start now, which is the only place it can be: the
registration is a JMAP call and the service worker has no session cookie to
make one with. So the guarantee is that push keeps working as long as ihasmail
is opened now and again, and a two-day renewal window against a seven-day
ceiling means once a week is enough. Registering is the same call as turning it
on -- deviceClientId makes a repeat replace rather than accumulate -- so there
is no second path to get wrong.

Two more things in the same area, both of which produce the same silence:

- webPushActive() asked whether the *account* had any subscription, so the
  moment one device had one, every other device showed the switch already on.
  A phone that had never successfully registered, or whose registration had
  since expired, read as on and delivered nothing. It matches on the device now.
- Turning push on reused an existing browser subscription and gave up if there
  was none. A browser drops or rotates one on its own, and there is no tab open
  to hear the pushsubscriptionchange when it does, so that state was permanent.
  Renewal re-subscribes rather than bailing.

Whether this browser has push on is now remembered locally, which is what
renewal keys off. It is per browser rather than per account on purpose: a
subscription is an endpoint and a device, and a phone having push says nothing
about the desktop. It is not kept across sign-out, matching sign-out already
destroying the subscription itself.

The mock is the reason this was invisible in development: it handed back
expires: null, so a client that never renewed worked perfectly against it
forever. It expires a subscription in seven days now, which is what makes
"does this client renew?" a question the mock can answer.

Checked against the mock: a create returns an expiry seven days out that
survives PushSubscription/get and parses, renewing the same deviceClientId
replaces rather than accumulates, and a device with no registration of its own
finds nothing where the old code saw two subscriptions and said yes. What the
live Stalwart sets for expires is not confirmed -- if it sets none, renewal
correctly does nothing and the other two fixes still stand.
2026-08-31 08:03:43 -07:00
Coffey Labs e17d109ece Merge pull request #142 from Coffey-Labs/features-touch-gestures
Write down what the mail list does under a finger
2026-08-31 07:51:28 -07:00
jcoffey-dev 42dfdc5a44 Write down what the mail list does under a finger
FEATURES.md and the touch gestures were written at the same time on separate
branches, so the inventory of everything ihasmail does landed knowing about
none of them: the mobile layout was still one line about a tab bar, and the
Appearance row of the settings table did not mention the two settings that
had just been added to it.

The gestures get a section of their own under Layout rather than a bullet,
because the interesting part is not the list of five -- it is why they are
gated on `(pointer: coarse)` rather than on width, why a direction with no
meaning in this folder refuses to move rather than moving and doing nothing,
and why the axis lock gives a diagonal drag to the scroller. The same
reasoning is in the code; this is where somebody reads it without opening
lib/touch.ts.

Also here: the `dvh` and safe-area notes alongside the mobile layout, a long
press added to the list's multi-select bullet, and a short note in the
settings-sync section on why the swipe actions follow the account despite
looking like a per-device setting.
2026-08-31 07:03:29 -07:00
Coffey Labs c987ecccf3 Merge pull request #141 from Coffey-Labs/mobile-gestures
Give the mail list the gestures a phone already has
2026-08-31 06:57:26 -07:00
jcoffey-dev b2769b9011 Give the mail list the gestures a phone already has
ihasmail's mail list was built for a mouse. A row is clicked, right-clicked
and dragged into a folder, and on a touchscreen two of those three do not
exist -- so the phone layout had the shape of a mail app and none of the
handling, and the things people reach for first simply did nothing.

Four gestures, all touch-only, so a mouse keeps drag-to-folder unchanged:

- Swipe a row sideways to act on it. Each direction is a setting -- right
  archives and left deletes by default, matching the app the phone came
  with -- and the strip revealed behind the row names what will happen in
  the folder it is happening in: "Delete forever" out of Deleted Items,
  "Not spam" inside Junk Mail, and nothing at all where the action is a
  no-op, in which case the row will not move that way.
- Hold a row to select it. Selection was reachable already, by aiming at a
  checkbox beside an avatar, which is not how anyone selects mail on a
  phone. The selection toolbar gained an overflow menu at the same time:
  report spam, mark unread and label were hidden on narrow screens and had
  nowhere else to be, so touch selection could not reach them at all.
- Hold a folder for the menu its ⋮ button opens.
- Pull the list down to refresh, and drag in from the left edge of a
  conversation to go back. The toolbar's button and arrow both stay: a
  gesture with no visible control is one only the people who already know
  about it can use.

The arithmetic behind them is in lib/touch.ts, away from the components and
under test, because the numbers are the whole thing: an axis lock biased
towards the vertical, so a diagonal flick stays a scroll rather than
deleting whatever it passes over.

Two layout bugs turned up while checking this on a 390px screen, both
older than the gestures. The app shell is a grid with only its rows named,
so it took an implicit auto column sized to the top bar's min-content --
about 470px -- and every message row ran off the right of the glass with
its date beyond the edge. The column is now stated as minmax(0, 1fr), and
the search field is allowed to shrink. Full-screen surfaces measure in dvh
rather than vh, and the tab bar, drawer and compose button keep out from
under the notch and the home indicator.
2026-08-31 06:52:25 -07:00
Coffey Labs c1c448b94d Merge pull request #140 from Coffey-Labs/features-inventory
Write down everything ihasmail does
2026-08-30 23:05:10 -07:00
jcoffey-dev f736bf0c34 Write down everything ihasmail does
The feature list lived in three places that each answered a different question: the site sells it, the docs teach the parts that surprise people, and the README summarises both in a paragraph. Nothing said, in one place and at full detail, what is actually built -- so evaluating ihasmail meant reading the source, and a feature that quietly stopped working had nowhere to be contradicted.

FEATURES.md is that inventory, written from the code rather than the copy: the capability matrix and what each missing one removes, the search operators as the parser actually reads them, the Sieve tests and actions, every settings section and which of them follow the account, the shortcut set, the security posture, and the environment. Where a behaviour is odd it says why, because the reason is usually the stateless constraint.

It also records what the docs had not caught up with: single-occurrence edit and delete are built, and the id renumbering that made them hard is described where someone changing that code will find it.
2026-08-30 23:02:14 -07:00
Coffey Labs 0fb504748d Merge pull request #139 from Coffey-Labs/immutable-by-default
Deploy immutably by default
2026-08-30 22:33:15 -07:00
jcoffey-dev 34e37e1786 Deploy immutably by default
Forgetting IHASMAIL_IMMUTABLE handed back a writable container with a
volume mounted, quietly, and then reported healthy. Nothing in the output
said the immutability had gone -- `docker inspect` was the only place it
showed, and only if you thought to look.

That is the wrong way round for a posture the project leads with. The
safe one is now what you get by default and giving it up is the half that
has to be deliberate.

Found by deploying prod: the running container had IMMUTABLE=1 and a
read-only root, and reproducing that took passing the variable by hand
because the script's default would have taken it away.
2026-08-30 22:26:21 -07:00
Coffey Labs 806e63d071 Merge pull request #138 from Coffey-Labs/mock-moved-occurrence
Mock: let an override move an occurrence, as the server does
2026-08-30 21:55:10 -07:00
jcoffey-dev 06943fd473 Mock: let an override move an occurrence, as the server does
Confirmed live on 0.16.20 (2026-08-31): one occurrence of a weekly 09:00
series moved to 14:00 comes back with `start` at 14:00 and
`recurrenceId` still at 09:00. The slot the rule made stays put; only the
clock time moves.

The mock set `start` from the slot after merging the override, so it
clobbered any `start` the override carried and a moved occurrence did not
move. Per-occurrence *time* editing - one of the main things the feature
is for - therefore looked broken against the mock and correct against the
server, which is the wrong way round for a mock to be wrong.

It also confirms the choice of handle: `recurrenceId` is the one name for
an instance that survives both a renumbering and a move, which is why the
store re-resolves from it rather than from `start` or a cached id.
2026-08-30 21:49:53 -07:00
Coffey Labs 362bd8b282 Merge pull request #137 from Coffey-Labs/calendar-per-occurrence
Calendar: edit and delete a single occurrence
2026-08-30 21:45:15 -07:00
jcoffey-dev 91481965bc Calendar: never mutate an occurrence by an id we are holding
Verified against the live 0.16.20 instance, which found two things the
mock had guessed wrong about.

A synthetic id encodes a position in the expanded series, and writing a
`recurrenceOverrides` entry renumbers it. A five-week series came back as
`e i m q u` over 03-01..03-29; after one override was written to 03-08
the same five ids addressed 03-01, 03-15, 03-29, 03-08 and 03-22. Nothing
was rejected. A stale id is not invalid, it is wrong - a confident answer
about the wrong day - so a delete meant for one occurrence removes
another.

`recurrenceId` is the stable name for a slot in a series, because it is
the date. `updateEvent` and `destroyEvent` now look the current id up by
it immediately before acting, and refuse outright when the date has left
the series rather than falling back to the id in hand.

The mock had this exactly backwards: it kept ids stable on purpose, which
agreed with the belief that is wrong. It now renumbers too - a different
permutation to Stalwart's, with the property that matters - and a test
holds an id across a write and watches it change meaning.

Second finding: the inherited properties are dropped *after* the server
has decided to write an override, so a patch made only of them still
writes one, carrying the server-filled start and duration and nothing
else. `{"privacy":"private"}` on one occurrence answered "updated", left
privacy untouched, and left that date with no title at all. Sending
nothing when narrowing empties a patch was written as a principle - a
request whose response could only be a meaningless "updated" is worse
than no request - and it turns out to prevent real data loss.

Both recorded in KNOWN-ISSUES with the dates they were confirmed on.
2026-08-30 21:39:30 -07:00
jcoffey-dev 5ced44ec13 Calendar: drop the per-event colour picker, which categories replaced
A category carries a colour. Offering a separate colour picker beside it
made two ways to say the same thing, and they could disagree: an explicit
colour wins over the category's in `eventColor`, so an event could be
filed under Work and drawn in the Travel colour with nothing on the menu
explaining why.

Categories are the one that carries meaning, so the swatch grid goes and
picking a category is how an event gets a colour.

Clearing an explicit colour stays, and only appears when there is one to
clear. An event that already has one - set before this, or by another
client, or by CalDAV - would otherwise ignore its category for ever with
no way to fix it from here. Same reasoning as leaving "Stop sharing" on a
folder whose share nobody can see: the escape hatch is worth most exactly
when the thing it undoes is invisible.

Nothing reads differently for events without an explicit colour, and
`CALENDAR_COLORS` is untouched - categories, labels and mailboxes all
still pick from it.
2026-08-30 21:35:01 -07:00
jcoffey-dev dd8998f178 Calendar: edit and delete a single occurrence
Closes #132.

Stalwart 0.16.20 accepts a synthetic id on `CalendarEvent/set`, writing a
`recurrenceOverrides` entry rather than touching the series, so editing
one date of a recurring event is now something the server does and this
does too.

Editing asks the scope *before* the form opens, because it decides which
event the form is even about: a form populated from the master shows the
series' start date, so editing Wednesday's standup would have offered to
move Monday's. Deleting asks in place of the old confirm.

The patch is narrowed rather than posted hopefully. 0.16.20 sorts
per-occurrence properties into three groups and only one is honest: ten
are refused with `invalidProperties`, twelve more are dropped from the
patch while the response still reports success, and the rest are applied.
That silent middle group is how #26 reached a live server - a successful
response is not evidence anything was written - so `occurrencePatch`
throws on the first group, reports the second to the caller, and the
editor leaves out the five it always sends. A patch that would be
entirely dropped is not sent at all.

The refusal for an occurrence of a this-and-future change offers the
series instead of a bare error toast. Nothing here writes one of those,
but an event synced from another client can carry one.

Two things the scope prompt cost, both worth knowing. A dialog is queued
in a store the moment it is asked for, so it outlives the effect that
asked: without a ref guard a remount queues a second prompt the first
answer cannot retract. And gating the *answer* on the effect's cleanup
flag is worse - StrictMode runs mount, cleanup, mount, so the flag is
already set by the time anyone clicks and the editor never opens.

The mock expands recurrences for the first time, which is what makes any
of this developable. It hands out synthetic ids for everything including
one-offs, gives occurrences a `recurrenceId` and no rule, and reproduces
the refusals - including the silent drops, since a mock that applied them
would let a client that sends them look correct everywhere but a real
server.
2026-08-30 21:35:01 -07:00
Coffey Labs 6ec2304fc2 Merge pull request #135 from Coffey-Labs/calendar-event-scope
Resolve the base event id in the calendar store, not at the call sites
2026-08-30 21:29:59 -07:00
Coffey Labs d6f9ef1243 Merge pull request #136 from Coffey-Labs/version-tense
Stalwart has not reached 1.0 yet
2026-08-30 21:28:08 -07:00
jcoffey-dev b0cb73a924 Stalwart has not reached 1.0 yet
Both the versioning comment and the README described 1.0 in the past
tense, which reads as though it has shipped and the numbering was
changed in response. It has not. The old `2.16.x` scheme was dropped
over where it would end up, not where it ended up, and the surrounding
conditionals are simplified to match: "would sort", "would read", rather
than "would have".

The badge quoted in the comment also said 0.16.19; it says 0.16.20 now.

Comments and prose only -- no behaviour changes.
2026-08-30 21:22:07 -07:00
jcoffey-dev 373822c2ae Resolve the base event id in the calendar store, not at the call sites
Closes #133.

`updateEvent`, `destroyEvent` and `rsvp` took an id and sent it. The
`baseEventId ?? id` that made them hit the series lived at four call
sites instead, and every one of them happened to be right.

That was backstopped by the server until now. Through 0.16.19 a synthetic
id reaching `destroy` came back as "Deleting synthetic ids is not yet
supported" and the user saw a toast. 0.16.20 accepts it and removes one
date instead, reporting success under a dialog that said "Delete all
occurrences?" - so a forgotten `??` became silent data loss rather than
an error.

The three methods now take the event and a required `scope`, and there is
exactly one place that turns an event into an id. A caller that wants the
series cannot get an occurrence by forgetting anything; a caller that
wants one occurrence has to say so.

`rsvp` takes the event rather than an id for the same reason, and no
longer looks it up: its patch is `participants/{key}/participationStatus`,
which is one of the pointers 0.16.20 *allows* on an occurrence, so aimed
at an instance it would quietly mean "only that day".

`findByUid` says in a comment that its query omits `expandRecurrences` on
purpose, since InviteCard hands the result straight to `destroyEvent`.
2026-08-30 21:06:56 -07:00
Coffey Labs 12157f47bf Merge pull request #134 from Coffey-Labs/stalwart-0.16.20
Stalwart 0.16.20 on the live instance
2026-08-30 21:02:22 -07:00
jcoffey-dev e41742a26c Stalwart 0.16.20 on the live instance
INBUXA moved 0.16.19 -> 0.16.20 on 2026-08-31 with eight seconds of
downtime. A 0.16.x -> 0.16.x upgrade is a binary replacement: no data
migration, no config change.

Nothing ihasmail depends on changed. The session capabilities, blob,
quota, submission and registry paths are untouched by the release, and
`urn:stalwart:jmap` is still absent at session level, so the three-place
lookup that sign-in turns on remains both correct and necessary. The
Locale enum did move from POSIX names to BCP-47 (`en_US` -> `en-US`,
`POSIX` dropped), which `normalizeLocale` already handled.

The recurrence entry is rewritten rather than deleted. 0.16.20 added
`CalendarEvent/set` support for synthetic ids, so per-occurrence editing
is a thing the server allows and ihasmail does not do yet (#132) - and
the refusal that used to catch a synthetic id reaching `destroy` is gone,
which is why the base-id resolution wants moving into the store (#133).

Dates on the existing entries are left at 0.16.19 on purpose: they record
what was actually run, and the upgrade was read from the diff, not re-run.
2026-08-30 20:58:52 -07:00
Coffey Labs 1e01b34384 Merge pull request #131 from Coffey-Labs/github-org-coffey-labs
Point at the Coffey-Labs organisation
2026-08-30 15:26:58 -07:00
jcoffey-dev 95f640b24c Point at the Coffey-Labs organisation
The repositories moved off LINUXexpert-org. The old URLs redirect, so
nothing was broken, but a redirect is not a correct address to publish.

The SOURCE_URL defaults matter most: the AGPL asks whoever runs a
modified version to offer that version's source, and the sign-in page and
About screen show this link. It is in four places that have to agree --
the compose file, .env.example, the server default and the web fallback.

The rest is documentation and issue links.
2026-08-30 15:17:43 -07:00
Coffey Labs 826a13ab39 Merge pull request #130 from LINUXexpert-org/calver-decouple-from-stalwart
Stop borrowing Stalwart's version number
2026-08-30 14:42:02 -07:00
jcoffey-dev 2f55b1e3e1 Stop borrowing Stalwart's version number
The middle field was the Stalwart generation a build targeted -- 16 for
0.16 -- which leaves nowhere to go when Stalwart reaches 1.0. There is no
honest value for it: 2.1 sorts below the 2.16 already deployed, so every
image and About screen would have read as a downgrade. Tying our
numbering to somebody else's was the mistake, and which Stalwart a build
needs is said properly in the README badge and KNOWN-ISSUES, where it can
be precise rather than one digit.

The version is now the date of the commit it was built from, and the pull
request moves after the + as build metadata. It is provenance rather than
a rank: at the rate they merge here it climbs without bound and says
nothing about how new a build is. Everything after the + is ignored when
versions are compared, which reads correctly -- two builds from the same
day differ in where they came from, not in age -- and nothing depends on
that comparison anyway, since images are pruned by creation time and a
rollback names a git ref.

The date is the commit's own, so rebuilding an old commit gives the
version it had the first time. package.json is no longer the source of
anything and sits at 0.0.0, which is what an unversioned build reports
and is meant to look wrong.

The formatting is a pure function now, so the rules have tests. They had
none while the version was the thing naming every image we ship.
2026-08-30 14:38:07 -07:00
Coffey Labs d75e9bc769 Merge pull request #129 from LINUXexpert-org/link-project-site-v2
Link the project site from inside the app
2026-08-30 14:19:21 -07:00
jcoffey-dev 08fd08e6fe Link the project site from inside the app
ihasmail.org was linked only from the login screen footer -- a page a signed-in
user sees once and then never again. From inside the app there was no way back
to the project site at all; Documentation went to docs.ihasmail.org and that
was the whole of it.

"About ihasmail" now sits under Documentation in the account menu, where
somebody looking for what this thing is would actually go.
2026-08-30 14:17:02 -07:00
Coffey Labs ecc030aa3f Merge pull request #128 from LINUXexpert-org/deploy-dry-run-needs-no-terminal
Let a dry run answer without a terminal
2026-08-30 14:13:14 -07:00
jcoffey-dev 8844fc9836 Let a dry run answer without a terminal
The confirmation ran before the dry-run check, so a dry run over SSH was
refused for having no terminal to confirm on -- and the refusal came out
instead of the report it was asked for. Nothing was going to be deployed
either way: it was asking whether to go ahead with something that was not
going to happen.

Print the report, stop there for a dry run, and gate only the real thing
on the confirmation. The hold list still refuses a held commit under
--dry-run, since that is an answer a dry run should give.

--help printed a fixed line range, which the header edit above would have
clipped. Print the leading comment block itself instead.
2026-08-30 14:11:26 -07:00
Coffey Labs ef7823d6de Merge pull request #127 from LINUXexpert-org/blob-download-compressed-length
Send the length of the bytes we are actually sending
2026-08-30 13:58:24 -07:00
jcoffey-dev 8d475e2b07 Refuse to save a script we only partly read
The transport fix stops the truncation that caused #76, but the save path
had no answer for a baseline that arrives incomplete. It is neither
unknown nor empty, so every existing guard passes it through: it parses
into a shorter rule list that looks exactly like a script with fewer
rules, and saving writes that back over the real one.

Check the script against the shape the generator emits instead. Every
rule comment parses, every enabled rule has an if and a closed body under
it, every block ends with a blank line. Structural rather than a
re-serialize-and-compare, so a script written by an older version whose
serializer differed is still editable.

The rule editor reports a short script as unreadable rather than showing
the rules that happened to parse, since a list that looks complete over a
script that is not is the most dangerous thing it could offer.

A cut at the end of a complete rule block is still a valid shorter script
and cannot be told apart from one; that residual is the proxy's to cover.
2026-08-30 13:56:12 -07:00
jcoffey-dev 0277b5b6a8 Send the length of the bytes we are actually sending
A gzip response is decompressed before the blob proxy sees the body, but
its content-length still describes the compressed bytes. Copying that
header onto the longer body made the browser stop reading that many bytes
in and call the download complete, so files arrived truncated with nothing
reporting a failure.

It took a hop that compresses to show up, and one that only compresses
above a threshold to look like a race: a Sieve script stayed intact for two
rules and came back cut off mid-rule once the third pushed it past 1 KiB.

Ask upstream for identity, and forward no length at all rather than one
that describes different bytes.
2026-08-30 13:46:05 -07:00
LINUXexpert.org 22c8eb4af6 Merge pull request #126 from LINUXexpert-org/copyright-coffey-labs
Change the copyright holder to Coffey Labs
2026-08-30 01:31:15 -07:00
jcoffey-dev bd3c4bf964 Change the copyright holder to Coffey Labs
Two lines in the README: the licence statement, and the "by" badge in the
header.

The badge mattered as much as the copyright line. It pointed at
linuxexpert.org, which is now retired -- it 301s its articles to jcoffey.dev
and answers 410 for everything else -- so "by LINUXexpert.org" sent a reader to
a site that no longer claims this work. It now reads "by Coffey Labs" and
points at coffeylabs.org.

Everything else that says LINUXexpert-org is a github.com URL: the source link
baked into .env.example, docker-compose.yml, web/src/lib/source.ts and
server/src/config.ts, plus issue and release links in the docs. Those are the
repository's real path and are unchanged -- the AGPL source offer in the app
depends on that URL resolving.

LICENSE untouched. Its only copyright is the FSF's on the AGPL text itself.
2026-08-30 01:29:17 -07:00
LINUXexpert.org 7eca17665a Merge pull request #124 from LINUXexpert-org/work/coc-contact
Say where to report a code of conduct violation
2026-08-29 00:29:33 -07:00
jcoffey-dev b6327ffb98 Say where to report a code of conduct violation
The Contributor Covenant ships its enforcement section with a placeholder
for the contact address, and this copy never filled it in. The sentence
whose entire job is to tell someone where to report harassment read:

    reported to the community leaders responsible for enforcement at
    .

So the document existed, scored on GitHub's community profile, and
answered the question it was there to answer with a full stop. Anyone who
needed it would have had to go looking somewhere else, at the moment they
were least inclined to.

Uses the same obfuscated address as SECURITY.md and CONTRIBUTING.md, so
there is one contact for the project rather than a second one to keep in
sync. Found while porting this file to cairnobs, which is about to go
public and would have inherited the same gap.
2026-08-29 00:26:17 -07:00
jcoffey-dev d8fc47d765 Clean up contributor metadata 2026-08-28 15:18:05 -07:00
LINUXexpert.org ddd1bbf9b3 Merge pull request #123 from LINUXexpert-org/untrusted-device-mode
Ask whose computer this is, and believe the answer
2026-08-28 14:20:40 -07:00
jcoffey-dev 0b01956535 Ask whose computer this is, and believe the answer
Sign-out never cleared local storage. It stopped push, flushed settings and
removed the subscription -- that last one reasoned explicitly that a browser
left holding someone's mail becomes somebody else's next -- and then left the
settings cache and the recently-addressed list on disk. That list is other
people's addresses, and nothing ever removed it.

Clearing it on sign-out is now unconditional, because lending a laptop is the
same exposure as a public machine, only quieter. The keep-list is short and
deliberate: lastUser, which only a trusted device writes; the trust flag; and
the random push device id. Everything else goes, so a key added later is
forgotten by default rather than by nobody having thought about it.

"Keep me signed in on this device" defaulted to true, which assumed the answer
most costly to get wrong -- someone on a library machine got a thirty-day
cookie unless they noticed a ticked box. It now asks whose computer this is,
defaults to not yours, and says what each answer does. Untrusted means a
session cookie, nothing written locally, no push subscription, and a five
minute idle sign-out.

The idle timer is there because the alternative does not work: custom
beforeunload text was removed from browsers years ago, and no event fires at
all for walking away from a signed-in screen, which is the case that matters.
A timer needs nobody's cooperation.

Reads are gated as well as writes, since a machine trusted once still has the
residue; an untrusted sign-in purges it outright. The wire keeps calling this
`remember` -- it is persisted in SESSION_FILE, and renaming it would invalidate
every session file on upgrade for a change of vocabulary.

Verified in a browser against the mock, not only in tests: untrusted sign-in
leaves localStorage empty through a full session including folder expansion;
trusted writes settings, recent and lastUser as before; sign-out clears recent
and settings while keeping lastUser; an untrusted sign-in afterwards clears
even that.
2026-08-28 14:15:15 -07:00
LINUXexpert.org 045dda109b Merge pull request #122 from LINUXexpert-org/roadmap-2fa-issue-closed
Say where the 2FA entry came from, not that something is tracking it
2026-08-28 12:02:42 -07:00
jcoffey-dev 1c678fabed Say where the 2FA entry came from, not that something is tracking it
The roadmap's preamble promised that anything with an issue number was tracked
in the issue tracker, and the two-factor entry ended in a bare "Reported as
#75". That issue was closed as completed on 2026-08-26, so the one entry the
promise applied to was the one it was wrong about: a reader following the link
finds a closed ticket and has to guess whether the work went with it.

It did not. #75 reported a sign-in refused with nothing but "Invalid
credentials", and that bug was fixed -- the message now says what is happening
and points at app passwords. The OAuth work the report uncovered stayed behind
on this page, which is exactly the case the preamble had no room for.

So the preamble now says an issue number records where an entry came from
rather than where it is tracked, and the entry says plainly that #75 is closed,
what closing it fixed, and that there is no ticket to watch for the rest.
2026-08-28 12:00:38 -07:00
243 changed files with 37010 additions and 1964 deletions
+51 -1
View File
@@ -12,6 +12,18 @@ APP_SECRET=change-me
HOST=0.0.0.0
PORT=8080
# Serve the app from a subpath instead of the domain root, for a reverse proxy
# that maps https://example.com/mail/ here. Leave it unset for the root, which
# is what every deployment gets unless it asks otherwise. "/mail", "mail" and
# "/mail/" all mean the same thing.
#
# The prefix must reach ihasmail intact -- do not strip it in the proxy -- and
# it has to be set for the *build* as well as the run: the web bundle writes
# its own asset URLs, so a build that does not know the prefix produces an app
# that cannot load itself under one. With Docker that means
# `--build-arg BASE_PATH=/mail` alongside `-e BASE_PATH=/mail`.
# BASE_PATH=/mail
# Set to "1" when running behind a TLS-terminating reverse proxy (trusts
# X-Forwarded-* and marks cookies Secure). Set to "0" for plain-HTTP dev.
TRUST_PROXY=1
@@ -56,4 +68,42 @@ APP_NAME=ihasmail
# asks whoever runs a modified version to offer *that* version's source -- so if
# you have patched it, point this at your own tree. Shown on the sign-in page
# and in Settings > About.
SOURCE_URL=https://github.com/LINUXexpert-org/ihasmail
SOURCE_URL=https://github.com/Coffey-Labs/ihasmail
# ---- Settings this installation decides (all optional) ----
#
# Seed what a new account starts on, lock what nobody may change, and turn
# something on once for accounts that already exist. Setting none of these --
# the default -- behaves exactly as ihasmail always has.
#
# A file is easier once there are `changes` in it. See the shipped
# settings-policy.example.json, and mount it read-only:
#
# -v /srv/ihasmail/policy.json:/etc/ihasmail/policy.json:ro
#
# SETTINGS_POLICY_FILE=/etc/ihasmail/policy.json
#
# Or inline, which is what an immutable deployment with no volume wants. These
# are ignored entirely when SETTINGS_POLICY_FILE is set, so a file and a stray
# variable cannot half-apply between them.
#
# SETTINGS_DEFAULTS={"externalSenderBanner":true}
# SETTINGS_ENFORCED={"externalRecipientConfirm":true}
# SETTINGS_CHANGES=[{"version":"20260902084513","settings":{"externalSenderBanner":true}}]
#
# Read once at startup: editing a policy means restarting the container.
# Docs: https://docs.ihasmail.org/configure/#settings-your-installation-decides
# ---- Several Stalwart servers (optional) ----
#
# Choose the upstream by the domain someone signs in with. STALWART_URL above
# stays required and stays the default; this only adds domains that go
# elsewhere. See the shipped stalwart-servers.example.json, and mount it
# read-only:
#
# -v /srv/ihasmail/servers.json:/etc/ihasmail/servers.json:ro
#
# STALWART_SERVERS_FILE=/etc/ihasmail/servers.json
#
# An unlisted domain, or a username with no domain, goes to STALWART_URL. A
# listed domain never falls back. Read once at startup: editing means a restart.
+68
View File
@@ -0,0 +1,68 @@
# Prune old image versions from GHCR.
#
# Releases are kept forever -- they carry no assets and their generated notes
# are this project's only changelog, so deleting one destroys history that
# cannot be reconstructed for nothing saved. Images are the opposite: a
# multi-arch build a week, and the by-digest push in publish.yml leaves two
# untagged per-architecture manifests behind each time on top of the tagged
# index. Those accumulate and nobody wants fifty of them.
#
# THE FOOTGUN: the obvious tool for this -- delete-package-versions with
# `delete-only-untagged-versions` -- will happily delete the per-architecture
# manifests that a multi-arch tag points *at*, because they are untagged by
# design. Nothing appears to break: the tag still exists, and pulls simply
# start failing for one architecture. This action understands manifest lists
# and will not orphan a retained index, and `validate` re-checks every
# multi-arch manifest against the registry afterwards.
#
# Separate from publish.yml, and dispatchable on its own, so `dry_run` can show
# exactly what would be deleted without rebuilding and re-pushing an image to
# find out.
name: Prune images
on:
workflow_call:
inputs:
dry_run:
type: boolean
default: false
workflow_dispatch:
inputs:
dry_run:
description: "List what would be deleted, delete nothing"
type: boolean
default: true
jobs:
prune:
runs-on: ubuntu-latest
permissions:
packages: write
steps:
# Pinned to a commit rather than a moving major tag. This action is
# handed `packages: write` and its whole job is deletion, so a tag
# repointed at something else -- by a compromise or a mistake upstream --
# is a bad day. v1.2.2.
- uses: dataaxiom/ghcr-cleanup-action@d52806a0dc70b430571a37da1fde39733ffd640f
with:
owner: Coffey-Labs
package: ihasmail
token: ${{ secrets.GITHUB_TOKEN }}
# Ten weekly releases is roughly a quarter of history, which is more
# than enough to roll back to and far less than the year's worth that
# would otherwise pile up. Older *releases* stay either way; this
# only removes the images.
keep-n-tagged: 10
# Belt and braces on top of the action's own manifest awareness:
# `latest` is never a candidate for deletion under any counting.
exclude-tags: latest
delete-untagged: true
# Sweeps the wreckage of a half-failed run: an index whose platform
# images did not all land, and referrers whose parent is gone.
delete-partial-images: true
delete-orphaned-images: true
# Checks every remaining multi-architecture manifest still resolves
# in the registry. This is the step that would catch the footgun
# above rather than leaving a reader to discover it on `docker pull`.
validate: true
dry-run: ${{ inputs.dry_run }}
+202
View File
@@ -0,0 +1,202 @@
# Publish the container image to GHCR.
#
# The README and the docs site have told people to run
# `ghcr.io/coffey-labs/ihasmail:latest` for a long time, and nothing ever
# pushed it: `docker pull` answered `denied`, because the package did not
# exist. This is the workflow that makes those instructions true. It is also
# the prerequisite for the self-hosted app catalogues -- TrueNAS and Unraid
# both install by pulling an image and neither builds from source.
#
# FIRST RUN: a package GHCR creates for the first time is **private**, even in
# a public repository, and an anonymous `docker pull` will still answer
# `denied`. Nothing in a workflow can change that -- the visibility is set once
# by hand under the package's settings, and until it is, this looks like it
# worked while the docs stay just as wrong as before. Check with a logged-out
# pull, not with one from a machine that has credentials.
#
# Two architectures, each built on its own native runner rather than under
# QEMU. Emulated arm64 has to run `npm ci` and the Vite build through
# instruction translation, which takes tens of minutes and occasionally runs
# out of memory; `ubuntu-24.04-arm` is free for public repositories and does
# the same work at native speed. The cost is the by-digest dance below: each
# runner pushes an untagged image, and a final job joins the two digests into
# one multi-arch tag.
name: Publish image
on:
release:
types: [published]
# Callable, so release.yml can build the release it just cut. This is not a
# stylistic choice: a release created with GITHUB_TOKEN does **not** raise a
# `release` event -- GitHub refuses to let a token trigger another workflow,
# to stop a workflow looping on its own output. A scheduled job that cut a
# release and expected this file to notice would silently never publish. The
# alternatives are a personal access token kept as a secret, or calling the
# workflow directly. This is the one that needs no credential.
workflow_call:
inputs:
ref:
description: "Tag, branch or SHA to build"
required: true
type: string
tag_latest:
description: "Also move :latest to this build"
type: boolean
default: false
# Same reasoning as ci.yml's dispatch trigger: a run GitHub queues and then
# orphans can be neither rerun nor cancelled, and this workflow otherwise
# only fires on a release -- which is not something to cut twice because a
# runner died. `ref` also allows publishing an image for a tag that predates
# this workflow, which is how the first one gets built.
workflow_dispatch:
inputs:
ref:
description: "Tag, branch or SHA to build"
required: true
default: main
tag_latest:
description: "Also move :latest to this build"
type: boolean
default: false
env:
# Hardcoded rather than derived from github.repository: a registry path must
# be lowercase and the owner is spelled `Coffey-Labs`, so deriving it means
# remembering to lowercase it. This is the string the docs already name.
IMAGE: ghcr.io/coffey-labs/ihasmail
jobs:
# The version is worked out once and handed to both builds, so the two
# architectures cannot disagree about what they are. scripts/version.mjs
# reads the commit date and how the commit arrived, so it needs real history
# rather than a shallow clone.
version:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.v.outputs.version }}
docker_tag: ${{ steps.v.outputs.docker_tag }}
steps:
- uses: actions/checkout@v4
with:
ref: ${{ inputs.ref || github.ref }}
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 22
- id: v
run: |
V="$(node scripts/version.mjs)"
echo "version=$V" >> "$GITHUB_OUTPUT"
# A Docker tag may not contain '+', so build metadata becomes '-'.
# The build is still *told* the real form, which is what About and
# /api/health report.
echo "docker_tag=${V/+/-}" >> "$GITHUB_OUTPUT"
echo "version $V -> tag ${V/+/-}"
build:
needs: version
runs-on: ${{ matrix.runner }}
permissions:
contents: read
packages: write
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
- platform: linux/arm64
runner: ubuntu-24.04-arm
steps:
- uses: actions/checkout@v4
with:
ref: ${{ inputs.ref || github.ref }}
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push by digest
id: push
uses: docker/build-push-action@v6
with:
context: .
platforms: ${{ matrix.platform }}
build-args: IHASMAIL_VERSION=${{ needs.version.outputs.version }}
# Attestations are off deliberately: they add manifests of their own
# to the index, and `imagetools create` below expects the two entries
# it pushed rather than four.
provenance: false
sbom: false
cache-from: type=gha,scope=${{ matrix.platform }}
cache-to: type=gha,mode=max,scope=${{ matrix.platform }}
outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true
- name: Save the digest
run: |
mkdir -p /tmp/digests
# The prefix is stripped here and put back in the merge job, so the
# filename is the bare hash. Leaving it on produces
# `image@sha256:sha256:...` when the reference is rebuilt.
digest="${{ steps.push.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- uses: actions/upload-artifact@v4
with:
# One artifact per platform; the merge job globs them back together.
name: digest-${{ strategy.job-index }}
path: /tmp/digests/*
retention-days: 1
if-no-files-found: error
# Joins the per-architecture digests into a single tagged manifest, so
# `docker pull ghcr.io/coffey-labs/ihasmail:<tag>` resolves on both.
publish:
needs: [version, build]
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/download-artifact@v4
with:
path: /tmp/digests
pattern: digest-*
merge-multiple: true
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Create the manifest
run: |
# Arrays rather than a string: the tags and the digest references
# have to reach docker as separate arguments, and building them by
# word-splitting an unquoted variable is the version of this that
# breaks the day a value contains a space.
tags=(-t "${IMAGE}:${{ needs.version.outputs.docker_tag }}")
# :latest follows real releases only. A prerelease that moved it
# would hand every `:latest` deployment an unfinished build, and a
# dispatch run has to ask for it on purpose.
if [ "${{ github.event_name }}" = "release" ] && [ "${{ github.event.release.prerelease }}" = "false" ]; then
tags+=(-t "${IMAGE}:latest")
elif [ "${{ inputs.tag_latest }}" = "true" ]; then
tags+=(-t "${IMAGE}:latest")
fi
refs=()
for f in /tmp/digests/*; do
refs+=("${IMAGE}@sha256:$(basename "$f")")
done
echo "tags: ${tags[*]}"
echo "refs: ${refs[*]}"
docker buildx imagetools create "${tags[@]}" "${refs[@]}"
- name: Show what landed
run: docker buildx imagetools inspect "${IMAGE}:${{ needs.version.outputs.docker_tag }}"
# Runs only after a successful publish, because that is the only moment the
# package grows. See cleanup.yml for why this is not the obvious one-liner.
prune:
needs: publish
permissions:
packages: write
uses: ./.github/workflows/cleanup.yml
+155
View File
@@ -0,0 +1,155 @@
# Cut a release once a week, but only if there is something in it.
#
# Releases had drifted 184 commits behind main, which made `:latest` describe
# a build nobody was running -- the demo, prod and anyone building from source
# were all ahead of it. Publishing on release is the right trigger only if
# releases actually happen, so this is the part that makes that true without
# anyone having to remember.
#
# It does nothing on a quiet week. A release with no commits in it is worse
# than no release: it moves `:latest` to an identical build, spends a version
# number, and mails everybody watching the repository about nothing.
name: Weekly release
on:
schedule:
# Mondays, 09:00 UTC. GitHub runs scheduled jobs on a best-effort basis and
# can delay a run by a good while when the queue is busy, so do not read
# the exact minute as a promise. Note also that GitHub disables scheduled
# workflows in a repository with no activity for 60 days -- not a concern
# while this one is being worked on weekly, but it is why a silent stop is
# worth checking for before assuming the file is broken.
- cron: "0 9 * * 1"
workflow_dispatch:
inputs:
dry_run:
description: "Work out what would be released, then stop"
type: boolean
default: false
# One at a time. Two overlapping runs would race to create the same tag, and
# the loser fails noisily for a reason that has nothing to do with the code.
concurrency:
group: weekly-release
cancel-in-progress: false
jobs:
check:
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
should_release: ${{ steps.decide.outputs.should_release }}
tag: ${{ steps.decide.outputs.tag }}
title: ${{ steps.decide.outputs.title }}
sha: ${{ steps.decide.outputs.sha }}
previous: ${{ steps.decide.outputs.previous }}
count: ${{ steps.decide.outputs.count }}
steps:
- uses: actions/checkout@v4
with:
ref: main
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 22
- id: decide
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
# The newest published release, or empty on a repository that has
# never had one -- in which case everything counts as new. Drafts are
# excluded: an unpublished draft is not a release anybody has, so
# counting from it would hide commits that have never shipped.
previous="$(gh release list --limit 1 --exclude-drafts --json tagName --jq '.[0].tagName // ""')"
# A tag named by a release is normally present after a full checkout,
# but a release can outlive its tag. Falling back to the whole
# history is the safe direction to be wrong in: it over-counts, which
# cuts a release that was due anyway, where under-counting would skip
# one that was.
if [ -n "$previous" ] && git rev-parse -q --verify "refs/tags/${previous}" >/dev/null; then
count="$(git rev-list --count "${previous}..HEAD")"
else
count="$(git rev-list --count HEAD)"
fi
version="$(node scripts/version.mjs)"
# A Docker tag may not contain '+', and neither should the git tag,
# so the two always agree about what to call a build.
tag="v${version/+/-}"
title="v${version%%+*}"
sha="$(git rev-parse HEAD)"
should_release=true
reason=""
if [ "$count" -eq 0 ]; then
should_release=false
reason="no commits since ${previous}"
elif git rev-parse -q --verify "refs/tags/${tag}" >/dev/null; then
# Same commit, different week: the version is derived from the
# commit, so nothing new means the tag already exists.
should_release=false
reason="tag ${tag} already exists"
fi
{
echo "should_release=$should_release"
echo "tag=$tag"
echo "title=$title"
echo "sha=$sha"
echo "previous=$previous"
echo "count=$count"
} >> "$GITHUB_OUTPUT"
# Written to the run summary so a skipped week reads as a decision
# rather than as a workflow that quietly did nothing.
{
echo "### Weekly release"
echo
if [ "$should_release" = "true" ]; then
echo "Releasing **${tag}** — ${count} commit(s) since ${previous:-the beginning}."
else
echo "Nothing to release: ${reason}."
fi
} >> "$GITHUB_STEP_SUMMARY"
cut:
needs: check
if: needs.check.outputs.should_release == 'true' && !inputs.dry_run
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
ref: main
fetch-depth: 0
- env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
args=(--target "${{ needs.check.outputs.sha }}"
--title "${{ needs.check.outputs.title }}"
--generate-notes)
# Bound the notes to what is actually new. Without a start tag the
# generator reaches back to whatever it decides is previous, which on
# a repository with older tag shapes is not always the last release.
if [ -n "${{ needs.check.outputs.previous }}" ]; then
args+=(--notes-start-tag "${{ needs.check.outputs.previous }}")
fi
gh release create "${{ needs.check.outputs.tag }}" "${args[@]}"
# Called rather than left to the `release` trigger on purpose: see the note
# at the top of publish.yml. A release created with GITHUB_TOKEN raises no
# event, so without this the tag would exist and no image would follow it.
publish:
needs: [check, cut]
permissions:
contents: read
packages: write
uses: ./.github/workflows/publish.yml
with:
ref: ${{ needs.check.outputs.sha }}
tag_latest: true
+3
View File
@@ -6,3 +6,6 @@ dist/
server/data/
.vite/
coverage/
# Worktrees used by parallel agents; never part of a commit.
.claude/worktrees/
+49
View File
@@ -0,0 +1,49 @@
# Upstream palette values, fetched from source (all MIT)
Fetched 2026-09-02 from the projects' own repositories, not from any
reimplementation.
## Dracula — dracula/dracula-theme, MIT
README section is titled "Color Palette (OSS)" and contains BOTH variants,
so Alucard is open source and not PRO-only.
### Dracula (dark)
Background #282a36 · Current Line #44475a · Selection #44475a
Foreground #f8f8f2 · Comment #6272a4
Cyan #8be9fd · Green #50fa7b · Orange #ffb86c · Pink #ff79c6
Purple #bd93f9 · Red #ff5555 · Yellow #f1fa8c
### Alucard (light)
Background #fffbeb · Current Line #6c664b · Selection #cfcfde
Foreground #1f1f1f · Comment #6c664b
Cyan #036a96 · Green #14710a · Orange #a34d14 · Pink #a3144d
Purple #644ac9 · Red #cb3a2a · Yellow #846e15
## Gruvbox — morhetz/gruvbox, MIT
dark0_hard #1d2021 · dark0 #282828 · dark0_soft #32302f · dark1 #3c3836
dark2 #504945 · dark3 #665c54 · dark4 #7c6f64 · gray #928374
light0_hard #f9f5d7 · light0 #fbf1c7 · light0_soft #f2e5bc · light1 #ebdbb2
light2 #d5c4a1 · light3 #bdae93 · light4 #a89984
bright: red #fb4934 green #b8bb26 yellow #fabd2f blue #83a598 purple #d3869b aqua #8ec07c orange #fe8019
neutral: red #cc241d green #98971a yellow #d79921 blue #458588 purple #b16286 aqua #689d6a orange #d65d0e
faded: red #9d0006 green #79740e yellow #b57614 blue #076678 purple #8f3f71 aqua #427b58 orange #af3a03
## Rosé Pine — rose-pine/palette, MIT (palette.json)
### main (dark)
base #191724 surface #1f1d2e overlay #26233a muted #6e6a86 subtle #908caa text #e0def4
love #eb6f92 gold #f6c177 rose #ebbcba pine #31748f foam #9ccfd8 iris #c4a7e7
### dawn (light)
base #faf4ed surface #fffaf3 overlay #f2e9e1 muted #9893a5 subtle #797593 text #464261
love #b4637a gold #ea9d34 rose #d7827e pine #286983 foam #56949f iris #907aa9
## Tokyo Night — enkia/tokyo-night-vscode-theme, MIT
### Night (dark)
bg #1a1b26 · bg_dark #16161e · fg #a9b1d6 · line numbers #363b54 · border #101014
selection #202330 · link #6183bb
accents: purple #bb9af7 · text-bright #c0caf5 · red #f7768e · cyan #0db9d7
blue #7aa2f7 · light-cyan #7dcfff · yellow #e0af68 · teal #73daca · green #9ece6a
### Day (light)
bg #e6e7ed · bg_dark #d6d8df · fg #343b59 · line numbers #9da0ab · border #c1c2c7
link #2959aa
accents: purple #65359d · red #8c4351 · cyan #006c86 · blue #2959aa
yellow #8f5e15 · teal #33635c · green #385f0d
+1 -1
View File
@@ -60,7 +60,7 @@ representative at an online or offline event.
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
.
**johnellisATlinuxDOTcom**.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
+1 -1
View File
@@ -16,7 +16,7 @@ By participating in this project, you agree to treat other contributors with res
### Reporting Bugs
Before opening a new issue, please search [existing issues](https://github.com/LINUXexpert-org/ihasmail/issues) to see if it's already been reported. When filing a bug report, include:
Before opening a new issue, please search [existing issues](https://github.com/Coffey-Labs/ihasmail/issues) to see if it's already been reported. When filing a bug report, include:
- A clear, descriptive title
- Steps to reproduce the issue
+22 -2
View File
@@ -7,6 +7,15 @@ FROM node:22-alpine AS build
# Left empty, the build falls back to the base version from package.json.
ARG IHASMAIL_VERSION=""
ENV IHASMAIL_VERSION=$IHASMAIL_VERSION
# The subpath the app will be served from, e.g. /mail. Empty -- the default --
# is the domain root and is what every deployment gets unless it asks
# otherwise. Unlike the rest of ihasmail's configuration this cannot wait for
# the process to start: the web build writes its own asset URLs into
# index.html, so a build that does not know the prefix produces a shell that
# cannot load itself under one. It is therefore a build argument here and an
# environment variable in the runtime stage, from the same value.
ARG BASE_PATH=""
ENV BASE_PATH=$BASE_PATH
WORKDIR /app
COPY package.json package-lock.json* ./
COPY server/package.json server/
@@ -19,12 +28,14 @@ RUN npm run build
FROM node:22-alpine AS runtime
# Re-declared: an ARG does not cross stages.
ARG IHASMAIL_VERSION=""
ARG BASE_PATH=""
ENV NODE_ENV=production \
HOST=0.0.0.0 \
PORT=8080 \
STATIC_DIR=/app/web/dist \
SESSION_FILE=/data/sessions.json \
IHASMAIL_VERSION=$IHASMAIL_VERSION
IHASMAIL_VERSION=$IHASMAIL_VERSION \
BASE_PATH=$BASE_PATH
WORKDIR /app
COPY package.json ./
COPY server/package.json server/
@@ -47,5 +58,14 @@ USER node
# that want the sessions to survive say so themselves: docker-compose.yml and
# deploy.example.sh both mount a *named* volume at /data, which is unaffected.
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s CMD wget -qO- http://127.0.0.1:8080/api/health || exit 1
# Shell form, so $BASE_PATH is expanded by the container rather than baked in
# empty at build time: the health endpoint moves with the mount.
#
# The two substitutions repeat, in sh, what scripts/basePath.mjs does in
# JavaScript -- drop a trailing slash, add a leading one -- because this runs
# before there is a Node process to ask. It is worth the duplication: an
# operator who writes BASE_PATH=mail/ gets a working server, and without this
# a healthcheck that says the working server is unhealthy and has Docker
# restart it forever.
HEALTHCHECK --interval=30s --timeout=5s CMD BP="${BASE_PATH%/}"; case "$BP" in ""|/*) ;; *) BP="/$BP";; esac; wget -qO- "http://127.0.0.1:8080$BP/api/health" || exit 1
CMD ["node", "server/dist/index.js"]
+1359
View File
File diff suppressed because it is too large Load Diff
+29 -9
View File
@@ -4,11 +4,17 @@ What was checked, against which server, and when. For a failure you are hitting
right now, start with [Troubleshooting](https://docs.ihasmail.org/troubleshooting/);
for what is not built yet, see [ROADMAP.md](ROADMAP.md).
The live instance runs **0.16.19**, and as of **2026-08-26 there is nothing
left pending**: every entry below has been exercised against it. What remains
here is not a list of unknowns but of things worth knowing — where Stalwart
departs from a spec, where a setting has to be turned on for a feature to work,
and what ihasmail deliberately does not do.
The live instance runs **0.16.20**, upgraded from 0.16.19 on 2026-08-31 with
eight seconds of downtime, and as of **2026-08-26 there is nothing left
pending**. Every entry below was exercised against 0.16.19 on the date it
names, and the dates still say so: the upgrade was read against the
0.16.19→0.16.20 diff rather than re-run, and nothing in it touches the session
capabilities, blob, quota, submission or registry paths these entries describe.
The calendar entries below carrying a 2026-08-31 date are the exception: those
were exercised against the live 0.16.20 directly.
What remains here is not a list of unknowns but of things worth knowing — where
Stalwart departs from a spec, where a setting has to be turned on for a feature
to work, and what ihasmail deliberately does not do.
Entries keep saying what was checked and when, because this section has been
wrong before: the 0.16 registry path was once recorded as verified live when a
@@ -19,7 +25,13 @@ upgraded on 2026-08-25. They are kept where the finding is about ihasmail
rather than about 0.15 — a byte cap that still applies, a flow that still
works the same way — and dropped where 0.15 was the whole subject. Support for
0.15 was removed on 2026-08-26; the last release that runs on it is tagged
[`stalwart-0.15-support`](https://github.com/LINUXexpert-org/ihasmail/releases/tag/stalwart-0.15-support).
[`stalwart-0.15-support`](https://github.com/Coffey-Labs/ihasmail/releases/tag/stalwart-0.15-support).
- **All nine translations have never been read by anybody who speaks them.** They were produced by AI against standard dictionaries on 2026-08-31 — German, Spanish, French, Dutch, Portuguese (Brazil), Russian, Ukrainian, Simplified Chinese and Japanese, which with English makes ten languages in the picker — and every one of the nine is marked **Beta** in the picker, with that stated in Settings beside a link for reporting anything that reads wrongly. This is the entry that matters most on this page, because it is the one thing here that cannot be closed by testing: a translation can be complete, consistent, pass every check, and still read like a machine wrote it, and nobody on this project can tell which. What *is* verified is the machinery around them. A missing key renders its English source, so a bad line can simply be deleted; a stale key — one whose English no longer exists — is caught by `npm run i18n:check` rather than sitting in the file looking correct and never being looked up. Plurals are asked of `Intl.PluralRules` rather than assumed, which is why Russian and Ukrainian carry three forms and Japanese and Chinese carry one; supplying `one` for Japanese would have been filling in a distinction the language does not draw. Confirmed live on the deployed instance (2026-08-31) against a 6,289-message mailbox: role folders localise and the ~20 custom folders keep the names their owner gave them, dates and the calendar follow the language, and 6,289 renders as *6289 листувань* — the genitive plural a number ending in nine takes, which is the first time the plural machinery ran on anything but a hand-picked value.
- **`npm run i18n:coverage` reported 100% while about two hundred strings rendered English in every language.** It reads JSX text, and it was not wrong about what it measured — none of them were JSX text. They were `toast.error(...)` arguments, `confirmDialog({ title, confirmLabel })` props, `title=` and `aria-label=` attributes, and template literals: every one built from an expression a codemod cannot read. The calendar's own view switcher was the clearest case, spelling its labels `v[0].toUpperCase() + v.slice(1)` — correct English, untranslatable anywhere else, and galling because **Day**, **Week**, **Month** and **Agenda** were already in all nine catalogues and the buttons simply never asked for them. Reported from production, where the switcher stayed English in a Japanese interface. All of them are now wrapped, and `npm run i18n:check` grew a second half (`scripts/i18n-literals.mjs`) that accepts a string wrapped where it is written *or* present as a catalogue key — the constant-table convention, where `SECTIONS` holds `label: "About"` and the render site calls `t(s.label)` — and refuses one that is neither, because that is a string no catalogue can translate however many languages ship. It found twenty more than a hand sweep had. Worth recording as a general lesson rather than an i18n one: a coverage number measures the thing it can see, and the strings it cannot see are exactly the ones nobody is checking.
- **A compressing hop in front of Stalwart truncated every blob download, and nothing said so.** Node decompresses a gzip response before the code ever sees the body, but leaves the `content-length` header describing the *compressed* bytes. The blob proxy copied that header onto the longer body it forwarded, so the browser stopped reading exactly that many bytes in and called the download complete. Reported on [#76](https://github.com/Coffey-Labs/ihasmail/issues/76) against a Coolify deployment, where Traefik's compress middleware only engages above 1 KiB: filter rules one and two were fine and the third pushed the script past the threshold, after which it came back cut off mid-rule — 384 bytes of a 1.3 KB script. The size threshold is what made it look like a race. This is the *second* cause behind that issue, and the first fix did not touch it: a truncated script is neither unknown nor empty, so the "refuse to save from a baseline we could not read" guard never fired — the script parsed, just with rules missing, and the next save wrote the short version back over the real one. Every blob download shared the fault, not just Sieve: message source, vCards, signature HTML, attachments being forwarded, and the `settings.json` sync. Settings degraded honestly by luck rather than design — a truncated file fails `JSON.parse`, which is caught and leaves the local cache in charge — so it stopped syncing between devices instead of being overwritten. The proxy now asks upstream for `identity` and, for a hop that compresses anyway, forwards no length at all rather than one describing different bytes. The image proxy is unaffected: it uses `node:http` directly, sends no `accept-encoding`, and never decompresses. The save path no longer trusts the transport either: a script is now checked for completeness against the shape the generator emits — every `# rule:` comment parses, every enabled rule has an `if` and a closed body below it, every block ends with a blank line — and saving refuses on anything short, as does the rule editor, which reports the script as unreadable rather than showing the rules that happened to parse. The check is structural rather than a re-serialize-and-compare, so a script written by an older version with a different serializer is still editable; refusing over a changed byte would be the worse bug. It catches a cut at every offset except the end of a complete rule block, which is a legitimately shorter script and indistinguishable from one in the bytes alone — that residual is what the proxy fix covers.
- **Delete all spam destroys, and does not pass through Deleted Items** — this is the point of the feature and the thing worth checking on a real server, since a folder that empties into another folder has solved nothing. `Email/set destroy`, walked a page at a time so it survives `maxObjectsInSet` the way emptying Deleted Items already had to. **Confirmed live on 0.16.19 (2026-08-26)**: Junk Mail emptied and Deleted Items stayed empty afterwards. There is no undo, which is why all three entry points share one dialog that says so. Only Deleted Items and Junk Mail can be emptied this way, enforced in the store rather than only hidden in the menus.
- **Sharing a mail folder is accepted and does nothing.** `Mailbox/set` with a `shareWith` map is applied, `Mailbox/get` reads it back, and the folder never appears for the account it was shared with — **confirmed live on 0.16.19 (2026-08-27)** with a folder shared read-only to another account on the same server, which never saw it. Stalwart's own sharing documentation lists calendars, address books and file storage; mail folders are not among them. Nothing reports a failure at any point, which is the whole problem: the share is stored, so a client that trusts what it reads back shows it as live for ever. The entry point is withdrawn. A folder that is *already* shared still offers **Stop sharing**, because a share nobody can see is exactly the one you want to be able to clear, and there is no other way to. File sharing is unaffected and works end to end.
@@ -29,11 +41,19 @@ works the same way — and dropped where 0.15 was the whole subject. Support for
- **Read receipts are built here, not by the server** — JMAP has an extension for them, [RFC 9007](https://www.rfc-editor.org/rfc/rfc9007.html)'s `MDN/send`, and Stalwart does not implement it: `urn:ietf:params:jmap:mdn` is not among its capabilities. So ihasmail assembles the `multipart/report` itself and sends it the long way round — raw MIME uploaded as a blob, `Email/import`, then `EmailSubmission` — which is also why the receipt lands in Sent, where it honestly belongs. Non-ASCII parts are base64 rather than `8bit`, so nothing depends on 8BITMIME surviving every hop. There is deliberately no "always send" setting: a receipt confirms to whoever asked that the address is live and when it was read, to an address of the sender's choosing, so each one is a decision. Verified against the mock end to end (upload, import, submit, `$mdnsent`), and **confirmed live on 0.16.19 (2026-08-26)**: a receipt asked for by a real sender was assembled, uploaded, imported and submitted, landed in Sent, and set `$mdnsent` so a second look does not offer to send another.
- **Where 0.16 advertises `urn:stalwart:jmap`** — not where a JMAP client would look, and this now decides whether a sign-in is allowed at all. Stalwart builds the session-level `capabilities` from a fixed list (`Session::new`, plus WebSocket) that has never contained this capability, in any 0.16.x from 0.16.0 to 0.16.19. It hands it out per-account instead, so it appears in `primaryAccounts` and in each account's `accountCapabilities`. ihasmail tested for it in `capabilities` alone, which made every real 0.16 server read as older than 0.16 — and that one check drove three things: self-service credentials fell back to `POST /api/account/auth`, which 0.16 removed, so password changes, 2FA and app passwords all failed with "this mail server does not offer self-service credential management"; About reported the wrong generation; and Files took the older code path. It now looks in all three places, and is covered by tests on each. Worth restating plainly, because the stakes went up when 0.15 support was dropped: there is no longer a fallback path for this check to be wrong *into*. Getting it wrong now refuses every sign-in against a perfectly good server — a loud failure rather than a quiet misrouting, which is the trade the removal was making.
- **HTML signatures** — Stalwart caps a signature at 2047 **bytes** (`value.len() < 2048` on a Rust string, so UTF-8 bytes, not characters). ihasmail compacts pasted HTML, moves images to Files and, if still too large, keeps the full signature in Files behind a short marker; other clients see a text fallback. Confirmed live on 0.15.5 (2026-08-24): oversized, non-ASCII and inline-image signatures all save, and a test message arrived intact at Gmail with the logo inline.
- **Settings live in the account's Files, not the browser** — every preference used to sit in `localStorage`, so none of them followed anyone between devices. The sharpest edge was the default identity: with none set the address that sorts first wins, so someone who set it at work found it unset at home and mail went out from an address the recipient might not recognise ([#54](https://github.com/LINUXexpert-org/ihasmail/issues/54)). They are now a `settings.json` in the `ihasmail` folder in JMAP Files, beside the signature images already kept there — which keeps ihasmail itself stateless: no volume, no database, nothing to back up separately, and the settings are covered by whatever backs up the mail store. `x:AccountSettings` was the other candidate and does not fit; its schema is `locale`/`timeZone`/`description` with no free-form field, and writing it needs `sysAccountSettingsSet`, where the built-in user role carries only the `…Get` half. `localStorage` stays on as a *cache* rather than the source of truth, so the first frame paints from it and the file corrects it a moment later; a browser with no cache shows defaults for that one frame, which is the trade for not gating the whole app on a round trip. Settings that describe *this* screen or browser deliberately stay local — list-pane sizes, density, font size, sidebar state, and the notification toggles, which track a permission the browser grants per-device and would be a claim about somewhere else it cannot make. That split is written as a list of exceptions, so a setting added later syncs by default. Writes are coalesced behind a three-second debounce, since `update()` fires on every frame of a splitter drag, and a tab going away or a sign-out flushes first. The `ihasmail` folder is now hidden from the Files view, contents and all: hiding the folder alone would be worse than showing it, because the tree attaches a node whose parent is missing to the root, so the signature images — visible there since signatures shipped — would have spilled into the top level. **Confirmed live on 0.16.19 (2026-08-26)**: settings set in Chrome came back on a fresh login in Firefox and in an incognito session, both of which start with an empty cache, so each read the account's file rather than anything local. Confirmed again on the deployed instance rather than only a pre-deployment build. Requires 0.16, which ihasmail now requires everywhere — `FileNode/query` cannot see directories before that, and sign-in refuses an older server outright. Two limits worth knowing: conflicts are last-write-wins, and a change made on one device does not reach another that already has ihasmail open until it signs in again.
- **Settings live in the account's Files, not the browser** — every preference used to sit in `localStorage`, so none of them followed anyone between devices. The sharpest edge was the default identity: with none set the address that sorts first wins, so someone who set it at work found it unset at home and mail went out from an address the recipient might not recognise ([#54](https://github.com/Coffey-Labs/ihasmail/issues/54)). They are now a `settings.json` in the `ihasmail` folder in JMAP Files, beside the signature images already kept there — which keeps ihasmail itself stateless: no volume, no database, nothing to back up separately, and the settings are covered by whatever backs up the mail store. `x:AccountSettings` was the other candidate and does not fit; its schema is `locale`/`timeZone`/`description` with no free-form field, and writing it needs `sysAccountSettingsSet`, where the built-in user role carries only the `…Get` half. `localStorage` stays on as a *cache* rather than the source of truth, so the first frame paints from it and the file corrects it a moment later; a browser with no cache shows defaults for that one frame, which is the trade for not gating the whole app on a round trip. Settings that describe *this* screen or browser deliberately stay local — list-pane sizes, density, font size, sidebar state, and the notification toggles, which track a permission the browser grants per-device and would be a claim about somewhere else it cannot make. That split is written as a list of exceptions, so a setting added later syncs by default. Writes are coalesced behind a three-second debounce, since `update()` fires on every frame of a splitter drag, and a tab going away or a sign-out flushes first. The `ihasmail` folder is now hidden from the Files view, contents and all: hiding the folder alone would be worse than showing it, because the tree attaches a node whose parent is missing to the root, so the signature images — visible there since signatures shipped — would have spilled into the top level. **Confirmed live on 0.16.19 (2026-08-26)**: settings set in Chrome came back on a fresh login in Firefox and in an incognito session, both of which start with an empty cache, so each read the account's file rather than anything local. Confirmed again on the deployed instance rather than only a pre-deployment build. Requires 0.16, which ihasmail now requires everywhere — `FileNode/query` cannot see directories before that, and sign-in refuses an older server outright. Two limits worth knowing: conflicts are last-write-wins, and a change made on one device does not reach another that already has ihasmail open until it signs in again.
- **Files on 0.16** — the pre-0.16 quirks this entry used to describe are gone with the support for them: `FileNode/query` masking directories out of its own results, `nodeType` not existing, and rights being a single `mayWrite`. What is left is what has actually been exercised on 0.16.19. Finding and creating a folder, creating a node with `nodeType`, uploading and downloading its blob, and pointing an existing node at a new one all ran live on 2026-08-26, as a side effect of the settings file. Rename, move and delete are **confirmed live on 0.16.19 (2026-08-26)** as well, which closes this out: what had been confirmed on 0.15.5 (2026-08-24) was the older code path, and that path no longer exists. Two fallbacks went with the removal and are worth knowing about: `ensureFolder` and `findInFolder` now filter on `parentId`/`isTopLevel` alone and match names client-side, since `name` is not a filter Stalwart is known to implement and one it does not know fails the whole query; and a refused filter or sort no longer drops the view into fetching every node in the account, which would have hidden a real fault behind a performance cliff nobody would notice.
- **Self-service credentials** — the registry path is **confirmed live** against Stalwart 0.16.19 (2026-08-25): app passwords created and revoked, password changed, 2FA enabled and disabled, with the browser session surviving the switch to an app password. The 0.15 REST path was confirmed live too, on 0.15.5 (2026-08-24), and has since been removed along with the rest of 0.15 support. The mock enforces the same rules the real server does (current password required, password policy, a TOTP code on every request once 2FA is on, app passwords exempt from it). Password changes are refused by Stalwart for accounts backed by an external directory (LDAP/SQL/OIDC); the server's own message is shown when that happens.
- **Scheduled send needs one setting turned on, and says nothing when it is off.** Stalwart advertises the delay in the account's `urn:ietf:params:jmap:submission` capability — `maxDelayedSend: 2592000` (30 days) and `FUTURERELEASE` among its `submissionExtensions`, and note it is the *account* capability, not the session-level one, which is empty. But the MTA only honours a hold when `futureRelease` is set under the session's MTA extensions, and [that setting defaults to `false`](https://stalw.art/docs/ref/object/mta-extensions/). With it off, Stalwart takes the `HOLDUNTIL` parameter, skips the hold and sends the message immediately **without an error** — the capability still says thirty days. So set `futureRelease` (to the longest hold you want to allow) before relying on this; a value shorter than 30 days is fine, and a request past it is refused honestly, with a `forbiddenMailFrom` naming the limit. `npm run dev:mock:no-future-release` reproduces the silent-drop case. ihasmail asks for the delay the way JMAP requires — a `HOLDUNTIL` parameter on the envelope's `mailFrom`, since RFC 8621 makes `sendAt` read-only and server-derived — and files the held message in a **Scheduled** folder, because `onSuccessUpdateEmail` would otherwise drop it in Sent the moment the submission is created. Nothing moves it out when the hold expires, so ihasmail reconciles the folder on the way in: released messages to Sent, cancelled ones back to Drafts. Three fixes this depends on landed in **0.16.17**, below the live instance's 0.16.19: `HOLDUNTIL` taking RFC 3339 date-times again (0.16.16 had it wanting Unix timestamps), `EmailSubmission/query` on `undoStatus` agreeing with `/get` about held submissions, and `EmailSubmission/get` without `ids` iterating the right index. The hold itself is now **confirmed against the live 0.16.19** (2026-08-25), once `futureRelease` was set to `30d` there: a submission carrying a `HOLDUNTIL` ten minutes out came back `pending`, with `sendAt` equal to the time asked for and a `250 2.1.5 Queued` from the MTA, rather than going out at once. Worth repeating that the capability is no evidence either way — it advertised `maxDelayedSend: 2592000` and `FUTURERELEASE` while the setting was still off. Only a submission tells you. The rest of the journey is **confirmed live too (2026-08-26)**: a hold expired and was delivered, and the **Scheduled** folder reconciled on the way in — a released message moved to Sent, a cancelled one back to Drafts. Nothing in Stalwart does that moving, so if ihasmail is never opened again the message still goes out; it is only the folder that waits to be tidied.
- **Stalwart 0.16 and RFC 8984 disagree about the calendar vocabulary, and the server only says so half the time.** A participant's address lives in `calendarAddress`, not RFC 8984's `sendTo`/`email`; the organizer is `organizerCalendarAddress`, not `replyTo`; and a recurrence is a single `recurrenceRule`, not a `recurrenceRules` array. Addressed the RFC's way, `CalendarEvent/set` **keeps the event and discards the whole participant map without an error** — guests disappeared on save and no invitation was ever sent, which is what [#26](https://github.com/LINUXexpert-org/ihasmail/issues/26) reported. The array form of the rule is refused honestly, with `invalidProperties`, so recurring events could not be created at all and existing ones showed no repeat ([#30](https://github.com/LINUXexpert-org/ihasmail/issues/30)). ihasmail now writes Stalwart's names and reads either, and the mock refuses what the real server refuses, since advertising the RFC spelling is precisely how this got as far as a live server. Verified against 0.16.19 on 2026-08-25, end to end: participants, organizer and rule all survive a create, an update and a re-read; an invitation to an external Gmail address arrived as an invite card, and the decline came back and was applied to the event (`needs-action``declined`, sequence 1). Cancelling the event notified the guest too. Adding guests to an event that had none, and clearing them again with `null`, both work on the update path, as does RSVP — which patches `participants/{key}/participationStatus` (and `participationComment`) rather than sending the whole map. That patch has to be aimed at the base event: `CalendarEvent/set` refuses a synthetic id with *"Updating synthetic ids is not yet supported"*, which is why RSVP resolves `baseEventId` first. Adding a *new* participant by patch is refused as well (`Patch operation failed`), so a changed guest list is written as the whole `participants` property. One more thing to know when reading this code: an expanded occurrence carries a `recurrenceId` but *no* rule of its own, and `baseEventId` is set on everything an expanded query returns — a one-off included, whose own id differs from its base — so neither is a test for recurrence.
- Recurring events: colour/category/edit/delete apply to the whole series (per-occurrence overrides aren't supported by the server yet).
- **Stalwart 0.16 and RFC 8984 disagree about the calendar vocabulary, and the server only says so half the time.** A participant's address lives in `calendarAddress`, not RFC 8984's `sendTo`/`email`; the organizer is `organizerCalendarAddress`, not `replyTo`; and a recurrence is a single `recurrenceRule`, not a `recurrenceRules` array. Addressed the RFC's way, `CalendarEvent/set` **keeps the event and discards the whole participant map without an error** — guests disappeared on save and no invitation was ever sent, which is what [#26](https://github.com/Coffey-Labs/ihasmail/issues/26) reported. The array form of the rule is refused honestly, with `invalidProperties`, so recurring events could not be created at all and existing ones showed no repeat ([#30](https://github.com/Coffey-Labs/ihasmail/issues/30)). ihasmail now writes Stalwart's names and reads either, and the mock refuses what the real server refuses, since advertising the RFC spelling is precisely how this got as far as a live server. Verified against 0.16.19 on 2026-08-25, end to end: participants, organizer and rule all survive a create, an update and a re-read; an invitation to an external Gmail address arrived as an invite card, and the decline came back and was applied to the event (`needs-action``declined`, sequence 1). Cancelling the event notified the guest too. Adding guests to an event that had none, and clearing them again with `null`, both work on the update path, as does RSVP — which patches `participants/{key}/participationStatus` (and `participationComment`) rather than sending the whole map. That patch had to be aimed at the base event: through 0.16.19 `CalendarEvent/set` refused a synthetic id with *"Updating synthetic ids is not yet supported"*, which is why RSVP resolves `baseEventId` first. 0.16.20 accepts one, so that resolution is now a choice rather than the only option — an RSVP aimed at an occurrence would answer for that date alone. It still resolves the base, which is the answer people mean. Adding a *new* participant by patch is refused as well (`Patch operation failed`), so a changed guest list is written as the whole `participants` property. One more thing to know when reading this code: an expanded occurrence carries a `recurrenceId` but *no* rule of its own, and `baseEventId` is set on everything an expanded query returns — a one-off included, whose own id differs from its base — so neither is a test for recurrence.
- **Free/busy between accounts needs no sharing, and calendar contents cannot be reached at all.** These are the two halves of the same finding, and the second is what makes the first safe. **Confirmed live on 0.16.20 (2026-09-01)** against the deployed instance: `Principal/getAvailability` was called for all seven principals the directory returns, none of whose calendars are shared with the calling account, and every one was answered — no `forbidden`, no error of any kind, from a server that refuses a malformed call instantly. It returns real data rather than a polite empty list: the caller's own principal reported one busy period against the one event in the next sixty days. And a `Principal` carries only `id`, `type`, `name`, `description` and `email`**no `accountId`** — so there is no handle with which to ask for anybody's calendars. Free/busy is therefore not the weaker of two permissions, it is the only channel between two accounts, and it is open by default. That is the right posture and worth recording, because a client that assumed sharing was a precondition would hide a working feature behind a setting nobody needs to touch. **One thing this did not settle**: the other six principals reported nothing over a nine-month window, which is equally consistent with "those accounts have empty calendars" — likely, since the session reaches one account — and with "an unreadable principal answers with an empty list rather than an error". Distinguishing them needs a second account with an event in it, and until somebody has one, ihasmail assumes the pessimistic reading everywhere it matters: a participant it cannot read is drawn as unknown rather than as free.
- **An override can move an occurrence, and then `start` and `recurrenceId` mean two different times.** The slot stays where the rule put it and only the clock time moves. **Confirmed live on 0.16.20 (2026-08-31)**: one occurrence of a weekly 09:00 series moved to 14:00 came back `start: 2027-06-14T14:00:00` with `recurrenceId` still `2027-06-14T09:00:00`. This is the right behaviour and it is the reason `recurrenceId` is the handle ihasmail holds: it is the one name for an instance that survives *both* a renumbering and a move, so a mutation can always be re-resolved from it. Worth recording because the mock got it wrong in the other direction — it overwrote an override's `start` with the slot time, so a moved occurrence did not move, and per-occurrence *time* editing looked broken against the mock and correct against the server. Found by asking a real server rather than by reading the mock, which is the only way this kind of disagreement ever surfaces.
- **A synthetic id is only true until the next write, and a stale one is wrong rather than invalid.** Stalwart's expanded-occurrence ids encode a position in the series, and writing a `recurrenceOverrides` entry adds a component that renumbers it. **Confirmed live on 0.16.20 (2026-08-31)**: a five-week series came back as `e i m q u` over 03-01 … 03-29; one override written to 03-08 left the *same five ids* addressing 03-01, 03-15, 03-29, 03-08 and 03-22. Nothing was rejected and nothing reported a change — `i` simply meant a week later than it had a moment earlier. So an id cached across a write silently points at another date, and a delete meant for one occurrence removes a different one. This is the second time the same shape of problem has cost a live debugging session, and it is worth saying plainly why it is dangerous: the failure is not a `notFound` a client would notice, it is a confident answer about the wrong day. ihasmail therefore never mutates an occurrence by an id it is holding. `recurrenceId` is the stable name for a slot in a series — it is the date — so `updateEvent` and `destroyEvent` look the current id up by it immediately before they act, and refuse outright if the date is no longer in the series rather than falling back to the id in hand. The mock renumbers too, by a different permutation to the real server's but with the property that matters, since a mock that kept ids stable would agree with precisely the belief that is wrong.
- **A per-occurrence patch made only of inherited properties creates an override that loses the title.** The twelve properties 0.16.20 drops from a per-occurrence patch are dropped *after* it has decided to write an override, so a patch consisting only of them still writes one — and that override carries the `start` and `duration` the server fills in and nothing else. **Confirmed live on 0.16.20 (2026-08-31)**: `{"privacy": "private"}` aimed at one occurrence answered `updated`, left `privacy` untouched on the series, and left that date with no title at all. A successful response, a silently discarded change, and real data loss on a third property nobody mentioned. ihasmail narrows a per-occurrence patch before sending it and sends nothing when narrowing empties it, which was written as a point of principle — a request whose response could only be a meaningless "updated" is worse than no request — and turns out to prevent this. Worth remembering as the argument for the principle.
- **Recurring events can be edited and deleted one date at a time, since 0.16.20.** A write aimed at a synthetic id was refused outright through 0.16.19; 0.16.20 turns it into a `recurrenceOverrides` entry instead, so "this occurrence" and "the whole series" are now two different things ihasmail asks about before acting. **Confirmed live on 0.16.20 (2026-08-31)** end to end against a five-week series: a legal patch landed on the override with `start` and `duration` filled in by the server; `useDefaultAlerts` was refused with *"This property cannot be modified on a single occurrence."*; a destroy removed one date and left the series; and a base event and one of its instances in the same request were refused together, both ids, with *"A base event and its instances cannot be modified in the same request."* The scope is chosen before the editor opens rather than on save, because it decides which event the form is about — one populated from the master shows the *series'* start date, so editing Wednesday would have offered to move Monday. Two entries below are the sharp edges this turned up.
- Editable date boxes are always Gregorian and in Latin digits, even for locales whose *display* uses another calendar or numbering system (`fa-IR`, `th-TH`, `ar-EG`) — they keep the locale's field order and separator, but a Buddhist-era year in a text box does not round-trip against the Gregorian calendar grid. Non-Gregorian calendar support is not implemented.
- The account locale is read from `x:AccountSettings/get`, whose permission the built-in user role has, falling back to `x:Account/get` (which needs the admin-only `sysAccountGet`). Both are Stalwart 0.16 methods: **on older servers neither is reachable** — they do not implement the registry and reject a request that so much as names the `urn:stalwart:jmap` capability — so there the locale still falls back to the browser's and can be chosen by hand. Confirmed live on 0.16.19 (2026-08-25), once the capability was looked for where Stalwart advertises it; a locale request that is merely refused no longer downgrades the detected generation.
+58
View File
@@ -0,0 +1,58 @@
# Third-party notices
ihasmail is licensed under the AGPL-3.0; see LICENSE. This file records work by
other people that ships inside it and the terms it comes under.
## Colour palettes
Four of the palettes offered in Settings Appearance are the work of their own
projects and are used under the MIT licence. Only the published colour values
are used — no code, and nothing from anyone else's reimplementation of them.
The values as fetched from each project are recorded in
`.palette-sources/palettes-upstream.md`, and the shades between them are
derived by `scripts/build-palettes.py`, which also lifts any tier that would
not meet the contrast ihasmail claims.
### Dracula and Alucard
Copyright (c) 2016 Dracula Theme — https://github.com/dracula/dracula-theme
Licensed under the MIT licence. "Dracula" is the dark variant and "Alucard" the
light one; both are published in that repository's own "Color Palette (OSS)"
section.
### Gruvbox
Copyright (c) 2018 Pavel Pertsev — https://github.com/morhetz/gruvbox
Licensed under the MIT licence.
### Rosé Pine
Copyright (c) 2021 Rosé Pine — https://github.com/rose-pine/rose-pine-theme
Licensed under the MIT licence. The light variant is "Dawn".
### Tokyo Night
Copyright (c) 2019 enkia — https://github.com/enkia/tokyo-night-vscode-theme
Licensed under the MIT licence. The light variant is "Day".
---
The MIT licence, under which all four are used:
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the "Software"),
to deal in the Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
+225 -16
View File
@@ -2,11 +2,16 @@
<img src="web/public/img/logo.png" alt="ihasmail" width="150">
</p>
<p align="center">
<strong><a href="https://demo.ihasmail.com">Try the demo</a></strong><br>
<sub>A working copy with an invented mailbox behind it — no sign-up, nothing real, nothing kept.</sub>
</p>
<p align="center">
<a href="LICENSE"><img alt="Licence: AGPL-3.0-or-later" src="https://img.shields.io/badge/licence-AGPL--3.0--or--later-2dd4bf?style=flat-square"></a>
<a href="https://stalw.art" target="_blank" rel="noreferrer"><img alt="Requires Stalwart 0.16 or newer; tested against 0.16.19" src="https://img.shields.io/badge/Stalwart-0.16.19-6366f1?style=flat-square"></a>
<a href="https://stalw.art" target="_blank" rel="noreferrer"><img alt="Requires Stalwart 0.16 or newer; tested against 0.16.20" src="https://img.shields.io/badge/Stalwart-0.16.20-6366f1?style=flat-square"></a>
<a href="https://docs.ihasmail.org" target="_blank" rel="noreferrer"><img alt="Documentation: docs.ihasmail.org" src="https://img.shields.io/badge/docs-docs.ihasmail.org-0ea5e9?style=flat-square"></a>
<a href="https://linuxexpert.org" target="_blank" rel="noreferrer"><img alt="by LINUXexpert.org" src="https://img.shields.io/badge/by-LINUXexpert.org-0f766e?style=flat-square"></a>
<a href="https://coffeylabs.org" target="_blank" rel="noreferrer"><img alt="by Coffey Labs" src="https://img.shields.io/badge/by-Coffey%20Labs-0f766e?style=flat-square"></a>
</p>
# ihasmail
@@ -24,6 +29,7 @@ durable belongs to Stalwart; the container is disposable.
| --- | --- |
| 🌐 **[ihasmail.org](https://ihasmail.org)** | What it is, what it looks like, the full feature list |
| 📘 **[docs.ihasmail.org](https://docs.ihasmail.org)** | [Installing](https://docs.ihasmail.org/install/) · [Configuring](https://docs.ihasmail.org/configure/) · [Using it](https://docs.ihasmail.org/using/) · [Shortcuts](https://docs.ihasmail.org/shortcuts/) · [Rebranding](https://docs.ihasmail.org/rebranding/) · [Troubleshooting](https://docs.ihasmail.org/troubleshooting/) |
| 📋 **[FEATURES.md](FEATURES.md)** | Everything it does, feature by feature, with the capability each one needs |
| 🧪 **[KNOWN-ISSUES.md](KNOWN-ISSUES.md)** | What was verified live, and where Stalwart departs from a spec |
| 🛣 **[ROADMAP.md](ROADMAP.md)** | What ihasmail does not do, and why |
@@ -44,12 +50,14 @@ More, including the mobile layout, on [ihasmail.org](https://ihasmail.org/#scree
## What's in it
- **Mail** — three-pane Gmail-style layout, conversation view, virtualised list, labels, undo, Gmail search operators and keyboard shortcuts, Sieve rules from a message's context menu, sanitised HTML with remote images blocked, read receipts, invitations and RSVP, multi-composer rich-text editing with signatures, scheduled send and undo send
- **Mail** — three-pane Gmail-style layout, conversation view, virtualised list, labels, undo, Gmail search operators and keyboard shortcuts, Sieve rules from a message's context menu, sanitised HTML with remote images blocked, read receipts, invitations and RSVP, an event made from a message with its guests already in it, multi-composer rich-text editing with signatures, scheduled send and undo send
- **Calendar** — JMAP Calendars / JSCalendar: month/week/day/agenda, recurrence, attendees and free-busy, colour categories
- **Contacts** — JMAP Contacts / JSContact: address books, groups, full editor, vCard import/export
- **Files** — JMAP FileNode: browse, upload, download, rename, move, delete
- **Settings that follow the account**, not the browser — kept in a `settings.json` in the account's own JMAP Files, so ihasmail itself stays stateless
- **Runs read-only** — one optional write path, and with it switched off the container needs no volume and no writable root. `IMMUTABLE=1` is checked at startup rather than trusted, so a half-applied switch refuses to boot instead of failing quietly. See [Running immutably](#running-immutably)
- **Nine new interface languages** — German, Spanish, French, Dutch, Portuguese (Brazil), Russian, Ukrainian, Simplified Chinese and Japanese, alongside English and separate from the date-and-time locale. Every one is marked **Beta**: they were made by AI and no native speaker has read them yet, which Settings says plainly, with a link for reporting anything wrong
- **On a phone** — swipe a message to archive or delete it (either direction, your choice), hold one to select it, hold a folder for its menu, pull the list to refresh, swipe back from a conversation
- **Platform** — installable PWA, Web Push with ihasmail closed, `mailto:` handler, no credentials in the browser, strict CSP, SSRF-safe image proxy
The long version is on [ihasmail.org](https://ihasmail.org/#features); how to
@@ -64,8 +72,8 @@ wrong guess had somewhere to fall back to, so it failed *quietly* — and that
reached production. With one supported generation a wrong guess is a loud error
on the first call.
- Still on 0.15? The last release that runs on it is tagged [`stalwart-0.15-support`](https://github.com/LINUXexpert-org/ihasmail/releases/tag/stalwart-0.15-support).
- Upgrading? [stalwart-migrator](https://github.com/LINUXexpert-org/stalwart-migrator) does it in place, checkpointing every phase and validating afterwards. The live instance moved 0.15.5 → 0.16.19 with eight seconds of downtime and nothing lost.
- Still on 0.15? The last release that runs on it is tagged [`stalwart-0.15-support`](https://github.com/Coffey-Labs/ihasmail/releases/tag/stalwart-0.15-support).
- Upgrading? [stalwart-migrator](https://github.com/Coffey-Labs/stalwart-migrator) does it in place, checkpointing every phase and validating afterwards. The live instance moved 0.15.5 → 0.16.19 with eight seconds of downtime and nothing lost.
## Quick start (Docker)
@@ -86,6 +94,32 @@ Full instructions, TLS, and every environment variable:
[Installing](https://docs.ihasmail.org/install/) ·
[Configuring](https://docs.ihasmail.org/configure/).
### Container images
Published to GHCR on every release, for `linux/amd64` and `linux/arm64`:
```bash
docker pull ghcr.io/coffey-labs/ihasmail:latest
```
| Tag | What it is |
| --- | --- |
| `latest` | The newest release. Prereleases never move it |
| `2026.9.2-pr243` | One specific build — the [version](#version-numbers) with `+` written as `-`, because a Docker tag may not contain `+` |
Pin the dated tag in anything you care about. `latest` is a moving target by
definition, and rolling back to a named tag is a `docker run` rather than a
rebuild.
Building it yourself stays fully supported and is what `docker compose up
--build` above does — the image is a convenience, not a new requirement. If you
build by hand, pass the version in, because `.dockerignore` excludes `.git` and
the build cannot work out what it is:
```bash
docker build --build-arg IHASMAIL_VERSION="$(node scripts/version.mjs)" -t ihasmail:local .
```
### Running immutably
The server writes to exactly one path, the optional `SESSION_FILE`. Clear it
@@ -109,6 +143,163 @@ nowhere to live across a restart. Removing it means moving the session upstream
into a token Stalwart itself issues and can revoke, which is what the OAuth work
in [ROADMAP.md](ROADMAP.md) is for.
### Several Stalwart servers
One ihasmail can front more than one Stalwart, choosing by the domain somebody
signs in with. **`STALWART_URL` stays required and stays the default**, so an
installation that sets nothing else behaves exactly as it always has.
```bash
-e STALWART_SERVERS_FILE=/etc/ihasmail/servers.json \
-v /srv/ihasmail/servers.json:/etc/ihasmail/servers.json:ro
```
```json
{
"example.com": "https://mail.example.com",
"customer-b.test": "https://jmap.customer-b.test"
}
```
[`stalwart-servers.example.json`](stalwart-servers.example.json) is that file
with the rules written in it.
A domain nobody listed — and a bare username, which Stalwart accepts and which
has no domain at all — goes to `STALWART_URL`. **A listed domain never falls
back.** If its server is unreachable that sign-in fails rather than retrying
against the default, because falling back would authenticate somebody against a
server their domain was deliberately routed away from; if the same account name
existed there they would land in another tenant's mailbox.
Read once at startup, so editing it means restarting the container. Malformed
JSON, a duplicate domain once lower-cased, or a value that is not an `http(s)`
URL stops the server rather than failing quietly at somebody's sign-in. The
servers themselves are not contacted at boot — a mapping is a routing table,
not a health check, and one customer's outage must not stop ihasmail starting
for everybody else.
This is one server per *person*, chosen at sign-in. Several servers at once for
one person, with unified or cross-account views, is not supported: JMAP account
ids are only unique within a server, so it would mean namespacing ids through
the proxy. Reading somebody else's mail, calendars or files on the *same* server
already works through JMAP sharing.
### Settings the installation decides
A deployment can seed and lock user settings, which is what a school wanting
"warn about outside senders" on for three thousand pupils needs — asking three
thousand pupils is not a plan.
```bash
-e SETTINGS_DEFAULTS='{"externalSenderBanner":true}' \
-e SETTINGS_ENFORCED='{"externalRecipientConfirm":true}'
```
Three powers, and the differences between them matter:
| Section | Applies to | Reader can change it |
| --- | --- | --- |
| `defaults` | accounts that have never had settings of their own | yes, at any time |
| `enforced` | everyone, on every load | no — the control goes dead |
| `changes` | everyone, **once each**, including existing accounts | yes, afterwards, and it stays changed |
`changes` is the one that needs explaining. It turns something on for people who
are *already here* — the reason a plain default is not enough — while still
leaving them the last word. Each entry carries its own `version`, which every
account remembers once it has had it, so the change is applied exactly once per
person and a reader who turns it back off keeps it off. It is a schema migration
in shape, and that is deliberately whose idea it was ([#207]).
Nothing is configured by default: an installation that sets none of these
behaves exactly as ihasmail always has.
### Passing a policy to Docker
Where a file is easier to manage than JSON quoted in a unit file — and it
usually is once there are `changes` in it — mount one and name it:
```bash
docker run -d --name ihasmail \
-e STALWART_URL=https://mail.example.org \
-e APP_SECRET="$(openssl rand -hex 32)" \
-e SETTINGS_POLICY_FILE=/etc/ihasmail/policy.json \
-v /srv/ihasmail/policy.json:/etc/ihasmail/policy.json:ro \
-p 8080:8080 ghcr.io/coffey-labs/ihasmail:latest
```
```json
{
"defaults": { "externalSenderBanner": true },
"enforced": { "externalRecipientConfirm": true },
"changes": [
{ "version": "20260902084513", "settings": { "externalSenderBanner": true } },
{ "version": "20261014091500", "settings": { "externalLinkWarning": true } }
]
}
```
[`settings-policy.example.json`](settings-policy.example.json) in this repo is
that file with every section explained in it — copy it and delete what you do
not want.
Mount it read-only: the server only ever reads it, and `:ro` keeps that true
under `--read-only` as well.
Or without a file at all, which is what an immutable deployment with no volume
wants:
```bash
docker run -d --name ihasmail --read-only --tmpfs /tmp \
-e IMMUTABLE=1 -e SESSION_FILE= \
-e STALWART_URL=https://mail.example.org \
-e APP_SECRET="$(openssl rand -hex 32)" \
-e SETTINGS_DEFAULTS='{"externalSenderBanner":true}' \
-e SETTINGS_ENFORCED='{"externalRecipientConfirm":true}' \
-e SETTINGS_CHANGES='[{"version":"20260902084513","settings":{"externalSenderBanner":true}}]' \
-p 8080:8080 ghcr.io/coffey-labs/ihasmail:latest
```
In `docker-compose.yml`:
```yaml
services:
ihasmail:
image: ghcr.io/coffey-labs/ihasmail:latest
environment:
SETTINGS_POLICY_FILE: /etc/ihasmail/policy.json
volumes:
- ./policy.json:/etc/ihasmail/policy.json:ro
```
A policy is read once at startup, so **editing it means restarting the
container**. There is no reload signal, deliberately: an installation-wide
setting changing under a running instance would be harder to reason about than
one that changes when you say so.
### Writing a policy
Both sections take the same names and values a settings export uses, so
`Settings → General → Export` on one account you have configured by hand is the
quickest way to write one — copy the keys you care about out of the file.
Three checks worth knowing about, because they fail loudly rather than quietly:
- **Malformed JSON stops the server at startup.** A policy that silently did not
apply is indistinguishable from the feature not working.
- **Every change needs a unique `version`.** Two changes sharing one, or a change
with no `version` or no `settings`, is a startup error.
- **Keys this build does not have are dropped**, the same rule an imported
settings file gets. A `changes` entry whose keys are *all* unknown is dropped
whole rather than recorded as applied, so it still runs on an ihasmail that
does have the setting.
Enforcement is applied in the settings store rather than only on the controls,
so an imported settings file, a settings file synced from a device that predates
the policy, and "reset to defaults" cannot get around it. Reset returns to your
defaults, not to ihasmail's.
[#207]: https://github.com/Coffey-Labs/ihasmail/issues/207
## Architecture
```
@@ -161,21 +352,39 @@ the sign-in refusal can be tested.
### Version numbers
`ihasmail v2.16.84``2` is ihasmail's own major, `16` the Stalwart generation
this build targets, `84` the pull request the commit came from. The first two
live in the root `package.json`; the third comes from git at build time, since
it does not exist until the PR has merged. A commit that did not arrive through
a PR carries the last number plus its short SHA — `2.16.84+g1fa6578`.
`ihasmail v2026.8.30+pr129` — the date of the commit this was built from, and
the pull request that commit arrived through. A commit that did not arrive
through one carries its short SHA instead: `2026.8.30+g1fa6578`. It all comes
from git at build time; nothing writes a version into the tree, and
`package.json` sits at `0.0.0` because it is no longer the source of anything.
The date is the commit's own rather than today's, so rebuilding an old commit
gives the version it had the first time.
```bash
node scripts/version.mjs # the version for the current checkout
docker build --build-arg IHASMAIL_VERSION="$(node scripts/version.mjs)" -t ihasmail:2.16 .
docker build --build-arg IHASMAIL_VERSION="$(node scripts/version.mjs)" -t ihasmail:2026.8.30 .
```
`.dockerignore` excludes `.git` deliberately, so an image build cannot work this
out for itself — pass it in. Left out, the build falls back to the base version
from `package.json`, so a version with no PR number means whoever built the
image did not pass one.
out for itself — pass it in. Left out, the build reports `0.0.0`, which is meant
to look wrong: a version with no `+pr` or `+g` means whoever built the image did
not pass one.
The version says nothing about Stalwart, deliberately. It used to: `2.16.x` had
`16` for the 0.16 generation it targeted, which leaves nowhere to go once
Stalwart reaches 1.0 — `2.1` sorts *below* the `2.16` already deployed, so every
image and About screen would read as a downgrade. Which Stalwart a build needs is
stated where it can be precise, in the badge at the top of this file and in
[KNOWN-ISSUES.md](KNOWN-ISSUES.md), rather than compressed into one digit.
The pull request lives after the `+`, as build metadata, because it is
provenance rather than a rank: at the rate they merge here it climbs without
bound and says nothing about how new a build is. Everything after the `+` is
ignored when versions are compared, which is the right reading — two builds from
the same day differ in where they came from, not in age. Nothing here depends on
that comparison: images are pruned oldest-first by creation time, and a rollback
names a git ref.
### Deploying
@@ -188,7 +397,7 @@ container, waits for healthy, then prunes all but the newest
```bash
./deploy.sh # origin/main, asks before shipping new commits
./deploy.sh --dry-run # run the guards and stop
./deploy.sh v2.16.84 --yes # a named ref, no prompt (there is no tty over ssh)
./deploy.sh v2026.8.30 --yes # a named ref, no prompt (there is no tty over ssh)
```
`--yes` does not override a hold; clearing one means deleting its line.
@@ -200,7 +409,7 @@ container, waits for healthy, then prunes all but the newest
## License
Copyright (C) 2026 LINUXexpert.org — AGPL-3.0-or-later. See
Copyright (C) 2026 Coffey Labs — AGPL-3.0-or-later. See
[LICENSE](LICENSE).
ihasmail was relicensed from GPL-3.0 to AGPL-3.0 on 2026-08-25: webmail is
+10 -5
View File
@@ -1,12 +1,17 @@
# Roadmap / not yet
Things ihasmail does not do, and why. Anything with an issue number is tracked
in [the issue tracker](https://github.com/LINUXexpert-org/ihasmail/issues); the
rest is here because the answer is "no", not "not yet".
Things ihasmail does not do, and why. An issue number here says where the entry
came from, not that it is tracked elsewhere — a report can be closed because the
bug in it was fixed while the larger thing it asked for stays on this page. What
is genuinely open lives in [the issue tracker](https://github.com/Coffey-Labs/ihasmail/issues);
the rest is here because the answer is "no", not "not yet".
See [KNOWN-ISSUES.md](KNOWN-ISSUES.md) for what is built but worth knowing about.
- **Sharing a mail folder.** Stalwart stores the share and never delivers it; see [KNOWN-ISSUES.md](KNOWN-ISSUES.md). Withdrawn until the server does something with it. Sharing files, calendars and address books is unaffected and works.
- **A scheduling view of its own**, for asking "when is everyone free next week?" without an event in hand. The grid itself is built and lives in the event editor — a row per participant, steppable, and clickable to place the event — which is where the question gets asked while you are arranging something. What is not built is the same thing as a destination you can visit with nothing in progress. Came out of [#172](https://github.com/Coffey-Labs/ihasmail/issues/172), which asked for a separate view and is closed by the panel: the reasoning for putting it in the editor is that a separate surface can only ever tell you a time you then retype, whereas one beside the event can set it. It stays here rather than in the tracker because nobody has yet said they want to ask the question on its own.
- **Per-message actions from the message list on a touchscreen.** Reply, Forward and compose-as-new are on the list row's context menu, which is a right-click — and holding a row on a phone starts selection instead, so none of them are reachable there. They are all available inside a thread, which is where the actions on a single message belong; what is missing is the shortcut from the list. Fixing it means deciding what a long press should do when it already means something, which is a bigger question than the actions themselves.
- Snooze (nothing in JMAP or Stalwart supports it, and ihasmail never stores a password, so nothing could act on a mailbox while you are away)
- Translations (strings are English-only for now)
- **Two-factor sign-in.** Today an account with 2FA must use an app password (see [Quick start](README.md#quick-start-docker)), and Settings Security offers no way to switch 2FA *on* — only off, for an account that already has it. Supporting a TOTP code directly means implementing OAuth: Stalwart offers the authorization-code and device flows and no password grant, so ihasmail would hand sign-in to Stalwart's own login and come back with a token. That is a better security posture than the sealed password it holds now — a refresh token rather than a credential — but it replaces ihasmail's own sign-in page for those users and may need an OAuth client registered. Reported as [#75](https://github.com/LINUXexpert-org/ihasmail/issues/75)
- **A translation anybody has checked.** The translations themselves shipped on 2026-08-31 and are no longer on this page: nine of them, alongside English, and the extraction that had always been the hard half is done — see [FEATURES.md](FEATURES.md#interface-language). What is *not* done is the other half, and it is the half that cannot be bought or automated. All nine were produced by AI against standard dictionaries and **not one has been read by anybody who speaks the language**, which is exactly where a bad translation does harm rather than merely looking untidy. They ship marked Beta, with that said in Settings and a link for reporting anything wrong, because shipping them quietly would ask people to trust text nobody has checked. A language loses the Beta mark when a speaker reads it and says so — a deliberate act by a person, not something a coverage percentage earns. If you speak one of them and are willing to read a few hundred strings, that is the single most useful thing anyone could contribute right now.
- **Right-to-left languages.** Arabic, Hebrew and Persian are held back deliberately, and not for want of translators. RTL is bidi and layout work throughout — mirrored panes, gesture directions, icon sides, the message list's own geometry — and a catalogue without it produces a page that is translated and unusable. Adding one is not another entry in the picker.
- **Two-factor sign-in.** Today an account with 2FA must use an app password (see [Quick start](README.md#quick-start-docker)), and Settings Security offers no way to switch 2FA *on* — only off, for an account that already has it. Supporting a TOTP code directly means implementing OAuth: Stalwart offers the authorization-code and device flows and no password grant, so ihasmail would hand sign-in to Stalwart's own login and come back with a token. That is a better security posture than the sealed password it holds now — a refresh token rather than a credential — but it replaces ihasmail's own sign-in page for those users and may need an OAuth client registered. Came out of [#75](https://github.com/Coffey-Labs/ihasmail/issues/75), which is closed: what was reported there was a sign-in refused with nothing but "Invalid credentials", and that was fixed by saying what is actually happening and pointing at app passwords. The OAuth work it uncovered is tracked here rather than as an open issue, so there is no ticket to watch for it.
+36 -17
View File
@@ -20,8 +20,12 @@
# whatever main happens to have picked up since the last
# release.
#
# --dry-run runs both guards, says what it would deploy, and stops before
# building or touching the container.
# --dry-run checks the hold list, says what it would deploy, and stops
# before building or touching the container. It does not ask
# for confirmation: there is nothing to agree to when nothing
# changes, and needing a terminal would make it useless over
# SSH -- which is where wanting to look before leaping is most
# likely.
#
# The container is replaced rather than restarted, because the image is rebuilt
# from the new checkout. Data lives in a named volume and survives that; the
@@ -50,6 +54,13 @@ VOLUME="${IHASMAIL_VOLUME:-ihasmail-data}"
# somewhere -- refuses to start here instead of looking fine until the next
# redeploy signs everyone out.
#
# It defaults to on, and the reason is what happens when it does not. Forgetting
# the variable used to hand back a writable container with a volume mounted --
# quietly, and then report healthy. Nothing in the output said the immutability
# had gone; `docker inspect` was the only place it showed. So the safe posture
# is what you get by default, and giving it up is the half that has to be
# deliberate, which is the way round these two should always have been.
#
# The standing cost is that sessions do not outlive a deploy, because there is
# nowhere left to keep them. Going back is this variable and nothing else:
#
@@ -57,7 +68,7 @@ VOLUME="${IHASMAIL_VOLUME:-ihasmail-data}"
#
# The named volume is never touched either way, so whatever was in it when the
# switch was thrown is still there to come back to.
IMMUTABLE="${IHASMAIL_IMMUTABLE:-0}"
IMMUTABLE="${IHASMAIL_IMMUTABLE:-1}"
# Image repository. Each build is tagged with its version as well, so an
# earlier one can be run again without rebuilding it.
IMAGE_REPO="${IHASMAIL_IMAGE:-ihasmail}"
@@ -97,7 +108,7 @@ for arg in "$@"; do
case "$arg" in
-y|--yes) ASSUME_YES=1 ;;
-n|--dry-run) DRY_RUN=1 ;;
-h|--help) sed -n '2,28p' "$0"; exit 0 ;;
-h|--help) awk 'NR > 1 { if (/^#/) print; else exit }' "$0"; exit 0 ;;
-*) echo "unknown option: $arg" >&2; exit 2 ;;
*)
if [ -n "$REF" ]; then echo "give at most one git-ref (got '$REF' and '$arg')" >&2; exit 2; fi
@@ -145,7 +156,22 @@ if [ -n "$NEW" ]; then
echo "==> $(git log --oneline -1 "$CURRENT") -> $(git log --oneline -1 "$TARGET")"
echo "==> introduces:"
printf '%s\n' "$NEW" | sed 's/^/ /'
if [ "$ASSUME_YES" -ne 1 ]; then
else
echo "==> already at $(git log --oneline -1 "$TARGET"); rebuilding"
fi
# A dry run has now said everything it has to say, so it stops here -- before
# the confirmation rather than after it. Asking whether to go ahead with
# something that is not going to happen is noise at a terminal; over SSH it was
# worse, because the refusal came out *instead of* the report above and a dry
# run could not be used from another machine at all. Which is the machine you
# are most likely to be on when you want one.
if [ "$DRY_RUN" -eq 1 ]; then
echo "==> dry run: would deploy $(git log --oneline -1 "$TARGET"); nothing was changed"
exit 0
fi
if [ -n "$NEW" ] && [ "$ASSUME_YES" -ne 1 ]; then
if [ -t 0 ]; then
read -r -p "deploy these to production? [y/N] " reply
case "$reply" in
@@ -157,14 +183,6 @@ if [ -n "$NEW" ]; then
echo " re-run with --yes if that is what you mean, or name the ref you want." >&2
exit 1
fi
fi
else
echo "==> already at $(git log --oneline -1 "$TARGET"); rebuilding"
fi
if [ "$DRY_RUN" -eq 1 ]; then
echo "==> dry run: would deploy $(git log --oneline -1 "$TARGET"); nothing was changed"
exit 0
fi
git reset --hard --quiet "$TARGET"
@@ -197,10 +215,11 @@ prune_old_images() {
}
VERSION="$(node scripts/version.mjs)"
# A Docker tag may not contain "+", which a version for a commit that did not
# come through a pull request does: 2.16.57+g1fa6578. The image is tagged with
# the "+" turned into "-"; what the build is *told* it is keeps the real form,
# so About and /api/health still report it correctly.
# A Docker tag may not contain "+", and every version has one now:
# 2026.8.30+pr129, or +g1fa6578 for a commit that did not come through a pull
# request. The image is tagged with the "+" turned into "-"; what the build is
# *told* it is keeps the real form, so About and /api/health still report it
# correctly.
TAG="${VERSION//+/-}"
echo "==> building $(git log --oneline -1) as v$VERSION"
docker build \
+9 -2
View File
@@ -1,6 +1,12 @@
services:
ihasmail:
build: .
build:
context: .
args:
# Passed to the build as well as the run because the web bundle writes
# its own asset URLs: a build that does not know the prefix produces an
# app that cannot load itself under one. Empty is the domain root.
BASE_PATH: ${BASE_PATH:-}
image: ihasmail:2
restart: unless-stopped
ports:
@@ -9,7 +15,8 @@ services:
STALWART_URL: ${STALWART_URL:?set STALWART_URL in .env}
APP_SECRET: ${APP_SECRET:?set APP_SECRET in .env (openssl rand -base64 48)}
APP_NAME: ${APP_NAME:-ihasmail}
SOURCE_URL: ${SOURCE_URL:-https://github.com/LINUXexpert-org/ihasmail}
BASE_PATH: ${BASE_PATH:-}
SOURCE_URL: ${SOURCE_URL:-https://github.com/Coffey-Labs/ihasmail}
TRUST_PROXY: "1"
IMAGE_PROXY: "1"
volumes:
+1 -1
View File
@@ -13,7 +13,7 @@ const chrome = spawn("google-chrome-stable", [
"--headless=new", `--remote-debugging-port=${PORT}`, "--hide-scrollbars",
"--no-first-run", "--no-default-browser-check",
"--window-size=1420,790", "--force-device-scale-factor=1",
"--user-data-dir=/tmp/claude-light-profile", "about:blank",
"--user-data-dir=/tmp/ihasmail-light-profile", "about:blank",
], { stdio: "ignore" });
const json = async (p) => { for (let i = 0; i < 60; i++) { try { return await (await fetch(`http://127.0.0.1:${PORT}${p}`)).json(); } catch { await sleep(250); } } throw new Error("no chrome"); };
+1 -1
View File
@@ -48,7 +48,7 @@ const PORT = 9333;
const chrome = spawn("google-chrome-stable", [
"--headless=new", `--remote-debugging-port=${PORT}`, "--hide-scrollbars",
"--no-first-run", "--no-default-browser-check", "--disable-gpu",
`--user-data-dir=/tmp/claude-shots-profile`, "about:blank",
`--user-data-dir=/tmp/ihasmail-shots-profile`, "about:blank",
], { stdio: "ignore" });
const json = async (path) => {
+19 -4
View File
@@ -1,12 +1,12 @@
{
"name": "ihasmail",
"version": "2.0.0",
"version": "0.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ihasmail",
"version": "2.0.0",
"version": "0.0.0",
"license": "AGPL-3.0-or-later",
"workspaces": [
"server",
@@ -2321,6 +2321,18 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/marked": {
"version": "18.0.11",
"resolved": "https://registry.npmjs.org/marked/-/marked-18.0.11.tgz",
"integrity": "sha512-HnslJfsZkRPBDJRHvVtAaWlZHEpSu7u8LgQuJCELjRKuWR+hpq4A7sLq3p8HaI9ypVoXDXxV34CsQJEe1+J5Aw==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/mitt": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
@@ -3814,7 +3826,8 @@
},
"server": {
"name": "@ihasmail/server",
"version": "2.0.0",
"version": "2.16.0",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@hono/node-server": "^1.13.8",
"hono": "^4.7.4"
@@ -3827,11 +3840,13 @@
},
"web": {
"name": "@ihasmail/web",
"version": "2.0.0",
"version": "0.0.0",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@tanstack/react-virtual": "^3.13.2",
"dompurify": "^3.2.4",
"lucide-react": "^0.477.0",
"marked": "^18.0.11",
"qrcode-generator": "^2.0.4",
"react": "^19.0.0",
"react-dom": "^19.0.0",
+5 -2
View File
@@ -1,6 +1,6 @@
{
"name": "ihasmail",
"version": "2.16.0",
"version": "0.0.0",
"private": true,
"description": "ihasmail \u2014 a fast, modern JMAP webmail for Stalwart Mail Server",
"license": "AGPL-3.0-or-later",
@@ -21,7 +21,10 @@
"lint": "npm run typecheck",
"mock": "npm run mock -w server",
"dev:mock": "concurrently -n mock,server,web -c yellow,blue,magenta \"npm run mock -w server\" \"STALWART_URL=http://127.0.0.1:8788 npm run dev -w server\" \"npm run dev -w web\"",
"dev:mock:no-future-release": "concurrently -n mock,server,web -c yellow,blue,magenta \"npm run mock:no-future-release -w server\" \"STALWART_URL=http://127.0.0.1:8788 npm run dev -w server\" \"npm run dev -w web\""
"dev:mock:no-future-release": "concurrently -n mock,server,web -c yellow,blue,magenta \"npm run mock:no-future-release -w server\" \"STALWART_URL=http://127.0.0.1:8788 npm run dev -w server\" \"npm run dev -w web\"",
"i18n:coverage": "node scripts/i18n-coverage.mjs",
"i18n:check": "node scripts/i18n-catalog-check.mjs && node scripts/i18n-literals.mjs",
"dev:mock:no-keyword-sort": "concurrently -n mock,server,web -c yellow,blue,magenta \"npm run mock:no-keyword-sort -w server\" \"STALWART_URL=http://127.0.0.1:8788 npm run dev -w server\" \"npm run dev -w web\""
},
"devDependencies": {
"concurrently": "^9.1.2",
+4
View File
@@ -0,0 +1,4 @@
/** Types for `basePath.mjs`, which is plain JS so both packages can import it. */
export function normalizeBasePath(value: string | undefined | null): string;
export function baseUrlOf(basePath: string | undefined | null): string;
export function stripBasePath(basePath: string | undefined | null, pathname: string): string | null;
+70
View File
@@ -0,0 +1,70 @@
/**
* The subpath ihasmail is mounted at, from `BASE_PATH`.
*
* Plain JS, and here rather than in either package, because both halves of the
* app have to agree on the answer: `web/vite.config.ts` bakes it into the built
* asset URLs and `server/src/config.ts` reads it again to decide where the
* routes live. Two implementations of "what does /mail/ mean" is exactly the
* bug where the server serves an app whose own script tags point somewhere
* else, and the page comes up blank with no clue why.
*
* The canonical form is a leading slash and no trailing one -- `/mail` -- with
* the empty string for the root. Empty is the ordinary case and it is chosen
* so that the concatenation `${base}/api/health` is right without a branch:
* anything with a trailing slash would need one, and every caller that forgot
* would produce `//api/health`, which browsers read as a *protocol-relative
* URL* and send to a host called `api`. Getting that wrong once, quietly, in
* one call site is worse than the small awkwardness of an empty string.
*/
/**
* Reduce whatever the operator wrote to the canonical form.
*
* Accepts `/mail`, `mail`, `/mail/`, `mail/`, `//mail//`, an empty string and
* undefined, because the variable is typed by a human into a compose file or a
* `docker run` line and every one of those is a reasonable thing to write.
* Being strict here would mean an instance that refuses to start over a
* trailing slash, which teaches nobody anything.
*
* A value of `/` means the root and is returned as empty, since `/` and `""`
* describe the same mount and only one of them can be the canonical one.
*/
export function normalizeBasePath(value) {
if (typeof value !== "string") return "";
// Collapse repeated separators before trimming: `//mail//` is a typo, not a
// path with empty segments in it, and `path.posix.normalize` is not
// available to the browser bundle that also uses this.
const trimmed = value.trim().replace(/\/+/g, "/").replace(/^\/|\/$/g, "");
if (!trimmed) return "";
return `/${trimmed}`;
}
/**
* The same value as a directory URL -- `/` or `/mail/`.
*
* This is the form Vite's `base` and the PWA scope want, both of which are
* about "the directory the app lives in" rather than a path to join onto.
*/
export function baseUrlOf(basePath) {
return `${normalizeBasePath(basePath)}/`;
}
/**
* Whether `pathname` falls inside the mount, and what is left of it if so.
*
* Returns null for anything outside, so a caller can 404 rather than guess.
* The bare mount with no trailing slash -- a request for `/mail` -- yields
* `/`, because that is the app's own index and typing the prefix without the
* slash is how people reach it.
*
* The comparison is deliberately not `startsWith(base)`: that would let
* `/mailbox` in under a `/mail` mount and serve it the app shell, which is
* both wrong and a small open door for a neighbouring site on the same host.
*/
export function stripBasePath(basePath, pathname) {
const base = normalizeBasePath(basePath);
if (!base) return pathname;
if (pathname === base) return "/";
if (pathname.startsWith(`${base}/`)) return pathname.slice(base.length);
return null;
}
+314
View File
@@ -0,0 +1,314 @@
#!/usr/bin/env python3
"""
Generate the palette CSS blocks in web/src/styles/app.css.
Every colour here comes from the palette's own project (all MIT); the values
are recorded in .palette-sources/palettes-upstream.md. What this script adds is
the *derivation*: ihasmail needs thirty-odd tokens and these projects publish
between twelve and twenty, so the tiers in between are computed rather than
guessed, and every text colour is then checked against the surface it sits on.
The check is the reason this is a script and not a hand-written block. ihasmail
claims WCAG AA, and several of these palettes do not meet it as published --
Dracula's comment grey on its own background is about 3.0:1, well under the 4.5
that normal text needs. Lifting those tiers by eye is how a claim quietly stops
being true; here it is arithmetic, and the script fails loudly if a token it
emitted would not pass.
Run: python3 scripts/build-palettes.py
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
CSS = ROOT / "web/src/styles/app.css"
BEGIN = "/* === generated palettes: begin === */"
END = "/* === generated palettes: end === */"
# ---------------------------------------------------------------- colour maths
def parse(hex_: str) -> tuple[float, float, float]:
h = hex_.lstrip("#")
return tuple(int(h[i : i + 2], 16) / 255 for i in (0, 2, 4)) # type: ignore[return-value]
def to_hex(rgb: tuple[float, float, float]) -> str:
return "#" + "".join(f"{max(0, min(255, round(c * 255))):02x}" for c in rgb)
def _lin(c: float) -> float:
return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
def luminance(hex_: str) -> float:
r, g, b = (_lin(c) for c in parse(hex_))
return 0.2126 * r + 0.7152 * g + 0.0722 * b
def contrast(a: str, b: str) -> float:
la, lb = luminance(a), luminance(b)
hi, lo = max(la, lb), min(la, lb)
return (hi + 0.05) / (lo + 0.05)
def mix(a: str, b: str, t: float) -> str:
ca, cb = parse(a), parse(b)
return to_hex(tuple(ca[i] + (cb[i] - ca[i]) * t for i in range(3)))
def rgba(hex_: str, alpha: float) -> str:
r, g, b = (round(c * 255) for c in parse(hex_))
return f"rgba({r}, {g}, {b}, {alpha})"
def toward_contrast(colour: str, bg: str, target: float, dark_ui: bool) -> str:
"""Nudge `colour` away from `bg` until it clears `target`.
Towards white on a dark background and towards black on a light one, so a
lifted tier keeps its hue instead of washing out to grey.
"""
if contrast(colour, bg) >= target:
return colour
anchor = "#ffffff" if dark_ui else "#000000"
best = colour
for i in range(1, 101):
candidate = mix(colour, anchor, i / 100)
best = candidate
if contrast(candidate, bg) >= target:
return candidate
return best
# ------------------------------------------------------------------- palettes
# Roles as each project publishes them. Nothing here is invented; see
# .palette-sources/palettes-upstream.md for where each value came from.
# ihasmail's own palette has a hand-written dark block further up the file --
# it is the identity this project is painted in, and regenerating it would
# quietly move colours nobody asked to move. Only its light half is derived
# here, which is why it appears in LIGHT_ONLY.
LIGHT_ONLY = {"ihasmail"}
SOURCES = {
"ihasmail": {
# Daylight over the same teal-navy: the dark palette's background
# becomes the text, so the two halves are recognisably one palette read
# from either end. The cat is still orange, so the star still is.
"light": dict(
bg="#f4f9f9", elev="#ffffff", sunken="#e7f1f2", line="#cfe2e4",
fg="#0d2430", muted="#4a6b74", accent="#46cac3", link="#0e7490",
danger="#dc2626", warn="#b45309", success="#15803d", star="#f9a34b",
q1="#0e7490", q2="#15803d", q3="#7c3aed",
),
"dark": {}, # see LIGHT_ONLY
},
"dracula": {
"dark": dict(
bg="#282a36", elev="#2f3140", sunken="#21222c", line="#44475a",
fg="#f8f8f2", muted="#6272a4", accent="#bd93f9", link="#8be9fd",
danger="#ff5555", warn="#ffb86c", success="#50fa7b", star="#f1fa8c",
q1="#8be9fd", q2="#50fa7b", q3="#ff79c6",
),
"light": dict( # Alucard
bg="#fffbeb", elev="#ffffff", sunken="#f6f1de", line="#cfcfde",
fg="#1f1f1f", muted="#6c664b", accent="#644ac9", link="#036a96",
danger="#cb3a2a", warn="#a34d14", success="#14710a", star="#846e15",
q1="#036a96", q2="#14710a", q3="#a3144d",
),
},
"gruvbox": {
"dark": dict(
bg="#282828", elev="#32302f", sunken="#1d2021", line="#504945",
fg="#ebdbb2", muted="#a89984", accent="#83a598", link="#8ec07c",
danger="#fb4934", warn="#fe8019", success="#b8bb26", star="#fabd2f",
q1="#83a598", q2="#b8bb26", q3="#d3869b",
),
"light": dict(
bg="#fbf1c7", elev="#f9f5d7", sunken="#f2e5bc", line="#d5c4a1",
fg="#3c3836", muted="#7c6f64", accent="#076678", link="#427b58",
danger="#9d0006", warn="#af3a03", success="#79740e", star="#b57614",
q1="#076678", q2="#79740e", q3="#8f3f71",
),
},
"rose-pine": {
"dark": dict( # main
bg="#191724", elev="#1f1d2e", sunken="#14121f", line="#26233a",
fg="#e0def4", muted="#908caa", accent="#c4a7e7", link="#9ccfd8",
danger="#eb6f92", warn="#f6c177", success="#31748f", star="#f6c177",
q1="#9ccfd8", q2="#31748f", q3="#c4a7e7",
),
"light": dict( # dawn
bg="#faf4ed", elev="#fffaf3", sunken="#f2e9e1", line="#dfd9d2",
fg="#464261", muted="#797593", accent="#907aa9", link="#286983",
danger="#b4637a", warn="#ea9d34", success="#56949f", star="#ea9d34",
q1="#286983", q2="#56949f", q3="#907aa9",
),
},
"tokyo-night": {
"dark": dict( # night
bg="#1a1b26", elev="#1f2130", sunken="#16161e", line="#363b54",
fg="#c0caf5", muted="#a9b1d6", accent="#7aa2f7", link="#7dcfff",
danger="#f7768e", warn="#e0af68", success="#9ece6a", star="#e0af68",
q1="#7dcfff", q2="#9ece6a", q3="#bb9af7",
),
"light": dict( # day
bg="#e6e7ed", elev="#f2f3f7", sunken="#d6d8df", line="#c1c2c7",
fg="#343b59", muted="#484c61", accent="#2959aa", link="#006c86",
danger="#8c4351", warn="#8f5e15", success="#385f0d", star="#8f5e15",
q1="#006c86", q2="#385f0d", q3="#65359d",
),
},
}
# What each token has to clear, and against which surface. Normal text is 4.5;
# the three-to-one entries are borders and large or non-essential marks, which
# is the ratio WCAG asks of a UI component rather than of prose.
TEXT_ON_BG = {"fg": 7.0, "muted": 4.5, "faint": 4.5, "link": 4.5, "danger": 4.5, "warn": 4.5, "success": 4.5}
UI_ON_BG = {"accent": 3.0, "border-strong": 3.0, "star": 3.0}
def build(pid: str, mode: str, src: dict[str, str]) -> tuple[dict[str, str], list[str]]:
dark = mode == "dark"
bg, fg = src["bg"], src["fg"]
notes: list[str] = []
def lift(name: str, colour: str, target: float) -> str:
out = toward_contrast(colour, bg, target, dark)
if out != colour:
notes.append(f"{name} {colour} -> {out} ({contrast(colour, bg):.2f} -> {contrast(out, bg):.2f})")
return out
muted = lift("muted", src["muted"], TEXT_ON_BG["muted"])
# Between muted and the background, but still readable: this is timestamps
# and counts, which are small and still prose.
faint = lift("faint", mix(muted, bg, 0.30), TEXT_ON_BG["faint"])
link = lift("link", src["link"], TEXT_ON_BG["link"])
danger = lift("danger", src["danger"], TEXT_ON_BG["danger"])
warn = lift("warn", src["warn"], TEXT_ON_BG["warn"])
success = lift("success", src["success"], TEXT_ON_BG["success"])
accent = lift("accent", src["accent"], UI_ON_BG["accent"])
star = lift("star", src["star"], UI_ON_BG["star"])
border_strong = lift("border-strong", mix(src["line"], fg, 0.15), UI_ON_BG["border-strong"])
accent_soft = rgba(accent, 0.16) if dark else mix(accent, bg, 0.86)
accent_soft_bg = mix(accent, bg, 0.84) if dark else mix(accent, bg, 0.86)
accent_soft_fg = toward_contrast(accent, accent_soft_bg, 4.5, dark)
accent_fg = "#ffffff" if contrast("#ffffff", accent) >= contrast(bg, accent) else bg
tokens = {
"--bg": bg,
"--bg-elev": src["elev"],
"--bg-sunken": src["sunken"],
"--bg-hover": rgba(fg, 0.06),
"--bg-active": rgba(fg, 0.11),
"--fg": fg,
"--fg-muted": muted,
"--fg-faint": faint,
"--border": src["line"],
"--border-strong": border_strong,
"--accent": accent,
"--accent-fg": accent_fg,
"--accent-soft": accent_soft,
"--accent-soft-fg": accent_soft_fg,
"--danger": danger,
"--danger-soft": rgba(danger, 0.15),
"--warn": warn,
"--warn-soft": rgba(warn, 0.15),
"--success": success,
"--success-soft": rgba(success, 0.15),
"--link": link,
"--unread-bg": src["elev"] if dark else "#ffffff",
"--read-bg": src["sunken"] if dark else mix(bg, fg, 0.03),
"--selected-bg": rgba(accent, 0.18) if dark else mix(accent, bg, 0.86),
"--focus-ring": f"0 0 0 3px {rgba(accent, 0.40)}",
"--star": star,
"--q1": lift("q1", src["q1"], 4.5),
"--q2": lift("q2", src["q2"], 4.5),
"--q3": lift("q3", src["q3"], 4.5),
"--scrollbar": rgba(muted, 0.35),
"color-scheme": "dark" if dark else "light",
}
if dark:
tokens["--shadow-1"] = "0 1px 2px rgba(0, 0, 0, 0.45)"
tokens["--shadow-2"] = "0 8px 24px rgba(0, 0, 0, 0.55)"
tokens["--shadow-3"] = "0 22px 60px -28px rgba(0, 0, 0, 0.75)"
return tokens, notes
def verify(pid: str, mode: str, tokens: dict[str, str]) -> list[str]:
"""Fail loudly rather than emit a palette that breaks the AA claim."""
bg = tokens["--bg"]
bad = []
for token, target in [
("--fg", 7.0), ("--fg-muted", 4.5), ("--fg-faint", 4.5), ("--link", 4.5),
("--danger", 4.5), ("--warn", 4.5), ("--success", 4.5),
("--accent", 3.0), ("--border-strong", 3.0), ("--star", 3.0),
("--q1", 4.5), ("--q2", 4.5), ("--q3", 4.5),
]:
ratio = contrast(tokens[token], bg)
if ratio + 1e-9 < target:
bad.append(f"{pid}/{mode} {token} {tokens[token]} on {bg}: {ratio:.2f} < {target}")
ratio = contrast(tokens["--accent-soft-fg"], tokens["--bg-elev"])
return bad
def css_for(pid: str, mode: str, tokens: dict[str, str]) -> str:
sel = f':root[data-palette="{pid}"]' if mode == "light" else f':root[data-theme="dark"][data-palette="{pid}"]'
lines = [f"{sel} {{"]
for k, v in tokens.items():
lines.append(f" {k}: {v};")
lines.append("}")
return "\n".join(lines)
def main() -> int:
blocks: list[str] = [
BEGIN,
"/*",
" * Written by scripts/build-palettes.py -- edit the sources there, not here.",
" *",
" * Every colour is from the palette's own project (all MIT); the published",
" * values are recorded in .palette-sources/palettes-upstream.md. The tiers",
" * between them are derived, and every text colour is checked against the",
" * surface it sits on: 4.5:1 for prose, 3:1 for borders and marks. Several",
" * of these palettes do not meet that as published -- Dracula's comment grey",
" * is about 3.0:1 on its own background -- so those tiers are lifted, which",
" * is why this is arithmetic rather than a hand-written block.",
" */",
]
problems: list[str] = []
for pid, modes in SOURCES.items():
for mode in ("light", "dark"):
if pid in LIGHT_ONLY and mode == "dark":
continue
tokens, notes = build(pid, mode, modes[mode])
problems += verify(pid, mode, tokens)
if notes:
blocks.append(f"/* {pid} ({mode}) lifted for contrast: " + "; ".join(notes) + " */")
blocks.append(css_for(pid, mode, tokens))
blocks.append(END)
generated = "\n\n".join(blocks) + "\n"
if problems:
print("Contrast check failed:", file=sys.stderr)
for p in problems:
print(" " + p, file=sys.stderr)
return 1
css = CSS.read_text(encoding="utf-8")
if BEGIN in css:
css = re.sub(re.escape(BEGIN) + r".*?" + re.escape(END) + r"\n?", generated, css, flags=re.S)
else:
css = css.rstrip() + "\n\n" + generated
CSS.write_text(css, encoding="utf-8")
print(f"Wrote {len(SOURCES) * 2} palette blocks to {CSS.relative_to(ROOT)}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+109
View File
@@ -0,0 +1,109 @@
#!/usr/bin/env node
/*
* Check a catalogue against the strings the code actually asks for.
*
* Two failures, and only one of them is visible without this.
*
* A *missing* key renders English. That is the designed fallback and shows up
* as an untranslated word on screen, which somebody will eventually notice.
*
* A *stale* key -- one whose English no longer exists, usually because it was
* mistyped when the catalogue was written -- is silent. The translation sits
* in the file looking correct, is never looked up, and the app renders English
* for ever. Nothing warns, because a catalogue is only ever read by key.
*/
import ts from "typescript";
import { readFileSync, globSync } from "node:fs";
const wanted = new Set();
for (const file of globSync("web/src/**/*.{ts,tsx}").filter((f) => !f.includes("__tests__") && !f.includes("/locales/"))) {
const src = ts.createSourceFile(file, readFileSync(file, "utf8"), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
const visit = (n) => {
/*
* Labels held in a constant and translated where they render -- t(s.label)
* -- reach t() as a variable, so there is no literal for this to find and
* every one of them looked "stale". They are collected from the constants
* instead: a `label:` property, or a value in an object of them. Without
* this the stale check cried wolf 33 times and would have been switched
* off, which is the only outcome worse than not having it.
*/
if (ts.isPropertyAssignment(n) && n.name.getText(src) === "label" && ts.isStringLiteral(n.initializer)) wanted.add(n.initializer.text);
if (ts.isVariableDeclaration(n) && ts.isIdentifier(n.name) && /_LABELS?$/.test(n.name.text)) {
const walk = (x) => { if (ts.isStringLiteral(x)) wanted.add(x.text); ts.forEachChild(x, walk); };
if (n.initializer) walk(n.initializer);
}
if (ts.isCallExpression(n) && ts.isIdentifier(n.expression)) {
const fn = n.expression.text, a0 = n.arguments[0];
if ((fn === "t" || fn === "translate" || fn === "tNode") && a0 && ts.isStringLiteral(a0)) wanted.add(a0.text);
// tc(context, source) keys the catalogue on both, joined by the same
// control character tc() uses. Without this the contextual entries all
// looked stale, which is the checker's own false alarm rather than a
// catalogue problem.
if (fn === "tc" && a0 && ts.isStringLiteral(a0) && n.arguments[1] && ts.isStringLiteral(n.arguments[1])) {
// Only the contextual key is required. The plain one is tc()'s
// fallback, not a second obligation -- asking for both would report
// work that does not exist.
wanted.add(`${a0.text}\u0004${n.arguments[1].text}`);
}
if (fn === "plural" && n.arguments[1] && ts.isObjectLiteralExpression(n.arguments[1])) {
for (const p of n.arguments[1].properties) {
if (ts.isPropertyAssignment(p) && p.name.getText(src) === "other" && ts.isStringLiteral(p.initializer)) wanted.add(p.initializer.text);
}
}
}
ts.forEachChild(n, visit);
};
visit(src);
}
/*
* A catalogue and a picker entry are two halves of one thing, and either half
* alone is dead weight. A catalogue with no entry in UI_LANGUAGES never
* reaches a reader -- it builds, it passes every test, and the language simply
* is not offered. That happened to Dutch: the entry was added by a text
* replacement anchored on a line that did not exist on that branch, so it was
* a silent no-op and nothing anywhere complained.
*/
const languagesSrc = readFileSync("web/src/lib/languages.ts", "utf8");
const registered = new Set([...languagesSrc.matchAll(/tag:\s*"([\w-]+)"/g)].map((m) => m[1]));
const catalogues = new Set(globSync("web/src/locales/*.ts").map((f) => f.split("/").pop().replace(".ts", "")));
let failed = false;
for (const tag of catalogues) {
if (!registered.has(tag)) {
failed = true;
console.log(`!! ${tag}.ts exists but is not in UI_LANGUAGES — the language is never offered\n`);
}
}
for (const tag of registered) {
if (tag !== "en" && !catalogues.has(tag)) {
failed = true;
console.log(`!! UI_LANGUAGES offers ${tag} but there is no ${tag}.ts — it would fall back to English\n`);
}
}
for (const file of globSync("web/src/locales/*.ts")) {
const tag = file.split("/").pop().replace(".ts", "");
const src = ts.createSourceFile(file, readFileSync(file, "utf8"), ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
const have = new Set();
const visit = (n) => {
if (ts.isPropertyAssignment(n) && ts.isStringLiteral(n.name)) have.add(n.name.text);
ts.forEachChild(n, visit);
};
visit(src);
const stale = [...have].filter((k) => !wanted.has(k) && !["one", "other", "few", "many", "zero", "two"].includes(k));
const missing = [...wanted].filter((k) => !have.has(k));
const pct = Math.round(((wanted.size - missing.length) / wanted.size) * 100);
console.log(`${tag}: ${wanted.size - missing.length}/${wanted.size} translated (${pct}%), ${missing.length} falling back to English`);
if (stale.length) {
failed = true;
console.log(`\n ${stale.length} STALE key(s) — translated but never looked up, so they do nothing:`);
for (const k of stale.slice(0, 25)) console.log(` ${JSON.stringify(k)}`);
if (stale.length > 25) console.log(` …and ${stale.length - 25} more`);
}
if (process.argv.includes("--missing")) {
console.log(`\n missing:`);
for (const k of missing) console.log(` ${JSON.stringify(k)}`);
}
}
if (failed && process.argv.includes("--check")) process.exit(1);
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env node
/*
* How much of the interface is extracted, and what is left.
*
* Extraction is ~1,000 strings across ~56 files, which is far too many to
* carry in anyone's head or to eyeball in review. This counts what is still
* hardcoded so the work can be done a file at a time and the remainder is
* always a number rather than a feeling.
*
* It is a progress report, not a gate: run it, do a file, run it again. It
* exits non-zero only with --check, so CI can be told to fail on regressions
* later, once the number is low enough for that to mean something.
*/
import ts from "typescript";
import { readFileSync, globSync } from "node:fs";
/** Attributes a person reads. `className` and `key` are not among them. */
const ATTRS = new Set(["title", "aria-label", "placeholder", "alt", "label", "hint", "confirmLabel", "message", "description"]);
/* Text that is not prose: punctuation, separators, and the single glyphs used
as dividers. Counting these as untranslated would put a floor under the
number that no amount of work could reach. */
const NOT_PROSE = /^[\s·—–\-—:;,.()[\]{}/|+×✓~<>#*@0-9]*$/u;
/*
* Text that is deliberately not translated is not "remaining work". Counting
* it put a floor under the number that no amount of effort could reach -- the
* report sat at 21 with only 6 real items left, which makes the number
* something to argue with rather than act on. Same rule the codemod uses.
*/
const CODE_TAGS = new Set(["code", "kbd", "pre", "samp", "var"]);
const optedOut = (node, src) => {
const opening = ts.isJsxElement(node) ? node.openingElement : ts.isJsxSelfClosingElement(node) ? node : null;
return Boolean(opening?.attributes.properties.some((a) =>
ts.isJsxAttribute(a) && a.name.getText(src) === "translate" &&
a.initializer && ts.isStringLiteral(a.initializer) && a.initializer.text === "no"));
};
const files = globSync("web/src/**/*.tsx").filter((f) => !f.includes("__tests__"));
const rows = [];
let done = 0, todo = 0;
for (const file of files) {
const text = readFileSync(file, "utf8");
const src = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
let left = 0;
const wrapped = (text.match(/\bt\(\s*["'`]/g) || []).length + (text.match(/\bplural\(/g) || []).length;
const visit = (node) => {
if ((ts.isJsxElement(node) && CODE_TAGS.has(node.openingElement.tagName.getText(src).toLowerCase())) || optedOut(node, src)) return;
if (ts.isJsxText(node) && node.text.trim().length > 1 && !NOT_PROSE.test(node.text.trim())) left++;
if (ts.isJsxAttribute(node) && ATTRS.has(node.name.getText(src))) {
const i = node.initializer;
const lit = i && (ts.isStringLiteral(i) ? i : ts.isJsxExpression(i) && i.expression && ts.isStringLiteral(i.expression) ? i.expression : null);
// The same prose test the text nodes get. Without it, placeholders that
// are format examples -- "123456" for a one-time code, "+1 555 0100" for
// a phone -- counted as untranslated work forever.
if (lit && lit.text.trim().length > 1 && !NOT_PROSE.test(lit.text.trim())) left++;
}
ts.forEachChild(node, visit);
};
visit(src);
done += wrapped;
todo += left;
if (left) rows.push([file.replace("web/src/", ""), left, wrapped]);
}
rows.sort((a, b) => b[1] - a[1]);
const pct = done + todo === 0 ? 100 : Math.round((done / (done + todo)) * 100);
console.log(`i18n extraction: ${done} wrapped, ${todo} remaining across ${rows.length} files (${pct}%)\n`);
for (const [f, left, w] of rows.slice(0, Number(process.argv.find((a) => a.startsWith("--top="))?.slice(6) ?? 15))) {
console.log(` ${String(left).padStart(4)} left${w ? `, ${w} done` : " "} ${f}`);
}
if (rows.length > 15 && !process.argv.includes("--all")) console.log(`\n …and ${rows.length - 15} more (--all, or --top=N)`);
if (process.argv.includes("--check") && todo > 0) process.exit(1);
+143
View File
@@ -0,0 +1,143 @@
#!/usr/bin/env node
/*
* Wrap the strings a codemod can safely wrap, and report the ones it cannot.
*
* Roughly 1,000 strings is too many to hand-edit without introducing typos
* into the copy itself, and a parser does not get bored. But it must not be
* trusted with everything: text that is split around an interpolation arrives
* as separate fragments, and wrapping each fragment on its own produces
* "Move " and " messages", which no translator can do anything with. Those are
* left alone and listed, because they need a sentence built by hand.
*
* node scripts/i18n-extract.mjs <file...> rewrite in place
* node scripts/i18n-extract.mjs --dry <file...>
*/
import ts from "typescript";
import { readFileSync, writeFileSync } from "node:fs";
const ATTRS = new Set(["title", "aria-label", "placeholder", "alt", "label", "hint", "confirmLabel", "description"]);
const NOT_PROSE = /^[\s·—–\-:;,.()[\]{}/|+×✓~<>#*@0-9]*$/u;
/*
* Elements whose text is not prose however much it looks like it. `label:name`
* inside <code> is a search operator: translating it breaks the thing it
* documents. The first run of this wrapped exactly that, which is why the list
* exists.
*/
const CODE_TAGS = new Set(["code", "kbd", "pre", "samp", "var"]);
/*
* JSX decodes HTML entities in text; a JS string literal does not. Moving
* `Language &amp; region` into t("...") without decoding renders the entity
* literally on screen -- which the first run of this did, and which no
* typecheck or test noticed. It took looking at the page.
*/
const ENTITIES = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: "\u00a0", mdash: "—", ndash: "", hellip: "…", times: "×", middot: "·" };
const decode = (s) => s.replace(/&(\w+);/g, (whole, name) => ENTITIES[name] ?? whole)
.replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(Number(n)));
const tagOf = (node, src) => (ts.isJsxElement(node) ? node.openingElement.tagName.getText(src) : "");
const optedOut = (node, src) => {
const opening = ts.isJsxElement(node) ? node.openingElement : ts.isJsxSelfClosingElement(node) ? node : null;
return Boolean(opening?.attributes.properties.some((a) =>
ts.isJsxAttribute(a) && a.name.getText(src) === "translate" &&
a.initializer && ts.isStringLiteral(a.initializer) && a.initializer.text === "no"));
};
const dry = process.argv.includes("--dry");
const files = process.argv.slice(2).filter((a) => !a.startsWith("--"));
let wrapped = 0;
const skipped = [];
for (const file of files) {
const text = readFileSync(file, "utf8");
const src = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
/*
* `t` is a natural name for a callback parameter, and several files already
* use it -- `(t: SieveTest) => ...`, `.map((t) => ...)`. An import called
* `t` is shadowed inside those callbacks, silently where the local happens
* to be callable. So the name is checked first and aliased where it is
* taken, per file, rather than assumed to be free.
*/
let bound = false;
const scan = (n) => {
if ((ts.isParameter(n) || ts.isVariableDeclaration(n) || ts.isBindingElement(n)) && n.name && ts.isIdentifier(n.name) && n.name.text === "t") bound = true;
ts.forEachChild(n, scan);
};
scan(src);
const T = bound ? "translate" : "t";
/** [start, end, replacement] — applied back-to-front so offsets hold. */
const edits = [];
const visit = (node) => {
if (ts.isJsxElement(node) || ts.isJsxFragment(node)) {
if (CODE_TAGS.has(tagOf(node, src).toLowerCase()) || optedOut(node, src)) return; // and not its children
const kids = node.children;
const meaningful = kids.filter((c) => !(ts.isJsxText(c) && !c.text.trim()));
for (const c of kids) {
if (!ts.isJsxText(c)) continue;
const raw = c.text;
const body = raw.trim();
if (body.length < 2 || NOT_PROSE.test(body)) continue;
/*
* "Split around an interpolation" is the dangerous case, and it is
* narrower than "has siblings". `<Plus /> New rule` is a phrase next
* to an icon: wrapping it alone is correct, and refusing it left a
* third of the remaining work to be done by hand for no reason.
* `Your script “{name}” was written by hand` is the real thing --
* a sibling that renders text, so the fragments are not sentences.
*/
const textSibling = kids.some((k) => k !== c && ts.isJsxExpression(k) && k.expression && !(() => {
let jsx = false;
const w = (n) => { if (ts.isJsxElement(n) || ts.isJsxSelfClosingElement(n) || ts.isJsxFragment(n)) { jsx = true; return; } ts.forEachChild(n, w); };
w(k.expression);
return jsx;
})());
if (textSibling) {
const { line } = src.getLineAndCharacterOfPosition(c.getStart(src));
skipped.push({ file, line: line + 1, why: "text split around an expression", text: body.slice(0, 52) });
continue;
}
if (decode(body).includes('"')) {
const { line } = src.getLineAndCharacterOfPosition(c.getStart(src));
skipped.push({ file, line: line + 1, why: "contains a quote", text: body.slice(0, 52) });
continue;
}
// Keep the original leading/trailing whitespace: JSX collapses it, and
// reflowing here would change the rendered spacing.
const lead = raw.slice(0, raw.indexOf(body[0]));
const tail = raw.slice(raw.lastIndexOf(body[body.length - 1]) + 1);
edits.push([c.getStart(src), c.getEnd(), `${lead}{${T}("${decode(body.replace(/\s+/g, " "))}")}${tail}`]);
wrapped++;
}
}
if (ts.isJsxAttribute(node) && ATTRS.has(node.name.getText(src))) {
const i = node.initializer;
const lit = i && (ts.isStringLiteral(i) ? i : ts.isJsxExpression(i) && i.expression && ts.isStringLiteral(i.expression) ? i.expression : null);
if (lit && lit.text.trim().length > 1 && !NOT_PROSE.test(lit.text)) {
if (decode(lit.text).includes('"')) {
const { line } = src.getLineAndCharacterOfPosition(lit.getStart(src));
skipped.push({ file, line: line + 1, why: "contains a quote", text: lit.text.slice(0, 52) });
} else {
edits.push([i.getStart(src), i.getEnd(), `{${T}("${decode(lit.text)}")}`]);
wrapped++;
}
}
}
ts.forEachChild(node, visit);
};
visit(src);
if (!edits.length) continue;
let out = text;
for (const [start, end, rep] of edits.sort((a, b) => b[0] - a[0])) out = out.slice(0, start) + rep + out.slice(end);
if (!/from "@\/lib\/i18n"/.test(out)) {
const lastImport = [...out.matchAll(/^import .*?;$/gm)].pop();
const decl = bound ? 'import { t as translate } from "@/lib/i18n";' : 'import { t } from "@/lib/i18n";';
if (lastImport) out = out.slice(0, lastImport.index + lastImport[0].length) + "\n" + decl + out.slice(lastImport.index + lastImport[0].length);
}
if (!dry) writeFileSync(file, out);
}
console.log(`${dry ? "would wrap" : "wrapped"} ${wrapped} strings across ${files.length} files`);
if (skipped.length) {
console.log(`\n${skipped.length} left for a person:`);
for (const s of skipped) console.log(` ${s.file.replace("web/src/", "")}:${s.line} (${s.why}) ${s.text}`);
}
+124
View File
@@ -0,0 +1,124 @@
#!/usr/bin/env node
/*
* User-visible English the extraction pass cannot see.
*
* `i18n:coverage` reads JSX text, and reported 100% while the calendar's
* Day/Week/Month/Agenda buttons rendered English in all nine languages. It was
* not wrong about what it measured -- those labels were never JSX text. They
* were built from an expression, and so was every toast argument, every
* `confirmDialog({ title })`, and every `Could not save: ${err}`.
*
* A string reaches a reader translated if either is true:
*
* 1. it is wrapped where it is written -- t(), tc(), tNode(), plural()
* 2. it is a catalogue key, translated somewhere else
*
* The second case is a real convention here, not a loophole: constant tables
* hold English and the render site calls `t(s.label)`. What this refuses is a
* string that is neither -- one no catalogue has a key for, which therefore
* cannot be translated at all, however many languages ship.
*/
import ts from "typescript";
import { readFileSync, globSync } from "node:fs";
/* Where a string literal in this position is shown to somebody. */
const UI_PROPS = new Set([
"title", "message", "label", "confirmLabel", "cancelLabel", "ariaLabel",
"placeholder", "hint", "occurrenceLabel", "occurrenceHint", "seriesLabel", "seriesHint",
]);
const UI_ATTRS = new Set(["title", "aria-label", "placeholder", "alt"]);
const TOASTS = new Set(["error", "success", "info", "show"]);
const WRAPPERS = ["t", "tc", "tNode", "translate", "plural"];
const EQUALITY = new Set([
ts.SyntaxKind.EqualsEqualsEqualsToken, ts.SyntaxKind.ExclamationEqualsEqualsToken,
ts.SyntaxKind.EqualsEqualsToken, ts.SyntaxKind.ExclamationEqualsToken,
]);
/*
* Product names, example addresses and URL scaffolding. These reach t() and
* are deliberately absent from every catalogue -- translating "ihasmail" or
* "[email protected]" would be a bug, not a feature -- so they would otherwise
* be reported for ever.
*/
const NEVER_TRANSLATED = new Set([
"ihasmail", "ihasmail.org", "ihasmail test", "Stalwart", "Stalwart Mail Server",
"AGPL-3.0-or-later · {source}", "•••", "https://", "https://…",
"https://meet.example.com/…", "[email protected]", "[email protected]",
"[email protected]", "List-Id", "X-Spam-Status",
]);
/* Prose, not an identifier: opens like a sentence, and has lower-case letters. */
const looksLikeUi = (s) =>
/[a-z]/.test(s) && /^[A-Z(“]/.test(s) && (/\s/.test(s) || /[.?!…]$/.test(s));
const keys = new Set();
{
const src = readFileSync("web/src/locales/de.ts", "utf8");
for (const m of src.matchAll(/^\s{4}"((?:[^"\\]|\\.)*)":/gm)) keys.add(m[1].replace("\\u0004", ""));
}
const found = [];
for (const file of globSync("web/src/**/*.{ts,tsx}").filter((f) => !f.includes("__tests__") && !f.includes("/locales/"))) {
const src = ts.createSourceFile(file, readFileSync(file, "utf8"), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
const report = (node, text) => {
if (!looksLikeUi(text) || keys.has(text) || NEVER_TRANSLATED.has(text)) return;
const { line } = src.getLineAndCharacterOfPosition(node.getStart(src));
found.push({ file, line: line + 1, text });
};
/*
* Literals that are not text on their way to a reader.
*
* Two kinds. One is already inside t("...") -- walking into the call would
* report the very string that proves it is handled. The other is an operand
* of an equality test: `rule.name === "New filter"` compares against a
* sentinel stored in the Sieve script, and translating it would not change
* what a reader sees, it would break the comparison.
*/
const exempt = new Set();
const mark = (n) => {
if (ts.isCallExpression(n) && ts.isIdentifier(n.expression) && WRAPPERS.includes(n.expression.text)) {
const walk = (x) => { if (ts.isStringLiteral(x)) exempt.add(x); ts.forEachChild(x, walk); };
for (const a of n.arguments) walk(a);
}
if (ts.isBinaryExpression(n) && EQUALITY.has(n.operatorToken.kind)) {
for (const side of [n.left, n.right]) if (ts.isStringLiteral(side)) exempt.add(side);
}
ts.forEachChild(n, mark);
};
mark(src);
const wrapped = exempt;
const visit = (n) => {
if (ts.isPropertyAssignment(n) && ts.isStringLiteral(n.initializer) && !wrapped.has(n.initializer)
&& UI_PROPS.has(n.name.getText(src).replace(/['"]/g, ""))) {
report(n.initializer, n.initializer.text);
}
if (ts.isJsxAttribute(n) && n.initializer && UI_ATTRS.has(n.name.getText(src))) {
const walk = (x) => {
if (ts.isStringLiteral(x) && !wrapped.has(x)) report(x, x.text);
if (!ts.isCallExpression(x)) ts.forEachChild(x, walk);
};
walk(n.initializer);
}
if (ts.isCallExpression(n) && ts.isPropertyAccessExpression(n.expression)
&& n.expression.expression.getText(src) === "toast" && TOASTS.has(n.expression.name.text)) {
const a0 = n.arguments[0];
if (a0 && ts.isStringLiteral(a0) && !wrapped.has(a0)) report(a0, a0.text);
/* A template literal cannot be a catalogue key at all, so it is always a find. */
if (a0 && ts.isTemplateExpression(a0)) report(a0, a0.head.text + "{}");
}
ts.forEachChild(n, visit);
};
visit(src);
}
if (!found.length) {
console.log("i18n literals: none -- every user-visible string is wrapped or has a catalogue key");
process.exit(0);
}
console.log(`${found.length} user-visible string(s) the extractor cannot see and no catalogue can translate:\n`);
for (const f of found) console.log(` ${f.file}:${f.line}\n ${JSON.stringify(f.text)}`);
console.log("\nWrap them in t() / plural(), or -- for a label held in a constant and");
console.log("translated where it renders -- make sure the English is a catalogue key.");
process.exit(process.argv.includes("--check") ? 1 : 0);
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env node
/*
* Every source string a catalogue needs, straight out of the calls.
*
* The English text is the key, so the catalogue's keys are not a list somebody
* maintains -- they are whatever t(), tNode() and plural() are actually asked
* for. Reading them from the code means a catalogue can never drift out of
* step with the app in the one direction that matters: a key that no longer
* exists is dead weight, but a call with no key is an untranslated string
* nobody noticed.
*/
import ts from "typescript";
import { readFileSync, globSync } from "node:fs";
const strings = new Set();
const plurals = new Set();
for (const file of globSync("web/src/**/*.{ts,tsx}").filter((f) => !f.includes("__tests__"))) {
const src = ts.createSourceFile(file, readFileSync(file, "utf8"), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
const visit = (node) => {
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression)) {
const fn = node.expression.text;
const a0 = node.arguments[0];
if ((fn === "t" || fn === "translate" || fn === "tNode") && a0 && ts.isStringLiteral(a0)) strings.add(a0.text);
if (fn === "plural" && node.arguments[1] && ts.isObjectLiteralExpression(node.arguments[1])) {
const other = node.arguments[1].properties.find((p) => ts.isPropertyAssignment(p) && p.name.getText(src) === "other");
const forms = {};
for (const p of node.arguments[1].properties) {
if (ts.isPropertyAssignment(p) && ts.isStringLiteral(p.initializer)) forms[p.name.getText(src)] = p.initializer.text;
}
if (other) plurals.add(JSON.stringify(forms));
}
}
ts.forEachChild(node, visit);
};
visit(src);
}
const out = { strings: [...strings].sort(), plurals: [...plurals].map((p) => JSON.parse(p)) };
if (process.argv.includes("--json")) console.log(JSON.stringify(out, null, 2));
else {
console.log(`${out.strings.length} strings, ${out.plurals.length} plural sets`);
const short = out.strings.filter((s) => s.length <= 30).length;
console.log(` ${short} short (<=30 chars), ${out.strings.length - short} longer`);
}
+2 -1
View File
@@ -1,4 +1,5 @@
/** Types for `version.mjs`, which is plain JS so the Dockerfile and shell can run it directly. */
export function baseVersion(): string;
export const UNVERSIONED: string;
export function formatVersion(commit: { date: string; subject?: string; sha: string }): string;
export function versionFromGit(): string | null;
export function resolveVersion(): string;
+63 -38
View File
@@ -1,38 +1,51 @@
/**
* Work out this build's version: `2.16.57`.
* Work out this build's version: `2026.8.30+pr129`.
*
* 2 ihasmail's own major
* 16 the Stalwart major this build targets — 0.16, the oldest it supports
* 57 the pull request the checked-out commit came from
* 2026.8.30 the date of the commit this was built from
* +pr129 the pull request it arrived through
*
* The first two are the `version` in the root package.json, so there is one
* place to bump them; the third is read from git, because it does not exist
* until the pull request has actually merged. Nothing writes a version back
* into the tree: a committed one would always be describing a merge that had
* not happened yet, and every branch would collide on the same line.
* The date leads because ihasmail's version used to be `2.16.<pr>`, where `16`
* was the Stalwart generation it targeted -- and Stalwart 1.0 will leave that
* with nowhere to go. `2.1` would sort *below* the `2.16` already deployed, so
* every image and About screen would read as a downgrade. Tying our
* numbering to somebody else's was the mistake; which Stalwart a build needs is
* said properly in the README badge and KNOWN-ISSUES, where it can be precise
* ("0.16 or newer; tested against 0.16.20") rather than one digit.
*
* A commit that did not arrive through a pull request has no number of its
* own, so it carries the last one plus its own short SHA — `2.16.57+g1fa6578`
* — which is honest about being past that PR rather than silently claiming to
* be it.
* The pull request moved into build metadata, after the `+`, because it is
* provenance rather than a position in a sequence: at a hundred merges a week
* it climbs without bound and says nothing about how new a build is. SemVer
* ignores everything after the `+` when comparing versions, which is the right
* reading -- two builds from the same day differ in where they came from, not
* in rank. Nothing here relies on that comparison anyway: images are pruned
* oldest-first by creation time and rollbacks name a git ref.
*
* A commit that did not arrive through a pull request carries its short SHA
* instead -- `2026.8.30+g1fa6578` -- which is honest about being some commit on
* that day rather than claiming a pull request it was only built after.
*
* The date is the commit's own, not today's, so rebuilding an old commit gives
* the same answer it gave the first time. It comes from the commit object,
* timezone included, so two machines agree.
*
* Nothing writes a version back into the tree: a committed one would always be
* describing a merge that had not happened yet, and every branch would collide
* on the same line. `package.json` no longer carries it either -- npm wants the
* field, so it stays at `0.0.0`, which is what an unversioned build reports and
* is meant to look wrong.
*
* `.dockerignore` excludes `.git`, so an image build cannot run any of this.
* It takes the answer through `--build-arg IHASMAIL_VERSION=...` instead, and
* whoever builds is responsible for computing it see ihasmail-deploy.sh.
* whoever builds is responsible for computing it -- see ihasmail-deploy.sh.
*/
import { execFileSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
/** "2.16" — ihasmail major and the Stalwart major this build is built for. */
export function baseVersion() {
const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
const [major, minor] = String(pkg.version).split(".");
return `${major}.${minor}`;
}
/** What a build with nothing to go on reports, and it should look wrong. */
export const UNVERSIONED = "0.0.0";
function git(...args) {
return execFileSync("git", args, { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
@@ -40,41 +53,53 @@ function git(...args) {
const PR_SUBJECT = /^Merge pull request #(\d+)\b/;
/**
* The version for a commit, from the three things about it that decide one.
* Pure, so the rules can be exercised without a repository staged to produce
* them: `{ date: "2026-08-30", subject: "Merge pull request #129 from ...",
* sha: "1fa6578" }` gives `2026.8.30+pr129`.
*
* Leading zeros are stripped because a version field may not carry them, so
* September is `9` rather than `09`.
*/
export function formatVersion({ date, subject = "", sha }) {
const [y, m, d] = date.split("-");
const calendar = `${Number(y)}.${Number(m)}.${Number(d)}`;
const pr = PR_SUBJECT.exec(subject)?.[1];
return pr ? `${calendar}+pr${pr}` : `${calendar}+g${sha}`;
}
/**
* The version for the commit checked out here, or null when there is no git to
* ask an unpacked tarball, or the Docker build context.
* ask -- an unpacked tarball, or the Docker build context.
*/
export function versionFromGit() {
let head;
let date;
try {
head = git("rev-parse", "--short", "HEAD");
// %cs is the committer date in the commit's own timezone, which is stored
// in the commit -- so this does not depend on the clock or zone of whoever
// is building.
date = git("show", "-s", "--format=%cs", "HEAD");
} catch {
return null;
}
const base = baseVersion();
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) return null;
let subject = "";
try {
// Walk back over first parents: a merge commit's subject names its PR, and
// anything after the newest one is work that has not been through one.
const log = git("log", "--first-parent", "--format=%H%x00%s", "-n", "200");
const commits = log ? log.split("\n").map((l) => l.split("\0")) : [];
for (const [sha, subject = ""] of commits) {
const pr = PR_SUBJECT.exec(subject)?.[1];
if (!pr) continue;
// The PR's own merge commit is the version; anything above it is past it.
const exact = sha.startsWith(git("rev-parse", "HEAD"));
return exact ? `${base}.${pr}` : `${base}.${pr}+g${head}`;
}
subject = git("show", "-s", "--format=%s", "HEAD");
} catch {
/* a shallow clone, or no history to read */
/* no subject to read; fall through to the SHA */
}
return `${base}.0+g${head}`;
return formatVersion({ date, subject, sha: head });
}
/** Whatever the environment was told, else git, else just the base. */
/** Whatever the environment was told, else git, else an answer that looks wrong. */
export function resolveVersion() {
const fromEnv = process.env.IHASMAIL_VERSION?.trim();
if (fromEnv) return fromEnv;
return versionFromGit() ?? `${baseVersion()}.0`;
return versionFromGit() ?? UNVERSIONED;
}
// `node scripts/version.mjs` prints it, for shell scripts and CI.
+2 -1
View File
@@ -12,7 +12,8 @@
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "tsx --test src/*.test.ts src/**/*.test.ts",
"mock": "tsx src/mock/index.ts",
"mock:no-future-release": "MOCK_NO_FUTURE_RELEASE=1 tsx src/mock/index.ts"
"mock:no-future-release": "MOCK_NO_FUTURE_RELEASE=1 tsx src/mock/index.ts",
"mock:no-keyword-sort": "MOCK_NO_KEYWORD_SORT=1 tsx src/mock/index.ts"
},
"dependencies": {
"@hono/node-server": "^1.13.8",
+1 -1
View File
@@ -65,7 +65,7 @@ function accountId(ctx: Ctx): string {
type Invocation = [string, Record<string, unknown>, string];
async function jmap(ctx: Ctx, methodCalls: Invocation[]): Promise<{ methodResponses?: [string, unknown, string][] }> {
const res = await fetch(absoluteUpstream(ctx.session.apiUrl), {
const res = await fetch(absoluteUpstream(ctx.session.apiUrl, ctx.session.baseUrl), {
method: "POST",
headers: { authorization: ctx.authorization, "content-type": "application/json", accept: "application/json" },
body: JSON.stringify({ using: [JMAP_CORE, STALWART_CAP], methodCalls }),
+109
View File
@@ -35,3 +35,112 @@ test("image proxy refuses private targets", async () => {
const res = await app.request("/api/image?url=http://127.0.0.1/x");
assert.equal(res.status, 401);
});
test("a compressed upstream blob is not forwarded with the compressed length", async () => {
const { forwardedContentLength } = await import("./app.js");
// gzip: the body we forward has already been decompressed, so the length on
// the wire describes different bytes and must not be copied (issue #76).
const gz = new Headers({ "content-encoding": "gzip", "content-length": "384" });
assert.equal(forwardedContentLength(gz), null);
// identity, spelled out or absent: the length describes the body we send.
assert.equal(forwardedContentLength(new Headers({ "content-encoding": "identity", "content-length": "1157" })), "1157");
assert.equal(forwardedContentLength(new Headers({ "content-length": "1157" })), "1157");
assert.equal(forwardedContentLength(new Headers({ "content-encoding": "BR", "content-length": "384" })), null);
// Nothing to forward is not an error.
assert.equal(forwardedContentLength(new Headers()), null);
});
test("a Sieve script larger than a compressing hop's threshold survives the proxy", async () => {
const http = await import("node:http");
const zlib = await import("node:zlib");
const { forwardedContentLength } = await import("./app.js");
const script =
"# ihasmail filters v1 - edit with care; rules are stored in the `# rule:` comments\nrequire [\"fileinto\"];\n\n" +
["a", "b", "c"]
.map(
(k) =>
`# rule:{"id":"r${k}","name":"From ${k}@example.com","enabled":true,"join":"allof","tests":[{"type":"header","header":"from","op":"contains","value":"${k}@example.com"}],"actions":[{"type":"fileinto","mailbox":"INBOX/${k}"}]}\n` +
`if header :contains "from" "${k}@example.com"\n{\n fileinto "INBOX/${k}";\n}\n\n`,
)
.join("");
const gz = zlib.gzipSync(Buffer.from(script));
assert.ok(gz.length < Buffer.byteLength(script), "the script has to compress for this test to mean anything");
// A hop that compresses regardless of what we asked for.
const origin = http.createServer((_req, res) => {
res.writeHead(200, { "content-type": "application/sieve", "content-encoding": "gzip", "content-length": String(gz.length) });
res.end(gz);
});
await new Promise<void>((r) => origin.listen(0, () => r()));
const port = (origin.address() as { port: number }).port;
try {
const up = await fetch(`http://127.0.0.1:${port}/`);
// What the blob route forwards.
const headers = new Headers({ "content-type": "application/sieve; charset=utf-8" });
const cl = forwardedContentLength(up.headers);
if (cl) headers.set("Content-Length", cl);
const out = new Response(await up.arrayBuffer(), { status: 200, headers });
assert.equal(out.headers.get("content-length"), null);
assert.equal(await out.text(), script);
} finally {
origin.close();
}
});
test("only a PDF blob may be framed, and only by us", async () => {
/*
* The PDF preview is an iframe, and the blanket X-Frame-Options: DENY on
* every response blocked it -- the dialog showed Chrome's "refused to
* connect" where the file should have been. The middleware now leaves a
* header a route has already set, so this pins both halves: the exception
* exists, and it did not become the rule.
*/
const app = createApp();
const health = await app.request("/api/health");
assert.equal(health.headers.get("x-frame-options"), "DENY");
const { securityHeadersFor } = await import("./app.js");
assert.equal(securityHeadersFor("application/pdf", true), "SAMEORIGIN");
assert.equal(securityHeadersFor("application/pdf", false), "DENY");
assert.equal(securityHeadersFor("image/png", true), "DENY");
assert.equal(securityHeadersFor("text/html", true), "DENY");
});
/*
* #239: retrying through an outage must not lock somebody out of the recovery.
*
* STALWART_URL at the top of this file is 127.0.0.1:1 — nothing listens there,
* so every sign-in here is the outage case. Before the fix, the eleventh of
* these came back 429 and stayed 429 for fifteen minutes, outliving whatever
* had actually been wrong.
*/
test("an unreachable upstream does not spend login attempts", async () => {
const app = createApp();
const login = () =>
app.request("/api/auth/login", {
method: "POST",
headers: { "content-type": "application/json", "x-requested-with": "ihasmail" },
body: JSON.stringify({ username: "[email protected]", password: "hunter2" }),
});
// Comfortably past LOGIN_RATE_LIMIT, which defaults to 10.
for (let i = 0; i < 25; i++) {
const res = await login();
assert.notEqual(res.status, 429, `attempt ${i + 1} was rate limited`);
assert.ok(res.status === 502 || res.status === 504, `attempt ${i + 1} said ${res.status}`);
}
});
test("an unreachable upstream says it is not the password", async () => {
const app = createApp();
const res = await app.request("/api/auth/login", {
method: "POST",
headers: { "content-type": "application/json", "x-requested-with": "ihasmail" },
body: JSON.stringify({ username: "[email protected]", password: "hunter2" }),
});
const body = (await res.json()) as { error: string; message: string };
assert.notEqual(body.error, "invalid_credentials");
assert.match(body.message, /not a problem with your password/i);
});
+149 -23
View File
@@ -16,6 +16,7 @@ import {
forgetUpstreamSession,
getAccountInfo,
getUpstreamSession,
upstreamFor,
localizeSession,
} from "./upstream.js";
import {
@@ -30,12 +31,27 @@ import {
revokeAppPassword,
} from "./account.js";
import { imageProxyHandler } from "./imageproxy.js";
import { icsProxyHandler } from "./icsproxy.js";
import { staticHandler } from "./static.js";
type Env = { Variables: { session: LiveSession } };
export const sessions: SessionBackend = new SessionStore(config.sessionFile);
const loginLimiter = new RateLimiter(config.loginRateLimit, 15 * 60_000);
/*
* The backstop that is never refunded.
*
* `loginLimiter` guards password guessing and gives its attempts back when the
* upstream never judged the password (#239) -- otherwise retrying through an
* outage locks somebody out until after it has ended. But "not counted" cannot
* mean "unlimited": each attempt still costs ihasmail an outbound connection
* that may sit there until `UPSTREAM_TIMEOUT`, so a flood during an outage is
* the one moment the endpoint is cheapest to abuse.
*
* Hence a second ceiling, per address, twenty times looser and refunded never.
* A person retrying an outage will not come near it; something hammering will.
*/
const loginFloodLimiter = new RateLimiter(config.loginRateLimit * 20, 15 * 60_000);
/**
* Credential changes verify the current password upstream, and Stalwart's
* fail2ban counts those failures against the *caller's* IP — which for a proxy
@@ -82,7 +98,9 @@ const securityHeaders: MiddlewareHandler = async (c, next) => {
await next();
const h = c.res.headers;
h.set("X-Content-Type-Options", "nosniff");
h.set("X-Frame-Options", "DENY");
/* A route that must be framable says so; everything else is DENY. The blob
route is the only one, and only for PDFs -- see the note there. */
if (!h.has("X-Frame-Options")) h.set("X-Frame-Options", "DENY");
h.set("Referrer-Policy", "no-referrer");
h.set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=(), usb=()");
h.set("Cross-Origin-Opener-Policy", "same-origin");
@@ -114,12 +132,28 @@ const requireSession: MiddlewareHandler<Env> = async (c, next) => {
await next();
};
/**
* Scope the session cookie to the mount, not the whole host.
*
* Under a prefix the browser is talking to a hostname that other applications
* share, and a cookie at `/` would be sent to every one of them. Path scoping
* is not a security boundary -- anything on the origin can reach the cookie
* jar -- but it keeps the credential out of requests that have no business
* carrying it, and it lets two ihasmail instances live at `/mail` and
* `/mail2` on one host without signing each other out, which a shared cookie
* name at `/` would do.
*
* `/` for the root case: an empty Path is not the same thing and browsers
* would fall back to the directory of the request that set it.
*/
const cookiePath = config.basePath || "/";
function setSessionCookie(c: Context, value: string, remember: boolean) {
setCookie(c, config.cookieName, value, {
httpOnly: true,
sameSite: "Lax",
secure: isSecureRequest(c),
path: "/",
path: cookiePath,
...(remember ? { maxAge: config.sessionRememberTtl } : {}),
});
}
@@ -130,13 +164,18 @@ function upstreamFailure(c: Context, err: unknown) {
}
const name = (err as Error)?.name ?? "";
if (name === "TimeoutError" || name === "AbortError") {
return c.json({ error: "upstream_timeout", message: "The mail server did not respond in time" }, 504);
return c.json({ error: "upstream_timeout", message: "The mail server did not respond in time. This is not a problem with your password." }, 504);
}
console.error("[ihasmail] upstream failure:", err);
return c.json({ error: "upstream_error", message: "Could not reach the mail server" }, 502);
return c.json({ error: "upstream_error", message: "Could not reach the mail server. This is not a problem with your password." }, 502);
}
export function createApp(): Hono<Env> {
/**
* `basePath` is a parameter rather than read straight from the config so the
* tests can mount the same app twice, at the root and under a prefix, without
* re-importing the module to change one environment variable.
*/
export function createApp(basePath = config.basePath): Hono<Env> {
const app = new Hono<Env>();
app.use("*", securityHeaders);
@@ -151,6 +190,9 @@ export function createApp(): Hono<Env> {
sourceUrl: config.sourceUrl,
imageProxy: config.imageProxy,
maxUploadBytes: config.maxUploadBytes,
/* Sent before sign-in like the rest of this: it says what the
installation has decided, not anything about who is asking. */
settingsPolicy: config.settingsPolicy,
}),
);
@@ -169,7 +211,24 @@ export function createApp(): Hono<Env> {
if (!username || !password) return c.json({ error: "missing_credentials" }, 400);
if (username.length > 320 || password.length > 1024) return c.json({ error: "bad_request" }, 400);
/*
* Three checks, answering different questions.
*
* `limitKey` is this username from this address, and `ip` is any username
* from it -- both guard guessing, and both are given back when the upstream
* never got as far as judging the password. Refunding only the first would
* not fix #239: ten retries through an outage would still spend the address
* budget, and behind one office NAT that budget belongs to the whole
* building.
*
* The flood ceiling is the one that is never refunded, and it is the reason
* the other two safely can be.
*/
const limitKey = `${ip}|${username.toLowerCase()}`;
if (!loginFloodLimiter.check(ip)) {
c.header("Retry-After", String(loginFloodLimiter.retryAfterSeconds(ip)));
return c.json({ error: "rate_limited", message: "Too many login attempts. Please wait and try again." }, 429);
}
if (!loginLimiter.check(limitKey) || !loginLimiter.check(ip)) {
c.header("Retry-After", String(loginLimiter.retryAfterSeconds(limitKey)));
return c.json({ error: "rate_limited", message: "Too many login attempts. Please wait and try again." }, 429);
@@ -179,12 +238,16 @@ export function createApp(): Hono<Env> {
const effectivePassword = totp ? `${password}$${totp}` : password;
const authorization = `Basic ${Buffer.from(`${username}:${effectivePassword}`, "utf8").toString("base64")}`;
try {
const upstream = await fetchUpstreamSession(authorization);
const upstream = await fetchUpstreamSession(authorization, upstreamFor(username));
// ihasmail requires Stalwart 0.16 or newer. Refuse here, once and
// clearly, rather than signing someone in and letting Files, the account
// locale and self-service credentials each fail in their own way with
// nothing to connect them. The credentials were good, so say so.
if (!hasStalwartRegistry(upstream)) {
// The credentials were accepted; only the server is too old. Not an
// attempt worth counting against them.
loginLimiter.refund(limitKey);
loginLimiter.refund(ip);
return c.json(
{
error: "unsupported_server",
@@ -232,6 +295,16 @@ export function createApp(): Hono<Env> {
401,
);
}
/*
* A 401 is a judgement about the password and stays counted. Anything
* else -- refused, timed out, DNS, TLS -- is the upstream failing to
* answer, which says nothing about the credentials and must not spend
* somebody's attempts while they wait for it to come back (#239).
*/
if (!(err instanceof UpstreamError && err.status === 401)) {
loginLimiter.refund(limitKey);
loginLimiter.refund(ip);
}
return upstreamFailure(c, err);
}
});
@@ -239,13 +312,13 @@ export function createApp(): Hono<Env> {
api.get("/auth/session", requireSession, async (c) => {
const session = c.get("session");
try {
const upstream = await getUpstreamSession(session.id, session.authorization, c.req.query("refresh") === "1");
const upstream = await getUpstreamSession(session.id, session.authorization, upstreamFor(session.username), c.req.query("refresh") === "1");
const info = await getAccountInfo(session.id, session.authorization, upstream);
return c.json(localizeSession(upstream, sessionExtras(session, info)));
} catch (err) {
if (err instanceof UpstreamError && err.status === 401) {
sessions.destroy(session.id);
deleteCookie(c, config.cookieName, { path: "/" });
deleteCookie(c, config.cookieName, { path: cookiePath });
}
return upstreamFailure(c, err);
}
@@ -258,7 +331,7 @@ export function createApp(): Hono<Env> {
sessions.destroy(session.id);
forgetUpstreamSession(session.id);
}
deleteCookie(c, config.cookieName, { path: "/" });
deleteCookie(c, config.cookieName, { path: cookiePath });
return c.json({ ok: true });
});
@@ -456,8 +529,8 @@ export function createApp(): Hono<Env> {
return c.json({ error: "unsupported_media_type" }, 415);
}
try {
const upstream = await getUpstreamSession(session.id, session.authorization);
const res = await fetch(absoluteUpstream(upstream.apiUrl), {
const upstream = await getUpstreamSession(session.id, session.authorization, upstreamFor(session.username));
const res = await fetch(absoluteUpstream(upstream.apiUrl, upstream.baseUrl), {
method: "POST",
headers: {
authorization: session.authorization,
@@ -471,7 +544,7 @@ export function createApp(): Hono<Env> {
if (res.status === 401) {
sessions.destroy(session.id);
forgetUpstreamSession(session.id);
deleteCookie(c, config.cookieName, { path: "/" });
deleteCookie(c, config.cookieName, { path: cookiePath });
return c.json({ error: "unauthenticated" }, 401);
}
return passthrough(res);
@@ -490,8 +563,8 @@ export function createApp(): Hono<Env> {
// suggestion; count the bytes as they go past.
const body = c.req.raw.body ? c.req.raw.body.pipeThrough(byteCap(config.maxUploadBytes)) : null;
try {
const upstream = await getUpstreamSession(session.id, session.authorization);
const url = absoluteUpstream(expandTemplate(upstream.uploadUrl, { accountId }));
const upstream = await getUpstreamSession(session.id, session.authorization, upstreamFor(session.username));
const url = absoluteUpstream(expandTemplate(upstream.uploadUrl, { accountId }), upstream.baseUrl);
const res = await fetch(url, {
method: "POST",
headers: {
@@ -516,17 +589,20 @@ export function createApp(): Hono<Env> {
const accept = c.req.query("accept") ?? "application/octet-stream";
const inline = c.req.query("inline") === "1";
try {
const upstream = await getUpstreamSession(session.id, session.authorization);
const url = absoluteUpstream(expandTemplate(upstream.downloadUrl, { accountId, blobId, name, type: accept }));
const upstream = await getUpstreamSession(session.id, session.authorization, upstreamFor(session.username));
const url = absoluteUpstream(expandTemplate(upstream.downloadUrl, { accountId, blobId, name, type: accept }), upstream.baseUrl);
const res = await fetch(url, {
headers: { authorization: session.authorization },
// Ask for the bytes as they are. undici would otherwise negotiate gzip
// on our behalf and hand back a decompressed body whose content-length
// header still describes the compressed one -- see forwardedContentLength.
headers: { authorization: session.authorization, "accept-encoding": "identity" },
signal: AbortSignal.timeout(Math.max(config.upstreamTimeout, 5 * 60_000)),
});
if (!res.ok) return c.json({ error: "not_found" }, res.status === 404 ? 404 : 502);
const headers = new Headers();
const type = sanitizeContentType(res.headers.get("content-type") ?? accept);
headers.set("Content-Type", type);
const cl = res.headers.get("content-length");
const cl = forwardedContentLength(res.headers);
if (cl) headers.set("Content-Length", cl);
const safeInline = inline && isInlineSafe(type);
headers.set(
@@ -535,7 +611,19 @@ export function createApp(): Hono<Env> {
);
headers.set("X-Content-Type-Options", "nosniff");
// Sandbox everything except the browser's built-in PDF viewer (which needs scripts to render).
if (!(safeInline && type === "application/pdf")) {
if (securityHeadersFor(type, safeInline) === "SAMEORIGIN") {
/*
* The one response on the server that may be framed.
*
* A PDF is shown in an iframe -- it is its own document and the app
* cannot lay it out -- and the blanket X-Frame-Options: DENY above
* blocked that, so the preview showed Chrome's "refused to connect"
* instead of the file. SAMEORIGIN, not a relaxation to any site: the
* frame is ours, on our origin, and the app's own CSP already says
* frame-src 'self'. Nothing else here is framed, so nothing else asks.
*/
headers.set("X-Frame-Options", "SAMEORIGIN");
} else {
headers.set("Content-Security-Policy", "sandbox; default-src 'none'; style-src 'unsafe-inline'; img-src data:");
}
headers.set("Cache-Control", "private, max-age=3600");
@@ -552,8 +640,8 @@ export function createApp(): Hono<Env> {
const closeafter = c.req.query("closeafter") ?? "no";
const ping = c.req.query("ping") ?? "30";
try {
const upstream = await getUpstreamSession(session.id, session.authorization);
const url = absoluteUpstream(expandTemplate(upstream.eventSourceUrl, { types, closeafter, ping }));
const upstream = await getUpstreamSession(session.id, session.authorization, upstreamFor(session.username));
const url = absoluteUpstream(expandTemplate(upstream.eventSourceUrl, { types, closeafter, ping }), upstream.baseUrl);
const controller = new AbortController();
c.req.raw.signal.addEventListener("abort", () => controller.abort());
const res = await fetch(url, {
@@ -575,6 +663,9 @@ export function createApp(): Hono<Env> {
// ---------- Remote image privacy proxy ----------
api.get("/image", requireSession, imageProxyHandler);
// Behind the session for the same reason the image proxy is: an open fetcher
// on someone else's server is a gift to whoever finds it.
api.get("/ics", requireSession, icsProxyHandler);
api.notFound((c) => c.json({ error: "not_found" }, 404));
api.onError((err, c) => {
@@ -582,10 +673,10 @@ export function createApp(): Hono<Env> {
return c.json({ error: "internal_error" }, 500);
});
app.route("/api", api);
app.route(`${basePath}/api`, api);
// ---------- Static SPA ----------
app.get("*", staticHandler(config.staticDir));
app.get("*", staticHandler(config.staticDir, basePath));
return app;
}
@@ -651,6 +742,32 @@ function passthrough(res: Response): Response {
return new Response(res.body, { status: res.status, headers });
}
/**
* The upstream content-length, but only when it describes the bytes we are
* about to forward.
*
* A compressed response is decompressed for us before we ever see the body --
* undici does it transparently -- while the content-length header is left
* describing the *compressed* length. Copying it onto the longer body we then
* send makes the browser stop reading exactly that many bytes in and call the
* download complete, so the file arrives silently truncated.
*
* That is the second half of issue #76. A hop in front of Stalwart compressed
* responses over 1 KiB, so a Sieve script stayed intact until the third rule
* pushed it past the threshold and it came back cut off mid-rule. Nothing
* reported an error: the script parsed, just with rules missing, and saving
* wrote that shortened version back over the real one.
*
* We ask for `identity` above so the usual case still carries a length the
* browser can show progress against; this is the guard for a hop that
* compresses anyway.
*/
export function forwardedContentLength(headers: Headers): string | null {
const encoding = headers.get("content-encoding")?.trim().toLowerCase();
if (encoding && encoding !== "identity") return null;
return headers.get("content-length");
}
function sanitizeContentType(ct: string): string {
const lower = ct.split(";")[0]!.trim().toLowerCase();
// Never let the browser render HTML/SVG/XML/JS served from the blob endpoint.
@@ -668,6 +785,15 @@ function sanitizeContentType(ct: string): string {
return lower || "application/octet-stream";
}
/**
* What X-Frame-Options a blob response carries. Exported so the rule is
* testable without standing up an upstream: a PDF served inline may be framed
* by us and nothing else may be framed at all.
*/
export function securityHeadersFor(type: string, safeInline: boolean): "SAMEORIGIN" | "DENY" {
return safeInline && type.split(";")[0]!.trim() === "application/pdf" ? "SAMEORIGIN" : "DENY";
}
function isInlineSafe(type: string): boolean {
const t = type.split(";")[0]!.trim();
return (
+63
View File
@@ -0,0 +1,63 @@
import { test } from "node:test";
import assert from "node:assert/strict";
process.env.STALWART_URL = "http://127.0.0.1:1";
const { createApp } = await import("./app.js");
/**
* `BASE_PATH` is read once, into `config`, so these mount the app by argument
* instead of re-importing the module with a different environment. The
* root-mounted half is the one that matters most: every instance in existence
* is at `/`, and this feature has to be invisible to them.
*/
test("at the root, the API is exactly where it was", async () => {
const app = createApp("");
const res = await app.request("/api/health");
assert.equal(res.status, 200);
});
test("under a prefix, the API moves with it", async () => {
const app = createApp("/mail");
const res = await app.request("/mail/api/health");
assert.equal(res.status, 200);
const body = (await res.json()) as { ok?: boolean };
assert.equal(body.ok, true);
});
test("under a prefix, the unprefixed API is gone", async () => {
// Not merely unrouted: a proxy that forwards without the prefix, against a
// server told to expect one, would otherwise appear to half-work -- the API
// answering while the app shell it belongs to 404s.
const app = createApp("/mail");
const res = await app.request("/api/health");
assert.equal(res.status, 404);
});
/*
* Whether a route reached the static handler, without depending on there being
* a web build in the tree. With one it serves the index; without one it says
* the build is missing. Either is proof the request got that far -- a routing
* mistake is the 404, and asserting on 200 or 503 would make these tests pass
* or fail on whether somebody had run `npm run build` first.
*/
const reachedTheApp = (status: number) => status === 200 || status === 503;
test("a deep SPA route under the prefix reaches the static handler", async () => {
const app = createApp("/mail");
const res = await app.request("/mail/calendar/week/2026-09-01");
assert.ok(reachedTheApp(res.status), `expected the app shell, got ${res.status}`);
});
test("a path that only shares the prefix's letters is not the app", async () => {
// `/mailbox` under a `/mail` mount belongs to whatever else the proxy
// serves on this host; answering it with our shell would shadow it.
const app = createApp("/mail");
assert.equal((await app.request("/mailbox")).status, 404);
assert.equal((await app.request("/")).status, 404);
});
test("the root mount still serves the SPA from the root", async () => {
const app = createApp("");
assert.ok(reachedTheApp((await app.request("/calendar/week/2026-09-01")).status));
assert.ok(reachedTheApp((await app.request("/")).status));
});
+146 -1
View File
@@ -1,4 +1,5 @@
import { resolveVersion } from "../../scripts/version.mjs";
import { normalizeBasePath } from "../../scripts/basePath.mjs";
import { randomBytes } from "node:crypto";
import { fileURLToPath } from "node:url";
import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
@@ -110,9 +111,139 @@ export function assertImmutable(sessionFile: string, root: string): void {
if (immutable) assertImmutable(sessionFile, fileURLToPath(new URL("../..", import.meta.url)));
/**
* Settings an installation decides, rather than each reader.
*
* A school turning on "warn about outside senders" for three thousand pupils
* cannot ask three thousand pupils to turn it on -- issue #207. Two sections,
* which are two different powers:
*
* - `defaults` seed an account that has never had settings of its own. The
* reader can change any of them afterwards; they are a starting point, not a
* rule.
* - `enforced` are applied on every load and cannot be changed here at all. The
* controls stay visible and go dead, which the issue asked for by name: a
* missing control confuses somebody who has used ihasmail elsewhere.
* - `changes` are applied once each, to everybody, including accounts that
* already exist -- and can be changed back afterwards. Each carries its own
* `version`, which is how an account remembers the ones it has had. The
* reporter's own analogy is a schema migration and this is that shape.
*
* Read from a file or straight from the environment, because ihasmail's own
* production runs read-only with no volume -- an installation that cannot mount
* a file can still set a variable.
*/
function readSettingsPolicy(): { defaults: Record<string, unknown>; enforced: Record<string, unknown>; changes: Array<{ version: string; settings: Record<string, unknown> }> } {
const parse = (raw: string, where: string): Record<string, unknown> => {
try {
const v = JSON.parse(raw) as unknown;
if (!v || typeof v !== "object" || Array.isArray(v)) throw new Error("not a JSON object");
return v as Record<string, unknown>;
} catch (err) {
/* Loud, and fatal. A policy that silently did not apply would look like
the feature not working, and the admin would have no way to tell. */
throw new Error(`Invalid ${where}: ${(err as Error).message}`);
}
};
/**
* A change list, checked rather than trusted.
*
* Every entry needs a `version` that is unique within the file: it is what an
* account stores to say it has had this one, so a duplicate would make two
* changes indistinguishable and a missing one would apply for ever.
*/
const parseChanges = (v: unknown, where: string): Array<{ version: string; settings: Record<string, unknown> }> => {
if (v === undefined) return [];
if (!Array.isArray(v)) throw new Error(`Invalid ${where}: "changes" must be a list`);
const seen = new Set<string>();
return v.map((entry, i) => {
const e = entry as { version?: unknown; settings?: unknown };
const version = typeof e.version === "string" ? e.version.trim() : "";
if (!version) throw new Error(`Invalid ${where}: changes[${i}] has no "version"`);
if (seen.has(version)) throw new Error(`Invalid ${where}: two changes share the version "${version}"`);
seen.add(version);
if (!e.settings || typeof e.settings !== "object" || Array.isArray(e.settings)) {
throw new Error(`Invalid ${where}: changes[${i}] ("${version}") has no "settings" object`);
}
return { version, settings: e.settings as Record<string, unknown> };
});
};
const file = process.env.SETTINGS_POLICY_FILE;
if (file) {
if (!existsSync(file)) throw new Error(`SETTINGS_POLICY_FILE does not exist: ${file}`);
const whole = parse(readFileSync(file, "utf8"), `SETTINGS_POLICY_FILE (${file})`);
return {
defaults: (whole.defaults as Record<string, unknown>) ?? {},
enforced: (whole.enforced as Record<string, unknown>) ?? {},
changes: parseChanges(whole.changes, `SETTINGS_POLICY_FILE (${file})`),
};
}
return {
defaults: process.env.SETTINGS_DEFAULTS ? parse(process.env.SETTINGS_DEFAULTS, "SETTINGS_DEFAULTS") : {},
enforced: process.env.SETTINGS_ENFORCED ? parse(process.env.SETTINGS_ENFORCED, "SETTINGS_ENFORCED") : {},
changes: process.env.SETTINGS_CHANGES ? parseChanges(JSON.parse(process.env.SETTINGS_CHANGES), "SETTINGS_CHANGES") : [],
};
}
/**
* Which Stalwart a domain signs in to.
*
* `STALWART_URL` stays required and stays the default; this only adds domains
* that go somewhere else (#238). An installation that sets nothing behaves
* exactly as it always has.
*
* Read once at boot and never written, so it mounts read-only and costs
* nothing in immutability -- the same shape as the settings policy.
*
* Servers are deliberately **not** probed here. A mapping is a routing table,
* not a health check, and refusing to boot because one of five customers is
* having an outage would take the other four down with it. What happens when
* one is unreachable is a sign-in question, answered in #239.
*/
function readStalwartServers(): Record<string, string> {
const file = process.env.STALWART_SERVERS_FILE;
if (!file) return {};
if (!existsSync(file)) throw new Error(`STALWART_SERVERS_FILE does not exist: ${file}`);
let raw: unknown;
try {
raw = JSON.parse(readFileSync(file, "utf8"));
} catch (err) {
throw new Error(`Invalid STALWART_SERVERS_FILE (${file}): ${(err as Error).message}`);
}
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
throw new Error(`Invalid STALWART_SERVERS_FILE (${file}): expected an object of domain to URL`);
}
const out: Record<string, string> = {};
for (const [rawDomain, rawUrl] of Object.entries(raw as Record<string, unknown>)) {
/* Lower-cased and stripped of the root dot, because that is how a domain
taken off a username will arrive and comparing them any other way means
a mapping that silently never matches. */
const domain = rawDomain.trim().toLowerCase().replace(/\.$/, "");
if (!domain) throw new Error(`Invalid STALWART_SERVERS_FILE (${file}): a domain key is empty`);
if (domain in out) throw new Error(`Invalid STALWART_SERVERS_FILE (${file}): "${domain}" appears twice once normalised`);
if (typeof rawUrl !== "string") throw new Error(`Invalid STALWART_SERVERS_FILE (${file}): "${domain}" is not a URL`);
let parsed: URL;
try {
parsed = new URL(rawUrl);
} catch {
throw new Error(`Invalid STALWART_SERVERS_FILE (${file}): "${domain}" is not an absolute URL`);
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new Error(`Invalid STALWART_SERVERS_FILE (${file}): "${domain}" must be http or https`);
}
out[domain] = rawUrl.replace(/\/+$/, "");
}
return out;
}
export const config = {
isProd,
appName: env("APP_NAME", "ihasmail"),
settingsPolicy: readSettingsPolicy(),
/**
* What this build calls itself: `2.16.57`. Set by the image build from
* `--build-arg IHASMAIL_VERSION`, since `.dockerignore` keeps `.git` out of
@@ -127,10 +258,24 @@ export const config = {
* source, not the one it was forked from -- so anyone deploying a patched
* ihasmail should point this at their own tree.
*/
sourceUrl: env("SOURCE_URL", "https://github.com/LINUXexpert-org/ihasmail"),
sourceUrl: env("SOURCE_URL", "https://github.com/Coffey-Labs/ihasmail"),
host: env("HOST", "0.0.0.0"),
port: int("PORT", 8080),
/**
* The subpath this instance answers on: `/mail` for a proxy that maps
* `https://example.com/mail/` here, and `""` -- the default -- for the root.
*
* The prefix is expected to arrive intact: a proxy that strips it before
* forwarding should leave BASE_PATH unset, because then as far as this
* process is concerned it *is* at the root. What must match is the web
* build, which bakes the same variable into its asset URLs; a server that
* strips a prefix the bundle still asks for serves an app that cannot load
* its own scripts. `staticHandler` says so at the first request rather than
* leaving a blank page to explain itself.
*/
basePath: normalizeBasePath(process.env.BASE_PATH),
stalwartUrl,
stalwartServers: readStalwartServers(),
appSecret,
trustProxy: bool("TRUST_PROXY", true),
/**
+67
View File
@@ -0,0 +1,67 @@
import { test } from "node:test";
import assert from "node:assert/strict";
process.env.STALWART_URL = "https://default.example";
const { upstreamFor } = await import("./upstream.js");
const { config } = await import("./config.js");
/**
* Which Stalwart a username goes to (#238).
*
* `STALWART_URL` is required and is the default. The mapping only adds domains
* that go elsewhere, so an installation with no mapping behaves exactly as it
* always has -- which is what these first cases pin.
*/
test("with no mapping at all, everything goes to the default", () => {
assert.deepEqual(config.stalwartServers, {});
assert.equal(upstreamFor("[email protected]"), "https://default.example");
assert.equal(upstreamFor("[email protected]"), "https://default.example");
});
test("a bare username has no domain to map, so it goes to the default", () => {
// Stalwart accepts a login with no domain at all.
assert.equal(upstreamFor("demo"), "https://default.example");
assert.equal(upstreamFor(""), "https://default.example");
});
test("a mapped domain goes to its own server", () => {
config.stalwartServers["mapped.test"] = "https://mail.mapped.test";
try {
assert.equal(upstreamFor("[email protected]"), "https://mail.mapped.test");
} finally {
delete config.stalwartServers["mapped.test"];
}
});
test("an unmapped domain still goes to the default while others are mapped", () => {
config.stalwartServers["mapped.test"] = "https://mail.mapped.test";
try {
assert.equal(upstreamFor("[email protected]"), "https://default.example");
} finally {
delete config.stalwartServers["mapped.test"];
}
});
test("the domain is matched however it was typed", () => {
// Keys are normalised on load; the username has to be normalised the same
// way or a mapping silently never matches.
config.stalwartServers["mapped.test"] = "https://mail.mapped.test";
try {
assert.equal(upstreamFor("[email protected]"), "https://mail.mapped.test");
assert.equal(upstreamFor("[email protected]."), "https://mail.mapped.test", "root dot");
assert.equal(upstreamFor("someone@ mapped.test "), "https://mail.mapped.test", "stray spaces");
} finally {
delete config.stalwartServers["mapped.test"];
}
});
test("an address with an @ in the local part maps on the last one", () => {
config.stalwartServers["mapped.test"] = "https://mail.mapped.test";
try {
assert.equal(upstreamFor('"odd@name"@mapped.test'), "https://mail.mapped.test");
} finally {
delete config.stalwartServers["mapped.test"];
}
});
+62
View File
@@ -0,0 +1,62 @@
import { test } from "node:test";
import assert from "node:assert/strict";
process.env.STALWART_URL = "http://127.0.0.1:1";
process.env.APP_SECRET = "test-secret-for-ics-proxy";
const { safeFetch, safeFetchStatus } = await import("./imageproxy.js");
/**
* Subscribing to a calendar makes the server fetch a URL a stranger published,
* which is the second time this app knocks on a door somebody else chose. It
* goes through the same guard as the first — these tests are about that guard
* being reached, and about `webcal:` not being a way around it.
*/
test("a calendar URL is refused before any connection when it points somewhere private", async () => {
for (const url of [
"http://127.0.0.1/calendar.ics",
"http://169.254.169.254/latest/meta-data/", // cloud metadata
"http://[::1]/calendar.ics",
"http://10.0.0.1/c.ics",
"https://192.168.1.1/c.ics",
]) {
const got = await safeFetch(url, 500);
assert.equal(got, "forbidden_target", url);
}
});
test("webcal: is treated as https rather than waved through", async () => {
// Every subscription URL people are given is a webcal: one. It has to be
// understood, and it must not be a way past the address check.
const got = await safeFetch("webcal://127.0.0.1/calendar.ics", 500);
assert.equal(got, "forbidden_target");
});
test("schemes that are not http, https or webcal are refused", async () => {
for (const url of ["file:///etc/passwd", "ftp://example.com/c.ics", "gopher://example.com", "data:text/calendar,BEGIN:VCALENDAR"]) {
const got = await safeFetch(url, 500);
assert.equal(got, "bad_scheme", url);
}
});
test("a URL carrying credentials is refused", async () => {
// Credentials in a subscription URL would be sent by the server on the
// reader's behalf to a host the reader may not have looked at.
assert.equal(await safeFetch("http://user:[email protected]/c.ics", 500), "bad_url");
});
test("nonsense is refused rather than guessed at", async () => {
for (const url of ["", "not a url", "://missing-scheme"]) {
assert.equal(await safeFetch(url, 500), "bad_url", JSON.stringify(url));
}
});
test("each refusal has a status that says which kind it was", () => {
assert.equal(safeFetchStatus("forbidden_target"), 403);
assert.equal(safeFetchStatus("bad_scheme"), 400);
assert.equal(safeFetchStatus("bad_url"), 400);
assert.equal(safeFetchStatus("bad_redirect"), 400);
assert.equal(safeFetchStatus("dns_failure"), 502);
assert.equal(safeFetchStatus("fetch_failed"), 502);
});
+85
View File
@@ -0,0 +1,85 @@
import type { Context } from "hono";
import { safeFetch, safeFetchStatus } from "./imageproxy.js";
/**
* Fetching a calendar somebody has subscribed to.
*
* The browser cannot do this itself: a calendar URL belongs to whoever
* published it and almost none of them send CORS headers, so the request has
* to be made from here. That makes it the second place ihasmail reaches out to
* an address a stranger chose, and it goes through exactly the same guard as
* the first — `safeFetch` resolves the name, refuses private space on every
* answer, pins the connection to the address it checked, and re-checks each
* redirect. There is deliberately no second implementation of that.
*
* **Nothing is stored.** The text goes straight back to the browser, which
* parses it and holds the result in memory for as long as the tab is open. The
* server keeps no copy, no cache and no schedule, which is what lets an
* immutable container serve this at all.
*/
/** Generous for a calendar, small enough that nobody can post a film through it. */
const MAX_ICS_BYTES = 4 * 1024 * 1024;
/**
* Types a calendar is served as in practice. `text/plain` and the octet-stream
* are here because a great many servers get this wrong, and refusing a real
* calendar over a header the publisher chose badly helps nobody -- the parser
* checks the content itself, which is the claim that actually matters.
*/
const ACCEPTABLE = new Set(["text/calendar", "text/plain", "application/octet-stream", "application/ics", ""]);
export async function icsProxyHandler(c: Context) {
const got = await safeFetch(c.req.query("url") ?? "", 20_000);
if (typeof got === "string") return c.json({ error: got }, safeFetchStatus(got) as 400);
const { res, done } = got;
if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) {
done();
res.resume();
return c.json({ error: "fetch_failed", status: res.statusCode ?? 0 }, 502);
}
const type = (res.headers["content-type"] ?? "").split(";")[0]!.trim().toLowerCase();
if (!ACCEPTABLE.has(type)) {
done();
res.resume();
return c.json({ error: "not_calendar", type }, 415);
}
const declared = Number(res.headers["content-length"] ?? "0");
if (declared > MAX_ICS_BYTES) {
done();
res.resume();
return c.json({ error: "too_large" }, 413);
}
// Read it here rather than streaming: the browser needs the whole document
// to parse it, and the cap has to hold whether or not a length was declared.
let total = 0;
const chunks: Buffer[] = [];
try {
await new Promise<void>((resolve, reject) => {
res.on("data", (chunk: Buffer) => {
total += chunk.byteLength;
if (total > MAX_ICS_BYTES) {
res.destroy();
reject(new Error("too_large"));
return;
}
chunks.push(chunk);
});
res.on("end", () => resolve());
res.on("error", reject);
});
} catch (err) {
done();
return c.json({ error: (err as Error).message === "too_large" ? "too_large" : "fetch_failed" }, 502);
}
done();
return c.body(Buffer.concat(chunks).toString("utf8"), 200, {
"Content-Type": "text/calendar; charset=utf-8",
// Never stored on disk, and never held by anything in between either.
"Cache-Control": "no-store",
"X-Content-Type-Options": "nosniff",
});
}
+60 -22
View File
@@ -98,32 +98,50 @@ export function fetchPinned(url: URL, addr: string, signal?: AbortSignal): Promi
* Gmail-style remote content proxy: hides the reader's IP address and
* user-agent from tracking pixels, and blocks SSRF to internal networks.
*/
export async function imageProxyHandler(c: Context) {
if (!config.imageProxy) return c.json({ error: "disabled" }, 404);
const raw = c.req.query("url") ?? "";
/** Why a guarded fetch refused, in the words the handlers answer with. */
export type SafeFetchError = "bad_url" | "bad_scheme" | "forbidden_target" | "dns_failure" | "fetch_failed" | "bad_redirect";
export interface SafeFetchResult {
res: IncomingMessage;
/** The URL actually fetched, which is not the one asked for if it redirected. */
url: URL;
done: () => void;
}
/**
* Fetch a URL nobody here chose, with every check the image proxy has always
* made — and made in one place, because a second copy of an SSRF guard is how
* one of them ends up missing a case.
*
* The name is resolved first and *every* answer has to be acceptable, the
* connection is pinned to the address that was checked, and each redirect hop
* is re-resolved and re-pinned rather than handed to the socket library.
*/
export async function safeFetch(raw: string, timeoutMs = 15_000): Promise<SafeFetchResult | SafeFetchError> {
let url: URL;
try {
url = new URL(raw);
} catch {
return c.json({ error: "bad_url" }, 400);
return "bad_url";
}
if (url.protocol !== "http:" && url.protocol !== "https:") return c.json({ error: "bad_scheme" }, 400);
if (url.username || url.password) return c.json({ error: "bad_url" }, 400);
// webcal: is an http URL wearing a different word; nothing else is allowed.
if (url.protocol === "webcal:") url = new URL(`https:${raw.slice(raw.indexOf(":") + 1)}`);
if (url.protocol !== "http:" && url.protocol !== "https:") return "bad_scheme";
if (url.username || url.password) return "bad_url";
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 15_000);
let res: IncomingMessage;
const timer = setTimeout(() => controller.abort(), timeoutMs);
const done = () => clearTimeout(timer);
try {
let addr: string;
try {
addr = await resolveAllowed(url.hostname);
} catch (err) {
clearTimeout(timer);
return err instanceof BlockedTarget ? c.json({ error: "forbidden_target" }, 403) : c.json({ error: "dns_failure" }, 502);
done();
return err instanceof BlockedTarget ? "forbidden_target" : "dns_failure";
}
res = await fetchPinned(url, addr, controller.signal);
let res = await fetchPinned(url, addr, controller.signal);
// Follow a limited number of redirects, re-checking and re-pinning each hop.
let hops = 0;
while (res.statusCode && [301, 302, 303, 307, 308].includes(res.statusCode) && hops < 3) {
const loc = res.headers.location;
@@ -131,38 +149,58 @@ export async function imageProxyHandler(c: Context) {
res.resume(); // discard the redirect body
const next = new URL(loc, url);
if (next.protocol !== "http:" && next.protocol !== "https:") {
clearTimeout(timer);
return c.json({ error: "bad_redirect" }, 400);
done();
return "bad_redirect";
}
try {
addr = await resolveAllowed(next.hostname);
} catch (err) {
clearTimeout(timer);
return err instanceof BlockedTarget ? c.json({ error: "forbidden_target" }, 403) : c.json({ error: "dns_failure" }, 502);
done();
return err instanceof BlockedTarget ? "forbidden_target" : "dns_failure";
}
url = next;
res = await fetchPinned(url, addr, controller.signal);
hops++;
}
return { res, url, done };
} catch {
clearTimeout(timer);
return c.json({ error: "fetch_failed" }, 502);
done();
return "fetch_failed";
}
}
const SAFE_FETCH_STATUS: Record<SafeFetchError, number> = {
bad_url: 400,
bad_scheme: 400,
bad_redirect: 400,
forbidden_target: 403,
dns_failure: 502,
fetch_failed: 502,
};
export function safeFetchStatus(err: SafeFetchError): number {
return SAFE_FETCH_STATUS[err];
}
export async function imageProxyHandler(c: Context) {
if (!config.imageProxy) return c.json({ error: "disabled" }, 404);
const got = await safeFetch(c.req.query("url") ?? "");
if (typeof got === "string") return c.json({ error: got }, safeFetchStatus(got) as 400);
const { res, done } = got;
if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) {
clearTimeout(timer);
done();
res.resume();
return c.json({ error: "fetch_failed" }, 502);
}
const type = (res.headers["content-type"] ?? "").split(";")[0]!.trim().toLowerCase();
if (!type.startsWith("image/") || type === "image/svg+xml") {
clearTimeout(timer);
done();
res.resume();
return c.json({ error: "not_image" }, 415);
}
const len = Number(res.headers["content-length"] ?? "0");
if (len > MAX_IMAGE_BYTES) {
clearTimeout(timer);
done();
res.resume();
return c.json({ error: "too_large" }, 413);
}
@@ -176,7 +214,7 @@ export async function imageProxyHandler(c: Context) {
else controller2.enqueue(chunk);
},
});
res.on("close", () => clearTimeout(timer));
res.on("close", done);
const headers = new Headers({
"Content-Type": type,
"Cache-Control": "private, max-age=86400",
+374 -16
View File
@@ -5,6 +5,7 @@
*/
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { randomUUID } from "node:crypto";
import { expandOccurrences, occurrenceAt, occurrenceView, parseSyntheticId, slotOfOccurrence, splitOccurrencePatch, syntheticId, type Occurrence } from "./recurrence.js";
import { parseOtpauthUrl, verifyTotp } from "../totp.js";
import { holdUntilOf, undoStatusOf } from "./futurerelease.js";
@@ -26,6 +27,8 @@ const NO_FUTURE_RELEASE = process.env.MOCK_NO_FUTURE_RELEASE === "1";
/** What the session advertises, matching Stalwart's own 30 days. */
const MAX_DELAYED_SEND = 86400 * 30;
const ACCOUNT = "a1";
/** How long a push subscription lives before the server drops it. */
const PUSH_TTL_MS = 7 * 24 * 60 * 60 * 1000;
/** An account somebody has shared with the demo user. See the session below. */
const SHARED_ACCOUNT = "a2";
const SHARED_CAPS: Obj = {
@@ -97,7 +100,43 @@ const subjects = [
];
const emails: Obj[] = [];
let counter = 1;
function addEmail(o: { from: [string, string]; to?: string; subject: string; daysAgo: number; mailbox: string; threadId?: string; unread?: boolean; flagged?: boolean; html?: boolean; attach?: boolean; inReplyTo?: string }) {
/**
* A real TNEF blob, built to the format description, so the winmail.dat
* decoder has something to open that is not a hand-made fixture in its own
* test file. Two files inside, one of them carrying a long name in the MAPI
* stream behind an 8.3 title -- which is the case the decoder exists for.
*/
function winmailDat(): Buffer {
const u16 = (v: number) => Buffer.from([v & 0xff, (v >> 8) & 0xff]);
const u32 = (v: number) => Buffer.from([v & 0xff, (v >> 8) & 0xff, (v >> 16) & 0xff, (v >>> 24) & 0xff]);
const sum = (b: Buffer) => { let n = 0; for (const x of b) n = (n + x) & 0xffff; return n; };
const attr = (level: number, id: number, data: Buffer) => Buffer.concat([Buffer.from([level]), u32(id), u32(data.length), data, u16(sum(data))]);
const asciiProp = (id: number, value: string) => {
const bytes = Buffer.concat([Buffer.from(value, "latin1"), Buffer.from([0])]);
const pad = Buffer.alloc((4 - (bytes.length % 4)) % 4);
return Buffer.concat([u32(((id & 0xffff) << 16) | 0x001e), u32(bytes.length), bytes, pad]);
};
const mapi = (props: Buffer[]) => Buffer.concat([u32(props.length), ...props]);
const renddata = Buffer.alloc(14);
const title = (n: string) => Buffer.concat([Buffer.from(n, "latin1"), Buffer.from([0])]);
const notes = Buffer.from("Numbers pulled from the mock, not from anywhere real.\n", "latin1");
const csv = Buffer.from("quarter,revenue\nQ1,120\nQ2,145\n", "latin1");
return Buffer.concat([
u32(0x223e9f78), u16(0x1234),
attr(1, 0x00089006, u32(0x00010000)), // attTnefVersion
attr(2, 0x00069002, renddata),
attr(2, 0x00018010, title("QUARTE~1.CSV")),
attr(2, 0x00069005, mapi([asciiProp(0x3707, "Quarterly Revenue Final.csv"), asciiProp(0x370e, "text/csv")])),
attr(2, 0x0006800f, csv),
attr(2, 0x00069002, renddata),
attr(2, 0x00018010, title("notes.txt")),
attr(2, 0x0006800f, notes),
]);
}
function addEmail(o: { from: [string, string]; to?: string; subject: string; daysAgo: number; mailbox: string; threadId?: string; unread?: boolean; flagged?: boolean; html?: boolean; attach?: boolean; winmail?: boolean; inReplyTo?: string }) {
const id = `e${counter++}`;
const received = new Date(Date.now() - o.daysAgo * 86400_000 - Math.random() * 3600_000 * 5).toISOString().replace(/\.\d{3}Z$/, "Z");
const text = `Hi,\n\nThis is a sample message about "${o.subject}". It was generated by the ihasmail mock server so you can try the interface without a real mailbox.\n\nSome highlights:\n- Keyboard shortcuts (press ? )\n- Conversation view\n- Drag & drop to folders\n\nCheers,\n${o.from[0]}\n\n> On Monday, someone wrote:\n> This is the quoted part of an earlier message.\n> It should be collapsed by default.`;
@@ -109,6 +148,10 @@ function addEmail(o: { from: [string, string]; to?: string; subject: string; day
attachments.push({ partId: "3", blobId: putBlob("%PDF-1.4 mock", "application/pdf"), size: 48213, name: "contract-v3.pdf", type: "application/pdf", charset: null, disposition: "attachment", cid: null });
attachments.push({ partId: "4", blobId: putBlob(Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==", "base64"), "image/png"), size: 68, name: "pixel.png", type: "image/png", charset: null, disposition: "attachment", cid: null });
}
if (o.winmail) {
const dat = winmailDat();
attachments.push({ partId: "6", blobId: putBlob(dat, "application/ms-tnef"), size: dat.length, name: "winmail.dat", type: "application/ms-tnef", charset: null, disposition: "attachment", cid: null });
}
if (o.html) attachments.push({ partId: "5", blobId: putBlob(Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP4z8DwHwAFAAH/q842iQAAAABJRU5ErkJggg==", "base64"), "image/png"), size: 68, name: "logo.png", type: "image/png", charset: null, disposition: "inline", cid: "logo@mock" });
const e: Obj = {
id, blobId: putBlob(`From: ${o.from[0]} <${o.from[1]}>\r\nTo: ${USER}\r\nSubject: ${o.subject}\r\nDate: ${received}\r\nMessage-ID: <${id}@mock>\r\n\r\n${text}`, "message/rfc822"),
@@ -125,6 +168,14 @@ function addEmail(o: { from: [string, string]; to?: string; subject: string; day
bodyStructure: { partId: null, blobId: null, size: 0, type: "multipart/mixed", name: null, charset: null, disposition: null, cid: null, subParts: [{ partId: "1", blobId: textBlob, size: text.length, type: "text/plain", name: null, charset: "utf-8", disposition: null, cid: null }, ...(o.html ? [{ partId: "2", blobId: htmlBlob, size: html.length, type: "text/html", name: null, charset: "utf-8", disposition: null, cid: null }] : []), ...attachments] },
"header:List-Unsubscribe:asText": o.from[1].includes("newsletter") ? "<mailto:[email protected]?subject=unsubscribe>, <https://newsletter.example/unsub>" : null,
"header:X-Priority:asText": o.subject.startsWith("Security") ? "1 (Highest)" : null,
// Stalwart's spam filter writes the SpamAssassin-shaped set at delivery, so
// delivered mail carries it and mail this account wrote does not.
"header:X-Spam-Status:asText":
o.mailbox === "junk"
? "Yes, score=14.2 required=5.0 tests=[BAYES_99=3.5, URIBL_BLOCKED=2.7, HTML_IMAGE_ONLY=1.4, SUBJ_ALL_CAPS=1.2, FROM_FREEMAIL=0.4] autolearn=no"
: o.mailbox === "inbox"
? "No, score=-1.8 required=5.0 tests=[BAYES_00=-1.9, DKIM_VALID=-0.7, SPF_PASS=-0.1, HTML_MESSAGE=0.9]"
: null,
};
emails.push(e);
return e;
@@ -142,6 +193,7 @@ for (let i = 0; i < 45; i++) {
}
addEmail({ from: ["Demo User", USER], to: "[email protected]", subject: "Draft: ideas for the retreat", daysAgo: 0.1, mailbox: "drafts", html: true }).keywords = { $draft: true, $seen: true };
addEmail({ from: ["Spammy", "[email protected]"], subject: "You have WON!!!", daysAgo: 2, mailbox: "junk", unread: true });
addEmail({ from: ["Outlook User", "[email protected]"], subject: "Q3 figures (sent from Outlook)", daysAgo: 1, mailbox: "inbox", unread: true, winmail: true });
addEmail({ from: ["Finance Team", "[email protected]"], subject: "Invoice 2201 approved", daysAgo: 1, mailbox: "work-inv", unread: true });
addEmail({ from: ["Finance Team", "[email protected]"], subject: "Invoice 2202 pending", daysAgo: 2, mailbox: "work-inv", unread: true });
// A thread whose unread message is not the last one: someone's server queued
@@ -196,6 +248,13 @@ const events: Obj[] = [];
events.push({ id: "ev1", calendarIds: { c1: true }, "@type": "Event", uid: "ev1", title: "Standup", start: local(d(0, 9)), timeZone: tz, duration: "PT30M", recurrenceRule: { "@type": "RecurrenceRule", frequency: "weekly", byDay: [{ day: "mo" }, { day: "tu" }, { day: "we" }, { day: "th" }, { day: "fr" }] }, showWithoutTime: false, status: "confirmed", freeBusyStatus: "busy", privacy: "public" });
events.push({ id: "ev2", calendarIds: { c2: true }, "@type": "Event", uid: "ev2", title: "Design review", start: local(d(1, 14)), timeZone: tz, duration: "PT1H30M", showWithoutTime: false, locations: { l: { "@type": "Location", name: "Room 2" } }, participants: { me: { "@type": "Participant", name: "Demo User", calendarAddress: `mailto:${USER}`, roles: { owner: true, attendee: true }, participationStatus: "accepted" }, p2: { "@type": "Participant", name: "Ada Lovelace", calendarAddress: "mailto:[email protected]", roles: { attendee: true, required: true }, participationStatus: "needs-action", expectReply: true } }, organizerCalendarAddress: `mailto:${USER}` });
events.push({ id: "ev3", calendarIds: { c1: true }, "@type": "Event", uid: "ev3", title: "Conference", start: local(d(3, 0)).slice(0, 10) + "T00:00:00", duration: "P2D", showWithoutTime: true, timeZone: null });
/*
* One event in a zone that is not the reader's, because every other fixture
* here uses the machine's own and so cannot tell a correct conversion from
* no conversion at all. Dragging this one is what proves a move keeps the
* time the event says it happens at.
*/
events.push({ id: "ev9", calendarIds: { c1: true }, "@type": "Event", uid: "ev9", title: "Tokyo sync", start: local(d(2, 15)), timeZone: "Asia/Tokyo", duration: "PT1H", showWithoutTime: false, color: "#7c3aed" });
events.push({ id: "ev4", calendarIds: { c1: true }, "@type": "Event", uid: "ev4", title: "Lunch with Grace", start: local(d(2, 12)), timeZone: tz, duration: "PT1H", showWithoutTime: false, color: "#db2777" });
// Two in the shared account, so a colleague's calendar has something in it.
sharedEvents.push({ id: "sv1", calendarIds: { c9: true }, "@type": "Event", uid: "sv1", title: "Grace: release planning", start: local(d(1, 10)), timeZone: tz, duration: "PT1H", showWithoutTime: false, status: "confirmed", freeBusyStatus: "busy", privacy: "public" });
@@ -212,10 +271,51 @@ const sharedCards: Obj[] = [
{ id: "sc1", addressBookIds: { ab9: true }, name: { full: "Katherine Johnson" }, emails: { e1: { address: "[email protected]", contexts: {} } }, phones: {}, organizations: {}, nicknames: {}, addresses: {}, notes: {}, updated: new Date().toISOString() },
{ id: "sc2", addressBookIds: { ab9: true }, name: { full: "Dorothy Vaughan" }, emails: { e1: { address: "[email protected]", contexts: {} } }, phones: {}, organizations: {}, nicknames: {}, addresses: {}, notes: {}, updated: new Date().toISOString() },
];
/**
* One sort property, as Email/query defines them. `hasKeyword` sorts a
* boolean, and false comes before true -- which is what makes "unread first"
* an *ascending* sort on $seen.
*/
function compareBy(x: Obj, y: Obj, property: string, keyword?: string): number {
const addr = (v: unknown) => String(((v as Obj[] | undefined)?.[0] as Obj | undefined)?.email ?? "");
switch (property) {
case "receivedAt": return String(x.receivedAt).localeCompare(String(y.receivedAt));
case "sentAt": return String(x.sentAt ?? x.receivedAt).localeCompare(String(y.sentAt ?? y.receivedAt));
case "size": return Number(x.size ?? 0) - Number(y.size ?? 0);
case "subject": return String(x.subject ?? "").localeCompare(String(y.subject ?? ""));
case "from": return addr(x.from).localeCompare(addr(y.from));
case "to": return addr(x.to).localeCompare(addr(y.to));
case "hasKeyword": {
const has = (e: Obj) => (keyword && (e.keywords as Obj | undefined)?.[keyword] ? 1 : 0);
return has(x) - has(y);
}
default: return 0;
}
}
/** A server that does not implement sorting on keywords, so the fallback can be developed against. */
const NO_KEYWORD_SORT = process.env.MOCK_NO_KEYWORD_SORT === "1";
const booksFor = (accountId: unknown): Obj[] => (accountId === SHARED_ACCOUNT ? sharedAddressBooks : addressBooks);
/** One per contact, by index; a gap means that card has no birthday. */
const BIRTHDAYS: Array<{ year?: number; month: number; day: number } | null> = [
{ year: 1815, month: 12, day: 10 },
{ month: 6, day: 9 }, // no year: the common case
{ year: 1912, month: 6, day: 23 },
null,
{ year: 2000, month: 2, day: 29 }, // lands on the 28th in a non-leap year
{ year: 1918, month: 8, day: 26 },
];
const cards: Obj[] = people.slice(0, 6).map((p, i) => {
const [given, surname] = p[0]!.split(" ");
return { id: `cc${i}`, addressBookIds: { ab1: true }, "@type": "Card", version: "1.0", uid: `uid-cc${i}`, kind: "individual", name: { components: [{ kind: "given", value: given }, { kind: "surname", value: surname ?? "" }], isOrdered: true }, emails: { e1: { address: p[1], contexts: { work: true } } }, phones: i % 2 ? { p1: { number: `+1 555 010${i}`, features: { mobile: true } } } : undefined, organizations: i % 3 ? { o1: { name: "Example Corp" } } : undefined };
return { id: `cc${i}`, addressBookIds: { ab1: true }, "@type": "Card", version: "1.0", uid: `uid-cc${i}`, kind: "individual", name: { components: [{ kind: "given", value: given }, { kind: "surname", value: surname ?? "" }], isOrdered: true }, emails: { e1: { address: p[1], contexts: { work: true } } }, phones: i % 2 ? { p1: { number: `+1 555 010${i}`, features: { mobile: true } } } : undefined, organizations: i % 3 ? { o1: { name: "Example Corp" } } : undefined,
/*
* Birthdays on most but not all of them, and one with no year, because a
* card that records only a day and month is the common case rather than
* the exceptional one.
*/
anniversaries: BIRTHDAYS[i] ? { a1: { "@type": "Anniversary", kind: "birth", date: { "@type": "PartialDate", ...BIRTHDAYS[i] } } } : undefined };
});
const principals: Obj[] = people.slice(0, 5).map((p, i) => ({ id: `pr${i}`, type: "individual", name: p[0], description: null, email: p[1], timeZone: "UTC" }));
const fileNodes: Obj[] = [
@@ -399,6 +499,24 @@ function genericGet(list: Obj[]) {
return { accountId: ACCOUNT, state: String(state.n), list: found.map((x) => pick(x, a.properties as string[] | null)), notFound: ids ? ids.filter((id) => !list.some((x) => x.id === id)) : [] };
};
}
/**
* An id, as either a stored event or one occurrence of one.
*
* A synthetic id whose base is gone, or whose index falls outside the series
* (deleted, or past a `count`), resolves to nothing — `notFound`, the way the
* server answers for an occurrence that is not there any more.
*/
function resolveEvent(list: Obj[], id: string): { base: Obj; occ?: Occurrence } | null {
const direct = list.find((x) => x.id === id);
if (direct) return { base: direct };
const parsed = parseSyntheticId(id);
if (!parsed) return null;
const base = list.find((x) => x.id === parsed.baseId);
if (!base) return null;
const occ = occurrenceAt(base, parsed.slot);
return occ ? { base, occ } : null;
}
/** Thrown from an onCreate hook to refuse a create the way a real server would. */
class SetError extends Error {
constructor(readonly type: string, readonly description: string, readonly properties?: string[]) { super(description); }
@@ -436,6 +554,179 @@ function genericSet(list: Obj[], prefix: string, onCreate?: (o: Obj) => void) {
};
}
/* ---------- calendar events ---------- */
/**
* `CalendarEvent/set`, including the synthetic-id handling 0.16.20 added.
*
* An update or destroy aimed at an occurrence does not touch the series: it
* writes a `recurrenceOverrides` entry keyed by that date, exactly as Stalwart
* does — `{ excluded: true }` for a destroy, the patch merged in for an update.
*
* The refusals are the point of reproducing this at all:
*
* - a base event and one of its instances in the same request is refused, both
* ids at once, because the server cannot apply them in a defined order;
* - the same id twice is "Duplicate event id.";
* - the ten event-level properties are refused with `invalidProperties`;
* - and the twelve inherited ones are dropped in silence, with the response
* still saying the update succeeded. A mock that applied them would let a
* client that sends them look correct everywhere except a real server.
*/
/**
* Enough of an iCalendar reader to stand in for Stalwart's.
*
* It reads per VEVENT rather than across the whole file, because a file is the
* case an emailed invitation never was: an export carries a year of them, and a
* regex over the whole text would find the first DTSTART and call that the
* answer. One event still comes back as a bare object, the shape this returned
* when an invitation was all it had to handle.
*
* The synthetic organiser and attendee only go on events that arrived with a
* METHOD. Those are scheduling messages, which is what the invitation fixtures
* are; a plain export is not addressed to anyone, and inventing participants
* for it would make imported events look like invitations nobody sent.
*/
function calendarEventParse(a: Obj) {
const parsed: Obj = {};
const notParsable: string[] = [];
for (const b of a.blobIds as string[]) {
const blob = blobs.get(b);
if (!blob) { notParsable.push(b); continue; }
const text = blob.data.toString();
const field = (src: string, k: string) => new RegExp(`^${k}[^:\r\n]*:(.*)$`, "m").exec(src)?.[1]?.trim();
const method = field(text, "METHOD");
const bodies = text.match(/BEGIN:VEVENT[\s\S]*?END:VEVENT/g) ?? [];
const events = bodies.map((body) => {
const g = (k: string) => field(body, k);
const ds = g("DTSTART") ?? "20260101T000000Z";
const de = g("DTEND") ?? ds;
const toLocal = (s: string) => `${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6, 8)}T${s.slice(9, 11)}:${s.slice(11, 13)}:00`;
const start = new Date(`${toLocal(ds)}Z`);
const end = new Date(`${toLocal(de)}Z`);
return {
"@type": "Event",
uid: g("UID"),
title: g("SUMMARY"),
start: toLocal(ds),
timeZone: "Etc/UTC",
duration: `PT${Math.round((end.getTime() - start.getTime()) / 60000)}M`,
method,
locations: g("LOCATION") ? { l: { name: g("LOCATION") } } : undefined,
participants: method
? {
org: { name: "Ada Lovelace", calendarAddress: "mailto:[email protected]", roles: { owner: true } },
me: { name: "Demo User", calendarAddress: `mailto:${USER}`, roles: { attendee: true, required: true }, participationStatus: "needs-action" },
}
: undefined,
};
});
if (!events.length) { notParsable.push(b); continue; }
parsed[b] = events.length === 1 ? events[0] : events;
}
return { accountId: ACCOUNT, parsed, notParsable };
}
function calendarEventSet(a: Obj) {
const created: Obj = {};
const updated: Obj = {};
const destroyed: string[] = [];
const notCreated: Obj = {};
const notUpdated: Obj = {};
const notDestroyed: Obj = {};
for (const [cid, obj] of Object.entries((a.create as Obj) ?? {})) {
const o: Obj = { ...(obj as Obj), id: `ev${randomUUID().slice(0, 6)}` };
// Stalwart 0.16 rejects the RFC 8984 array outright and silently discards
// participants addressed the RFC 8984 way. The mock did neither, which is
// how #26 and #30 reached a live server unnoticed — so it does both.
if (o.recurrenceRules) { notCreated[cid] = new SetError("invalidProperties", "Invalid property.", ["recurrenceRules"]).toJSON(); continue; }
const parts = o.participants as Record<string, Obj> | undefined;
if (parts && Object.values(parts).some((p) => !p.calendarAddress)) delete o.participants;
if (o.replyTo && !o.organizerCalendarAddress) delete o.replyTo;
o.uid = o.uid ?? randomUUID();
events.push(o);
created[cid] = { id: o.id };
}
const updates = Object.entries((a.update as Obj) ?? {});
const destroys = ((a.destroy as string[]) ?? []).slice();
const seen = new Set<string>();
/* A base and one of its instances cannot be settled in the same request. */
const baseOf = (id: string): string | null => {
const r = resolveEvent(events, id);
return r ? (r.base.id as string) : null;
};
const touched = new Map<string, { base: string[]; instance: string[] }>();
for (const id of [...updates.map(([id]) => id), ...destroys]) {
const b = baseOf(id);
if (!b) continue;
const entry = touched.get(b) ?? { base: [], instance: [] };
(parseSyntheticId(id) ? entry.instance : entry.base).push(id);
touched.set(b, entry);
}
const conflicted = new Set<string>();
for (const [, e] of touched) {
if (e.base.length && e.instance.length) for (const id of [...e.base, ...e.instance]) conflicted.add(id);
}
const conflict = () => new SetError("invalidProperties", "A base event and its instances cannot be modified in the same request.", ["id"]).toJSON();
for (const [id, patch] of updates) {
if (conflicted.has(id)) { notUpdated[id] = conflict(); continue; }
if (seen.has(id)) { notUpdated[id] = new SetError("invalidProperties", "Duplicate event id.", ["id"]).toJSON(); continue; }
seen.add(id);
const resolved = resolveEvent(events, id);
if (!resolved) { notUpdated[id] = { type: "notFound" }; continue; }
if (!resolved.occ) { applyPatch(resolved.base, patch as Obj); updated[id] = null; continue; }
const { rejected, applied } = splitOccurrencePatch(patch as Obj);
if (rejected) { notUpdated[id] = new SetError("invalidProperties", "This property cannot be modified on a single occurrence.", [rejected]).toJSON(); continue; }
writeOverride(resolved.base, resolved.occ, applied);
updated[id] = null;
}
for (const id of destroys) {
if (conflicted.has(id)) { notDestroyed[id] = conflict(); continue; }
const resolved = resolveEvent(events, id);
if (!resolved) { notDestroyed[id] = { type: "notFound" }; continue; }
if (resolved.occ) {
// One date off a series, which is an override rather than a deletion.
writeOverride(resolved.base, resolved.occ, { excluded: true }, true);
destroyed.push(id);
continue;
}
const i = events.findIndex((x) => x.id === id);
if (i >= 0) { events.splice(i, 1); destroyed.push(id); }
}
return setResp({
created, updated, destroyed,
...(Object.keys(notCreated).length ? { notCreated } : {}),
...(Object.keys(notUpdated).length ? { notUpdated } : {}),
...(Object.keys(notDestroyed).length ? { notDestroyed } : {}),
});
}
/**
* Merge a patch into the override for one date.
*
* Stalwart fills `start` and `duration` in when the patch leaves them out, so
* an override always carries its own timing; the mock does the same, or a
* client could depend on inheriting them and be right only here.
*/
function writeOverride(base: Obj, occ: Occurrence, patch: Obj, replace = false) {
const overrides = (base.recurrenceOverrides as Record<string, Obj> | undefined) ?? {};
const existing = replace ? {} : (overrides[occ.recurrenceId] ?? {});
const next: Obj = { ...existing };
if (!replace) {
if (!("start" in next)) next.start = occ.start;
if (!("duration" in next) && base.duration) next.duration = base.duration;
}
applyPatch(next, patch);
overrides[occ.recurrenceId] = next;
base.recurrenceOverrides = overrides;
}
/* ---------- submissions ---------- */
/**
* Held messages, the way Stalwart models them: `sendAt` is derived from the
@@ -475,7 +766,26 @@ const handlers: Record<string, Handler> = {
"Mailbox/changes": () => ({ accountId: ACCOUNT, oldState: "1", newState: String(state.n), hasMoreChanges: false, created: [], updated: [], destroyed: [] }),
"Email/query": (a) => {
let list = emails.filter((e) => matchFilter(e, a.filter as Obj));
list.sort((x, y) => String(y.receivedAt).localeCompare(String(x.receivedAt)));
/*
* Honour the sort rather than always answering newest-first. This used to
* ignore it entirely, which reproduced a server that silently returns a
* different order from the one asked for -- the one shape of wrongness a
* client cannot detect.
*/
const sort = (a.sort as Obj[] | undefined) ?? [{ property: "receivedAt", isAscending: false }];
if (NO_KEYWORD_SORT && sort.some((c) => String(c.property) === "hasKeyword")) {
// A method-level failure, the way a real server refuses an optional sort:
// the whole call fails rather than the sort being quietly dropped.
throw new MethodError("unsupportedSort", "Sorting on hasKeyword is not supported.");
}
list.sort((x, y) => {
for (const c of sort) {
const asc = c.isAscending !== false;
const cmp = compareBy(x, y, String(c.property), c.keyword as string | undefined);
if (cmp !== 0) return asc ? cmp : -cmp;
}
return 0;
});
if (a.collapseThreads) {
const seen = new Set<string>();
list = list.filter((e) => { const t = e.threadId as string; if (seen.has(t)) return false; seen.add(t); return true; });
@@ -634,8 +944,18 @@ const handlers: Record<string, Handler> = {
const clash = pushSubscriptions.findIndex((s) => s.deviceClientId === deviceId);
if (clash >= 0) pushSubscriptions.splice(clash, 1);
const id = `ps${randomUUID().slice(0, 6)}`;
pushSubscriptions.push({ id, deviceClientId: deviceId, url: o.url, types: o.types ?? null, emailPush: o.emailPush ?? null, expires: null, keys, verified: false, code: `v${randomUUID().slice(0, 8)}` });
created[cid] = { id, expires: null };
/*
* A subscription expires, and this used to hand back `expires: null`.
* That is the one shape that makes the client's real problem invisible in
* development: JMAP puts a ceiling of seven days on a push subscription
* and expects the client to re-register before it lapses, so a client
* that never renews works perfectly against a mock that never expires
* anything and goes silent a week after being deployed. Seven days here,
* so "does this client renew?" is a question the mock can answer.
*/
const expires = new Date(Date.now() + PUSH_TTL_MS).toISOString();
pushSubscriptions.push({ id, deviceClientId: deviceId, url: o.url, types: o.types ?? null, emailPush: o.emailPush ?? null, expires, keys, verified: false, code: `v${randomUUID().slice(0, 8)}` });
created[cid] = { id, expires };
state.n++;
}
for (const [id, patch] of Object.entries((a.update as Obj) ?? {})) {
@@ -774,23 +1094,61 @@ const handlers: Record<string, Handler> = {
"SieveScript/validate": () => ({ accountId: ACCOUNT, error: null }),
"Calendar/get": (a) => hideShareWithUnlessAsked(a, genericGet(calendarsFor(a.accountId))(a) as { list: Obj[] }) as never,
"Calendar/set": (a) => genericSet(calendarsFor(a.accountId), "c", (o) => Object.assign(o, { color: "#0f766e", isSubscribed: true, isVisible: true, isDefault: false, includeInAvailability: "all", timeZone: null, shareWith: null, myRights: rightsCal(), description: null, sortOrder: 0, ...o }))(a),
"CalendarEvent/query": (a) => { const list = eventsFor(a.accountId); return { accountId: a.accountId ?? ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: list.filter((e) => !(a.filter as Obj)?.uid || e.uid === (a.filter as Obj).uid).map((e) => e.id), total: list.length }; },
"CalendarEvent/get": (a) => genericGet(eventsFor(a.accountId))(a),
/*
* With `expandRecurrences` every id that comes back is synthetic — a one-off
* included, which is what a live 0.16.19 does and what makes `baseEventId`
* useless as a test for a series. Without it (the `findByUid` path) the
* stored ids come back untouched, because callers hand those straight to a
* destroy and mean the whole event.
*/
"CalendarEvent/query": (a) => {
const list = eventsFor(a.accountId);
const filter = (a.filter as Obj) ?? {};
const matching = list.filter((e) => !filter.uid || e.uid === filter.uid);
if (!a.expandRecurrences) {
return { accountId: a.accountId ?? ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: matching.map((e) => e.id), total: matching.length };
}
const from = filter.after ? new Date(filter.after as string) : new Date(-8640000000000);
const to = filter.before ? new Date(filter.before as string) : new Date(8640000000000);
const ids: string[] = [];
for (const e of matching) for (const occ of expandOccurrences(e, from, to)) ids.push(syntheticId(e.id as string, slotOfOccurrence(e, occ)));
return { accountId: a.accountId ?? ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids, total: ids.length };
},
"CalendarEvent/get": (a) => {
const list = eventsFor(a.accountId);
const ids = a.ids as string[] | null | undefined;
if (!ids) return genericGet(list)(a);
const found: Obj[] = [];
const notFound: string[] = [];
for (const id of ids) {
const resolved = resolveEvent(list, id);
if (!resolved) { notFound.push(id); continue; }
found.push(resolved.occ ? occurrenceView(resolved.base, resolved.occ) : resolved.base);
}
return { accountId: ACCOUNT, state: String(state.n), list: found.map((x) => pick(x, a.properties as string[] | null)), notFound };
},
// Stalwart 0.16 rejects the RFC 8984 array outright and silently discards
// participants addressed the RFC 8984 way. The mock did neither, which is how
// #26 and #30 reached a live server unnoticed — so it now does both.
"CalendarEvent/set": genericSet(events, "ev", (o) => {
if (o.recurrenceRules) throw new SetError("invalidProperties", "Invalid property.", ["recurrenceRules"]);
const parts = o.participants as Record<string, Obj> | undefined;
if (parts && Object.values(parts).some((p) => !p.calendarAddress)) delete o.participants;
if (o.replyTo && !o.organizerCalendarAddress) delete o.replyTo;
return Object.assign(o, { uid: o.uid ?? randomUUID() });
}),
"CalendarEvent/parse": (a) => { const parsed: Obj = {}; for (const b of a.blobIds as string[]) { const blob = blobs.get(b); if (!blob) continue; const t = blob.data.toString(); const g = (k: string) => new RegExp(`^${k}[^:]*:(.*)$`, "m").exec(t)?.[1]?.trim(); const ds = g("DTSTART") ?? "20260101T000000Z"; const de = g("DTEND") ?? ds; const toLocal = (s: string) => `${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6, 8)}T${s.slice(9, 11)}:${s.slice(11, 13)}:00`; const start = new Date(`${toLocal(ds)}Z`); const end = new Date(`${toLocal(de)}Z`); parsed[b] = { "@type": "Event", uid: g("UID"), title: g("SUMMARY"), start: toLocal(ds), timeZone: "Etc/UTC", duration: `PT${Math.round((end.getTime() - start.getTime()) / 60000)}M`, method: g("METHOD"), locations: g("LOCATION") ? { l: { name: g("LOCATION") } } : undefined, participants: { org: { name: "Ada Lovelace", calendarAddress: "mailto:[email protected]", roles: { owner: true } }, me: { name: "Demo User", calendarAddress: `mailto:${USER}`, roles: { attendee: true, required: true }, participationStatus: "needs-action" } } }; } return { accountId: ACCOUNT, parsed, notParsable: [] }; },
"CalendarEvent/set": (a) => calendarEventSet(a),
"CalendarEvent/parse": (a) => calendarEventParse(a),
"ParticipantIdentity/get": genericGet(participantIdentities),
"Principal/query": () => ({ accountId: ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: principals.map((p) => p.id) }),
"Principal/get": genericGet(principals),
"Principal/getAvailability": (a) => ({ accountId: ACCOUNT, list: [{ utcStart: String(a.utcStart).slice(0, 11) + "13:00:00Z", utcEnd: String(a.utcStart).slice(0, 11) + "14:30:00Z", busyStatus: "confirmed", event: null }] }),
// One busy block a day across whatever range was asked for. It used to answer
// with a single block on the first day whatever the range, which was all an
// availability bar a day wide could show -- and left a bar covering several
// days looking as though everyone were free for all but the first of them.
"Principal/getAvailability": (a) => {
const from = new Date(String(a.utcStart));
const to = new Date(String(a.utcEnd));
const list: Obj[] = [];
for (let day = new Date(from); day < to && list.length < 31; day.setUTCDate(day.getUTCDate() + 1)) {
const date = day.toISOString().slice(0, 11);
list.push({ utcStart: `${date}13:00:00Z`, utcEnd: `${date}14:30:00Z`, busyStatus: "confirmed", event: null });
}
return { accountId: ACCOUNT, list };
},
"AddressBook/get": (a) => hideShareWithUnlessAsked(a, genericGet(booksFor(a.accountId))(a) as { list: Obj[] }) as never,
"AddressBook/set": (a) => {
/* Stalwart refuses any update to a book shared read-only, `isSubscribed`
+210
View File
@@ -0,0 +1,210 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { expandOccurrences, occurrenceAt, occurrenceView, parseSyntheticId, slotOfOccurrence, splitOccurrencePatch, syntheticId } from "./recurrence.js";
/**
* The mock expands recurrences so that per-occurrence editing can be developed
* against something. What it has to get right is not the expansion — that is
* the easy half — but the three things a live server does that a client will
* otherwise be written against wrongly:
*
* - every expanded id is synthetic, one-offs included;
* - an occurrence carries a `recurrenceId` and no rule;
* - a per-occurrence patch loses some properties in silence.
*/
const WEEKDAYS = { "@type": "RecurrenceRule", frequency: "weekly", byDay: [{ day: "mo" }, { day: "tu" }, { day: "we" }, { day: "th" }, { day: "fr" }] };
/** A standup at 09:00 every weekday, starting Monday 2026-09-07. */
const series = () => ({ id: "ev1", "@type": "Event", uid: "u1", title: "Standup", start: "2026-09-07T09:00:00", duration: "PT30M", recurrenceRule: WEEKDAYS } as Record<string, unknown>);
const oneOff = () => ({ id: "ev2", "@type": "Event", uid: "u2", title: "Lunch", start: "2026-09-08T12:00:00", duration: "PT1H" } as Record<string, unknown>);
const week = (from: string, to: string) => [new Date(from), new Date(to)] as const;
describe("expandOccurrences", () => {
it("gives a weekday rule five dates in a week and skips the weekend", () => {
const [a, b] = week("2026-09-07T00:00:00", "2026-09-14T00:00:00");
const out = expandOccurrences(series(), a, b);
assert.deepEqual(out.map((o) => o.start), [
"2026-09-07T09:00:00", "2026-09-08T09:00:00", "2026-09-09T09:00:00",
"2026-09-10T09:00:00", "2026-09-11T09:00:00",
]);
});
it("gives a one-off exactly one occurrence, at index 0", () => {
const [a, b] = week("2026-09-01T00:00:00", "2026-10-01T00:00:00");
const out = expandOccurrences(oneOff(), a, b);
assert.equal(out.length, 1);
assert.equal(out[0]!.index, 0);
});
it("honours count", () => {
const ev = { ...series(), recurrenceRule: { ...WEEKDAYS, count: 3 } };
const [a, b] = week("2026-09-07T00:00:00", "2026-10-01T00:00:00");
assert.equal(expandOccurrences(ev, a, b).length, 3);
});
it("drops an excluded date from the expansion, keeping the series positions", () => {
const ev = { ...series(), recurrenceOverrides: { "2026-09-08T09:00:00": { excluded: true } } };
const [a, b] = week("2026-09-07T00:00:00", "2026-09-14T00:00:00");
const out = expandOccurrences(ev, a, b);
assert.deepEqual(out.map((o) => o.start), [
"2026-09-07T09:00:00", "2026-09-09T09:00:00", "2026-09-10T09:00:00", "2026-09-11T09:00:00",
]);
// The position within the series is unchanged — Wednesday is still the
// third date the rule produces, whatever happened to Tuesday. It is the
// *id* built on top of that which moves, and only after a write.
assert.equal(out[1]!.index, 2);
});
it("carries an override onto the occurrence it keys", () => {
const ev = { ...series(), recurrenceOverrides: { "2026-09-09T09:00:00": { title: "Standup (long)" } } };
const [a, b] = week("2026-09-07T00:00:00", "2026-09-14T00:00:00");
const out = expandOccurrences(ev, a, b);
assert.deepEqual(out.find((o) => o.start === "2026-09-09T09:00:00")!.override, { title: "Standup (long)" });
});
});
describe("occurrenceView", () => {
it("strips the rule, sets recurrenceId, and points baseEventId at the master", () => {
const base = series();
const occ = occurrenceAt(base, 1)!;
const view = occurrenceView(base, occ);
assert.equal(view.id, syntheticId("ev1", 1));
assert.equal(view.baseEventId, "ev1");
assert.equal(view.recurrenceId, "2026-09-08T09:00:00");
assert.equal(view.recurrenceRule, undefined);
assert.equal(view.recurrenceOverrides, undefined);
});
it("gives a one-off a synthetic id over a different base, and no recurrenceId", () => {
// Both halves matter. The id is why `baseEventId` proves nothing about a
// series; the absent `recurrenceId` is why a one-off does not read as one.
const base = oneOff();
const view = occurrenceView(base, occurrenceAt(base, 0)!);
assert.equal(view.id, "ev2-o0");
assert.equal(view.baseEventId, "ev2");
assert.notEqual(view.id, view.baseEventId);
assert.equal(view.recurrenceId, undefined);
});
it("lets an override win over the series", () => {
const base = { ...series(), recurrenceOverrides: { "2026-09-08T09:00:00": { title: "Moved" } } };
// Slot 2, not 1: one override has already shifted the numbering. Reaching
// for the id this occurrence had *before* the write is the bug below.
const view = occurrenceView(base, occurrenceAt(base, 2)!);
assert.equal(view.start, "2026-09-08T09:00:00");
assert.equal(view.title, "Moved");
});
});
describe("parseSyntheticId", () => {
it("round-trips", () => {
assert.deepEqual(parseSyntheticId(syntheticId("ev1", 12)), { baseId: "ev1", slot: 12 });
});
it("does not claim a stored id", () => {
assert.equal(parseSyntheticId("ev1"), null);
});
});
describe("splitOccurrencePatch", () => {
it("applies what an occurrence takes", () => {
const { rejected, applied } = splitOccurrencePatch({ title: "Just today", color: "#f00" });
assert.equal(rejected, undefined);
assert.deepEqual(applied, { title: "Just today", color: "#f00" });
});
it("refuses an event-level property by name", () => {
assert.equal(splitOccurrencePatch({ calendarIds: { c2: true } }).rejected, "calendarIds");
assert.equal(splitOccurrencePatch({ hideAttendees: true }).rejected, "hideAttendees");
});
it("drops an inherited property in silence, which is the dangerous half", () => {
// No `rejected`, nothing applied, and a real server would still answer
// "updated". Anything that trusts the response believes this landed.
const { rejected, applied } = splitOccurrencePatch({ privacy: "private", recurrenceRule: null });
assert.equal(rejected, undefined);
assert.deepEqual(applied, {});
});
it("judges a pointer patch on its first token", () => {
assert.deepEqual(splitOccurrencePatch({ "participants/me/participationStatus": "accepted" }).applied,
{ "participants/me/participationStatus": "accepted" });
assert.deepEqual(splitOccurrencePatch({ "participants/me/calendarAddress": "mailto:x@y" }).applied, {});
});
});
describe("synthetic ids are only true until the next write", () => {
/*
* Confirmed live on 0.16.20 (2026-08-31): writing one `recurrenceOverrides`
* entry renumbered a five-week series so that the *same* ids addressed
* different dates. Nothing was rejected. The mock reproduces the shape of
* that rather than the exact permutation, because the property that bites is
* not which date an id moves to but that it moves at all, silently.
*/
it("makes a cached id address a different date after an override is written", () => {
const before = series();
const held = syntheticId("ev1", slotOfOccurrence(before, occurrenceAt(before, 3)!));
const dateBefore = occurrenceAt(before, parseSyntheticId(held)!.slot)!.start;
const after = { ...before, recurrenceOverrides: { "2026-09-07T09:00:00": { title: "changed" } } };
const dateAfter = occurrenceAt(after, parseSyntheticId(held)!.slot)!.start;
assert.notEqual(dateAfter, dateBefore);
// And crucially it still resolves — a stale id is wrong, not invalid, so a
// client that trusts it gets a confident answer about the wrong day.
assert.ok(dateAfter);
});
it("keeps recurrenceId meaning the same date across a write, which is why it is the handle", () => {
const before = series();
const occ = occurrenceAt(before, 3)!;
const after = { ...before, recurrenceOverrides: { "2026-09-07T09:00:00": { title: "changed" } } };
const same = expandOccurrences(after, new Date("2026-09-01T00:00:00"), new Date("2026-10-01T00:00:00"))
.find((o) => o.recurrenceId === occ.recurrenceId);
assert.equal(same!.start, occ.start);
});
});
describe("an override that moves an occurrence", () => {
/*
* Confirmed live on 0.16.20 (2026-08-31): one occurrence of a weekly 09:00
* series moved to 14:00 comes back with `start` at 14:00 and `recurrenceId`
* still at 09:00 — the slot the rule made, which the move does not touch.
*
* The mock used to clobber the override's `start` with the slot time, so a
* moved occurrence did not move. That made per-occurrence *time* editing —
* one of the main things the feature is for — look broken against the mock
* and fine against the server.
*/
const moved = () => ({
...series(),
recurrenceOverrides: { "2026-09-08T09:00:00": { start: "2026-09-08T14:00:00" } },
});
it("moves the occurrence and leaves its recurrenceId on the original slot", () => {
const [a, b] = week("2026-09-07T00:00:00", "2026-09-14T00:00:00");
const occ = expandOccurrences(moved(), a, b).find((o) => o.recurrenceId === "2026-09-08T09:00:00")!;
assert.equal(occ.start, "2026-09-08T14:00:00");
assert.equal(occ.recurrenceId, "2026-09-08T09:00:00");
});
it("shows the moved time on the occurrence a get returns", () => {
const base = moved();
const occ = expandOccurrences(base, new Date("2026-09-07T00:00:00"), new Date("2026-09-14T00:00:00"))
.find((o) => o.recurrenceId === "2026-09-08T09:00:00")!;
const view = occurrenceView(base, occ);
assert.equal(view.start, "2026-09-08T14:00:00");
assert.equal(view.recurrenceId, "2026-09-08T09:00:00");
});
it("keeps the occurrence findable by recurrenceId after the move", () => {
// This is the property the store depends on: `recurrenceId` survives both
// a renumbering and a move, so it is the handle a mutation resolves from.
const base = moved();
const all = expandOccurrences(base, new Date("2026-09-01T00:00:00"), new Date("2026-10-01T00:00:00"));
assert.equal(all.filter((o) => o.recurrenceId === "2026-09-08T09:00:00").length, 1);
});
});
+238
View File
@@ -0,0 +1,238 @@
/**
* Enough recurrence expansion for the mock to behave like Stalwart 0.16.20.
*
* The mock used to hand a recurring event back once, as its stored self. Three
* things that only a live server showed were therefore impossible to develop
* against, and all three had already cost a debugging session:
*
* - an expanded query gives *everything* a synthetic id over a `baseEventId`,
* a one-off included, so `baseEventId` is no evidence of a series;
* - an occurrence carries a `recurrenceId` and no rule of its own;
* - 0.16.20 takes a write aimed at a synthetic id and turns it into a
* `recurrenceOverrides` entry rather than touching the series.
*
* A mock that agrees with the client rather than with the server is how #26 and
* #30 reached a live instance, so the refusals matter as much as the successes:
* what Stalwart rejects is rejected here, and what it drops in silence is
* dropped here, in silence, on purpose.
*/
export type Obj = Record<string, unknown>;
/** How far the expander will walk before giving up on a rule. */
const MAX_ITERATIONS = 750;
const DAYS = ["su", "mo", "tu", "we", "th", "fr", "sa"];
/**
* The id an occurrence is addressed by, which is only true until the next write.
*
* Stalwart's are opaque; the mock's are parseable because it has to resolve
* them, and nothing in ihasmail may read either.
*
* They are also deliberately **unstable**, because the real ones are.
* **Confirmed live on 0.16.20 (2026-08-31):** a synthetic id encodes a position
* in the expanded series, and writing a `recurrenceOverrides` entry adds a
* component that renumbers it. A five-week series held `e i m q u` over
* 03-01…03-29; after one override was written to 03-08 the same ids addressed
* 03-01, 03-15, 03-29, 03-08, 03-22. Nothing was rejected — they just meant
* different dates.
*
* That is the hazard worth reproducing, and note which way round it goes: a
* stale id is not *invalid*, it is *wrong*. A mock that expired them instead
* would hand back a loud `notFound` and let a client that caches ids look
* careful. So the numbering is shifted by the number of overrides — an
* arbitrary stand-in for Stalwart's renumbering, with the one property that
* matters: hold an id across a write and it silently addresses another date.
*/
export const syntheticId = (baseId: string, slot: number): string => `${baseId}-o${slot}`;
export function parseSyntheticId(id: string): { baseId: string; slot: number } | null {
const m = /^(.+)-o(\d+)$/.exec(id);
return m ? { baseId: m[1]!, slot: Number(m[2]) } : null;
}
/** How far the id numbering has been rotated away from the series order. */
function rotation(base: Obj): number {
return Object.keys((base.recurrenceOverrides as Record<string, Obj> | undefined) ?? {}).length;
}
/** The id slot this occurrence currently answers to. */
export function slotOfOccurrence(base: Obj, occ: Occurrence): number {
return occ.index + rotation(base);
}
/** `2026-08-31T09:00:00` — the naive local form the mock stores `start` in. */
export function localDateTime(d: Date): string {
const p = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
}
const parseLocal = (s: string): Date => new Date(s);
export interface Occurrence {
index: number;
/** The slot in the series this instance fills, which keys any override. */
recurrenceId: string;
start: string;
/** Set when a `recurrenceOverrides` entry applies to this date. */
override?: Obj;
}
interface Rule {
frequency?: string;
interval?: number;
count?: number;
until?: string;
byDay?: { day: string }[];
}
/**
* Every occurrence of `base` between `from` and `to`, in series order.
*
* An event with no rule has exactly one, at index 0 — which is what gives a
* one-off the synthetic id a real server would give it.
*/
export function expandOccurrences(base: Obj, from: Date, to: Date): Occurrence[] {
const overrides = (base.recurrenceOverrides as Record<string, Obj> | undefined) ?? {};
const startStr = base.start as string;
if (!startStr) return [];
const first = parseLocal(startStr);
const rule = base.recurrenceRule as Rule | undefined;
const out: Occurrence[] = [];
const emit = (index: number, at: Date): boolean => {
const recurrenceId = localDateTime(at);
const override = overrides[recurrenceId];
// An excluded date is simply gone from the expansion. Its slot is not
// reserved -- see `syntheticId` for why nothing here pretends otherwise.
if (override?.excluded === true) return true;
/*
* An override may move the occurrence, and then `start` and `recurrenceId`
* are two different times: the slot it fills stays where the rule put it,
* and only the clock time moves. **Confirmed live on 0.16.20
* (2026-08-31)**: one occurrence of a weekly 09:00 series moved to 14:00
* came back `start: 2027-06-14T14:00:00` with `recurrenceId` still
* `2027-06-14T09:00:00`.
*
* Which is exactly why `recurrenceId` is what a client holds on to. It is
* the one name for this instance that neither a renumbering nor a move
* changes.
*/
const start = (typeof override?.start === "string" ? override.start : null) ?? recurrenceId;
const shown = parseLocal(start);
if (shown >= from && shown < to) {
out.push({ index, recurrenceId, start, ...(override ? { override } : {}) });
}
return at < to;
};
if (!rule?.frequency) {
emit(0, first);
return out;
}
const interval = Math.max(1, rule.interval ?? 1);
const until = rule.until ? parseLocal(rule.until) : null;
const byDay = rule.byDay?.length ? new Set(rule.byDay.map((d) => d.day.toLowerCase())) : null;
let index = 0;
let emitted = 0;
const cursor = new Date(first);
for (let step = 0; step < MAX_ITERATIONS; step++) {
if (until && cursor > until) break;
if (rule.count != null && emitted >= rule.count) break;
const matches = !byDay || byDay.has(DAYS[cursor.getDay()]!);
if (matches) {
emitted++;
const keepGoing = emit(index, new Date(cursor));
index++;
if (!keepGoing) break;
}
// A rule with byDay walks day by day and keeps the days it names; without
// one it steps by its own frequency.
if (byDay) cursor.setDate(cursor.getDate() + 1);
else if (rule.frequency === "daily") cursor.setDate(cursor.getDate() + interval);
else if (rule.frequency === "weekly") cursor.setDate(cursor.getDate() + 7 * interval);
else if (rule.frequency === "monthly") cursor.setMonth(cursor.getMonth() + interval);
else if (rule.frequency === "yearly") cursor.setFullYear(cursor.getFullYear() + interval);
else break;
}
return out;
}
/** Fields that describe the series and never travel down to one instance. */
const SERIES_ONLY = ["recurrenceRule", "recurrenceRules", "excludedRecurrenceRules", "recurrenceOverrides"];
/**
* The object a `CalendarEvent/get` returns for one occurrence.
*
* The rule is stripped, `recurrenceId` is set, and `baseEventId` points at the
* master — so an occurrence is recognisable by its `recurrenceId` and by
* nothing else, which is the shape `isRecurring` was written against.
*/
export function occurrenceView(base: Obj, occ: Occurrence): Obj {
const view: Obj = { ...base };
for (const k of SERIES_ONLY) delete view[k];
Object.assign(view, occ.override ?? {});
view.id = syntheticId(base.id as string, slotOfOccurrence(base, occ));
view.baseEventId = base.id;
view.start = occ.start;
// Only a genuine instance of a series carries one. A one-off expanded into
// its single occurrence does not, or every one-off would look recurring.
if (base.recurrenceRule) view.recurrenceId = occ.recurrenceId;
delete view.excluded;
return view;
}
/* ---------- what a single occurrence will not take ---------- */
/** Refused outright, with `invalidProperties`. */
export const OCCURRENCE_REJECTED = new Set([
"baseEventId", "calendarIds", "isDraft", "isOrigin", "utcStart", "utcEnd",
"useDefaultAlerts", "mayInviteSelf", "mayInviteOthers", "hideAttendees",
]);
/**
* Dropped from the patch, with the response still reporting success.
*
* This is the half that has to be reproduced most carefully. A mock that
* *applied* these would agree with a client that sends them, and the belief
* would ship — which is exactly the road #26 took to a live server.
*/
export const OCCURRENCE_INHERITED = new Set([
"@type", "method", "organizerCalendarAddress", "privacy", "prodId",
"recurrenceId", "recurrenceIdTimeZone", "sentBy", "uid",
"recurrenceOverrides", "recurrenceRule", "relatedTo",
]);
/**
* Split a per-occurrence patch the way the server's validator does.
*
* `rejected` is the first property that would be refused, if any; `applied` is
* what actually lands on the override. Everything else vanishes without a word.
*/
export function splitOccurrencePatch(patch: Obj): { rejected?: string; applied: Obj } {
const applied: Obj = {};
for (const [key, value] of Object.entries(patch)) {
const [head, , third] = key.split("/");
const root = head ?? key;
if (OCCURRENCE_REJECTED.has(root)) return { rejected: root, applied };
if (OCCURRENCE_INHERITED.has(root)) continue;
if (root === "participants" && third === "calendarAddress") continue;
if (root === "id") continue;
applied[key] = value;
}
return { applied };
}
/** The occurrence a slot currently addresses — which is not a fixed thing. */
export function occurrenceAt(base: Obj, slot: number): Occurrence | null {
const index = slot - rotation(base);
if (index < 0) return null;
const all = expandOccurrences(base, new Date(-8640000000000), new Date(8640000000000));
return all.find((o) => o.index === index) ?? null;
}
+67
View File
@@ -0,0 +1,67 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { RateLimiter } from "./ratelimit.js";
/**
* The limiter's job is to slow down password guessing. #239 is about the
* attempts it takes for outcomes that were never a guess: ihasmail runs apart
* from Stalwart, so an upstream that refuses a connection is ordinary, and
* retrying through one used to spend the window and lock somebody out until
* after the cause had gone.
*/
test("check allows up to the limit and then refuses", () => {
const rl = new RateLimiter(3, 60_000);
assert.equal(rl.check("k"), true);
assert.equal(rl.check("k"), true);
assert.equal(rl.check("k"), true);
assert.equal(rl.check("k"), false);
});
test("refund gives back exactly one attempt", () => {
const rl = new RateLimiter(2, 60_000);
rl.check("k");
rl.check("k");
assert.equal(rl.check("k"), false, "spent");
rl.refund("k");
assert.equal(rl.check("k"), true, "one back");
assert.equal(rl.check("k"), false, "and only one");
});
test("refunding every attempt leaves the key spending nothing", () => {
// The outage case: every try refunded, so a person retrying through it is
// not locked out when the server returns.
const rl = new RateLimiter(2, 60_000);
for (let i = 0; i < 20; i++) {
assert.equal(rl.check("k"), true, `attempt ${i} allowed`);
rl.refund("k");
}
});
test("a run of real failures still adds up around a refunded one", () => {
// Refund takes one attempt back, not the key's whole history -- an outage in
// the middle of somebody guessing must not clear what they spent before it.
const rl = new RateLimiter(3, 60_000);
rl.check("k"); // a wrong password
rl.check("k"); // another
rl.check("k"); rl.refund("k"); // an outage, given back
assert.equal(rl.check("k"), true, "third real attempt");
assert.equal(rl.check("k"), false, "and now spent");
});
test("refunding a key that never spent anything is harmless", () => {
const rl = new RateLimiter(1, 60_000);
rl.refund("never-seen");
assert.equal(rl.check("never-seen"), true);
});
test("reset clears the key, refund does not", () => {
const rl = new RateLimiter(2, 60_000);
rl.check("k");
rl.check("k");
rl.refund("k");
assert.equal(rl.check("k"), true);
assert.equal(rl.check("k"), false);
rl.reset("k");
assert.equal(rl.check("k"), true, "reset is the successful-sign-in case");
});
+22
View File
@@ -23,6 +23,28 @@ export class RateLimiter {
return true;
}
/**
* Give back the attempt `check` just took.
*
* For an outcome that says nothing about whether the credentials were right.
* ihasmail runs in its own container, usually on its own host, so an upstream
* that never answered is an ordinary Tuesday rather than an attack -- and the
* limiter exists to slow down password guessing, which a server that refused
* the connection has not told us anything about. Without this, retrying
* through a thirty-second outage spends the window and locks somebody out
* until well after the cause has gone (#239).
*
* Refunds one attempt rather than clearing the key, so a run of real failures
* with an outage in the middle still adds up.
*/
refund(key: string): void {
const arr = this.hits.get(key);
if (!arr?.length) return;
arr.pop();
if (arr.length) this.hits.set(key, arr);
else this.hits.delete(key);
}
reset(key: string): void {
this.hits.delete(key);
}
+82
View File
@@ -0,0 +1,82 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
/**
* The shipped example policy, checked against the rules the server enforces.
*
* An example that has drifted out of step with the parser is worse than no
* example: somebody copies it, the server refuses to start, and the first
* experience of the feature is a crash loop. This does not import the config
* module -- reading it has side effects and wants a whole environment -- so the
* rules it checks are restated here, and both are short enough that saying them
* twice is cheaper than the machinery to say them once.
*/
const EXAMPLE = fileURLToPath(new URL("../../settings-policy.example.json", import.meta.url));
test("the example policy is valid JSON", () => {
assert.doesNotThrow(() => JSON.parse(readFileSync(EXAMPLE, "utf8")));
});
test("the example policy has the three sections, in the shapes the server reads", () => {
const p = JSON.parse(readFileSync(EXAMPLE, "utf8")) as Record<string, unknown>;
for (const section of ["defaults", "enforced"]) {
const v = p[section];
assert.ok(v && typeof v === "object" && !Array.isArray(v), `${section} must be an object`);
}
assert.ok(Array.isArray(p.changes), "changes must be a list");
});
test("every change in the example has a unique version and settings", () => {
const p = JSON.parse(readFileSync(EXAMPLE, "utf8")) as { changes: Array<{ version?: unknown; settings?: unknown }> };
const seen = new Set<string>();
for (const [i, c] of p.changes.entries()) {
assert.equal(typeof c.version, "string", `changes[${i}] needs a string version`);
assert.ok((c.version as string).trim(), `changes[${i}] needs a non-empty version`);
assert.ok(!seen.has(c.version as string), `changes[${i}] repeats version ${String(c.version)}`);
seen.add(c.version as string);
assert.ok(c.settings && typeof c.settings === "object" && !Array.isArray(c.settings), `changes[${i}] needs a settings object`);
}
});
test("the example's commentary cannot be mistaken for a section", () => {
/*
* JSON has no comments, so the example explains itself in `_`-prefixed keys.
* The server reads three names and ignores everything else, which is what
* makes that safe -- but only for as long as no comment key collides with a
* real one.
*/
const p = JSON.parse(readFileSync(EXAMPLE, "utf8")) as Record<string, unknown>;
const real = new Set(["defaults", "enforced", "changes"]);
for (const key of Object.keys(p)) {
assert.ok(real.has(key) || key.startsWith("_"), `unexpected top-level key ${key}`);
}
});
/**
* The shipped server-mapping example, checked the same way and for the same
* reason: an example that no longer loads is worse than no example, because
* the first experience of the feature is a server that refuses to start.
*/
const SERVERS = fileURLToPath(new URL("../../stalwart-servers.example.json", import.meta.url));
test("the example server mapping is valid JSON", () => {
assert.doesNotThrow(() => JSON.parse(readFileSync(SERVERS, "utf8")));
});
test("every entry in the example mapping is a domain and an http(s) URL", () => {
const m = JSON.parse(readFileSync(SERVERS, "utf8")) as Record<string, unknown>;
const seen = new Set<string>();
for (const [key, value] of Object.entries(m)) {
if (key.startsWith("_")) continue;
const domain = key.trim().toLowerCase().replace(/\.$/, "");
assert.ok(domain, "a domain key is empty");
assert.ok(!seen.has(domain), `${domain} appears twice once normalised`);
seen.add(domain);
assert.equal(typeof value, "string", `${domain} is not a string`);
const url = new URL(value as string);
assert.ok(url.protocol === "http:" || url.protocol === "https:", `${domain} must be http or https`);
}
assert.ok(seen.size > 0, "the example should show at least one mapping");
});
+35 -2
View File
@@ -3,6 +3,7 @@ import { stat, readFile } from "node:fs/promises";
import { extname, join, normalize, resolve, sep } from "node:path";
import { Readable } from "node:stream";
import type { Context, Handler } from "hono";
import { stripBasePath } from "../../scripts/basePath.mjs";
const MIME: Record<string, string> = {
".html": "text/html; charset=utf-8",
@@ -47,9 +48,30 @@ export const APP_CSP = [
"manifest-src 'self'",
].join("; ");
export function staticHandler(root: string): Handler {
export function staticHandler(root: string, basePath = ""): Handler {
const absRoot = resolve(root);
let indexCache: { body: string; mtime: number } | null = null;
let mismatchWarned = false;
/**
* A build that does not know the prefix loads nothing under it, and says so
* with a blank page and a 404 in a console nobody has open. The shell is
* already being read here, so checking what it asks for costs one substring
* search per rebuild and turns a mystery into a line in the log.
*
* A warning rather than a refusal: this reads a built artefact to guess at a
* misconfiguration, and a wrong guess that stops the server from starting is
* worse than the problem it is describing.
*/
function warnOnBaseMismatch(body: string) {
if (mismatchWarned || !basePath) return;
if (body.includes(`src="${basePath}/assets/`)) return;
mismatchWarned = true;
console.warn(
`[ihasmail] BASE_PATH is ${basePath}, but the web build in ${absRoot} references its assets elsewhere. ` +
`The prefix is baked in at build time: rebuild with BASE_PATH=${basePath} set, or the app will not load.`,
);
}
async function serveIndex(c: Context) {
try {
@@ -57,7 +79,9 @@ export function staticHandler(root: string): Handler {
const st = await stat(p);
if (!indexCache || indexCache.mtime !== st.mtimeMs) {
indexCache = { body: await readFile(p, "utf8"), mtime: st.mtimeMs };
mismatchWarned = false;
}
warnOnBaseMismatch(indexCache.body);
c.header("Content-Type", "text/html; charset=utf-8");
c.header("Cache-Control", "no-cache");
c.header("Content-Security-Policy", APP_CSP);
@@ -70,7 +94,16 @@ export function staticHandler(root: string): Handler {
return async (c) => {
if (c.req.method !== "GET" && c.req.method !== "HEAD") return c.text("Method Not Allowed", 405);
const urlPath = decodeURIComponent(new URL(c.req.url).pathname);
/*
* Everything below works in paths relative to the mount, so the prefix
* comes off once, here. Anything outside it is a 404 and not the app
* shell: under `/mail` this process shares a hostname with whatever else
* the proxy serves, and answering `/` or `/other-app/thing` with our
* index would shadow a neighbour rather than let it 404 honestly.
*/
const fullPath = decodeURIComponent(new URL(c.req.url).pathname);
const urlPath = stripBasePath(basePath, fullPath);
if (urlPath === null) return c.text("Not Found", 404);
if (urlPath === "/" || urlPath === "/index.html") return serveIndex(c);
const rel = normalize(urlPath).replace(/^(\.\.[/\\])+/, "");
const filePath = join(absRoot, rel);
+42 -12
View File
@@ -10,6 +10,15 @@ export interface UpstreamSession {
uploadUrl: string;
eventSourceUrl: string;
state: string;
/**
* Which Stalwart this document came from.
*
* Recorded rather than looked up again, because the relative URLs inside it
* -- apiUrl, uploadUrl and the rest -- only mean anything against the server
* that issued them. Anything holding a session already knows where to send
* the next request. Not part of the JMAP session resource; ours.
*/
baseUrl: string;
}
export class UpstreamError extends Error {
@@ -24,16 +33,37 @@ export class UpstreamError extends Error {
const sessionCache = new Map<string, { session: UpstreamSession; fetchedAt: number }>();
const SESSION_CACHE_MS = 5 * 60_000;
export function wellKnownUrl(): string {
return `${config.stalwartUrl}/.well-known/jmap`;
/**
* The Stalwart a username belongs to.
*
* `STALWART_URL` is the default and is always the answer for a domain nobody
* mapped -- and for a bare username, which Stalwart accepts and which has no
* domain to map (#238).
*
* A *mapped* domain never falls back. If its server is unreachable that
* sign-in fails, because falling back would authenticate somebody against a
* server their domain was deliberately routed away from -- and if the same
* account name exists there, they would land in another tenant's mailbox. The
* fallback is a decision about unmapped domains, taken before any network
* call, not a recovery path.
*/
export function upstreamFor(username: string): string {
const at = username.lastIndexOf("@");
if (at < 0) return config.stalwartUrl;
const domain = username.slice(at + 1).trim().toLowerCase().replace(/\.$/, "");
return config.stalwartServers[domain] ?? config.stalwartUrl;
}
export function wellKnownUrl(base: string = config.stalwartUrl): string {
return `${base}/.well-known/jmap`;
}
/**
* Fetch the JMAP session resource from Stalwart using the given Authorization
* header. Throws UpstreamError(401) on bad credentials.
*/
export async function fetchUpstreamSession(authorization: string): Promise<UpstreamSession> {
const res = await fetch(wellKnownUrl(), {
export async function fetchUpstreamSession(authorization: string, base: string = config.stalwartUrl): Promise<UpstreamSession> {
const res = await fetch(wellKnownUrl(base), {
headers: { authorization, accept: "application/json" },
redirect: "follow",
signal: AbortSignal.timeout(config.upstreamTimeout),
@@ -46,13 +76,13 @@ export async function fetchUpstreamSession(authorization: string): Promise<Upstr
}
const session = (await res.json()) as UpstreamSession;
if (!session.apiUrl) throw new UpstreamError("Upstream returned an invalid JMAP session", 502);
return session;
return { ...session, baseUrl: base };
}
export async function getUpstreamSession(sessionId: string, authorization: string, force = false) {
export async function getUpstreamSession(sessionId: string, authorization: string, base: string = config.stalwartUrl, force = false) {
const cached = sessionCache.get(sessionId);
if (!force && cached && Date.now() - cached.fetchedAt < SESSION_CACHE_MS) return cached.session;
const session = await fetchUpstreamSession(authorization);
const session = await fetchUpstreamSession(authorization, base);
sessionCache.set(sessionId, { session, fetchedAt: Date.now() });
return session;
}
@@ -210,9 +240,9 @@ function localeOf(call: [string, Record<string, unknown>, string] | undefined):
* Which edition the server is running. Stalwart deliberately does not publish
* its version number to clients, but 0.16 does report its edition here.
*/
async function fetchEdition(authorization: string): Promise<string | null> {
async function fetchEdition(authorization: string, base: string): Promise<string | null> {
try {
const res = await fetch(`${config.stalwartUrl}/api/account`, {
const res = await fetch(`${base}/api/account`, {
headers: { authorization, accept: "application/json" },
signal: AbortSignal.timeout(config.upstreamTimeout),
});
@@ -230,7 +260,7 @@ export async function getAccountInfo(sessionId: string, authorization: string, s
let info = EMPTY_INFO;
try {
info = await fetchAccountInfo(authorization, session);
info = { ...info, edition: await fetchEdition(authorization) };
info = { ...info, edition: await fetchEdition(authorization, session.baseUrl) };
} catch {
/* all of this is a nicety - never fail the session over it */
}
@@ -258,9 +288,9 @@ export function localizeSession(s: UpstreamSession, extras: Record<string, unkno
}
/** Resolve a possibly-relative upstream URL template against STALWART_URL. */
export function absoluteUpstream(url: string): string {
export function absoluteUpstream(url: string, base: string = config.stalwartUrl): string {
try {
return new URL(url, config.stalwartUrl).toString();
return new URL(url, base).toString();
} catch {
return url;
}
+63
View File
@@ -0,0 +1,63 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { formatVersion, resolveVersion, UNVERSIONED, versionFromGit } from "../../scripts/version.mjs";
/**
* The version is this build's public identity: it names the image, and it is
* what About and /api/health report. It had no tests while it was
* `2.16.<pr>`; it has them now that the rules moved.
*/
test("a pull request merge is named by its number", () => {
assert.equal(
formatVersion({ date: "2026-08-30", subject: "Merge pull request #129 from Coffey-Labs/link-project-site-v2", sha: "1fa6578" }),
"2026.8.30+pr129",
);
});
test("a commit that did not come through a pull request carries its SHA", () => {
// Claiming the last PR would say it *is* that PR rather than something after it.
assert.equal(formatVersion({ date: "2026-08-30", subject: "Fix a thing directly on main", sha: "1fa6578" }), "2026.8.30+g1fa6578");
});
test("leading zeros are stripped, since a version field may not carry them", () => {
assert.equal(formatVersion({ date: "2026-09-05", subject: "Merge pull request #7 from x/y", sha: "abc1234" }), "2026.9.5+pr7");
assert.equal(formatVersion({ date: "2027-01-01", subject: "", sha: "abc1234" }), "2027.1.1+gabc1234");
});
test("it sorts forward from the versions it replaces", () => {
// 2.16.129 was deployed. 2.1.x would have read as a downgrade, which is the
// whole reason the Stalwart generation left the version.
const [older, newer] = ["2.16.129", "2026.8.30"].map((v) => v.split(".").map(Number));
assert.ok(newer![0]! > older![0]!, "the leading field has to increase");
});
test("two builds from the same day differ, even though they rank the same", () => {
const a = formatVersion({ date: "2026-08-30", subject: "Merge pull request #128 from x/y", sha: "aaaaaaa" });
const b = formatVersion({ date: "2026-08-30", subject: "Merge pull request #129 from x/y", sha: "bbbbbbb" });
assert.notEqual(a, b);
assert.equal(a.split("+")[0], b.split("+")[0]);
});
test("the same commit always resolves to the same version", () => {
// Built from the commit's own date, not today's, so an old commit rebuilt
// now reports what it reported then.
const commit = { date: "2026-08-30", subject: "Merge pull request #129 from x/y", sha: "1fa6578" };
assert.equal(formatVersion(commit), formatVersion(commit));
});
test("an explicit IHASMAIL_VERSION wins, because the Docker build has no git", () => {
const before = process.env.IHASMAIL_VERSION;
process.env.IHASMAIL_VERSION = "2026.8.30+pr129";
try {
assert.equal(resolveVersion(), "2026.8.30+pr129");
} finally {
if (before === undefined) delete process.env.IHASMAIL_VERSION;
else process.env.IHASMAIL_VERSION = before;
}
});
test("a checkout with git resolves to a real version, and an unversioned build looks wrong", () => {
assert.match(versionFromGit() ?? "", /^\d{4}\.\d{1,2}\.\d{1,2}\+(pr\d+|g[0-9a-f]+)$/);
assert.equal(UNVERSIONED, "0.0.0");
});
+56
View File
@@ -0,0 +1,56 @@
{
"_comment": [
"A settings policy: what this installation decides, rather than each reader.",
"Point at it with SETTINGS_POLICY_FILE=/etc/ihasmail/policy.json and mount it",
"read-only. Read once at startup, so editing it means restarting.",
"Delete the sections you do not want -- all three are optional, and an",
"installation that sets none of them behaves exactly as ihasmail always has.",
"Keys and values are the ones a settings export uses: configure one account",
"by hand, Settings > General > Export, and copy out what you care about.",
"Docs: https://docs.ihasmail.org/configure/#settings-your-installation-decides"
],
"_defaults_comment": [
"A starting point for accounts that have never had settings of their own.",
"The reader can change any of these afterwards. An account that already",
"exists never sees them -- use `changes` below to reach those."
],
"defaults": {
"externalSenderBanner": true,
"conversationMode": true
},
"_enforced_comment": [
"Reapplied on every load, and the reader cannot change them at all. Their",
"controls stay visible in Settings and go dead with a line saying why.",
"Reset, an imported settings file, and a settings file synced from a device",
"that predates this policy all cannot get around them."
],
"enforced": {
"externalRecipientConfirm": true
},
"_changes_comment": [
"Applied once each, to everybody, including accounts that already exist --",
"and the reader may change them back afterwards, which sticks.",
"",
"Each entry needs a `version` that is unique in this file. It is opaque: a",
"timestamp sorts and never repeats, but any unique string works. Every",
"account remembers the versions it has had, so a change runs exactly once",
"per person -- not once per browser.",
"",
"Note that a change DOES override a decision a reader has already made. That",
"is the point of it: it reaches people who are already here. If you want it",
"to stay on regardless of what they do next, that is `enforced`, not this."
],
"changes": [
{
"version": "20260902084513",
"settings": { "externalSenderBanner": true }
},
{
"version": "20261014091500",
"settings": { "externalLinkWarning": true }
}
]
}
+25
View File
@@ -0,0 +1,25 @@
{
"_comment": [
"Optional: which Stalwart a domain signs in to.",
"",
"STALWART_URL stays required and stays the default. This file only adds",
"domains that go somewhere else -- delete it and nothing changes.",
"",
"Point at it with STALWART_SERVERS_FILE=/etc/ihasmail/servers.json and mount",
"it read-only. Read once at startup, so editing it means restarting.",
"",
"A domain that is not listed here, and a bare username with no domain at",
"all, go to STALWART_URL. A domain that IS listed never falls back: if its",
"server is unreachable that sign-in fails, because falling back would",
"authenticate somebody against a server their domain was routed away from.",
"",
"Keys are lower-cased and stripped of a trailing dot when read. Malformed",
"JSON, a duplicate domain, or a value that is not an http(s) URL stops the",
"server at startup rather than failing quietly at somebody's sign-in.",
"",
"Docs: https://docs.ihasmail.org/configure/#several-stalwart-servers"
],
"example.com": "https://mail.example.com",
"customer-b.test": "https://jmap.customer-b.test"
}
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@ihasmail/web",
"version": "2.16.0",
"version": "0.0.0",
"private": true,
"license": "AGPL-3.0-or-later",
"type": "module",
@@ -15,6 +15,7 @@
"@tanstack/react-virtual": "^3.13.2",
"dompurify": "^3.2.4",
"lucide-react": "^0.477.0",
"marked": "^18.0.11",
"qrcode-generator": "^2.0.4",
"react": "^19.0.0",
"react-dom": "^19.0.0",
+10 -9
View File
@@ -2,12 +2,13 @@
"name": "ihasmail",
"short_name": "ihasmail",
"description": "Fast, friendly JMAP webmail for Stalwart",
"start_url": "/mail",
"scope": "/",
"_comment": "JSON has no comments, so: every URL below is relative on purpose. Manifest members resolve against the manifest's own address, so these follow BASE_PATH with nothing substituted into them at build time. Root-absolute values pinned the installed app, its scope and its shortcuts to the domain root whatever the mount was.",
"start_url": "mail",
"scope": "./",
"protocol_handlers": [
{
"protocol": "mailto",
"url": "/mail?mailto=%s"
"url": "mail?mailto=%s"
}
],
"display": "standalone",
@@ -16,17 +17,17 @@
"theme_color": "#0f766e",
"icons": [
{
"src": "/img/icon-192.png",
"src": "img/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/img/icon-512.png",
"src": "img/icon-512.png",
"sizes": "512x512",
"type": "image/png"
},
{
"src": "/img/icon-maskable.png",
"src": "img/icon-maskable.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
@@ -35,16 +36,16 @@
"shortcuts": [
{
"name": "Compose",
"url": "/mail?compose=new",
"url": "mail?compose=new",
"description": "Write a new message"
},
{
"name": "Calendar",
"url": "/calendar"
"url": "calendar"
},
{
"name": "Contacts",
"url": "/contacts"
"url": "contacts"
}
]
}
+42 -12
View File
@@ -3,7 +3,22 @@
are never cached), and Web Push, which is the only part of ihasmail that runs
when no tab is open. */
const VERSION = "ihasmail-v2";
const SHELL = ["/", "/manifest.webmanifest", "/img/logo.png", "/img/icon-192.png", "/favicon.ico"];
/*
* The mount, worked out rather than configured.
*
* This file is copied to the build verbatim -- Vite's `base` never touches
* public/ -- so there is nothing to substitute BASE_PATH into. It does not
* need one: the worker is served from the mount, so its own address says
* where that is. `/mail/sw.js` gives `/mail`, `/sw.js` gives `""`, which is
* the same canonical form the rest of the app uses.
*
* Deriving it here also means the worker cannot disagree with the page that
* registered it, which a second copy of the value in a build-time constant
* eventually would.
*/
const BASE = new URL("./", self.location).pathname.replace(/\/$/, "");
const SHELL = [`${BASE}/`, `${BASE}/manifest.webmanifest`, `${BASE}/img/logo.png`, `${BASE}/img/icon-192.png`, `${BASE}/favicon.ico`];
self.addEventListener("install", (event) => {
event.waitUntil(caches.open(VERSION).then((c) => c.addAll(SHELL)).then(() => self.skipWaiting()));
@@ -20,10 +35,10 @@ self.addEventListener("fetch", (event) => {
if (req.method !== "GET") return;
const url = new URL(req.url);
if (url.origin !== self.location.origin) return;
if (url.pathname.startsWith("/api/")) return;
if (url.pathname.startsWith(`${BASE}/api/`)) return;
// Hashed build assets: cache-first.
if (url.pathname.startsWith("/assets/")) {
if (url.pathname.startsWith(`${BASE}/assets/`)) {
event.respondWith(
caches.match(req).then((hit) => hit || fetch(req).then((res) => {
const copy = res.clone();
@@ -36,7 +51,7 @@ self.addEventListener("fetch", (event) => {
// Navigations & everything else: network-first, fall back to cached shell.
if (req.mode === "navigate") {
event.respondWith(fetch(req).catch(() => caches.match("/")));
event.respondWith(fetch(req).catch(() => caches.match(`${BASE}/`)));
return;
}
event.respondWith(fetch(req).catch(() => caches.match(req)));
@@ -59,7 +74,18 @@ self.addEventListener("fetch", (event) => {
* credentials), so it is stashed for a tab to collect and confirm.
*/
const VERIFY_KEY = "ihasmail-push-verification";
/*
* Absolute, and anchored to the mount rather than to whatever page happens to
* be open.
*
* A relative key is resolved against the URL of whoever is asking: the worker
* lives at `<base>/sw.js`, so it stored this under `<base>/…`, while a tab at
* `/mail/inbox/abc` looked for it under `/mail/inbox/…`. The two only ever
* agreed when the open page was the root, so a verification code that arrived
* with no tab open was written where the next tab would not look -- and the
* subscription stayed silent, which is the same thing push failing looks like.
*/
const VERIFY_KEY = `${BASE}/ihasmail-push-verification`;
function textOf(email) {
const from = email?.from?.[0];
@@ -98,7 +124,7 @@ self.addEventListener("push", (event) => {
// A StateChange, or a payload too large to carry the message. Say
// something true rather than inventing a sender.
await self.registration.showNotification("New mail", {
icon: "/img/icon-192.png", badge: "/img/favicon-64.png", tag: "ihasmail-mail", data: { url: "/mail" },
icon: `${BASE}/img/icon-192.png`, badge: `${BASE}/img/favicon-64.png`, tag: "ihasmail-mail", data: { url: `${BASE}/mail` },
});
return;
}
@@ -108,10 +134,10 @@ self.addEventListener("push", (event) => {
const { title, body, preview } = textOf(email);
await self.registration.showNotification(title, {
body: preview ? `${body}\n${preview}` : body,
icon: "/img/icon-192.png",
badge: "/img/favicon-64.png",
icon: `${BASE}/img/icon-192.png`,
badge: `${BASE}/img/favicon-64.png`,
tag: `ihasmail-${email.id || body}`,
data: { url: email.id ? `/mail/inbox/${email.id}` : "/mail" },
data: { url: email.id ? `${BASE}/mail/inbox/${email.id}` : `${BASE}/mail` },
});
}
})());
@@ -119,12 +145,16 @@ self.addEventListener("push", (event) => {
self.addEventListener("notificationclick", (event) => {
event.notification.close();
const url = event.notification.data?.url || "/mail";
const url = event.notification.data?.url || `${BASE}/mail`;
event.waitUntil((async () => {
const clients = await self.clients.matchAll({ includeUncontrolled: true, type: "window" });
// Reuse a tab if one is open rather than piling up windows.
// Reuse a tab if one is open rather than piling up windows. Same origin is
// not enough under a prefix: `includeUncontrolled` widens the match to the
// whole origin, so on a host that also serves something else this would
// navigate a stranger's tab to our inbox.
for (const c of clients) {
if (new URL(c.url).origin === self.location.origin) {
const at = new URL(c.url);
if (at.origin === self.location.origin && (at.pathname === BASE || at.pathname.startsWith(`${BASE}/`))) {
await c.focus();
if ("navigate" in c) await c.navigate(url).catch(() => {});
return;
+150 -15
View File
@@ -1,5 +1,5 @@
import { lazy, Suspense, useEffect } from "react";
import { Route, Switch, Redirect, useLocation } from "wouter";
import { Fragment, lazy, Suspense, useEffect, useState } from "react";
import { Route, Switch, Redirect, useLocation, Router } from "wouter";
import { useSession } from "@/store/session";
import { useMail } from "@/store/mail";
import { scheduleSupported, useScheduled } from "@/store/scheduled";
@@ -9,7 +9,7 @@ import { useFiles } from "@/store/files";
import { useSieve } from "@/store/sieve";
import { push } from "@/jmap/push";
import { client } from "@/jmap/client";
import { ToastHost } from "@/ui/toast";
import { ToastHost, toast } from "@/ui/toast";
import { ConfirmHost } from "@/ui/dialog";
import { Spinner } from "@/ui/misc";
import { LoginPage } from "@/views/Login";
@@ -17,9 +17,14 @@ import { AppShell } from "@/views/AppShell";
import { MailView } from "@/views/mail/MailView";
import { ComposerDock } from "@/views/compose/ComposerDock";
import { setUnreadBadge } from "@/lib/notify";
import { useSettings, syncedPart } from "@/store/settings";
import { armSettingsSync, loadRemoteSettings, queueSettingsPush, settingsSyncAvailable } from "@/lib/settingsSync";
import { listenForVerification } from "@/lib/webpushEnable";
import { PAINTED_FROM_CACHE, useSettings, syncedPart } from "@/store/settings";
import { armSettingsSync, loadRemoteSettings, queueSettingsPush, settingsAlreadyLoadedFor, settingsSyncAvailable } from "@/lib/settingsSync";
import { loadSettingsPolicy } from "@/lib/settingsPolicy";
import { listenForVerification, renewWebPush } from "@/lib/webpushEnable";
import { plural, t, useLanguageVersion, whenLanguageReady } from "@/lib/i18n";
import { confirmLeaveUnsaved, hasUnsavedChanges } from "@/lib/unsavedChanges";
import { BASE_PATH, withBase } from "@/lib/basePath";
import { DEFAULT_APP_NAME } from "@/lib/brand";
const ContactsView = lazy(() => import("@/views/contacts/ContactsView").then((m) => ({ default: m.ContactsView })));
const CalendarView = lazy(() => import("@/views/calendar/CalendarView").then((m) => ({ default: m.CalendarView })));
@@ -29,11 +34,39 @@ const SettingsView = lazy(() => import("@/views/settings/SettingsView").then((m)
export function App() {
const status = useSession((s) => s.status);
const bootstrap = useSession((s) => s.bootstrap);
/*
* Subscribed once, here, and used as a key below.
*
* `t()` is a plain function rather than a hook, so a component has no way of
* knowing its strings just changed. Rather than make every one of the
* thousand call sites a subscriber -- which would turn extracting a string
* from "wrap it" into "wrap it and add a hook" -- the whole tree is thrown
* away and rebuilt when the catalogue changes. Picking a language is a
* once-in-an-account event; paying for it there is far cheaper than paying
* for it on every render everywhere.
*/
const languageVersion = useLanguageVersion();
useEffect(() => {
void bootstrap();
}, [bootstrap]);
if (status === "loading") {
/*
* Wait for the catalogue 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 the gap is emitted in English and stays English, in an interface that
* is otherwise not. The wait costs nothing visible: the session bootstrap
* is already showing a spinner, and English resolves immediately.
*/
const [languageReady, setLanguageReady] = useState(false);
useEffect(() => {
let live = true;
void whenLanguageReady().finally(() => live && setLanguageReady(true));
return () => { live = false; };
}, []);
if (status === "loading" || !languageReady) {
return (
<div className="center" style={{ height: "100%" }}>
<Spinner size="lg" />
@@ -41,11 +74,43 @@ export function App() {
);
}
return (
<>
{status === "anonymous" ? <LoginPage /> : <AuthedApp />}
/*
* Every in-app navigation runs through `aroundNav` -- links, redirects and
* `navigate()` alike, since wouter routes them all through the same place.
* That is what makes the guard hold for the app rail and the settings nav
* without either of them knowing an editor exists.
*
* The back button is the gap: by the time `popstate` arrives the history
* has already moved, and the only way to hold the page would be to push an
* entry back, which breaks the button for everyone who has nothing pending.
* Reload and tab close are covered by `beforeunload` instead.
*/
<Router
/*
* The one place the mount prefix enters the router. Every `<Route path>`,
* `<Link href>` and `navigate()` in the app stays written root-absolute
* -- `/mail/:mailboxId?` -- and wouter strips the base off the address
* before matching and puts it back on when it navigates. So a deep link
* to `/mail/inbox/abc` under a `/mail` mount is `/mail/mail/inbox/abc`
* and nothing in the views has to know it.
*
* Empty is wouter's own default, so the root case is untouched.
*/
base={BASE_PATH}
aroundNav={(navigate, to, options) => {
if (!hasUnsavedChanges()) {
navigate(to, options);
return;
}
void confirmLeaveUnsaved().then((ok) => {
if (ok) navigate(to, options);
});
}}
>
<Fragment key={languageVersion}>{status === "anonymous" ? <LoginPage /> : <AuthedApp />}</Fragment>
<ToastHost />
<ConfirmHost />
</>
</Router>
);
}
@@ -53,16 +118,66 @@ function AuthedApp() {
const accountId = useSession((s) => s.accountId);
const [location] = useLocation();
// Settings that live with the account rather than the browser. The cached
// ones have already painted, so this only has to correct them (issue #54).
/*
* Settings that live with the account rather than the browser.
*
* When this browser has them cached they have already painted, and this only
* has to correct them (issue #54). When it does not -- an untrusted device,
* or the sign-out that every deploy causes -- the first frame is the
* defaults, and the defaults are English. Rendering then means anything
* computed before the settings land is computed in the wrong language: not
* the interface, which is rebuilt when the catalogue arrives, but a string
* emitted once, like a toast. That is why the stale-folder toast came out
* in English on an otherwise German screen.
*
* So without a cache the tree waits, which costs nothing: there was nothing
* worth painting yet. With one it does not wait, and the screen is as quick
* as it was.
*
* Once per account, not once per mount: this subtree is keyed on the
* language version, so picking a language throws it away and builds it
* again. Re-reading the settings file there would apply a copy written
* before the change and undo it.
*/
const [ready, setReady] = useState(PAINTED_FROM_CACHE);
useEffect(() => {
if (!accountId) return;
if (settingsAlreadyLoadedFor(accountId)) {
setReady(true);
return;
}
let cancelled = false;
void (async () => {
/* Before the account's own settings, so both the seeding below and the
enforcement inside `hydrate` have something to apply. */
await loadSettingsPolicy();
if (cancelled) return;
const remote = await loadRemoteSettings();
if (cancelled) return;
if (remote) useSettings.getState().hydrate(remote);
// Pushes were held back until now so they could not race the load.
// No settings file: this account has never had settings of its own, so
// the installation's defaults are what it starts on rather than
// ihasmail's. Issue #207.
else useSettings.getState().seedFromPolicy();
/*
* After both, and for everybody: a change the installation wants applied
* once has to reach accounts that already exist, which is the whole of
* why it is not just a default. Each is remembered, so a reader who turns
* one back off keeps it off. Issue #207.
*/
const applied = useSettings.getState().applyPolicyChanges();
if (applied.length) {
toast.show(plural(applied.length, {
one: "Your administrator changed {n} setting",
other: "Your administrator changed {n} settings",
}), { action: { label: t("Settings"), onClick: () => { window.location.href = withBase("/settings/general"); } } });
}
// The catalogue for whatever language that turned out to be. Hydrating
// asks for it; this is waiting for the answer.
await whenLanguageReady();
if (cancelled) return;
setReady(true);
// Pushes were held back until now so they could not race the load. A
// change made while it was in flight was kept, and goes out here.
armSettingsSync();
// No file yet — seed one from what this browser has, so the next device
// to sign in starts from these rather than from the defaults.
@@ -91,6 +206,16 @@ function AuthedApp() {
// A push subscription stays silent until its verification code is echoed
// back, and the code may have arrived while no tab was open.
listenForVerification();
/*
* And a subscription expires -- seven days is the ceiling JMAP puts on one,
* and re-registering before that is the client's job. Nothing did it, so
* background notifications lapsed within a week of being switched on and
* only came back if somebody
* happened to toggle the switch. Opening the app is the only moment this
* can be done -- registering is a JMAP call, and the service worker has no
* session to make one with -- so it is done on every start.
*/
void renewWebPush();
const pending = new Map<string, Set<string>>();
let timer: number | null = null;
const unsub = push.subscribe((acct, type) => {
@@ -130,7 +255,7 @@ function AuthedApp() {
const id = s.roleId("inbox");
return id ? (s.mailboxes[id]?.unreadEmails ?? 0) : 0;
});
const appName = useSession((s) => s.session?.ihasmail?.appName ?? "ihasmail");
const appName = useSession((s) => s.session?.ihasmail?.appName) || DEFAULT_APP_NAME;
useEffect(() => {
void import("@/lib/notify").then((m) => {
m.setBaseTitle(appName);
@@ -144,6 +269,16 @@ function AuthedApp() {
if (notif) void import("@/lib/notify").then((m) => m.requestNotificationPermission());
}, [notif]);
// Nothing worth painting until the account's settings are in force; see the
// comment on `ready` above. With a cache this was true from the first frame.
if (!ready) {
return (
<div className="center" style={{ height: "100%" }}>
<Spinner size="lg" />
</div>
);
}
return (
<AppShell>
<Suspense fallback={<Spinner size="lg" />}>
+12 -4
View File
@@ -1,4 +1,5 @@
import type { Id, Invocation, JmapResponse, JmapSession, MethodError, UploadResponse } from "./types";
import { withBase } from "@/lib/basePath";
export const CAP = {
core: "urn:ietf:params:jmap:core",
@@ -62,9 +63,16 @@ export type ResultRef = { resultOf: string; name: string; path: string };
const HEADERS = { "content-type": "application/json", accept: "application/json", "x-requested-with": "ihasmail" };
/** Generic fetch against our same-origin API with CSRF header + auth handling. */
/**
* Generic fetch against our same-origin API with CSRF header + auth handling.
*
* `path` is written root-absolute at every call site -- `/api/jmap` -- and the
* mount prefix is added here rather than there. One place to get it right, and
* the `startsWith` below keeps working on the path as written rather than on
* whatever the deployment happens to be called.
*/
export async function apiFetch<T = unknown>(path: string, init: RequestInit = {}): Promise<T> {
const res = await fetch(path, {
const res = await fetch(withBase(path), {
...init,
headers: { ...HEADERS, ...(init.headers as Record<string, string> | undefined) },
credentials: "same-origin",
@@ -276,12 +284,12 @@ export class JmapClient {
}
uploadUrl(accountId: Id): string {
return `/api/upload/${encodeURIComponent(accountId)}`;
return withBase(`/api/upload/${encodeURIComponent(accountId)}`);
}
downloadUrl(accountId: Id, blobId: Id, name: string, type: string, inline = false): string {
const safeName = (name || "attachment").replace(/[/\\?#%]/g, "_");
const u = `/api/blob/${encodeURIComponent(accountId)}/${encodeURIComponent(blobId)}/${encodeURIComponent(safeName)}?accept=${encodeURIComponent(type || "application/octet-stream")}`;
const u = withBase(`/api/blob/${encodeURIComponent(accountId)}/${encodeURIComponent(blobId)}/${encodeURIComponent(safeName)}?accept=${encodeURIComponent(type || "application/octet-stream")}`);
return inline ? `${u}&inline=1` : u;
}
+2 -1
View File
@@ -1,4 +1,5 @@
import type { Id, StateChange } from "./types";
import { withBase } from "@/lib/basePath";
export type PushListener = (accountId: Id, type: string, newState: string) => void;
@@ -70,7 +71,7 @@ class PushManager {
private connect(): void {
if (this.stopped || this.es) return;
if (this.state !== "connected") this.setState("connecting");
const url = `/api/events?types=*&closeafter=no&ping=30`;
const url = withBase(`/api/events?types=*&closeafter=no&ping=30`);
const es = new EventSource(url, { withCredentials: true });
this.es = es;
es.onopen = () => {
+109
View File
@@ -0,0 +1,109 @@
import { describe, expect, it } from "vitest";
import { appointmentDraft, nextHalfHour } from "@/lib/appointment";
import type { Email, EmailBodyPart } from "@/jmap/types";
/**
* A reminder made out of a mail: the subject becomes the title and the body
* becomes the description, and the reader supplies the one thing the message
* cannot — when it happens. What these pin is that the copy is faithful and
* bounded, because everything else about the event is the editor's job.
*/
function part(partId: string, type: string): EmailBodyPart {
return { partId, type } as EmailBodyPart;
}
function email(parts: Partial<Email>): Email {
return { id: "m1", subject: null, ...parts } as Email;
}
function body(subject: string, type: "text/plain" | "text/html", value: string): Email {
const key = type === "text/plain" ? "textBody" : "htmlBody";
return email({ subject, [key]: [part("1", type)], bodyValues: { 1: { value, isEncodingProblem: false, isTruncated: false } } });
}
const text = (value: string) => body("Water bill", "text/plain", value);
describe("the time an appointment starts", () => {
it("rounds up to the next half hour", () => {
expect(nextHalfHour(new Date("2026-08-31T09:12:40")).toTimeString().slice(0, 5)).toBe("09:30");
expect(nextHalfHour(new Date("2026-08-31T09:41:00")).toTimeString().slice(0, 5)).toBe("10:00");
});
it("moves on from a time already on the boundary, rather than starting now", () => {
expect(nextHalfHour(new Date("2026-08-31T09:30:00")).toTimeString().slice(0, 5)).toBe("10:00");
});
it("runs for an hour", () => {
const d = appointmentDraft(text("anything"), new Date("2026-08-31T09:12:00"));
expect(d.end.getTime() - d.start.getTime()).toBe(3600_000);
expect(d.allDay).toBe(false);
});
});
describe("what is copied from the message", () => {
it("takes the subject as the title and the body as the description", () => {
const d = appointmentDraft(text("Due on the 14th.\nAccount 4471.\n"));
expect(d.title).toBe("Water bill");
expect(d.description).toBe("Due on the 14th.\nAccount 4471.");
});
it("reads an HTML-only message as text, so the description is not markup", () => {
const d = appointmentDraft(body("Renewal", "text/html", "<p>Renews <b>Friday</b></p>"));
expect(d.description).toBe("Renews Friday");
});
it("leaves the title empty when there is no subject, for the editor to prompt for", () => {
expect(appointmentDraft(email({ subject: null })).title).toBe("");
});
/*
* A newsletter is a message too. The whole body would be stored on the
* event, synced everywhere, and shown in a three-row box, so the tail is
* dropped — visibly, so a truncated bill is not read as the whole of it.
*/
it("truncates a body too long to be a description", () => {
const d = appointmentDraft(text("x".repeat(9000)));
expect(d.description).toHaveLength(5001);
expect(d.description.endsWith("…")).toBe(true);
});
});
const between = (parts: Partial<Email>) => email({ subject: "Kickoff", ...parts });
const addr = (email: string, name: string | null = null) => ({ name, email });
describe("who is invited", () => {
it("carries the sender and everyone it was addressed to", () => {
const d = appointmentDraft(
between({ from: [addr("[email protected]", "Grace")], to: [addr("[email protected]"), addr("[email protected]")], cc: [addr("[email protected]")] }),
new Date(),
["[email protected]"],
);
expect(d.attendees.map((a) => a.email)).toEqual(["[email protected]", "[email protected]", "[email protected]"]);
expect(d.attendees[0]?.name).toBe("Grace");
});
it("leaves the reader out, whatever case their address was written in", () => {
const d = appointmentDraft(between({ from: [addr("[email protected]")], to: [addr("[email protected]")] }), new Date(), ["[email protected]"]);
expect(d.attendees.map((a) => a.email)).toEqual(["[email protected]"]);
});
it("counts someone once, however many headers they appear in", () => {
const d = appointmentDraft(between({ from: [addr("[email protected]")], to: [addr("[email protected]")], cc: [addr("[email protected]")] }));
expect(d.attendees).toHaveLength(1);
});
/*
* On a message the reader sent, a blind copy is still a recipient — and
* putting one on a guest list shows them to every other guest. Turning a
* hidden copy into a visible one is not something a menu item may do.
*/
it("never turns a blind copy into a guest", () => {
const d = appointmentDraft(between({ from: [addr("[email protected]")], to: [addr("[email protected]")], bcc: [addr("[email protected]")] }), new Date(), ["[email protected]"]);
expect(d.attendees.map((a) => a.email)).toEqual(["[email protected]"]);
});
it("invites nobody when the message has no addresses at all", () => {
expect(appointmentDraft(between({})).attendees).toEqual([]);
});
});
+103
View File
@@ -0,0 +1,103 @@
import { describe, expect, it } from "vitest";
import { archiveSegments, archivePath, groupByArchivePath } from "@/lib/archiveDate";
/**
* The dates below are written as local-time strings on purpose. The segments
* follow the reader's timezone, so a test pinned to UTC instants would pass or
* fail depending on where it ran.
*/
describe("archiveSegments", () => {
it("gives the year, and the zero-padded month", () => {
expect(archiveSegments("2026-09-04T10:00:00", "year")).toEqual(["2026"]);
expect(archiveSegments("2026-09-04T10:00:00", "month")).toEqual(["2026", "09"]);
});
it("zero-pads every month below October, so the folders sort", () => {
expect(archiveSegments("2026-01-15T10:00:00", "month")).toEqual(["2026", "01"]);
expect(archiveSegments("2026-10-15T10:00:00", "month")).toEqual(["2026", "10"]);
expect(archiveSegments("2026-12-15T10:00:00", "month")).toEqual(["2026", "12"]);
});
it("returns nothing to append when the date cannot be read", () => {
// Archive itself, rather than a folder named after a guess.
expect(archiveSegments(null, "month")).toEqual([]);
expect(archiveSegments(undefined, "month")).toEqual([]);
expect(archiveSegments("", "month")).toEqual([]);
expect(archiveSegments("not a date", "month")).toEqual([]);
});
it("joins to a path", () => {
expect(archivePath(["2026", "09"])).toBe("2026/09");
expect(archivePath([])).toBe("");
});
});
describe("groupByArchivePath", () => {
it("keeps one destination for a selection from one month", () => {
const groups = groupByArchivePath(
[
{ id: "a", receivedAt: "2026-09-04T10:00:00" },
{ id: "b", receivedAt: "2026-09-28T10:00:00" },
],
"month",
);
expect(groups).toHaveLength(1);
expect(groups[0]!.segments).toEqual(["2026", "09"]);
expect(groups[0]!.ids).toEqual(["a", "b"]);
});
it("splits a selection that spans months, which is the case that matters", () => {
const groups = groupByArchivePath(
[
{ id: "a", receivedAt: "2026-09-04T10:00:00" },
{ id: "b", receivedAt: "2026-08-30T10:00:00" },
{ id: "c", receivedAt: "2026-09-01T10:00:00" },
],
"month",
);
expect(groups.map((g) => g.segments)).toEqual([
["2026", "09"],
["2026", "08"],
]);
expect(groups[0]!.ids).toEqual(["a", "c"]);
expect(groups[1]!.ids).toEqual(["b"]);
});
it("collapses the same span back to one group at year granularity", () => {
const entries = [
{ id: "a", receivedAt: "2026-09-04T10:00:00" },
{ id: "b", receivedAt: "2026-02-28T10:00:00" },
];
expect(groupByArchivePath(entries, "month")).toHaveLength(2);
expect(groupByArchivePath(entries, "year")).toHaveLength(1);
});
it("orders groups by where their first message appeared", () => {
const groups = groupByArchivePath(
[
{ id: "a", receivedAt: "2024-01-04T10:00:00" },
{ id: "b", receivedAt: "2026-01-04T10:00:00" },
],
"year",
);
expect(groups.map((g) => archivePath(g.segments))).toEqual(["2024", "2026"]);
});
it("gathers the undatable ones into their own group, bound for Archive itself", () => {
const groups = groupByArchivePath(
[
{ id: "a", receivedAt: "2026-09-04T10:00:00" },
{ id: "b", receivedAt: null },
{ id: "c", receivedAt: "bad" },
],
"month",
);
expect(groups).toHaveLength(2);
expect(groups[1]!.segments).toEqual([]);
expect(groups[1]!.ids).toEqual(["b", "c"]);
});
it("has nothing to do with an empty selection", () => {
expect(groupByArchivePath([], "month")).toEqual([]);
});
});
@@ -0,0 +1,113 @@
import { describe, expect, it } from "vitest";
import { availabilityWindow } from "@/lib/availabilityWindow";
const at = (s: string) => new Date(s);
const hours = (w: { ticks: { time: Date }[] }) => w.ticks.map((t) => `${t.time.getDate()}@${t.time.getHours()}`);
describe("the span an availability bar covers", () => {
it("covers the whole day for an event inside one", () => {
const w = availabilityWindow(at("2026-09-02T09:00:00"), at("2026-09-02T10:30:00"));
expect(w.start.getHours()).toBe(0);
expect(w.days).toBe(1);
expect(w.end.getDate()).toBe(3);
expect(w.end.getHours()).toBe(0);
});
it("stretches to cover an event running over several days", () => {
const w = availabilityWindow(at("2026-09-02T09:00:00"), at("2026-09-04T17:00:00"));
expect(w.days).toBe(3);
expect(w.start.getDate()).toBe(2);
expect(w.end.getDate()).toBe(5);
});
it("ends an event on the day it ends on, not the midnight it stops at", () => {
// An all-day event on the 2nd runs to midnight starting the 3rd; it does
// not touch the 3rd and the bar should not show it.
const w = availabilityWindow(at("2026-09-02T00:00:00"), at("2026-09-03T00:00:00"));
expect(w.days).toBe(1);
expect(w.end.getDate()).toBe(3);
});
it("never collapses to nothing, even when start and end are the same moment", () => {
const w = availabilityWindow(at("2026-09-02T09:00:00"), at("2026-09-02T09:00:00"));
expect(w.days).toBe(1);
expect(w.span).toBeGreaterThan(0);
});
it("marks a single day every three hours, labelling every six", () => {
const w = availabilityWindow(at("2026-09-02T09:00:00"), at("2026-09-02T10:00:00"));
expect(w.scale).toBe("hours");
expect(hours(w)).toEqual(["2@0", "2@3", "2@6", "2@9", "2@12", "2@15", "2@18", "2@21"]);
expect(w.ticks.filter((t) => t.major).map((t) => t.time.getHours())).toEqual([0, 6, 12, 18]);
});
it("thins the marks out to every six hours across two days", () => {
const w = availabilityWindow(at("2026-09-02T09:00:00"), at("2026-09-03T10:00:00"));
expect(w.scale).toBe("hours");
expect(hours(w)).toEqual(["2@0", "2@6", "2@12", "2@18", "3@0", "3@6", "3@12", "3@18"]);
});
it("marks day boundaries once there are more than two", () => {
const w = availabilityWindow(at("2026-09-02T09:00:00"), at("2026-09-05T10:00:00"));
expect(w.scale).toBe("days");
expect(hours(w)).toEqual(["2@0", "3@0", "4@0", "5@0"]);
expect(w.ticks.every((t) => t.major)).toBe(true);
});
it("puts every mark at its true fraction of the span", () => {
const w = availabilityWindow(at("2026-09-02T09:00:00"), at("2026-09-02T10:00:00"));
expect(w.ticks[0]!.at).toBe(0);
expect(w.ticks[4]!.at).toBeCloseTo(0.5, 5); // noon
expect(w.ticks.every((t) => t.at >= 0 && t.at < 1)).toBe(true);
});
it("stops at a week and says how much it left out", () => {
const w = availabilityWindow(at("2026-09-01T09:00:00"), at("2026-09-30T17:00:00"));
expect(w.days).toBe(7);
expect(w.daysHidden).toBe(23);
});
it("hides nothing when the event fits", () => {
expect(availabilityWindow(at("2026-09-02T09:00:00"), at("2026-09-04T17:00:00")).daysHidden).toBe(0);
});
it("lands on real midnights, and measures the span between them", () => {
/*
* The span is what every position is a fraction of, so it has to be the
* distance between the two boundaries rather than a count of 24-hour days:
* on the day a clock changes those differ by an hour, which would end the
* bar early and put every block after the change in the wrong place. This
* asserts the relationship; whether the run happens to sit in a zone with
* DST is not something a test should depend on.
*/
for (const day of ["2026-03-29", "2026-10-25", "2026-09-02"]) {
const w = availabilityWindow(at(`${day}T09:00:00`), at(`${day}T10:00:00`));
expect(w.start.getHours(), day).toBe(0);
expect(w.end.getHours(), day).toBe(0);
expect(w.span, day).toBe(w.end.getTime() - w.start.getTime());
}
});
});
describe("looking around the event without changing it", () => {
it("slides the whole window forward, keeping its width", () => {
const here = availabilityWindow(at("2026-09-02T09:00:00"), at("2026-09-04T17:00:00"));
const later = availabilityWindow(at("2026-09-02T09:00:00"), at("2026-09-04T17:00:00"), { offsetDays: 3 });
expect(later.days).toBe(here.days);
expect(later.start.getDate()).toBe(5);
expect(later.end.getDate()).toBe(8);
});
it("slides backwards, across the end of a month", () => {
const w = availabilityWindow(at("2026-09-02T09:00:00"), at("2026-09-02T10:00:00"), { offsetDays: -3 });
expect(w.start.getMonth()).toBe(7); // August
expect(w.start.getDate()).toBe(30);
expect(w.days).toBe(1);
});
it("keeps the marks in step with where the window moved to", () => {
const w = availabilityWindow(at("2026-09-02T09:00:00"), at("2026-09-02T10:00:00"), { offsetDays: 1 });
expect(w.ticks[0]!.time.getDate()).toBe(3);
expect(w.ticks[0]!.at).toBe(0);
});
});
+113
View File
@@ -0,0 +1,113 @@
import { describe, expect, it } from "vitest";
import { baseUrlOf, normalizeBasePath, stripBasePath } from "../../../../scripts/basePath.mjs";
import { BASE_PATH, withBase } from "@/lib/basePath";
/**
* `BASE_PATH` is typed into a compose file or a `docker run` line by hand, and
* the four spellings below are all reasonable things for someone to write.
* The one that has to be exactly right is the empty one: every deployment that
* exists today is at the root, and this feature must be invisible to them.
*
* The canonical form is a leading slash and no trailing one, so that the
* concatenation `${base}/api/health` is correct with no branch. A trailing
* slash would make the empty case produce `//api/health`, which is not a path
* on this host but a protocol-relative URL pointing at a host called `api` --
* which is why the tests below check the joined result and not just the value.
*/
describe("normalizing what the operator wrote", () => {
it("leaves the canonical form alone", () => {
expect(normalizeBasePath("/mail")).toBe("/mail");
});
it("accepts a missing leading slash", () => {
expect(normalizeBasePath("mail")).toBe("/mail");
});
it("accepts a trailing slash", () => {
expect(normalizeBasePath("/mail/")).toBe("/mail");
expect(normalizeBasePath("mail/")).toBe("/mail");
});
it("accepts a nested mount, however it is punctuated", () => {
expect(normalizeBasePath("apps/mail")).toBe("/apps/mail");
expect(normalizeBasePath("/apps/mail/")).toBe("/apps/mail");
});
it("tidies away doubled separators and stray whitespace", () => {
expect(normalizeBasePath("//mail//")).toBe("/mail");
expect(normalizeBasePath(" /mail ")).toBe("/mail");
});
});
describe("the root, which must behave exactly as it did", () => {
it("is the empty string for every way of saying it", () => {
expect(normalizeBasePath("")).toBe("");
expect(normalizeBasePath("/")).toBe("");
expect(normalizeBasePath("///")).toBe("");
expect(normalizeBasePath(undefined)).toBe("");
expect(normalizeBasePath(null)).toBe("");
});
it("joins onto an app path without doubling the slash", () => {
// `//api/health` would be read as a protocol-relative URL and sent to a
// host called `api`. This is the assertion the whole canonical form is for.
expect(`${normalizeBasePath("/")}/api/health`).toBe("/api/health");
expect(`${normalizeBasePath("/mail")}/api/health`).toBe("/mail/api/health");
});
});
describe("the directory form Vite and the PWA scope want", () => {
it("always ends in a slash", () => {
expect(baseUrlOf("")).toBe("/");
expect(baseUrlOf("mail")).toBe("/mail/");
expect(baseUrlOf("/mail/")).toBe("/mail/");
});
});
describe("taking the prefix off an incoming request", () => {
it("passes everything through untouched at the root", () => {
expect(stripBasePath("", "/")).toBe("/");
expect(stripBasePath("", "/assets/index.js")).toBe("/assets/index.js");
expect(stripBasePath("", "/mail/inbox/abc")).toBe("/mail/inbox/abc");
});
it("strips the mount and keeps the rest", () => {
expect(stripBasePath("/mail", "/mail/assets/index.js")).toBe("/assets/index.js");
expect(stripBasePath("/mail", "/mail/api/health")).toBe("/api/health");
});
it("treats the bare mount as the app's index", () => {
// Typing the prefix without the trailing slash is how people reach it.
expect(stripBasePath("/mail", "/mail")).toBe("/");
expect(stripBasePath("/mail", "/mail/")).toBe("/");
});
it("refuses a path that merely starts with the same letters", () => {
// A plain startsWith would hand `/mailbox` the app shell, shadowing
// whatever else the proxy serves on this host.
expect(stripBasePath("/mail", "/mailbox")).toBe(null);
expect(stripBasePath("/mail", "/mailing/list")).toBe(null);
});
it("refuses anything outside the mount", () => {
expect(stripBasePath("/mail", "/")).toBe(null);
expect(stripBasePath("/mail", "/other-app/thing")).toBe(null);
});
});
describe("the browser's view of the mount", () => {
/*
* Vitest builds with Vite's default base, so this is the root deployment --
* which is the case that must not regress, and the reason these assertions
* are worth writing down rather than dismissing as trivial.
*/
it("is empty in a root build", () => {
expect(BASE_PATH).toBe("");
});
it("leaves app paths exactly as written", () => {
expect(withBase("/api/health")).toBe("/api/health");
expect(withBase("/img/logo.png")).toBe("/img/logo.png");
expect(withBase("/sw.js")).toBe("/sw.js");
});
});
+135
View File
@@ -0,0 +1,135 @@
import { describe, expect, it } from "vitest";
import { birthdaysInRange, isBirthdayEvent, BIRTHDAY_ID_PREFIX } from "@/lib/birthdays";
import type { ContactCard } from "@/jmap/types";
const card = (id: string, full: string, date: { year?: number; month?: number; day?: number; utc?: string } | null, kind = "birth"): ContactCard =>
({
id,
uid: id,
addressBookIds: { b1: true },
name: { full },
...(date ? { anniversaries: { a1: { kind, date } } } : {}),
}) as ContactCard;
const range = (from: string, to: string) => [new Date(from), new Date(to)] as const;
const names = (b: ReturnType<typeof birthdaysInRange>) => b.map((x) => `${x.name} ${x.date.toISOString().slice(0, 10)}${x.age === null ? "" : ` (${x.age})`}`);
describe("birthdaysInRange", () => {
it("puts a birthday in the year the range covers, with the age", () => {
const [s, e] = range("2026-01-01", "2027-01-01");
expect(names(birthdaysInRange([card("c1", "Ada Lovelace", { year: 1990, month: 6, day: 15 })], s, e))).toEqual(["Ada Lovelace 2026-06-15 (36)"]);
});
it("gives no age when the card recorded only a day and month", () => {
// Very common, and a real answer rather than a broken one.
const [s, e] = range("2026-01-01", "2027-01-01");
const out = birthdaysInRange([card("c1", "Ada", { month: 6, day: 15 })], s, e);
expect(out[0]!.age).toBeNull();
expect(out[0]!.date.getMonth()).toBe(5);
});
it("emits one occurrence per year across a range that spans years", () => {
const [s, e] = range("2025-06-01", "2027-06-01");
expect(names(birthdaysInRange([card("c1", "Ada", { year: 2000, month: 12, day: 25 })], s, e))).toEqual([
"Ada 2025-12-25 (25)",
"Ada 2026-12-25 (26)",
]);
});
it("leaves out a birthday outside the range", () => {
const [s, e] = range("2026-07-01", "2026-08-01");
expect(birthdaysInRange([card("c1", "Ada", { month: 6, day: 15 })], s, e)).toEqual([]);
});
it("puts 29 February on the 28th in a year that has no 29th", () => {
// The month is the fact; moving it to 1 March is the arithmetic winning.
const [s, e] = range("2026-01-01", "2027-01-01");
const out = birthdaysInRange([card("c1", "Ada", { year: 2000, month: 2, day: 29 })], s, e);
expect(out[0]!.date.getMonth()).toBe(1);
expect(out[0]!.date.getDate()).toBe(28);
});
it("keeps 29 February on the 29th in a leap year", () => {
const [s, e] = range("2028-01-01", "2029-01-01");
const out = birthdaysInRange([card("c1", "Ada", { year: 2000, month: 2, day: 29 })], s, e);
expect(out[0]!.date.getDate()).toBe(29);
});
it("reads a timestamp date as well as a partial one", () => {
const [s, e] = range("2026-01-01", "2027-01-01");
const out = birthdaysInRange([card("c1", "Ada", { utc: "1990-06-15T00:00:00Z" })], s, e);
expect(out[0]!.date.getMonth()).toBe(5);
expect(out[0]!.age).toBe(36);
});
it("ignores anniversaries that are not birthdays", () => {
const [s, e] = range("2026-01-01", "2027-01-01");
expect(birthdaysInRange([card("c1", "Ada", { month: 6, day: 15 }, "wedding")], s, e)).toEqual([]);
});
it("ignores a card with no anniversary and one with no usable name", () => {
const [s, e] = range("2026-01-01", "2027-01-01");
expect(birthdaysInRange([card("c1", "Ada", null)], s, e)).toEqual([]);
expect(birthdaysInRange([card("c2", "", { month: 6, day: 15 })], s, e)).toEqual([]);
});
it("falls back to a name built from components, then to the organisation", () => {
const [s, e] = range("2026-01-01", "2027-01-01");
const parts = {
id: "c1",
uid: "c1",
addressBookIds: {},
name: { components: [{ kind: "given", value: "Grace" }, { kind: "surname", value: "Hopper" }] },
anniversaries: { a1: { kind: "birth", date: { month: 12, day: 9 } } },
} as unknown as ContactCard;
expect(birthdaysInRange([parts], s, e)[0]!.name).toBe("Grace Hopper");
const org = {
id: "c2",
uid: "c2",
addressBookIds: {},
organizations: { o1: { name: "Acme Ltd" } },
anniversaries: { a1: { kind: "birth", date: { month: 3, day: 1 } } },
} as unknown as ContactCard;
expect(birthdaysInRange([org], s, e)[0]!.name).toBe("Acme Ltd");
});
it("never reports a negative age from a birth year in the future", () => {
const [s, e] = range("2026-01-01", "2027-01-01");
expect(birthdaysInRange([card("c1", "Ada", { year: 2040, month: 6, day: 15 })], s, e)[0]!.age).toBeNull();
});
it("ignores an impossible date rather than inventing one", () => {
const [s, e] = range("2026-01-01", "2027-01-01");
expect(birthdaysInRange([card("c1", "Ada", { month: 13, day: 40 })], s, e)).toEqual([]);
expect(birthdaysInRange([card("c1", "Ada", { month: 4, day: 31 })], s, e)).toEqual([]);
});
it("returns them in date order, whatever order the contacts were in", () => {
const [s, e] = range("2026-01-01", "2027-01-01");
const out = birthdaysInRange(
[card("c1", "Zoe", { month: 11, day: 2 }), card("c2", "Amy", { month: 2, day: 3 })],
s,
e,
);
expect(out.map((b) => b.name)).toEqual(["Amy", "Zoe"]);
});
it("gives each occurrence a stable, unique id that marks it as synthesised", () => {
const [s, e] = range("2025-01-01", "2027-01-01");
const out = birthdaysInRange([card("c1", "Ada", { month: 6, day: 15 })], s, e);
expect(new Set(out.map((b) => b.id)).size).toBe(out.length);
expect(out.every((b) => isBirthdayEvent(b.id))).toBe(true);
expect(out[0]!.id.startsWith(BIRTHDAY_ID_PREFIX)).toBe(true);
// Nothing that came off the server should ever look like one.
expect(isBirthdayEvent("abc123")).toBe(false);
expect(isBirthdayEvent(null)).toBe(false);
});
it("declines a range that is empty, backwards, or absurdly wide", () => {
const cards = [card("c1", "Ada", { month: 6, day: 15 })];
expect(birthdaysInRange(cards, new Date("2026-01-01"), new Date("2026-01-01"))).toEqual([]);
expect(birthdaysInRange(cards, new Date("2027-01-01"), new Date("2026-01-01"))).toEqual([]);
expect(birthdaysInRange(cards, new Date("2000-01-01"), new Date("2100-01-01"))).toEqual([]);
});
});
+40
View File
@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import { DEFAULT_APP_NAME } from "@/lib/brand";
/*
* The name an instance calls itself.
*
* `APP_NAME` is a runtime variable, so every place showing the name has to ask
* the server rather than have it written in. The sign-in page did not (#236's
* neighbour): it fetched `/api/config`, received the name and used only
* `sourceUrl`, so a rebranded instance still said "ihasmail" on the page a new
* user meets first. These pin the shape of the answer rather than the name.
*/
const nameFrom = (config: { appName?: unknown } | null) =>
config && typeof config.appName === "string" && config.appName.trim() ? config.appName.trim() : DEFAULT_APP_NAME;
describe("resolving the instance name", () => {
it("uses what the server says", () => {
expect(nameFrom({ appName: "Acme Mail" })).toBe("Acme Mail");
});
it("trims it, because a name with an edge of whitespace is a layout bug", () => {
expect(nameFrom({ appName: " Acme Mail " })).toBe("Acme Mail");
});
it("falls back when the request failed", () => {
// A sign-in form with no name on it is worse than one with the wrong name.
expect(nameFrom(null)).toBe(DEFAULT_APP_NAME);
});
it("falls back on a name that is empty or only spaces", () => {
expect(nameFrom({ appName: "" })).toBe(DEFAULT_APP_NAME);
expect(nameFrom({ appName: " " })).toBe(DEFAULT_APP_NAME);
});
it("falls back on a name that is not a string at all", () => {
expect(nameFrom({ appName: 42 })).toBe(DEFAULT_APP_NAME);
expect(nameFrom({})).toBe(DEFAULT_APP_NAME);
});
});
@@ -0,0 +1,93 @@
/**
* The two sentence builders, which had no tests while they were building
* English by concatenation -- and no test would have caught the thing wrong
* with them, since the English output was correct. These pin the two
* properties that matter now: every fragment goes through the catalogue, and
* the joining is Intl's rather than a hardcoded " and ".
*/
import { describe, expect, it } from "vitest";
import { describeRule as describeSieve } from "../sieve";
import { describeRule as describeRecurrence, weekdayOptions } from "../recurrence";
import { setUiLanguageForFormatting } from "../datetime";
import { setCatalog } from "../i18n";
describe("sieve describeRule", () => {
it("names the header and operator through the catalogue", () => {
const s = describeSieve({
id: "1", name: "r", join: "allof", enabled: true,
tests: [{ type: "header", header: "subject", op: "contains", value: "invoice" }],
actions: [{ type: "fileinto", mailbox: "Work" }],
} as never);
expect(s).toContain("Subject");
expect(s).toContain("contains");
expect(s).toContain("invoice");
expect(s).toContain("Work");
});
it("joins an allof rule as a conjunction and anyof as a disjunction", () => {
const base = {
id: "1", name: "r", enabled: true,
tests: [
{ type: "header", header: "from", op: "is", value: "a@b" },
{ type: "header", header: "to", op: "is", value: "c@d" },
],
actions: [{ type: "keep" }],
};
expect(describeSieve({ ...base, join: "allof" } as never)).toContain(" and ");
expect(describeSieve({ ...base, join: "anyof" } as never)).toContain(" or ");
});
it("says 'always' when a rule has no tests", () => {
const s = describeSieve({ id: "1", name: "r", join: "allof", enabled: true, tests: [], actions: [{ type: "stop" }] } as never);
expect(s).toContain("always");
});
});
describe("recurrence describeRule", () => {
it("describes the simple frequencies", () => {
expect(describeRecurrence(undefined)).toBe("Does not repeat");
expect(describeRecurrence({ "@type": "RecurrenceRule", frequency: "daily" } as never)).toBe("Daily");
expect(describeRecurrence({ "@type": "RecurrenceRule", frequency: "daily", interval: 3 } as never)).toBe("Every 3 days");
});
it("recognises Monday to Friday as every weekday", () => {
const rule = {
"@type": "RecurrenceRule", frequency: "weekly",
byDay: ["mo", "tu", "we", "th", "fr"].map((day) => ({ "@type": "NDay", day })),
};
expect(describeRecurrence(rule as never)).toBe("Every weekday");
});
it("uses a word, not a suffix, for the nth weekday of a month", () => {
const s = describeRecurrence({
"@type": "RecurrenceRule", frequency: "monthly",
byDay: [{ "@type": "NDay", day: "tu", nthOfPeriod: 2 }],
} as never);
expect(s).toContain("second");
expect(s).not.toContain("2nd");
});
it("wraps the sentence for count and until rather than appending to it", () => {
const s = describeRecurrence({ "@type": "RecurrenceRule", frequency: "daily", count: 5 } as never);
expect(s).toBe("Daily, 5 times");
const u = describeRecurrence({ "@type": "RecurrenceRule", frequency: "daily", until: "2026-05-03T00:00:00" } as never);
expect(u).toBe("Daily, until 2026-05-03");
});
it("takes its weekday names from the locale, not a table of English", () => {
setUiLanguageForFormatting("de-DE");
const names = weekdayOptions().map((w) => w.label);
expect(names[0]).toBe("Montag");
expect(names).toHaveLength(7);
// The narrow forms collide in English ("T" for both Tuesday and Thursday),
// which is why they cannot be catalogue keys and come from Intl instead.
expect(weekdayOptions().map((w) => w.short)).toHaveLength(7);
setUiLanguageForFormatting(null);
});
it("renders a translated rule through the catalogue", () => {
setCatalog("de", { strings: { Daily: "Täglich" }, plurals: {} });
expect(describeRecurrence({ "@type": "RecurrenceRule", frequency: "daily" } as never)).toBe("Täglich");
setCatalog("en", { strings: {}, plurals: {} });
});
});
+55
View File
@@ -0,0 +1,55 @@
import { describe, expect, it } from "vitest";
import { emlFilename, sanitizeFilename } from "@/lib/emlName";
describe("emlFilename", () => {
it("keeps an ordinary subject, with spaces as underscores", () => {
expect(emlFilename("Quarterly report")).toBe("Quarterly_report.eml");
});
it("keeps letters from any script, which the ASCII rule threw away", () => {
// The whole point: none of these may come out as a row of underscores.
expect(emlFilename("Квартальный отчёт")).toBe("Квартальный_отчёт.eml");
expect(emlFilename("四半期報告")).toBe("四半期報告.eml");
expect(emlFilename("Rapport trimestriel été")).toBe("Rapport_trimestriel_été.eml");
});
it("keeps the punctuation that is fine in a filename", () => {
expect(emlFilename("Re- budget (v3) [final]")).toBe("Re-_budget_(v3)_[final].eml");
});
it("drops path separators and the characters Windows reserves", () => {
expect(emlFilename("a/b\\c:d*e?f\"g<h>i|j")).toBe("abcdefghij.eml");
});
it("drops control characters", () => {
expect(emlFilename("a\u0007b\u0000c")).toBe("abc.eml");
expect(emlFilename("a\u007fb")).toBe("ab.eml");
});
it("falls back when there is no subject, or nothing survives", () => {
expect(emlFilename("")).toBe("message.eml");
expect(emlFilename(null)).toBe("message.eml");
expect(emlFilename(undefined)).toBe("message.eml");
expect(emlFilename("///")).toBe("message.eml");
expect(emlFilename(" ")).toBe("message.eml");
});
it("does not end in a dot or a space, which Windows refuses", () => {
expect(emlFilename("Report.")).toBe("Report.eml");
expect(emlFilename("Report ")).toBe("Report.eml");
expect(emlFilename("...Report...")).toBe("Report.eml");
});
it("does not start with a dot, which would hide the file on Unix", () => {
expect(emlFilename(".hidden")).toBe("hidden.eml");
});
it("caps the length so it survives a filesystem limit", () => {
const name = emlFilename("x".repeat(500));
expect(name).toBe(`${"x".repeat(80)}.eml`);
});
it("exposes the stem on its own", () => {
expect(sanitizeFilename("Quarterly report")).toBe("Quarterly_report");
});
});
+230
View File
@@ -0,0 +1,230 @@
import { describe, expect, it } from "vitest";
import {
canDragEvent,
formatDuration,
MIN_DURATION_MINUTES,
movedBy,
movedToDay,
pixelsToMinutes,
resizedBy,
snap,
movePatch,
moveByDaysPatch,
dayDelta,
resizePatch,
SNAP_MINUTES,
} from "@/lib/eventDrag";
import { BIRTHDAY_ID_PREFIX } from "@/lib/birthdays";
import type { CalendarEvent } from "@/jmap/types";
const at = (h: number, m = 0, d = 4) => new Date(2026, 8, d, h, m, 0, 0);
const span = (from: Date, to: Date) => ({ start: from, end: to });
const hhmm = (d: Date) => `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
const ymd = (d: Date) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
describe("snap", () => {
it("rounds to the nearest quarter hour", () => {
expect(snap(0)).toBe(0);
expect(snap(7)).toBe(0);
expect(snap(8)).toBe(15);
expect(snap(22)).toBe(15);
expect(snap(23)).toBe(30);
expect(snap(-8)).toBe(-15);
});
it("takes another slot when asked", () => {
expect(snap(20, 30)).toBe(30);
expect(snap(14, 30)).toBe(0);
});
});
describe("movedBy", () => {
it("moves both ends, so the length does not change", () => {
const out = movedBy(span(at(14), at(15)), 30);
expect(hhmm(out.start)).toBe("14:30");
expect(hhmm(out.end)).toBe("15:30");
});
it("snaps the drag rather than taking it literally", () => {
const out = movedBy(span(at(14), at(15)), 7);
expect(hhmm(out.start)).toBe("14:00");
});
it("moves backwards too", () => {
const out = movedBy(span(at(14), at(15)), -60);
expect(hhmm(out.start)).toBe("13:00");
expect(hhmm(out.end)).toBe("14:00");
});
it("carries an event across midnight without losing its length", () => {
const out = movedBy(span(at(23, 30), at(23, 45)), 60);
expect(ymd(out.start)).toBe("2026-09-05");
expect(hhmm(out.start)).toBe("00:30");
expect(out.end.getTime() - out.start.getTime()).toBe(15 * 60_000);
});
});
describe("movedToDay", () => {
it("keeps the time of day, which is what the month grid is not asking about", () => {
// Dragged from Friday to Monday: still at two o'clock.
const out = movedToDay(span(at(14), at(15, 30)), new Date(2026, 8, 7));
expect(ymd(out.start)).toBe("2026-09-07");
expect(hhmm(out.start)).toBe("14:00");
expect(hhmm(out.end)).toBe("15:30");
});
it("keeps a length that spans days", () => {
const out = movedToDay(span(at(14, 0, 4), at(10, 0, 6)), new Date(2026, 8, 20));
expect(ymd(out.start)).toBe("2026-09-20");
expect(ymd(out.end)).toBe("2026-09-22");
});
it("moves across a month boundary", () => {
const out = movedToDay(span(at(9), at(10)), new Date(2026, 9, 1));
expect(ymd(out.start)).toBe("2026-10-01");
expect(hhmm(out.start)).toBe("09:00");
});
});
describe("resizedBy", () => {
it("moves the end and leaves the start alone", () => {
const out = resizedBy(span(at(14), at(15)), 30);
expect(hhmm(out.start)).toBe("14:00");
expect(hhmm(out.end)).toBe("15:30");
});
it("clamps at one slot rather than refusing the drag", () => {
// A drag that goes too far is still a drag; stopping is what the reader
// sees happening while they do it.
const out = resizedBy(span(at(14), at(15)), -600);
expect(out.end.getTime() - out.start.getTime()).toBe(MIN_DURATION_MINUTES * 60_000);
expect(hhmm(out.end)).toBe("14:15");
});
it("never lets the end cross the start", () => {
for (const delta of [-60, -120, -1000]) {
const out = resizedBy(span(at(9), at(9, 30)), delta);
expect(out.end.getTime()).toBeGreaterThan(out.start.getTime());
}
});
});
describe("formatDuration", () => {
it("writes the shapes the wire expects", () => {
expect(formatDuration(3600)).toBe("PT1H");
expect(formatDuration(5400)).toBe("PT1H30M");
expect(formatDuration(900)).toBe("PT15M");
expect(formatDuration(86400)).toBe("P1D");
expect(formatDuration(90000)).toBe("P1DT1H");
expect(formatDuration(0)).toBe("PT0S");
expect(formatDuration(45)).toBe("PT45S");
});
});
describe("the patch a drag sends, computed in the event's own frame", () => {
/*
* The bug this shape exists to prevent: working the new time 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. An event two hours from the
* reader jumped two hours the first time it was dragged and then sat still.
* None of these functions touches a zone at all.
*/
it("moves the stored start by the snapped delta", () => {
expect(movePatch("2026-09-04T14:00:00", 30)).toEqual({ start: "2026-09-04T14:30:00" });
expect(movePatch("2026-09-04T14:00:00", -60)).toEqual({ start: "2026-09-04T13:00:00" });
expect(movePatch("2026-09-04T14:00:00", 7)).toEqual({ start: "2026-09-04T14:00:00" });
});
it("carries a move across midnight and across a month", () => {
expect(movePatch("2026-09-30T23:30:00", 60)).toEqual({ start: "2026-10-01T00:30:00" });
});
it("never sends a duration for a move, so the length is left alone", () => {
expect(movePatch("2026-09-04T14:00:00", 30).duration).toBeUndefined();
});
it("keeps the time of day when moving by whole days", () => {
expect(moveByDaysPatch("2026-09-04T14:30:00", 6)).toEqual({ start: "2026-09-10T14:30:00" });
expect(moveByDaysPatch("2026-09-04T14:30:00", -3)).toEqual({ start: "2026-09-01T14:30:00" });
});
it("moves by the delta the hand made, not to the date that was dropped on", () => {
/*
* The month grid's cells are local days; the stored date is in the event's
* own zone. Writing the dropped-on date put a Tokyo event dropped on the
* 11th onto the 10th, because 15:00 in Tokyo is the previous evening in
* Phoenix it went where its own calendar said, not where the pointer did.
*/
const storedTokyo = "2026-09-04T15:00:00"; // shown to a Phoenix reader on the 3rd
const shownOn = new Date(2026, 8, 3);
const droppedOn = new Date(2026, 8, 11);
const patch = moveByDaysPatch(storedTokyo, dayDelta(shownOn, droppedOn));
// Eight days later in its own frame, so eight days later on screen too.
expect(patch).toEqual({ start: "2026-09-12T15:00:00" });
});
it("counts whole local days, ignoring the time on either side", () => {
expect(dayDelta(new Date(2026, 8, 3, 23, 30), new Date(2026, 8, 4, 0, 30))).toBe(1);
expect(dayDelta(new Date(2026, 8, 4), new Date(2026, 8, 4))).toBe(0);
expect(dayDelta(new Date(2026, 8, 11), new Date(2026, 8, 3))).toBe(-8);
expect(dayDelta(new Date(2026, 8, 30), new Date(2026, 9, 2))).toBe(2);
});
it("never sends a start for a resize, so the zone question does not arise", () => {
const patch = resizePatch(3600, 60);
expect(patch).toEqual({ duration: "PT2H" });
expect(patch.start).toBeUndefined();
});
it("clamps a resize at one slot", () => {
expect(resizePatch(3600, -600)).toEqual({ duration: "PT15M" });
});
it("says nothing at all about a start it cannot read", () => {
expect(movePatch("not a date", 30)).toEqual({});
expect(moveByDaysPatch("", 3)).toEqual({});
expect(moveByDaysPatch("2026-09-04T14:00:00", Number.NaN)).toEqual({});
});
});
describe("canDragEvent", () => {
const writable = { myRights: { mayWriteAll: true } };
const readonly = { myRights: { mayWriteAll: false, mayWriteOwn: false } };
const event = { id: "e1" } as CalendarEvent;
it("allows a normal event on a calendar you can write to", () => {
expect(canDragEvent(event, writable)).toBe(true);
expect(canDragEvent(event, { myRights: { mayWriteOwn: true } })).toBe(true);
});
it("refuses a birthday, which is derived and has nothing to move", () => {
expect(canDragEvent({ id: `${BIRTHDAY_ID_PREFIX}c1:2026` } as CalendarEvent, writable)).toBe(false);
});
it("refuses a calendar you cannot write to, and one that is not there", () => {
expect(canDragEvent(event, readonly)).toBe(false);
expect(canDragEvent(event, undefined)).toBe(false);
});
it("refuses nothing at all", () => {
expect(canDragEvent(null, writable)).toBe(false);
});
});
describe("pixelsToMinutes", () => {
it("converts against the grid's own scale", () => {
expect(pixelsToMinutes(48, 48)).toBe(60);
expect(pixelsToMinutes(24, 48)).toBe(30);
expect(pixelsToMinutes(-48, 48)).toBe(-60);
});
it("says nothing rather than dividing by zero before the grid is measured", () => {
expect(pixelsToMinutes(100, 0)).toBe(0);
});
it("round-trips through snap to the slot the pointer is over", () => {
expect(snap(pixelsToMinutes(10, 48))).toBe(15);
expect(snap(pixelsToMinutes(2, 48))).toBe(0);
expect(SNAP_MINUTES).toBe(15);
});
});
@@ -0,0 +1,66 @@
import { describe, expect, it } from "vitest";
import { canDropFileNodes, NODE_MIME, readDraggedIds } from "@/lib/filenode";
import type { FileNode, Id } from "@/jmap/types";
const rights = { mayRead: true, mayAddChildren: true, mayRename: true, mayDelete: true, mayModifyContent: true, mayShare: true };
function node(id: string, parentId: Id | null, nodeType: FileNode["nodeType"] = "file"): FileNode {
return { id, parentId, nodeType, blobId: nodeType === "file" ? `b${id}` : null, size: 1, name: id, type: "text/plain", created: "", modified: null, myRights: rights } as FileNode;
}
/*
* A multi-file drag carries its ids in one payload, because `dataTransfer`
* holds one string per type and the drop has to be one action. These two
* functions are the whole of that contract -- the gesture itself cannot be
* driven synthetically, so this is what pins it.
*/
describe("readDraggedIds", () => {
const dt = (value: string) => ({ getData: (type: string) => (type === NODE_MIME ? value : "") }) as DataTransfer;
it("reads one id as a list of one", () => {
expect(readDraggedIds(dt("f1"))).toEqual(["f1"]);
});
it("reads a whole selection", () => {
expect(readDraggedIds(dt("f1,f2,f3"))).toEqual(["f1", "f2", "f3"]);
});
it("is empty for a drag that carries nothing of ours", () => {
// A drag from outside the app: the caller checks `types` first, but an
// empty string here must not read as a file called "".
expect(readDraggedIds(dt(""))).toEqual([]);
expect(readDraggedIds(dt(",,"))).toEqual([]);
});
});
describe("canDropFileNodes", () => {
const nodes: Record<Id, FileNode> = {
root1: node("root1", null),
root2: node("root2", null),
dir: node("dir", null, "directory"),
inside: node("inside", "dir"),
};
it("allows a drop only when every file can make it", () => {
expect(canDropFileNodes(nodes, ["root1", "root2"], "dir")).toBe(true);
// `inside` is already in `dir`, so the move is a no-op for it -- and a drop
// that would move one of two files is refused rather than half-done.
expect(canDropFileNodes(nodes, ["root1", "inside"], "dir")).toBe(false);
});
it("refuses a folder dropped into itself, whoever it is dragged with", () => {
expect(canDropFileNodes(nodes, ["dir"], "dir")).toBe(false);
expect(canDropFileNodes(nodes, ["root1", "dir"], "dir")).toBe(false);
});
it("has nothing to drop when nothing is dragged", () => {
expect(canDropFileNodes(nodes, [], "dir")).toBe(false);
});
it("treats the top level like any other target", () => {
expect(canDropFileNodes(nodes, ["inside"], null)).toBe(true);
// Already at the top: nothing to do.
expect(canDropFileNodes(nodes, ["root1"], null)).toBe(false);
expect(canDropFileNodes(nodes, ["inside", "root1"], null)).toBe(false);
});
});
+152
View File
@@ -0,0 +1,152 @@
import { afterEach, describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { CONTEXT_SEPARATOR, currentLanguage, interpolate, plural, setCatalog, subscribeForTest, t, tc, tNode, type Catalog } from "@/lib/i18n";
const de: Catalog = {
strings: {
"Archive": "Archivieren",
"Move {n} to {folder}": "{n} nach {folder} verschieben",
// German puts the parts in a different order, which is the whole reason
// the element is a named hole rather than a split sentence.
"Open {scheme} links here": "{scheme}-Links hier öffnen",
},
plurals: { "{n} messages": { one: "{n} Nachricht", other: "{n} Nachrichten" } },
};
/* Russian is the reason plural() does not take (one, other): it needs three
forms, and which one applies is not a question about the number 1. */
const ru: Catalog = {
strings: {},
plurals: { "{n} messages": { one: "{n} сообщение", few: "{n} сообщения", many: "{n} сообщений", other: "{n} сообщения" } },
};
afterEach(() => setCatalog("en", { strings: {}, plurals: {} }));
describe("t", () => {
it("returns the English it was given when nothing is loaded", () => {
// The whole point of English-as-key: a missing translation degrades to
// readable English rather than to a symbolic name leaking into the UI.
expect(t("Archive")).toBe("Archive");
expect(currentLanguage()).toBe("en");
});
it("translates once a catalogue is in force", () => {
setCatalog("de", de);
expect(t("Archive")).toBe("Archivieren");
});
it("falls back per string, not per catalogue", () => {
setCatalog("de", de);
expect(t("Report spam")).toBe("Report spam");
});
});
describe("interpolation", () => {
it("fills named placeholders", () => {
expect(interpolate("Move {n} to {folder}", { n: 3, folder: "Archive" })).toBe("Move 3 to Archive");
});
it("survives a translator reordering the sentence", () => {
// Positional arguments would not: German moves the parts around and means
// the same thing.
setCatalog("de", de);
expect(t("Move {n} to {folder}", { n: 3, folder: "Archiv" })).toBe("3 nach Archiv verschieben");
});
it("leaves an unknown placeholder alone rather than printing undefined", () => {
expect(interpolate("Hello {who}", {})).toBe("Hello {who}");
});
});
describe("plural", () => {
const FORMS = { one: "{n} message", other: "{n} messages" };
it("picks the English form without a catalogue", () => {
expect(plural(1, FORMS)).toBe("1 message");
expect(plural(0, FORMS)).toBe("0 messages");
expect(plural(5, FORMS)).toBe("5 messages");
});
it("uses the target language's own rule, not English's", () => {
setCatalog("ru", ru);
expect(plural(1, FORMS)).toBe("1 сообщение"); // one
expect(plural(3, FORMS)).toBe("3 сообщения"); // few
expect(plural(7, FORMS)).toBe("7 сообщений"); // many
});
it("falls back to `other` when the catalogue lacks the category", () => {
setCatalog("de", de);
// German has no "few"; asking for 3 must not render undefined.
expect(plural(3, FORMS)).toBe("3 Nachrichten");
});
it("takes extra variables alongside the count", () => {
expect(plural(2, { one: "{n} message in {folder}", other: "{n} messages in {folder}" }, { folder: "Inbox" }))
.toBe("2 messages in Inbox");
});
});
describe("tNode", () => {
const render = (node: React.ReactNode) => renderToStaticMarkup(<>{node}</>);
it("keeps an element inside the sentence", () => {
expect(render(tNode("Open {scheme} links here", { scheme: <code>mailto:</code> })))
.toBe("Open <code>mailto:</code> links here");
});
it("lets a translator move the element", () => {
// Splitting the sentence into two t() calls could not do this: the
// fragments would render in the English order whatever the catalogue said.
setCatalog("de", de);
expect(render(tNode("Open {scheme} links here", { scheme: <code>mailto:</code> })))
.toBe("<code>mailto:</code>-Links hier öffnen");
});
it("leaves a placeholder alone when nothing is supplied for it", () => {
expect(render(tNode("Open {scheme} links here", {}))).toBe("Open {scheme} links here");
});
it("takes plain variables alongside elements", () => {
expect(render(tNode("{count} of {scheme}", { scheme: <b>x</b> }, { count: 3 }))).toBe("3 of <b>x</b>");
});
});
describe("setCatalog", () => {
it("does not announce a change that did not happen", () => {
/*
* The root keys its tree on the language version, so every publish
* remounts the app -- which re-runs the effect that loads the account's
* settings, which calls applyLang, which lands back in setCatalog with the
* same language. Publishing that non-change looped for ever, and from the
* outside it looked like the message list refreshing without end.
*/
const seen: number[] = [];
const stop = subscribeForTest(() => seen.push(1));
const cat: Catalog = { strings: { Archive: "Archivieren" }, plurals: {} };
setCatalog("de", cat);
setCatalog("de", cat);
setCatalog("de", cat);
expect(seen.length).toBe(1);
setCatalog("en", { strings: {}, plurals: {} });
expect(seen.length).toBe(2);
stop();
});
});
describe("tc", () => {
it("tells apart an English word doing two jobs", () => {
// "Archive" is the button and the folder; German wants a different word
// for each, and one key cannot hold both.
setCatalog("de", {
strings: { "Archive": "Archivieren", [`folder${CONTEXT_SEPARATOR}Archive`]: "Archiv" },
plurals: {},
});
expect(t("Archive")).toBe("Archivieren");
expect(tc("folder", "Archive")).toBe("Archiv");
});
it("falls back to the plain translation, then to English", () => {
setCatalog("de", { strings: { "Drafts": "Entwürfe" }, plurals: {} });
expect(tc("folder", "Drafts")).toBe("Entwürfe"); // no context entry yet
expect(tc("folder", "Sent")).toBe("Sent"); // nothing at all
});
});
+187
View File
@@ -0,0 +1,187 @@
import { describe, expect, it } from "vitest";
import { looksLikeCalendar, parseIcs, parseIcsDuration, parseDateValue, parseLine, unescapeText, unfold } from "@/lib/ics";
const cal = (body: string) => `BEGIN:VCALENDAR\r\nVERSION:2.0\r\n${body}\r\nEND:VCALENDAR\r\n`;
const event = (props: string) => `BEGIN:VEVENT\r\n${props}\r\nEND:VEVENT`;
const ymd = (d: Date) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
const hhmm = (d: Date) => `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
describe("unfold", () => {
it("joins a continuation with nothing between, per the RFC", () => {
expect(unfold("SUMMARY:A very\r\n long title")).toEqual(["SUMMARY:A very long title"]);
expect(unfold("SUMMARY:A\r\n\tB")).toEqual(["SUMMARY:AB"]);
});
it("handles all three line endings", () => {
expect(unfold("A\r\nB\nC\rD")).toEqual(["A", "B", "C", "D"]);
});
it("does not treat a leading space on the first line as a continuation", () => {
expect(unfold(" oops")).toEqual([" oops"]);
});
});
describe("parseLine", () => {
it("splits a plain property", () => {
expect(parseLine("SUMMARY:Standup")).toEqual({ name: "SUMMARY", params: {}, value: "Standup" });
});
it("reads parameters", () => {
expect(parseLine("DTSTART;VALUE=DATE:20260904")).toEqual({
name: "DTSTART",
params: { VALUE: "DATE" },
value: "20260904",
});
});
it("ignores a colon inside a quoted parameter, which is a real shape", () => {
// A naive indexOf(":") reads this as a property called DTSTART;TZID="GMT+01
const line = parseLine('DTSTART;TZID="GMT+01:00":20260904T140000');
expect(line?.name).toBe("DTSTART");
expect(line?.value).toBe("20260904T140000");
expect(line?.params.TZID).toBe("GMT+01:00");
});
it("uppercases the name, since the RFC does not require any particular case", () => {
expect(parseLine("summary:x")?.name).toBe("SUMMARY");
});
it("says nothing about a line with no colon", () => {
expect(parseLine("NONSENSE")).toBeNull();
expect(parseLine("")).toBeNull();
});
});
describe("unescapeText", () => {
it("undoes the four escapes and leaves everything else", () => {
expect(unescapeText("a\\nb")).toBe("a\nb");
expect(unescapeText("a\\Nb")).toBe("a\nb");
expect(unescapeText("a\\,b\\;c")).toBe("a,b;c");
expect(unescapeText("a\\\\b")).toBe("a\\b");
expect(unescapeText("100% \\real")).toBe("100% \\real");
});
});
describe("parseDateValue", () => {
it("reads a date as all-day in local time, not UTC midnight", () => {
// UTC midnight lands on the day before for anyone west of Greenwich.
const out = parseDateValue("20260904");
expect(out?.allDay).toBe(true);
expect(ymd(out!.date)).toBe("2026-09-04");
expect(hhmm(out!.date)).toBe("00:00");
});
it("respects VALUE=DATE even on a longer string", () => {
expect(parseDateValue("20260904", { VALUE: "DATE" })?.allDay).toBe(true);
});
it("reads a UTC instant", () => {
const out = parseDateValue("20260904T140000Z");
expect(out?.allDay).toBe(false);
expect(out?.date.toISOString()).toBe("2026-09-04T14:00:00.000Z");
});
it("reads a floating wall clock as local time", () => {
const out = parseDateValue("20260904T140000");
expect(out?.allDay).toBe(false);
expect(hhmm(out!.date)).toBe("14:00");
expect(ymd(out!.date)).toBe("2026-09-04");
});
it("says nothing about a value it cannot read", () => {
expect(parseDateValue("not a date")).toBeNull();
expect(parseDateValue("")).toBeNull();
});
});
describe("parseIcsDuration", () => {
it("reads the forms a DTEND substitute uses", () => {
expect(parseIcsDuration("PT1H")).toBe(3600);
expect(parseIcsDuration("PT30M")).toBe(1800);
expect(parseIcsDuration("P1D")).toBe(86400);
expect(parseIcsDuration("P1W")).toBe(604800);
expect(parseIcsDuration("P1DT2H30M")).toBe(95400);
expect(parseIcsDuration("-PT1H")).toBe(-3600);
});
it("says nothing about nonsense", () => {
expect(parseIcsDuration("1 hour")).toBeNull();
expect(parseIcsDuration("")).toBeNull();
});
});
describe("looksLikeCalendar", () => {
it("recognises a calendar and rejects an error page", () => {
expect(looksLikeCalendar("BEGIN:VCALENDAR\r\nEND:VCALENDAR")).toBe(true);
expect(looksLikeCalendar("<!doctype html><title>404</title>")).toBe(false);
});
});
describe("parseIcs", () => {
it("reads a timed event with a summary and an end", () => {
const { events } = parseIcs(cal(event("UID:a@x\r\nSUMMARY:Standup\r\nDTSTART:20260904T090000Z\r\nDTEND:20260904T091500Z")));
expect(events).toHaveLength(1);
expect(events[0]!.summary).toBe("Standup");
expect(events[0]!.uid).toBe("a@x");
expect(events[0]!.allDay).toBe(false);
expect(events[0]!.end.getTime() - events[0]!.start.getTime()).toBe(15 * 60_000);
});
it("reads an all-day event", () => {
const { events } = parseIcs(cal(event("UID:b@x\r\nSUMMARY:Holiday\r\nDTSTART;VALUE=DATE:20260904")));
expect(events[0]!.allDay).toBe(true);
expect(ymd(events[0]!.start)).toBe("2026-09-04");
expect(events[0]!.end.getTime() - events[0]!.start.getTime()).toBe(86400_000);
});
it("takes DURATION when there is no DTEND", () => {
const { events } = parseIcs(cal(event("UID:c@x\r\nDTSTART:20260904T090000Z\r\nDURATION:PT90M")));
expect(events[0]!.end.getTime() - events[0]!.start.getTime()).toBe(90 * 60_000);
});
it("reads the calendar's own name where it gives one", () => {
expect(parseIcs(cal(`X-WR-CALNAME:Team calendar\r\n${event("UID:d\r\nDTSTART:20260904T090000Z")}`)).name).toBe("Team calendar");
});
it("unfolds a long summary before reading it", () => {
const { events } = parseIcs(cal("BEGIN:VEVENT\r\nUID:e\r\nDTSTART:20260904T090000Z\r\nSUMMARY:A very\r\n long title\r\nEND:VEVENT"));
expect(events[0]!.summary).toBe("A very long title");
});
it("steps over components that are not events", () => {
const doc = cal(`BEGIN:VTIMEZONE\r\nTZID:Europe/London\r\nBEGIN:STANDARD\r\nDTSTART:19701025T020000\r\nEND:STANDARD\r\nEND:VTIMEZONE\r\n${event("UID:f\r\nSUMMARY:Real\r\nDTSTART:20260904T090000Z")}\r\nBEGIN:VTODO\r\nSUMMARY:Not an event\r\nEND:VTODO`);
const { events } = parseIcs(doc);
expect(events.map((e) => e.summary)).toEqual(["Real"]);
});
it("counts a recurring event once and does not expand it", () => {
// Showing the wrong dates would be worse than showing the first and saying so.
const { events, recurringCount } = parseIcs(cal(event("UID:g\r\nSUMMARY:Weekly\r\nDTSTART:20260904T090000Z\r\nRRULE:FREQ=WEEKLY;COUNT=10")));
expect(events).toHaveLength(1);
expect(events[0]!.recurring).toBe(true);
expect(recurringCount).toBe(1);
});
it("drops an event with no usable start rather than inventing a time", () => {
const { events } = parseIcs(cal(event("UID:h\r\nSUMMARY:When?")));
expect(events).toEqual([]);
});
it("repairs an end that is before its start", () => {
const { events } = parseIcs(cal(event("UID:i\r\nDTSTART:20260904T100000Z\r\nDTEND:20260904T090000Z")));
expect(events[0]!.end.getTime()).toBeGreaterThanOrEqual(events[0]!.start.getTime());
});
it("gives an event with no UID one of its own, so keys stay unique", () => {
const { events } = parseIcs(cal(`${event("SUMMARY:One\r\nDTSTART:20260904T090000Z")}\r\n${event("SUMMARY:Two\r\nDTSTART:20260905T090000Z")}`));
expect(events).toHaveLength(2);
expect(events[0]!.uid).not.toBe(events[1]!.uid);
});
it("reads several events, and survives an empty document", () => {
const many = cal([1, 2, 3].map((n) => event(`UID:m${n}\r\nSUMMARY:E${n}\r\nDTSTART:2026090${n}T090000Z`)).join("\r\n"));
expect(parseIcs(many).events.map((e) => e.summary)).toEqual(["E1", "E2", "E3"]);
expect(parseIcs("").events).toEqual([]);
expect(parseIcs("<!doctype html>").events).toEqual([]);
});
});
+305
View File
@@ -0,0 +1,305 @@
import { describe, expect, it } from "vitest";
import { toIcs, parseIcs } from "@/lib/ics";
import type { JSCalendarEvent } from "@/jmap/types";
/*
* Writing iCalendar out of the server's RFC 8984 objects.
*
* The properties worth pinning are the ones where the two formats disagree, or
* where getting it wrong shows up as a wrong time rather than as an error: how
* a zone is said, what UNTIL is measured in, and where a changed occurrence
* goes.
*/
const base: JSCalendarEvent = {
"@type": "Event", uid: "[email protected]", title: "Kickoff",
start: "2026-09-02T09:00:00", duration: "PT1H", timeZone: "Europe/Berlin",
};
const lines = (e: JSCalendarEvent[], name?: string) => toIcs(e, name).split("\r\n");
/*
* From the first event onwards. The zone definitions above carry DTSTART and
* TZNAME of their own, and a test asking "what is this event's DTSTART" must
* not be answered by a transition rule.
*/
const eventLines = (e: JSCalendarEvent[]) => {
const all = lines(e);
return all.slice(all.indexOf("BEGIN:VEVENT"));
};
const find = (e: JSCalendarEvent[], prefix: string) => eventLines(e).filter((l) => l.startsWith(prefix));
const one = (e: JSCalendarEvent, prefix: string) => find([e], prefix)[0];
describe("the document around the events", () => {
it("is a calendar a reader will recognise", () => {
const l = lines([base]);
expect(l[0]).toBe("BEGIN:VCALENDAR");
expect(l).toContain("VERSION:2.0");
expect(l).toContain("END:VCALENDAR");
expect(l.some((x) => x.startsWith("PRODID:"))).toBe(true);
});
it("carries the calendar's name where a reader will look for it", () => {
expect(lines([base], "Work")).toContain("X-WR-CALNAME:Work");
});
it("ends every line the way the format requires", () => {
expect(toIcs([base]).endsWith("\r\n")).toBe(true);
expect(toIcs([base]).includes("\n\n")).toBe(false);
});
});
describe("times and zones", () => {
it("names the zone rather than converting, so a series survives a DST change", () => {
expect(one(base, "DTSTART")).toBe("DTSTART;TZID=Europe/Berlin:20260902T090000");
});
it("writes UTC as UTC", () => {
expect(one({ ...base, timeZone: "Etc/UTC" }, "DTSTART")).toBe("DTSTART:20260902T090000Z");
});
it("leaves a floating time floating, with no zone at all", () => {
// No zone means "whatever clock the reader is on", which is a real and
// different thing from UTC -- a 09:00 alarm clock, not an instant.
expect(one({ ...base, timeZone: null }, "DTSTART")).toBe("DTSTART:20260902T090000");
});
it("writes an all-day event as a date, not as midnight", () => {
const e = { ...base, showWithoutTime: true, duration: "P1D" };
expect(one(e, "DTSTART")).toBe("DTSTART;VALUE=DATE:20260902");
});
it("keeps the duration rather than working out an end", () => {
expect(one(base, "DURATION")).toBe("DURATION:PT1H");
});
it("says nothing about duration when the event has none", () => {
expect(find([{ ...base, duration: undefined }], "DURATION")).toEqual([]);
});
});
describe("recurrence", () => {
const weekly = { ...base, recurrenceRule: { frequency: "weekly" as const, byDay: [{ day: "we" as const }] } };
it("writes the rule rather than expanding it into a year of events", () => {
expect(one(weekly, "RRULE")).toBe("RRULE:FREQ=WEEKLY;BYDAY=WE");
expect(find([weekly], "BEGIN:VEVENT")).toHaveLength(1);
});
it("reads the array form as well as the single rule Stalwart stores", () => {
const e = { ...base, recurrenceRules: [{ frequency: "monthly" as const, interval: 2, count: 5 }] };
expect(one(e, "RRULE")).toBe("RRULE:FREQ=MONTHLY;INTERVAL=2;COUNT=5");
});
it("measures UNTIL in UTC, so a series does not stop a day early elsewhere", () => {
const e = { ...base, recurrenceRule: { frequency: "weekly" as const, until: "2026-12-30T09:00:00" } };
expect(one(e, "RRULE")).toBe("RRULE:FREQ=WEEKLY;UNTIL=20261230T090000Z");
});
it("measures UNTIL as a date when the series is all-day", () => {
const e = { ...base, showWithoutTime: true, recurrenceRule: { frequency: "daily" as const, until: "2026-12-30T00:00:00" } };
expect(one(e, "RRULE")).toBe("RRULE:FREQ=DAILY;UNTIL=20261230");
});
it("keeps the nth-weekday form that BYDAY carries a number for", () => {
const e = { ...base, recurrenceRule: { frequency: "monthly" as const, byDay: [{ day: "th" as const, nthOfPeriod: -1 }] } };
expect(one(e, "RRULE")).toBe("RRULE:FREQ=MONTHLY;BYDAY=-1TH");
});
it("turns a cancelled occurrence into an EXDATE", () => {
const e = { ...weekly, recurrenceOverrides: { "2026-09-09T09:00:00": null } };
expect(one(e, "EXDATE")).toBe("EXDATE;TZID=Europe/Berlin:20260909T090000");
expect(find([e], "BEGIN:VEVENT")).toHaveLength(1);
});
it("treats an override marked excluded the same way", () => {
const e = { ...weekly, recurrenceOverrides: { "2026-09-09T09:00:00": { excluded: true } } };
expect(one(e, "EXDATE")).toBe("EXDATE;TZID=Europe/Berlin:20260909T090000");
});
it("gives a changed occurrence its own event, sharing the uid", () => {
/*
* Which is how iCalendar has always said it: the same UID, plus the
* RECURRENCE-ID of the slot being replaced. The master keeps its rule and
* the override must not.
*/
const e = { ...weekly, recurrenceOverrides: { "2026-09-09T09:00:00": { title: "Kickoff (moved)" } } };
const l = lines([e]);
expect(l.filter((x) => x === "BEGIN:VEVENT")).toHaveLength(2);
expect(l.filter((x) => x === "UID:[email protected]")).toHaveLength(2);
expect(l).toContain("RECURRENCE-ID;TZID=Europe/Berlin:20260909T090000");
expect(l).toContain("SUMMARY:Kickoff (moved)");
// One RRULE in the file, on the master.
expect(l.filter((x) => x.startsWith("RRULE:"))).toHaveLength(1);
});
});
describe("the rest of an event", () => {
it("escapes what the format uses as punctuation", () => {
const e = { ...base, title: "Budget; Q4, final", description: "line one\nline two" };
// Both escapes doubled here for JS's sake: what reaches the file is one
// backslash before each of the two characters the format reserves.
expect(one(e, "SUMMARY")).toBe("SUMMARY:Budget\\; Q4\\, final");
expect(one(e, "DESCRIPTION")).toBe("DESCRIPTION:line one\\nline two");
});
it("folds a long line rather than writing it past the limit", () => {
const e = { ...base, title: "x".repeat(200) };
for (const l of lines([e])) expect(l.length).toBeLessThanOrEqual(75);
});
it("puts a room in LOCATION and a video link in URL", () => {
// A meeting URL where a room name goes is what makes a printed agenda
// useless, and they are different fields in both formats.
const e = {
...base,
locations: { l1: { name: "Room 3" } },
virtualLocations: { v1: { uri: "https://meet.example.org/abc" } },
} as JSCalendarEvent;
expect(one(e, "LOCATION")).toBe("LOCATION:Room 3");
expect(one(e, "URL")).toBe("URL:https://meet.example.org/abc");
});
it("maps the words the two formats spell differently", () => {
const e = { ...base, status: "tentative" as const, privacy: "secret" as const, freeBusyStatus: "free" as const };
expect(one(e, "STATUS")).toBe("STATUS:TENTATIVE");
expect(one(e, "CLASS")).toBe("CLASS:CONFIDENTIAL");
expect(one(e, "TRANSP")).toBe("TRANSP:TRANSPARENT");
});
it("writes the organiser and the guests, with what each answered", () => {
const e = {
...base,
organizerCalendarAddress: "mailto:[email protected]",
participants: {
p1: { roles: { attendee: true }, name: "Ada", calendarAddress: "mailto:[email protected]", participationStatus: "accepted" as const, expectReply: true },
p2: { roles: { optional: true }, sendTo: { imip: "mailto:[email protected]" }, participationStatus: "needs-action" as const },
},
} as JSCalendarEvent;
expect(one(e, "ORGANIZER")).toBe("ORGANIZER:mailto:[email protected]");
const att = find([e], "ATTENDEE");
expect(att[0]).toBe("ATTENDEE;CN=Ada;PARTSTAT=ACCEPTED;RSVP=TRUE:mailto:[email protected]");
expect(att[1]).toBe("ATTENDEE;PARTSTAT=NEEDS-ACTION;ROLE=OPT-PARTICIPANT:mailto:[email protected]");
});
it("skips a participant with no address at all rather than writing a broken line", () => {
const e = { ...base, participants: { p1: { roles: { attendee: true }, name: "Nobody" } } } as JSCalendarEvent;
expect(find([e], "ATTENDEE")).toEqual([]);
});
it("nests an alarm inside the event it belongs to", () => {
const e = { ...base, alerts: { a1: { trigger: { offset: "-PT15M" } } } } as JSCalendarEvent;
const l = lines([e]);
expect(l).toContain("BEGIN:VALARM");
expect(l).toContain("TRIGGER:-PT15M");
expect(l).toContain("ACTION:DISPLAY");
expect(l.indexOf("BEGIN:VALARM")).toBeLessThan(l.indexOf("END:VEVENT"));
});
it("says when an alarm hangs off the end rather than the start", () => {
const e = { ...base, alerts: { a1: { trigger: { offset: "PT5M", relativeTo: "end" as const } } } } as JSCalendarEvent;
expect(one(e, "TRIGGER")).toBe("TRIGGER;RELATED=END:PT5M");
});
});
describe("what comes back out of the parser", () => {
/*
* Not a full round trip -- the reader is a subscription parser and keeps far
* less than the writer emits -- but what it does read should be what went in.
*/
it("reads back the events it wrote", () => {
const two = [base, { ...base, uid: "[email protected]", title: "Retro", start: "2026-09-09T14:00:00" }];
const back = parseIcs(toIcs(two));
expect(back.events.map((e) => e.uid)).toEqual(["[email protected]", "[email protected]"]);
expect(back.events.map((e) => e.summary)).toEqual(["Kickoff", "Retro"]);
});
it("reads back a title that needed escaping, unescaped", () => {
const back = parseIcs(toIcs([{ ...base, title: "Budget; Q4, final" }]));
expect(back.events[0]!.summary).toBe("Budget; Q4, final");
});
});
/*
* Time zone definitions.
*
* These exist because leaving them out was wrong, and measurably: ical.js --
* Mozilla's library, the one Thunderbird's calendar uses -- reads a TZID with
* nothing defining it as *floating*, so a 09:00 in Phoenix opened anywhere else
* reads as 09:00 there. Seven hours out, silently, on every timed event.
*/
describe("the zones an export names", () => {
const inZone = (uid: string, tz: string, start = "2026-09-02T09:00:00") =>
({ ...base, uid, timeZone: tz, start }) as JSCalendarEvent;
it("defines every zone its events refer to", () => {
const l = lines([inZone("a", "America/Phoenix"), inZone("b", "Asia/Tokyo")]);
expect(l.filter((x) => x === "BEGIN:VTIMEZONE")).toHaveLength(2);
expect(l).toContain("TZID:America/Phoenix");
expect(l).toContain("TZID:Asia/Tokyo");
});
it("defines a zone once however many events use it", () => {
const l = lines([inZone("a", "Europe/Berlin"), inZone("b", "Europe/Berlin"), inZone("c", "Europe/Berlin")]);
expect(l.filter((x) => x === "BEGIN:VTIMEZONE")).toHaveLength(1);
});
it("says nothing about UTC, which needs no definition", () => {
expect(lines([inZone("a", "Etc/UTC")]).filter((x) => x === "BEGIN:VTIMEZONE")).toHaveLength(0);
});
it("says nothing about an all-day event, which has no zone to define", () => {
const e = { ...base, showWithoutTime: true, timeZone: "Europe/Berlin" } as JSCalendarEvent;
expect(lines([e]).filter((x) => x === "BEGIN:VTIMEZONE")).toHaveLength(0);
});
it("writes a zone that never changes as one standing rule", () => {
// Phoenix keeps MST all year: one sub-component, and the two offsets equal.
const l = lines([inZone("a", "America/Phoenix")]);
expect(l.filter((x) => x === "BEGIN:DAYLIGHT")).toHaveLength(0);
expect(l.filter((x) => x === "BEGIN:STANDARD")).toHaveLength(1);
expect(l).toContain("TZOFFSETFROM:-0700");
expect(l).toContain("TZOFFSETTO:-0700");
expect(l).toContain("TZNAME:MST");
});
it("finds the transitions of a zone that does change", () => {
const l = lines([inZone("a", "Europe/Berlin")]);
// Both directions, and at the hours the EU actually changes at.
expect(l).toContain("DTSTART:20260329T020000");
expect(l).toContain("DTSTART:20261025T030000");
const spring = l.indexOf("DTSTART:20260329T020000");
expect(l[spring - 1]).toBe("BEGIN:DAYLIGHT");
expect(l[spring + 1]).toBe("TZOFFSETFROM:+0100");
expect(l[spring + 2]).toBe("TZOFFSETTO:+0200");
});
it("covers years around the events rather than only the year they fall in", () => {
// An open-ended weekly meeting outlives the year it was created in, so a
// definition that stopped at that year would leave later occurrences
// undefined.
const l = lines([inZone("a", "Europe/Berlin")]);
const years = new Set(l.filter((x) => x.startsWith("DTSTART:")).map((x) => x.slice(8, 12)));
expect(years.size).toBeGreaterThan(5);
expect([...years].some((y) => Number(y) > 2030)).toBe(true);
});
it("leaves out a zone name that only repeats the offset", () => {
// Intl answers "GMT+9" for Tokyo, which says nothing TZOFFSETTO has not.
const l = lines([inZone("a", "Asia/Tokyo")]);
expect(l.some((x) => x.startsWith("TZNAME:GMT"))).toBe(false);
expect(l).toContain("TZOFFSETTO:+0900");
});
it("says nothing at all about a zone the browser does not know", () => {
// Rather than writing a definition made up out of nothing. The TZID stays
// on the event, which is where it was before any of this.
const l = lines([inZone("a", "Mars/Olympus_Mons")]);
expect(l.filter((x) => x === "BEGIN:VTIMEZONE")).toHaveLength(0);
expect(l).toContain("DTSTART;TZID=Mars/Olympus_Mons:20260902T090000");
});
it("puts the definitions before the events that use them", () => {
const l = lines([inZone("a", "Europe/Berlin")]);
expect(l.indexOf("BEGIN:VTIMEZONE")).toBeLessThan(l.indexOf("BEGIN:VEVENT"));
});
});
+54
View File
@@ -0,0 +1,54 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { startIdleLogout, stopIdleLogout, IDLE_TIMEOUT_MS } from "@/lib/idleLogout";
describe("idle sign-out on an untrusted device", () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => {
stopIdleLogout();
vi.useRealTimers();
});
it("signs out after five minutes of nothing happening", () => {
const expire = vi.fn();
startIdleLogout(expire);
expect(IDLE_TIMEOUT_MS).toBe(5 * 60 * 1000);
vi.advanceTimersByTime(IDLE_TIMEOUT_MS - 1);
expect(expire).not.toHaveBeenCalled();
vi.advanceTimersByTime(1);
expect(expire).toHaveBeenCalledTimes(1);
});
it("starts the clock again on any sign of a person", () => {
const expire = vi.fn();
startIdleLogout(expire);
vi.advanceTimersByTime(IDLE_TIMEOUT_MS - 1000);
window.dispatchEvent(new Event("keydown"));
vi.advanceTimersByTime(IDLE_TIMEOUT_MS - 1000);
expect(expire).not.toHaveBeenCalled();
vi.advanceTimersByTime(1000);
expect(expire).toHaveBeenCalledTimes(1);
});
it("fires once, not repeatedly, and stops listening afterwards", () => {
const expire = vi.fn();
startIdleLogout(expire);
vi.advanceTimersByTime(IDLE_TIMEOUT_MS * 3);
expect(expire).toHaveBeenCalledTimes(1);
// A late event must not resurrect a timer for a session that has ended.
window.dispatchEvent(new Event("keydown"));
vi.advanceTimersByTime(IDLE_TIMEOUT_MS * 2);
expect(expire).toHaveBeenCalledTimes(1);
});
it("stops cleanly, so a trusted sign-in is never signed out", () => {
const expire = vi.fn();
startIdleLogout(expire);
stopIdleLogout();
vi.advanceTimersByTime(IDLE_TIMEOUT_MS * 2);
expect(expire).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,94 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { isTextEntry, keyboard } from "@/lib/keyboard";
/*
* Shortcuts after a click on a checkbox (#260).
*
* The guard that stops "a" archiving while you are typing into the search box
* tested `tagName === "INPUT"`, which is also true of a checkbox. A checkbox
* keeps focus after a click, so ticking "select all" disabled every shortcut
* until the reader clicked somewhere else and nothing about a checkbox
* swallows a keystroke in the first place.
*/
const pressFrom = (el: Element, key: string) => {
const e = new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true });
el.dispatchEvent(e);
return e;
};
let pop: (() => void) | null = null;
afterEach(() => {
pop?.();
pop = null;
document.body.innerHTML = "";
vi.restoreAllMocks();
});
describe("isTextEntry", () => {
const input = (type?: string) => {
const el = document.createElement("input");
if (type) el.setAttribute("type", type);
return el;
};
it("is false for the inputs you cannot type into", () => {
for (const type of ["checkbox", "radio", "button", "submit", "reset", "file", "color", "range"]) {
expect(isTextEntry(input(type)), type).toBe(false);
}
});
it("is true for the ones you can", () => {
for (const type of ["text", "search", "email", "url", "tel", "password", "number", "date", "time"]) {
expect(isTextEntry(input(type)), type).toBe(true);
}
});
it("treats an input with no type as text, which is what the browser does", () => {
expect(isTextEntry(input())).toBe(true);
});
it("covers textarea, select and contenteditable", () => {
expect(isTextEntry(document.createElement("textarea"))).toBe(true);
// A select takes letters too: typing jumps to the matching option, and a
// shortcut would steal that.
expect(isTextEntry(document.createElement("select"))).toBe(true);
const div = document.createElement("div");
div.contentEditable = "true";
Object.defineProperty(div, "isContentEditable", { value: true });
expect(isTextEntry(div)).toBe(true);
});
it("is false for a button and for nothing at all", () => {
expect(isTextEntry(document.createElement("button"))).toBe(false);
expect(isTextEntry(null)).toBe(false);
});
});
describe("shortcuts with a checkbox focused", () => {
it("still fire — the reported bug", () => {
const handler = vi.fn();
pop = keyboard.pushScope("test", [{ keys: "e", description: "Archive", group: "Mail", handler }]);
const box = document.createElement("input");
box.type = "checkbox";
document.body.appendChild(box);
box.focus();
pressFrom(box, "e");
expect(handler).toHaveBeenCalledTimes(1);
});
it("still do not fire from a text field", () => {
const handler = vi.fn();
pop = keyboard.pushScope("test", [{ keys: "e", description: "Archive", group: "Mail", handler }]);
const field = document.createElement("input");
field.type = "search";
document.body.appendChild(field);
field.focus();
pressFrom(field, "e");
expect(handler).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,101 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { keyboard } from "@/lib/keyboard";
/*
* Two-key sequences against the single keys they start with.
*
* "Go to folder" is `g o` while `o` on its own opens a conversation (#233), so
* the whole feature rests on a pending prefix being tried before a bare key.
* That was true when it was written and nothing said so out loud, which is the
* kind of thing a later refactor quietly reverses.
*/
const press = (key: string) => {
const e = new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true });
window.dispatchEvent(e);
return e;
};
let pop: (() => void) | null = null;
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
pop?.();
pop = null;
vi.useRealTimers();
vi.restoreAllMocks();
});
describe("a sequence sharing its second key with a single binding", () => {
it("runs the sequence, not the single key", () => {
const seq = vi.fn();
const single = vi.fn();
pop = keyboard.pushScope("t", [
{ keys: "g o", description: "Go to folder", group: "Navigation", handler: seq },
{ keys: "o", description: "Open", group: "Mail", handler: single },
]);
press("g");
press("o");
expect(seq).toHaveBeenCalledOnce();
expect(single).not.toHaveBeenCalled();
});
it("runs the single key when no prefix is pending", () => {
const seq = vi.fn();
const single = vi.fn();
pop = keyboard.pushScope("t", [
{ keys: "g o", description: "Go to folder", group: "Navigation", handler: seq },
{ keys: "o", description: "Open", group: "Mail", handler: single },
]);
press("o");
expect(single).toHaveBeenCalledOnce();
expect(seq).not.toHaveBeenCalled();
});
it("forgets the prefix after a pause, so a later key means itself again", () => {
const seq = vi.fn();
const single = vi.fn();
pop = keyboard.pushScope("t", [
{ keys: "g o", description: "Go to folder", group: "Navigation", handler: seq },
{ keys: "o", description: "Open", group: "Mail", handler: single },
]);
press("g");
vi.advanceTimersByTime(2000);
press("o");
expect(seq).not.toHaveBeenCalled();
expect(single).toHaveBeenCalledOnce();
});
it("swallows the prefix rather than letting it act on its own", () => {
// `g` is not a binding by itself; pressing it must not fall through to
// anything, or holding it would type into the page.
const seq = vi.fn();
pop = keyboard.pushScope("t", [
{ keys: "g o", description: "Go to folder", group: "Navigation", handler: seq },
]);
const e = press("g");
expect(e.defaultPrevented).toBe(true);
expect(seq).not.toHaveBeenCalled();
});
it("lets a key that completes no sequence still act as itself", () => {
/*
* `g` then `z`, where `g z` is nothing. The prefix is dropped and `z` runs
* on that same press rather than being eaten so a mistyped prefix costs
* the prefix and not the keystroke after it.
*/
const seq = vi.fn();
const single = vi.fn();
pop = keyboard.pushScope("t", [
{ keys: "g o", description: "Go to folder", group: "Navigation", handler: seq },
{ keys: "z", description: "Zed", group: "Mail", handler: single },
]);
press("g");
press("z");
expect(seq).not.toHaveBeenCalled();
expect(single).toHaveBeenCalledOnce();
});
});
+109
View File
@@ -0,0 +1,109 @@
import { describe, expect, it } from "vitest";
import { labelTree, visibleLabels, descendantKeywords } from "@/lib/labelTree";
import type { Label } from "@/store/settings";
const L = (keyword: string, over: Partial<Label> = {}): Label => ({ keyword, name: keyword, color: "#000", ...over });
const flat = (labels: Label[], counts: Record<string, number> = {}) =>
visibleLabels(labelTree(labels, counts)).map((n) => `${" ".repeat(n.depth)}${n.label.keyword}`);
describe("labelTree", () => {
it("nests a label under its parent and indents it", () => {
const roots = labelTree([L("work"), L("work_urgent", { parent: "work" })]);
expect(roots).toHaveLength(1);
expect(roots[0]!.label.keyword).toBe("work");
expect(roots[0]!.children[0]!.label.keyword).toBe("work_urgent");
expect(roots[0]!.children[0]!.depth).toBe(1);
});
it("nests three deep", () => {
expect(flat([L("a"), L("b", { parent: "a" }), L("c", { parent: "b" })])).toEqual(["a", " b", " c"]);
});
it("puts a label back at the top when its parent no longer exists", () => {
// Settings sync between devices; a parent can be deleted on one while
// another still points at it. Dropping the child would lose it for good.
expect(flat([L("orphan", { parent: "gone" })])).toEqual(["orphan"]);
});
it("survives a cycle rather than hanging", () => {
const out = flat([L("a", { parent: "b" }), L("b", { parent: "a" })]);
expect(out).toHaveLength(2);
expect(out.map((s) => s.trim()).sort()).toEqual(["a", "b"]);
});
it("survives a label parented to itself", () => {
expect(flat([L("a", { parent: "a" })])).toEqual(["a"]);
});
it("carries each label's own unread count, not its children's", () => {
const roots = labelTree([L("a"), L("b", { parent: "a" })], { a: 2, b: 5 });
expect(roots[0]!.unread).toBe(2);
expect(roots[0]!.children[0]!.unread).toBe(5);
});
});
describe("visibleLabels", () => {
it("draws everything set to always", () => {
expect(flat([L("a"), L("b")])).toEqual(["a", "b"]);
});
it("never draws a hidden label", () => {
expect(flat([L("a"), L("b", { visibility: "hidden" })], { b: 9 })).toEqual(["a"]);
});
it("draws an unread-only label just while it has unread mail", () => {
const labels = [L("a", { visibility: "unread" })];
expect(flat(labels, { a: 0 })).toEqual([]);
expect(flat(labels, { a: 1 })).toEqual(["a"]);
});
it("keeps a parent that would otherwise be dropped, when a child survives", () => {
// A child cannot be drawn under a parent that is not there, and promoting
// it would silently rearrange the tree. The parent comes back as a
// container instead.
const labels = [L("work", { visibility: "unread" }), L("work_urgent", { parent: "work" })];
expect(flat(labels, { work: 0 })).toEqual(["work", " work_urgent"]);
});
it("keeps a hidden parent too, when a child survives", () => {
const labels = [L("work", { visibility: "hidden" }), L("work_urgent", { parent: "work" })];
expect(flat(labels, {})).toEqual(["work", " work_urgent"]);
});
it("drops a whole branch when nothing in it survives", () => {
const labels = [
L("work", { visibility: "unread" }),
L("work_urgent", { parent: "work", visibility: "unread" }),
L("other"),
];
expect(flat(labels, { work: 0, work_urgent: 0 })).toEqual(["other"]);
});
it("keeps a grandparent when only a grandchild survives", () => {
const labels = [
L("a", { visibility: "hidden" }),
L("b", { parent: "a", visibility: "hidden" }),
L("c", { parent: "b" }),
];
expect(flat(labels, {})).toEqual(["a", " b", " c"]);
});
it("treats a label with no visibility set as always, so old settings parse unchanged", () => {
const l = L("a");
expect(l.visibility).toBeUndefined();
expect(flat([l], {})).toEqual(["a"]);
});
});
describe("descendantKeywords", () => {
it("names everything below a label, so the parent picker cannot offer a cycle", () => {
const roots = labelTree([L("a"), L("b", { parent: "a" }), L("c", { parent: "b" }), L("d")]);
expect([...descendantKeywords(roots, "a")].sort()).toEqual(["b", "c"]);
expect([...descendantKeywords(roots, "d")]).toEqual([]);
});
it("says nothing about a label that is not there", () => {
expect([...descendantKeywords(labelTree([L("a")]), "missing")]).toEqual([]);
});
});
+60
View File
@@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest";
import { DEFAULT_UI_LANGUAGE, UI_LANGUAGES, resolveUiLanguage } from "@/lib/languages";
import { DEFAULT_SETTINGS, acceptRemote } from "@/store/settings";
/**
* The interface language decides what `<html lang>` claims, and a wrong claim
* is exactly what makes Chrome offer to translate a page that needs no
* translating which is the offer that ends in a rewritten DOM and a crashed
* component tree. So the resolution is deliberately narrow.
*/
describe("resolveUiLanguage", () => {
it("is English when nothing has been chosen", () => {
// The absent case covers both a new account and every settings file
// written before this setting existed.
expect(resolveUiLanguage(undefined)).toBe("en");
expect(resolveUiLanguage(null)).toBe("en");
expect(resolveUiLanguage("")).toBe("en");
expect(DEFAULT_SETTINGS.uiLanguage).toBe(DEFAULT_UI_LANGUAGE);
});
it("refuses a language whose strings are not shipped", () => {
// The account travels between machines and can outlive a catalogue. A
// page that says lang="fr" while rendering English is worse than one that
// admits to English: it stops the reader translating it themselves.
// Derived rather than named, so shipping another language does not turn
// this into a failing test that is really just out of date.
const unshipped = ["cy", "is", "mt", "eu"].find((tag) => !UI_LANGUAGES.some((l) => l.tag === tag))!;
expect(resolveUiLanguage(unshipped)).toBe("en");
expect(resolveUiLanguage("xx-XX")).toBe("en");
});
it("carries the Beta flag until a person has signed the language off", () => {
// Not a completeness measure. A catalogue can be word-for-word finished
// and still read like a machine wrote it, which is what this marks.
// Every shipped language except English is unreviewed, and stays marked
// until a person says otherwise.
for (const l of UI_LANGUAGES) {
if (l.tag === "en") expect(l.beta).toBeUndefined();
else expect(l.beta).toBe(true);
}
});
it("honours one that is", () => {
for (const l of UI_LANGUAGES) expect(resolveUiLanguage(l.tag)).toBe(l.tag);
});
it("only offers languages that resolve to themselves", () => {
// Guards the ordering mistake: adding a picker entry before its catalogue.
for (const l of UI_LANGUAGES) {
expect(resolveUiLanguage(l.tag)).toBe(l.tag);
expect(l.name.trim()).not.toBe("");
}
});
it("follows the account rather than the device", () => {
// Language is a preference about the person, not the screen: it is not in
// DEVICE_KEYS, so it rides in the settings file like the rest.
expect(acceptRemote({ uiLanguage: "en" })).toEqual({ uiLanguage: "en" });
});
});
+105
View File
@@ -0,0 +1,105 @@
import { describe, expect, it } from "vitest";
import { parseLdif } from "@/lib/ldif";
/** The example from issue #174, as SOGo exports it -- lowercased attribute names and all. */
const SOGO = `dn: cn=Jane Doe
objectClass: top
objectClass: inetOrgPerson
objectClass: mozillaAbPersonAlpha
givenName: Jane
description: Description
sn: Doe
cn: Jane Doe
mail: jane.doe@example.com
telephoneNumber: +1-555-0199
mobile: +1-555-0188
mozillahomepostalcode: 10000
c: ExampleCountry
postalcode: 10000
l: Examplecity
mozillahomecountryname: ExampleCountry
mozillahomelocalityname: Examplecity
mozillahomestreet: Street Number
street: Street Number
`;
describe("parseLdif", () => {
it("reads an entry and keeps repeated attributes in file order", () => {
const [r] = parseLdif(SOGO);
expect(r!.dn).toBe("cn=Jane Doe");
expect(r!.attrs.cn).toEqual(["Jane Doe"]);
expect(r!.attrs.objectclass).toEqual(["top", "inetOrgPerson", "mozillaAbPersonAlpha"]);
expect(r!.attrs.mail).toEqual(["[email protected]"]);
});
it("folds attribute names to one case, since exporters disagree", () => {
const [r] = parseLdif("dn: cn=X\nMozillaHomeStreet: One\ntelephonenumber: 2\n");
expect(r!.attrs.mozillahomestreet).toEqual(["One"]);
expect(r!.attrs.telephonenumber).toEqual(["2"]);
});
it("drops attribute options, keeping the attribute", () => {
const [r] = parseLdif("dn: cn=X\nmail;pref: [email protected]\ncn;lang-de: Herr X\n");
expect(r!.attrs.mail).toEqual(["[email protected]"]);
expect(r!.attrs.cn).toEqual(["Herr X"]);
});
it("splits entries on blank lines", () => {
const two = parseLdif("dn: cn=One\ncn: One\n\ndn: cn=Two\ncn: Two\n");
expect(two.map((r) => r.attrs.cn?.[0])).toEqual(["One", "Two"]);
});
it("starts a new entry at a dn even without a blank line between", () => {
const two = parseLdif("dn: cn=One\ncn: One\ndn: cn=Two\ncn: Two\n");
expect(two).toHaveLength(2);
expect(two[1]!.attrs.cn).toEqual(["Two"]);
});
it("unfolds a value continued on the next line", () => {
const [r] = parseLdif("dn: cn=X\ndescription: this note runs on\n and on\n");
expect(r!.attrs.description).toEqual(["this note runs on and on"]);
});
it("decodes a base64 value, including one that is not ASCII", () => {
// "Zoë Müller" in UTF-8, base64.
const b64 = Buffer.from("Zoë Müller", "utf8").toString("base64");
const [r] = parseLdif(`dn: cn=X\ncn:: ${b64}\n`);
expect(r!.attrs.cn).toEqual(["Zoë Müller"]);
});
it("drops a value that will not decode rather than the whole import", () => {
const [r] = parseLdif("dn: cn=X\ncn: Real Name\ndescription:: !!!not base64!!!\n");
expect(r!.attrs.cn).toEqual(["Real Name"]);
expect(r!.attrs.description).toBeUndefined();
});
it("skips a URL reference, which a browser reading one file cannot follow", () => {
const [r] = parseLdif("dn: cn=X\ncn: X\njpegPhoto:< file:///photos/x.jpg\n");
expect(r!.attrs.jpegphoto).toBeUndefined();
expect(r!.attrs.cn).toEqual(["X"]);
});
it("ignores comments and the version header", () => {
const rs = parseLdif("version: 1\n# exported by something\n# a comment\n that folds\n\ndn: cn=X\ncn: X\n");
expect(rs).toHaveLength(1);
expect(rs[0]!.attrs.version).toBeUndefined();
});
it("keeps an add change record and drops the rest", () => {
const rs = parseLdif(
"dn: cn=Kept\nchangetype: add\ncn: Kept\n\ndn: cn=Gone\nchangetype: modify\ncn: Gone\n\ndn: cn=Also gone\nchangetype: delete\n",
);
expect(rs.map((r) => r.attrs.cn?.[0])).toEqual(["Kept"]);
});
it("returns nothing for a file that is not LDIF at all", () => {
expect(parseLdif("this is a shopping list\nmilk\n")).toEqual([]);
expect(parseLdif("")).toEqual([]);
});
it("survives CRLF, which is what a file from Windows arrives as", () => {
const [r] = parseLdif("dn: cn=X\r\ncn: X\r\nsn: Y\r\n");
expect(r!.attrs.cn).toEqual(["X"]);
expect(r!.attrs.sn).toEqual(["Y"]);
});
});
+104
View File
@@ -0,0 +1,104 @@
import { describe, expect, it } from "vitest";
import { rowClick, type RowClick } from "@/lib/listSelection";
const IDS = ["a", "b", "c", "d", "e"];
const click = (over: Partial<Parameters<typeof rowClick>[0]> = {}): RowClick =>
rowClick({
rowId: "c", ids: IDS, anchor: null, selected: {},
modifiers: { shift: false, ctrl: false }, isMobile: false,
...over,
});
describe("a plain click", () => {
it("opens the message rather than selecting it", () => {
expect(click()).toEqual({ kind: "open" });
});
it("opens it even when another message is already open", () => {
expect(click({ anchor: "a" })).toEqual({ kind: "open" });
});
it("goes on selecting on a touchscreen once a selection exists", () => {
// There is no modifier to hold on a phone, and opening a message in the
// middle of picking several is almost never what the tap meant.
expect(click({ isMobile: true, selected: { a: true } })).toEqual({ kind: "select", ids: ["c"], on: true, moveAnchor: true });
});
it("still opens on a touchscreen when nothing is selected", () => {
expect(click({ isMobile: true })).toEqual({ kind: "open" });
});
});
describe("ctrl-clicking", () => {
it("takes the message that was already current with it", () => {
// Issue #186: this used to select only the row clicked, leaving the open
// message highlighted but unticked, so actions applied to one of two.
expect(click({ anchor: "a", modifiers: { shift: false, ctrl: true } }))
.toEqual({ kind: "select", ids: ["a", "c"], on: true, moveAnchor: true });
});
it("toggles one row once there is a selection, and leaves the rest alone", () => {
expect(click({ anchor: "a", selected: { a: true, c: true }, modifiers: { shift: false, ctrl: true } }))
.toEqual({ kind: "select", ids: ["c"], on: false, moveAnchor: true });
expect(click({ anchor: "a", selected: { a: true }, modifiers: { shift: false, ctrl: true } }))
.toEqual({ kind: "select", ids: ["c"], on: true, moveAnchor: true });
});
it("selects just the row when there is nothing current to bring along", () => {
expect(click({ anchor: null, modifiers: { shift: false, ctrl: true } }))
.toEqual({ kind: "select", ids: ["c"], on: true, moveAnchor: true });
});
it("does not bring along a row that has scrolled out of the list", () => {
// The anchor can name a message from a folder that is no longer shown.
expect(click({ anchor: "gone", modifiers: { shift: false, ctrl: true } }))
.toEqual({ kind: "select", ids: ["c"], on: true, moveAnchor: true });
});
it("does not pair a row with itself", () => {
expect(click({ rowId: "a", anchor: "a", modifiers: { shift: false, ctrl: true } }))
.toEqual({ kind: "select", ids: ["a"], on: true, moveAnchor: true });
});
});
describe("shift-clicking", () => {
it("takes the whole run, including the row it started from", () => {
expect(click({ rowId: "d", anchor: "b", modifiers: { shift: true, ctrl: false } }))
.toEqual({ kind: "select", ids: ["b", "c", "d"], on: true, moveAnchor: false });
});
it("works the same way backwards", () => {
expect(click({ rowId: "b", anchor: "d", modifiers: { shift: true, ctrl: false } }))
.toEqual({ kind: "select", ids: ["b", "c", "d"], on: true, moveAnchor: false });
});
it("leaves the anchor where it is, so the range grows from one place", () => {
const first = click({ rowId: "c", anchor: "a", modifiers: { shift: true, ctrl: false } });
expect(first).toMatchObject({ moveAnchor: false });
// Extending again still starts at "a" rather than at "c".
expect(click({ rowId: "e", anchor: "a", modifiers: { shift: true, ctrl: false } }))
.toMatchObject({ ids: ["a", "b", "c", "d", "e"] });
});
it("falls back to opening when there is nothing to extend from", () => {
expect(click({ anchor: null, modifiers: { shift: true, ctrl: false } })).toEqual({ kind: "open" });
});
it("falls back when the anchor is no longer in the list", () => {
expect(click({ anchor: "gone", modifiers: { shift: true, ctrl: false } })).toEqual({ kind: "open" });
});
});
describe("the two rules agree with each other", () => {
it("both include the row the selection started from", () => {
// The bug was that only one of them did. Whatever else changes, a modifier
// click that begins a selection has to contain the anchor.
const withCtrl = click({ rowId: "d", anchor: "b", modifiers: { shift: false, ctrl: true } });
const withShift = click({ rowId: "d", anchor: "b", modifiers: { shift: true, ctrl: false } });
for (const result of [withCtrl, withShift]) {
expect(result.kind, JSON.stringify(result)).toBe("select");
expect((result as { ids: string[] }).ids).toContain("b");
expect((result as { ids: string[] }).ids).toContain("d");
}
});
});
+138
View File
@@ -0,0 +1,138 @@
import { describe, expect, it } from "vitest";
import { appliesTo, comparatorsFor, isOptionalSort, MAX_LEVELS, withoutOptionalSorts } from "@/lib/listSort";
describe("comparatorsFor, presets", () => {
it("puts newest first by default, and can reverse it", () => {
expect(comparatorsFor("newest")).toEqual([{ property: "receivedAt", isAscending: false }]);
// No tiebreak appended: receivedAt already *is* the tiebreaker, and adding
// a contradictory second one after it would say nothing.
expect(comparatorsFor("oldest")).toEqual([{ property: "receivedAt", isAscending: true }]);
});
it("sorts unread first as $seen ASCENDING, because false sorts before true", () => {
// Getting this backwards puts exactly the mail you were looking for at the
// bottom, which is why it is asserted rather than assumed.
expect(comparatorsFor("unreadFirst")[0]).toEqual({ property: "hasKeyword", keyword: "$seen", isAscending: true });
});
it("sorts starred first as $flagged DESCENDING, which is the other way round", () => {
expect(comparatorsFor("starredFirst")[0]).toEqual({ property: "hasKeyword", keyword: "$flagged", isAscending: false });
});
it("handles the plain field presets", () => {
expect(comparatorsFor("largest")[0]).toEqual({ property: "size", isAscending: false });
expect(comparatorsFor("sender")[0]).toEqual({ property: "from", isAscending: true });
expect(comparatorsFor("subject")[0]).toEqual({ property: "subject", isAscending: true });
});
it("falls back to newest for a preset it does not know", () => {
expect(comparatorsFor("nonsense" as never)).toEqual([{ property: "receivedAt", isAscending: false }]);
});
});
describe("comparatorsFor, the tiebreak", () => {
it("always ends newest-first, so a tie does not shuffle between loads", () => {
for (const p of ["unreadFirst", "starredFirst", "largest", "sender", "subject"] as const) {
const out = comparatorsFor(p);
expect(out[out.length - 1]).toEqual({ property: "receivedAt", isAscending: false });
}
});
it("does not add a second one when the sort already ends on receivedAt", () => {
expect(comparatorsFor("newest")).toHaveLength(1);
expect(comparatorsFor("oldest")).toHaveLength(1);
expect(comparatorsFor("custom", [{ field: "date", descending: false }])).toHaveLength(1);
});
});
describe("comparatorsFor, custom levels", () => {
it("keeps the levels in the order given", () => {
const out = comparatorsFor("custom", [
{ field: "starred", descending: true },
{ field: "unread", descending: true },
]);
expect(out).toEqual([
{ property: "hasKeyword", keyword: "$flagged", isAscending: false },
{ property: "hasKeyword", keyword: "$seen", isAscending: true },
{ property: "receivedAt", isAscending: false },
]);
});
it("takes at most three, since past that nobody can predict the result", () => {
const out = comparatorsFor("custom", [
{ field: "starred", descending: true },
{ field: "unread", descending: true },
{ field: "from", descending: false },
{ field: "size", descending: true },
]);
expect(out.filter((c) => c.property === "size")).toEqual([]);
expect(out).toHaveLength(MAX_LEVELS + 1); // three levels plus the tiebreak
});
it("drops a field repeated at two levels, which can only be a mistake", () => {
const out = comparatorsFor("custom", [
{ field: "from", descending: false },
{ field: "from", descending: true },
]);
expect(out).toEqual([
{ property: "from", isAscending: true },
{ property: "receivedAt", isAscending: false },
]);
});
it("falls back to the tiebreak alone when no levels were given", () => {
expect(comparatorsFor("custom", [])).toEqual([{ property: "receivedAt", isAscending: false }]);
});
it("reverses a date level without losing the tiebreak", () => {
const out = comparatorsFor("custom", [{ field: "sent", descending: false }]);
expect(out).toEqual([
{ property: "sentAt", isAscending: true },
{ property: "receivedAt", isAscending: false },
]);
});
});
describe("optional sorts, which a server is allowed to refuse", () => {
it("recognises the keyword properties", () => {
expect(isOptionalSort({ property: "hasKeyword", keyword: "$seen" })).toBe(true);
expect(isOptionalSort({ property: "someInThreadHaveKeyword", keyword: "$flagged" })).toBe(true);
expect(isOptionalSort({ property: "receivedAt" })).toBe(false);
expect(isOptionalSort({ property: "size" })).toBe(false);
});
it("strips them, leaving something the server must accept", () => {
const out = withoutOptionalSorts(comparatorsFor("custom", [
{ field: "unread", descending: true },
{ field: "size", descending: true },
]));
expect(out).toEqual([
{ property: "size", isAscending: false },
{ property: "receivedAt", isAscending: false },
]);
});
it("still ends on the tiebreak when stripping removed everything else", () => {
expect(withoutOptionalSorts(comparatorsFor("unreadFirst"))).toEqual([{ property: "receivedAt", isAscending: false }]);
});
it("leaves a sort that was never optional alone", () => {
const plain = comparatorsFor("largest");
expect(withoutOptionalSorts(plain)).toEqual(plain);
});
});
describe("appliesTo", () => {
it("covers only the inbox on the narrow scope", () => {
// Unread-first is what people want where they triage, and confusing in
// Sent, where everything is read.
expect(appliesTo("inbox", "inbox")).toBe(true);
expect(appliesTo("inbox", "sent")).toBe(false);
expect(appliesTo("inbox", null)).toBe(false);
});
it("covers everything on the wide one", () => {
expect(appliesTo("all", "sent")).toBe(true);
expect(appliesTo("all", null)).toBe(true);
});
});
+68
View File
@@ -0,0 +1,68 @@
import { afterEach, describe, expect, it } from "vitest";
import { isLocalisedName, mailboxDisplayName, mailboxDisplayPath } from "@/lib/mailboxName";
import { setCatalog, type Catalog } from "@/lib/i18n";
import type { Mailbox } from "@/jmap/types";
/**
* Stalwart names the standard folders once, at account creation, and never
* renames them so a German reader on an English-provisioned account would
* otherwise see "Deleted Items" in an otherwise German app. The role is what
* lets ihasmail say "Papierkorb" without writing anything to the server.
*/
const de: Catalog = {
strings: { Inbox: "Posteingang", "Deleted Items": "Papierkorb", Drafts: "Entwürfe" },
plurals: {},
};
const mb = (id: string, name: string, role: string | null = null, parentId: string | null = null) =>
({ id, name, role, parentId } as unknown as Mailbox);
afterEach(() => setCatalog("en", { strings: {}, plurals: {} }));
describe("mailboxDisplayName", () => {
it("is the server's name until a catalogue says otherwise", () => {
expect(mailboxDisplayName(mb("1", "Deleted Items", "trash"))).toBe("Deleted Items");
});
it("follows the interface language for a folder carrying a role", () => {
setCatalog("de", de);
expect(mailboxDisplayName(mb("1", "Deleted Items", "trash"))).toBe("Papierkorb");
expect(mailboxDisplayName(mb("2", "Inbox", "inbox"))).toBe("Posteingang");
});
it("leaves a folder somebody made alone", () => {
// "Newsletters" is their word. Translating it would name a folder they
// never created, and it would not match what any other client shows.
setCatalog("de", de);
expect(mailboxDisplayName(mb("3", "Newsletters"))).toBe("Newsletters");
expect(mailboxDisplayName(mb("4", "Work", "subscribed"))).toBe("Work");
});
it("survives a missing mailbox rather than printing undefined", () => {
expect(mailboxDisplayName(null)).toBe("");
expect(mailboxDisplayName(undefined)).toBe("");
});
});
describe("isLocalisedName", () => {
it("tells an editor when the name on screen is not the server's", () => {
// A rename box prefilled with "Papierkorb" would rename the folder to that
// the moment somebody pressed Save — a real change made by accident.
expect(isLocalisedName(mb("1", "Deleted Items", "trash"))).toBe(true);
expect(isLocalisedName(mb("2", "Newsletters"))).toBe(false);
expect(isLocalisedName(mb("3", "Work", "subscribed"))).toBe(false);
});
});
describe("mailboxDisplayPath", () => {
it("localises each part that has a role and leaves the rest", () => {
setCatalog("de", de);
const all = { a: mb("a", "Inbox", "inbox"), b: mb("b", "Projects", null, "a") };
expect(mailboxDisplayPath(all.b!, all)).toBe("Posteingang / Projects");
});
it("stops rather than looping on a parent cycle", () => {
// A malformed tree from the server must not hang the folder picker.
const all: Record<string, Mailbox> = { a: mb("a", "A", null, "b"), b: mb("b", "B", null, "a") };
expect(mailboxDisplayPath(all.a!, all)).toBe("B / A");
});
});
+75
View File
@@ -0,0 +1,75 @@
import { describe, expect, it } from "vitest";
import { isMarkdown, renderMarkdown } from "@/lib/markdown";
describe("isMarkdown", () => {
it("takes the type when there is one", () => {
expect(isMarkdown("text/markdown", "a")).toBe(true);
expect(isMarkdown("text/x-markdown; charset=utf-8", "a")).toBe(true);
expect(isMarkdown("text/plain", "notes.txt")).toBe(false);
});
it("falls back to the name, which is the usual case for an upload", () => {
expect(isMarkdown("application/octet-stream", "README.md")).toBe(true);
expect(isMarkdown("application/octet-stream", "NOTES.MARKDOWN")).toBe(true);
expect(isMarkdown(null, "changelog.mkd")).toBe(true);
expect(isMarkdown(null, "readme.txt")).toBe(false);
expect(isMarkdown(null, null)).toBe(false);
});
});
describe("renderMarkdown", () => {
it("renders the ordinary things", () => {
const html = renderMarkdown("# Title\n\nSome **bold** and `code`.\n\n- one\n- two\n");
expect(html).toContain("<h1");
expect(html).toContain("<strong>bold</strong>");
expect(html).toContain("<code>code</code>");
expect(html).toContain("<li>one</li>");
});
it("renders GitHub tables and fenced code", () => {
const html = renderMarkdown("| a | b |\n| - | - |\n| 1 | 2 |\n\n```js\nconst x = 1;\n```\n");
expect(html).toContain("<table>");
expect(html).toContain("<pre>");
});
/*
* Markdown passes raw HTML through by design, and the file came from
* somewhere else -- an upload, or a share from another account. Every one of
* these renders as a script tag without a sanitiser.
*/
it("takes out anything that would execute", () => {
const html = renderMarkdown("<script>alert(1)</script>\n\n<img src=x onerror=alert(1)>\n\n<iframe src='https://evil.example'></iframe>\n");
expect(html).not.toContain("<script");
expect(html).not.toContain("onerror");
expect(html).not.toContain("<iframe");
});
it("does not keep a javascript: link", () => {
const html = renderMarkdown("[click](javascript:alert(1))");
expect(html).not.toContain("javascript:");
});
it("shows an image as a link instead of fetching it", () => {
// A remote image in a file is a tracking pixel by another name; this app
// blocks those in mail and does not undo that here.
const html = renderMarkdown("![a diagram](https://tracker.example/px.png)");
expect(html).not.toContain("<img");
expect(html).toContain('class="md-img"');
expect(html).toContain("a diagram");
expect(html).toContain("https://tracker.example/px.png");
});
it("keeps a relative image visible even though it cannot resolve", () => {
const html = renderMarkdown("![local](./diagram.png)");
expect(html).not.toContain("<img");
expect(html).toContain("local");
// Nothing to link to, so it is text rather than a dead link.
expect(html).not.toContain('href="./diagram.png"');
});
it("sends links out of the app safely", () => {
const html = renderMarkdown("[docs](https://docs.ihasmail.org)");
expect(html).toContain('rel="noopener noreferrer"');
expect(html).toContain('target="_blank"');
});
});
+147
View File
@@ -0,0 +1,147 @@
import { describe, expect, it } from "vitest";
import { parseLdif } from "@/lib/ldif";
import { cardFromLdif } from "@/lib/mozillaAb";
import type { ContactCard } from "@/jmap/types";
const card = (ldif: string) => cardFromLdif(parseLdif(ldif)[0]!);
const values = <T,>(m: Record<string, T> | undefined) => Object.values(m ?? {});
/** Address components as `kind: value`, which is easier to assert than the array. */
const parts = (a: NonNullable<ContactCard["addresses"]>[string]) => (a.components ?? []).map((c) => `${c.kind}: ${c.value}`);
/** The entry from issue #174, exactly as SOGo wrote it. */
const JANE = `dn: cn=Jane Doe
objectClass: top
objectClass: inetOrgPerson
objectClass: mozillaAbPersonAlpha
givenName: Jane
description: Description
sn: Doe
cn: Jane Doe
mail: jane.doe@example.com
telephoneNumber: +1-555-0199
mobile: +1-555-0188
mozillahomepostalcode: 10000
c: ExampleCountry
postalcode: 10000
l: Examplecity
mozillahomecountryname: ExampleCountry
mozillahomelocalityname: Examplecity
mozillahomestreet: Street Number
street: Street Number
`;
describe("the entry from the issue", () => {
const c = card(JANE)!;
it("becomes a person with a name", () => {
expect(c.kind).toBe("individual");
expect(c.name?.full).toBe("Jane Doe");
expect(c.name?.components).toEqual([
{ "@type": "NameComponent", kind: "given", value: "Jane" },
{ "@type": "NameComponent", kind: "surname", value: "Doe" },
]);
});
it("keeps the address, marked as the one to use", () => {
const emails = values(c.emails);
expect(emails).toHaveLength(1);
expect(emails[0]).toMatchObject({ address: "[email protected]", pref: 1 });
});
it("tells the work phone from the mobile", () => {
const phones = values(c.phones);
expect(phones).toContainEqual(expect.objectContaining({ number: "+1-555-0199", contexts: { work: true } }));
expect(phones).toContainEqual(expect.objectContaining({ number: "+1-555-0188", features: { mobile: true } }));
});
it("splits the two addresses the schema keeps apart", () => {
const addrs = values(c.addresses);
expect(addrs).toHaveLength(2);
const work = addrs.find((a) => a.contexts?.work)!;
const home = addrs.find((a) => a.contexts?.private)!;
expect(parts(work)).toEqual(["name: Street Number", "locality: Examplecity", "postcode: 10000", "country: ExampleCountry"]);
expect(parts(home)).toEqual(["name: Street Number", "locality: Examplecity", "postcode: 10000", "country: ExampleCountry"]);
});
it("keeps the description as the note", () => {
expect(values(c.notes)[0]?.note).toBe("Description");
});
});
describe("the rest of the schema", () => {
it("reads the second email, after the first", () => {
const c = card("dn: cn=X\nmail: [email protected]\nmozillaSecondEmail: [email protected]\n")!;
const emails = values(c.emails);
expect(emails.map((e) => e.address)).toEqual(["[email protected]", "[email protected]"]);
expect(emails[0]!.pref).toBe(1);
expect(emails[1]!.pref).toBeUndefined();
});
it("reads every kind of phone the schema has", () => {
const c = card("dn: cn=X\ncn: X\nhomePhone: 1\nfacsimileTelephoneNumber: 2\npager: 3\n")!;
const phones = values(c.phones);
expect(phones).toContainEqual(expect.objectContaining({ number: "1", contexts: { private: true } }));
expect(phones).toContainEqual(expect.objectContaining({ number: "2", features: { fax: true } }));
expect(phones).toContainEqual(expect.objectContaining({ number: "3", features: { pager: true } }));
});
it("reads the organisation, its units and the job title", () => {
const c = card("dn: cn=X\ncn: X\no: Example Corp\nou: Research\nou: Optics\ntitle: Lens Grinder\n")!;
expect(values(c.organizations)[0]).toMatchObject({
name: "Example Corp",
units: [{ "@type": "OrgUnit", name: "Research" }, { "@type": "OrgUnit", name: "Optics" }],
});
expect(values(c.titles)[0]).toMatchObject({ name: "Lens Grinder", kind: "title" });
});
it("reads the nickname, the web pages and the messaging handle", () => {
const c = card("dn: cn=X\ncn: X\nmozillaNickname: Zed\nmozillaWorkUrl: https://work.example\nmozillaHomeUrl: https://home.example\nnsAIMid: zedzed\n")!;
expect(values(c.nicknames)[0]?.name).toBe("Zed");
expect(values(c.links).map((l) => l.uri)).toEqual(["https://work.example", "https://home.example"]);
expect(values(c.onlineServices)[0]).toMatchObject({ service: "AIM", user: "zedzed" });
});
it("keeps both street lines and the post office box", () => {
const c = card("dn: cn=X\ncn: X\nstreet: 1 Long Road\nmozillaWorkStreet2: Floor 4\npostOfficeBox: PO 12\n")!;
expect(parts(values(c.addresses)[0]!)).toEqual([
"name: 1 Long Road",
"name: Floor 4",
"postOfficeBox: PO 12",
]);
});
it("keeps the custom fields in the note rather than dropping them", () => {
const c = card("dn: cn=X\ncn: X\ndescription: A note\nmozillaCustom1: Met at a conference\nmozillaCustom3: Renewal in May\n")!;
expect(values(c.notes)[0]?.note).toBe("A note\nCustom 1: Met at a conference\nCustom 3: Renewal in May");
});
it("prefers the directory's own rendering of a name when it differs", () => {
// "Doe, Jane" is not what the parts put back together, and is what the
// export meant to display.
const c = card("dn: cn=Doe, Jane\ngivenName: Jane\nsn: Doe\ncn: Doe, Jane\n")!;
expect(c.name?.full).toBe("Doe, Jane");
expect(c.name?.components).toHaveLength(2);
});
it("takes displayName over cn, which is what Thunderbird shows", () => {
const c = card("dn: cn=X\ncn: Robert Smith\ndisplayName: Bob\n")!;
expect(c.name?.full).toBe("Bob");
});
it("manages an entry that is only an address", () => {
const c = card("dn: cn=X\nmail: [email protected]\n")!;
expect(c.name).toBeUndefined();
expect(values(c.emails)[0]?.address).toBe("[email protected]");
});
it("refuses an entry with neither a name nor an address", () => {
expect(card("dn: cn=X\nobjectClass: top\ntelephoneNumber: 1\n")).toBeNull();
});
it("leaves out every section the entry said nothing about", () => {
const c = card("dn: cn=X\ncn: X\n")!;
for (const empty of ["emails", "phones", "addresses", "links", "notes", "organizations", "titles", "nicknames", "onlineServices"] as const) {
expect(c[empty], empty).toBeUndefined();
}
});
});
+86
View File
@@ -0,0 +1,86 @@
import { describe, expect, it } from "vitest";
import { effectiveMode, legacyTheme, migrateTheme, paletteMeta, PALETTES, toggleTarget } from "@/lib/palette";
describe("the palettes themselves", () => {
it("has a light and a dark half for every one of them", () => {
// The reason there is no "this palette is dark only" machinery: there is
// no such palette. ihasmail's own gained a light half, and the override,
// the toggle's memory and a greyed-out control all went with it.
expect(PALETTES.map((p) => p.id)).toEqual(["default", "ihasmail", "dracula", "gruvbox", "rose-pine", "tokyo-night"]);
});
it("credits every borrowed palette and neither of ihasmail's own", () => {
for (const p of PALETTES) {
if (p.id === "default" || p.id === "ihasmail") expect(p.credit).toBeUndefined();
else expect(p.credit).toMatch(/MIT/);
}
});
it("falls back to the default for an id it does not know", () => {
expect(paletteMeta("nonsense").id).toBe("default");
expect(paletteMeta(null).id).toBe("default");
});
});
describe("effectiveMode", () => {
it("follows the system when asked to", () => {
expect(effectiveMode("system", true)).toBe("dark");
expect(effectiveMode("system", false)).toBe("light");
});
it("takes an explicit mode over the system", () => {
expect(effectiveMode("light", true)).toBe("light");
expect(effectiveMode("dark", false)).toBe("dark");
});
});
describe("migrateTheme, which has to keep working indefinitely", () => {
it("reads every value the old enum could hold", () => {
expect(migrateTheme("ihasmail")).toEqual({ palette: "ihasmail", mode: "dark" });
expect(migrateTheme("light")).toEqual({ palette: "default", mode: "light" });
expect(migrateTheme("dark")).toEqual({ palette: "default", mode: "dark" });
expect(migrateTheme("system")).toEqual({ palette: "default", mode: "system" });
});
it("gives a new account what it would have got anyway", () => {
// Absent, unknown, or written by something newer.
for (const v of [undefined, null, "", "gruvbox-ish", "whatever"]) {
expect(migrateTheme(v)).toEqual({ palette: "ihasmail", mode: "dark" });
}
});
});
describe("legacyTheme, read by a device still on an older build", () => {
it("round-trips the four values the old enum had", () => {
for (const v of ["ihasmail", "light", "dark", "system"] as const) {
expect(legacyTheme(migrateTheme(v))).toBe(v);
}
});
it("expresses a new palette as the light or dark it actually is", () => {
// It cannot say "Gruvbox", but it can say dark, which is the half that
// stops an older device showing a theme nobody chose.
expect(legacyTheme({ palette: "gruvbox", mode: "dark" })).toBe("dark");
expect(legacyTheme({ palette: "rose-pine", mode: "light" })).toBe("light");
expect(legacyTheme({ palette: "tokyo-night", mode: "system" }, true)).toBe("dark");
expect(legacyTheme({ palette: "tokyo-night", mode: "system" }, false)).toBe("light");
// ihasmail's light half is new and has no old name, so an older build is
// told "light" rather than being handed a word it would read as dark.
expect(legacyTheme({ palette: "ihasmail", mode: "light" })).toBe("light");
expect(legacyTheme({ palette: "ihasmail", mode: "dark" })).toBe("ihasmail");
});
});
describe("toggleTarget", () => {
it("flips the mode and keeps the colours, whatever the palette", () => {
for (const palette of ["default", "ihasmail", "gruvbox", "dracula", "rose-pine", "tokyo-night"] as const) {
expect(toggleTarget({ palette, mode: "dark" }, false)).toEqual({ palette, mode: "light" });
expect(toggleTarget({ palette, mode: "light" }, false)).toEqual({ palette, mode: "dark" });
}
});
it("reads the system when the mode is system", () => {
expect(toggleTarget({ palette: "default", mode: "system" }, true).mode).toBe("light");
expect(toggleTarget({ palette: "default", mode: "system" }, false).mode).toBe("dark");
});
});
+80
View File
@@ -0,0 +1,80 @@
import { describe, expect, it } from "vitest";
import { openableInTab, previewKind } from "@/lib/preview";
describe("previewKind", () => {
it("goes by the declared type when there is one", () => {
expect(previewKind("image/png", "photo.png")).toBe("image");
expect(previewKind("application/pdf", "invoice.pdf")).toBe("pdf");
expect(previewKind("text/plain", "notes.txt")).toBe("text");
expect(previewKind("text/markdown", "README.md")).toBe("text");
expect(previewKind("application/json", "data.json")).toBe("text");
expect(previewKind("image/png; charset=binary", "photo.png")).toBe("image");
});
it("falls back to the name when the type is a generic wrapper", () => {
// What an upload gets when the browser cannot guess -- files.ts stores
// `f.type || "application/octet-stream"`, so this is the common case for
// anything unusual, and it is what made the old exact-type check useless
// on real uploads.
expect(previewKind("application/octet-stream", "README.md")).toBe("text");
expect(previewKind("application/octet-stream", "shot.PNG")).toBe("image");
expect(previewKind("application/octet-stream", "report.pdf")).toBe("pdf");
expect(previewKind("", "notes.txt")).toBe("text");
expect(previewKind(null, "deploy.sh")).toBe("text");
expect(previewKind(undefined, undefined)).toBeNull();
});
it("does not let the name override a type the server was specific about", () => {
// A .txt served as a zip is a zip. Guessing from the name here would be
// taking the sender's word for the extension over the server's for the
// bytes.
expect(previewKind("application/zip", "archive.txt")).toBeNull();
expect(previewKind("video/mp4", "clip.txt")).toBeNull();
});
it("leaves SVG alone", () => {
// It carries script and the server refuses to serve it inline; it stays a
// download until that is decided deliberately.
expect(previewKind("image/svg+xml", "logo.svg")).toBeNull();
expect(previewKind("application/octet-stream", "logo.svg")).toBeNull();
});
it("has nothing to show for the rest", () => {
expect(previewKind("application/zip", "backup.zip")).toBeNull();
expect(previewKind("application/vnd.openxmlformats-officedocument.wordprocessingml.document", "letter.docx")).toBeNull();
});
});
describe("openableInTab", () => {
/*
* This mirrors `isInlineSafe` in server/src/app.ts. If the two drift, the
* "open in a new tab" button silently starts downloading instead, because
* the server sends Content-Disposition: attachment for anything not on its
* list. These cases are the list.
*/
it("matches what the server will serve inline", () => {
expect(openableInTab("image/png")).toBe(true);
expect(openableInTab("video/mp4")).toBe(true);
expect(openableInTab("audio/mpeg")).toBe(true);
expect(openableInTab("application/pdf")).toBe(true);
expect(openableInTab("text/plain; charset=utf-8")).toBe(true);
expect(openableInTab("text/calendar")).toBe(true);
expect(openableInTab("text/vcard")).toBe(true);
});
it("refuses what the server will not", () => {
expect(openableInTab("image/svg+xml")).toBe(false);
expect(openableInTab("text/html")).toBe(false);
expect(openableInTab("text/markdown")).toBe(false);
expect(openableInTab("application/json")).toBe(false);
expect(openableInTab("application/octet-stream")).toBe(false);
expect(openableInTab(null)).toBe(false);
});
it("is narrower than what we can show ourselves", () => {
// Markdown is the case that proves the two questions are different: the
// dialog reads it with fetch, which ignores Content-Disposition.
expect(previewKind("text/markdown", "README.md")).toBe("text");
expect(openableInTab("text/markdown")).toBe(false);
});
});
@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import { withBase, BASE_PATH } from "@/lib/basePath";
/**
* The verification code a push subscription needs is written by the service
* worker when no tab is open, and collected by the next tab to start. Both
* sides have to name the same cache entry.
*
* A relative key does not do that. It is resolved against the URL of whoever
* is asking: the worker lives at `<base>/sw.js`, so it wrote under `<base>/…`,
* while a tab at `/mail/inbox/abc` looked under `/mail/inbox/…`. They agreed
* only when the open page happened to be the root and a subscription that
* never gets its code back stays silent, which is indistinguishable from push
* simply not working.
*
* These tests pin the shape of the key rather than the plumbing: what matters
* is that it is absolute and anchored to the mount, so it cannot vary with the
* route.
*/
const KEY = "/ihasmail-push-verification";
describe("the push verification cache key", () => {
it("is absolute, so it does not depend on which page is open", () => {
expect(withBase(KEY).startsWith("/")).toBe(true);
});
it("is the same string wherever it is asked for", () => {
// The bug was that this was not true: the page and the worker each
// resolved a relative key against their own URL.
expect(withBase(KEY)).toBe(withBase(KEY));
});
it("is anchored to the mount, which is what the worker anchors to", () => {
// The worker builds `${BASE}/ihasmail-push-verification`, where BASE comes
// from `new URL("./", self.location)` — the same mount this derives from.
expect(withBase(KEY)).toBe(`${BASE_PATH}${KEY}`);
});
it("carries no route in it", () => {
for (const route of ["mail", "inbox", "calendar", "settings"]) {
expect(withBase(KEY)).not.toContain(`/${route}/`);
}
});
});
+56 -4
View File
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import { DEFAULT_SETTINGS, DEVICE_KEYS, acceptRemote, syncedPart, type Settings } from "@/store/settings";
import { DEFAULT_SETTINGS, DEVICE_KEYS, acceptRemote, mergeRemote, syncedPart, type Settings } from "@/store/settings";
import { isAppFolder } from "../appFolder";
import { settingsAlreadyLoadedFor, stopSettingsSync } from "../settingsSync";
/**
* Settings used to live only in localStorage, so nothing followed the user
@@ -42,20 +43,27 @@ describe("which settings follow the account", () => {
});
describe("applying a settings file", () => {
/*
* A file carrying the old `theme` and no palette is read through the old
* enum, so these gain the two fields it resolves to. That is the migration,
* not a leak: see the palette tests for the rule itself.
*/
const MIGRATED_DARK = { theme: "dark", palette: "default", mode: "dark" };
it("takes known, non-device keys", () => {
const applied = acceptRemote({ theme: "dark", weekStart: 0, locale: "de-DE" });
expect(applied).toEqual({ theme: "dark", weekStart: 0, locale: "de-DE" });
expect(applied).toEqual({ ...MIGRATED_DARK, weekStart: 0, locale: "de-DE" });
});
it("ignores keys it has never heard of", () => {
// A newer ihasmail's settings, or a hand-edited file.
expect(acceptRemote({ theme: "dark", somethingNewer: 42 })).toEqual({ theme: "dark" });
expect(acceptRemote({ theme: "dark", somethingNewer: 42 })).toEqual(MIGRATED_DARK);
});
it("refuses device keys even when the file carries them", () => {
// An earlier build wrote the whole settings object up; that file must not
// now drag one machine's pane width onto every other one.
expect(acceptRemote({ theme: "dark", listPaneWidth: 900, fontSize: "large" })).toEqual({ theme: "dark" });
expect(acceptRemote({ theme: "dark", listPaneWidth: 900, fontSize: "large" })).toEqual(MIGRATED_DARK);
});
it("does not invent keys from an empty file", () => {
@@ -81,3 +89,47 @@ describe("the client's own folder", () => {
expect(isAppFolder({ name: "ihasmail", parentId: null, nodeType: "file" })).toBe(false);
});
});
/**
* Picking a language used to come undone.
*
* The subtree that reads the account's settings file is keyed on the language
* version, so choosing a language throws it away and builds it again. The
* remount re-read the file which still held the old language, because the
* write is debounced by three seconds and applied it, putting the old
* language back. Reported as "sometimes it takes several clicks": the click
* that appeared to work was the one made after the previous write had landed.
*/
describe("a change made but not yet written up", () => {
it("is not read back over by the file it has not reached yet", () => {
const current: Settings = { ...DEFAULT_SETTINGS, uiLanguage: "ja" };
const file = { uiLanguage: "en", theme: "dark" };
const merged = mergeRemote(current, file, new Set(["uiLanguage"]));
expect(merged.uiLanguage).toBe("ja");
// Only the queued key is held back; the rest of the file still applies.
expect(merged.theme).toBe("dark");
});
it("applies the whole file when nothing is queued", () => {
const current: Settings = { ...DEFAULT_SETTINGS, uiLanguage: "ja" };
const merged = mergeRemote(current, { uiLanguage: "en" });
expect(merged.uiLanguage).toBe("en");
});
it("reads the file again for an account after a sign-out", () => {
stopSettingsSync();
expect(settingsAlreadyLoadedFor("a1")).toBe(false);
// The remount that a language change causes must not read it a second time.
expect(settingsAlreadyLoadedFor("a1")).toBe(true);
// Signing out drops the claim, so signing back in reads the file rather
// than trusting whatever the previous session left behind.
stopSettingsSync();
expect(settingsAlreadyLoadedFor("a1")).toBe(false);
stopSettingsSync();
});
it("treats a missing account as already loaded, so nothing is fetched", () => {
expect(settingsAlreadyLoadedFor(null)).toBe(true);
expect(settingsAlreadyLoadedFor(undefined)).toBe(true);
});
});
+107
View File
@@ -0,0 +1,107 @@
import { describe, expect, it } from "vitest";
import { spamReport } from "@/lib/spamScore";
const sa = (v: string) => spamReport({ "header:X-Spam-Status:asText": v });
const rs = (v: string) => spamReport({ "header:X-Spamd-Result:asText": v });
describe("spamReport, SpamAssassin-shaped headers", () => {
it("reads the verdict, score, threshold and tests", () => {
const r = sa("Yes, score=6.7 required=5.0 tests=[BAYES_99=3.5, HTML_MESSAGE=0.001, URIBL=2.2] autolearn=no");
expect(r).not.toBeNull();
expect(r!.verdict).toBe("spam");
expect(r!.score).toBe(6.7);
expect(r!.threshold).toBe(5);
expect(r!.source).toBe("spamassassin");
// Biggest mover first, so the reason it was scored reads off the top.
expect(r!.rules.map((x) => x.name)).toEqual(["BAYES_99", "URIBL", "HTML_MESSAGE"]);
});
it("reads a negative score and a clean verdict", () => {
const r = sa("No, score=-2.6 required=5.0 tests=[BAYES_00=-1.9, DKIM_VALID=-0.7]");
expect(r!.verdict).toBe("clean");
expect(r!.score).toBe(-2.6);
expect(r!.rules[0]).toEqual({ name: "BAYES_00", score: -1.9 });
});
it("survives a folded header, which is how they arrive", () => {
const r = sa("Yes, score=6.7\n\trequired=5.0 tests=[BAYES_99=3.5,\n\tURIBL=2.2]");
expect(r!.score).toBe(6.7);
expect(r!.rules).toHaveLength(2);
});
it("keeps a verdict that states no score, and a score that states no verdict", () => {
expect(sa("Yes")!.verdict).toBe("spam");
expect(sa("Yes")!.score).toBeNull();
const scoreOnly = sa("score=1.2 required=5.0");
expect(scoreOnly!.verdict).toBeNull();
expect(scoreOnly!.score).toBe(1.2);
});
it("says nothing when there is nothing it understands", () => {
expect(sa("")).toBeNull();
expect(sa("something else entirely")).toBeNull();
expect(spamReport({})).toBeNull();
});
it("drops a malformed test rather than scoring it as zero", () => {
const r = sa("Yes, score=3.0 tests=[GOOD=1.0, BROKEN=, =2.0, ALSO_GOOD=2.0]");
expect(r!.rules.map((x) => x.name)).toEqual(["ALSO_GOOD", "GOOD"]);
});
});
describe("spamReport, Rspamd", () => {
it("reads the action, score, threshold and rules with their notes", () => {
const r = rs("default: False [1.20 / 15.00]; MIME_GOOD(-0.10)[text/plain]; DKIM_ALLOW(-0.20)[example.com]; SUBJ_CAPS(2.00)[]");
expect(r!.verdict).toBe("clean");
expect(r!.score).toBe(1.2);
expect(r!.threshold).toBe(15);
expect(r!.source).toBe("rspamd");
expect(r!.rules[0]).toEqual({ name: "SUBJ_CAPS", score: 2 });
expect(r!.rules.find((x) => x.name === "DKIM_ALLOW")?.detail).toBe("example.com");
// An empty bracket is not a note.
expect(r!.rules[0]!.detail).toBeUndefined();
});
it("treats the acting verdicts as spam and False as clean", () => {
expect(rs("default: True [20.00 / 15.00];")!.verdict).toBe("spam");
expect(rs("default: reject [20.00 / 15.00];")!.verdict).toBe("spam");
expect(rs("default: add_header [16.00 / 15.00];")!.verdict).toBe("spam");
expect(rs("default: False [1.00 / 15.00];")!.verdict).toBe("clean");
});
it("declines to call greylisting a verdict about the message", () => {
const r = rs("default: greylist [8.00 / 15.00];");
expect(r!.verdict).toBeNull();
expect(r!.score).toBe(8);
});
it("says nothing for a header it cannot read", () => {
expect(rs("")).toBeNull();
expect(rs("default: False")).toBeNull();
});
});
describe("spamReport, precedence and fallback", () => {
it("prefers the SpamAssassin set, which is what Stalwart's own filter writes", () => {
const r = spamReport({
"header:X-Spam-Status:asText": "Yes, score=6.7 required=5.0",
"header:X-Spamd-Result:asText": "default: False [1.20 / 15.00];",
});
expect(r!.source).toBe("spamassassin");
expect(r!.verdict).toBe("spam");
});
it("falls back to a bare score, with no threshold to read it against", () => {
const r = spamReport({ "header:X-Spam-Score:asText": "+4.1" });
expect(r!.score).toBe(4.1);
expect(r!.threshold).toBeNull();
expect(r!.verdict).toBeNull();
expect(r!.rules).toEqual([]);
});
it("does not invent a verdict from score against threshold", () => {
// Above the threshold, but the filter did not say "Yes" -- so neither do we.
const r = sa("score=9.9 required=5.0 tests=[X=9.9]");
expect(r!.verdict).toBeNull();
});
});
+114
View File
@@ -0,0 +1,114 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import {
accountKey,
clearAllData,
clearSignedInData,
isDeviceTrusted,
loadJson,
loadRaw,
saveJson,
setDeviceTrusted,
} from "@/lib/storage";
/**
* The gate is a privacy boundary rather than a convenience, so it is tested
* from both sides: that a trusted device still works exactly as it did, and
* that an untrusted one leaves nothing to find.
*/
describe("device-trusted storage", () => {
let store: Map<string, string>;
beforeEach(() => {
store = new Map();
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: {
get length() {
return store.size;
},
key: (i: number) => [...store.keys()][i] ?? null,
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => void store.set(k, v),
removeItem: (k: string) => void store.delete(k),
},
});
setDeviceTrusted(false);
});
afterEach(() => {
setDeviceTrusted(false);
Reflect.deleteProperty(globalThis, "localStorage");
});
it("writes nothing at all when the device is not trusted", () => {
saveJson("settings", { theme: "dark" });
saveJson(accountKey("acct1", "recent"), [{ email: "[email protected]" }]);
expect([...store.keys()].filter((k) => k !== "ihasmail:deviceTrusted")).toEqual([]);
});
it("does not read residue left by an earlier trusted session", () => {
setDeviceTrusted(true);
saveJson(accountKey("acct1", "recent"), [{ email: "[email protected]" }]);
setDeviceTrusted(false);
// The bytes are still on disk until a purge; the gate must not serve them.
expect(loadRaw(accountKey("acct1", "recent"), [])).toEqual([]);
});
it("round-trips normally on a trusted device", () => {
setDeviceTrusted(true);
saveJson("settings", { theme: "dark" });
expect(loadJson("settings", { theme: "light", accent: "blue" })).toEqual({ theme: "dark", accent: "blue" });
expect(isDeviceTrusted()).toBe(true);
});
it("remembers trust across a reload, so a trusted device still paints from cache", () => {
setDeviceTrusted(true);
expect(store.get("ihasmail:deviceTrusted")).toBe("1");
setDeviceTrusted(false);
expect(store.has("ihasmail:deviceTrusted")).toBe(false);
});
it("clears the account's data on sign-out but keeps the deliberate exceptions", () => {
setDeviceTrusted(true);
saveJson("settings", { theme: "dark" });
saveJson("mbx-expanded", { a: true });
saveJson(accountKey("acct1", "recent"), [{ email: "[email protected]" }]);
store.set("ihasmail:lastUser", "[email protected]");
store.set("ihasmail:pushDeviceId", "ihasmail-abc");
store.set("ihasmail:pushEnabled", "1");
clearSignedInData();
expect(store.has("ihasmail:settings")).toBe(false);
expect(store.has("ihasmail:mbx-expanded")).toBe(false);
expect(store.has("ihasmail:acct1:recent")).toBe(false);
// Kept on purpose: prefills sign-in, and only a trusted device wrote it.
expect(store.get("ihasmail:lastUser")).toBe("[email protected]");
expect(store.get("ihasmail:pushDeviceId")).toBe("ihasmail-abc");
/*
* Kept for the ending that is not a sign-out. A deploy expires every
* session, and that path clears local data without unsubscribing -- there
* is no session left to unsubscribe with. Losing the flag there would
* strand a live subscription with nothing renewing it, and the switch in
* Settings would still say background notifications were on.
*/
expect(store.get("ihasmail:pushEnabled")).toBe("1");
});
it("clears everything, lastUser included, for an untrusted sign-in", () => {
setDeviceTrusted(true);
saveJson("settings", { theme: "dark" });
store.set("ihasmail:lastUser", "[email protected]");
clearAllData();
expect([...store.keys()]).toEqual([]);
});
it("leaves keys belonging to anything else alone", () => {
setDeviceTrusted(true);
store.set("someone-elses-key", "keep me");
clearAllData();
expect(store.get("someone-elses-key")).toBe("keep me");
});
});
+63
View File
@@ -0,0 +1,63 @@
import { describe, expect, it } from "vitest";
import { SWIPE_CHOICES, describeSwipe, type SwipeAction } from "../swipe";
/**
* A swipe names what it is about to do on a coloured strip the reader sees for
* about a third of a second before letting go. These check that the name is
* true in the folder it is being read in which is the whole reason the
* descriptor exists rather than a fixed label per setting.
*/
const inbox = { role: "inbox", unread: false, starred: false };
describe("describeSwipe", () => {
it("offers nothing for a direction turned off", () => {
expect(describeSwipe("none", inbox)).toBe(null);
});
it("refuses to archive out of the archive", () => {
expect(describeSwipe("archive", { ...inbox, role: "archive" })).toBe(null);
expect(describeSwipe("archive", inbox)).toMatchObject({ label: "Archive", removes: true });
});
it("says out loud that a delete from Deleted Items is permanent", () => {
expect(describeSwipe("delete", inbox)?.label).toBe("Delete");
expect(describeSwipe("delete", { ...inbox, role: "trash" })?.label).toBe("Delete forever");
});
it("turns the spam action around inside the junk folder", () => {
expect(describeSwipe("spam", inbox)).toMatchObject({ label: "Report spam", icon: "spam" });
expect(describeSwipe("spam", { ...inbox, role: "junk" })).toMatchObject({ label: "Not spam", icon: "not-spam" });
});
it("has no opinion on whether your own mail is spam", () => {
expect(describeSwipe("spam", { ...inbox, role: "drafts" })).toBe(null);
expect(describeSwipe("spam", { ...inbox, role: "sent" })).toBe(null);
});
it("names the state a toggle is about to set, and carries it", () => {
expect(describeSwipe("read", { ...inbox, unread: true })).toMatchObject({ label: "Mark as read", icon: "read", on: true });
expect(describeSwipe("read", { ...inbox, unread: false })).toMatchObject({ label: "Mark as unread", icon: "unread", on: false });
expect(describeSwipe("star", { ...inbox, starred: false })).toMatchObject({ label: "Add star", on: true });
expect(describeSwipe("star", { ...inbox, starred: true })).toMatchObject({ label: "Remove star", on: false });
});
it("brings a row home for the actions that open something instead", () => {
// The row has to be back under the finger before the folder picker covers
// the list, or it is still hanging half-open when the picker closes again.
expect(describeSwipe("move", inbox)).toMatchObject({ label: "Move to…", removes: false });
for (const action of ["archive", "delete", "spam"] as const) {
expect(describeSwipe(action, inbox)?.removes).toBe(true);
}
});
it("can describe everything the settings picker offers", () => {
// A choice the picker offers and the list cannot describe is a direction
// that silently does nothing — the one failure nobody would report.
for (const { value } of SWIPE_CHOICES) {
const d = describeSwipe(value as SwipeAction, inbox);
if (value === "none") expect(d).toBe(null);
else expect(d?.label).toBeTruthy();
}
});
});
+68
View File
@@ -0,0 +1,68 @@
import { describe, expect, it } from "vitest";
import { navSwipeThreshold, swipeNavDirection, swipeThreshold, lockAxis } from "@/lib/touch";
describe("navSwipeThreshold", () => {
it("asks for more travel than a row swipe does, at every width", () => {
// Not because the consequence is bigger -- stepping back undoes it -- but
// because this gesture reveals nothing on the way and offers no Undo
// after, so the distance is the only chance to not mean it.
for (const width of [320, 360, 414, 768, 1024]) {
expect(navSwipeThreshold(width)).toBeGreaterThan(swipeThreshold(width));
}
});
it("is a share of the width, bounded at both ends", () => {
expect(navSwipeThreshold(360)).toBe(108);
expect(navSwipeThreshold(200)).toBe(80); // floor
expect(navSwipeThreshold(1000)).toBe(180); // ceiling
});
});
describe("swipeNavDirection", () => {
const W = 400; // threshold is 120 at this width
it("goes forward when the finger drags left, the way pages turn", () => {
expect(swipeNavDirection(-200, W)).toBe(1);
});
it("goes back when the finger drags right", () => {
expect(swipeNavDirection(200, W)).toBe(-1);
});
it("does nothing short of the threshold, in either direction", () => {
expect(swipeNavDirection(-60, W)).toBe(0);
expect(swipeNavDirection(60, W)).toBe(0);
expect(swipeNavDirection(0, W)).toBe(0);
});
it("fires exactly at the threshold and not a pixel before", () => {
const at = navSwipeThreshold(W);
expect(swipeNavDirection(-at, W)).toBe(1);
expect(swipeNavDirection(-(at - 1), W)).toBe(0);
expect(swipeNavDirection(at, W)).toBe(-1);
expect(swipeNavDirection(at - 1, W)).toBe(0);
});
it("scales with the width, so a tablet asks for more than a phone", () => {
// The same 120px drag commits on a narrow screen and does not on a wide one.
expect(swipeNavDirection(-120, 360)).toBe(1);
expect(swipeNavDirection(-120, 1024)).toBe(0);
});
});
describe("the axis lock this shares with the row swipe", () => {
it("keeps a mostly-vertical drag as a scroll, which is what the day grid needs", () => {
// The day view scrolls through the hours; a scroll misread as a swipe
// throws the reader into another day.
expect(lockAxis(20, 30)).toBe("y");
expect(lockAxis(30, 25)).toBe("y");
});
it("commits to sideways only when it is clearly sideways", () => {
expect(lockAxis(40, 10)).toBe("x");
});
it("is undecided until the drag has moved at all", () => {
expect(lockAxis(2, 2)).toBeNull();
});
});
@@ -0,0 +1,80 @@
import { describe, expect, it } from "vitest";
import { fillPlaceholders, PLACEHOLDER_NAMES, type PlaceholderContext } from "@/lib/templatePlaceholders";
const AT = new Date("2026-03-04T15:07:00Z");
function ctx(over: Partial<PlaceholderContext> = {}): PlaceholderContext {
return {
to: [{ name: "Ada Lovelace", email: "[email protected]" }],
from: { name: "Grace Hopper", email: "[email protected]" },
subject: "Quarterly report",
now: AT,
...over,
};
}
describe("fillPlaceholders", () => {
it("fills the names it knows", () => {
expect(fillPlaceholders("Hi {{recipientFirstName}},", ctx(), { html: true })).toBe("Hi Ada,");
expect(fillPlaceholders("{{recipientName}} <{{recipientEmail}}>", ctx(), { html: false })).toBe("Ada Lovelace <[email protected]>");
expect(fillPlaceholders("-- {{myName}}", ctx(), { html: true })).toBe("-- Grace Hopper");
expect(fillPlaceholders("Re: {{subject}}", ctx(), { html: false })).toBe("Re: Quarterly report");
});
it("tolerates spaces inside the braces but not a different case", () => {
expect(fillPlaceholders("{{ myEmail }}", ctx(), { html: false })).toBe("[email protected]");
expect(fillPlaceholders("{{MyEmail}}", ctx(), { html: false })).toBe("{{MyEmail}}");
});
it("leaves a placeholder it cannot answer exactly as written", () => {
// The case the design is about: a template inserted before the message is
// addressed. "Hi ," would be wrong; "Hi {{recipientFirstName}}," is unfinished.
const unaddressed = ctx({ to: [] });
expect(fillPlaceholders("Hi {{recipientFirstName}},", unaddressed, { html: true })).toBe("Hi {{recipientFirstName}},");
expect(fillPlaceholders("{{recipientEmail}}", unaddressed, { html: false })).toBe("{{recipientEmail}}");
expect(fillPlaceholders("{{myName}}", ctx({ from: null }), { html: false })).toBe("{{myName}}");
});
it("leaves a name it does not know alone rather than eating it", () => {
expect(fillPlaceholders("{{nonsense}} {{}} {{ }}", ctx(), { html: true })).toBe("{{nonsense}} {{}} {{ }}");
});
it("falls back to the local part when a recipient has no name", () => {
const c = ctx({ to: [{ name: null, email: "[email protected]" }] });
expect(fillPlaceholders("{{recipientName}}", c, { html: false })).toBe("ada.lovelace");
expect(fillPlaceholders("{{recipientFirstName}}", c, { html: false })).toBe("ada.lovelace");
});
it("escapes a substituted value on the way into HTML, and not into a subject", () => {
const c = ctx({ to: [{ name: 'Ada <script>alert("x")</script>', email: "[email protected]" }] });
expect(fillPlaceholders("{{recipientName}}", c, { html: true })).not.toContain("<script>");
expect(fillPlaceholders("{{recipientName}}", c, { html: true })).toContain("&lt;script&gt;");
expect(fillPlaceholders("{{recipientName}}", c, { html: false })).toContain("<script>");
});
it("repeats a placeholder as many times as it appears", () => {
expect(fillPlaceholders("{{recipientFirstName}} {{recipientFirstName}}", ctx(), { html: true })).toBe("Ada Ada");
});
it("answers date and time from the injected clock", () => {
const date = fillPlaceholders("{{date}}", ctx(), { html: false });
const time = fillPlaceholders("{{time}}", ctx(), { html: false });
expect(date).not.toBe("{{date}}");
expect(date).toMatch(/2026/);
expect(time).not.toBe("{{time}}");
expect(time).toMatch(/\d/);
});
it("names every resolver in the list Settings shows", () => {
expect(PLACEHOLDER_NAMES).toEqual([
"recipientName",
"recipientFirstName",
"recipientEmail",
"myName",
"myEmail",
"subject",
"date",
"time",
]);
});
});
+61 -43
View File
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import { DEFAULT_SETTINGS, DEVICE_KEYS, acceptRemote, isDarkTheme, syncedPart, toggleTarget, useSettings, type Theme } from "@/store/settings";
import { loadJson, saveJson } from "@/lib/storage";
import { DEFAULT_SETTINGS, DEVICE_KEYS, acceptRemote, isDarkTheme, syncedPart, useSettings, type Theme } from "@/store/settings";
import { toggleTarget, type Mode, type PaletteId } from "@/lib/palette";
import { loadJson, saveJson, setDeviceTrusted } from "@/lib/storage";
/**
* "ihasmail" is a dark theme wearing ihasmail.org's palette. Everything that
@@ -60,9 +61,14 @@ describe("the default theme", () => {
removeItem: (k: string) => void store.delete(k),
},
});
// Reads and writes are gated on device trust now, and the gate defaults to
// closed. These tests are about `loadJson`'s merge, so open it and put it
// back -- an untrusted device is covered by storage.test.ts instead.
setDeviceTrusted(true);
try {
fn();
} finally {
setDeviceTrusted(false);
Reflect.deleteProperty(globalThis, "localStorage");
}
};
@@ -93,55 +99,49 @@ describe("the default theme", () => {
describe("the top-bar toggle", () => {
it("goes to light from anything dark", () => {
expect(toggleTarget("dark", "ihasmail")).toBe("light");
expect(toggleTarget("dark", "dark")).toBe("light");
expect(toggleTarget("dark", "system")).toBe("light");
expect(toggleTarget({ palette: "ihasmail", mode: "dark" }, false).mode).toBe("light");
expect(toggleTarget({ palette: "default", mode: "dark" }, false).mode).toBe("light");
expect(toggleTarget({ palette: "default", mode: "system" }, true).mode).toBe("light");
});
it("comes back to the theme you were actually on", () => {
// The whole point: two clicks from ihasmail must return to ihasmail, not
it("comes back to the palette you were actually on", () => {
// The whole point: two presses from ihasmail must return to ihasmail, not
// deposit you on plain dark.
expect(toggleTarget("light", "ihasmail")).toBe("ihasmail");
expect(toggleTarget("light", "dark")).toBe("dark");
});
it("can bring back \"match system\", which the toggle used to strand", () => {
expect(toggleTarget("light", "system")).toBe("system");
});
it("round-trips every dark theme there is", () => {
for (const t of ["dark", "ihasmail", "system"] as const) {
expect(toggleTarget(toggleTarget("light", t) === "light" ? "light" : "dark", t), t).toBe("light");
expect(toggleTarget("light", t), t).toBe(t);
}
const away = toggleTarget({ palette: "ihasmail", mode: "dark" }, false);
expect(toggleTarget(away, false).palette).toBe("ihasmail");
expect(toggleTarget({ palette: "default", mode: "light" }, false)).toMatchObject({ palette: "default", mode: "dark" });
});
});
describe("remembering which dark theme you were on", () => {
const setTheme = (t: Theme) => {
useSettings.getState().update({ theme: t });
describe("remembering the palette you were on", () => {
const set = (palette: PaletteId, mode: Mode) => {
useSettings.getState().update({ palette, mode });
return useSettings.getState().settings;
};
it("records a dark theme chosen from Settings, not just from the toggle", () => {
// update() is the single path every way of choosing a theme goes through,
// which is why the remembering lives there rather than at the call sites.
expect(setTheme("dark").lastDarkTheme).toBe("dark");
expect(setTheme("ihasmail").lastDarkTheme).toBe("ihasmail");
expect(setTheme("system").lastDarkTheme).toBe("system");
});
it("does not let light overwrite it — that is the theme being toggled away from", () => {
setTheme("ihasmail");
expect(setTheme("light").lastDarkTheme).toBe("ihasmail");
it("derives the legacy theme from whatever set the palette or mode", () => {
// `theme` is no longer chosen; it is kept in step so a device on an older
// build is not stranded on a theme nobody picked.
expect(set("default", "dark").theme).toBe("dark");
expect(set("ihasmail", "dark").theme).toBe("ihasmail");
expect(set("default", "system").theme).toBe("system");
expect(set("gruvbox", "light").theme).toBe("light");
expect(set("dracula", "dark").theme).toBe("dark");
});
it("survives a there-and-back through the toggle", () => {
setTheme("ihasmail");
const away = setTheme(toggleTarget("dark", useSettings.getState().settings.lastDarkTheme));
expect(away.theme).toBe("light");
const back = setTheme(toggleTarget("light", away.lastDarkTheme));
expect(back.theme).toBe("ihasmail");
// Two presses return you exactly where you started, and the palette never
// moves -- which is the whole of what the old lastDarkTheme existed for.
set("ihasmail", "dark");
const away = toggleTarget({ palette: "ihasmail", mode: "dark" }, false);
expect(away).toEqual({ palette: "ihasmail", mode: "light" });
expect(toggleTarget(away, false)).toEqual({ palette: "ihasmail", mode: "dark" });
});
it("keeps the colours when the palette has both sides", () => {
const away = toggleTarget({ palette: "gruvbox", mode: "dark" }, false);
expect(away.palette).toBe("gruvbox");
expect(away.mode).toBe("light");
});
});
@@ -154,12 +154,30 @@ describe("where the theme settings live", () => {
// its expectation would move too.
const synced = syncedPart(DEFAULT_SETTINGS);
expect(synced).toHaveProperty("theme");
expect(synced).toHaveProperty("lastDarkTheme");
expect(DEVICE_KEYS.has("theme")).toBe(false);
expect(DEVICE_KEYS.has("lastDarkTheme")).toBe(false);
expect(synced).toHaveProperty("palette");
expect(synced).toHaveProperty("mode");
for (const k of ["theme", "palette", "mode"] as const) {
expect(DEVICE_KEYS.has(k)).toBe(false);
}
});
it("is applied from a settings file another device wrote", () => {
expect(acceptRemote({ theme: "dark", lastDarkTheme: "dark" })).toEqual({ theme: "dark", lastDarkTheme: "dark" });
expect(acceptRemote({ palette: "gruvbox", mode: "light" })).toEqual({ palette: "gruvbox", mode: "light" });
});
it("reads a file written before palettes existed through the old enum", () => {
// Settings live in the account's Files and are opened by whatever version
// runs next, so this is not a one-release migration.
expect(acceptRemote({ theme: "ihasmail" })).toMatchObject({ palette: "ihasmail", mode: "dark" });
expect(acceptRemote({ theme: "light" })).toMatchObject({ palette: "default", mode: "light" });
});
it("prefers the new fields when a file carries both", () => {
// A file with both is newer, and its `theme` is the derived copy rather
// than the choice -- so it must not overrule the palette beside it.
expect(acceptRemote({ theme: "dark", palette: "rose-pine", mode: "light" })).toMatchObject({
palette: "rose-pine",
mode: "light",
});
});
});
+177
View File
@@ -0,0 +1,177 @@
import { describe, expect, it } from "vitest";
import { isTnef, parseTnef } from "@/lib/tnef";
/**
* The blobs below are **built from the format description, not captured from
* Outlook**. That is worth saying plainly: they prove the parser reads what the
* spec says, and they cannot prove it reads what Outlook actually emits. The
* cases most likely to differ in the wild are the MAPI property stream, where
* real messages carry many more properties than these do, and named properties
* (id >= 0x8000), which the parser stops at rather than guessing past.
*/
const SIGNATURE = 0x223e9f78;
const ATT = {
attachRenddata: 0x00069002,
attachTitle: 0x00018010,
attachData: 0x0006800f,
attachment: 0x00069005,
tnefVersion: 0x00089006,
} as const;
const sum16 = (b: number[]) => b.reduce((a, x) => (a + x) & 0xffff, 0);
const u16 = (v: number) => [v & 0xff, (v >> 8) & 0xff];
const u32 = (v: number) => [v & 0xff, (v >> 8) & 0xff, (v >> 16) & 0xff, (v >>> 24) & 0xff];
const ascii = (s: string) => [...s].map((c) => c.charCodeAt(0));
const utf16 = (s: string) => [...s].flatMap((c) => u16(c.charCodeAt(0)));
interface Attr {
level?: number;
id: number;
data: number[];
/** Deliberately wrong, for the desync case. */
badChecksum?: boolean;
}
function tnef(attrs: Attr[], opts: { signature?: number } = {}): Uint8Array {
const out: number[] = [...u32(opts.signature ?? SIGNATURE), ...u16(0x1234)];
for (const a of attrs) {
out.push(a.level ?? 2, ...u32(a.id), ...u32(a.data.length), ...a.data, ...u16(a.badChecksum ? (sum16(a.data) + 1) & 0xffff : sum16(a.data)));
}
return new Uint8Array(out);
}
/** A MAPI property stream carrying the given string properties. */
function mapi(props: Array<{ id: number; type: number; value: string }>): number[] {
const out: number[] = [...u32(props.length)];
for (const p of props) {
out.push(...u32(((p.id & 0xffff) << 16) | (p.type & 0xffff)));
const bytes = p.type === 0x001f ? [...utf16(p.value), 0, 0] : [...ascii(p.value), 0];
out.push(...u32(bytes.length), ...bytes);
const pad = (4 - (bytes.length % 4)) % 4;
for (let i = 0; i < pad; i++) out.push(0);
}
return out;
}
const file = (name: string, body: string, extra: Attr[] = []): Attr[] => [
{ id: ATT.attachRenddata, data: new Array(14).fill(0) },
{ id: ATT.attachTitle, data: [...ascii(name), 0] },
...extra,
{ id: ATT.attachData, data: ascii(body) },
];
const text = (a: Uint8Array) => new TextDecoder().decode(a);
describe("isTnef", () => {
it("recognises the types and the filename", () => {
expect(isTnef("application/ms-tnef", null)).toBe(true);
expect(isTnef("application/vnd.ms-tnef; name=winmail.dat", null)).toBe(true);
expect(isTnef("application/octet-stream", "winmail.dat")).toBe(true);
expect(isTnef("application/octet-stream", "WINMAIL.DAT")).toBe(true);
});
it("leaves everything else alone", () => {
expect(isTnef("application/pdf", "report.pdf")).toBe(false);
expect(isTnef(null, null)).toBe(false);
});
});
describe("parseTnef", () => {
it("pulls one attachment out, with its name and bytes", () => {
const out = parseTnef(tnef(file("report.pdf", "hello")));
expect(out).toHaveLength(1);
expect(out[0]!.name).toBe("report.pdf");
expect(text(out[0]!.data)).toBe("hello");
expect(out[0]!.size).toBe(5);
// Guessed from the extension, since this blob names no type of its own.
expect(out[0]!.type).toBe("application/pdf");
});
it("pulls several out, in order", () => {
const out = parseTnef(tnef([...file("a.txt", "one"), ...file("b.png", "two"), ...file("c.zip", "three")]));
expect(out.map((a) => a.name)).toEqual(["a.txt", "b.png", "c.zip"]);
expect(out.map((a) => text(a.data))).toEqual(["one", "two", "three"]);
expect(out.map((a) => a.type)).toEqual(["text/plain", "image/png", "application/zip"]);
});
it("prefers the long filename over the 8.3 one", () => {
// The whole reason for reading the MAPI stream at all.
const attrs = file("QUARTE~1.DOC", "body", [
{ id: ATT.attachment, data: mapi([{ id: 0x3707, type: 0x001e, value: "Quarterly Report Final.docx" }]) },
]);
expect(parseTnef(tnef(attrs))[0]!.name).toBe("Quarterly Report Final.docx");
});
it("reads a unicode long filename", () => {
const attrs = file("SHORT~1.DOC", "body", [
{ id: ATT.attachment, data: mapi([{ id: 0x3707, type: 0x001f, value: "四半期報告.docx" }]) },
]);
expect(parseTnef(tnef(attrs))[0]!.name).toBe("四半期報告.docx");
});
it("takes the MIME type the blob states over one guessed from the name", () => {
const attrs = file("data.bin", "body", [
{ id: ATT.attachment, data: mapi([{ id: 0x370e, type: 0x001e, value: "image/webp" }]) },
]);
expect(parseTnef(tnef(attrs))[0]!.type).toBe("image/webp");
});
it("falls back to octet-stream for a name that says nothing", () => {
expect(parseTnef(tnef(file("mystery", "x")))[0]!.type).toBe("application/octet-stream");
});
it("names an attachment that carries no title at all", () => {
const out = parseTnef(tnef([{ id: ATT.attachRenddata, data: new Array(14).fill(0) }, { id: ATT.attachData, data: ascii("x") }]));
expect(out[0]!.name).toBe("attachment");
});
it("ignores attributes it has no use for", () => {
const out = parseTnef(tnef([{ level: 1, id: ATT.tnefVersion, data: u32(0x00010000) }, ...file("a.txt", "one")]));
expect(out.map((a) => a.name)).toEqual(["a.txt"]);
});
it("is not TNEF, and says so quietly", () => {
// The caller gets here by guessing from a filename, so this is an ordinary
// answer rather than an error worth showing anybody.
expect(parseTnef(new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]))).toEqual([]);
expect(parseTnef(tnef(file("a.txt", "x"), { signature: 0xdeadbeef }))).toEqual([]);
expect(parseTnef(new Uint8Array([]))).toEqual([]);
});
it("keeps what it read when the stream goes out of step", () => {
// Half the attachments beats none: the alternative is a reader who can see
// the file is there and cannot have it.
const bad = tnef([...file("good.txt", "kept"), { id: ATT.attachRenddata, data: new Array(14).fill(0), badChecksum: true }, ...file("lost.txt", "gone")]);
const out = parseTnef(bad);
expect(out.map((a) => a.name)).toEqual(["good.txt"]);
});
it("keeps what it read when the blob is truncated mid-attribute", () => {
const full = tnef([...file("good.txt", "kept"), ...file("cut.txt", "partial")]);
const out = parseTnef(full.slice(0, full.length - 12));
expect(out.map((a) => a.name)).toEqual(["good.txt"]);
});
it("stops at a named property rather than guessing past it", () => {
// A named property carries a GUID before its value; the stream cannot be
// trusted to stay aligned past one, so the long name is simply not found.
const attrs = file("SHORT~1.DOC", "body", [
{ id: ATT.attachment, data: mapi([{ id: 0x8001, type: 0x001e, value: "whatever" }, { id: 0x3707, type: 0x001e, value: "Long Name.docx" }]) },
]);
expect(parseTnef(tnef(attrs))[0]!.name).toBe("SHORT~1.DOC");
});
it("survives a MAPI stream that is nonsense, keeping the attachment", () => {
const attrs = file("keep.txt", "body", [{ id: ATT.attachment, data: [...u32(0xffff), 1, 2, 3] }]);
const out = parseTnef(tnef(attrs));
expect(out).toHaveLength(1);
expect(out[0]!.name).toBe("keep.txt");
});
it("drops an attachment that has a name but no data", () => {
const out = parseTnef(tnef([{ id: ATT.attachRenddata, data: new Array(14).fill(0) }, { id: ATT.attachTitle, data: [...ascii("empty.txt"), 0] }]));
expect(out).toEqual([]);
});
});
+85
View File
@@ -0,0 +1,85 @@
import { describe, expect, it } from "vitest";
import { AXIS_SLOP, PULL_MAX, PULL_TRIGGER, lockAxis, pullDistance, swipeOffset, swipeThreshold } from "../touch";
/**
* The arithmetic behind the touch gestures, checked without a touchscreen.
*
* These are the numbers that decide whether a finger meant to scroll the list
* or to act on a message, and getting them wrong is not a crash it is an app
* that deletes mail when someone tried to scroll past it. Worth pinning down.
*/
describe("lockAxis", () => {
it("stays undecided until the finger has committed", () => {
expect(lockAxis(0, 0)).toBe(null);
expect(lockAxis(AXIS_SLOP - 1, AXIS_SLOP - 1)).toBe(null);
});
it("reads a clearly sideways drag as a swipe", () => {
expect(lockAxis(40, 4)).toBe("x");
expect(lockAxis(-40, 4)).toBe("x");
});
it("gives a diagonal to the scroller, not the swipe", () => {
// 45 degrees is more sideways than not, and is still a scroll: someone
// flicking down a list does not travel straight down the glass.
expect(lockAxis(30, 30)).toBe("y");
expect(lockAxis(30, 25)).toBe("y");
});
it("counts distance on either axis towards committing", () => {
expect(lockAxis(0, AXIS_SLOP)).toBe("y");
expect(lockAxis(AXIS_SLOP, 0)).toBe("x");
});
});
describe("swipeThreshold", () => {
it("scales with the row but never off either end", () => {
expect(swipeThreshold(300)).toBeCloseTo(84); // a phone: a share of the row
expect(swipeThreshold(160)).toBe(56); // a narrow row: a fixed floor
expect(swipeThreshold(2000)).toBe(96); // a tablet: not the whole reach
});
});
describe("swipeOffset", () => {
const width = 360;
const limit = swipeThreshold(width);
it("follows the finger exactly until the action would fire", () => {
expect(swipeOffset(20, width)).toBe(20);
expect(swipeOffset(-20, width)).toBe(-20);
expect(swipeOffset(limit, width)).toBe(limit);
});
it("resists past the threshold, in both directions", () => {
const over = swipeOffset(limit + 100, width);
expect(over).toBeGreaterThan(limit);
expect(over).toBeLessThan(limit + 100);
expect(swipeOffset(-(limit + 100), width)).toBeCloseTo(-over);
});
it("never travels further than the row is wide", () => {
// Past the row's own width there is nothing left to reveal, so a hard
// flick stops there rather than accumulating travel with nowhere to show.
expect(Math.abs(swipeOffset(2000, width))).toBe(width);
expect(Math.abs(swipeOffset(-2000, width))).toBe(width);
});
});
describe("pullDistance", () => {
it("ignores an upward drag", () => {
expect(pullDistance(0)).toBe(0);
expect(pullDistance(-50)).toBe(0);
});
it("asks for a deliberate pull, not the overscroll at the top of a list", () => {
expect(pullDistance(40)).toBeLessThan(PULL_TRIGGER);
expect(pullDistance(60)).toBeLessThan(PULL_TRIGGER);
expect(pullDistance(140)).toBeGreaterThanOrEqual(PULL_TRIGGER);
});
it("stops coming down however hard it is pulled", () => {
expect(pullDistance(400)).toBe(PULL_MAX);
expect(pullDistance(4000)).toBe(PULL_MAX);
});
});
@@ -0,0 +1,110 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
/*
* The guard's answers, and which of them the dialog leans on.
*
* It shipped with "Discard changes" as the only choice carrying a colour, which
* made losing the work the easy thing to click on a dialog whose entire purpose
* is to stop that (#175). The emphasis belongs on the safe answer; the
* destructive one stays legible as destructive without being the loudest thing
* in the box.
*/
const choiceDialog = vi.fn();
vi.mock("@/ui/dialog", () => ({ choiceDialog: (...args: unknown[]) => choiceDialog(...args) }));
const { confirmLeaveUnsaved, useUnsavedChanges } = await import("@/lib/unsavedChanges");
interface Choice { value: string; label: string; hint?: string; danger?: boolean; primary?: boolean }
const save = vi.fn(async () => true);
const discard = vi.fn();
function Editor() {
useUnsavedChanges({ dirty: true, save, discard, message: "Your filters have unsaved changes." });
return null;
}
let root: Root | null = null;
let host: HTMLDivElement | null = null;
/** One dirty editor on screen, which is what registers anything at all. */
function mountDirtyEditor() {
host = document.createElement("div");
document.body.appendChild(host);
root = createRoot(host);
act(() => root!.render(<Editor />));
}
const asked = () => choiceDialog.mock.calls[0]![0] as { choices: Choice[]; cancelLabel: string; title: string; message: string };
beforeEach(() => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
choiceDialog.mockReset().mockResolvedValue(null);
save.mockClear().mockResolvedValue(true);
discard.mockClear();
mountDirtyEditor();
});
afterEach(() => {
act(() => root!.unmount());
host!.remove();
root = null;
host = null;
});
describe("the unsaved-changes guard", () => {
it("highlights saving, not discarding", async () => {
await confirmLeaveUnsaved();
const [saveChoice, discardChoice] = asked().choices;
expect(saveChoice!.primary).toBe(true);
expect(discardChoice!.primary).toBeFalsy();
});
it("still says which answer is the destructive one", async () => {
await confirmLeaveUnsaved();
const [saveChoice, discardChoice] = asked().choices;
expect(discardChoice!.danger).toBe(true);
expect(discardChoice!.hint).toBeTruthy();
expect(saveChoice!.danger).toBeFalsy();
});
it("offers saving first, and staying as the way out", async () => {
await confirmLeaveUnsaved();
expect(asked().choices.map((c) => c.value)).toEqual(["save", "discard"]);
expect(asked().cancelLabel).toBeTruthy();
});
it("says what is about to be lost, in the editor's own words", async () => {
await confirmLeaveUnsaved();
expect(asked().message).toBe("Your filters have unsaved changes.");
});
it("stays put when the dialog is dismissed, and loses nothing", async () => {
choiceDialog.mockResolvedValue(null);
await expect(confirmLeaveUnsaved()).resolves.toBe(false);
expect(save).not.toHaveBeenCalled();
expect(discard).not.toHaveBeenCalled();
});
it("saves and goes when saving is chosen", async () => {
choiceDialog.mockResolvedValue("save");
await expect(confirmLeaveUnsaved()).resolves.toBe(true);
expect(save).toHaveBeenCalledOnce();
});
it("holds you on the page when the save fails", async () => {
choiceDialog.mockResolvedValue("save");
save.mockResolvedValue(false);
await expect(confirmLeaveUnsaved()).resolves.toBe(false);
});
it("discards and goes when discarding is chosen", async () => {
choiceDialog.mockResolvedValue("discard");
await expect(confirmLeaveUnsaved()).resolves.toBe(true);
expect(discard).toHaveBeenCalledOnce();
expect(save).not.toHaveBeenCalled();
});
});
+155
View File
@@ -0,0 +1,155 @@
import { describe, expect, it } from "vitest";
import {
crossesRecipientThreshold,
domainCovered,
externalRecipients,
internalDomains,
isExternalSender,
linkVerdict,
shownDomain,
} from "@/lib/warnings";
const addr = (email: string, name: string | null = null) => ({ name, email });
describe("internalDomains", () => {
it("always counts your own identities, without them being configured", () => {
// An account signed in as [email protected] warning that example.com is
// external would be absurd, and is what an empty list would do.
const d = internalDomains(["[email protected]", "[email protected]"], []);
expect([...d].sort()).toEqual(["example.com", "example.org"]);
});
it("adds configured domains, tolerating a leading @ and stray case", () => {
const d = internalDomains([], ["@Partner.com", " sister.org "]);
expect([...d].sort()).toEqual(["partner.com", "sister.org"]);
});
it("ignores empty entries rather than adding an empty domain", () => {
expect(internalDomains(["notanemail"], ["", " ", "@"]).size).toBe(0);
});
});
describe("domainCovered", () => {
const internal = internalDomains([], ["example.com"]);
it("covers the domain itself and its subdomains", () => {
expect(domainCovered("example.com", internal)).toBe(true);
expect(domainCovered("mail.example.com", internal)).toBe(true);
expect(domainCovered("a.b.example.com", internal)).toBe(true);
});
it("does not cover a domain that merely ends with the same letters", () => {
// The whole point of matching on a dot boundary: this is the shape an
// attacker registers.
expect(domainCovered("notexample.com", internal)).toBe(false);
expect(domainCovered("example.com.evil.net", internal)).toBe(false);
});
it("is case-insensitive and says no to nothing", () => {
expect(domainCovered("MAIL.EXAMPLE.COM", internal)).toBe(true);
expect(domainCovered("", internal)).toBe(false);
});
});
describe("externalRecipients", () => {
const internal = internalDomains(["[email protected]"], []);
it("returns only those outside, in the order addressed", () => {
const out = externalRecipients(
[addr("[email protected]"), addr("[email protected]"), addr("[email protected]"), addr("[email protected]")],
internal,
);
expect(out.map((a) => a.email)).toEqual(["[email protected]", "[email protected]"]);
});
it("is empty when everyone is inside", () => {
expect(externalRecipients([addr("[email protected]")], internal)).toEqual([]);
});
});
describe("isExternalSender", () => {
const internal = internalDomains(["[email protected]"], []);
it("reads the first From address", () => {
expect(isExternalSender([addr("[email protected]")], internal)).toBe(true);
expect(isExternalSender([addr("[email protected]")], internal)).toBe(false);
});
it("claims nothing about a message with no sender", () => {
expect(isExternalSender(null, internal)).toBe(false);
expect(isExternalSender([], internal)).toBe(false);
});
});
describe("crossesRecipientThreshold", () => {
it("is off at zero, whatever the count", () => {
expect(crossesRecipientThreshold(500, 0)).toBe(false);
});
it("fires at the threshold and above, not below", () => {
expect(crossesRecipientThreshold(9, 10)).toBe(false);
expect(crossesRecipientThreshold(10, 10)).toBe(true);
expect(crossesRecipientThreshold(11, 10)).toBe(true);
});
});
describe("shownDomain", () => {
it("reads a domain out of link text that is a URL or a bare host", () => {
expect(shownDomain("https://example.com/x")).toBe("example.com");
expect(shownDomain("example.com")).toBe("example.com");
expect(shownDomain(" WWW.Example.COM ")).toBe("www.example.com");
});
it("reads nothing out of text that is prose", () => {
// "click" and "here" are not claims about a destination.
expect(shownDomain("click here")).toBeNull();
expect(shownDomain("here")).toBeNull();
expect(shownDomain("")).toBeNull();
expect(shownDomain(null)).toBeNull();
});
});
describe("linkVerdict", () => {
const trusted = ["example.com"];
it("says nothing about a trusted destination", () => {
expect(linkVerdict("https://example.com/a", "example.com", trusted)).toEqual({ warn: false });
expect(linkVerdict("https://mail.example.com/a", null, trusted)).toEqual({ warn: false });
});
it("warns about an untrusted destination", () => {
expect(linkVerdict("https://unknown.net/a", null, trusted)).toEqual({
warn: true,
reason: "untrusted",
domain: "unknown.net",
});
});
it("warns about a mismatch even when the destination is trusted", () => {
// Trusted is not the same as being the place the text claimed.
expect(linkVerdict("https://example.com/login", "yourbank.com", trusted)).toEqual({
warn: true,
reason: "mismatch",
domain: "example.com",
shownDomain: "yourbank.com",
});
});
it("treats a subdomain of the claimed domain as no mismatch", () => {
expect(linkVerdict("https://login.yourbank.com/", "yourbank.com", ["yourbank.com"])).toEqual({ warn: false });
});
it("leaves alone anything that is not http or https", () => {
// mailto opens the composer; an anchor goes nowhere. Warning about these
// is noise, and noise is how a warning stops being read.
expect(linkVerdict("mailto:[email protected]", null, [])).toEqual({ warn: false });
expect(linkVerdict("#section", null, [])).toEqual({ warn: false });
expect(linkVerdict("javascript:alert(1)", null, [])).toEqual({ warn: false });
expect(linkVerdict("not a url at all", null, [])).toEqual({ warn: false });
});
it("warns about everything when nothing is trusted yet", () => {
const v = linkVerdict("https://example.com/a", null, []);
expect(v).toEqual({ warn: true, reason: "untrusted", domain: "example.com" });
});
});
+129 -1
View File
@@ -1,13 +1,21 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { client } from "@/jmap/client";
import {
applicationServerKey,
decodeApplicationServerKey,
encodeKey,
findSubscription,
needsRenewal,
RENEW_WITHIN_MS,
subscriptionPayload,
pushEnabledHere,
setPushEnabledHere,
supportsEmailPush,
unsubscribeThisDevice,
webPushAvailable,
type JmapPushSubscription,
} from "@/lib/webpush";
import { setDeviceTrusted } from "@/lib/storage";
import type { JmapSession } from "@/jmap/types";
/**
@@ -175,3 +183,123 @@ describe("the emailPush filter", () => {
expect(filter.notKeyword).toBe("$seen");
});
});
/**
* Keeping a subscription alive.
*
* The failure this guards against leaves no trace anywhere: the switch says
* background notifications are on, the browser still holds a subscription, and
* the server quietly stopped delivering days ago because the registration
* expired and nothing renewed it. Nobody reports that as a bug they report
* that push "doesn't really work".
*/
const sub = (deviceClientId: string, expires: string | null): JmapPushSubscription =>
({ id: `i-${deviceClientId}`, deviceClientId, url: "https://push.example/x", expires });
const MINE = "ihasmail-this-browser";
const NOW = Date.parse("2026-09-01T12:00:00Z");
const inDays = (n: number) => new Date(NOW + n * 24 * 60 * 60 * 1000).toISOString();
describe("finding this browser's subscription", () => {
it("matches on the device id rather than taking the first one", () => {
const subs = [sub("ihasmail-desktop", null), sub(MINE, null), sub("ihasmail-tablet", null)];
expect(findSubscription(subs, MINE)?.deviceClientId).toBe(MINE);
});
it("finds nothing when only other devices are registered", () => {
// The bug this replaces: any subscription at all counted as this one, so a
// phone that had never registered read as already on and stayed silent.
expect(findSubscription([sub("ihasmail-desktop", null)], MINE)).toBe(null);
});
});
describe("needsRenewal", () => {
it("renews when this browser is not registered at all", () => {
expect(needsRenewal([], MINE, NOW)).toBe(true);
expect(needsRenewal([sub("ihasmail-desktop", inDays(6))], MINE, NOW)).toBe(true);
});
it("leaves a subscription alone while it has time on it", () => {
expect(needsRenewal([sub(MINE, inDays(6))], MINE, NOW)).toBe(false);
expect(needsRenewal([sub(MINE, inDays(3))], MINE, NOW)).toBe(false);
});
it("renews inside the window, so a weekend does not lose it", () => {
expect(needsRenewal([sub(MINE, inDays(2))], MINE, NOW)).toBe(true);
expect(needsRenewal([sub(MINE, inDays(1))], MINE, NOW)).toBe(true);
expect(RENEW_WITHIN_MS).toBeLessThan(7 * 24 * 60 * 60 * 1000);
});
it("renews one that has already lapsed", () => {
expect(needsRenewal([sub(MINE, inDays(-1))], MINE, NOW)).toBe(true);
});
it("leaves a subscription with no expiry alone", () => {
// A server that never expires one has nothing to renew, and rewriting the
// registration on every cold start would be a JMAP call for nothing.
expect(needsRenewal([sub(MINE, null)], MINE, NOW)).toBe(false);
});
it("renews rather than trusts an expiry it cannot read", () => {
expect(needsRenewal([sub(MINE, "whenever")], MINE, NOW)).toBe(true);
});
});
/**
* Whether push is on *in this browser* is the flag the renewal on app start
* keys off, so the two endings that can clear it have to be told apart.
*
* Signing out clears it, alongside destroying the subscription itself: a
* browser left notifying for a mailbox nobody is signed into is somebody
* else's mail on a shared machine. A session merely expiring must not, because
* that path -- which is what a deploy does to everyone at once -- leaves the
* subscription registered and has no session left to remove it with. That half
* is enforced by `KEEP_ON_SIGN_OUT` and tested in storage.test.ts.
*/
describe("remembering that push is on here", () => {
let store: Map<string, string>;
beforeEach(() => {
store = new Map();
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: {
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => void store.set(k, v),
removeItem: (k: string) => void store.delete(k),
},
});
setDeviceTrusted(true);
});
afterEach(() => {
setDeviceTrusted(false);
Reflect.deleteProperty(globalThis, "localStorage");
});
it("round-trips, and is off until something turns it on", () => {
expect(pushEnabledHere()).toBe(false);
setPushEnabledHere(true);
expect(pushEnabledHere()).toBe(true);
setPushEnabledHere(false);
expect(pushEnabledHere()).toBe(false);
});
it("stays off on a device nobody said was theirs", () => {
// Push is refused there anyway; reading the flag as set would start the
// renewal trying on every load for a subscription that cannot exist.
setPushEnabledHere(true);
setDeviceTrusted(false);
expect(pushEnabledHere()).toBe(false);
});
it("is cleared by signing out, even when the server end cannot be reached", () => {
setPushEnabledHere(true);
vi.spyOn(client, "call").mockRejectedValue(new Error("offline"));
return unsubscribeThisDevice().then(() => {
// The subscription may well survive at the server; this browser must
// still stop believing it has push, or renewal would resurrect it.
expect(pushEnabledHere()).toBe(false);
});
});
});
+97
View File
@@ -0,0 +1,97 @@
import type { Email, EmailAddress } from "@/jmap/types";
import { useCalendar, type EventDraft } from "@/store/calendar";
import { useMail } from "@/store/mail";
import { uniqueAddresses } from "./address";
import { toLocalDateOnly } from "./dates";
import { htmlToText } from "./text";
/**
* How much of a message body is copied into an event description.
*
* The reader is making a reminder out of a mail, and a newsletter is a mail
* too: whole bodies run to hundreds of kilobytes, which would be stored on the
* event, synced to every device, and shown in a three-row textarea. What is
* worth keeping is near the top -- the amount owed, the date, the address --
* so the tail is what gets dropped, and visibly, so nobody reads a truncated
* bill as the whole of it.
*/
const MAX_DESCRIPTION = 5000;
/**
* The next half-hour, which is when an appointment made now can start.
*
* Always forward, never the current instant: the reader still has a form to
* fill in, and a start time that is already in the past by the time they press
* Create is one they have to fix by hand.
*/
export function nextHalfHour(now: Date = new Date()): Date {
const d = new Date(now);
d.setSeconds(0, 0);
d.setMinutes(d.getMinutes() + (30 - (d.getMinutes() % 30)));
return d;
}
/** The message's body as plain text, however it was sent. */
function bodyText(email: Email): string {
const textPart = email.textBody?.[0];
const text = textPart?.partId ? (email.bodyValues?.[textPart.partId]?.value ?? "") : "";
if (text.trim()) return text;
const htmlPart = email.htmlBody?.[0];
const html = htmlPart?.partId ? (email.bodyValues?.[htmlPart.partId]?.value ?? "") : "";
return html ? htmlToText(html) : "";
}
/**
* An event seeded from a message: its subject, its body, and a time to fix.
*
* Deliberately nothing clever. The date is the one thing the message cannot
* supply -- "the 14th" in a bill is not a due date the parser could trust --
* so the editor opens with the reader's cursor on a form they finish, rather
* than a guess they have to check.
*/
/**
* Everyone the message was between, as guests: the sender and the people it
* was addressed to.
*
* The reader's own addresses come out -- they are the organiser, and an
* organiser listed among their own guests is an event that invites you to your
* own appointment. Bcc stays out too, on a message the reader sent themselves:
* a blind recipient added to a guest list is visible to every other guest, and
* turning a hidden copy into a public one is not something a menu item should
* do quietly.
*/
function guests(email: Email, ownEmails: string[]): EmailAddress[] {
const own = new Set(ownEmails.map((e) => e.toLowerCase()));
return uniqueAddresses([...(email.from ?? []), ...(email.to ?? []), ...(email.cc ?? [])]).filter((a) => !own.has(a.email.trim().toLowerCase()));
}
export function appointmentDraft(email: Email, now: Date = new Date(), ownEmails: string[] = []): EventDraft {
const start = nextHalfHour(now);
const body = bodyText(email).trim();
return {
title: email.subject?.trim() ?? "",
description: body.length > MAX_DESCRIPTION ? `${body.slice(0, MAX_DESCRIPTION).trimEnd()}` : body,
start,
end: new Date(start.getTime() + 3600_000),
allDay: false,
attendees: guests(email, ownEmails),
};
}
/**
* Open the calendar's event editor on a draft made from this message.
*
* The list holds a message without its body -- only a preview -- so the full
* one is fetched first; `getEmails` serves it from the cache when the message
* has already been read.
*/
export async function startAppointment(email: Email, navigate: (to: string) => void): Promise<void> {
const mail = useMail.getState();
const full = (await mail.getEmails([email.id], true))[0] ?? email;
// Which addresses are the reader's own decides who is a guest, so they are
// worth a round trip when the session has not loaded them yet.
const identities = mail.identities.length ? mail.identities : await mail.loadIdentities();
const draft = appointmentDraft(full, new Date(), identities.map((i) => i.email));
useCalendar.getState().setDraft(draft);
navigate(`/calendar/day/${toLocalDateOnly(draft.start)}`);
}
+71
View File
@@ -0,0 +1,71 @@
/**
* Where a message goes when it is archived by date.
*
* The folders are **numeric and zero-padded** -- `Archive/2026`,
* `Archive/2026/09` -- and deliberately not month names. Two reasons, both
* about the fact that these are real server-side mailboxes rather than
* anything of ihasmail's:
*
* - Every other client sees them. A folder created as "September" by someone
* reading in English stays "September" for the same account read in
* Japanese, because the name is stored, not translated. A number reads the
* same in every language ihasmail ships.
* - They sort. `09` sits between `08` and `10` in any folder list; "September"
* sits between "October" and nothing useful.
*
* The date is read in the reader's own timezone rather than UTC, because it has
* to agree with the date shown against the message in the list. A message that
* arrived at 00:30 UTC on 1 September is dated 31 August in New York, and
* filing it under `09` while the list says August would be the app disagreeing
* with itself.
*/
export type ArchiveGranularity = "year" | "month";
/**
* Path segments below the Archive folder. Empty means "no dated subfolder" --
* a message whose date cannot be read belongs in Archive itself rather than in
* a folder named after a guess.
*/
export function archiveSegments(when: string | null | undefined, granularity: ArchiveGranularity): string[] {
if (!when) return [];
const d = new Date(when);
if (Number.isNaN(d.getTime())) return [];
const year = String(d.getFullYear());
if (granularity === "year") return [year];
return [year, String(d.getMonth() + 1).padStart(2, "0")];
}
/** The segments as one string, for grouping and for naming the destination. */
export function archivePath(segments: string[]): string {
return segments.join("/");
}
export interface ArchiveGroup {
segments: string[];
ids: string[];
}
/**
* Split a selection by where each message is going.
*
* Archiving by month across a selection spanning two months is two
* destinations, not one, so this is the shape the caller needs -- and the
* reason the action cannot simply resolve one folder up front. Groups come
* back in the order their first message appeared, so the toast that follows
* names them in the order the reader was looking at.
*/
export function groupByArchivePath(
entries: Array<{ id: string; receivedAt?: string | null }>,
granularity: ArchiveGranularity,
): ArchiveGroup[] {
const groups = new Map<string, ArchiveGroup>();
for (const e of entries) {
const segments = archiveSegments(e.receivedAt, granularity);
const key = archivePath(segments);
const existing = groups.get(key);
if (existing) existing.ids.push(e.id);
else groups.set(key, { segments, ids: [e.id] });
}
return [...groups.values()];
}
+95
View File
@@ -0,0 +1,95 @@
import { DAY_MS } from "@/lib/dates";
/**
* The span an availability bar covers, and the marks along it.
*
* The bar used to be a day wide whatever it was showing: it began at midnight
* on the event's start day and stopped 24 hours later, so an event running over
* two days showed availability for the first of them and gave no sign that
* there was more. It also carried no marks at all, which left "is this the
* whole day or only working hours" unanswerable without dragging the event
* around to see where its own outline moved. That is issue #172, parts 1 and 2.
*
* Whole days, always: a bar that started at the event's own start time would
* move under the reader every time they adjusted it, and "busy from about a
* third of the way along" is not a time anybody can read.
*/
export interface AvailabilityWindow {
/** Midnight at the start of the first day shown. */
start: Date;
/** Midnight at the end of the last day shown. */
end: Date;
/** Milliseconds between the two, which a DST change makes not a multiple of a day. */
span: number;
/** Days actually shown. */
days: number;
/**
* Marks along the bar. `at` is a fraction of the span, so a caller positions
* one with a percentage and never does date arithmetic of its own. Only
* `major` marks are worth a label; the rest are there to read a block against.
*/
ticks: { at: number; time: Date; major: boolean }[];
/** Whether marks fall on hours or on days, which decides how to label them. */
scale: "hours" | "days";
/**
* Days the event covers that the bar does not. An event long enough to need
* this is not one anybody is checking for a free slot, and drawing a month at
* eight pixels a day would say nothing; saying how much was left out is more
* use than showing it.
*/
daysHidden: number;
}
/** Midnight starting the day `d` falls in, in local time. */
function startOfDay(d: Date): Date {
const out = new Date(d);
out.setHours(0, 0, 0, 0);
return out;
}
/**
* `n` days on from `d`, by the calendar rather than by arithmetic: a day is 23
* or 25 hours twice a year, and adding 24 of them lands an hour off.
*/
function addDays(d: Date, n: number): Date {
const out = new Date(d);
out.setDate(out.getDate() + n);
out.setHours(0, 0, 0, 0);
return out;
}
/** How far apart the marks go, in hours, and which of them get a label. */
function spacing(days: number): { every: number; label: number } {
if (days <= 1) return { every: 3, label: 6 };
if (days <= 2) return { every: 6, label: 12 };
return { every: 24, label: 24 };
}
export function availabilityWindow(start: Date, end: Date, opts: { maxDays?: number; offsetDays?: number } = {}): AvailabilityWindow {
const maxDays = opts.maxDays ?? 7;
/*
* Days moved from where the event sits, for looking around it without
* changing it. The whole window slides rather than growing: keeping the span
* fixed means what you compare when you step forward is the same width as
* what you were looking at, which is the point of stepping.
*/
const from = addDays(startOfDay(start), opts.offsetDays ?? 0);
// The last day is the one the event ends *on*. An event ending exactly at
// midnight ends on the day before, not at the start of a day it never
// touches -- that is the whole of what all-day events do.
const lastDay = addDays(startOfDay(new Date(Math.max(end.getTime() - 1, start.getTime()))), opts.offsetDays ?? 0);
const total = Math.max(1, Math.round((lastDay.getTime() - from.getTime()) / DAY_MS) + 1);
const days = Math.min(total, maxDays);
const to = addDays(from, days);
const span = to.getTime() - from.getTime();
const { every, label } = spacing(days);
const ticks: AvailabilityWindow["ticks"] = [];
for (let hour = 0; ; hour += every) {
const time = new Date(from.getTime() + hour * 3600_000);
if (time.getTime() >= to.getTime()) break;
ticks.push({ at: (time.getTime() - from.getTime()) / span, time, major: hour % label === 0 });
}
return { start: from, end: to, span, days, ticks, scale: days <= 2 ? "hours" : "days", daysHidden: total - days };
}
+29
View File
@@ -0,0 +1,29 @@
/**
* The subpath this build is mounted at, as the browser sees it.
*
* `import.meta.env.BASE_URL` is Vite's own copy of the `base` it built with,
* and `vite.config.ts` sets that from `BASE_PATH` through the shared
* normaliser -- so this is the same answer the server reached, not a second
* guess at it. Reading it here rather than re-deriving it from
* `window.location` matters because the app is a SPA: at `/mail/inbox/abc`
* there is nothing in the address that says how much of it is the mount.
*
* Vite guarantees the value ends in a slash, so the only conversion is
* dropping it; `""` for the root, `/mail` otherwise, matching
* `scripts/basePath.mjs`.
*/
export const BASE_PATH: string = import.meta.env.BASE_URL.replace(/\/+$/, "");
/**
* Turn a root-absolute app path into one the server will answer.
*
* Every `/api/...`, `/img/...` and `/sw.js` in the app goes through here.
* Router paths do not: wouter is given `BASE_PATH` as its base and strips and
* re-adds the prefix itself, so `<Link href="/mail">` stays written that way.
* Mixing the two would double the prefix, which is why this asserts nothing
* and simply concatenates -- the discipline is at the call sites, and the
* callers that need it are few and all in this repo.
*/
export function withBase(path: string): string {
return BASE_PATH + path;
}

Some files were not shown because too many files have changed in this diff Show More