Compare commits

..
10 Commits
88 changed files with 120307 additions and 91543 deletions
+38
View File
@@ -39,6 +39,9 @@ import {
type NotificationTapPayload,
} from './src/lib/push-notifications';
import { addUnifiedPushEndpointListener } from './src/lib/unified-push';
// Pause between a mute change and the push-filter update it sends (#78).
const MUTES_RESYNC_DELAY_MS = 1_000;
import type { MainTabsParamList, RootStackParamList } from './src/navigation/types';
import ComposeScreen from './src/screens/ComposeScreen';
import EmailThreadScreen from './src/screens/EmailThreadScreen';
@@ -684,6 +687,41 @@ export default function App() {
};
}, [client, isAuthenticated, emailNotificationsEnabled, activeAccountId]);
// Muting a folder or account changes the server-side push filter (#78),
// which the setup above would only send at the next launch. Send it now,
// after a short pause so a few taps in a row make one update. Compared by
// content, since hydration hands back an equal but new object; the value
// seen first after hydration is the baseline, not a change.
const notificationMutes = useSettingsStore((s) => s.notificationMutes);
const settingsHydrated = useSettingsStore((s) => s.hydrated);
const mutesKey = JSON.stringify(notificationMutes);
const lastMutesKey = React.useRef<string | null>(null);
React.useEffect(() => {
if (!settingsHydrated) return;
if (lastMutesKey.current === null || lastMutesKey.current === mutesKey) {
lastMutesKey.current = mutesKey;
return;
}
lastMutesKey.current = mutesKey;
if (!isAuthenticated || !client || !emailNotificationsEnabled) return;
let cancelled = false;
const timer = setTimeout(async () => {
const relayBaseUrl = await getStoredRelayBaseUrl();
if (!relayBaseUrl || cancelled) return;
try {
await resyncPushNotifications({ relayBaseUrl, accountLabel: client.username ?? undefined });
} catch (error) {
console.warn('[push] mute update failed:', error instanceof Error ? error.message : error);
}
}, MUTES_RESYNC_DELAY_MS);
return () => {
cancelled = true;
clearTimeout(timer);
};
// Only a change of the mutes should send an update; the rest is read live.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mutesKey, settingsHydrated]);
// Live updates (SSE with polling fallback), re-armed on every account
// switch and every re-established session — the singleton `client` object
// never changes identity, so it cannot be the dependency on its own.
+1 -1
View File
@@ -1 +1 @@
0.1.81
0.1.83
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Debug builds load the bundle from Metro over http, which the main config
forbids (a network security config replaces the manifest's
usesCleartextTraffic). Same trust anchors as the release config.
-->
<network-security-config>
<base-config cleartextTrafficPermitted="true">
<trust-anchors>
<certificates src="system" />
<certificates src="user" />
</trust-anchors>
</base-config>
</network-security-config>
+1 -1
View File
@@ -21,7 +21,7 @@
<data android:scheme="https"/>
</intent>
</queries>
<application android:name=".MainApplication" android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round" android:allowBackup="false" android:theme="@style/AppTheme" android:supportsRtl="true" android:enableOnBackInvokedCallback="false" android:fullBackupContent="@xml/secure_store_backup_rules" android:dataExtractionRules="@xml/secure_store_data_extraction_rules">
<application android:name=".MainApplication" android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round" android:allowBackup="false" android:theme="@style/AppTheme" android:supportsRtl="true" android:enableOnBackInvokedCallback="false" android:fullBackupContent="@xml/secure_store_backup_rules" android:dataExtractionRules="@xml/secure_store_data_extraction_rules" android:networkSecurityConfig="@xml/network_security_config">
<meta-data android:name="expo.modules.updates.ENABLED" android:value="false"/>
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_CHECK_ON_LAUNCH" android:value="ALWAYS"/>
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_LAUNCH_WAIT_MS" android:value="0"/>
@@ -82,7 +82,9 @@ class BulwarkPdfModule(reactContext: ReactApplicationContext) : ReactContextBase
bitmap.eraseColor(Color.WHITE)
page.render(bitmap, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY)
val out = fileOf(outUri)
out.parentFile?.mkdirs()
// The preview's folder is gone once it closed; a page
// still queued then must not bring it back.
if (out.parentFile?.isDirectory != true) throw IllegalStateException("preview closed")
FileOutputStream(out).use { stream -> bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream) }
} finally {
bitmap.recycle()
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Trust anchors for every connection the app makes (fetch, the client
certificate module, the sync adapters). Android 7+ trusts only the system
CAs unless the app opts in; a self-hosted server behind a private CA
installed under Settings > Security > Encryption & credentials would fail
with "Network request failed" otherwise (#3). Cleartext stays off, as it is
for an app targeting API 28+ without this file; the debug source set
overrides this for Metro.
-->
<network-security-config>
<base-config cleartextTrafficPermitted="false">
<trust-anchors>
<certificates src="system" />
<certificates src="user" />
</trust-anchors>
</base-config>
</network-security-config>
+1 -1
View File
@@ -175,7 +175,7 @@ RN has a solid single-message reader (WebView body with CSP, shrink-to-fit + pin
- What RN does: single tap follows the global `mailAttachmentAction`; `jszip` is already a dependency (`package.json:36`) but unused for mail.
- Fix hint: long-press sheet with "Open / Save / Share"; a "Download all" entry building the zip with `jszip` into `Paths.cache` then sharing.
- [x] **In-app attachment preview (images, PDF, text, audio/video) is absent; iOS "Preview" is just the share sheet — fixed in d2ed27f** — `P3` — `partial`
- [x] **In-app attachment preview (images, PDF, text, audio/video) is absent; iOS "Preview" is just the share sheet — fixed in d2ed27f; audio/video, HTML, Markdown, SVG, Android PDFs and ZIPs since 0.1.80-0.1.82** — `P3` — `partial`
- What WEB does: `FilePreviewModal` for images/PDF (bytes handed to pdf.js, #871)/text/markdown/audio/video/eml, script-bearing types forced to download (`components/mail/mail-app.tsx:2887-2935`, `lib/file-preview.ts:29-92`).
- What RN does: Android hands the file to an external viewer via `ACTION_VIEW`, iOS opens the share sheet (`src/lib/email-export.ts:64-79`, `115-141`); nothing is previewed inside the app.
- Fix hint: for images/PDF/text open a modal `WebView` on the cached `file://` (or use `expo-file-system`'s `File.uri` with a QuickLook bridge on iOS); keep the external-viewer path as fallback.
+1 -1
View File
@@ -110,7 +110,7 @@ Filters: RN carries a byte-for-byte port of WEB's Sieve parser/generator/tests a
- What RN does: `getDocumentAsync({ multiple: false })` (`src/screens/FilesScreen.tsx:357-360`); uploading the same name twice creates two nodes with the same name.
- Fix hint: `multiple: true` + sequential upload loop; reuse WEB's `getUniqueName` against `visibleFiles`.
- [x] **No in-app preview (image/text/markdown/PDF/audio/video/eml)** — `P2` — `missing` — done in 409cdb1 (images, text and source files, `.eml`, and PDFs on iOS open in the reader's preview; PDFs on Android, audio, video, HTML and Markdown still go to another app)
- [x] **No in-app preview (image/text/markdown/PDF/audio/video/eml)** — `P2` — `missing` — done in 409cdb1 (images, text and source files, `.eml`, and PDFs on iOS open in the reader's preview); since 0.1.80/0.1.81 also audio, video, SVG/ICO, HTML, Markdown, PDFs on Android (native `PdfRenderer`) and ZIP listings, through the one preview the reader, list, Files and compose share (`src/components/email/use-file-preview.ts`)
- What WEB does: `ImagePreviewModal` and `FilePreviewModal` (text, markdown, PDF incl. mobile pdf.js viewer, audio, video, EML) chosen via `getFilePreviewKind` (`lib/file-preview.ts:29-68`; `components/files/file-preview-modal.tsx`, `image-preview-modal.tsx`, `pdf-mobile-viewer.tsx`; `files-app.tsx:487-495`, `690-708`).
- What RN does: tapping a file calls `shareAttachment` which downloads to cache and hands off to the OS share/viewer sheet (`src/screens/FilesScreen.tsx:248-258`; `src/lib/email-export.ts` "preview" variant). Works but there is no inline viewer and no "recent files" tracking.
- Fix hint: reuse whatever attachment viewer the mail area has (image modal / WebView for text+PDF); N/A if the team decides the OS viewer is the mobile answer - then close this item.
+4506 -3645
View File
File diff suppressed because it is too large Load Diff
+4506 -3645
View File
File diff suppressed because it is too large Load Diff
+4506 -3645
View File
File diff suppressed because it is too large Load Diff
+4506 -3645
View File
File diff suppressed because it is too large Load Diff
+953 -92
View File
File diff suppressed because it is too large Load Diff
+951 -90
View File
File diff suppressed because it is too large Load Diff
+4506 -3645
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+4506 -3645
View File
File diff suppressed because it is too large Load Diff
+4506 -3645
View File
File diff suppressed because it is too large Load Diff
+4507 -3646
View File
File diff suppressed because it is too large Load Diff
+4506 -3645
View File
File diff suppressed because it is too large Load Diff
+4506 -3645
View File
File diff suppressed because it is too large Load Diff
+4506 -3645
View File
File diff suppressed because it is too large Load Diff
+4506 -3645
View File
File diff suppressed because it is too large Load Diff
+4506 -3645
View File
File diff suppressed because it is too large Load Diff
+4506 -3645
View File
File diff suppressed because it is too large Load Diff
+4506 -3645
View File
File diff suppressed because it is too large Load Diff
+4506 -3645
View File
File diff suppressed because it is too large Load Diff
+4506 -3645
View File
File diff suppressed because it is too large Load Diff
+4506 -3645
View File
File diff suppressed because it is too large Load Diff
+6 -6
View File
@@ -410,7 +410,11 @@
"unsupported": "لا توجد معاينة لهذا النوع من الملفات.",
"media_unsupported": "لا يمكن تشغيل هذا الملف هنا.",
"pdf_protected": "ملف PDF هذا محمي بكلمة مرور. افتحه في تطبيق آخر لإدخال كلمة المرور.",
"pdf_unreadable": "لا يمكن عرض ملف PDF هذا هنا."
"pdf_unreadable": "لا يمكن عرض ملف PDF هذا هنا.",
"archive_files": "{count, plural, zero {لا ملفات} one {ملف واحد} two {ملفان} few {# ملفات} many {# ملفًا} other {# ملف}}",
"archive_first_files": "أول {count} ملف",
"archive_protected": "هذا الأرشيف محمي بكلمة مرور. افتحه في تطبيق آخر لإدخال كلمة المرور.",
"archive_unreadable": "لا يمكن فتح هذا الأرشيف هنا."
},
"received_via": "إلى {name}",
"remove_color": "إزالة الوسم",
@@ -441,7 +445,6 @@
"open_error": "تعذّر فتح الملف",
"open_with": "فتح باستخدام…",
"preview_too_large": "هذا الملف كبير جدًا بحيث لا يمكن معاينته.",
"selected_count": "{count, plural, zero {تم تحديد #} one {تم تحديد عنصر واحد} two {تم تحديد عنصرين} few {تم تحديد # عناصر} many {تم تحديد # عنصرًا} other {تم تحديد # عنصر}}",
"share_add_people": "إضافة أشخاص",
"share_empty": "لم تتم المشاركة مع أي شخص بعد",
"share_error": "فشل تحديث المشاركة",
@@ -1098,7 +1101,6 @@
},
"tabs": {
"plugins": "الإضافات",
"themes": "السمات",
"updates": "التحديثات"
},
"templates": {
@@ -1115,9 +1117,7 @@
},
"themes": {
"default_description": "لوحات الألوان الفاتحة والداكنة الافتراضية.",
"default_name": "Bulwark",
"mobile_description": "اختر سمة ألوان. تتبع السمات إعداد الوضع الفاتح/الداكن من المظهر؛ وتُدار حزم السمات المخصصة في بريد الويب.",
"title": "السمات"
"mobile_description": "اختر سمة ألوان. تتبع السمات إعداد الوضع الفاتح/الداكن من المظهر؛ وتُدار حزم السمات المخصصة في بريد الويب."
},
"vacation": {
"warnings": {
+6 -6
View File
@@ -410,7 +410,11 @@
"unsupported": "No hi ha previsualització per a aquest tipus de fitxer.",
"media_unsupported": "Aquest fitxer no es pot reproduir aquí.",
"pdf_protected": "Aquest PDF està protegit amb contrasenya. Obre'l en una altra aplicació per introduir-la.",
"pdf_unreadable": "Aquest PDF no es pot mostrar aquí."
"pdf_unreadable": "Aquest PDF no es pot mostrar aquí.",
"archive_files": "{count, plural, one {# fitxer} other {# fitxers}}",
"archive_first_files": "Els primers {count} fitxers",
"archive_protected": "Aquest arxiu està protegit amb contrasenya. Obre'l en una altra aplicació per introduir-la.",
"archive_unreadable": "Aquest arxiu no es pot obrir aquí."
},
"received_via": "per a {name}",
"remove_color": "Elimina l'etiqueta",
@@ -441,7 +445,6 @@
"open_error": "No s'ha pogut obrir el fitxer",
"open_with": "Obre amb…",
"preview_too_large": "Aquest fitxer és massa gran per previsualitzar-lo.",
"selected_count": "{count, plural, one {# seleccionat} other {# seleccionats}}",
"share_add_people": "Afegeix persones",
"share_empty": "Encara no s'ha compartit amb ningú",
"share_error": "No s'ha pogut actualitzar la compartició",
@@ -1098,7 +1101,6 @@
},
"tabs": {
"plugins": "Connectors",
"themes": "Temes",
"updates": "Actualitzacions"
},
"templates": {
@@ -1115,9 +1117,7 @@
},
"themes": {
"default_description": "Les paletes clara i fosca per defecte.",
"default_name": "Bulwark",
"mobile_description": "Trieu un tema de color. Els temes segueixen el paràmetre clar/fosc d'Aparença; els paquets de temes personalitzats es gestionen al webmail.",
"title": "Temes"
"mobile_description": "Trieu un tema de color. Els temes segueixen el paràmetre clar/fosc d'Aparença; els paquets de temes personalitzats es gestionen al webmail."
},
"vacation": {
"warnings": {
+6 -6
View File
@@ -410,7 +410,11 @@
"unsupported": "Pro tento typ souboru není náhled k dispozici.",
"media_unsupported": "Tento soubor zde nelze přehrát.",
"pdf_protected": "Toto PDF je chráněné heslem. Otevřete ho v jiné aplikaci a zadejte heslo.",
"pdf_unreadable": "Toto PDF zde nelze zobrazit."
"pdf_unreadable": "Toto PDF zde nelze zobrazit.",
"archive_files": "{count, plural, one {# soubor} few {# soubory} many {# souboru} other {# souborů}}",
"archive_first_files": "Prvních {count} souborů",
"archive_protected": "Tento archiv je chráněný heslem. Otevřete ho v jiné aplikaci a zadejte heslo.",
"archive_unreadable": "Tento archiv zde nelze otevřít."
},
"received_via": "pro {name}",
"remove_color": "Odebrat štítek",
@@ -441,7 +445,6 @@
"open_error": "Soubor se nepodařilo otevřít",
"open_with": "Otevřít pomocí…",
"preview_too_large": "Tento soubor je příliš velký pro náhled.",
"selected_count": "{count, plural, one {# vybraná} few {# vybrané} many {# vybrané} other {# vybraných}}",
"share_add_people": "Přidat osoby",
"share_empty": "Zatím nikomu nesdíleno",
"share_error": "Aktualizace sdílení selhala",
@@ -1098,7 +1101,6 @@
},
"tabs": {
"plugins": "Pluginy",
"themes": "Motivy",
"updates": "Aktualizace"
},
"templates": {
@@ -1115,9 +1117,7 @@
},
"themes": {
"default_description": "Výchozí světlé a tmavé palety.",
"default_name": "Bulwark",
"mobile_description": "Vyberte barevný motiv. Motivy se řídí nastavením světlý/tmavý ve Vzhledu; vlastní balíčky motivů se spravují ve webmailu.",
"title": "Motivy"
"mobile_description": "Vyberte barevný motiv. Motivy se řídí nastavením světlý/tmavý ve Vzhledu; vlastní balíčky motivů se spravují ve webmailu."
},
"vacation": {
"warnings": {
+6 -6
View File
@@ -410,7 +410,11 @@
"unsupported": "Ingen forhåndsvisning for denne filtype.",
"media_unsupported": "Denne fil kan ikke afspilles her.",
"pdf_protected": "Denne PDF er beskyttet med adgangskode. Åbn den i en anden app for at indtaste adgangskoden.",
"pdf_unreadable": "Denne PDF kan ikke vises her."
"pdf_unreadable": "Denne PDF kan ikke vises her.",
"archive_files": "{count, plural, one {# fil} other {# filer}}",
"archive_first_files": "De første {count} filer",
"archive_protected": "Dette arkiv er beskyttet med adgangskode. Åbn det i en anden app for at indtaste adgangskoden.",
"archive_unreadable": "Dette arkiv kan ikke åbnes her."
},
"received_via": "til {name}",
"remove_color": "Fjern tag",
@@ -441,7 +445,6 @@
"open_error": "Kunne ikke åbne filen",
"open_with": "Åbn med…",
"preview_too_large": "Denne fil er for stor til forhåndsvisning.",
"selected_count": "{count, plural, one {# valgt} other {# valgte}}",
"share_add_people": "Tilføj personer",
"share_empty": "Ikke delt med nogen endnu",
"share_error": "Kunne ikke opdatere deling",
@@ -1098,7 +1101,6 @@
},
"tabs": {
"plugins": "Plugins",
"themes": "Temaer",
"updates": "Opdateringer"
},
"templates": {
@@ -1115,9 +1117,7 @@
},
"themes": {
"default_description": "Standardpaletterne til lys og mørk.",
"default_name": "Bulwark",
"mobile_description": "Vælg et farvetema. Temaer følger indstillingen for lys/mørk under Udseende; brugerdefinerede temapakker administreres i webmailen.",
"title": "Temaer"
"mobile_description": "Vælg et farvetema. Temaer følger indstillingen for lys/mørk under Udseende; brugerdefinerede temapakker administreres i webmailen."
},
"vacation": {
"warnings": {
+6 -6
View File
@@ -416,7 +416,11 @@
"unsupported": "Keine Vorschau für diesen Dateityp.",
"media_unsupported": "Diese Datei kann hier nicht abgespielt werden.",
"pdf_protected": "Diese PDF ist passwortgeschützt. Öffne sie in einer anderen App, um das Passwort einzugeben.",
"pdf_unreadable": "Diese PDF kann hier nicht angezeigt werden."
"pdf_unreadable": "Diese PDF kann hier nicht angezeigt werden.",
"archive_files": "{count, plural, one {# Datei} other {# Dateien}}",
"archive_first_files": "Die ersten {count} Dateien",
"archive_protected": "Dieses Archiv ist passwortgeschützt. Öffne es in einer anderen App, um das Passwort einzugeben.",
"archive_unreadable": "Dieses Archiv kann hier nicht geöffnet werden."
},
"received_via": "an {name}",
"remove_color": "Label entfernen",
@@ -447,7 +451,6 @@
"open_error": "Die Datei konnte nicht geöffnet werden",
"open_with": "Öffnen mit…",
"preview_too_large": "Diese Datei ist zu groß für eine Vorschau.",
"selected_count": "{count, plural, one {# ausgewählt} other {# ausgewählt}}",
"share_add_people": "Personen hinzufügen",
"share_empty": "Noch mit niemandem geteilt",
"share_error": "Freigabe konnte nicht aktualisiert werden",
@@ -1116,7 +1119,6 @@
},
"tabs": {
"plugins": "Plugins",
"themes": "Designs",
"updates": "Updates",
"storage": "Speicher & Sicherung",
"about": "Über",
@@ -1136,9 +1138,7 @@
},
"themes": {
"default_description": "Die Standardpaletten für Hell und Dunkel.",
"default_name": "Bulwark",
"mobile_description": "Wählen Sie ein Farbdesign. Designs folgen der Hell/Dunkel-Einstellung unter Darstellung; eigene Designpakete werden im Webmail verwaltet.",
"title": "Designs"
"mobile_description": "Wählen Sie ein Farbdesign. Designs folgen der Hell/Dunkel-Einstellung unter Darstellung; eigene Designpakete werden im Webmail verwaltet."
},
"vacation": {
"warnings": {
+61 -36
View File
@@ -284,7 +284,6 @@
"attach_camera": "Camera",
"attachment_reminder_body": "Your message mentions \"{keyword}\" but no file is attached. Send anyway?",
"attachment_reminder_title": "Forgot an attachment?",
"attachment_too_large": "This file is larger than the server allows ({size} > {max}).",
"attachments_too_large_total": "The attachments would exceed the {max} this server allows per message.",
"editor_unavailable": "Could not read the message content. Copy your text, then close and reopen the composer.",
"format_plain_text": "Switch to plain text",
@@ -297,6 +296,7 @@
"no_sent_folder": "No Sent folder found",
"permission_camera": "Camera permission is required to take a photo.",
"permission_photos": "Photo library permission is required to attach images.",
"read_receipt_off": "Request a read receipt",
"recipient_add_contact": "Add to contacts",
"recipient_copy": "Copy address",
"recipient_move_to": "Move to {field}",
@@ -322,15 +322,14 @@
"send_timeout_body": "The message may already have gone out. Check your Sent folder before sending it again.",
"send_timeout_title": "No answer from the server",
"toolbar": {
"background_color": "Background color",
"insert_image": "Insert image",
"remove_background_color": "Remove background color",
"remove_link": "Remove link"
},
"undo_send_label": "Sending in {seconds}s…",
"upload_failed_short": "Upload failed",
"uploading": "Uploading...",
"uploading_pct": "Uploading {pct}%",
"use_template": "Template",
"validation": {
"subject_required": "Add a subject"
}
@@ -422,12 +421,16 @@
"forwarded": "Forwarded",
"load_failed": "Failed to load email",
"preview": {
"archive_files": "{count, plural, one {# file} other {# files}}",
"archive_first_files": "The first {count} files",
"archive_protected": "This archive is password-protected. Open it in another app to enter the password.",
"archive_unreadable": "This archive can't be opened here.",
"media_unsupported": "This file can't be played here.",
"open_external": "Open in app",
"pdf_external": "PDFs open in your PDF viewer on this device.",
"unsupported": "No preview for this file type.",
"media_unsupported": "This file can't be played here.",
"pdf_protected": "This PDF is password-protected. Open it in another app to enter the password.",
"pdf_unreadable": "This PDF can't be shown here."
"pdf_unreadable": "This PDF can't be shown here.",
"unsupported": "No preview for this file type."
},
"received_via": "to {name}",
"remove_color": "Remove tag",
@@ -458,7 +461,23 @@
"open_error": "Could not open the file",
"open_with": "Open with…",
"preview_too_large": "This file is too large to preview.",
"selected_count": "{count, plural, one {# selected} other {# selected}}",
"settings_behavior": "Behavior",
"settings_colored_icons": "Colored Icons",
"settings_colored_icons_desc": "Use colorful icons instead of monochrome",
"settings_default_sort": "Default Sort",
"settings_default_sort_desc": "Choose the default sorting for files",
"settings_default_view": "Default View",
"settings_default_view_desc": "Choose between grid and list layout",
"settings_display": "Display",
"settings_icons": "Icons",
"settings_show_hidden": "Show Hidden Files",
"settings_show_hidden_desc": "Display files and folders that start with a dot",
"settings_show_icons": "Show File Icons",
"settings_show_icons_desc": "Display icons next to files and folders",
"settings_show_thumbnails": "Show Thumbnails",
"settings_show_thumbnails_desc": "Display image previews instead of icons for image files",
"settings_sort_direction": "Sort Direction",
"settings_sort_direction_desc": "Choose ascending or descending order",
"share_add_people": "Add people",
"share_empty": "Not shared with anyone yet",
"share_error": "Failed to update sharing",
@@ -473,6 +492,7 @@
"share_unavailable": "Sharing is not available on this device",
"shared_with": "Shared with",
"sharing_access": "Sharing & access",
"upload": "Upload",
"upload_unavailable": "The file picker is not installed in this build. Rebuild the app to enable uploads.",
"uploading_progress": "Uploading {current}/{total} · {name}"
},
@@ -586,6 +606,10 @@
"qr_title": "Sign-in code",
"scan_desc": "The fastest way in if you're signed in on the web",
"scan_title": "Scan a sign-in code",
"server_cert_active": "Signing in with the client certificate \"{alias}\". Tap to choose another.",
"server_cert_clear": "Sign in without the certificate",
"server_cert_failed": "Could not pick a certificate",
"server_cert_hint": "Does your server ask for a client certificate? Pick one installed on this phone.",
"server_hint": "Same address you type in the browser. We'll add https:// for you.",
"server_label": "Server address",
"server_not_found": "We couldn't find a server for {domain}. If you self-host, the address is the one you use for webmail.",
@@ -839,12 +863,6 @@
"plain_text_mode": {
"description_mobile": "Compose and read in plain text only."
},
"reply_identity_match": {
"description": "Which received addresses count as yours. Exact address only picks one of your configured identities. Same domain also treats any other address on one of your identity domains as a catch-all alias and rewrites the From header to it. Choose exact address if those addresses are distribution lists rather than aliases.",
"domain": "Any address on my domains",
"exact": "Exact address only",
"label": "Received Address Matching"
},
"send_delay": {
"description_mobile": "Hold outgoing mail for a few seconds so you can cancel it. Requires server support."
},
@@ -982,6 +1000,8 @@
"email": {
"app_icon_badge": "Unread count on app icon",
"app_icon_badge_desc": "Show the inbox unread count as a badge on the app icon.",
"mute_account": "Mute this account",
"mute_account_desc": "No alerts for new mail in this account. Long-press a folder in the sidebar to mute or unmute it on its own.",
"sound_channel_hint": "Sound and vibration for mail alerts are controlled by the Android notification channel: long-press a notification or open the system app settings."
},
"push": {
@@ -1180,38 +1200,37 @@
"mobile_explainer": "S/MIME is not available in the mobile app yet. Manage keys and certificates in the webmail; signed messages are shown without verification here."
},
"summary": {
"composing": "Signature, send delay, drafts",
"contacts": "Address books, sorting",
"content_senders": "Remote images, trusted senders",
"downloads": "File names for saved mail",
"events_off": "events off",
"events_on": "events on",
"files": "View, sorting, thumbnails",
"filters": "Rules for incoming mail",
"folders": "Create, rename, roles",
"identities": "Addresses you send from",
"keywords": "Tag names and colours",
"layout": "Quick actions, swipes, list order",
"mail_off": "Mail off",
"mail_on": "Mail on",
"offline_off": "Offline mail off",
"offline_on": "Offline mail on · {days, plural, one {# day} other {# days}}, {mb} MB",
"version": "Version {version}",
"week_starts": "week starts {day}",
"layout": "Quick actions, swipes, list order",
"reading": "Read status, delete, attachments",
"composing": "Signature, send delay, drafts",
"identities": "Addresses you send from",
"vacation": "Automatic reply while away",
"filters": "Rules for incoming mail",
"templates": "Reusable messages",
"folders": "Create, rename, roles",
"keywords": "Tag names and colours",
"downloads": "File names for saved mail",
"security": "Password, two-factor, app passwords",
"content_senders": "Remote images, trusted senders",
"contacts": "Address books, sorting",
"files": "View, sorting, thumbnails",
"sidebar_apps": "Web apps in the side menu",
"updates": "Install new versions"
"templates": "Reusable messages",
"updates": "Install new versions",
"vacation": "Automatic reply while away",
"version": "Version {version}",
"week_starts": "week starts {day}"
},
"tabs": {
"plugins": "Plugins",
"themes": "Themes",
"updates": "Updates",
"storage": "Storage & backup",
"about": "About",
"troubleshooting": "Troubleshooting"
"plugins": "Plugins",
"storage": "Storage & backup",
"troubleshooting": "Troubleshooting",
"updates": "Updates"
},
"templates": {
"delete_confirm": "Permanently delete \"{name}\"?",
@@ -1227,9 +1246,7 @@
},
"themes": {
"default_description": "The default light and dark palettes.",
"default_name": "Bulwark",
"mobile_description": "Pick a colour theme. Themes follow the light/dark setting from Appearance; custom theme packages are managed in the webmail.",
"title": "Themes"
"mobile_description": "Pick a colour theme. Themes follow the light/dark setting from Appearance; custom theme packages are managed in the webmail."
},
"troubleshooting": {
"cache_desc": "When a list looks stale or wrong.",
@@ -1238,6 +1255,14 @@
"title": "Troubleshooting"
},
"vacation": {
"date_range": {
"description": "Optionally limit the auto-reply to a specific period",
"title": "Date Range"
},
"message": {
"description": "The message that will be sent as a reply",
"title": "Auto-Reply Message"
},
"warnings": {
"date_format": "Dates must be \"YYYY-MM-DD\" or \"YYYY-MM-DD HH:MM\""
}
+6 -6
View File
@@ -410,7 +410,11 @@
"unsupported": "No hay vista previa para este tipo de archivo.",
"media_unsupported": "Este archivo no se puede reproducir aquí.",
"pdf_protected": "Este PDF está protegido con contraseña. Ábrelo en otra aplicación para introducirla.",
"pdf_unreadable": "Este PDF no se puede mostrar aquí."
"pdf_unreadable": "Este PDF no se puede mostrar aquí.",
"archive_files": "{count, plural, one {# archivo} other {# archivos}}",
"archive_first_files": "Los primeros {count} archivos",
"archive_protected": "Este archivo comprimido está protegido con contraseña. Ábrelo en otra aplicación para introducirla.",
"archive_unreadable": "Este archivo comprimido no se puede abrir aquí."
},
"received_via": "para {name}",
"remove_color": "Eliminar etiqueta",
@@ -441,7 +445,6 @@
"open_error": "No se pudo abrir el archivo",
"open_with": "Abrir con…",
"preview_too_large": "Este archivo es demasiado grande para la vista previa.",
"selected_count": "{count, plural, one {# seleccionado} other {# seleccionados}}",
"share_add_people": "Añadir personas",
"share_empty": "Aún no se ha compartido con nadie",
"share_error": "No se pudo actualizar el uso compartido",
@@ -1098,7 +1101,6 @@
},
"tabs": {
"plugins": "Plugins",
"themes": "Temas",
"updates": "Actualizaciones"
},
"templates": {
@@ -1115,9 +1117,7 @@
},
"themes": {
"default_description": "Las paletas clara y oscura predeterminadas.",
"default_name": "Bulwark",
"mobile_description": "Elige un tema de color. Los temas siguen el ajuste claro/oscuro de Apariencia; los paquetes de temas personalizados se gestionan en el webmail.",
"title": "Temas"
"mobile_description": "Elige un tema de color. Los temas siguen el ajuste claro/oscuro de Apariencia; los paquetes de temas personalizados se gestionan en el webmail."
},
"vacation": {
"warnings": {
+6 -6
View File
@@ -410,7 +410,11 @@
"unsupported": "برای این نوع فایل پیش‌نمایشی وجود ندارد.",
"media_unsupported": "این فایل اینجا پخش نمی‌شود.",
"pdf_protected": "این PDF با رمز عبور محافظت شده است. برای وارد کردن رمز، آن را در برنامهٔ دیگری باز کنید.",
"pdf_unreadable": "این PDF اینجا نمایش داده نمی‌شود."
"pdf_unreadable": "این PDF اینجا نمایش داده نمی‌شود.",
"archive_files": "{count, plural, one {# فایل} other {# فایل}}",
"archive_first_files": "{count} فایل نخست",
"archive_protected": "این بایگانی با رمز عبور محافظت شده است. برای وارد کردن رمز، آن را در برنامهٔ دیگری باز کنید.",
"archive_unreadable": "این بایگانی اینجا باز نمی‌شود."
},
"received_via": "به {name}",
"remove_color": "حذف برچسب",
@@ -441,7 +445,6 @@
"open_error": "باز کردن فایل ممکن نشد",
"open_with": "باز کردن با…",
"preview_too_large": "این فایل برای پیش‌نمایش بیش از حد بزرگ است.",
"selected_count": "{count, plural, one {# انتخاب شده} other {# انتخاب شده}}",
"share_add_people": "افزودن افراد",
"share_empty": "هنوز با کسی به اشتراک گذاشته نشده",
"share_error": "به‌روزرسانی اشتراک‌گذاری ناموفق بود",
@@ -1098,7 +1101,6 @@
},
"tabs": {
"plugins": "افزونه‌ها",
"themes": "تم‌ها",
"updates": "به‌روزرسانی‌ها"
},
"templates": {
@@ -1115,9 +1117,7 @@
},
"themes": {
"default_description": "پالت‌های روشن و تاریک پیش‌فرض.",
"default_name": "Bulwark",
"mobile_description": "یک تم رنگی انتخاب کنید. تم‌ها از تنظیم روشن/تاریک بخش ظاهر پیروی می‌کنند؛ بسته‌های تم سفارشی در ایمیل تحت وب مدیریت می‌شوند.",
"title": "تم‌ها"
"mobile_description": "یک تم رنگی انتخاب کنید. تم‌ها از تنظیم روشن/تاریک بخش ظاهر پیروی می‌کنند؛ بسته‌های تم سفارشی در ایمیل تحت وب مدیریت می‌شوند."
},
"vacation": {
"warnings": {
+6 -6
View File
@@ -410,7 +410,11 @@
"unsupported": "Aucun aperçu pour ce type de fichier.",
"media_unsupported": "Ce fichier ne peut pas être lu ici.",
"pdf_protected": "Ce PDF est protégé par un mot de passe. Ouvrez-le dans une autre application pour le saisir.",
"pdf_unreadable": "Ce PDF ne peut pas être affiché ici."
"pdf_unreadable": "Ce PDF ne peut pas être affiché ici.",
"archive_files": "{count, plural, one {# fichier} other {# fichiers}}",
"archive_first_files": "Les {count} premiers fichiers",
"archive_protected": "Cette archive est protégée par un mot de passe. Ouvrez-la dans une autre application pour le saisir.",
"archive_unreadable": "Cette archive ne peut pas être ouverte ici."
},
"received_via": "à {name}",
"remove_color": "Retirer l'étiquette",
@@ -441,7 +445,6 @@
"open_error": "Impossible d'ouvrir le fichier",
"open_with": "Ouvrir avec…",
"preview_too_large": "Ce fichier est trop volumineux pour être prévisualisé.",
"selected_count": "{count, plural, one {# sélectionné} other {# sélectionnés}}",
"share_add_people": "Ajouter des personnes",
"share_empty": "Pas encore partagé",
"share_error": "Échec de la mise à jour du partage",
@@ -1098,7 +1101,6 @@
},
"tabs": {
"plugins": "Plugins",
"themes": "Thèmes",
"updates": "Mises à jour"
},
"templates": {
@@ -1115,9 +1117,7 @@
},
"themes": {
"default_description": "Les palettes claire et sombre par défaut.",
"default_name": "Bulwark",
"mobile_description": "Choisissez un thème de couleurs. Les thèmes suivent le réglage clair/sombre de l'Apparence. Les packs de thèmes personnalisés se gèrent dans le webmail.",
"title": "Thèmes"
"mobile_description": "Choisissez un thème de couleurs. Les thèmes suivent le réglage clair/sombre de l'Apparence. Les packs de thèmes personnalisés se gèrent dans le webmail."
},
"vacation": {
"warnings": {
+6 -6
View File
@@ -410,7 +410,11 @@
"unsupported": "אין תצוגה מקדימה לסוג קובץ זה.",
"media_unsupported": "לא ניתן להפעיל את הקובץ הזה כאן.",
"pdf_protected": "קובץ ה-PDF הזה מוגן בסיסמה. פתחו אותו באפליקציה אחרת כדי להזין את הסיסמה.",
"pdf_unreadable": "לא ניתן להציג את קובץ ה-PDF הזה כאן."
"pdf_unreadable": "לא ניתן להציג את קובץ ה-PDF הזה כאן.",
"archive_files": "{count, plural, one {קובץ אחד} two {שני קבצים} other {# קבצים}}",
"archive_first_files": "{count} הקבצים הראשונים",
"archive_protected": "הארכיון הזה מוגן בסיסמה. פתחו אותו באפליקציה אחרת כדי להזין את הסיסמה.",
"archive_unreadable": "לא ניתן לפתוח את הארכיון הזה כאן."
},
"received_via": "אל {name}",
"remove_color": "הסר תג",
@@ -441,7 +445,6 @@
"open_error": "לא ניתן היה לפתוח את הקובץ",
"open_with": "פתח באמצעות…",
"preview_too_large": "הקובץ גדול מכדי להציג תצוגה מקדימה.",
"selected_count": "{count, plural, one {נבחר פריט אחד} two {נבחרו שני פריטים} other {נבחרו # פריטים}}",
"share_add_people": "הוסף אנשים",
"share_empty": "עדיין לא משותף עם אף אחד",
"share_error": "עדכון השיתוף נכשל",
@@ -1098,7 +1101,6 @@
},
"tabs": {
"plugins": "תוספים",
"themes": "ערכות נושא",
"updates": "עדכונים"
},
"templates": {
@@ -1115,9 +1117,7 @@
},
"themes": {
"default_description": "ערכות הצבעים הבהירה והכהה המוגדרות כברירת מחדל.",
"default_name": "Bulwark",
"mobile_description": "בחר ערכת צבעים. ערכות הנושא פועלות לפי הגדרת בהיר/כהה במראה; חבילות ערכות נושא מותאמות מנוהלות בדואר האינטרנט.",
"title": "ערכות נושא"
"mobile_description": "בחר ערכת צבעים. ערכות הנושא פועלות לפי הגדרת בהיר/כהה במראה; חבילות ערכות נושא מותאמות מנוהלות בדואר האינטרנט."
},
"vacation": {
"warnings": {
+6 -6
View File
@@ -410,7 +410,11 @@
"unsupported": "Ehhez a fájltípushoz nincs előnézet.",
"media_unsupported": "Ez a fájl itt nem játszható le.",
"pdf_protected": "Ez a PDF jelszóval védett. Nyisd meg egy másik alkalmazásban a jelszó megadásához.",
"pdf_unreadable": "Ez a PDF itt nem jeleníthető meg."
"pdf_unreadable": "Ez a PDF itt nem jeleníthető meg.",
"archive_files": "{count, plural, one {# fájl} other {# fájl}}",
"archive_first_files": "Az első {count} fájl",
"archive_protected": "Ez az archívum jelszóval védett. Nyisd meg egy másik alkalmazásban a jelszó megadásához.",
"archive_unreadable": "Ez az archívum itt nem nyitható meg."
},
"received_via": "címzett: {name}",
"remove_color": "Címke eltávolítása",
@@ -441,7 +445,6 @@
"open_error": "Nem sikerült megnyitni a fájlt",
"open_with": "Megnyitás ezzel…",
"preview_too_large": "Ez a fájl túl nagy az előnézethez.",
"selected_count": "{count, plural, one {# kijelölve} other {# kijelölve}}",
"share_add_people": "Személyek hozzáadása",
"share_empty": "Még nincs megosztva senkivel",
"share_error": "Nem sikerült frissíteni a megosztást",
@@ -1098,7 +1101,6 @@
},
"tabs": {
"plugins": "Bővítmények",
"themes": "Témák",
"updates": "Frissítések"
},
"templates": {
@@ -1115,9 +1117,7 @@
},
"themes": {
"default_description": "Az alapértelmezett világos és sötét paletták.",
"default_name": "Bulwark",
"mobile_description": "Válassz színtémát. A témák a Megjelenés világos/sötét beállítását követik; az egyéni témacsomagokat a webmailben kezelheted.",
"title": "Témák"
"mobile_description": "Válassz színtémát. A témák a Megjelenés világos/sötét beállítását követik; az egyéni témacsomagokat a webmailben kezelheted."
},
"vacation": {
"warnings": {
+6 -6
View File
@@ -410,7 +410,11 @@
"unsupported": "Nessuna anteprima per questo tipo di file.",
"media_unsupported": "Questo file non può essere riprodotto qui.",
"pdf_protected": "Questo PDF è protetto da password. Aprilo in un'altra app per inserirla.",
"pdf_unreadable": "Questo PDF non può essere visualizzato qui."
"pdf_unreadable": "Questo PDF non può essere visualizzato qui.",
"archive_files": "{count, plural, one {# file} other {# file}}",
"archive_first_files": "I primi {count} file",
"archive_protected": "Questo archivio è protetto da password. Aprilo in un'altra app per inserirla.",
"archive_unreadable": "Questo archivio non può essere aperto qui."
},
"received_via": "a {name}",
"remove_color": "Rimuovi etichetta",
@@ -441,7 +445,6 @@
"open_error": "Impossibile aprire il file",
"open_with": "Apri con…",
"preview_too_large": "Questo file è troppo grande per l'anteprima.",
"selected_count": "{count, plural, one {# selezionato} other {# selezionati}}",
"share_add_people": "Aggiungi persone",
"share_empty": "Non ancora condiviso con nessuno",
"share_error": "Impossibile aggiornare la condivisione",
@@ -1098,7 +1101,6 @@
},
"tabs": {
"plugins": "Plugin",
"themes": "Temi",
"updates": "Aggiornamenti"
},
"templates": {
@@ -1115,9 +1117,7 @@
},
"themes": {
"default_description": "Le palette chiara e scura predefinite.",
"default_name": "Bulwark",
"mobile_description": "Scegli un tema di colori. I temi seguono l'impostazione chiaro/scuro di Aspetto; i pacchetti di temi personalizzati si gestiscono nella webmail.",
"title": "Temi"
"mobile_description": "Scegli un tema di colori. I temi seguono l'impostazione chiaro/scuro di Aspetto; i pacchetti di temi personalizzati si gestiscono nella webmail."
},
"vacation": {
"warnings": {
+6 -6
View File
@@ -410,7 +410,11 @@
"unsupported": "このファイル形式はプレビューできません。",
"media_unsupported": "このファイルはここでは再生できません。",
"pdf_protected": "このPDFはパスワードで保護されています。パスワードを入力するには別のアプリで開いてください。",
"pdf_unreadable": "このPDFはここでは表示できません。"
"pdf_unreadable": "このPDFはここでは表示できません。",
"archive_files": "{count, plural, other {# 個のファイル}}",
"archive_first_files": "最初の {count} 個のファイル",
"archive_protected": "このアーカイブはパスワードで保護されています。パスワードを入力するには別のアプリで開いてください。",
"archive_unreadable": "このアーカイブはここでは開けません。"
},
"received_via": "{name}宛",
"remove_color": "ラベルを削除",
@@ -441,7 +445,6 @@
"open_error": "ファイルを開けませんでした",
"open_with": "別のアプリで開く…",
"preview_too_large": "このファイルは大きすぎるため、プレビューできません。",
"selected_count": "{count, plural, other {#件選択中}}",
"share_add_people": "ユーザーを追加",
"share_empty": "まだ誰にも共有されていません",
"share_error": "共有の更新に失敗しました",
@@ -1098,7 +1101,6 @@
},
"tabs": {
"plugins": "プラグイン",
"themes": "テーマ",
"updates": "アップデート"
},
"templates": {
@@ -1115,9 +1117,7 @@
},
"themes": {
"default_description": "デフォルトのライト/ダークパレットです。",
"default_name": "Bulwark",
"mobile_description": "カラーテーマを選択します。テーマは「外観」のライト/ダーク設定に従います。カスタムテーマパッケージはウェブメールで管理します。",
"title": "テーマ"
"mobile_description": "カラーテーマを選択します。テーマは「外観」のライト/ダーク設定に従います。カスタムテーマパッケージはウェブメールで管理します。"
},
"vacation": {
"warnings": {
+6 -6
View File
@@ -410,7 +410,11 @@
"unsupported": "이 파일 형식은 미리보기를 지원하지 않아요.",
"media_unsupported": "이 파일은 여기서 재생할 수 없습니다.",
"pdf_protected": "이 PDF는 암호로 보호되어 있습니다. 암호를 입력하려면 다른 앱에서 여세요.",
"pdf_unreadable": "이 PDF는 여기서 표시할 수 없습니다."
"pdf_unreadable": "이 PDF는 여기서 표시할 수 없습니다.",
"archive_files": "{count, plural, other {파일 #개}}",
"archive_first_files": "처음 {count}개 파일",
"archive_protected": "이 압축 파일은 암호로 보호되어 있습니다. 암호를 입력하려면 다른 앱에서 여세요.",
"archive_unreadable": "이 압축 파일은 여기서 열 수 없습니다."
},
"received_via": "받는 사람: {name}",
"remove_color": "태그 제거",
@@ -441,7 +445,6 @@
"open_error": "파일을 열 수 없어요",
"open_with": "다른 앱으로 열기…",
"preview_too_large": "파일이 너무 커서 미리볼 수 없어요.",
"selected_count": "{count, plural, other {#개 선택됨}}",
"share_add_people": "사용자 추가",
"share_empty": "아직 아무와도 공유하지 않았어요",
"share_error": "공유 업데이트에 실패했어요",
@@ -1098,7 +1101,6 @@
},
"tabs": {
"plugins": "플러그인",
"themes": "테마",
"updates": "업데이트"
},
"templates": {
@@ -1115,9 +1117,7 @@
},
"themes": {
"default_description": "기본 라이트 및 다크 팔레트예요.",
"default_name": "Bulwark",
"mobile_description": "색상 테마를 선택하세요. 테마는 화면 설정의 라이트/다크 설정을 따르며, 사용자 지정 테마 패키지는 웹메일에서 관리해요.",
"title": "테마"
"mobile_description": "색상 테마를 선택하세요. 테마는 화면 설정의 라이트/다크 설정을 따르며, 사용자 지정 테마 패키지는 웹메일에서 관리해요."
},
"vacation": {
"warnings": {
+6 -6
View File
@@ -410,7 +410,11 @@
"unsupported": "Šim faila tipam nav priekšskatījuma.",
"media_unsupported": "Šo failu šeit nevar atskaņot.",
"pdf_protected": "Šis PDF ir aizsargāts ar paroli. Atveriet to citā lietotnē, lai ievadītu paroli.",
"pdf_unreadable": "Šo PDF šeit nevar parādīt."
"pdf_unreadable": "Šo PDF šeit nevar parādīt.",
"archive_files": "{count, plural, zero {# failu} one {# fails} other {# faili}}",
"archive_first_files": "Pirmie {count} faili",
"archive_protected": "Šis arhīvs ir aizsargāts ar paroli. Atveriet to citā lietotnē, lai ievadītu paroli.",
"archive_unreadable": "Šo arhīvu šeit nevar atvērt."
},
"received_via": "kam: {name}",
"remove_color": "Noņemt tagu",
@@ -441,7 +445,6 @@
"open_error": "Neizdevās atvērt failu",
"open_with": "Atvērt ar…",
"preview_too_large": "Šis fails ir pārāk liels priekšskatīšanai.",
"selected_count": "{count, plural, zero {Atlasīti #} one {Atlasīts #} other {Atlasīti #}}",
"share_add_people": "Pievienot personas",
"share_empty": "Vēl nav kopīgots ne ar vienu",
"share_error": "Neizdevās atjaunināt kopīgošanu",
@@ -1098,7 +1101,6 @@
},
"tabs": {
"plugins": "Spraudņi",
"themes": "Tēmas",
"updates": "Atjauninājumi"
},
"templates": {
@@ -1115,9 +1117,7 @@
},
"themes": {
"default_description": "Noklusējuma gaišās un tumšās paletes.",
"default_name": "Bulwark",
"mobile_description": "Izvēlieties krāsu tēmu. Tēmas seko gaišā/tumšā režīma iestatījumam sadaļā Izskats; pielāgotas tēmu pakotnes tiek pārvaldītas tīmekļa pastā.",
"title": "Tēmas"
"mobile_description": "Izvēlieties krāsu tēmu. Tēmas seko gaišā/tumšā režīma iestatījumam sadaļā Izskats; pielāgotas tēmu pakotnes tiek pārvaldītas tīmekļa pastā."
},
"vacation": {
"warnings": {
+6 -6
View File
@@ -410,7 +410,11 @@
"unsupported": "Энэ төрлийн файлыг урьдчилан харах боломжгүй.",
"media_unsupported": "Энэ файлыг энд тоглуулах боломжгүй.",
"pdf_protected": "Энэ PDF нууц үгээр хамгаалагдсан. Нууц үг оруулахын тулд өөр апп-аар нээнэ үү.",
"pdf_unreadable": "Энэ PDF-ийг энд харуулах боломжгүй."
"pdf_unreadable": "Энэ PDF-ийг энд харуулах боломжгүй.",
"archive_files": "{count, plural, one {# файл} other {# файл}}",
"archive_first_files": "Эхний {count} файл",
"archive_protected": "Энэ архив нууц үгээр хамгаалагдсан. Нууц үг оруулахын тулд өөр апп-аар нээнэ үү.",
"archive_unreadable": "Энэ архивыг энд нээх боломжгүй."
},
"received_via": "Хэнд: {name}",
"remove_color": "Тагийг хасах",
@@ -441,7 +445,6 @@
"open_error": "Файлыг нээж чадсангүй",
"open_with": "Өөр аппаар нээх…",
"preview_too_large": "Энэ файл урьдчилан харахад хэт том байна.",
"selected_count": "{count, plural, one {Сонгосон: #} other {Сонгосон: #}}",
"share_add_people": "Хүн нэмэх",
"share_empty": "Одоохондоо хэнтэй ч хуваалцаагүй",
"share_error": "Хуваалцалтыг шинэчилж чадсангүй",
@@ -1098,7 +1101,6 @@
},
"tabs": {
"plugins": "Плагинууд",
"themes": "Өнгөний загварууд",
"updates": "Шинэчлэлт"
},
"templates": {
@@ -1115,9 +1117,7 @@
},
"themes": {
"default_description": "Өгөгдмөл цайвар ба харанхуй палитр.",
"default_name": "Bulwark",
"mobile_description": "Өнгөний загвар сонгоно уу. Өнгөний загварууд Гадаад төрх хэсгийн цайвар/харанхуй тохиргоог дагана; өөрийн загварын багцуудыг вэбмэйлд удирдана.",
"title": "Өнгөний загварууд"
"mobile_description": "Өнгөний загвар сонгоно уу. Өнгөний загварууд Гадаад төрх хэсгийн цайвар/харанхуй тохиргоог дагана; өөрийн загварын багцуудыг вэбмэйлд удирдана."
},
"vacation": {
"warnings": {
+6 -6
View File
@@ -410,7 +410,11 @@
"unsupported": "Ingen forhåndsvisning for denne filtypen.",
"media_unsupported": "Denne filen kan ikke spilles av her.",
"pdf_protected": "Denne PDF-en er passordbeskyttet. Åpne den i en annen app for å skrive inn passordet.",
"pdf_unreadable": "Denne PDF-en kan ikke vises her."
"pdf_unreadable": "Denne PDF-en kan ikke vises her.",
"archive_files": "{count, plural, one {# fil} other {# filer}}",
"archive_first_files": "De første {count} filene",
"archive_protected": "Dette arkivet er passordbeskyttet. Åpne det i en annen app for å skrive inn passordet.",
"archive_unreadable": "Dette arkivet kan ikke åpnes her."
},
"received_via": "til {name}",
"remove_color": "Fjern etiketten",
@@ -441,7 +445,6 @@
"open_error": "Kunne ikke åpne filen",
"open_with": "Åpne med…",
"preview_too_large": "Denne filen er for stor til å forhåndsvises.",
"selected_count": "{count, plural, one {# valgt} other {# valgte}}",
"share_add_people": "Legg til personer",
"share_empty": "Ikke delt med noen ennå",
"share_error": "Kunne ikke oppdatere deling",
@@ -1098,7 +1101,6 @@
},
"tabs": {
"plugins": "Programtillegg",
"themes": "Temaer",
"updates": "Oppdateringer"
},
"templates": {
@@ -1115,9 +1117,7 @@
},
"themes": {
"default_description": "Standardpalettene for lyst og mørkt.",
"default_name": "Bulwark",
"mobile_description": "Velg et fargetema. Temaene følger innstillingen for lyst/mørkt under Utseende; egendefinerte temapakker administreres i nettposten.",
"title": "Temaer"
"mobile_description": "Velg et fargetema. Temaene følger innstillingen for lyst/mørkt under Utseende; egendefinerte temapakker administreres i nettposten."
},
"vacation": {
"warnings": {
+6 -6
View File
@@ -410,7 +410,11 @@
"unsupported": "Geen voorbeeld voor dit bestandstype.",
"media_unsupported": "Dit bestand kan hier niet worden afgespeeld.",
"pdf_protected": "Deze pdf is beveiligd met een wachtwoord. Open hem in een andere app om het wachtwoord in te voeren.",
"pdf_unreadable": "Deze pdf kan hier niet worden weergegeven."
"pdf_unreadable": "Deze pdf kan hier niet worden weergegeven.",
"archive_files": "{count, plural, one {# bestand} other {# bestanden}}",
"archive_first_files": "De eerste {count} bestanden",
"archive_protected": "Dit archief is beveiligd met een wachtwoord. Open het in een andere app om het wachtwoord in te voeren.",
"archive_unreadable": "Dit archief kan hier niet worden geopend."
},
"received_via": "aan {name}",
"remove_color": "Label verwijderen",
@@ -441,7 +445,6 @@
"open_error": "Kon het bestand niet openen",
"open_with": "Openen met…",
"preview_too_large": "Dit bestand is te groot voor een voorbeeld.",
"selected_count": "{count, plural, one {# geselecteerd} other {# geselecteerd}}",
"share_add_people": "Personen toevoegen",
"share_empty": "Nog met niemand gedeeld",
"share_error": "Delen kon niet worden bijgewerkt",
@@ -1098,7 +1101,6 @@
},
"tabs": {
"plugins": "Plug-ins",
"themes": "Thema's",
"updates": "Updates"
},
"templates": {
@@ -1115,9 +1117,7 @@
},
"themes": {
"default_description": "De standaard lichte en donkere paletten.",
"default_name": "Bulwark",
"mobile_description": "Kies een kleurthema. Thema's volgen de instelling licht/donker onder Uiterlijk; aangepaste themapakketten worden beheerd in de webmail.",
"title": "Thema's"
"mobile_description": "Kies een kleurthema. Thema's volgen de instelling licht/donker onder Uiterlijk; aangepaste themapakketten worden beheerd in de webmail."
},
"vacation": {
"warnings": {
+6 -6
View File
@@ -410,7 +410,11 @@
"unsupported": "Brak podglądu dla tego typu pliku.",
"media_unsupported": "Tego pliku nie można tutaj odtworzyć.",
"pdf_protected": "Ten plik PDF jest chroniony hasłem. Otwórz go w innej aplikacji, aby wpisać hasło.",
"pdf_unreadable": "Tego pliku PDF nie można tutaj wyświetlić."
"pdf_unreadable": "Tego pliku PDF nie można tutaj wyświetlić.",
"archive_files": "{count, plural, one {# plik} few {# pliki} many {# plików} other {# pliku}}",
"archive_first_files": "Pierwsze {count} plików",
"archive_protected": "To archiwum jest chronione hasłem. Otwórz je w innej aplikacji, aby wpisać hasło.",
"archive_unreadable": "Tego archiwum nie można tutaj otworzyć."
},
"received_via": "do {name}",
"remove_color": "Usuń etykietę",
@@ -441,7 +445,6 @@
"open_error": "Nie udało się otworzyć pliku",
"open_with": "Otwórz za pomocą…",
"preview_too_large": "Ten plik jest zbyt duży, aby wyświetlić podgląd.",
"selected_count": "{count, plural, one {# zaznaczony} few {# zaznaczone} many {# zaznaczonych} other {# zaznaczonego}}",
"share_add_people": "Dodaj osoby",
"share_empty": "Jeszcze nikomu nie udostępniono",
"share_error": "Nie udało się zaktualizować udostępniania",
@@ -1098,7 +1101,6 @@
},
"tabs": {
"plugins": "Wtyczki",
"themes": "Motywy",
"updates": "Aktualizacje"
},
"templates": {
@@ -1115,9 +1117,7 @@
},
"themes": {
"default_description": "Domyślne jasne i ciemne palety kolorów.",
"default_name": "Bulwark",
"mobile_description": "Wybierz motyw kolorystyczny. Motywy stosują ustawienie jasny/ciemny z sekcji Wygląd; własnymi pakietami motywów zarządza się w webmailu.",
"title": "Motywy"
"mobile_description": "Wybierz motyw kolorystyczny. Motywy stosują ustawienie jasny/ciemny z sekcji Wygląd; własnymi pakietami motywów zarządza się w webmailu."
},
"vacation": {
"warnings": {
+6 -6
View File
@@ -410,7 +410,11 @@
"unsupported": "Sem pré-visualização para este tipo de arquivo.",
"media_unsupported": "Este ficheiro não pode ser reproduzido aqui.",
"pdf_protected": "Este PDF está protegido por palavra-passe. Abra-o noutra aplicação para a introduzir.",
"pdf_unreadable": "Este PDF não pode ser mostrado aqui."
"pdf_unreadable": "Este PDF não pode ser mostrado aqui.",
"archive_files": "{count, plural, one {# ficheiro} other {# ficheiros}}",
"archive_first_files": "Os primeiros {count} ficheiros",
"archive_protected": "Este arquivo está protegido por palavra-passe. Abra-o noutra aplicação para a introduzir.",
"archive_unreadable": "Este arquivo não pode ser aberto aqui."
},
"received_via": "para {name}",
"remove_color": "Remover etiqueta",
@@ -441,7 +445,6 @@
"open_error": "Não foi possível abrir o arquivo",
"open_with": "Abrir com…",
"preview_too_large": "Este arquivo é grande demais para pré-visualizar.",
"selected_count": "{count, plural, one {# selecionado} other {# selecionados}}",
"share_add_people": "Adicionar pessoas",
"share_empty": "Ainda não compartilhado com ninguém",
"share_error": "Falha ao atualizar o compartilhamento",
@@ -1098,7 +1101,6 @@
},
"tabs": {
"plugins": "Plugins",
"themes": "Temas",
"updates": "Atualizações"
},
"templates": {
@@ -1115,9 +1117,7 @@
},
"themes": {
"default_description": "As paletas clara e escura padrão.",
"default_name": "Bulwark",
"mobile_description": "Escolha um tema de cores. Os temas seguem a configuração clara/escura de Aparência; pacotes de temas personalizados são gerenciados no webmail.",
"title": "Temas"
"mobile_description": "Escolha um tema de cores. Os temas seguem a configuração clara/escura de Aparência; pacotes de temas personalizados são gerenciados no webmail."
},
"vacation": {
"warnings": {
+6 -6
View File
@@ -410,7 +410,11 @@
"unsupported": "Nu există previzualizare pentru acest tip de fișier.",
"media_unsupported": "Acest fișier nu poate fi redat aici.",
"pdf_protected": "Acest PDF este protejat cu parolă. Deschide-l în altă aplicație pentru a introduce parola.",
"pdf_unreadable": "Acest PDF nu poate fi afișat aici."
"pdf_unreadable": "Acest PDF nu poate fi afișat aici.",
"archive_files": "{count, plural, one {# fișier} few {# fișiere} other {# de fișiere}}",
"archive_first_files": "Primele {count} fișiere",
"archive_protected": "Această arhivă este protejată cu parolă. Deschide-o în altă aplicație pentru a introduce parola.",
"archive_unreadable": "Această arhivă nu poate fi deschisă aici."
},
"received_via": "către {name}",
"remove_color": "Eliminați eticheta",
@@ -441,7 +445,6 @@
"open_error": "Fișierul nu a putut fi deschis",
"open_with": "Deschideți cu…",
"preview_too_large": "Acest fișier este prea mare pentru previzualizare.",
"selected_count": "{count, plural, one {# element selectat} few {# elemente selectate} other {# de elemente selectate}}",
"share_add_people": "Adăugați persoane",
"share_empty": "Nu a fost partajat încă cu nimeni",
"share_error": "Nu s-a putut actualiza partajarea",
@@ -1098,7 +1101,6 @@
},
"tabs": {
"plugins": "Pluginuri",
"themes": "Teme",
"updates": "Actualizări"
},
"templates": {
@@ -1115,9 +1117,7 @@
},
"themes": {
"default_description": "Paletele implicite, luminoasă și întunecată.",
"default_name": "Bulwark",
"mobile_description": "Alegeți o temă de culori. Temele urmează setarea luminos/întunecat din Aspect; pachetele de teme personalizate se gestionează în webmail.",
"title": "Teme"
"mobile_description": "Alegeți o temă de culori. Temele urmează setarea luminos/întunecat din Aspect; pachetele de teme personalizate se gestionează în webmail."
},
"vacation": {
"warnings": {
+6 -6
View File
@@ -410,7 +410,11 @@
"unsupported": "Для этого типа файлов нет предпросмотра.",
"media_unsupported": "Этот файл нельзя воспроизвести здесь.",
"pdf_protected": "Этот PDF защищён паролем. Откройте его в другом приложении, чтобы ввести пароль.",
"pdf_unreadable": "Этот PDF нельзя показать здесь."
"pdf_unreadable": "Этот PDF нельзя показать здесь.",
"archive_files": "{count, plural, one {# файл} few {# файла} many {# файлов} other {# файла}}",
"archive_first_files": "Первые {count} файлов",
"archive_protected": "Этот архив защищён паролем. Откройте его в другом приложении, чтобы ввести пароль.",
"archive_unreadable": "Этот архив нельзя открыть здесь."
},
"received_via": "на {name}",
"remove_color": "Убрать тег",
@@ -441,7 +445,6 @@
"open_error": "Не удалось открыть файл",
"open_with": "Открыть с помощью…",
"preview_too_large": "Файл слишком большой для предпросмотра.",
"selected_count": "{count, plural, one {Выбрано: #} few {Выбрано: #} many {Выбрано: #} other {Выбрано: #}}",
"share_add_people": "Добавить людей",
"share_empty": "Пока никому не предоставлен доступ",
"share_error": "Не удалось обновить общий доступ",
@@ -1098,7 +1101,6 @@
},
"tabs": {
"plugins": "Плагины",
"themes": "Темы",
"updates": "Обновления"
},
"templates": {
@@ -1115,9 +1117,7 @@
},
"themes": {
"default_description": "Стандартные светлая и тёмная палитры.",
"default_name": "Bulwark",
"mobile_description": "Выберите цветовую тему. Темы учитывают светлый или тёмный режим из раздела «Внешний вид»; собственными пакетами тем можно управлять в веб-почте.",
"title": "Темы"
"mobile_description": "Выберите цветовую тему. Темы учитывают светлый или тёмный режим из раздела «Внешний вид»; собственными пакетами тем можно управлять в веб-почте."
},
"vacation": {
"warnings": {
+6 -6
View File
@@ -410,7 +410,11 @@
"unsupported": "Pre tento typ súboru nie je k dispozícii náhľad.",
"media_unsupported": "Tento súbor tu nie je možné prehrať.",
"pdf_protected": "Toto PDF je chránené heslom. Otvorte ho v inej aplikácii a zadajte heslo.",
"pdf_unreadable": "Toto PDF tu nie je možné zobraziť."
"pdf_unreadable": "Toto PDF tu nie je možné zobraziť.",
"archive_files": "{count, plural, one {# súbor} few {# súbory} many {# súboru} other {# súborov}}",
"archive_first_files": "Prvých {count} súborov",
"archive_protected": "Tento archív je chránený heslom. Otvorte ho v inej aplikácii a zadajte heslo.",
"archive_unreadable": "Tento archív tu nie je možné otvoriť."
},
"received_via": "pre {name}",
"remove_color": "Odstrániť štítok",
@@ -441,7 +445,6 @@
"open_error": "Súbor sa nepodarilo otvoriť",
"open_with": "Otvoriť pomocou…",
"preview_too_large": "Tento súbor je príliš veľký na náhľad.",
"selected_count": "{count, plural, one {# vybraná} few {# vybrané} many {# vybranej} other {# vybraných}}",
"share_add_people": "Pridať osoby",
"share_empty": "Zatiaľ nikomu nezdieľané",
"share_error": "Aktualizácia zdieľania zlyhala",
@@ -1098,7 +1101,6 @@
},
"tabs": {
"plugins": "Pluginy",
"themes": "Témy",
"updates": "Aktualizácie"
},
"templates": {
@@ -1115,9 +1117,7 @@
},
"themes": {
"default_description": "Predvolené svetlé a tmavé palety.",
"default_name": "Bulwark",
"mobile_description": "Vyberte farebnú tému. Témy sa riadia nastavením svetlý/tmavý vo Vzhľade; vlastné balíky tém sa spravujú vo webmaile.",
"title": "Témy"
"mobile_description": "Vyberte farebnú tému. Témy sa riadia nastavením svetlý/tmavý vo Vzhľade; vlastné balíky tém sa spravujú vo webmaile."
},
"vacation": {
"warnings": {
+6 -6
View File
@@ -410,7 +410,11 @@
"unsupported": "Bu dosya türü için önizleme yok.",
"media_unsupported": "Bu dosya burada oynatılamıyor.",
"pdf_protected": "Bu PDF parola korumalı. Parolayı girmek için başka bir uygulamada açın.",
"pdf_unreadable": "Bu PDF burada gösterilemiyor."
"pdf_unreadable": "Bu PDF burada gösterilemiyor.",
"archive_files": "{count, plural, one {# dosya} other {# dosya}}",
"archive_first_files": "İlk {count} dosya",
"archive_protected": "Bu arşiv parola korumalı. Parolayı girmek için başka bir uygulamada açın.",
"archive_unreadable": "Bu arşiv burada açılamıyor."
},
"received_via": "alıcı: {name}",
"remove_color": "Etiketi kaldır",
@@ -441,7 +445,6 @@
"open_error": "Dosya açılamadı",
"open_with": "Birlikte aç…",
"preview_too_large": "Bu dosya, önizleme için çok büyük.",
"selected_count": "{count, plural, one {# seçildi} other {# seçildi}}",
"share_add_people": "Kişi ekle",
"share_empty": "Henüz kimseyle paylaşılmadı",
"share_error": "Paylaşım güncellenemedi",
@@ -1098,7 +1101,6 @@
},
"tabs": {
"plugins": "Eklentiler",
"themes": "Temalar",
"updates": "Güncellemeler"
},
"templates": {
@@ -1115,9 +1117,7 @@
},
"themes": {
"default_description": "Varsayılan açık ve koyu paletler.",
"default_name": "Bulwark",
"mobile_description": "Bir renk teması seçin. Temalar Görünüm'deki açık/koyu ayarını izler; özel tema paketleri webmail'de yönetilir.",
"title": "Temalar"
"mobile_description": "Bir renk teması seçin. Temalar Görünüm'deki açık/koyu ayarını izler; özel tema paketleri webmail'de yönetilir."
},
"vacation": {
"warnings": {
+6 -6
View File
@@ -410,7 +410,11 @@
"unsupported": "Для цього типу файлів немає попереднього перегляду.",
"media_unsupported": "Цей файл не можна відтворити тут.",
"pdf_protected": "Цей PDF захищено паролем. Відкрийте його в іншому застосунку, щоб ввести пароль.",
"pdf_unreadable": "Цей PDF не можна показати тут."
"pdf_unreadable": "Цей PDF не можна показати тут.",
"archive_files": "{count, plural, one {# файл} few {# файли} many {# файлів} other {# файлу}}",
"archive_first_files": "Перші {count} файлів",
"archive_protected": "Цей архів захищено паролем. Відкрийте його в іншому застосунку, щоб ввести пароль.",
"archive_unreadable": "Цей архів не можна відкрити тут."
},
"received_via": "на {name}",
"remove_color": "Прибрати тег",
@@ -441,7 +445,6 @@
"open_error": "Не вдалося відкрити файл",
"open_with": "Відкрити за допомогою…",
"preview_too_large": "Файл завеликий для попереднього перегляду.",
"selected_count": "{count, plural, one {Вибрано: #} few {Вибрано: #} many {Вибрано: #} other {Вибрано: #}}",
"share_add_people": "Додати людей",
"share_empty": "Поки що ні з ким не поділено",
"share_error": "Не вдалося оновити спільний доступ",
@@ -1098,7 +1101,6 @@
},
"tabs": {
"plugins": "Плагіни",
"themes": "Теми",
"updates": "Оновлення"
},
"templates": {
@@ -1115,9 +1117,7 @@
},
"themes": {
"default_description": "Стандартні світла й темна палітри.",
"default_name": "Bulwark",
"mobile_description": "Виберіть кольорову тему. Теми враховують світлий або темний режим із розділу «Зовнішній вигляд»; власними пакетами тем можна керувати у веб-пошті.",
"title": "Теми"
"mobile_description": "Виберіть кольорову тему. Теми враховують світлий або темний режим із розділу «Зовнішній вигляд»; власними пакетами тем можна керувати у веб-пошті."
},
"vacation": {
"warnings": {
+6 -6
View File
@@ -410,7 +410,11 @@
"unsupported": "無法預覽此檔案類型。",
"media_unsupported": "無法在此播放這個檔案。",
"pdf_protected": "此 PDF 受密碼保護。請在其他應用程式中開啟以輸入密碼。",
"pdf_unreadable": "無法在此顯示這個 PDF。"
"pdf_unreadable": "無法在此顯示這個 PDF。",
"archive_files": "{count, plural, other {# 個檔案}}",
"archive_first_files": "前 {count} 個檔案",
"archive_protected": "此壓縮檔受密碼保護。請在其他應用程式中開啟以輸入密碼。",
"archive_unreadable": "無法在此開啟這個壓縮檔。"
},
"received_via": "寄給 {name}",
"remove_color": "移除標籤",
@@ -441,7 +445,6 @@
"open_error": "無法開啟檔案",
"open_with": "以其他應用程式開啟…",
"preview_too_large": "此檔案太大,無法預覽。",
"selected_count": "{count, plural, other {已選取 # 項}}",
"share_add_people": "新增使用者",
"share_empty": "尚未與任何人分享",
"share_error": "無法更新分享設定",
@@ -1098,7 +1101,6 @@
},
"tabs": {
"plugins": "外掛程式",
"themes": "主題",
"updates": "更新"
},
"templates": {
@@ -1115,9 +1117,7 @@
},
"themes": {
"default_description": "預設的淺色與深色配色。",
"default_name": "Bulwark",
"mobile_description": "選擇色彩主題。主題會依照「外觀」中的淺色/深色設定;自訂主題套件請在網頁郵件中管理。",
"title": "主題"
"mobile_description": "選擇色彩主題。主題會依照「外觀」中的淺色/深色設定;自訂主題套件請在網頁郵件中管理。"
},
"vacation": {
"warnings": {
+6 -6
View File
@@ -410,7 +410,11 @@
"unsupported": "无法预览此类文件。",
"media_unsupported": "无法在此播放该文件。",
"pdf_protected": "此 PDF 受密码保护。请在其他应用中打开以输入密码。",
"pdf_unreadable": "无法在此显示该 PDF。"
"pdf_unreadable": "无法在此显示该 PDF。",
"archive_files": "{count, plural, other {# 个文件}}",
"archive_first_files": "前 {count} 个文件",
"archive_protected": "此压缩包受密码保护。请在其他应用中打开以输入密码。",
"archive_unreadable": "无法在此打开该压缩包。"
},
"received_via": "发给 {name}",
"remove_color": "删除标签",
@@ -441,7 +445,6 @@
"open_error": "无法打开文件",
"open_with": "打开方式…",
"preview_too_large": "此文件过大,无法预览。",
"selected_count": "{count, plural, other {已选择 # 项}}",
"share_add_people": "添加用户",
"share_empty": "尚未与任何人共享",
"share_error": "更新共享失败",
@@ -1098,7 +1101,6 @@
},
"tabs": {
"plugins": "插件",
"themes": "主题",
"updates": "更新"
},
"templates": {
@@ -1115,9 +1117,7 @@
},
"themes": {
"default_description": "默认的浅色和深色配色。",
"default_name": "Bulwark",
"mobile_description": "选择颜色主题。主题会跟随“外观”中的浅色/深色设置;自定义主题包需在网页邮箱中管理。",
"title": "主题"
"mobile_description": "选择颜色主题。主题会跟随“外观”中的浅色/深色设置;自定义主题包需在网页邮箱中管理。"
},
"vacation": {
"warnings": {
+4506 -3645
View File
File diff suppressed because it is too large Load Diff
+4506 -3645
View File
File diff suppressed because it is too large Load Diff
+4506 -3645
View File
File diff suppressed because it is too large Load Diff
+4506 -3645
View File
File diff suppressed because it is too large Load Diff
+4506 -3645
View File
File diff suppressed because it is too large Load Diff
+4506 -3645
View File
File diff suppressed because it is too large Load Diff
+4506 -3645
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "bulwark-mobile",
"version": "0.1.81",
"version": "0.1.83",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bulwark-mobile",
"version": "0.1.81",
"version": "0.1.83",
"hasInstallScript": true,
"license": "AGPL-3.0-only",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "bulwark-mobile",
"version": "0.1.81",
"version": "0.1.83",
"license": "AGPL-3.0-only",
"main": "index.ts",
"scripts": {
+1
View File
@@ -37,6 +37,7 @@ const MAP = {
ArrowUpNarrowWide: 'sort-ascending',
Ban: 'ban',
Bell: 'bell',
BellOff: 'bell-off',
BellRing: 'bell-ringing',
Bold: 'bold',
Book: 'book-2',
+76 -3
View File
@@ -9,7 +9,7 @@ import {
Inbox, Send, File as FileIcon, Trash2, Ban, Archive, Star,
Folder, FolderOpen, ChevronDown, ChevronRight, X, Settings, LogOut, Check, Plus,
Clock, Layers, Users, Tag, Mails, MailOpen, StickyNote, AlarmClock, Flag,
CheckCheck, Eraser, FolderPlus, Pencil, AlertTriangle, UserMinus,
CheckCheck, Eraser, FolderPlus, Pencil, AlertTriangle, UserMinus, Bell, BellOff, RotateCcw,
type AppIcon,
} from './icons';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
@@ -40,6 +40,11 @@ import type { Mailbox } from '../api/types';
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
import { confirmSignOut } from '../lib/sign-out-confirm';
import { setPendingSettingsTab } from '../navigation/pending-settings-tab';
import {
isAccountMuted, isMailboxMuted, mailboxOverridesForAccount, resetMailboxMutes, setAccountMuted, setMailboxMuted,
type NotificationMutes,
} from '../lib/notification-mutes';
import { serverSupportsEmailPush } from '../lib/push-notifications';
const CHEVRON_SLOT = 20;
const INDENT_STEP = 12;
@@ -157,6 +162,8 @@ interface SidebarRowProps {
showTotal: boolean;
hasChildren: boolean;
isExpanded: boolean;
/** New mail here does not notify (#78). */
muted?: boolean;
onPress: () => void;
onLongPress?: () => void;
onToggleExpand: () => void;
@@ -165,7 +172,7 @@ interface SidebarRowProps {
function SidebarRow({
icon, label, depth, isSelected, isAccountHeader, unread, total, showTotal,
hasChildren, isExpanded, onPress, onLongPress, onToggleExpand, onPressUnread,
hasChildren, isExpanded, muted, onPress, onLongPress, onToggleExpand, onPressUnread,
}: SidebarRowProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
@@ -215,6 +222,11 @@ function SidebarRow({
>
{label}
</Text>
{muted && (
<View style={styles.rowMuted} accessibilityLabel={t('sidebar.notifications_muted', 'Notifications muted')}>
<BellOff size={12} color={c.textMuted} />
</View>
)}
<RowCounts unread={unread} total={total} showTotal={showTotal} onPressUnread={onPressUnread} />
</Pressable>
);
@@ -334,6 +346,8 @@ export default function SidebarDrawer({ visible, onClose, docked = false }: Side
const showFolderTotalCount = useSettingsStore((s) => s.showFolderTotalCount);
const includeGroupInUnified = useSettingsStore((s) => s.includeGroupInUnified);
const unifiedCrossAccount = useSettingsStore((s) => s.unifiedCrossAccount);
const notificationMutes = useSettingsStore((s) => s.notificationMutes);
const updateSetting = useSettingsStore((s) => s.updateSetting);
const keywordDefs = useKeywordsStore((s) => s.keywords);
const keywordsHydrated = useKeywordsStore((s) => s.hydrated);
const hydrateKeywords = useKeywordsStore((s) => s.hydrate);
@@ -459,6 +473,41 @@ export default function SidebarDrawer({ visible, onClose, docked = false }: Side
id: mb.originalId ?? mb.id,
accountId: mb.isShared ? mb.accountId : undefined,
});
// Notification mutes (#78). Muting only takes effect through the server's
// emailPush filter, so the actions show only where that exists. The
// settings are keyed by the server's ids: a folder of the own account may
// carry no accountId, so the client's stands in.
let primaryJmapAccountId: string | null = null;
try { primaryJmapAccountId = jmapClient.accountId; } catch { primaryJmapAccountId = null; }
const canMute = primaryJmapAccountId !== null && serverSupportsEmailPush();
const muteAccountOf = (accountId?: string | null): string => accountId || primaryJmapAccountId || '';
const saveMutes = (next: NotificationMutes) => updateSetting('notificationMutes', next);
const muteActionsForAccount = (accountId: string): SheetAction[] => {
const muted = isAccountMuted(notificationMutes, accountId);
const overrides = mailboxOverridesForAccount(notificationMutes, accountId);
const actions: SheetAction[] = [{
key: 'mute',
label: muted
? t('mailbox_context_menu.unmute_account_notifications', 'Unmute notifications for this account')
: t('mailbox_context_menu.mute_account_notifications', 'Mute notifications for this account'),
icon: muted ? Bell : BellOff,
onPress: () => saveMutes(setAccountMuted(notificationMutes, accountId, !muted)),
}];
if (overrides.muted.length + overrides.unmuted.length > 0) {
actions.push({
key: 'reset-mutes',
label: t('mailbox_context_menu.reset_folder_notifications', 'Reset folder notification settings'),
icon: RotateCcw,
onPress: () => saveMutes(resetMailboxMutes(notificationMutes, accountId)),
});
}
return actions;
};
const openOwnAccountSheet = () => {
if (!canMute) return;
setSheet({ title: username ?? t('sidebar.folders', 'Folders'), actions: muteActionsForAccount(muteAccountOf()) });
};
const runFolderAction = async (label: string, work: () => Promise<unknown>, affectsCurrent: boolean) => {
setBusy(true);
try {
@@ -499,6 +548,18 @@ export default function SidebarDrawer({ visible, onClose, docked = false }: Side
if (subtree.length > 1) {
actions.push({ key: 'tree', label: t('mailbox_context_menu.mark_folder_tree_read', 'Mark folder & subfolders as read'), icon: CheckCheck, onPress: () => markRead(subtree) });
}
if (canMute) {
const accountId = muteAccountOf(mb.accountId);
const muted = isMailboxMuted(notificationMutes, accountId, ref.id);
actions.push({
key: 'mute',
label: muted
? t('mailbox_context_menu.unmute_notifications', 'Unmute notifications')
: t('mailbox_context_menu.mute_notifications', 'Mute notifications'),
icon: muted ? Bell : BellOff,
onPress: () => saveMutes(setMailboxMuted(notificationMutes, accountId, ref.id, !muted)),
});
}
if (!mb.isShared) {
actions.push({
key: 'all',
@@ -622,6 +683,7 @@ export default function SidebarDrawer({ visible, onClose, docked = false }: Side
onPress: () => markRead(mailboxes.filter((m) => m.isShared && m.accountId === accountId).map((m) => m.id)),
},
];
if (canMute) actions.push(...muteActionsForAccount(accountId));
if (canCreate) {
actions.push({
key: 'new',
@@ -1036,7 +1098,14 @@ export default function SidebarDrawer({ visible, onClose, docked = false }: Side
)}
{/* Folders section */}
<Pressable style={styles.sectionHeader} onPress={() => toggleSection('foldersExpanded', setFoldersExpanded)}>
<Pressable
style={styles.sectionHeader}
onPress={() => toggleSection('foldersExpanded', setFoldersExpanded)}
// The own account's notification mutes (#78), like the webmail's
// menu on this header.
onLongPress={canMute ? openOwnAccountSheet : undefined}
delayLongPress={350}
>
{foldersExpanded ? (
<ChevronDown size={14} color={c.textMuted} />
) : (
@@ -1090,6 +1159,9 @@ export default function SidebarDrawer({ visible, onClose, docked = false }: Side
unread={node.unreadEmails}
total={node.totalEmails}
showTotal={showFolderTotalCount && !node.isAccountNode}
muted={canMute && (node.isAccountNode
? isAccountMuted(notificationMutes, muteAccountOf(node.accountId))
: isMailboxMuted(notificationMutes, muteAccountOf(node.accountId), node.originalId ?? node.id))}
hasChildren={hasChildren}
isExpanded={isExpanded}
onPress={
@@ -1458,6 +1530,7 @@ function makeStyles(c: ThemePalette) {
...typography.bodySemibold,
color: c.text,
},
rowMuted: { marginLeft: spacing.xs },
rowLabelAccount: {
fontSize: 13,
lineHeight: 18,
@@ -1,6 +1,6 @@
import React from 'react';
import {
View, Text, StyleSheet, Modal, Pressable, ScrollView, Image, ActivityIndicator, Platform, PixelRatio,
View, Text, StyleSheet, Modal, Pressable, ScrollView, Image, ActivityIndicator, Platform, PixelRatio, Alert,
useWindowDimensions,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
@@ -10,10 +10,17 @@ import type { Email } from '../../api/types';
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useLocaleStore } from '../../stores/locale-store';
import { useSettingsStore } from '../../stores/settings-store';
import EmailBodyView from '../EmailBodyView';
import { needsNativeImageDecoder, needsWebImageViewer, type PreviewKind } from '../../lib/file-preview';
import {
handoffMimeType, needsNativeImageDecoder, needsWebImageViewer, type PreviewKind,
} from '../../lib/file-preview';
import { pdfPageFileName, renderPdfPage } from '../../lib/pdf-pages';
import type { ZipArchive } from '../../lib/zip-preview';
import { formatFullDateTime } from '../../lib/email-date';
import type { EmlPreview } from './use-body-override';
import { useFilePreview } from './use-file-preview';
import { ArchiveSummary, PreviewPartList, type PreviewPart } from './PreviewPartList';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
export interface PreviewItem {
@@ -30,6 +37,8 @@ export interface PreviewItem {
html?: string;
/** Parsed embedded message for .eml previews. */
eml?: EmlPreview;
/** A ZIP's directory, for archive previews. */
archive?: ZipArchive;
/** Replaces "No preview for this file type." on the fallback screen (e.g. the file is too large). */
notice?: string;
}
@@ -131,9 +140,11 @@ function PdfPagesView({ fileUri, viewerUri, background, onFailed }: {
/**
* In-app preview for images, PDFs (iOS's WebView shows them, Android renders the pages), video
* and audio (the WebView's player), text, Markdown and HTML documents and
* embedded messages. Files that don't decode, and types without a preview,
* offer another app instead. Used by every place that opens a file.
* and audio (the WebView's player), text, Markdown and HTML documents,
* embedded messages and ZIP archives. The files inside an archive or a
* message open in a preview of their own on top. Files that don't decode,
* and types without a preview, offer another app instead. Used by every
* place that opens a file.
*/
export function AttachmentPreviewModal({
item, loading, title, onClose, onOpenExternal, onShare, onDownload, docked = false,
@@ -141,7 +152,45 @@ export function AttachmentPreviewModal({
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
const locale = useLocaleStore((s) => s.locale);
const timeFormat = useSettingsStore((s) => s.timeFormat);
const { width } = useWindowDimensions();
// Files inside this one (archive entries, an .eml's attachments).
const nested = useFilePreview();
const { open: openNested } = nested;
const openPart = React.useCallback(async (part: PreviewPart) => {
try {
const bytes = await part.read();
await openNested({
from: 'bytes',
bytes,
name: part.name,
type: part.type || handoffMimeType(part.name, ''),
size: bytes.byteLength,
});
} catch (e) {
Alert.alert(t('email_viewer.attachment_failed', 'Could not open attachment'), e instanceof Error ? e.message : String(e));
}
}, [openNested, t]);
const archive = item?.kind === 'archive' ? item.archive : undefined;
const archiveParts = React.useMemo<PreviewPart[]>(() => {
if (!archive) return [];
return archive.entries.map((entry) => ({
key: entry.path,
name: entry.name,
folder: entry.folder,
size: entry.size,
read: () => archive.read(entry.path),
}));
}, [archive]);
const emlAttachments = item?.kind === 'eml' ? item.eml?.attachments : undefined;
const emlParts = React.useMemo<PreviewPart[]>(() => (emlAttachments ?? []).map((att, i) => ({
key: `${i}-${att.name}`,
name: att.name,
type: att.type,
size: att.size,
read: async () => att.bytes,
})), [emlAttachments]);
// Which viewers failed for the open file, keyed by its URI so the next one
// starts clean: React Native's Image falls back to the WebView viewer, and
// a WebView that can't decode the file falls back to another app.
@@ -196,10 +245,11 @@ export function AttachmentPreviewModal({
</View>
);
const renderDocument = (header?: React.ReactNode) => (
const renderDocument = (header?: React.ReactNode, footer?: React.ReactNode) => (
<ScrollView style={styles.flex}>
{header}
{docEmail && <EmailBodyView email={docEmail} bodyOverride={{ html: item?.html ?? item?.eml?.html, text: item?.eml?.text }} />}
{footer}
</ScrollView>
);
@@ -303,12 +353,30 @@ export function AttachmentPreviewModal({
<View style={styles.emlHeader}>
<Text style={styles.emlSubject}>{item.eml?.subject || t('email_viewer.no_subject', '(No Subject)')}</Text>
{item.eml?.from ? <Text style={styles.emlMeta}>{item.eml.from}</Text> : null}
{item.eml?.date ? <Text style={styles.emlMeta}>{item.eml.date}</Text> : null}
{item.eml?.date ? <Text style={styles.emlMeta}>{formatFullDateTime(item.eml.date, timeFormat, locale) || item.eml.date}</Text> : null}
</View>,
emlParts.length > 0 ? (
<PreviewPartList
inline
parts={emlParts}
onOpen={openPart}
header={<ArchiveSummary count={emlParts.length} truncated={false} />}
/>
) : null,
);
case 'archive':
if (!item.archive) break;
return (
<PreviewPartList
parts={archiveParts}
onOpen={openPart}
header={<ArchiveSummary count={archiveParts.length} truncated={item.archive.truncated} />}
/>
);
default:
return renderFallback(item.notice ?? t('email_viewer.preview.unsupported', 'No preview for this file type.'));
break;
}
return renderFallback(item.notice ?? t('email_viewer.preview.unsupported', 'No preview for this file type.'));
};
const content = (
@@ -359,6 +427,7 @@ export function AttachmentPreviewModal({
</Pressable>
</View>
{renderBody()}
{nested.modalProps.item || nested.modalProps.loading ? <AttachmentPreviewModal {...nested.modalProps} /> : null}
</SafeAreaView>
);
if (docked) return item || loading ? content : null;
+3 -5
View File
@@ -9,7 +9,7 @@ import { useColors } from '../../theme/colors';
import SenderAvatar from '../SenderAvatar';
import { useSettingsStore } from '../../stores/settings-store';
import { useLocaleStore } from '../../stores/locale-store';
import { useKeywordsStore, keywordToken } from '../../stores/keywords-store';
import { useKeywordsStore } from '../../stores/keywords-store';
import { emailDisplayDate, formatHeaderDate, formatHeaderTime, formatFullDateTime } from '../../lib/email-date';
import {
deriveHeaderInfo, deliveryDeltaMs, formatDelta, isAuthenticationSpoofed, findReceivingIdentity,
@@ -17,6 +17,7 @@ import {
} from '../../lib/email-headers';
import { formatSize } from '../../lib/attachment-display';
import { isSmimeEmail } from '../../lib/smime';
import { resolveEmailTags } from '../../lib/thread-utils';
interface Props {
email: Email;
@@ -121,10 +122,7 @@ export function MessageHeader({ email, identities, headerInfo, onToggleStar, onA
return null;
}, [spoofed, identities, from?.email, email]);
const tags = React.useMemo(
() => keywordDefs.filter((kw) => !!email.keywords?.[keywordToken(kw.id)]),
[keywordDefs, email.keywords],
);
const tags = React.useMemo(() => resolveEmailTags(email.keywords, keywordDefs), [keywordDefs, email.keywords]);
const important = !!email.keywords?.$important;
const answered = !!email.keywords?.$answered;
const forwarded = !!email.keywords?.$forwarded;
+152
View File
@@ -0,0 +1,152 @@
import React from 'react';
import { ActivityIndicator, FlatList, Pressable, StyleSheet, Text, View } from 'react-native';
import {
File as FileIcon, FileArchive, FileAudio, FileImage, FileSpreadsheet, FileText, FileVideo,
} from '../icons';
import { formatSize } from '../../lib/attachment-display';
import { filePreviewKind, handoffMimeType } from '../../lib/file-preview';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useLocaleStore } from '../../stores/locale-store';
/** A file inside a file: an archive entry or an attachment of an .eml. */
export interface PreviewPart {
key: string;
name: string;
/** Folder inside an archive, '' at the top. */
folder?: string;
type?: string;
size?: number;
read: () => Promise<Uint8Array>;
}
function iconFor(part: PreviewPart) {
const type = part.type || handoffMimeType(part.name, '');
if (/\.(xlsx?|ods|csv|tsv|numbers)$/i.test(part.name)) return FileSpreadsheet;
if (/\.(zip|7z|rar|gz|tgz|tar|bz2|xz)$/i.test(part.name)) return FileArchive;
// Platform-independent: the icon names the format, not what plays here.
switch (filePreviewKind({ name: part.name, type }, { platform: 'android' })) {
case 'image': return FileImage;
case 'video': return FileVideo;
case 'audio': return FileAudio;
case 'none': return /^(application\/(msword|vnd\.|rtf)|text\/)/.test(type) ? FileText : FileIcon;
default: return FileText;
}
}
interface Props {
parts: PreviewPart[];
/** Opens a part; the row shows a spinner until the promise settles. */
onOpen: (part: PreviewPart) => Promise<void>;
/** Above the rows (an .eml's body, an archive's summary). */
header?: React.ReactElement;
/** Renders as a plain column inside a parent ScrollView instead of a list of its own. */
inline?: boolean;
}
/**
* The files inside an archive or an embedded message, each opening in the
* file preview like any attachment.
*/
export function PreviewPartList({ parts, onOpen, header, inline = false }: Props) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const [busy, setBusy] = React.useState<string | null>(null);
const open = (part: PreviewPart) => {
if (busy) return;
setBusy(part.key);
onOpen(part).finally(() => setBusy(null));
};
const renderRow = (part: PreviewPart) => {
const Icon = iconFor(part);
return (
<Pressable
key={part.key}
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={() => open(part)}
disabled={!!busy}
accessibilityRole="button"
accessibilityLabel={part.name}
>
<View style={styles.icon}>
{busy === part.key ? <ActivityIndicator size="small" color={c.textMuted} /> : <Icon size={20} color={c.textMuted} />}
</View>
<View style={styles.text}>
<Text style={styles.name} numberOfLines={1}>{part.name}</Text>
{part.folder ? <Text style={styles.folder} numberOfLines={1}>{part.folder}</Text> : null}
</View>
{part.size != null ? <Text style={styles.size}>{formatSize(part.size) || '0 B'}</Text> : null}
</Pressable>
);
};
if (inline) {
return (
<View style={styles.inline}>
{header}
{parts.map(renderRow)}
</View>
);
}
return (
<FlatList
style={styles.list}
data={parts}
keyExtractor={(p) => p.key}
renderItem={({ item }) => renderRow(item)}
ListHeaderComponent={header}
initialNumToRender={30}
/>
);
}
/** The summary line above an archive's entries. */
export function ArchiveSummary({ count, truncated }: { count: number; truncated: boolean }) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
return (
<Text style={styles.summary}>
{truncated
? t('email_viewer.preview.archive_first_files', 'The first {count} files', { count })
: t('email_viewer.preview.archive_files', '{count, plural, one {# file} other {# files}}', { count })}
</Text>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
list: { flex: 1 },
inline: { paddingBottom: spacing.lg },
row: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
minHeight: 52,
},
rowPressed: { backgroundColor: c.surfaceHover },
icon: {
width: 36,
height: 36,
borderRadius: radius.md,
backgroundColor: c.surface,
alignItems: 'center',
justifyContent: 'center',
},
text: { flex: 1, minWidth: 0 },
name: { ...typography.body, color: c.text },
folder: { ...typography.caption, color: c.textMuted },
size: { ...typography.caption, color: c.textMuted },
summary: {
...typography.caption,
color: c.textMuted,
paddingHorizontal: spacing.lg,
paddingTop: spacing.md,
paddingBottom: spacing.xs,
},
});
}
+4 -2
View File
@@ -60,16 +60,18 @@ export interface EmlPreview {
date?: string;
html: string | null;
text: string | null;
/** The message's own attachments (not the images its body embeds). */
attachments: ExtractedAttachment[];
}
/** Parse a message once for the preview: its unwrapped body plus the header lines shown above it. */
export async function emlPreviewFromBytes(bytes: Uint8Array): Promise<EmlPreview> {
const parsed = await parseMessage(bytes);
const { html, text } = unwrapParsed(parsed);
const { html, text, attachments } = unwrapParsed(parsed);
const from = parsed.from && 'address' in parsed.from
? `${parsed.from.name ? `${parsed.from.name} ` : ''}<${parsed.from.address}>`
: parsed.from?.name;
return { subject: parsed.subject, from, date: parsed.date, html, text };
return { subject: parsed.subject, from, date: parsed.date, html, text, attachments };
}
function unwrapParsed(parsed: ParsedEmail): UnwrappedMessage {
+16
View File
@@ -13,6 +13,7 @@ import type { PreviewKind } from '../../lib/file-preview';
import { markdownToHtml } from '../../lib/markdown-html';
import { imageViewerHtml, mediaViewerHtml, pdfViewerHtml, type ViewerColors } from '../../lib/preview-viewer-html';
import { closePdfPages, openPdfPages, PdfOpenError } from '../../lib/pdf-pages';
import { openZip, ZipEncryptedError } from '../../lib/zip-preview';
import { useLocaleStore } from '../../stores/locale-store';
import { useColors, useResolvedTheme } from '../../theme/colors';
import type { PreviewItem } from './AttachmentPreviewModal';
@@ -79,6 +80,8 @@ interface Notices {
tooLarge: string;
pdfProtected: string;
pdfUnreadable: string;
zipProtected: string;
zipUnreadable: string;
}
async function buildItem(
@@ -100,6 +103,12 @@ async function buildItem(
return { ...base, kind, html: await local.text() };
case 'eml':
return { ...base, kind, eml: await emlPreviewFromBytes(await local.bytes()) };
case 'archive':
try {
return { ...base, kind, archive: await openZip(await local.bytes()) };
} catch (e) {
return { ...base, kind: 'none', notice: e instanceof ZipEncryptedError ? notices.zipProtected : notices.zipUnreadable };
}
case 'image':
return { ...base, kind, fileUri: local.uri, viewerUri: writeViewer(local, imageViewerHtml(local.name, colors)) };
case 'video':
@@ -208,8 +217,15 @@ export function useFilePreview(opts: {
'This PDF is password-protected. Open it in another app to enter the password.',
),
pdfUnreadable: t('email_viewer.preview.pdf_unreadable', "This PDF can't be shown here."),
zipProtected: t(
'email_viewer.preview.archive_protected',
'This archive is password-protected. Open it in another app to enter the password.',
),
zipUnreadable: t('email_viewer.preview.archive_unreadable', "This archive can't be opened here."),
}, colors.current);
if (current()) setItem(next);
// Closed while the PDF was opening: end() ran before the document was.
else closePdfPages(local.uri);
} catch (e) {
if (!current()) return;
close();
+2
View File
@@ -29,6 +29,7 @@ const G = {
'ban': ["M3 12a9 9 0 1 0 18 0a9 9 0 1 0 -18 0","M5.7 5.7l12.6 12.6"],
'beach': ["M17.553 16.75a7.5 7.5 0 0 0 -10.606 0","M18 3.804a6 6 0 0 0 -8.196 2.196l10.392 6a6 6 0 0 0 -2.196 -8.196","M16.732 10c1.658 -2.87 2.225 -5.644 1.268 -6.196c-.957 -.552 -3.075 1.326 -4.732 4.196","M15 9l-3 5.196","M3 19.25a2.4 2.4 0 0 1 1 -.25a2.4 2.4 0 0 1 2 1a2.4 2.4 0 0 0 2 1a2.4 2.4 0 0 0 2 -1a2.4 2.4 0 0 1 2 -1a2.4 2.4 0 0 1 2 1a2.4 2.4 0 0 0 2 1a2.4 2.4 0 0 0 2 -1a2.4 2.4 0 0 1 2 -1a2.4 2.4 0 0 1 1 .25"],
'bell': ["M10 5a2 2 0 1 1 4 0a7 7 0 0 1 4 6v3a4 4 0 0 0 2 3h-16a4 4 0 0 0 2 -3v-3a7 7 0 0 1 4 -6","M9 17v1a3 3 0 0 0 6 0v-1"],
'bell-off': ["M9.346 5.353c.21 -.129 .428 -.246 .654 -.353a2 2 0 1 1 4 0a7 7 0 0 1 4 6v3m-1 3h-13a4 4 0 0 0 2 -3v-3a6.996 6.996 0 0 1 1.273 -3.707","M9 17v1a3 3 0 0 0 6 0v-1","M3 3l18 18"],
'bell-ringing': ["M10 5a2 2 0 0 1 4 0a7 7 0 0 1 4 6v3a4 4 0 0 0 2 3h-16a4 4 0 0 0 2 -3v-3a7 7 0 0 1 4 -6","M9 17v1a3 3 0 0 0 6 0v-1","M21 6.727a11.05 11.05 0 0 0 -2.794 -3.727","M3 6.727a11.05 11.05 0 0 1 2.792 -3.727"],
'bold': ["M7 5h6a3.5 3.5 0 0 1 0 7h-6l0 -7","M13 12h1a3.5 3.5 0 0 1 0 7h-7v-7"],
'book': ["M3 19a9 9 0 0 1 9 0a9 9 0 0 1 9 0","M3 6a9 9 0 0 1 9 0a9 9 0 0 1 9 0","M3 6l0 13","M12 6l0 13","M21 6l0 13"],
@@ -190,6 +191,7 @@ export const ArrowUpDown = makeIcon('ArrowUpDown', G['arrows-sort']);
export const ArrowUpNarrowWide = makeIcon('ArrowUpNarrowWide', G['sort-ascending']);
export const Ban = makeIcon('Ban', G['ban']);
export const Bell = makeIcon('Bell', G['bell']);
export const BellOff = makeIcon('BellOff', G['bell-off']);
export const BellRing = makeIcon('BellRing', G['bell-ringing']);
export const Bold = makeIcon('Bold', G['bold']);
export const Book = makeIcon('Book', G['book-2']);
@@ -27,6 +27,7 @@ import {
PushSetupError,
readPushAccountIds,
revokePushDevice,
serverSupportsEmailPush,
setStoredRelayBaseUrl,
setupPushNotifications,
getStoredPushTransport,
@@ -42,6 +43,8 @@ import {
saveUnifiedPushDistributor,
} from '../../lib/unified-push';
import { canBadgeAppIcon } from '../../lib/app-badge';
import { isAccountMuted, setAccountMuted } from '../../lib/notification-mutes';
import { jmapClient } from '../../api/jmap-client';
type PushStatus =
| { kind: 'idle' }
@@ -78,6 +81,14 @@ export function NotificationSettings() {
const hydrate = useSettingsStore((s) => s.hydrate);
const update = useSettingsStore((s) => s.updateSetting);
const emailEnabled = useSettingsStore((s) => s.emailNotificationsEnabled);
const notificationMutes = useSettingsStore((s) => s.notificationMutes);
// Muting works through the server's emailPush filter (#78): offered only
// where it exists, for the account this screen shows. Single folders are
// muted from the sidebar.
let ownJmapAccountId: string | null = null;
try { ownJmapAccountId = jmapClient.accountId; } catch { ownJmapAccountId = null; }
const canMuteAccount = ownJmapAccountId !== null && serverSupportsEmailPush();
const accountMuted = ownJmapAccountId !== null && isAccountMuted(notificationMutes, ownJmapAccountId);
const calEnabled = useSettingsStore((s) => s.calendarNotificationsEnabled);
const invitationParsing = useSettingsStore((s) => s.calendarInvitationParsingEnabled);
const appIconBadge = useSettingsStore((s) => s.appIconUnreadBadge);
@@ -467,6 +478,22 @@ export function NotificationSettings() {
>
<ToggleSwitch checked={emailEnabled} onChange={(v) => update('emailNotificationsEnabled', v)} />
</SettingItem>
{canMuteAccount && (
<SettingItem
label={t('settings.notifications.email.mute_account', 'Mute this account')}
description={t(
'settings.notifications.email.mute_account_desc',
'No alerts for new mail in this account. Long-press a folder in the sidebar to mute or unmute it on its own.',
)}
>
<ToggleSwitch
checked={accountMuted}
onChange={(v) => {
if (ownJmapAccountId) update('notificationMutes', setAccountMuted(notificationMutes, ownJmapAccountId, v));
}}
/>
</SettingItem>
)}
{canBadgeAppIcon() && (
<SettingItem
label={t('settings.notifications.email.app_icon_badge', 'Unread count on app icon')}
+10 -2
View File
@@ -93,8 +93,16 @@ describe('filePreviewKind', () => {
expect(filePreviewKind({ name: 'cover.mp3', type: 'image/jpeg' }, ANDROID)).toBe('image');
});
it('leaves archives, office documents and binaries to other apps', () => {
expect(filePreviewKind({ name: 'archive.zip', type: 'application/zip' }, IOS)).toBe('none');
it('lists the files in a ZIP, but not in the formats built on ZIP', () => {
expect(filePreviewKind({ name: 'archive.zip', type: 'application/zip' }, IOS)).toBe('archive');
expect(filePreviewKind({ name: 'photos', type: 'application/x-zip-compressed' }, ANDROID)).toBe('archive');
expect(filePreviewKind({ name: 'Archive.ZIP', type: 'application/octet-stream' }, ANDROID)).toBe('archive');
expect(filePreviewKind({ name: 'app.apk', type: 'application/vnd.android.package-archive' }, ANDROID)).toBe('none');
});
it('leaves other archives, office documents and binaries to other apps', () => {
expect(filePreviewKind({ name: 'backup.7z', type: 'application/x-7z-compressed' }, IOS)).toBe('none');
expect(filePreviewKind({ name: 'logs.tar.gz', type: 'application/gzip' }, ANDROID)).toBe('none');
expect(filePreviewKind({ name: 'sheet.xlsx', type: 'application/octet-stream' }, IOS)).toBe('none');
expect(filePreviewKind({ name: 'contract.docx', type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' }, ANDROID)).toBe('none');
expect(filePreviewKind({ name: 'data.bin', type: undefined }, IOS)).toBe('none');
@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest';
import {
EMPTY_NOTIFICATION_MUTES,
isMailboxMuted,
mailboxOverridesForAccount,
normalizeNotificationMutes,
resetMailboxMutes,
setAccountMuted,
setMailboxMuted,
} from '../notification-mutes';
describe('notification mutes (#78)', () => {
it('lets a folder follow its account until it has its own setting', () => {
const accountMuted = setAccountMuted(EMPTY_NOTIFICATION_MUTES, 'a', true);
expect(isMailboxMuted(accountMuted, 'a', 'reports')).toBe(true);
expect(isMailboxMuted(accountMuted, 'b', 'reports')).toBe(false);
const unmutedFolder = setMailboxMuted(accountMuted, 'a', 'inbox', false);
expect(isMailboxMuted(unmutedFolder, 'a', 'inbox')).toBe(false);
expect(isMailboxMuted(unmutedFolder, 'a', 'reports')).toBe(true);
// The folder's own setting wins over the account in both directions.
const accountBack = setAccountMuted(unmutedFolder, 'a', false);
const mutedFolder = setMailboxMuted(accountBack, 'a', 'renovate', true);
expect(isMailboxMuted(mutedFolder, 'a', 'renovate')).toBe(true);
expect(isMailboxMuted(mutedFolder, 'a', 'reports')).toBe(false);
});
it('drops the folder setting when it is set back to what the account says', () => {
const muted = setMailboxMuted(EMPTY_NOTIFICATION_MUTES, 'a', 'renovate', true);
const unmuted = setMailboxMuted(muted, 'a', 'renovate', false);
expect(unmuted.mailboxes).toEqual({});
// So muting the account later reaches this folder too.
expect(isMailboxMuted(setAccountMuted(unmuted, 'a', true), 'a', 'renovate')).toBe(true);
});
it('resets the folder settings of one account only', () => {
let mutes = setMailboxMuted(EMPTY_NOTIFICATION_MUTES, 'a', 'x', true);
mutes = setMailboxMuted(mutes, 'ab', 'y', true);
mutes = resetMailboxMutes(mutes, 'a');
expect(mailboxOverridesForAccount(mutes, 'a')).toEqual({ muted: [], unmuted: [] });
// An account id that merely starts with "a" keeps its settings.
expect(mailboxOverridesForAccount(mutes, 'ab')).toEqual({ muted: ['y'], unmuted: [] });
});
it('repairs malformed stored values', () => {
expect(normalizeNotificationMutes(null)).toEqual(EMPTY_NOTIFICATION_MUTES);
expect(normalizeNotificationMutes(['a'])).toEqual(EMPTY_NOTIFICATION_MUTES);
expect(normalizeNotificationMutes({ accounts: ['a', 3, ''], mailboxes: { 'a/x': true, 'a/y': 'yes' } }))
.toEqual({ accounts: ['a'], mailboxes: { 'a/x': true } });
});
});
@@ -19,6 +19,7 @@ vi.mock('../../api/email', () => ({
}));
import { getMailboxes, getSharedMailboxes } from '../../api/email';
import { EMPTY_NOTIFICATION_MUTES, type NotificationMutes } from '../notification-mutes';
import { buildEmailPushConfig } from '../push-notifications';
import { provideLoadedMailboxes } from '../mailbox-source';
import { generateAccountId } from '../account-utils';
@@ -65,3 +66,72 @@ describe('push filter folders', () => {
});
});
});
// Muted accounts and folders (#78) are left out by the server-side filter.
describe('push filter with muted folders and accounts (#78)', () => {
const folders = [
{ id: 'inbox', role: 'inbox', accountId: 'jmap-primary' },
{ id: 'junk', role: 'junk', accountId: 'jmap-primary' },
{ id: 'renovate', accountId: 'jmap-primary' },
{ id: 'team:in', originalId: 'in', role: 'inbox', accountId: 'team', isShared: true },
{ id: 'team:j', originalId: 'j', role: 'junk', accountId: 'team', isShared: true },
];
const mutes = (accounts: string[], mailboxes: Record<string, boolean> = {}): NotificationMutes => ({ accounts, mailboxes });
const junkOnly = (ids: string[]) => ({ operator: 'AND', conditions: [{ notKeyword: '$junk' }, { inMailboxOtherThan: ids }] });
beforeEach(() => {
provideLoadedMailboxes(async () => folders as never);
});
it('changes nothing without mutes', async () => {
const config = await buildEmailPushConfig(EMPTY_NOTIFICATION_MUTES);
expect(config['jmap-primary'].filter).toEqual(junkOnly(['junk']));
expect(config.team.filter).toEqual(junkOnly(['j']));
});
it('adds a muted folder to the excluded mailboxes next to Junk', async () => {
const config = await buildEmailPushConfig(mutes([], { 'jmap-primary/renovate': true, 'jmap-primary/gone': true }));
expect(config['jmap-primary'].filter).toEqual(junkOnly(['junk', 'renovate']));
// Another account's folders are untouched.
expect(config.team.filter).toEqual(junkOnly(['j']));
});
it('silences a muted account entirely', async () => {
const config = await buildEmailPushConfig(mutes(['team']));
expect(config.team.filter).toEqual({
operator: 'AND',
conditions: [{ notKeyword: '$junk' }, { hasKeyword: '$junk' }],
});
expect(config['jmap-primary'].filter).toEqual(junkOnly(['junk']));
});
it('lets a muted account notify for the folders unmuted again', async () => {
const one = await buildEmailPushConfig(mutes(['jmap-primary'], { 'jmap-primary/renovate': false }));
expect(one['jmap-primary'].filter).toEqual({
operator: 'AND',
conditions: [{ notKeyword: '$junk' }, { inMailbox: 'renovate' }],
});
const two = await buildEmailPushConfig(mutes(['jmap-primary'], {
'jmap-primary/renovate': false,
'jmap-primary/inbox': false,
// Gone from the server: must not end up in the filter.
'jmap-primary/deleted': false,
// Unmuting Junk does not bring spam back.
'jmap-primary/junk': false,
}));
expect(two['jmap-primary'].filter).toEqual({
operator: 'AND',
conditions: [
{ notKeyword: '$junk' },
{ operator: 'OR', conditions: [{ inMailbox: 'inbox' }, { inMailbox: 'renovate' }] },
],
});
});
it('fails instead of dropping the mutes when the folders cannot be read', async () => {
provideLoadedMailboxes(async () => null);
vi.mocked(getMailboxes).mockRejectedValueOnce(new Error('JMAP down'));
await expect(buildEmailPushConfig(mutes(['jmap-primary']))).rejects.toThrow(/JMAP down/);
});
});
+10
View File
@@ -4,6 +4,7 @@ import {
expandThreadSelection,
getEmailTagIds,
getThreadTagIds,
resolveEmailTags,
tagIdFromKeyword,
threadKeyOf,
rowKeyOf,
@@ -30,6 +31,15 @@ describe('tag ids', () => {
])).toEqual(['work', 'todo']);
});
it('keeps a tag with no local definition, grey and under its raw id (#79)', () => {
const defs = [{ id: 'work', label: 'Work', color: 'blue' as const }];
expect(resolveEmailTags({ '$label:work': true, '$label:pgp-encrypted': true, $seen: true }, defs)).toEqual([
{ id: 'work', label: 'Work', color: 'blue' },
{ id: 'pgp-encrypted', label: 'pgp-encrypted', color: 'gray' },
]);
expect(resolveEmailTags(undefined, defs)).toEqual([]);
});
it('extracts the tag id from a keyword', () => {
expect(tagIdFromKeyword('$label:work')).toBe('work');
expect(tagIdFromKeyword('$color:work')).toBe('work');
+72
View File
@@ -0,0 +1,72 @@
import { describe, it, expect } from 'vitest';
import JSZip from 'jszip';
import { openZip, isZipArchive, ZipEncryptedError, MAX_ZIP_ENTRIES } from '../zip-preview';
async function makeZip(files: Record<string, string>): Promise<Uint8Array> {
const zip = new JSZip();
for (const [path, text] of Object.entries(files)) zip.file(path, text);
return zip.generateAsync({ type: 'uint8array', compression: 'DEFLATE' });
}
// Set the "encrypted" bit in every local and central header, as a
// password-protected archive has it.
function markEncrypted(bytes: Uint8Array): Uint8Array {
const out = bytes.slice();
const view = new DataView(out.buffer);
for (let i = 0; i + 4 <= out.length; i++) {
const sig = view.getUint32(i, true);
if (sig === 0x04034b50) view.setUint16(i + 6, view.getUint16(i + 6, true) | 1, true);
if (sig === 0x02014b50) view.setUint16(i + 8, view.getUint16(i + 8, true) | 1, true);
}
return out;
}
describe('openZip', () => {
it('lists the files by folder and name, without folders or OS metadata', async () => {
const archive = await openZip(await makeZip({
'b.txt': 'b',
'docs/z.md': '# z',
'a.txt': 'a',
'docs/inner/c.csv': '1,2',
'__MACOSX/docs/._z.md': 'junk',
'.DS_Store': 'junk',
'docs/Thumbs.db': 'junk',
}));
expect(archive.entries.map((e) => e.path)).toEqual(['a.txt', 'b.txt', 'docs/z.md', 'docs/inner/c.csv']);
expect(archive.entries[2]).toMatchObject({ name: 'z.md', folder: 'docs', size: 3 });
expect(archive.truncated).toBe(false);
});
it('reads an entry from the loaded archive', async () => {
const archive = await openZip(await makeZip({ 'docs/notes.txt': 'Grüße' }));
expect(new TextDecoder().decode(await archive.read('docs/notes.txt'))).toBe('Grüße');
await expect(archive.read('missing.txt')).rejects.toThrow(/not in the archive/);
});
it('stops listing at the cap', async () => {
const files: Record<string, string> = {};
for (let i = 0; i < MAX_ZIP_ENTRIES + 5; i++) files[`f${i}.txt`] = '';
const archive = await openZip(await makeZip(files));
expect(archive.entries).toHaveLength(MAX_ZIP_ENTRIES);
expect(archive.truncated).toBe(true);
});
it('says when an archive is password-protected', async () => {
const locked = markEncrypted(await makeZip({ 'secret.txt': 'x' }));
await expect(openZip(locked)).rejects.toBeInstanceOf(ZipEncryptedError);
});
it('rejects data that is not a ZIP', async () => {
await expect(openZip(new TextEncoder().encode('not a zip'))).rejects.not.toBeInstanceOf(ZipEncryptedError);
});
});
describe('isZipArchive', () => {
it('takes ZIPs by type or name, not the formats built on ZIP', () => {
expect(isZipArchive('a.zip', 'application/zip')).toBe(true);
expect(isZipArchive('bundle', 'application/x-zip-compressed')).toBe(true);
expect(isZipArchive('Archive.ZIP', 'application/octet-stream')).toBe(true);
expect(isZipArchive('contract.docx', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document')).toBe(false);
expect(isZipArchive('app.apk', 'application/vnd.android.package-archive')).toBe(false);
});
});
+8 -3
View File
@@ -5,7 +5,7 @@
// everything else goes to the OS "Open with" chooser.
export type PreviewKind =
| 'image' | 'pdf' | 'text' | 'markdown' | 'html' | 'eml' | 'video' | 'audio' | 'none';
| 'image' | 'pdf' | 'text' | 'markdown' | 'html' | 'eml' | 'video' | 'audio' | 'archive' | 'none';
export interface FilePreviewOptions {
/**
@@ -26,14 +26,16 @@ interface PreviewableFile {
* Largest file each preview kind loads. Text is rendered in one selectable
* `<Text>`, which gets slow to lay out past a few hundred KB; HTML and
* Markdown go through the mail sanitiser in JS; parsing an .eml and decoding
* an image hold the whole file in memory. PDFs are paged by their viewer and
* media stream from the downloaded copy. Larger files open in another app.
* an image hold the whole file in memory, and so does listing a ZIP. PDFs
* are paged by their viewer and media stream from the downloaded copy.
* Larger files open in another app.
*/
export const FILE_PREVIEW_MAX_BYTES: Record<Exclude<PreviewKind, 'none'>, number> = {
text: 512 * 1024,
markdown: 1024 * 1024,
html: 5 * 1024 * 1024,
eml: 10 * 1024 * 1024,
archive: 50 * 1024 * 1024,
image: 25 * 1024 * 1024,
pdf: 100 * 1024 * 1024,
audio: 500 * 1024 * 1024,
@@ -94,6 +96,7 @@ const MEDIA: Record<'video' | 'audio', Record<FilePreviewOptions['platform'], {
},
};
const ZIP_MIME = new Set(['application/zip', 'application/x-zip-compressed', 'application/x-zip']);
const HTML_MIME = new Set(['text/html', 'application/xhtml+xml']);
const HTML_EXT = new Set(['html', 'htm', 'xhtml', 'xht']);
const MARKDOWN_MIME = new Set(['text/markdown', 'text/x-markdown']);
@@ -128,6 +131,8 @@ export function filePreviewKind(file: Pick<PreviewableFile, 'name' | 'type'>, op
const media = /^(image|audio|video)\//.test(mime);
if (mime === 'message/rfc822' || ext === 'eml') return 'eml';
// ZIPs list their files; formats built on ZIP (docx, apk) are not ZIPs here.
if (ZIP_MIME.has(mime) || ((!mime || mime.startsWith('application/x-')) && ext === 'zip')) return 'archive';
if (HTML_MIME.has(mime) || HTML_EXT.has(ext)) return 'html';
if (MARKDOWN_MIME.has(mime) || MARKDOWN_EXT.has(ext)) return 'markdown';
+104
View File
@@ -0,0 +1,104 @@
/**
* Which accounts and folders stay silent when new mail arrives (#78,
* webmail #1169). Port of the webmail's lib/notification-mutes.ts - keep in sync.
*
* An account is either muted or not. A folder can follow its account or carry
* its own setting, and its own setting always wins: mute one folder in a
* normal account, or mute a whole account and unmute the folders that still
* matter. Ids are the server's JMAP account and mailbox ids, which is what the
* push filter needs; shared-account mailboxes must pass their originalId.
*/
export interface NotificationMutes {
/** JMAP account ids muted as a whole. */
accounts: string[];
/** `accountId/mailboxId` -> true (muted) or false (unmuted). Missing = follow the account. */
mailboxes: Record<string, boolean>;
}
export const EMPTY_NOTIFICATION_MUTES: NotificationMutes = { accounts: [], mailboxes: {} };
function mailboxKey(accountId: string, mailboxId: string): string {
return `${accountId}/${mailboxId}`;
}
/** Repairs a value from storage or another build into a usable mutes object. */
export function normalizeNotificationMutes(value: unknown): NotificationMutes {
if (!value || typeof value !== 'object' || Array.isArray(value)) return EMPTY_NOTIFICATION_MUTES;
const raw = value as { accounts?: unknown; mailboxes?: unknown };
const accounts = Array.isArray(raw.accounts)
? raw.accounts.filter((id): id is string => typeof id === 'string' && id !== '')
: [];
const mailboxes: Record<string, boolean> = {};
if (raw.mailboxes && typeof raw.mailboxes === 'object' && !Array.isArray(raw.mailboxes)) {
for (const [key, muted] of Object.entries(raw.mailboxes as Record<string, unknown>)) {
if (typeof muted === 'boolean') mailboxes[key] = muted;
}
}
return { accounts, mailboxes };
}
export function isAccountMuted(mutes: NotificationMutes, accountId: string): boolean {
return mutes.accounts.includes(accountId);
}
/** The folder's own setting, or undefined when it follows its account. */
export function mailboxMuteOverride(
mutes: NotificationMutes,
accountId: string,
mailboxId: string,
): boolean | undefined {
return mutes.mailboxes[mailboxKey(accountId, mailboxId)];
}
export function isMailboxMuted(mutes: NotificationMutes, accountId: string, mailboxId: string): boolean {
return mailboxMuteOverride(mutes, accountId, mailboxId) ?? isAccountMuted(mutes, accountId);
}
/** Mailbox ids of one account with their own setting, split by that setting. */
export function mailboxOverridesForAccount(
mutes: NotificationMutes,
accountId: string,
): { muted: string[]; unmuted: string[] } {
const prefix = mailboxKey(accountId, '');
const muted: string[] = [];
const unmuted: string[] = [];
for (const [key, value] of Object.entries(mutes.mailboxes)) {
if (!key.startsWith(prefix)) continue;
(value ? muted : unmuted).push(key.slice(prefix.length));
}
return { muted, unmuted };
}
export function setAccountMuted(mutes: NotificationMutes, accountId: string, muted: boolean): NotificationMutes {
const accounts = mutes.accounts.filter((id) => id !== accountId);
if (muted) accounts.push(accountId);
return { ...mutes, accounts };
}
/**
* Mutes or unmutes one folder. Setting it back to what the account says drops
* the folder's own setting, so a folder muted and unmuted again follows the
* account later instead of staying pinned to "unmuted".
*/
export function setMailboxMuted(
mutes: NotificationMutes,
accountId: string,
mailboxId: string,
muted: boolean,
): NotificationMutes {
const mailboxes = { ...mutes.mailboxes };
const key = mailboxKey(accountId, mailboxId);
if (muted === isAccountMuted(mutes, accountId)) delete mailboxes[key];
else mailboxes[key] = muted;
return { ...mutes, mailboxes };
}
/** Drops every folder setting of one account so all its folders follow the account again. */
export function resetMailboxMutes(mutes: NotificationMutes, accountId: string): NotificationMutes {
const prefix = mailboxKey(accountId, '');
const mailboxes = Object.fromEntries(
Object.entries(mutes.mailboxes).filter(([key]) => !key.startsWith(prefix)),
);
return { ...mutes, mailboxes };
}
+50 -9
View File
@@ -14,6 +14,13 @@ import { JMAPMethodError, requireMethodResult } from '../api/jmap-result';
import { CAPABILITIES } from '../api/types';
import type { EmailPushConfig, JMAPAccountInfo, JMAPSession, Mailbox } from '../api/types';
import { generateAccountId } from './account-utils';
import {
EMPTY_NOTIFICATION_MUTES,
isAccountMuted,
mailboxOverridesForAccount,
type NotificationMutes,
} from './notification-mutes';
import { useSettingsStore } from '../stores/settings-store';
import { t } from '../stores/locale-store';
import {
authorityOfType,
@@ -296,32 +303,62 @@ export function serverSupportsEmailPush(): boolean {
* a Junk-role mailbox (Sieve `fileinto` doesn't set the keyword). The two are
* ANDed so a stale mailbox id - the user deleted and recreated Junk - degrades
* to keyword-only filtering rather than letting everything through.
*
* Muted accounts and folders (#78) are cut out of the same filter: a muted
* folder joins Junk in the "not only in" list; a muted account only lets
* through mail in the folders the user unmuted again, or nothing at all.
*/
export async function buildEmailPushConfig(): Promise<Record<string, EmailPushConfig>> {
export async function buildEmailPushConfig(
mutes: NotificationMutes = EMPTY_NOTIFICATION_MUTES,
): Promise<Record<string, EmailPushConfig>> {
const primary = jmapClient.accountId;
const junkByAccount = new Map<string, string[]>([[primary, []]]);
const mailboxIdsByAccount = new Map<string, Set<string>>();
// The mail store loads the same folders at the same moment (sign-in,
// start): reuse its list rather than a second Mailbox/get of our own.
const username = jmapClient.username;
const serverUrl = jmapClient.serverUrl;
const loaded = username && serverUrl ? await loadedMailboxes(generateAccountId(username, serverUrl)) : null;
const all = loaded ?? [
...(await getMailboxes().catch(() => [] as Mailbox[])),
...(await getSharedMailboxes().catch(() => [] as Mailbox[])),
];
// With mutes a swallowed fetch failure would look like "no such folder"
// and unmute everything, so let the real error surface; otherwise the
// filter can still degrade to keyword-only on a transient miss.
const hasMutes = mutes.accounts.length > 0 || Object.keys(mutes.mailboxes).length > 0;
const all = loaded ?? (hasMutes
? [...(await getMailboxes()), ...(await getSharedMailboxes())]
: [
...(await getMailboxes().catch(() => [] as Mailbox[])),
...(await getSharedMailboxes().catch(() => [] as Mailbox[])),
]);
for (const m of all) {
const accountId = m.accountId || primary;
const junk = junkByAccount.get(accountId) ?? [];
// Shared-account mailboxes carry a client-side "<account>:<id>" id;
// the server only knows the original.
if (m.role === 'junk') junk.push(m.originalId ?? m.id);
const serverId = m.originalId ?? m.id;
const ids = mailboxIdsByAccount.get(accountId) ?? new Set<string>();
ids.add(serverId);
mailboxIdsByAccount.set(accountId, ids);
const junk = junkByAccount.get(accountId) ?? [];
if (m.role === 'junk') junk.push(serverId);
junkByAccount.set(accountId, junk);
}
const config: Record<string, EmailPushConfig> = {};
for (const [accountId, junkIds] of junkByAccount) {
// Settings for folders that no longer exist are left out.
const existing = mailboxIdsByAccount.get(accountId) ?? new Set<string>();
const overrides = mailboxOverridesForAccount(mutes, accountId);
const conditions: Record<string, unknown>[] = [{ notKeyword: '$junk' }];
if (junkIds.length > 0) conditions.push({ inMailboxOtherThan: [...junkIds].sort() });
if (isAccountMuted(mutes, accountId)) {
const allowed = overrides.unmuted
.filter((id) => existing.has(id) && !junkIds.includes(id))
.sort();
if (allowed.length === 0) conditions.push(NEVER_MATCH);
else if (allowed.length === 1) conditions.push({ inMailbox: allowed[0] });
else conditions.push({ operator: 'OR', conditions: allowed.map((id) => ({ inMailbox: id })) });
} else {
const excluded = new Set([...junkIds, ...overrides.muted.filter((id) => existing.has(id))]);
if (excluded.size > 0) conditions.push({ inMailboxOtherThan: [...excluded].sort() });
}
config[accountId] = {
// Always the operator form: that's how the server echoes it back, so a
// stored config compares equal to a freshly built one.
@@ -333,6 +370,10 @@ export async function buildEmailPushConfig(): Promise<Record<string, EmailPushCo
return config;
}
// Contradicts the `notKeyword: '$junk'` every filter starts with, so the AND
// never matches: the account stays in the map but never pushes.
const NEVER_MATCH = { hasKeyword: '$junk' } as const;
function normalizeEmailPush(value: unknown): string {
const sortKeys = (v: unknown): unknown => {
if (Array.isArray(v)) return v.map(sortKeys);
@@ -980,7 +1021,7 @@ async function setupPushNotificationsInner(
if (params.forceRecreate) await AsyncStorage.removeItem(emailPushRefusedKey(accountId));
const refusedBefore = await readRefusedEmailPushAccounts(accountId);
const emailPush = serverSupportsEmailPush()
? withoutAccounts(await buildEmailPushConfig(), refusedBefore)
? withoutAccounts(await buildEmailPushConfig(useSettingsStore.getState().notificationMutes), refusedBefore)
: null;
const subKey = subscriptionIdKey(accountId);
const storedServerId = await AsyncStorage.getItem(subKey);
+16
View File
@@ -1,4 +1,5 @@
import type { Email } from '../api/types';
import type { KeywordDef } from '../stores/keywords-store';
// Port of the webmail's `lib/thread-utils.ts` (tag id extraction, thread
// grouping helpers) trimmed to what the native list needs.
@@ -23,6 +24,21 @@ export function getEmailTagIds(keywords: Record<string, boolean> | undefined): s
return [...tags];
}
/**
* The tags to draw for a message: every tag id on it, with its local
* definition where there is one. A tag no local definition explains (set by
* another client or by the server) is kept, grey and labelled with its raw
* id, so the header shows the same tags as the list row (#79).
*/
export function resolveEmailTags(
keywords: Record<string, boolean> | undefined,
defs: readonly KeywordDef[],
): KeywordDef[] {
return getEmailTagIds(keywords).map(
(id) => defs.find((d) => d.id === id) ?? { id, label: id, color: 'gray' },
);
}
/**
* Every tag anywhere in a thread, deduplicated. A collapsed thread row stands
* in for all its messages, so it has to account for all their tags.
+90
View File
@@ -0,0 +1,90 @@
// ZIP archives in the file preview: the entries as a list, and one entry's
// bytes when it is opened. Nothing is extracted to disk under the archive's
// paths; an opened entry goes through the same preview as any attachment.
import type JSZip from 'jszip';
export interface ZipEntry {
/** Full path inside the archive, as stored. */
path: string;
/** Last path segment. */
name: string;
/** Folder path, '' at the top. */
folder: string;
/** Uncompressed size, when the archive records it. */
size?: number;
modified?: Date;
}
export interface ZipArchive {
entries: ZipEntry[];
/** True when the listing stopped at {@link MAX_ZIP_ENTRIES}. */
truncated: boolean;
/** One entry's bytes. */
read(path: string): Promise<Uint8Array>;
}
/** The archive is password-protected; JSZip can't decrypt it. */
export class ZipEncryptedError extends Error {}
// Metadata the archives of macOS and Windows carry and nobody wants to open.
const JUNK = /(^|\/)(__MACOSX\/|\.DS_Store$|Thumbs\.db$|desktop\.ini$)/i;
/** Most entries listed; a larger archive lists the first ones. */
export const MAX_ZIP_ENTRIES = 2000;
// JSZip keeps the directory's sizes here without exposing them.
interface ZipObjectInternals {
_data?: { uncompressedSize?: number };
}
/**
* Read an archive's directory: its files (not folders), sorted by folder and
* then name, and a reader for their contents.
*/
export async function openZip(bytes: Uint8Array): Promise<ZipArchive> {
const { default: Zip } = await import('jszip');
let zip: JSZip;
try {
zip = await Zip.loadAsync(bytes, { createFolders: false });
} catch (e) {
if (/encrypt/i.test(e instanceof Error ? e.message : String(e))) throw new ZipEncryptedError('encrypted');
throw e;
}
const entries: ZipEntry[] = [];
let truncated = false;
for (const file of Object.values(zip.files)) {
if (file.dir || JUNK.test(file.name)) continue;
if (entries.length >= MAX_ZIP_ENTRIES) {
truncated = true;
break;
}
const path = file.name;
const slash = path.lastIndexOf('/');
const size = (file as unknown as ZipObjectInternals)._data?.uncompressedSize;
entries.push({
path,
name: slash >= 0 ? path.slice(slash + 1) : path,
folder: slash >= 0 ? path.slice(0, slash) : '',
size: typeof size === 'number' && size >= 0 ? size : undefined,
modified: file.date instanceof Date && !Number.isNaN(file.date.getTime()) ? file.date : undefined,
});
}
entries.sort((a, b) => a.folder.localeCompare(b.folder) || a.name.localeCompare(b.name));
return {
entries,
truncated,
read: async (path) => {
const file = zip.file(path);
if (!file) throw new Error(`${path} is not in the archive`);
return file.async('uint8array');
},
};
}
/** Whether a file is a ZIP the preview lists (not one of the formats built on ZIP). */
export function isZipArchive(name: string, type?: string | null): boolean {
const mime = (type || '').split(';')[0].trim().toLowerCase();
if (mime === 'application/zip' || mime === 'application/x-zip-compressed' || mime === 'application/x-zip') return true;
return /\.zip$/i.test(name);
}
+2 -2
View File
@@ -360,8 +360,8 @@ export default function FilesScreen() {
}
}, [t]);
// Tap on a file: the in-app preview for the types it shows (images, text
// and code, .eml, PDFs on iOS), the other app for the rest.
// Tap on a file: the in-app preview for the types it shows (lib/file-preview),
// the other app for the rest.
const openFile = useCallback((row: FileRow) => {
if (canPreviewInApp(row)) setPreviewTarget(row);
else void openFileWith(row);
+55
View File
@@ -20,6 +20,12 @@ import {
normalizeServerUrl,
} from '../lib/server-discovery';
import { describeLoginError, type LoginErrorCopy } from '../lib/login-errors';
import {
clearClientCertAlias,
getClientCertAlias,
isClientCertSupported,
pickClientCertAlias,
} from '../lib/client-cert';
import LoginShell from './login/LoginShell';
import ChooseStep from './login/ChooseStep';
import EmailStep from './login/EmailStep';
@@ -109,6 +115,19 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo
// Addresses typed before on this device (last 5) plus the registry's, so a
// returning user taps instead of retyping. Never contains passwords.
const [recentEmails, setRecentEmails] = React.useState<string[]>([]);
// TLS client certificate (#3). Every request from discovery on presents
// the picked certificate (lib/client-cert secureFetch), so a server behind
// a proxy that demands one has to get it before the first sign-in - the
// settings screen that also offers it is only reachable afterwards. The
// choice is device-wide, as in the settings.
const certSupported = isClientCertSupported();
const [certAlias, setCertAlias] = React.useState<string | null>(null);
const [certBusy, setCertBusy] = React.useState(false);
React.useEffect(() => {
if (!certSupported) return;
void getClientCertAlias().then(setCertAlias).catch(() => undefined);
}, [certSupported]);
React.useEffect(() => {
void AsyncStorage.getItem(RECENT_EMAILS_KEY).then((raw) => {
try {
@@ -135,6 +154,36 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo
// Servers we already hold credentials for. Discovery trusts these without a
// probe, which makes "second account on the same host" instant.
const knownServerUrls = React.useMemo(() => accounts.map((a) => a.serverUrl), [accounts]);
const pickCert = React.useCallback(async () => {
if (certBusy) return;
setCertBusy(true);
setNotice(null);
try {
// The host narrows the system picker to certificates issued for it.
const normalized = normalizeServerUrl(serverInput);
const host = normalized ? new URL(normalized).hostname : null;
const alias = await pickClientCertAlias(host);
if (alias !== null) setCertAlias(alias);
} catch (err) {
setNotice({
title: t('login.mobile.server_cert_failed', 'Could not pick a certificate'),
detail: err instanceof Error ? err.message : String(err),
});
} finally {
setCertBusy(false);
}
}, [certBusy, serverInput, t]);
const clearCert = React.useCallback(async () => {
if (certBusy) return;
setCertBusy(true);
try {
await clearClientCertAlias();
setCertAlias(null);
} finally {
setCertBusy(false);
}
}, [certBusy]);
const knownServerUrl = React.useMemo(() => {
const active = accounts.find((a) => a.id === activeAccountId);
return active?.serverUrl ?? accounts[0]?.serverUrl ?? null;
@@ -562,6 +611,12 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo
onPaste={() => setPasteVisible(true)}
failedDomain={failedDomain}
notice={notice}
clientCert={certSupported ? {
alias: certAlias,
busy: certBusy,
onPick: () => { void pickCert(); },
onClear: () => { void clearCert(); },
} : undefined}
/>
) : null}
+44 -1
View File
@@ -1,6 +1,6 @@
import React from 'react';
import { View, Text, Pressable, StyleSheet } from 'react-native';
import { ClipboardPaste, Globe, QrCode } from '../../components/icons';
import { ClipboardPaste, Globe, QrCode, ShieldCheck, X } from '../../components/icons';
import { spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { Button, Input } from '../../components';
@@ -16,6 +16,16 @@ interface ServerStepProps {
/** Set when we got here because discovery came back empty for this domain. */
failedDomain?: string | null;
notice?: { title: string; detail?: string } | null;
/**
* TLS client certificate for a reverse proxy that asks for one (#3).
* Undefined where the platform has no certificate store to pick from.
*/
clientCert?: {
alias: string | null;
busy: boolean;
onPick: () => void;
onClear: () => void;
};
}
/**
@@ -31,6 +41,7 @@ export default function ServerStep({
onPaste,
failedDomain,
notice,
clientCert,
}: ServerStepProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
@@ -82,6 +93,38 @@ export default function ServerStep({
{t('login.mobile.server_paste_hint', 'Have a sign-in link instead? Paste it here.')}
</Text>
</Pressable>
{clientCert ? (
<Pressable
onPress={clientCert.onPick}
disabled={clientCert.busy}
hitSlop={8}
style={styles.scanRow}
accessibilityRole="button"
testID="login-client-cert"
>
<ShieldCheck size={15} color={clientCert.alias ? c.success : c.textMuted} />
<Text style={styles.scanText}>
{clientCert.alias
? t('login.mobile.server_cert_active', 'Signing in with the client certificate "{alias}". Tap to choose another.', { alias: clientCert.alias })
: t('login.mobile.server_cert_hint', 'Does your server ask for a client certificate? Pick one installed on this phone.')}
</Text>
</Pressable>
) : null}
{clientCert?.alias ? (
<Pressable
onPress={clientCert.onClear}
disabled={clientCert.busy}
hitSlop={8}
style={styles.scanRow}
accessibilityRole="button"
testID="login-client-cert-clear"
>
<X size={15} color={c.textMuted} />
<Text style={styles.scanText}>
{t('login.mobile.server_cert_clear', 'Sign in without the certificate')}
</Text>
</Pressable>
) : null}
</View>
</View>
);
+7
View File
@@ -4,6 +4,7 @@ import type { Identity } from '../api/types';
import { getIdentities as fetchIdentities } from '../api/identity';
import { jmapClient } from '../api/jmap-client';
import type { SortLevel, MessageListOrderScope } from '../lib/message-list-order';
import { EMPTY_NOTIFICATION_MUTES, normalizeNotificationMutes, type NotificationMutes } from '../lib/notification-mutes';
export type ExternalContentPolicy = 'allow' | 'block' | 'ask';
export type ThemeMode = 'light' | 'dark' | 'system';
@@ -260,6 +261,9 @@ interface PersistedSettings {
// Notifications. Sound/vibration live in the Android notification channel
// (the OS owns them after channel creation), so there are no sound keys.
emailNotificationsEnabled: boolean;
// Accounts and folders whose new mail does not notify (#78), keyed by the
// server's JMAP ids; see lib/notification-mutes.
notificationMutes: NotificationMutes;
calendarNotificationsEnabled: boolean;
calendarInvitationParsingEnabled: boolean;
// Inbox unread count on the app icon (iOS, see lib/app-badge). Same
@@ -413,6 +417,7 @@ const DEFAULT_PERSISTED: PersistedSettings = {
filesShowHiddenFiles: false,
emailNotificationsEnabled: true,
notificationMutes: EMPTY_NOTIFICATION_MUTES,
calendarNotificationsEnabled: true,
calendarInvitationParsingEnabled: true,
appIconUnreadBadge: true,
@@ -605,6 +610,8 @@ export function mergeWithDefaults(parsed: Partial<PersistedSettings>): Persisted
if (validator && !validator(v)) continue;
if (k === 'bottomQuickActions') {
out[k] = normalizeBottomQuickActions(v);
} else if (k === 'notificationMutes') {
out[k] = normalizeNotificationMutes(v);
} else if (Array.isArray(def)) {
if (Array.isArray(v)) out[k] = v;
} else if (typeof def === 'object') {