Compare commits

...
41 Commits
Author SHA1 Message Date
Linus Rath 1c9bbcc383 chore: update version number to 0.1.83 2026-10-19 13:16:57 +02:00
Linus Rath c7eda4735c feat: trust user-installed CAs and pick the TLS client certificate before the first sign-in #3 2026-10-19 13:16:47 +02:00
Linus Rath 5f5ffdaddb feat: mute notifications for single folders or whole accounts #78 2026-10-19 13:11:12 +02:00
Linus Rath 083ec958a0 chore: sync the locale catalogs from the webmail 2026-10-19 13:03:52 +02:00
Linus Rath 2b486cb1ef fix: show tags with no local definition in the message header, as the list does #79 2026-10-19 13:02:29 +02:00
Linus Rath 86400b124a chore: update version number to 0.1.82 2026-10-16 19:46:04 +02:00
Linus Rath 323797b4ee fix: release a PDF that finishes opening after its preview was closed 2026-10-16 19:42:04 +02:00
Linus Rath 9166b0fb3c docs: note which files the preview shows 2026-10-16 19:40:59 +02:00
Linus Rath 1f253e9488 fix: stop a PDF page rendered after its preview closed from leaving files behind 2026-10-16 19:40:59 +02:00
Linus Rath d22fb8d5d0 feat: open the files inside ZIP archives and attached messages 2026-10-16 19:39:57 +02:00
Linus Rath e0f18713aa chore: update version number to 0.1.81 2026-10-16 19:27:04 +02:00
Linus Rath 56d826c508 test: cover how the PDF preview reports protected and unreadable files 2026-10-16 19:24:19 +02:00
Linus Rath e5492b89c8 feat: show PDFs in the app on Android 2026-10-16 19:23:17 +02:00
Linus Rath b50436b1ba chore: update version number to 0.1.80 2026-10-16 19:12:43 +02:00
Linus Rath 11e74042e2 fix: hand files with a generic type to other apps by the type their name implies 2026-10-16 19:11:50 +02:00
Linus Rath 95580b84ea feat: play video and audio, and show SVG, HTML and Markdown files in the preview 2026-10-16 19:11:24 +02:00
Linus Rath 9a15334bef chore: update version number to 0.1.79 2026-10-16 18:49:43 +02:00
Linus Rath 3aca2d80c0 fix: preview .eml attachments that carry attachments of their own 2026-10-16 18:48:26 +02:00
Linus Rath 7c534301ee refactor: open files from mail, the list, Files and compose through one preview 2026-10-16 18:48:26 +02:00
Linus Rath 14dc5ff2d4 Merge pull request #76 from lucletoffe/feat/calendar-sort-order
feat(calendar): order calendars by sortOrder and move them from the drawer
2026-10-15 23:37:37 +02:00
Linus Rath 14ba28c79c Merge pull request #74 from lucletoffe/fix/compose-visual-viewport
fix(composer): stop the body's first lines hiding under To/Cc/Subject on Android
2026-10-15 23:23:40 +02:00
Linus Rath 598adc154d Merge pull request #72 from lucletoffe/fix/draft-part-blobids
fix(drafts): keep a re-opened draft's attachments on live blobs across saves and sends
2026-10-15 23:23:26 +02:00
lucletoffeandClaude Opus 5.5 3c0af52c4e feat(calendar): order calendars by sortOrder and move them from the drawer
Calendars were listed in the order the server returns them (creation
order on Stalwart) and sortOrder was never read. Sort them like the
webmail now does and as JMAP Calendars asks: lower sortOrder first,
equal sortOrder by name (case- and accent-insensitive, numbers by
value), each account's calendars kept together. The store keeps the list
sorted, so the drawer, the event editor and the other pickers agree.

The drawer's long-press panel gains Move up / Move down for the user's
own calendars. A move renumbers that account's calendars 1, 2, 3... and
writes sortOrder only where it changes; the panel stays open so a
calendar can be moved several places.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-15 22:07:33 +02:00
Linus Rath 7108431dbb chore: update version number to 0.1.78 2026-10-15 16:58:08 +02:00
Linus Rath 744b17908d fix: open a settings page at its top instead of the list's scroll position 2026-10-15 15:36:59 +02:00
Linus Rath c7edae9ed3 fix: move a setting's control under its label when a long label leaves no room 2026-10-15 15:29:13 +02:00
Linus Rath c07b39a070 fix: keep device sync free of expo by registering device locales from i18n #75 2026-10-15 15:19:18 +02:00
Linus Rath 7e5e296271 fix: use the app switch for the sender override in compose 2026-10-15 15:16:32 +02:00
Linus Rath ad6dff79ba fix: draw contact form fields like the webmail and quiet the letter headers in the contact list 2026-10-15 15:16:32 +02:00
Linus Rath a4ca72fb3d fix: draw event and task fields like the webmail and use the app switch in the event form 2026-10-15 15:16:32 +02:00
Linus Rath 107eb807e3 chore: update version number to 0.1.77 2026-10-15 15:15:29 +02:00
Linus Rath e731357b89 fix: format dates in the device region for the app language #75 2026-10-15 15:15:19 +02:00
Linus Rath 9cf1d3e849 fix: draw the text field in dialogs like the other inputs 2026-10-15 15:04:01 +02:00
Linus Rath d3dcc4e830 fix: show folder roles, filter origins and app modes as outline badges 2026-10-15 15:04:01 +02:00
Linus Rath e0d2b190d3 fix: give the Files header the same title size as Mail and Contacts 2026-10-15 15:04:00 +02:00
Linus Rath b048eaca03 fix: tint unread mail and draw the mail search, subject, quick reply and thread buttons like the webmail 2026-10-15 15:04:00 +02:00
Linus Rath 6f515e15f7 feat: rework settings into the picked list, About and Storage & backup pages
The settings list now has the account card on top, title-case group
labels and 56 dp rows that show each pane's current value. About and
"About & Data" were one placeholder page with a letter logo, a fixed
name and a store link that pointed at GitHub releases.

- About: the real logo, the app name from app.config.js, the update
  state, a source link that a build can set with SOURCE_CODE_URL, and
  rows for the server, update checks (Android), open-source licences,
  privacy and a prefilled GitHub issue. iPhone updates link TestFlight.
- Storage & backup: usage card, offline mail, settings export/import
  and reset, and a Troubleshooting page for refresh and debug logging.
- Sender favicons moved to Appearance, like the webmail.
- The S/MIME and Plugins stub pages are gone; both are webmail plugins.

The licence list is generated by `scripts/generate-licences.mjs`.
2026-10-15 14:49:37 +02:00
Linus Rath 0a176ab51c feat: draw buttons, switches, selects, inputs and badges like the webmail 2026-10-15 14:49:37 +02:00
Linus Rath 75299244cf refactor: draw the webmail's Tabler icons instead of Lucide 2026-10-15 14:13:02 +02:00
lucletoffeandClaude Opus 5.5 8ce53816af fix(composer): stop the body's first lines hiding under To/Cc/Subject on Android
Android's WebView shrinks the editor page's visual viewport to the part of
the view that is on screen and pans it to reveal the caret while typing.
The editor is an auto-height WebView inside the composer's ScrollView, so
nothing ever resets that pan: scrolled back to the top, the body is still
drawn shifted up by the pan offset and its first lines sit hidden under the
header fields until a scroll to the very bottom clamps it away.

The page now undoes every pan as it happens (scrolling the page top into
view, since window.scrollTo only moves the layout viewport) and reports the
offset as a `viewportShift` message; the composer scrolls its ScrollView by
the same amount so the caret stays visible. Android only; iOS and the other
RichTextEditor hosts keep their behaviour (hosts without the callback still
get the reset).

Reproduced and verified on a Pixel 4 (Android 13, WebView 153): before,
visualViewport.offsetTop stayed at ~18 px at the top of the composer and
cut the first line; after, it stays 0 and the caret still follows typing
above the keyboard.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 08:49:57 +02:00
lucletoffeandClaude Opus 5.5 767d4b9f9f fix: keep a re-opened draft's attachments on live blobs across saves and sends
A draft's attachments and inline images are MIME parts of that draft, and
their blobIds die with the version that holds them. Each save creates the
next version and then destroys the previous one (#849), but the composer kept
sending the blobIds it was seeded with. The first save worked, every later
save failed with "blobId ... does not exist on this server", and so did the
send. This is the second half of webmail #849, which the webmail fixed in
5d2b937a but which was never ported here.

- saveDraftVersion() reads the new version's attachment parts back in the
  same request (Email/get on the creation id "#draft"; Stalwart 0.16 does not
  resolve a result reference into Email/set "created"). createDraft() keeps
  its signature on top of it.
- The composer maps each held blobId to the matching part of the latest
  version (inline by Content-ID, the rest by name and size) and sends that.
  Uploads are re-pointed too, since an unreferenced upload expires. The map
  lives in a ref, so the dirty snapshot does not change on every save.
- Opening a draft with inline images no longer looks like an edit. The
  hydration swapped the editor body for its normalised DOM HTML but compared
  it with the raw server HTML, so the autosave replaced the draft about two
  seconds after it opened. That destroyed the opened version, and a plain
  "open, then Send" failed with the same blobNotFound.

Tests: unit tests for the matching and rebasing, a fake-server test that
reproduces the failure without the rebase, and an opt-in live Stalwart test
(verified against Stalwart 0.16.22).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 08:47:21 +02:00
242 changed files with 126133 additions and 93285 deletions
+39 -1
View File
@@ -8,7 +8,7 @@ import {
} from '@react-navigation/native';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import { createNativeStackNavigator, type NativeStackScreenProps } from '@react-navigation/native-stack';
import { Mail, Calendar, BookUser, HardDrive, Settings } from 'lucide-react-native';
import { Mail, Calendar, BookUser, HardDrive, Settings } from './src/components/icons';
import { startLiveUpdates, type LiveUpdatesHandle } from './src/api/push-stream';
import { jmapClient } from './src/api/jmap-client';
@@ -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.76
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"/>
@@ -15,6 +15,7 @@ class BulwarkFcmPackage : ReactPackage {
add(BulwarkClientCertModule(reactContext))
add(BulwarkDeviceSyncModule(reactContext))
add(BulwarkWidgetsModule(reactContext))
add(BulwarkPdfModule(reactContext))
// The bridge registers React Native's own module under this name.
if (ReactNativeNewArchitectureFeatureFlags.enableBridgelessArchitecture()) {
add(BulwarkHeadlessJsTaskSupportModule(reactContext))
@@ -0,0 +1,143 @@
package com.anonymous.bulwarkmobile
import android.graphics.Bitmap
import android.graphics.Color
import android.graphics.pdf.PdfRenderer
import android.net.Uri
import android.os.ParcelFileDescriptor
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
import java.io.File
import java.io.FileOutputStream
import java.util.concurrent.Executors
/**
* Renders PDF pages to PNG files for the in-app file preview, with the
* platform's own PdfRenderer: Android has no PDF view, and its WebView
* doesn't show PDFs either. The preview draws the pages in a WebView of
* its own so they zoom, asking for each page as it scrolls into view.
*
* PdfRenderer is not thread-safe and allows one open page at a time, so
* every call runs on one background thread, and the open document is kept
* between calls for the file the preview shows.
*/
class BulwarkPdfModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {
private val worker = Executors.newSingleThreadExecutor { r -> Thread(r, "BulwarkPdf").apply { isDaemon = true } }
private var openPath: String? = null
private var descriptor: ParcelFileDescriptor? = null
private var renderer: PdfRenderer? = null
override fun getName(): String = "BulwarkPdf"
override fun invalidate() {
worker.execute { closeDocument() }
worker.shutdown()
super.invalidate()
}
/** Page count and each page's size in points: `{ pages: [{ width, height }] }`. */
@ReactMethod
fun open(fileUri: String, promise: Promise) {
worker.execute {
try {
val doc = document(fileUri)
val pages = Arguments.createArray()
for (i in 0 until doc.pageCount) {
doc.openPage(i).use { page ->
pages.pushMap(Arguments.createMap().apply {
putInt("width", page.width)
putInt("height", page.height)
})
}
}
promise.resolve(Arguments.createMap().apply { putArray("pages", pages) })
} catch (e: SecurityException) {
// Password-protected; PdfRenderer can't open those.
closeDocument()
promise.reject("E_PDF_PROTECTED", e.message, e)
} catch (e: Throwable) {
closeDocument()
promise.reject("E_PDF_OPEN", e.message, e)
}
}
}
/** Render one page `width` pixels wide into `outUri` (a PNG). Resolves with the file URI. */
@ReactMethod
fun renderPage(fileUri: String, index: Int, width: Int, outUri: String, promise: Promise) {
worker.execute {
try {
val doc = document(fileUri)
if (index < 0 || index >= doc.pageCount) throw IndexOutOfBoundsException("page $index")
doc.openPage(index).use { page ->
val w = width.coerceIn(1, MAX_PAGE_PX)
val h = (w.toLong() * page.height / page.width.coerceAtLeast(1)).toInt().coerceIn(1, MAX_PAGE_PX * 2)
val bitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
try {
// Pages are transparent where nothing is drawn.
bitmap.eraseColor(Color.WHITE)
page.render(bitmap, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY)
val out = fileOf(outUri)
// 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()
}
}
promise.resolve(outUri)
} catch (e: Throwable) {
promise.reject("E_PDF_RENDER", e.message, e)
}
}
}
/** Close the document the preview showed. */
@ReactMethod
fun close(fileUri: String, promise: Promise) {
worker.execute {
if (openPath == fileOf(fileUri).absolutePath) closeDocument()
promise.resolve(null)
}
}
private fun document(fileUri: String): PdfRenderer {
val path = fileOf(fileUri).absolutePath
renderer?.let { if (openPath == path) return it }
closeDocument()
val fd = ParcelFileDescriptor.open(File(path), ParcelFileDescriptor.MODE_READ_ONLY)
try {
val doc = PdfRenderer(fd)
descriptor = fd
renderer = doc
openPath = path
return doc
} catch (e: Throwable) {
fd.close()
throw e
}
}
private fun closeDocument() {
try { renderer?.close() } catch (_: Throwable) {}
try { descriptor?.close() } catch (_: Throwable) {}
renderer = null
descriptor = null
openPath = null
}
private fun fileOf(uri: String): File {
val path = if (uri.startsWith("file:")) Uri.parse(uri).path else uri
return File(path ?: throw IllegalArgumentException("not a file: $uri"))
}
companion object {
// A 4096 px page bitmap is 64 MB; keep well below.
private const val MAX_PAGE_PX = 2480
}
}
@@ -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>
+3
View File
@@ -99,6 +99,9 @@ module.exports = {
],
extra: {
commit: COMMIT,
// Settings → About → Source code. A modified build sets SOURCE_CODE_URL
// to where its own source is (AGPL §13); the default is upstream.
sourceCodeUrl: process.env.SOURCE_CODE_URL || 'https://github.com/bulwarkmail/native',
},
},
};
-1
View File
@@ -10,6 +10,5 @@ module.exports = function babelConfig(api) {
api.cache(true);
return {
presets: [presetExpo],
plugins: [require.resolve('./scripts/babel-plugin-lucide-imports')],
};
};
+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
+9 -6
View File
@@ -407,7 +407,14 @@
"preview": {
"open_external": "فتح في تطبيق",
"pdf_external": "تُفتح ملفات PDF في عارض PDF على هذا الجهاز.",
"unsupported": "لا توجد معاينة لهذا النوع من الملفات."
"unsupported": "لا توجد معاينة لهذا النوع من الملفات.",
"media_unsupported": "لا يمكن تشغيل هذا الملف هنا.",
"pdf_protected": "ملف 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": "إزالة الوسم",
@@ -438,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": "فشل تحديث المشاركة",
@@ -1095,7 +1101,6 @@
},
"tabs": {
"plugins": "الإضافات",
"themes": "السمات",
"updates": "التحديثات"
},
"templates": {
@@ -1112,9 +1117,7 @@
},
"themes": {
"default_description": "لوحات الألوان الفاتحة والداكنة الافتراضية.",
"default_name": "Bulwark",
"mobile_description": "اختر سمة ألوان. تتبع السمات إعداد الوضع الفاتح/الداكن من المظهر؛ وتُدار حزم السمات المخصصة في بريد الويب.",
"title": "السمات"
"mobile_description": "اختر سمة ألوان. تتبع السمات إعداد الوضع الفاتح/الداكن من المظهر؛ وتُدار حزم السمات المخصصة في بريد الويب."
},
"vacation": {
"warnings": {
+9 -6
View File
@@ -407,7 +407,14 @@
"preview": {
"open_external": "Obre en una aplicació",
"pdf_external": "Els PDF s'obren amb el visualitzador de PDF d'aquest dispositiu.",
"unsupported": "No hi ha previsualització per a aquest tipus de fitxer."
"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í.",
"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",
@@ -438,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ó",
@@ -1095,7 +1101,6 @@
},
"tabs": {
"plugins": "Connectors",
"themes": "Temes",
"updates": "Actualitzacions"
},
"templates": {
@@ -1112,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": {
+9 -6
View File
@@ -407,7 +407,14 @@
"preview": {
"open_external": "Otevřít v aplikaci",
"pdf_external": "Soubory PDF se otevírají v prohlížeči PDF na tomto zařízení.",
"unsupported": "Pro tento typ souboru není náhled k dispozici."
"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.",
"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",
@@ -438,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",
@@ -1095,7 +1101,6 @@
},
"tabs": {
"plugins": "Pluginy",
"themes": "Motivy",
"updates": "Aktualizace"
},
"templates": {
@@ -1112,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": {
+9 -6
View File
@@ -407,7 +407,14 @@
"preview": {
"open_external": "Åbn i app",
"pdf_external": "PDF'er åbnes i din PDF-fremviser på denne enhed.",
"unsupported": "Ingen forhåndsvisning for denne filtype."
"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.",
"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",
@@ -438,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",
@@ -1095,7 +1101,6 @@
},
"tabs": {
"plugins": "Plugins",
"themes": "Temaer",
"updates": "Opdateringer"
},
"templates": {
@@ -1112,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": {
+106 -10
View File
@@ -413,7 +413,14 @@
"preview": {
"open_external": "In App öffnen",
"pdf_external": "PDFs werden in Ihrem PDF-Viewer auf diesem Gerät geöffnet.",
"unsupported": "Keine Vorschau für diesen Dateityp."
"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.",
"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",
@@ -444,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",
@@ -648,12 +654,14 @@
},
"toolbar_labels": {
"description_mobile": "Textbeschriftungen neben den Symbolleistensymbolen anzeigen."
}
},
"description_mobile": "Passe an, wie die App aussieht"
},
"badges": {
"experimental": "Experimentell",
"not_implemented": "Nicht implementiert",
"unavailable": "Nicht verfügbar"
"unavailable": "Nicht verfügbar",
"not_on_server": "Nicht auf diesem Server"
},
"contacts": {
"book_count": "{count, plural, one {# Kontakt} other {# Kontakte}}",
@@ -996,7 +1004,15 @@
"window": "Zeitraum",
"window_24h": "Letzte 24 Stunden",
"window_days": "Letzte {count} Tage",
"window_desc": "Wie weit zurück zwischengespeichert wird, gemessen am Empfangsdatum der Nachricht."
"window_desc": "Wie weit zurück zwischengespeichert wird, gemessen am Empfangsdatum der Nachricht.",
"description_short": "Neue Nachrichten auf dem Telefon behalten, damit sie auch ohne Verbindung aufgehen.",
"keep_24h": "24 Stunden",
"keep_days": "{count} Tage",
"keep_from": "Mails der letzten",
"messages_offline": "{count, plural, one {# Nachricht offline} other {# Nachrichten offline}}",
"synced": "Synchronisiert {when}",
"use_at_most": "Höchstens belegen",
"used_of": "{used} von {limit} MB"
},
"plugins": {
"mobile_description": "Plugins laufen im Webmail und erweitern es im Browser.",
@@ -1103,8 +1119,10 @@
},
"tabs": {
"plugins": "Plugins",
"themes": "Designs",
"updates": "Updates"
"updates": "Updates",
"storage": "Speicher & Sicherung",
"about": "Über",
"troubleshooting": "Fehlerbehebung"
},
"templates": {
"delete_confirm": "\"{name}\" endgültig löschen?",
@@ -1120,14 +1138,92 @@
},
"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": {
"date_format": "Datumsangaben müssen das Format \"YYYY-MM-DD\" oder \"YYYY-MM-DD HH:MM\" haben"
}
},
"about": {
"check_now": "Jetzt prüfen",
"checked_at": "Geprüft {when}",
"licences": "Open-Source-Lizenzen",
"licences_desc": "AGPL-3.0 für die App und die Bibliotheken, die sie nutzt",
"never_checked": "Noch nicht geprüft",
"privacy": "Datenschutz",
"privacy_desc": "Was auf diesem Telefon bleibt und was an deinen Server geht",
"report_problem": "Problem melden",
"report_problem_desc": "Öffnet ein Issue auf GitHub mit eingetragener Version",
"server": "Server",
"server_value": "{host} · angemeldet als {user}",
"source_code": "Quellcode",
"this_device": "Dieses Gerät",
"this_device_desc": "Womit die App verbunden ist und wo du Hilfe bekommst.",
"up_to_date": "Aktuell",
"updates": "Updates"
},
"backup": {
"description": "Übertrage deine Einstellungen auf ein anderes Telefon oder ins Webmail.",
"export_import": "Exportieren oder importieren",
"export_import_desc": "Eine JSON-Datei im selben Format wie im Webmail",
"reset": "Zurücksetzen",
"reset_desc": "Zurück auf die Standardwerte; Mails bleiben",
"title": "Einstellungen sichern"
},
"licences": {
"app_desc": "Die App ist freie Software unter der {licence}. Du darfst sie nutzen, untersuchen, weitergeben und ändern.",
"libraries": "Bibliotheken",
"libraries_desc": "Open-Source-Code, mit dem die App gebaut ist.",
"read_licence": "Den vollständigen Lizenztext lesen"
},
"privacy": {
"favicons": "Absendersymbole für geschäftliche Mails kommen vom Symboldienst von DuckDuckGo, der dabei die Domain des Absenders sieht. Ausschalten kannst du sie unter Darstellung → Absender-Favicons.",
"no_tracking": "Die App hat keine Analyse und keine Werbung.",
"other_title": "Weitere Verbindungen",
"phone_backup": "Nichts davon landet in der Cloud-Sicherung von Android.",
"phone_secure": "Deine Anmeldedaten liegen im sicheren Speicher des Systems. Einstellungen, der Offline-Cache und noch nicht synchronisierte Änderungen liegen im Speicher der App.",
"phone_title": "Auf diesem Telefon",
"push": "Wenn Benachrichtigungen an sind, meldet dein Server dem Push-Relay ({relay}), dass sich etwas geändert hat, und das Relay weckt die App über {service}. Das Relay sieht ein Gerätetoken und eine Änderungsmarke, nie deine Mails. Unter Benachrichtigungen kannst du ein eigenes Relay eintragen.",
"push_title": "Push-Benachrichtigungen",
"server": "Mails, Kalender, Kontakte und Dateien werden von deinem Server geladen und dort gespeichert. Außer den Verbindungen unten spricht die App nur mit deinem Server.",
"server_title": "Dein Mailserver",
"service_apple": "den Push-Dienst von Apple",
"service_google": "den Push-Dienst von Google",
"updates": "Die App fragt bei GitHub nach der neuesten Version, um dir Updates anzuzeigen.",
"updates_android": "Die App fragt bei GitHub nach der neuesten Version, um dir Updates anzuzeigen. Ausschalten kannst du das unter Updates."
},
"summary": {
"events_off": "Termine aus",
"events_on": "Termine an",
"mail_off": "Mail aus",
"mail_on": "Mail an",
"offline_off": "Offline-Mails aus",
"offline_on": "Offline-Mails an · {days, plural, one {# Tag} other {# Tage}}, {mb} MB",
"version": "Version {version}",
"week_starts": "Woche beginnt {day}",
"layout": "Schnellaktionen, Wischen, Reihenfolge",
"reading": "Gelesen-Status, Löschen, Anhänge",
"composing": "Signatur, Sendeverzögerung, Entwürfe",
"identities": "Adressen, von denen du sendest",
"vacation": "Automatische Antwort bei Abwesenheit",
"filters": "Regeln für eingehende Mails",
"templates": "Wiederverwendbare Nachrichten",
"folders": "Anlegen, umbenennen, Rollen",
"keywords": "Namen und Farben der Tags",
"downloads": "Dateinamen für gespeicherte Mails",
"security": "Passwort, Zwei-Faktor, App-Passwörter",
"content_senders": "Externe Bilder, vertrauenswürdige Absender",
"contacts": "Adressbücher, Sortierung",
"files": "Ansicht, Sortierung, Vorschaubilder",
"sidebar_apps": "Web-Apps im Seitenmenü",
"updates": "Neue Versionen installieren"
},
"troubleshooting": {
"cache_desc": "Wenn eine Liste veraltet oder falsch aussieht.",
"cache_title": "Zwischengespeicherte Daten",
"row_desc": "Daten neu laden, Debug-Protokoll",
"title": "Fehlerbehebung"
}
},
"sidebar": {
+135 -14
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,8 +421,15 @@
"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.",
"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.",
"unsupported": "No preview for this file type."
},
"received_via": "to {name}",
@@ -455,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",
@@ -470,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}"
},
@@ -583,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.",
@@ -627,6 +654,24 @@
"send_now_failed": "Could not send now"
},
"settings": {
"about": {
"check_now": "Check now",
"checked_at": "Checked {when}",
"licences": "Open-source licences",
"licences_desc": "AGPL-3.0 for the app, and the libraries it uses",
"never_checked": "Not checked yet",
"privacy": "Privacy",
"privacy_desc": "What stays on this phone and what goes to your server",
"report_problem": "Report a problem",
"report_problem_desc": "Opens an issue on GitHub with the version filled in",
"server": "Server",
"server_value": "{host} · signed in as {user}",
"source_code": "Source code",
"this_device": "This device",
"this_device_desc": "What the app is connected to and how to get help.",
"up_to_date": "Up to date",
"updates": "Updates"
},
"account": {
"accounts": {
"description_mobile": "Reorder how accounts appear in the account switcher, pick the default, or sign one out.",
@@ -653,6 +698,7 @@
}
},
"appearance": {
"description_mobile": "Customize how the app looks",
"language": {
"restart_for_direction": "Restart the app to apply the new text direction.",
"system_default": "System default"
@@ -661,9 +707,18 @@
"description_mobile": "Display text labels next to toolbar icons."
}
},
"backup": {
"description": "Move your settings to another phone or the webmail.",
"export_import": "Export or import",
"export_import_desc": "A JSON file, same format as the webmail",
"reset": "Reset",
"reset_desc": "Back to the defaults; mail stays",
"title": "Settings backup"
},
"badges": {
"experimental": "Experimental",
"not_implemented": "Not implemented",
"not_on_server": "Not on this server",
"unavailable": "Unavailable"
},
"contacts": {
@@ -808,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."
},
@@ -928,6 +977,12 @@
"unified_cross_account": "Unified views across accounts",
"unified_cross_account_description": "Combine every signed-in account in \"All inboxes\". Off keeps the unified views inside the active account and its shared folders."
},
"licences": {
"app_desc": "The app is free software under the {licence}. You can use, study, share and change it.",
"libraries": "Libraries",
"libraries_desc": "Open-source code the app is built with.",
"read_licence": "Read the full licence text"
},
"message_list_order": {
"description": "Which messages come first in the list. Newest first is the tie-breaker within each group; the header toggle still flips oldest/newest.",
"preset_chronological": "Newest first",
@@ -945,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": {
@@ -993,23 +1050,31 @@
"clear_title": "Clear offline mail",
"days_ago": "{count} d ago",
"description": "Download recent messages so they open instantly and remain readable without a connection. Bodies only — attachments are still fetched on demand.",
"description_short": "Keep recent messages on this phone so they open without a connection.",
"downloading": "Downloading {completed}/{total}…",
"enabled": "Cache recent mail",
"enabled_desc": "When on, the app keeps message bodies for the selected window on this device.",
"hours_ago": "{count} h ago",
"just_now": "just now",
"keep_24h": "24 hours",
"keep_days": "{count} days",
"keep_from": "Keep mail from the last",
"last_sync": "Last sync {when}",
"last_sync_failed": "Last sync failed: {message}",
"max_size": "Maximum size",
"max_size_desc": "Oldest messages are removed when the cache grows past this.",
"messages_offline": "{count, plural, one {# message offline} other {# messages offline}}",
"minutes_ago": "{count} min ago",
"never": "never",
"nothing_cached": "Nothing cached yet",
"queued_changes": "{count, plural, one {# change} other {# changes}} waiting to sync",
"scanning": "Scanning recent mail…",
"sync_now": "Sync now",
"synced": "Synced {when}",
"syncing": "Syncing…",
"title": "Offline mail",
"use_at_most": "Use at most",
"used_of": "{used} of {limit} MB",
"window": "Window",
"window_24h": "Last 24 hours",
"window_days": "Last {count} days",
@@ -1019,6 +1084,22 @@
"mobile_description": "Plugins run inside the webmail and extend it in the browser.",
"mobile_explainer": "The mobile app has no plugin runtime. Install and manage plugins from the webmail under Settings → Plugins; themes and filters they provide are not applied here."
},
"privacy": {
"favicons": "Sender icons for business mail come from DuckDuckGo's icon service, which sees the sender's domain. Turn them off under Appearance → Sender favicons.",
"no_tracking": "The app has no analytics and no ads.",
"other_title": "Other connections",
"phone_backup": "None of it goes into Android's cloud backup.",
"phone_secure": "Your sign-in details are kept in the system's secure storage. Settings, the offline mail cache and changes waiting to sync are kept in the app's own storage.",
"phone_title": "On this phone",
"push": "When notifications are on, your server tells the push relay ({relay}) that something changed, and the relay wakes the app through {service}. The relay sees a device token and a change marker, never your mail. You can use your own relay under Notifications.",
"push_title": "Push notifications",
"server": "Mail, calendars, contacts and files are loaded from your server and saved back to it. Apart from the connections below, the app talks only to your server.",
"server_title": "Your mail server",
"service_apple": "Apple's push service",
"service_google": "Google's push service",
"updates": "The app asks GitHub for the latest release to tell you about updates.",
"updates_android": "The app asks GitHub for the latest release to tell you about updates. Turn that off under Updates."
},
"security": {
"client_cert": {
"active": "Active: {alias}",
@@ -1118,9 +1199,37 @@
"mobile_description": "Sign and encrypt mail with S/MIME certificates.",
"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",
"reading": "Read status, delete, attachments",
"security": "Password, two-factor, app passwords",
"sidebar_apps": "Web apps in the side menu",
"templates": "Reusable messages",
"updates": "Install new versions",
"vacation": "Automatic reply while away",
"version": "Version {version}",
"week_starts": "week starts {day}"
},
"tabs": {
"about": "About",
"plugins": "Plugins",
"themes": "Themes",
"storage": "Storage & backup",
"troubleshooting": "Troubleshooting",
"updates": "Updates"
},
"templates": {
@@ -1137,11 +1246,23 @@
},
"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.",
"cache_title": "Cached data",
"row_desc": "Refresh cached data, debug logging",
"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\""
}
+9 -6
View File
@@ -407,7 +407,14 @@
"preview": {
"open_external": "Abrir en una app",
"pdf_external": "Los PDF se abren en el visor de PDF de este dispositivo.",
"unsupported": "No hay vista previa para este tipo de archivo."
"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í.",
"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",
@@ -438,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",
@@ -1095,7 +1101,6 @@
},
"tabs": {
"plugins": "Plugins",
"themes": "Temas",
"updates": "Actualizaciones"
},
"templates": {
@@ -1112,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": {
+9 -6
View File
@@ -407,7 +407,14 @@
"preview": {
"open_external": "باز کردن در برنامه",
"pdf_external": "فایل‌های PDF در نمایشگر PDF این دستگاه باز می‌شوند.",
"unsupported": "برای این نوع فایل پیش‌نمایشی وجود ندارد."
"unsupported": "برای این نوع فایل پیش‌نمایشی وجود ندارد.",
"media_unsupported": "این فایل اینجا پخش نمی‌شود.",
"pdf_protected": "این PDF با رمز عبور محافظت شده است. برای وارد کردن رمز، آن را در برنامهٔ دیگری باز کنید.",
"pdf_unreadable": "این PDF اینجا نمایش داده نمی‌شود.",
"archive_files": "{count, plural, one {# فایل} other {# فایل}}",
"archive_first_files": "{count} فایل نخست",
"archive_protected": "این بایگانی با رمز عبور محافظت شده است. برای وارد کردن رمز، آن را در برنامهٔ دیگری باز کنید.",
"archive_unreadable": "این بایگانی اینجا باز نمی‌شود."
},
"received_via": "به {name}",
"remove_color": "حذف برچسب",
@@ -438,7 +445,6 @@
"open_error": "باز کردن فایل ممکن نشد",
"open_with": "باز کردن با…",
"preview_too_large": "این فایل برای پیش‌نمایش بیش از حد بزرگ است.",
"selected_count": "{count, plural, one {# انتخاب شده} other {# انتخاب شده}}",
"share_add_people": "افزودن افراد",
"share_empty": "هنوز با کسی به اشتراک گذاشته نشده",
"share_error": "به‌روزرسانی اشتراک‌گذاری ناموفق بود",
@@ -1095,7 +1101,6 @@
},
"tabs": {
"plugins": "افزونه‌ها",
"themes": "تم‌ها",
"updates": "به‌روزرسانی‌ها"
},
"templates": {
@@ -1112,9 +1117,7 @@
},
"themes": {
"default_description": "پالت‌های روشن و تاریک پیش‌فرض.",
"default_name": "Bulwark",
"mobile_description": "یک تم رنگی انتخاب کنید. تم‌ها از تنظیم روشن/تاریک بخش ظاهر پیروی می‌کنند؛ بسته‌های تم سفارشی در ایمیل تحت وب مدیریت می‌شوند.",
"title": "تم‌ها"
"mobile_description": "یک تم رنگی انتخاب کنید. تم‌ها از تنظیم روشن/تاریک بخش ظاهر پیروی می‌کنند؛ بسته‌های تم سفارشی در ایمیل تحت وب مدیریت می‌شوند."
},
"vacation": {
"warnings": {
+9 -6
View File
@@ -407,7 +407,14 @@
"preview": {
"open_external": "Ouvrir dans une app",
"pdf_external": "Les PDF s'ouvrent dans votre lecteur PDF sur cet appareil.",
"unsupported": "Aucun aperçu pour ce type de fichier."
"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.",
"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",
@@ -438,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",
@@ -1095,7 +1101,6 @@
},
"tabs": {
"plugins": "Plugins",
"themes": "Thèmes",
"updates": "Mises à jour"
},
"templates": {
@@ -1112,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": {
+9 -6
View File
@@ -407,7 +407,14 @@
"preview": {
"open_external": "פתח באפליקציה",
"pdf_external": "קובצי PDF נפתחים במציג ה-PDF במכשיר זה.",
"unsupported": "אין תצוגה מקדימה לסוג קובץ זה."
"unsupported": "אין תצוגה מקדימה לסוג קובץ זה.",
"media_unsupported": "לא ניתן להפעיל את הקובץ הזה כאן.",
"pdf_protected": "קובץ ה-PDF הזה מוגן בסיסמה. פתחו אותו באפליקציה אחרת כדי להזין את הסיסמה.",
"pdf_unreadable": "לא ניתן להציג את קובץ ה-PDF הזה כאן.",
"archive_files": "{count, plural, one {קובץ אחד} two {שני קבצים} other {# קבצים}}",
"archive_first_files": "{count} הקבצים הראשונים",
"archive_protected": "הארכיון הזה מוגן בסיסמה. פתחו אותו באפליקציה אחרת כדי להזין את הסיסמה.",
"archive_unreadable": "לא ניתן לפתוח את הארכיון הזה כאן."
},
"received_via": "אל {name}",
"remove_color": "הסר תג",
@@ -438,7 +445,6 @@
"open_error": "לא ניתן היה לפתוח את הקובץ",
"open_with": "פתח באמצעות…",
"preview_too_large": "הקובץ גדול מכדי להציג תצוגה מקדימה.",
"selected_count": "{count, plural, one {נבחר פריט אחד} two {נבחרו שני פריטים} other {נבחרו # פריטים}}",
"share_add_people": "הוסף אנשים",
"share_empty": "עדיין לא משותף עם אף אחד",
"share_error": "עדכון השיתוף נכשל",
@@ -1095,7 +1101,6 @@
},
"tabs": {
"plugins": "תוספים",
"themes": "ערכות נושא",
"updates": "עדכונים"
},
"templates": {
@@ -1112,9 +1117,7 @@
},
"themes": {
"default_description": "ערכות הצבעים הבהירה והכהה המוגדרות כברירת מחדל.",
"default_name": "Bulwark",
"mobile_description": "בחר ערכת צבעים. ערכות הנושא פועלות לפי הגדרת בהיר/כהה במראה; חבילות ערכות נושא מותאמות מנוהלות בדואר האינטרנט.",
"title": "ערכות נושא"
"mobile_description": "בחר ערכת צבעים. ערכות הנושא פועלות לפי הגדרת בהיר/כהה במראה; חבילות ערכות נושא מותאמות מנוהלות בדואר האינטרנט."
},
"vacation": {
"warnings": {
+9 -6
View File
@@ -407,7 +407,14 @@
"preview": {
"open_external": "Megnyitás alkalmazásban",
"pdf_external": "A PDF-fájlok az eszköz PDF-megjelenítőjében nyílnak meg.",
"unsupported": "Ehhez a fájltípushoz nincs előnézet."
"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.",
"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",
@@ -438,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",
@@ -1095,7 +1101,6 @@
},
"tabs": {
"plugins": "Bővítmények",
"themes": "Témák",
"updates": "Frissítések"
},
"templates": {
@@ -1112,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": {
+9 -6
View File
@@ -407,7 +407,14 @@
"preview": {
"open_external": "Apri in un'app",
"pdf_external": "I PDF si aprono nel lettore PDF di questo dispositivo.",
"unsupported": "Nessuna anteprima per questo tipo di file."
"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.",
"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",
@@ -438,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",
@@ -1095,7 +1101,6 @@
},
"tabs": {
"plugins": "Plugin",
"themes": "Temi",
"updates": "Aggiornamenti"
},
"templates": {
@@ -1112,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": {
+9 -6
View File
@@ -407,7 +407,14 @@
"preview": {
"open_external": "アプリで開く",
"pdf_external": "PDF はこのデバイスの PDF ビューアーで開きます。",
"unsupported": "このファイル形式はプレビューできません。"
"unsupported": "このファイル形式はプレビューできません。",
"media_unsupported": "このファイルはここでは再生できません。",
"pdf_protected": "このPDFはパスワードで保護されています。パスワードを入力するには別のアプリで開いてください。",
"pdf_unreadable": "このPDFはここでは表示できません。",
"archive_files": "{count, plural, other {# 個のファイル}}",
"archive_first_files": "最初の {count} 個のファイル",
"archive_protected": "このアーカイブはパスワードで保護されています。パスワードを入力するには別のアプリで開いてください。",
"archive_unreadable": "このアーカイブはここでは開けません。"
},
"received_via": "{name}宛",
"remove_color": "ラベルを削除",
@@ -438,7 +445,6 @@
"open_error": "ファイルを開けませんでした",
"open_with": "別のアプリで開く…",
"preview_too_large": "このファイルは大きすぎるため、プレビューできません。",
"selected_count": "{count, plural, other {#件選択中}}",
"share_add_people": "ユーザーを追加",
"share_empty": "まだ誰にも共有されていません",
"share_error": "共有の更新に失敗しました",
@@ -1095,7 +1101,6 @@
},
"tabs": {
"plugins": "プラグイン",
"themes": "テーマ",
"updates": "アップデート"
},
"templates": {
@@ -1112,9 +1117,7 @@
},
"themes": {
"default_description": "デフォルトのライト/ダークパレットです。",
"default_name": "Bulwark",
"mobile_description": "カラーテーマを選択します。テーマは「外観」のライト/ダーク設定に従います。カスタムテーマパッケージはウェブメールで管理します。",
"title": "テーマ"
"mobile_description": "カラーテーマを選択します。テーマは「外観」のライト/ダーク設定に従います。カスタムテーマパッケージはウェブメールで管理します。"
},
"vacation": {
"warnings": {
+9 -6
View File
@@ -407,7 +407,14 @@
"preview": {
"open_external": "앱에서 열기",
"pdf_external": "PDF는 이 기기의 PDF 뷰어에서 열려요.",
"unsupported": "이 파일 형식은 미리보기를 지원하지 않아요."
"unsupported": "이 파일 형식은 미리보기를 지원하지 않아요.",
"media_unsupported": "이 파일은 여기서 재생할 수 없습니다.",
"pdf_protected": "이 PDF는 암호로 보호되어 있습니다. 암호를 입력하려면 다른 앱에서 여세요.",
"pdf_unreadable": "이 PDF는 여기서 표시할 수 없습니다.",
"archive_files": "{count, plural, other {파일 #개}}",
"archive_first_files": "처음 {count}개 파일",
"archive_protected": "이 압축 파일은 암호로 보호되어 있습니다. 암호를 입력하려면 다른 앱에서 여세요.",
"archive_unreadable": "이 압축 파일은 여기서 열 수 없습니다."
},
"received_via": "받는 사람: {name}",
"remove_color": "태그 제거",
@@ -438,7 +445,6 @@
"open_error": "파일을 열 수 없어요",
"open_with": "다른 앱으로 열기…",
"preview_too_large": "파일이 너무 커서 미리볼 수 없어요.",
"selected_count": "{count, plural, other {#개 선택됨}}",
"share_add_people": "사용자 추가",
"share_empty": "아직 아무와도 공유하지 않았어요",
"share_error": "공유 업데이트에 실패했어요",
@@ -1095,7 +1101,6 @@
},
"tabs": {
"plugins": "플러그인",
"themes": "테마",
"updates": "업데이트"
},
"templates": {
@@ -1112,9 +1117,7 @@
},
"themes": {
"default_description": "기본 라이트 및 다크 팔레트예요.",
"default_name": "Bulwark",
"mobile_description": "색상 테마를 선택하세요. 테마는 화면 설정의 라이트/다크 설정을 따르며, 사용자 지정 테마 패키지는 웹메일에서 관리해요.",
"title": "테마"
"mobile_description": "색상 테마를 선택하세요. 테마는 화면 설정의 라이트/다크 설정을 따르며, 사용자 지정 테마 패키지는 웹메일에서 관리해요."
},
"vacation": {
"warnings": {
+9 -6
View File
@@ -407,7 +407,14 @@
"preview": {
"open_external": "Atvērt lietotnē",
"pdf_external": "PDF faili tiek atvērti šīs ierīces PDF skatītājā.",
"unsupported": "Šim faila tipam nav priekšskatījuma."
"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.",
"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",
@@ -438,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",
@@ -1095,7 +1101,6 @@
},
"tabs": {
"plugins": "Spraudņi",
"themes": "Tēmas",
"updates": "Atjauninājumi"
},
"templates": {
@@ -1112,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": {
+9 -6
View File
@@ -407,7 +407,14 @@
"preview": {
"open_external": "Апп-д нээх",
"pdf_external": "PDF файлууд энэ төхөөрөмжийн PDF үзэгчид нээгдэнэ.",
"unsupported": "Энэ төрлийн файлыг урьдчилан харах боломжгүй."
"unsupported": "Энэ төрлийн файлыг урьдчилан харах боломжгүй.",
"media_unsupported": "Энэ файлыг энд тоглуулах боломжгүй.",
"pdf_protected": "Энэ PDF нууц үгээр хамгаалагдсан. Нууц үг оруулахын тулд өөр апп-аар нээнэ үү.",
"pdf_unreadable": "Энэ PDF-ийг энд харуулах боломжгүй.",
"archive_files": "{count, plural, one {# файл} other {# файл}}",
"archive_first_files": "Эхний {count} файл",
"archive_protected": "Энэ архив нууц үгээр хамгаалагдсан. Нууц үг оруулахын тулд өөр апп-аар нээнэ үү.",
"archive_unreadable": "Энэ архивыг энд нээх боломжгүй."
},
"received_via": "Хэнд: {name}",
"remove_color": "Тагийг хасах",
@@ -438,7 +445,6 @@
"open_error": "Файлыг нээж чадсангүй",
"open_with": "Өөр аппаар нээх…",
"preview_too_large": "Энэ файл урьдчилан харахад хэт том байна.",
"selected_count": "{count, plural, one {Сонгосон: #} other {Сонгосон: #}}",
"share_add_people": "Хүн нэмэх",
"share_empty": "Одоохондоо хэнтэй ч хуваалцаагүй",
"share_error": "Хуваалцалтыг шинэчилж чадсангүй",
@@ -1095,7 +1101,6 @@
},
"tabs": {
"plugins": "Плагинууд",
"themes": "Өнгөний загварууд",
"updates": "Шинэчлэлт"
},
"templates": {
@@ -1112,9 +1117,7 @@
},
"themes": {
"default_description": "Өгөгдмөл цайвар ба харанхуй палитр.",
"default_name": "Bulwark",
"mobile_description": "Өнгөний загвар сонгоно уу. Өнгөний загварууд Гадаад төрх хэсгийн цайвар/харанхуй тохиргоог дагана; өөрийн загварын багцуудыг вэбмэйлд удирдана.",
"title": "Өнгөний загварууд"
"mobile_description": "Өнгөний загвар сонгоно уу. Өнгөний загварууд Гадаад төрх хэсгийн цайвар/харанхуй тохиргоог дагана; өөрийн загварын багцуудыг вэбмэйлд удирдана."
},
"vacation": {
"warnings": {
+9 -6
View File
@@ -407,7 +407,14 @@
"preview": {
"open_external": "Åpne i app",
"pdf_external": "PDF-filer åpnes i PDF-leseren på denne enheten.",
"unsupported": "Ingen forhåndsvisning for denne filtypen."
"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.",
"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",
@@ -438,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",
@@ -1095,7 +1101,6 @@
},
"tabs": {
"plugins": "Programtillegg",
"themes": "Temaer",
"updates": "Oppdateringer"
},
"templates": {
@@ -1112,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": {
+9 -6
View File
@@ -407,7 +407,14 @@
"preview": {
"open_external": "Openen in app",
"pdf_external": "PDF's worden geopend in de PDF-viewer op dit apparaat.",
"unsupported": "Geen voorbeeld voor dit bestandstype."
"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.",
"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",
@@ -438,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",
@@ -1095,7 +1101,6 @@
},
"tabs": {
"plugins": "Plug-ins",
"themes": "Thema's",
"updates": "Updates"
},
"templates": {
@@ -1112,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": {
+9 -6
View File
@@ -407,7 +407,14 @@
"preview": {
"open_external": "Otwórz w aplikacji",
"pdf_external": "Pliki PDF otwierają się w przeglądarce PDF na tym urządzeniu.",
"unsupported": "Brak podglądu dla tego typu pliku."
"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ć.",
"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ę",
@@ -438,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",
@@ -1095,7 +1101,6 @@
},
"tabs": {
"plugins": "Wtyczki",
"themes": "Motywy",
"updates": "Aktualizacje"
},
"templates": {
@@ -1112,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": {
+9 -6
View File
@@ -407,7 +407,14 @@
"preview": {
"open_external": "Abrir em outro app",
"pdf_external": "PDFs são abertos no leitor de PDF deste dispositivo.",
"unsupported": "Sem pré-visualização para este tipo de arquivo."
"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.",
"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",
@@ -438,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",
@@ -1095,7 +1101,6 @@
},
"tabs": {
"plugins": "Plugins",
"themes": "Temas",
"updates": "Atualizações"
},
"templates": {
@@ -1112,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": {
+9 -6
View File
@@ -407,7 +407,14 @@
"preview": {
"open_external": "Deschideți într-o aplicație",
"pdf_external": "Fișierele PDF se deschid în vizualizatorul PDF de pe acest dispozitiv.",
"unsupported": "Nu există previzualizare pentru acest tip de fișier."
"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.",
"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",
@@ -438,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",
@@ -1095,7 +1101,6 @@
},
"tabs": {
"plugins": "Pluginuri",
"themes": "Teme",
"updates": "Actualizări"
},
"templates": {
@@ -1112,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": {
+9 -6
View File
@@ -407,7 +407,14 @@
"preview": {
"open_external": "Открыть в приложении",
"pdf_external": "PDF-файлы открываются в программе просмотра PDF на этом устройстве.",
"unsupported": "Для этого типа файлов нет предпросмотра."
"unsupported": "Для этого типа файлов нет предпросмотра.",
"media_unsupported": "Этот файл нельзя воспроизвести здесь.",
"pdf_protected": "Этот 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": "Убрать тег",
@@ -438,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": "Не удалось обновить общий доступ",
@@ -1095,7 +1101,6 @@
},
"tabs": {
"plugins": "Плагины",
"themes": "Темы",
"updates": "Обновления"
},
"templates": {
@@ -1112,9 +1117,7 @@
},
"themes": {
"default_description": "Стандартные светлая и тёмная палитры.",
"default_name": "Bulwark",
"mobile_description": "Выберите цветовую тему. Темы учитывают светлый или тёмный режим из раздела «Внешний вид»; собственными пакетами тем можно управлять в веб-почте.",
"title": "Темы"
"mobile_description": "Выберите цветовую тему. Темы учитывают светлый или тёмный режим из раздела «Внешний вид»; собственными пакетами тем можно управлять в веб-почте."
},
"vacation": {
"warnings": {
+9 -6
View File
@@ -407,7 +407,14 @@
"preview": {
"open_external": "Otvoriť v aplikácii",
"pdf_external": "Súbory PDF sa otvárajú v prehliadači PDF na tomto zariadení.",
"unsupported": "Pre tento typ súboru nie je k dispozícii náhľad."
"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ť.",
"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",
@@ -438,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",
@@ -1095,7 +1101,6 @@
},
"tabs": {
"plugins": "Pluginy",
"themes": "Témy",
"updates": "Aktualizácie"
},
"templates": {
@@ -1112,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": {
+9 -6
View File
@@ -407,7 +407,14 @@
"preview": {
"open_external": "Uygulamada aç",
"pdf_external": "PDF dosyaları bu cihazdaki PDF görüntüleyicide açılır.",
"unsupported": "Bu dosya türü için önizleme yok."
"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.",
"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",
@@ -438,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",
@@ -1095,7 +1101,6 @@
},
"tabs": {
"plugins": "Eklentiler",
"themes": "Temalar",
"updates": "Güncellemeler"
},
"templates": {
@@ -1112,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": {
+9 -6
View File
@@ -407,7 +407,14 @@
"preview": {
"open_external": "Відкрити в застосунку",
"pdf_external": "PDF-файли відкриваються в переглядачі PDF на цьому пристрої.",
"unsupported": "Для цього типу файлів немає попереднього перегляду."
"unsupported": "Для цього типу файлів немає попереднього перегляду.",
"media_unsupported": "Цей файл не можна відтворити тут.",
"pdf_protected": "Цей 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": "Прибрати тег",
@@ -438,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": "Не вдалося оновити спільний доступ",
@@ -1095,7 +1101,6 @@
},
"tabs": {
"plugins": "Плагіни",
"themes": "Теми",
"updates": "Оновлення"
},
"templates": {
@@ -1112,9 +1117,7 @@
},
"themes": {
"default_description": "Стандартні світла й темна палітри.",
"default_name": "Bulwark",
"mobile_description": "Виберіть кольорову тему. Теми враховують світлий або темний режим із розділу «Зовнішній вигляд»; власними пакетами тем можна керувати у веб-пошті.",
"title": "Теми"
"mobile_description": "Виберіть кольорову тему. Теми враховують світлий або темний режим із розділу «Зовнішній вигляд»; власними пакетами тем можна керувати у веб-пошті."
},
"vacation": {
"warnings": {
+9 -6
View File
@@ -407,7 +407,14 @@
"preview": {
"open_external": "在應用程式中開啟",
"pdf_external": "PDF 會在此裝置的 PDF 檢視器中開啟。",
"unsupported": "無法預覽此檔案類型。"
"unsupported": "無法預覽此檔案類型。",
"media_unsupported": "無法在此播放這個檔案。",
"pdf_protected": "此 PDF 受密碼保護。請在其他應用程式中開啟以輸入密碼。",
"pdf_unreadable": "無法在此顯示這個 PDF。",
"archive_files": "{count, plural, other {# 個檔案}}",
"archive_first_files": "前 {count} 個檔案",
"archive_protected": "此壓縮檔受密碼保護。請在其他應用程式中開啟以輸入密碼。",
"archive_unreadable": "無法在此開啟這個壓縮檔。"
},
"received_via": "寄給 {name}",
"remove_color": "移除標籤",
@@ -438,7 +445,6 @@
"open_error": "無法開啟檔案",
"open_with": "以其他應用程式開啟…",
"preview_too_large": "此檔案太大,無法預覽。",
"selected_count": "{count, plural, other {已選取 # 項}}",
"share_add_people": "新增使用者",
"share_empty": "尚未與任何人分享",
"share_error": "無法更新分享設定",
@@ -1095,7 +1101,6 @@
},
"tabs": {
"plugins": "外掛程式",
"themes": "主題",
"updates": "更新"
},
"templates": {
@@ -1112,9 +1117,7 @@
},
"themes": {
"default_description": "預設的淺色與深色配色。",
"default_name": "Bulwark",
"mobile_description": "選擇色彩主題。主題會依照「外觀」中的淺色/深色設定;自訂主題套件請在網頁郵件中管理。",
"title": "主題"
"mobile_description": "選擇色彩主題。主題會依照「外觀」中的淺色/深色設定;自訂主題套件請在網頁郵件中管理。"
},
"vacation": {
"warnings": {
+9 -6
View File
@@ -407,7 +407,14 @@
"preview": {
"open_external": "在应用中打开",
"pdf_external": "PDF 将在此设备的 PDF 查看器中打开。",
"unsupported": "无法预览此类文件。"
"unsupported": "无法预览此类文件。",
"media_unsupported": "无法在此播放该文件。",
"pdf_protected": "此 PDF 受密码保护。请在其他应用中打开以输入密码。",
"pdf_unreadable": "无法在此显示该 PDF。",
"archive_files": "{count, plural, other {# 个文件}}",
"archive_first_files": "前 {count} 个文件",
"archive_protected": "此压缩包受密码保护。请在其他应用中打开以输入密码。",
"archive_unreadable": "无法在此打开该压缩包。"
},
"received_via": "发给 {name}",
"remove_color": "删除标签",
@@ -438,7 +445,6 @@
"open_error": "无法打开文件",
"open_with": "打开方式…",
"preview_too_large": "此文件过大,无法预览。",
"selected_count": "{count, plural, other {已选择 # 项}}",
"share_add_people": "添加用户",
"share_empty": "尚未与任何人共享",
"share_error": "更新共享失败",
@@ -1095,7 +1101,6 @@
},
"tabs": {
"plugins": "插件",
"themes": "主题",
"updates": "更新"
},
"templates": {
@@ -1112,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
-15
View File
@@ -2,19 +2,4 @@ const { getDefaultConfig } = require('expo/metro-config');
const config = getDefaultConfig(__dirname);
// babel.config.js rewrites lucide-react-native imports to per-icon modules
// under dist/ (scripts/babel-plugin-lucide-imports.js). The package's
// `exports` map only lists its barrel, so resolve those paths as plain files
// instead of letting Metro warn about every one of them.
const LUCIDE_DEEP_IMPORT = 'lucide-react-native/dist/';
const upstreamResolveRequest = config.resolver.resolveRequest;
config.resolver.resolveRequest = (context, moduleName, platform) => {
const resolve = upstreamResolveRequest ?? context.resolveRequest;
if (moduleName.startsWith(LUCIDE_DEEP_IMPORT)) {
return resolve({ ...context, unstable_enablePackageExports: false }, moduleName, platform);
}
return resolve(context, moduleName, platform);
};
module.exports = config;
+3 -14
View File
@@ -1,12 +1,13 @@
{
"name": "bulwark-mobile",
"version": "0.1.76",
"version": "0.1.83",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bulwark-mobile",
"version": "0.1.76",
"version": "0.1.83",
"hasInstallScript": true,
"license": "AGPL-3.0-only",
"dependencies": {
"@react-native-async-storage/async-storage": "2.2.0",
@@ -33,7 +34,6 @@
"expo-status-bar": "~3.0.9",
"expo-web-browser": "~15.0.8",
"jszip": "^3.10.1",
"lucide-react-native": "^1.7.0",
"postal-mime": "^3.0.0",
"react": "19.1.0",
"react-native": "0.81.5",
@@ -7790,17 +7790,6 @@
"yallist": "^3.0.2"
}
},
"node_modules/lucide-react-native": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/lucide-react-native/-/lucide-react-native-1.7.0.tgz",
"integrity": "sha512-wGJY5nosSawh028jg8r1ZKqnGPDIVfIL9xvKOs4wPYFQHeJMHsADYm/lmuFYXMXXatSkHhpsCjeqIRgeFGzf8g==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-native": "*",
"react-native-svg": "^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+1 -2
View File
@@ -1,6 +1,6 @@
{
"name": "bulwark-mobile",
"version": "0.1.76",
"version": "0.1.83",
"license": "AGPL-3.0-only",
"main": "index.ts",
"scripts": {
@@ -43,7 +43,6 @@
"expo-status-bar": "~3.0.9",
"expo-web-browser": "~15.0.8",
"jszip": "^3.10.1",
"lucide-react-native": "^1.7.0",
"postal-mime": "^3.0.0",
"react": "19.1.0",
"react-native": "0.81.5",
+82
View File
@@ -0,0 +1,82 @@
diff --git a/node_modules/postal-mime/src/base64-decoder.js b/node_modules/postal-mime/src/base64-decoder.js
index 93c2aea..ba6e3f1 100644
--- a/node_modules/postal-mime/src/base64-decoder.js
+++ b/node_modules/postal-mime/src/base64-decoder.js
@@ -1,4 +1,4 @@
-import { decodeBase64, blobToArrayBuffer } from './decode-strings.js';
+import { decodeBase64, concatChunks } from './decode-strings.js';
export default class Base64Decoder {
constructor(opts) {
@@ -49,6 +49,6 @@ export default class Base64Decoder {
finalize() {
this.flushRemainder();
- return blobToArrayBuffer(new Blob(this.chunks, { type: 'application/octet-stream' }));
+ return concatChunks(this.chunks);
}
}
diff --git a/node_modules/postal-mime/src/decode-strings.js b/node_modules/postal-mime/src/decode-strings.js
index a5e058e..f255e8d 100644
--- a/node_modules/postal-mime/src/decode-strings.js
+++ b/node_modules/postal-mime/src/decode-strings.js
@@ -145,6 +145,23 @@ export function getDecoder(charset) {
* @param {Blob} blob Blob to convert
* @returns {ArrayBuffer} Converted value
*/
+// React Native's Blob cannot be built from ArrayBuffers or typed arrays, so
+// the decoders join their chunks here instead of through a Blob.
+export function concatChunks(chunks) {
+ const parts = chunks.map(chunk => {
+ if (typeof chunk === 'string') return textEncoder.encode(chunk);
+ if (chunk instanceof ArrayBuffer) return new Uint8Array(chunk);
+ return new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength);
+ });
+ const out = new Uint8Array(parts.reduce((n, part) => n + part.byteLength, 0));
+ let pos = 0;
+ for (const part of parts) {
+ out.set(part, pos);
+ pos += part.byteLength;
+ }
+ return out.buffer;
+}
+
export async function blobToArrayBuffer(blob) {
if ('arrayBuffer' in blob) {
return await blob.arrayBuffer();
diff --git a/node_modules/postal-mime/src/pass-through-decoder.js b/node_modules/postal-mime/src/pass-through-decoder.js
index ad1c222..1e1d012 100644
--- a/node_modules/postal-mime/src/pass-through-decoder.js
+++ b/node_modules/postal-mime/src/pass-through-decoder.js
@@ -1,4 +1,4 @@
-import { blobToArrayBuffer } from './decode-strings.js';
+import { concatChunks } from './decode-strings.js';
export default class PassThroughDecoder {
constructor() {
@@ -12,6 +12,6 @@ export default class PassThroughDecoder {
finalize() {
// convert an array of arraybuffers into a blob and then back into a single arraybuffer
- return blobToArrayBuffer(new Blob(this.chunks, { type: 'application/octet-stream' }));
+ return concatChunks(this.chunks);
}
}
diff --git a/node_modules/postal-mime/src/qp-decoder.js b/node_modules/postal-mime/src/qp-decoder.js
index adee72c..3973673 100644
--- a/node_modules/postal-mime/src/qp-decoder.js
+++ b/node_modules/postal-mime/src/qp-decoder.js
@@ -1,4 +1,4 @@
-import { blobToArrayBuffer, hexNibble } from './decode-strings.js';
+import { concatChunks, hexNibble } from './decode-strings.js';
const CHR_EQUALS = 0x3d;
const CHR_LF = 0x0a;
@@ -83,6 +83,6 @@ export default class QPDecoder {
this.flushBuffer();
// convert an array of arraybuffers into a blob and then back into a single arraybuffer
- return blobToArrayBuffer(new Blob(this.chunks, { type: 'application/octet-stream' }));
+ return concatChunks(this.chunks);
}
}
-91
View File
@@ -1,91 +0,0 @@
// Babel plugin: rewrite named imports from the `lucide-react-native` barrel to
// the per-icon modules they re-export, so the bundle only contains (and the
// app only evaluates) the icons it uses. The barrel re-exports all ~1,700
// icons, and Metro has no tree shaking, so one `import { Mail }` used to pull
// in and run every icon at startup.
//
// import { Mail, Trash2 as Bin } from 'lucide-react-native';
// becomes
// import Mail from 'lucide-react-native/dist/esm/icons/mail.js';
// import Bin from 'lucide-react-native/dist/esm/icons/trash-2.js';
//
// The name -> file table is read from the package's own ESM barrel, so icon
// aliases (`MailIcon`, `LucideMail`, renamed icons) resolve exactly as they do
// through the barrel. metro.config.js resolves these deep paths, which the
// package's `exports` map does not list.
const fs = require('node:fs');
const path = require('node:path');
const PACKAGE = 'lucide-react-native';
const ESM_DIR = 'dist/esm';
/** Map every name the barrel exports to `{ source, imported }`. */
function loadExportMap() {
// `main` is dist/cjs/lucide-react-native.js; the ESM tree sits next to it.
const pkgDir = path.resolve(path.dirname(require.resolve(PACKAGE)), '..', '..');
const barrel = fs.readFileSync(path.join(pkgDir, ESM_DIR, `${PACKAGE}.js`), 'utf8');
const map = new Map();
for (const m of barrel.matchAll(/export\s*\{([^}]*)\}\s*from\s*'\.\/([^']+)'/g)) {
const source = `${PACKAGE}/${ESM_DIR}/${m[2]}`;
for (const spec of m[1].split(',')) {
const [imported, exported = imported] = spec.trim().split(/\s+as\s+/);
if (imported) map.set(exported, { source, imported });
}
}
if (map.size === 0) {
throw new Error(`${PACKAGE}: could not read the export list from its ESM barrel`);
}
return map;
}
let exportMap = null;
function getExportMap() {
if (!exportMap) exportMap = loadExportMap();
return exportMap;
}
// A value-less binding (only used as a type) is dropped by the TypeScript
// transform anyway; treat it the same way instead of failing the build.
function isOnlyUsedAsType(importPath, localName) {
const binding = importPath.scope.getBinding(localName);
if (!binding) return true;
return binding.referencePaths.every((ref) => Boolean(ref.findParent((p) => p.isTSType())));
}
function lucideImportsPlugin({ types: t }) {
return {
name: 'lucide-direct-imports',
visitor: {
ImportDeclaration(importPath) {
const { node } = importPath;
if (node.source.value !== PACKAGE || node.importKind === 'type') return;
// Side-effect, default and namespace imports keep using the barrel.
if (node.specifiers.length === 0) return;
if (!node.specifiers.every((s) => t.isImportSpecifier(s))) return;
const map = getExportMap();
const replacements = [];
for (const spec of node.specifiers) {
if (spec.importKind === 'type') continue;
const name = t.isIdentifier(spec.imported) ? spec.imported.name : spec.imported.value;
const target = map.get(name);
if (!target) {
if (isOnlyUsedAsType(importPath, spec.local.name)) continue;
throw importPath.buildCodeFrameError(`${PACKAGE} has no export named "${name}"`);
}
const local = t.identifier(spec.local.name);
const specifier = target.imported === 'default'
? t.importDefaultSpecifier(local)
: t.importSpecifier(local, t.identifier(target.imported));
replacements.push(t.importDeclaration([specifier], t.stringLiteral(target.source)));
}
if (replacements.length === 0) importPath.remove();
else importPath.replaceWithMultiple(replacements);
},
},
};
}
module.exports = lucideImportsPlugin;
module.exports.loadExportMap = loadExportMap;
module.exports.PACKAGE = PACKAGE;
+242
View File
@@ -0,0 +1,242 @@
// Writes src/components/icons/index.ts from Tabler's outline path data.
//
// The app draws the same icon set as the webmail (Tabler Icons, MIT) under the
// names it used when it was on Lucide, so screens import `{ Mail, Trash2 }`
// from components/icons. Only the glyphs listed in MAP ship in the bundle.
//
// node scripts/generate-icons.mjs [path/to/tabler-nodes-outline.json]
//
// The default path is the webmail checkout this repo usually sits in
// (repos/react-native inside jmap-webmail), whose node_modules has
// @tabler/icons. Add a line to MAP to make a new icon importable.
import { readFileSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const SOURCE = process.argv[2]
?? join(ROOT, '..', '..', 'node_modules', '@tabler', 'icons', 'tabler-nodes-outline.json');
// App name -> Tabler outline name. Where the webmail (components/icons) maps
// the same name, this uses its choice.
const MAP = {
AlarmClock: 'alarm',
AlertCircle: 'alert-circle',
AlertTriangle: 'alert-triangle',
AlignCenter: 'align-center',
AlignLeft: 'align-left',
AlignRight: 'align-right',
Archive: 'archive',
ArrowDown: 'arrow-down',
ArrowDownWideNarrow: 'sort-descending',
ArrowLeft: 'arrow-left',
ArrowRight: 'arrow-right',
ArrowUp: 'arrow-up',
ArrowUpDown: 'arrows-sort',
ArrowUpNarrowWide: 'sort-ascending',
Ban: 'ban',
Bell: 'bell',
BellOff: 'bell-off',
BellRing: 'bell-ringing',
Bold: 'bold',
Book: 'book-2',
BookOpen: 'book',
BookUser: 'address-book',
Bug: 'bug',
Building: 'building',
Cake: 'cake',
Calendar: 'calendar',
CalendarDays: 'calendar-month',
CalendarPlus: 'calendar-plus',
Camera: 'camera',
Check: 'check',
CheckCheck: 'checks',
CheckCircle: 'circle-check',
CheckCircle2: 'circle-check',
CheckSquare: 'square-check',
ChevronDown: 'chevron-down',
ChevronLeft: 'chevron-left',
ChevronRight: 'chevron-right',
ChevronUp: 'chevron-up',
CircleCheck: 'circle-check',
ClipboardPaste: 'clipboard-text',
Clock: 'clock',
CloudDownload: 'cloud-download',
CloudOff: 'cloud-off',
Code: 'code',
Columns3: 'columns-3',
Columns4: 'layout-columns',
Copy: 'copy',
Database: 'database',
Download: 'download',
Edit2: 'pencil',
Eraser: 'eraser',
ExternalLink: 'external-link',
Eye: 'eye',
EyeOff: 'eye-off',
File: 'file',
FileArchive: 'file-zip',
FileAudio: 'file-music',
FileCode: 'file-code',
FileCode2: 'file-code-2',
FileImage: 'photo',
FileInput: 'file-import',
FileSpreadsheet: 'file-spreadsheet',
FileText: 'file-text',
FileVideo: 'video',
Filter: 'filter',
Flag: 'flag',
FlaskConical: 'flask',
Folder: 'folder',
FolderArchive: 'archive',
FolderInput: 'folder-down',
FolderOpen: 'folder-open',
FolderPlus: 'folder-plus',
FolderSync: 'folders',
Forward: 'arrow-forward-up',
Globe: 'world',
Grid3x3: 'grid-dots',
GripVertical: 'grip-vertical',
HardDrive: 'server-2',
Heading1: 'h-1',
Heading2: 'h-2',
Heart: 'heart',
HelpCircle: 'help-circle',
Highlighter: 'highlight',
Image: 'photo',
Import: 'file-import',
Inbox: 'inbox',
Info: 'info-circle',
Italic: 'italic',
Key: 'key',
KeyRound: 'key',
Languages: 'language',
Layers: 'stack',
LayoutGrid: 'layout-grid',
LayoutTemplate: 'template',
Link2: 'link',
Link2Off: 'unlink',
List: 'list',
ListChecks: 'list-check',
ListOrdered: 'list-numbers',
Lock: 'lock',
LogOut: 'logout',
Mail: 'mail',
MailCheck: 'mail-check',
MailMinus: 'mail-minus',
MailOpen: 'mail-opened',
Mails: 'inbox',
MapPin: 'map-pin',
Maximize2: 'maximize',
Menu: 'menu-2',
MessageSquare: 'message',
Minus: 'minus',
Monitor: 'device-desktop',
Moon: 'moon',
MoreHorizontal: 'dots',
MoreVertical: 'dots-vertical',
MoveVertical: 'arrows-vertical',
Palette: 'palette',
Palmtree: 'beach',
PanelLeftClose: 'layout-sidebar-left-collapse',
PanelRight: 'layout-sidebar-right',
Paperclip: 'paperclip',
PenLine: 'pencil',
PenSquare: 'edit',
Pencil: 'pencil',
Phone: 'phone',
Pin: 'pinned',
PinOff: 'pinned-off',
Plus: 'plus',
Puzzle: 'puzzle',
QrCode: 'qrcode',
Quote: 'quote',
Redo2: 'arrow-forward',
RefreshCw: 'refresh',
RemoveFormatting: 'clear-formatting',
Repeat: 'repeat',
Reply: 'arrow-back-up',
ReplyAll: 'arrow-back-up-double',
RotateCcw: 'rotate',
Rows2: 'layout-rows',
Rss: 'rss',
Save: 'device-floppy',
ScanSearch: 'zoom-scan',
Scale: 'scale',
Search: 'search',
Send: 'send',
Server: 'server',
Settings: 'settings',
Share2: 'share-2',
Shield: 'shield',
ShieldAlert: 'shield-exclamation',
ShieldCheck: 'shield-check',
ShieldQuestion: 'shield-question',
Shuffle: 'arrows-shuffle',
Smartphone: 'device-mobile',
Square: 'square',
SquareCheck: 'square-check',
SquarePen: 'edit',
Star: 'star',
StickyNote: 'note',
Strikethrough: 'strikethrough',
Sun: 'sun',
Table: 'table',
Tag: 'tag',
Tags: 'tags',
Terminal: 'terminal',
Trash: 'trash',
Trash2: 'trash',
Type: 'typography',
Underline: 'underline',
Undo2: 'arrow-back',
Upload: 'upload',
User: 'user',
UserCircle: 'user-circle',
UserMinus: 'user-minus',
UserPen: 'user-edit',
UserPlus: 'user-plus',
UserRound: 'user',
UserRoundPlus: 'user-plus',
Users: 'users',
Video: 'video',
X: 'x',
XCircle: 'circle-x',
};
function main() {
const nodes = JSON.parse(readFileSync(SOURCE, 'utf8'));
const glyphs = new Map();
const missing = [];
for (const [appName, tabler] of Object.entries(MAP)) {
const icon = nodes[tabler];
if (!icon) { missing.push(`${appName} -> ${tabler}`); continue; }
const paths = icon.map(([tag, attrs]) => {
if (tag !== 'path') throw new Error(`${tabler}: unexpected <${tag}>`);
return attrs.d;
});
glyphs.set(tabler, paths);
}
if (missing.length) throw new Error(`Not in Tabler: ${missing.join(', ')}`);
const lines = [
'// Generated by scripts/generate-icons.mjs from @tabler/icons (MIT).',
'// Do not edit by hand: add the icon to MAP in the script and run it.',
'',
"import { makeIcon } from './icon';",
'',
"export type { AppIcon, IconProps } from './icon';",
'',
'const G = {',
...[...glyphs.keys()].sort().map((k) => ` '${k}': ${JSON.stringify(glyphs.get(k))},`),
'} as const;',
'',
...Object.entries(MAP).map(([a, t]) => `export const ${a} = makeIcon('${a}', G['${t}']);`),
'',
];
writeFileSync(join(ROOT, 'src', 'components', 'icons', 'index.ts'), lines.join('\n'));
console.log(`${Object.keys(MAP).length} names, ${glyphs.size} glyphs`);
}
main();
+46
View File
@@ -0,0 +1,46 @@
// Writes src/lib/licences.json: the libraries the app ships (the runtime
// dependencies in package.json) with their licence, for Settings → About →
// Open-source licences. Run after adding or upgrading a dependency:
//
// node scripts/generate-licences.mjs
//
// The test in src/lib/__tests__/licences.test.ts fails when a dependency is
// missing from the list.
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8'));
// Code that isn't an npm dependency but ships in the app.
const VENDORED = [
{ name: 'Tabler Icons', version: '', license: 'MIT', url: 'https://tabler.io/icons' },
];
function repoUrl(meta) {
const repo = typeof meta.repository === 'string' ? meta.repository : meta.repository?.url;
const url = meta.homepage || repo || '';
return url
.replace(/^git\+/, '')
.replace(/^git:\/\//, 'https://')
.replace(/^github:/, 'https://github.com/')
.replace(/\.git$/, '')
.replace(/#.*$/, '');
}
const libraries = Object.keys(pkg.dependencies).sort().map((name) => {
const file = join(ROOT, 'node_modules', name, 'package.json');
if (!existsSync(file)) throw new Error(`${name} is not installed`);
const meta = JSON.parse(readFileSync(file, 'utf8'));
const license = typeof meta.license === 'string' ? meta.license : meta.license?.type ?? 'See package';
return { name, version: meta.version, license, url: repoUrl(meta) };
});
const out = {
app: { name: pkg.name, license: pkg.license },
libraries: [...libraries, ...VENDORED],
};
writeFileSync(join(ROOT, 'src', 'lib', 'licences.json'), `${JSON.stringify(out, null, 2)}\n`);
console.log(`${out.libraries.length} libraries`);
+138
View File
@@ -0,0 +1,138 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('../jmap-client', () => ({
jmapClient: {
accountId: 'acc-1',
request: vi.fn(),
getMaxCallsInRequest: vi.fn(() => 16),
getMaxObjectsInGet: vi.fn(() => 500),
getMaxObjectsInSet: vi.fn(() => 500),
},
}));
import { jmapClient } from '../jmap-client';
import { saveDraftVersion, type OutgoingAttachment, type OutgoingEmail } from '../email';
import { initialDraftPartBlobState, liveBlobId, rebaseDraftPartBlobs } from '../../lib/draft-part-blobs';
const mockRequest = jmapClient.request as ReturnType<typeof vi.fn>;
/**
* A tiny server with Stalwart's blob semantics, as measured on 0.16.22 (2026-10-14):
* an email's part blobIds are derived from that email and stop resolving the
* moment it is destroyed ("blobId … does not exist on this server"); creating
* an email copies the referenced bytes into new parts with new blobIds.
*/
function fakeStalwart() {
let seq = 0;
const emails = new Map<string, Array<{ blobId: string; name: string; size: number; cid?: string; type: string }>>();
const live = (blobId: string) => [...emails.values()].some((parts) => parts.some((p) => p.blobId === blobId));
const seed = (id: string, parts: OutgoingAttachment[]) => {
emails.set(id, parts.map((p) => ({ blobId: p.blobId, name: p.name, size: p.size ?? 0, cid: p.cid, type: p.type })));
};
mockRequest.mockImplementation(async (calls: Array<[string, Record<string, any>, string]>) => {
const out: Array<[string, unknown, string]> = [];
let createdId: string | undefined;
for (const [method, args, callId] of calls) {
if (method === 'Email/set' && args.create) {
const create = args.create.draft;
const dead = (create.attachments ?? []).find((a: { blobId: string }) => !live(a.blobId));
if (dead) {
out.push(['Email/set', { notCreated: { draft: {
type: 'blobNotFound', description: `blobId ${dead.blobId} does not exist on this server.`,
} } }, callId]);
continue;
}
createdId = `e${++seq}`;
emails.set(createdId, (create.attachments ?? []).map((a: OutgoingAttachment, i: number) => ({
blobId: `${createdId}-part${i}`, name: a.name, size: a.size ?? 0, cid: a.cid ? `<${a.cid}>` : undefined, type: a.type,
})));
out.push(['Email/set', { created: { draft: { id: createdId } } }, callId]);
} else if (method === 'Email/set' && args.destroy) {
for (const id of args.destroy) emails.delete(id);
out.push(['Email/set', { destroyed: args.destroy }, callId]);
} else if (method === 'Email/get') {
if (!createdId || args.ids?.[0] !== '#draft') {
out.push(['error', { type: 'invalidResultReference' }, callId]);
continue;
}
out.push(['Email/get', { list: [{ id: createdId, attachments: emails.get(createdId) }] }, callId]);
}
}
return { methodResponses: out };
});
return { seed, emails };
}
const IMG_A: OutgoingAttachment = { blobId: 'd0-part-a', name: 'schema-a.png', size: 136574, type: 'image/png', disposition: 'inline', cid: 'a@undust.co' };
const IMG_B: OutgoingAttachment = { blobId: 'd0-part-b', name: 'schema-b.png', size: 175155, type: 'image/png', disposition: 'inline', cid: 'b@undust.co' };
const draftWith = (attachments: OutgoingAttachment[]): OutgoingEmail => ({
from: [{ email: 'me@undust.co' }],
to: [{ email: 'test@example.invalid' }],
subject: 'RE: draft with two inline images',
htmlBody: '<p>x <img src="cid:a@undust.co"> <img src="cid:b@undust.co"></p>',
textBody: 'x',
attachments,
});
beforeEach(() => {
vi.clearAllMocks();
});
describe('saveDraftVersion', () => {
it('reads the new version\'s parts back in the same request, by creation id', async () => {
fakeStalwart().seed('d0', [IMG_A]);
const saved = await saveDraftVersion(draftWith([IMG_A]), 'drafts', 'd0');
const calls = mockRequest.mock.calls[0][0];
expect(calls.map((c: [string]) => c[0])).toEqual(['Email/set', 'Email/get']);
expect(calls[1][1].ids).toEqual(['#draft']);
expect(saved.id).toBe('e1');
expect(saved.attachments.map((a) => a.blobId)).toEqual(['e1-part0']);
});
it('destroys the previous version only after the new one exists', async () => {
fakeStalwart().seed('d0', [IMG_A]);
await saveDraftVersion(draftWith([IMG_A]), 'drafts', 'd0');
const destroyCall = mockRequest.mock.calls[1][0][0];
expect(destroyCall[0]).toBe('Email/set');
expect(destroyCall[1].destroy).toEqual(['d0']);
});
it('still returns the id when the read-back fails', async () => {
mockRequest.mockResolvedValueOnce({ methodResponses: [
['Email/set', { created: { draft: { id: 'e9' } } }, '0'],
['error', { type: 'serverFail' }, '1'],
] });
const saved = await saveDraftVersion(draftWith([]), 'drafts');
expect(saved).toEqual({ id: 'e9', attachments: [] });
});
});
describe('re-opened draft with inline images: save, save again, send', () => {
// What ComposeScreen does: buildOutgoing maps each held blobId through the
// state, saveDraftOnce rebases it on the parts read back.
async function editSession(rebase: boolean) {
const server = fakeStalwart();
server.seed('d0', [IMG_A, IMG_B]); // the draft as deposited (IMAP APPEND or an earlier save)
let state = initialDraftPartBlobState();
let draftId = 'd0';
const outgoing = () => draftWith([IMG_A, IMG_B].map((a) => ({ ...a, blobId: liveBlobId(state, a.blobId) })));
for (let i = 0; i < 2; i++) {
const sent = outgoing();
const saved = await saveDraftVersion(sent, 'drafts', draftId);
draftId = saved.id;
if (rebase) state = rebaseDraftPartBlobs(state, sent.attachments ?? [], saved.attachments);
}
return { server, draftId };
}
it('reproduces the bug without the rebase: the second save references the destroyed draft\'s parts', async () => {
await expect(editSession(false)).rejects.toThrow('blobId d0-part-a does not exist on this server.');
});
it('keeps every save (and so the send, which builds the same attachments) on live blobs', async () => {
const { server, draftId } = await editSession(true);
expect([...server.emails.keys()]).toEqual([draftId]);
expect(server.emails.get(draftId)!.map((p) => p.name)).toEqual(['schema-a.png', 'schema-b.png']);
});
});
+43
View File
@@ -22,6 +22,8 @@ import {
getFullEmails,
getThreadsHeaders,
createDraft,
saveDraftVersion,
destroyEmails,
sendEmail,
markAsSpam,
undoSpam,
@@ -33,6 +35,9 @@ import {
EMAIL_FULL_PROPERTIES,
} from '../email';
import { getIdentities } from '../identity';
import { uploadBytes } from '../blob';
import { initialDraftPartBlobState, liveBlobId, rebaseDraftPartBlobs } from '../../lib/draft-part-blobs';
import type { OutgoingAttachment } from '../email';
import { getCalendars, queryEvents, createEvent, deleteEvents } from '../calendar';
import { getAddressBooks, createContact, deleteContacts } from '../contacts';
@@ -192,6 +197,44 @@ live('live Stalwart', () => {
await destroyEmails([id]);
}, 60_000);
it('keeps a re-opened draft with an inline image saveable across saves (#849 part blobs)', async () => {
// A 1x1 PNG uploaded once; D0 stands for the draft as it sits on the server.
const png = Uint8Array.from(atob('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=='), (c) => c.charCodeAt(0));
const up = await uploadBytes(png, 'image/png');
const base = {
from: [{ email: USER }],
to: [{ email: 'test@example.invalid' }],
subject: '[TEST bulwark-blob — supprimable]',
htmlBody: '<p>live <img src="cid:img1@live.test"></p>',
textBody: 'live',
};
const img = (blobId: string): OutgoingAttachment => ({
blobId, type: 'image/png', name: 'img1.png', size: png.byteLength, disposition: 'inline', cid: 'img1@live.test',
});
const d0 = await saveDraftVersion({ ...base, attachments: [img(up.blobId)] }, drafts);
const created = [d0.id];
try {
// Re-open D0: the composer holds D0's part blobId.
const seeded = d0.attachments[0].blobId;
let state = initialDraftPartBlobState();
let draftId = d0.id;
for (let i = 0; i < 2; i++) {
const sent = [img(liveBlobId(state, seeded))];
const saved = await saveDraftVersion({ ...base, attachments: sent }, drafts, draftId);
created.push(saved.id);
draftId = saved.id;
state = rebaseDraftPartBlobs(state, sent, saved.attachments);
}
// Without the rebase the seeded part blob is gone with D0.
await expect(saveDraftVersion({ ...base, attachments: [img(seeded)] }, drafts))
.rejects.toThrow(/does not exist|blobNotFound/);
const [last] = await getFullEmails([draftId]);
expect(last.attachments?.[0]?.cid).toContain('img1@live.test');
} finally {
await destroyEmails(created).catch(() => undefined);
}
}, 90_000);
it('sends through Drafts and files into Sent only after submission (#188)', async () => {
const identities = await getIdentities();
expect(identities.length).toBeGreaterThan(0);
+48 -5
View File
@@ -1679,31 +1679,64 @@ export async function sendEmail(
};
}
/** A saved draft version and the attachment parts the server built for it. */
export interface SavedDraftVersion {
id: string;
/**
* Attachment and inline parts of the new version, with their blobIds. A
* part's blobId only lives as long as this version: re-opened drafts must
* send these (not the previous version's) on their next save or send.
* Empty when the server did not answer the read-back.
*/
attachments: Attachment[];
}
const DRAFT_PART_BODY_PROPERTIES = ['partId', 'blobId', 'size', 'name', 'type', 'cid', 'disposition'];
/**
* Save a draft into the Drafts folder (`$draft` + `$seen`). When `previousDraftId`
* is given it is destroyed only AFTER the replacement was created (#849): a
* combined create+destroy would delete the last good copy when the create
* fails (e.g. blobNotFound on a re-opened draft's attachments). Returns the
* new draft id.
* fails (e.g. blobNotFound on a re-opened draft's attachments). The new
* version's attachment parts are read back in the same request, so the
* composer can re-point its draft-part blobIds before the old version (and
* its part blobs) is destroyed.
*/
export async function createDraft(
export async function saveDraftVersion(
email: OutgoingEmail,
draftsMailboxId: string,
previousDraftId?: string,
accountIdOverride?: string,
): Promise<string> {
): Promise<SavedDraftVersion> {
const accountId = accountIdOverride ?? jmapClient.accountId;
const emailCreate = buildEmailCreate(email);
emailCreate.mailboxIds = { [draftsMailboxId]: true };
emailCreate.keywords = { $seen: true, $draft: true };
const res = await jmapClient.request([
['Email/set', { accountId, create: { draft: emailCreate } }, '0'],
// `#draft` is the creation id (RFC 8620 §5.3). Stalwart 0.16 does not
// resolve a result reference into an Email/set `created` map
// (invalidResultReference), but it does resolve the creation id.
['Email/get', {
accountId,
ids: ['#draft'],
properties: ['id', 'attachments'],
bodyProperties: DRAFT_PART_BODY_PROPERTIES,
}, '1'],
]);
const body = requireMethodResult(res, '0', 'Email/set');
const notCreated = body.notCreated?.draft as { description?: string; type?: string } | undefined;
if (notCreated) throw new Error(notCreated.description || notCreated.type || 'Failed to save draft');
const id = body.created?.draft?.id as string | undefined;
if (!id) throw new Error('Draft save returned no id');
let attachments: Attachment[] = [];
try {
const got = requireMethodResult(res, '1', 'Email/get');
const saved = (got.list as Array<{ id: string; attachments?: Attachment[] }> | undefined)?.find((e) => e.id === id);
attachments = (saved?.attachments ?? []).filter((a) => !!a.blobId);
} catch (err) {
console.warn('[email] could not read back the saved draft parts:', err);
}
if (previousDraftId && previousDraftId !== id) {
try {
await destroyEmails([previousDraftId], accountId);
@@ -1711,7 +1744,17 @@ export async function createDraft(
console.warn('[email] failed to destroy previous draft version:', err);
}
}
return id;
return { id, attachments };
}
/** {@link saveDraftVersion} for callers that only need the new draft id. */
export async function createDraft(
email: OutgoingEmail,
draftsMailboxId: string,
previousDraftId?: string,
accountIdOverride?: string,
): Promise<string> {
return (await saveDraftVersion(email, draftsMailboxId, previousDraftId, accountIdOverride)).id;
}
export interface ScheduledEmail {
+17 -11
View File
@@ -1,20 +1,25 @@
import React from 'react';
import { View, Text, StyleSheet, ViewStyle } from 'react-native';
import { radius, typography, type ThemePalette } from '../theme/tokens';
import { radius, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
interface BadgeProps {
/** Sentence case ("Not on this server"); badges are never upper case. */
children: string;
variant?: 'default' | 'primary' | 'destructive' | 'outline' | 'muted';
style?: ViewStyle;
}
/**
* A small status pill. The default is the webmail's outline badge: hairline
* border, muted 12px text. The tinted variants keep the same size.
*/
export default function Badge({ children, variant = 'default', style }: BadgeProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
return (
<View style={[styles.base, styles[`bg_${variant}` as const], style]}>
<Text style={[styles.text, styles[`text_${variant}` as const]]}>
<Text style={[styles.text, styles[`text_${variant}` as const]]} numberOfLines={1}>
{children}
</Text>
</View>
@@ -25,23 +30,24 @@ function makeStyles(c: ThemePalette) {
return StyleSheet.create({
base: {
borderRadius: radius.full,
borderWidth: 1,
borderColor: 'transparent',
paddingHorizontal: 8,
paddingVertical: 2,
paddingVertical: 1,
alignSelf: 'flex-start',
},
text: { ...typography.caption, fontWeight: '500' },
text: { fontSize: 12, lineHeight: 16 },
bg_default: { backgroundColor: c.surface },
bg_primary: { backgroundColor: c.primaryBg },
bg_default: { borderColor: c.border },
bg_outline: { borderColor: c.border },
bg_primary: { backgroundColor: c.accent },
bg_destructive: { backgroundColor: c.errorBg },
bg_outline: { backgroundColor: 'transparent', borderWidth: 1, borderColor: c.border },
bg_muted: { backgroundColor: c.muted },
text_default: { color: c.text },
text_primary: { color: c.primary },
text_default: { color: c.mutedForeground },
text_outline: { color: c.mutedForeground },
text_primary: { color: c.accentForeground },
text_destructive: { color: c.error },
text_outline: { color: c.text },
text_muted: { color: c.mutedForeground },
});
}
+43 -27
View File
@@ -1,9 +1,19 @@
import React from 'react';
import { Pressable, Text, StyleSheet, ViewStyle, ActivityIndicator } from 'react-native';
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens';
import { spacing, radius, componentSizes, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
type ButtonVariant = 'default' | 'ghost' | 'outline' | 'destructive';
/**
* The webmail's standard-theme buttons (components/ui/button.tsx):
* - default: bg-primary text-primary-foreground
* - secondary: bg-muted text-foreground
* - outline: border, transparent
* - ghost: no border or fill until pressed
* - destructive: red fill, white text
* - destructiveOutline: red border and text, for a reset in a settings row
* All 36 dp tall (lg 44 for a page's main action), radius 6, 14px medium.
*/
type ButtonVariant = 'default' | 'secondary' | 'ghost' | 'outline' | 'destructive' | 'destructiveOutline';
type ButtonSize = 'sm' | 'md' | 'lg' | 'icon';
interface ButtonProps {
@@ -19,8 +29,8 @@ interface ButtonProps {
accessibilityLabel?: string;
}
// Extra touch area so even the small sizes reach a 48 dp target.
const HIT_SLOP: Record<ButtonSize, number> = { sm: 6, md: 4, lg: 2, icon: 4 };
// Extra touch area so every size reaches a 48 dp target.
const HIT_SLOP: Record<ButtonSize, number> = { sm: 6, md: 6, lg: 2, icon: 4 };
export default function Button({
variant = 'default',
@@ -36,6 +46,9 @@ export default function Button({
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const isDisabled = disabled || loading;
const spinnerColor = variant === 'default'
? c.primaryForeground
: variant === 'destructive' ? '#ffffff' : c.text;
return (
<Pressable
@@ -55,15 +68,12 @@ export default function Button({
accessibilityState={{ disabled: isDisabled, busy: loading }}
>
{loading ? (
<ActivityIndicator
size="small"
color={variant === 'default' ? c.primaryForeground : c.primary}
/>
<ActivityIndicator size="small" color={spinnerColor} />
) : (
<>
{icon}
{typeof children === 'string' ? (
<Text style={[styles.text, styles[`text_${variant}` as const], isDisabled && styles.textDisabled]}>
<Text style={[styles.text, styles[`text_${variant}` as const]]} numberOfLines={1}>
{children}
</Text>
) : (
@@ -79,30 +89,36 @@ function makeStyles(c: ThemePalette) {
return StyleSheet.create({
base: {
flexDirection: 'row', alignItems: 'center', justifyContent: 'center',
borderRadius: radius.md, gap: spacing.sm,
borderRadius: radius.sm, gap: spacing.sm,
},
text: { ...typography.bodyMedium },
text: { fontSize: 14, fontWeight: '500', lineHeight: 20 },
disabled: { opacity: 0.5 },
textDisabled: { opacity: 0.5 },
size_sm: { height: componentSizes.buttonSm, paddingHorizontal: spacing.md },
size_md: { height: componentSizes.buttonMd, paddingHorizontal: spacing.lg, paddingVertical: spacing.sm },
size_lg: { height: componentSizes.buttonLg, paddingHorizontal: spacing.xxxl },
// minHeight, so a larger system font grows the button instead of clipping.
size_sm: { minHeight: componentSizes.buttonSm, paddingHorizontal: spacing.md },
size_md: { minHeight: componentSizes.buttonSm, paddingHorizontal: 14 },
size_lg: { minHeight: componentSizes.buttonLg, paddingHorizontal: spacing.xxl },
size_icon: { height: componentSizes.buttonMd, width: componentSizes.buttonMd, paddingHorizontal: 0 },
bg_default: { backgroundColor: c.primary, shadowColor: '#000', shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.05, shadowRadius: 1, elevation: 1 },
bg_ghost: { backgroundColor: 'transparent' },
bg_outline: { backgroundColor: c.background, borderWidth: 1, borderColor: c.border },
bg_destructive: { backgroundColor: c.error, shadowColor: '#000', shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.05, shadowRadius: 1, elevation: 1 },
bg_default: { backgroundColor: c.primary },
bg_secondary: { backgroundColor: c.muted },
bg_ghost: { backgroundColor: 'transparent' },
bg_outline: { backgroundColor: 'transparent', borderWidth: 1, borderColor: c.border },
bg_destructive: { backgroundColor: c.error },
bg_destructiveOutline: { backgroundColor: 'transparent', borderWidth: 1, borderColor: c.error },
pressed_default: { backgroundColor: c.primaryDark, shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.1, shadowRadius: 2, elevation: 2 },
pressed_ghost: { backgroundColor: c.accent },
pressed_outline: { backgroundColor: c.accent },
pressed_destructive: { opacity: 0.9 },
pressed_default: { opacity: 0.9 },
pressed_secondary: { opacity: 0.8 },
pressed_ghost: { backgroundColor: c.muted },
pressed_outline: { backgroundColor: c.muted },
pressed_destructive: { opacity: 0.9 },
pressed_destructiveOutline: { backgroundColor: c.errorBg },
text_default: { color: c.primaryForeground },
text_ghost: { color: c.text },
text_outline: { color: c.text },
text_destructive: { color: c.errorForeground },
text_default: { color: c.primaryForeground },
text_secondary: { color: c.text },
text_ghost: { color: c.text },
text_outline: { color: c.text },
text_destructive: { color: '#ffffff' },
text_destructiveOutline: { color: c.error },
});
}
+6 -4
View File
@@ -1,6 +1,6 @@
import React from 'react';
import { View, Text, StyleSheet, Modal, TextInput, type TextInputProps } from 'react-native';
import { AlertTriangle } from 'lucide-react-native';
import { AlertTriangle } from './icons';
import Button from './Button';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
@@ -72,7 +72,7 @@ export default function Dialog({
value={input.value}
onChangeText={input.onChangeText}
placeholder={input.placeholder}
placeholderTextColor={c.textMuted}
placeholderTextColor={c.mutedForeground}
autoFocus
onSubmitEditing={confirmDisabled ? undefined : onConfirm}
accessibilityLabel={input.placeholder ?? title}
@@ -143,15 +143,17 @@ function makeStyles(c: ThemePalette) {
color: c.mutedForeground, // text-muted-foreground
marginTop: spacing.sm,
},
// The shared Input look: 40 dp, transparent, radius 6.
input: {
...typography.body,
color: c.text,
backgroundColor: c.surface,
backgroundColor: 'transparent',
borderWidth: 1,
borderColor: c.border,
borderRadius: radius.sm,
paddingHorizontal: spacing.md,
paddingVertical: 10,
paddingVertical: 0,
minHeight: 40,
marginTop: spacing.md,
},
footer: {
+1 -1
View File
@@ -3,7 +3,7 @@ import { View, StyleSheet, Linking, Platform, Text, Pressable, useWindowDimensio
import { WebView, type WebViewMessageEvent } from 'react-native-webview';
import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { Image as ImageIcon, ShieldCheck } from 'lucide-react-native';
import { Image as ImageIcon, ShieldCheck } from './icons';
import type { Email } from '../api/types';
import { extractCidRefs } from '../lib/email-html';
import { hasTruncatedDisplayedBody, pickEmailBody, selectRenderableHtml } from '../lib/email-body';
+1 -1
View File
@@ -3,7 +3,7 @@ import {
View, Text, StyleSheet, ScrollView, Pressable, Modal, Animated, Easing,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { X, Check, Mail } from 'lucide-react-native';
import { X, Check, Mail } from './icons';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import { useSheetDrag } from '../lib/use-sheet-drag';
+12 -13
View File
@@ -14,13 +14,12 @@ interface InputProps extends TextInputProps {
}
/**
* Matches webmail input.tsx:
* - h-10 w-full rounded-md border border-input bg-background
* - px-3 py-2 text-sm text-foreground
* - placeholder:text-muted-foreground
* - hover:border-muted-foreground
* - focus:ring-2 ring-ring border-ring
* - disabled:opacity-50
* Matches webmail input.tsx in the standard theme:
* - h-10 rounded-md border border-input, transparent background
* - px-3 text-sm text-foreground, placeholder in muted-foreground
* - focus: border-ring
* - disabled: opacity-50
* - label above in text-sm font-medium text-foreground
*/
export default function Input({
label,
@@ -50,7 +49,7 @@ export default function Input({
ref={ref}
{...props}
style={[styles.input, style]}
placeholderTextColor={c.textMuted}
placeholderTextColor={c.mutedForeground}
onFocus={(e) => { setFocused(true); props.onFocus?.(e); }}
onBlur={(e) => { setFocused(false); props.onBlur?.(e); }}
/>
@@ -65,8 +64,8 @@ function makeStyles(c: ThemePalette) {
return StyleSheet.create({
label: {
...typography.bodyMedium,
color: c.textSecondary,
marginBottom: spacing.sm,
color: c.text,
marginBottom: 6,
},
inputWrapper: {
flexDirection: 'row',
@@ -75,12 +74,12 @@ function makeStyles(c: ThemePalette) {
borderWidth: 1,
borderColor: c.border, // border-input
borderRadius: radius.sm, // rounded-md = 6px
backgroundColor: c.background, // bg-background
backgroundColor: 'transparent',
paddingHorizontal: spacing.md, // px-3 = 12px
},
// Colour only, so focusing doesn't shift the text by a pixel.
inputFocused: {
borderColor: c.borderFocus, // border-ring (blue-500)
borderWidth: 2,
borderColor: c.borderFocus,
},
inputError: {
borderColor: c.error,
+3 -3
View File
@@ -5,8 +5,8 @@ import {
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import {
Inbox, Send, File as FileIcon, Trash2, Ban, Archive, Folder,
X, Check, type LucideIcon,
} from 'lucide-react-native';
X, Check, type AppIcon,
} from './icons';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import { buildMailboxTree, flattenVisible, type MailboxNode } from '../lib/mailbox-tree';
@@ -17,7 +17,7 @@ import type { Mailbox } from '../api/types';
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
import { useSheetInsets } from '../lib/use-layout';
function moveTargetIcon(role: string | null | undefined, name: string): LucideIcon {
function moveTargetIcon(role: string | null | undefined, name: string): AppIcon {
const lower = name.toLowerCase();
if (role === 'inbox' || lower.includes('inbox')) return Inbox;
if (role === 'sent' || lower.includes('sent')) return Send;
+1 -1
View File
@@ -1,6 +1,6 @@
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { CloudOff } from 'lucide-react-native';
import { CloudOff } from './icons';
import { useNetworkStore } from '../stores/network-store';
import { useOutboxStore } from '../stores/outbox-store';
import { useLocaleStore } from '../stores/locale-store';
+1 -1
View File
@@ -4,7 +4,7 @@
import React from 'react';
import { View, Text, StyleSheet, Pressable } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { CloudDownload, X } from 'lucide-react-native';
import { CloudDownload, X } from './icons';
import { useOfflineCacheStore } from '../stores/offline-cache-store';
import { useUpdatesStore } from '../stores/updates-store';
import { formatBytes } from '../lib/offline-sync';
+1 -1
View File
@@ -12,7 +12,7 @@ import {
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import * as Clipboard from 'expo-clipboard';
import { ClipboardPaste, X } from 'lucide-react-native';
import { ClipboardPaste, X } from './icons';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import Button from './Button';
+1 -1
View File
@@ -2,7 +2,7 @@ import React from 'react';
import { View, Text, StyleSheet, Pressable, Animated, Easing } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useFloatingCardFrame } from '../lib/use-layout';
import { BellRing, X } from 'lucide-react-native';
import { BellRing, X } from './icons';
import Button from './Button';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
+1 -1
View File
@@ -2,7 +2,7 @@ import React from 'react';
import { View, Text, Pressable, StyleSheet, Modal, Platform, Linking } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { CameraView, useCameraPermissions, type BarcodeScanningResult } from 'expo-camera';
import { X } from 'lucide-react-native';
import { X } from './icons';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
import { useColors, useResolvedTheme } from '../theme/colors';
import Button from './Button';
+17 -2
View File
@@ -78,10 +78,16 @@ interface Props {
onSelectionChange?: (state: RichTextSelectionState) => void;
onFocus?: () => void;
onBlur?: () => void;
/**
* Android: the page undid a caret-reveal pan of `dy` px (dp) inside the
* WebView. The host should scroll its ScrollView down by `dy` so the caret
* stays visible (see `pinVisualViewport` in editor-html).
*/
onViewportShift?: (dy: number) => void;
}
const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function RichTextEditor(
{ initialHtml = '', placeholder = '', blockRemoteImages = false, onChange, onSelectionChange, onFocus, onBlur },
{ initialHtml = '', placeholder = '', blockRemoteImages = false, onChange, onSelectionChange, onFocus, onBlur, onViewportShift },
ref,
) {
const c = useColors();
@@ -92,6 +98,8 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
const onSelectionRef = React.useRef(onSelectionChange);
const onFocusRef = React.useRef(onFocus);
const onBlurRef = React.useRef(onBlur);
const onViewportShiftRef = React.useRef(onViewportShift);
onViewportShiftRef.current = onViewportShift;
onChangeRef.current = onChange;
onSelectionRef.current = onSelectionChange;
onFocusRef.current = onFocus;
@@ -100,7 +108,9 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
// Build the source HTML once. Theming changes won't auto-rebuild the page,
// which is fine: themes change rarely and the user can recompose if needed.
const html = React.useMemo(
() => buildEditorHtml({ initialHtml, placeholder, c, blockRemoteImages }),
() => buildEditorHtml({
initialHtml, placeholder, c, blockRemoteImages, pinVisualViewport: Platform.OS === 'android',
}),
// c reference changes per theme switch but we don't want to lose user
// edits on a theme change. Bake in the initial colors only.
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -224,6 +234,11 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
case 'blur':
onBlurRef.current?.();
break;
case 'viewportShift': {
const dy = typeof data.payload === 'number' ? data.payload : Number(data.payload);
if (Number.isFinite(dy) && dy !== 0) onViewportShiftRef.current?.(dy);
break;
}
}
};
+84 -12
View File
@@ -9,9 +9,9 @@ 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,
type LucideIcon,
} from 'lucide-react-native';
CheckCheck, Eraser, FolderPlus, Pencil, AlertTriangle, UserMinus, Bell, BellOff, RotateCcw,
type AppIcon,
} from './icons';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import { useAnimDuration } from '../theme/dynamic';
@@ -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;
@@ -65,7 +70,7 @@ function iconFor(
name: string | undefined,
hasChildren: boolean,
isExpanded: boolean,
): LucideIcon {
): AppIcon {
switch (role) {
case 'inbox': return Inbox;
case 'sent': return Send;
@@ -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>
);
@@ -224,7 +236,7 @@ function SidebarRow({
interface SheetAction {
key: string;
label: string;
icon: LucideIcon;
icon: AppIcon;
destructive?: boolean;
onPress: () => void;
}
@@ -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',
@@ -685,7 +747,7 @@ export default function SidebarDrawer({ visible, onClose, docked = false }: Side
};
}, [mailboxes, accountSnapshots, activeAccountId, unifiedCrossAccount, includeGroupInUnified]);
const unifiedIcon = (role: UnifiedRole): LucideIcon => iconFor(role, undefined, false, false);
const unifiedIcon = (role: UnifiedRole): AppIcon => iconFor(role, undefined, false, false);
const slideX = React.useRef(new Animated.Value(-Dimensions.get('window').width)).current;
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
@@ -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,11 +1530,11 @@ function makeStyles(c: ThemePalette) {
...typography.bodySemibold,
color: c.text,
},
rowMuted: { marginLeft: spacing.xs },
rowLabelAccount: {
...typography.caption,
fontWeight: '600',
textTransform: 'uppercase',
letterSpacing: 0.5,
fontSize: 13,
lineHeight: 18,
fontWeight: '500',
color: c.textSecondary,
},
counts: {
+4 -4
View File
@@ -4,8 +4,8 @@ import {
} from 'react-native';
import {
Archive, Trash2, ShieldAlert, ShieldCheck, MailOpen, Mail, Star, Pin, PinOff, FolderInput,
type LucideIcon,
} from 'lucide-react-native';
type AppIcon,
} from './icons';
import { typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import { useLocaleStore } from '../stores/locale-store';
@@ -43,7 +43,7 @@ export interface SwipeContext {
// Distance the row flies off-screen by before the action callback fires.
const EXIT_DISTANCE = 600;
const ACTION_META: Record<Exclude<SwipeAction, 'none'>, { icon: LucideIcon; bg: string; defaultLabel: string }> = {
const ACTION_META: Record<Exclude<SwipeAction, 'none'>, { icon: AppIcon; bg: string; defaultLabel: string }> = {
archive: { icon: Archive, bg: '#1d4ed8', defaultLabel: 'Archive' },
delete: { icon: Trash2, bg: '#b91c1c', defaultLabel: 'Delete' },
spam: { icon: ShieldAlert, bg: '#a16207', defaultLabel: 'Spam' },
@@ -68,7 +68,7 @@ function actionLabel(action: SwipeAction, context: SwipeContext, t: Translate):
}
}
function actionIcon(action: Exclude<SwipeAction, 'none'>, context: SwipeContext): LucideIcon {
function actionIcon(action: Exclude<SwipeAction, 'none'>, context: SwipeContext): AppIcon {
if (action === 'spam' && context.inJunk) return ShieldCheck;
if (action === 'read' && !context.unread) return Mail;
if (action === 'pin' && context.pinned) return PinOff;
+1 -1
View File
@@ -3,7 +3,7 @@ import {
View, Text, StyleSheet, ScrollView, Pressable, Modal, Animated, Easing,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { X, Check, Tag } from 'lucide-react-native';
import { X, Check, Tag } from './icons';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import { useSheetDrag } from '../lib/use-sheet-drag';
+1 -1
View File
@@ -3,7 +3,7 @@ import {
View, Text, StyleSheet, ScrollView, Pressable, Modal, Animated, Easing, TextInput,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { X, Star, FileText } from 'lucide-react-native';
import { X, Star, FileText } from './icons';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import { useSheetDrag } from '../lib/use-sheet-drag';
+1 -1
View File
@@ -5,7 +5,7 @@ import {
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useFloatingCardFrame } from '../lib/use-layout';
import { AlertCircle, AlertTriangle, CheckCircle2, Info } from 'lucide-react-native';
import { AlertCircle, AlertTriangle, CheckCircle2, Info } from './icons';
import { spacing, radius, type ThemePalette } from '../theme/tokens';
import { useColors, useResolvedTheme } from '../theme/colors';
import { useShouldAnimate } from '../theme/dynamic';
+11 -7
View File
@@ -12,11 +12,9 @@ interface ToggleSwitchProps {
}
/**
* Matches webmail ToggleSwitch:
* - h-6 w-11 rounded-full
* - checked: bg-primary, unchecked: bg-muted
* - thumb: h-4 w-4 rounded-full bg-background
* - translate-x-6 (checked) / translate-x-1 (unchecked)
* The webmail switch in the standard theme: a 44 x 24 track, bg-primary when
* on (knob in primary-foreground), bg-muted when off (knob in background).
* The knob is 20 dp with 2 dp of track around it.
*/
export default function ToggleSwitch({ value, onValueChange, disabled = false, accessibilityLabel }: ToggleSwitchProps) {
const c = useColors();
@@ -38,7 +36,8 @@ export default function ToggleSwitch({ value, onValueChange, disabled = false, a
<View
style={[
styles.thumb,
{ transform: [{ translateX: value ? 24 : 4 }] },
value ? styles.thumbOn : styles.thumbOff,
{ transform: [{ translateX: value ? 22 : 2 }] },
]}
/>
</Pressable>
@@ -60,9 +59,14 @@ function makeStyles(c: ThemePalette) {
backgroundColor: c.muted,
},
thumb: {
width: componentSizes.toggleThumb, // h-4 w-4 = 16
width: componentSizes.toggleThumb,
height: componentSizes.toggleThumb,
borderRadius: radius.full,
},
thumbOn: {
backgroundColor: c.primaryForeground,
},
thumbOff: {
backgroundColor: c.background,
},
disabled: {
+1 -1
View File
@@ -1,7 +1,7 @@
import React from 'react';
import { View, Text, StyleSheet, Pressable, Linking } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Download, ShieldAlert, X } from 'lucide-react-native';
import { Download, ShieldAlert, X } from './icons';
import { useUpdatesStore } from '../stores/updates-store';
import { useLocaleStore } from '../stores/locale-store';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
@@ -8,7 +8,7 @@ import { findUntranslatedLiterals } from './untranslated-literals';
// Pane file (relative to src/components/) → texts that stay as they are on
// purpose: product names, units, format patterns and sample data.
const TRANSLATED_PANES: Record<string, string[]> = {
'settings/AboutDataSettings.tsx': ['MB', 'GitHub'],
'settings/AboutSettings.tsx': [],
'settings/AccountSecuritySettings.tsx': ['AES-128', 'AES-256'],
'settings/AccountSettings.tsx': [],
'settings/AppearanceSettings.tsx': [],
@@ -31,12 +31,14 @@ const TRANSLATED_PANES: Record<string, string[]> = {
'settings/LanguageSettings.tsx': [],
'settings/LayoutSettings.tsx': [],
'settings/NotificationSettings.tsx': [],
'settings/PluginsSettings.tsx': [],
'settings/LicencesSettings.tsx': ['GNU Affero General Public License v3.0'],
'settings/PrivacySettings.tsx': [],
'settings/ReadingSettings.tsx': [],
'settings/SidebarAppsSettings.tsx': ['https://example.com'],
'settings/SmimeSettings.tsx': [],
'settings/StorageSettings.tsx': ['MB'],
'settings/TemplateSettings.tsx': ['JSON', '{"templates": [...]}'],
'settings/ThemesSettings.tsx': [],
'settings/TroubleshootingSettings.tsx': [],
'settings/UpdatesSettings.tsx': [],
'settings/VacationSettings.tsx': ['YYYY-MM-DD HH:MM'],
'settings/settings-section.tsx': [],
@@ -10,7 +10,7 @@ import {
TouchableWithoutFeedback,
View,
} from 'react-native';
import { Check } from 'lucide-react-native';
import { Check } from '../icons';
import type { Calendar } from '../../api/types';
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
+1 -1
View File
@@ -1,7 +1,7 @@
import React from 'react';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Animated, Easing, Pressable, StyleSheet, Text, View } from 'react-native';
import { CalendarPlus, CircleCheck, Plus } from 'lucide-react-native';
import { CalendarPlus, CircleCheck, Plus } from '../icons';
import { componentSizes, radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useAnimDuration } from '../../theme/dynamic';
@@ -11,7 +11,7 @@ import {
TouchableWithoutFeedback,
View,
} from 'react-native';
import { Trash2, UserPlus, Users } from 'lucide-react-native';
import { Trash2, UserPlus, Users } from '../icons';
import { getPrincipals, ownPrincipalId } from '../../api/files';
import type { Calendar, CalendarRights, Principal } from '../../api/types';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';

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