Merge branch 'cu-readx' into catch-up-2026-11

This commit is contained in:
Linus Rath
2026-10-31 14:59:37 +01:00
41 changed files with 3880 additions and 219 deletions
Binary file not shown.
Binary file not shown.
+107
View File
@@ -0,0 +1,107 @@
The reading fonts in this folder (Settings > Accessibility > Font) are
licensed under the SIL Open Font License, Version 1.1, copied below.
AtkinsonHyperlegibleNext*.ttf:
Copyright 2020-2024 The Atkinson Hyperlegible Next Project Authors
(https://github.com/googlefonts/atkinson-hyperlegible-next)
Lexend*.ttf:
Copyright 2018 The Lexend Project Authors (https://github.com/googlefonts/lexend),
with Reserved Font Name "RevReading Lexend".
OpenDyslexic*.ttf:
Copyright (c) 2019-07-29, Abbie Gonzalez (https://abbiecod.es|support@abbiecod.es),
with Reserved Font Name OpenDyslexic.
Copyright (c) 12/2012 - 2019
The files are built by scripts/build-reading-fonts.py from the woff2 files
the webmail bundles (from Fontsource, https://fontsource.org): the variable
Atkinson Hyperlegible Next and Lexend fonts as static regular and bold
instances, OpenDyslexic unchanged apart from the woff2 decompression.
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
+3
View File
@@ -1,3 +1,6 @@
// First: wraps react-native's Text for the reading font before any module
// copies it (src/theme/reading-font-text).
import './src/theme/reading-font-install';
import { createElement } from 'react';
import { AppRegistry } from 'react-native';
import { registerRootComponent } from 'expo';
+785
View File
@@ -0,0 +1,785 @@
{
"login": {
"jmap_endpoint_cors_hint": "Der Mailserver muss Anmeldungen von dieser Website erlauben. Ihre Administration kann das einrichten.",
"reconnecting": "Die Verbindung ist weg. Wir versuchen, wieder zu verbinden…",
"error": {
"invalid_credentials": "Die E-Mail-Adresse oder das Passwort ist falsch. Prüfen Sie beides und versuchen Sie es noch einmal.",
"invalid_token": "Der Server hat dieses Zugriffstoken nicht angenommen. Vielleicht ist es nicht vollständig oder nicht mehr gültig. Kopieren Sie es noch einmal oder erstellen Sie ein neues.",
"connection_failed": "Wir erreichen den Mailserver nicht. Prüfen Sie Ihre Internetverbindung und versuchen Sie es noch einmal.",
"cors_blocked": "Der Mailserver läuft, erlaubt aber keine Anmeldung von dieser Website. Bitten Sie Ihre Administration, diese Website auf dem Server zu erlauben.",
"server_error": "Der Mailserver hat gerade ein Problem. Versuchen Sie es in ein paar Minuten noch einmal.",
"generic": "Etwas ist schiefgelaufen. Versuchen Sie es noch einmal. Wenn das öfter passiert, fragen Sie Ihre Administration.",
"totp_required": "Ihr Konto braucht zur Anmeldung einen zweiten Code. Öffnen Sie Ihre Authenticator-App und geben Sie unten den 6-stelligen Code ein.",
"totp_invalid": "Dieser Code ist falsch oder zu alt. Geben Sie den Code ein, den Ihre Authenticator-App gerade anzeigt.",
"oauth_discovery_failed": "Die Anmeldung über Ihre Organisation funktioniert gerade nicht. Versuchen Sie es später noch einmal oder fragen Sie Ihre Administration.",
"token_exchange_failed": "Passwort und Code sind richtig, aber der Mailserver hat diese App nicht hereingelassen. Bitten Sie Ihre Administration, die Anmelde-Einstellungen für diese App zu prüfen."
},
"totp_toggle": "Ich habe einen Code aus meiner Authenticator-App",
"token_hint": "Manche Mailanbieter geben Ihnen ein Token statt eines Passworts. Erstellen Sie es in den Einstellungen Ihres Mailanbieters, kopieren Sie es und fügen Sie es hier ein.",
"config_error": {
"title": "Das Webmail ist nicht richtig eingerichtet",
"fetch_failed": "Das Webmail konnte seine Einstellungen nicht laden. Versuchen Sie es in ein paar Minuten noch einmal.",
"server_not_configured": "Es ist noch kein Mailserver eingerichtet. Bitten Sie Ihre Administration, einen einzurichten."
},
"session_expired": "Sie wurden abgemeldet. Melden Sie sich wieder an, um weiterzumachen.",
"oauth_error": {
"title": "Die Anmeldung hat nicht geklappt",
"invalid_state": "Die Anmeldung wurde unterbrochen. Fangen Sie noch einmal an.",
"missing_params": "Es fehlen Anmeldedaten. Fangen Sie noch einmal an.",
"token_exchange_failed": "Wir konnten die Anmeldung nicht abschließen. Versuchen Sie es noch einmal.",
"access_denied": "Sie dürfen sich hier nicht anmelden. Bitten Sie Ihre Administration um Zugang."
}
},
"session_limits": {
"idle_message": "Sie haben eine Weile nichts getan. In {seconds, plural, one {# Sekunde} other {# Sekunden}} werden Sie abgemeldet. E-Mails, die Sie gerade schreiben, werden als Entwurf gespeichert.",
"max_message": "Ihre Organisation meldet Sie {hours, plural, one {# Stunde} other {# Stunden}} nach der Anmeldung ab. In {seconds, plural, one {# Sekunde} other {# Sekunden}} werden Sie abgemeldet. E-Mails, die Sie gerade schreiben, werden als Entwurf gespeichert.",
"reason_max": "Sie wurden abgemeldet, weil Sie {hours, plural, one {# Stunde} other {# Stunden}} angemeldet waren. Länger erlaubt es Ihre Organisation nicht.",
"drafts_not_saved": "Einige E-Mails, die Sie geschrieben haben, konnten nicht als Entwurf gespeichert werden."
},
"totp_reauth": {
"title": "Neuen Code eingeben",
"description": "Ihr letzter Code gilt nicht mehr. Öffnen Sie Ihre Authenticator-App und geben Sie den neuen Code ein, um weiterzumachen.",
"oauth_hint": "Diese Abfrage kommt immer wieder. Ihre Administration kann das abstellen, indem sie eine andere Anmeldeart (OAuth) einschaltet.",
"code_label": "Code aus Ihrer Authenticator-App"
},
"email_list": {
"permanent_delete_confirm_message": "Diese E-Mail wird endgültig gelöscht. Sie können sie nicht zurückholen.",
"permanent_delete_confirm_batch_message": "{count, plural, one {Diese E-Mail wird} other {Diese # E-Mails werden}} endgültig gelöscht. Sie können sie nicht zurückholen.",
"empty_folder": {
"confirm_message": "Alle E-Mails in diesem Ordner werden endgültig gelöscht. Sie können sie nicht zurückholen."
}
},
"email_viewer": {
"read_receipt": {
"send_failed": "Die Lesebestätigung konnte nicht gesendet werden"
},
"archive_mailbox_not_found": "Es gibt keinen Archiv-Ordner, deshalb kann die E-Mail nicht archiviert werden",
"source_load_failed": "Die Rohdaten der E-Mail konnten nicht geladen werden. Versuchen Sie es noch einmal oder speichern Sie die E-Mail als Datei (.eml).",
"spam": {
"error": "Die E-Mail konnte nicht als Spam markiert werden. Versuchen Sie es noch einmal.",
"error_not_spam": "Die E-Mail konnte nicht zurückgeholt werden. Versuchen Sie es noch einmal."
},
"unsubscribe_banner": {
"error": "Das Abbestellen hat nicht geklappt. Versuchen Sie es später noch einmal."
},
"calendar_invitation": {
"parse_error": "Diese Einladung kann nicht gelesen werden",
"action_failed": "Das hat in Ihrem Kalender nicht geklappt. Versuchen Sie es noch einmal."
},
"undo_send_failed": "Das Senden konnte nicht gestoppt werden. Die E-Mail ist vielleicht schon gesendet.",
"undo_send_signed_out": "Das Senden konnte nicht gestoppt werden, weil Sie bei dem Konto, das sie sendet, nicht mehr angemeldet sind.",
"send_cancelled_restore_failed": "Das Senden wurde gestoppt. Die E-Mail konnte aber nicht zurück in „Entwürfe“ gelegt werden. Sie liegt noch in „Gesendet“.",
"send_cancelled_remove_failed": "Das Senden wurde gestoppt. Die E-Mail konnte aber nicht aus „Gesendet“ entfernt werden.",
"cancel_scheduled_failed": "Das geplante Senden konnte nicht gestoppt werden. Versuchen Sie es noch einmal.",
"save_to_files": {
"failed": "Die Datei konnte nicht in „Dateien“ gespeichert werden. Versuchen Sie es noch einmal."
}
},
"email_composer": {
"files_picker": {
"load_failed": "Ihre Dateien konnten nicht geladen werden. Versuchen Sie es noch einmal."
},
"save_failed": "Der Entwurf konnte nicht gespeichert werden. Versuchen Sie es noch einmal.",
"validation": {
"invalid_recipients": "Nicht gesendet. Diese Adressen sind nicht richtig: {addresses}",
"attachment_upload_failed": "Nicht gesendet. Ein Anhang konnte nicht hochgeladen werden. Entfernen Sie ihn und versuchen Sie es noch einmal."
},
"upload_failed": "{filename} konnte nicht hochgeladen werden. Versuchen Sie es noch einmal.",
"send_failed": "Ihre E-Mail wurde nicht gesendet. Versuchen Sie es noch einmal.",
"send_offline": "Sie sind nicht mit dem Internet verbunden. Ihre E-Mail ist noch da. Senden Sie sie noch einmal, wenn Sie wieder online sind.",
"send_timeout": "Der Server hat nicht mehr geantwortet. Vielleicht wurde Ihre E-Mail trotzdem gesendet. Schauen Sie in Ihren Ordner „Gesendet“, bevor Sie sie noch einmal senden.",
"submission_limit": "Der Server hat die E-Mail nicht gesendet, weil Sie eine Grenze erreicht haben: {reason}",
"send_plugin_failed": "Nicht gesendet. Eine Erweiterung, die die E-Mail sendet, hat nicht funktioniert oder zu lange gebraucht. Schauen Sie in Ihren Ordner „Gesendet“, bevor Sie sie noch einmal senden.",
"schedule_send_invalid": "Geben Sie ein Datum und eine Uhrzeit ein.",
"schedule_send_cleanup_warning": "Die E-Mail wird zur gewählten Zeit gesendet. Der Entwurf konnte aber nicht entfernt werden und liegt noch in „Entwürfe“.",
"invalid_recipient": "Das ist keine richtige E-Mail-Adresse",
"send_filing_warning": "Gesendet. Ein alter Entwurf dieser E-Mail liegt aber vielleicht noch in „Entwürfe“.",
"send_guard_failed": "Nicht gesendet. Eine Erweiterung, die E-Mails vor dem Senden prüft, hat nicht funktioniert oder nicht geantwortet."
},
"common": {
"reconnecting": "Die Verbindung ist weg. Wir versuchen, wieder zu verbinden…"
},
"notifications": {
"error_archiving": "Die E-Mail konnte nicht archiviert werden. Versuchen Sie es noch einmal.",
"error_updating": "Die E-Mail konnte nicht geändert werden. Versuchen Sie es noch einmal.",
"error_sending": "Ihre E-Mail wurde nicht gesendet. Versuchen Sie es noch einmal.",
"error_deleting": "Die E-Mail konnte nicht gelöscht werden. Versuchen Sie es noch einmal.",
"error_loading": "Ihre E-Mails konnten nicht geladen werden. Versuchen Sie es noch einmal.",
"move_failed": "Verschieben hat nicht geklappt. Versuchen Sie es noch einmal.",
"undo_failed": "Rückgängig machen hat nicht geklappt. Versuchen Sie es noch einmal.",
"copy_failed": "Kopieren hat nicht geklappt. Versuchen Sie es noch einmal.",
"move_error": "Die E-Mails konnten nicht in diesen Ordner verschoben werden. Versuchen Sie es noch einmal.",
"tag_failed": "Das Label konnte nicht hinzugefügt werden. Versuchen Sie es noch einmal.",
"identity_create_failed": "Die Absenderadresse konnte nicht hinzugefügt werden: {error}",
"identity_update_failed": "Die Absenderadresse konnte nicht geändert werden: {error}",
"identity_delete_failed": "Die Absenderadresse konnte nicht gelöscht werden: {error}",
"identity_unauthorized": "Sie dürfen von dieser E-Mail-Adresse nicht senden. Fragen Sie Ihre Administration, wenn Sie sie brauchen.",
"vacation_save_failed": "Ihre automatische Antwort konnte nicht gespeichert werden. Versuchen Sie es noch einmal.",
"filters_save_failed": "Ihre Filterregeln konnten nicht gespeichert werden. Versuchen Sie es noch einmal.",
"export_email_error": "Die E-Mail konnte nicht als Datei gespeichert werden. Versuchen Sie es noch einmal.",
"import_email_error": "Die E-Mail konnte nicht aus der Datei hinzugefügt werden. Prüfen Sie die Datei und versuchen Sie es noch einmal.",
"rule_apply_failed": "Die Regel konnte nicht auf die E-Mails angewendet werden, die schon da sind",
"rule_folder_failed": "Der Ordner konnte nicht erstellt werden. Versuchen Sie es noch einmal.",
"vacation_filters_save_failed": "Ihre automatische Antwort ist gespeichert. Die Weiterleitung und die Antwortliste konnten aber nicht gespeichert werden. Versuchen Sie es noch einmal."
},
"settings": {
"reset_confirm": "Alle Einstellungen auf den Anfangszustand zurücksetzen?",
"themes": {
"description": "Wählen Sie ein Aussehen. Ihre Administration kann weitere Designs hinzufügen.",
"locked": "Ihre Administration hat das Design gewählt. Sie können es nicht ändern.",
"forced": "Ihre Administration hat das Design „{name}“ gewählt. Sie können es nicht ändern."
},
"appearance": {
"message_list_order": {
"description": "Die Reihenfolge der E-Mails in der Liste. Der Server sortiert den ganzen Ordner, nicht nur die E-Mails, die Sie sehen.",
"tiebreak_note": "Sie können bis zu drei Stufen wählen. Eine spätere Stufe zählt nur, wenn die früheren gleich sind. Am Ende kommen immer die neuesten E-Mails zuerst.",
"scope": {
"description": "Ist das aus, gilt die Reihenfolge nur im Posteingang. Andere Ordner zeigen die neuesten E-Mails zuerst."
},
"unsupported_note": "Ihr Mailserver kann nicht nach gelesen oder ungelesen, Stern oder Label sortieren. Diese Stufen fallen weg, und die neuesten E-Mails kommen zuerst."
},
"description": "Ändern Sie, wie das Webmail aussieht",
"theme": {
"description": "Wählen Sie helle oder dunkle Farben"
},
"font_size": {
"description": "Machen Sie den Text größer oder kleiner"
},
"list_density": {
"description": "Wie viel Platz zwischen den Einträgen ist"
},
"animations": {
"description": "Dinge beim Öffnen und Schließen sanft bewegen"
},
"toolbar_position": {
"description": "Wo die Knöpfe für eine E-Mail stehen, etwa Antworten, Archivieren oder Löschen"
},
"toolbar_labels": {
"description": "Wörter neben den Symbolen der Knöpfe zeigen. Schalten Sie das aus, um Platz zu sparen, wenn Sie die Symbole kennen."
},
"hide_account_switcher": {
"description": "Die Kontoauswahl oben in der Ordnerliste ausblenden. Sie können das Konto weiterhin unten in der linken Leiste wechseln."
},
"show_rail_account_list": {
"label": "Ihre Konten in der linken Leiste zeigen",
"description": "Unten in der linken Leiste für jedes Konto einen Kreis zeigen, damit Sie schnell wechseln können. Darunter ist ein Knopf zum Abmelden."
},
"unified_mailbox": {
"description": "Ordner zeigen, die die E-Mails mehrerer Konten zusammenfassen, etwa einen Posteingang für alle. Dafür brauchen Sie mehr als ein Konto (oder ein Gruppenpostfach unten).",
"cross_account": {
"description": "Die E-Mails aller Ihrer Konten zusammenfassen, nicht nur des Kontos, das Sie gerade nutzen."
},
"include_group": {
"description": "Auch die Postfächer hinzufügen, die Sie mit einer Gruppe teilen."
}
},
"all_mail": {
"folders_description": "Wählen Sie, welche Ordner dieses Kontos zu den Listen „Alle Mails“, „Ungelesen“ und „Markiert“ gehören."
},
"colorful_sidebar_icons": {
"description": "Ordner- und Label-Symbole farbig zeigen (blauer Posteingang, roter Spam, grünes Gesendet und so weiter). Schalten Sie das aus für graue Symbole."
},
"tint_list_rows": {
"description": "Jede E-Mail in der Liste in der Farbe ihres ersten Labels einfärben. Ist das aus, zeigen die kleinen Punkte die Label-Farbe trotzdem."
},
"show_folder_total_count": {
"description": "Zeigen, wie viele E-Mails ein Ordner oder Label insgesamt hat, neben der Zahl der ungelesenen."
},
"favicon_unread_badge": {
"description": "Die Zahl der ungelesenen E-Mails auf dem Browser-Tab und dem App-Symbol zeigen. So sehen Sie neue E-Mails, auch wenn Sie gerade einen anderen Tab nutzen."
},
"show_shared_mail_accounts": {
"description": "Die Ordner von Postfächern, die andere mit Ihnen teilen, in der Ordnerliste unter „Geteilt“ zeigen. Schalten Sie das aus, um alle auszublenden, oder blenden Sie unten einzelne Konten aus. Kalender und Kontakte bleiben."
},
"pro_interface": {
"description": "Eine Ansicht für große Bildschirme mit vielen Tabs und mehreren Konten gleichzeitig. Sie können jederzeit zur normalen Ansicht zurück."
},
"account_marker": {
"description": "In den zusammengefassten Ordnern jede E-Mail mit der Farbe ihres Kontos markieren."
}
},
"keywords": {
"description": "Erstellen Sie Labels, um Ihre E-Mails zu ordnen. Die Labels werden mit Ihren E-Mails auf dem Server gespeichert.",
"migration_error": "Das Label konnte bei Ihren E-Mails nicht geändert werden. Versuchen Sie es noch einmal.",
"filters_migration_error": "Manche Filterregeln nutzen noch das alte Label. Ändern Sie sie selbst.",
"nesting": {
"description": "Labels in andere Labels legen, wie Ordner. Die Seitenleiste zeigt sie als Baum."
},
"has_children_locked": "In diesem Label liegen andere Labels. Deshalb können Sie es nicht umbenennen oder verschieben. Verschieben oder löschen Sie zuerst die Labels darin.",
"has_children_delete": "Löschen Sie zuerst die Labels in diesem Label",
"unrecognized": {
"description": "Dieser Browser speichert die Namen und Farben Ihrer Labels. Die Labels selbst sind an Ihren E-Mails gespeichert. Suchen Sie in Ihren E-Mails nach Labels, die hier fehlen, und fügen Sie sie wieder hinzu.",
"partial": "Nur Ihre {scanned} neuesten von {total} E-Mails wurden durchsucht. Labels, die nur an älteren E-Mails hängen, fehlen vielleicht.",
"error": "Ihre E-Mails konnten nicht nach Labels durchsucht werden. Versuchen Sie es noch einmal."
}
},
"notifications": {
"push": {
"description": "Erhalten Sie eine Meldung auf Ihrem Bildschirm, wenn neue E-Mails ankommen, auch wenn diese Website geschlossen ist. Der Dienst, der die Meldung weitergibt, sieht Ihre E-Mails nie.",
"relay_label": "Benachrichtigungsdienst",
"relay_desc": "Der Dienst, der Benachrichtigungen an Ihr Gerät weitergibt. Ihre Administration legt fest, welche Sie wählen können.",
"relay_locked_desc": "Ihre Administration hat den Benachrichtigungsdienst gewählt. Sie können ihn nicht ändern.",
"status_unsupported": "Dieser Browser kann keine Benachrichtigungen zeigen, wenn die Website geschlossen ist",
"status_lite": "Diese Version des Webmails kann keine Benachrichtigungen senden, wenn sie geschlossen ist.",
"reenable": "Wieder einschalten",
"ios_hint": "Auf iPhone und iPad fügen Sie diese Website zuerst zum Home-Bildschirm hinzu. Safari sendet Benachrichtigungen nur für Websites auf dem Home-Bildschirm.",
"devices_title": "Geräte, die Benachrichtigungen bekommen",
"devices_desc": "Alle Geräte, die Benachrichtigungen für dieses Konto bekommen. Wenn Sie ein Gerät entfernen, bekommt es sofort keine mehr.",
"devices_error": "Die Liste der Geräte konnte nicht geladen werden. Versuchen Sie es noch einmal.",
"device_status_unknown": "Der Benachrichtigungsdienst kennt dieses Gerät nicht",
"revoke": "Entfernen",
"confirm_revoke_title": "Dieses Gerät entfernen?",
"confirm_revoke_message": "Es bekommt sofort keine Benachrichtigungen mehr. Um sie wieder zu bekommen, schalten Sie die Benachrichtigungen auf diesem Gerät wieder ein.",
"confirm_revoke_message_this": "Dieses Gerät bekommt sofort keine Benachrichtigungen mehr.",
"shared_mailboxes_no_push": "Für Postfächer, die andere mit Ihnen teilen, bekommen Sie keine Benachrichtigungen.",
"error_forbidden": "Der Mailserver erlaubt für dieses Konto keine Benachrichtigungen.",
"error_over_quota": "Zu viele Geräte bekommen Benachrichtigungen für dieses Konto (meist sind höchstens 15 erlaubt). Entfernen Sie Geräte, die Sie nicht mehr nutzen, und versuchen Sie es dann noch einmal."
},
"push_all": {
"description": "Benachrichtigungen für mehrere Konten auf einmal ein- oder ausschalten.",
"result_partial": "Für {enabled} Konten eingeschaltet. Bei {failed} Konten hat es nicht geklappt."
},
"sound_selection": {
"choose_desc": "Wählen Sie einen Ton. Klicken Sie auf den Lautsprecher, um ihn anzuhören."
},
"email": {
"description": "Wählen Sie, wie Sie von neuen E-Mails erfahren",
"inbox_only_desc": "Nur bei E-Mails im Posteingang melden, nicht bei E-Mails, die ein Filter in einen anderen Ordner legt"
},
"calendar": {
"description": "Wählen Sie, wie Sie an Ihre Termine erinnert werden"
}
},
"language_region": {
"description": "Sprache, wie Datum und Uhrzeit aussehen, und andere Dinge für Ihr Land",
"date_locale": {
"description": "Die Reihenfolge von Tag, Monat und Jahr im Datum"
},
"time_format": {
"description": "Uhrzeiten wie 3:00 PM (12 Stunden) oder wie 15:00 (24 Stunden) zeigen"
},
"time_zone": {
"description": "Die Zeiten von E-Mails und Terminen in dieser Zeitzone zeigen. Wenn Sie keine wählen, gilt die Zeitzone Ihres Browsers."
}
},
"email_behavior": {
"inbox_type": {
"description": "Bei „Ungelesene zuerst“ stehen Ihre 25 neuesten ungelesenen E-Mails oben im Posteingang"
},
"clear_search_on_folder_change": {
"description": "Wenn Sie einen anderen Ordner öffnen, endet Ihre Suche und Sie sehen den ganzen Ordner. Ist das aus, sucht die Suche im neuen Ordner weiter."
},
"request_read_receipt": {
"label": "Immer eine Lesebestätigung anfordern",
"description": "Neue E-Mails bitten den Empfänger, Ihnen zu melden, wenn er die E-Mail geöffnet hat. Sie können das bei jeder E-Mail ausschalten."
},
"read_receipt_response": {
"label": "Wenn jemand eine Lesebestätigung möchte",
"description": "Manche Absender möchten erfahren, wann Sie ihre E-Mail öffnen. Wählen Sie, was dann passiert."
},
"description": "Legen Sie fest, was mit Ihren E-Mails passiert",
"mark_read": {
"description": "Wann eine geöffnete E-Mail als gelesen gilt"
},
"delete_action": {
"warning": "Gelöschte E-Mails sind dann endgültig weg. Sie können sie nicht zurückholen."
},
"message_spacing": {
"description": "Der Abstand um den Text einer geöffneten E-Mail"
},
"archive_mode": {
"description": "Wie archivierte E-Mails in Ordner sortiert werden"
},
"permanently_delete_junk": {
"label": "Spam endgültig löschen",
"description": "Wenn Sie eine E-Mail im Spam-Ordner löschen, ist sie sofort endgültig weg. Sie kommt nicht erst in den Papierkorb."
},
"confirm_bulk_delete": {
"description": "Fragen, bevor mehrere ausgewählte E-Mails in den Papierkorb kommen. Beim endgültigen Löschen wird immer gefragt."
},
"mail_layout": {
"description": "Wählen Sie, wie die E-Mail-Liste und die geöffnete E-Mail auf dem Bildschirm stehen.",
"split_description": "Die E-Mail-Liste und die geöffnete E-Mail stehen nebeneinander.",
"focus_description": "Jede E-Mail hat eine Zeile. Eine geöffnete E-Mail nutzt die ganze Breite. Die Ordnerliste bleibt sichtbar.",
"horizontal_description": "Die E-Mail-Liste steht oben. Die geöffnete E-Mail steht darunter."
},
"list_columns": {
"description": "Die Liste als Tabelle mit den Spalten Von, Betreff, Datum und Größe zeigen."
},
"show_verification_codes": {
"description": "Wenn eine E-Mail einen Anmelde- oder Bestätigungscode enthält, gibt es einen Knopf zum Kopieren. In der Liste bleibt der Knopf einen Tag lang."
},
"plain_text_mode": {
"description": "Alle E-Mails als reinen Text schreiben, ohne Fettdruck, Farben oder Bilder. Das gilt auch für Antworten und Weiterleitungen."
},
"auto_select_reply_identity": {
"description": "Wenn Sie antworten, geht die Antwort von der Adresse aus, an die die E-Mail ging. Das klappt mit all Ihren eigenen Adressen. Weiter unten wählen Sie, ob auch andere Adressen Ihrer Domain zählen."
},
"reply_identity_match": {
"label": "Welche Adressen als Ihre gelten",
"description": "„Nur exakte Adresse“ nutzt nur die Adressen, die Sie eingerichtet haben. „Jede Adresse auf meinen Domains“ antwortet auch von anderen Adressen Ihrer Domains. Wählen Sie die exakte Adresse, wenn diese anderen Adressen Verteilerlisten sind."
},
"signature_position": {
"description": "Wo Ihre Signatur steht, wenn Sie antworten oder weiterleiten. Über dem zitierten Text schließt sie Ihre Antwort ab. Darunter bleibt die alte E-Mail an einem Stück."
},
"signature_separator": {
"label": "Linie vor der Signatur",
"description": "Die übliche Zeile „-- “ vor Ihre Signatur setzen. Viele Mail-Apps erkennen die Signatur daran."
},
"sub_address_delimiter": {
"label": "Zeichen vor dem Adress-Stichwort",
"description": "Das Zeichen zwischen Ihrem Namen und dem Stichwort in einer Adresse, etwa user{delimiter}tag@domain.com. Nehmen Sie dasselbe Zeichen wie Ihr Mailserver."
},
"attachment_click_action": {
"label": "Wenn Sie auf einen Anhang klicken",
"description": "Wählen Sie, ob ein Klick auf einen Anhang ihn anzeigt oder auf Ihrem Gerät speichert"
},
"attachment_position": {
"description": "Wo Anhänge in einer geöffneten E-Mail stehen"
},
"always_light_mode": {
"description": "E-Mails mit hellem Hintergrund zeigen, auch wenn die App dunkel ist. Manche E-Mails sehen falsch aus, wenn sie dunkel dargestellt werden."
},
"external_content": {
"label": "Bilder aus dem Internet",
"description": "Wählen Sie, ob Bilder gezeigt werden, die eine E-Mail aus dem Internet lädt. Das Laden kann dem Absender verraten, dass Sie die E-Mail geöffnet haben."
},
"trusted_senders": {
"description": "Absender, deren Bilder immer gezeigt werden",
"empty_description": "Wenn eine E-Mail blockierte Bilder hat, klicken Sie auf „Diesem Absender immer vertrauen“, um den Absender hier hinzuzufügen.",
"save_error": "Die Liste konnte nicht gespeichert werden. Versuchen Sie es noch einmal.",
"use_address_book_label": "Liste im Adressbuch speichern",
"use_address_book_description": "Vertrauenswürdige Absender in einem eigenen Adressbuch speichern. Dann ist die Liste auf allen Ihren Geräten gleich."
},
"hover_actions": {
"description": "Die Knöpfe, die Sie sehen, wenn Sie mit der Maus über eine E-Mail in der Liste fahren"
},
"default_mail_program": {
"description": "E-Mail-Links auf Websites (mailto:-Links) in {appName} öffnen",
"error": "Ihr Browser kann das nicht"
},
"attachment_reminder": {
"description": "Wenn Ihre E-Mail von einem Anhang spricht, aber nichts angehängt ist, werden Sie vor dem Senden gewarnt",
"keywords_label": "Wörter, bei denen die Erinnerung kommt",
"keywords_description": "Wenn Ihre E-Mail eines dieser Wörter enthält, kommt die Erinnerung"
},
"placeholder_warning": {
"description": "Vor dem Senden fragen, wenn die E-Mail noch Lücken hat, etwa [TIME] oder [Ihr Name], leere Vorlagenfelder oder Notizen wie TODO."
},
"recipient_mentions": {
"description": "Tippen Sie @ in Ihrer E-Mail, um einen Empfänger auszuwählen. Sein Vorname kommt in den Text. Mit Enter oder Tab wählen Sie den markierten Namen."
},
"mention_adds_recipient": {
"description": "Wenn Sie @ tippen, können Sie auch Personen aus Ihren Kontakten wählen. Sind sie noch keine Empfänger, werden sie zur E-Mail hinzugefügt."
},
"hide_inline_image_attachments": {
"label": "Bilder im Text nicht als Anhang zeigen",
"description": "Bilder, die zum Text der E-Mail gehören, stehen nicht noch einmal in der Liste der Anhänge"
},
"attachment_image_previews": {
"description": "Kleine Vorschaubilder von angehängten Bildern statt eines Datei-Symbols zeigen"
},
"send_delay": {
"description": "Ihre E-Mails warten ein paar Sekunden, bevor sie gesendet werden. In dieser Zeit können Sie sie noch stoppen.",
"unsupported": "Der Mailserver dieses Kontos kann mit dem Senden nicht warten. Die Einstellung wird trotzdem für Ihre anderen Konten gespeichert."
},
"swipe_right_action": {
"description": "Auf einem Touchscreen: was passiert, wenn Sie eine E-Mail nach rechts wischen."
},
"swipe_left_action": {
"description": "Auf einem Touchscreen: was passiert, wenn Sie eine E-Mail nach links wischen."
},
"rtl_editing": {
"label": "Schreiben von rechts nach links",
"description": "Fügt dem Editor einen Knopf hinzu. Damit schreiben Sie einen Absatz von links nach rechts oder von rechts nach links."
}
},
"account": {
"description": "Sehen Sie die Angaben zu Ihrem Konto",
"auth_method_label": "So melden Sie sich an",
"auth_method_oauth": "Anmeldeseite Ihrer Organisation",
"server": {
"label": "Adresse des Mailservers"
},
"accounts": {
"description": "Ziehen Sie die Konten, um ihre Reihenfolge in der Kontoliste zu ändern"
},
"backup": {
"description": "Speichern Sie die Liste Ihrer Konten auf dem Server und holen Sie sie auf einem anderen Gerät zurück. Ein Passwort, das nur Sie kennen, schützt die Liste. Die Passwörter der Postfächer werden nicht gespeichert. Sie melden sich also bei jedem Konto neu an.",
"description_passwords": "Speichern Sie die Liste Ihrer Konten auf dem Server und holen Sie sie auf einem anderen Gerät zurück. Ein Passwort, das nur Sie kennen, schützt die Liste. Sie können auch die Passwörter der Postfächer speichern. Dann melden sich die Konten von selbst an.",
"password_hint": "Mindestens 10 Zeichen. Niemand kann dieses Passwort zurücksetzen. Wenn Sie es vergessen, speichern Sie die Liste noch einmal mit einem neuen Passwort.",
"restore_hint": "Das Passwort, das Sie beim Speichern der Liste gewählt haben.",
"include_passwords_hint": "Wenn Sie die Liste zurückholen, melden sich diese Konten von selbst an. Aber Vorsicht: Wer das Passwort der Sicherung kennt, kann jedes Postfach in der Liste öffnen.",
"skipped": "{count, plural, one {# Konto wurde} other {# Konten wurden}} nicht hinzugefügt, weil Sie schon die höchste erlaubte Zahl an Konten haben.",
"sign_in_failed": "{count, plural, one {# gespeichertes Passwort hat nicht funktioniert} other {# gespeicherte Passwörter haben nicht funktioniert}}. Melden Sie sich in der Kontoliste selbst bei diesen Konten an.",
"errors": {
"unlock_failed": "Das Passwort ist falsch, oder diese Liste gehört zu einem anderen Konto.",
"owner_signin_required": "Melden Sie sich noch einmal bei diesem Konto an. Versuchen Sie es dann erneut.",
"https_required": "Das geht nur über eine sichere Verbindung (die Adresse beginnt mit https).",
"storage_failed": "Der Server konnte die Liste nicht speichern oder öffnen. Versuchen Sie es später noch einmal."
}
},
"shared_accounts": {
"description": "Konten, die Sie mit anderen teilen und verwalten dürfen. Wählen Sie eines aus, um seine Filter, automatischen Antworten, Kalender und Kontakte zu ändern."
}
},
"security": {
"description": "Ändern Sie Ihr Passwort und schützen Sie Ihr Konto",
"not_available": "Die Sicherheitseinstellungen für diesen Mailserver können Sie hier nicht ändern. Vielleicht erlaubt Ihr Server das nicht. Mehr dazu steht in der <a href=\"https://bulwarkmail.org/docs/guides/account-security\" class=\"underline hover:opacity-80\" target=\"_blank\">Dokumentation</a>.",
"password": {
"error_title": "Ihr Passwort wurde nicht geändert",
"error_mismatch": "Die beiden neuen Passwörter sind nicht gleich. Geben Sie sie noch einmal ein.",
"error_min_length": "Ihr Passwort braucht mindestens 8 Zeichen",
"error_generic": "Ihr Passwort konnte nicht geändert werden. Versuchen Sie es noch einmal."
},
"display_name": {
"description": "Der Name, den der Mailserver für Sie anzeigt",
"error": "Ihr Name konnte nicht geändert werden. Versuchen Sie es noch einmal."
},
"totp": {
"label": "Code aus einer Authenticator-App",
"description": "Schützen Sie Ihr Konto besser: Bei der Anmeldung geben Sie zusätzlich einen Code aus einer App auf Ihrem Handy ein",
"enable_error": "Die Zwei-Faktor-Authentifizierung konnte nicht eingeschaltet werden. Versuchen Sie es noch einmal.",
"disable_error": "Die Zwei-Faktor-Authentifizierung konnte nicht ausgeschaltet werden. Versuchen Sie es noch einmal.",
"setup_instructions": "Öffnen Sie Ihre Authenticator-App (zum Beispiel Google Authenticator oder Authy) und fügen Sie diesen Link dort hinzu:",
"verification_code": "Code aus der App",
"disable_confirm_prompt": "Um die Zwei-Faktor-Authentifizierung auszuschalten, geben Sie Ihr Passwort ein.",
"password_required": "Geben Sie Ihr Passwort ein",
"code_required": "Geben Sie den Code aus der App ein",
"code_invalid": "Dieser Code ist falsch oder zu alt. Geben Sie den Code ein, den die App gerade anzeigt."
},
"app_passwords": {
"description": "Zusätzliche Passwörter für Mail-Apps, die keinen zweiten Code abfragen können, etwa manche Mail-Apps auf dem Handy",
"allowed_ips_label": "Nur von diesen Internetadressen (optional)",
"allowed_ips_hint": "Trennen Sie die Adressen mit Komma oder Leerzeichen. Lassen Sie das Feld leer, um alle Adressen zu erlauben.",
"copy_now_warning": "Kopieren Sie dieses Passwort jetzt. Sie sehen es nicht noch einmal.",
"add_error": "Das App-Passwort konnte nicht erstellt werden. Versuchen Sie es noch einmal.",
"remove_error": "Das App-Passwort konnte nicht gelöscht werden. Versuchen Sie es noch einmal.",
"delete_message": "Apps, die {name} nutzen, können sich dann nicht mehr anmelden."
},
"api_keys": {
"description": "Schlüssel für Programme und Skripte, die ohne Sie mit Ihrem Postfach arbeiten, zum Beispiel eine Sicherung",
"copy_now_warning": "Kopieren Sie diesen Schlüssel jetzt. Sie sehen ihn nicht noch einmal.",
"add_error": "Der Schlüssel konnte nicht erstellt werden. Versuchen Sie es noch einmal.",
"remove_error": "Der Schlüssel konnte nicht gelöscht werden. Versuchen Sie es noch einmal.",
"delete_message": "Programme und Skripte, die {name} nutzen, funktionieren dann nicht mehr."
},
"encryption": {
"label": "E-Mails verschlüsselt speichern",
"description": "Der Server speichert Ihre E-Mails verschlüsselt. Nur Ihr Schlüssel kann sie wieder lesbar machen.",
"error": "Die Verschlüsselung konnte nicht geändert werden. Versuchen Sie es noch einmal.",
"algorithm_label": "Art der Verschlüsselung",
"encrypt_on_append": "Auch E-Mails verschlüsseln, die Apps in Ihr Postfach legen",
"allow_spam_training": "Den Spamfilter aus neuen E-Mails lernen lassen, bevor sie verschlüsselt werden",
"disable_message": "Ab jetzt speichert der Server neue E-Mails ohne Verschlüsselung."
},
"public_keys": {
"key_label": "Öffentlicher Schlüssel (S/MIME oder PGP, als Text)",
"add_error": "Der öffentliche Schlüssel konnte nicht hinzugefügt werden. Prüfen Sie, ob Sie ihn ganz eingefügt haben.",
"remove_error": "Der öffentliche Schlüssel konnte nicht gelöscht werden. Versuchen Sie es noch einmal."
},
"email_client": {
"description": "Mit diesen Angaben richten Sie E-Mail auf Ihrem Computer oder Handy ein, zum Beispiel in Thunderbird, Apple Mail oder Outlook",
"jmap_username_label": "Benutzername für Mail-Apps",
"password_instructions": "Nutzen Sie in Ihrer Mail-App den Benutzernamen oben und ein App-Passwort. Ein App-Passwort erstellen Sie weiter unten auf dieser Seite."
},
"link_device": {
"description": "Melden Sie sich in der Bulwark-Mail-App auf Ihrem Handy an, ohne Ihr Passwort einzutippen. Ihr Handy bekommt eine eigene Anmeldung. Wenn Sie sich hier abmelden, bleibt Ihr Handy angemeldet.",
"error": "Der Code konnte nicht erstellt werden. Versuchen Sie es noch einmal.",
"password_prompt": "Geben Sie Ihr Passwort ein, damit wir wissen, dass Sie es sind. Ihr Passwort wird nicht an das Handy geschickt.",
"error_not_signed_in": "Sie wurden abgemeldet. Melden Sie sich wieder an und verbinden Sie dann Ihr Handy.",
"error_impersonation": "Sie sehen gerade das Postfach einer anderen Person. Von hier aus können Sie kein Handy verbinden.",
"error_too_many_attempts": "Sie haben es zu oft versucht. Warten Sie kurz und versuchen Sie es dann noch einmal.",
"error_session_secret": "Das Verbinden eines Handys ist auf diesem Server nicht eingerichtet. Fragen Sie Ihre Administration.",
"error_server_unreachable": "Wir erreichen Ihren Mailserver nicht. Versuchen Sie es noch einmal.",
"error_pairing_unavailable": "Ihr Mailserver erlaubt das Verbinden eines Handys auf diese Weise nicht. Fragen Sie Ihre Administration.",
"error_insecure_server": "Die Handy-App braucht eine sichere Verbindung zum Mailserver (https). Dieser Server hat keine."
},
"sections": {
"devices_description": "Lesen Sie Ihre E-Mails in anderen Apps und auf anderen Geräten."
}
},
"identities": {
"description": "Die E-Mail-Adressen, von denen Sie senden können",
"identities_count": {
"label": "Ihre Absenderadressen",
"description": "Adressen, von denen Sie E-Mails senden können"
},
"sub_addressing": {
"label": "Adressen mit Stichwort",
"description": "Hängen Sie ein Stichwort an Ihre Adresse, etwa user+shop@domain.com. E-Mails daran kommen trotzdem bei Ihnen an, und Sie können sie nach dem Stichwort sortieren."
}
},
"vacation": {
"description": "Neue E-Mails automatisch beantworten, während Sie weg sind",
"not_supported": "Ihr Mailserver kann keine automatischen Antworten senden.",
"fetch_error": "Ihre automatische Antwort konnte nicht geladen werden. Versuchen Sie es noch einmal.",
"status": {
"description": "Alle, die Ihnen schreiben, bekommen eine automatische Antwort"
},
"date_range": {
"start_description": "Leer lassen, um sofort zu beginnen"
},
"message": {
"subject_description": "Der Betreff der automatischen Antwort",
"html_description": "Fügen Sie eine Version mit Links und Formatierung hinzu. Wer sie in seiner Mail-App nicht sehen kann, bekommt den einfachen Text oben."
},
"warnings": {
"invalid_date": "Geben Sie ein Start- und Enddatum mit Uhrzeit ein oder lassen Sie das Feld leer",
"empty_body": "Die Nachricht ist leer. Die Leute würden eine Antwort ohne Text bekommen.",
"subject_too_long": "Der Betreff ist zu lang für den Server. Kürzen Sie ihn (höchstens {max} Byte).",
"body_too_long": "Die Nachricht ist zu lang für den Server. Kürzen Sie sie (höchstens {max} Byte, mit Formatierung).",
"forward_address": "Geben Sie die E-Mail-Adresse ein, an die weitergeleitet wird",
"not_running": "Weiterleitung und Antwortliste sind gespeichert, aber gerade aus. Klicken Sie auf Speichern, um sie wieder einzuschalten."
},
"forward": {
"description": "Neue E-Mails in der Zeit oben an eine andere Adresse weiterschicken, mit oder ohne automatische Antwort. Ohne Enddatum läuft das, bis Sie es ausschalten. Spam wird nicht weitergeleitet.",
"keep_description": "Ist das aus, wird die E-Mail nur weitergeleitet. Sie bleibt nicht in diesem Postfach, und Ihre Filterregeln laufen nicht darauf."
}
},
"folders": {
"description": "Verwalten Sie Ihre Ordner und legen Sie fest, wofür welcher Ordner dient",
"folder_list_description": "Klicken Sie auf das Symbol eines Ordners, um es zu ändern",
"standard_roles_description": "Legen Sie fest, welcher Ordner Ihr Posteingang, Gesendet, Papierkorb und so weiter ist.",
"reorder": "Ziehen Sie nach oben oder unten, um die Reihenfolge zu ändern. Ziehen Sie nach links oder rechts, um den Ordner in einen anderen Ordner zu legen.",
"move_partial_error": "{count, plural, one {# Ordner konnte} other {# Ordner konnten}} nicht verschoben werden. Versuchen Sie es noch einmal.",
"confirm_delete": "„{name}“ löschen? Die E-Mails in diesem Ordner kommen in den Papierkorb.",
"cannot_delete_role": "Dieser Ordner hat eine besondere Aufgabe, etwa Posteingang oder Papierkorb. Entfernen Sie zuerst die Aufgabe, dann können Sie ihn löschen.",
"error_delete_has_children": "Der Ordner wurde nicht gelöscht, weil andere Ordner darin liegen. Löschen oder verschieben Sie diese zuerst.",
"error_delete_has_email": "Der Ordner wurde nicht gelöscht, weil noch E-Mails darin liegen. Verschieben oder löschen Sie diese zuerst.",
"error_role": "Die Aufgabe des Ordners konnte nicht geändert werden. Versuchen Sie es noch einmal."
},
"advanced": {
"debug_mode": {
"description": "Mehr Einzelheiten darüber aufzeichnen, was die App tut. Das hilft, Fehler zu finden."
},
"settings_sync": {
"label": "Gleiche Einstellungen auf allen Geräten",
"description": "Ihre Einstellungen auf dem Server speichern, damit alle Ihre Browser und Geräte sie nutzen"
},
"sender_favicons": {
"description": "Bei Firmen das Symbol ihrer Website als Bild des Absenders zeigen",
"refresh_description": "Die Website-Symbole neu laden, zum Beispiel wenn eine Firma ein neues hat"
},
"show_avatars_in_junk": {
"label": "Bilder der Absender im Spam-Ordner zeigen",
"description": "Das Bild oder Website-Symbol des Absenders auch im Spam-Ordner zeigen. Das ist anfangs aus, weil gefälschte E-Mails mit Bild echter wirken."
},
"keyboard_shortcuts": {
"description": "Alle Tasten ansehen, die Sie statt der Maus nutzen können"
},
"refresh_cache": {
"label": "Daten neu vom Server laden",
"description": "Ihre Kontakte, Kalender und Ordner neu vom Server laden. Nutzen Sie das, wenn etwas alt oder falsch aussieht. Sie bleiben angemeldet.",
"confirm_title": "Daten neu vom Server laden?"
},
"reset_settings": {
"description": "Alle Einstellungen auf den Anfangszustand zurücksetzen"
},
"export_settings": {
"description": "Ihre Einstellungen als Datei auf Ihrem Gerät speichern"
},
"import_settings": {
"description": "Ihre Einstellungen aus einer früher gespeicherten Datei laden"
},
"sections": {
"backup_description": "Dieselben Einstellungen in anderen Browsern und auf anderen Geräten nutzen.",
"fix_description": "Wenn etwas alt oder falsch aussieht oder Sie neu anfangen möchten."
}
},
"sidebar_apps": {
"description": "Fügen Sie der linken Leiste eigene Apps und Links hinzu",
"keep_loaded_description": "Apps im Hintergrund offen lassen, wenn Sie wechseln, damit sie nicht neu laden müssen",
"managed_description": "Ihre Administration hat diese Apps für alle eingerichtet. Sie können sie nicht ändern."
},
"contacts": {
"group_by_letter_description": "In der Kontaktliste einen Buchstaben über jede Gruppe von Namen setzen",
"sort_by_last_name_description": "Kontakte nach Nachnamen sortieren, damit Familien nebeneinander stehen",
"import_description": "Kontakte aus einer Kontaktdatei (.vcf) hinzufügen",
"export_description": "Alle Kontakte in einer Kontaktdatei (.vcf) speichern"
},
"downloads": {
"description": "Legen Sie die Dateinamen für E-Mails und Anhänge fest, die Sie speichern.",
"email_template": {
"description": "Der Dateiname, wenn Sie eine E-Mail speichern oder in einen Ordner auf Ihrem Computer ziehen. Die Endung .eml wird automatisch angehängt."
},
"attachment_template": {
"description": "Der Dateiname, wenn Sie einen Anhang speichern oder herausziehen. Wenn Sie '{filename}' und '{ext}' weglassen, behält die Datei ihre Endung."
},
"bundle_template": {
"description": "Der Dateiname, wenn Sie mehrere E-Mails zusammen in einer .zip-Datei speichern. Die Endung .zip wird automatisch angehängt."
},
"spaces": {
"description": "Im Dateinamen ein anderes Zeichen statt Leerzeichen nutzen."
},
"lowercase": {
"description": "Den ganzen Dateinamen kleinschreiben."
},
"strip_diacritics": {
"description": "Buchstaben mit Akzenten in einfache Buchstaben ändern (ä → a, é → e). Manche Programme kommen mit Akzenten nicht klar."
},
"collapse_separators": {
"description": "Wenn mehrere Leerzeichen, _ oder - hintereinander stehen, nur eines behalten."
},
"after_export": {
"description": "Sie können die E-Mail in einen anderen Ordner verschieben, nachdem Sie sie als Datei gespeichert haben."
}
},
"filters": {
"description": "Regeln, die neue E-Mails von selbst sortieren, zum Beispiel in einen Ordner",
"no_rules_description": "Erstellen Sie eine Regel, dann werden neue E-Mails von selbst sortiert",
"vacation_active_description": "Neue E-Mails bekommen eine automatische Antwort",
"delete_confirm": "Diese Regel löschen?",
"raw_editor": "Regeln als Code bearbeiten (Sieve)",
"raw_editor_warning": "Wenn Sie den Code ändern, können Sie die Regeln hier vielleicht nicht mehr mit dem normalen Editor bearbeiten. Ihre Änderungen im Code ersetzen die normalen Regeln.",
"not_supported": "Ihr Mailserver kann keine Filterregeln nutzen.",
"match_all": "Alle Bedingungen müssen zutreffen",
"match_any": "Eine Bedingung genügt",
"stop_processing": "Die Regeln darunter nicht mehr prüfen",
"condition_fields": {
"header": "Andere Kopfzeile",
"body": "Text der E-Mail"
},
"comparators": {
"matches": "passt zum Muster (* = beliebiger Text)"
},
"forward_limit": "Dieser Server leitet eine E-Mail an höchstens {count} Adressen weiter. Weitere Weiterleitungen fallen weg.",
"forward_unavailable": "Dieser Server erlaubt keine Weiterleitung. Regeln, die E-Mails weiterleiten, tun nichts.",
"header_name": "Name der Kopfzeile",
"opaque_warning": "Diese Regeln wurden in einer anderen App geändert. Sie können sie hier nur als Code bearbeiten.",
"open_sieve_editor": "Regeln als Code bearbeiten (Sieve)",
"expanded_view_description": "Jede Regel mit allen Bedingungen und Aktionen zeigen",
"reset_warning": "Damit werden alle Ihre jetzigen Regeln gelöscht und Sie fangen ohne Regeln neu an.",
"validation_empty_conditions": "Fügen Sie mindestens eine Bedingung hinzu und füllen Sie ihren Wert aus",
"validation_empty_actions": "Fügen Sie mindestens eine Aktion hinzu",
"validation_header_name": "Verwenden Sie im Namen der Kopfzeile nur die Buchstaben A bis Z, Ziffern, - und _",
"sieve_editor": {
"title": "Filterregeln als Code (Sieve)",
"warning": "Wenn Sie den Code ändern, können Sie die Regeln hier vielleicht nicht mehr mit dem normalen Editor bearbeiten. Ihre Änderungen im Code ersetzen die normalen Regeln.",
"script_content": "Regeln als Code",
"valid": "Der Code hat keine Fehler",
"invalid": "Der Code hat Fehler",
"validating": "Wird geprüft…",
"validate": "Prüfen",
"overwrite_message": "Dieser Code enthält die Regeln aus dem normalen Editor nicht mehr. Wenn Sie speichern, ersetzt er sie. Das können Sie nicht rückgängig machen.",
"validation_failed": "Der Code konnte nicht geprüft werden. Versuchen Sie es noch einmal."
},
"managed_by_tooltip": "{source} verwaltet das. Ändern Sie es in dieser App oder bearbeiten Sie die Regeln als Code.",
"apply_existing_unsupported": "Nur manche Regeln können auch für E-Mails laufen, die schon da sind: Regeln, die Absender, Empfänger, Betreff oder Kopfzeilen prüfen und die verschieben, kopieren, als gelesen markieren, mit Stern markieren oder mit Label versehen.",
"period_hint": "Die Regel gilt für E-Mails, die in dieser Zeit ankommen. Lassen Sie ein Feld leer, wenn es keinen Start oder kein Ende gibt.",
"validation_period_invalid": "Geben Sie ein Datum mit Uhrzeit ein oder lassen Sie das Feld leer",
"apply_existing_period": "Eine Regel mit Zeitraum gilt nur für E-Mails, die in dieser Zeit ankommen. Deshalb kann sie nicht für E-Mails laufen, die schon da sind."
},
"templates": {
"description": "Speichern Sie E-Mails, die Sie oft schreiben, und nutzen Sie sie wieder. Sie können Lücken haben, die Sie jedes Mal ausfüllen.",
"export_import_description": "Speichern Sie Ihre Vorlagen als Datei oder bringen Sie sie auf ein anderes Gerät",
"validation": {
"too_long": "Der Name darf höchstens 200 Zeichen haben"
}
},
"files": {
"sort_direction": {
"description": "Von A bis Z oder von Z bis A"
},
"show_thumbnails": {
"description": "Von jedem Bild ein kleines Vorschaubild statt eines Symbols zeigen"
},
"show_hidden": {
"description": "Versteckte Dateien und Ordner zeigen (ihr Name beginnt mit einem Punkt)"
}
},
"reading_page": {
"sections": {
"images_description": "Bilder aus dem Internet können dem Absender verraten, dass Sie die E-Mail geöffnet haben."
}
}
},
"errors": {
"page_error_description": "Diese Seite funktioniert gerade nicht. Versuchen Sie es noch einmal oder gehen Sie zurück zum Posteingang.",
"sidebar_error": "Ihre Ordner konnten nicht geladen werden",
"email_list_error": "Ihre E-Mails konnten nicht geladen werden",
"viewer_error_title": "Diese E-Mail kann nicht angezeigt werden",
"viewer_error_description": "Etwas in dieser E-Mail verhindert, dass sie hier angezeigt wird. Vielleicht nutzt sie ein Format, das wir nicht unterstützen.",
"composer_error": "Das Fenster für neue E-Mails konnte nicht geöffnet werden",
"settings_error_title": "Die Einstellungen konnten nicht geöffnet werden",
"settings_error_description": "Ihre Einstellungen konnten nicht geladen werden. Was Sie jetzt ändern, wird vielleicht nicht gespeichert."
},
"identities": {
"delete_confirm": "Diese Absenderadresse löschen? Das können Sie nicht rückgängig machen.",
"cannot_delete": "Diese Absenderadresse können Sie nicht löschen",
"validation_errors": {
"invalid_emails": "Diese E-Mail-Adressen sind nicht richtig: {emails}"
},
"form": {
"email_invalid": "Geben Sie eine richtige E-Mail-Adresse ein",
"email_immutable": "Die E-Mail-Adresse können Sie nach dem Hinzufügen nicht mehr ändern",
"signature_byte_limit_reached": "Die Signatur ist zu lang für den Server"
},
"sub_address": {
"invalid_tag": "Verwenden Sie im Stichwort nur Buchstaben, Ziffern und Bindestriche (-)"
}
},
"calendar": {
"import": {
"url_description": "Geben Sie die Webadresse einer Kalenderdatei (.ics) ein, um ihre Termine hinzuzufügen."
},
"management": {
"description": "Erstellen Sie neue Kalender, benennen Sie sie um und ändern Sie ihre Farbe. Sie können auch mit der rechten Maustaste auf einen Kalender in der Seitenleiste klicken, um die Farbe zu ändern.",
"description_touch": "Erstellen Sie neue Kalender, benennen Sie sie um und ändern Sie ihre Farbe.",
"confirm_delete": "„{name}“ löschen? Alle Termine in diesem Kalender werden auch gelöscht.",
"confirm_clear": "Alle Termine in „{name}“ löschen? Das können Sie nicht rückgängig machen.",
"kind_hint": "Das legt fest, welche Apps auf Ihren anderen Geräten diesen Kalender zeigen. Sie können es später nicht ändern.",
"default_alerts_hint": "Neue Termine in diesem Kalender bekommen diese Erinnerungen, außer Sie ändern sie im Termin. Eine Änderung hier ändert keine Termine, die Sie schon haben. Erinnerungen per E-Mail schickt der Mailserver."
},
"subscription": {
"description": "Folgen Sie einem Kalender aus dem Internet, zum Beispiel mit Feiertagen. Seine Termine werden von selbst aktualisiert und in einem eigenen Kalender gezeigt. Die Adresse beginnt mit https:// oder webcal://.",
"confirm_delete": "„{name}“ nicht mehr folgen? Der Kalender und alle seine Termine werden entfernt."
}
},
"sharing": {
"description": "Erlauben Sie anderen Personen oder Gruppen auf diesem Server den Zugriff. Die Änderung gilt sofort.",
"directory_restricted": "Ihr Server zeigt keine Liste aller Benutzer. Deshalb sehen Sie nur Ihre Gruppen und Personen, die etwas mit Ihnen teilen. Ihre Administration kann das ändern.",
"directory_not_browsable": "Ihr Server zeigt keine Liste aller Benutzer.",
"directory_failed": "Die Liste der Personen und Gruppen konnte nicht geladen werden. Versuchen Sie es noch einmal.",
"share_failed": "Die Freigabe konnte nicht geändert werden. Versuchen Sie es noch einmal."
},
"delegates": {
"description": "Erlauben Sie jemandem in einem Schritt den Zugriff auf Ihre E-Mails, Kalender und Kontakte. Sehen Sie, wer worauf Zugriff hat.",
"read_failed": "Ein Teil Ihrer Freigaben konnte nicht geladen werden. Sie können Zugriffe ändern oder entfernen, sobald alles geladen ist.",
"remove_message": "{name} hat dann keinen Zugriff mehr auf alles, was Sie aus Ihrem Konto geteilt haben: E-Mails, Kalender, Kontakte und Dateien.",
"not_found": "Auf diesem Server gibt es niemanden mit der Adresse {address}.",
"lookup_failed": "Diese Adresse wurde nicht gefunden. Prüfen Sie sie und versuchen Sie es noch einmal.",
"known_only": "Sie sehen nur Ihre Gruppen und Personen, die etwas mit Ihnen geteilt haben.",
"directory_off_note": "Um jemand Neues hinzuzufügen, muss Ihre Administration zuerst die Suche nach Benutzern auf dem Server erlauben.",
"directory_failed": "Die Liste der Personen und Gruppen konnte nicht geladen werden. Versuchen Sie es noch einmal.",
"nothing_to_change": "Es hat sich nichts geändert. Wählen Sie, was die Person bekommen soll, oder klicken Sie auf „Zugriff entfernen“.",
"change_note": "Ordner, Kalender und Adressbücher, die Sie nicht auswählen, sind nicht mehr geteilt. Geteilte Dateien bleiben geteilt. „Zugriff entfernen“ nimmt auch diese weg.",
"rights_keep_hint": "Was die Person schon hat, bleibt gleich. Alles Neue bekommt dieselben Rechte wie der Rest dieser Art, oder nur Lesen.",
"send_note": "Personen mit Zugriff können Ihre E-Mails und Ihren Kalender lesen und ordnen. Sie können keine E-Mails als Sie oder für Sie senden. Wenn mehrere Personen eine Adresse brauchen, fragen Sie Ihre Administration nach einem geteilten Postfach.",
"partial_title": "Einiges konnte nicht geändert werden. Alles andere ist erledigt."
},
"smime": {
"your_certificates_desc": "Fügen Sie Ihre Zertifikate hinzu und verwalten Sie sie. Sie brauchen eines, um E-Mails mit S/MIME zu signieren oder zu verschlüsseln.",
"recipient_certificates_desc": "Zertifikate anderer Personen. Damit können Sie diesen Personen verschlüsselte E-Mails senden.",
"identity_bindings_desc": "Wählen Sie, welches Zertifikat zu welcher Ihrer Absenderadressen gehört",
"defaults_desc": "Wählen Sie, ob neue E-Mails von Anfang an signiert oder verschlüsselt werden",
"encrypt_by_default_desc": "Eine E-Mail von selbst verschlüsseln, wenn Sie für jeden Empfänger ein Zertifikat haben",
"remember_unlocked_desc": "Ihre Schlüssel entsperrt lassen, bis Sie diesen Browser schließen",
"p12_passphrase_desc": "Geben Sie das Passwort dieser Zertifikatsdatei ein",
"storage_passphrase_desc": "Wählen Sie ein Passwort. Es schützt diesen Schlüssel, solange er in Ihrem Browser gespeichert ist.",
"unlock_key_desc": "Geben Sie das Passwort dieses Schlüssels ein, um E-Mails zu signieren oder zu öffnen",
"cert_expired": "Dieses Zertifikat ist nicht mehr gültig",
"status_encrypted_no_key": "Diese E-Mail ist verschlüsselt, aber Ihnen fehlt der Schlüssel, um sie zu öffnen",
"status_encrypted_failed": "Diese E-Mail ist verschlüsselt und konnte nicht entschlüsselt werden. Vielleicht fehlt Ihnen der passende Schlüssel.",
"status_signed_invalid": "Die Signatur ist ungültig. Die E-Mail wurde vielleicht verändert.",
"status_signed_expired_cert": "Mit einem Zertifikat signiert, das nicht mehr gültig ist",
"status_signed_self_signed": "Die Signatur ist gültig. Der Absender hat das Zertifikat aber selbst erstellt, also hat niemand geprüft, wer er ist.",
"status_signed_mismatch": "Die Signatur ist gültig, gehört aber zu jemand anderem als dem Absender",
"auto_import_signer_certs_desc": "Wenn eine signierte E-Mail geprüft ist, das Zertifikat des Absenders speichern. Dann können Sie ihm später verschlüsselte E-Mails senden.",
"export_passphrase_desc": "Wählen Sie ein Passwort, um die gespeicherte Datei zu schützen",
"export_storage_desc": "Geben Sie das Passwort dieses Schlüssels ein, um ihn als Datei zu speichern"
},
"recover_deleted": {
"load_failed": "Gelöschte E-Mails konnten nicht geladen werden. Versuchen Sie es noch einmal."
}
}
+793
View File
@@ -0,0 +1,793 @@
{
"login": {
"jmap_endpoint_cors_hint": "The mail server must allow sign-ins from this website. Your administrator can set this up.",
"reconnecting": "The connection is lost. We are trying to connect again…",
"error": {
"invalid_credentials": "The email address or password is wrong. Check both and try again.",
"invalid_token": "The server did not accept this access token. Maybe it is not complete, or it is no longer valid. Copy it again or create a new one.",
"connection_failed": "We can't reach the mail server. Check your internet connection and try again.",
"cors_blocked": "The mail server works, but it does not allow sign-ins from this website. Ask your administrator to allow this website on the server.",
"server_error": "The mail server has a problem right now. Try again in a few minutes.",
"generic": "Something went wrong. Try again. If it keeps happening, ask your administrator for help.",
"totp_required": "Your account needs a second code to sign in. Open your authenticator app and type the 6-digit code below.",
"totp_invalid": "This code is wrong or too old. Type the code your authenticator app shows right now.",
"oauth_discovery_failed": "Sign-in through your organisation does not work right now. Try again later or ask your administrator.",
"token_exchange_failed": "Your password and code are correct, but the mail server did not let this app in. Ask your administrator to check the sign-in settings for this app."
},
"totp_toggle": "I have a code from my authenticator app",
"token_hint": "Some mail providers give you a token instead of a password. Create one in your mail provider's settings, copy it, and paste it here.",
"config_error": {
"title": "The webmail is not set up correctly",
"fetch_failed": "The webmail could not load its settings. Try again in a few minutes.",
"server_not_configured": "No mail server is set up yet. Ask your administrator to set one up."
},
"session_expired": "You were signed out. Sign in again to continue.",
"oauth_error": {
"title": "Sign-in did not work",
"invalid_state": "The sign-in was interrupted. Start again.",
"missing_params": "Some sign-in data is missing. Start again.",
"token_exchange_failed": "We could not finish signing you in. Try again.",
"access_denied": "You are not allowed to sign in here. Ask your administrator for access."
}
},
"session_limits": {
"idle_message": "You haven't done anything for a while. You will be signed out in {seconds, plural, one {# second} other {# seconds}}. Messages you are writing are saved as drafts.",
"max_message": "Your organisation signs you out {hours, plural, one {# hour} other {# hours}} after you sign in. You will be signed out in {seconds, plural, one {# second} other {# seconds}}. Messages you are writing are saved as drafts.",
"reason_max": "You were signed out because you were signed in for {hours, plural, one {# hour} other {# hours}}. That is the longest time your organisation allows.",
"drafts_not_saved": "Some messages you were writing could not be saved as drafts."
},
"totp_reauth": {
"title": "Enter a new code",
"description": "Your last code is no longer valid. Open your authenticator app and type the new code to continue.",
"oauth_hint": "This question comes back from time to time. Your administrator can stop it by turning on a different way to sign in (OAuth).",
"code_label": "Code from your authenticator app"
},
"email_list": {
"permanent_delete_confirm_message": "This message will be deleted for good. You can't get it back.",
"permanent_delete_confirm_batch_message": "{count, plural, one {This message} other {These # messages}} will be deleted for good. You can't get {count, plural, one {it} other {them}} back.",
"empty_folder": {
"confirm_message": "All messages in this folder will be deleted for good. You can't get them back."
}
},
"email_viewer": {
"read_receipt": {
"send_failed": "The read receipt could not be sent"
},
"archive_mailbox_not_found": "There is no Archive folder, so the message can't be archived",
"source_load_failed": "The raw message could not be loaded. Try again, or save the message as a file (.eml).",
"spam": {
"error": "The message could not be marked as spam. Try again.",
"error_not_spam": "The message could not be moved back. Try again."
},
"unsubscribe_banner": {
"error": "Unsubscribing did not work. Try again later."
},
"calendar_invitation": {
"parse_error": "This invitation can't be read",
"action_failed": "That did not work in your calendar. Try again."
},
"undo_send_failed": "Sending could not be stopped. The message may already be sent.",
"undo_send_signed_out": "Sending could not be stopped, because you are no longer signed in to the account that sends it.",
"send_cancelled_restore_failed": "Sending was stopped. But the message could not be put back into Drafts. It is still in Sent.",
"send_cancelled_remove_failed": "Sending was stopped. But the message could not be removed from Sent.",
"cancel_scheduled_failed": "The planned sending could not be stopped. Try again.",
"save_to_files": {
"failed": "The file could not be saved in Files. Try again."
}
},
"email_composer": {
"files_picker": {
"load_failed": "Your files could not be loaded. Try again."
},
"save_failed": "The draft could not be saved. Try again.",
"validation": {
"invalid_recipients": "Not sent. These addresses are not correct: {addresses}",
"attachment_upload_failed": "Not sent. An attachment could not be uploaded. Remove it and try again."
},
"upload_failed": "{filename} could not be uploaded. Try again.",
"send_failed": "Your message was not sent. Try again.",
"send_offline": "You are not connected to the internet. Your message is still here. Send it again when you are back online.",
"send_timeout": "The server stopped answering. Your message may have been sent anyway. Look in your Sent folder before you send it again.",
"submission_limit": "The server did not send the message because you reached a limit: {reason}",
"send_plugin_failed": "Not sent. An add-on that sends the message did not work or took too long. Look in your Sent folder before you send it again.",
"schedule_send_invalid": "Enter a date and a time.",
"schedule_send_cleanup_warning": "The message will be sent at the chosen time. But the draft could not be removed, so it is still in Drafts.",
"invalid_recipient": "This is not a correct email address",
"send_filing_warning": "Sent. But an old draft of this message may still be in Drafts.",
"send_guard_failed": "Not sent. An add-on that checks messages before sending did not work or did not answer.",
"attachment_upload_failed": "Could not upload"
},
"common": {
"reconnecting": "The connection is lost. We are trying to connect again…"
},
"notifications": {
"error_archiving": "The message could not be archived. Try again.",
"error_updating": "The message could not be changed. Try again.",
"error_sending": "Your message was not sent. Try again.",
"error_deleting": "The message could not be deleted. Try again.",
"error_loading": "Your messages could not be loaded. Try again.",
"move_failed": "Could not move. Try again.",
"undo_failed": "Could not undo. Try again.",
"copy_failed": "Could not copy. Try again.",
"move_error": "The messages could not be moved to this folder. Try again.",
"tag_failed": "The label could not be added. Try again.",
"identity_create_failed": "The sender address could not be added: {error}",
"identity_update_failed": "The sender address could not be changed: {error}",
"identity_delete_failed": "The sender address could not be deleted: {error}",
"identity_unauthorized": "You are not allowed to send from this email address. Ask your administrator if you need it.",
"vacation_save_failed": "Your automatic reply could not be saved. Try again.",
"filters_save_failed": "Your filter rules could not be saved. Try again.",
"export_email_error": "The message could not be saved as a file. Try again.",
"import_email_error": "The message could not be added from the file. Check the file and try again.",
"rule_apply_failed": "The rule could not be used for the messages that are already here",
"rule_folder_failed": "The folder could not be created. Try again.",
"vacation_filters_save_failed": "Your automatic reply is saved. But the forwarding and the reply list could not be saved. Try again."
},
"settings": {
"reset_confirm": "Put all settings back to how they were at the start?",
"themes": {
"description": "Choose a look. Your administrator may add more themes.",
"locked": "Your administrator chose the theme. You can't change it.",
"forced": "Your administrator chose the theme \"{name}\". You can't change it."
},
"appearance": {
"message_list_order": {
"description": "The order of the messages in the list. The server sorts the whole folder, not only the messages you can see.",
"tiebreak_note": "You can choose up to three steps. A later step only counts when the earlier steps are the same. At the end, the newest messages always come first.",
"scope": {
"description": "When this is off, the order is only used in the Inbox. Other folders show the newest messages first."
},
"unsupported_note": "Your mail server can't sort by read or unread, star or tag. Those steps are left out, and the newest messages come first."
},
"description": "Change how the webmail looks",
"theme": {
"description": "Choose light or dark colours"
},
"font_size": {
"description": "Make the text bigger or smaller"
},
"list_density": {
"description": "How much space there is between items"
},
"animations": {
"description": "Let things move softly when they open or close"
},
"toolbar_position": {
"description": "Where the buttons for a message are, like Reply, Archive or Delete"
},
"toolbar_labels": {
"description": "Show words next to the button icons. Turn this off to save space when you know the icons."
},
"hide_account_switcher": {
"description": "Hide the account list at the top of the folder list. You can still change accounts at the bottom of the left bar."
},
"show_rail_account_list": {
"label": "Show your accounts in the left bar",
"description": "Show a circle for each account at the bottom of the left bar, so you can change accounts quickly. A button to sign out is below them."
},
"unified_mailbox": {
"description": "Show folders that combine the mail of several accounts, like one Inbox for all. You need more than one account (or a group inbox below) to see them.",
"cross_account": {
"description": "Combine the mail of all your accounts, not only the account you use now."
},
"include_group": {
"description": "Also add the inboxes you share with a group."
}
},
"all_mail": {
"folders_description": "Choose which folders of this account are part of the lists All mail, Unread and Starred."
},
"colorful_sidebar_icons": {
"description": "Give folder and tag icons colours (blue Inbox, red Spam, green Sent and so on). Turn this off for grey icons."
},
"tint_list_rows": {
"description": "Colour each message in the list with the colour of its first tag. The tag colour still shows in the small dots when this is off."
},
"show_folder_total_count": {
"description": "Show how many messages a folder or tag has in total, next to the number of unread ones."
},
"favicon_unread_badge": {
"description": "Show the number of unread messages on the browser tab and on the app icon. Then you see new mail even when you use another tab."
},
"show_shared_mail_accounts": {
"description": "Show the folders of mailboxes that others share with you, under Shared in the folder list. Turn this off to hide them all, or hide single accounts below. Their calendars and contacts stay."
},
"pro_interface": {
"description": "A layout for large screens with many tabs and several accounts at once. You can go back to the normal layout at any time."
},
"account_marker": {
"description": "In the combined folders, mark each message with the colour of its account."
}
},
"keywords": {
"description": "Make tags to sort your messages. The tags are saved with your messages on the server.",
"migration_error": "The tag could not be changed on your messages. Try again.",
"filters_migration_error": "Some filter rules still use the old tag. Change them yourself.",
"nesting": {
"description": "Put tags inside other tags, like folders. The sidebar shows them as a tree."
},
"has_children_locked": "Other tags are inside this tag. So you can't rename or move it. First move or delete the tags inside it.",
"has_children_delete": "First delete the tags inside this tag",
"unrecognized": {
"description": "This browser keeps the names and colours of your tags. The tags themselves are saved on your messages. Search your mail for tags that are missing here and add them again.",
"partial": "Only your {scanned} newest messages out of {total} were searched. Tags that are only on older mail may be missing.",
"error": "Your mail could not be searched for tags. Try again."
}
},
"notifications": {
"push": {
"description": "Get a message on your screen about new mail, even when this website is closed. The service that passes on the message never sees your mail.",
"relay_label": "Notification service",
"relay_desc": "The service that passes notifications on to your device. Your administrator decides which ones you can choose.",
"relay_locked_desc": "Your administrator chose the notification service. You can't change it.",
"status_unsupported": "This browser can't show notifications when the website is closed",
"status_lite": "This version of the webmail can't send notifications when it is closed.",
"reenable": "Turn on again",
"ios_hint": "On iPhone and iPad, first add this website to your home screen. Safari only sends notifications for websites on the home screen.",
"devices_title": "Devices that get notifications",
"devices_desc": "All devices that get notifications for this account. If you remove a device, it stops getting them at once.",
"devices_error": "The list of devices could not be loaded. Try again.",
"device_status_unknown": "The notification service does not know this device",
"revoke": "Remove",
"confirm_revoke_title": "Remove this device?",
"confirm_revoke_message": "It stops getting notifications at once. To get them again, turn notifications on again on that device.",
"confirm_revoke_message_this": "This device stops getting notifications at once.",
"shared_mailboxes_no_push": "You don't get notifications for mailboxes that others share with you.",
"error_forbidden": "The mail server does not allow notifications for this account.",
"error_over_quota": "Too many devices get notifications for this account (the limit is usually 15). Remove devices you don't use any more, then try again."
},
"push_all": {
"description": "Turn notifications on or off for several accounts at once.",
"result_partial": "Turned on for {enabled} accounts. For {failed} accounts, it did not work."
},
"sound_selection": {
"choose_desc": "Choose a sound. Click the speaker to hear it."
},
"email": {
"description": "Choose how you hear about new mail",
"inbox_only_desc": "Only tell me about mail in the Inbox, not about mail that a filter puts into another folder"
},
"calendar": {
"description": "Choose how you hear about your appointments"
}
},
"language_region": {
"description": "Language, how dates and times look, and other things for your country",
"date_locale": {
"description": "The order of day, month and year in dates"
},
"time_format": {
"description": "Show times like 3:00 PM (12 hours) or like 15:00 (24 hours)"
},
"time_zone": {
"description": "Show the times of mail and appointments in this time zone. If you don't choose one, the time zone of your browser is used."
}
},
"email_behavior": {
"inbox_type": {
"description": "With \"Unread first\", your 25 newest unread messages are shown at the top of the inbox"
},
"clear_search_on_folder_change": {
"description": "When you open another folder, your search ends and you see the whole folder. When this is off, the search continues in the new folder."
},
"request_read_receipt": {
"label": "Always ask for a read receipt",
"description": "New messages ask the recipient to tell you when they have opened the message. You can turn this off for each message."
},
"read_receipt_response": {
"label": "When someone asks for a read receipt",
"description": "Some senders ask to be told when you open their message. Choose what to do then."
},
"description": "Choose what happens with your messages",
"mark_read": {
"description": "When an opened message counts as read"
},
"delete_action": {
"description": "What happens when you delete a message",
"warning": "Deleted messages are gone for good. You can't get them back."
},
"message_spacing": {
"description": "The space around the text of an open message"
},
"archive_mode": {
"description": "How archived messages are put into folders"
},
"permanently_delete_junk": {
"label": "Delete spam for good",
"description": "When you delete a message in the Spam folder, it is gone for good. It does not go to the Trash first."
},
"confirm_bulk_delete": {
"description": "Ask before several selected messages go to the Trash. Deleting for good always asks."
},
"mail_layout": {
"description": "Choose how the message list and the open message are placed on the screen.",
"split_description": "The message list and the open message are side by side.",
"focus_description": "Each message takes one line. An open message fills the whole width. The folder list stays visible.",
"horizontal_description": "The message list is at the top. The open message is shown below it."
},
"list_columns": {
"description": "Show the list as a table with the columns From, Subject, Date and Size."
},
"show_verification_codes": {
"description": "When a message contains a sign-in or confirmation code, show a button to copy it. In the list, the button stays for one day."
},
"plain_text_mode": {
"description": "Write all messages as plain text, without bold, colours or pictures. This also applies to replies and forwards."
},
"auto_select_reply_identity": {
"description": "When you reply, the reply is sent from the address the message was sent to. This works for all your own addresses. Below, you can choose if other addresses on your domain count too."
},
"reply_identity_match": {
"label": "Which addresses count as yours",
"description": "\"Exact address only\" uses only the addresses you set up. \"Any address on my domains\" also replies from other addresses on your domains. Choose the exact address if those other addresses are group lists."
},
"signature_position": {
"description": "Where your signature goes when you reply or forward. Above the quoted text, it ends your reply. Below, the old message stays in one piece."
},
"signature_separator": {
"label": "Line before the signature",
"description": "Put the usual \"-- \" line before your signature. Many mail apps use it to recognise the signature."
},
"sub_address_delimiter": {
"label": "Character before the address tag",
"description": "The character between your name and the tag in an address, like user{delimiter}tag@domain.com. Use the same character as your mail server."
},
"attachment_click_action": {
"label": "When you click an attachment",
"description": "Choose if a click on an attachment shows it or saves it to your device"
},
"attachment_position": {
"description": "Where attachments are shown in an open message"
},
"always_light_mode": {
"description": "Show messages with a light background even when the app is dark. Some messages look wrong when they are made dark."
},
"external_content": {
"label": "Pictures from the internet",
"description": "Choose if pictures that a message loads from the internet are shown. Loading them can tell the sender that you opened the message."
},
"trusted_senders": {
"description": "Senders whose pictures are always shown",
"empty_description": "When a message has blocked pictures, click \"Always trust this sender\" to add the sender here.",
"save_error": "The list could not be saved. Try again.",
"use_address_book_label": "Keep the list in your address book",
"use_address_book_description": "Save trusted senders in their own address book. Then the list is the same on all your devices."
},
"hover_actions": {
"description": "The buttons you see when you move the mouse over a message in the list"
},
"default_mail_program": {
"description": "Open email links on websites (mailto: links) in {appName}",
"error": "Your browser can't do this"
},
"attachment_reminder": {
"description": "If your message talks about an attachment but nothing is attached, you get a warning before it is sent",
"keywords_label": "Words that start the reminder",
"keywords_description": "If your message contains one of these words, you get the reminder"
},
"placeholder_warning": {
"description": "Ask before sending if the message still has gaps to fill in, like [TIME] or [Your Name], empty template fields, or notes like TODO."
},
"recipient_mentions": {
"description": "Type @ in your message to pick one of its recipients. Their first name is put into the text. Press Enter or Tab to pick the marked name."
},
"mention_adds_recipient": {
"description": "When you type @, you can also pick people from your contacts. If they are not a recipient yet, they are added to the message."
},
"hide_inline_image_attachments": {
"label": "Don't list pictures in the text as attachments",
"description": "Pictures that are part of the message text are not shown again in the list of attachments"
},
"attachment_image_previews": {
"description": "Show small pictures of attached images instead of a file icon"
},
"send_delay": {
"description": "Your messages wait a few seconds before they are sent. In that time, you can still stop them.",
"unsupported": "This account's mail server can't wait before sending. The setting is still saved for your other accounts."
},
"swipe_right_action": {
"description": "On a touch screen: what happens when you swipe a message to the right."
},
"swipe_left_action": {
"description": "On a touch screen: what happens when you swipe a message to the left."
},
"rtl_editing": {
"label": "Writing from right to left",
"description": "Adds a button to the editor. With it, you can write a paragraph from left to right or from right to left."
}
},
"account": {
"description": "See the details of your account",
"auth_method_label": "How you sign in",
"auth_method_oauth": "Sign-in page of your organisation",
"server": {
"label": "Mail server address"
},
"accounts": {
"description": "Drag the accounts to change their order in the account list"
},
"backup": {
"description": "Save the list of your accounts on the server and get it back on another device. A password that only you know protects the list. Mailbox passwords are not saved, so you sign in to each account again.",
"description_passwords": "Save the list of your accounts on the server and get it back on another device. A password that only you know protects the list. You can also save the mailbox passwords. Then the accounts sign in by themselves.",
"password_hint": "Use at least 10 characters. Nobody can reset this password. If you forget it, save the list again with a new password.",
"restore_hint": "The password you chose when you saved the list.",
"include_passwords_hint": "After you get the list back, these accounts sign in by themselves. But be careful: anyone who knows the backup password can open every mailbox in the list.",
"skipped": "{count, plural, one {# account was} other {# accounts were}} not added because you already have the most accounts allowed.",
"sign_in_failed": "{count, plural, one {# saved password did not work} other {# saved passwords did not work}}. Sign in to these accounts yourself from the account list.",
"errors": {
"unlock_failed": "The password is wrong, or this list belongs to a different account.",
"owner_signin_required": "Sign in to this account again. Then try once more.",
"https_required": "This only works on a secure connection (the address starts with https).",
"storage_failed": "The server could not save or open the list. Try again later."
}
},
"shared_accounts": {
"description": "Accounts that you share with others and may manage. Choose one to change its filters, its automatic replies, its calendars and its contacts."
}
},
"security": {
"description": "Change your password and protect your account",
"not_available": "You can't change the security settings for this mail server here. Your server may not allow it. The <a href=\"https://bulwarkmail.org/docs/guides/account-security\" class=\"underline hover:opacity-80\" target=\"_blank\">documentation</a> explains more.",
"password": {
"error_title": "Your password was not changed",
"error_mismatch": "The two new passwords are not the same. Type them again.",
"error_min_length": "Your password needs at least 8 characters",
"error_generic": "Your password could not be changed. Try again."
},
"display_name": {
"description": "The name the mail server shows for you",
"error": "Your name could not be changed. Try again."
},
"totp": {
"label": "Code from an authenticator app",
"description": "Protect your account better: when you sign in, you also type a code from an app on your phone",
"enable_error": "Two-factor authentication could not be turned on. Try again.",
"disable_error": "Two-factor authentication could not be turned off. Try again.",
"setup_instructions": "Open your authenticator app (for example Google Authenticator or Authy) and add this link to it:",
"verification_code": "Code from the app",
"disable_confirm_prompt": "To turn off two-factor authentication, type your password.",
"password_required": "Type your password",
"code_required": "Type the code from the app",
"code_invalid": "This code is wrong or too old. Type the code the app shows right now."
},
"app_passwords": {
"description": "Extra passwords for mail apps that can't ask for a second code, like some phone mail apps",
"allowed_ips_label": "Only from these internet addresses (optional)",
"allowed_ips_hint": "Put a comma or a space between addresses. Leave it empty to allow every address.",
"copy_now_warning": "Copy this password now. You won't see it again.",
"add_error": "The app password could not be created. Try again.",
"remove_error": "The app password could not be deleted. Try again.",
"delete_message": "Apps that use {name} will not be able to sign in any more."
},
"api_keys": {
"description": "Keys for programs and scripts that work with your mailbox without you, for example a backup",
"copy_now_warning": "Copy this key now. You won't see it again.",
"add_error": "The key could not be created. Try again.",
"remove_error": "The key could not be deleted. Try again.",
"delete_message": "Programs and scripts that use {name} will stop working."
},
"encryption": {
"label": "Store mail encrypted",
"description": "The server stores your mail in a locked form. Only your key can unlock it.",
"error": "The encryption setting could not be changed. Try again.",
"algorithm_label": "Type of encryption",
"encrypt_on_append": "Also encrypt mail that apps add to your mailbox",
"allow_spam_training": "Let the spam filter learn from new mail before it is encrypted",
"disable_message": "From now on, the server stores new mail without encryption."
},
"public_keys": {
"key_label": "Public key (S/MIME or PGP, as text)",
"add_error": "The public key could not be added. Check that you pasted all of it.",
"remove_error": "The public key could not be deleted. Try again."
},
"email_client": {
"description": "Use these details to set up mail on your computer or phone, for example in Thunderbird, Apple Mail or Outlook",
"jmap_username_label": "User name for mail apps",
"password_instructions": "In your mail app, use the user name above and an app password. You can create an app password further down on this page."
},
"link_device": {
"description": "Sign in to the Bulwark Mail app on your phone without typing your password. Your phone gets its own sign-in. If you sign out here, your phone stays signed in.",
"error": "The code could not be created. Try again.",
"password_prompt": "Type your password to show it's you. Your password is not sent to the phone.",
"error_not_signed_in": "You were signed out. Sign in again, then link your phone.",
"error_impersonation": "You are looking at someone else's mailbox right now. You can't link a phone from here.",
"error_too_many_attempts": "You tried too often. Wait a moment, then try again.",
"error_session_secret": "Linking a phone is not set up on this server. Ask your administrator.",
"error_server_unreachable": "We can't reach your mail server. Try again.",
"error_pairing_unavailable": "Your mail server does not allow linking a phone this way. Ask your administrator.",
"error_insecure_server": "The phone app needs a secure connection to the mail server (https). This server does not have one."
},
"sections": {
"devices_description": "Read your mail in other apps and on other devices."
}
},
"identities": {
"description": "The email addresses you can send from",
"identities_count": {
"label": "Your sender addresses",
"description": "Addresses you can send mail from"
},
"sub_addressing": {
"label": "Addresses with a tag",
"description": "Add a tag to your address, like user+shop@domain.com. Mail to it still reaches you, and you can sort it by the tag."
}
},
"vacation": {
"description": "Answer new mail by yourself while you are away",
"not_supported": "Your mail server can't send automatic replies.",
"fetch_error": "Your automatic reply could not be loaded. Try again.",
"status": {
"description": "Everyone who writes to you gets an automatic reply"
},
"date_range": {
"start_description": "Leave it empty to start right away"
},
"message": {
"subject_description": "The subject of the automatic reply",
"html_description": "Add a version with links and formatting. People whose mail app can't show it get the plain text above."
},
"warnings": {
"end_before_start": "The end date must come after the start date",
"invalid_date": "Enter a start and end date with a time, or leave the field empty",
"empty_body": "The message is empty. People would get a reply with no text.",
"subject_too_long": "The subject is too long for the server. Make it shorter (at most {max} bytes).",
"body_too_long": "The message is too long for the server. Make it shorter (at most {max} bytes, with formatting).",
"forward_address": "Enter the email address to forward to",
"not_running": "Forwarding and the reply list are saved, but they are off right now. Click Save to turn them on again."
},
"forward": {
"description": "Send new mail on to another address during the time above, with or without the automatic reply. Without an end date, this goes on until you turn it off. Spam is not forwarded.",
"keep_description": "If this is off, the message is only forwarded. It is not kept in this mailbox, and your filter rules don't run on it."
}
},
"folders": {
"description": "Manage your folders and choose which folder is used for what",
"folder_list_description": "Click the icon of a folder to change it",
"standard_roles_description": "Choose which folder is your Inbox, Sent, Trash and so on.",
"reorder": "Drag up or down to change the order. Drag left or right to put the folder in another folder.",
"move_partial_error": "{count, plural, one {# folder} other {# folders}} could not be moved. Try again.",
"confirm_delete": "Delete \"{name}\"? The messages in this folder go to the Trash.",
"cannot_delete_role": "This folder has a special job, like Inbox or Trash. Remove the job first, then you can delete it.",
"error_delete_has_children": "The folder was not deleted because there are other folders inside it. Delete or move them first.",
"error_delete_has_email": "The folder was not deleted because there are still messages in it. Move or delete them first.",
"error_role": "The job of the folder could not be changed. Try again."
},
"advanced": {
"debug_mode": {
"description": "Record more details about what the app does. This helps to find problems."
},
"settings_sync": {
"label": "Same settings on all devices",
"description": "Save your settings on the server, so all your browsers and devices use them"
},
"sender_favicons": {
"description": "For companies, show the icon of their website as the sender's picture",
"refresh_description": "Load the website icons again, for example when a company has a new one"
},
"show_avatars_in_junk": {
"label": "Show sender pictures in the Spam folder",
"description": "Show the sender's picture or website icon in the Spam folder too. This is off at first, because fake messages can look more real with a picture."
},
"keyboard_shortcuts": {
"description": "See all keys you can use instead of the mouse"
},
"refresh_cache": {
"label": "Load data from the server again",
"description": "Load your contacts, calendars and folders from the server again. Use this when something looks old or wrong. You stay signed in.",
"confirm_title": "Load data from the server again?"
},
"reset_settings": {
"description": "Put all settings back to how they were at the start"
},
"export_settings": {
"description": "Save your settings as a file on your device"
},
"import_settings": {
"description": "Load your settings from a file you saved before"
},
"sections": {
"backup_description": "Use the same settings in other browsers and on other devices.",
"fix_description": "When something looks old or wrong, or you want to start again."
}
},
"sidebar_apps": {
"description": "Add your own apps and links to the left bar",
"keep_loaded_description": "Keep apps open in the background when you switch, so they don't have to load again",
"managed_description": "Your administrator set up these apps for everyone. You can't change them."
},
"contacts": {
"group_by_letter_description": "Put a letter above each group of names in the contact list",
"sort_by_last_name_description": "Sort contacts by family name, so families are next to each other",
"import_description": "Add contacts from a contacts file (.vcf)",
"export_description": "Save all contacts in a contacts file (.vcf)"
},
"downloads": {
"description": "Choose the file names for messages and attachments you save.",
"email_template": {
"description": "The file name when you save a message or drag it to a folder on your computer. The ending .eml is added for you."
},
"attachment_template": {
"description": "The file name when you save an attachment or drag it out. If you leave out '{filename}' and '{ext}', the file keeps its ending."
},
"bundle_template": {
"description": "The file name when you save several messages together in one .zip file. The ending .zip is added for you."
},
"spaces": {
"description": "Use another character instead of spaces in the file name."
},
"lowercase": {
"description": "Write the whole file name in small letters."
},
"strip_diacritics": {
"description": "Change letters with accents into plain letters (ä → a, é → e). Some programs can't handle letters with accents."
},
"collapse_separators": {
"description": "When there are several spaces, _ or - in a row, keep only one."
},
"after_export": {
"description": "You can move the message to another folder after you save it as a file."
}
},
"filters": {
"description": "Rules that sort new mail by themselves, for example into a folder",
"no_rules_description": "Make a rule, and new mail is sorted by itself",
"vacation_active_description": "New mail gets an automatic reply",
"delete_confirm": "Delete this rule?",
"raw_editor": "Edit rules as code (Sieve)",
"raw_editor_warning": "If you change the code, you may not be able to edit the rules here with the normal editor any more. Your changes in the code replace the normal rules.",
"not_supported": "Your mail server can't use filter rules.",
"match_all": "All conditions must be true",
"match_any": "One condition is enough",
"stop_processing": "Don't check the rules below this one",
"value_placeholder_multi": "Value (put commas between several)",
"condition_fields": {
"header": "Other header line",
"body": "Message text"
},
"comparators": {
"matches": "matches the pattern (* = any text)"
},
"forward_limit": "This server forwards a message to at most {count} addresses. Other forwards are left out.",
"forward_unavailable": "This server does not allow forwarding. Rules that forward mail do nothing.",
"header_name": "Name of the header line",
"opaque_warning": "These rules were changed in another app. You can only edit them here as code.",
"open_sieve_editor": "Edit rules as code (Sieve)",
"expanded_view_description": "Show each rule with all its conditions and actions",
"reset_warning": "This deletes all your current rules and starts again with none.",
"validation_empty_conditions": "Add at least one condition and fill in its value",
"validation_empty_actions": "Add at least one action",
"validation_header_name": "Use only the letters A to Z, numbers, - and _ in the name of the header line",
"sieve_editor": {
"title": "Filter rules as code (Sieve)",
"warning": "If you change the code, you may not be able to edit the rules here with the normal editor any more. Your changes in the code replace the normal rules.",
"script_content": "Rules as code",
"valid": "The code has no mistakes",
"invalid": "The code has mistakes",
"validating": "Checking…",
"validate": "Check",
"overwrite_message": "This code no longer contains the rules you made with the normal editor. If you save, it replaces them. You can't undo this.",
"validation_failed": "The code could not be checked. Try again."
},
"managed_by_tooltip": "{source} manages this. Change it in that app, or edit the rules as code.",
"apply_existing_unsupported": "Only some rules can also run on messages that are already here: rules that check the sender, recipients, subject or header lines, and that move, copy, mark as read, star or tag.",
"validation_period_order": "The end date must come after the start date",
"period_hint": "The rule works for messages that arrive in this time. Leave a field empty if there is no start or no end.",
"validation_period_invalid": "Enter a date with a time, or leave the field empty",
"apply_existing_period": "A rule with a time period only works for messages that arrive in that time. So it can't run on messages that are already here."
},
"templates": {
"description": "Save messages you write often and use them again. They can have gaps that you fill in each time.",
"export_import_description": "Save your templates as a file, or move them to another device",
"validation": {
"too_long": "The name can have at most 200 characters"
}
},
"files": {
"sort_direction": {
"description": "From A to Z or from Z to A"
},
"show_thumbnails": {
"description": "Show a small picture of each image instead of an icon"
},
"show_hidden": {
"description": "Show hidden files and folders (their names start with a dot)"
}
},
"reading_page": {
"sections": {
"images_description": "Pictures from the internet can tell the sender that you opened the message."
}
}
},
"errors": {
"page_error_description": "This page stopped working. Try again, or go back to your inbox.",
"sidebar_error": "Your folders could not be loaded",
"email_list_error": "Your messages could not be loaded",
"viewer_error_title": "This message can't be shown",
"viewer_error_description": "Something in this message stops it from being shown here. It may use a format we don't support.",
"composer_error": "The editor for new messages could not be opened",
"settings_error_title": "Settings could not be opened",
"settings_error_description": "Your settings could not be loaded. Changes you make now may not be saved."
},
"identities": {
"delete_confirm": "Delete this sender address? You can't undo this.",
"cannot_delete": "You can't delete this sender address",
"validation_errors": {
"invalid_emails": "These email addresses are not correct: {emails}"
},
"form": {
"email_invalid": "Enter a correct email address",
"email_immutable": "You can't change the email address after you add it",
"signature_byte_limit_reached": "The signature is too long for the server"
},
"sub_address": {
"invalid_tag": "Use only letters, numbers and dashes (-) in the tag"
}
},
"calendar": {
"import": {
"url_description": "Enter the web address of a calendar file (.ics) to add its events."
},
"management": {
"description": "Make new calendars, rename them and change their colour. You can also right-click a calendar in the side bar to change its colour.",
"description_touch": "Make new calendars, rename them and change their colour.",
"confirm_delete": "Delete \"{name}\"? All appointments in this calendar are deleted too.",
"confirm_clear": "Delete all appointments in \"{name}\"? You can't undo this.",
"kind_hint": "This decides which apps on your other devices show this calendar. You can't change it later.",
"default_alerts_hint": "New appointments in this calendar get these reminders, unless you change them in the appointment. A change here does not change appointments you already have. The mail server sends reminders by email."
},
"subscription": {
"description": "Follow a calendar from the internet, for example public holidays. Its appointments are updated by themselves and shown in their own calendar. The address starts with https:// or webcal://.",
"confirm_delete": "Stop following \"{name}\"? The calendar and all its appointments are removed."
}
},
"sharing": {
"description": "Let other people or groups on this server use this. The change works at once.",
"directory_restricted": "Your server does not show a list of all its users. So you only see your groups and people who share something with you. Your administrator can change this.",
"directory_not_browsable": "Your server does not show a list of all its users.",
"directory_failed": "The list of people and groups could not be loaded. Try again.",
"share_failed": "Sharing could not be changed. Try again."
},
"delegates": {
"description": "Let someone use your mail, calendars and contacts, all in one step. See who can use what.",
"read_failed": "Some of your sharing could not be loaded. You can change or remove access when it has loaded.",
"remove_message": "{name} can no longer use anything you shared from your account: mail, calendars, contacts and files.",
"not_found": "There is nobody with the address {address} on this server.",
"lookup_failed": "This address could not be found. Check it and try again.",
"known_only": "You only see your groups and people who shared something with you.",
"directory_off_note": "To add someone new, your administrator first has to allow searching for users on the server.",
"directory_failed": "The list of people and groups could not be loaded. Try again.",
"nothing_to_change": "Nothing has changed. Choose what the person should get, or click Remove access.",
"change_note": "Folders, calendars and address books that you don't choose are no longer shared. Shared files stay shared. Remove access takes them away too.",
"rights_keep_hint": "What they already have stays the same. Anything new gets the same rights as the rest of its kind, or read only.",
"send_note": "People you give access can read and sort your mail and calendar. They can't send mail as you or for you. If several people need one address, ask your administrator for a shared mailbox.",
"partial_title": "Some things could not be changed. Everything else is done."
},
"smime": {
"your_certificates_desc": "Add and manage your certificates. You need one to sign or encrypt mail with S/MIME.",
"recipient_certificates_desc": "Certificates of other people. With them, you can send encrypted mail to these people.",
"identity_bindings_desc": "Choose which certificate belongs to which of your sender addresses",
"defaults_desc": "Choose if new messages are signed or encrypted from the start",
"encrypt_by_default_desc": "Encrypt a message by itself when you have a certificate for every recipient",
"remember_unlocked_desc": "Keep your keys unlocked until you close this browser",
"p12_passphrase_desc": "Enter the password of this certificate file",
"storage_passphrase_desc": "Choose a password. It protects this key while it is saved in your browser.",
"unlock_key_desc": "Enter the password of this key to sign or open messages",
"cert_expired": "This certificate is no longer valid",
"status_encrypted_no_key": "This message is encrypted, but you don't have the key to open it",
"status_encrypted_failed": "This message is locked, and it could not be unlocked. You may not have the right key.",
"status_signed_invalid": "The signature is not valid. The message may have been changed.",
"status_signed_expired_cert": "Signed with a certificate that is no longer valid",
"status_signed_self_signed": "The signature is valid. But the sender made the certificate themselves, so nobody has checked who they are.",
"status_signed_mismatch": "The signature is valid, but it belongs to someone other than the sender",
"auto_import_signer_certs_desc": "When a signed message is checked, save the sender's certificate. Then you can send them encrypted mail later.",
"export_passphrase_desc": "Choose a password to protect the file you save",
"export_storage_desc": "Enter the password of this key to save it as a file"
},
"recover_deleted": {
"load_failed": "Deleted mail could not be loaded. Try again."
},
"global_search": {
"account_error": "{account} could not be searched"
}
}
+16
View File
@@ -1384,6 +1384,22 @@
"appearance": "Darstellung",
"general": "Allgemein",
"privacy": "Datenschutz & Sicherheit"
},
"accessibility": {
"presets": {
"dyslexia": {
"description_mobile": "Atkinson Hyperlegible als Schrift, auch für Nachrichten als reiner Text."
}
},
"font": {
"description_ios": "Atkinson Hyperlegible und Lexend sind so gestaltet, dass sich die Buchstaben gut unterscheiden; OpenDyslexic hilft manchen Menschen mit Legasthenie. Auf diesem Gerät gilt die Schrift für Nachrichten als reiner Text."
},
"cvd_palette": {
"description_mobile": "Verwendet Farben für Tags und Kalender, die bei Rot-Grün- oder Blau-Gelb-Schwäche unterscheidbar bleiben."
},
"plain_language": {
"description_mobile": "Einfachere Formulierungen in Einstellungen und Fehlermeldungen. Auf Deutsch und Englisch verfügbar."
}
}
},
"sharing": {
+13 -1
View File
@@ -963,10 +963,22 @@
"updates": "Updates"
},
"accessibility": {
"cvd_palette": {
"description_mobile": "Uses tag and calendar colours that stay distinct with red-green or blue-yellow colour blindness."
},
"font": {
"description_ios": "Atkinson Hyperlegible and Lexend are designed to be easy to tell apart; OpenDyslexic helps some people with dyslexia. On this device the font is used for plain-text messages."
},
"plain_language": {
"description_mobile": "Simpler wording for settings and error messages. Available in English and German."
},
"presets": {
"applied_mobile": "{name} changed {count, plural, one {# setting} other {# settings}}",
"apply": "Apply",
"apply_named": "Apply {name}",
"dyslexia": {
"description_mobile": "Atkinson Hyperlegible as the font, also for plain-text messages."
},
"low-vision": {
"description_mobile": "Text at 150% and labels under the toolbar icons."
},
@@ -1016,7 +1028,6 @@
"accessibility_link": {
"description_mobile": "Text size, reduced motion and the accessibility presets are on the Accessibility page."
},
"description_mobile": "Customize how the app looks",
"cross_all": {
"label_mobile": "All mail"
},
@@ -1026,6 +1037,7 @@
"cross_unread": {
"label_mobile": "All unread"
},
"description_mobile": "Customize how the app looks",
"language": {
"restart_for_direction": "Restart the app to apply the new text direction.",
"system_default": "System default"
+158
View File
@@ -0,0 +1,158 @@
"""Build the reading fonts the app bundles, from the webmail's copies.
Settings > Accessibility > Font offers the webmail's three reading fonts
(app/accessibility.css): Atkinson Hyperlegible Next, Lexend and OpenDyslexic,
all under the SIL Open Font License. The webmail ships them as woff2 subsets
(latin and latin-ext). The app needs them twice:
- Android draws UI text with fonts from android/app/src/main/assets/fonts,
looked up by family name, with a `_bold` file for bold text. Android can't
read woff2 or variable weights there, so each family becomes a static
Regular (400) and Bold (700) TTF, latin and latin-ext merged.
- A plain-text mail body renders in a WebView, which reads the woff2 files
directly; they go into src/theme/reading-fonts-data.ts as base64 so the
sandboxed page (CSP font-src data:) can load them without file access.
Run from the app root with the webmail checked out two levels up (or
WEBMAIL_DIR set to it):
python scripts/build-reading-fonts.py
Needs fontTools with brotli (pip install fonttools brotli).
"""
import base64
import io
import os
import re
import sys
from fontTools.merge import Merger
from fontTools.ttLib import TTFont
from fontTools.varLib.instancer import instantiateVariableFont
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# The webmail checkout (the app lives in its repos/react-native); WEBMAIL_DIR
# points elsewhere, e.g. from a separate worktree.
WEBMAIL_FONTS = os.path.join(os.environ.get('WEBMAIL_DIR') or os.path.join(ROOT, '..', '..'), 'app', 'fonts')
ASSETS = os.path.join(ROOT, 'android', 'app', 'src', 'main', 'assets', 'fonts')
DATA = os.path.join(ROOT, 'src', 'theme', 'reading-fonts-data.ts')
# Android family name -> the webmail's woff2 subsets (latin first).
FAMILIES = {
'AtkinsonHyperlegibleNext': {
'variable': ['atkinson-hyperlegible-next-latin-wght-normal.woff2',
'atkinson-hyperlegible-next-latin-ext-wght-normal.woff2'],
},
'Lexend': {
'variable': ['lexend-latin-wght-normal.woff2', 'lexend-latin-ext-wght-normal.woff2'],
},
'OpenDyslexic': {
400: ['opendyslexic-latin-400-normal.woff2'],
700: ['opendyslexic-latin-700-normal.woff2'],
},
}
# The woff2 files a mail body loads: (export name, css family, weight range, files).
WEB_FACES = [
('ATKINSON', 'Atkinson Hyperlegible Next', '200 800', FAMILIES['AtkinsonHyperlegibleNext']['variable']),
('LEXEND', 'Lexend', '100 900', FAMILIES['Lexend']['variable']),
('OPENDYSLEXIC', 'OpenDyslexic', '400', FAMILIES['OpenDyslexic'][400]),
]
def load(name):
return TTFont(os.path.join(WEBMAIL_FONTS, name))
def static_instance(name, weight):
font = load(name)
if 'fvar' in font:
font = instantiateVariableFont(font, {'wght': weight}, updateFontNames=False)
font.flavor = None
return font
def merge(fonts, out_path):
paths = []
for i, font in enumerate(fonts):
tmp = out_path + f'.part{i}.ttf'
font.save(tmp)
paths.append(tmp)
if len(paths) == 1:
os.replace(paths[0], out_path)
return
merged = Merger().merge(paths)
merged.save(out_path)
for p in paths:
os.remove(p)
def build_assets():
os.makedirs(ASSETS, exist_ok=True)
for family, spec in FAMILIES.items():
for weight, suffix in ((400, ''), (700, '_bold')):
files = spec.get('variable') or spec[weight]
fonts = [static_instance(f, weight) for f in files]
out = os.path.join(ASSETS, family + suffix + '.ttf')
merge(fonts, out)
print('wrote', os.path.relpath(out, ROOT), os.path.getsize(out))
def unicode_ranges():
"""woff2 file name -> its unicode-range in the webmail's app/accessibility.css."""
css_path = os.path.join(WEBMAIL_FONTS, '..', 'accessibility.css')
with open(css_path, encoding='utf-8') as fh:
css = fh.read()
out = {}
for block in re.findall(r'@font-face\s*\{([^}]*)\}', css):
src = re.search(r"fonts/([^\"')]+\.woff2)", block)
rng = re.search(r'unicode-range:\s*([^;]+);', block)
if src:
out[src.group(1)] = rng.group(1).strip() if rng else ''
return out
def build_data():
ranges = unicode_ranges()
lines = [
"// Generated by scripts/build-reading-fonts.py from the webmail's",
'// app/fonts (SIL Open Font License 1.1, see',
'// android/app/src/main/assets/fonts/OFL.txt). Do not edit by hand. The',
'// woff2 files a plain-text mail body loads as data: URIs, with the',
"// unicode ranges of the webmail's app/accessibility.css.",
'',
'export interface ReadingFontFace {',
' /** CSS font-family name, as the webmail uses it. */',
' family: string;',
' /** font-weight descriptor (a range for variable fonts). */',
' weight: string;',
' /** One per unicode subset: base64 woff2 and its unicode-range ("" = all). */',
' subsets: Array<{ woff2: string; unicodeRange: string }>;',
'}',
'',
]
for export, family, weight, files in WEB_FACES:
lines.append(f'export const {export}: ReadingFontFace = {{')
lines.append(f" family: '{family}',")
lines.append(f" weight: '{weight}',")
lines.append(' subsets: [')
for name in files:
if name not in ranges:
sys.exit(f"{name} is not in the webmail's accessibility.css")
with open(os.path.join(WEBMAIL_FONTS, name), 'rb') as fh:
blob = base64.b64encode(fh.read()).decode('ascii')
lines.append(' {')
lines.append(f" unicodeRange: '{ranges[name]}',")
lines.append(f" woff2: '{blob}',")
lines.append(' },')
lines.append(' ],')
lines.append('};')
lines.append('')
with open(DATA, 'w', encoding='utf-8', newline='\n') as fh:
fh.write('\n'.join(lines))
print('wrote', os.path.relpath(DATA, ROOT), os.path.getsize(DATA))
if __name__ == '__main__':
if not os.path.isdir(WEBMAIL_FONTS):
sys.exit(f'No webmail fonts at {WEBMAIL_FONTS}')
build_assets()
build_data()
+4
View File
@@ -17,6 +17,10 @@ const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8'));
// Code that isn't an npm dependency but ships in the app.
const VENDORED = [
{ name: 'Tabler Icons', version: '', license: 'MIT', url: 'https://tabler.io/icons' },
// Reading fonts (scripts/build-reading-fonts.py).
{ name: 'Atkinson Hyperlegible Next', version: '', license: 'OFL-1.1', url: 'https://github.com/googlefonts/atkinson-hyperlegible-next' },
{ name: 'Lexend', version: '', license: 'OFL-1.1', url: 'https://github.com/googlefonts/lexend' },
{ name: 'OpenDyslexic', version: '', license: 'OFL-1.1', url: 'https://opendyslexic.org' },
];
function repoUrl(meta) {
+18 -1
View File
@@ -1,4 +1,6 @@
// Vendor the webmail locale catalogs into ./locales/<lang>/common.json.
// Vendor the webmail locale catalogs into ./locales/<lang>/common.json, and
// the plain-language strings (./locales/<lang>/plain.json, English and German
// only; src/i18n/index.ts lays them over the catalog when Plain language is on).
// Run from the RN repo root: `node scripts/sync-locales.mjs [--check]`.
//
// The webmail catalog is the source of truth and is copied verbatim. Keys the
@@ -68,6 +70,21 @@ for (const lang of langs) {
copied++;
}
const plainSrc = join(WEBMAIL_LOCALES, lang, 'plain.json');
const plainDest = join(destDir, 'plain.json');
if (existsSync(plainSrc)) {
if (CHECK_ONLY) {
const same = existsSync(plainDest)
&& (await readFile(plainSrc, 'utf8')) === (await readFile(plainDest, 'utf8'));
if (!same) {
console.log(`stale: locales/${lang}/plain.json differs from the webmail`);
problems++;
}
} else {
await copyFile(plainSrc, plainDest);
}
}
// Report overlay keys the webmail now ships (safe to delete from the overlay)
// so the RN-only set stays minimal.
const overlayPath = join(RN_OVERLAYS, `${lang}.json`);
+16 -111
View File
@@ -6,6 +6,7 @@ import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { Image as ImageIcon, ShieldCheck } from './icons';
import type { Email } from '../api/types';
import { extractCidRefs, isUserLinkNavigation } from '../lib/email-html';
import { buildEmailDarkModeScript } from '../lib/email-dark-mode';
import { hasTruncatedDisplayedBody, pickEmailBody, selectRenderableHtml } from '../lib/email-body';
import { buildQuoteCollapseScript } from '../lib/quote-collapse';
import { bodyDocument } from '../lib/email-body-document';
@@ -67,109 +68,6 @@ interface EmailBodyViewProps {
onShowFormatted?: () => void;
}
// Dark-mode re-inversion, mirroring the webmail's handleIframeLoad pass. The
// body's `filter: invert(1)` flips colored emoji (yellow smiley -> blue, red
// heart -> cyan) and stylesheet-defined background images, which the static CSS
// attribute selectors can't reach. This walks the DOM once and wraps/re-inverts
// those so they keep their original colors. Only injected when inversion is on.
const DARK_REINVERT_SCRIPT = `
(function () {
if (document.__rnDarkInvertDone) return;
var doc = document;
var win = window;
if (!doc.body) return;
document.__rnDarkInvertDone = true;
// Re-invert elements with stylesheet-defined background images (CSS attribute
// selectors only catch inline styles, not rules that survive in <style>).
doc.body.querySelectorAll('*').forEach(function (el) {
// Skip elements already handled by CSS attribute selectors.
if (el.style.backgroundImage || el.style.background ||
el.hasAttribute('background') || el.hasAttribute('bgcolor')) return;
// Skip leaf media elements (already re-inverted by CSS).
var tag = el.tagName;
if (['IMG','VIDEO','SVG','CANVAS','OBJECT','EMBED'].indexOf(tag) !== -1) return;
var computed = win.getComputedStyle(el);
if (computed.backgroundImage && computed.backgroundImage !== 'none') {
// Re-invert so the background image returns to its original
// appearance. Matches the CSS background-image rules: re-invert even
// when media is nested, then cancel the per-image re-invert on that
// media so it isn't left double-inverted.
el.style.filter = 'invert(1) hue-rotate(180deg)';
el.querySelectorAll('img, video, svg, canvas, object, embed, input[type="image"]')
.forEach(function (m) { m.style.filter = 'none'; });
}
});
// Re-invert emoji glyphs so they keep their original colors. Wrap each emoji
// run in a span that re-inverts. Only act when the ancestor invert depth is
// odd - emojis inside a double-inverted bgcolor container already render at
// their original colors.
var emojiRe;
try {
emojiRe = new RegExp('\\\\p{RGI_Emoji}', 'gv');
} catch (e) {
emojiRe = /\\p{Extended_Pictographic}(?:\\uFE0F)?(?:\\u200D\\p{Extended_Pictographic}(?:\\uFE0F)?)*/gu;
}
var emojiTestRe = /\\p{Extended_Pictographic}/u;
var SKIP_TAGS = { SCRIPT: 1, STYLE: 1, NOSCRIPT: 1, TEXTAREA: 1, IFRAME: 1 };
function isOddInvertDepth(start) {
var count = 0;
var n = start;
while (n) {
if (n === doc.body) { count++; break; }
var cs = win.getComputedStyle(n);
if (cs.filter && cs.filter.indexOf('invert') !== -1) count++;
n = n.parentElement;
}
return count % 2 === 1;
}
var walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_TEXT, {
acceptNode: function (node) {
var p = node.parentElement;
while (p) {
if (SKIP_TAGS[p.tagName]) return NodeFilter.FILTER_REJECT;
p = p.parentElement;
}
return emojiTestRe.test(node.nodeValue || '')
? NodeFilter.FILTER_ACCEPT
: NodeFilter.FILTER_REJECT;
}
});
var emojiTextNodes = [];
var cur;
while ((cur = walker.nextNode())) emojiTextNodes.push(cur);
emojiTextNodes.forEach(function (textNode) {
var parent = textNode.parentElement;
if (!parent || !isOddInvertDepth(parent)) return;
var text = textNode.nodeValue || '';
emojiRe.lastIndex = 0;
var frag = doc.createDocumentFragment();
var lastIndex = 0;
var m;
while ((m = emojiRe.exec(text)) !== null) {
if (m.index > lastIndex) {
frag.appendChild(doc.createTextNode(text.slice(lastIndex, m.index)));
}
var span = doc.createElement('span');
span.style.cssText = 'filter:invert(1) hue-rotate(180deg)';
span.textContent = m[0];
frag.appendChild(span);
lastIndex = m.index + m[0].length;
}
if (lastIndex === 0) return;
if (lastIndex < text.length) {
frag.appendChild(doc.createTextNode(text.slice(lastIndex)));
}
parent.replaceChild(frag, textNode);
});
})();
`;
// Image fix-ups, mirroring the webmail's post-render passes:
// - collapse empty <td>/<div> wrappers left behind by blocked images so a
// blocked newsletter doesn't show rows of empty cells;
@@ -546,6 +444,9 @@ export default function EmailBodyView({
const syncTrustedSenders = isTrustedSendersSyncOn(trustedSendersAddressBook, hasContacts);
const emailAlwaysLightMode = useSettingsStore((s) => s.emailAlwaysLightMode);
const plainTextFont = useSettingsStore((s) => s.plainTextFont);
// The reading font reaches a plain-text body ("Same as above"), as in the
// webmail; HTML mail keeps the sender's fonts.
const appFont = useSettingsStore((s) => s.appFont);
const messageSpacing = useSettingsStore((s) => s.messageSpacing);
const plainTextMode = useSettingsStore((s) => s.plainTextMode);
// Body text follows the app's text size and the system font scale (webmail
@@ -569,8 +470,8 @@ export default function EmailBodyView({
React.useEffect(() => {
if (syncTrustedSenders && !trustedSendersLoaded) void loadTrustedSendersBook(false);
}, [syncTrustedSenders, trustedSendersLoaded, loadTrustedSendersBook]);
// Most marketing email is authored against a white background, so dark-mode
// inversion can wreck logos/banners. With this flag the user opts to render
// Most marketing email is authored against a white background, and some
// people prefer it as sent. With this flag the user opts to render
// emails on a light surface even while the rest of the app is dark; the
// per-message toggle in the More sheet overrides both.
const renderAsDark = themeOverride
@@ -713,9 +614,10 @@ export default function EmailBodyView({
quoteLabels,
textFactor,
plainDarkColors,
appFont,
}), [
jmapAccountId, email.id, rawHtml, text, email.preview, emptyLabel, shouldBlock, cidMap, renderAsDark,
messageSpacing, plainTextFont, quoteLabels, textFactor, plainDarkColors,
messageSpacing, plainTextFont, quoteLabels, textFactor, plainDarkColors, appFont,
]);
const source = React.useMemo(
@@ -730,18 +632,21 @@ export default function EmailBodyView({
// Still cut off after the API's larger refetch (or the refetch failed).
const showTruncated = !bodyOverride && hasTruncatedDisplayedBody(email);
// Inversion is applied for HTML bodies in dark mode unless the email ships
// its own dark-mode CSS - decided once by prepareEmailHtml on the same
// input the CSS was built from, so the DOM pass and the stylesheet agree.
// An HTML body shown dark has its colours rewritten after layout (webmail
// applyEmailDarkMode): decided once by prepareEmailHtml, whose head hides
// the body until this pass ran. It comes after the quote toggle and the
// Play buttons exist, so those are converted too, as in the webmail. Only
// with remote content allowed may a remote image be fetched again to look
// at its pixels.
const injectedJs = React.useMemo(
() =>
(prepared.isHtml ? buildMailTextSizeScript(textFactor) : '')
+ (prepared.isHtml ? buildQuoteCollapseScript(quoteLabels) : '')
+ (prepared.applyInversion ? DARK_REINVERT_SCRIPT : '')
+ (prepared.isHtml ? IMG_FIXUP_SCRIPT : '')
+ (prepared.isHtml && reduceMotion ? buildPauseAnimatedImagesScript(pauseLabels) : '')
+ (prepared.recolorDark ? buildEmailDarkModeScript({ remoteContentAllowed: !shouldBlock }) : '')
+ HEIGHT_REPORTER + SWIPE_DETECTOR + PINCH_ZOOM,
[prepared.isHtml, prepared.applyInversion, quoteLabels, textFactor, reduceMotion, pauseLabels],
[prepared.isHtml, prepared.recolorDark, shouldBlock, quoteLabels, textFactor, reduceMotion, pauseLabels],
);
// Performance harness: how much of opening a message is the WebView.
@@ -1,10 +1,11 @@
import React from 'react';
import { View } from 'react-native';
import { Platform, View } from 'react-native';
import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
import Button from '../Button';
import { spacing } from '../../theme/tokens';
import { useLocaleStore } from '../../stores/locale-store';
import { FONT_SIZES, useSettingsStore, type FontSize } from '../../stores/settings-store';
import { APP_FONTS, FONT_SIZES, useSettingsStore, type AppFont, type FontSize } from '../../stores/settings-store';
import { uiFontsBundled } from '../../theme/reading-fonts';
import { toast } from '../../stores/toast-store';
import {
ACCESSIBILITY_PRESET_IDS,
@@ -15,6 +16,14 @@ import {
} from '../../lib/accessibility-presets';
import { TOAST_DURATION_CHOICES } from '../../lib/toast-duration';
// Font names are names: the same in every language (webmail
// accessibility-settings.tsx).
const FONT_NAMES: Record<Exclude<AppFont, 'default'>, string> = {
atkinson: 'Atkinson Hyperlegible',
lexend: 'Lexend',
opendyslexic: 'OpenDyslexic',
};
// Percentages read the same in every language the app has.
const TEXT_SIZE_LABELS: Record<FontSize, string> = {
small: '87.5%',
@@ -41,6 +50,11 @@ function presetCopy(id: AccessibilityPresetId, t: Translate): { label: string; d
label: t('settings.accessibility.presets.low-vision.label', 'Low vision'),
description: t('settings.accessibility.presets.low-vision.description_mobile', 'Text at 150% and labels under the toolbar icons.'),
};
case 'dyslexia':
return {
label: t('settings.accessibility.presets.dyslexia.label', 'Dyslexia'),
description: t('settings.accessibility.presets.dyslexia.description_mobile', 'Atkinson Hyperlegible as the font, also for plain-text messages.'),
};
case 'motion':
return {
label: t('settings.accessibility.presets.motion.label', 'Motion and seizure safe'),
@@ -61,6 +75,9 @@ export function AccessibilitySettings() {
const hydrate = useSettingsStore((s) => s.hydrate);
const update = useSettingsStore((s) => s.updateSetting);
const fontSize = useSettingsStore((s) => s.fontSize);
const appFont = useSettingsStore((s) => s.appFont);
const colorBlindPalette = useSettingsStore((s) => s.colorBlindPalette);
const plainLanguage = useSettingsStore((s) => s.plainLanguage);
const animationsEnabled = useSettingsStore((s) => s.animationsEnabled);
const toastDuration = useSettingsStore((s) => s.toastDuration);
const sendConfirmation = useSettingsStore((s) => s.sendConfirmation);
@@ -130,7 +147,6 @@ export function AccessibilitySettings() {
<SettingItem
label={t('settings.accessibility.text_size.label', 'Text size')}
description={t('settings.accessibility.text_size.description_mobile', "Scales the app's text on top of your phone's text size. 100% is your phone's size.")}
noBorder
>
<Select
value={fontSize}
@@ -138,6 +154,46 @@ export function AccessibilitySettings() {
options={FONT_SIZES.map((size) => ({ value: size, label: TEXT_SIZE_LABELS[size] }))}
/>
</SettingItem>
<SettingItem
label={t('settings.accessibility.font.label', 'Font')}
description={uiFontsBundled(Platform.OS)
? t('settings.accessibility.font.description', 'Atkinson Hyperlegible and Lexend are designed to be easy to tell apart; OpenDyslexic helps some people with dyslexia. They cover Latin script.')
: t('settings.accessibility.font.description_ios', 'Atkinson Hyperlegible and Lexend are designed to be easy to tell apart; OpenDyslexic helps some people with dyslexia. On this device the font is used for plain-text messages.')}
>
<Select
value={appFont}
onChange={(v) => update('appFont', v as AppFont)}
options={APP_FONTS.map((font) => ({
value: font,
label: font === 'default' ? t('settings.accessibility.font.default', 'Theme font') : FONT_NAMES[font],
}))}
/>
</SettingItem>
<SettingItem
label={t('settings.accessibility.plain_language.label', 'Plain language')}
description={t('settings.accessibility.plain_language.description_mobile', 'Simpler wording for settings and error messages. Available in English and German.')}
noBorder
>
<ToggleSwitch
checked={plainLanguage}
onChange={(v) => update('plainLanguage', v)}
accessibilityLabel={t('settings.accessibility.plain_language.label', 'Plain language')}
/>
</SettingItem>
</SettingsSection>
<SettingsSection title={t('settings.accessibility.sections.contrast', 'Colour and contrast')}>
<SettingItem
label={t('settings.accessibility.cvd_palette.label', 'Colour-blind-safe colours')}
description={t('settings.accessibility.cvd_palette.description_mobile', 'Uses tag and calendar colours that stay distinct with red-green or blue-yellow colour blindness.')}
noBorder
>
<ToggleSwitch
checked={colorBlindPalette}
onChange={(v) => update('colorBlindPalette', v)}
accessibilityLabel={t('settings.accessibility.cvd_palette.label', 'Colour-blind-safe colours')}
/>
</SettingItem>
</SettingsSection>
<SettingsSection title={t('settings.accessibility.sections.motion', 'Motion')}>
+6 -4
View File
@@ -113,7 +113,7 @@ export function KeywordSettings() {
/>
);
}
const palette = c.tags[kw.color];
const palette = (c.tagsAsPicked ?? c.tags)[kw.color];
return (
<View key={kw.id} style={styles.kwRow}>
<View style={[styles.kwDot, { backgroundColor: palette.dot }]} />
@@ -178,7 +178,7 @@ export function KeywordSettings() {
</Text>
{scan.found.map((kw) => (
<View key={kw.id} style={styles.kwRow}>
<View style={[styles.kwDot, { backgroundColor: c.tags[kw.color]?.dot ?? c.textMuted }]} />
<View style={[styles.kwDot, { backgroundColor: (c.tagsAsPicked ?? c.tags)[kw.color]?.dot ?? c.textMuted }]} />
<View style={{ flex: 1 }}>
<Text style={styles.kwLabel}>{kw.label}</Text>
<Text style={styles.kwId}>{kw.keyword} · {kw.count}</Text>
@@ -251,7 +251,7 @@ function SharedTagsSection() {
) : (
tags.map((kw) => (
<View key={kw.id} style={styles.kwRow}>
<View style={[styles.kwDot, { backgroundColor: c.tags[kw.color]?.dot ?? c.textMuted }]} />
<View style={[styles.kwDot, { backgroundColor: (c.tagsAsPicked ?? c.tags)[kw.color]?.dot ?? c.textMuted }]} />
<Text style={styles.kwLabel}>{kw.label}</Text>
<Text style={styles.kwId}>$label:{kw.id}</Text>
</View>
@@ -313,7 +313,9 @@ function KeywordForm({ initial, existingIds, onSave, onCancel }: KeywordFormProp
<Text style={styles.formLabel}>{t('settings.keywords.color_field', "Color")}</Text>
<View style={styles.palette}>
{PALETTE_KEYS.map((key) => {
const p = c.tags[key];
// The colours as picked, also with colour-blind-safe colours on
// (webmail keyword-settings.tsx draws KEYWORD_PALETTE).
const p = (c.tagsAsPicked ?? c.tags)[key];
return (
<Pressable
key={key}
+129
View File
@@ -0,0 +1,129 @@
// Plain language (Settings > Accessibility, webmail i18n/plain-language.ts and
// lib/__tests__/plain-language-strings.test.ts): simpler English and German
// strings laid over the catalog while the setting is on.
import { describe, it, expect, vi } from 'vitest';
import { readFileSync, readdirSync, existsSync } from 'node:fs';
import { join } from 'node:path';
vi.mock('expo-localization', () => ({ getLocales: () => [{ languageTag: 'en-US', languageCode: 'en' }] }));
// The setup file's stub plus I18nManager, which the locale store uses.
vi.mock('react-native', () => ({
Platform: { OS: 'android', Version: 33, select: <T,>(spec: { default?: T; android?: T }) => spec.android ?? spec.default },
NativeModules: {},
NativeEventEmitter: class { addListener() { return { remove: () => undefined }; } removeAllListeners() {} },
Linking: { openURL: async () => undefined },
Appearance: { getColorScheme: () => 'dark', addChangeListener: () => ({ remove: () => undefined }) },
AppState: { currentState: 'active', addEventListener: () => ({ remove: () => undefined }) },
I18nManager: { isRTL: false, allowRTL: () => {}, forceRTL: () => {} },
}));
import { hasPlainLanguage, translate, SUPPORTED_LOCALES } from '../index';
import { followPlainLanguage, useLocaleStore } from '../../stores/locale-store';
import { mergeWithDefaults } from '../../stores/settings-store';
const LOCALES = join(__dirname, '..', '..', '..', 'locales');
type Tree = Record<string, unknown>;
function leaves(node: unknown, prefix = ''): Array<[string, unknown]> {
if (node && typeof node === 'object' && !Array.isArray(node)) {
return Object.entries(node as Tree).flatMap(([key, value]) => leaves(value, prefix ? `${prefix}.${key}` : key));
}
return [[prefix, node]];
}
const load = (locale: string, file: string): Tree => JSON.parse(readFileSync(join(LOCALES, locale, file), 'utf8'));
// Argument names a message uses: `{name}` and `{count, plural, …}`; the word
// in a plural branch (`one {item}`) is text.
function argumentsOf(message: string): string[] {
const names = new Set<string>();
for (const m of message.matchAll(/(?<!\b(?:zero|one|two|few|many|other)\s*|=\d+\s*)\{\s*(\w+)\s*[,}]/g)) {
names.add(m[1]);
}
return [...names].sort();
}
const plainLocales = readdirSync(LOCALES).filter((l) => existsSync(join(LOCALES, l, 'plain.json')));
describe('plain-language strings', () => {
it('exist for English and German only, each with a loader', () => {
expect(plainLocales.sort()).toEqual(['de', 'en']);
expect(SUPPORTED_LOCALES.map((l) => l.code).filter(hasPlainLanguage).sort()).toEqual(['de', 'en']);
});
for (const locale of ['en', 'de']) {
describe(locale, () => {
const plain = leaves(load(locale, 'plain.json'));
const common = new Map(leaves(load(locale, 'common.json')));
it('has strings', () => {
expect(plain.length).toBeGreaterThan(100);
});
it('only overrides keys that exist in common.json', () => {
expect(plain.map(([key]) => key).filter((key) => !common.has(key))).toEqual([]);
});
it('only holds non-empty strings with the placeholders of the string they replace', () => {
const bad = plain
.filter(([key, value]) => typeof value !== 'string' || !value.trim()
|| JSON.stringify(argumentsOf(value)) !== JSON.stringify(argumentsOf(String(common.get(key)))))
.map(([key]) => key);
expect(bad).toEqual([]);
});
});
}
});
describe('translate with plain language', () => {
const key = 'login.error.invalid_credentials';
it('uses the plain string where there is one', () => {
expect(translate('en', key)).toBe('Invalid email or password. Please check your credentials and try again.');
expect(translate('en', key, undefined, undefined, true)).toBe('The email address or password is wrong. Check both and try again.');
const de = translate('de', key, undefined, undefined, true);
expect(de).not.toBe(translate('de', key));
expect(de).toBe((load('de', 'plain.json').login as Tree & { error: Tree }).error.invalid_credentials);
});
it('keeps the normal string where there is no plain one, and other languages as they are', () => {
expect(translate('en', 'settings.tabs.account', undefined, undefined, true)).toBe(translate('en', 'settings.tabs.account'));
expect(translate('fr', key, undefined, undefined, true)).toBe(translate('fr', key));
});
});
describe('the locale store', () => {
const key = 'login.error.invalid_credentials';
it('hands out a new t that speaks plainly while the setting is on', () => {
useLocaleStore.getState().setOverride('en');
const normal = useLocaleStore.getState().t;
useLocaleStore.getState().setPlainLanguage(true);
const plain = useLocaleStore.getState().t;
expect(plain).not.toBe(normal);
expect(plain(key)).toBe('The email address or password is wrong. Check both and try again.');
// Changing the language keeps the plain wording.
useLocaleStore.getState().setOverride('de');
expect(useLocaleStore.getState().t(key)).toBe(translate('de', key, undefined, undefined, true));
useLocaleStore.getState().setPlainLanguage(false);
useLocaleStore.getState().setOverride('en');
expect(useLocaleStore.getState().t(key)).toBe(translate('en', key));
});
it('follows the settings store switch', () => {
let state = { plainLanguage: false };
const listeners: Array<(s: typeof state) => void> = [];
followPlainLanguage({ getState: () => state, subscribe: (l) => listeners.push(l) });
expect(useLocaleStore.getState().plainLanguage).toBe(false);
state = { plainLanguage: true };
listeners.forEach((l) => l(state));
expect(useLocaleStore.getState().plainLanguage).toBe(true);
state = { plainLanguage: false };
listeners.forEach((l) => l(state));
expect(useLocaleStore.getState().plainLanguage).toBe(false);
});
it('is off by default and kept when stored', () => {
expect(mergeWithDefaults({}).plainLanguage).toBe(false);
expect(mergeWithDefaults({ plainLanguage: true }).plainLanguage).toBe(true);
});
});
+36 -5
View File
@@ -144,16 +144,44 @@ const OVERLAY_LOADERS: Record<LocaleCode, () => Dictionary> = {
'zh-TW': () => require('../../locales/rn/zh-TW.json'),
};
const dictionaries: Partial<Record<LocaleCode, Dictionary>> = {};
// Plain language (Settings > Accessibility, webmail i18n/plain-language.ts):
// simpler wording for settings and error messages. Only English and German
// have it; ../../locales/<code>/plain.json holds just the keys that get a
// simpler version, under the same paths as common.json, and anything it
// leaves out keeps the normal string. Every other language is unchanged
// when the setting is on.
const PLAIN_LOADERS: Partial<Record<LocaleCode, () => Dictionary>> = {
en: () => require('../../locales/en/plain.json'),
de: () => require('../../locales/de/plain.json'),
};
/** The merged catalog (vendored + RN overlay) for a locale, loaded on first use. */
export function getDictionary(locale: LocaleCode): Dictionary {
/** Whether a language has plain-language strings. */
export function hasPlainLanguage(locale: string): boolean {
return Object.prototype.hasOwnProperty.call(PLAIN_LOADERS, locale);
}
const dictionaries: Partial<Record<LocaleCode, Dictionary>> = {};
const plainDictionaries: Partial<Record<LocaleCode, Dictionary>> = {};
/**
* The merged catalog (vendored + RN overlay) for a locale, loaded on first
* use. With `plain`, the plain-language strings win over it where the
* language has them.
*/
export function getDictionary(locale: LocaleCode, plain = false): Dictionary {
let dict = dictionaries[locale];
if (!dict) {
dict = deepMerge(CATALOG_LOADERS[locale](), OVERLAY_LOADERS[locale]());
dictionaries[locale] = dict;
}
return dict;
const loadPlain = plain ? PLAIN_LOADERS[locale] : undefined;
if (!loadPlain) return dict;
let plainDict = plainDictionaries[locale];
if (!plainDict) {
plainDict = deepMerge(dict, loadPlain());
plainDictionaries[locale] = plainDict;
}
return plainDict;
}
/** Which catalogs have been loaded so far (for tests). */
@@ -250,8 +278,11 @@ export function translate(
key: string,
fallback?: string,
params?: MessageParams,
plain = false,
): string {
const message = lookup(getDictionary(locale), key)
// A key the language lacks falls back to normal English, as in the
// webmail (plain strings are laid over the language's own catalog only).
const message = lookup(getDictionary(locale, plain), key)
?? lookup(getDictionary('en'), key)
?? fallback
?? key;
+89
View File
@@ -0,0 +1,89 @@
// Colour-blind-safe colours (webmail lib/cvd-palette.ts and its tests,
// cf4d02b49), and how the app's theme draws tags with the setting on.
import { describe, it, expect } from 'vitest';
import { CVD_COLORS, cvdColor, cvdHue, cvdTagPalette } from '../cvd-palette';
import { resolvePalette } from '../../theme/colors';
import { DARK_COLORS, LIGHT_COLORS } from '../../theme/tokens';
import { mergeWithDefaults } from '../../stores/settings-store';
describe('cvdHue', () => {
it('maps the default calendar and tag colours onto separate palette colours', () => {
expect(cvdHue('#ef4444')).toBe('vermillion');
expect(cvdHue('#f97316')).toBe('orange');
expect(cvdHue('#eab308')).toBe('yellow');
expect(cvdHue('#22c55e')).toBe('bluishGreen');
expect(cvdHue('#06b6d4')).toBe('skyBlue');
expect(cvdHue('#3b82f6')).toBe('blue');
expect(cvdHue('#a855f7')).toBe('reddishPurple');
});
it('keeps red and green apart, the pair red-green colour blindness merges', () => {
expect(cvdHue('#dc2626')).not.toBe(cvdHue('#16a34a'));
expect(cvdHue('#b91c1c')).toBe('vermillion');
expect(cvdHue('#15803d')).toBe('bluishGreen');
});
it('reads tag palette names, with their light and dark shades', () => {
expect(cvdHue('red')).toBe('vermillion');
expect(cvdHue('teal-light')).toBe('skyBlue');
expect(cvdHue('indigo-dark')).toBe('blue');
expect(cvdHue('gray')).toBe('grey');
});
it('reads short hex, rgb() and hsl()', () => {
expect(cvdHue('#f00')).toBe('vermillion');
expect(cvdHue('rgb(0, 128, 0)')).toBe('bluishGreen');
expect(cvdHue('hsl(220, 80%, 50%)')).toBe('blue');
});
it('turns greys grey and gives up on what it cannot read', () => {
expect(cvdHue('#777777')).toBe('grey');
expect(cvdHue('var(--color-primary)')).toBeNull();
expect(cvdHue('')).toBeNull();
expect(cvdHue(undefined)).toBeNull();
});
});
describe('cvdColor', () => {
it('draws a colour as it is while the setting is off', () => {
expect(cvdColor('#3b82f6', false)).toBe('#3b82f6');
});
it('draws the palette colour while it is on, and leaves unreadable colours alone', () => {
expect(cvdColor('#3b82f6', true)).toBe(CVD_COLORS.blue);
expect(cvdColor('var(--color-primary)', true)).toBe('var(--color-primary)');
});
});
describe('tags drawn colour-blind-safe', () => {
it('draws the dot in the palette colour, the fill as its tint, the label in the text colour', () => {
const tags = cvdTagPalette(DARK_COLORS.tags, '#fafafa');
expect(tags.red).toEqual({ dot: CVD_COLORS.vermillion, bg: 'rgba(213, 94, 0, 0.15)', text: '#fafafa' });
expect(tags.green.dot).toBe(CVD_COLORS.bluishGreen);
expect(tags.red.dot).not.toBe(tags.green.dot);
expect(tags.gray.dot).toBe(CVD_COLORS.grey);
});
it('switches every c.tags colour in the theme and keeps the picked ones for the picker', () => {
const plain = resolvePalette('dark', null);
expect(plain).toBe(DARK_COLORS);
expect(plain.tags.red.dot).toBe('#ef4444');
const safe = resolvePalette('dark', null, true);
expect(safe.tags.red.dot).toBe(CVD_COLORS.vermillion);
expect(safe.tags.red.text).toBe(DARK_COLORS.text);
expect(safe.tagsAsPicked).toBe(DARK_COLORS.tags);
// The rest of the theme is untouched, and the palette object is stable.
expect(safe.primary).toBe(DARK_COLORS.primary);
expect(resolvePalette('dark', null, true)).toBe(safe);
const light = resolvePalette('light', 'builtin-roundcube-elastic', true);
expect(light.tags.blue.dot).toBe(CVD_COLORS.blue);
expect(light.tagsAsPicked).toBe(LIGHT_COLORS.tags);
});
it('is off by default and kept when stored', () => {
expect(mergeWithDefaults({}).colorBlindPalette).toBe(false);
expect(mergeWithDefaults({ colorBlindPalette: true }).colorBlindPalette).toBe(true);
});
});
@@ -50,6 +50,15 @@ describe('bodyDocument', () => {
expect(bodyDocument(input({ cidMap: { a: 'data:image/png;base64,AA' } }))).not.toBe(first);
});
it('draws a plain-text body in the reading font and rebuilds when it changes', () => {
const text = input({ rawHtml: null, text: 'Hello' });
const plain = bodyDocument(text);
const lexend = bodyDocument({ ...text, appFont: 'lexend' });
expect(lexend).not.toBe(plain);
expect(lexend.html).toContain('font-family: "Lexend"');
expect(plain.html).not.toContain('Lexend');
});
it('keeps messages apart by key', () => {
const a = bodyDocument(input({ key: '|e1' }));
const b = bodyDocument(input({ key: 'group|e1' }));
+332
View File
@@ -0,0 +1,332 @@
// The dark-mode recolour pass (webmail lib/email-dark-mode.ts). The pass is
// JavaScript source injected into the mail WebView; these tests evaluate that
// same source. The colour cases are the webmail's
// (lib/__tests__/email-dark-mode.test.ts). The app has no DOM library, so the
// DOM pass runs on a small fake document that answers getComputedStyle the
// way a browser does for the parts the pass reads (inline styles win, colour
// inherits, everything else has its initial value).
import { describe, it, expect } from 'vitest';
import {
DARK_PENDING_CLASS,
EMAIL_DARK_MODE_LIB,
buildEmailDarkModeScript,
emailColorScheme,
resolveColorSchemeMedia,
type EmailDarkModeLib,
type Rgba,
} from '../email-dark-mode';
const DM = new Function(`return ${EMAIL_DARK_MODE_LIB}`)()() as EmailDarkModeLib;
const c = (value: string): Rgba => DM.parseComputedColor(value)!;
const lightness = ({ r, g, b }: Rgba) => (r + g + b) / 3;
describe('darkenBackground', () => {
it('turns white into a dark canvas', () => {
expect(lightness(DM.darkenBackground(c('rgb(255, 255, 255)')))).toBeLessThan(40);
});
it('keeps a light grey canvas lighter than a white card on it', () => {
const card = DM.darkenBackground(c('rgb(255, 255, 255)'));
const canvas = DM.darkenBackground(c('rgb(244, 244, 244)'));
expect(lightness(canvas)).toBeGreaterThan(lightness(card));
});
it('keeps the hue of a light tint', () => {
const out = DM.darkenBackground(c('rgb(214, 233, 248)'));
expect(lightness(out)).toBeLessThan(80);
expect(out.b).toBeGreaterThan(out.r);
});
it('leaves vivid brand colours and dark fills as sent', () => {
const red = c('rgb(227, 6, 19)');
const green = c('rgb(115, 215, 0)');
const navy = c('rgb(0, 32, 91)');
expect(DM.darkenBackground(red)).toBe(red);
expect(DM.darkenBackground(green)).toBe(green);
expect(DM.darkenBackground(navy)).toBe(navy);
});
it('keeps alpha', () => {
expect(DM.darkenBackground(c('rgba(255, 255, 255, 0.5)')).a).toBe(0.5);
});
});
describe('ensureContrast', () => {
const dark = c('rgb(20, 20, 20)');
it('keeps text that already reads', () => {
const white = c('rgb(255, 255, 255)');
expect(DM.ensureContrast(white, dark, 4.5)).toBe(white);
});
it('lifts dark text above 4.5:1 on a dark background', () => {
const out = DM.ensureContrast(c('rgb(51, 51, 51)'), dark, 4.5);
expect(DM.contrastRatio(out, dark)).toBeGreaterThanOrEqual(4.5);
});
it('keeps the order of text greys', () => {
const black = DM.ensureContrast(c('rgb(0, 0, 0)'), dark, 4.5);
const grey = DM.ensureContrast(c('rgb(102, 102, 102)'), dark, 4.5);
expect(lightness(black)).toBeGreaterThan(lightness(grey));
});
it('keeps a red link red', () => {
const out = DM.ensureContrast(c('rgb(200, 0, 0)'), dark, 4.5);
expect(out.r).toBeGreaterThan(out.g + 60);
});
});
describe('parseComputedColor / formatColor', () => {
it('reads the forms getComputedStyle returns', () => {
expect(c('rgb(1, 2, 3)')).toEqual({ r: 1, g: 2, b: 3, a: 1 });
expect(c('rgba(1, 2, 3, 0.5)')).toEqual({ r: 1, g: 2, b: 3, a: 0.5 });
expect(c('rgb(1 2 3 / 50%)')).toEqual({ r: 1, g: 2, b: 3, a: 0.5 });
expect(DM.parseComputedColor('transparent')).toBeNull();
});
it('writes rgb() when opaque and rgba() otherwise', () => {
expect(DM.formatColor({ r: 1.4, g: 2, b: 3, a: 1 })).toBe('rgb(1, 2, 3)');
expect(DM.formatColor({ r: 1, g: 2, b: 3, a: 0.25 })).toBe('rgba(1, 2, 3, 0.25)');
});
});
describe('resolveColorSchemeMedia', () => {
const html = '<style>@media (prefers-color-scheme: dark) { .a { color: white } } @media (prefers-color-scheme:light) { .b {} }</style><p>(prefers-color-scheme: dark)</p>';
it('turns the dark query on and the light one off in dark mode', () => {
const out = resolveColorSchemeMedia(html, true);
expect(out).toContain('@media (min-width: 0px) { .a');
expect(out).toContain('@media (max-width: 0.001px) { .b');
});
it('turns the dark query off in light mode', () => {
const out = resolveColorSchemeMedia(html, false);
expect(out).toContain('@media (max-width: 0.001px) { .a');
expect(out).toContain('@media (min-width: 0px) { .b');
});
it('leaves text outside <style> alone', () => {
expect(resolveColorSchemeMedia(html, true)).toContain('<p>(prefers-color-scheme: dark)</p>');
});
});
describe('emailColorScheme', () => {
it('is dark only for a mail with its own dark styles shown dark', () => {
expect(emailColorScheme(true, true)).toBe('dark');
expect(emailColorScheme(true, false)).toBe('light');
expect(emailColorScheme(false, true)).toBe('light');
});
});
// ─── A fake rendered document ─────────────────────────────────────────────
type Css = Partial<Record<'color' | 'backgroundColor' | 'backgroundImage' | 'border', string>>;
class FakeStyle {
private props = new Map<string, { value: string; priority: string }>();
setProperty(name: string, value: string, priority = '') { this.props.set(name, { value, priority }); }
getPropertyValue(name: string) { return this.props.get(name)?.value ?? ''; }
getPropertyPriority(name: string) { return this.props.get(name)?.priority ?? ''; }
}
class FakeElement {
parentElement: FakeElement | null = null;
children: FakeElement[] = [];
style = new FakeStyle();
private classes = new Set<string>();
classList = {
remove: (name: string) => { this.classes.delete(name); },
contains: (name: string) => this.classes.has(name),
add: (name: string) => { this.classes.add(name); },
};
constructor(public tagName: string, public css: Css = {}, public id = '') {}
append(...kids: FakeElement[]) {
for (const kid of kids) {
kid.parentElement = this;
this.children.push(kid);
}
return this;
}
descendants(): FakeElement[] {
return this.children.flatMap((kid) => [kid, ...kid.descendants()]);
}
querySelectorAll(selector: string): FakeElement[] {
const all = this.descendants();
return selector === '*' ? all : all.filter((el) => el.tagName.toLowerCase() === selector);
}
closest(selector: string): FakeElement | null {
for (let n: FakeElement | null = this; n; n = n.parentElement) {
if (n.tagName.toLowerCase() === selector) return n;
}
return null;
}
}
/** What the browser would report: the pass's inline writes win, colour inherits. */
function computed(el: FakeElement) {
const own = (prop: string, css: string | undefined) => el.style.getPropertyValue(prop) || css;
const color = (): string => {
for (let n: FakeElement | null = el; n; n = n.parentElement) {
const v = n.style.getPropertyValue('color') || n.css.color;
if (v) return v;
}
return 'rgb(0, 0, 0)';
};
const border = el.css.border;
return {
color: color(),
backgroundColor: own('background-color', el.css.backgroundColor) ?? 'rgba(0, 0, 0, 0)',
backgroundImage: own('background-image', el.css.backgroundImage) ?? 'none',
getPropertyValue(name: string) {
const side = /^border-(top|right|bottom|left)-(style|width|color)$/.exec(name);
if (!side) return '';
if (side[2] === 'color') return el.style.getPropertyValue(name) || border || '';
if (side[2] === 'style') return border ? 'solid' : 'none';
return border ? '1px' : '0px';
},
};
}
function hex(value: string): string {
const h = value.replace('#', '');
const full = h.length === 3 ? h.split('').map((d) => d + d).join('') : h;
const [r, g, b] = [0, 2, 4].map((i) => parseInt(full.slice(i, i + 2), 16));
return `rgb(${r}, ${g}, ${b})`;
}
/**
* A document in the state the head CSS leaves it in: pending class on
* <html>, our dark canvas on <html> and <body>, light fallback text.
*/
function makeDoc(content: FakeElement[], opts: { htmlBg?: string; bodyBg?: string } = {}) {
const html = new FakeElement('HTML', { backgroundColor: opts.htmlBg ?? 'rgb(20, 20, 20)' });
const body = new FakeElement('BODY', { backgroundColor: opts.bodyBg ?? 'rgb(20, 20, 20)', color: 'rgb(230, 230, 230)' });
html.classList.add(DARK_PENDING_CLASS);
html.append(body);
body.append(...content);
const doc = {
documentElement: html,
body,
defaultView: { getComputedStyle: computed },
byId: (id: string) => html.descendants().find((e) => e.id === id)!,
};
return doc;
}
const run = (doc: ReturnType<typeof makeDoc>) => DM.apply(doc as unknown as Document);
const writtenBg = (el: FakeElement) => {
expect(el.style.getPropertyPriority('background-color')).toBe('important');
return c(el.style.getPropertyValue('background-color'));
};
const el = (tag: string, css: Css = {}, id = '', ...kids: FakeElement[]) => new FakeElement(tag, css, id).append(...kids);
describe('the DOM pass', () => {
it('darkens a white cell', () => {
const doc = makeDoc([el('TABLE', {}, '', el('TR', {}, '', el('TD', { backgroundColor: hex('#fff') }, 'a')))]);
run(doc);
expect(lightness(writtenBg(doc.byId('a')))).toBeLessThan(40);
});
it('makes dark text on a darkened cell readable', () => {
const doc = makeDoc([el('DIV', { backgroundColor: hex('#fff') }, 'd', el('P', { color: hex('#222') }, 'p'))]);
run(doc);
const p = doc.byId('p');
expect(p.style.getPropertyPriority('color')).toBe('important');
const bg = writtenBg(doc.byId('d'));
expect(DM.contrastRatio(c(p.style.getPropertyValue('color')), bg)).toBeGreaterThan(4.5);
});
it('keeps a brand button and its label as sent', () => {
const doc = makeDoc([el('A', { backgroundColor: hex('#e30613'), color: hex('#ffffff') }, 'btn')]);
run(doc);
const btn = doc.byId('btn');
expect(btn.style.getPropertyPriority('background-color')).toBe('');
expect(btn.style.getPropertyPriority('color')).toBe('');
});
it('leaves text on a background image as sent', () => {
const doc = makeDoc([
el('DIV', { backgroundImage: 'url("data:image/png;base64,AAAA")', backgroundColor: hex('#ffffff') }, 'photo',
el('P', { color: hex('#111') }, 'p')),
]);
run(doc);
expect(doc.byId('p').style.getPropertyPriority('color')).toBe('');
expect(doc.byId('photo').style.getPropertyPriority('background-color')).toBe('');
});
it('converts a cell whose remote background the blocker stripped', () => {
const doc = makeDoc([
el('TD', { backgroundImage: 'url("")', backgroundColor: hex('#ffffff') }, 'hero', el('P', { color: hex('#111') }, 'p')),
]);
run(doc);
expect(lightness(writtenBg(doc.byId('hero')))).toBeLessThan(40);
expect(doc.byId('p').style.getPropertyPriority('color')).toBe('important');
});
it('keeps a vivid cell as sent', () => {
const doc = makeDoc([el('TD', { backgroundColor: hex('#e30613'), color: hex('#ffffff') }, 'x')]);
run(doc);
expect(doc.byId('x').style.getPropertyValue('background-color')).toBe('');
});
it('darkens a light border with the backgrounds', () => {
const doc = makeDoc([el('DIV', { backgroundColor: hex('#ffffff'), border: hex('#dddddd') }, 'card')]);
run(doc);
const card = doc.byId('card');
expect(card.style.getPropertyPriority('border-top-color')).toBe('important');
expect(lightness(c(card.style.getPropertyValue('border-top-color')))).toBeLessThan(110);
});
it('leaves the dark canvas behind a mail without colours alone', () => {
const doc = makeDoc([el('P', {}, 'p')]);
run(doc);
expect(doc.body.style.getPropertyValue('background-color')).toBe('');
expect(doc.documentElement.style.getPropertyValue('background-color')).toBe('');
// Our light fallback text already reads on it.
expect(doc.byId('p').style.getPropertyValue('color')).toBe('');
});
it('un-hides the body', () => {
const doc = makeDoc([el('P')]);
run(doc);
expect(doc.documentElement.classList.contains(DARK_PENDING_CLASS)).toBe(false);
});
// Newsletters (Lieferando) paint the canvas through `html { background }`.
it('darkens a canvas the mail paints on <html>', () => {
const doc = makeDoc([el('P')], { htmlBg: hex('#ffffff'), bodyBg: hex('#ffffff') });
run(doc);
expect(lightness(writtenBg(doc.documentElement))).toBeLessThan(40);
expect(lightness(writtenBg(doc.body))).toBeLessThan(40);
});
});
describe('buildEmailDarkModeScript', () => {
const inject = (script: string, doc: ReturnType<typeof makeDoc>) => {
const win: Record<string, unknown> = {};
new Function('document', 'window', script)(doc, win);
};
it('runs the pass on the page once, however often the view injects it', () => {
const doc = makeDoc([el('TD', { backgroundColor: hex('#fff') }, 'a')]);
const script = buildEmailDarkModeScript({ remoteContentAllowed: false });
inject(script, doc);
const first = doc.byId('a').style.getPropertyValue('background-color');
expect(lightness(c(first))).toBeLessThan(40);
// A second pass would read the darkened colour back and could shift it.
doc.byId('a').style.setProperty('background-color', 'rgb(255, 255, 255)');
inject(script, doc);
expect(doc.byId('a').style.getPropertyValue('background-color')).toBe('rgb(255, 255, 255)');
});
it('shows the body even when the pass throws', () => {
const doc = makeDoc([el('P')]);
(doc.defaultView as { getComputedStyle: unknown }).getComputedStyle = () => { throw new Error('boom'); };
inject(buildEmailDarkModeScript(), doc);
expect(doc.documentElement.classList.contains(DARK_PENDING_CLASS)).toBe(false);
});
});
+46 -12
View File
@@ -254,21 +254,39 @@ describe('prepareEmailHtml', () => {
expect(prepareEmailHtml('<p>x</p>').html).not.toMatch(/<script/i);
});
it('evaluates native dark mode once and reports the inversion decision', () => {
const native = prepareEmailHtml('<style>@media (prefers-color-scheme: dark){body{color:#fff}}</style><p>x</p>', { isDark: true });
expect(native.hasNativeDark).toBe(true);
expect(native.applyInversion).toBe(false);
expect(native.html).toContain('color-scheme: light dark');
expect(native.html).not.toContain('invert(1)');
// Dark mode rewrites the mail's colours after layout (lib/email-dark-mode)
// instead of inverting the body with a filter, as the webmail does since
// 7401ea665: the head only sets fallback colours and hides the body until
// the pass ran.
it('recolours a mail shown dark instead of inverting it', () => {
const plain = prepareEmailHtml('<p>x</p>', { isDark: true });
expect(plain.applyInversion).toBe(true);
expect(plain.html).toContain('body { filter: invert(1) hue-rotate(180deg)');
// Current webmail revision: background-image containers re-invert without the :has guard.
expect(plain.html).toMatch(/\[style\*="background-image"\],\s*\[background\]\s*\{\s*filter: invert\(1\)/);
expect(plain.recolorDark).toBe(true);
expect(plain.html).not.toContain('invert(1)');
expect(plain.html).toContain('<html class="bulwark-dark-pending"');
expect(plain.html).toContain('body { color: #e6e6e6; background: #141414; }');
expect(plain.html).toContain('animation: bulwark-dark-reveal 0s 1.5s forwards');
const light = prepareEmailHtml('<p>x</p>', { isDark: false });
expect(light.applyInversion).toBe(false);
expect(light.recolorDark).toBe(false);
expect(light.html).not.toContain('bulwark-dark-pending');
expect(light.html).not.toContain('#141414');
});
it('recolours a mail with its own dark styles too and pins them to the app theme', () => {
const mail = '<style>@media (prefers-color-scheme: dark){body{color:#fff}} @media (prefers-color-scheme: light){p{color:#000}}</style><p>x</p>';
const dark = prepareEmailHtml(mail, { isDark: true });
expect(dark.hasNativeDark).toBe(true);
expect(dark.recolorDark).toBe(true);
expect(dark.html).toContain('color-scheme: dark');
expect(dark.html).toContain('@media (min-width: 0px){body{color:#fff}}');
expect(dark.html).toContain('@media (max-width: 0.001px){p{color:#000}}');
// A light app (or "Always view emails in light mode") on a dark phone
// must not get the mail's dark variant.
const light = prepareEmailHtml(mail, { isDark: false });
expect(light.html).toContain('color-scheme: light');
expect(light.html).not.toContain('light dark');
expect(light.html).toContain('@media (max-width: 0.001px){body{color:#fff}}');
});
it('reports blocked external content', () => {
@@ -370,6 +388,22 @@ describe('wrapPlainTextEmail', () => {
it('lets the content pick its text direction', () => {
expect(wrapPlainTextEmail('مرحبا بالعالم.')).toContain('<body dir="auto">');
});
// Settings > Accessibility > Font: a plain body sits in the app document in
// the webmail, so it is drawn in the reading font ("Same as above").
it('draws the body in the reading font, which the page loads from data:', () => {
const html = wrapPlainTextEmail('x', { font: 'sans', readingFont: 'opendyslexic' });
expect(html).toContain('font-family: "OpenDyslexic", -apple-system');
expect(html).toMatch(/@font-face \{ font-family: "OpenDyslexic";[^}]*src: url\("data:font\/woff2;base64,/);
expect(html).toContain('font-src data:');
});
it('keeps monospace and the theme font as they were', () => {
const mono = wrapPlainTextEmail('x', { font: 'mono', readingFont: 'opendyslexic' });
expect(mono).toContain('font-family: ui-monospace');
expect(mono).not.toContain('OpenDyslexic');
expect(wrapPlainTextEmail('x', { font: 'sans', readingFont: 'default' })).not.toContain('@font-face');
});
});
describe('hasNativeDarkMode', () => {
+11 -4
View File
@@ -6,14 +6,15 @@
//
// The phone takes the presets whose settings it has: screen reader (no
// conversation grouping), low vision (150% text, labels under the toolbar
// icons) and motion (no animations). The webmail's others need settings the
// app doesn't have (reading fonts, spacing, focus outlines, hover, sounds).
// icons), dyslexia (the reading font, also for plain-text mail) and motion
// (no animations). The webmail's others need settings the app doesn't have
// (spacing, focus outlines, hover, sounds).
import type { PersistedSettings } from '../stores/settings-store';
export type AccessibilityPresetId = 'screen-reader' | 'low-vision' | 'motion';
export type AccessibilityPresetId = 'screen-reader' | 'low-vision' | 'dyslexia' | 'motion';
export const ACCESSIBILITY_PRESET_IDS: readonly AccessibilityPresetId[] = ['screen-reader', 'low-vision', 'motion'];
export const ACCESSIBILITY_PRESET_IDS: readonly AccessibilityPresetId[] = ['screen-reader', 'low-vision', 'dyslexia', 'motion'];
type PresetValues = Partial<PersistedSettings>;
@@ -27,6 +28,12 @@ export const ACCESSIBILITY_PRESETS: Record<AccessibilityPresetId, PresetValues>
fontSize: 'xx-large',
showToolbarLabels: true,
},
// The webmail's font part (its spacing, reading width and mail style
// options have no phone counterpart yet).
dyslexia: {
appFont: 'atkinson',
plainTextFont: 'sans',
},
motion: {
animationsEnabled: false,
},
+135
View File
@@ -0,0 +1,135 @@
/**
* The colour-blind-safe palette behind Settings > Accessibility >
* "Colour-blind-safe colours" (colorBlindPalette). A port of the webmail's
* lib/cvd-palette.ts (cf4d02b49).
*
* Tags and calendars keep the colours the user picked; this only changes how
* they are drawn. Every colour is moved to the nearest of the Okabe-Ito
* colours by hue: eight colours chosen to stay apart for protanopia,
* deuteranopia and tritanopia (Okabe & Ito, "Color Universal Design", 2008).
* Two picked colours of a similar hue (blue and indigo) can land on the same
* one; the names next to them still tell them apart.
*/
export const CVD_COLORS = {
vermillion: '#D55E00',
orange: '#E69F00',
yellow: '#F0E442',
bluishGreen: '#009E73',
skyBlue: '#56B4E9',
blue: '#0072B2',
reddishPurple: '#CC79A7',
grey: '#999999',
} as const;
export type CvdHue = keyof typeof CVD_COLORS;
/** The hues of the tag palette (theme/tokens TAGS), as Tailwind's 500 shades. */
const PALETTE_HUES: Record<string, string> = {
red: '#ef4444',
orange: '#f97316',
amber: '#f59e0b',
yellow: '#eab308',
lime: '#84cc16',
green: '#22c55e',
teal: '#14b8a6',
cyan: '#06b6d4',
blue: '#3b82f6',
indigo: '#6366f1',
purple: '#a855f7',
pink: '#ec4899',
gray: '#6b7280',
};
/** Hue (0-360) and saturation (0-1) of a colour, or null when it can't be read. */
function hueAndSaturation(color: string): { hue: number; saturation: number } | null {
let r: number, g: number, b: number;
const value = color.trim().toLowerCase();
const hex = /^#([0-9a-f]{3,8})$/.exec(value);
if (hex) {
let digits = hex[1];
if (digits.length === 3 || digits.length === 4) digits = digits.slice(0, 3).split('').map((d) => d + d).join('');
if (digits.length !== 6 && digits.length !== 8) return null;
r = parseInt(digits.slice(0, 2), 16);
g = parseInt(digits.slice(2, 4), 16);
b = parseInt(digits.slice(4, 6), 16);
} else {
const rgb = /^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)/.exec(value);
const hsl = /^hsla?\(\s*([\d.]+)(?:deg)?[\s,]+([\d.]+)%[\s,]+([\d.]+)%/.exec(value);
if (rgb) {
[r, g, b] = [Number(rgb[1]), Number(rgb[2]), Number(rgb[3])];
} else if (hsl) {
return { hue: Number(hsl[1]) % 360, saturation: Number(hsl[2]) / 100 };
} else {
return null;
}
}
const [rn, gn, bn] = [r / 255, g / 255, b / 255];
const max = Math.max(rn, gn, bn);
const min = Math.min(rn, gn, bn);
const delta = max - min;
const lightness = (max + min) / 2;
const saturation = delta === 0 ? 0 : delta / (1 - Math.abs(2 * lightness - 1));
let hue = 0;
if (delta !== 0) {
if (max === rn) hue = ((gn - bn) / delta) % 6;
else if (max === gn) hue = (bn - rn) / delta + 2;
else hue = (rn - gn) / delta + 4;
hue *= 60;
if (hue < 0) hue += 360;
}
return { hue, saturation };
}
/**
* The palette colour for `color`: a CSS colour (#hex, rgb(), hsl()) or a
* tag palette name ("red", "teal-light"). Null when it can't be read.
*/
export function cvdHue(color: string | null | undefined): CvdHue | null {
if (!color) return null;
const paletteName = color.replace(/-(light|dark)$/, '');
const parsed = hueAndSaturation(PALETTE_HUES[paletteName] ?? color);
if (!parsed) return null;
const { hue, saturation } = parsed;
if (saturation < 0.15) return 'grey';
if (hue < 18 || hue >= 345) return 'vermillion';
if (hue < 44) return 'orange';
if (hue < 70) return 'yellow';
if (hue < 165) return 'bluishGreen';
if (hue < 200) return 'skyBlue';
if (hue < 255) return 'blue';
return 'reddishPurple';
}
/**
* The colour to draw `color` in: itself, or its colour-blind-safe
* replacement when `enabled`. A colour it can't read is drawn as it is.
*/
export function cvdColor(color: string, enabled: boolean): string {
if (!enabled) return color;
const hue = cvdHue(color);
return hue ? CVD_COLORS[hue] : color;
}
/** `#RRGGBB` at `alpha`, as rgba() (React Native has no 8-digit-hex tint helper here). */
function withAlpha(hex: string, alpha: number): string {
const n = parseInt(hex.slice(1), 16);
return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${alpha})`;
}
export interface TagSwatch { dot: string; bg: string; text: string }
/**
* A tag palette drawn colour-blind-safe, the way the webmail draws tags with
* the setting on (CVD_CLASSES): the dot in the palette colour, the fill as a
* 15 % tint of it, and the label in the text colour, since several of these
* colours are too light to read as text.
*/
export function cvdTagPalette<K extends string>(tags: Record<K, TagSwatch>, textColor: string): Record<K, TagSwatch> {
const out = {} as Record<K, TagSwatch>;
for (const key of Object.keys(tags) as K[]) {
const hex = CVD_COLORS[cvdHue(key) ?? cvdHue(tags[key].dot) ?? 'grey'];
out[key] = { dot: hex, bg: withAlpha(hex, hex === CVD_COLORS.yellow ? 0.2 : 0.15), text: textColor };
}
return out;
}
+8 -4
View File
@@ -10,6 +10,7 @@ import {
} from './email-html';
import { collapsePlainTextQuotes, type QuoteCollapseLabels } from './quote-collapse';
import { mailBaseFontPx } from './mail-reading';
import type { AppFont } from '../theme/reading-fonts';
export interface BodyDocumentInput {
/** The message (account and id); the sources below are compared as well. */
@@ -33,12 +34,14 @@ export interface BodyDocumentInput {
textFactor?: number;
/** The app theme's colours for a plain-text body in dark mode. */
plainDarkColors?: PlainTextColors;
/** The reading font (Settings > Accessibility > Font) for a plain-text body. */
appFont?: AppFont;
}
export interface BodyDocument {
html: string;
/** Inversion CSS is in effect; the DOM re-invert pass should run. */
applyInversion: boolean;
/** Shown dark: the recolour pass (lib/email-dark-mode) has to run. */
recolorDark: boolean;
hasNativeDark: boolean;
/** At least one external resource was neutralised. */
blockedExternal: boolean;
@@ -68,8 +71,9 @@ export function buildBodyDocument(input: BodyDocumentInput): BodyDocument {
font: input.plainTextFont,
fontPx: mailBaseFontPx(input.textFactor ?? 1),
darkColors: input.plainDarkColors,
readingFont: input.appFont,
}),
applyInversion: false,
recolorDark: false,
hasNativeDark: false,
blockedExternal: false,
isHtml: false,
@@ -97,7 +101,7 @@ export function bodyDocument(input: BodyDocumentInput): BodyDocument {
const key = JSON.stringify([
input.key, input.blockRemoteImages, input.isDark, input.messageSpacing, input.plainTextFont,
input.quoteLabels.show, input.quoteLabels.hide, input.emptyLabel, input.textFactor ?? 1,
input.plainDarkColors ?? null,
input.plainDarkColors ?? null, input.appFont ?? 'default',
]);
const hit = documents.get(key);
if (hit && sameSources(hit.input, input)) {
+544
View File
@@ -0,0 +1,544 @@
/**
* Dark mode for HTML mail bodies (webmail lib/email-dark-mode.ts, 7401ea665,
* 6e5e07107). Instead of inverting the whole body with a CSS filter (which
* turns brand red into pink, flips photos that then need re-inverting and
* emoji that need wrapping), the rendered document is read back through
* getComputedStyle and its colours are rewritten in place:
*
* - Light backgrounds are darkened with their hue kept. Vivid colours (brand
* buttons, banners) and backgrounds that are already dark stay as sent.
* - Text and borders only change when they lack contrast against the new
* background; they are then lightened (or darkened) in their own hue.
* - Images are never touched. An element painted with a background image
* keeps its colours, and so does the text drawn straight on it.
*
* Computed styles only exist inside the WebView, so the pass runs there as an
* injected script. Hermes keeps no function source, so the pass can't be
* stringified from TypeScript: it lives below as JavaScript source
* (EMAIL_DARK_MODE_LIB), and the tests evaluate that same source, so what is
* tested is what the WebView runs.
*/
/** Canvas the converted mail sits on (white maps to this). */
export const EMAIL_DARK_BACKGROUND = '#141414';
const DARK_TEXT = '#e6e6e6';
const DARK_LINK = '#8ab4f8';
/** Class on <html> that keeps the body hidden until the pass has run. */
export const DARK_PENDING_CLASS = 'bulwark-dark-pending';
/**
* `color-scheme` for the document root. A mail with its own dark styles gets
* `dark`, so its `prefers-color-scheme: dark` rules apply whenever the app
* shows it dark - not only when the phone is dark too.
*/
export function emailColorScheme(isDark: boolean, nativeDarkMode: boolean): 'light' | 'dark' {
return isDark && nativeDarkMode ? 'dark' : 'light';
}
const ALWAYS = '(min-width: 0px)';
const NEVER = '(max-width: 0.001px)';
/**
* Pins the mail's `prefers-color-scheme` media queries to the theme the app
* shows it in. Inside the WebView they would follow the phone, not the app
* (or the per-message light/dark switch). Only <style> blocks are rewritten.
*/
export function resolveColorSchemeMedia(html: string, isDark: boolean): string {
if (!/prefers-color-scheme/i.test(html)) return html;
return html.replace(/(<style\b[^>]*>)([\s\S]*?)(<\/style>)/gi, (_m, open: string, css: string, close: string) =>
open + css.replace(/\(\s*prefers-color-scheme\s*:\s*(dark|light)\s*\)/gi, (_q, scheme: string) =>
(scheme.toLowerCase() === 'dark') === isDark ? ALWAYS : NEVER) + close);
}
/**
* Stylesheet for the document head in dark mode. Sets the fallback colours
* for mails that set none, and hides the body until the pass ran so the light
* original never flashes. The reveal animation is a failsafe: the body shows
* after a moment even if the pass never runs.
*/
export function emailDarkModeCss(): string {
return `
html { background: ${EMAIL_DARK_BACKGROUND}; }
body { color: ${DARK_TEXT}; background: ${EMAIL_DARK_BACKGROUND}; }
a { color: ${DARK_LINK}; }
@keyframes bulwark-dark-reveal { to { visibility: visible; } }
html.${DARK_PENDING_CLASS} { background-color: ${EMAIL_DARK_BACKGROUND} !important; }
html.${DARK_PENDING_CLASS} body { visibility: hidden; animation: bulwark-dark-reveal 0s 1.5s forwards; }
`;
}
/** An sRGB colour with alpha, as the pass reads it from computed styles. */
export interface Rgba { r: number; g: number; b: number; a: number }
export interface EmailDarkModeOptions {
/**
* The user allowed remote content for this message. Only then may a remote
* image be fetched a second time (with CORS) to look at its pixels.
*/
remoteContentAllowed?: boolean;
}
/** What EMAIL_DARK_MODE_LIB evaluates to. */
export interface EmailDarkModeLib {
parseComputedColor(value: string): Rgba | null;
formatColor(color: Rgba): string;
contrastRatio(x: Rgba, y: Rgba): number;
darkenBackground(color: Rgba): Rgba;
ensureContrast(color: Rgba, bg: Rgba, minRatio: number): Rgba;
hasImage(backgroundImage: string): boolean;
apply(doc: Document, options?: EmailDarkModeOptions): void;
}
/**
* The pass as JavaScript source: an expression that evaluates to a factory
* returning {@link EmailDarkModeLib}. A line-for-line port of the webmail's
* lib/email-dark-mode.ts (colour maths in OKLab/OKLCH, WCAG contrast, the
* read-then-write DOM pass and the image plates); keep the two in step.
* No template literals or `${` inside: the source is a raw string.
*/
export const EMAIL_DARK_MODE_LIB = String.raw`(function () {
'use strict';
// Colour maths (sRGB <-> OKLab/OKLCH, WCAG contrast)
function parseComputedColor(value) {
var m = /^rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)(?:\s*[,\/]\s*([\d.]+%?))?\s*\)$/i.exec(value || '');
if (!m) return null;
var a = 1;
if (m[4] !== undefined && m[4] !== '') {
a = m[4].charAt(m[4].length - 1) === '%' ? parseFloat(m[4]) / 100 : parseFloat(m[4]);
}
return { r: +m[1], g: +m[2], b: +m[3], a: a };
}
function formatColor(c) {
var R = Math.round(c.r), G = Math.round(c.g), B = Math.round(c.b);
return c.a >= 1
? 'rgb(' + R + ', ' + G + ', ' + B + ')'
: 'rgba(' + R + ', ' + G + ', ' + B + ', ' + (+c.a.toFixed(3)) + ')';
}
function toLinear(v) {
var c = v / 255;
return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
}
function fromLinear(v) {
var c = v <= 0.0031308 ? v * 12.92 : 1.055 * Math.pow(v, 1 / 2.4) - 0.055;
return c * 255;
}
function toLch(c) {
var lr = toLinear(c.r), lg = toLinear(c.g), lb = toLinear(c.b);
var l = Math.cbrt(0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb);
var m = Math.cbrt(0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb);
var s = Math.cbrt(0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb);
var L = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s;
var A = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s;
var B = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s;
return { l: L, c: Math.hypot(A, B), h: Math.atan2(B, A) };
}
function lchToLinear(lch) {
var A = lch.c * Math.cos(lch.h), B = lch.c * Math.sin(lch.h), L = lch.l;
var l = Math.pow(L + 0.3963377774 * A + 0.2158037573 * B, 3);
var m = Math.pow(L - 0.1055613458 * A - 0.0638541728 * B, 3);
var s = Math.pow(L - 0.0894841775 * A - 1.291485548 * B, 3);
return [
4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s
];
}
function outOfGamut(lin) {
for (var i = 0; i < 3; i++) if (lin[i] < -1e-4 || lin[i] > 1 + 1e-4) return true;
return false;
}
// OKLCH back to sRGB, pulling chroma in until the colour fits the gamut.
function fromLch(lch, a) {
var c = lch.c;
var lin = lchToLinear({ l: lch.l, c: c, h: lch.h });
for (var i = 0; i < 24 && outOfGamut(lin); i++) {
c *= 0.9;
lin = lchToLinear({ l: lch.l, c: c, h: lch.h });
}
var out = lin.map(function (v) {
return Math.min(255, Math.max(0, fromLinear(Math.min(1, Math.max(0, v)))));
});
return { r: out[0], g: out[1], b: out[2], a: a };
}
function luminance(c) {
return 0.2126 * toLinear(c.r) + 0.7152 * toLinear(c.g) + 0.0722 * toLinear(c.b);
}
function contrastRatio(x, y) {
var lx = luminance(x), ly = luminance(y);
return (Math.max(lx, ly) + 0.05) / (Math.min(lx, ly) + 0.05);
}
function sameColor(x, y) {
return Math.round(x.r) === Math.round(y.r) && Math.round(x.g) === Math.round(y.g)
&& Math.round(x.b) === Math.round(y.b) && x.a === y.a;
}
// Paints a translucent colour over an opaque one.
function composite(top, under) {
var a = top.a;
return {
r: top.r * a + under.r * (1 - a),
g: top.g * a + under.g * (1 - a),
b: top.b * a + under.b * (1 - a),
a: 1
};
}
// Lightness white lands on, and the bottom of the "already dark" range.
var BG_WHITE_L = 0.2;
var BG_DARK_L = 0.45;
// Chroma from which a background counts as a deliberate, vivid colour.
var VIVID_CHROMA = 0.1;
// Dark counterpart of a background colour. Light colours are mirrored into
// the dark range (white -> the dark canvas, light grey -> a little lighter,
// so the mail's card/canvas layering survives), mid greys move down to meet
// that range, and vivid or already-dark colours are left alone.
function darkenBackground(color) {
if (color.a === 0) return color;
var lch = toLch(color);
if (lch.l <= BG_DARK_L) return color;
if (lch.c >= VIVID_CHROMA) return color;
var l;
if (lch.l >= 0.7) {
// 1.0 -> 0.2, 0.7 -> 0.38
l = BG_WHITE_L + (1 - lch.l) * 0.6;
} else {
// 0.45 (kept) ... 0.7 (-> 0.38): fold mid greys into the dark range.
l = BG_DARK_L - ((lch.l - BG_DARK_L) / (0.7 - BG_DARK_L)) * (BG_DARK_L - 0.38);
}
return fromLch({ l: l, c: lch.c, h: lch.h }, color.a);
}
// Returns color if it already reads against bg, otherwise a colour of the
// same hue moved away from the background's lightness until it reaches
// minRatio. Dark text on a dark background is mirrored first (black -> near
// white, dark grey -> light grey), which keeps the sender's hierarchy of
// text greys instead of flattening them all to one value.
function ensureContrast(color, bg, minRatio) {
if (color.a === 0) return color;
var opaque = color.a < 1 ? composite(color, bg) : color;
if (contrastRatio(opaque, bg) >= minRatio) return color;
var lch = toLch(opaque);
var bgL = toLch(bg).l;
var lighten = bgL < 0.6;
var l = lighten ? Math.max(lch.l, 0.96 - lch.l * 0.5) : Math.min(lch.l, 0.3);
var out = fromLch({ l: l, c: lch.c, h: lch.h }, 1);
for (var i = 0; i < 40 && contrastRatio(out, bg) < minRatio; i++) {
l = lighten ? Math.min(1, l + 0.02) : Math.max(0, l - 0.02);
out = fromLch({ l: l, c: lch.c, h: lch.h }, 1);
if (l === 0 || l === 1) break;
}
return out;
}
// Rewrites every rgb()/rgba() in a computed gradient.
function mapGradientColors(value, map) {
return value.replace(/rgba?\([^)]*\)/gi, function (m) {
var c = parseComputedColor(m);
return c ? formatColor(map(c)) : m;
});
}
// The DOM pass
var BORDER_SIDES = ['top', 'right', 'bottom', 'left'];
var WHITE = { r: 255, g: 255, b: 255, a: 1 };
var SKIP_TAGS = {
IMG: 1, VIDEO: 1, CANVAS: 1, PICTURE: 1, SOURCE: 1, svg: 1, SVG: 1, STYLE: 1, SCRIPT: 1,
BR: 1, WBR: 1, HEAD: 1, TITLE: 1, META: 1, LINK: 1
};
// True for a background-image that actually paints a picture.
function hasImage(backgroundImage) {
if (!backgroundImage || backgroundImage === 'none') return false;
// The blocker rewrites remote urls to url() (computed as url("")); those
// paint nothing.
return /url\(/i.test(backgroundImage.replace(/url\(\s*(?:""|''|)\s*\)/gi, ''));
}
// Lifts a kept dark fill that stood out from its light surroundings (a
// near-black button on a white card) but now sinks into the darkened
// surface around it.
function keepSeparation(next, original, parent) {
if (next !== original || original.a < 0.9) return next;
if (contrastRatio(original, parent.original) < 3) return next;
var target = 1.8;
if (contrastRatio(next, parent.bg) >= target) return next;
var lch = toLch(next);
var parentL = toLch(parent.bg).l;
var l = Math.max(lch.l, parentL);
var out = next;
for (var i = 0; i < 25 && contrastRatio(out, parent.bg) < target; i++) {
l += 0.02;
out = fromLch({ l: l, c: lch.c, h: lch.h }, next.a);
}
return out;
}
// Looks at an image's pixels: does it have see-through areas, and would
// what it draws there disappear against bg? 'unknown' when the pixels can't
// be read (a cross-origin image without CORS).
function judgeImagePixels(source, doc, bg) {
var w = source.naturalWidth, h = source.naturalHeight;
if (!w || !h) return 'fine';
var scale = Math.min(1, 48 / Math.max(w, h));
var cw = Math.max(1, Math.round(w * scale)), ch = Math.max(1, Math.round(h * scale));
var canvas = doc.createElement('canvas');
canvas.width = cw;
canvas.height = ch;
var ctx = canvas.getContext('2d', { willReadFrequently: true });
if (!ctx) return 'unknown';
var data;
try {
ctx.drawImage(source, 0, 0, cw, ch);
data = ctx.getImageData(0, 0, cw, ch).data;
} catch (e) {
return 'unknown';
}
var clear = 0, drawn = 0, hidden = 0;
for (var i = 0; i < data.length; i += 4) {
var a = data[i + 3] / 255;
if (a < 0.5) { clear++; continue; }
drawn++;
var px = composite({ r: data[i], g: data[i + 1], b: data[i + 2], a: a }, bg);
if (contrastRatio(px, bg) < 2) hidden++;
}
var total = data.length / 4;
// Opaque images cover any plate; mostly-readable artwork doesn't need one.
if (clear / total < 0.02 || drawn === 0) return 'fine';
return hidden / drawn > 0.35 ? 'plate' : 'fine';
}
function whenLoaded(img) {
if (img.complete) return Promise.resolve(img.naturalWidth > 0);
return new Promise(function (resolve) {
img.addEventListener('load', function () { resolve(true); }, { once: true });
img.addEventListener('error', function () { resolve(false); }, { once: true });
});
}
// Loads a second copy of a remote image with CORS so its pixels can be read.
function loadCorsCopy(img, doc) {
var src = img.currentSrc || img.src;
if (!/^https?:/i.test(src)) return Promise.resolve(null);
var copy = doc.createElement('img');
copy.crossOrigin = 'anonymous';
copy.referrerPolicy = 'no-referrer';
var loaded = new Promise(function (resolve) {
var timer = setTimeout(function () { resolve(null); }, 8000);
copy.onload = function () { clearTimeout(timer); resolve(copy); };
copy.onerror = function () { clearTimeout(timer); resolve(null); };
});
copy.src = src;
return loaded;
}
function plateIfNeeded(img, surface, options) {
return whenLoaded(img).then(function (ok) {
if (!ok) return;
var doc = img.ownerDocument;
// Spacers and tracking pixels.
if (img.naturalWidth <= 2 && img.naturalHeight <= 2) return;
var verdict = judgeImagePixels(img, doc, surface.bg);
var next = verdict === 'unknown' && options.remoteContentAllowed
? loadCorsCopy(img, doc).then(function (copy) {
return copy ? judgeImagePixels(copy, doc, surface.bg) : verdict;
})
: Promise.resolve(verdict);
return next.then(function (v) {
// Pixels we can't read get the plate: a light box around a logo is
// better than a logo that isn't there.
if (v === 'fine' || !img.isConnected) return;
img.style.setProperty('background-color', formatColor(surface.original), 'important');
});
}).catch(function () {});
}
function each(list, fn) {
Array.prototype.forEach.call(list, fn);
}
// Converts a rendered mail document to dark colours in place. Call once,
// after the document has laid out; image plates are decided once each
// image has loaded, after this returns.
function apply(doc, options) {
options = options || {};
var win = doc.defaultView;
var body = doc.body;
try {
if (!win || !body) return;
// Un-pin the canvas to read the mail's own; nothing paints before the
// writes below, so the light original never shows.
var html = doc.documentElement;
html.classList.remove('bulwark-dark-pending');
var ours = { r: 20, g: 20, b: 20, a: 1 };
var canvas = parseComputedColor(win.getComputedStyle(html).backgroundColor);
// A mail that paints <html> itself gets that canvas darkened like any
// other fill; left alone it shows wherever the body doesn't cover it.
var mailCanvas = canvas && canvas.a === 1 && !sameColor(canvas, ours) ? canvas : null;
var base = mailCanvas ? darkenBackground(mailCanvas) : ours;
// Read everything first, then write: interleaving would force a style
// recalc per element.
var elements = [body];
each(body.querySelectorAll('*'), function (el) {
if (!SKIP_TAGS[el.tagName] && !el.closest('svg')) elements.push(el);
});
var snaps = elements.map(function (el) {
var cs = win.getComputedStyle(el);
return {
el: el,
color: parseComputedColor(cs.color),
bg: parseComputedColor(cs.backgroundColor),
bgImage: cs.backgroundImage,
borders: BORDER_SIDES.map(function (side) {
return cs.getPropertyValue('border-' + side + '-style') !== 'none'
&& parseFloat(cs.getPropertyValue('border-' + side + '-width')) > 0
? parseComputedColor(cs.getPropertyValue('border-' + side + '-color'))
: null;
})
};
});
// The original canvas is the mail's own or white: that is what the
// light view paints.
var root = { bg: base, original: mailCanvas || WHITE, onImage: false };
var surfaces = new Map();
var surfaceOf = function (el) {
for (var n = el; n; n = n.parentElement) {
var s = surfaces.get(n);
if (s) return s;
}
return root;
};
var writes = [];
if (mailCanvas && base !== mailCanvas) {
writes.push(function () { html.style.setProperty('background-color', formatColor(base), 'important'); });
}
snaps.forEach(function (snap) {
var el = snap.el;
var parent = el === body ? root : surfaceOf(el.parentElement);
var surface = parent;
if (hasImage(snap.bgImage)) {
// Picture background: keep the element's own fill (it shows
// wherever the picture is transparent or fails to load) and its
// text as sent.
var fill = snap.bg && snap.bg.a > 0 ? composite(snap.bg, parent.bg) : parent.bg;
surface = { bg: fill, original: fill, onImage: true };
} else {
if (snap.bgImage && snap.bgImage !== 'none' && !parent.onImage) {
var gradient = mapGradientColors(snap.bgImage, darkenBackground);
if (gradient !== snap.bgImage) {
writes.push(function () { el.style.setProperty('background-image', gradient, 'important'); });
}
}
// A translucent box over a picture still shows the picture, so it
// keeps its colours; an opaque one is a surface of its own.
if (el === body && snap.bg && sameColor(snap.bg, ours)) {
// Our own dark canvas (emailDarkModeCss), not a fill the sender
// chose: in the light view this is the white page.
} else if (snap.bg && snap.bg.a > 0 && !(parent.onImage && snap.bg.a < 0.9)) {
var original = snap.bg;
// The body has no surroundings to stand out from.
var nextBg = parent.onImage || el === body
? darkenBackground(original)
: keepSeparation(darkenBackground(original), original, parent);
surface = {
bg: composite(nextBg, parent.bg),
original: composite(original, parent.original),
onImage: false
};
if (nextBg !== original) {
writes.push(function () { el.style.setProperty('background-color', formatColor(nextBg), 'important'); });
}
}
}
surfaces.set(el, surface);
if (surface.onImage) return;
if (snap.color) {
var nextColor = ensureContrast(snap.color, surface.bg, 4.5);
if (nextColor !== snap.color) {
writes.push(function () { el.style.setProperty('color', formatColor(nextColor), 'important'); });
}
}
snap.borders.forEach(function (border, i) {
if (!border || border.a === 0) return;
// Borders sit between the element and its parent; a light rule on a
// light mail darkens with the backgrounds, a dark one is lifted
// just enough to stay visible.
var darkened = darkenBackground(border);
var nextBorder = darkened !== border ? darkened : ensureContrast(border, parent.bg, 1.6);
if (nextBorder !== border) {
writes.push(function () {
el.style.setProperty('border-' + BORDER_SIDES[i] + '-color', formatColor(nextBorder), 'important');
});
}
});
});
// Images keep their pixels, but a transparent one (a black logo, a dark
// icon) was drawn for the light background it sat on and can vanish on
// the darkened one. Those get that original background back behind them.
var plates = [];
each(body.querySelectorAll('img'), function (img) {
var surface = surfaceOf(img.parentElement);
if (surface.onImage || surface.bg === surface.original) return;
if (toLch(surface.original).l < 0.7 || contrastRatio(surface.original, surface.bg) < 3) return;
plates.push({ img: img, surface: surface });
});
writes.forEach(function (write) { write(); });
plates.forEach(function (p) { plateIfNeeded(p.img, p.surface, options); });
} finally {
doc.documentElement.classList.remove('bulwark-dark-pending');
}
}
return {
parseComputedColor: parseComputedColor,
formatColor: formatColor,
contrastRatio: contrastRatio,
darkenBackground: darkenBackground,
ensureContrast: ensureContrast,
hasImage: hasImage,
apply: apply
};
})`;
/**
* The script the body view injects into an HTML mail shown dark: runs the
* pass once per document (the view injects its scripts again after load) and
* asks for a fresh height report.
*/
export function buildEmailDarkModeScript(options: EmailDarkModeOptions = {}): string {
const opts = JSON.stringify({ remoteContentAllowed: !!options.remoteContentAllowed });
return `
(function () {
if (document.__rnDarkRecolorDone || !document.body) return;
document.__rnDarkRecolorDone = true;
try {
(${EMAIL_DARK_MODE_LIB})().apply(document, ${opts});
} catch (e) {
document.documentElement.classList.remove('${DARK_PENDING_CLASS}');
}
if (window.__rnReport) setTimeout(window.__rnReport, 0);
})();
true;
`;
}
+41 -61
View File
@@ -21,6 +21,9 @@
// class-based colours (native #46/#49). When external content is blocked the
// sheet is scrubbed of url()/@import/@font-face references instead.
import { DARK_PENDING_CLASS, emailColorScheme, emailDarkModeCss, resolveColorSchemeMedia } from './email-dark-mode';
import { mailFontStack, readingFontFaceCss, type AppFont } from '../theme/reading-fonts';
const FORBIDDEN_TAGS = [
'script', 'iframe', 'object', 'embed', 'form',
'input', 'button', 'textarea', 'select', 'option',
@@ -675,9 +678,9 @@ export function hasRemoteContent(html: string): boolean {
// ─── Styles ──────────────────────────────────────────────────────────────
// Base styles for HTML emails - mirrors the webmail iframe body.
// Emails are authored for light mode; we render them true-to-life and apply a
// filter inversion trick for dark mode (unless the email has native dark
// support via @media (prefers-color-scheme: dark)).
// Emails are authored for light mode; they are rendered true-to-life, and in
// dark mode their colours are rewritten after layout by the injected pass in
// ./email-dark-mode.
function baseStyles(bodyPadding: string): string {
return `
html { background: #ffffff; height: auto !important; }
@@ -731,46 +734,6 @@ body { line-height: 1.15; }
p.MsoNormal, li.MsoNormal, div.MsoNormal { margin: 0 0 6px; }
`;
// Applied when the host is in dark mode and the email does NOT have native
// dark-mode CSS. Matches the webmail's darkModeCSS verbatim.
//
// Background-COLOR containers (bgcolor, background: shorthand) are re-inverted
// so their fill flips to a dark tone, but skipped when they wrap media: the
// inner image already gets its own re-invert, and a filter on the container
// would double-invert it. Nested colour containers must NOT stack another
// invert either - each filter toggles the inversion, so an odd number of
// stacked filters lands back on light-on-light; the descendant rule disables
// filter on a colour container nested inside another.
//
// Background-IMAGE containers are different: a real photo and whatever is
// composited over it was designed by the sender to read together, so the
// whole subtree should return to its original light-mode appearance - even
// when it wraps media. Re-invert those unconditionally (no :has guard) and
// cancel the per-image re-invert for media inside them.
const DARK_INVERSION = `
html { background: #121212; }
body { filter: invert(1) hue-rotate(180deg); background: #ededed; }
img, video, svg, canvas, object, embed, input[type="image"] {
filter: invert(1) hue-rotate(180deg);
}
[style*="background:"]:not(:has(img, video, svg, canvas, object, embed)),
[bgcolor]:not(:has(img, video, svg, canvas, object, embed)) {
filter: invert(1) hue-rotate(180deg);
}
:where([style*="background:"], [bgcolor])
:where([style*="background:"], [bgcolor]):not(:has(img, video, svg, canvas, object, embed)) {
filter: none !important;
}
[style*="background-image"],
[background] {
filter: invert(1) hue-rotate(180deg);
}
[style*="background-image"] :where(img, video, svg, canvas, object, embed, input[type="image"]),
[background] :where(img, video, svg, canvas, object, embed, input[type="image"]) {
filter: none;
}
`;
// Matches the webmail's `emailHasNativeDarkMode` detector: true if the HTML
// contains a `@media (prefers-color-scheme: dark)` rule.
export function hasNativeDarkMode(html: string): boolean {
@@ -855,16 +818,16 @@ export interface WrapOptions {
/** Neutralise external resources and switch to the strict CSP. */
blockRemoteImages?: boolean;
cidMap?: Record<string, string>;
/** When true, the host renders in dark mode; inversion is applied unless
* the email already has native dark-mode CSS. */
/** When true, the host renders in dark mode: the head gets the dark
* fallback colours and the body stays hidden until the recolour pass ran. */
isDark?: boolean;
messageSpacing?: MessageSpacing;
}
export interface PreparedEmailHtml {
html: string;
/** Inversion CSS is in effect; the DOM re-invert pass should run. */
applyInversion: boolean;
/** Shown dark: the recolour pass (./email-dark-mode) has to run. */
recolorDark: boolean;
/** The email ships its own `prefers-color-scheme: dark` rules. */
hasNativeDark: boolean;
/** At least one external resource was neutralised. */
@@ -897,10 +860,10 @@ function buildCsp(strict: boolean): string {
}
/**
* Turn a raw HTML body into a complete, sandboxed document. `hasNativeDark`
* and `applyInversion` are evaluated once on the sanitised input and returned
* so the caller injects the matching DOM pass (a mismatch between the CSS
* decision and the script decision was native #49).
* Turn a raw HTML body into a complete, sandboxed document. `recolorDark` is
* returned so the caller injects the recolour pass exactly when the head
* hides the body for it (a mismatch between the CSS decision and the script
* decision was native #49).
*/
export function prepareEmailHtml(innerHtml: string, options: WrapOptions = {}): PreparedEmailHtml {
const { blockRemoteImages = false, cidMap, isDark = true, messageSpacing = 'auto' } = options;
@@ -915,9 +878,14 @@ export function prepareEmailHtml(innerHtml: string, options: WrapOptions = {}):
}
const hasNativeDark = hasNativeDarkMode(processed);
const applyInversion = isDark && !hasNativeDark;
const colorScheme = isDark && hasNativeDark ? 'light dark' : 'light';
const darkCss = applyInversion ? DARK_INVERSION : '';
// The recolour pass runs on every mail shown dark, also one with its own
// dark styles: those are pinned to the app's theme below, and the pass
// leaves colours that are already dark or readable alone (webmail
// email-viewer.tsx recolorDark).
const recolorDark = isDark;
const colorScheme = emailColorScheme(isDark, hasNativeDark);
const darkCss = recolorDark ? emailDarkModeCss() : '';
const rootClass = recolorDark ? ` class="${DARK_PENDING_CLASS}"` : '';
const word = isWordHtml(processed);
const hasStyleTag = /<style\b/i.test(processed);
@@ -929,7 +897,7 @@ export function prepareEmailHtml(innerHtml: string, options: WrapOptions = {}):
const bodyDir = declaresOwnDirection(processed) ? '' : ' dir="auto"';
const html = `<!doctype html>
<html style="color-scheme: ${colorScheme};">
<html${rootClass} style="color-scheme: ${colorScheme};">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
@@ -937,10 +905,10 @@ export function prepareEmailHtml(innerHtml: string, options: WrapOptions = {}):
<meta name="referrer" content="no-referrer">
<style>${baseStyles(bodyPadding)}${word ? WORD_HTML_CSS : ''}${darkCss}</style>
</head>
<body${bodyDir}>${processed}<style>html,body{height:auto!important;min-height:0!important;max-height:none!important}</style></body>
<body${bodyDir}>${resolveColorSchemeMedia(processed, isDark)}<style>html,body{height:auto!important;min-height:0!important;max-height:none!important}</style></body>
</html>`;
return { html, applyInversion, hasNativeDark, blockedExternal };
return { html, recolorDark, hasNativeDark, blockedExternal };
}
export function wrapEmailHtml(innerHtml: string, options: WrapOptions = {}): string {
@@ -968,10 +936,22 @@ function cssColor(value: string, fallback: string): string {
export function wrapPlainTextEmail(
innerHtml: string,
options: { isDark?: boolean; font?: PlainTextFont; fontPx?: number; darkColors?: PlainTextColors } = {},
options: {
isDark?: boolean;
font?: PlainTextFont;
fontPx?: number;
darkColors?: PlainTextColors;
/**
* The app's reading font (Settings > Accessibility > Font). "Same as
* above" (sans) draws the body in it, as the webmail does for a plain
* body in the app document; monospace stays monospace.
*/
readingFont?: AppFont;
} = {},
): string {
const { isDark = true, font = 'sans', fontPx = 14, darkColors } = options;
const { isDark = true, font = 'sans', fontPx = 14, darkColors, readingFont } = options;
const cleaned = stripDangerousTags(innerHtml);
const reading = font === 'sans' ? readingFont : undefined;
// In dark mode the body takes the app theme's ground, text and link colours
// when they are given (a built-in dark theme isn't #0a0a0a); light mode
@@ -981,9 +961,9 @@ export function wrapPlainTextEmail(
const link = isDark ? cssColor(darkColors?.link ?? '', '#60a5fa') : '#1a73e8';
const fontFamily = font === 'mono'
? 'ui-monospace, Menlo, Consolas, "SF Mono", monospace'
: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
: mailFontStack(reading);
const styles = `
const styles = `${readingFontFaceCss(reading)}
html, body { background: ${bg}; }
/* Pinch-zoomed content overflows sideways; it is panned via the injected
transform, never via native horizontal scrolling. */
+18
View File
@@ -213,6 +213,24 @@
"version": "",
"license": "MIT",
"url": "https://tabler.io/icons"
},
{
"name": "Atkinson Hyperlegible Next",
"version": "",
"license": "OFL-1.1",
"url": "https://github.com/googlefonts/atkinson-hyperlegible-next"
},
{
"name": "Lexend",
"version": "",
"license": "OFL-1.1",
"url": "https://github.com/googlefonts/lexend"
},
{
"name": "OpenDyslexic",
"version": "",
"license": "OFL-1.1",
"url": "https://opendyslexic.org"
}
]
}
+2 -2
View File
@@ -57,8 +57,8 @@ export const QUOTE_COLLAPSED_ATTR = 'data-quote-collapsed';
/**
* The in-page implementation of `setupQuoteCollapse`, as a self-contained
* script for `injectedJavaScript`. Idempotent per document. Uses inline
* styles only (style-src allows inline; neutral greys read fine after the
* dark-mode invert filter too).
* styles only (style-src allows inline; the dark-mode recolour pass runs
* after it and converts the toggle like the rest of the mail).
*/
export function buildQuoteCollapseScript(labels: QuoteCollapseLabels): string {
const cfg = JSON.stringify({
+5 -1
View File
@@ -63,8 +63,12 @@ export const SETTINGS_SEARCH_PATHS: Record<SettingsTabId, string[]> = {
accessibility: [
'settings.accessibility.presets.screen-reader',
'settings.accessibility.presets.low-vision',
'settings.accessibility.presets.dyslexia',
'settings.accessibility.presets.motion',
'settings.accessibility.text_size',
'settings.accessibility.font',
'settings.accessibility.plain_language',
'settings.accessibility.cvd_palette',
'settings.accessibility.reduce_motion',
'settings.accessibility.toast_duration',
'settings.accessibility.confirm_send',
@@ -267,7 +271,7 @@ export const SETTINGS_SEARCH_KEYWORDS: Record<SettingsTabId, string> = {
recovery: 'recover restore undelete deleted trash emptied lost messages',
language: 'locale region date time format translation',
notifications: 'alert push badge unifiedpush relay device reminder',
accessibility: 'a11y screen reader talkback voiceover low vision text size font bigger larger motion animation preset undo timeout duration confirm',
accessibility: 'a11y screen reader talkback voiceover low vision text size font bigger larger motion animation preset undo timeout duration confirm dyslexia dyslexic typeface atkinson lexend opendyslexic colour color blind blindness palette plain simple easy language wording',
appearance: 'theme dark light font size text color animation density',
layout: 'swipe gesture quick actions toolbar order sort unified folder count avatar',
reading: 'mark read preview thread conversation archive delete attachment open monospace mono font plain text receipt',
+40 -4
View File
@@ -24,8 +24,15 @@ interface LocaleState {
* next launch, so the language pane shows a "restart to apply" hint.
*/
directionChangePending: boolean;
/**
* Plain language (Settings > Accessibility): simpler wording where the
* language has it (English, German). Mirrors the settings store's
* plainLanguage, which is where the user's choice is kept.
*/
plainLanguage: boolean;
hydrate: () => Promise<void>;
setOverride: (locale: LocaleCode | null) => void;
setPlainLanguage: (on: boolean) => void;
t: TranslateFn;
}
@@ -51,8 +58,8 @@ function applyLayoutDirection(locale: LocaleCode): boolean {
// A new `t` per locale: most components select only `t`, and a function
// that never changed identity left them in the device language after the
// saved one loaded on a cold start (tab labels, the folder title).
function translatorFor(locale: LocaleCode): TranslateFn {
return (key, fallback, params) => translate(locale, key, fallback, params);
function translatorFor(locale: LocaleCode, plain = false): TranslateFn {
return (key, fallback, params) => translate(locale, key, fallback, params, plain);
}
export const useLocaleStore = create<LocaleState>((set, get) => ({
@@ -60,6 +67,7 @@ export const useLocaleStore = create<LocaleState>((set, get) => ({
override: null,
hydrated: false,
directionChangePending: false,
plainLanguage: false,
hydrate: async () => {
if (get().hydrated) return;
@@ -69,7 +77,7 @@ export const useLocaleStore = create<LocaleState>((set, get) => ({
const override = stored && isSupportedLocale(stored) ? stored : null;
const locale = override ?? detectDeviceLocale();
const directionChangePending = applyLayoutDirection(locale);
set({ override, locale, hydrated: true, directionChangePending, t: translatorFor(locale) });
set({ override, locale, hydrated: true, directionChangePending, t: translatorFor(locale, get().plainLanguage) });
} catch {
set({ hydrated: true });
}
@@ -78,13 +86,41 @@ export const useLocaleStore = create<LocaleState>((set, get) => ({
setOverride: (override) => {
const locale = override ?? detectDeviceLocale();
const directionChangePending = applyLayoutDirection(locale);
set({ override, locale, directionChangePending, t: translatorFor(locale) });
set({ override, locale, directionChangePending, t: translatorFor(locale, get().plainLanguage) });
void AsyncStorage.setItem(STORAGE_KEY, JSON.stringify({ override })).catch(() => {});
},
// A new `t` here too, so every component that selects it re-renders.
setPlainLanguage: (on) => {
if (get().plainLanguage === on) return;
set({ plainLanguage: on, t: translatorFor(get().locale, on) });
},
t: translatorFor(detectDeviceLocale()),
}));
interface PlainLanguageSource {
getState: () => { plainLanguage?: boolean };
subscribe: (listener: (state: { plainLanguage?: boolean }) => void) => unknown;
}
/** Keeps the wording in step with the settings store's Plain language switch. */
export function followPlainLanguage(settings: PlainLanguageSource): void {
useLocaleStore.getState().setPlainLanguage(!!settings.getState().plainLanguage);
settings.subscribe((state) => {
useLocaleStore.getState().setPlainLanguage(!!state.plainLanguage);
});
}
// Required, not imported: the settings store loads the API client, which
// tests of this store's users mock piecemeal; without it (a test) the
// wording stays normal.
try {
followPlainLanguage((require('./settings-store') as typeof import('./settings-store')).useSettingsStore);
} catch {
// No settings store: normal wording.
}
// Non-hook accessor for code outside React (background tasks, stores).
export function t(key: string, fallback?: string, params?: MessageParams): string {
return useLocaleStore.getState().t(key, fallback, params);
+20
View File
@@ -17,6 +17,9 @@ export type MailAttachmentAction = 'preview' | 'download';
export type AttachmentPosition = 'beside-sender' | 'below-header';
export type PlainTextFont = 'sans' | 'mono';
export type MessageSpacing = 'auto' | 'always' | 'edge';
// a11y-reading: the webmail's reading fonts (lib/accessibility-settings.ts AppFont).
export type AppFont = 'default' | 'atkinson' | 'lexend' | 'opendyslexic';
export const APP_FONTS: readonly AppFont[] = ['default', 'atkinson', 'lexend', 'opendyslexic'];
export type ReadReceiptResponse = 'ask' | 'always' | 'never';
export type PostExportAction = 'keep' | 'archive' | 'trash';
export type SwipeAction =
@@ -159,6 +162,17 @@ export interface PersistedSettings {
emailAlwaysLightMode: boolean;
activeThemeId: string | null;
// a11y-reading: Settings > Accessibility (webmail lib/accessibility-
// settings.ts, same keys for export/import). The reading font for the
// app's text and plain-text mail (theme/reading-fonts).
appFont: AppFont;
// Tag and calendar colours drawn in the colour-blind-safe palette
// (lib/cvd-palette, theme/colors).
colorBlindPalette: boolean;
// Simpler wording in English and German (locales/<code>/plain.json,
// i18n/index, stores/locale-store).
plainLanguage: boolean;
// Composing
autoSelectReplyIdentity: boolean;
replyIdentityMatch: ReplyIdentityMatch;
@@ -359,6 +373,10 @@ const DEFAULT_PERSISTED: PersistedSettings = {
animationsEnabled: true,
emailAlwaysLightMode: false,
activeThemeId: null,
// a11y-reading:
appFont: 'default',
colorBlindPalette: false,
plainLanguage: false,
autoSelectReplyIdentity: false,
replyIdentityMatch: 'domain',
@@ -617,6 +635,8 @@ const VALIDATORS: Partial<Record<keyof PersistedSettings, (v: unknown) => boolea
// Levels are sanitized by the consumer (lib/message-list-order sanitizeSortLevels).
messageListOrder: (v) => Array.isArray(v),
fontSize: oneOf(FONT_SIZES),
// a11y-reading:
appFont: oneOf(APP_FONTS),
density: oneOf(['extra-compact', 'compact', 'regular', 'comfortable']),
attachmentReminderKeywords: stringArray,
// Same set the webmail accepts (stores/settings-store.ts importSettings).
+141
View File
@@ -0,0 +1,141 @@
// Reading fonts (Settings > Accessibility > Font, webmail
// lib/accessibility-settings.ts AppFont, app/accessibility.css): the names,
// the bundled Android files, the @font-face rules a mail body gets, and the
// wrapper that puts the font on the app's Text.
import { describe, it, expect, vi } from 'vitest';
import { existsSync } from 'node:fs';
import { join } from 'node:path';
// The wrapper subscribes with useSyncExternalStore; outside a React render
// the test reads the snapshot directly.
vi.mock('react', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useSyncExternalStore: (_subscribe: unknown, getSnapshot: () => unknown) => getSnapshot(),
}));
import {
appFontAssetFamily,
appFontFamilyName,
mailFontStack,
readingFontFaceCss,
uiFontFamily,
uiFontsBundled,
} from '../reading-fonts';
import {
getUiFontFamily,
setUiFontFamily,
withFontFamily,
wrapModuleDefaults,
wrapWithReadingFont,
} from '../reading-font-text';
import { APP_FONTS, mergeWithDefaults } from '../../stores/settings-store';
import { presetChanges } from '../../lib/accessibility-presets';
const ASSETS = join(__dirname, '..', '..', '..', 'android', 'app', 'src', 'main', 'assets', 'fonts');
describe('reading fonts', () => {
it('offers the webmail fonts under its names and keys', () => {
expect(APP_FONTS).toEqual(['default', 'atkinson', 'lexend', 'opendyslexic']);
expect(appFontFamilyName('atkinson')).toBe('Atkinson Hyperlegible Next');
expect(appFontFamilyName('lexend')).toBe('Lexend');
expect(appFontFamilyName('opendyslexic')).toBe('OpenDyslexic');
expect(appFontFamilyName('default')).toBeNull();
});
it('bundles a regular and a bold file on Android for every font', () => {
for (const font of APP_FONTS) {
const family = appFontAssetFamily(font);
if (font === 'default') {
expect(family).toBeNull();
continue;
}
expect(existsSync(join(ASSETS, `${family}.ttf`))).toBe(true);
expect(existsSync(join(ASSETS, `${family}_bold.ttf`))).toBe(true);
}
expect(existsSync(join(ASSETS, 'OFL.txt'))).toBe(true);
});
it('names a family for the app text only where the fonts are bundled', () => {
expect(uiFontsBundled('android')).toBe(true);
expect(uiFontFamily('opendyslexic', 'android')).toBe('OpenDyslexic');
expect(uiFontFamily('default', 'android')).toBeNull();
// iOS reports an unknown family as an error.
expect(uiFontFamily('opendyslexic', 'ios')).toBeNull();
});
it('puts the font first in a mail body and keeps the system stack behind it', () => {
expect(mailFontStack('lexend')).toMatch(/^"Lexend", -apple-system, /);
expect(mailFontStack('default')).toMatch(/^-apple-system, /);
});
it('gives a mail document the font as data: woff2 with the webmail unicode ranges', () => {
const css = readingFontFaceCss('atkinson');
const faces = css.split('\n');
expect(faces).toHaveLength(2);
for (const face of faces) {
expect(face).toContain('font-family: "Atkinson Hyperlegible Next";');
expect(face).toContain('font-weight: 200 800;');
// "wOF2", the woff2 signature, in base64.
expect(face).toMatch(/src: url\("data:font\/woff2;base64,d09GMg[A-Za-z0-9+/=]+"\) format\("woff2"\);/);
expect(face).toMatch(/unicode-range: U\+/);
}
expect(readingFontFaceCss('opendyslexic')).toContain('font-family: "OpenDyslexic";');
expect(readingFontFaceCss('default')).toBe('');
});
it('keeps a stored font and drops one the app does not know', () => {
expect(mergeWithDefaults({ appFont: 'lexend' }).appFont).toBe('lexend');
expect(mergeWithDefaults({ appFont: 'comic-sans' as never }).appFont).toBe('default');
});
it('has a dyslexia preset that picks the readable font for the app and plain-text mail', () => {
expect(presetChanges('dyslexia', { appFont: 'default', plainTextFont: 'mono' })).toEqual([
{ key: 'appFont', from: 'default', to: 'atkinson' },
{ key: 'plainTextFont', from: 'mono', to: 'sans' },
]);
});
});
describe('the reading font on the app text', () => {
it('puts the family under the caller style, which still wins', () => {
const own = { color: 'red' };
expect(withFontFamily({ style: own }, 'Lexend')).toEqual({ style: [{ fontFamily: 'Lexend' }, own] });
expect(withFontFamily({}, 'Lexend')).toEqual({ style: { fontFamily: 'Lexend' } });
const props = { style: own };
expect(withFontFamily(props, null)).toBe(props);
});
it('renders the original component with the current family', () => {
const seen: unknown[] = [];
const Original = Object.assign((props: Record<string, unknown>) => { seen.push(props); return null; }, {
displayName: 'TextInput',
State: { currentlyFocusedInput: () => null },
});
const Wrapped = wrapWithReadingFont(Original);
// Statics such as TextInput.State stay reachable.
expect((Wrapped as unknown as typeof Original).State).toBe(Original.State);
expect(Wrapped.displayName).toBe('TextInput');
setUiFontFamily(null);
Wrapped({ style: { color: 'red' }, children: 'x' });
expect(seen.pop()).toEqual({ style: { color: 'red' }, children: 'x' });
setUiFontFamily('OpenDyslexic');
expect(getUiFontFamily()).toBe('OpenDyslexic');
Wrapped({ style: { color: 'red' }, children: 'x' });
expect(seen.pop()).toEqual({ style: [{ fontFamily: 'OpenDyslexic' }, { color: 'red' }], children: 'x' });
setUiFontFamily(null);
});
it('wraps each module default once and leaves non-functions alone', () => {
const text = { default: () => null };
const memo = { default: { $$typeof: Symbol.for('react.memo') } };
const original = text.default;
expect(wrapModuleDefaults([text, memo])).toBe(1);
expect(text.default).not.toBe(original);
const wrapped = text.default;
expect(wrapModuleDefaults([text])).toBe(0);
expect(text.default).toBe(wrapped);
expect(memo.default).toEqual({ $$typeof: Symbol.for('react.memo') });
});
});
+28 -6
View File
@@ -1,8 +1,9 @@
import { useMemo } from 'react';
import { useCallback, useMemo } from 'react';
import { useColorScheme } from 'react-native';
import { useSettingsStore } from '../stores/settings-store';
import { LIGHT_COLORS, DARK_COLORS, type ThemePalette } from './tokens';
import { getBuiltinTheme } from './builtin-themes';
import { cvdColor, cvdTagPalette } from '../lib/cvd-palette';
export type { ThemePalette };
@@ -12,15 +13,24 @@ const paletteCache = new Map<string, ThemePalette>();
* Base light/dark palette with the active built-in theme's overrides applied
* (Settings → Themes). Memoised per (scheme, theme) pair so `useColors()`
* keeps returning a stable object for `useMemo(() => makeStyles(c), [c])`.
*
* With "Colour-blind-safe colours" (Settings → Accessibility, webmail
* colorBlindPalette) the tag colours are drawn in that palette
* (lib/cvd-palette): every place that draws a tag from `c.tags` follows,
* as the webmail's tag formatter does. The picked colours stay in
* `tagsAsPicked` for the colour picker and tag settings.
*/
export function resolvePalette(scheme: 'light' | 'dark', themeId: string | null): ThemePalette {
export function resolvePalette(scheme: 'light' | 'dark', themeId: string | null, colorBlind = false): ThemePalette {
const base = scheme === 'light' ? LIGHT_COLORS : DARK_COLORS;
const theme = getBuiltinTheme(themeId);
if (!theme) return base;
const key = `${scheme}:${theme.id}`;
if (!theme && !colorBlind) return base;
const key = `${scheme}:${theme?.id ?? ''}:${colorBlind ? 'cvd' : ''}`;
const cached = paletteCache.get(key);
if (cached) return cached;
const merged: ThemePalette = { ...base, ...(scheme === 'light' ? theme.light : theme.dark) };
let merged: ThemePalette = theme ? { ...base, ...(scheme === 'light' ? theme.light : theme.dark) } : base;
if (colorBlind) {
merged = { ...merged, tags: cvdTagPalette(merged.tags, merged.text), tagsAsPicked: merged.tags };
}
paletteCache.set(key, merged);
return merged;
}
@@ -36,12 +46,24 @@ export function resolvePalette(scheme: 'light' | 'dark', themeId: string | null)
export function useColors(): ThemePalette {
const themePref = useSettingsStore((s) => s.theme);
const activeThemeId = useSettingsStore((s) => s.activeThemeId);
const colorBlind = useSettingsStore((s) => s.colorBlindPalette);
const systemScheme = useColorScheme();
const resolved =
themePref === 'system'
? systemScheme === 'light' ? 'light' : 'dark'
: themePref;
return useMemo(() => resolvePalette(resolved, activeThemeId), [resolved, activeThemeId]);
return useMemo(() => resolvePalette(resolved, activeThemeId, colorBlind), [resolved, activeThemeId, colorBlind]);
}
/**
* Draws a colour the user picked (a calendar's, a tag's hex) in the
* colour-blind-safe palette while that setting is on, and as it is otherwise
* (webmail hooks/use-cvd-color.ts). For colours that don't come from
* `c.tags`, such as calendar colours from the server.
*/
export function useCvdColor(): (color: string) => string {
const enabled = useSettingsStore((s) => s.colorBlindPalette);
return useCallback((color: string) => cvdColor(color, enabled), [enabled]);
}
/**
+22
View File
@@ -0,0 +1,22 @@
// Installs the reading font for the app's text (./reading-font-text). Imported
// first thing by index.ts, before any screen module copies `Text` out of
// react-native. Android only: iOS has no bundled reading fonts yet
// (./reading-fonts). The settings store is required after the wrapping, so
// nothing it loads can copy the plain Text first.
/* eslint-disable @typescript-eslint/no-require-imports */
import { Platform } from 'react-native';
import { setUiFontFamily, wrapModuleDefaults } from './reading-font-text';
import { uiFontFamily, uiFontsBundled } from './reading-fonts';
if (uiFontsBundled(Platform.OS)) {
wrapModuleDefaults([
require('react-native/Libraries/Text/Text'),
require('react-native/Libraries/Components/TextInput/TextInput'),
]);
const { useSettingsStore } = require('../stores/settings-store') as typeof import('../stores/settings-store');
setUiFontFamily(uiFontFamily(useSettingsStore.getState().appFont, Platform.OS));
useSettingsStore.subscribe((state, prev) => {
if (state.appFont !== prev.appFont) setUiFontFamily(uiFontFamily(state.appFont, Platform.OS));
});
}
+88
View File
@@ -0,0 +1,88 @@
// The reading font for the app's own text (Settings > Accessibility > Font).
//
// The webmail sets one CSS variable on <html> and every piece of text follows
// (app/accessibility.css data-app-font). React Native has no inherited font:
// each Text takes its family from its own style, the app's styles name none,
// and Text.defaultProps is gone on the new architecture. So the app's Text
// and TextInput components are wrapped once at startup: the wrapper puts the
// chosen family under the caller's own style (an explicit fontFamily still
// wins) and re-renders when the setting changes.
//
// Wrapping happens before any screen module loads (index.ts imports
// ./reading-font-install first): with Metro's import support every module
// copies `Text` out of react-native when it is evaluated, so a module loaded
// earlier keeps the plain component. The wrapper stays installed for the
// whole run, also while the theme font is chosen; swapping the component
// later would remount every text field.
import { useSyncExternalStore } from 'react';
type Props = { style?: unknown } & Record<string, unknown>;
type FunctionComponent = ((props: Props) => unknown) & { displayName?: string };
let family: string | null = null;
const listeners = new Set<() => void>();
const subscribe = (listener: () => void) => {
listeners.add(listener);
return () => { listeners.delete(listener); };
};
/** The family the app's text is drawn in right now, or null for the theme font. */
export function getUiFontFamily(): string | null {
return family;
}
/** Changes the family; every wrapped Text re-renders. */
export function setUiFontFamily(next: string | null): void {
if (next === family) return;
family = next;
for (const listener of [...listeners]) listener();
}
/** `props` with the family under the caller's own style; unchanged for null. */
export function withFontFamily<P extends { style?: unknown }>(props: P, fontFamily: string | null): P {
if (!fontFamily) return props;
return { ...props, style: props.style == null ? { fontFamily } : [{ fontFamily }, props.style] };
}
const WRAPPED = Symbol.for('bulwark.readingFont');
/**
* A component that renders `Original` with the reading font. `Original` is
* called as a function, not rendered as a child, so no extra component is
* added per text: its hooks run as part of the wrapper, always in the same
* order. Statics (TextInput.State) and the display name are kept.
*/
export function wrapWithReadingFont(Original: FunctionComponent): FunctionComponent {
const Wrapped = function ReadingFontText(props: Props) {
const current = useSyncExternalStore(subscribe, getUiFontFamily, getUiFontFamily);
return Original(withFontFamily(props, current));
} as FunctionComponent;
Object.assign(Wrapped, Original);
Wrapped.displayName = Original.displayName ?? Original.name;
(Wrapped as unknown as Record<symbol, unknown>)[WRAPPED] = true;
return Wrapped;
}
/**
* Wraps the default export of each module (react-native's Text and
* TextInput) in place. Only plain function components are wrapped; anything
* else (a future memo or forwardRef object) is left alone. Returns how many
* were wrapped; a second call wraps nothing.
*/
export function wrapModuleDefaults(modules: Array<{ default?: unknown }>): number {
let wrapped = 0;
for (const mod of modules) {
const component = mod?.default;
if (typeof component !== 'function') continue;
if ((component as unknown as Record<symbol, unknown>)[WRAPPED]) continue;
try {
mod.default = wrapWithReadingFont(component as FunctionComponent);
wrapped++;
} catch {
// A read-only export: leave the component as it is.
}
}
return wrapped;
}
File diff suppressed because one or more lines are too long
+97
View File
@@ -0,0 +1,97 @@
// The reading fonts on Settings > Accessibility > Font (webmail
// lib/accessibility-settings.ts AppFont, app/accessibility.css,
// lib/mail-reading.ts appFontFamilyName): Atkinson Hyperlegible Next, Lexend
// and OpenDyslexic. As in the webmail the font is the app's text font, and a
// plain-text mail body (which the webmail draws in the app document) uses it
// too; HTML mail keeps the sender's fonts.
//
// The app's text: Android finds the fonts in android/app/src/main/assets/
// fonts by family name (scripts/build-reading-fonts.py), and the Text patch in
// ./reading-font-text applies the family. iOS needs them registered in the
// app bundle (expo-font's config plugin), which the app doesn't have yet, so
// there the setting only reaches mail bodies.
//
// A mail body renders in a sandboxed WebView, which gets the woff2 files as
// data: URIs (./reading-fonts-data: a few hundred KB of base64 string
// constants, which Hermes keeps in the bytecode's string table rather than
// parsing them at startup).
import type { AppFont } from '../stores/settings-store';
import { ATKINSON, LEXEND, OPENDYSLEXIC, type ReadingFontFace } from './reading-fonts-data';
export type { AppFont };
/** The CSS family name a font is bundled under (webmail appFontFamilyName), or null for the default. */
export function appFontFamilyName(font: AppFont | null | undefined): string | null {
switch (font) {
case 'atkinson': return 'Atkinson Hyperlegible Next';
case 'lexend': return 'Lexend';
case 'opendyslexic': return 'OpenDyslexic';
default: return null;
}
}
/** The Android asset family (assets/fonts/<name>.ttf and <name>_bold.ttf). */
export function appFontAssetFamily(font: AppFont | null | undefined): string | null {
switch (font) {
case 'atkinson': return 'AtkinsonHyperlegibleNext';
case 'lexend': return 'Lexend';
case 'opendyslexic': return 'OpenDyslexic';
default: return null;
}
}
/** Whether the platform has the fonts for the app's own text. */
export function uiFontsBundled(platform: string): boolean {
return platform === 'android';
}
/**
* The fontFamily the app's text is drawn in: null for the theme font, and
* where the fonts aren't bundled (an unknown family is an error on iOS).
*/
export function uiFontFamily(font: AppFont | null | undefined, platform: string): string | null {
return uiFontsBundled(platform) ? appFontAssetFamily(font) : null;
}
/** The mail body's own font stack (lib/email-html). */
export const MAIL_SYSTEM_FONT_STACK = '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
/**
* font-family for a mail body: the reading font first (it covers Latin
* script; other scripts fall through to the system stack, as in the webmail).
*/
export function mailFontStack(font: AppFont | null | undefined): string {
const family = appFontFamilyName(font);
return family ? `"${family}", ${MAIL_SYSTEM_FONT_STACK}` : MAIL_SYSTEM_FONT_STACK;
}
function faceFor(font: AppFont): ReadingFontFace | null {
switch (font) {
case 'atkinson': return ATKINSON;
case 'lexend': return LEXEND;
case 'opendyslexic': return OPENDYSLEXIC;
default: return null;
}
}
/**
* The @font-face rules a mail document needs for the font, with the fonts as
* data: URIs (the page's CSP allows font-src data: in every mode). Empty for
* the default font.
*/
export function readingFontFaceCss(font: AppFont | null | undefined): string {
if (!font || font === 'default') return '';
const face = faceFor(font);
if (!face) return '';
return face.subsets.map((subset) => [
'@font-face {',
`font-family: "${face.family}";`,
'font-style: normal;',
'font-display: swap;',
`font-weight: ${face.weight};`,
`src: url("data:font/woff2;base64,${subset.woff2}") format("woff2");`,
subset.unicodeRange ? `unicode-range: ${subset.unicodeRange};` : '',
'}',
].filter(Boolean).join(' ')).join('\n');
}
+6
View File
@@ -74,6 +74,12 @@ export interface ThemePalette {
// Tags
tags: TagColors;
/**
* The tag colours as the user picks them, set while tags are drawn in the
* colour-blind-safe palette (theme/colors): the colour picker and the tag
* settings show the colours themselves, as in the webmail.
*/
tagsAsPicked?: TagColors;
// Navigation
navActive: string;