This commit is contained in:
Linus Rath
2026-04-14 14:30:22 +02:00
24 changed files with 475 additions and 187 deletions
+2 -2
View File
@@ -51,9 +51,9 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
const isFocusedMailLayout = mailLayout === 'focus';
const inlinePreview = showPreview && email.preview ? ` ${email.preview}` : '';
// Resolve color tags using keyword definitions from settings
// Resolve color tags using keyword definitions from settings; unknown tags fall back to gray
const colorTagIds = getEmailColorTags(email.keywords);
const keywordDefs = colorTagIds.map(id => emailKeywords.find(k => k.id === id)).filter(Boolean) as typeof emailKeywords;
const keywordDefs = colorTagIds.map(id => emailKeywords.find(k => k.id === id) ?? { id, label: id, color: 'gray' });
// Use first tag for background coloring
const keywordDef = keywordDefs[0] ?? null;
const colorTag = keywordDef ? KEYWORD_PALETTE[keywordDef.color]?.bg ?? null : null;
+8 -8
View File
@@ -3070,13 +3070,13 @@ export function EmailViewer({
<>
<span className="flex items-center gap-0.5">
{currentColors.slice(0, 3).map((tagId) => {
const kw = emailKeywords.find(k => k.id === tagId);
return kw ? <span key={tagId} className={cn("w-3 h-3 rounded-full", KEYWORD_PALETTE[kw.color]?.dot)} /> : null;
const kw = emailKeywords.find(k => k.id === tagId) ?? { id: tagId, label: tagId, color: 'gray' };
return <span key={tagId} className={cn("w-3 h-3 rounded-full", KEYWORD_PALETTE[kw.color]?.dot || 'bg-gray-500')} />;
})}
</span>
{showToolbarLabels && currentColors.length === 1 && (
<span className="text-xs font-medium text-foreground">
{emailKeywords.find(k => k.id === currentColors[0])?.label}
{emailKeywords.find(k => k.id === currentColors[0])?.label ?? currentColors[0]}
</span>
)}
</>
@@ -3674,11 +3674,11 @@ export function EmailViewer({
{currentColors.length > 0 && (
<span className="flex items-center gap-0.5">
{currentColors.map((tagId) => {
const kw = emailKeywords.find(k => k.id === tagId);
const dotClass = kw ? KEYWORD_PALETTE[kw.color]?.dot : null;
return dotClass ? (
<span key={tagId} className={cn("w-2.5 h-2.5 rounded-full flex-shrink-0", dotClass)} title={kw!.label} />
) : null;
const kw = emailKeywords.find(k => k.id === tagId) ?? { id: tagId, label: tagId, color: 'gray' };
const dotClass = KEYWORD_PALETTE[kw.color]?.dot || 'bg-gray-500';
return (
<span key={tagId} className={cn("w-2.5 h-2.5 rounded-full flex-shrink-0", dotClass)} title={kw.label} />
);
})}
</span>
)}
+3 -3
View File
@@ -67,9 +67,9 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
const isFocusedMailLayout = mailLayout === 'focus';
const inlinePreview = showPreview && email.preview ? ` ${email.preview}` : '';
// Resolve color tags using keyword definitions
// Resolve color tags using keyword definitions; unknown tags fall back to gray
const tagIds = getEmailColorTags(email.keywords);
const resolvedKeywordDefs = tagIds.map(id => emailKeywords.find(k => k.id === id)).filter(Boolean) as typeof emailKeywords;
const resolvedKeywordDefs = tagIds.map(id => emailKeywords.find(k => k.id === id) ?? { id, label: id, color: 'gray' });
const resolvedKeywordDef = resolvedKeywordDefs[0] ?? null;
const resolvedColorTag = (() => {
if (colorTag) return colorTag;
@@ -375,7 +375,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
const threadColor = getThreadColorTag(thread.emails);
const emailKeywordDefs = useSettingsStore((state) => state.emailKeywords);
const keywordDef = threadColor ? emailKeywordDefs.find(k => k.id === threadColor) : null;
const keywordDef = threadColor ? (emailKeywordDefs.find(k => k.id === threadColor) ?? { id: threadColor, label: threadColor, color: 'gray' }) : null;
const colorTag = keywordDef ? KEYWORD_PALETTE[keywordDef.color]?.bg ?? null : null;
const isSelected = selectedEmailId === latestEmail.id ||
+11 -4
View File
@@ -17,6 +17,7 @@ import type {
} from "@/lib/jmap/sieve-types";
import type { Mailbox } from "@/lib/jmap/types";
import { buildMailboxTree, flattenMailboxTree, type MailboxNode, generateUUID } from "@/lib/utils";
import { useSettingsStore } from "@/stores/settings-store";
interface FilterRuleModalProps {
rule?: FilterRule;
@@ -58,6 +59,7 @@ export function FilterRuleModal({
}: FilterRuleModalProps) {
const t = useTranslations("settings.filters");
const isEdit = !!rule;
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
const [name, setName] = useState(rule?.name || "");
const [matchType, setMatchType] = useState<"all" | "any">(rule?.matchType || "all");
@@ -375,12 +377,17 @@ export function FilterRuleModal({
)}
{action.type === "add_label" && (
<Input
<select
value={action.value || ""}
onChange={(e) => updateAction(index, { value: e.target.value })}
placeholder={t("label_placeholder")}
className="flex-1 min-w-[140px]"
/>
className={`${selectClass} flex-1 min-w-[140px]`}
aria-label={t("label_placeholder")}
>
<option value="">{t("label_placeholder")}</option>
{emailKeywords.map((kw) => (
<option key={kw.id} value={kw.id}>{kw.label}</option>
))}
</select>
)}
<button
+27 -2
View File
@@ -14,6 +14,7 @@ import type {
AdminPageSection,
CalendarEventAction,
SlotName,
PluginI18n,
} from './plugin-types';
import { IMPLICIT_PERMISSIONS as IMPLICIT } from './plugin-types';
import {
@@ -22,8 +23,9 @@ import {
taskHooks, templateHooks, smimeHooks, vacationHooks,
uiHooks, themeHooks, toastHooks, dragDropHooks,
keyboardHooks, appLifecycleHooks, accountSecurityHooks,
sidebarAppHooks, avatarHooks,
sidebarAppHooks, avatarHooks, renderHooks,
} from './plugin-hooks';
import { createPluginI18n } from './plugin-i18n';
import { toast as appToast } from '@/stores/toast-store';
import { useAuthStore } from '@/stores/auth-store';
@@ -109,6 +111,8 @@ function createPluginLogger(pluginId: string) {
export interface PluginAPI {
plugin: { id: string; version: string; settings: Record<string, unknown> };
/** Localisation API — register translations and call t() to get strings */
i18n: PluginI18n;
ui: {
registerToolbarAction: (action: ToolbarAction) => Disposable;
registerEmailBanner: (factory: BannerFactory) => Disposable;
@@ -149,6 +153,8 @@ export interface PluginHooksAPI {
onEmailClose: (handler: () => void) => Disposable;
onEmailContentRender: (handler: (...args: unknown[]) => unknown) => Disposable;
onThreadExpand: (handler: (...args: unknown[]) => unknown) => Disposable;
/** Intercept — receives ComposeOptions, may mutate fields, return false to cancel */
onBeforeCompose: (handler: (options: import('./plugin-types').ComposeOptions) => boolean | void | Promise<boolean | void>) => Disposable;
onComposerOpen: (handler: (...args: unknown[]) => unknown) => Disposable;
onBeforeEmailSend: (handler: (...args: unknown[]) => unknown) => Disposable;
onAfterEmailSend: (handler: (...args: unknown[]) => unknown) => Disposable;
@@ -157,6 +163,10 @@ export interface PluginHooksAPI {
onAfterEmailDelete: (handler: (...args: unknown[]) => unknown) => Disposable;
onBeforeEmailMove: (handler: (...args: unknown[]) => unknown) => Disposable;
onAfterEmailMove: (handler: (...args: unknown[]) => unknown) => Disposable;
/** Emitted after emails are moved to the Archive mailbox */
onEmailArchive: (handler: (emailIds: string[]) => void) => Disposable;
/** Emitted after emails are moved out of the Archive mailbox */
onEmailUnarchive: (handler: (emailIds: string[]) => void) => Disposable;
onEmailReadStateChange: (handler: (...args: unknown[]) => unknown) => Disposable;
onEmailStarToggle: (handler: (...args: unknown[]) => unknown) => Disposable;
onEmailSpamToggle: (handler: (...args: unknown[]) => unknown) => Disposable;
@@ -173,6 +183,8 @@ export interface PluginHooksAPI {
onNewEmailReceived: (handler: (...args: unknown[]) => unknown) => Disposable;
onPushConnectionChange: (handler: (...args: unknown[]) => unknown) => Disposable;
onQuotaChange: (handler: (...args: unknown[]) => unknown) => Disposable;
/** Intercept — receives MailtoContext, return false to prevent the system mail client */
onMailtoIntercept: (handler: (ctx: import('./plugin-types').MailtoContext) => boolean | void | Promise<boolean | void>) => Disposable;
// Calendar
onCalendarEventOpen: (handler: (...args: unknown[]) => unknown) => Disposable;
onBeforeEventCreate: (handler: (...args: unknown[]) => unknown) => Disposable;
@@ -215,6 +227,8 @@ export interface PluginHooksAPI {
onDirectoryCreate: (handler: (...args: unknown[]) => unknown) => Disposable;
onBeforeFileDelete: (handler: (...args: unknown[]) => unknown) => Disposable;
onAfterFileDelete: (handler: (...args: unknown[]) => unknown) => Disposable;
/** Intercept — receives { file: FileResourceView, newName: string }, return false to cancel */
onBeforeFileRename: (handler: (ctx: { file: import('./plugin-types').FileResourceView; newName: string }) => boolean | void | Promise<boolean | void>) => Disposable;
onFileRename: (handler: (...args: unknown[]) => unknown) => Disposable;
onFileMove: (handler: (...args: unknown[]) => unknown) => Disposable;
onFileCopy: (handler: (...args: unknown[]) => unknown) => Disposable;
@@ -316,6 +330,9 @@ export interface PluginHooksAPI {
onSidebarAppChange: (handler: (...args: unknown[]) => unknown) => Disposable;
// Avatar
onAvatarResolve: (handler: (...args: unknown[]) => unknown) => Disposable;
// Render — transform hook for email list row badges
// Handler: (badges: EmailListBadge[], ctx: { emailId: string; email: EmailReadView }) => EmailListBadge[]
onEmailListItemRender: (handler: (...args: unknown[]) => unknown) => Disposable;
}
// --- Permission mapping for hooks ----------------------------
@@ -324,14 +341,17 @@ const HOOK_PERMISSIONS: Record<string, Permission> = {
// Email
onEmailOpen: 'email:read', onEmailClose: 'email:read',
onEmailContentRender: 'email:read', onThreadExpand: 'email:read',
onComposerOpen: 'email:read', onDraftAutoSave: 'email:read',
onBeforeCompose: 'email:read', onComposerOpen: 'email:read',
onDraftAutoSave: 'email:read',
onMailboxChange: 'email:read', onMailboxesRefresh: 'email:read',
onSearch: 'email:read', onSearchResults: 'email:read',
onEmailSelectionChange: 'email:read', onNewEmailReceived: 'email:read',
onPushConnectionChange: 'email:read', onQuotaChange: 'email:read',
onMailtoIntercept: 'email:read', onEmailListItemRender: 'email:read',
onBeforeEmailSend: 'email:send', onAfterEmailSend: 'email:send',
onBeforeEmailDelete: 'email:write', onAfterEmailDelete: 'email:write',
onBeforeEmailMove: 'email:write', onAfterEmailMove: 'email:write',
onEmailArchive: 'email:write', onEmailUnarchive: 'email:write',
onEmailReadStateChange: 'email:write', onEmailStarToggle: 'email:write',
onEmailSpamToggle: 'email:write', onEmailKeywordChange: 'email:write',
onMailboxCreate: 'email:write', onMailboxRename: 'email:write',
@@ -358,6 +378,7 @@ const HOOK_PERMISSIONS: Record<string, Permission> = {
onBeforeFileUpload: 'files:write', onAfterFileUpload: 'files:write',
onFileUploadCancel: 'files:write', onDirectoryCreate: 'files:write',
onBeforeFileDelete: 'files:write', onAfterFileDelete: 'files:write',
onBeforeFileRename: 'files:write',
onFileRename: 'files:write', onFileMove: 'files:write', onFileCopy: 'files:write',
onFileDuplicate: 'files:write', onFileFavoriteToggle: 'files:write', onFileUndo: 'files:write',
// Auth
@@ -469,6 +490,8 @@ const HOOK_BUSES: Record<string, { register: (pluginId: string, handler: (...arg
...Object.fromEntries(Object.entries(sidebarAppHooks)),
// Avatar
...Object.fromEntries(Object.entries(avatarHooks)),
// Render
...Object.fromEntries(Object.entries(renderHooks)),
};
// --- Slot registration bridge --------------------------------
@@ -530,6 +553,8 @@ export function createPluginAPI(plugin: InstalledPlugin): PluginAPI {
settings: { ...plugin.settings },
},
i18n: createPluginI18n(plugin.id),
ui: {
registerToolbarAction: (action: ToolbarAction) => {
requirePermission(plugin, 'ui:toolbar');
+26 -1
View File
@@ -172,6 +172,10 @@ export const emailHooks = {
onEmailClose: new HookBus(),
onEmailContentRender: new HookBus(),
onThreadExpand: new HookBus(),
// Intercept hook — fires before the composer opens.
// Handlers receive ComposeOptions and may mutate fields in place.
// Return false to cancel opening the composer.
onBeforeCompose: new HookBus(),
onComposerOpen: new HookBus(),
onBeforeEmailSend: new HookBus(),
onAfterEmailSend: new HookBus(),
@@ -180,6 +184,10 @@ export const emailHooks = {
onAfterEmailDelete: new HookBus(),
onBeforeEmailMove: new HookBus(),
onAfterEmailMove: new HookBus(),
// Fired after one or more emails are archived to the Archive mailbox
onEmailArchive: new HookBus(),
// Fired after one or more emails are moved out of the Archive mailbox
onEmailUnarchive: new HookBus(),
onEmailReadStateChange: new HookBus(),
onEmailStarToggle: new HookBus(),
onEmailSpamToggle: new HookBus(),
@@ -196,6 +204,9 @@ export const emailHooks = {
onNewEmailReceived: new HookBus(),
onPushConnectionChange: new HookBus(),
onQuotaChange: new HookBus(),
// Intercept hook — fired when a mailto: link is clicked.
// Return false to prevent the browser from opening the system mail client.
onMailtoIntercept: new HookBus(),
};
// §7.2 Calendar Hooks
@@ -250,6 +261,10 @@ export const fileHooks = {
onDirectoryCreate: new HookBus(),
onBeforeFileDelete: new HookBus(),
onAfterFileDelete: new HookBus(),
// Intercept hook — fires before a file is renamed.
// Receives { file: FileResourceView, newName: string }.
// Return false to cancel the rename.
onBeforeFileRename: new HookBus(),
onFileRename: new HookBus(),
onFileMove: new HookBus(),
onFileCopy: new HookBus(),
@@ -406,6 +421,16 @@ export const avatarHooks = {
onAvatarResolve: new HookBus(),
};
// §7.22 Render Hooks
export const renderHooks = {
// Transform hook — runs for each visible email list row.
// Initial value: EmailListBadge[] (always starts as [])
// Second argument: { emailId: string; email: EmailReadView }
// Handlers return a new (or extended) badges array.
// Rendered by the email list row component next to the subject line.
onEmailListItemRender: new HookBus(),
};
// ─── Aggregate: remove all handlers for a plugin across all buses ───
const allHookGroups = [
@@ -414,7 +439,7 @@ const allHookGroups = [
taskHooks, templateHooks, smimeHooks, vacationHooks,
uiHooks, themeHooks, toastHooks, dragDropHooks,
keyboardHooks, appLifecycleHooks, accountSecurityHooks, sidebarAppHooks,
avatarHooks,
avatarHooks, renderHooks,
];
export function removeAllPluginHooks(pluginId: string): void {
+118
View File
@@ -0,0 +1,118 @@
// Plugin i18n registry — manages per-plugin translation tables
//
// Each plugin gets its own namespace keyed by:
// pluginId → locale → { messageKey → translated string }
//
// Resolution order when calling t(key):
// 1. Exact locale match ("fr-CA")
// 2. Language-prefix match ("fr" from "fr-CA")
// 3. English fallback ("en")
// 4. Raw key (plugin is never broken by missing strings)
//
// Interpolation uses {paramName} placeholders.
// ─── Registry ────────────────────────────────────────────────
/** pluginId → locale → key → translated string */
const registry = new Map<string, Map<string, Record<string, string>>>();
let currentLocale = 'en';
// ─── Locale sync (called by plugin-loader) ───────────────────
/** Keep the registry in sync with the app locale */
export function setPluginI18nLocale(locale: string): void {
currentLocale = locale;
}
export function getPluginI18nLocale(): string {
return currentLocale;
}
// ─── Cleanup ─────────────────────────────────────────────────
/** Remove all translations for a plugin (called on deactivation) */
export function clearPluginI18nTranslations(pluginId: string): void {
registry.delete(pluginId);
}
// ─── Helpers ─────────────────────────────────────────────────
function interpolate(template: string, params?: Record<string, string | number>): string {
if (!params) return template;
return template.replace(/\{(\w+)\}/g, (_, key) => String(params[key] ?? `{${key}}`));
}
function resolve(pluginId: string, key: string): string | undefined {
const byLocale = registry.get(pluginId);
if (!byLocale) return undefined;
// 1. Exact locale (e.g. "fr-CA")
const exact = byLocale.get(currentLocale)?.[key];
if (exact !== undefined) return exact;
// 2. Language prefix (e.g. "fr" from "fr-CA")
const lang = currentLocale.split('-')[0];
if (lang !== currentLocale) {
const langMatch = byLocale.get(lang)?.[key];
if (langMatch !== undefined) return langMatch;
}
// 3. English fallback
return byLocale.get('en')?.[key];
}
// ─── Public API factory ──────────────────────────────────────
/**
* Build the i18n API object exposed as `api.i18n` inside each plugin.
*
* @example
* // In your plugin activate():
* api.i18n.addTranslations('en', { 'banner.title': 'Hello' });
* api.i18n.addTranslations('de', { 'banner.title': 'Hallo' });
*
* // Later, in any React component the plugin renders:
* const title = api.i18n.t('banner.title');
* const greeting = api.i18n.t('welcome', { name: 'Alice' }); // 'Hello, {name}!'
*/
export function createPluginI18n(pluginId: string) {
return {
/**
* Register translations for one locale.
* Multiple calls for the same locale are merged (last-write-wins on key collision).
*
* @param locale BCP-47 locale tag, e.g. "en", "de", "fr-CA"
* @param strings Key → translated string map. Use {paramName} for interpolation.
*/
addTranslations(locale: string, strings: Record<string, string>): void {
let byLocale = registry.get(pluginId);
if (!byLocale) {
byLocale = new Map<string, Record<string, string>>();
registry.set(pluginId, byLocale);
}
const existing = byLocale.get(locale) ?? {};
byLocale.set(locale, { ...existing, ...strings });
},
/**
* Translate a key using the current app locale.
* Falls back through: exact locale → language prefix → 'en' → raw key.
*
* @param key Translation key, e.g. `'banner.title'`
* @param params Optional interpolation values, e.g. `{ count: 3 }`
*/
t(key: string, params?: Record<string, string | number>): string {
const template = resolve(pluginId, key);
if (template !== undefined) return interpolate(template, params);
return key; // never throw — just return the key
},
/** The current app locale (e.g. "en", "de", "fr") */
getLocale(): string {
return currentLocale;
},
};
}
export type PluginI18nInstance = ReturnType<typeof createPluginI18n>;
+25 -1
View File
@@ -1,15 +1,18 @@
// Plugin Loader — loads and activates plugins via blob URL dynamic import
// Plugin Loader loads and activates plugins via blob URL dynamic import
import type { InstalledPlugin, Disposable } from './plugin-types';
import { pluginStorage } from './plugin-storage';
import { createPluginAPI, type PluginAPI } from './plugin-api';
import { removeAllPluginHooks, pluginErrorTracker } from './plugin-hooks';
import { setPluginI18nLocale, clearPluginI18nTranslations } from './plugin-i18n';
import React from 'react';
import ReactDOM from 'react-dom';
import * as ReactJSX from 'react/jsx-runtime';
// --- Shared React (window.__PLUGIN_EXTERNALS__) -------------
let localeSyncInitialised = false;
export function exposePluginExternals(): void {
if (typeof window === 'undefined') return;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -18,6 +21,16 @@ export function exposePluginExternals(): void {
ReactDOM,
ReactJSX,
};
// Sync plugin i18n with the app locale (runs once per page load)
if (!localeSyncInitialised) {
localeSyncInitialised = true;
// Dynamic import avoids a circular dependency chain at module evaluation time
import('@/stores/locale-store').then(({ useLocaleStore }) => {
setPluginI18nLocale(useLocaleStore.getState().locale);
useLocaleStore.subscribe((state) => setPluginI18nLocale(state.locale));
}).catch(() => {/* locale sync is best-effort */});
}
}
// --- Active plugin tracking ----------------------------------
@@ -78,6 +91,14 @@ export async function loadPlugin(plugin: InstalledPlugin): Promise<void> {
// 4. Build sandboxed API
const api = createPluginAPI(plugin);
// 4b. Auto-register translations bundled in the manifest (plugin.locales)
// Plugins may still call api.i18n.addTranslations() in activate() to add more.
if (plugin.locales) {
for (const [locale, strings] of Object.entries(plugin.locales)) {
api.i18n.addTranslations(locale, strings);
}
}
// 5. Call activate
const disposable = await mod.activate(api);
@@ -119,6 +140,9 @@ export function deactivatePlugin(pluginId: string): void {
// Remove all hook subscriptions for this plugin
removeAllPluginHooks(pluginId);
// Clear cached translations (avoids memory leak on repeated enable/disable cycles)
clearPluginI18nTranslations(pluginId);
// Reset error tracker
pluginErrorTracker.reset(pluginId);
+89
View File
@@ -34,6 +34,13 @@ export interface PluginManifest {
entrypoint: string;
minAppVersion?: string;
settingsSchema?: Record<string, SettingFieldSchema>;
/**
* Bundled translations shipped inside the plugin ZIP.
* Keyed by BCP-47 locale tag ("en", "de", "fr-CA", …).
* The loader auto-registers these before calling activate(),
* so plugins can use api.i18n.t() without calling addTranslations() first.
*/
locales?: Record<string, Record<string, string>>;
}
export interface SettingFieldSchema {
@@ -83,6 +90,8 @@ export interface InstalledPlugin {
adminApproved?: boolean;
settingsSchema?: Record<string, SettingFieldSchema>;
settings: Record<string, unknown>;
/** Bundled translations, carried over from the manifest on install. */
locales?: Record<string, Record<string, string>>;
}
// ─── UI Slots ────────────────────────────────────────────────
@@ -388,6 +397,86 @@ export interface ComposerContext {
originalSubject?: string;
}
// ─── New hook context types ──────────────────────────────────
/**
* Passed to onBeforeCompose handlers.
* Handlers may mutate the object in place to pre-fill fields; returning false cancels the compose.
*/
export interface ComposeOptions {
to: string[];
cc: string[];
subject: string;
body: string;
mode: 'new' | 'reply' | 'reply-all' | 'forward';
}
/**
* A small visual indicator injected into an email list row via onEmailListItemRender.
*/
export interface EmailListBadge {
/** Stable unique key within the plugin — used as React key */
key: string;
/** Short label text displayed in the badge */
label: string;
/** CSS color value for the badge background, e.g. "#e74c3c" or "var(--color-warning)" */
color?: string;
/** Tooltip / aria-label */
title?: string;
}
/**
* Passed to onMailtoIntercept handlers.
* Return false to prevent the browser from opening the system mail client.
*/
export interface MailtoContext {
/** The raw href, e.g. "mailto:alice@example.com?subject=Hello" */
href: string;
/** Parsed list of recipient addresses */
to: string[];
subject?: string;
body?: string;
}
// ─── Plugin i18n API ─────────────────────────────────────────
/**
* Localisation API exposed as `api.i18n` inside every plugin.
*
* Plugins ship their own translation tables; the app locale is tracked
* automatically so `t()` always returns the right string without any
* extra setup from the plugin side.
*/
export interface PluginI18n {
/**
* Register translations for one locale.
* Multiple calls for the same locale are merged (last-write-wins per key).
*
* @param locale BCP-47 tag, e.g. "en", "de", "fr-CA"
* @param strings Key → translated string map. Use {paramName} for interpolation.
*
* @example
* api.i18n.addTranslations('en', { 'banner.title': 'Tracking blocked' });
* api.i18n.addTranslations('de', { 'banner.title': 'Tracking blockiert' });
*/
addTranslations(locale: string, strings: Record<string, string>): void;
/**
* Return the translated string for `key` using the current app locale,
* with optional {param} interpolation.
*
* Falls back: exact locale → language prefix → "en" → raw key.
*
* @example
* api.i18n.t('banner.title')
* api.i18n.t('items_found', { count: 3 }) // 'Found {count} items' → 'Found 3 items'
*/
t(key: string, params?: Record<string, string | number>): string;
/** The current app locale string (e.g. "en", "de", "fr") */
getLocale(): string;
}
// ─── Permission Reference ────────────────────────────────────
export const ALL_PERMISSIONS = [
+2 -2
View File
@@ -194,9 +194,9 @@ describe('generateScript', () => {
expect(script).toContain('addflag "\\\\Flagged";');
});
it('generates add_label as addflag $Label', () => {
it('generates add_label as addflag $label:Label', () => {
const script = generateScript([makeRule({ actions: [{ type: 'add_label', value: 'Important' }] })]);
expect(script).toContain('addflag "$Important";');
expect(script).toContain('addflag "$label:Important";');
});
it('generates discard', () => {
+1 -1
View File
@@ -65,7 +65,7 @@ function generateActions(actions: FilterAction[]): string[] {
case 'star':
return 'addflag "\\\\Flagged";';
case 'add_label':
return `addflag "$${escapeString(action.value || '')}";`;
return `addflag "$label:${escapeString(action.value || '')}";`;
case 'discard':
return 'discard;';
case 'reject':
+12 -12
View File
@@ -657,7 +657,7 @@
"filters": "Filter",
"templates": "Vorlagen",
"folders": "Ordner",
"keywords": "Schlüsselwörter",
"keywords": "Labels",
"security": "Sicherheit",
"encryption": "Verschlüsselung",
"files": "Dateien",
@@ -725,23 +725,23 @@
}
},
"keywords": {
"title": "E-Mail-Schlüsselwörter",
"description": "Definieren Sie Schlüsselwörter (Labels/Tags) zum Organisieren Ihrer E-Mails mit Farben.",
"add_keyword": "Schlüsselwort hinzufügen",
"title": "E-Mail-Labels",
"description": "Labels definieren, um Ihre E-Mails mit Farben zu organisieren. Diese werden als JMAP-Keywords auf dem Server gespeichert.",
"add_keyword": "Label hinzufügen",
"reset_defaults": "Auf Standard zurücksetzen",
"label_field": "Anzeigename",
"label_placeholder": "z.B. Arbeit, Privat, Dringend",
"id_field": "Schlüsselwort-ID",
"id_field": "Label-ID",
"id_placeholder": "z.B. arbeit, privat",
"color_field": "Farbe",
"id_exists": "Diese Schlüsselwort-ID existiert bereits",
"edit": "Schlüsselwort bearbeiten",
"delete": "Schlüsselwort löschen",
"id_exists": "Diese Label-ID existiert bereits",
"edit": "Label bearbeiten",
"delete": "Label löschen",
"save": "Speichern",
"add": "Hinzufügen",
"cancel": "Abbrechen",
"migrating": "Schlüsselwort bei bestehenden E-Mails aktualisieren…",
"migration_error": "Schlüsselwort konnte bei bestehenden E-Mails nicht aktualisiert werden"
"migrating": "Label auf vorhandenen E-Mails aktualisieren…",
"migration_error": "Label auf vorhandenen E-Mails konnte nicht aktualisiert werden"
},
"notifications": {
"test_sound": "Benachrichtigungston testen",
@@ -923,7 +923,7 @@
"star": "Markieren / Markierung aufheben",
"mark_read": "Als gelesen / ungelesen markieren",
"archive": "Archivieren",
"tag": "Schlagwort",
"tag": "Label",
"spam": "Als Spam markieren",
"none_selected": "Keine Aktionen ausgewählt",
"mode_label": "Anzeigemodus",
@@ -1350,7 +1350,7 @@
"reject_message": "Ablehnungsnachricht",
"reject_placeholder": "Ihre E-Mail wurde abgelehnt",
"label_name": "Label-Name",
"label_placeholder": "z.B. wichtig",
"label_placeholder": "Label auswählen",
"header_name": "Header-Name",
"header_placeholder": "z.B. X-Mailing-List",
"size_bytes": "Größe in Bytes",
+15 -15
View File
@@ -657,7 +657,7 @@
"filters": "Filters",
"templates": "Templates",
"folders": "Folders",
"keywords": "Keywords",
"keywords": "Tags",
"security": "Security",
"files": "Files",
"contacts": "Contacts",
@@ -725,23 +725,23 @@
}
},
"keywords": {
"title": "Email Keywords",
"description": "Define keywords (labels/tags) to organize your emails with colors. These are stored as JMAP keywords on the server.",
"add_keyword": "Add Keyword",
"title": "Email Tags",
"description": "Define tags to organize your emails with colors. These are stored as JMAP keywords on the server.",
"add_keyword": "Add Tag",
"reset_defaults": "Reset to Defaults",
"label_field": "Display Name",
"label_placeholder": "e.g. Work, Personal, Urgent",
"id_field": "Keyword ID",
"id_field": "Tag ID",
"id_placeholder": "e.g. work, personal",
"color_field": "Color",
"id_exists": "This keyword ID already exists",
"edit": "Edit keyword",
"delete": "Delete keyword",
"id_exists": "This tag ID already exists",
"edit": "Edit tag",
"delete": "Delete tag",
"save": "Save",
"add": "Add",
"cancel": "Cancel",
"migrating": "Updating keyword on existing emails…",
"migration_error": "Failed to update keyword on existing emails"
"migrating": "Updating tag on existing emails…",
"migration_error": "Failed to update tag on existing emails"
},
"notifications": {
"test_sound": "Test notification sound",
@@ -1337,7 +1337,7 @@
"forward": "Forward to",
"mark_read": "Mark as read",
"star": "Star message",
"add_label": "Add label",
"add_label": "Add tag",
"discard": "Discard (delete silently)",
"reject": "Reject with message",
"keep": "Keep in inbox",
@@ -1349,8 +1349,8 @@
"forward_placeholder": "email@example.com",
"reject_message": "Rejection message",
"reject_placeholder": "Your email has been rejected",
"label_name": "Label name",
"label_placeholder": "e.g., important",
"label_name": "Tag name",
"label_placeholder": "Select tag",
"header_name": "Header name",
"header_placeholder": "e.g., X-Mailing-List",
"size_bytes": "Size in bytes",
@@ -1524,8 +1524,8 @@
"delete": "Delete",
"mark_as_spam": "Report spam",
"not_spam": "Not spam",
"color_tag": "Label",
"remove_color": "Remove Label",
"color_tag": "Tag",
"remove_color": "Remove tag",
"items_selected": "{count} emails selected",
"edit_draft": "Edit Draft"
},
+9 -9
View File
@@ -657,7 +657,7 @@
"filters": "Filtros",
"templates": "Plantillas",
"folders": "Carpetas",
"keywords": "Palabras clave",
"keywords": "Etiquetas",
"security": "Seguridad",
"encryption": "Cifrado",
"files": "Archivos",
@@ -725,18 +725,18 @@
}
},
"keywords": {
"title": "Palabras clave de correo",
"description": "Define palabras clave (etiquetas) para organizar tus correos con colores.",
"add_keyword": "Añadir palabra clave",
"title": "Etiquetas de correo",
"description": "Define etiquetas para organizar tus correos con colores. Se almacenan como palabras clave JMAP en el servidor.",
"add_keyword": "Añadir etiqueta",
"reset_defaults": "Restablecer valores predeterminados",
"label_field": "Nombre para mostrar",
"label_placeholder": "ej. Trabajo, Personal, Urgente",
"id_field": "ID de palabra clave",
"id_field": "ID de etiqueta",
"id_placeholder": "ej. trabajo, personal",
"color_field": "Color",
"id_exists": "Esta ID de palabra clave ya existe",
"edit": "Editar palabra clave",
"delete": "Eliminar palabra clave",
"id_exists": "Este ID de etiqueta ya existe",
"edit": "Editar etiqueta",
"delete": "Eliminar etiqueta",
"save": "Guardar",
"add": "Añadir",
"cancel": "Cancelar",
@@ -1350,7 +1350,7 @@
"reject_message": "Mensaje de rechazo",
"reject_placeholder": "Su correo ha sido rechazado",
"label_name": "Nombre de la etiqueta",
"label_placeholder": "ej. importante",
"label_placeholder": "Seleccionar etiqueta",
"header_name": "Nombre del encabezado",
"header_placeholder": "ej. X-Mailing-List",
"size_bytes": "Tamaño en bytes",
+13 -13
View File
@@ -657,7 +657,7 @@
"filters": "Filtres",
"templates": "Modèles",
"folders": "Dossiers",
"keywords": "Mots-clés",
"keywords": "Étiquettes",
"security": "Sécurité",
"encryption": "Chiffrement",
"files": "Fichiers",
@@ -725,23 +725,23 @@
}
},
"keywords": {
"title": "Mots-clés des e-mails",
"description": "Définissez des mots-clés (étiquettes) pour organiser vos e-mails avec des couleurs.",
"add_keyword": "Ajouter un mot-clé",
"title": "Étiquettes de messagerie",
"description": "Définissez des étiquettes pour organiser vos e-mails avec des couleurs. Elles sont stockées sous forme de mots-clés JMAP sur le serveur.",
"add_keyword": "Ajouter une étiquette",
"reset_defaults": "Réinitialiser par défaut",
"label_field": "Nom d'affichage",
"label_placeholder": "ex. Travail, Personnel, Urgent",
"id_field": "ID du mot-clé",
"id_field": "ID d'étiquette",
"id_placeholder": "ex. travail, personnel",
"color_field": "Couleur",
"id_exists": "Cet ID de mot-clé existe déjà",
"edit": "Modifier le mot-clé",
"delete": "Supprimer le mot-clé",
"id_exists": "Cet ID d'étiquette existe déjà",
"edit": "Éditer l'étiquette",
"delete": "Supprimer l'étiquette",
"save": "Enregistrer",
"add": "Ajouter",
"cancel": "Annuler",
"migrating": "Mise à jour du mot-clé sur les e-mails existants…",
"migration_error": "Échec de la mise à jour du mot-clé sur les e-mails existants"
"migrating": "Mise à jour de l'étiquette sur les e-mails existants…",
"migration_error": "Impossible de mettre à jour l'étiquette sur les e-mails existants"
},
"notifications": {
"test_sound": "Tester le son de notification",
@@ -1337,7 +1337,7 @@
"forward": "Transférer à",
"mark_read": "Marquer comme lu",
"star": "Marquer d'une étoile",
"add_label": "Ajouter un libellé",
"add_label": "Ajouter une étiquette",
"discard": "Supprimer silencieusement",
"reject": "Rejeter avec un message",
"keep": "Conserver dans la boîte de réception",
@@ -1349,8 +1349,8 @@
"forward_placeholder": "email@exemple.com",
"reject_message": "Message de rejet",
"reject_placeholder": "Votre e-mail a été rejeté",
"label_name": "Nom du libellé",
"label_placeholder": "ex. important",
"label_name": "Nom de l'étiquette",
"label_placeholder": "Sélectionner une étiquette",
"header_name": "Nom de l'en-tête",
"header_placeholder": "ex. X-Mailing-List",
"size_bytes": "Taille en octets",
+11 -11
View File
@@ -657,7 +657,7 @@
"filters": "Filtri",
"templates": "Modelli",
"folders": "Cartelle",
"keywords": "Parole chiave",
"keywords": "Etichette",
"security": "Sicurezza",
"encryption": "Cifratura",
"files": "File",
@@ -725,23 +725,23 @@
}
},
"keywords": {
"title": "Parole chiave e-mail",
"description": "Definisci parole chiave (etichette) per organizzare le tue e-mail con colori.",
"add_keyword": "Aggiungi parola chiave",
"title": "Etichette e-mail",
"description": "Definisci etichette per organizzare le tue e-mail con i colori. Vengono archiviate come parole chiave JMAP sul server.",
"add_keyword": "Aggiungi etichetta",
"reset_defaults": "Ripristina predefiniti",
"label_field": "Nome visualizzato",
"label_placeholder": "es. Lavoro, Personale, Urgente",
"id_field": "ID parola chiave",
"id_field": "ID etichetta",
"id_placeholder": "es. lavoro, personale",
"color_field": "Colore",
"id_exists": "Questo ID parola chiave esiste già",
"edit": "Modifica parola chiave",
"delete": "Elimina parola chiave",
"id_exists": "Questo ID etichetta esiste già",
"edit": "Modifica etichetta",
"delete": "Elimina etichetta",
"save": "Salva",
"add": "Aggiungi",
"cancel": "Annulla",
"migrating": "Aggiornamento parola chiave sulle email esistenti…",
"migration_error": "Impossibile aggiornare la parola chiave sulle email esistenti"
"migrating": "Aggiornamento dell'etichetta nelle e-mail esistenti…",
"migration_error": "Impossibile aggiornare l'etichetta nelle e-mail esistenti"
},
"notifications": {
"test_sound": "Testa il suono di notifica",
@@ -1350,7 +1350,7 @@
"reject_message": "Messaggio di rifiuto",
"reject_placeholder": "La tua email è stata rifiutata",
"label_name": "Nome dell'etichetta",
"label_placeholder": "es. importante",
"label_placeholder": "Seleziona etichetta",
"header_name": "Nome dell'intestazione",
"header_placeholder": "es. X-Mailing-List",
"size_bytes": "Dimensione in byte",
+12 -12
View File
@@ -657,7 +657,7 @@
"filters": "フィルター",
"templates": "テンプレート",
"folders": "フォルダー",
"keywords": "キーワード",
"keywords": "ラベル",
"security": "セキュリティ",
"encryption": "暗号化",
"files": "ファイル",
@@ -725,23 +725,23 @@
}
},
"keywords": {
"title": "メールキーワード",
"description": "色でメールを整理するためのキーワード(ラベル/タグ)を定義します。",
"add_keyword": "キーワードを追加",
"title": "メールラベル",
"description": "メールをカラーで整理するためのラベルを定義します。サーバーにJMAPキーワードとして保存されます。",
"add_keyword": "ラベルを追加",
"reset_defaults": "デフォルトに戻す",
"label_field": "表示名",
"label_placeholder": "例:仕事、個人、緊急",
"id_field": "キーワードID",
"id_field": "ラベルID",
"id_placeholder": "例:work、personal",
"color_field": "色",
"id_exists": "このキーワードIDは既に存在します",
"edit": "キーワードを編集",
"delete": "キーワードを削除",
"id_exists": "このラベルIDは既に存在します",
"edit": "ラベルを編集",
"delete": "ラベルを削除",
"save": "保存",
"add": "追加",
"cancel": "キャンセル",
"migrating": "既存のメールでキーワードを更新中…",
"migration_error": "既存のメールでのキーワード更新に失敗しました"
"migrating": "既存のメールのラベルを更新中…",
"migration_error": "既存のメールのラベルの更新に失敗しました"
},
"notifications": {
"test_sound": "通知音をテスト",
@@ -923,7 +923,7 @@
"star": "スター付け / 解除",
"mark_read": "既読 / 未読にする",
"archive": "アーカイブ",
"tag": "タグ",
"tag": "ラベル",
"spam": "スパムとしてマーク",
"none_selected": "アクションが選択されていません",
"mode_label": "表示モード",
@@ -1350,7 +1350,7 @@
"reject_message": "拒否メッセージ",
"reject_placeholder": "あなたのメールは拒否されました",
"label_name": "ラベル名",
"label_placeholder": "例:重要",
"label_placeholder": "ラベルを選択",
"header_name": "ヘッダー名",
"header_placeholder": "例:X-Mailing-List",
"size_bytes": "サイズ(バイト)",
+15 -15
View File
@@ -657,7 +657,7 @@
"filters": "필터",
"templates": "템플릿",
"folders": "폴더",
"keywords": "키워드",
"keywords": "태그",
"security": "보안",
"files": "파일",
"contacts": "연락처",
@@ -725,23 +725,23 @@
}
},
"keywords": {
"title": "이메일 키워드",
"description": "이메일을 분류할 키워드(라벨/태그)설정해 보세요. 설정한 키워드는 서버에 저장돼요.",
"add_keyword": "키워드 추가",
"title": "이메일 태그",
"description": "색상으로 이메일을 정리하기 위한 태그를 정의합니다. 서버에 JMAP 키워드로 저장됩니다.",
"add_keyword": "태그 추가",
"reset_defaults": "기본값으로 초기화",
"label_field": "표시 이름",
"label_placeholder": "예: 업무, 개인, 긴급",
"id_field": "키워드 ID",
"id_field": "태그 ID",
"id_placeholder": "예: work, personal",
"color_field": "색상",
"id_exists": "이미 존재하는 키워드 ID예요",
"edit": "키워드 수정",
"delete": "키워드 삭제",
"id_exists": "이 태그 ID는 이미 존재합니다",
"edit": "태그 편집",
"delete": "태그 삭제",
"save": "저장",
"add": "추가",
"cancel": "취소",
"migrating": "기존 이메일의 키워드를 업데이트하는 중...",
"migration_error": "기존 이메일의 키워드를 업데이트하지 못했어요"
"migrating": "기존 이메일의 태그 업데이트 중…",
"migration_error": "기존 이메일의 태그 업데이트에 실패했습니다"
},
"notifications": {
"test_sound": "알림음 테스트",
@@ -1337,7 +1337,7 @@
"forward": "다음으로 전달",
"mark_read": "읽은 상태로 표시",
"star": "별표 달기",
"add_label": "라벨(태그) 추가",
"add_label": "태그 추가",
"discard": "삭제 (조용히 지움)",
"reject": "메시지와 함께 수신 거부",
"keep": "받은편지함에 유지",
@@ -1349,8 +1349,8 @@
"forward_placeholder": "email@example.com",
"reject_message": "거부 메시지",
"reject_placeholder": "메일 수신이 거부되었습니다",
"label_name": "라벨 이름",
"label_placeholder": "예: important",
"label_name": "태그 이름",
"label_placeholder": "태그 선택",
"header_name": "헤더 이름",
"header_placeholder": "예: X-Mailing-List",
"size_bytes": "크기 (바이트)",
@@ -1524,8 +1524,8 @@
"delete": "삭제",
"mark_as_spam": "스팸 신고",
"not_spam": "정상 메일",
"color_tag": "라벨 지정",
"remove_color": "라벨 제거",
"color_tag": "태그",
"remove_color": "태그 제거",
"items_selected": "{count}개의 메일 선택됨",
"edit_draft": "임시보관 메일 수정"
},
+15 -15
View File
@@ -657,7 +657,7 @@
"filters": "Filtri",
"templates": "Veidnes",
"folders": "Mapes",
"keywords": "Atslēgvārdi",
"keywords": "Tagi",
"security": "Drošība",
"files": "Faili",
"contacts": "Kontakti",
@@ -725,23 +725,23 @@
}
},
"keywords": {
"title": "Vēstuļu atslēgvārdi",
"description": "Definējiet atslēgvārdus (etiķetes/tagus) vēstuļu organizēšanai ar krāsām. Tie tiek saglabāti serverī kā JMAP atslēgvārdi.",
"add_keyword": "Pievienot atslēgvārdu",
"title": "E-pasta tagi",
"description": "Definējiet tagus, lai organizētu e-pastus ar krāsām. Tie tiek saglabāti kā JMAP atslēgvārdi serverī.",
"add_keyword": "Pievienot tagu",
"reset_defaults": "Atiestatīt noklusējumu",
"label_field": "Redzamais nosaukums",
"label_placeholder": "piem., Darbs, Personīgi, Steidzami",
"id_field": "Atslēgvārda identifikators",
"id_field": "Taga identifikators",
"id_placeholder": "piem., darbs, personigi",
"color_field": "Krāsa",
"id_exists": "Šāds atslēgvārda identifikators jau eksistē",
"edit": "Rediģēt atslēgvārdu",
"delete": "Dzēst atslēgvārdu",
"id_exists": "Šāds taga identifikators jau pastāv",
"edit": "Rediģēt tagu",
"delete": "Dzēst tagu",
"save": "Saglabāt",
"add": "Pievienot",
"cancel": "Atcelt",
"migrating": "Atjaunina atslēgvārdu esošajās vēstulēs...",
"migration_error": "Neizdevās atjaunināt atslēgvārdu esošajās vēstulēs"
"migrating": "Taga atjaunināšana esošajos e-pastos…",
"migration_error": "Neizdevās atjaunināt tagu esošajos e-pastos"
},
"notifications": {
"test_sound": "Pārbaudīt paziņojuma skaņu",
@@ -1337,7 +1337,7 @@
"forward": "Pārsūtīt uz",
"mark_read": "Atzīmēt kā izlasītu",
"star": "Pievienot zvaigznīti",
"add_label": "Pievienot etiķeti",
"add_label": "Pievienot tagu",
"discard": "Dzēst (bez paziņojuma)",
"reject": "Noraidīt ar ziņojumu",
"keep": "Atstāt iesūtnē",
@@ -1349,8 +1349,8 @@
"forward_placeholder": "lietotajs@piemers.lv",
"reject_message": "Noraidīšanas ziņojums",
"reject_placeholder": "Jūsu e-pasts tika noraidīts",
"label_name": "Etiķetes nosaukums",
"label_placeholder": "piem., svarigi",
"label_name": "Taga nosaukums",
"label_placeholder": "Izvēlēties tagu",
"header_name": "Galvenes nosaukums",
"header_placeholder": "piem., X-Mailing-List",
"size_bytes": "Izmērs baitos",
@@ -1524,8 +1524,8 @@
"delete": "Dzēst",
"mark_as_spam": "Atzīmēt kā mēstuli",
"not_spam": "Nav mēstule",
"color_tag": "Etiķete",
"remove_color": "Noņemt etiķeti",
"color_tag": "Tags",
"remove_color": "Noņemt tagu",
"items_selected": "{count} vēstules atlasītas",
"edit_draft": "Rediģēt melnrakstu"
},
+11 -11
View File
@@ -657,7 +657,7 @@
"filters": "Filters",
"templates": "Sjablonen",
"folders": "Mappen",
"keywords": "Sleutelwoorden",
"keywords": "Labels",
"security": "Beveiliging",
"encryption": "Versleuteling",
"files": "Bestanden",
@@ -725,23 +725,23 @@
}
},
"keywords": {
"title": "E-mail trefwoorden",
"description": "Definieer trefwoorden (labels/tags) om uw e-mails met kleuren te organiseren.",
"add_keyword": "Trefwoord toevoegen",
"title": "E-maillabels",
"description": "Definieer labels om uw e-mails met kleuren te organiseren. Deze worden opgeslagen als JMAP-trefwoorden op de server.",
"add_keyword": "Label toevoegen",
"reset_defaults": "Standaardwaarden herstellen",
"label_field": "Weergavenaam",
"label_placeholder": "bijv. Werk, Persoonlijk, Urgent",
"id_field": "Trefwoord-ID",
"id_field": "Label-ID",
"id_placeholder": "bijv. werk, persoonlijk",
"color_field": "Kleur",
"id_exists": "Dit trefwoord-ID bestaat al",
"edit": "Trefwoord bewerken",
"delete": "Trefwoord verwijderen",
"id_exists": "Deze label-ID bestaat al",
"edit": "Label bewerken",
"delete": "Label verwijderen",
"save": "Opslaan",
"add": "Toevoegen",
"cancel": "Annuleren",
"migrating": "Trefwoord bijwerken op bestaande e-mails…",
"migration_error": "Kan trefwoord niet bijwerken op bestaande e-mails"
"migrating": "Label bijwerken op bestaande e-mails…",
"migration_error": "Label bijwerken op bestaande e-mails mislukt"
},
"notifications": {
"test_sound": "Meldingsgeluid testen",
@@ -1350,7 +1350,7 @@
"reject_message": "Afwijzingsbericht",
"reject_placeholder": "Uw e-mail is afgewezen",
"label_name": "Labelnaam",
"label_placeholder": "bijv. belangrijk",
"label_placeholder": "Label kiezen",
"header_name": "Headernaam",
"header_placeholder": "bijv. X-Mailing-List",
"size_bytes": "Grootte in bytes",
+11 -11
View File
@@ -657,7 +657,7 @@
"filters": "Filtry",
"templates": "Szablony",
"folders": "Foldery",
"keywords": "Słowa kluczowe",
"keywords": "Etykiety",
"security": "Bezpieczeństwo",
"files": "Pliki",
"contacts": "Kontakty",
@@ -725,23 +725,23 @@
}
},
"keywords": {
"title": "Słowa kluczowe wiadomości e-mail",
"description": "Zdefiniuj słowa kluczowe (etykiety/tagi), aby porządkować wiadomości e-mail kolorami. Są one przechowywane na serwerze jako słowa kluczowe JMAP.",
"add_keyword": "Dodaj słowo kluczowe",
"title": "Etykiety e-mail",
"description": "Zdefiniuj etykiety do organizowania e-maili za pomocą kolorów. Są one przechowywane jako słowa kluczowe JMAP na serwerze.",
"add_keyword": "Dodaj etykietę",
"reset_defaults": "Przywróć domyślne",
"label_field": "Nazwa wyświetlana",
"label_placeholder": "np. Praca, Osobiste, Pilne",
"id_field": "Identyfikator słowa kluczowego",
"id_field": "ID etykiety",
"id_placeholder": "np. praca, osobiste",
"color_field": "Kolor",
"id_exists": "Ten identyfikator słowa kluczowego już istnieje",
"edit": "Edytuj słowo kluczowe",
"delete": "Usuń słowo kluczowe",
"id_exists": "Ten ID etykiety już istnieje",
"edit": "Edytuj etykietę",
"delete": "Usuń etykietę",
"save": "Zapisz",
"add": "Dodaj",
"cancel": "Anuluj",
"migrating": "Aktualizowanie słowa kluczowego w istniejących wiadomościach e-mail…",
"migration_error": "Nie udało się zaktualizować słowa kluczowego w istniejących wiadomościach e-mail"
"migrating": "Aktualizowanie etykiety w istniejących e-mailach…",
"migration_error": "Nie udało się zaktualizować etykiety w istniejących e-mailach"
},
"notifications": {
"test_sound": "Przetestuj dźwięk powiadomienia",
@@ -1350,7 +1350,7 @@
"reject_message": "Wiadomość odrzucenia",
"reject_placeholder": "Twoja wiadomość e-mail została odrzucona",
"label_name": "Nazwa etykiety",
"label_placeholder": "np. ważne",
"label_placeholder": "Wybierz etykietę",
"header_name": "Nazwa nagłówka",
"header_placeholder": "np. X-Mailing-List",
"size_bytes": "Rozmiar w bajtach",
+12 -12
View File
@@ -657,7 +657,7 @@
"filters": "Filtros",
"templates": "Modelos",
"folders": "Pastas",
"keywords": "Palavras-chave",
"keywords": "Etiquetas",
"security": "Segurança",
"encryption": "Criptografia",
"files": "Arquivos",
@@ -725,22 +725,22 @@
}
},
"keywords": {
"title": "Palavras-chave de e-mail",
"description": "Defina palavras-chave (rótulos/tags) para organizar seus e-mails com cores.",
"add_keyword": "Adicionar palavra-chave",
"title": "Etiquetas de e-mail",
"description": "Defina etiquetas para organizar os seus e-mails com cores. São armazenadas como palavras-chave JMAP no servidor.",
"add_keyword": "Adicionar etiqueta",
"reset_defaults": "Restaurar padrões",
"label_field": "Nome de exibição",
"label_placeholder": "ex. Trabalho, Pessoal, Urgente",
"id_field": "ID da palavra-chave",
"id_field": "ID da etiqueta",
"id_placeholder": "ex. trabalho, pessoal",
"color_field": "Cor",
"id_exists": "Este ID de palavra-chave já existe",
"edit": "Editar palavra-chave",
"delete": "Excluir palavra-chave",
"id_exists": "Este ID de etiqueta já existe",
"edit": "Editar etiqueta",
"delete": "Eliminar etiqueta",
"save": "Salvar",
"add": "Adicionar",
"cancel": "Cancelar",
"migrating": "Atualizando etiqueta nos e-mails existentes…",
"migrating": "A atualizar etiqueta nos e-mails existentes…",
"migration_error": "Falha ao atualizar etiqueta nos e-mails existentes"
},
"notifications": {
@@ -1337,7 +1337,7 @@
"forward": "Encaminhar para",
"mark_read": "Marcar como lido",
"star": "Destacar mensagem",
"add_label": "Adicionar rótulo",
"add_label": "Adicionar etiqueta",
"discard": "Descartar (excluir silenciosamente)",
"reject": "Rejeitar com mensagem",
"keep": "Manter na caixa de entrada",
@@ -1349,8 +1349,8 @@
"forward_placeholder": "email@exemplo.com",
"reject_message": "Mensagem de rejeição",
"reject_placeholder": "Seu e-mail foi rejeitado",
"label_name": "Nome do rótulo",
"label_placeholder": "ex. importante",
"label_name": "Nome da etiqueta",
"label_placeholder": "Selecionar etiqueta",
"header_name": "Nome do cabeçalho",
"header_placeholder": "ex. X-Mailing-List",
"size_bytes": "Tamanho em bytes",
+15 -15
View File
@@ -657,7 +657,7 @@
"filters": "Фильтры",
"templates": "Шаблоны",
"folders": "Папки",
"keywords": "Ключевые слова",
"keywords": "Теги",
"security": "Безопасность",
"files": "Файлы",
"contacts": "Контакты",
@@ -725,23 +725,23 @@
}
},
"keywords": {
"title": "Ключевые слова писем",
"description": "Определите ключевые слова (метки/теги) для организации писем с помощью цветов. Они хранятся как ключевые слова JMAP на сервере.",
"add_keyword": "Добавить ключевое слово",
"title": "Теги электронной почты",
"description": "Определите теги для организации электронных писем с помощью цветов. Они хранятся как ключевые слова JMAP на сервере.",
"add_keyword": "Добавить тег",
"reset_defaults": "Сбросить по умолчанию",
"label_field": "Отображаемое название",
"label_placeholder": "напр., Работа, Личное, Срочно",
"id_field": "Идентификатор ключевого слова",
"id_field": "Идентификатор тега",
"id_placeholder": "напр., work, personal",
"color_field": "Цвет",
"id_exists": "Этот идентификатор ключевого слова уже существует",
"edit": "Редактировать ключевое слово",
"delete": "Удалить ключевое слово",
"id_exists": "Этот идентификатор тега уже существует",
"edit": "Редактировать тег",
"delete": "Удалить тег",
"save": "Сохранить",
"add": "Добавить",
"cancel": "Отмена",
"migrating": "Обновление ключевого слова в существующих письмах…",
"migration_error": "Не удалось обновить ключевое слово в существующих письмах"
"migrating": "Обновление тега в существующих письмах…",
"migration_error": "Не удалось обновить тег в существующих письмах"
},
"notifications": {
"test_sound": "Проверить звук уведомления",
@@ -1337,7 +1337,7 @@
"forward": "Переслать на",
"mark_read": "Отметить прочитанным",
"star": "Пометить сообщение",
"add_label": "Добавить метку",
"add_label": "Добавить тег",
"discard": "Удалить (без уведомления)",
"reject": "Отклонить с сообщением",
"keep": "Оставить во входящих",
@@ -1349,8 +1349,8 @@
"forward_placeholder": "user@пример.рф",
"reject_message": "Сообщение об отклонении",
"reject_placeholder": "Ваше письмо было отклонено",
"label_name": "Название метки",
"label_placeholder": "напр., важное",
"label_name": "Название тега",
"label_placeholder": "Выбрать тег",
"header_name": "Имя заголовка",
"header_placeholder": "напр., X-Mailing-List",
"size_bytes": "Размер в байтах",
@@ -1524,8 +1524,8 @@
"delete": "Удалить",
"mark_as_spam": "Отметить как спам",
"not_spam": "Не спам",
"color_tag": "Метка",
"remove_color": "Убрать метку",
"color_tag": "Тег",
"remove_color": "Удалить тег",
"items_selected": "{count} писем выбрано",
"edit_draft": "Редактировать черновик"
},
+12 -12
View File
@@ -657,7 +657,7 @@
"filters": "过滤器",
"templates": "模板",
"folders": "文件夹",
"keywords": "关键词",
"keywords": "标签",
"security": "安全",
"files": "文件",
"contacts": "联系人",
@@ -725,23 +725,23 @@
}
},
"keywords": {
"title": "邮件关键字",
"description": "定义关键字(标签)并用颜色整理邮件。这些关键字会作为 JMAP 关键字保存在服务器上。",
"add_keyword": "添加关键字",
"title": "电子邮件标签",
"description": "定义标签以使用颜色组织您的电子邮件。这些标签作为JMAP关键词存储在服务器上。",
"add_keyword": "添加标签",
"reset_defaults": "重置为默认值",
"label_field": "显示名称",
"label_placeholder": "例如工作、个人、紧急",
"id_field": "关键字 ID",
"id_field": "标签ID",
"id_placeholder": "例如工作、个人",
"color_field": "颜色",
"id_exists": "该关键字 ID 已存在",
"edit": "编辑关键字",
"delete": "删除关键字",
"id_exists": "此标签ID已存在",
"edit": "编辑标签",
"delete": "删除标签",
"save": "保存",
"add": "添加",
"cancel": "取消",
"migrating": "正在更新现有邮件的关键字...",
"migration_error": "无法更新现有邮件的关键字"
"migrating": "正在更新现有邮件的标签…",
"migration_error": "更新现有邮件的标签失败"
},
"notifications": {
"test_sound": "测试通知声音",
@@ -1350,7 +1350,7 @@
"reject_message": "拒绝留言",
"reject_placeholder": "您的邮件已被拒绝",
"label_name": "标签名称",
"label_placeholder": "例如,重要",
"label_placeholder": "选择标签",
"header_name": "标头名称",
"header_placeholder": "例如,X-Mailing-List",
"size_bytes": "大小(以字节为单位)",
@@ -1525,7 +1525,7 @@
"mark_as_spam": "举报垃圾邮件",
"not_spam": "不是垃圾邮件",
"color_tag": "标签",
"remove_color": "除标签",
"remove_color": "除标签",
"items_selected": "已选择 {count} 封邮件",
"edit_draft": "编辑草稿"
},