Merge branch 'feat/desktop-offline' into feat/do-ux-p2-mail-fix

# Conflicts:
#	components/ui/first-start-notice.tsx
This commit is contained in:
Linus Rath
2026-11-04 13:46:47 +01:00
117 changed files with 4901 additions and 669 deletions
@@ -81,6 +81,17 @@ describe('the desktop app, with a server of one\'s own', () => {
expect(screen.queryByTestId('login-server-trouble')).toBeNull();
}, 30_000);
it('makes "Sign in" the main button once a password is typed', async () => {
render(<LoginPage />);
const button = await browserButton();
const submit = email().closest('form')!.querySelector<HTMLButtonElement>('button[type=submit]')!;
expect(button.className).toContain('bg-primary');
expect(submit.className).not.toContain('bg-primary');
fireEvent.change(document.getElementById('password')!, { target: { value: 'secret' } });
await waitFor(() => expect(submit.className).toContain('bg-primary'));
expect(button.className).not.toContain('bg-primary');
}, 30_000);
it('asks for the address under its field when the browser button is pressed first', async () => {
render(<LoginPage />);
fireEvent.click(await browserButton());
@@ -139,10 +139,76 @@ describe('sign-in to a server of one\'s own', () => {
fireEvent.submit(form());
expect(await screen.findByTestId('login-server-error')).toHaveTextContent('server_invalid');
expect(serverField()).toHaveAttribute('aria-invalid', 'true');
// Marked red, not only for screen readers.
expect(serverField()!.className).toContain('aria-invalid:border-destructive');
await waitFor(() => expect(document.activeElement).toBe(serverField()));
expect(login).not.toHaveBeenCalled();
}, 30_000);
it('Change keeps the server in use and doesn\'t say it wasn\'t found', async () => {
configOverrides.value = { jmapServerUrl: 'https://default.example.com' };
render(<LoginPage />);
await typeAddress('sam@example.net');
fireEvent.click(await screen.findByTestId('login-server-change', {}, { timeout: 5000 }));
await waitFor(() => expect(serverField()).not.toBeNull());
expect(serverField()!.value).toBe('default.example.com');
expect(screen.queryByTestId('login-server-not-found')).toBeNull();
expect(screen.getByTestId('login-server-change-hint')).toHaveTextContent('server_change_hint');
await waitFor(() => expect(document.activeElement).toBe(serverField()));
}, 30_000);
it('focuses the server field when it opens after the user left the address field', async () => {
render(<LoginPage />);
await waitFor(() => expect(email()).not.toBeNull(), { timeout: 10000 });
fireEvent.change(email(), { target: { value: 'sam@example.net' } });
// Tab went on to the (still empty) password field before the lookup ended.
(document.getElementById('password') as HTMLInputElement).focus();
await waitFor(() => expect(serverField()).not.toBeNull(), { timeout: 5000 });
await waitFor(() => expect(document.activeElement).toBe(serverField()));
}, 30_000);
it('leaves focus in the address field while the user is still typing there', async () => {
render(<LoginPage />);
await waitFor(() => expect(email()).not.toBeNull(), { timeout: 10000 });
email().focus();
fireEvent.change(email(), { target: { value: 'sam@example.net' } });
await waitFor(() => expect(serverField()).not.toBeNull(), { timeout: 5000 });
expect(document.activeElement).toBe(email());
}, 30_000);
it('a wrong password focuses the password field with its text selected', async () => {
found['example.org'] = 'https://mail.example.org';
login.mockImplementation(async () => {
useAuthStore.setState({ error: 'invalid_credentials', isLoading: false });
return false;
});
render(<LoginPage />);
await typeAddress('sam@example.org');
const button = form().querySelector<HTMLButtonElement>('button[type=submit]')!;
button.focus();
fireEvent.submit(form());
const password = document.getElementById('password') as HTMLInputElement;
await waitFor(() => expect(document.activeElement).toBe(password));
expect(password.selectionStart).toBe(0);
expect(password.selectionEnd).toBe('secret'.length);
expect(password.className).toContain('aria-invalid:border-destructive');
login.mockImplementation(async () => false);
}, 30_000);
it('keeps the access token behind "Other ways to sign in"', async () => {
configOverrides.value = { loginShowTokenLogin: true };
render(<LoginPage />);
const toggle = await screen.findByTestId('login-token-toggle', {}, { timeout: 10000 });
const other = screen.getByTestId('login-other-ways');
expect(other.tagName).toBe('DETAILS');
expect(other).toContainElement(toggle);
expect(other.querySelector('summary')).toHaveTextContent('other_ways');
fireEvent.click(toggle);
// The way back to the password is in sight, not folded away.
await waitFor(() => expect(screen.queryByTestId('login-other-ways')).toBeNull());
expect(screen.getByTestId('login-token-toggle')).toHaveTextContent('password_toggle');
}, 30_000);
it('says plainly that Gmail can\'t be reached, and asks nothing', async () => {
render(<LoginPage />);
await typeAddress('sam@gmail.com');
+77 -20
View File
@@ -310,6 +310,10 @@ function LoginPageContent() {
const customServerMode = !hasServerList && allowCustomJmapEndpoint;
const canLookUpServer = customServerMode && !IS_LITE;
const [serverEditing, setServerEditing] = useState(false);
// Change on a found server: another one is wanted, nothing failed.
const [serverChanging, setServerChanging] = useState(false);
// Asks for focus in the server field once it is on screen (the field mounts in the next render).
const [serverFocusRequest, setServerFocusRequest] = useState(0);
const [serverLookup, setServerLookup] = useState<{ domain: string; status: "looking" | "found" | "failed"; url: string | null } | null>(null);
const [serverError, setServerError] = useState<ServerFieldError>(null);
const [emailNeeded, setEmailNeeded] = useState(false);
@@ -400,6 +404,7 @@ function LoginPageContent() {
const justRemovedSuggestion = useRef(false);
const totpInputRef = useRef<HTMLInputElement>(null);
const tokenInputRef = useRef<HTMLInputElement>(null);
const passwordInputRef = useRef<HTMLInputElement>(null);
const prevError = useRef<string | null>(null);
const themeMenuRef = useRef<HTMLDivElement>(null);
const themeButtonRef = useRef<HTMLButtonElement>(null);
@@ -510,6 +515,23 @@ function LoginPageContent() {
return () => clearTimeout(timer);
}, [typedDomain, typedAddress, unsupportedMailProvider, serverLookup?.domain, lookUpServer]);
useEffect(() => {
if (serverFocusRequest) serverInputRef.current?.focus();
}, [serverFocusRequest]);
// The field opens by itself when the lookup found nothing, usually after the user
// already left the address field. Focus goes there, unless they are typing elsewhere.
const serverFieldOpenedByLookup = canLookUpServer && lookupForAddress?.status === "failed" && !configuredServerUrl && !unsupportedMailProvider;
useEffect(() => {
if (!serverFieldOpenedByLookup) return;
const active = document.activeElement;
if (active && active.id === "username") return;
if (active instanceof HTMLInputElement || active instanceof HTMLTextAreaElement) {
if (!(active.id === "password" && !active.value)) return;
}
serverInputRef.current?.focus();
}, [serverFieldOpenedByLookup, typedDomain]);
/**
* The server to sign in to in custom-server mode, looked up now if need be.
* Null after it opened the field and said what is missing there.
@@ -529,7 +551,7 @@ function LoginPageContent() {
}
setServerEditing(true);
setServerError(serverFieldOpen && jmapEndpoint.trim() ? "invalid" : "needed");
setTimeout(() => serverInputRef.current?.focus(), 0);
setServerFocusRequest((n) => n + 1);
return null;
};
@@ -582,6 +604,19 @@ function LoginPageContent() {
prevError.current = error;
}, [error]);
// A wrong password or token: focus that field with its text selected, ready to type again.
useEffect(() => {
if (error === "invalid_credentials" && !totpBlamed) {
passwordInputRef.current?.focus();
passwordInputRef.current?.select();
} else if (error === "invalid_token") {
tokenInputRef.current?.focus();
tokenInputRef.current?.select();
}
// Only on a new error, not when the code field changes.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [error]);
// Auto-show and focus TOTP field when server requires it
useEffect(() => {
if (error === 'totp_required') {
@@ -1232,6 +1267,10 @@ function LoginPageContent() {
};
const browserSignInShown = browserSignInAvailable() && desktopSignInEnabled;
// Once a password (or token) is typed, "Sign in" is what the user means: it becomes the
// main button and "Sign in with your browser" the other way.
const formSignInPreferred = signInWithToken ? accessToken.trim() !== "" : formData.password !== "";
const browserSignInPrimary = browserSignInShown && !formSignInPreferred;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -1413,7 +1452,10 @@ function LoginPageContent() {
<div className="space-y-3">
<Button
type="button"
className="w-full h-11 font-medium text-[0.9375rem] bg-primary hover:bg-primary/90 transition-all duration-200 rounded-xl shadow-md shadow-primary/15 hover:shadow-lg hover:shadow-primary/20"
variant={browserSignInPrimary ? undefined : "outline"}
className={browserSignInPrimary
? "w-full h-11 font-medium text-[0.9375rem] bg-primary hover:bg-primary/90 transition-all duration-200 rounded-xl shadow-md shadow-primary/15 hover:shadow-lg hover:shadow-primary/20"
: "w-full h-11 font-medium text-[0.9375rem] rounded-xl border-border/60 hover:bg-muted/50"}
onClick={handleBrowserSignIn}
disabled={desktopWaiting || isLoading}
data-testid="desktop-browser-sign-in"
@@ -2052,8 +2094,10 @@ function LoginPageContent() {
onEdit={() => {
setJmapEndpoint(autoServer ? serverHostLabel(autoServer) : "");
setServerEditing(true);
setTimeout(() => serverInputRef.current?.focus(), 0);
setServerChanging(true);
setServerFocusRequest((n) => n + 1);
}}
changing={serverChanging}
provider={unsupportedMailProvider}
error={serverError}
showTrouble={!IS_DESKTOP_BUILD}
@@ -2071,11 +2115,12 @@ function LoginPageContent() {
</label>
<div className="relative">
<Input
ref={passwordInputRef}
id="password"
type={showPassword ? "text" : "password"}
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
className="h-11 px-3.5 pe-11 bg-muted/40 border-border/60 rounded-xl focus:bg-background focus:border-primary/50 transition-all duration-200"
className="h-11 px-3.5 pe-11 bg-muted/40 border-border/60 rounded-xl focus:bg-background focus:border-primary/50 transition-all duration-200 aria-invalid:border-destructive aria-invalid:ring-2 aria-invalid:ring-destructive/20"
placeholder={t("password_placeholder")}
required={!liteSsoRequired && !signInWithToken}
autoComplete="current-password"
@@ -2168,20 +2213,31 @@ function LoginPageContent() {
</div>
)}
{loginShowTokenLogin && !isMobileHandoff && !liteSsoRequired && (
<button
type="button"
onClick={() => {
clearError();
setTokenMode(!signInWithToken);
setTimeout(() => (signInWithToken ? inputRef : tokenInputRef).current?.focus(), 50);
}}
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<KeyRound className="w-3.5 h-3.5" />
{signInWithToken ? t("password_toggle") : t("token_toggle")}
</button>
)}
{loginShowTokenLogin && !isMobileHandoff && !liteSsoRequired && (() => {
const tokenToggle = (
<button
type="button"
data-testid="login-token-toggle"
onClick={() => {
clearError();
setTokenMode(!signInWithToken);
setTimeout(() => (signInWithToken ? inputRef : tokenInputRef).current?.focus(), 50);
}}
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<KeyRound className="w-3.5 h-3.5" />
{signInWithToken ? t("password_toggle") : t("token_toggle")}
</button>
);
// Few people have a token: the way back to the password stays in sight,
// the way to a token waits behind "Other ways to sign in".
return signInWithToken ? tokenToggle : (
<details className="text-xs text-muted-foreground" data-testid="login-other-ways">
<summary className="cursor-pointer hover:text-foreground w-fit">{tLogin("other_ways")}</summary>
<div className="mt-2 ps-1">{tokenToggle}</div>
</details>
);
})()}
{/* Remember me, for the password and the SSO buttons below */}
{showRememberMe && rememberMeBox}
@@ -2189,8 +2245,9 @@ function LoginPageContent() {
<Button
type="submit"
variant={browserSignInShown ? "outline" : undefined}
className={browserSignInShown
data-testid="login-submit"
variant={browserSignInPrimary ? "outline" : undefined}
className={browserSignInPrimary
? "w-full h-11 font-medium text-[0.9375rem] rounded-xl border-border/60 hover:bg-muted/50"
: "w-full h-11 font-medium text-[0.9375rem] bg-primary hover:bg-primary/90 transition-all duration-200 rounded-xl shadow-md shadow-primary/15 hover:shadow-lg hover:shadow-primary/20"}
disabled={isLoading || (liteSsoRequired && oauthLoading)}
@@ -85,16 +85,29 @@ describe('Quick install', () => {
expect(within(card).queryByText('hook')).toBeNull();
});
it('asks to turn plugins on for users when they are off', async () => {
it('offers to turn plugins on in the same dialog when they are off, and asks nothing after', async () => {
policy = { features: { pluginsEnabled: false } };
render(<MarketplacePage />);
const card = (await screen.findByText('Send Checks')).closest('.group') as HTMLElement;
fireEvent.click(within(card).getByRole('button', { name: /Quick install/ }));
fireEvent.click(within(await screen.findByRole('alertdialog')).getByRole('button', { name: 'Install' }));
const question = await screen.findByText('Turn on plugins for users too?');
fireEvent.click(within(question.closest('[role="alertdialog"]') as HTMLElement).getByRole('button', { name: 'Turn on' }));
const dialog = await screen.findByRole('alertdialog');
fireEvent.click(await within(dialog).findByRole('button', { name: 'Install and turn on plugins' }));
expect(within(dialog).queryByText(/turned on for them/)).toBeNull();
await waitFor(() => expect(calls).toContainEqual(expect.objectContaining({
url: '/api/admin/policy', method: 'PUT', body: expect.objectContaining({ features: { pluginsEnabled: true } }),
})));
expect(installs()).toHaveLength(1);
expect(screen.queryByText('Turn on plugins for users too?')).toBeNull();
});
it('installs only, leaving plugins off, when the admin says so', async () => {
policy = { features: { pluginsEnabled: false } };
render(<MarketplacePage />);
const card = (await screen.findByText('Send Checks')).closest('.group') as HTMLElement;
fireEvent.click(within(card).getByRole('button', { name: /Quick install/ }));
fireEvent.click(await within(await screen.findByRole('alertdialog')).findByRole('button', { name: 'Install only' }));
await waitFor(() => expect(installs()).toHaveLength(1));
await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull());
expect(calls.some((c) => c.url === '/api/admin/policy' && c.method === 'PUT')).toBe(false);
});
});
+55 -15
View File
@@ -21,10 +21,11 @@ import { EmptyState } from '@/components/admin/empty-state';
import { useAdminFeedback } from '@/components/admin/feedback';
import { ConfigNote, inputClass } from '@/components/admin/config-items';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { Dialog } from '@/components/ui/dialog';
import { useConfirmDialog } from '@/hooks/use-confirm-dialog';
import { ChangelogBadges, ChangelogEntries, anyFlag, type ChangelogEntry, type ChangelogFlags } from '@/components/admin/changelog';
import { PluginPermissionList } from '@/components/plugins/plugin-permission-list';
import { offerToTurnOnPlugins, turnOnNote, type TurnOnOutcome } from './turn-on-plugins';
import { offerToTurnOnPlugins, pluginsOffForUsers, turnOnNote, type TurnOnOutcome } from './turn-on-plugins';
import { pluginTypeLabel } from './plugin-type-label';
const CURRENT_APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || '0.0.0';
@@ -176,6 +177,16 @@ export function MarketplacePage() {
const [updatesLoading, setUpdatesLoading] = useState(true);
// A plugin's quick install asks first, with what it may do (themes don't).
const [quickInstall, setQuickInstall] = useState<MarketplaceListing | null>(null);
// Whether plugins are off for users, so the one quick-install dialog can offer to
// turn them on too instead of asking again after installing. null: not known yet.
const [quickPluginsOff, setQuickPluginsOff] = useState<boolean | null>(null);
useEffect(() => {
if (!quickInstall) return;
let current = true;
setQuickPluginsOff(null);
void pluginsOffForUsers().then((off) => { if (current) setQuickPluginsOff(off); });
return () => { current = false; };
}, [quickInstall]);
const fetchExtensions = useCallback(async () => {
setLoading(true);
@@ -238,7 +249,8 @@ export function MarketplacePage() {
return next;
});
async function install(ext: { slug: string; name: string; type: 'plugin' | 'theme' }, version: string, isUpdate: boolean) {
/** `turnOn`: the admin already said whether to turn plugins on for users; otherwise they're asked after installing. */
async function install(ext: { slug: string; name: string; type: 'plugin' | 'theme' }, version: string, isUpdate: boolean, turnOn?: boolean) {
markInstalling(ext.slug, true);
setMessage(null);
const result = await installVersion({ slug: ext.slug, version, type: ext.type }, confirm);
@@ -249,7 +261,9 @@ export function MarketplacePage() {
}
const warnings = result.warnings.length ? ` (${result.warnings.length} warning${result.warnings.length === 1 ? '' : 's'})` : '';
const signed = result.signature === 'verified' ? ', signed by the directory' : '';
const outcome: TurnOnOutcome = !isUpdate && ext.type === 'plugin' ? await offerToTurnOnPlugins(confirm) : 'already-on';
const outcome: TurnOnOutcome = !isUpdate && ext.type === 'plugin'
? await offerToTurnOnPlugins(turnOn === undefined ? confirm : () => turnOn)
: 'already-on';
setMessage({
type: 'success',
text: isUpdate
@@ -305,24 +319,50 @@ export function MarketplacePage() {
/>
<ConfirmDialog {...confirmProps} />
<ConfirmDialog
<Dialog
isOpen={quickInstall !== null}
onClose={() => setQuickInstall(null)}
onConfirm={() => {
const ext = quickInstall;
setQuickInstall(null);
if (ext?.latestVersion) void install(ext, ext.latestVersion, false);
}}
role="alertdialog"
showClose={false}
title={`Install ${quickInstall?.name ?? ''}?`}
message={`${quickInstall?.author ? `By ${quickInstall.author.displayName}. ` : ''}It is installed for every user and turned on for them; they can turn it off in their settings. What it may do in each mailbox:`}
details={quickInstall ? (
description={`${quickInstall?.author ? `By ${quickInstall.author.displayName}. ` : ''}${quickPluginsOff
? 'It is installed for every user. Plugins are off for users, so it only runs once you turn them on.'
: 'It is installed for every user and turned on for them; they can turn it off in their settings.'} What it may do in each mailbox:`}
footer={(
<>
<Button variant="outline" onClick={() => setQuickInstall(null)}>Cancel</Button>
{quickPluginsOff && (
<Button
variant="outline"
onClick={() => {
const ext = quickInstall;
setQuickInstall(null);
if (ext?.latestVersion) void install(ext, ext.latestVersion, false, false);
}}
>
Install only
</Button>
)}
<Button
disabled={quickPluginsOff === null}
onClick={() => {
const ext = quickInstall;
const turnOn = quickPluginsOff ?? false;
setQuickInstall(null);
if (ext?.latestVersion) void install(ext, ext.latestVersion, false, turnOn);
}}
>
{quickPluginsOff ? 'Install and turn on plugins' : 'Install'}
</Button>
</>
)}
>
{quickInstall && (
<div className="mt-3 max-h-64 overflow-y-auto" data-testid="quick-install-permissions">
<PluginPermissionList permissions={quickInstall.permissions ?? []} compact />
</div>
) : null}
confirmText="Install"
cancelText="Cancel"
/>
)}
</Dialog>
<div className="space-y-10">
{updates && noticeCount > 0 && (
@@ -44,6 +44,18 @@ export async function offerToTurnOnPlugins(
}
}
/** Whether plugins are off for users; null when the policy can't be read. */
export async function pluginsOffForUsers(): Promise<boolean | null> {
try {
const res = await apiFetch('/api/admin/policy');
if (!res.ok) return null;
const policy = (await res.json()) as SettingsPolicy;
return !policy.features?.pluginsEnabled;
} catch {
return null;
}
}
/** The line added to the "installed" message for each outcome. */
export function turnOnNote(outcome: TurnOnOutcome): string {
switch (outcome) {
+13 -4
View File
@@ -35,6 +35,8 @@ interface LoginServerFieldProps {
value: string;
onChange: (value: string) => void;
onEdit: () => void;
/** The user pressed Change on a server that was found: they want another one, nothing went wrong. */
changing?: boolean;
/** A provider Bulwark can't reach (Gmail, Outlook.com, iCloud Mail), by name. */
provider: string | null;
error: ServerFieldError;
@@ -49,13 +51,15 @@ interface LoginServerFieldProps {
* field only opens when nothing was found or the user wants another one.
*/
export const LoginServerField = forwardRef<HTMLInputElement, LoginServerFieldProps>(function LoginServerField(
{ lookup, server, editing, value, onChange, onEdit, provider, error, showTrouble, disabled },
{ lookup, server, editing, value, onChange, onEdit, changing, provider, error, showTrouble, disabled },
ref,
) {
const t = useTranslations("uxs_login");
const id = useId();
const hintId = `${id}-hint`;
const errorId = `${id}-error`;
// After Change the lookup's result no longer matters: the user picks another server.
const notFound = lookup === "failed" && !provider && !changing;
const providerNote = provider && (
<div role="status" data-testid="login-provider-unsupported" className="p-3 rounded-xl border border-warning/30 bg-warning/5 flex items-start gap-2.5 text-sm text-foreground leading-relaxed">
@@ -112,16 +116,21 @@ export const LoginServerField = forwardRef<HTMLInputElement, LoginServerFieldPro
spellCheck={false}
value={value}
onChange={(e) => onChange(e.target.value)}
className="h-11 px-3.5 bg-muted/40 border-border/60 rounded-xl focus:bg-background focus:border-primary/50 transition-all duration-200"
className="h-11 px-3.5 bg-muted/40 border-border/60 rounded-xl focus:bg-background focus:border-primary/50 transition-all duration-200 aria-invalid:border-destructive aria-invalid:ring-2 aria-invalid:ring-destructive/20"
placeholder={t("server_placeholder")}
aria-invalid={error ? true : undefined}
aria-describedby={[error ? errorId : null, lookup === "failed" && !provider ? hintId : null].filter(Boolean).join(" ") || undefined}
aria-describedby={[error ? errorId : null, notFound || changing ? hintId : null].filter(Boolean).join(" ") || undefined}
/>
{lookup === "failed" && !provider && !error && (
{notFound && !error && (
<p id={hintId} className="text-xs text-muted-foreground leading-snug" data-testid="login-server-not-found">
{t("server_not_found")}
</p>
)}
{changing && !provider && !error && (
<p id={hintId} className="text-xs text-muted-foreground leading-snug" data-testid="login-server-change-hint">
{t("server_change_hint")}
</p>
)}
{error && (
<p id={errorId} role="alert" className="text-xs text-destructive leading-snug" data-testid="login-server-error">
{error === "invalid" ? t("server_invalid") : t("server_needed")}
@@ -779,7 +779,7 @@ describe('CalendarApp view menu', () => {
describe('CalendarApp long names', () => {
it('shows the full name of a cut-off calendar on hover', () => {
const long = { ...calendars[0], name: 'Stalwart Calendar (usera@internal.bulwarkmail.org)' };
const long = { ...calendars[0], name: 'Quarterly planning with the regional sales team (usera@internal.bulwarkmail.org)' };
useCalendarStore.setState({ calendars: [long] } as never);
renderApp();
expect(screen.getByTestId('calendar-item').querySelector('[title]')).toHaveAttribute('title', long.name);
@@ -0,0 +1,78 @@
import { render, screen } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { CalendarSidebarPanel } from '../calendar-sidebar-panel';
import { ContactsSidebar } from '@/components/contacts/contacts-sidebar';
import { collectionLabel } from '@/lib/default-collection-name';
import type { AddressBook, Calendar } from '@/lib/jmap/types';
// next-intl is mocked globally in vitest.setup: t returns the key.
vi.mock('@/stores/account-store', () => {
const state = { accounts: [], activeAccountId: null };
const hook = (sel?: (s: typeof state) => unknown) => (typeof sel === 'function' ? sel(state) : state);
hook.getState = () => state;
return { useAccountStore: hook };
});
function cal(overrides: Partial<Calendar>): Calendar {
return {
id: 'c', name: 'Cal', color: '#3b82f6', sortOrder: 0, isSubscribed: true, isVisible: true,
isDefault: false, includeInAvailability: 'all', timeZone: null, shareWith: null,
myRights: { mayWriteOwn: true, mayShare: false, mayDelete: true },
...overrides,
} as unknown as Calendar;
}
function book(overrides: Partial<AddressBook>): AddressBook {
return {
id: 'b', name: 'Book', isDefault: false, isSubscribed: true, sortOrder: 0, shareWith: null,
myRights: { mayRead: true, mayWrite: true, mayShare: false, mayDelete: true },
...overrides,
} as unknown as AddressBook;
}
describe('the server\'s default calendar and address book names (settings-36)', () => {
it('reads "Calendar" in the calendar sidebar, as in Settings, and keeps names the user chose', () => {
render(
<CalendarSidebarPanel
calendars={[
cal({ id: 'd', name: 'Stalwart Calendar (sam@example.org)', isDefault: true }),
cal({ id: 'w', name: 'Work' }),
]}
selectedCalendarIds={['d', 'w']}
onToggleVisibility={() => {}}
/>,
);
expect(screen.queryByText('Stalwart Calendar (sam@example.org)')).toBeNull();
expect(screen.getByText('default_calendar')).toBeInTheDocument();
expect(screen.getByText('Work')).toBeInTheDocument();
});
it('reads "Contacts" in the contacts sidebar', () => {
render(
<ContactsSidebar
groups={[]}
individuals={[]}
addressBooks={[
book({ id: 'd', name: 'Stalwart Address Book (sam@example.org)', isDefault: true }),
book({ id: 'f', name: 'Family' }),
]}
activeCategory="all"
onSelectCategory={vi.fn()}
onCreateGroup={vi.fn()}
onCreateContact={vi.fn()}
onEditGroup={vi.fn()}
onDeleteGroup={vi.fn()}
onComposeGroup={vi.fn()}
/>,
);
expect(screen.queryByText('Stalwart Address Book (sam@example.org)')).toBeNull();
expect(screen.getAllByText('default_address_book').length).toBeGreaterThan(0);
expect(screen.getByText('Family')).toBeInTheDocument();
});
it('keeps whose a colleague\'s shared default calendar is', () => {
expect(collectionLabel({ name: 'Stalwart Calendar (bob@example.org)', isShared: true }, 'calendar', 'Calendar')).toBe('Calendar (bob@example.org)');
expect(collectionLabel({ name: 'Stalwart Calendar (sam@example.org)' }, 'calendar', 'Calendar')).toBe('Calendar');
expect(collectionLabel({ name: 'Team', isShared: true }, 'calendar', 'Calendar')).toBe('Team');
});
});
+5 -3
View File
@@ -133,6 +133,7 @@ import { useConfig } from "@/hooks/use-config";
import { CalendarPrint, type CalendarPrintContent } from "@/components/calendar/calendar-print";
import { usePrintListener, usePrinting } from "@/components/print/print-root";
import { requestPrint, usePrintStore } from "@/stores/print-store";
import { useCollectionNames } from "@/hooks/use-collection-names";
// `fromEditor`: asked on the way out of the event editor, which stays open
// until a scope is picked, so Cancel goes back to it with the changes.
@@ -153,6 +154,7 @@ export interface CalendarAppProps {
export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = {}) {
// Static Lite build: the route params are empty, read the link from the URL.
const linkSegments = useLiteLinkSegments('calendar', routeSegments);
const shownName = useCollectionNames();
const router = useRouter();
const t = useTranslations("calendar");
// Organisation > Devices > No printing: no Print buttons (Ctrl+P prints a note).
@@ -2338,7 +2340,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
onClearCalendar={client ? async (cal: Calendar) => {
const ok = await confirmAction({
title: tMgmt("clear_events"),
message: tMgmt("confirm_clear", { name: cal.name }),
message: tMgmt("confirm_clear", { name: shownName.calendar(cal) }),
variant: "destructive",
confirmText: tMgmt("clear_events"),
});
@@ -2354,7 +2356,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
onDeleteCalendar={client ? async (cal: Calendar) => {
const ok = await confirmAction({
title: tMgmt("delete"),
message: tMgmt("confirm_delete", { name: cal.name }),
message: tMgmt("confirm_delete", { name: shownName.calendar(cal) }),
variant: "destructive",
confirmText: tMgmt("delete"),
});
@@ -2714,7 +2716,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
<ShareCollectionDialog
client={client}
kind="calendar"
collectionName={cal.name}
collectionName={shownName.calendar(cal)}
shareWith={cal.shareWith}
ownAccountId={client.getAccountId()}
onShare={async (principalId, rights) => {
+7 -3
View File
@@ -46,6 +46,7 @@ import {
type AgendaDay,
} from "@/lib/calendar-print";
import type { TaskViewFilter } from "@/stores/task-store";
import { useCollectionNames } from "@/hooks/use-collection-names";
type TimeFormat = "12h" | "24h";
@@ -94,6 +95,7 @@ function useTimeFormatter(timeFormat: TimeFormat) {
/** The calendar page's print copy. */
export function CalendarPrint(props: CalendarPrintProps) {
const shownName = useCollectionNames();
const t = useTranslations("calendar");
const locale = useLocale();
const { formatMonthYear, formatWeekRange, formatFullDate } = useCalendarLocale();
@@ -120,7 +122,7 @@ export function CalendarPrint(props: CalendarPrintProps) {
const meta = (
<p className="pr-meta">
{props.shownCalendars.length > 0 && (
<span>{t("print.calendars", { names: props.shownCalendars.map((c) => c.name).join(", ") })}</span>
<span>{t("print.calendars", { names: props.shownCalendars.map((c) => shownName.calendar(c)).join(", ") })}</span>
)}
{props.shownCalendars.length > 0 && <span className="pr-meta-sep" aria-hidden>·</span>}
<span>{t("print.time_zone", { zone: formatPrintTimeZone(props.timeZone, locale, props.periodDate) })}</span>
@@ -634,6 +636,7 @@ function priorityKey(priority: number): string | null {
}
function TaskListPrint({ tasks, calendars, timeFormat }: { tasks: CalendarTask[]; calendars: Calendar[]; timeFormat: TimeFormat }) {
const shownName = useCollectionNames();
const t = useTranslations("calendar");
const intlFormatter = useDisplayDateFormatter();
if (tasks.length === 0) return <p className="pr-empty">{t("tasks.no_tasks")}</p>;
@@ -661,7 +664,7 @@ function TaskListPrint({ tasks, calendars, timeFormat }: { tasks: CalendarTask[]
<span className="pr-checkbox" aria-hidden>{done ? "✓" : ""}</span>
<div>
<div className="pr-task-title"><bdi>{task.title || t("tasks.no_title")}</bdi></div>
{calendar && <div className="pr-task-meta"><bdi>{calendar.name}</bdi></div>}
{calendar && <div className="pr-task-meta"><bdi>{shownName.calendar(calendar)}</bdi></div>}
</div>
<div className="pr-task-meta">{dueLabel}</div>
<div className="pr-task-meta">{priority ? t(priority) : ""}</div>
@@ -682,6 +685,7 @@ export function EventPrintSheet({
timeFormat: TimeFormat;
timeZone: string;
}) {
const shownName = useCollectionNames();
const t = useTranslations("calendar");
const locale = useLocale();
const formatEventDate = useFormatEventDate();
@@ -740,7 +744,7 @@ export function EventPrintSheet({
{meeting && (<><dt>{t("print.meeting")}</dt><dd><bdi>{meeting.uri}</bdi></dd></>)}
{recurrence && (<><dt>{t("print.repeats")}</dt><dd>{recurrence}</dd></>)}
{alerts && (<><dt>{t("tasks.alert")}</dt><dd>{alerts}</dd></>)}
{calendar && (<><dt>{t("tasks.calendar")}</dt><dd><bdi>{calendar.name}</bdi></dd></>)}
{calendar && (<><dt>{t("tasks.calendar")}</dt><dd><bdi>{shownName.calendar(calendar)}</bdi></dd></>)}
{organizer && (
<>
<dt>{t("participants.organizer")}</dt>
@@ -26,6 +26,7 @@ import { displayNow } from "@/lib/timezone";
import { readableTextOn } from "@/lib/color-transform";
import { shareRecipientNames, useShareRecipientLabels } from "@/hooks/use-share-recipient-labels";
import { useCvdColor } from "@/hooks/use-cvd-color";
import { useCollectionNames } from "@/hooks/use-collection-names";
/**
* Split a per-account calendar list into "owned" (the user's own) and
@@ -94,6 +95,7 @@ export function CalendarSidebarPanel({
client,
multiAccountMode,
}: CalendarSidebarPanelProps) {
const shownName = useCollectionNames();
const t = useTranslations("calendar");
const tUx = useTranslations("ux_pim_calendar");
const paint = useCvdColor();
@@ -359,8 +361,8 @@ export function CalendarSidebarPanel({
>
{isVisible && <Check className="w-2.5 h-2.5" stroke={3.5} />}
</span>
<span className={cn("truncate", !isVisible && "text-muted-foreground")} title={cal.name}>
{cal.name}
<span className={cn("truncate", !isVisible && "text-muted-foreground")} title={shownName.calendar(cal)}>
{shownName.calendar(cal)}
</span>
{isSubscriptionCalendar(cal.id) && (
<>
@@ -393,7 +395,7 @@ export function CalendarSidebarPanel({
"opacity-0 group-hover/cal:opacity-100 group-focus-within/cal:opacity-100 focus-visible:opacity-100 pointer-coarse:opacity-100",
contextMenu.isOpen && contextMenu.data?.id === cal.id && "opacity-100"
)}
label={t('calendar_options', { name: cal.name })}
label={t('calendar_options', { name: shownName.calendar(cal) })}
>
<MoreVertical />
</IconButton>
+6 -4
View File
@@ -16,6 +16,7 @@ import type { Calendar } from "@/lib/jmap/types";
import { useCalendarLocale } from "@/hooks/use-calendar-locale";
import { displayNow } from "@/lib/timezone";
import { useCvdColor } from "@/hooks/use-cvd-color";
import { useCollectionNames } from "@/hooks/use-collection-names";
interface CalendarToolbarProps {
selectedDate: Date;
@@ -105,6 +106,7 @@ export function CalendarToolbar({
onCloseSearch,
panelToggle,
}: CalendarToolbarProps) {
const shownName = useCollectionNames();
const t = useTranslations("calendar");
const paint = useCvdColor();
const tCommon = useTranslations("common");
@@ -342,8 +344,8 @@ export function CalendarToolbar({
)}
style={isVisible ? { backgroundColor: color, borderColor: color } : undefined}
/>
<span className={cn("truncate", !isVisible && "text-muted-foreground")} title={cal.name}>
{cal.name}
<span className={cn("truncate", !isVisible && "text-muted-foreground")} title={shownName.calendar(cal)}>
{shownName.calendar(cal)}
</span>
</button>
);
@@ -382,8 +384,8 @@ export function CalendarToolbar({
)}
style={isVisible ? { backgroundColor: color, borderColor: color } : undefined}
/>
<span className={cn("truncate", !isVisible && "text-muted-foreground")} title={cal.name}>
{cal.name}
<span className={cn("truncate", !isVisible && "text-muted-foreground")} title={shownName.calendar(cal)}>
{shownName.calendar(cal)}
</span>
</button>
);
+3 -1
View File
@@ -38,6 +38,7 @@ import { isEditableEventTarget } from "@/lib/keyboard";
import { isSingleKeyShortcutOff } from "@/lib/single-key-shortcuts";
import { useCvdColor } from "@/hooks/use-cvd-color";
import { gateLinkClick } from "@/lib/plugin-hook-result";
import { useCollectionNames } from "@/hooks/use-collection-names";
interface EventDetailPopoverProps {
event: CalendarEvent;
@@ -146,6 +147,7 @@ export function EventDetailPopover({
isMobile,
pinned = false,
}: EventDetailPopoverProps) {
const shownName = useCollectionNames();
const t = useTranslations("calendar");
const paint = useCvdColor();
const tCommon = useTranslations("common");
@@ -643,7 +645,7 @@ export function EventDetailPopover({
{calendar && (
<div className="flex items-start gap-2.5">
<CalendarIcon className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
<span dir="auto" className="text-sm text-foreground truncate" title={calendar.name}>{calendar.name}</span>
<span dir="auto" className="text-sm text-foreground truncate" title={shownName.calendar(calendar)}>{shownName.calendar(calendar)}</span>
</div>
)}
+3 -1
View File
@@ -58,6 +58,7 @@ import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
import { useFocusTrap } from "@/hooks/use-focus-trap";
import { useCvdColor } from "@/hooks/use-cvd-color";
import { gateLinkClick } from "@/lib/plugin-hook-result";
import { useCollectionNames } from "@/hooks/use-collection-names";
export interface PendingEventPreview {
start: Date;
@@ -275,6 +276,7 @@ export function EventModal({
maxParticipants = null,
isMobile = false,
}: EventModalProps) {
const shownName = useCollectionNames();
const t = useTranslations("calendar");
const paint = useCvdColor();
// Ids that tie each visible label to its field.
@@ -1527,7 +1529,7 @@ export function EventModal({
>
{selectableCalendars.map((cal) => (
<option key={cal.id} value={cal.id}>
{cal.name}
{shownName.calendar(cal)}
</option>
))}
</select>
+3 -1
View File
@@ -17,6 +17,7 @@ import { apiFetch } from "@/lib/browser-navigation";
import { explainServerAuthError } from "@/lib/server-auth-status";
import { IS_LITE } from "@/lib/lite";
import { isOfflineNow } from "@/lib/offline/state";
import { useCollectionNames } from "@/hooks/use-collection-names";
interface ICalImportModalProps {
calendars: Calendar[];
@@ -34,6 +35,7 @@ type ImportStep = "select" | "preview" | "importing";
type ImportMode = "file" | "url";
export function ICalImportModal({ calendars, client, onClose, initialUrl, initialFile }: ICalImportModalProps) {
const shownName = useCollectionNames();
const t = useTranslations("calendar.import");
const tLaunch = useTranslations("pwaLaunch");
const tCal = useTranslations("calendar");
@@ -465,7 +467,7 @@ export function ICalImportModal({ calendars, client, onClose, initialUrl, initia
>
{calendars.map((cal) => (
<option key={cal.id} value={cal.id}>
{cal.name}
{shownName.calendar(cal)}
</option>
))}
</select>
+3 -1
View File
@@ -11,6 +11,7 @@ import { IconButton } from "@/components/ui/icon-button";
import type { Calendar } from "@/lib/jmap/types";
import { useDisplayDateFormatter } from "@/hooks/use-display-date-formatter";
import { useCvdColor } from "@/hooks/use-cvd-color";
import { useCollectionNames } from "@/hooks/use-collection-names";
export interface QuickCreateDraft {
start: Date;
@@ -69,6 +70,7 @@ export function QuickCreatePopover({
onMoreOptions,
onClose,
}: QuickCreatePopoverProps) {
const shownName = useCollectionNames();
const t = useTranslations("calendar");
const paint = useCvdColor();
const tCommon = useTranslations("common");
@@ -207,7 +209,7 @@ export function QuickCreatePopover({
className="flex-1 min-w-0 rounded-md border border-input bg-background px-3 py-1.5 text-sm focus:outline-hidden"
>
{calendars.map((c) => (
<option key={c.id} value={c.id}>{c.name}</option>
<option key={c.id} value={c.id}>{shownName.calendar(c)}</option>
))}
</select>
</div>
+3 -1
View File
@@ -12,6 +12,7 @@ import { useSettingsStore } from "@/stores/settings-store";
import { useCalendarStore } from "@/stores/calendar-store";
import { useDisplayDateFormatter } from "@/hooks/use-display-date-formatter";
import { useCvdColor } from "@/hooks/use-cvd-color";
import { useCollectionNames } from "@/hooks/use-collection-names";
import { parseTaskDue } from "@/lib/task-due-parser";
interface TaskListViewProps {
@@ -220,6 +221,7 @@ export function TaskListView({
selectedTaskId,
onQuickCreate,
}: TaskListViewProps) {
const shownName = useCollectionNames();
const t = useTranslations("calendar");
const paint = useCvdColor();
const timeFormat = useSettingsStore((s) => s.timeFormat);
@@ -307,7 +309,7 @@ export function TaskListView({
{cal && (
<span className="text-xs text-muted-foreground flex items-center gap-1">
<span className="w-2 h-2 rounded-full flex-shrink-0" style={{ backgroundColor: paint(cal.color || "#3b82f6") }} />
{cal.name}
{shownName.calendar(cal)}
</span>
)}
</span>
+3 -1
View File
@@ -16,6 +16,7 @@ import { taskToView } from "@/lib/plugin-projection";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
import { canCreateEventsIn } from "@/lib/calendar-editability";
import { useCollectionNames } from "@/hooks/use-collection-names";
import { defaultTaskList } from "@/components/calendar/task-list-view";
interface TaskModalProps {
@@ -61,6 +62,7 @@ export function TaskModal({
isMobile = false,
isSubscriptionCalendar,
}: TaskModalProps) {
const shownName = useCollectionNames();
const t = useTranslations("calendar");
const tUx = useTranslations("ux_pim_calendar");
const tCommon = useTranslations("common");
@@ -353,7 +355,7 @@ export function TaskModal({
className="rounded-md border border-input bg-background px-3 py-1.5 text-sm w-full"
>
{calendarOptions.map((cal) => (
<option key={cal.id} value={cal.id}>{cal.name}</option>
<option key={cal.id} value={cal.id}>{shownName.calendar(cal)}</option>
))}
</select>
</div>
@@ -570,12 +570,12 @@ describe('ContactForm', () => {
it("names the account of an address book once, not twice", () => {
const books = [
{ id: 'b1', name: 'Stalwart Address Book (usera@example.org)', accountName: 'usera@example.org', isDefault: true },
{ id: 'b1', name: 'Personal (usera@example.org)', accountName: 'usera@example.org', isDefault: true },
{ id: 'b2', name: 'Team', accountName: 'userb@example.org', isShared: true },
] as never;
render(<ContactForm addressBooks={books} onSave={vi.fn()} onCancel={vi.fn()} />);
const options = screen.getAllByRole('option').map((o) => o.textContent);
expect(options).toContain('Stalwart Address Book (usera@example.org)');
expect(options).toContain('Personal (usera@example.org)');
expect(options).toContain('Team (userb@example.org)');
});
});
@@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button";
import { Dialog } from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
import type { AddressBook } from "@/lib/jmap/types";
import { useCollectionNames } from "@/hooks/use-collection-names";
interface AddressBookPickerDialogProps {
isOpen: boolean;
@@ -21,6 +22,7 @@ interface AddressBookPickerDialogProps {
/** "Move to…" for contacts: pick the address book they go to. */
export function AddressBookPickerDialog({ isOpen, onClose, addressBooks, currentId, count, onPick }: AddressBookPickerDialogProps) {
const shownName = useCollectionNames();
const t = useTranslations("contacts");
const [selected, setSelected] = useState<string | null>(null);
const choice = addressBooks.find((b) => b.id === selected && b.id !== currentId) ?? null;
@@ -64,7 +66,7 @@ export function AddressBookPickerDialog({ isOpen, onClose, addressBooks, current
)}
>
<Book className="w-4 h-4 shrink-0 text-muted-foreground" />
<span className="truncate flex-1" title={book.name}>{book.name}</span>
<span className="truncate flex-1" title={shownName.addressBook(book)}>{shownName.addressBook(book)}</span>
{book.isShared && book.accountName && (
<span className="text-xs text-muted-foreground truncate max-w-[40%]" title={book.accountName}>{book.accountName}</span>
)}
+3 -1
View File
@@ -14,6 +14,7 @@ import { cn } from "@/lib/utils";
import { addressStyle, addressToParts, countryCodeFromName, partsToComponents, type AddressStyle } from "@/lib/address-format";
import { useBottomControls } from "@/hooks/use-bottom-controls";
import type { ContactCard, ContactOnlineService, ContactAnniversary, ContactPersonalInfo, AddressBook, AnniversaryDate, PartialDate, ContactAddress, ContactMedia } from "@/lib/jmap/types";
import { useCollectionNames } from "@/hooks/use-collection-names";
// Each row of a list field keeps the `key` of the card's entry it was read
// from, so Save can send an entry the user didn't touch back unchanged.
@@ -291,6 +292,7 @@ function Select({ value, onChange, children, className, id, "aria-label": ariaLa
}
export function ContactForm({ contact, addressBooks, allKeywords, defaultAddressBookId, prefill, onSave, onCancel, loadCurrentMedia, onDirtyChange }: ContactFormProps) {
const shownName = useCollectionNames();
const t = useTranslations("contacts.form");
const tx = useTranslations("ux_pim_contact");
const locale = useLocale();
@@ -909,7 +911,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
{addressBooks.map((book) => (
<option key={book.id} value={book.id}>
{/* A shared book says whose it is, unless its name already does. */}
{book.isShared && book.accountName && !book.name.includes(book.accountName) ? `${book.name} (${book.accountName})` : book.name}
{book.isShared && book.accountName && !shownName.addressBook(book).includes(book.accountName) ? `${shownName.addressBook(book)} (${book.accountName})` : shownName.addressBook(book)}
</option>
))}
</select>
@@ -14,6 +14,7 @@ import {
import type { AddressBook, ContactCard } from "@/lib/jmap/types";
import { getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
import { isOfflineNow } from "@/lib/offline/state";
import { useCollectionNames } from "@/hooks/use-collection-names";
interface ContactImportDialogProps {
existingContacts: ContactCard[];
@@ -53,6 +54,7 @@ export function ContactImportDialog({
defaultAddressBookId,
}: ContactImportDialogProps) {
const t = useTranslations("contacts");
const shownName = useCollectionNames();
const tCsv = useTranslations("ux_pim_cimport");
const tLaunch = useTranslations("pwaLaunch");
const fileRef = useRef<HTMLInputElement>(null);
@@ -354,7 +356,7 @@ export function ContactImportDialog({
>
{addressBooks.map((book) => (
<option key={book.id} value={book.id}>
{book.isShared && book.accountName ? `${book.name} (${book.accountName})` : book.name}
{book.isShared && book.accountName ? `${shownName.addressBook(book)} (${book.accountName})` : shownName.addressBook(book)}
</option>
))}
</select>
+10 -7
View File
@@ -80,6 +80,7 @@ import {
DirectoryEntryPrintSheet,
DirectoryListPrint,
} from "@/components/contacts/contact-print-views";
import { useCollectionNames } from "@/hooks/use-collection-names";
type View =
| "list"
@@ -101,6 +102,7 @@ export interface ContactsAppProps {
function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps = {}) {
// Static Lite build: the route params are empty, read the link from the URL.
const linkSegments = useLiteLinkSegments('contacts', routeSegments);
const shownName = useCollectionNames();
const t = useTranslations("contacts");
const skeleton = useSkeletonLabels();
// Organisation > Devices > No printing: no Print buttons (Ctrl+P prints a note).
@@ -490,14 +492,14 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
try {
await moveContactToAddressBook(client, contactIds, addressBook);
const msg = contactIds.length === 1
? t("address_books.moved", { name: addressBook.name })
: t("address_books.moved_plural", { count: contactIds.length, name: addressBook.name });
? t("address_books.moved", { name: shownName.addressBook(addressBook) })
: t("address_books.moved_plural", { count: contactIds.length, name: shownName.addressBook(addressBook) });
toast.success(msg);
} catch (error) {
console.error('Failed to move contacts:', error);
toast.error(t("address_books.move_failed"));
}
}, [client, moveContactToAddressBook, t]);
}, [client, moveContactToAddressBook, t, shownName]);
// Address books contacts can be moved to; "Move to…" needs a second one.
const writableBooks = useMemo(() => addressBooks.filter((b) => b.myRights?.mayWrite !== false), [addressBooks]);
@@ -1379,7 +1381,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
onDeleteAddressBook={client ? fromDrawer(async (book: AddressBook) => {
const ok = await confirmDialog({
title: t("address_books.delete_title"),
message: t("address_books.confirm_delete", { name: book.name }),
message: t("address_books.confirm_delete", { name: shownName.addressBook(book) }),
variant: "destructive",
confirmText: t("address_books.delete"),
});
@@ -1640,12 +1642,13 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
onClose={() => setRenamingAddressBook(null)}
title={t("address_books.rename")}
placeholder={t("address_books.name_label")}
defaultValue={renamingAddressBook?.name ?? ""}
defaultValue={renamingAddressBook ? shownName.addressBook(renamingAddressBook) : ""}
confirmText={t("form.save")}
cancelText={t("form.cancel")}
onSubmit={(newName) => {
const book = renamingAddressBook;
if (!client || !book || newName === book.name) return;
// The shown name kept as it was is no rename, also for the server's default name.
if (!client || !book || newName === book.name || newName === shownName.addressBook(book)) return;
void (async () => {
try {
await renameAddressBook(client, book, newName);
@@ -1705,7 +1708,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
<ShareCollectionDialog
client={client}
kind="addressBook"
collectionName={book.name}
collectionName={shownName.addressBook(book)}
shareWith={book.shareWith}
ownAccountId={client.getAccountId()}
onShare={async (principalId, rights) => {
+5 -2
View File
@@ -12,6 +12,7 @@ import { cn } from "@/lib/utils";
import type { ContactCard, AddressBook } from "@/lib/jmap/types";
import { getContactDisplayName, TRUSTED_SENDERS_BOOK_NAME } from "@/stores/contact-store";
import { useAccountStore } from "@/stores/account-store";
import { useCollectionNames } from "@/hooks/use-collection-names";
export type ContactCategory = "all" | "directory" | { groupId: string } | { addressBookId: string } | { keyword: string } | "uncategorized";
@@ -124,6 +125,7 @@ export function ContactsSidebar({
className,
multiAccountMode,
}: ContactsSidebarProps) {
const shownName = useCollectionNames();
const t = useTranslations("contacts");
const tx = useTranslations("ux_pim_contact");
const router = useRouter();
@@ -137,7 +139,7 @@ export function ContactsSidebar({
const bookMenu = (book: AddressBook) => hasBookMenu ? {
onContextMenu: (e: React.MouseEvent) => openBookContextMenu(e, book),
onOpenMenu: (anchor: HTMLElement) => openBookMenuFrom(anchor, book, "end"),
menuLabel: t("row_actions", { name: book.name }),
menuLabel: t("row_actions", { name: shownName.addressBook(book) }),
} : {};
const [collapsed, setCollapsed] = useState<Record<string, boolean>>(loadCollapsed);
@@ -915,6 +917,7 @@ function AddressBookItem({
onSelect: () => void;
onDropContacts?: (contactIds: string[], addressBook: AddressBook) => void;
}) {
const shownName = useCollectionNames();
const [isDragOver, setIsDragOver] = useState(false);
const handleDragOver = useCallback((e: DragEvent<HTMLDivElement>) => {
@@ -949,7 +952,7 @@ function AddressBookItem({
isActive={isActive}
isDragOver={isDragOver}
icon={<Book className="w-4 h-4 flex-shrink-0" />}
label={book.name}
label={shownName.addressBook(book)}
count={contactCount}
extra={sharedOut ? <Users className="w-3 h-3 text-muted-foreground flex-shrink-0 ms-auto" /> : undefined}
onSelect={onSelect}
@@ -0,0 +1,28 @@
import { render, screen, waitFor } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import React from 'react';
import { RichTextEditor } from '../rich-text-editor';
// settings-p2-19: a three-line out-of-office reply doesn't need the
// composer's headings, code block and table. The short toolbar keeps bold,
// italic, underline, the lists and link.
async function setup(toolbar?: 'full' | 'short') {
let ready = false;
render(<RichTextEditor content="<p>Hello</p>" onChange={() => {}} toolbar={toolbar} onEditorReady={() => { ready = true; }} />);
await waitFor(() => expect(ready).toBe(true));
return Array.from(screen.getByRole('toolbar', { name: 'formatting' }).querySelectorAll<HTMLElement>('[data-toolbar-item]'))
.map((el) => el.dataset.toolbarItem);
}
describe('the short formatting toolbar', () => {
it('has only bold, italic, underline, the lists and link', async () => {
expect(await setup('short')).toEqual(['bold', 'italic', 'underline', 'bullet-list', 'ordered-list', 'link']);
});
it('leaves the full toolbar as it was', async () => {
const items = await setup();
for (const name of ['font-size', 'heading-1', 'code-block', 'table', 'text-color', 'undo']) expect(items).toContain(name);
});
});
@@ -53,6 +53,7 @@ import { sanitizeColor } from '@/components/calendar/event-card';
import { RecipientPopover } from './recipient-popover';
import { useCalendarErrorMessage } from '@/hooks/use-calendar-error-message';
import { useAccountOwner } from '@/components/calendar/answer-bars';
import { useCollectionNames } from "@/hooks/use-collection-names";
import { resolveTimeZone } from '@/lib/timezone';
interface InvitationChangeItem {
@@ -380,6 +381,7 @@ interface CalendarInvitationBannerProps {
type BannerState = 'loading' | 'parsed' | 'rsvp-sent' | 'imported' | 'error';
export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProps) {
const shownName = useCollectionNames();
const t = useTranslations('email_viewer.calendar_invitation');
const tEvent = useTranslations('ux_pim_event');
const describeError = useCalendarErrorMessage();
@@ -1260,7 +1262,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
className="w-3 h-3 rounded-full flex-shrink-0"
style={{ backgroundColor: sanitizeColor(cal.color) }}
/>
<span className="truncate text-foreground">{cal.name}</span>
<span className="truncate text-foreground">{shownName.calendar(cal)}</span>
</button>
))}
</div>,
+12
View File
@@ -179,6 +179,11 @@ interface RichTextEditorProps {
* away entirely.
*/
toolbarPlacement?: 'top' | 'bottom' | 'hidden';
/**
* "short" keeps bold, italic, underline, the lists and link: enough for a
* few lines such as an out-of-office reply. "full" is the composer's.
*/
toolbar?: 'full' | 'short';
/** The editing area's accessible name ("Message"). */
ariaLabel?: string;
/** The body failed validation: exposed as aria-invalid on the editing area. */
@@ -479,6 +484,7 @@ export function RichTextEditor({
onMentionAdd,
collapseToolbarOnMobile = false,
toolbarPlacement = 'top',
toolbar = 'full',
ariaLabel,
ariaInvalid,
ariaDescribedBy,
@@ -928,7 +934,9 @@ export function RichTextEditor({
// A group that doesn't fit renders in the "More formatting" panel instead, in row order.
const folded = new Set<FoldableGroup>(FOLDABLE_GROUPS.slice(0, foldCount).map((group) => group.id));
const overflow: React.ReactNode[] = [];
const short = toolbar === 'short';
const place = (group: FoldableGroup, node: React.ReactNode) => {
if (short) return null;
if (!folded.has(group)) return node;
overflow.push(<React.Fragment key={group}>{node}</React.Fragment>);
return null;
@@ -1033,6 +1041,7 @@ export function RichTextEditor({
>
{/* Font size - two baseline-aligned A's read as "size" at a glance;
once a size is chosen the button shows that number instead. */}
{!short && <>
<div ref={fontSizeWrapperRef} className="relative">
<ToolbarButton
ref={fontSizeButtonRef}
@@ -1098,6 +1107,7 @@ export function RichTextEditor({
</div>
<ToolbarSeparator />
</>}
<ToolbarButton
item="bold"
@@ -1123,6 +1133,7 @@ export function RichTextEditor({
>
<UnderlineIcon className="w-4 h-4" />
</ToolbarButton>
{!short && <>
<ToolbarButton
item="strike"
pressed={editor.isActive("strike")}
@@ -1216,6 +1227,7 @@ export function RichTextEditor({
</ToolbarPopover>
)}
</div>
</>}
<ToolbarSeparator />
@@ -17,6 +17,7 @@ import { isDocumentRTL } from '@/i18n/direction';
import type { Email } from '@/lib/jmap/types';
import type { StructuredCard } from '@/lib/structured-mail';
import { useBookingCalendar } from './use-booking-calendar';
import { useCollectionNames } from "@/hooks/use-collection-names";
const BUTTON = 'inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground px-3 py-1.5 rounded-md border border-border hover:bg-muted transition-colors min-h-[36px] disabled:opacity-50';
const PICKER_WIDTH = 208;
@@ -47,6 +48,7 @@ function pickerPosition(rect: DOMRect, count: number): PickerPosition {
* action (see useBookingCalendar).
*/
export function BookingCalendarActions({ booking, email }: { booking: StructuredCard; email: Pick<Email, 'from' | 'subject'> }) {
const shownName = useCollectionNames();
const t = useTranslations('structured_mail_calendar');
const calendar = useBookingCalendar(booking, email);
const { status, calendars, busy, error, notice } = calendar;
@@ -186,7 +188,7 @@ export function BookingCalendarActions({ booking, email }: { booking: Structured
className="w-full px-3 py-1.5 text-sm text-start hover:bg-muted focus:bg-muted focus:outline-none flex items-center gap-2 min-h-[36px]"
>
<span className="w-3 h-3 rounded-full flex-shrink-0" style={{ backgroundColor: sanitizeColor(cal.color) }} />
<span className="truncate text-foreground">{cal.name}</span>
<span className="truncate text-foreground">{shownName.calendar(cal)}</span>
</button>
))}
</div>,
+52 -10
View File
@@ -1,6 +1,6 @@
"use client";
import { useState, useCallback, useMemo, useRef } from "react";
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
import { useLocale, useTranslations } from "next-intl";
import { sortFolderTree } from "@/lib/move-menu";
import { Button } from "@/components/ui/button";
@@ -41,6 +41,16 @@ interface FilterRuleModalProps {
suggestions?: RuleSuggestion[];
/** Offer "Also apply to existing messages in this folder". */
offerApplyToExisting?: boolean;
/**
* The folder the offer is about, by name ("Inbox"), when the dialog isn't
* opened from that folder (Settings › Filters).
*/
applyExistingFolder?: string;
/**
* How many messages in that folder the rule as typed would change. The
* offer says the number, and is greyed when it is 0.
*/
countExisting?: (rule: FilterRule) => Promise<number>;
mailboxes: Mailbox[];
/** Server cap on redirects per message (Sieve `maxNumberRedirects`). */
maxRedirects?: number | null;
@@ -198,6 +208,8 @@ export function FilterRuleModal({
initialRule,
suggestions = [],
offerApplyToExisting = false,
applyExistingFolder,
countExisting,
mailboxes,
maxRedirects,
forwardsBefore = 0,
@@ -345,6 +357,30 @@ export function FilterRuleModal({
return retroactiveSupport({ conditions: validConditions, actions: checkActions }).ok;
}, [offerApplyToExisting, validConditions, validActions, periodOn]);
// How many messages the rule would change there, asked of the server a
// moment after the conditions and actions stop changing. null: not known
// (still asking, no action yet, or the server didn't answer).
const [existingCount, setExistingCount] = useState<number | null>(null);
const countKey = canApplyToExisting && countExisting && validActions.length > 0
? JSON.stringify([matchType, validConditions, validActions])
: null;
const countRef = useRef(countExisting);
countRef.current = countExisting;
useEffect(() => {
setExistingCount(null);
if (!countKey || !countRef.current) return;
const [type, conds, acts] = JSON.parse(countKey) as [FilterRule["matchType"], FilterCondition[], FilterAction[]];
let stale = false;
const timer = setTimeout(() => {
countRef.current!({ id: "count", name: "", enabled: true, matchType: type, conditions: conds, actions: acts, stopProcessing: false })
.then((count) => { if (!stale) setExistingCount(count); })
.catch(() => { /* the offer stays, without a number */ });
}, 400);
return () => { stale = true; clearTimeout(timer); };
}, [countKey]);
const noneToApply = existingCount === 0;
const canTickApply = canApplyToExisting && !noneToApply;
// What keeps the rule from being saved, said under the part it is about
// once Save was pressed, and updated as the user fixes it.
const [showErrors, setShowErrors] = useState(false);
@@ -427,8 +463,8 @@ export function FilterRuleModal({
includeSpam: includeSpam && validActions.some((a) => ACTIONS_WITH_MAILBOX.has(a.type)) ? true : undefined,
activeFrom: activeFrom ?? undefined,
activeUntil: activeUntil ?? undefined,
}, { applyToExisting: applyToExisting && canApplyToExisting });
}, [name, matchType, validConditions, validActions, stopProcessing, includeSpam, periodOn, activeFrom, activeUntil, hasErrors, start, onSave, applyToExisting, canApplyToExisting]);
}, { applyToExisting: applyToExisting && canTickApply });
}, [name, matchType, validConditions, validActions, stopProcessing, includeSpam, periodOn, activeFrom, activeUntil, hasErrors, start, onSave, applyToExisting, canTickApply]);
const visibleSuggestions = suggestions.filter((s) => !usedSuggestions.has(s.id));
@@ -954,22 +990,28 @@ export function FilterRuleModal({
<input
type="checkbox"
id="applyToExisting"
checked={applyToExisting && canApplyToExisting}
disabled={!canApplyToExisting}
checked={applyToExisting && canTickApply}
disabled={!canTickApply}
onChange={(e) => setApplyToExisting(e.target.checked)}
className="rounded border-input disabled:opacity-50"
aria-describedby={canApplyToExisting ? undefined : "applyToExistingHint"}
aria-describedby={canTickApply ? undefined : "applyToExistingHint"}
/>
<label
htmlFor="applyToExisting"
className={cn("text-sm", canApplyToExisting ? "text-foreground" : "text-muted-foreground")}
className={cn("text-sm", canTickApply ? "text-foreground" : "text-muted-foreground")}
>
{t("apply_existing")}
{applyExistingFolder === undefined
? t("apply_existing")
: existingCount && canApplyToExisting
? t("apply_existing_count", { count: existingCount, folder: applyExistingFolder })
: t("apply_existing_in", { folder: applyExistingFolder })}
</label>
</div>
{!canApplyToExisting && (
{!canTickApply && (
<p id="applyToExistingHint" className="mt-1 ms-6 text-xs text-muted-foreground">
{periodOn ? t("apply_existing_period") : t("apply_existing_unsupported")}
{!canApplyToExisting
? (periodOn ? t("apply_existing_period") : t("apply_existing_unsupported"))
: t("apply_existing_none", { folder: applyExistingFolder ?? "" })}
</p>
)}
</div>
+6 -4
View File
@@ -34,6 +34,7 @@ export function IdentityForm({ identity, onSave, onCancel, onDirtyChange }: Iden
const t = useTranslations('identities.form');
const tValidation = useTranslations('identities.validation_errors');
const tOrg = useTranslations('org_signature');
const tName = useTranslations('uxs2_ident.your_name');
const isEditing = !!identity;
// Composing > Plain text only: the signature is plain text too.
const plainTextMode = useSettingsStore((s) => s.plainTextMode);
@@ -156,8 +157,9 @@ export function IdentityForm({ identity, onSave, onCancel, onDirtyChange }: Iden
<form onSubmit={handleSubmit} className="space-y-4">
{/* Name */}
<div>
{/* "Your name", like the Account page: the one name recipients see. */}
<label htmlFor="identity-name" className="block text-sm font-medium mb-1">
{t('name_label')}
{tName('label')}
</label>
<Input
id="identity-name"
@@ -165,12 +167,12 @@ export function IdentityForm({ identity, onSave, onCancel, onDirtyChange }: Iden
maxLength={256}
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder={t('name_placeholder')}
disabled={isSubmitting}
className={errors.name ? 'border-destructive' : ''}
aria-describedby={errors.name ? 'name-error' : undefined}
aria-describedby={errors.name ? 'name-error' : 'identity-name-hint'}
aria-invalid={!!errors.name}
/>
<p id="identity-name-hint" className="text-xs text-muted-foreground mt-1">{tName('description')}</p>
{errors.name && (
<p
id="name-error"
@@ -324,7 +326,7 @@ export function IdentityForm({ identity, onSave, onCancel, onDirtyChange }: Iden
}
/** The signature an address sends with when the admin locked signatures, read-only. */
function LockedSignature({ html, text }: { html: string; text: string }) {
export function LockedSignature({ html, text }: { html: string; text: string }) {
const t = useTranslations('org_signature');
const preview = html.trim() ? sanitizeSignatureHtmlForDisplay(html) : '';
const plain = preview ? '' : getPlainTextSignature({ textSignature: text, htmlSignature: html });
+14 -13
View File
@@ -48,6 +48,7 @@ interface IdentityManagerModalProps {
export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalProps) {
const t = useTranslations('identities');
const tAddr = useTranslations('uxs2_ident.addresses');
const tNotif = useTranslations('notifications');
// A locked or hidden signature: the identity's own isn't what it sends with.
const signatureRestriction = useRestriction('signature');
@@ -131,13 +132,13 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
await refreshIdentities();
setIsCreating(false);
toast.success(tNotif('identity_created'));
toast.success(tAddr('added'));
} catch (error) {
const message = error instanceof Error ? error.message : t('validation_errors.unknown_error');
toast.error(tNotif('identity_create_failed', { error: message }));
throw error;
}
}, [client, refreshIdentities, t, tNotif]);
}, [client, refreshIdentities, t, tAddr, tNotif]);
const handleUpdate = useCallback(async (identity: Identity, data: IdentityFormData) => {
if (!client) return;
@@ -154,13 +155,13 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
await refreshIdentities();
setEditingId(null);
toast.success(tNotif('identity_updated'));
toast.success(tAddr('saved'));
} catch (error) {
const message = error instanceof Error ? error.message : t('validation_errors.unknown_error');
toast.error(tNotif('identity_update_failed', { error: message }));
throw error;
}
}, [client, refreshIdentities, t, tNotif]);
}, [client, refreshIdentities, t, tAddr, tNotif]);
const handleDelete = useCallback(async (identity: Identity) => {
if (!client) return;
@@ -170,7 +171,7 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
}
const confirmed = await confirmDialog({
title: t('delete_confirm_title'),
title: tAddr('delete_title'),
message: t('delete_confirm'),
confirmText: t('delete_button'),
variant: "destructive",
@@ -182,14 +183,14 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
try {
if (!(await deleteIdentityWithHooks(client, identity))) return;
await refreshIdentities();
toast.success(tNotif('identity_deleted'));
toast.success(tAddr('deleted'));
} catch (error) {
const message = error instanceof Error ? error.message : t('validation_errors.unknown_error');
toast.error(tNotif('identity_delete_failed', { error: message }));
} finally {
setDeletingId(null);
}
}, [client, refreshIdentities, t, tNotif, confirmDialog]);
}, [client, refreshIdentities, t, tAddr, tNotif, confirmDialog]);
const handleSetPrimary = useCallback((identity: Identity) => {
setPreferredPrimary(identity.id);
@@ -223,7 +224,7 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
title={
<span className="flex items-center gap-3">
<Mail className="w-5 h-5 text-muted-foreground" />
{t('modal_title')}
{tAddr('modal_title')}
</span>
}
>
@@ -231,7 +232,7 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
{/* Create New Form */}
{isCreating && (
<div className="mb-6 p-4 border border-border rounded-lg bg-muted/30">
<h3 className="text-sm font-semibold mb-4">{t('create_new')}</h3>
<h3 className="text-sm font-semibold mb-4">{tAddr('add')}</h3>
<IdentityForm
onSave={handleCreate}
onCancel={() => void cancelForm()}
@@ -247,7 +248,7 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
className="mb-6 w-full sm:w-auto"
>
<Plus className="w-4 h-4 me-2" />
{t('create_new')}
{tAddr('add')}
</Button>
)}
@@ -261,7 +262,7 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
{editingId === identity.id ? (
<div className="p-4 bg-muted/30">
<h3 className="text-sm font-semibold mb-4">
{t('edit_identity')}
{tAddr('edit')}
</h3>
<IdentityForm
identity={identity}
@@ -324,7 +325,7 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
</IconButton>
)}
<IconButton
label={t('edit_identity')}
label={tAddr('edit')}
onClick={() => setEditingId(identity.id)}
disabled={!!editingId || isCreating}
>
@@ -358,7 +359,7 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
{identities.length === 0 && !isCreating && (
<div className="text-center py-12 text-muted-foreground">
<Mail className="w-12 h-12 mx-auto mb-3 opacity-50" />
<p className="text-sm">{t('no_identities')}</p>
<p className="text-sm">{tAddr('count', { count: 0 })}</p>
</div>
)}
</div>
@@ -1,9 +1,8 @@
import { act, fireEvent, render, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it } from 'vitest';
// Removing an account from the popover: the app's confirm dialog instead of
// the browser's, a control a touch screen can reach, and sign-out rows in
// the menu's plain text colour.
// The popover's rows: no remove button (settings-p2-11), whole addresses,
// and sign-out rows in the menu's plain text colour.
const { AccountSwitcher } = await import('../account-switcher');
const { useAccountStore } = await import('@/stores/account-store');
@@ -18,35 +17,34 @@ function account(id: string) {
};
}
const removeAccount = vi.fn();
beforeEach(() => {
removeAccount.mockClear();
useAccountStore.setState({ accounts: [account('a'), account('b')] } as never);
useAuthStore.setState({ activeAccountId: 'a', removeAccount } as never);
useAuthStore.setState({ activeAccountId: 'a' } as never);
useSettingsStore.setState({ interfaceLayout: 'default' } as never);
});
describe('removing an account from the popover', () => {
it('asks with the app dialog, not the browser, and removes on confirm', async () => {
const browserConfirm = vi.spyOn(window, 'confirm').mockReturnValue(true);
// settings-p2-11: each row had a hidden X next to "Sign out of …" below.
// An account you're not in is removed under Settings > Account, where
// "Manage accounts…" leads.
it('has no remove button on the rows', () => {
render(<AccountSwitcher variant="rail" />);
fireEvent.click(screen.getByTestId('account-switcher'));
fireEvent.click(screen.getByRole('menuitem', { name: 'remove_account' }));
expect(browserConfirm).not.toHaveBeenCalled();
const dialog = screen.getByRole('alertdialog', { name: 'remove_account_title' });
expect(removeAccount).not.toHaveBeenCalled();
await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'remove_account' })); });
expect(removeAccount).toHaveBeenCalledWith('b');
expect(dialog).not.toBeInTheDocument();
browserConfirm.mockRestore();
expect(screen.queryByRole('menuitem', { name: 'remove_account' })).toBeNull();
expect(screen.getAllByTestId('account-option')).toHaveLength(2);
});
it('keeps the remove control visible on a touch screen', () => {
// The address is what tells two same-named accounts apart, and it was cut
// ("dso-ux-settings@example…").
it('shows the whole address, wrapped, with the full text as a tooltip', () => {
const long = (id: string, email: string) => ({ ...account(id), email, username: email, label: 'Sam', displayName: 'Sam' });
useAccountStore.setState({ accounts: [long('a', 'dso-ux-settings@example.org'), long('b', 'dso-ux-settings-b@example.org')] } as never);
render(<AccountSwitcher variant="rail" />);
fireEvent.click(screen.getByTestId('account-switcher'));
expect(screen.getByRole('menuitem', { name: 'remove_account' }).className).toContain('pointer-coarse:opacity-100');
const title = screen.getByText('dso-ux-settings-b@example.org');
expect(title.className).not.toContain('truncate');
expect(title.className).toContain('wrap-anywhere');
expect(title.getAttribute('title')).toBe('dso-ux-settings-b@example.org');
});
it('draws "sign out of all accounts" like the other rows, start-aligned', () => {
@@ -1,5 +1,5 @@
import React from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
// "Vacation responder is active" opens Settings > Out of Office, not
@@ -21,10 +21,13 @@ vi.mock('@/hooks/use-mailbox-drop', () => ({
}));
vi.mock('@/hooks/use-tag-drop', () => ({ useTagDrop: () => ({ dropHandlers: {}, isValidDropTarget: false }) }));
vi.mock('../account-switcher', () => ({ AccountSwitcher: () => null }));
const toast = vi.hoisted(() => ({ error: vi.fn(), success: vi.fn(), info: vi.fn(), progress: vi.fn(), dismiss: vi.fn() }));
vi.mock('@/stores/toast-store', () => ({ toast }));
const { Sidebar } = await import('../sidebar');
const { useVacationStore } = await import('@/stores/vacation-store');
const { buildSettingsPath } = await import('@/lib/deep-links');
const { useAuthStore } = await import('@/stores/auth-store');
describe('vacation banner', () => {
it('opens the Out of Office settings', () => {
@@ -34,4 +37,25 @@ describe('vacation banner', () => {
expect(nav.push).toHaveBeenCalledWith(buildSettingsPath('vacation'));
expect(buildSettingsPath('vacation')).not.toBe('/settings');
});
// settings-p2-12: the banner says "Out of office is on", as the setting is
// called, and turns it off without a trip to Settings, like Gmail's "End now".
it('turns out of office off on the server', async () => {
const update = vi.fn(async () => true);
useAuthStore.setState({ client: {} as never });
useVacationStore.setState({ isEnabled: true, isSupported: true, updateVacationResponse: update } as never);
render(<Sidebar mailboxes={[]} />);
fireEvent.click(screen.getByRole('button', { name: 'vacation_turn_off' }));
await waitFor(() => expect(update).toHaveBeenCalledWith(expect.anything(), { isEnabled: false }, undefined));
expect(toast.error).not.toHaveBeenCalled();
});
it('says so when turning it off fails', async () => {
const update = vi.fn(async () => { throw new Error('down'); });
useAuthStore.setState({ client: {} as never });
useVacationStore.setState({ isEnabled: true, isSupported: true, updateVacationResponse: update } as never);
render(<Sidebar mailboxes={[]} />);
fireEvent.click(screen.getByRole('button', { name: 'vacation_turn_off' }));
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('vacation_save_failed'));
});
});
+9 -45
View File
@@ -12,7 +12,7 @@ import { useAuthStore } from "@/stores/auth-store";
import { useEmailStore } from "@/stores/email-store";
import { useSettingsStore } from "@/stores/settings-store";
import { HelpMenuItems } from "@/components/layout/help-links";
import { getMaxAccounts, sortDefaultFirst, reorderNonDefaultIds, accountRowText, accountAvatarName } from "@/lib/account-utils";
import { getMaxAccounts, sortDefaultFirst, reorderNonDefaultIds, accountRowText, accountAvatarName, accountSharesAddress } from "@/lib/account-utils";
import { inboxUnread, totalInboxUnread } from "@/lib/account-inbox-unread";
import { isDocumentRTL } from "@/i18n/direction";
import { cn, formatFileSize } from "@/lib/utils";
@@ -20,9 +20,6 @@ import { useRouter } from "@/i18n/navigation";
import { onRefusedLoginsChanged, siblingWaits, waitDate } from "@/lib/devices/wipe-state";
import { Avatar } from "@/components/ui/avatar";
import { useMenuNavigation } from "@/hooks/use-menu-navigation";
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { IconButton } from "@/components/ui/icon-button";
import { toUnicodeDomain, toUnicodeEmail } from "@/lib/idn";
import { buildSettingsPath } from "@/lib/deep-links";
@@ -118,9 +115,7 @@ export function AccountSwitcher({
const activeAccount = accounts.find((a) => a.id === activeAccountId);
const switchAccount = useAuthStore((s) => s.switchAccount);
const logout = useAuthStore((s) => s.logout);
const removeAccount = useAuthStore((s) => s.removeAccount);
const logoutAll = useAuthStore((s) => s.logoutAll);
const { dialogProps: confirmDialogProps, confirm } = useConfirmDialog();
const tWipe = useTranslations("offlineWipe");
// An account that signed out by itself beside this one: its data waits.
const [, noteWaitsChanged] = useState(0);
@@ -218,20 +213,6 @@ export function AccountSwitcher({
router.push(buildSettingsPath("account", "browser") as never);
};
const handleRemove = async (e: React.MouseEvent, account: AccountEntry) => {
e.stopPropagation();
const label = toUnicodeEmail(account.email || account.username);
// The popover closes first: the dialog is the only thing asking now.
setOpen(false);
const ok = await confirm({
title: t("remove_account_title"),
message: t("remove_account_confirm", { account: label }),
confirmText: t("remove_account"),
variant: "destructive",
});
if (ok) removeAccount(account.id);
};
const handleLogout = () => {
setOpen(false);
logout();
@@ -316,9 +297,7 @@ export function AccountSwitcher({
const unreadFor = (account: AccountEntry) =>
mvPopover || severalAccounts ? inboxUnread(unreadByAccount[account.id]) : undefined;
// The server is only worth a line when it is what tells two rows apart.
const addressKey = (account: AccountEntry) => toUnicodeEmail(account.email || account.username).trim().toLowerCase();
const sharesAddress = (account: AccountEntry) =>
accounts.some((other) => other.id !== account.id && addressKey(other) === addressKey(account));
const sharesAddress = (account: AccountEntry) => accountSharesAddress(account, accounts);
const connectedIds = accounts.filter((account) => account.isConnected).map((account) => account.id);
const allInboxesUnread = totalInboxUnread(connectedIds, unreadByAccount);
// What waits in the accounts not on screen: a dot on the avatar says so.
@@ -341,7 +320,7 @@ export function AccountSwitcher({
: "w-full px-2 py-1.5 hover:bg-muted text-start min-w-0",
className
)}
title={variant !== "expanded" ? [displayName, othersUnreadText].filter(Boolean).join("\n") : undefined}
title={[displayName, displaySubtitle, variant !== "expanded" ? othersUnreadText : null].filter(Boolean).join("\n")}
aria-label={othersUnreadText ? `${t("switch_account")}, ${othersUnreadText}` : t("switch_account")}
aria-expanded={open}
aria-haspopup="menu"
@@ -414,8 +393,8 @@ export function AccountSwitcher({
<div className="flex items-center gap-4 rounded-t-3xl bg-background px-4 pt-4 pb-3" data-mv-account-current="">
<AccountAvatar account={activeAccount} accounts={accounts} size="xl" />
<div className="min-w-0 flex-1">
<p className="text-lg leading-6 truncate">{displayName}</p>
{displaySubtitle && <p className="text-sm text-muted-foreground truncate">{displaySubtitle}</p>}
<p className="text-lg leading-6 wrap-anywhere">{displayName}</p>
{displaySubtitle && <p className="text-sm text-muted-foreground wrap-anywhere">{displaySubtitle}</p>}
</div>
</div>
)}
@@ -479,7 +458,7 @@ export function AccountSwitcher({
className={cn(
"w-full flex items-start gap-3 px-3 py-2.5 text-start transition-colors",
isActive ? "bg-accent/50" : "hover:bg-muted",
(!isActive && !account.isDefault) ? (isDraggable ? "pe-16" : "pe-10") : (isDraggable && "pe-7")
isDraggable && "pe-7"
)}
role="menuitem"
disabled={isActive}
@@ -494,7 +473,7 @@ export function AccountSwitcher({
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1">
<span className="text-sm font-medium truncate">
<span className="text-sm font-medium wrap-anywhere" title={toUnicodeEmail(text.title)}>
{toUnicodeEmail(text.title)}
</span>
{account.isDefault && accounts.length > 1 && (
@@ -505,7 +484,7 @@ export function AccountSwitcher({
<UnreadBadge count={unreadFor(account)} />
</div>
{text.subtitle && (
<p className="text-xs text-muted-foreground truncate">
<p className="text-xs text-muted-foreground wrap-anywhere" title={toUnicodeEmail(text.subtitle)}>
{toUnicodeEmail(text.subtitle)}
</p>
)}
@@ -524,25 +503,11 @@ export function AccountSwitcher({
{isDraggable && (
<span
aria-hidden
className={cn(
"pointer-events-none absolute top-1/2 -translate-y-1/2 text-muted-foreground/50 opacity-0 transition-opacity group-hover/acct:opacity-100",
!isActive && !account.isDefault ? "end-10" : "end-1.5"
)}
className="pointer-events-none absolute top-1/2 -translate-y-1/2 end-1.5 text-muted-foreground/50 opacity-0 transition-opacity group-hover/acct:opacity-100"
>
<GripVertical className="w-4 h-4" />
</span>
)}
{!isActive && !account.isDefault && (
// Shown on hover and focus, and always on a touch screen (R7).
<IconButton
onClick={(e) => void handleRemove(e, account)}
role="menuitem"
label={t("remove_account")}
className="absolute end-1.5 top-1/2 -translate-y-1/2 text-muted-foreground opacity-0 transition-opacity group-hover/acct:opacity-100 focus-visible:opacity-100 pointer-coarse:opacity-100 hover:bg-destructive/10 hover:text-destructive"
>
<X />
</IconButton>
)}
</div>
);
})}
@@ -718,7 +683,6 @@ export function AccountSwitcher({
</div>,
document.body
)}
<ConfirmDialog {...confirmDialogProps} />
</>
);
}
+44 -10
View File
@@ -77,6 +77,7 @@ import { sidebarFolded, useUIStore } from "@/stores/ui-store";
import { useAuthStore } from "@/stores/auth-store";
import { unifiedLoadProgress } from "@/lib/unified-mailbox";
import { useVacationStore } from "@/stores/vacation-store";
import { useManagedAccountStore } from "@/stores/managed-account-store";
import { useSettingsStore, getKeywordVisibility } from "@/stores/settings-store";
import { usePolicyStore } from "@/stores/policy-store";
import { useEmailStore, buildTagViewAccountClients } from "@/stores/email-store";
@@ -1106,26 +1107,59 @@ function DemoBanner({ ruled }: { ruled: boolean }) {
);
}
function VacationBanner() {
export function VacationBanner() {
const t = useTranslations('sidebar');
const tNotifications = useTranslations('notifications');
const tFilters = useTranslations('settings.filters');
const router = useRouter();
const { isEnabled, isSupported } = useVacationStore(useShallow((state) => ({ isEnabled: state.isEnabled, isSupported: state.isSupported })));
const { isEnabled, isSupported, isSaving } = useVacationStore(useShallow((state) => ({
isEnabled: state.isEnabled, isSupported: state.isSupported, isSaving: state.isSaving,
})));
// The store holds the reply of the account Settings last loaded it for.
const managedAccountId = useManagedAccountStore((s) => s.managedAccountId);
const client = useAuthStore((s) => s.client);
const [turningOff, setTurningOff] = useState(false);
if (!isSupported || !isEnabled) return null;
// Like Gmail's "End now": off on the server, without opening Settings.
const turnOff = async () => {
if (!client || turningOff) return;
setTurningOff(true);
try {
await useVacationStore.getState().updateVacationResponse(client, { isEnabled: false }, managedAccountId ?? undefined);
} catch {
toast.error(tNotifications('vacation_save_failed'));
} finally {
setTurningOff(false);
}
};
return (
<button
onClick={() => router.push(buildSettingsPath('vacation'))}
<div
className={cn(
"flex items-center gap-2 w-full px-3 py-2 text-xs",
"flex items-center gap-2 w-full px-3 py-1.5 text-xs",
"bg-amber-500/10 dark:bg-amber-400/10 text-amber-700 dark:text-amber-400",
"hover:bg-amber-500/15 dark:hover:bg-amber-400/15 transition-colors"
)}
>
<Palmtree className="w-3.5 h-3.5 flex-shrink-0" />
<span className="truncate font-medium">{t("vacation_active")}</span>
<Settings className="w-3 h-3 ms-auto flex-shrink-0 opacity-60" />
</button>
<button
type="button"
onClick={() => router.push(buildSettingsPath('vacation'))}
className="flex items-center gap-2 min-w-0 flex-1 py-0.5 text-start hover:underline"
>
<Palmtree className="w-3.5 h-3.5 flex-shrink-0" />
<span className="truncate font-medium">{tFilters("vacation_active")}</span>
</button>
<button
type="button"
onClick={() => void turnOff()}
disabled={turningOff || isSaving}
className="inline-flex flex-shrink-0 items-center gap-1 px-2 py-0.5 rounded-sm font-medium hover:bg-amber-500/15 dark:hover:bg-amber-400/15 disabled:opacity-60 transition-colors"
>
{turningOff ? <Loader2 className="w-3 h-3 animate-spin" aria-hidden /> : null}
{t("vacation_turn_off")}
</button>
</div>
);
}
+2 -3
View File
@@ -128,7 +128,7 @@ import type { Attachment, SendEmailResult } from "@/lib/jmap/types";
import type { IJMAPClient } from "@/lib/jmap/client-interface";
import { peekListAttachments, requestListAttachments, type ListAttachmentSource, type LoadListAttachments } from "@/lib/list-attachments";
import { useSearchHistoryStore } from "@/stores/search-history-store";
import { useWelcomeBannerDue, WelcomeBanner } from "@/components/ui/welcome-banner";
import { WelcomeBanner } from "@/components/ui/welcome-banner";
import { FirstStartNoticeBar } from "@/components/ui/first-start-notice";
import { NavigationRail } from "@/components/layout/navigation-rail";
import { AppMain } from "@/components/layout/app-landmarks";
@@ -288,7 +288,6 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
const linkSegments = useLiteLinkSegments('mail', routeSegments);
const t = useTranslations();
// First-start notices wait until the welcome tips are closed: one thing at a time.
const welcomeDue = useWelcomeBannerDue();
// The page skeleton the gates below draw: the same one the server sent.
const skeletonLabels = useSkeletonLabels();
const tNotifications = useTranslations('notifications');
@@ -6186,7 +6185,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
unless an enabled plugin registered tabs via api.tabs.set. */}
{!isScheduledView && <MessageListTabs panelId={messageListPanelId} />}
{!emptyPaneBesideList && <WelcomeBanner />}
<FirstStartNoticeBar hidden={welcomeDue && !emptyPaneBesideList} />
<FirstStartNoticeBar />
<div {...messageListPanelProps} className="flex-1 min-h-0 flex flex-col overflow-hidden">
<ErrorBoundary fallback={EmailListErrorFallback}>
@@ -174,3 +174,13 @@ describe('the offline banner', () => {
expect(screen.queryByTestId('offline-banner')).toBeNull();
});
});
// settings-p2-18: in a browser the page said "use disk encryption" twice,
// once in the warning and again in the key note below it.
describe('Settings > Offline in a browser', () => {
it('warns about the browser profile once', async () => {
await act(async () => { render(<OfflineSettings />); });
expect(screen.getByTestId('offline-warning').textContent).toBe('warning');
expect(screen.queryByText('browser_note')).toBeNull();
});
});
+2 -1
View File
@@ -68,7 +68,8 @@ export function OfflineSettings() {
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" aria-hidden="true" />
<p data-testid="offline-warning">{isDesktopApp() ? tStorage('warning_desktop') : t('warning')}</p>
</div>
<KeyProtectionNotes browser />
{/* The warning above already says "use disk encryption"; the browser note said it again (settings-p2-18). */}
<KeyProtectionNotes />
{enabledCount > 0 && persist === 'refused' && (
<div className="flex flex-col gap-2 rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-900 dark:text-amber-200 sm:flex-row sm:items-center sm:justify-between" role="status" data-testid="offline-persist-refused">
<p className="min-w-0">{tStorage('persist_refused')}</p>
@@ -52,18 +52,26 @@ beforeEach(() => {
});
describe('AccountSecuritySettings sign-in section', () => {
// settings-p2-18: the heading is "Two-step sign-in", the row's own name, not "Two-factor authentication".
it('heads two-step sign-in with the name of its row', () => {
signIn('basic', false);
render(<AccountSecuritySettings />);
expect(screen.queryByText('totp.section_title')).toBeNull();
expect(screen.getByRole('heading', { name: 'totp.label' })).toBeInTheDocument();
});
it('shows password and two-factor to a password sign-in', () => {
signIn('basic', false);
render(<AccountSecuritySettings />);
expect(screen.getByText('password.title')).toBeInTheDocument();
expect(screen.getByText('totp.section_title')).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'totp.label' })).toBeInTheDocument();
});
it('shows them to a password plus TOTP sign-in, which runs on OAuth tokens (#1432)', () => {
signIn('oauth', false);
render(<AccountSecuritySettings />);
expect(screen.getByText('password.title')).toBeInTheDocument();
expect(screen.getByText('totp.section_title')).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'totp.label' })).toBeInTheDocument();
});
it.each([
@@ -74,7 +82,7 @@ describe('AccountSecuritySettings sign-in section', () => {
signIn(authMode, providerSession);
render(<AccountSecuritySettings />);
expect(screen.queryByText('password.title')).toBeNull();
expect(screen.queryByText('totp.section_title')).toBeNull();
expect(screen.queryByRole('heading', { name: 'totp.label' })).toBeNull();
expect(screen.getByText('email_client.title')).toBeInTheDocument();
});
});
@@ -0,0 +1,112 @@
import { act, fireEvent, render, screen, within } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { AccountSettings } from '../account-settings';
import { useAccountStore } from '@/stores/account-store';
import { useAuthStore } from '@/stores/auth-store';
import { useIdentityStore } from '@/stores/identity-store';
import type { Identity } from '@/lib/jmap/types';
// settings-p2-2: Account had "Display name" (Stalwart's account description,
// "DSO UX audit settings (throwaway…)") while mail went out as the identity's
// "Sam Settings". There is one name now: the address's, "Your name".
// settings-p2-11: the server host stood under every account, and the drag
// hint showed with one account.
vi.mock('next-intl', () => {
const t = (key: string, values?: Record<string, unknown>) => (values ? `${key} ${JSON.stringify(values)}` : key);
return { useTranslations: () => t, useLocale: () => 'en' };
});
vi.mock('@/lib/stalwart/principal', () => ({ fetchPrincipalDisplayName: vi.fn(async () => 'Admin note (throwaway)') }));
vi.mock('../identity-settings', () => ({ EmailAddressesSection: () => null }));
vi.mock('../layout-settings', () => ({ MultipleAccountsSection: () => null }));
vi.mock('../sign-out-row', () => ({ SignOutRow: () => null }));
vi.mock('../account-archive-settings', () => ({ AccountArchiveSettings: () => null }));
const sam: Identity = {
id: 'i1', name: 'Sam Settings', email: 'sam@x.test', textSignature: '', htmlSignature: '', mayDelete: false,
};
function entry(id: string, email: string, serverUrl = 'https://mail.x.test') {
return {
id, label: 'Admin note (throwaway)', displayName: 'Admin note (throwaway)', email, username: email, serverUrl,
authMode: 'basic' as const, rememberMe: true, cookieSlot: 0, isConnected: true, hasError: false, lastLoginAt: 1,
avatarColor: '#2563eb', isDefault: id === 'a',
};
}
const updateIdentity = vi.fn();
const removeAccount = vi.fn();
function signIn(accounts: ReturnType<typeof entry>[]) {
useAccountStore.setState({ accounts: accounts as never, activeAccountId: 'a', defaultAccountId: 'a' });
useIdentityStore.setState({ identities: [sam] });
useAuthStore.setState({
username: 'sam@x.test', serverUrl: 'https://mail.x.test', isDemoMode: false, authMode: 'basic',
client: { updateIdentity, getSharedAccounts: () => [] } as never,
activeAccountId: 'a', identities: [sam], primaryIdentity: sam, removeAccount,
} as never);
}
beforeEach(() => {
updateIdentity.mockReset().mockResolvedValue(undefined);
removeAccount.mockReset();
});
describe('Account: your name', () => {
it("shows the address's name, not the server's account description, and saves it to the address", async () => {
signIn([entry('a', 'sam@x.test')]);
render(<AccountSettings />);
const row = screen.getByText('label').closest('[data-search-label]') as HTMLElement;
const input = within(row).getByRole('textbox');
expect(input).toHaveValue('Sam Settings');
const profile = document.querySelector('[data-settings-section="profile"]') as HTMLElement;
expect(within(profile).queryByText('Admin note (throwaway)')).toBeNull();
expect(within(profile).queryByDisplayValue('Admin note (throwaway)')).toBeNull();
expect(screen.queryByText('name_label')).toBeNull();
fireEvent.change(input, { target: { value: 'Sam Lee' } });
await act(async () => { fireEvent.submit(input.closest('form')!); });
expect(updateIdentity).toHaveBeenCalledWith('i1', { name: 'Sam Lee' });
expect(useAuthStore.getState().primaryIdentity?.name).toBe('Sam Lee');
// The switcher and the avatar follow the name people see.
await vi.waitFor(() => expect(useAccountStore.getState().getAccountById('a')?.displayName).toBe('Sam Lee'));
});
});
describe('Account: signed-in accounts', () => {
it('shows no server line and no drag hint for one account', () => {
signIn([entry('a', 'sam@x.test')]);
render(<AccountSettings />);
expect(screen.queryByTestId('account-server')).toBeNull();
expect(screen.queryByText('mail.x.test')).toBeNull();
expect(screen.queryByText('accounts.description')).toBeNull();
expect(screen.queryByRole('button', { name: 'accounts.move_up' })).toBeNull();
});
it('shows the server only when two accounts share an address', () => {
signIn([entry('a', 'sam@x.test'), entry('b', 'sam@y.test', 'https://mail.y.test')]);
const { unmount } = render(<AccountSettings />);
expect(screen.queryByTestId('account-server')).toBeNull();
expect(screen.getByText('accounts.description')).toBeTruthy();
unmount();
signIn([entry('a', 'sam@x.test'), entry('b', 'sam@x.test', 'https://mail.y.test')]);
render(<AccountSettings />);
expect(screen.getAllByTestId('account-server').map((el) => el.textContent)).toEqual(['mail.x.test', 'mail.y.test']);
});
it('removes an account you are not in after asking', async () => {
signIn([entry('a', 'sam@x.test'), entry('b', 'sam@y.test')]);
render(<AccountSettings />);
// Only the other account has a remove button.
const buttons = screen.getAllByRole('button', { name: 'remove_account' });
expect(buttons).toHaveLength(1);
fireEvent.click(buttons[0]);
const dialog = await screen.findByRole('alertdialog');
expect(removeAccount).not.toHaveBeenCalled();
await act(async () => { fireEvent.click(within(dialog).getByRole('button', { name: 'remove_account' })); });
expect(removeAccount).toHaveBeenCalledWith('b');
});
});
@@ -65,4 +65,12 @@ describe('Settings > Calendar on a phone', () => {
expect(screen.queryByRole('button', { name: 'title' })).toBeNull();
expect(screen.getByRole('button', { name: 'add_calendar' })).toBeInTheDocument();
});
// settings-p2-21: at 800 px the three buttons ran 4 px past the edge.
it('lets the New, Import and Subscribe buttons wrap when a row is too narrow', () => {
render(<CalendarManagementSettings />);
const row = screen.getByTestId('calendar-create-buttons');
expect(row.className).toMatch(/(^|\s)sm:flex-wrap(\s|$)/);
expect(row.querySelectorAll('button')).toHaveLength(3);
});
});
@@ -0,0 +1,87 @@
import { act, fireEvent, render, screen, within } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ComposingSettings } from '../composing-settings';
import { useSettingsStore } from '@/stores/settings-store';
import { useAuthStore } from '@/stores/auth-store';
import { useIdentityStore } from '@/stores/identity-store';
import type { Identity } from '@/lib/jmap/types';
// settings-p2-3: the signature was four clicks deep (Composing > "Email
// addresses" > Account > Manage identities > Edit identity). Composing now
// holds each address with its signature editor, saved as you type.
vi.mock('next-intl', () => ({
useTranslations: () => (key: string) => key,
useLocale: () => 'en',
}));
vi.mock('../template-settings', () => ({ TemplateSettings: () => null }));
vi.mock('../proofreading-settings', () => ({ ProofreadingSettings: () => null }));
const identity = (id: string, email: string, textSignature = ''): Identity => ({
id, name: id.toUpperCase(), email, textSignature, htmlSignature: '', mayDelete: true,
});
const updateIdentity = vi.fn();
beforeEach(() => {
vi.useFakeTimers();
updateIdentity.mockReset().mockResolvedValue(undefined);
useSettingsStore.getState().resetToDefaults();
// The plain text box: the formatting editor needs a real browser.
useSettingsStore.setState({ plainTextMode: true });
const ids = [identity('work', 'sam@work.test', 'Sam'), identity('home', 'sam@home.test')];
useIdentityStore.setState({ identities: ids });
useAuthStore.setState({ client: { updateIdentity, hasDelayedSend: () => false } as never, identities: ids, primaryIdentity: ids[0] });
});
afterEach(() => {
vi.useRealTimers();
});
const editorOf = (email: string) => {
const box = screen.getAllByTestId('address-signature').find((el) => el.getAttribute('data-address') === email)!;
return within(box).getByRole('textbox');
};
describe('Composing > Signatures', () => {
it('shows every sending address with its signature on the page', () => {
render(<ComposingSettings />);
expect(screen.getAllByTestId('address-signature').map((el) => el.getAttribute('data-address')))
.toEqual(['sam@work.test', 'sam@home.test']);
expect(editorOf('sam@work.test')).toHaveValue('Sam');
expect(screen.getByText('WORK <sam@work.test>')).toBeTruthy();
});
it('saves a signature to its address once typing pauses', async () => {
render(<ComposingSettings />);
fireEvent.change(editorOf('sam@home.test'), { target: { value: 'Cheers, Sam' } });
expect(updateIdentity).not.toHaveBeenCalled();
await act(async () => { await vi.advanceTimersByTimeAsync(1000); });
expect(updateIdentity).toHaveBeenCalledTimes(1);
expect(updateIdentity).toHaveBeenCalledWith('home', { htmlSignature: null, textSignature: 'Cheers, Sam' });
expect(useIdentityStore.getState().identities.find((i) => i.id === 'home')?.textSignature).toBe('Cheers, Sam');
expect(screen.getByText('saved')).toBeTruthy();
});
it('saves at once when the field loses focus', async () => {
render(<ComposingSettings />);
fireEvent.change(editorOf('sam@work.test'), { target: { value: 'Sam Lee' } });
await act(async () => { fireEvent.blur(editorOf('sam@work.test')); });
expect(updateIdentity).toHaveBeenCalledWith('work', { htmlSignature: null, textSignature: 'Sam Lee' });
});
it('does not save a signature over the server limit', async () => {
render(<ComposingSettings />);
fireEvent.change(editorOf('sam@work.test'), { target: { value: 'x'.repeat(2100) } });
await act(async () => { await vi.advanceTimersByTimeAsync(1500); });
expect(updateIdentity).not.toHaveBeenCalled();
});
it('shows one editor without an address line when there is one address', () => {
const only = [identity('work', 'sam@work.test', 'Sam')];
useIdentityStore.setState({ identities: only });
render(<ComposingSettings />);
expect(screen.getAllByTestId('address-signature')).toHaveLength(1);
expect(screen.queryByText('WORK <sam@work.test>')).toBeNull();
});
});
@@ -9,8 +9,8 @@ import { useFilterStore } from '@/stores/filter-store';
import { useSettingsStore } from '@/stores/settings-store';
// settings-13 (usability pass 2026-11-04): a rule made in Settings only sees
// new mail, so after Save the page offers to apply it to the Inbox; the raw
// Sieve editor is an advanced tool.
// new mail, so its dialog offers to apply it to the Inbox; the raw Sieve
// editor is an advanced tool.
const toast = vi.hoisted(() => ({ error: vi.fn(), success: vi.fn(), info: vi.fn(), progress: vi.fn(), dismiss: vi.fn() }));
vi.mock('@/stores/toast-store', () => ({ toast }));
@@ -57,6 +57,7 @@ beforeEach(() => {
updateSieveScript: vi.fn(async () => {}),
getSieveAccountId: () => 'primary',
queryEmailFields,
batchUpdateKeywords: vi.fn(async () => {}),
};
target.value = {
client, clientAccountId: null, key: 'k', accountId: 'primary', sieveAccountId: 'primary',
@@ -89,40 +90,71 @@ function showSettings(rules: FilterRule[] = [], extra: Record<string, unknown> =
render(<FilterSettings />);
}
async function createRule(from: string, action: string) {
async function fillRule(from: string, action: string) {
fireEvent.click(screen.getByText('add_rule'));
fireEvent.change(screen.getByLabelText('rule_name'), { target: { value: 'News' } });
fireEvent.change(screen.getByLabelText('condition_value'), { target: { value: from } });
fireEvent.change(screen.getAllByLabelText('actions')[0], { target: { value: action } });
fireEvent.click(screen.getByText('save'));
}
describe('a new rule in Settings › Filters', () => {
it('offers to apply it to the messages already in the Inbox', async () => {
// settings-p2-6: the offer is a box in the rule's dialog, with the number of
// messages it would change, not a toast that is gone in a few seconds.
describe('a rule in Settings › Filters', () => {
it('offers, in its dialog, to apply it to the messages already in the Inbox', async () => {
showSettings();
await createRule('news@example.org', 'star');
await waitFor(() => expect(toast.success).toHaveBeenCalled());
const [, options] = toast.success.mock.calls.at(-1)!;
expect(options.action.label).toBe('apply_to_existing');
await fillRule('news@example.org', 'star');
const box = await screen.findByRole('checkbox', { name: 'apply_existing_count' }, { timeout: 2000 });
expect(box).toBeEnabled();
expect(queryEmailFields).toHaveBeenCalled();
fireEvent.click(box);
queryEmailFields.mockClear();
fireEvent.click(screen.getByText('save'));
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('rule_applied', expect.anything()));
// Saving ran the rule; no toast offered it.
expect(queryEmailFields).toHaveBeenCalled();
expect(toast.success.mock.calls.some(([, options]) => options?.action)).toBe(false);
});
it('says nothing more when no message in the Inbox matches', async () => {
it('runs nothing on old mail when the box is left empty', async () => {
showSettings();
await createRule('nobody@example.com', 'star');
await waitFor(() => expect(queryEmailFields).toHaveBeenCalled());
await new Promise((r) => setTimeout(r, 0));
expect(toast.success).not.toHaveBeenCalled();
});
it('is not offered for a rule that can only act on arriving mail', async () => {
showSettings();
await createRule('news@example.org', 'discard');
await fillRule('news@example.org', 'star');
await screen.findByRole('checkbox', { name: 'apply_existing_count' }, { timeout: 2000 });
queryEmailFields.mockClear();
fireEvent.click(screen.getByText('save'));
await waitFor(() => expect(client.updateSieveScript).toHaveBeenCalled());
await new Promise((r) => setTimeout(r, 0));
expect(queryEmailFields).not.toHaveBeenCalled();
expect(toast.success).not.toHaveBeenCalled();
});
it('greys the box when no message in the Inbox matches', async () => {
showSettings();
await fillRule('nobody@example.com', 'star');
await waitFor(() => expect(queryEmailFields).toHaveBeenCalled(), { timeout: 2000 });
await waitFor(() => expect(screen.getByText('apply_existing_none')).toBeInTheDocument());
expect(screen.getByRole('checkbox', { name: 'apply_existing_in' })).toBeDisabled();
});
it('is greyed for a rule that can only act on arriving mail', async () => {
showSettings();
await fillRule('news@example.org', 'discard');
expect(screen.getByRole('checkbox', { name: 'apply_existing_in' })).toBeDisabled();
expect(screen.getByText('apply_existing_unsupported')).toBeInTheDocument();
await new Promise((r) => setTimeout(r, 500));
expect(queryEmailFields).not.toHaveBeenCalled();
});
});
describe('the rule list', () => {
it('says what its switch does', () => {
showSettings([{
id: 'r1', name: 'News', enabled: true, matchType: 'all',
conditions: [{ field: 'from', comparator: 'contains', value: 'news@example.org' }],
actions: [{ type: 'star' }], stopProcessing: false,
} as FilterRule]);
expect(screen.getByRole('switch', { name: 'show_rule_details' })).toBeInTheDocument();
expect(screen.queryByText('expanded_view')).toBeNull();
});
});
describe('the raw Sieve editor button', () => {
@@ -1,5 +1,6 @@
import { fireEvent, render, screen, within } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import en from '@/locales/en/common.json';
import { EmailAddressesSection } from '../identity-settings';
// SET-19: "Learn more" next to Sub-Addressing opened the identity manager,
@@ -21,4 +22,17 @@ describe('Email addresses section', () => {
fireEvent.click(screen.getByRole('button', { name: 'manage' }));
expect(screen.getByRole('dialog')).toBeTruthy();
});
// settings-p2-18: "Email addresses: The email addresses you can send from"
// over "Your sender addresses: Addresses you can send mail from". The
// section says it once; the row only counts them, without "identity".
it('says what the addresses are once, and counts them as addresses', () => {
render(<EmailAddressesSection />);
const row = screen.getByText('label').closest('[data-search-label]') as HTMLElement;
expect(row.querySelector('p')).toBeNull();
expect(within(row).getByText('count')).toBeTruthy();
expect(screen.queryByText('identities_count.description')).toBeNull();
expect(en.uxs2_ident.addresses.count).not.toMatch(/identit/i);
expect(en.uxs2_ident.addresses.manage).toBe('Edit addresses…');
});
});
@@ -1,6 +1,6 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { MultiAccountPushSettings } from '../multi-account-push';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { AccountPushSwitches } from '../multi-account-push';
import { useAccountStore } from '@/stores/account-store';
import { useAuthStore } from '@/stores/auth-store';
import * as webPush from '@/lib/web-push';
@@ -10,73 +10,82 @@ vi.mock('next-intl', () => ({
values ? `${key} ${JSON.stringify(values)}` : key,
}));
vi.mock('../settings-section', async (importOriginal) => ({
...(await importOriginal<typeof import('../settings-section')>()),
SettingsSection: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
// Push state is stored under the JMAP account id the server assigned: only
// "jmap-a" has a registration here.
vi.mock('@/lib/web-push', () => ({
isWebPushSupported: () => true,
isWebPushEnabled: vi.fn(async (id: string) => id === 'jmap-a'),
enableWebPushForAccounts: vi.fn(),
disableWebPush: vi.fn(),
disableWebPush: vi.fn(async () => undefined),
}));
describe('MultiAccountPushSettings', () => {
it('reads each login\'s push state under its JMAP account id', async () => {
const clients: Record<string, { getAccountId: () => string }> = {
'a@mail.example.com': { getAccountId: () => 'jmap-a' },
'b@mail.example.com': { getAccountId: () => 'jmap-b' },
};
useAccountStore.setState({
accounts: [
{ id: 'a@mail.example.com', username: 'a', email: 'a@example.com', isConnected: true },
{ id: 'b@mail.example.com', username: 'b', email: 'b@example.com', isConnected: true },
] as never,
});
useAuthStore.setState({ getClientForAccount: ((id: string) => clients[id]) as never });
const clients: Record<string, { getAccountId: () => string }> = {
'a@mail.example.com': { getAccountId: () => 'jmap-a' },
'b@mail.example.com': { getAccountId: () => 'jmap-b' },
};
const { container } = render(<MultiAccountPushSettings />);
beforeEach(() => {
vi.clearAllMocks();
useAccountStore.setState({
accounts: [
{ id: 'a@mail.example.com', username: 'a', email: 'a@example.com', displayName: 'Alice', isConnected: true },
{ id: 'b@mail.example.com', username: 'b', email: 'b@example.com', displayName: 'Bob', isConnected: true },
] as never,
});
useAuthStore.setState({ getClientForAccount: ((id: string) => clients[id]) as never, isDemoMode: false });
});
await waitFor(() => expect(container.textContent).toContain('a@example.com · state_on'));
expect(container.textContent).toContain('b@example.com · state_off');
const switchFor = (name: string) => screen.getByRole('switch', { name });
// settings-p2-17 (2026-11-04): a switch per account replaces the checklist
// with "Enable for selected" and "Disable for selected".
describe('AccountPushSwitches', () => {
it('shows one switch per account, set the way push is for it on this device', async () => {
render(<AccountPushSwitches />);
await waitFor(() => expect(switchFor('Alice (a@example.com)').getAttribute('aria-checked')).toBe('true'));
expect(switchFor('Bob (b@example.com)').getAttribute('aria-checked')).toBe('false');
expect(webPush.isWebPushEnabled).toHaveBeenCalledWith('jmap-a');
expect(webPush.isWebPushEnabled).not.toHaveBeenCalledWith('a@mail.example.com');
expect(screen.queryByText('select_all')).toBeNull();
expect(screen.queryByText('enable_selected')).toBeNull();
expect(screen.queryByRole('checkbox')).toBeNull();
});
it('names each account the bulk enable left off, with the reason', async () => {
const clients: Record<string, { getAccountId: () => string }> = {
'a@mail.example.com': { getAccountId: () => 'jmap-a' },
'b@mail.example.com': { getAccountId: () => 'jmap-b' },
'c@mail.example.com': { getAccountId: () => 'jmap-c' },
};
useAccountStore.setState({
accounts: [
{ id: 'a@mail.example.com', username: 'a', email: 'a@example.com', displayName: 'Alice', isConnected: true },
{ id: 'b@mail.example.com', username: 'b', email: 'b@example.com', displayName: 'Bob', isConnected: true },
{ id: 'c@mail.example.com', username: 'c', email: 'c@example.com', displayName: 'Carol', isConnected: true },
] as never,
});
useAuthStore.setState({ getClientForAccount: ((id: string) => clients[id]) as never });
it('turns push on for the one account whose switch was flipped', async () => {
vi.mocked(webPush.enableWebPushForAccounts).mockResolvedValueOnce({ enabled: ['b@mail.example.com'], failed: [] });
const onChanged = vi.fn();
render(<AccountPushSwitches onChanged={onChanged} />);
await waitFor(() => expect(switchFor('Alice (a@example.com)').getAttribute('aria-checked')).toBe('true'));
fireEvent.click(switchFor('Bob (b@example.com)'));
await waitFor(() => expect(onChanged).toHaveBeenCalled());
expect(webPush.enableWebPushForAccounts).toHaveBeenCalledTimes(1);
const [targets] = vi.mocked(webPush.enableWebPushForAccounts).mock.calls[0];
expect(targets.map((t) => t.accountId)).toEqual(['b@mail.example.com']);
});
it('turns it off for that account only', async () => {
render(<AccountPushSwitches />);
await waitFor(() => expect(switchFor('Alice (a@example.com)').getAttribute('aria-checked')).toBe('true'));
fireEvent.click(switchFor('Alice (a@example.com)'));
await waitFor(() => expect(webPush.disableWebPush).toHaveBeenCalledTimes(1));
expect(vi.mocked(webPush.disableWebPush).mock.calls[0][0].client).toBe(clients['a@mail.example.com']);
});
it('says why an account stayed off, under its switch', async () => {
vi.mocked(webPush.enableWebPushForAccounts).mockResolvedValueOnce({
enabled: ['a@mail.example.com'],
failed: [
{ accountId: 'b@mail.example.com', error: new Error('relay unreachable') },
{ accountId: 'c@mail.example.com', error: 'timeout' },
],
enabled: [],
failed: [{ accountId: 'b@mail.example.com', error: new Error('relay unreachable') }],
});
render(<MultiAccountPushSettings />);
await waitFor(() => expect(screen.getByText('select_all')).toBeInTheDocument());
fireEvent.click(screen.getByLabelText('select_all'));
fireEvent.click(screen.getByText('enable_selected'));
render(<AccountPushSwitches />);
await waitFor(() => expect(switchFor('Bob (b@example.com)')).toBeTruthy());
fireEvent.click(switchFor('Bob (b@example.com)'));
const alert = await screen.findByRole('alert');
expect(screen.getByText(/result_partial/).textContent).toContain('"enabled":1,"failed":2');
expect(alert.textContent).toContain('Bob (b@example.com): relay unreachable');
expect(alert.textContent).toContain('Carol (c@example.com): timeout');
expect(alert.textContent).not.toContain('Alice');
expect(alert.textContent).toBe('relay unreachable');
});
it('draws nothing with one account', () => {
useAccountStore.setState({ accounts: [useAccountStore.getState().accounts[0]] });
const { container } = render(<AccountPushSwitches />);
expect(container.textContent).toBe('');
});
});
@@ -279,3 +279,40 @@ describe('NotificationSettings - the unread count in the desktop app', () => {
await findByText('favicon_unread_badge.label');
});
});
// settings-p2-17 (2026-11-04): with several accounts the page had one switch
// for the account on screen and, below it, a checklist with bulk buttons.
describe('NotificationSettings - several accounts', () => {
beforeEach(async () => {
vi.clearAllMocks();
const { useAccountStore } = await import('@/stores/account-store');
const clients: Record<string, unknown> = { a: fakeClient, b: { getAccountId: () => 'acct-2' } };
useAccountStore.setState({
accounts: [
{ id: 'a', username: 'a', email: 'a@example.com', displayName: 'Alice', isConnected: true },
{ id: 'b', username: 'b', email: 'b@example.com', displayName: 'Bob', isConnected: true },
] as never,
});
useAuthStore.setState({ client: fakeClient, username: 'a@example.com', isDemoMode: false, getClientForAccount: ((id: string) => clients[id]) as never });
usePolicyStore.setState({
...usePolicyStore.getState(),
policy: {},
isSettingLocked: () => false,
isSettingHidden: () => false,
} as never);
});
afterEach(async () => {
const { useAccountStore } = await import('@/stores/account-store');
useAccountStore.setState({ accounts: [] });
});
it('has one switch per account instead of the switch for this account and a checklist', async () => {
const { findByRole, queryByText, queryByRole } = render(<NotificationSettings />);
expect(await findByRole('switch', { name: 'Alice (a@example.com)' })).toBeTruthy();
expect(await findByRole('switch', { name: 'Bob (b@example.com)' })).toBeTruthy();
expect(queryByText('push.enable')).toBeNull();
expect(queryByText('select_all')).toBeNull();
expect(queryByRole('checkbox')).toBeNull();
});
});
@@ -102,8 +102,9 @@ describe('identity dialog', () => {
// In a portal on body, not inside the settings page.
expect(screen.getByRole('dialog').parentElement?.parentElement).toBe(document.body);
fireEvent.click(screen.getByRole('button', { name: 'create_new' }));
fireEvent.change(screen.getByPlaceholderText('name_placeholder'), { target: { value: 'Work' } });
// "Add address" and "Your name" (settings-p2-2, p2-3), no placeholder.
fireEvent.click(screen.getByRole('button', { name: 'add' }));
fireEvent.change(screen.getByLabelText('label'), { target: { value: 'Work' } });
clickOutside();
expect(onClose).not.toHaveBeenCalled();
@@ -0,0 +1,185 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
import { useAuthStore } from '@/stores/auth-store';
import { usePolicyStore } from '@/stores/policy-store';
import { useSettingsStore } from '@/stores/settings-store';
// Usability pass 2, 2026-11-04: Notifications, Privacy, Backup & reset and
// Troubleshooting in the desktop app and in a browser (settings-p2-4, -9,
// -18; desktop-p2-4). Labels are keys here (next-intl mock).
const config = vi.hoisted(() => ({ settingsSyncEnabled: true }));
vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Bulwark', settingsSyncEnabled: config.settingsSyncEnabled }) }));
vi.mock('../desktop-telemetry-settings', () => ({ DesktopTelemetrySettings: () => null }));
vi.mock('@/components/mail-transfer/mail-backup-settings', () => ({ MailBackupSettings: () => null }));
vi.mock('../debug-settings', () => ({ DebugSettings: () => null }));
vi.mock('@/lib/web-push', () => ({
WebPushUnsupportedError: class WebPushUnsupportedError extends Error {},
isWebPushSupported: () => false,
isWebPushEnabled: vi.fn(async () => false),
enableWebPush: vi.fn(),
enableWebPushForAccounts: vi.fn(),
disableWebPush: vi.fn(),
listPushDevices: vi.fn(async () => []),
mailAccountsWithoutPush: vi.fn(() => []),
revokePushDevice: vi.fn(),
serverSupportsEmailPush: vi.fn(() => false),
}));
const { NotificationSettings } = await import('../notification-settings');
const { PrivacySettings } = await import('../privacy-settings');
const { AboutDataSettings } = await import('../about-data-settings');
const { DesktopDevToolsSettings } = await import('../desktop-devtools-settings');
const { AdvancedSettingsProvider } = await import('../settings-section');
const osStatus = {
settings: {
closeToTray: true, startAtLogin: false, startMinimized: false, attentionOnNewMail: true,
quietHoursEnabled: false, quietHoursStart: '22:00', quietHoursEnd: '07:00', showShortcut: null,
},
pausedUntil: null,
shortcutActive: false,
};
function installBridge() {
(window as unknown as { bulwarkDesktop: unknown }).bulwarkDesktop = {
version: 1,
platform: 'win32',
os: {
getStatus: vi.fn(async () => osStatus),
defaultAppStatus: vi.fn(async () => ({ mailto: false })),
onStatusChange: vi.fn(() => () => {}),
setSettings: vi.fn(async () => osStatus),
openDefaultAppSettings: vi.fn(),
},
devTools: {
get: vi.fn(async () => ({ on: false, forcedOff: false, locked: false })),
set: vi.fn(),
onChange: vi.fn(() => () => {}),
},
};
}
async function settle() {
await act(async () => { await new Promise((r) => setTimeout(r, 20)); });
}
const realUserAgent = navigator.userAgent;
function setUserAgent(ua: string) {
Object.defineProperty(navigator, 'userAgent', { value: ua, configurable: true });
}
beforeEach(() => {
usePolicyStore.setState({
...usePolicyStore.getState(),
policy: {},
isSettingLocked: () => false,
isSettingHidden: () => false,
isFeatureEnabled: () => true,
} as never);
useAuthStore.setState({ client: null, username: null } as never);
useSettingsStore.setState({ showAdvancedSettings: false, settingsSyncDisabled: false });
config.settingsSyncEnabled = true;
});
afterEach(() => {
cleanup();
delete (window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop;
setUserAgent(realUserAgent);
});
describe('Notifications when the app is closed', () => {
it('in the desktop app: no browser push section, one line about the tray instead', async () => {
installBridge();
render(<NotificationSettings />);
await settle();
expect(screen.queryByText('push.title')).toBeNull();
expect(screen.queryByText('push.enable')).toBeNull();
expect(screen.queryByText(/ios_hint|status_unsupported|push_unsupported/)).toBeNull();
expect(screen.getByText('closed_label')).toBeTruthy();
expect(screen.getByText('closed_description')).toBeTruthy();
// The section with the taskbar and quiet hours isn't called "Desktop app", like General's.
expect(screen.getByText('title')).toBeTruthy();
});
it('in a browser that can\'t: one sentence, without the iPhone hint off iOS', async () => {
setUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64) Firefox/140.0');
render(<NotificationSettings />);
await settle();
expect(screen.getByText('push_unsupported')).toBeTruthy();
expect(screen.queryByText(/push\.ios_hint/)).toBeNull();
expect(screen.queryByText(/status_unsupported/)).toBeNull();
});
it('on an iPhone: the home screen hint after it, as its own sentence', async () => {
setUserAgent('Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) Safari/604.1');
render(<NotificationSettings />);
await settle();
expect(screen.getByText('push_unsupported push.ios_hint')).toBeTruthy();
});
});
describe('Privacy', () => {
it('links to the push relay in a browser only', async () => {
const browser = render(<PrivacySettings />);
await settle();
expect(screen.getByText('push_title')).toBeTruthy();
browser.unmount();
installBridge();
render(<PrivacySettings />);
await settle();
expect(screen.queryByText('push_title')).toBeNull();
});
});
describe('Backup & reset', () => {
it('says "other devices" in the app, not "other browsers"', async () => {
installBridge();
render(<AboutDataSettings />);
await settle();
expect(screen.getByText('backup_description_app')).toBeTruthy();
expect(screen.queryByText('sections.backup_description')).toBeNull();
});
it('says what a reset keeps, and whether other devices change', async () => {
render(<AboutDataSettings />);
await settle();
fireEvent.click(screen.getByText('reset_settings.button'));
expect(screen.getByText('reset.message reset.sync_on')).toBeTruthy();
cleanup();
useSettingsStore.setState({ settingsSyncDisabled: true });
render(<AboutDataSettings />);
await settle();
fireEvent.click(screen.getByText('reset_settings.button'));
expect(screen.getByText('reset.message reset.sync_off')).toBeTruthy();
});
it('keeps the choice to sync settings through a reset', () => {
useSettingsStore.setState({ settingsSyncDisabled: true, showAdvancedSettings: true });
useSettingsStore.getState().resetToDefaults();
expect(useSettingsStore.getState().settingsSyncDisabled).toBe(true);
expect(useSettingsStore.getState().showAdvancedSettings).toBe(false);
});
});
describe('Developer tools in the desktop app', () => {
it('show only with advanced settings on', async () => {
installBridge();
const page = () => (
<AdvancedSettingsProvider>
<DesktopDevToolsSettings />
</AdvancedSettingsProvider>
);
render(page());
await settle();
expect(screen.queryByTestId('desktop-devtools-toggle')).toBeNull();
cleanup();
useSettingsStore.setState({ showAdvancedSettings: true });
render(page());
await settle();
expect(screen.getByTestId('desktop-devtools-toggle')).toBeTruthy();
});
});
@@ -58,7 +58,8 @@ async function openResult(query: string, label: string) {
describe('settings search and the Advanced switch', () => {
it('leaves Advanced off for a row that is not an advanced one', async () => {
await openResult('favicons', 'Sender favicons');
// settings-p2-18: the row is named like its section now, "Sender pictures".
await openResult('company websites', 'Pictures from company websites');
expect(useSettingsStore.getState().showAdvancedSettings).toBe(false);
});
@@ -1,5 +1,5 @@
import 'fake-indexeddb/auto';
import { render } from '@testing-library/react';
import { act, render } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { NextIntlClientProvider } from 'next-intl';
import en from '@/locales/en/common.json';
@@ -11,6 +11,8 @@ import en from '@/locales/en/common.json';
// row marked with its label, and is advanced exactly when the page says so.
// settings-8 (2026-11-04): and that every section a page draws can be found,
// so a new section (offline, app lock) can't be left out of the index.
// settings-p2-5 (2026-11-04): and in the desktop app, with its bridge
// there, so the app's own rows (tray, quiet hours, menu bar) are found too.
vi.unmock('next-intl');
vi.mock('@/components/plugins/plugin-iframe-slot', () => ({ PluginIframeSlot: () => null }));
@@ -73,6 +75,42 @@ const OVERRIDES: Record<string, () => unknown> = {
getShareTargets: async () => ({ targets: [], failed: [] }), listPrincipals: async () => ({ principals: [], status: 'ok' }),
getPrincipals: async () => [], listArchivedEmails: async () => ({ items: [], nextPosition: null }),
};
/** Pages with rows of the desktop app's own, or browser rows it hides. */
const DESKTOP_PAGES = new Set(['general', 'notifications', 'privacy', 'backup']);
/** The desktop app's bridge, answering as Windows does, with every switch off. */
const DESKTOP_BRIDGE = {
version: 1,
platform: 'win32',
mode: 'standalone',
windows: {},
menu: { bar: { setPinned: () => {}, onEvent: () => () => {} } },
os: {
getStatus: async () => ({
settings: {
closeToTray: false, startAtLogin: false, startMinimized: false, attentionOnNewMail: false,
quietHoursEnabled: false, quietHoursStart: '22:00', quietHoursEnd: '07:00', showShortcut: null,
},
pausedUntil: null,
shortcutActive: false,
}),
defaultAppStatus: async () => ({ registered: true, mailto: false, webcal: false }),
onStatusChange: () => () => {},
setSettings: async () => { throw new Error('not here'); },
openDefaultAppSettings: async () => ({ registered: true, mailto: false, webcal: false }),
},
updates: {
state: async () => ({ version: '1.0.0', channel: 'stable', channelLocked: false, allowed: true, ability: 'install', perMachine: false, status: 'idle' }),
onChanged: () => () => {},
},
devTools: { get: async () => ({ on: false, forcedOff: false, locked: false }), set: async () => ({ on: false, forcedOff: false, locked: false }), onChange: () => () => {} },
privacy: {
get: async () => ({ telemetry: { on: false, forcedOff: false }, crashReports: { on: false, forcedOff: false, uploadUrl: '' } }),
listCrashes: async () => [],
onChange: () => () => {},
},
};
const CLIENT = new Proxy({}, {
get: (_t, prop) => (prop === 'then' ? undefined : (OVERRIDES[String(prop)] ?? (() => null))),
});
@@ -88,7 +126,7 @@ const TAB_PAGES: Record<string, string[]> = {
};
describe('settings search index', () => {
it('has a row for every entry on its page', { timeout: 120_000 }, () => {
it('has a row for every entry on its page', { timeout: 180_000 }, async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('no network')));
useAuthStore.setState({ isAuthenticated: true, authMode: 'basic', client: CLIENT, activeAccountId: 'acc' } as never);
useVacationStore.setState({ isSupported: true, isLoading: false, forwardAvailable: true, fetchVacationResponse: vi.fn(async () => {}) } as never);
@@ -103,6 +141,7 @@ describe('settings search index', () => {
const realMatchMedia = window.matchMedia;
const variants: Record<string, () => void> = {
base: () => {},
desktop: () => { window.bulwarkDesktop = DESKTOP_BRIDGE as never; },
touch: () => {
window.matchMedia = ((q: string) => ({ ...realMatchMedia(q), matches: q.includes('coarse') })) as typeof window.matchMedia;
},
@@ -120,12 +159,18 @@ describe('settings search index', () => {
useSettingsStore.setState(settingsInitial, true);
useAccountStore.setState(accountsInitial, true);
window.matchMedia = realMatchMedia;
window.bulwarkDesktop = undefined;
apply();
out[variant] = {};
for (const [tab, Page] of Object.entries(pages)) {
const labels = (adv: boolean) => {
// The desktop app changes only these pages; the others are the browser's.
if (variant === 'desktop' && !DESKTOP_PAGES.has(tab)) {
out[variant][tab] = out.base[tab];
continue;
}
const draw = (adv: boolean) => {
useSettingsStore.setState({ showAdvancedSettings: adv });
const { container, unmount } = render(
return render(
<NextIntlClientProvider locale="en" messages={en}>
<TourProvider>
<AdvancedSettingsProvider>
@@ -134,14 +179,23 @@ describe('settings search index', () => {
</TourProvider>
</NextIntlClientProvider>,
);
};
const read = ({ container, unmount }: ReturnType<typeof draw>) => {
const found = Array.from(container.querySelectorAll('[data-search-label]')).map((el) => el.getAttribute('data-search-label') ?? '');
const sections = Array.from(container.querySelectorAll('[data-settings-section]')).map((el) => el.getAttribute('data-search-label') ?? '');
unmount();
return { found, sections };
};
// The desktop rows draw once the bridge has answered. Elsewhere nothing
// waits: the pages' own loads would answer from the stub client.
const labels = async (adv: boolean) => {
const view = draw(adv);
if (variant === 'desktop') await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); });
return read(view);
};
try {
const on = labels(true);
const off = labels(false);
const on = variant === 'desktop' ? await labels(true) : read(draw(true));
const off = variant === 'desktop' ? await labels(false) : read(draw(false));
out[variant][tab] = { on: on.found, off: off.found, sections: on.sections };
} catch (e) {
out[variant][tab] = { on: [], off: [], sections: [], error: String(e) };
@@ -149,13 +203,17 @@ describe('settings search index', () => {
}
}
window.matchMedia = realMatchMedia;
window.bulwarkDesktop = undefined;
const problems: string[] = [];
for (const [tab, entries] of Object.entries(tabSearchEntries)) {
for (const entry of entries) {
// Rows that name the app show the stock name, as the index does.
const text = withAppName(getByPath(en, entry.label), 'Bulwark') as string;
const variant = out[entry.when ?? 'base'];
// The desktop app's rows are all drawn in the Windows variant; a Mac row there is the Dock's.
const variantName = entry.when?.startsWith('desktop') ? 'desktop' : entry.when === 'browser' ? 'base' : (entry.when ?? 'base');
if (entry.when === 'desktopMac') continue;
const variant = out[variantName];
const parts = TAB_PAGES[tab].map((page) => variant[page]);
const errors = parts.filter((part) => part.error).map((part) => part.error);
if (errors.length) { problems.push(`${tab}: ${errors.join('; ')}`); continue; }
@@ -167,13 +225,20 @@ describe('settings search index', () => {
}
}
}
// A browser's row isn't in the desktop app (the push section, the browser's mail links).
for (const [tab, entries] of Object.entries(tabSearchEntries)) {
for (const entry of entries.filter((e) => e.when === 'browser')) {
const text = withAppName(getByPath(en, entry.label), 'Bulwark') as string;
if (TAB_PAGES[tab].some((page) => out.desktop[page].on.includes(text))) problems.push(`${tab}: ${entry.label} is drawn in the desktop app`);
}
}
// Every section a page draws has an entry, so searching its name finds it.
for (const [tab, entries] of Object.entries(tabSearchEntries)) {
const indexed = new Set(entries.map((entry) => withAppName(getByPath(en, entry.label), 'Bulwark') as string));
// A section named like its page is the page itself, which the search lists anyway.
indexed.add(getByPath(en, settingsTabLabelKey(tab as never)) as string);
for (const page of TAB_PAGES[tab]) {
for (const section of out.base[page].sections) {
for (const section of [...out.base[page].sections, ...out.desktop[page].sections]) {
if (!indexed.has(section)) problems.push(`${tab}: section "${section}" has no search entry`);
}
}
@@ -181,3 +246,14 @@ describe('settings search index', () => {
expect([...new Set(problems)]).toEqual([]);
});
});
// Rows only a browser draws are not in the desktop app, and the other way round.
describe('settings search index, browser and desktop app', () => {
it('marks the rows each draws', () => {
const when = (label: string) => Object.values(tabSearchEntries).flat().find((entry) => entry.label === label)?.when;
expect(when('settings.notifications.push.title')).toBe('browser');
expect(when('desktopOs.close_to_tray_label')).toBe('desktop');
expect(when('desktopOs.quiet_hours_label')).toBe('desktop');
expect(when('desktopMenuBar.always_show_label')).toBe('desktopMenuBar');
});
});
@@ -73,4 +73,27 @@ describe('time zone setting', () => {
fireEvent.change(input, { target: { value: '+05:45' } });
expect(optionTexts()).toEqual([expect.stringMatching(/^Kathmandu/)]);
});
// settings-p2-20: abbreviations and an exact offset find their zones first.
it('finds zones by abbreviation', () => {
const input = openTimeZones();
fireEvent.change(input, { target: { value: 'CET' } });
const found = optionTexts();
expect(found.length).toBeGreaterThan(0);
expect(found).toEqual(expect.arrayContaining([expect.stringMatching(/^Berlin/), expect.stringMatching(/^Paris/)]));
fireEvent.change(input, { target: { value: 'pst' } });
expect(optionTexts()[0]).toMatch(/^Los Angeles/);
});
it('lists the exact offset before offsets that start with the same digits', () => {
const input = openTimeZones();
fireEvent.change(input, { target: { value: 'GMT+1' } });
const found = optionTexts();
const offsetOf = (text: string) => /GMT[+-]\d{2}:\d{2}/.exec(text)?.[0];
const first = offsetOf(found[0]);
// Whatever +1 is today (winter or summer time), it's listed first.
expect(first).toMatch(/^GMT\+01:00$/);
const firstOther = found.findIndex((text) => offsetOf(text) !== first);
expect(found.slice(firstOther).every((text) => offsetOf(text) !== 'GMT+01:00')).toBe(true);
});
});
@@ -378,6 +378,9 @@ describe('the vacation period in the card', () => {
it('checks the fields again on save, which a field filled in only half may never have told', async () => {
const server = stalwart([rule('a')]);
await openCard(server);
// Typing in it: an empty field left alone turns into a text field with a
// placeholder (settings-p2-19).
fireEvent.focus(dateField(1));
fireEvent.change(dateField(1), { target: { value: '' } });
halfFilledIn(dateField(1), true);
expect(await screen.findByText('warnings.invalid_date', undefined, { timeout: 3000 })).toBeInTheDocument();
@@ -18,8 +18,8 @@ vi.mock('next-intl', () => {
});
// The editor hands the card its HTML as typed.
vi.mock('@/components/email/rich-text-editor', () => ({
RichTextEditor: ({ content, onChange, placeholder }: { content: string; onChange: (html: string) => void; placeholder?: string }) => (
<textarea data-testid="message-editor" value={content} placeholder={placeholder} onChange={(e) => onChange(e.target.value)} />
RichTextEditor: ({ content, onChange, placeholder, toolbar }: { content: string; onChange: (html: string) => void; placeholder?: string; toolbar?: string }) => (
<textarea data-testid="message-editor" data-toolbar={toolbar ?? 'full'} value={content} placeholder={placeholder} onChange={(e) => onChange(e.target.value)} />
),
}));
@@ -138,3 +138,33 @@ describe('the out-of-office message', () => {
expect(lastSent(server)).toMatchObject({ isEnabled: true, htmlBody: '<p>Back on 5 May.</p>', textBody: 'Back soon.' });
}, 15_000);
});
// settings-p2-19: a short toolbar for a few lines, and date fields that say
// what an empty one means instead of the browser's "dd.mm.yyyy --:--".
describe('the out-of-office form', () => {
it('gives the message the short formatting toolbar', async () => {
await openCard(blank());
expect(editor().dataset.toolbar).toBe('short');
});
it('shows empty dates as words, and the date picker once a field is used', async () => {
await openCard(blank());
const from = screen.getByLabelText('from_label') as HTMLInputElement;
const until = screen.getByLabelText('until_label') as HTMLInputElement;
expect(from.type).toBe('text');
expect(from.placeholder).toBe('from_placeholder');
expect(until.type).toBe('text');
expect(until.placeholder).toBe('until_placeholder');
fireEvent.focus(from);
expect(from.type).toBe('datetime-local');
fireEvent.change(from, { target: { value: '2030-01-02T09:00' } });
fireEvent.blur(from);
// A field with a date keeps the picker; the empty one goes back to words.
expect(from.type).toBe('datetime-local');
expect(from.value).toBe('2030-01-02T09:00');
fireEvent.focus(until);
fireEvent.blur(until);
expect(until.type).toBe('text');
});
});
+9 -2
View File
@@ -18,6 +18,7 @@ import { toast } from '@/stores/toast-store';
import { DebugSettings } from './debug-settings';
import { MailBackupSettings } from '@/components/mail-transfer/mail-backup-settings';
import { productName } from '@/lib/product-name';
import { isDesktopApp } from '@/lib/desktop/bridge';
const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "0.0.0";
const GIT_COMMIT = process.env.NEXT_PUBLIC_GIT_COMMIT || "unknown";
@@ -157,6 +158,12 @@ export function AboutDataSettings() {
const { settingsSyncEnabled } = useConfig();
const [showResetConfirm, setShowResetConfirm] = useState(false);
const [showRefreshConfirm, setShowRefreshConfirm] = useState(false);
const tP2 = useTranslations('uxp2_settings');
// The app has no "other browsers" (settings-p2-4).
const [inApp, setInApp] = useState(false);
useEffect(() => setInApp(isDesktopApp()), []);
// A reset keeps the sync choice, so the other devices change only when sync is on (settings-p2-18).
const syncOn = settingsSyncEnabled && !settingsSyncDisabled;
const fileInputRef = useRef<HTMLInputElement>(null);
const { isFeatureEnabled } = usePolicyStore();
const handleExport = () => {
@@ -202,7 +209,7 @@ export function AboutDataSettings() {
return (
<div className="space-y-8">
<SettingsSection id="backup" title={t('sections.backup')} description={t('sections.backup_description')}>
<SettingsSection id="backup" title={t('sections.backup')} description={inApp ? tP2('backup_description_app') : t('sections.backup_description')}>
{settingsSyncEnabled && (
<SettingItem label={t('settings_sync.label')} description={t('settings_sync.description')} term="sync">
<ToggleSwitch checked={!settingsSyncDisabled} onChange={(checked) => updateSetting('settingsSyncDisabled', !checked)} />
@@ -264,7 +271,7 @@ export function AboutDataSettings() {
onClose={() => setShowResetConfirm(false)}
onConfirm={handleReset}
title={t('reset_settings.confirm_title')}
message={t('reset_settings.description')}
message={`${tP2('reset.message')} ${syncOn ? tP2('reset.sync_on') : tP2('reset.sync_off')}`}
confirmText={t('reset_settings.button')}
variant="destructive"
/>
@@ -174,60 +174,6 @@ function PasswordChangeSection() {
);
}
/**
* Editable display name (the Stalwart principal's name). Rendered by the
* Account page's Profile section; the Security page no longer repeats it.
*/
export function DisplayNameSection() {
const t = useTranslations('settings.security');
const { displayName, updateDisplayName, isSaving, isLoadingPrincipal } = useAccountSecurityStore();
const [name, setName] = useState(displayName);
const [saved, setSaved] = useState(false);
useEffect(() => {
setName(displayName);
}, [displayName]);
const handleSave = async () => {
try {
await updateDisplayName(name);
setSaved(true);
setTimeout(() => setSaved(false), 2000);
toast.success(t('display_name.success'));
} catch (err) {
toast.error(t('display_name.error'), err instanceof Error ? err.message : undefined);
}
};
if (isLoadingPrincipal) {
return (
<SettingItem label={t('display_name.label')} description={t('display_name.description')}>
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
</SettingItem>
);
}
return (
<SettingItem label={t('display_name.label')} description={t('display_name.description')}>
<div className="flex items-center gap-2">
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={displayName || t('display_name.placeholder')}
className="w-48"
/>
<Button
size="sm"
onClick={handleSave}
disabled={isSaving || name === displayName}
>
{saved ? <Check className="w-4 h-4" /> : isSaving ? <Loader2 className="w-4 h-4 animate-spin" /> : t('display_name.save')}
</Button>
</div>
</SettingItem>
);
}
function generateTotp(accountLabel: string, issuer: string): { totp: OTPAuth.TOTP; url: string } {
const totp = new OTPAuth.TOTP({
issuer,
@@ -1555,7 +1501,8 @@ export function AccountSecuritySettings() {
<div>
<div className="flex items-center gap-2 mb-3">
<Shield className="w-4 h-4 text-muted-foreground" />
<h4 data-search-label={t('totp.section_title')} className="text-sm font-medium text-foreground">{t('totp.section_title')}</h4>
{/* Named like its row, "Two-step sign-in", not "Two-factor authentication" (settings-p2-18). */}
<h4 data-search-label={t('totp.label')} className="text-sm font-medium text-foreground">{t('totp.label')}</h4>
<TermInfo id="two_factor" />
</div>
<TotpSection />
+79 -59
View File
@@ -1,8 +1,8 @@
"use client";
import { useState, useRef, useCallback, useMemo, useEffect } from 'react';
import { useState, useRef, useCallback, useMemo } from 'react';
import { useTranslations } from 'next-intl';
import { Check, GripVertical, Plus, Star, AlertCircle, ChevronRight, ChevronUp, ChevronDown } from '@/components/icons';
import { Check, GripVertical, Plus, Star, AlertCircle, ChevronRight, ChevronUp, ChevronDown, X } from '@/components/icons';
import { useAuthStore } from '@/stores/auth-store';
import { useEmailStore } from '@/stores/email-store';
import { useAccountStore, type AccountEntry } from '@/stores/account-store';
@@ -12,18 +12,18 @@ import { SettingsSection, SettingItem } from './settings-section';
import { Avatar } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import { useRouter } from '@/i18n/navigation';
import { getMaxAccounts, accountRowText } from '@/lib/account-utils';
import { getMaxAccounts, accountRowText, accountSharesAddress } from '@/lib/account-utils';
import { formatFileSize, cn } from '@/lib/utils';
import { toUnicodeDomain, toUnicodeEmail } from '@/lib/idn';
import { IS_LITE } from '@/lib/lite';
import { isPasswordSession } from '@/lib/auth/password-session';
import { usePolicyStore } from '@/stores/policy-store';
import { AccountArchiveSettings } from './account-archive-settings';
import { SignOutRow } from './sign-out-row';
import { DisplayNameSection } from './account-security-settings';
import { YourNameRow } from './your-name-row';
import { EmailAddressesSection } from './identity-settings';
import { MultipleAccountsSection } from './layout-settings';
import { useAccountSecurityStore } from '@/stores/account-security-store';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { useConfirmDialog } from '@/hooks/use-confirm-dialog';
function hostnameOf(serverUrl: string): string {
try { return toUnicodeDomain(new URL(serverUrl).hostname); } catch { return serverUrl; }
@@ -42,30 +42,15 @@ function firstScopedTab(caps: SharedAccount['capabilities']): string | null {
export function AccountSettings() {
const t = useTranslations('settings.account');
const tCommon = useTranslations('common');
const tSidebar = useTranslations('sidebar');
const router = useRouter();
const removeAccount = useAuthStore((s) => s.removeAccount);
const { dialogProps: confirmDialogProps, confirm } = useConfirmDialog();
const { username, serverUrl, isDemoMode, primaryIdentity, authMode, client } = useAuthStore();
const activeAccountId = useAuthStore((s) => s.activeAccountId);
const switchAccount = useAuthStore((s) => s.switchAccount);
const setManagedAccount = useManagedAccountStore((s) => s.setManagedAccount);
const archiveEnabled = usePolicyStore((s) => s.isFeatureEnabled('accountArchiveEnabled')) && !IS_LITE && !isDemoMode;
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
// The display name is editable on Stalwart (the principal's name) for
// password sign-ins; identity-provider and token sessions cannot manage
// the principal.
const { isStalwart, probe, fetchPrincipal } = useAccountSecurityStore();
const activeAccount = useAccountStore((s) => (activeAccountId ? s.getAccountById(activeAccountId) : undefined));
const canEditDisplayName = isStalwart === true && isPasswordSession(authMode, activeAccount);
// Wait for `client` before probing, as the Security page does: on reload
// the persisted session flips authenticated before the client reconnects.
useEffect(() => {
if (!isAuthenticated || !client) return;
if (isStalwart === null) {
void probe().then((detected) => { if (detected) void fetchPrincipal(); });
} else if (isStalwart) {
void fetchPrincipal();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isAuthenticated, client, isStalwart]);
// Shared/group accounts delegated to this session (excludes the user's own
// primary account). These can be drilled into for scoped settings editing.
@@ -83,10 +68,8 @@ export function AccountSettings() {
const draggedIndexRef = useRef<number | null>(null);
const quotaPercentage = quota && quota.total > 0 ? Math.min(Math.round((quota.used / quota.total) * 100), 100) : 0;
// `account.displayName` is refreshed from the server on every login/restore
// (Stalwart principal "Full name" when available - #900); the identity name
// is only a fallback until that entry exists.
const displayName = account?.displayName || primaryIdentity?.name || (isDemoMode ? 'Nora Lindqvist' : undefined);
// Shown when there is no identity to edit: the name the app knows.
const displayName = primaryIdentity?.name || account?.displayName || (isDemoMode ? 'Nora Lindqvist' : undefined);
const email = primaryIdentity?.email || account?.email || username;
const max = getMaxAccounts();
@@ -131,6 +114,18 @@ export function AccountSettings() {
void switchAccount(id);
}, [activeAccountId, switchAccount]);
// The switcher has no remove button on its rows; an account you're not in
// is removed here ("Manage accounts…" leads to this list).
const handleRemove = useCallback(async (target: AccountEntry) => {
const ok = await confirm({
title: tSidebar('remove_account_title'),
message: tSidebar('remove_account_confirm', { account: toUnicodeEmail(target.email || target.username) }),
confirmText: tSidebar('remove_account'),
variant: 'destructive',
});
if (ok) removeAccount(target.id);
}, [confirm, removeAccount, tSidebar]);
const handleAddAccount = useCallback(() => {
router.push(`/login?mode=add-account` as never);
}, [router]);
@@ -148,14 +143,9 @@ export function AccountSettings() {
return (
<div className="space-y-8">
<SettingsSection id="profile" title={t('title')} description={t('description')}>
{/* Display Name: editable where the server lets us, read-only otherwise. */}
{canEditDisplayName ? (
<DisplayNameSection />
) : (
<SettingItem label={t('name_label')}>
<span className="text-sm text-foreground">{displayName || tCommon('unknown')}</span>
</SettingItem>
)}
{/* One name: the primary address's, what recipients see (settings-p2-2).
Stalwart's principal description is an admin's field and isn't shown. */}
<YourNameRow fallbackName={displayName} />
{/* Email Address */}
<SettingItem label={t('email.label')}>
@@ -225,13 +215,20 @@ export function AccountSettings() {
{/* Logged-in accounts list */}
{accounts.length > 0 && (
<SettingsSection id="browser" title={t('accounts.title')} description={t('accounts.description')}>
<SettingsSection
id="browser"
title={t('accounts.title')}
// "Drag to change the order" says nothing with one account.
description={accounts.length > 1 ? t('accounts.description') : undefined}
>
<div className="space-y-2">
{accounts.map((a, index) => (
<AccountRow
key={a.id}
account={a}
text={accountRowText(a, accounts)}
showServer={a.hasError || accountSharesAddress(a, accounts)}
canReorder={accounts.length > 1}
index={index}
isActive={a.id === activeAccountId}
isFirst={index === 0}
@@ -245,6 +242,7 @@ export function AccountSettings() {
onMoveDown={() => moveAccount(index, index + 1)}
onSwitch={() => handleSwitch(a.id)}
onSetDefault={() => setDefaultAccount(a.id)}
onRemove={() => void handleRemove(a)}
labels={{
active: t('accounts.active'),
default: t('accounts.default_badge'),
@@ -253,6 +251,7 @@ export function AccountSettings() {
moveUp: t('accounts.move_up'),
moveDown: t('accounts.move_down'),
dragHandle: t('accounts.drag_handle'),
remove: tSidebar('remove_account'),
}}
/>
))}
@@ -272,6 +271,8 @@ export function AccountSettings() {
</SettingsSection>
)}
<ConfirmDialog {...confirmDialogProps} />
{archiveEnabled && <AccountArchiveSettings />}
<MultipleAccountsSection />
@@ -320,6 +321,10 @@ export function AccountSettings() {
interface AccountRowProps {
account: AccountEntry;
text: { title: string; subtitle: string | null };
/** The server line: only for an account in trouble or a shared address. */
showServer: boolean;
/** Dragging and the arrows: only with two accounts or more. */
canReorder: boolean;
index: number;
isActive: boolean;
isFirst: boolean;
@@ -333,6 +338,7 @@ interface AccountRowProps {
onMoveDown: () => void;
onSwitch: () => void;
onSetDefault: () => void;
onRemove: () => void;
labels: {
active: string;
default: string;
@@ -341,12 +347,15 @@ interface AccountRowProps {
moveUp: string;
moveDown: string;
dragHandle: string;
remove: string;
};
}
function AccountRow({
account,
text,
showServer,
canReorder,
index,
isActive,
isFirst,
@@ -360,11 +369,12 @@ function AccountRow({
onMoveDown,
onSwitch,
onSetDefault,
onRemove,
labels,
}: AccountRowProps) {
return (
<div
draggable
draggable={canReorder}
onDragStart={(e) => onDragStart(e, index)}
onDragOver={(e) => onDragOver(e, index)}
onDrop={(e) => onDrop(e, index)}
@@ -378,12 +388,14 @@ function AccountRow({
: 'border-border hover:bg-muted/50'
)}
>
<div
className="cursor-grab active:cursor-grabbing text-muted-foreground/50 hover:text-muted-foreground flex-shrink-0"
title={labels.dragHandle}
>
<GripVertical className="w-4 h-4" />
</div>
{canReorder && (
<div
className="cursor-grab active:cursor-grabbing text-muted-foreground/50 hover:text-muted-foreground flex-shrink-0"
title={labels.dragHandle}
>
<GripVertical className="w-4 h-4" />
</div>
)}
<div className="relative flex-shrink-0">
<Avatar
@@ -412,7 +424,7 @@ function AccountRow({
title={isActive ? labels.active : labels.switchTo}
>
<div className="flex items-center gap-1.5">
<span className="text-sm font-medium truncate">
<span className="text-sm font-medium wrap-anywhere" title={toUnicodeEmail(text.title)}>
{toUnicodeEmail(text.title)}
</span>
{account.isDefault && (
@@ -420,23 +432,18 @@ function AccountRow({
)}
</div>
{text.subtitle && (
<p dir="ltr" className="text-xs text-muted-foreground truncate rtl:text-right">
<p dir="ltr" className="text-xs text-muted-foreground wrap-anywhere rtl:text-right">
{toUnicodeEmail(text.subtitle)}
</p>
)}
<div className="flex items-center gap-1 mt-0.5">
{account.hasError ? (
<AlertCircle className="w-3 h-3 text-destructive" />
) : (
<span className={cn(
'w-1.5 h-1.5 rounded-full',
account.isConnected ? 'bg-green-500' : 'bg-muted-foreground/40'
)} />
)}
<span className="text-[0.625rem] text-muted-foreground truncate">
{hostnameOf(account.serverUrl)}
</span>
</div>
{showServer && (
<div className="flex items-center gap-1 mt-0.5" data-testid="account-server">
{account.hasError && <AlertCircle className="w-3 h-3 text-destructive" />}
<span className="text-xs text-muted-foreground truncate">
{hostnameOf(account.serverUrl)}
</span>
</div>
)}
</button>
<div className="flex items-center gap-0.5 flex-shrink-0">
@@ -451,6 +458,7 @@ function AccountRow({
<Star className="w-3.5 h-3.5" />
</button>
)}
{canReorder && (<>
<button
type="button"
onClick={onMoveUp}
@@ -471,6 +479,18 @@ function AccountRow({
>
<ChevronDown className="w-3.5 h-3.5" aria-hidden="true" />
</button>
</>)}
{!isActive && (
<button
type="button"
onClick={onRemove}
className="p-1.5 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
title={labels.remove}
aria-label={labels.remove}
>
<X className="w-3.5 h-3.5" aria-hidden="true" />
</button>
)}
</div>
</div>
);
@@ -827,8 +827,9 @@ export function CalendarManagementSettings() {
isLoading={isLoading}
/>
) : (
// Stacked on a phone: side by side the three labels wrapped.
<div className="flex flex-col sm:flex-row gap-2">
// Stacked on a phone: side by side the three labels wrapped. Too
// narrow for all three in a row (800 px), the last one goes under.
<div data-testid="calendar-create-buttons" className="flex flex-col sm:flex-row sm:flex-wrap gap-2">
<button
type="button"
onClick={() => setIsCreating(true)}
+5 -16
View File
@@ -10,13 +10,13 @@ import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-s
import { TemplateSettings } from './template-settings';
import { ProofreadingSettings } from './proofreading-settings';
import { PrivacyLinkRow } from './privacy-settings';
import { Button } from '@/components/ui/button';
import { X, ChevronRight } from '@/components/icons';
import { SignaturesSection } from './signatures-section';
import { X } from '@/components/icons';
/**
* The Composing page: the editor, replying, what happens before a send,
* templates (their own tab before) and pointers to where signatures and
* read receipts live, since people look for them here.
* templates (their own tab before), the signature of each address, and a
* pointer to where read receipts live, since people look for them here.
*/
export function ComposingSettings() {
const t = useTranslations('settings.email_behavior');
@@ -250,18 +250,7 @@ export function ComposingSettings() {
{templatesEnabled && <TemplateSettings />}
<SettingsSection id="signatures" title={t('signatures_link.label')}>
<SettingItem label={t('signatures_link.label')} description={t('signatures_link.description')}>
<Button
variant="outline"
size="sm"
onClick={() => window.dispatchEvent(new CustomEvent('settings-tab-change', { detail: { tab: 'account', section: 'addresses' } }))}
>
{t('signatures_link.button')}
<ChevronRight className="w-4 h-4 ms-1" />
</Button>
</SettingItem>
</SettingsSection>
<SignaturesSection />
</div>
);
}
@@ -37,7 +37,8 @@ export function DesktopDevToolsSettings() {
const description = state.forcedOff ? t("forced_off") : state.locked ? t("locked") : t("description", { shortcut });
return (
<SettingsSection id="desktop-troubleshooting" title={t("section_title")}>
// Only with "Show advanced settings" on: nobody needs it to read mail (settings-p2-9).
<SettingsSection id="desktop-troubleshooting" advanced title={t("section_title")}>
<SettingItem label={t("label", { shortcut })} description={description} locked={state.forcedOff}>
<ToggleSwitch
checked={state.on}
+14 -1
View File
@@ -8,6 +8,9 @@ import type { DesktopOsDefaultAppStatus, DesktopOsSettings as Settings, DesktopO
import { describeAccelerator, showShortcutDefault } from "@/lib/desktop/shortcut-keys";
import { DesktopQuickComposeSetting } from "./desktop-quick-compose-setting";
import { SettingItem, SettingsSection, ToggleSwitch } from "./settings-section";
import { goToSettings, SettingsLinkRow } from "./privacy-settings";
import { productName } from "@/lib/product-name";
import { useConfig } from "@/hooks/use-config";
/**
* The desktop app's own settings: only where there's an app to set. A browser never sees
@@ -17,6 +20,9 @@ import { SettingItem, SettingsSection, ToggleSwitch } from "./settings-section";
*/
export function DesktopOsSettings({ part = "app" }: { part?: "app" | "notifications" } = {}) {
const t = useTranslations("desktopOs");
const tP2 = useTranslations("uxp2_settings.desktop_notifications");
const tTabs = useTranslations("settings.tabs");
const appName = productName(useConfig().appName);
const [status, setStatus] = useState<DesktopOsStatus | null>(null);
const [defaultApp, setDefaultApp] = useState<DesktopOsDefaultAppStatus | null>(null);
const bridge = getDesktopBridge();
@@ -48,7 +54,14 @@ export function DesktopOsSettings({ part = "app" }: { part?: "app" | "notificati
if (part === "notifications") {
return (
<SettingsSection id="desktop-notifications" title={t("section_title")}>
<SettingsSection id="desktop-notifications" title={tP2("title")}>
{/* The app tells about new mail as long as it runs; a browser's push section has nothing to add (settings-p2-4). */}
<SettingsLinkRow
label={tP2("closed_label")}
description={tP2("closed_description", { appName, page: tTabs("general") })}
button={tTabs("general")}
onClick={() => goToSettings("general", "desktop-app")}
/>
<SettingItem
label={bridge?.platform === "darwin" ? t("attention_mac_label") : t("attention_label")}
description={t("attention_description")}
+34 -23
View File
@@ -1,7 +1,7 @@
"use client";
import { MoveButtons } from "@/components/ui/move-buttons";
import { useState, useEffect, useCallback, useRef } from "react";
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import { useTranslations } from "next-intl";
import { SettingsSection, ToggleSwitch } from "./settings-section";
import { Button } from "@/components/ui/button";
@@ -24,9 +24,8 @@ import { supportsRegex } from "@/lib/sieve/regex";
import { formatDateTime } from "@/lib/utils";
import { useVacationStore } from "@/stores/vacation-store";
import { useManagedAccountStore } from "@/stores/managed-account-store";
import { offerRuleOnFolder } from "@/lib/filters/quick-rule-flow";
import { planOnServer, runRuleOnFolder } from "@/lib/filters/quick-rule-flow";
import { resolveMailboxRuleTarget } from "@/lib/filters/quick-rule-target";
import { retroactiveSupport } from "@/lib/filters/retroactive";
import { localizeMailboxName } from "@/lib/mailbox-label";
import {
Plus,
@@ -215,7 +214,6 @@ export function FilterSettings() {
const tNotifications = useTranslations("notifications");
const tMenu = useTranslations("context_menu");
const tSidebar = useTranslations("sidebar");
const tFolders = useTranslations("uxs_folders");
// The raw script editor is for people who asked for the advanced settings,
// unless the script is one only it can edit.
const showAdvanced = useSettingsStore((s) => s.showAdvancedSettings);
@@ -384,8 +382,24 @@ export function FilterSettings() {
[client, deleteRule, saveFilters, tNotifications]
);
// A rule only sees new mail. Its dialog offers to sort what is already in
// the Inbox too, as Gmail's "Also apply filter to matching conversations"
// does: for the user's own account, where the Inbox can be searched.
const inboxOffer = useMemo(() => {
if (!isPrimaryAccount || !client) return null;
const inbox = storeMailboxes.find((m) => m.role === "inbox" && !m.isShared);
const target = inbox ? resolveMailboxRuleTarget(inbox) : null;
if (!inbox || !target || target.client !== client) return null;
const folder = localizeMailboxName(inbox.role, inbox.name, (k) => tSidebar(`mailboxes.${k}`));
return { inbox, target, folder };
}, [isPrimaryAccount, client, storeMailboxes, tSidebar]);
const countInInbox = useCallback(async (rule: FilterRule) => {
if (!inboxOffer) return 0;
return (await planOnServer(inboxOffer.target, rule, inboxOffer.inbox.id)).ids.length;
}, [inboxOffer]);
const handleSaveRule = useCallback(
async (rule: FilterRule) => {
async (rule: FilterRule, options?: { applyToExisting: boolean }) => {
const previousRules = [...rules];
if (editingRule) {
updateRule(rule.id, rule);
@@ -403,25 +417,19 @@ export function FilterSettings() {
toast.error(tNotifications("filters_save_failed"));
return;
}
// A new rule only sees new mail. Offer to sort what is already in the
// Inbox, as Gmail's "Also apply to matching conversations" does.
if (!editingRule && isPrimaryAccount && retroactiveSupport(rule).ok) {
const inbox = storeMailboxes.find((m) => m.role === "inbox" && !m.isShared);
const target = inbox ? resolveMailboxRuleTarget(inbox) : null;
if (inbox && target && target.client === client) {
const folder = localizeMailboxName(inbox.role, inbox.name, (k) => tSidebar(`mailboxes.${k}`));
void offerRuleOnFolder(
target,
rule,
inbox.id,
{ notifications: tNotifications, filters: t, menu: tMenu },
(count) => tFolders("apply_to_existing", { count, folder }),
);
}
// The box in the dialog: run the saved rule over the Inbox now.
if (options?.applyToExisting && inboxOffer) {
void runRuleOnFolder(
inboxOffer.target,
rule,
inboxOffer.inbox.id,
inboxOffer.folder,
{ notifications: tNotifications, filters: t, menu: tMenu },
);
}
}
},
[editingRule, updateRule, addRule, rules, client, saveFilters, tNotifications, isPrimaryAccount, storeMailboxes, tSidebar, t, tMenu, tFolders]
[editingRule, updateRule, addRule, rules, client, saveFilters, tNotifications, inboxOffer, t, tMenu]
);
const handleSaveSieve = useCallback(
@@ -804,11 +812,11 @@ export function FilterSettings() {
)}
{!isOpaque && rules.length > 0 && (
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">{t("expanded_view")}</span>
<span className="text-xs text-muted-foreground">{t("show_rule_details")}</span>
<ToggleSwitch
checked={expandedFilterView}
onChange={(v) => updateSetting("expandedFilterView", v)}
ariaLabel={t("expanded_view")}
ariaLabel={t("show_rule_details")}
/>
</div>
)}
@@ -824,6 +832,9 @@ export function FilterSettings() {
regexSupported={supportsRegex(sieveCapabilities?.sieveExtensions)}
forwardsBefore={forwardsOfEdited.before}
forwardsAfter={forwardsOfEdited.after}
offerApplyToExisting={!!inboxOffer}
applyExistingFolder={inboxOffer?.folder}
countExisting={inboxOffer ? countInInbox : undefined}
onSave={handleSaveRule}
onClose={() => {
setShowRuleModal(false);
+7 -13
View File
@@ -27,29 +27,23 @@ export function EmailAddressesSection() {
const t = useTranslations('settings.identities');
const tAccount = useTranslations('settings.account');
const tEmail = useTranslations('settings.email_behavior');
const tAddr = useTranslations('uxs2_ident.addresses');
const { identities } = useIdentityStore();
const { subAddressDelimiter, updateSetting } = useSettingsStore();
const [showModal, setShowModal] = useState(false);
return (
<>
<SettingsSection id="addresses" title={tAccount('sections.addresses')} description={t('description')}>
{/* Identity Count */}
<SettingItem
label={t('identities_count.label')}
description={t('identities_count.description')}
term="identity"
>
{/* The section says what the addresses are; the row under it doesn't
say it again (settings-p2-18). No "identity": a developer word. */}
<SettingsSection id="addresses" title={tAccount('sections.addresses')} description={tAddr('description')}>
<SettingItem label={tAddr('label')}>
<div className="flex items-center gap-2">
<span className="text-sm text-foreground">
{identities.length === 0
? t('identities_count.count_zero')
: identities.length === 1
? t('identities_count.count_one')
: t('identities_count.count_other', { count: identities.length })}
{tAddr('count', { count: identities.length })}
</span>
<Button onClick={() => setShowModal(true)} size="sm">
{t('manage')}
{tAddr('manage')}
</Button>
</div>
</SettingItem>
+65 -113
View File
@@ -2,57 +2,50 @@
import { useCallback, useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { Loader2 } from '@/components/icons';
import { Button } from '@/components/ui/button';
import { SettingsSection } from './settings-section';
import { SettingItem, ToggleSwitch } from './settings-section';
import { useAccountStore } from '@/stores/account-store';
import { useAuthStore } from '@/stores/auth-store';
import { useSettingsStore } from '@/stores/settings-store';
import { disableWebPush, enableWebPushForAccounts, isWebPushEnabled, isWebPushSupported } from '@/lib/web-push';
import { webPushErrorKey } from '@/lib/web-push-errors';
interface FailedAccount {
id: string;
name: string;
email: string;
reason: string;
/** The accounts signed in here with a live connection: each can have push on this device. */
export function usePushAccounts() {
const accounts = useAccountStore((s) => s.accounts);
// Re-read the client map whenever a connection appears or drops.
useAuthStore((s) => s.connectedAccountsRevision);
const isDemoMode = useAuthStore((s) => s.isDemoMode);
if (isDemoMode || !isWebPushSupported()) return [];
return accounts.filter((a) => a.isConnected && useAuthStore.getState().getClientForAccount(a.id));
}
/**
* Turn push on or off for several accounts at once.
*
* The per-account panel above only ever addresses the account on screen, so
* arming a fresh device used to mean switching account N times. One browser
* subscription already serves every account (see `enableWebPushForAccounts`);
* what was missing was a place to register them all against it in one go.
* One switch per account for notifications while the app is closed, under
* "Notifications when … is closed" (settings-p2-17). It replaces a checklist
* with "Enable for selected" and "Disable for selected": with a switch each,
* an account's state is what its switch shows. One browser subscription
* serves every account (see `enableWebPushForAccounts`).
*/
export function MultiAccountPushSettings({ relayBaseUrl }: { relayBaseUrl?: string }) {
const t = useTranslations('settings.notifications.push_all');
export function AccountPushSwitches({ relayBaseUrl, disabled = false, onChanged }: {
relayBaseUrl?: string;
/** No relay to send through: the switches show their state but can't turn on. */
disabled?: boolean;
/** After a switch changed something, so the page can re-read its own state. */
onChanged?: () => void;
}) {
const tPush = useTranslations('settings.notifications.push');
const connected = usePushAccounts();
const revision = useAuthStore((s) => s.connectedAccountsRevision);
const [enabled, setEnabled] = useState<Record<string, boolean>>({});
const [busy, setBusy] = useState<string | null>(null);
// The reason an account's switch didn't move, shown under it.
const [failures, setFailures] = useState<Record<string, string>>({});
const failureReason = (error: unknown): string => {
const key = webPushErrorKey(error);
if (key) return tPush(key);
return error instanceof Error && error.message ? error.message : String(error);
};
const failureOf = (account: (typeof accounts)[number], error: unknown): FailedAccount => ({
id: account.id,
name: account.displayName || account.label || account.username,
email: account.email || account.username,
reason: failureReason(error),
});
const accounts = useAccountStore(s => s.accounts);
// Re-read the client map whenever a connection appears or drops.
const revision = useAuthStore(s => s.connectedAccountsRevision);
const isDemoMode = useAuthStore(s => s.isDemoMode);
const [enabled, setEnabled] = useState<Record<string, boolean>>({});
const [chosen, setChosen] = useState<Set<string>>(new Set());
const [busy, setBusy] = useState(false);
const [notice, setNotice] = useState('');
// Which accounts the last bulk enable left off, and why: a bare count
// leaves the user guessing which login to retry.
const [failures, setFailures] = useState<FailedAccount[]>([]);
const connected = accounts.filter(a => a.isConnected && useAuthStore.getState().getClientForAccount(a.id));
const refresh = useCallback(async () => {
// Push state is stored under the JMAP account id the server assigned (see
@@ -68,98 +61,57 @@ export function MultiAccountPushSettings({ relayBaseUrl }: { relayBaseUrl?: stri
useEffect(() => { void refresh(); }, [refresh, revision]);
if (!isWebPushSupported() || isDemoMode || connected.length < 2) return null;
if (connected.length < 2) return null;
const toggle = (id: string, on: boolean) => setChosen(prev => {
const next = new Set(prev);
if (on) next.add(id); else next.delete(id);
return next;
});
const run = async (action: 'enable' | 'disable') => {
const auth = useAuthStore.getState();
const targets = connected.filter(a => chosen.has(a.id));
if (!targets.length) return;
setBusy(true); setNotice(''); setFailures([]);
const change = async (account: (typeof connected)[number], on: boolean) => {
const client = useAuthStore.getState().getClientForAccount(account.id);
if (!client) return;
setBusy(account.id);
setFailures(({ [account.id]: _dropped, ...rest }) => rest);
try {
if (action === 'enable') {
if (on) {
const outcome = await enableWebPushForAccounts(
targets.map(a => ({ accountId: a.id, client: auth.getClientForAccount(a.id)!, accountLabel: a.username })),
[{ accountId: account.id, client, accountLabel: account.username }],
{
relayBaseUrl,
inboxOnly: useSettingsStore.getState().pushNotifyInboxOnly,
mutes: useSettingsStore.getState().notificationMutes,
},
);
setNotice(outcome.failed.length
? t('result_partial', { enabled: outcome.enabled.length, failed: outcome.failed.length })
: t('result_enabled', { count: outcome.enabled.length }));
setFailures(outcome.failed.map(({ accountId, error }) => {
const account = targets.find(a => a.id === accountId);
return {
id: accountId,
name: account ? (account.displayName || account.label || account.username) : accountId,
email: account ? (account.email || account.username) : '',
reason: failureReason(error),
};
}));
const failed = outcome.failed[0];
if (failed) setFailures((prev) => ({ ...prev, [account.id]: failureReason(failed.error) }));
} else {
// Every account ends up off here; one the server or relay couldn't be
// told about is named, since it may keep getting notifications for days.
const failed: FailedAccount[] = [];
for (const account of targets) {
await disableWebPush({ client: auth.getClientForAccount(account.id)!, relayBaseUrl })
.catch((error: unknown) => { failed.push(failureOf(account, error)); });
}
setNotice(t('result_disabled', { count: targets.length }));
setFailures(failed);
await disableWebPush({ client, relayBaseUrl });
}
setChosen(new Set());
} catch (error) {
setFailures((prev) => ({ ...prev, [account.id]: failureReason(error) }));
} finally {
setBusy(false);
setBusy(null);
await refresh();
onChanged?.();
}
};
const allChosen = connected.length > 0 && connected.every(a => chosen.has(a.id));
return <SettingsSection id="push-all" title={t('title')} description={t('description')}>
<div className="space-y-3">
<label className="flex items-center gap-2 text-sm font-medium">
<input type="checkbox" disabled={busy} checked={allChosen}
onChange={e => setChosen(e.target.checked ? new Set(connected.map(a => a.id)) : new Set())} />
{t('select_all')}
</label>
<ul className="space-y-2">
{connected.map(account => <li key={account.id}>
<label className="flex items-start gap-2 ps-6 text-sm">
<input type="checkbox" className="mt-1" disabled={busy} checked={chosen.has(account.id)}
onChange={e => toggle(account.id, e.target.checked)} />
<span className="min-w-0 wrap-anywhere">
<span className="block">{account.displayName || account.label || account.username}</span>
<span className="block text-xs text-muted-foreground">
<bdi dir="ltr">{account.email || account.username}</bdi> · {enabled[account.id] ? t('state_on') : t('state_off')}
</span>
</span>
</label>
</li>)}
</ul>
<div className="flex flex-wrap items-center gap-2">
<Button type="button" onClick={() => void run('enable')} disabled={busy || !chosen.size}>
{busy && <Loader2 className="me-2 h-4 w-4 animate-spin" />}{t('enable_selected')}
</Button>
<Button type="button" variant="outline" onClick={() => void run('disable')} disabled={busy || !chosen.size}>
{t('disable_selected')}
</Button>
</div>
{notice && <p className="text-sm text-muted-foreground">{notice}</p>}
{failures.length > 0 && <ul role="alert" className="space-y-1 text-sm text-destructive">
{failures.map(f => <li key={f.id}>
<span className="font-medium">{f.name}</span>
{f.email && f.email !== f.name && <span> (<bdi dir="ltr">{f.email}</bdi>)</span>}
<span>: {f.reason}</span>
</li>)}
</ul>}
</div>
</SettingsSection>;
return (
<>
{connected.map((account) => {
const name = account.displayName || account.label || account.username;
const email = account.email || account.username;
const failure = failures[account.id];
return (
<div key={account.id} data-testid="account-push-row">
<SettingItem label={name} description={email !== name ? email : undefined}>
<ToggleSwitch
checked={!!enabled[account.id]}
disabled={busy !== null || (disabled && !enabled[account.id])}
ariaLabel={email !== name ? `${name} (${email})` : name}
onChange={(on) => void change(account, on)}
/>
</SettingItem>
{failure && <p role="alert" className="-mt-2 pb-2 text-xs text-destructive">{failure}</p>}
</div>
);
})}
</>
);
}
+34 -7
View File
@@ -15,7 +15,8 @@ import { usePolicyStore } from '@/stores/policy-store';
import { useAuthStore } from '@/stores/auth-store';
import { useAccountStore } from '@/stores/account-store';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { MultiAccountPushSettings } from './multi-account-push';
import { AccountPushSwitches, usePushAccounts } from './multi-account-push';
import { isDesktopApp } from '@/lib/desktop/bridge';
import { productName } from '@/lib/product-name';
import { useConfig } from '@/hooks/use-config';
import { useConfirmDialog } from '@/hooks/use-confirm-dialog';
@@ -33,7 +34,6 @@ import {
import { webPushErrorKey } from '@/lib/web-push-errors';
import type { PushDevice } from '@/lib/web-push';
import { IS_LITE } from '@/lib/lite';
import { isDesktopApp } from '@/lib/desktop/bridge';
import { useNoThirdPartyTraffic } from '@/hooks/use-third-party-mode';
import {
resolveActiveRelayUrl,
@@ -53,6 +53,12 @@ type DevicesState =
| { kind: 'loaded'; devices: PushDevice[] }
| { kind: 'error' };
/** iPhone or iPad, where Safari only pushes for a site on the home screen. iPadOS says it's a Mac with touch. */
function isAppleMobile(): boolean {
if (typeof navigator === 'undefined') return false;
return /iPhone|iPad|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
}
export function NotificationSettings() {
const t = useTranslations('settings.notifications');
const tAppearance = useTranslations('settings.appearance');
@@ -60,6 +66,11 @@ export function NotificationSettings() {
const tUnread = useTranslations('account_unread');
const severalAccounts = useAccountStore((s) => s.accounts.length > 1);
const tThirdParty = useTranslations('third_party');
const tP2 = useTranslations('uxp2_settings');
// The desktop app tells about new mail while it runs, in the tray too; the
// browser's push section talks about websites and Safari (settings-p2-4).
const inApp = isDesktopApp();
const pushAccounts = usePushAccounts();
const appName = productName(useConfig().appName);
// The relay list depends on it; re-render once the config is known (#903).
const noThirdParty = useNoThirdPartyTraffic();
@@ -88,11 +99,15 @@ export function NotificationSettings() {
const supported = typeof window !== 'undefined' && isWebPushSupported() && !isDemoMode;
// "Inbox only" is enforced by the server's emailPush filter; without that
// capability (Stalwart < 0.16.16) the toggle would do nothing.
const inboxOnlyAvailable = supported && !!client && serverSupportsEmailPush(client);
const inboxOnlyAvailable = supported && !inApp && !!client && serverSupportsEmailPush(client);
const [pushStatus, setPushStatus] = useState<PushStatus>(
supported ? { kind: 'idle' } : { kind: 'unsupported' },
);
const [devices, setDevices] = useState<DevicesState>({ kind: 'idle' });
// Bumped when an account's own switch changed push, so this account's state is read again.
const [pushRecheck, setPushRecheck] = useState(0);
// Several accounts connected: a switch each instead of one for the account on screen (settings-p2-17).
const perAccount = supported && pushAccounts.length > 1;
const [revokingId, setRevokingId] = useState<string | null>(null);
// Relay URLs come from admin policy only - users pick one of the offered
@@ -135,7 +150,7 @@ export function NotificationSettings() {
window.removeEventListener('focus', onVisible);
document.removeEventListener('visibilitychange', onVisible);
};
}, [supported, client, pushStatus.kind]);
}, [supported, client, pushStatus.kind, pushRecheck]);
// The device list is worth loading even when push is off on this browser -
// revoking a stale registration left on another device is exactly what you
@@ -158,7 +173,8 @@ export function NotificationSettings() {
// In the static Lite build push is off by design (no service worker, see
// components/service-worker-registration.tsx), not a browser limitation.
const statusDescription = pushStatus.kind === 'unsupported'
? (IS_LITE ? t('push.status_lite') : isDemoMode ? t('push.status_demo') : `${t('push.status_unsupported')} ${t('push.ios_hint')}`)
// Two sentences, and the home-screen hint only where it applies (settings-p2-4).
? (IS_LITE ? t('push.status_lite') : isDemoMode ? t('push.status_demo') : isAppleMobile() ? `${tP2('push_unsupported')} ${t('push.ios_hint')}` : tP2('push_unsupported'))
: noRelay && !pushEnabled
? tThirdParty('push_needs_relay')
: busy
@@ -436,7 +452,18 @@ export function NotificationSettings() {
{/* The desktop app's taskbar flash and quiet hours. Nothing in a browser. */}
<DesktopOsSettings part="notifications" />
{!inApp && (
<SettingsSection id="push" title={t('push.title', { appName })} description={t('push.description')} term="push">
{perAccount ? (
<>
{noRelay && <p className="text-xs text-muted-foreground">{tThirdParty('push_needs_relay')}</p>}
<AccountPushSwitches
relayBaseUrl={activeRelayUrl}
disabled={noRelay}
onChanged={() => { setPushRecheck((n) => n + 1); void refreshDevices(); }}
/>
</>
) : (
<SettingItem label={t('push.enable')} description={statusDescription}>
<div className="flex items-center gap-2">
{pushEnabled && (
@@ -451,6 +478,7 @@ export function NotificationSettings() {
/>
</div>
</SettingItem>
)}
{sharedMailboxesWithoutPush && (
<p className="text-xs text-muted-foreground">{t('push.shared_mailboxes_no_push')}</p>
@@ -481,8 +509,7 @@ export function NotificationSettings() {
</p>
)}
</SettingsSection>
<MultiAccountPushSettings relayBaseUrl={activeRelayUrl} />
)}
{client && (
<SettingsSection id="devices" advanced title={t('push.devices_title')} description={t('push.devices_desc')}>
+17 -3
View File
@@ -15,6 +15,9 @@ import { Button } from '@/components/ui/button';
import { ChevronRight, RefreshCw } from '@/components/icons';
import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
import { DesktopTelemetrySettings } from './desktop-telemetry-settings';
import { isDesktopApp } from '@/lib/desktop/bridge';
import { productName } from '@/lib/product-name';
import { useConfig } from '@/hooks/use-config';
/** Opens another settings page (and section) the way deep links do. */
export function goToSettings(tab: string, section?: string) {
@@ -160,13 +163,15 @@ function ReadReceiptsSection() {
function SenderPicturesSection() {
const tPage = useTranslations('settings.message_list');
const tAdvanced = useTranslations('settings.advanced');
const tP2 = useTranslations('uxp2_settings');
const faviconsAllowed = !useNoThirdPartyTraffic();
const refreshFavicons = useFaviconRefreshStore((s) => s.refresh);
const { senderFavicons, showAvatarsInJunk, updateSetting } = useSettingsStore();
return (
<SettingsSection id="senders" title={tPage('sections.senders')}>
{!IS_LITE && faviconsAllowed && (
<SettingItem label={tAdvanced('sender_favicons.label')} description={tAdvanced('sender_favicons.description')}>
// Named like its section, "Sender pictures", not "favicons" (settings-p2-18).
<SettingItem label={tP2('sender_favicons.label')} description={tP2('sender_favicons.description')}>
<ToggleSwitch checked={senderFavicons} onChange={(checked) => updateSetting('senderFavicons', checked)} />
</SettingItem>
)}
@@ -200,20 +205,29 @@ function SenderPicturesSection() {
*/
export function PrivacySettings() {
const t = useTranslations('uxs_structure.privacy');
const tP2 = useTranslations('uxp2_settings.privacy_push');
const tTabs = useTranslations('settings.tabs');
const appName = productName(useConfig().appName);
// The desktop app has no push relay: it tells about new mail while it runs (settings-p2-4).
const [inApp, setInApp] = useState(false);
useEffect(() => setInApp(isDesktopApp()), []);
return (
<div className="space-y-8">
<RemoteContentSection />
<ReadReceiptsSection />
<SenderPicturesSection />
<DesktopTelemetrySettings />
{!inApp && (
<SettingsSection id="push" title={t('push_title')}>
{/* The relay itself is chosen with advanced settings on; the link says what the page always has (settings-p2-18). */}
<SettingsLinkRow
label={t('push_link.label')}
description={t('push_link.description')}
label={tP2('label')}
description={tP2('description', { appName, page: tTabs('notifications') })}
button={t('push_link.button')}
onClick={() => goToSettings('notifications', 'push')}
/>
</SettingsSection>
)}
</div>
);
}
+12 -2
View File
@@ -84,6 +84,7 @@ import {
type SubResult,
getByPath,
matchesAllWords,
matchesSubResult,
settingsSubResults,
settingsTabLabelKey,
subResultText,
@@ -91,6 +92,8 @@ import {
tabSearchEntries,
ARCHIVE_ACTION_SEARCH_PATHS,
} from '@/lib/settings-search';
import { getDesktopBridge } from '@/lib/desktop/bridge';
import { menuBarAvailable } from '@/lib/desktop/menu-bar';
import { useAccountStore } from '@/stores/account-store';
import { useMediaQuery, atLeast, PHONE_BELOW } from '@/hooks/use-media-query';
import { useLiteLinkSegments } from '@/hooks/use-lite-link-segments';
@@ -366,6 +369,13 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
if (entry.when === 'touch') return touchScreen;
if (entry.when === 'mountainView') return mountainViewLayout;
if (entry.when === 'multiAccount') return multiAccount;
// The desktop app's own rows (settings-p2-5), and the browser's that it hides.
const desktop = getDesktopBridge();
if (entry.when === 'desktop') return !!desktop;
if (entry.when === 'desktopMac') return desktop?.platform === 'darwin';
if (entry.when === 'desktopNotMac') return !!desktop && desktop.platform !== 'darwin';
if (entry.when === 'desktopMenuBar') return menuBarAvailable();
if (entry.when === 'browser') return !desktop;
return true;
};
for (const tabId of Object.keys(tabSearchEntries) as Tab[]) {
@@ -766,7 +776,7 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
if (!trimmedQuery) return true;
const pageText = `${tab.label} ${String(tab.id).replace(/_/g, ' ')} ${tabKeywords[tab.id as Tab] ?? ''}`;
return matchesAllWords(pageText, trimmedQuery)
|| (tabSubResults[tab.id as Tab] ?? []).some((r) => matchesAllWords(subResultText(r), trimmedQuery));
|| (tabSubResults[tab.id as Tab] ?? []).some((r) => matchesSubResult(r, trimmedQuery));
};
const subResultsForTab = (tabId: SettingsTabId): SubResult[] => {
@@ -775,7 +785,7 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
// Rows whose name matches come before rows matched by their description or synonyms.
const byName = (r: SubResult) => (matchesAllWords(r.label, trimmedQuery) ? 0 : 1);
return list
.filter((r) => matchesAllWords(subResultText(r), trimmedQuery))
.filter((r) => matchesSubResult(r, trimmedQuery))
.sort((a, b) => byName(a) - byName(b))
.slice(0, 6);
};
+137
View File
@@ -0,0 +1,137 @@
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslations } from 'next-intl';
import type { Identity } from '@/lib/jmap/types';
import { SettingsSection } from './settings-section';
import { SignatureEditor, SIGNATURE_MAX_BYTES, signatureBytes, type SignatureChange } from '@/components/identity/signature-editor';
import { LockedSignature } from '@/components/identity/identity-form';
import { useOrgSignatureSetup } from '@/hooks/use-org-signature';
import { sanitizeSignatureHtml } from '@/lib/email-sanitization';
import { toUnicodeEmail } from '@/lib/idn';
import { useAuthStore } from '@/stores/auth-store';
import { useIdentityStore, saveIdentityChanges } from '@/stores/identity-store';
import { useSettingsStore } from '@/stores/settings-store';
/** How long typing has to pause before the signature is saved. */
export const SIGNATURE_SAVE_DELAY_MS = 1000;
/**
* Settings > Composing > Signatures: every address you send from with its
* signature editor right there, saved as you type (settings-p2-3). Gmail
* and Outlook keep the signature on the compose settings; it used to sit
* four clicks deep in the address editor.
*/
export function SignaturesSection() {
const t = useTranslations('uxs2_ident.signatures');
const identities = useIdentityStore((s) => s.identities);
return (
<SettingsSection id="signatures" title={t('title')} description={t('description')}>
{identities.length === 0 ? (
<p className="text-sm text-muted-foreground">{t('no_addresses')}</p>
) : (
<div className="space-y-6">
{identities.map((identity) => (
<AddressSignature key={identity.id} identity={identity} showAddress={identities.length > 1} />
))}
</div>
)}
</SettingsSection>
);
}
function AddressSignature({ identity, showAddress }: { identity: Identity; showAddress: boolean }) {
const t = useTranslations('uxs2_ident.signatures');
const client = useAuthStore((s) => s.client);
const plainTextMode = useSettingsStore((s) => s.plainTextMode);
// The admin's `signature` restriction, as in the address editor.
const org = useOrgSignatureSetup({ name: identity.name, email: identity.email });
const hidden = org.restriction.hidden;
const locked = org.restriction.locked && !hidden;
const [draft, setDraft] = useState({ html: identity.htmlSignature ?? '', text: identity.textSignature ?? '' });
const [status, setStatus] = useState<'idle' | 'saved' | 'error'>('idle');
const draftRef = useRef(draft);
draftRef.current = draft;
const savedRef = useRef(draft);
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
const flush = useCallback(async () => {
if (timer.current) { clearTimeout(timer.current); timer.current = null; }
const next = draftRef.current;
const last = savedRef.current;
if (next.html === last.html && next.text === last.text) return;
// Too long: the editor says so under the field, and nothing is cut off.
if (signatureBytes(next.html, next.text, plainTextMode) > SIGNATURE_MAX_BYTES) return;
if (!client) return;
// JMAP clears a field with null, as the address editor sends it.
const updates = {
htmlSignature: next.html.trim() ? sanitizeSignatureHtml(next.html) : null,
textSignature: next.text.trim() ? next.text : null,
};
savedRef.current = next;
try {
if (!(await saveIdentityChanges(client, identity, updates))) {
savedRef.current = last;
return;
}
useAuthStore.getState().syncIdentities();
setStatus('saved');
} catch {
savedRef.current = last;
setStatus('error');
}
}, [client, identity, plainTextMode]);
// Leaving the page saves what is still waiting for its pause.
const flushRef = useRef(flush);
flushRef.current = flush;
useEffect(() => () => { void flushRef.current(); }, []);
const onChange = (change: SignatureChange) => {
setDraft((prev) => {
const next = { html: change.htmlSignature ?? prev.html, text: change.textSignature ?? prev.text };
draftRef.current = next;
return next;
});
setStatus('idle');
if (timer.current) clearTimeout(timer.current);
timer.current = setTimeout(() => { void flushRef.current(); }, SIGNATURE_SAVE_DELAY_MS);
};
if (hidden) return null;
return (
<div
data-testid="address-signature"
data-address={identity.email}
onBlur={(e) => {
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) void flush();
}}
>
{showAddress && (
<p className="text-sm font-medium text-foreground mb-2 wrap-anywhere" dir="auto">
{identity.name ? `${identity.name} <${toUnicodeEmail(identity.email)}>` : toUnicodeEmail(identity.email)}
</p>
)}
{locked ? (
<LockedSignature
html={org.signature ? org.signature.html : draft.html}
text={org.signature ? org.signature.text : draft.text}
/>
) : (
<SignatureEditor
html={draft.html}
text={draft.text}
plainOnly={plainTextMode}
onChange={onChange}
/>
)}
<p className="text-xs mt-1 min-h-4" role="status" aria-live="polite">
{status === 'saved' && <span className="text-muted-foreground">{t('saved')}</span>}
{status === 'error' && <span className="text-destructive">{t('error')}</span>}
</p>
</div>
);
}
+12 -3
View File
@@ -1,10 +1,10 @@
"use client";
import { useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslations } from 'next-intl';
import { SearchableSelect, type SearchableOption } from '@/components/ui/searchable-select';
import { AUTO_TIME_ZONE, getBrowserTimeZone, listTimeZones } from '@/lib/timezone';
import { timeZoneChoices, timeZoneCity, type TimeZoneChoice } from '@/lib/time-zone-choices';
import { timeZoneChoices, timeZoneCity, timeZoneSearchRank, type TimeZoneChoice } from '@/lib/time-zone-choices';
import { getEffectiveLocale } from '@/i18n/detect-locale';
/**
@@ -25,11 +25,13 @@ export function TimeZonePicker({ value, onChange }: { value: string; onChange: (
setZones(listTimeZones());
}, []);
const offsets = useMemo(() => new Map<string, number>(), []);
const options = useMemo<SearchableOption[]>(() => {
// A zone synced from another browser (newer tz database) may be missing
// from this runtime's list; keep it selectable.
const extra = value !== AUTO_TIME_ZONE ? [value] : [];
const { common, all } = timeZoneChoices(zones, { locale: getEffectiveLocale(), extra });
for (const choice of all) offsets.set(choice.value, choice.offsetMinutes);
const toOption = (group: string) => (choice: TimeZoneChoice): SearchableOption => ({
value: choice.value,
label: choice.city,
@@ -42,7 +44,13 @@ export function TimeZonePicker({ value, onChange }: { value: string; onChange: (
...common.map(toOption(tz('time_zone_common'))),
...all.map(toOption(tz('time_zone_all'))),
];
}, [zones, value, browserTimeZone, t, tz]);
}, [zones, value, browserTimeZone, t, tz, offsets]);
// "CET" and "GMT+1" find their zones first (settings-p2-20).
const rank = useCallback(
(query: string, option: SearchableOption, wordsMatch: boolean) =>
timeZoneSearchRank(query, { value: option.value, offsetMinutes: offsets.get(option.value) }, wordsMatch),
[offsets],
);
const current = options.find((option) => option.value === value);
return (
@@ -52,6 +60,7 @@ export function TimeZonePicker({ value, onChange }: { value: string; onChange: (
value={value}
options={options}
onSelect={onChange}
rank={rank}
ariaLabel={`${t('time_zone.label')}: ${current?.label ?? timeZoneCity(value)}`}
searchLabel={tz('time_zone_search')}
noMatchLabel={tz('time_zone_no_match')}
+24 -6
View File
@@ -159,6 +159,19 @@ export function VacationSettings() {
const [halfFilled, setHalfFilled] = useState({ from: false, to: false });
const fromRef = useRef<HTMLInputElement>(null);
const toRef = useRef<HTMLInputElement>(null);
// An empty date field that isn't being edited is a text field with a
// placeholder in words: the browser's own date field shows its raw pattern
// ("dd.mm.yyyy --:--") there, whatever the app's date format.
const [dateEditing, setDateEditing] = useState<'from' | 'to' | null>(null);
const dateFieldType = (field: 'from' | 'to') =>
dateEditing === field || halfFilled[field] || (field === 'from' ? fromInput : toInput) ? 'datetime-local' : 'text';
useEffect(() => {
if (!dateEditing) return;
const input = (dateEditing === 'from' ? fromRef : toRef).current;
// Opens the browser's picker after a click; a Tab into the field has no
// user activation and just leaves the field focused for typing.
try { input?.showPicker?.(); } catch { /* not allowed without a click */ }
}, [dateEditing]);
const localFromDate = fieldMoment(fromInput, fromDate, inputZone);
const localToDate = fieldMoment(toInput, toDate, inputZone);
useEffect(() => {
@@ -568,11 +581,13 @@ export function VacationSettings() {
>
<input
ref={fromRef}
type="datetime-local"
max={DATE_INPUT_MAX}
type={dateFieldType('from')}
max={dateFieldType('from') === 'datetime-local' ? DATE_INPUT_MAX : undefined}
value={fromInput}
placeholder={tx('from_placeholder')}
onFocus={() => setDateEditing('from')}
onChange={(e) => readDateField('from', e.currentTarget)}
onBlur={(e) => readDateField('from', e.currentTarget)}
onBlur={(e) => { readDateField('from', e.currentTarget); setDateEditing(null); }}
aria-label={tx('from_label')}
aria-invalid={!!fromError}
aria-describedby={fromError ? 'vacation-from-error' : undefined}
@@ -587,11 +602,13 @@ export function VacationSettings() {
>
<input
ref={toRef}
type="datetime-local"
max={DATE_INPUT_MAX}
type={dateFieldType('to')}
max={dateFieldType('to') === 'datetime-local' ? DATE_INPUT_MAX : undefined}
value={toInput}
placeholder={tx('until_placeholder')}
onFocus={() => setDateEditing('to')}
onChange={(e) => readDateField('to', e.currentTarget)}
onBlur={(e) => readDateField('to', e.currentTarget)}
onBlur={(e) => { readDateField('to', e.currentTarget); setDateEditing(null); }}
aria-label={tx('until_label')}
aria-invalid={!!toError}
aria-describedby={toError ? 'vacation-to-error' : undefined}
@@ -643,6 +660,7 @@ export function VacationSettings() {
<RichTextEditor
content={localBody}
onChange={editBody}
toolbar="short"
ariaLabel={tx('message_label')}
ariaInvalid={!!bodyError}
ariaDescribedBy={bodyError ? 'vacation-body-error' : undefined}
+76
View File
@@ -0,0 +1,76 @@
'use client';
import { useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { Check, Loader2 } from '@/components/icons';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { SettingItem } from './settings-section';
import { useAuthStore } from '@/stores/auth-store';
import { saveIdentityChanges } from '@/stores/identity-store';
import { toast } from '@/stores/toast-store';
/**
* "Your name" on the Account page: the name on the primary sending address,
* which is what recipients see next to it. It saves to that identity, so the
* Account page and the address editor change the same name (settings-p2-2).
* Without an identity there is nothing to save to, and the row only shows
* the name the app knows.
*/
export function YourNameRow({ fallbackName }: { fallbackName?: string }) {
const t = useTranslations('uxs2_ident.your_name');
const tForm = useTranslations('identities.form');
const tCommon = useTranslations('common');
const client = useAuthStore((s) => s.client);
const identity = useAuthStore((s) => s.primaryIdentity);
const current = identity?.name ?? '';
const [name, setName] = useState(current);
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
useEffect(() => { setName(current); }, [current]);
if (!identity || !client) {
return (
<SettingItem label={t('label')} description={t('description')}>
<span className="text-sm text-foreground">{fallbackName || tCommon('unknown')}</span>
</SettingItem>
);
}
const save = async () => {
setSaving(true);
try {
if (!(await saveIdentityChanges(client, identity, { name: name.trim() }))) return;
useAuthStore.getState().syncIdentities();
setSaved(true);
setTimeout(() => setSaved(false), 2000);
toast.success(t('saved'));
} catch (error) {
toast.error(t('error'), error instanceof Error ? error.message : undefined);
} finally {
setSaving(false);
}
};
return (
<SettingItem label={t('label')} description={t('description')}>
<form
className="flex items-center gap-2"
onSubmit={(e) => { e.preventDefault(); void save(); }}
>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
maxLength={256}
autoComplete="name"
className="w-48"
disabled={saving}
/>
<Button type="submit" size="sm" disabled={saving || name.trim() === current.trim()}>
{saved ? <Check className="w-4 h-4" /> : saving ? <Loader2 className="w-4 h-4 animate-spin" /> : tForm('save')}
</Button>
</form>
</SettingItem>
);
}
@@ -2,15 +2,30 @@ import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { FirstStartNoticeBar } from '../first-start-notice';
import { resetFirstStartNotices, showFirstStartNotice } from '@/lib/first-start-notices';
import { useSettingsStore } from '@/stores/settings-store';
beforeEach(() => {
localStorage.clear();
resetFirstStartNotices();
// The welcome tips were closed: notices may show.
useSettingsStore.setState({ onboardingCompleted: true, showOnboardingOnNewDevices: false });
});
afterEach(() => cleanup());
describe('first-start notices', () => {
it('wait for the welcome tips, wherever they show, and come after Got it', () => {
useSettingsStore.setState({ onboardingCompleted: false });
render(<FirstStartNoticeBar />);
act(() => showFirstStartNotice({ id: 'offline', text: 'Mail is kept on this device.' }));
expect(screen.queryByText('Mail is kept on this device.')).toBeNull();
act(() => {
localStorage.setItem('onboarding_completed', 'true');
useSettingsStore.setState({ onboardingCompleted: true });
});
expect(screen.getByText('Mail is kept on this device.')).toBeTruthy();
});
it('show one at a time, each with a close button, the next once the first is closed', () => {
render(<FirstStartNoticeBar />);
act(() => {
+8 -4
View File
@@ -6,12 +6,15 @@ import { Button } from "@/components/ui/button";
import { IconButton } from "@/components/ui/icon-button";
import { closeFirstStartNotice, useCurrentFirstStartNotice } from "@/lib/first-start-notices";
import { useConnectionState } from "@/hooks/use-connection-state";
import { useWelcomeBannerDue } from "@/components/ui/welcome-banner";
/**
* The first-start notice that's waiting, as a bar above the message list (see
* lib/first-start-notices). In the page's flow, it covers nothing. `hidden` while
* something else greets the user there, so only one shows at a time. Offline, the
* offline bar above says what matters then, so the notice waits.
* lib/first-start-notices). In the page's flow, it covers nothing. It waits while the
* welcome tips are due, wherever they show (above the list or in the reading pane),
* so the first start has one greeting at a time. `hidden` while something else greets
* the user there. Offline, the offline bar above says what matters then, so the
* notice waits too.
*
* One line: a longer text is cut, and shows whole on hover.
*/
@@ -19,7 +22,8 @@ export function FirstStartNoticeBar({ hidden = false }: { hidden?: boolean }) {
const tCommon = useTranslations("common");
const notice = useCurrentFirstStartNotice();
const { offline } = useConnectionState();
if (!notice || hidden || offline) return null;
const welcomeDue = useWelcomeBannerDue();
if (!notice || hidden || offline || welcomeDue) return null;
return (
<div
role="status"
+21 -1
View File
@@ -33,6 +33,13 @@ interface SearchableSelectProps {
renderValue?: (option: SearchableOption | undefined) => ReactNode;
className?: string;
menuClassName?: string;
/**
* Orders the results, lower first, and can add or drop options:
* `wordsMatch` says whether every typed word is in the option's text.
* null leaves the option out. Without it, the matching options keep
* their order.
*/
rank?: (query: string, option: SearchableOption, wordsMatch: boolean) => number | null;
}
/**
@@ -50,6 +57,7 @@ export function SearchableSelect({
renderValue,
className,
menuClassName,
rank,
}: SearchableSelectProps) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
@@ -74,12 +82,24 @@ export function SearchableSelect({
const words = needle.split(/\s+/);
// An option listed in two groups shows once in the results.
const seen = new Set<string>();
if (rank) {
const ranked: { entry: (typeof entries)[number]; rank: number }[] = [];
for (const e of entries) {
if (seen.has(e.value)) continue;
const r = rank(query, e, words.every((w) => e.haystack.includes(w)));
if (r === null) continue;
seen.add(e.value);
ranked.push({ entry: e, rank: r });
}
// Array sort is stable: equal ranks keep the list's order.
return ranked.sort((a, b) => a.rank - b.rank).map((r) => r.entry);
}
return entries.filter((e) => {
if (seen.has(e.value) || !words.every((w) => e.haystack.includes(w))) return false;
seen.add(e.value);
return true;
});
}, [entries, query]);
}, [entries, query, rank]);
const close = (refocus: boolean) => {
setOpen(false);
+30
View File
@@ -0,0 +1,30 @@
"use client";
import { useMemo } from "react";
import { useTranslations } from "next-intl";
import { collectionLabel } from "@/lib/default-collection-name";
interface NamedCollection {
name: string;
isShared?: boolean;
}
/**
* Names for calendars and address books as Settings shows them: Stalwart's own
* "Stalwart Calendar (<address>)" becomes "Calendar", in the user's language.
* Use it wherever a calendar or address book name reaches the screen.
*/
export function useCollectionNames(): {
calendar: (calendar: NamedCollection) => string;
addressBook: (book: NamedCollection) => string;
} {
const t = useTranslations("uxs_wording");
return useMemo(() => {
const calendarName = t("default_calendar");
const bookName = t("default_address_book");
return {
calendar: (calendar: NamedCollection) => collectionLabel(calendar, "calendar", calendarName),
addressBook: (book: NamedCollection) => collectionLabel(book, "addressBook", bookName),
};
}, [t]);
}
@@ -0,0 +1,41 @@
import { describe, it, expect } from 'vitest';
import en from '@/locales/en/common.json';
import de from '@/locales/de/common.json';
import {
type SettingsSearchTab,
matchesAllWords,
settingsSubResults,
subResultText,
tabSearchEntries,
} from '../settings-search';
// settings-p2-3: "identity", "alias", "sender" and "from address" found
// nothing, and "signature" led to a row that only linked elsewhere. The
// signatures now sit on Composing, and these words find them.
const everywhere = (messages: unknown, query: string) => (Object.keys(tabSearchEntries) as SettingsSearchTab[]).flatMap((tab) =>
settingsSubResults(messages, tab, 'Bulwark')
.filter((r) => matchesAllWords(subResultText(r), query))
.map((r) => `${tab}: ${r.label}`));
describe('settings search: addresses, signatures and your name', () => {
it('finds the signatures on Composing', () => {
for (const query of ['signature', 'identity', 'sender', 'from address']) {
expect(everywhere(en, query), query).toContain('composing: Signatures');
}
});
it('finds the sending addresses on Account for identity and alias', () => {
for (const query of ['identity', 'alias', 'sender address']) {
expect(everywhere(en, query), query).toContain('account: Email addresses');
}
});
it('finds "Your name" for display name', () => {
expect(everywhere(en, 'display name')).toContain('account: Your name');
});
it('finds them in German too', () => {
expect(everywhere(de, 'Absender')).toContain('composing: Signaturen');
expect(everywhere(de, 'Identität')).toContain('account: E-Mail-Adressen');
});
});
+100 -6
View File
@@ -5,16 +5,16 @@ import {
descriptionKeyFor,
getByPath,
matchesAllWords,
matchesSubResult,
tabKeywords,
settingsSubResults,
subResultText,
tabSearchEntries,
withAppName,
} from '../settings-search';
const labelsFor = (tab: SettingsSearchTab) => settingsSubResults(en, tab, 'Bulwark').map((r) => r.label);
const resultsFor = (tab: SettingsSearchTab, query: string) =>
settingsSubResults(en, tab, 'Bulwark').filter((r) => matchesAllWords(`${r.label} ${r.description ?? ''}`, query));
settingsSubResults(en, tab, 'Bulwark').filter((r) => matchesSubResult(r, query));
describe('settings search index (English messages)', () => {
it('every entry names a label that exists, without a raw placeholder', () => {
@@ -40,7 +40,7 @@ describe('settings search index (English messages)', () => {
expect(labelsFor('notifications')).toContain('Unread count on the tab and app icon');
// settings-24 (2026-11-04): sender pictures, remote images and trusted
// senders moved from Message list and Reading to the new Privacy page.
expect(labelsFor('privacy')).toContain('Sender favicons');
expect(labelsFor('privacy')).toContain('Pictures from company websites');
expect(labelsFor('message_list')).toContain('Clear search when switching folders');
expect(labelsFor('privacy')).toContain('Trusted senders');
expect(labelsFor('composing')).toContain('Undo send');
@@ -66,7 +66,7 @@ describe('settings search index (English messages)', () => {
expect(resultsFor('message_list', 'inbox type').map((r) => r.label)).toEqual(['Inbox type']);
expect(resultsFor('calendar', 'import calendar').map((r) => r.label)).toContain('Import calendar');
expect(resultsFor('composing', 'mention').map((r) => r.label).slice(0, 2)).toEqual(['Mention recipients with @', 'Contacts in @ mentions']);
expect(resultsFor('security', 'two-factor').map((r) => r.label)).toContain('Two-factor authentication');
expect(resultsFor('security', 'two-factor').map((r) => r.label)).toContain('Two-step sign-in');
expect(resultsFor('filters', 'new rule').map((r) => r.label)).toEqual(['New rule']);
// "add rule" finds the Filters page itself.
expect(matchesAllWords(tabKeywords.filters, 'add rule')).toBe(true);
@@ -89,7 +89,7 @@ describe('settings search index (English messages)', () => {
it('marks the rows the Advanced switch hides', () => {
const advanced = settingsSubResults(en, 'message_list', 'Bulwark').filter((r) => r.advanced).map((r) => r.label);
expect(advanced).toContain('Emails per page');
expect(settingsSubResults(en, 'privacy', 'Bulwark').filter((r) => r.advanced).map((r) => r.label)).not.toContain('Sender favicons');
expect(settingsSubResults(en, 'privacy', 'Bulwark').filter((r) => r.advanced).map((r) => r.label)).not.toContain('Pictures from company websites');
});
});
@@ -99,7 +99,7 @@ describe('settings search: words, word starts and synonyms', () => {
/** Every row of every page that the query finds, as "page: label". */
const everywhere = (query: string) => (Object.keys(tabSearchEntries) as SettingsSearchTab[]).flatMap((tab) =>
settingsSubResults(en, tab, 'Bulwark')
.filter((r) => matchesAllWords(subResultText(r), query))
.filter((r) => matchesSubResult(r, query))
.map((r) => `${tab}: ${r.label}`));
it('matches whole words and the start of words, not the inside of a word', () => {
@@ -190,3 +190,97 @@ describe('{appName} in the index (#1314)', () => {
expect(withAppName({ a: 'x {appName} y', b: ['{appName}'], c: 3 }, 'N')).toEqual({ a: 'x N y', b: ['N'], c: 3 });
});
});
// settings-p2-14 (2026-11-04): words people type that found nothing or the
// wrong row, and short words that matched a dozen descriptions.
describe('settings search: more words people use', () => {
const everywhere = (query: string, shown?: (when: string | undefined) => boolean) =>
(Object.keys(tabSearchEntries) as SettingsSearchTab[]).flatMap((tab) =>
settingsSubResults(en, tab, 'Bulwark', (entry) => (shown ? shown(entry.when) : !entry.when?.startsWith('desktop')))
.filter((r) => matchesSubResult(r, query))
.map((r) => `${tab}: ${r.label}`));
it.each([
['threads', 'message_list: Group messages by conversation'],
// "Pictures from the internet" in the plain English the app shows.
['block images', 'privacy: External content'],
['dark mode', 'appearance: Light or dark'],
['timezone', 'general: Time zone'],
['reading pane', 'appearance: Mail layout'],
['week start', 'general: First day of week'],
['empty trash', 'message_list: Deleting & archiving'],
['quiet hours', 'accessibility: Do not disturb'],
])('finds "%s"', (query, row) => {
expect(everywhere(query)).toContain(row);
});
it('looks for one short word in names and synonyms only, not in descriptions', () => {
// "about" starts a word in many descriptions ("how you hear about new mail").
expect(everywhere('about')).toEqual(['about: About Bulwark']);
const row = { label: 'Sound', description: 'Plays a chime about new mail when it arrives' };
expect(matchesSubResult(row, 'about')).toBe(false);
expect(matchesSubResult(row, 'chime')).toBe(false);
expect(matchesSubResult({ ...row, keywords: 'chime bell' }, 'chime')).toBe(true);
// A longer word, or more words, still look in the description.
expect(matchesSubResult(row, 'arrives')).toBe(true);
expect(matchesSubResult(row, 'sound mail')).toBe(true);
expect(matchesSubResult(row, 'sound tray')).toBe(false);
// Chinese has no spaces: its words are looked for in descriptions too.
expect(matchesSubResult({ label: '声音', description: '新邮件时播放提示音' }, '邮件')).toBe(true);
});
});
// settings-p2-5 (2026-11-04): in the desktop app the app's own rows were
// missing from the search; "tray" found an unread count instead.
describe('settings search in the desktop app', () => {
const inApp = (when: string | undefined, mac = false) => {
if (when === 'browser') return false;
if (when === 'desktopMac') return mac;
if (when === 'desktopNotMac' || when === 'desktopMenuBar') return !mac;
return when !== 'touch' && when !== 'mountainView' && when !== 'multiAccount';
};
const inAppResults = (query: string, mac = false) => (Object.keys(tabSearchEntries) as SettingsSearchTab[]).flatMap((tab) =>
settingsSubResults(en, tab, 'Bulwark', (entry) => inApp(entry.when, mac))
.filter((r) => matchesSubResult(r, query))
.map((r) => `${tab}: ${r.label}`));
const inBrowser = (query: string) => (Object.keys(tabSearchEntries) as SettingsSearchTab[]).flatMap((tab) =>
settingsSubResults(en, tab, 'Bulwark', (entry) => !entry.when?.startsWith('desktop'))
.filter((r) => matchesSubResult(r, query))
.map((r) => `${tab}: ${r.label}`));
it.each([
['tray', 'general: Keep running in the tray when closed'],
['quiet hours', 'notifications: Quiet hours'],
['do not disturb', 'notifications: Quiet hours'],
['autostart', 'general: Start when you sign in to your computer'],
['start with windows', 'general: Start when you sign in to your computer'],
['startup', 'general: Start when you sign in to your computer'],
['menu bar', 'general: Always show the menu bar'],
['taskbar', 'notifications: Flash the taskbar on new mail'],
['developer tools', 'general: Troubleshooting'],
['updates', 'general: Updates'],
['usage', 'privacy: Send usage info'],
['crash', 'privacy: Send crash reports'],
['default mail app', 'general: Default mail app'],
])('finds "%s"', (query, row) => {
expect(inAppResults(query)).toContain(row);
});
it('lists "tray" first for the tray row, and the Dock row on a Mac', () => {
expect(inAppResults('tray')[0]).toBe('general: Keep running in the tray when closed');
expect(inAppResults('taskbar', true)).toContain('notifications: Bounce the Dock icon on new mail');
expect(inAppResults('menu bar', true)).not.toContain('general: Always show the menu bar');
});
it('marks developer tools as an advanced row', () => {
const devTools = settingsSubResults(en, 'general', 'Bulwark').find((r) => r.label === 'Troubleshooting');
expect(devTools?.advanced).toBe(true);
});
it('keeps the app rows out of a browser, and the browser push rows out of the app', () => {
expect(inBrowser('tray')).not.toContain('general: Keep running in the tray when closed');
expect(inBrowser('quiet hours')).not.toContain('notifications: Quiet hours');
expect(inAppResults('relay')).toEqual([]);
expect(inBrowser('relay')).toContain('privacy: Relay for notifications');
});
});
+21 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { timeZoneChoices, timeZoneCity } from '../time-zone-choices';
import { parseTypedOffset, timeZoneChoices, timeZoneCity, timeZoneSearchRank } from '../time-zone-choices';
const JAN = new Date(Date.UTC(2026, 0, 15, 12));
@@ -38,3 +38,23 @@ describe('time zone choices', () => {
expect(all.map((c) => c.value)).toContain('America/Ciudad_Juarez');
});
});
describe('time zone search', () => {
it('reads offsets as people type them', () => {
expect(parseTypedOffset('GMT+1')).toBe(60);
expect(parseTypedOffset('utc-5')).toBe(-300);
expect(parseTypedOffset('+05:30')).toBe(330);
expect(parseTypedOffset('-0800')).toBe(-480);
expect(parseTypedOffset('Berlin')).toBeNull();
expect(parseTypedOffset('GMT')).toBeNull();
});
it('puts the zones of an abbreviation and the exact offset first', () => {
expect(timeZoneSearchRank('CET', { value: 'Europe/Berlin', offsetMinutes: 60 }, false)).toBe(0);
expect(timeZoneSearchRank('cest', { value: 'Europe/Paris', offsetMinutes: 120 }, false)).toBe(0);
expect(timeZoneSearchRank('CET', { value: 'Asia/Tokyo', offsetMinutes: 540 }, false)).toBeNull();
expect(timeZoneSearchRank('GMT+1', { value: 'Europe/Berlin', offsetMinutes: 60 }, true)).toBe(0);
expect(timeZoneSearchRank('GMT+1', { value: 'Australia/Sydney', offsetMinutes: 660 }, true)).toBe(2);
expect(timeZoneSearchRank('Berlin', { value: 'Europe/Berlin', offsetMinutes: 60 }, true)).toBe(1);
});
});
+14
View File
@@ -1,4 +1,5 @@
import { IS_DESKTOP_BUILD } from '@/lib/desktop/flag';
import { toUnicodeEmail } from '@/lib/idn';
/**
* Utilities for multi-account support:
@@ -174,6 +175,19 @@ export function accountRowText(
return { title: name, subtitle: address };
}
/**
* Whether another signed-in account has the same address (one mailbox on two
* servers). Only then is the server worth a line under an account: in every
* other case it says nothing the person needs (settings-14, settings-p2-11).
*/
export function accountSharesAddress(
account: NamedAccount,
accounts: NamedAccount[],
): boolean {
const key = (a: NamedAccount) => toUnicodeEmail(a.email || a.username).trim().toLowerCase();
return accounts.some((other) => other.id !== account.id && key(other) === key(account));
}
/**
* The name an account's avatar takes its initials from. Usually the account's
* name. When another account has the same name, both would show the same two
+17
View File
@@ -21,3 +21,20 @@ export function isServerDefaultCollectionName(name: string | undefined, kind: De
export function collectionDisplayName(name: string, kind: DefaultCollectionKind, plainName: string): string {
return isServerDefaultCollectionName(name, kind) ? plainName : name;
}
/**
* The name to show for a collection anywhere in the app: the plain name for the
* user's own default one, and for a colleague's shared default the plain name with
* whose it is ("Calendar (bob@example.org)"), so two of them stay apart.
*/
export function collectionLabel(
collection: { name: string; isShared?: boolean },
kind: DefaultCollectionKind,
plainName: string,
): string {
const { name } = collection;
if (!isServerDefaultCollectionName(name, kind)) return name;
if (!collection.isShared) return plainName;
const owner = /\(([^()]*)\)\s*$/.exec(name.trim())?.[1];
return owner ? `${plainName} (${owner})` : plainName;
}
-24
View File
@@ -213,30 +213,6 @@ async function applyAndReport(
}
}
/**
* After a rule was saved in Settings › Filters: when it would change mail
* already in `mailboxId` (the Inbox), say how much and offer to apply it
* there. Nothing is said when it would change nothing or can't be checked.
*/
export async function offerRuleOnFolder(
target: QuickRuleTarget,
rule: FilterRule,
mailboxId: string,
text: QuickRuleText,
applyLabel: (count: number) => string,
): Promise<void> {
const plan = await planSafely(target, rule, mailboxId);
if (plan.ids.length === 0) return;
toast.success(text.notifications('rule_created'), {
message: rule.name,
duration: SAVED_TOAST_MS,
action: {
label: applyLabel(plan.ids.length),
onClick: () => { void applyAndReport(target, rule, mailboxId, text); },
},
});
}
/**
* Run a saved rule over the mail already in a folder, from the folder's
* context menu. A progress toast stays up while the server is asked which
+79 -23
View File
@@ -35,7 +35,17 @@ export type SettingsSearchCondition =
/** The Mountain View interface layout. */
| 'mountainView'
/** More than one account signed in. */
| 'multiAccount';
| 'multiAccount'
/** In the desktop app (its bridge is there). */
| 'desktop'
/** In the desktop app on Windows or Linux. */
| 'desktopNotMac'
/** In the desktop app on macOS. */
| 'desktopMac'
/** In the desktop app where its own menu bar can be pinned (not macOS). */
| 'desktopMenuBar'
/** In a browser, not the desktop app. */
| 'browser';
export interface SettingsSearchEntry {
/**
@@ -57,6 +67,8 @@ export interface SettingsSearchEntry {
/** Key of a row's synonyms in the locale files. */
const syn = (id: string) => `uxs_structure.search.${id}`;
/** Synonyms added in the second settings pass. */
const syn2 = (id: string) => `uxp2_settings.search.${id}`;
// The settings each page draws, as the search lists them. Only labels a page
// renders belong here: settings-search-anchors.test.tsx renders every page
@@ -64,12 +76,12 @@ const syn = (id: string) => `uxs_structure.search.${id}`;
// the label (see descriptionKeyFor) and searched too.
export const tabSearchEntries: Record<Tab, SettingsSearchEntry[]> = {
account: [
{ label: 'settings.account.name_label' },
{ label: 'uxs2_ident.your_name.label', synonyms: 'uxs2_ident.search.your_name' },
{ label: 'settings.account.email.label' },
{ label: 'settings.account.auth_method_label' },
{ label: 'settings.account.server.label', advanced: true },
{ label: 'settings.account.sections.addresses', synonyms: syn('signature') },
{ label: 'settings.identities.identities_count.label' },
{ label: 'settings.account.sections.addresses', synonyms: 'uxs2_ident.search.addresses' },
{ label: 'uxs2_ident.addresses.label' },
{ label: 'settings.identities.sub_addressing.label' },
{ label: 'settings.email_behavior.sub_address_delimiter.label', advanced: true },
{ label: 'settings.account.accounts.title', when: 'multiAccount' },
@@ -81,17 +93,29 @@ export const tabSearchEntries: Record<Tab, SettingsSearchEntry[]> = {
{ label: 'settings.language_region.date_format.label' },
{ label: 'settings.language_region.date_locale.label', advanced: true },
{ label: 'settings.language_region.time_format.label' },
{ label: 'settings.language_region.time_zone.label' },
{ label: 'settings.language_region.first_day.label' },
{ label: 'settings.language_region.time_zone.label', synonyms: syn2('timezone') },
{ label: 'settings.language_region.first_day.label', synonyms: syn2('week_start') },
{ label: 'appLock.section_title' },
{ label: 'appLock.enable_label', synonyms: syn('app_lock') },
{ label: 'protocol_handlers.title' },
{ label: 'protocol_handlers.mailto_label' },
{ label: 'protocol_handlers.webcal_label' },
{ label: 'protocol_handlers.protocol_open_mode_label', advanced: true },
{ label: 'desktopOs.section_title', when: 'desktop' },
{ label: 'desktopOs.close_to_tray_label', when: 'desktop', synonyms: syn2('tray') },
{ label: 'desktopOs.start_at_login_label', when: 'desktop', synonyms: syn2('autostart') },
{ label: 'desktopOs.show_shortcut_label', when: 'desktop' },
{ label: 'desktopOs.default_app_label', when: 'desktop', synonyms: syn2('default_app') },
{ label: 'protocol_handlers.title', when: 'browser' },
{ label: 'protocol_handlers.mailto_label', when: 'browser' },
{ label: 'protocol_handlers.webcal_label', when: 'browser' },
{ label: 'protocol_handlers.protocol_open_mode_label', advanced: true, when: 'browser' },
{ label: 'desktopUi.settings.title', when: 'desktop' },
{ label: 'desktopUi.settings.double_click_label', when: 'desktop' },
{ label: 'desktopUi.settings.restore_label', when: 'desktop' },
{ label: 'desktopMenuBar.always_show_label', when: 'desktopMenuBar', synonyms: syn2('menu_bar') },
{ label: 'tour.settings_section' },
{ label: 'tour.restart_title' },
{ label: 'tour.show_on_new_devices_title', advanced: true },
{ label: 'desktop_updates.title', when: 'desktop', synonyms: syn2('updates') },
{ label: 'desktop_updates.channel_label', when: 'desktop' },
{ label: 'desktopDevTools.section_title', when: 'desktop', advanced: true, synonyms: syn2('devtools') },
],
notifications: [
{ label: 'settings.notifications.email.title' },
@@ -103,18 +127,23 @@ export const tabSearchEntries: Record<Tab, SettingsSearchEntry[]> = {
{ label: 'settings.notifications.calendar.enabled' },
{ label: 'settings.notifications.sound_selection.title' },
{ label: 'settings.notifications.sound_selection.choose' },
{ label: 'settings.notifications.push.title' },
{ label: 'settings.notifications.push.relay_label', advanced: true },
{ label: 'uxp2_settings.desktop_notifications.title', when: 'desktop' },
{ label: 'desktopOs.attention_label', when: 'desktopNotMac', synonyms: syn2('taskbar') },
{ label: 'desktopOs.attention_mac_label', when: 'desktopMac', synonyms: syn2('taskbar') },
{ label: 'desktopOs.quiet_hours_label', when: 'desktop', synonyms: syn2('quiet_hours') },
{ label: 'uxp2_settings.desktop_notifications.closed_label', when: 'desktop' },
{ label: 'settings.notifications.push.title', when: 'browser' },
{ label: 'settings.notifications.push.relay_label', advanced: true, when: 'browser' },
{ label: 'settings.notifications.push.devices_title', advanced: true },
],
appearance: [
{ label: 'settings.appearance.theme.label' },
{ label: 'settings.appearance.theme.label', synonyms: syn2('dark_mode') },
{ label: 'settings.themes.title' },
{ label: 'settings.appearance.sections.text' },
{ label: 'settings.appearance.list_density.label' },
{ label: 'settings.appearance.sections.layout' },
{ label: 'settings.appearance.interface_layout.label' },
{ label: 'settings.email_behavior.mail_layout.label' },
{ label: 'settings.email_behavior.mail_layout.label', synonyms: syn2('reading_pane') },
{ label: 'settings.appearance.toolbar_position.label' },
{ label: 'settings.appearance.toolbar_labels.label' },
{ label: 'settings.appearance.sections.sidebar' },
@@ -177,7 +206,7 @@ export const tabSearchEntries: Record<Tab, SettingsSearchEntry[]> = {
{ label: 'settings.accessibility.sections.alerts' },
{ label: 'settings.accessibility.visual_alerts.label' },
{ label: 'settings.accessibility.unread_counts.label' },
{ label: 'settings.accessibility.do_not_disturb.label' },
{ label: 'settings.accessibility.do_not_disturb.label', synonyms: syn2('do_not_disturb') },
{ label: 'settings.accessibility.sections.screen_readers' },
{ label: 'settings.accessibility.announce_new_mail.label' },
{ label: 'settings.accessibility.announce_preview.label' },
@@ -186,7 +215,7 @@ export const tabSearchEntries: Record<Tab, SettingsSearchEntry[]> = {
{ label: 'settings.accessibility.reset.label' },
],
message_list: [
{ label: 'settings.email_behavior.group_conversations.label' },
{ label: 'settings.email_behavior.group_conversations.label', synonyms: syn2('threads') },
{ label: 'settings.email_behavior.show_preview.label' },
{ label: 'settings.appearance.message_list_order.label' },
{ label: 'settings.appearance.message_list_order.scope.label' },
@@ -194,7 +223,7 @@ export const tabSearchEntries: Record<Tab, SettingsSearchEntry[]> = {
{ label: 'settings.email_behavior.show_verification_codes.label' },
{ label: 'settings.message_list.sections.actions' },
{ label: 'settings.email_behavior.hover_actions.label' },
{ label: 'settings.message_list.sections.delete' },
{ label: 'settings.message_list.sections.delete', synonyms: syn2('empty_trash') },
{ label: 'settings.email_behavior.delete_action.label' },
{ label: 'settings.email_behavior.archive_mode.label' },
{ label: 'settings.email_behavior.confirm_bulk_delete.label' },
@@ -251,7 +280,7 @@ export const tabSearchEntries: Record<Tab, SettingsSearchEntry[]> = {
{ label: 'settings.email_behavior.placeholder_warning.label' },
{ label: 'settings.templates.title' },
{ label: 'settings.templates.export_import', advanced: true },
{ label: 'settings.email_behavior.signatures_link.label', synonyms: syn('signature') },
{ label: 'uxs2_ident.signatures.title', synonyms: 'uxs2_ident.search.signatures' },
],
vacation: [
{ label: 'uxs_vacation.from_label' },
@@ -286,7 +315,6 @@ export const tabSearchEntries: Record<Tab, SettingsSearchEntry[]> = {
{ label: 'settings.security.sections.devices' },
{ label: 'settings.security.sections.encryption', advanced: true, synonyms: syn('encryption') },
{ label: 'settings.security.password.title', synonyms: syn('password') },
{ label: 'settings.security.totp.section_title' },
{ label: 'settings.security.app_passwords.title' },
{ label: 'settings.security.api_keys.title', advanced: true },
{ label: 'settings.security.encryption.section_title', advanced: true },
@@ -352,18 +380,21 @@ export const tabSearchEntries: Record<Tab, SettingsSearchEntry[]> = {
],
privacy: [
{ label: 'settings.reading_page.sections.images', synonyms: syn('remote_content') },
{ label: 'settings.email_behavior.external_content.label' },
{ label: 'settings.email_behavior.external_content.label', synonyms: syn2('block_images') },
{ label: 'settings.email_behavior.trusted_senders.label' },
{ label: 'settings.email_behavior.trusted_senders.use_address_book_label' },
{ label: 'settings.composing_page.sections.receipts' },
{ label: 'settings.email_behavior.request_read_receipt.label' },
{ label: 'settings.email_behavior.read_receipt_response.label' },
{ label: 'settings.message_list.sections.senders' },
{ label: 'settings.advanced.sender_favicons.label' },
{ label: 'uxp2_settings.sender_favicons.label' },
{ label: 'settings.advanced.sender_favicons.refresh_label' },
{ label: 'settings.advanced.show_avatars_in_junk.label', advanced: true },
{ label: 'uxs_structure.privacy.push_title' },
{ label: 'uxs_structure.privacy.push_link.label', synonyms: syn('push') },
{ label: 'desktopTelemetry.section_title', when: 'desktop' },
{ label: 'desktopTelemetry.telemetry_label', when: 'desktop', synonyms: syn2('telemetry') },
{ label: 'desktopTelemetry.crash_label', when: 'desktop', synonyms: syn2('crash') },
{ label: 'uxs_structure.privacy.push_title', when: 'browser' },
{ label: 'uxp2_settings.privacy_push.label', when: 'browser', synonyms: syn('push') },
],
backup: [
{ label: 'settings.advanced.sections.backup' },
@@ -521,6 +552,31 @@ export function subResultText(result: SubResult): string {
return [result.label, result.description ?? '', result.keywords ?? ''].join(' ');
}
/**
* Shortest one-word query that is looked for in a row's description too.
* A short word on its own ("about", "mail", "tab") starts a word in a dozen
* descriptions, which buried the row named by it (settings-p2-14). With more
* words the others narrow it down, so "reply signature" still finds the row
* whose description talks about replies.
*/
export const DESCRIPTION_MIN_WORD = 6;
/**
* A row matches when every word of the query starts a word of its name,
* description or synonyms; a single word of up to five letters only in its
* name and synonyms. Chinese, Japanese and Thai words are short by nature
* and always look in the description too.
*/
export function matchesSubResult(result: SubResult, query: string): boolean {
const words = searchWords(query);
if (words.length === 0) return false;
const [only] = words;
if (words.length === 1 && only.length < DESCRIPTION_MIN_WORD && !NO_WORD_BREAKS.test(only)) {
return matchesAllWords(`${result.label} ${result.keywords ?? ''}`, only);
}
return matchesAllWords(subResultText(result), query);
}
export function getByPath(obj: unknown, path: string): unknown {
let cur: unknown = obj;
for (const key of path.split('.')) {
+87
View File
@@ -134,3 +134,90 @@ export function timeZoneChoices(
}
return { common, all };
}
// Abbreviations people type for a zone, with the zones they stand for. Both
// the winter and the summer name, since people type either all year.
const CET = ['Europe/Berlin', 'Europe/Paris', 'Europe/Madrid', 'Europe/Rome', 'Europe/Amsterdam', 'Europe/Brussels',
'Europe/Vienna', 'Europe/Zurich', 'Europe/Stockholm', 'Europe/Oslo', 'Europe/Copenhagen', 'Europe/Warsaw',
'Europe/Prague', 'Europe/Budapest', 'Europe/Belgrade', 'Europe/Luxembourg'];
const EET = ['Europe/Athens', 'Europe/Helsinki', 'Europe/Kyiv', 'Europe/Kiev', 'Europe/Bucharest', 'Europe/Sofia',
'Europe/Riga', 'Europe/Tallinn', 'Europe/Vilnius', 'Africa/Cairo'];
const WET = ['Europe/Lisbon', 'Atlantic/Canary', 'Atlantic/Madeira', 'Atlantic/Faroe', 'Atlantic/Faeroe'];
const US_EAST = ['America/New_York', 'America/Toronto', 'America/Detroit', 'America/Indiana/Indianapolis'];
const US_CENTRAL = ['America/Chicago', 'America/Winnipeg', 'America/Mexico_City'];
const US_MOUNTAIN = ['America/Denver', 'America/Phoenix', 'America/Edmonton', 'America/Boise'];
const US_PACIFIC = ['America/Los_Angeles', 'America/Vancouver', 'America/Tijuana'];
const AU_EAST = ['Australia/Sydney', 'Australia/Melbourne', 'Australia/Brisbane', 'Australia/Hobart', 'Australia/Canberra'];
const UK = ['Europe/London', 'Europe/Dublin'];
export const TIME_ZONE_ABBREVIATIONS: Record<string, readonly string[]> = {
UTC: ['UTC', 'Etc/UTC'],
GMT: ['UTC', 'Etc/UTC', ...UK],
BST: UK,
IST: ['Asia/Kolkata', 'Asia/Calcutta'],
CET, CEST: CET, MEZ: CET, MESZ: CET,
EET, EEST: EET,
WET, WEST: WET,
MSK: ['Europe/Moscow'],
EST: US_EAST, EDT: US_EAST, ET: US_EAST,
CST: [...US_CENTRAL, 'Asia/Shanghai'], CDT: US_CENTRAL, CT: US_CENTRAL,
MST: US_MOUNTAIN, MDT: US_MOUNTAIN, MT: US_MOUNTAIN,
PST: US_PACIFIC, PDT: US_PACIFIC, PT: US_PACIFIC,
AKST: ['America/Anchorage'], AKDT: ['America/Anchorage'],
HST: ['Pacific/Honolulu'],
JST: ['Asia/Tokyo'],
KST: ['Asia/Seoul'],
HKT: ['Asia/Hong_Kong'],
SGT: ['Asia/Singapore'],
AEST: AU_EAST, AEDT: AU_EAST,
ACST: ['Australia/Adelaide', 'Australia/Darwin'], ACDT: ['Australia/Adelaide'],
AWST: ['Australia/Perth'],
NZST: ['Pacific/Auckland'], NZDT: ['Pacific/Auckland'],
BRT: ['America/Sao_Paulo'],
ART: ['America/Argentina/Buenos_Aires', 'America/Buenos_Aires'],
SAST: ['Africa/Johannesburg'],
WAT: ['Africa/Lagos'],
EAT: ['Africa/Nairobi'],
PKT: ['Asia/Karachi'],
WIB: ['Asia/Jakarta'],
ICT: ['Asia/Bangkok', 'Asia/Ho_Chi_Minh', 'Asia/Saigon'],
PHT: ['Asia/Manila'],
GST: ['Asia/Dubai'],
};
/**
* An offset typed in the search ("GMT+1", "UTC-5", "+05:30", "-0800"), in
* minutes east of UTC; null when the text isn't one. "GMT" alone is UTC.
*/
export function parseTypedOffset(text: string): number | null {
const m = /^(?:gmt|utc)?\s*([+\-−])\s*(\d{1,2})(?::?(\d{2}))?$/i.exec(text.trim());
if (!m) return null;
const hours = Number(m[2]);
const minutes = m[3] ? Number(m[3]) : 0;
if (hours > 14 || minutes > 59) return null;
return (m[1] === '+' ? 1 : -1) * (hours * 60 + minutes);
}
/**
* Where a zone goes in the search results, lower first; null leaves it out.
* An abbreviation ("CET") or an exact offset ("GMT+1") finds its zones
* first; anything the words match comes after. Without this, "GMT+1" listed
* GMT+11 and GMT+10 before GMT+01:00, and "CET" found nothing.
*/
export function timeZoneSearchRank(
query: string,
zone: { value: string; offsetMinutes?: number },
wordsMatch: boolean,
): number | null {
const typed = query.trim();
const zones = TIME_ZONE_ABBREVIATIONS[typed.toUpperCase()];
if (zones?.includes(zone.value)) return 0;
const offset = parseTypedOffset(typed);
if (offset !== null && zone.offsetMinutes !== undefined) {
if (zone.offsetMinutes === offset) return 0;
// "+1" also finds "+10", but after the zones at +01:00.
return wordsMatch ? 2 : null;
}
return wordsMatch ? 1 : null;
}
+84
View File
@@ -174,6 +174,7 @@
},
"clear_search": "مسح البحث",
"vacation_active": "الرد التلقائي أثناء الغياب مفعّل",
"vacation_turn_off": "إيقاف",
"demo_banner": "وضع تجريبي",
"demo_reset": "إعادة تعيين",
"demo_tour": "جولة",
@@ -1405,6 +1406,8 @@
"from_description": "اتركه فارغًا للبدء فورًا.",
"until_label": "حتى (اختياري)",
"until_description": "اتركه فارغًا لمواصلة الرد حتى توقفه.",
"from_placeholder": "فورًا",
"until_placeholder": "حتى توقفه",
"audience_all": "الجميع",
"audience_internal": "مؤسستي فقط ({domains})",
"audience_external": "من هم خارج مؤسستي فقط",
@@ -2925,6 +2928,7 @@
"open_sieve_editor": "فتح محرر Sieve الخام",
"fetch_error": "فشل تحميل المرشحات",
"expanded_view": "عرض موسّع",
"show_rule_details": "عرض شروط كل قاعدة وإجراءاتها",
"expanded_view_description": "إظهار قواعد المرشح مع كتل الشروط والإجراءات المفصّلة",
"if": "إذا",
"then": "عندئذٍ",
@@ -3003,6 +3007,9 @@
"validation_period_empty": "أدخل تاريخ بدء أو انتهاء للفترة",
"validation_period_invalid": "أدخل تاريخًا صالحًا مع الوقت للفترة، أو اترك الحقل فارغًا",
"apply_existing_period": "القاعدة ذات الفترة الزمنية تعمل فقط على الرسائل التي تصل خلالها، لذا لا يمكن تطبيقها على الرسائل الموجودة.",
"apply_existing_in": "طبّقها أيضًا على الرسائل الموجودة في {folder}",
"apply_existing_count": "{count, plural, zero {طبّقها أيضًا على الرسائل الموجودة في {folder}} one {طبّقها أيضًا على الرسالة الموجودة في {folder}} two {طبّقها أيضًا على الرسالتين الموجودتين في {folder}} few {طبّقها أيضًا على # رسائل موجودة في {folder}} many {طبّقها أيضًا على # رسالة موجودة في {folder}} other {طبّقها أيضًا على # رسالة موجودة في {folder}}}",
"apply_existing_none": "لا توجد رسالة في {folder} تطابق هذه القاعدة بعد.",
"period_status_scheduled": "مجدولة",
"period_status_active": "نشطة",
"period_status_expired": "منتهية"
@@ -3510,6 +3517,48 @@
"description": "تسجيل الخروج من هذا الحساب على هذا الجهاز."
}
},
"uxp2_settings": {
"search": {
"tray": "شريط النظام منطقة الإشعارات الخلفية التصغير إلى شريط النظام tray",
"autostart": "التشغيل التلقائي بدء التشغيل التشغيل مع Windows عند تسجيل الدخول",
"default_app": "تطبيق البريد الافتراضي برنامج البريد الافتراضي mailto روابط البريد",
"menu_bar": "شريط القوائم القوائم مفتاح Alt ملف تحرير عرض",
"updates": "التحديثات تحديث إصدار جديد بيتا قناة",
"devtools": "أدوات المطور وحدة التحكم فحص F12 تصحيح الأخطاء devtools",
"taskbar": "شريط المهام وميض تنبيه Dock",
"quiet_hours": "ساعات الهدوء عدم الإزعاج صامت كتم ليلي",
"telemetry": "معلومات الاستخدام القياس عن بعد إحصاءات تحليلات",
"crash": "تقارير الأعطال تقارير الأخطاء تعطل",
"threads": "سلاسل المحادثات محادثة سلسلة threads",
"block_images": "حظر الصور الصور الخارجية تحميل الصور",
"dark_mode": "الوضع الداكن الوضع الفاتح الوضع الليلي dark mode",
"timezone": "المنطقة الزمنية التوقيت timezone",
"reading_pane": "جزء القراءة جزء المعاينة عرض مقسم",
"week_start": "بداية الأسبوع أول يوم الاثنين الأحد السبت",
"empty_trash": "إفراغ سلة المهملات المهملات العناصر المحذوفة",
"do_not_disturb": "ساعات الهدوء كتم صمت"
},
"desktop_notifications": {
"title": "على هذا الكمبيوتر",
"closed_label": "عند إغلاق النافذة",
"closed_description": "يخبرك {appName} بالبريد الجديد ما دام يعمل. ليبقى يعمل في شريط النظام بعد إغلاق نافذته، فعّل ذلك في {page}."
},
"push_unsupported": "لا يستطيع هذا المتصفح عرض الإشعارات عندما يكون الموقع مغلقًا.",
"backup_description_app": "استخدم الإعدادات نفسها على أجهزتك الأخرى.",
"sender_favicons": {
"label": "صور من مواقع الشركات",
"description": "إظهار أيقونات المواقع كصور شخصية للمرسلين من الشركات"
},
"privacy_push": {
"label": "مرحّل الإشعارات",
"description": "تمر الإشعارات أثناء إغلاق {appName} عبر مرحّل دفع لا يرى بريدك أبدًا. تشغّلها أو توقفها في صفحة {page}."
},
"reset": {
"message": "أعد جميع الإعدادات إلى ما كانت عليه في البداية. يبقى بريدك وعوامل التصفية والرد التلقائي والتواقيع كما هي.",
"sync_on": "تستخدم أجهزتك الأخرى الإعدادات نفسها، لذا ستتغير هي أيضًا.",
"sync_off": "تحتفظ أجهزتك الأخرى بإعداداتها."
}
},
"setup": {
"title": "إعداد Bulwark Webmail",
"subtitle": "اضبط نسخة البريد الإلكتروني على الويب من المتصفح.",
@@ -7329,6 +7378,39 @@
"app_lock_timeout_hours": "{count, plural, zero {# ساعة} one {ساعة واحدة} two {ساعتان} few {# ساعات} many {# ساعة} other {# ساعة}}",
"desktop_attention_title": "على هذا الكمبيوتر"
},
"uxs2_ident": {
"your_name": {
"label": "اسمك",
"description": "يرى الناس هذا الاسم بجانب عنوانك عندما ترسل إليهم رسالة.",
"saved": "تم حفظ اسمك.",
"error": "تعذّر حفظ اسمك. حاول مرة أخرى."
},
"addresses": {
"description": "العناوين التي يمكنك الإرسال منها، لكل منها اسمه وتوقيعه.",
"label": "عناوينك",
"count": "{count, plural, =0 {لا توجد عناوين بعد} zero {# عنوان} one {عنوان واحد} two {عنوانان} few {# عناوين} many {# عنوانًا} other {# عنوان}}",
"manage": "تعديل العناوين…",
"modal_title": "عناوين بريدك الإلكتروني",
"add": "إضافة عنوان",
"edit": "تعديل العنوان",
"added": "تمت إضافة العنوان.",
"saved": "تم حفظ العنوان.",
"deleted": "تم حذف العنوان.",
"delete_title": "حذف هذا العنوان؟"
},
"signatures": {
"title": "التوقيعات",
"description": "يُضاف في نهاية الرسائل التي تكتبها. لكل عنوان توقيعه الخاص.",
"saved": "تم حفظ التوقيع.",
"error": "تعذّر حفظ التوقيع. حاول مرة أخرى.",
"no_addresses": "لا يوجد عنوان للإرسال منه بعد."
},
"search": {
"addresses": "هوية هويات اسم مستعار مرسل عنوان المرسل عنوان من identity alias sender",
"signatures": "توقيع توقيع البريد تذييل هوية مرسل عنوان من signature",
"your_name": "الاسم المعروض الاسم الكامل اسم المرسل display name"
}
},
"desktop_sign_in": {
"server_needed": "أدخل عنوان الخادم أولًا.",
"server_declined": "لم تسمح بهذا الخادم، لذا لم يُرسل إليه أي شيء.",
@@ -7371,10 +7453,12 @@
"server_looking": "جارٍ البحث عن خادم بريدك…",
"server_found": "خادم البريد: {server}",
"server_change": "تغيير",
"server_change_hint": "اكتب عنوان خادم آخر.",
"server_not_found": "لم نعثر على خادم البريد لهذا العنوان. أدخله هنا، مثل mail.example.com.",
"server_needed": "أدخل عنوان خادم بريدك.",
"server_invalid": "هذا ليس عنوان خادم. اكتبه بهذا الشكل: mail.example.com.",
"email_needed": "أدخل عنوان بريدك الإلكتروني أولاً.",
"other_ways": "طرق أخرى لتسجيل الدخول",
"trouble": "هل تواجه مشكلة؟",
"trouble_cors": "يجب أن يسمح خادم بريدك بتسجيل الدخول من هذا الموقع. إذا فشل تسجيل الدخول، فاطلب من مسؤول خادم بريدك السماح بذلك.",
"provider_unsupported": "لا يوفّر {provider} بروتوكول JMAP الذي يتواصل به Bulwark مع خوادم البريد، لذلك لا يستطيع Bulwark الاتصال به مباشرة بعد."
+84
View File
@@ -174,6 +174,7 @@
},
"clear_search": "Neteja la cerca",
"vacation_active": "La resposta automàtica està activa",
"vacation_turn_off": "Desactiva",
"demo_banner": "Mode de demostració",
"demo_reset": "Reinicia",
"demo_tour": "Visita guiada",
@@ -1405,6 +1406,8 @@
"from_description": "Deixa-ho buit per començar ara mateix.",
"until_label": "Fins a (opcional)",
"until_description": "Deixa-ho buit per continuar responent fins que ho desactivis.",
"from_placeholder": "Des d’ara",
"until_placeholder": "Fins que el desactiveu",
"audience_all": "Tothom",
"audience_internal": "Només la meva organització ({domains})",
"audience_external": "Només persones de fora de la meva organització",
@@ -2925,6 +2928,7 @@
"open_sieve_editor": "Obre l'editor Sieve en brut",
"fetch_error": "No s'han pogut carregar els filtres",
"expanded_view": "Vista ampliada",
"show_rule_details": "Mostra les condicions i les accions de cada regla",
"expanded_view_description": "Mostra les regles de filtre amb blocs detallats de condicions i accions",
"if": "Si",
"then": "Aleshores",
@@ -3003,6 +3007,9 @@
"validation_period_empty": "Introdueix una data d'inici o de finalització per al període",
"validation_period_invalid": "Introdueix una data vàlida amb l'hora per al període, o deixa el camp buit",
"apply_existing_period": "Una regla amb període només actua sobre els missatges que arriben dins d'aquest període, de manera que no es pot aplicar als missatges existents.",
"apply_existing_in": "Aplica-la també als missatges que ja són a {folder}",
"apply_existing_count": "{count, plural, one {Aplica-la també al # missatge que ja és a {folder}} other {Aplica-la també als # missatges que ja són a {folder}}}",
"apply_existing_none": "Encara cap missatge de {folder} no compleix aquesta regla.",
"period_status_scheduled": "programada",
"period_status_active": "activa",
"period_status_expired": "caducada"
@@ -3510,6 +3517,48 @@
"description": "Tanca la sessió d’aquest compte en aquest dispositiu."
}
},
"uxp2_settings": {
"search": {
"tray": "safata safata del sistema àrea de notificació segon pla minimitzar",
"autostart": "inici automàtic arrencada iniciar amb Windows en iniciar la sessió",
"default_app": "aplicació de correu predeterminada programa de correu mailto enllaços de correu",
"menu_bar": "barra de menús menús tecla Alt Fitxer Edita Visualitza",
"updates": "actualitzacions actualitzar versió nova beta canal",
"devtools": "eines de desenvolupament devtools consola inspeccionar F12 depuració",
"taskbar": "barra de tasques parpelleig Dock atenció",
"quiet_hours": "hores de silenci no molestis silenci mut nit",
"telemetry": "informació d’ús telemetria estadístiques analítica",
"crash": "informes d’error informes de fallada fallada",
"threads": "fils converses conversa agrupar threads",
"block_images": "bloquejar imatges imatges externes carregar imatges",
"dark_mode": "mode fosc mode clar mode nocturn tema fosc",
"timezone": "fus horari zona horària timezone",
"reading_pane": "panell de lectura panell de previsualització vista dividida",
"week_start": "inici de la setmana primer dia dilluns diumenge",
"empty_trash": "buidar la paperera paperera elements suprimits",
"do_not_disturb": "hores de silenci silenci mut"
},
"desktop_notifications": {
"title": "En aquest ordinador",
"closed_label": "Amb la finestra tancada",
"closed_description": "{appName} us avisa del correu nou mentre s’executa. Perquè continuï a la safata quan tanqueu la finestra, activeu-ho a {page}."
},
"push_unsupported": "Aquest navegador no pot mostrar notificacions quan el lloc web està tancat.",
"backup_description_app": "Feu servir la mateixa configuració als altres dispositius.",
"sender_favicons": {
"label": "Imatges dels llocs web de les empreses",
"description": "Mostra icones de llocs web com a imatges de perfil per als remitents d'empresa"
},
"privacy_push": {
"label": "Repetidor de notificacions",
"description": "Les notificacions amb {appName} tancat passen per un repetidor push, que mai no veu el vostre correu. Les activeu o desactiveu a la pàgina {page}."
},
"reset": {
"message": "Torna tota la configuració a com era al principi. El correu, els filtres, la resposta automàtica i les signatures es queden com estan.",
"sync_on": "Els altres dispositius fan servir la mateixa configuració, així que també canvien.",
"sync_off": "Els altres dispositius mantenen la seva configuració."
}
},
"setup": {
"title": "Configuració de Bulwark Webmail",
"subtitle": "Configureu la vostra instància de correu web des del navegador.",
@@ -7329,6 +7378,39 @@
"app_lock_timeout_hours": "{count, plural, one {# hora} other {# hores}}",
"desktop_attention_title": "En aquest ordinador"
},
"uxs2_ident": {
"your_name": {
"label": "El vostre nom",
"description": "La gent veu aquest nom al costat de la vostra adreça quan els envieu correu.",
"saved": "S'ha desat el vostre nom.",
"error": "No s'ha pogut desar el vostre nom. Torneu-ho a provar."
},
"addresses": {
"description": "Les adreces des de les quals podeu enviar, cadascuna amb el seu nom i la seva signatura.",
"label": "Les vostres adreces",
"count": "{count, plural, =0 {Encara no hi ha adreces} one {# adreça} other {# adreces}}",
"manage": "Edita les adreces…",
"modal_title": "Les vostres adreces de correu",
"add": "Afegeix una adreça",
"edit": "Edita l'adreça",
"added": "S'ha afegit l'adreça.",
"saved": "S'ha desat l'adreça.",
"deleted": "S'ha suprimit l'adreça.",
"delete_title": "Voleu suprimir aquesta adreça?"
},
"signatures": {
"title": "Signatures",
"description": "S'afegeix al final dels missatges que escriviu. Cada adreça té la seva.",
"saved": "S'ha desat la signatura.",
"error": "No s'ha pogut desar la signatura. Torneu-ho a provar.",
"no_addresses": "Encara no teniu cap adreça per enviar."
},
"search": {
"addresses": "identitat identitats àlies remitent adreça del remitent adreça de identity alias sender",
"signatures": "signatura signatura de correu peu identitat remitent adreça de signature",
"your_name": "nom visible nom complet nom del remitent display name"
}
},
"desktop_sign_in": {
"server_needed": "Primer escriu l'adreça del servidor.",
"server_declined": "No has permès aquest servidor, així que no s'hi ha enviat res.",
@@ -7371,10 +7453,12 @@
"server_looking": "S'està cercant el teu servidor de correu…",
"server_found": "Servidor de correu: {server}",
"server_change": "Canvia",
"server_change_hint": "Escriviu una altra adreça de servidor.",
"server_not_found": "No hem trobat el servidor de correu d'aquesta adreça. Escriu-lo aquí, per exemple mail.example.com.",
"server_needed": "Escriu l'adreça del teu servidor de correu.",
"server_invalid": "Això no és l'adreça d'un servidor. Escriu-la com mail.example.com.",
"email_needed": "Primer escriu la teva adreça electrònica.",
"other_ways": "Altres maneres d'iniciar la sessió",
"trouble": "Tens problemes?",
"trouble_cors": "El teu servidor de correu ha de permetre iniciar sessió des d'aquest lloc web. Si no funciona, demana a qui gestiona el servidor que ho permeti.",
"provider_unsupported": "{provider} no ofereix JMAP, la manera com Bulwark parla amb els servidors de correu, així que Bulwark encara no s'hi pot connectar directament."
+84
View File
@@ -174,6 +174,7 @@
},
"clear_search": "Vymazat hledání",
"vacation_active": "Automatická odpověď je aktivní",
"vacation_turn_off": "Vypnout",
"demo_banner": "Demo režim",
"demo_reset": "Resetovat",
"demo_tour": "Průvodce",
@@ -1405,6 +1406,8 @@
"from_description": "Nechte prázdné, aby to začalo hned.",
"until_label": "Do (nepovinné)",
"until_description": "Nechte prázdné, aby se odpovídalo, dokud to nevypnete.",
"from_placeholder": "Hned",
"until_placeholder": "Dokud ji nevypnete",
"audience_all": "Všem",
"audience_internal": "Jen mé organizaci ({domains})",
"audience_external": "Jen lidem mimo mou organizaci",
@@ -2918,6 +2921,7 @@
"open_sieve_editor": "Otevřít textový editor Sieve",
"fetch_error": "Nepodařilo se načíst filtry",
"expanded_view": "Rozšířené zobrazení",
"show_rule_details": "Zobrazit podmínky a akce každého pravidla",
"expanded_view_description": "Zobrazovat pravidla filtrů s podrobnými bloky podmínek a akcí",
"if": "Pokud",
"then": "Pak",
@@ -2996,6 +3000,9 @@
"validation_period_empty": "Zadejte datum zahájení nebo ukončení období",
"validation_period_invalid": "Zadejte pro období platné datum včetně času, nebo pole nechte prázdné",
"apply_existing_period": "Pravidlo s obdobím působí jen na zprávy, které v něm přijdou, a proto ho nelze použít na existující zprávy.",
"apply_existing_in": "Použít i na zprávy, které už jsou ve složce {folder}",
"apply_existing_count": "{count, plural, one {Použít i na # zprávu, která už je ve složce {folder}} few {Použít i na # zprávy, které už jsou ve složce {folder}} many {Použít i na # zprávy, které už jsou ve složce {folder}} other {Použít i na # zpráv, které už jsou ve složce {folder}}}",
"apply_existing_none": "Tomuto pravidlu zatím neodpovídá žádná zpráva ve složce {folder}.",
"period_status_scheduled": "naplánováno",
"period_status_active": "aktivní",
"period_status_expired": "vypršelo"
@@ -3510,6 +3517,48 @@
"description": "Odhlásit tento účet na tomto zařízení."
}
},
"uxp2_settings": {
"search": {
"tray": "oznamovací oblast systémová lišta tray na pozadí minimalizovat",
"autostart": "automatické spuštění po startu spustit s Windows při přihlášení",
"default_app": "výchozí poštovní aplikace výchozí e-mailový program mailto odkazy na e-mail",
"menu_bar": "panel nabídek nabídka klávesa Alt Soubor Úpravy Zobrazení",
"updates": "aktualizace nová verze beta kanál",
"devtools": "nástroje pro vývojáře devtools konzole prozkoumat F12 ladění",
"taskbar": "hlavní panel blikání Dock pozornost",
"quiet_hours": "tiché hodiny nerušit ztlumit noc",
"telemetry": "údaje o používání telemetrie statistiky analytika",
"crash": "hlášení o pádu chybová hlášení pád",
"threads": "vlákna konverzace seskupit threads",
"block_images": "blokovat obrázky externí obrázky načítat obrázky",
"dark_mode": "tmavý režim světlý režim noční režim dark mode",
"timezone": "časové pásmo timezone",
"reading_pane": "podokno čtení náhled rozdělené zobrazení",
"week_start": "začátek týdne první den pondělí neděle",
"empty_trash": "vysypat koš koš smazané položky",
"do_not_disturb": "tiché hodiny ztlumit ticho"
},
"desktop_notifications": {
"title": "Na tomto počítači",
"closed_label": "Při zavřeném okně",
"closed_description": "{appName} vás upozorní na novou poštu, dokud běží. Aby po zavření okna běžel dál v oznamovací oblasti, zapněte to v části {page}."
},
"push_unsupported": "Tento prohlížeč nemůže zobrazovat oznámení, když je web zavřený.",
"backup_description_app": "Používejte stejná nastavení na svých dalších zařízeních.",
"sender_favicons": {
"label": "Obrázky z webů firem",
"description": "Zobrazovat ikony webových stránek jako profilové obrázky firemních odesílatelů"
},
"privacy_push": {
"label": "Relay pro oznámení",
"description": "Oznámení při zavřeném {appName} jdou přes push relay, který nikdy nevidí vaši poštu. Zapínáte a vypínáte je na stránce {page}."
},
"reset": {
"message": "Vrátí všechna nastavení do původního stavu. Vaše pošta, filtry, automatická odpověď a podpisy zůstanou, jak jsou.",
"sync_on": "Vaše další zařízení používají stejná nastavení, takže se změní také.",
"sync_off": "Vaše další zařízení si nastavení ponechají."
}
},
"setup": {
"title": "Nastavení Bulwark Webmail",
"subtitle": "Nastavte svou instanci webmailu v prohlížeči.",
@@ -7329,6 +7378,39 @@
"app_lock_timeout_hours": "{count, plural, one {# hodina} few {# hodiny} many {# hodiny} other {# hodin}}",
"desktop_attention_title": "Na tomto počítači"
},
"uxs2_ident": {
"your_name": {
"label": "Vaše jméno",
"description": "Toto jméno lidé vidí vedle vaší adresy, když jim pošlete e-mail.",
"saved": "Vaše jméno je uloženo.",
"error": "Vaše jméno se nepodařilo uložit. Zkuste to znovu."
},
"addresses": {
"description": "Adresy, ze kterých můžete odesílat, každá s vlastním jménem a podpisem.",
"label": "Vaše adresy",
"count": "{count, plural, =0 {Zatím žádné adresy} one {# adresa} few {# adresy} many {# adresy} other {# adres}}",
"manage": "Upravit adresy…",
"modal_title": "Vaše e-mailové adresy",
"add": "Přidat adresu",
"edit": "Upravit adresu",
"added": "Adresa přidána.",
"saved": "Adresa uložena.",
"deleted": "Adresa smazána.",
"delete_title": "Smazat tuto adresu?"
},
"signatures": {
"title": "Podpisy",
"description": "Přidá se na konec zpráv, které píšete. Každá adresa má svůj vlastní.",
"saved": "Podpis uložen.",
"error": "Podpis se nepodařilo uložit. Zkuste to znovu.",
"no_addresses": "Zatím nemáte adresu, ze které lze odesílat."
},
"search": {
"addresses": "identita identity alias aliasy odesílatel adresa odesílatele adresa od identity alias sender",
"signatures": "podpis e-mailový podpis zápatí identita odesílatel adresa od signature",
"your_name": "zobrazované jméno celé jméno jméno odesílatele display name"
}
},
"desktop_sign_in": {
"server_needed": "Nejdřív zadejte adresu serveru.",
"server_declined": "Tento server jste nepovolili, proto se na něj nic neodeslalo.",
@@ -7371,10 +7453,12 @@
"server_looking": "Hledáme váš poštovní server…",
"server_found": "Poštovní server: {server}",
"server_change": "Změnit",
"server_change_hint": "Zadejte adresu jiného serveru.",
"server_not_found": "Pro tuto adresu jsme poštovní server nenašli. Zadejte ho sem, například mail.example.com.",
"server_needed": "Zadejte adresu svého poštovního serveru.",
"server_invalid": "Tohle není adresa serveru. Napište ji jako mail.example.com.",
"email_needed": "Nejdřív zadejte svou e-mailovou adresu.",
"other_ways": "Další způsoby přihlášení",
"trouble": "Nedaří se to?",
"trouble_cors": "Váš poštovní server musí povolit přihlašování z tohoto webu. Pokud přihlášení selže, požádejte správce poštovního serveru, aby to povolil.",
"provider_unsupported": "{provider} nenabízí JMAP, způsob, jakým Bulwark komunikuje s poštovními servery, takže se k němu Bulwark zatím nemůže připojit přímo."
+84
View File
@@ -174,6 +174,7 @@
},
"clear_search": "Ryd søgning",
"vacation_active": "Fraværsbeskeder er aktive",
"vacation_turn_off": "Slå fra",
"demo_banner": "Demo-tilstand",
"demo_reset": "Nulstil",
"demo_tour": "Rundvisning",
@@ -1405,6 +1406,8 @@
"from_description": "Lad feltet være tomt for at starte med det samme.",
"until_label": "Til (valgfrit)",
"until_description": "Lad feltet være tomt for at blive ved med at svare, til du slår det fra.",
"from_placeholder": "Med det samme",
"until_placeholder": "Indtil du slår det fra",
"audience_all": "Alle",
"audience_internal": "Kun min organisation ({domains})",
"audience_external": "Kun folk uden for min organisation",
@@ -2921,6 +2924,7 @@
"open_sieve_editor": "Åbn raw Sieve-editor",
"fetch_error": "Kunne ikke indlæse filtre",
"expanded_view": "Udvidet visning",
"show_rule_details": "Vis hver regels betingelser og handlinger",
"expanded_view_description": "Vis filterregler med detaljerede betingelses- og handlingsblokke",
"if": "Hvis",
"then": "Så",
@@ -2999,6 +3003,9 @@
"validation_period_empty": "Angiv en start- eller slutdato for perioden",
"validation_period_invalid": "Angiv en gyldig dato med klokkeslæt for perioden, eller lad feltet stå tomt",
"apply_existing_period": "En regel med en periode virker kun på beskeder, der ankommer i perioden, og kan derfor ikke køres på eksisterende beskeder.",
"apply_existing_in": "Anvend også på beskederne, der allerede er i {folder}",
"apply_existing_count": "{count, plural, one {Anvend også på den # besked, der allerede er i {folder}} other {Anvend også på de # beskeder, der allerede er i {folder}}}",
"apply_existing_none": "Ingen beskeder i {folder} passer til reglen endnu.",
"period_status_scheduled": "planlagt",
"period_status_active": "aktiv",
"period_status_expired": "udløbet"
@@ -3510,6 +3517,48 @@
"description": "Log ud af denne konto på denne enhed."
}
},
"uxp2_settings": {
"search": {
"tray": "systembakke meddelelsesområde baggrund minimer til bakke tray",
"autostart": "autostart opstart start med Windows ved login",
"default_app": "standardmailapp standard e-mailprogram mailto mail-links",
"menu_bar": "menulinje menuer Alt-tast Filer Rediger Vis",
"updates": "opdateringer opdatering ny version beta kanal",
"devtools": "udviklerværktøjer devtools konsol inspicer F12 fejlfinding",
"taskbar": "proceslinje blink Dock opmærksomhed",
"quiet_hours": "stille timer forstyr ikke lydløs nat",
"telemetry": "brugsdata telemetri statistik analyse",
"crash": "nedbrudsrapporter fejlrapporter nedbrud",
"threads": "tråde samtaler samtalevisning threads",
"block_images": "bloker billeder eksterne billeder indlæs billeder",
"dark_mode": "mørk tilstand lys tilstand nattilstand dark mode",
"timezone": "tidszone timezone",
"reading_pane": "læserude eksempelrude opdelt visning",
"week_start": "ugestart første ugedag mandag søndag",
"empty_trash": "tøm papirkurven papirkurv slettede elementer",
"do_not_disturb": "stille timer lydløs stilhed"
},
"desktop_notifications": {
"title": "På denne computer",
"closed_label": "Når vinduet er lukket",
"closed_description": "{appName} giver besked om ny mail, så længe den kører. Slå det til under {page}, hvis den skal køre videre i systembakken, når du lukker vinduet."
},
"push_unsupported": "Denne browser kan ikke vise notifikationer, når websiden er lukket.",
"backup_description_app": "Brug de samme indstillinger på dine andre enheder.",
"sender_favicons": {
"label": "Billeder fra virksomheders websteder",
"description": "Vis hjemmesideikoner som profilbilleder for forretningsafsendere"
},
"privacy_push": {
"label": "Relæ til notifikationer",
"description": "Notifikationer mens {appName} er lukket går gennem et push-relæ, som aldrig ser din mail. Du slår dem til og fra på siden {page}."
},
"reset": {
"message": "Sæt alle indstillinger tilbage til, hvordan de var fra start. Din mail, dine filtre, dit autosvar og dine signaturer forbliver, som de er.",
"sync_on": "Dine andre enheder bruger de samme indstillinger, så de ændres også.",
"sync_off": "Dine andre enheder beholder deres indstillinger."
}
},
"setup": {
"title": "Opsætning af Bulwark Webmail",
"subtitle": "Opsæt din webmailinstans i browseren.",
@@ -7329,6 +7378,39 @@
"app_lock_timeout_hours": "{count, plural, one {# time} other {# timer}}",
"desktop_attention_title": "På denne computer"
},
"uxs2_ident": {
"your_name": {
"label": "Dit navn",
"description": "Folk ser dette navn ved siden af din adresse, når du sender dem mail.",
"saved": "Dit navn er gemt.",
"error": "Dit navn kunne ikke gemmes. Prøv igen."
},
"addresses": {
"description": "De adresser, du kan sende fra, hver med sit eget navn og sin egen signatur.",
"label": "Dine adresser",
"count": "{count, plural, =0 {Ingen adresser endnu} one {# adresse} other {# adresser}}",
"manage": "Rediger adresser…",
"modal_title": "Dine e-mailadresser",
"add": "Tilføj adresse",
"edit": "Rediger adresse",
"added": "Adressen er tilføjet.",
"saved": "Adressen er gemt.",
"deleted": "Adressen er slettet.",
"delete_title": "Slet denne adresse?"
},
"signatures": {
"title": "Signaturer",
"description": "Tilføjes i slutningen af de beskeder, du skriver. Hver adresse har sin egen.",
"saved": "Signaturen er gemt.",
"error": "Signaturen kunne ikke gemmes. Prøv igen.",
"no_addresses": "Der er endnu ingen adresse at sende fra."
},
"search": {
"addresses": "identitet identiteter alias afsender afsenderadresse fra-adresse identity alias sender",
"signatures": "signatur e-mailsignatur sidefod identitet afsender fra-adresse signature",
"your_name": "visningsnavn fulde navn afsendernavn display name"
}
},
"desktop_sign_in": {
"server_needed": "Angiv først serverens adresse.",
"server_declined": "Du tillod ikke denne server, så der blev ikke sendt noget til den.",
@@ -7371,10 +7453,12 @@
"server_looking": "Leder efter din mailserver…",
"server_found": "Mailserver: {server}",
"server_change": "Skift",
"server_change_hint": "Skriv en anden serveradresse.",
"server_not_found": "Vi kunne ikke finde mailserveren til denne adresse. Skriv den her, for eksempel mail.example.com.",
"server_needed": "Skriv adressen på din mailserver.",
"server_invalid": "Det er ikke en serveradresse. Skriv den som mail.example.com.",
"email_needed": "Skriv først din e-mailadresse.",
"other_ways": "Andre måder at logge ind på",
"trouble": "Problemer?",
"trouble_cors": "Din mailserver skal tillade login fra dette websted. Hvis login mislykkes, så bed den, der driver din mailserver, om at tillade det.",
"provider_unsupported": "{provider} tilbyder ikke JMAP, den måde Bulwark taler med mailservere på, så Bulwark kan endnu ikke forbinde direkte til det."
+84
View File
@@ -174,6 +174,7 @@
},
"clear_search": "Suche löschen",
"vacation_active": "Abwesenheitsnotiz ist aktiv",
"vacation_turn_off": "Ausschalten",
"demo_banner": "Demo-Modus",
"demo_reset": "Zurücksetzen",
"demo_tour": "Tour",
@@ -1405,6 +1406,8 @@
"from_description": "Leer lassen, um sofort zu beginnen.",
"until_label": "Bis (optional)",
"until_description": "Leer lassen, um weiter zu antworten, bis Sie es ausschalten.",
"from_placeholder": "Ab sofort",
"until_placeholder": "Bis Sie sie ausschalten",
"audience_all": "Allen",
"audience_internal": "Nur meiner Organisation ({domains})",
"audience_external": "Nur Leuten außerhalb meiner Organisation",
@@ -2918,6 +2921,7 @@
"open_sieve_editor": "Sieve-Skript-Editor öffnen",
"fetch_error": "Filter konnten nicht geladen werden",
"expanded_view": "Erweiterte Ansicht",
"show_rule_details": "Bedingungen und Aktionen jeder Regel anzeigen",
"expanded_view_description": "Filterregeln mit detaillierten Bedingungs- und Aktionsblöcken anzeigen",
"if": "Wenn",
"then": "Dann",
@@ -2996,6 +3000,9 @@
"validation_period_empty": "Bitte ein Start- oder Enddatum für den Zeitraum angeben",
"validation_period_invalid": "Bitte für den Zeitraum ein gültiges Datum mit Uhrzeit angeben oder das Feld leer lassen",
"apply_existing_period": "Eine Regel mit Zeitraum wirkt nur auf Nachrichten, die in diesem Zeitraum eintreffen, und lässt sich daher nicht auf vorhandene Nachrichten anwenden.",
"apply_existing_in": "Auch auf die Nachrichten anwenden, die schon in {folder} sind",
"apply_existing_count": "{count, plural, one {Auch auf die # Nachricht anwenden, die schon in {folder} ist} other {Auch auf die # Nachrichten anwenden, die schon in {folder} sind}}",
"apply_existing_none": "Noch keine Nachricht in {folder} passt zu dieser Regel.",
"period_status_scheduled": "geplant",
"period_status_active": "aktiv",
"period_status_expired": "abgelaufen"
@@ -3510,6 +3517,48 @@
"description": "Von diesem Konto auf diesem Gerät abmelden."
}
},
"uxp2_settings": {
"search": {
"tray": "Infobereich Taskleiste Tray Hintergrund im Hintergrund weiterlaufen minimieren",
"autostart": "Autostart Systemstart mit Windows starten beim Anmelden starten automatisch starten",
"default_app": "Standard-Mail-App Standard-E-Mail-Programm mailto E-Mail-Links Standardprogramm",
"menu_bar": "Menüleiste Menü Alt-Taste Datei Bearbeiten Ansicht",
"updates": "Updates Aktualisierung neue Version Beta Kanal",
"devtools": "Entwicklertools Devtools Konsole untersuchen F12 Fehlersuche",
"taskbar": "Taskleiste blinken aufblinken Dock hüpfen Aufmerksamkeit",
"quiet_hours": "Ruhezeiten nicht stören stumm lautlos Nacht",
"telemetry": "Nutzungsdaten Telemetrie Statistik Analyse",
"crash": "Absturzberichte Fehlerberichte Absturz",
"threads": "Threads Unterhaltungen Konversation gruppieren Verlauf",
"block_images": "Bilder blockieren externe Bilder Bilder laden",
"dark_mode": "Dunkelmodus dunkler Modus heller Modus Nachtmodus dark mode",
"timezone": "Zeitzone timezone",
"reading_pane": "Lesebereich Vorschaufenster Vorschau geteilte Ansicht",
"week_start": "Wochenbeginn Wochenanfang Montag Sonntag",
"empty_trash": "Papierkorb leeren Papierkorb gelöschte Elemente",
"do_not_disturb": "Ruhezeiten stumm Stille"
},
"desktop_notifications": {
"title": "Auf diesem Computer",
"closed_label": "Bei geschlossenem Fenster",
"closed_description": "{appName} meldet neue E-Mails, solange es läuft. Damit es nach dem Schließen des Fensters im Infobereich weiterläuft, schalten Sie das unter {page} ein."
},
"push_unsupported": "Dieser Browser kann keine Benachrichtigungen zeigen, wenn die Website geschlossen ist.",
"backup_description_app": "Dieselben Einstellungen auf Ihren anderen Geräten nutzen.",
"sender_favicons": {
"label": "Bilder von Firmen-Websites",
"description": "Bei Firmen das Symbol ihrer Website als Bild des Absenders zeigen"
},
"privacy_push": {
"label": "Relay für Benachrichtigungen",
"description": "Benachrichtigungen bei geschlossenem {appName} laufen über ein Push-Relay, das Ihre E-Mails nie sieht. Sie schalten sie auf der Seite {page} ein und aus."
},
"reset": {
"message": "Alle Einstellungen auf den Anfangszustand zurücksetzen. Ihre E-Mails, Filter, Abwesenheitsnotiz und Signaturen bleiben, wie sie sind.",
"sync_on": "Ihre anderen Geräte nutzen dieselben Einstellungen, also ändern sie sich auch.",
"sync_off": "Ihre anderen Geräte behalten ihre Einstellungen."
}
},
"setup": {
"title": "Einrichtung von Bulwark Webmail",
"subtitle": "Richten Sie Ihre Webmail-Instanz im Browser ein.",
@@ -7329,6 +7378,39 @@
"app_lock_timeout_hours": "{count, plural, one {# Stunde} other {# Stunden}}",
"desktop_attention_title": "Auf diesem Computer"
},
"uxs2_ident": {
"your_name": {
"label": "Ihr Name",
"description": "Diesen Namen sehen andere neben Ihrer Adresse, wenn Sie ihnen schreiben.",
"saved": "Ihr Name ist gespeichert.",
"error": "Ihr Name konnte nicht gespeichert werden. Versuchen Sie es noch einmal."
},
"addresses": {
"description": "Die Adressen, von denen Sie senden können, jede mit eigenem Namen und eigener Signatur.",
"label": "Ihre Adressen",
"count": "{count, plural, =0 {Noch keine Adressen} one {# Adresse} other {# Adressen}}",
"manage": "Adressen bearbeiten…",
"modal_title": "Ihre E-Mail-Adressen",
"add": "Adresse hinzufügen",
"edit": "Adresse bearbeiten",
"added": "Adresse hinzugefügt.",
"saved": "Adresse gespeichert.",
"deleted": "Adresse gelöscht.",
"delete_title": "Diese Adresse löschen?"
},
"signatures": {
"title": "Signaturen",
"description": "Steht am Ende der Nachrichten, die Sie schreiben. Jede Adresse hat ihre eigene.",
"saved": "Signatur gespeichert.",
"error": "Die Signatur konnte nicht gespeichert werden. Versuchen Sie es noch einmal.",
"no_addresses": "Es gibt noch keine Adresse zum Senden."
},
"search": {
"addresses": "Identität Identitäten Alias Aliasse Absender Absenderadresse Von-Adresse Absendername identity alias sender",
"signatures": "Signatur E-Mail-Signatur Grußformel Fußzeile Identität Absender Von-Adresse signature",
"your_name": "Anzeigename vollständiger Name Absendername display name"
}
},
"desktop_sign_in": {
"server_needed": "Geben Sie zuerst die Adresse Ihres Servers ein.",
"server_declined": "Sie haben diesen Server nicht zugelassen, daher wurde nichts an ihn gesendet.",
@@ -7371,10 +7453,12 @@
"server_looking": "Ihr Mailserver wird gesucht…",
"server_found": "Mailserver: {server}",
"server_change": "Ändern",
"server_change_hint": "Geben Sie eine andere Serveradresse ein.",
"server_not_found": "Für diese Adresse wurde kein Mailserver gefunden. Geben Sie ihn hier ein, zum Beispiel mail.example.com.",
"server_needed": "Geben Sie die Adresse Ihres Mailservers ein.",
"server_invalid": "Das ist keine Serveradresse. Schreiben Sie sie wie mail.example.com.",
"email_needed": "Geben Sie zuerst Ihre E-Mail-Adresse ein.",
"other_ways": "Andere Anmeldewege",
"trouble": "Probleme?",
"trouble_cors": "Ihr Mailserver muss Anmeldungen von dieser Website erlauben. Wenn die Anmeldung nicht klappt, bitten Sie die Betreiber Ihres Mailservers, das zu erlauben.",
"provider_unsupported": "{provider} bietet kein JMAP an, die Art, wie Bulwark mit Mailservern spricht. Deshalb kann sich Bulwark noch nicht direkt damit verbinden."
+84
View File
@@ -174,6 +174,7 @@
},
"clear_search": "Clear search",
"vacation_active": "Vacation responder is active",
"vacation_turn_off": "Turn off",
"demo_banner": "Demo Mode",
"demo_reset": "Reset",
"demo_tour": "Tour",
@@ -1405,6 +1406,8 @@
"from_description": "Leave it empty to start right away.",
"until_label": "Until (optional)",
"until_description": "Leave it empty to keep replying until you turn it off.",
"from_placeholder": "Right away",
"until_placeholder": "Until you turn it off",
"audience_all": "Everyone",
"audience_internal": "Only my organisation ({domains})",
"audience_external": "Only people outside my organisation",
@@ -2925,6 +2928,7 @@
"open_sieve_editor": "Open raw Sieve editor",
"fetch_error": "Couldn't load the filters",
"expanded_view": "Expanded view",
"show_rule_details": "Show each rule’s conditions and actions",
"expanded_view_description": "Show filter rules with detailed condition and action blocks",
"if": "If",
"then": "Then",
@@ -3003,6 +3007,9 @@
"validation_period_empty": "Enter a start or end date for the period",
"validation_period_invalid": "Enter a valid date with a time for the period, or leave the field empty",
"apply_existing_period": "A rule with a period acts only on messages that arrive within it, so it cannot run on existing messages.",
"apply_existing_in": "Also apply to the messages already in {folder}",
"apply_existing_count": "{count, plural, one {Also apply to the # message already in {folder}} other {Also apply to the # messages already in {folder}}}",
"apply_existing_none": "No message in {folder} matches this rule yet.",
"period_status_scheduled": "scheduled",
"period_status_active": "active",
"period_status_expired": "expired"
@@ -3510,6 +3517,48 @@
"description": "Sign out of this account on this device."
}
},
"uxp2_settings": {
"search": {
"tray": "tray system tray notification area background close to tray minimize to tray",
"autostart": "autostart startup start with Windows launch at login open at login run at startup",
"default_app": "default mail app default email program mailto email links",
"menu_bar": "menu bar menus Alt key File Edit View",
"updates": "updates update new version beta channel",
"devtools": "developer tools devtools console inspect F12 debugging",
"taskbar": "taskbar flash blink Dock bounce attention",
"quiet_hours": "quiet hours do not disturb silent mute night",
"telemetry": "usage info telemetry statistics analytics",
"crash": "crash reports error reports crash dump",
"threads": "threads thread conversation view conversations",
"block_images": "block images remote images load images external images",
"dark_mode": "dark mode light mode night mode dark theme",
"timezone": "timezone time zone",
"reading_pane": "reading pane preview pane split view",
"week_start": "week start start of week Monday Sunday",
"empty_trash": "empty trash trash bin deleted items",
"do_not_disturb": "quiet hours mute silence"
},
"desktop_notifications": {
"title": "On this computer",
"closed_label": "With the window closed",
"closed_description": "{appName} tells you about new mail as long as it runs. To keep it running in the tray after you close its window, turn that on in {page}."
},
"push_unsupported": "This browser can't show notifications when the website is closed.",
"backup_description_app": "Use the same settings on your other devices.",
"sender_favicons": {
"label": "Pictures from company websites",
"description": "For companies, show the icon of their website as the sender's picture"
},
"privacy_push": {
"label": "Relay for notifications",
"description": "Notifications while {appName} is closed go through a push relay, which never sees your mail. You turn them on or off on the {page} page."
},
"reset": {
"message": "Put all settings back to how they were at the start. Your mail, filters, out of office and signatures stay as they are.",
"sync_on": "Your other devices use the same settings, so they change too.",
"sync_off": "Your other devices keep their settings."
}
},
"setup": {
"title": "Bulwark Webmail setup",
"subtitle": "Configure your webmail instance from the browser.",
@@ -7329,6 +7378,39 @@
"app_lock_timeout_hours": "{count, plural, one {# hour} other {# hours}}",
"desktop_attention_title": "On this computer"
},
"uxs2_ident": {
"your_name": {
"label": "Your name",
"description": "People see this name next to your address when you send them mail.",
"saved": "Your name is saved.",
"error": "Your name could not be saved. Try again."
},
"addresses": {
"description": "The addresses you can send from, each with its own name and signature.",
"label": "Your addresses",
"count": "{count, plural, =0 {No addresses yet} one {# address} other {# addresses}}",
"manage": "Edit addresses…",
"modal_title": "Your email addresses",
"add": "Add address",
"edit": "Edit address",
"added": "Address added.",
"saved": "Address saved.",
"deleted": "Address deleted.",
"delete_title": "Delete this address?"
},
"signatures": {
"title": "Signatures",
"description": "Added at the end of the messages you write. Each address has its own.",
"saved": "Signature saved.",
"error": "The signature could not be saved. Try again.",
"no_addresses": "There is no address to send from yet."
},
"search": {
"addresses": "identity identities alias aliases sender sender address from address from name",
"signatures": "signature email signature sign-off footer identity sender from address",
"your_name": "display name full name sender name from name"
}
},
"desktop_sign_in": {
"server_needed": "Enter your server's address first.",
"server_declined": "You didn't allow this server, so nothing was sent to it.",
@@ -7371,10 +7453,12 @@
"server_looking": "Looking for your mail server…",
"server_found": "Mail server: {server}",
"server_change": "Change",
"server_change_hint": "Type another server address.",
"server_not_found": "We couldn't find the mail server for this address. Enter it here, for example mail.example.com.",
"server_needed": "Enter the address of your mail server.",
"server_invalid": "This isn't a server address. Type it like mail.example.com.",
"email_needed": "Enter your email address first.",
"other_ways": "Other ways to sign in",
"trouble": "Having trouble?",
"trouble_cors": "Your mail server has to allow sign-ins from this website. If signing in fails, ask whoever runs your mail server to allow it.",
"provider_unsupported": "{provider} doesn't offer JMAP, the way Bulwark talks to mail servers, so Bulwark can't connect to it directly yet."
+84
View File
@@ -174,6 +174,7 @@
},
"clear_search": "Limpiar búsqueda",
"vacation_active": "Respuesta automática activa",
"vacation_turn_off": "Desactivar",
"demo_banner": "Modo demo",
"demo_reset": "Restablecer",
"demo_tour": "Tour",
@@ -1405,6 +1406,8 @@
"from_description": "Déjalo vacío para empezar ya.",
"until_label": "Hasta (opcional)",
"until_description": "Déjalo vacío para seguir respondiendo hasta que lo desactives.",
"from_placeholder": "Desde ahora",
"until_placeholder": "Hasta que lo desactive",
"audience_all": "Todos",
"audience_internal": "Solo mi organización ({domains})",
"audience_external": "Solo personas de fuera de mi organización",
@@ -2918,6 +2921,7 @@
"open_sieve_editor": "Abrir editor Sieve",
"fetch_error": "Error al cargar los filtros",
"expanded_view": "Vista expandida",
"show_rule_details": "Mostrar las condiciones y acciones de cada regla",
"expanded_view_description": "Mostrar reglas de filtro con bloques detallados de condiciones y acciones",
"if": "Si",
"then": "Entonces",
@@ -2996,6 +3000,9 @@
"validation_period_empty": "Introduce una fecha de inicio o de fin para el período",
"validation_period_invalid": "Introduce una fecha válida con hora para el período o deja el campo vacío",
"apply_existing_period": "Una regla con período solo actúa sobre los mensajes que llegan dentro de él, por lo que no se puede aplicar a los mensajes existentes.",
"apply_existing_in": "Aplicar también a los mensajes que ya están en {folder}",
"apply_existing_count": "{count, plural, one {Aplicar también al # mensaje que ya está en {folder}} other {Aplicar también a los # mensajes que ya están en {folder}}}",
"apply_existing_none": "Ningún mensaje de {folder} coincide todavía con esta regla.",
"period_status_scheduled": "programada",
"period_status_active": "activa",
"period_status_expired": "caducada"
@@ -3510,6 +3517,48 @@
"description": "Cerrar sesión de esta cuenta en este dispositivo."
}
},
"uxp2_settings": {
"search": {
"tray": "bandeja bandeja del sistema área de notificación segundo plano minimizar",
"autostart": "inicio automático arranque iniciar con Windows al iniciar sesión",
"default_app": "aplicación de correo predeterminada programa de correo mailto enlaces de correo",
"menu_bar": "barra de menús menús tecla Alt Archivo Editar Ver",
"updates": "actualizaciones actualizar nueva versión beta canal",
"devtools": "herramientas de desarrollo devtools consola inspeccionar F12 depuración",
"taskbar": "barra de tareas parpadear Dock atención",
"quiet_hours": "horas de silencio no molestar silencio silenciar noche",
"telemetry": "información de uso telemetría estadísticas analítica",
"crash": "informes de errores informes de fallos fallo",
"threads": "hilos conversaciones agrupar threads",
"block_images": "bloquear imágenes imágenes externas cargar imágenes",
"dark_mode": "modo oscuro modo claro modo nocturno tema oscuro",
"timezone": "zona horaria huso horario timezone",
"reading_pane": "panel de lectura panel de vista previa vista dividida",
"week_start": "inicio de semana primer día lunes domingo",
"empty_trash": "vaciar la papelera papelera elementos eliminados",
"do_not_disturb": "horas de silencio silenciar silencio"
},
"desktop_notifications": {
"title": "En este ordenador",
"closed_label": "Con la ventana cerrada",
"closed_description": "{appName} le avisa del correo nuevo mientras está abierto. Para que siga en la bandeja al cerrar la ventana, actívelo en {page}."
},
"push_unsupported": "Este navegador no puede mostrar notificaciones cuando el sitio web está cerrado.",
"backup_description_app": "Use los mismos ajustes en sus otros dispositivos.",
"sender_favicons": {
"label": "Imágenes de los sitios web de empresas",
"description": "Mostrar iconos de sitios web como fotos de perfil para remitentes empresariales"
},
"privacy_push": {
"label": "Relé de notificaciones",
"description": "Las notificaciones con {appName} cerrado pasan por un relé push, que nunca ve su correo. Las activa o desactiva en la página {page}."
},
"reset": {
"message": "Devuelve todos los ajustes a como estaban al principio. Su correo, filtros, respuesta automática y firmas no cambian.",
"sync_on": "Sus otros dispositivos usan los mismos ajustes, así que también cambian.",
"sync_off": "Sus otros dispositivos conservan sus ajustes."
}
},
"setup": {
"title": "Configuración de Bulwark Webmail",
"subtitle": "Configura tu instancia de webmail desde el navegador.",
@@ -7329,6 +7378,39 @@
"app_lock_timeout_hours": "{count, plural, one {# hora} other {# horas}}",
"desktop_attention_title": "En este ordenador"
},
"uxs2_ident": {
"your_name": {
"label": "Tu nombre",
"description": "La gente ve este nombre junto a tu dirección cuando les envías un correo.",
"saved": "Tu nombre se ha guardado.",
"error": "No se ha podido guardar tu nombre. Inténtalo de nuevo."
},
"addresses": {
"description": "Las direcciones desde las que puedes enviar, cada una con su propio nombre y firma.",
"label": "Tus direcciones",
"count": "{count, plural, =0 {Todavía no hay direcciones} one {# dirección} other {# direcciones}}",
"manage": "Editar direcciones…",
"modal_title": "Tus direcciones de correo",
"add": "Añadir dirección",
"edit": "Editar dirección",
"added": "Dirección añadida.",
"saved": "Dirección guardada.",
"deleted": "Dirección eliminada.",
"delete_title": "¿Eliminar esta dirección?"
},
"signatures": {
"title": "Firmas",
"description": "Se añade al final de los mensajes que escribes. Cada dirección tiene la suya.",
"saved": "Firma guardada.",
"error": "No se ha podido guardar la firma. Inténtalo de nuevo.",
"no_addresses": "Todavía no hay ninguna dirección desde la que enviar."
},
"search": {
"addresses": "identidad identidades alias remitente dirección del remitente dirección de envío identity alias sender",
"signatures": "firma firma de correo pie identidad remitente dirección de envío signature",
"your_name": "nombre visible nombre completo nombre del remitente display name"
}
},
"desktop_sign_in": {
"server_needed": "Primero escribe la dirección del servidor.",
"server_declined": "No permitiste este servidor, así que no se le envió nada.",
@@ -7371,10 +7453,12 @@
"server_looking": "Buscando tu servidor de correo…",
"server_found": "Servidor de correo: {server}",
"server_change": "Cambiar",
"server_change_hint": "Escribe otra dirección de servidor.",
"server_not_found": "No encontramos el servidor de correo de esta dirección. Escríbelo aquí, por ejemplo mail.example.com.",
"server_needed": "Escribe la dirección de tu servidor de correo.",
"server_invalid": "Esto no es la dirección de un servidor. Escríbela como mail.example.com.",
"email_needed": "Primero escribe tu dirección de correo.",
"other_ways": "Otras formas de iniciar sesión",
"trouble": "¿Tienes problemas?",
"trouble_cors": "Tu servidor de correo tiene que permitir iniciar sesión desde este sitio web. Si no funciona, pide a quien gestiona tu servidor de correo que lo permita.",
"provider_unsupported": "{provider} no ofrece JMAP, la forma en que Bulwark habla con los servidores de correo, así que Bulwark todavía no puede conectarse directamente."
+84
View File
@@ -174,6 +174,7 @@
},
"clear_search": "Tühjenda otsing",
"vacation_active": "Puhkuse vastaja on aktiivne",
"vacation_turn_off": "Lülita välja",
"demo_banner": "Demorežiim",
"demo_reset": "Lähtesta",
"demo_tour": "Tutvustus",
@@ -1405,6 +1406,8 @@
"from_description": "Jäta tühjaks, et alustada kohe.",
"until_label": "Kuni (valikuline)",
"until_description": "Jäta tühjaks, et vastata, kuni selle välja lülitad.",
"from_placeholder": "Kohe",
"until_placeholder": "Kuni selle välja lülitate",
"audience_all": "Kõigile",
"audience_internal": "Ainult minu organisatsioonile ({domains})",
"audience_external": "Ainult inimestele väljastpoolt minu organisatsiooni",
@@ -2925,6 +2928,7 @@
"open_sieve_editor": "Ava Sieve lähtekoodi redaktor",
"fetch_error": "Filtrite laadimine ebaõnnestus",
"expanded_view": "Laiendatud vaade",
"show_rule_details": "Näita iga reegli tingimusi ja toiminguid",
"expanded_view_description": "Näita filtrireegleid koos üksikasjalike tingimuste ja tegevuste plokkidega",
"if": "Kui",
"then": "Siis",
@@ -3003,6 +3007,9 @@
"validation_period_empty": "Sisesta ajavahemiku algus- või lõppkuupäev",
"validation_period_invalid": "Sisesta ajavahemiku jaoks kehtiv kuupäev koos kellaajaga või jäta väli tühjaks",
"apply_existing_period": "Ajavahemikuga reegel toimib ainult kirjadele, mis saabuvad selle jooksul, seega ei saa seda olemasolevatele kirjadele rakendada.",
"apply_existing_in": "Rakenda ka kirjadele, mis on juba kaustas {folder}",
"apply_existing_count": "{count, plural, one {Rakenda ka # kirjale, mis on juba kaustas {folder}} other {Rakenda ka # kirjale, mis on juba kaustas {folder}}}",
"apply_existing_none": "Kaustas {folder} pole veel ühtegi kirja, mis selle reegliga sobiks.",
"period_status_scheduled": "ajastatud",
"period_status_active": "aktiivne",
"period_status_expired": "aegunud"
@@ -3510,6 +3517,48 @@
"description": "Logi sellest kontost selles seadmes välja."
}
},
"uxp2_settings": {
"search": {
"tray": "salv süsteemisalv teavitusala taust minimeeri salve tray",
"autostart": "automaatne käivitus käivitus käivita koos Windowsiga sisselogimisel",
"default_app": "vaikimisi meilirakendus vaikimisi e-posti programm mailto meililingid",
"menu_bar": "menüüriba menüüd Alt-klahv Fail Redigeeri Vaade",
"updates": "uuendused uuendus uus versioon beeta kanal",
"devtools": "arendustööriistad devtools konsool uuri F12 silumine",
"taskbar": "tegumiriba vilkumine Dock tähelepanu",
"quiet_hours": "vaikne aeg ära sega vaigista öö",
"telemetry": "kasutusandmed telemeetria statistika analüütika",
"crash": "krahhiaruanded vearaportid krahh",
"threads": "lõimed vestlused rühmita threads",
"block_images": "blokeeri pildid välised pildid laadi pildid",
"dark_mode": "tume režiim hele režiim öörežiim dark mode",
"timezone": "ajavöönd timezone",
"reading_pane": "lugemispaan eelvaatepaan jagatud vaade",
"week_start": "nädala algus esimene päev esmaspäev pühapäev",
"empty_trash": "tühjenda prügikast prügikast kustutatud üksused",
"do_not_disturb": "vaikne aeg vaigista vaikus"
},
"desktop_notifications": {
"title": "Selles arvutis",
"closed_label": "Suletud aknaga",
"closed_description": "{appName} annab uutest kirjadest teada, kuni see töötab. Et see pärast akna sulgemist salves edasi töötaks, lülita see sisse jaotises {page}."
},
"push_unsupported": "See brauser ei saa teavitusi näidata, kui veebileht on suletud.",
"backup_description_app": "Kasuta samu seadeid oma teistes seadmetes.",
"sender_favicons": {
"label": "Pildid ettevõtete veebisaitidelt",
"description": "Näita äriliste saatjate profiilipiltidena veebisaitide ikoone"
},
"privacy_push": {
"label": "Teavituste relee",
"description": "Teavitused ajal, mil {appName} on suletud, käivad läbi push-relee, mis ei näe kunagi sinu kirju. Lülitad need sisse või välja lehel {page}."
},
"reset": {
"message": "Taastab kõik seaded algsel kujul. Sinu kirjad, filtrid, automaatvastus ja allkirjad jäävad samaks.",
"sync_on": "Sinu teised seadmed kasutavad samu seadeid, seega muutuvad ka need.",
"sync_off": "Sinu teised seadmed jätavad oma seaded alles."
}
},
"setup": {
"title": "Bulwark Webmaili seadistamine",
"subtitle": "Seadista oma veebipostiteenus brauseris.",
@@ -7329,6 +7378,39 @@
"app_lock_timeout_hours": "{count, plural, one {# tund} other {# tundi}}",
"desktop_attention_title": "Selles arvutis"
},
"uxs2_ident": {
"your_name": {
"label": "Teie nimi",
"description": "Seda nime näevad inimesed teie aadressi kõrval, kui saadate neile kirja.",
"saved": "Teie nimi on salvestatud.",
"error": "Teie nime ei õnnestunud salvestada. Proovige uuesti."
},
"addresses": {
"description": "Aadressid, millelt saate saata, igaühel oma nimi ja allkiri.",
"label": "Teie aadressid",
"count": "{count, plural, =0 {Aadresse veel pole} one {# aadress} other {# aadressi}}",
"manage": "Muuda aadresse…",
"modal_title": "Teie e-posti aadressid",
"add": "Lisa aadress",
"edit": "Muuda aadressi",
"added": "Aadress lisatud.",
"saved": "Aadress salvestatud.",
"deleted": "Aadress kustutatud.",
"delete_title": "Kas kustutada see aadress?"
},
"signatures": {
"title": "Allkirjad",
"description": "Lisatakse teie kirjutatud kirjade lõppu. Igal aadressil on oma.",
"saved": "Allkiri salvestatud.",
"error": "Allkirja ei õnnestunud salvestada. Proovige uuesti.",
"no_addresses": "Teil pole veel aadressi, millelt saata."
},
"search": {
"addresses": "identiteet identiteedid alias saatja saatja aadress saatja nimi identity alias sender",
"signatures": "allkiri e-kirja allkiri jalus identiteet saatja saatja aadress signature",
"your_name": "kuvatav nimi täisnimi saatja nimi display name"
}
},
"desktop_sign_in": {
"server_needed": "Sisesta kõigepealt serveri aadress.",
"server_declined": "Sa ei lubanud seda serverit, nii et sellele ei saadetud midagi.",
@@ -7371,10 +7453,12 @@
"server_looking": "Otsime sinu meiliserverit…",
"server_found": "Meiliserver: {server}",
"server_change": "Muuda",
"server_change_hint": "Sisestage mõne teise serveri aadress.",
"server_not_found": "Sellele aadressile ei leitud meiliserverit. Sisesta see siia, näiteks mail.example.com.",
"server_needed": "Sisesta oma meiliserveri aadress.",
"server_invalid": "See ei ole serveri aadress. Kirjuta see nagu mail.example.com.",
"email_needed": "Sisesta kõigepealt oma e-posti aadress.",
"other_ways": "Muud sisselogimise viisid",
"trouble": "Probleeme?",
"trouble_cors": "Sinu meiliserver peab lubama sisselogimist sellelt veebisaidilt. Kui sisselogimine ei õnnestu, palu meiliserveri haldajal see lubada.",
"provider_unsupported": "{provider} ei paku JMAP-i, mille kaudu Bulwark meiliserveritega suhtleb, seega ei saa Bulwark sellega veel otse ühendust luua."
+84
View File
@@ -174,6 +174,7 @@
},
"clear_search": "پاک کردن جستجو",
"vacation_active": "پاسخ‌گوی خودکار فعال است",
"vacation_turn_off": "خاموش کردن",
"demo_banner": "حالت آزمایشی",
"demo_reset": "بازنشانی",
"demo_tour": "تور",
@@ -1405,6 +1406,8 @@
"from_description": "برای شروع فوری خالی بگذارید.",
"until_label": "تا (اختیاری)",
"until_description": "خالی بگذارید تا پاسخ دادن تا زمانی که خاموشش کنید ادامه یابد.",
"from_placeholder": "از همین حالا",
"until_placeholder": "تا وقتی خاموشش کنید",
"audience_all": "همه",
"audience_internal": "فقط سازمان من ({domains})",
"audience_external": "فقط افراد بیرون از سازمان من",
@@ -2925,6 +2928,7 @@
"open_sieve_editor": "باز کردن ویرایشگر خام Sieve",
"fetch_error": "بارگذاری فیلترها ناموفق بود",
"expanded_view": "نمای گسترده",
"show_rule_details": "نمایش شرط‌ها و کارهای هر قانون",
"expanded_view_description": "نمایش قوانین فیلتر با بلوک‌های شرط و عملیات جزئی",
"if": "اگر",
"then": "آنگاه",
@@ -3003,6 +3007,9 @@
"validation_period_empty": "تاریخ شروع یا پایان بازه را وارد کنید",
"validation_period_invalid": "برای بازه یک تاریخ معتبر همراه با ساعت وارد کنید یا فیلد را خالی بگذارید",
"apply_existing_period": "قانونی که بازه زمانی دارد فقط روی پیام‌هایی عمل می‌کند که در آن بازه می‌رسند، بنابراین روی پیام‌های موجود اجرا نمی‌شود.",
"apply_existing_in": "روی پیام‌هایی هم که الان در {folder} هستند اعمال شود",
"apply_existing_count": "{count, plural, one {روی # پیامی هم که الان در {folder} است اعمال شود} other {روی # پیامی هم که الان در {folder} هستند اعمال شود}}",
"apply_existing_none": "هنوز هیچ پیامی در {folder} با این قانون جور نیست.",
"period_status_scheduled": "زمان‌بندی‌شده",
"period_status_active": "فعال",
"period_status_expired": "منقضی‌شده"
@@ -3510,6 +3517,48 @@
"description": "خروج از این حساب در این دستگاه."
}
},
"uxp2_settings": {
"search": {
"tray": "سینی سیستم ناحیه اعلان پس‌زمینه کوچک کردن به سینی tray",
"autostart": "اجرای خودکار راه‌اندازی شروع با Windows هنگام ورود",
"default_app": "برنامه ایمیل پیش‌فرض برنامه پیش‌فرض ایمیل mailto پیوندهای ایمیل",
"menu_bar": "نوار منو منوها کلید Alt پرونده ویرایش نما",
"updates": "به‌روزرسانی‌ها به‌روزرسانی نسخه جدید بتا کانال",
"devtools": "ابزارهای توسعه‌دهنده کنسول بازرسی F12 اشکال‌زدایی devtools",
"taskbar": "نوار وظیفه چشمک Dock توجه",
"quiet_hours": "ساعات سکوت مزاحم نشوید بی‌صدا شب",
"telemetry": "اطلاعات استفاده تله‌متری آمار تحلیل",
"crash": "گزارش خرابی گزارش خطا خرابی",
"threads": "رشته‌ها گفتگوها گروه‌بندی threads",
"block_images": "مسدود کردن تصاویر تصاویر خارجی بارگذاری تصاویر",
"dark_mode": "حالت تیره حالت روشن حالت شب dark mode",
"timezone": "منطقه زمانی timezone",
"reading_pane": "پنل خواندن پنل پیش‌نمایش نمای تقسیم‌شده",
"week_start": "شروع هفته اولین روز شنبه دوشنبه یکشنبه",
"empty_trash": "خالی کردن سطل زباله سطل زباله موارد حذف‌شده",
"do_not_disturb": "ساعات سکوت بی‌صدا سکوت"
},
"desktop_notifications": {
"title": "روی این رایانه",
"closed_label": "وقتی پنجره بسته است",
"closed_description": "{appName} تا وقتی اجرا می‌شود از ایمیل‌های تازه خبرتان می‌کند. برای اینکه پس از بستن پنجره در سینی سیستم اجرا بماند، آن را در {page} روشن کنید."
},
"push_unsupported": "این مرورگر وقتی وب‌سایت بسته است نمی‌تواند اعلان نشان دهد.",
"backup_description_app": "از همین تنظیمات در دستگاه‌های دیگر خود استفاده کنید.",
"sender_favicons": {
"label": "تصاویر از وب‌سایت شرکت‌ها",
"description": "نمایش آیکون وبسایت به عنوان تصویر پروفایل فرستندگان تجاری"
},
"privacy_push": {
"label": "رله اعلان‌ها",
"description": "اعلان‌ها وقتی {appName} بسته است از یک رله پوش می‌گذرند که هرگز ایمیل شما را نمی‌بیند. آن‌ها را در صفحه {page} روشن یا خاموش می‌کنید."
},
"reset": {
"message": "همه تنظیمات را به حالت اول برمی‌گرداند. ایمیل‌ها، فیلترها، پاسخ خودکار و امضاهای شما همان‌طور می‌مانند.",
"sync_on": "دستگاه‌های دیگر شما از همین تنظیمات استفاده می‌کنند، پس آن‌ها هم تغییر می‌کنند.",
"sync_off": "دستگاه‌های دیگر شما تنظیمات خود را نگه می‌دارند."
}
},
"setup": {
"title": "راه‌اندازی Bulwark Webmail",
"subtitle": "نمونهٔ وب‌میل خود را از مرورگر پیکربندی کنید.",
@@ -7329,6 +7378,39 @@
"app_lock_timeout_hours": "{count, plural, one {# ساعت} other {# ساعت}}",
"desktop_attention_title": "روی این رایانه"
},
"uxs2_ident": {
"your_name": {
"label": "نام شما",
"description": "وقتی برای دیگران نامه می‌فرستید، این نام را کنار نشانی شما می‌بینند.",
"saved": "نام شما ذخیره شد.",
"error": "نام شما ذخیره نشد. دوباره امتحان کنید."
},
"addresses": {
"description": "نشانی‌هایی که می‌توانید از آن‌ها نامه بفرستید، هر کدام با نام و امضای خودش.",
"label": "نشانی‌های شما",
"count": "{count, plural, =0 {هنوز نشانی‌ای نیست} one {# نشانی} other {# نشانی}}",
"manage": "ویرایش نشانی‌ها…",
"modal_title": "نشانی‌های ایمیل شما",
"add": "افزودن نشانی",
"edit": "ویرایش نشانی",
"added": "نشانی افزوده شد.",
"saved": "نشانی ذخیره شد.",
"deleted": "نشانی حذف شد.",
"delete_title": "این نشانی حذف شود؟"
},
"signatures": {
"title": "امضاها",
"description": "به انتهای پیام‌هایی که می‌نویسید افزوده می‌شود. هر نشانی امضای خودش را دارد.",
"saved": "امضا ذخیره شد.",
"error": "امضا ذخیره نشد. دوباره امتحان کنید.",
"no_addresses": "هنوز نشانی‌ای برای فرستادن ندارید."
},
"search": {
"addresses": "هویت هویت‌ها نام مستعار فرستنده نشانی فرستنده نشانی از identity alias sender",
"signatures": "امضا امضای ایمیل پانویس هویت فرستنده نشانی از signature",
"your_name": "نام نمایشی نام کامل نام فرستنده display name"
}
},
"desktop_sign_in": {
"server_needed": "ابتدا نشانی سرور را وارد کنید.",
"server_declined": "شما این سرور را مجاز نکردید، پس چیزی به آن فرستاده نشد.",
@@ -7371,10 +7453,12 @@
"server_looking": "در حال یافتن سرور ایمیل شما…",
"server_found": "سرور ایمیل: {server}",
"server_change": "تغییر",
"server_change_hint": "نشانی سرور دیگری را بنویسید.",
"server_not_found": "سرور ایمیل این نشانی پیدا نشد. آن را اینجا وارد کنید، مثلاً mail.example.com.",
"server_needed": "نشانی سرور ایمیل خود را وارد کنید.",
"server_invalid": "این نشانی سرور نیست. آن را مثل mail.example.com بنویسید.",
"email_needed": "اول نشانی ایمیل خود را وارد کنید.",
"other_ways": "راه‌های دیگر ورود",
"trouble": "مشکل دارید؟",
"trouble_cors": "سرور ایمیل شما باید ورود از این وب‌سایت را مجاز کند. اگر ورود انجام نشد، از مسئول سرور ایمیل بخواهید آن را مجاز کند.",
"provider_unsupported": "{provider} از JMAP، روشی که Bulwark با سرورهای ایمیل صحبت می‌کند، پشتیبانی نمی‌کند؛ پس Bulwark هنوز نمی‌تواند مستقیم به آن وصل شود."
+84
View File
@@ -174,6 +174,7 @@
},
"clear_search": "Effacer la recherche",
"vacation_active": "Répondeur d'absence activé",
"vacation_turn_off": "Désactiver",
"demo_banner": "Mode démo",
"demo_reset": "Réinitialiser",
"demo_tour": "Visite",
@@ -1405,6 +1406,8 @@
"from_description": "Laissez vide pour commencer tout de suite.",
"until_label": "Au (facultatif)",
"until_description": "Laissez vide pour continuer à répondre jusqu'à ce que vous le désactiviez.",
"from_placeholder": "Dès maintenant",
"until_placeholder": "Jusqu’à ce que vous le désactiviez",
"audience_all": "Tout le monde",
"audience_internal": "Seulement mon organisation ({domains})",
"audience_external": "Seulement les personnes hors de mon organisation",
@@ -2918,6 +2921,7 @@
"open_sieve_editor": "Ouvrir l'éditeur Sieve brut",
"fetch_error": "Échec du chargement des filtres",
"expanded_view": "Vue étendue",
"show_rule_details": "Afficher les conditions et actions de chaque règle",
"expanded_view_description": "Afficher les règles de filtre avec des blocs de conditions et d'actions détaillés",
"if": "Si",
"then": "Alors",
@@ -2996,6 +3000,9 @@
"validation_period_empty": "Indiquez une date de début ou de fin pour la période",
"validation_period_invalid": "Indiquez une date valide avec l'heure pour la période, ou laissez le champ vide",
"apply_existing_period": "Une règle avec une période n'agit que sur les messages qui arrivent pendant celle-ci ; elle ne peut donc pas s'appliquer aux messages existants.",
"apply_existing_in": "Appliquer aussi aux messages déjà dans {folder}",
"apply_existing_count": "{count, plural, one {Appliquer aussi au # message déjà dans {folder}} other {Appliquer aussi aux # messages déjà dans {folder}}}",
"apply_existing_none": "Aucun message de {folder} ne correspond encore à cette règle.",
"period_status_scheduled": "planifiée",
"period_status_active": "active",
"period_status_expired": "expirée"
@@ -3510,6 +3517,48 @@
"description": "Se déconnecter de ce compte sur cet appareil."
}
},
"uxp2_settings": {
"search": {
"tray": "zone de notification barre système arrière-plan réduire tray",
"autostart": "démarrage automatique lancement démarrer avec Windows à l’ouverture de session",
"default_app": "application de messagerie par défaut programme de messagerie mailto liens e-mail",
"menu_bar": "barre de menus menus touche Alt Fichier Édition Affichage",
"updates": "mises à jour mise à jour nouvelle version bêta canal",
"devtools": "outils de développement devtools console inspecter F12 débogage",
"taskbar": "barre des tâches clignoter Dock attention",
"quiet_hours": "heures calmes ne pas déranger silencieux muet nuit",
"telemetry": "données d’utilisation télémétrie statistiques analyse",
"crash": "rapports de plantage rapports d’erreur plantage",
"threads": "fils conversations regrouper threads",
"block_images": "bloquer les images images externes charger les images",
"dark_mode": "mode sombre mode clair mode nuit thème sombre",
"timezone": "fuseau horaire timezone",
"reading_pane": "volet de lecture volet d’aperçu vue partagée",
"week_start": "début de semaine premier jour lundi dimanche",
"empty_trash": "vider la corbeille corbeille éléments supprimés",
"do_not_disturb": "heures calmes muet silence"
},
"desktop_notifications": {
"title": "Sur cet ordinateur",
"closed_label": "Fenêtre fermée",
"closed_description": "{appName} vous prévient des nouveaux e-mails tant qu’il fonctionne. Pour qu’il reste dans la zone de notification une fois la fenêtre fermée, activez-le dans {page}."
},
"push_unsupported": "Ce navigateur ne peut pas afficher de notifications quand le site est fermé.",
"backup_description_app": "Utilisez les mêmes paramètres sur vos autres appareils.",
"sender_favicons": {
"label": "Images des sites web des entreprises",
"description": "Afficher les icônes de sites web comme photos de profil pour les expéditeurs professionnels"
},
"privacy_push": {
"label": "Relais des notifications",
"description": "Les notifications reçues quand {appName} est fermé passent par un relais push, qui ne voit jamais vos e-mails. Vous les activez ou désactivez sur la page {page}."
},
"reset": {
"message": "Remet tous les paramètres comme au départ. Vos e-mails, filtres, réponse automatique et signatures restent tels quels.",
"sync_on": "Vos autres appareils utilisent les mêmes paramètres, ils changent donc aussi.",
"sync_off": "Vos autres appareils gardent leurs paramètres."
}
},
"setup": {
"title": "Configuration de Bulwark Webmail",
"subtitle": "Configurez votre instance de webmail depuis le navigateur.",
@@ -7329,6 +7378,39 @@
"app_lock_timeout_hours": "{count, plural, one {# heure} other {# heures}}",
"desktop_attention_title": "Sur cet ordinateur"
},
"uxs2_ident": {
"your_name": {
"label": "Votre nom",
"description": "Les gens voient ce nom à côté de votre adresse quand vous leur écrivez.",
"saved": "Votre nom est enregistré.",
"error": "Votre nom n'a pas pu être enregistré. Réessayez."
},
"addresses": {
"description": "Les adresses depuis lesquelles vous pouvez envoyer, chacune avec son nom et sa signature.",
"label": "Vos adresses",
"count": "{count, plural, =0 {Pas encore d'adresse} one {# adresse} other {# adresses}}",
"manage": "Modifier les adresses…",
"modal_title": "Vos adresses e-mail",
"add": "Ajouter une adresse",
"edit": "Modifier l'adresse",
"added": "Adresse ajoutée.",
"saved": "Adresse enregistrée.",
"deleted": "Adresse supprimée.",
"delete_title": "Supprimer cette adresse ?"
},
"signatures": {
"title": "Signatures",
"description": "Ajoutée à la fin des messages que vous écrivez. Chaque adresse a la sienne.",
"saved": "Signature enregistrée.",
"error": "La signature n'a pas pu être enregistrée. Réessayez.",
"no_addresses": "Vous n'avez encore aucune adresse d'envoi."
},
"search": {
"addresses": "identité identités alias expéditeur adresse d'expédition adresse de l'expéditeur identity alias sender",
"signatures": "signature signature e-mail pied de page identité expéditeur adresse d'expédition",
"your_name": "nom affiché nom complet nom de l'expéditeur display name"
}
},
"desktop_sign_in": {
"server_needed": "Saisissez d'abord l'adresse du serveur.",
"server_declined": "Vous n'avez pas autorisé ce serveur, donc rien ne lui a été envoyé.",
@@ -7371,10 +7453,12 @@
"server_looking": "Recherche de votre serveur de messagerie…",
"server_found": "Serveur de messagerie : {server}",
"server_change": "Modifier",
"server_change_hint": "Saisissez l'adresse d'un autre serveur.",
"server_not_found": "Nous n'avons pas trouvé le serveur de messagerie de cette adresse. Saisissez-le ici, par exemple mail.example.com.",
"server_needed": "Saisissez l'adresse de votre serveur de messagerie.",
"server_invalid": "Ce n'est pas une adresse de serveur. Écrivez-la comme mail.example.com.",
"email_needed": "Saisissez d'abord votre adresse e-mail.",
"other_ways": "Autres façons de se connecter",
"trouble": "Un problème ?",
"trouble_cors": "Votre serveur de messagerie doit autoriser les connexions depuis ce site. Si la connexion échoue, demandez à la personne qui gère votre serveur de l'autoriser.",
"provider_unsupported": "{provider} ne propose pas JMAP, la façon dont Bulwark communique avec les serveurs de messagerie, donc Bulwark ne peut pas encore s'y connecter directement."
+84
View File
@@ -165,6 +165,7 @@
},
"clear_search": "נקה חיפוש",
"vacation_active": "משיב חופשה פעיל",
"vacation_turn_off": "כיבוי",
"demo_banner": "מצב הדגמה",
"demo_reset": "אִתחוּל",
"demo_tour": "סִיוּר",
@@ -1349,6 +1350,8 @@
"from_description": "השאירו ריק כדי להתחיל מיד.",
"until_label": "עד (לא חובה)",
"until_description": "השאירו ריק כדי להמשיך להשיב עד שתכבו.",
"from_placeholder": "מיד",
"until_placeholder": "עד שתכבו",
"audience_all": "כולם",
"audience_internal": "רק הארגון שלי ({domains})",
"audience_external": "רק אנשים מחוץ לארגון שלי",
@@ -2816,6 +2819,7 @@
"open_sieve_editor": "פתח את עורך Sieve הגולמי",
"fetch_error": "טעינת המסננים נכשלה",
"expanded_view": "תצוגה מורחבת",
"show_rule_details": "הצגת התנאים והפעולות של כל כלל",
"expanded_view_description": "הצג כללי סינון עם תנאים מפורטים ואבני פעולה",
"if": "אם",
"then": "ואז",
@@ -2897,6 +2901,9 @@
"validation_period_empty": "יש להזין תאריך התחלה או סיום לטווח",
"validation_period_invalid": "יש להזין תאריך תקין עם שעה לטווח, או להשאיר את השדה ריק",
"apply_existing_period": "כלל עם טווח זמן פועל רק על הודעות שמגיעות בתוכו, ולכן לא ניתן להחיל אותו על הודעות קיימות.",
"apply_existing_in": "להחיל גם על ההודעות שכבר נמצאות בתיקייה {folder}",
"apply_existing_count": "{count, plural, one {להחיל גם על ההודעה שכבר נמצאת בתיקייה {folder}} other {להחיל גם על # ההודעות שכבר נמצאות בתיקייה {folder}}}",
"apply_existing_none": "עדיין אין הודעה בתיקייה {folder} שמתאימה לכלל הזה.",
"period_status_scheduled": "מתוזמן",
"period_status_active": "פעיל",
"period_status_expired": "פג תוקף"
@@ -3455,6 +3462,48 @@
"description": "התנתקות מהחשבון הזה במכשיר הזה."
}
},
"uxp2_settings": {
"search": {
"tray": "מגש המערכת אזור ההודעות רקע מזעור למגש tray",
"autostart": "הפעלה אוטומטית הפעלה עם Windows בעת הכניסה",
"default_app": "אפליקציית דואר ברירת מחדל תוכנת דואר mailto קישורי דואר",
"menu_bar": "שורת תפריטים תפריטים מקש Alt קובץ עריכה תצוגה",
"updates": "עדכונים עדכון גרסה חדשה בטא ערוץ",
"devtools": "כלי מפתחים מסוף בדיקה F12 ניפוי באגים devtools",
"taskbar": "שורת המשימות הבהוב Dock תשומת לב",
"quiet_hours": "שעות שקט נא לא להפריע השתקה לילה",
"telemetry": "נתוני שימוש טלמטריה סטטיסטיקה ניתוח",
"crash": "דוחות קריסה דוחות שגיאה קריסה",
"threads": "שרשורים שיחות קיבוץ threads",
"block_images": "חסימת תמונות תמונות חיצוניות טעינת תמונות",
"dark_mode": "מצב כהה מצב בהיר מצב לילה dark mode",
"timezone": "אזור זמן timezone",
"reading_pane": "חלונית קריאה חלונית תצוגה מקדימה תצוגה מפוצלת",
"week_start": "תחילת השבוע היום הראשון ראשון שני",
"empty_trash": "ריקון האשפה אשפה פריטים שנמחקו",
"do_not_disturb": "שעות שקט השתקה שקט"
},
"desktop_notifications": {
"title": "במחשב הזה",
"closed_label": "כשהחלון סגור",
"closed_description": "{appName} מודיע על דואר חדש כל עוד הוא פועל. כדי שימשיך לפעול במגש המערכת אחרי סגירת החלון, יש להפעיל זאת ב{page}."
},
"push_unsupported": "הדפדפן הזה לא יכול להציג התראות כשהאתר סגור.",
"backup_description_app": "אותן הגדרות במכשירים האחרים שלך.",
"sender_favicons": {
"label": "תמונות מאתרי חברות",
"description": "הצג סמלי אתרים כתמונות פרופיל עבור שולחים עסקיים"
},
"privacy_push": {
"label": "ממסר להתראות",
"description": "התראות כש{appName} סגור עוברות דרך ממסר push שלעולם אינו רואה את הדואר שלך. מפעילים ומכבים אותן בדף {page}."
},
"reset": {
"message": "כל ההגדרות יחזרו למצבן ההתחלתי. הדואר, המסננים, המענה האוטומטי והחתימות שלך יישארו כפי שהם.",
"sync_on": "המכשירים האחרים שלך משתמשים באותן הגדרות, ולכן גם הם ישתנו.",
"sync_off": "המכשירים האחרים שלך ישמרו על ההגדרות שלהם."
}
},
"setup": {
"title": "הגדרת Bulwark Webmail",
"subtitle": "הגדירו את מופע דואר הרשת שלכם מהדפדפן.",
@@ -7330,6 +7379,39 @@
"app_lock_timeout_hours": "{count, plural, one {שעה אחת} two {שעתיים} other {# שעות}}",
"desktop_attention_title": "במחשב הזה"
},
"uxs2_ident": {
"your_name": {
"label": "השם שלכם",
"description": "אנשים רואים את השם הזה ליד הכתובת שלכם כשאתם שולחים להם דואר.",
"saved": "השם שלכם נשמר.",
"error": "לא ניתן היה לשמור את השם שלכם. נסו שוב."
},
"addresses": {
"description": "הכתובות שמהן אפשר לשלוח, לכל אחת שם וחתימה משלה.",
"label": "הכתובות שלכם",
"count": "{count, plural, =0 {עדיין אין כתובות} one {כתובת אחת} two {# כתובות} other {# כתובות}}",
"manage": "עריכת כתובות…",
"modal_title": "כתובות הדואר שלכם",
"add": "הוספת כתובת",
"edit": "עריכת כתובת",
"added": "הכתובת נוספה.",
"saved": "הכתובת נשמרה.",
"deleted": "הכתובת נמחקה.",
"delete_title": "למחוק את הכתובת הזו?"
},
"signatures": {
"title": "חתימות",
"description": "נוספת בסוף ההודעות שאתם כותבים. לכל כתובת חתימה משלה.",
"saved": "החתימה נשמרה.",
"error": "לא ניתן היה לשמור את החתימה. נסו שוב.",
"no_addresses": "עדיין אין כתובת לשליחה."
},
"search": {
"addresses": "זהות זהויות כינוי שולח כתובת השולח כתובת מאת identity alias sender",
"signatures": "חתימה חתימת דואר כותרת תחתונה זהות שולח כתובת מאת signature",
"your_name": "שם תצוגה שם מלא שם השולח display name"
}
},
"desktop_sign_in": {
"server_needed": "קודם צריך להזין את כתובת השרת.",
"server_declined": "לא אישרת את השרת הזה, לכן לא נשלח אליו דבר.",
@@ -7372,10 +7454,12 @@
"server_looking": "מחפשים את שרת הדואר שלך…",
"server_found": "שרת דואר: {server}",
"server_change": "שינוי",
"server_change_hint": "הקלידו כתובת של שרת אחר.",
"server_not_found": "לא מצאנו את שרת הדואר של הכתובת הזו. הזינו אותו כאן, למשל mail.example.com.",
"server_needed": "הזינו את הכתובת של שרת הדואר שלכם.",
"server_invalid": "זו לא כתובת של שרת. כתבו אותה כמו mail.example.com.",
"email_needed": "הזינו קודם את כתובת הדוא״ל שלכם.",
"other_ways": "דרכים אחרות להתחבר",
"trouble": "נתקלתם בבעיה?",
"trouble_cors": "שרת הדואר שלכם צריך לאפשר כניסה מהאתר הזה. אם הכניסה נכשלת, בקשו ממי שמפעיל את שרת הדואר לאפשר זאת.",
"provider_unsupported": "{provider} לא מציע JMAP, הדרך שבה Bulwark מדבר עם שרתי דואר, ולכן Bulwark עדיין לא יכול להתחבר אליו ישירות."
+84
View File
@@ -174,6 +174,7 @@
},
"clear_search": "Keresés törlése",
"vacation_active": "Automatikus válasz aktív",
"vacation_turn_off": "Kikapcsolás",
"demo_banner": "Demó mód",
"demo_reset": "Visszaállítás",
"demo_tour": "Bemutató",
@@ -1405,6 +1406,8 @@
"from_description": "Hagyja üresen az azonnali kezdéshez.",
"until_label": "Eddig (nem kötelező)",
"until_description": "Hagyja üresen, hogy a válaszolás addig tartson, amíg ki nem kapcsolja.",
"from_placeholder": "Azonnal",
"until_placeholder": "Amíg ki nem kapcsolja",
"audience_all": "Mindenkinek",
"audience_internal": "Csak a szervezetemnek ({domains})",
"audience_external": "Csak a szervezetemen kívülieknek",
@@ -2921,6 +2924,7 @@
"open_sieve_editor": "Nyers Sieve szerkesztő megnyitása",
"fetch_error": "Nem sikerült betölteni a szűrőket",
"expanded_view": "Részletes nézet",
"show_rule_details": "Minden szabály feltételeinek és műveleteinek megjelenítése",
"expanded_view_description": "Szűrőszabályok megjelenítése részletes feltétel- és műveletblokkokkal",
"if": "Ha",
"then": "Akkor",
@@ -2999,6 +3003,9 @@
"validation_period_empty": "Adjon meg kezdő vagy befejező dátumot az időszakhoz",
"validation_period_invalid": "Adjon meg érvényes dátumot időponttal az időszakhoz, vagy hagyja üresen a mezőt",
"apply_existing_period": "Az időszakhoz kötött szabály csak az abban érkező üzenetekre hat, ezért meglévő üzenetekre nem alkalmazható.",
"apply_existing_in": "Alkalmazás a már itt lévő üzenetekre is: {folder}",
"apply_existing_count": "{count, plural, one {Alkalmazás a már itt lévő # üzenetre is: {folder}} other {Alkalmazás a már itt lévő # üzenetre is: {folder}}}",
"apply_existing_none": "Itt még egy üzenet sem felel meg ennek a szabálynak: {folder}.",
"period_status_scheduled": "ütemezett",
"period_status_active": "aktív",
"period_status_expired": "lejárt"
@@ -3510,6 +3517,48 @@
"description": "Kijelentkezés ebből a fiókból ezen az eszközön."
}
},
"uxp2_settings": {
"search": {
"tray": "tálca értesítési terület háttér tálcára kicsinyítés tray",
"autostart": "automatikus indítás indításkor Windows-zal együtt bejelentkezéskor",
"default_app": "alapértelmezett levelezőalkalmazás alapértelmezett levelezőprogram mailto e-mail-hivatkozások",
"menu_bar": "menüsor menük Alt billentyű Fájl Szerkesztés Nézet",
"updates": "frissítések frissítés új verzió béta csatorna",
"devtools": "fejlesztői eszközök devtools konzol vizsgálat F12 hibakeresés",
"taskbar": "tálca villogás Dock figyelem",
"quiet_hours": "csendes órák ne zavarjanak némítás éjszaka",
"telemetry": "használati adatok telemetria statisztika elemzés",
"crash": "összeomlási jelentések hibajelentések összeomlás",
"threads": "szálak beszélgetések csoportosítás threads",
"block_images": "képek letiltása külső képek képek betöltése",
"dark_mode": "sötét mód világos mód éjszakai mód dark mode",
"timezone": "időzóna timezone",
"reading_pane": "olvasóablak előnézeti ablak osztott nézet",
"week_start": "hét kezdete első nap hétfő vasárnap",
"empty_trash": "kuka ürítése kuka törölt elemek",
"do_not_disturb": "csendes órák némítás csend"
},
"desktop_notifications": {
"title": "Ezen a számítógépen",
"closed_label": "Bezárt ablakkal",
"closed_description": "A(z) {appName} szól az új levelekről, amíg fut. Ha azt szeretné, hogy az ablak bezárása után a tálcán fusson tovább, kapcsolja be itt: {page}."
},
"push_unsupported": "Ez a böngésző nem tud értesítést mutatni, ha a webhely be van zárva.",
"backup_description_app": "Ugyanazok a beállítások a többi eszközén.",
"sender_favicons": {
"label": "Képek a cégek webhelyeiről",
"description": "Weboldal ikonok megjelenítése profilképekként az üzleti feladók számára"
},
"privacy_push": {
"label": "Értesítési továbbító",
"description": "A bezárt {appName} értesítései egy push továbbítón mennek át, amely soha nem látja a leveleit. Ki- és bekapcsolni a(z) {page} oldalon lehet."
},
"reset": {
"message": "Minden beállítást visszaállít a kezdeti állapotra. A levelei, szűrői, automatikus válasza és aláírásai maradnak.",
"sync_on": "A többi eszköze ugyanazokat a beállításokat használja, ezért azok is megváltoznak.",
"sync_off": "A többi eszköze megtartja a beállításait."
}
},
"setup": {
"title": "A Bulwark Webmail beállítása",
"subtitle": "Állítsa be a webmail-példányát a böngészőből.",
@@ -7329,6 +7378,39 @@
"app_lock_timeout_hours": "{count, plural, one {# óra} other {# óra}}",
"desktop_attention_title": "Ezen a számítógépen"
},
"uxs2_ident": {
"your_name": {
"label": "Az Ön neve",
"description": "Ezt a nevet látják mások a címe mellett, amikor levelet küld nekik.",
"saved": "A neve mentve.",
"error": "A nevét nem sikerült menteni. Próbálja újra."
},
"addresses": {
"description": "A címek, amelyekről küldhet, mindegyik saját névvel és aláírással.",
"label": "Az Ön címei",
"count": "{count, plural, =0 {Még nincs cím} one {# cím} other {# cím}}",
"manage": "Címek szerkesztése…",
"modal_title": "Az Ön e-mail-címei",
"add": "Cím hozzáadása",
"edit": "Cím szerkesztése",
"added": "Cím hozzáadva.",
"saved": "Cím mentve.",
"deleted": "Cím törölve.",
"delete_title": "Törli ezt a címet?"
},
"signatures": {
"title": "Aláírások",
"description": "Az Ön által írt üzenetek végére kerül. Minden címnek saját aláírása van.",
"saved": "Aláírás mentve.",
"error": "Az aláírást nem sikerült menteni. Próbálja újra.",
"no_addresses": "Még nincs cím, amelyről küldhetne."
},
"search": {
"addresses": "identitás identitások álnév alias feladó feladó címe feladócím identity alias sender",
"signatures": "aláírás e-mail aláírás lábléc identitás feladó feladócím signature",
"your_name": "megjelenített név teljes név feladó neve display name"
}
},
"desktop_sign_in": {
"server_needed": "Először adja meg a szerver címét.",
"server_declined": "Nem engedélyezte ezt a szervert, ezért semmit sem küldtünk neki.",
@@ -7371,10 +7453,12 @@
"server_looking": "A levelezőszerver keresése…",
"server_found": "Levelezőszerver: {server}",
"server_change": "Módosítás",
"server_change_hint": "Írjon be egy másik szervercímet.",
"server_not_found": "Ehhez a címhez nem találtunk levelezőszervert. Írja be ide, például mail.example.com.",
"server_needed": "Írja be a levelezőszervere címét.",
"server_invalid": "Ez nem szervercím. Így írja: mail.example.com.",
"email_needed": "Először írja be az e-mail-címét.",
"other_ways": "Más bejelentkezési módok",
"trouble": "Probléma van?",
"trouble_cors": "A levelezőszervernek engedélyeznie kell a bejelentkezést erről a webhelyről. Ha a bejelentkezés nem sikerül, kérje meg a levelezőszerver üzemeltetőjét, hogy engedélyezze.",
"provider_unsupported": "A(z) {provider} nem kínál JMAP-ot, azt a módot, ahogyan a Bulwark a levelezőszerverekkel beszél, ezért a Bulwark még nem tud közvetlenül csatlakozni hozzá."
+84
View File
@@ -174,6 +174,7 @@
},
"clear_search": "Cancella ricerca",
"vacation_active": "Risponditore automatico attivo",
"vacation_turn_off": "Disattiva",
"demo_banner": "Modalità demo",
"demo_reset": "Reimposta",
"demo_tour": "Tour",
@@ -1405,6 +1406,8 @@
"from_description": "Lascia vuoto per iniziare subito.",
"until_label": "Al (facoltativo)",
"until_description": "Lascia vuoto per continuare a rispondere finché non lo disattivi.",
"from_placeholder": "Da subito",
"until_placeholder": "Finché non lo disattivi",
"audience_all": "Tutti",
"audience_internal": "Solo la mia organizzazione ({domains})",
"audience_external": "Solo persone esterne alla mia organizzazione",
@@ -2918,6 +2921,7 @@
"open_sieve_editor": "Apri editor Sieve",
"fetch_error": "Impossibile caricare i filtri",
"expanded_view": "Vista espansa",
"show_rule_details": "Mostra condizioni e azioni di ogni regola",
"expanded_view_description": "Mostra le regole dei filtri con blocchi dettagliati di condizioni e azioni",
"if": "Se",
"then": "Allora",
@@ -2996,6 +3000,9 @@
"validation_period_empty": "Inserisci una data di inizio o di fine per il periodo",
"validation_period_invalid": "Inserisci una data valida con l'ora per il periodo oppure lascia vuoto il campo",
"apply_existing_period": "Una regola con periodo agisce solo sui messaggi che arrivano al suo interno, quindi non si può applicare ai messaggi esistenti.",
"apply_existing_in": "Applica anche ai messaggi già in {folder}",
"apply_existing_count": "{count, plural, one {Applica anche al # messaggio già in {folder}} other {Applica anche ai # messaggi già in {folder}}}",
"apply_existing_none": "Nessun messaggio in {folder} corrisponde ancora a questa regola.",
"period_status_scheduled": "programmata",
"period_status_active": "attiva",
"period_status_expired": "scaduta"
@@ -3510,6 +3517,48 @@
"description": "Esci da questo account su questo dispositivo."
}
},
"uxp2_settings": {
"search": {
"tray": "area di notifica barra di sistema background riduci a icona tray",
"autostart": "avvio automatico avvio avvia con Windows all’accesso",
"default_app": "app di posta predefinita programma di posta predefinito mailto link email",
"menu_bar": "barra dei menu menu tasto Alt File Modifica Visualizza",
"updates": "aggiornamenti aggiornamento nuova versione beta canale",
"devtools": "strumenti per sviluppatori devtools console ispeziona F12 debug",
"taskbar": "barra delle applicazioni lampeggia Dock attenzione",
"quiet_hours": "ore di silenzio non disturbare silenzioso muto notte",
"telemetry": "dati di utilizzo telemetria statistiche analisi",
"crash": "segnalazioni di arresto anomalo segnalazioni errori crash",
"threads": "thread conversazioni raggruppa",
"block_images": "blocca immagini immagini esterne carica immagini",
"dark_mode": "modalità scura modalità chiara modalità notte tema scuro",
"timezone": "fuso orario timezone",
"reading_pane": "riquadro di lettura riquadro anteprima vista divisa",
"week_start": "inizio settimana primo giorno lunedì domenica",
"empty_trash": "svuota il cestino cestino elementi eliminati",
"do_not_disturb": "ore di silenzio muto silenzio"
},
"desktop_notifications": {
"title": "Su questo computer",
"closed_label": "Con la finestra chiusa",
"closed_description": "{appName} ti avvisa della nuova posta finché è in esecuzione. Per farlo restare nell’area di notifica dopo aver chiuso la finestra, attivalo in {page}."
},
"push_unsupported": "Questo browser non può mostrare notifiche quando il sito è chiuso.",
"backup_description_app": "Usa le stesse impostazioni sugli altri dispositivi.",
"sender_favicons": {
"label": "Immagini dai siti delle aziende",
"description": "Mostra le icone dei siti web come immagini profilo per i mittenti aziendali"
},
"privacy_push": {
"label": "Relay per le notifiche",
"description": "Le notifiche con {appName} chiuso passano da un relay push, che non vede mai la tua posta. Le attivi o disattivi nella pagina {page}."
},
"reset": {
"message": "Riporta tutte le impostazioni a come erano all’inizio. Posta, filtri, risposta automatica e firme restano come sono.",
"sync_on": "Gli altri dispositivi usano le stesse impostazioni, quindi cambiano anche loro.",
"sync_off": "Gli altri dispositivi mantengono le loro impostazioni."
}
},
"setup": {
"title": "Configurazione di Bulwark Webmail",
"subtitle": "Configura la tua istanza webmail dal browser.",
@@ -7329,6 +7378,39 @@
"app_lock_timeout_hours": "{count, plural, one {# ora} other {# ore}}",
"desktop_attention_title": "Su questo computer"
},
"uxs2_ident": {
"your_name": {
"label": "Il tuo nome",
"description": "Le persone vedono questo nome accanto al tuo indirizzo quando scrivi loro.",
"saved": "Il tuo nome è stato salvato.",
"error": "Non è stato possibile salvare il tuo nome. Riprova."
},
"addresses": {
"description": "Gli indirizzi da cui puoi inviare, ognuno con il proprio nome e la propria firma.",
"label": "I tuoi indirizzi",
"count": "{count, plural, =0 {Ancora nessun indirizzo} one {# indirizzo} other {# indirizzi}}",
"manage": "Modifica indirizzi…",
"modal_title": "I tuoi indirizzi email",
"add": "Aggiungi indirizzo",
"edit": "Modifica indirizzo",
"added": "Indirizzo aggiunto.",
"saved": "Indirizzo salvato.",
"deleted": "Indirizzo eliminato.",
"delete_title": "Eliminare questo indirizzo?"
},
"signatures": {
"title": "Firme",
"description": "Aggiunta alla fine dei messaggi che scrivi. Ogni indirizzo ha la sua.",
"saved": "Firma salvata.",
"error": "Non è stato possibile salvare la firma. Riprova.",
"no_addresses": "Non c'è ancora un indirizzo da cui inviare."
},
"search": {
"addresses": "identità alias mittente indirizzo del mittente indirizzo da identity sender",
"signatures": "firma firma email piè di pagina identità mittente indirizzo da signature",
"your_name": "nome visualizzato nome completo nome del mittente display name"
}
},
"desktop_sign_in": {
"server_needed": "Inserisci prima l'indirizzo del server.",
"server_declined": "Non hai autorizzato questo server, quindi non gli è stato inviato nulla.",
@@ -7371,10 +7453,12 @@
"server_looking": "Ricerca del tuo server di posta…",
"server_found": "Server di posta: {server}",
"server_change": "Cambia",
"server_change_hint": "Scrivi l'indirizzo di un altro server.",
"server_not_found": "Non abbiamo trovato il server di posta di questo indirizzo. Inseriscilo qui, per esempio mail.example.com.",
"server_needed": "Inserisci l'indirizzo del tuo server di posta.",
"server_invalid": "Questo non è l'indirizzo di un server. Scrivilo come mail.example.com.",
"email_needed": "Inserisci prima il tuo indirizzo email.",
"other_ways": "Altri modi per accedere",
"trouble": "Problemi?",
"trouble_cors": "Il tuo server di posta deve consentire l'accesso da questo sito. Se l'accesso non riesce, chiedi a chi gestisce il server di posta di consentirlo.",
"provider_unsupported": "{provider} non offre JMAP, il modo in cui Bulwark parla con i server di posta, quindi Bulwark non può ancora collegarsi direttamente."
+84
View File
@@ -174,6 +174,7 @@
},
"clear_search": "検索をクリア",
"vacation_active": "不在応答が有効です",
"vacation_turn_off": "オフにする",
"demo_banner": "デモモード",
"demo_reset": "リセット",
"demo_tour": "ツアー",
@@ -1405,6 +1406,8 @@
"from_description": "空欄にするとすぐに始まります。",
"until_label": "終了(任意)",
"until_description": "空欄にすると、オフにするまで返信を続けます。",
"from_placeholder": "今すぐ",
"until_placeholder": "オフにするまで",
"audience_all": "全員",
"audience_internal": "自分の組織のみ({domains})",
"audience_external": "組織外の人のみ",
@@ -2918,6 +2921,7 @@
"open_sieve_editor": "Sieveスクリプトエディタを開く",
"fetch_error": "フィルターの読み込みに失敗しました",
"expanded_view": "詳細表示",
"show_rule_details": "各ルールの条件と動作を表示",
"expanded_view_description": "フィルタールールを条件とアクションのブロックで表示",
"if": "条件",
"then": "実行",
@@ -2996,6 +3000,9 @@
"validation_period_empty": "期間の開始日または終了日を入力してください",
"validation_period_invalid": "期間には時刻付きの有効な日付を入力するか、欄を空のままにしてください",
"apply_existing_period": "期間付きのルールは期間内に届いたメッセージにのみ適用されるため、既存のメッセージには適用できません。",
"apply_existing_in": "{folder} にあるメッセージにも適用する",
"apply_existing_count": "{count, plural, other {{folder} にある # 件にも適用する}}",
"apply_existing_none": "{folder} にこのルールに合うメッセージはまだありません。",
"period_status_scheduled": "予定",
"period_status_active": "有効",
"period_status_expired": "期限切れ"
@@ -3510,6 +3517,48 @@
"description": "このデバイスでこのアカウントからサインアウトします。"
}
},
"uxp2_settings": {
"search": {
"tray": "トレイ システムトレイ 通知領域 バックグラウンド 最小化 tray",
"autostart": "自動起動 スタートアップ Windows と一緒に起動 サインイン時に起動",
"default_app": "既定のメールアプリ 既定のメールプログラム mailto メールリンク",
"menu_bar": "メニューバー メニュー Alt キー ファイル 編集 表示",
"updates": "アップデート 更新 新しいバージョン ベータ チャンネル",
"devtools": "開発者ツール devtools コンソール 検証 F12 デバッグ",
"taskbar": "タスクバー 点滅 Dock 注意",
"quiet_hours": "おやすみ時間 通知停止 サイレント ミュート 夜間",
"telemetry": "使用状況 テレメトリ 統計 分析",
"crash": "クラッシュレポート エラーレポート クラッシュ",
"threads": "スレッド 会話 グループ化 threads",
"block_images": "画像をブロック 外部画像 画像の読み込み",
"dark_mode": "ダークモード ライトモード 夜間モード dark mode",
"timezone": "タイムゾーン 時間帯 timezone",
"reading_pane": "閲覧ウィンドウ プレビューウィンドウ 分割表示",
"week_start": "週の始まり 週の最初の日 月曜日 日曜日",
"empty_trash": "ごみ箱を空にする ごみ箱 削除済み",
"do_not_disturb": "おやすみ時間 ミュート 消音"
},
"desktop_notifications": {
"title": "このコンピューター",
"closed_label": "ウィンドウを閉じたとき",
"closed_description": "{appName} は動いている間、新着メールをお知らせします。ウィンドウを閉じた後もトレイで動かし続けるには、{page} でオンにしてください。"
},
"push_unsupported": "このブラウザでは、サイトを閉じていると通知を表示できません。",
"backup_description_app": "ほかのデバイスでも同じ設定を使います。",
"sender_favicons": {
"label": "会社のウェブサイトの画像",
"description": "ビジネス送信者のプロフィール画像としてウェブサイトアイコンを表示"
},
"privacy_push": {
"label": "通知のリレー",
"description": "{appName} を閉じているときの通知はプッシュリレーを通りますが、リレーがメールを見ることはありません。オン・オフは {page} ページで切り替えます。"
},
"reset": {
"message": "すべての設定を最初の状態に戻します。メール、フィルター、自動返信、署名はそのまま残ります。",
"sync_on": "ほかのデバイスも同じ設定を使っているため、そちらも変わります。",
"sync_off": "ほかのデバイスの設定はそのままです。"
}
},
"setup": {
"title": "Bulwark Webmail のセットアップ",
"subtitle": "ブラウザーから Web メールのインスタンスを設定します。",
@@ -7329,6 +7378,39 @@
"app_lock_timeout_hours": "{count, plural, other {#時間}}",
"desktop_attention_title": "このコンピューター"
},
"uxs2_ident": {
"your_name": {
"label": "あなたの名前",
"description": "メールを送ると、相手にはこの名前がアドレスの横に表示されます。",
"saved": "名前を保存しました。",
"error": "名前を保存できませんでした。もう一度お試しください。"
},
"addresses": {
"description": "送信に使えるアドレスです。アドレスごとに名前と署名があります。",
"label": "あなたのアドレス",
"count": "{count, plural, =0 {まだアドレスはありません} other {# 件のアドレス}}",
"manage": "アドレスを編集…",
"modal_title": "あなたのメールアドレス",
"add": "アドレスを追加",
"edit": "アドレスを編集",
"added": "アドレスを追加しました。",
"saved": "アドレスを保存しました。",
"deleted": "アドレスを削除しました。",
"delete_title": "このアドレスを削除しますか?"
},
"signatures": {
"title": "署名",
"description": "書いたメッセージの最後に付きます。アドレスごとに別の署名を使えます。",
"saved": "署名を保存しました。",
"error": "署名を保存できませんでした。もう一度お試しください。",
"no_addresses": "送信に使えるアドレスがまだありません。"
},
"search": {
"addresses": "ID アイデンティティ エイリアス 差出人 差出人アドレス 送信元アドレス identity alias sender",
"signatures": "署名 メール署名 フッター アイデンティティ 差出人 送信元アドレス signature",
"your_name": "表示名 氏名 差出人名 display name"
}
},
"desktop_sign_in": {
"server_needed": "先にサーバーのアドレスを入力してください。",
"server_declined": "このサーバーを許可しなかったため、何も送信していません。",
@@ -7371,10 +7453,12 @@
"server_looking": "メールサーバーを探しています…",
"server_found": "メールサーバー: {server}",
"server_change": "変更",
"server_change_hint": "別のサーバーのアドレスを入力してください。",
"server_not_found": "このアドレスのメールサーバーが見つかりませんでした。ここに入力してください(例: mail.example.com)。",
"server_needed": "メールサーバーのアドレスを入力してください。",
"server_invalid": "サーバーのアドレスではありません。mail.example.com のように入力してください。",
"email_needed": "まずメールアドレスを入力してください。",
"other_ways": "その他のサインイン方法",
"trouble": "うまくいかない場合",
"trouble_cors": "メールサーバーがこのウェブサイトからのサインインを許可している必要があります。サインインできない場合は、メールサーバーの管理者に許可を依頼してください。",
"provider_unsupported": "{provider} は Bulwark がメールサーバーとやり取りする方式である JMAP に対応していないため、Bulwark はまだ直接接続できません。"
+84
View File
@@ -174,6 +174,7 @@
},
"clear_search": "검색어 지우기",
"vacation_active": "부재중 자동 응답이 켜져 있어요",
"vacation_turn_off": "끄기",
"demo_banner": "데모 모드",
"demo_reset": "초기화",
"demo_tour": "둘러보기",
@@ -1405,6 +1406,8 @@
"from_description": "비워 두면 바로 시작합니다.",
"until_label": "종료 (선택)",
"until_description": "비워 두면 끌 때까지 계속 답장합니다.",
"from_placeholder": "지금부터",
"until_placeholder": "끌 때까지",
"audience_all": "모든 사람",
"audience_internal": "내 조직만 ({domains})",
"audience_external": "내 조직 밖의 사람만",
@@ -2918,6 +2921,7 @@
"open_sieve_editor": "원본 Sieve 에디터 열기",
"fetch_error": "필터를 불러오지 못했어요",
"expanded_view": "자세히 보기",
"show_rule_details": "각 규칙의 조건과 동작 표시",
"expanded_view_description": "필터 규칙의 조건과 동작을 모두 펼쳐서 보여줘요",
"if": "만약 (If)",
"then": "다음 동작 실행 (Then)",
@@ -2996,6 +3000,9 @@
"validation_period_empty": "기간의 시작일 또는 종료일을 입력해 주세요",
"validation_period_invalid": "기간에 시간을 포함한 올바른 날짜를 입력하거나 칸을 비워 두세요",
"apply_existing_period": "기간이 설정된 규칙은 그 기간에 도착한 메시지에만 적용되므로 기존 메시지에는 적용할 수 없어요.",
"apply_existing_in": "{folder}에 이미 있는 메일에도 적용",
"apply_existing_count": "{count, plural, other {{folder}에 이미 있는 메일 #개에도 적용}}",
"apply_existing_none": "{folder}에 이 규칙에 맞는 메일이 아직 없습니다.",
"period_status_scheduled": "예정",
"period_status_active": "활성",
"period_status_expired": "만료"
@@ -3510,6 +3517,48 @@
"description": "이 기기에서 이 계정을 로그아웃해요."
}
},
"uxp2_settings": {
"search": {
"tray": "트레이 시스템 트레이 알림 영역 백그라운드 최소화 tray",
"autostart": "자동 시작 시작 프로그램 Windows와 함께 시작 로그인 시 시작",
"default_app": "기본 메일 앱 기본 이메일 프로그램 mailto 메일 링크",
"menu_bar": "메뉴 표시줄 메뉴 Alt 키 파일 편집 보기",
"updates": "업데이트 새 버전 베타 채널",
"devtools": "개발자 도구 devtools 콘솔 검사 F12 디버깅",
"taskbar": "작업 표시줄 깜박임 Dock 주의",
"quiet_hours": "방해 금지 시간 방해 금지 무음 음소거 야간",
"telemetry": "사용 정보 원격 분석 통계 분석",
"crash": "충돌 보고서 오류 보고서 충돌",
"threads": "스레드 대화 묶기 threads",
"block_images": "이미지 차단 외부 이미지 이미지 불러오기",
"dark_mode": "다크 모드 라이트 모드 야간 모드 dark mode",
"timezone": "시간대 표준 시간대 timezone",
"reading_pane": "읽기 창 미리 보기 창 분할 보기",
"week_start": "주 시작 첫째 요일 월요일 일요일",
"empty_trash": "휴지통 비우기 휴지통 삭제된 항목",
"do_not_disturb": "방해 금지 시간 음소거 무음"
},
"desktop_notifications": {
"title": "이 컴퓨터에서",
"closed_label": "창을 닫았을 때",
"closed_description": "{appName}은(는) 실행 중인 동안 새 메일을 알려 드려요. 창을 닫은 뒤에도 트레이에서 계속 실행하려면 {page}에서 켜세요."
},
"push_unsupported": "이 브라우저는 웹사이트가 닫혀 있을 때 알림을 표시할 수 없어요.",
"backup_description_app": "다른 기기에서도 같은 설정을 사용해요.",
"sender_favicons": {
"label": "회사 웹사이트의 사진",
"description": "비즈니스 메일의 경우 해당 웹사이트의 아이콘을 프로필 사진으로 보여줘요"
},
"privacy_push": {
"label": "알림 릴레이",
"description": "{appName}이(가) 닫혀 있을 때의 알림은 푸시 릴레이를 거치며, 릴레이는 메일을 절대 보지 않아요. {page} 페이지에서 켜고 끌 수 있어요."
},
"reset": {
"message": "모든 설정을 처음 상태로 되돌려요. 메일, 필터, 자동 응답, 서명은 그대로 남아요.",
"sync_on": "다른 기기도 같은 설정을 사용하므로 함께 바뀌어요.",
"sync_off": "다른 기기의 설정은 그대로예요."
}
},
"setup": {
"title": "Bulwark Webmail 설정",
"subtitle": "브라우저에서 웹메일 인스턴스를 설정하세요.",
@@ -7329,6 +7378,39 @@
"app_lock_timeout_hours": "{count, plural, other {#시간}}",
"desktop_attention_title": "이 컴퓨터에서"
},
"uxs2_ident": {
"your_name": {
"label": "내 이름",
"description": "메일을 보내면 받는 사람에게 주소 옆에 이 이름이 보입니다.",
"saved": "이름을 저장했습니다.",
"error": "이름을 저장하지 못했습니다. 다시 시도하세요."
},
"addresses": {
"description": "보낼 때 쓸 수 있는 주소입니다. 주소마다 이름과 서명이 따로 있습니다.",
"label": "내 주소",
"count": "{count, plural, =0 {아직 주소가 없습니다} other {주소 #개}}",
"manage": "주소 편집…",
"modal_title": "내 이메일 주소",
"add": "주소 추가",
"edit": "주소 편집",
"added": "주소를 추가했습니다.",
"saved": "주소를 저장했습니다.",
"deleted": "주소를 삭제했습니다.",
"delete_title": "이 주소를 삭제할까요?"
},
"signatures": {
"title": "서명",
"description": "작성한 메시지 끝에 붙습니다. 주소마다 서명이 따로 있습니다.",
"saved": "서명을 저장했습니다.",
"error": "서명을 저장하지 못했습니다. 다시 시도하세요.",
"no_addresses": "아직 보낼 수 있는 주소가 없습니다."
},
"search": {
"addresses": "ID 아이덴티티 별칭 보낸 사람 보낸 사람 주소 발신 주소 identity alias sender",
"signatures": "서명 이메일 서명 바닥글 아이덴티티 보낸 사람 발신 주소 signature",
"your_name": "표시 이름 전체 이름 보낸 사람 이름 display name"
}
},
"desktop_sign_in": {
"server_needed": "먼저 서버 주소를 입력하세요.",
"server_declined": "이 서버를 허용하지 않아 아무것도 보내지 않았습니다.",
@@ -7371,10 +7453,12 @@
"server_looking": "메일 서버를 찾고 있어요…",
"server_found": "메일 서버: {server}",
"server_change": "변경",
"server_change_hint": "다른 서버 주소를 입력하세요.",
"server_not_found": "이 주소의 메일 서버를 찾지 못했어요. 여기에 입력하세요. 예: mail.example.com",
"server_needed": "메일 서버 주소를 입력하세요.",
"server_invalid": "서버 주소가 아니에요. mail.example.com처럼 입력하세요.",
"email_needed": "먼저 이메일 주소를 입력하세요.",
"other_ways": "다른 로그인 방법",
"trouble": "문제가 있나요?",
"trouble_cors": "메일 서버가 이 웹사이트에서의 로그인을 허용해야 해요. 로그인이 안 되면 메일 서버 관리자에게 허용해 달라고 요청하세요.",
"provider_unsupported": "{provider}은(는) Bulwark가 메일 서버와 통신하는 방식인 JMAP을 제공하지 않아서, Bulwark가 아직 직접 연결할 수 없어요."
+84
View File
@@ -174,6 +174,7 @@
},
"clear_search": "Notīrīt meklēšanu",
"vacation_active": "Atvaļinājuma atbilde ir aktīva",
"vacation_turn_off": "Izslēgt",
"demo_banner": "Demo režīms",
"demo_reset": "Atiestatīt",
"demo_tour": "Ekskursija",
@@ -1405,6 +1406,8 @@
"from_description": "Atstājiet tukšu, lai sāktu uzreiz.",
"until_label": "Līdz (neobligāti)",
"until_description": "Atstājiet tukšu, lai atbildētu, līdz to izslēgsiet.",
"from_placeholder": "Uzreiz",
"until_placeholder": "Līdz to izslēgsiet",
"audience_all": "Visiem",
"audience_internal": "Tikai manai organizācijai ({domains})",
"audience_external": "Tikai cilvēkiem ārpus manas organizācijas",
@@ -2918,6 +2921,7 @@
"open_sieve_editor": "Atvērt Sieve redaktoru",
"fetch_error": "Neizdevās ielādēt filtrus",
"expanded_view": "Izvērstais skats",
"show_rule_details": "Rādīt katra noteikuma nosacījumus un darbības",
"expanded_view_description": "Rādīt filtra noteikumus ar detalizētiem nosacījumiem un darbībām",
"if": "Ja",
"then": "Tad",
@@ -2996,6 +3000,9 @@
"validation_period_empty": "Norādiet laika posma sākuma vai beigu datumu",
"validation_period_invalid": "Norādiet laika posmam derīgu datumu ar laiku vai atstājiet lauku tukšu",
"apply_existing_period": "Noteikums ar laika posmu darbojas tikai ar ziņojumiem, kas pienāk šajā posmā, tāpēc to nevar lietot esošajiem ziņojumiem.",
"apply_existing_in": "Piemērot arī vēstulēm, kas jau ir mapē {folder}",
"apply_existing_count": "{count, plural, zero {Piemērot arī # vēstulēm, kas jau ir mapē {folder}} one {Piemērot arī # vēstulei, kas jau ir mapē {folder}} other {Piemērot arī # vēstulēm, kas jau ir mapē {folder}}}",
"apply_existing_none": "Mapē {folder} vēl nav nevienas vēstules, kas atbilst šim noteikumam.",
"period_status_scheduled": "ieplānots",
"period_status_active": "aktīvs",
"period_status_expired": "beidzies"
@@ -3510,6 +3517,48 @@
"description": "Izrakstīties no šī konta šajā ierīcē."
}
},
"uxp2_settings": {
"search": {
"tray": "tekne sistēmas tekne paziņojumu apgabals fonā minimizēt tray",
"autostart": "automātiska palaišana startēšana palaist ar Windows pierakstoties",
"default_app": "noklusējuma pasta lietotne noklusējuma e-pasta programma mailto e-pasta saites",
"menu_bar": "izvēlņu josla izvēlnes Alt taustiņš Fails Rediģēt Skats",
"updates": "atjauninājumi atjauninājums jauna versija beta kanāls",
"devtools": "izstrādātāja rīki devtools konsole pārbaudīt F12 atkļūdošana",
"taskbar": "uzdevumjosla mirgošana Dock uzmanība",
"quiet_hours": "klusuma stundas netraucēt klusums nakts",
"telemetry": "lietojuma informācija telemetrija statistika analītika",
"crash": "avāriju ziņojumi kļūdu ziņojumi avārija",
"threads": "pavedieni sarunas grupēt threads",
"block_images": "bloķēt attēlus ārējie attēli ielādēt attēlus",
"dark_mode": "tumšais režīms gaišais režīms nakts režīms dark mode",
"timezone": "laika josla timezone",
"reading_pane": "lasīšanas rūts priekšskatījuma rūts dalīts skats",
"week_start": "nedēļas sākums pirmā diena pirmdiena svētdiena",
"empty_trash": "iztukšot atkritni atkritne dzēstie vienumi",
"do_not_disturb": "klusuma stundas izslēgt skaņu klusums"
},
"desktop_notifications": {
"title": "Šajā datorā",
"closed_label": "Ar aizvērtu logu",
"closed_description": "{appName} ziņo par jaunu pastu, kamēr tā darbojas. Lai pēc loga aizvēršanas tā turpinātu darboties teknē, ieslēdziet to sadaļā {page}."
},
"push_unsupported": "Šī pārlūkprogramma nevar rādīt paziņojumus, kad vietne ir aizvērta.",
"backup_description_app": "Izmantojiet tos pašus iestatījumus citās savās ierīcēs.",
"sender_favicons": {
"label": "Attēli no uzņēmumu vietnēm",
"description": "Rādīt vietņu ikonas kā sūtītāju avatarus"
},
"privacy_push": {
"label": "Paziņojumu retranslators",
"description": "Paziņojumi, kamēr {appName} ir aizvērta, iet caur push retranslatoru, kas nekad neredz jūsu pastu. Tos ieslēdz vai izslēdz lapā {page}."
},
"reset": {
"message": "Atjauno visus iestatījumus sākotnējā stāvoklī. Jūsu pasts, filtri, automātiskā atbilde un paraksti paliek, kā ir.",
"sync_on": "Jūsu citas ierīces izmanto tos pašus iestatījumus, tāpēc mainās arī tās.",
"sync_off": "Jūsu citas ierīces saglabā savus iestatījumus."
}
},
"setup": {
"title": "Bulwark Webmail iestatīšana",
"subtitle": "Iestatiet savu tīmekļa pasta instanci pārlūkā.",
@@ -7329,6 +7378,39 @@
"app_lock_timeout_hours": "{count, plural, zero {# stundu} one {# stunda} other {# stundas}}",
"desktop_attention_title": "Šajā datorā"
},
"uxs2_ident": {
"your_name": {
"label": "Jūsu vārds",
"description": "Šo vārdu cilvēki redz blakus jūsu adresei, kad sūtāt viņiem vēstuli.",
"saved": "Jūsu vārds ir saglabāts.",
"error": "Jūsu vārdu neizdevās saglabāt. Mēģiniet vēlreiz."
},
"addresses": {
"description": "Adreses, no kurām varat sūtīt, katrai savs vārds un paraksts.",
"label": "Jūsu adreses",
"count": "{count, plural, =0 {Vēl nav adrešu} zero {# adrešu} one {# adrese} other {# adreses}}",
"manage": "Rediģēt adreses…",
"modal_title": "Jūsu e-pasta adreses",
"add": "Pievienot adresi",
"edit": "Rediģēt adresi",
"added": "Adrese pievienota.",
"saved": "Adrese saglabāta.",
"deleted": "Adrese izdzēsta.",
"delete_title": "Vai dzēst šo adresi?"
},
"signatures": {
"title": "Paraksti",
"description": "Tiek pievienots jūsu rakstīto ziņojumu beigās. Katrai adresei ir savs.",
"saved": "Paraksts saglabāts.",
"error": "Parakstu neizdevās saglabāt. Mēģiniet vēlreiz.",
"no_addresses": "Vēl nav adreses, no kuras sūtīt."
},
"search": {
"addresses": "identitāte identitātes aizstājvārds sūtītājs sūtītāja adrese no adrese identity alias sender",
"signatures": "paraksts e-pasta paraksts kājene identitāte sūtītājs no adrese signature",
"your_name": "parādāmais vārds pilns vārds sūtītāja vārds display name"
}
},
"desktop_sign_in": {
"server_needed": "Vispirms ievadiet servera adresi.",
"server_declined": "Jūs neatļāvāt šo serveri, tāpēc tam nekas netika nosūtīts.",
@@ -7371,10 +7453,12 @@
"server_looking": "Meklējam jūsu pasta serveri…",
"server_found": "Pasta serveris: {server}",
"server_change": "Mainīt",
"server_change_hint": "Ierakstiet citu servera adresi.",
"server_not_found": "Šai adresei pasta serveri neatradām. Ievadiet to šeit, piemēram, mail.example.com.",
"server_needed": "Ievadiet sava pasta servera adresi.",
"server_invalid": "Tā nav servera adrese. Rakstiet to kā mail.example.com.",
"email_needed": "Vispirms ievadiet savu e-pasta adresi.",
"other_ways": "Citi pierakstīšanās veidi",
"trouble": "Neizdodas?",
"trouble_cors": "Jūsu pasta serverim jāatļauj pieteikšanās no šīs vietnes. Ja pieteikties neizdodas, palūdziet pasta servera uzturētājam to atļaut.",
"provider_unsupported": "{provider} nepiedāvā JMAP, veidu, kā Bulwark sazinās ar pasta serveriem, tāpēc Bulwark tam vēl nevar pieslēgties tieši."
+84
View File
@@ -174,6 +174,7 @@
},
"clear_search": "Хайлтыг арилгах",
"vacation_active": "Амралтын хариулагч идэвхтэй байна",
"vacation_turn_off": "Унтраах",
"demo_banner": "Демо горим",
"demo_reset": "Дахин тохируулах",
"demo_tour": "Аялал",
@@ -1405,6 +1406,8 @@
"from_description": "Шууд эхлүүлэхийн тулд хоосон үлдээнэ үү.",
"until_label": "Дуусах (заавал биш)",
"until_description": "Унтраах хүртэл хариулсаар байхын тулд хоосон үлдээнэ үү.",
"from_placeholder": "Одоо даруй",
"until_placeholder": "Таныг унтраах хүртэл",
"audience_all": "Бүгдэд",
"audience_internal": "Зөвхөн миний байгууллагад ({domains})",
"audience_external": "Зөвхөн байгууллагаас гадуурх хүмүүст",
@@ -2925,6 +2928,7 @@
"open_sieve_editor": "Raw Sieve редакторыг нээнэ үү",
"fetch_error": "Шүүлтүүрийг ачаалж чадсангүй",
"expanded_view": "Өргөтгөсөн харах",
"show_rule_details": "Дүрэм бүрийн нөхцөл, үйлдлийг харуулах",
"expanded_view_description": "Шүүлтүүрийн дүрмийг нарийвчилсан нөхцөл, үйлдлийн блокоор харуулах",
"if": "Хэрэв",
"then": "Дараа нь",
@@ -3003,6 +3007,9 @@
"validation_period_empty": "Хугацааны эхлэх эсвэл төгсгөлийн огноог оруулна уу",
"validation_period_invalid": "Хугацаанд цагтай хүчинтэй огноо оруулах эсвэл талбарыг хоосон үлдээнэ үү",
"apply_existing_period": "Хугацаатай дүрэм зөвхөн тэр хугацаанд ирсэн мессежид үйлчилдэг тул одоо байгаа мессежид хэрэглэх боломжгүй.",
"apply_existing_in": "{folder} доторх байгаа захианд бас хэрэглэх",
"apply_existing_count": "{count, plural, one {{folder} доторх байгаа # захианд бас хэрэглэх} other {{folder} доторх байгаа # захианд бас хэрэглэх}}",
"apply_existing_none": "{folder} дотор энэ дүрэмд тохирох захиа одоогоор алга.",
"period_status_scheduled": "төлөвлөсөн",
"period_status_active": "идэвхтэй",
"period_status_expired": "хугацаа дууссан"
@@ -3510,6 +3517,48 @@
"description": "Энэ төхөөрөмж дээр энэ бүртгэлээс гарах."
}
},
"uxp2_settings": {
"search": {
"tray": "тавиур системийн тавиур мэдэгдлийн хэсэг арын горим жижигрүүлэх tray",
"autostart": "автоматаар эхлэх эхлэл Windows-той хамт эхлэх нэвтрэхэд",
"default_app": "үндсэн шуудангийн апп үндсэн имэйл програм mailto имэйлийн холбоос",
"menu_bar": "цэсний мөр цэс Alt товч Файл Засах Харах",
"updates": "шинэчлэлт шинэ хувилбар бета суваг",
"devtools": "хөгжүүлэгчийн хэрэгсэл devtools консол шалгах F12 алдаа засах",
"taskbar": "ажлын самбар анивчих Dock анхаарал",
"quiet_hours": "чимээгүй цаг саад бүү болго дуугүй шөнө",
"telemetry": "ашиглалтын мэдээлэл телеметр статистик шинжилгээ",
"crash": "гацалтын тайлан алдааны тайлан гацалт",
"threads": "урсгал харилцан яриа бүлэглэх threads",
"block_images": "зураг хаах гадны зураг зураг ачаалах",
"dark_mode": "харанхуй горим гэрэлтэй горим шөнийн горим dark mode",
"timezone": "цагийн бүс timezone",
"reading_pane": "унших хэсэг урьдчилан харах хэсэг хуваасан харагдац",
"week_start": "долоо хоногийн эхлэл эхний өдөр даваа ням",
"empty_trash": "хогийн савыг хоослох хогийн сав устгасан зүйлс",
"do_not_disturb": "чимээгүй цаг дуугүй чимээгүй"
},
"desktop_notifications": {
"title": "Энэ компьютер дээр",
"closed_label": "Цонх хаалттай үед",
"closed_description": "{appName} ажиллаж байх хугацаанд шинэ захидлын тухай мэдэгдэнэ. Цонхоо хаасны дараа тавиур дээр үргэлжлүүлэн ажиллуулахыг хүсвэл {page} хэсэгт асаана уу."
},
"push_unsupported": "Энэ хөтөч вэбсайт хаалттай үед мэдэгдэл харуулж чадахгүй.",
"backup_description_app": "Бусад төхөөрөмж дээрээ ижил тохиргоог ашиглана.",
"sender_favicons": {
"label": "Компаниудын вэбсайтын зураг",
"description": "Вэб сайтын дүрсийг бизнес илгээгчдэд профайл зураг болгон харуулах"
},
"privacy_push": {
"label": "Мэдэгдлийн реле",
"description": "{appName} хаалттай үеийн мэдэгдэл push релеээр дамждаг бөгөөд реле таны захидлыг хэзээ ч хардаггүй. Тэдгээрийг {page} хуудсанд асааж унтраана."
},
"reset": {
"message": "Бүх тохиргоог анхны байдалд нь буцаана. Таны захидал, шүүлтүүр, автомат хариу, гарын үсэг хэвээр үлдэнэ.",
"sync_on": "Таны бусад төхөөрөмж ижил тохиргоог ашигладаг тул тэд ч мөн өөрчлөгдөнө.",
"sync_off": "Таны бусад төхөөрөмж тохиргоогоо хадгална."
}
},
"setup": {
"title": "Bulwark Webmail-ийн тохиргоо",
"subtitle": "Вэб шуудангийн инстансаа хөтчөөс тохируулна уу.",
@@ -7329,6 +7378,39 @@
"app_lock_timeout_hours": "{count, plural, one {# цаг} other {# цаг}}",
"desktop_attention_title": "Энэ компьютер дээр"
},
"uxs2_ident": {
"your_name": {
"label": "Таны нэр",
"description": "Та хүмүүст захидал илгээхэд энэ нэр таны хаягийн хажууд харагдана.",
"saved": "Таны нэр хадгалагдлаа.",
"error": "Таны нэрийг хадгалж чадсангүй. Дахин оролдоно уу."
},
"addresses": {
"description": "Таны илгээж болох хаягууд, тус бүр өөрийн нэр, гарын үсэгтэй.",
"label": "Таны хаягууд",
"count": "{count, plural, =0 {Одоогоор хаяг алга} one {# хаяг} other {# хаяг}}",
"manage": "Хаягуудыг засах…",
"modal_title": "Таны имэйл хаягууд",
"add": "Хаяг нэмэх",
"edit": "Хаягийг засах",
"added": "Хаяг нэмэгдлээ.",
"saved": "Хаяг хадгалагдлаа.",
"deleted": "Хаяг устгагдлаа.",
"delete_title": "Энэ хаягийг устгах уу?"
},
"signatures": {
"title": "Гарын үсгүүд",
"description": "Таны бичсэн захидлын төгсгөлд нэмэгдэнэ. Хаяг бүр өөрийн гарын үсэгтэй.",
"saved": "Гарын үсэг хадгалагдлаа.",
"error": "Гарын үсгийг хадгалж чадсангүй. Дахин оролдоно уу.",
"no_addresses": "Илгээх хаяг одоогоор алга."
},
"search": {
"addresses": "хувийн мэдээлэл таних тэмдэг өөр нэр илгээгч илгээгчийн хаяг хэнээс хаяг identity alias sender",
"signatures": "гарын үсэг имэйлийн гарын үсэг хөл хэсэг илгээгч хэнээс хаяг signature",
"your_name": "харагдах нэр бүтэн нэр илгээгчийн нэр display name"
}
},
"desktop_sign_in": {
"server_needed": "Эхлээд серверийн хаягаа оруулна уу.",
"server_declined": "Та энэ серверийг зөвшөөрөөгүй тул юу ч илгээгээгүй.",
@@ -7371,10 +7453,12 @@
"server_looking": "Таны шуудангийн серверийг хайж байна…",
"server_found": "Шуудангийн сервер: {server}",
"server_change": "Өөрчлөх",
"server_change_hint": "Өөр серверийн хаяг бичнэ үү.",
"server_not_found": "Энэ хаягийн шуудангийн сервер олдсонгүй. Энд оруулна уу, жишээ нь mail.example.com.",
"server_needed": "Шуудангийн серверийнхээ хаягийг оруулна уу.",
"server_invalid": "Энэ серверийн хаяг биш байна. mail.example.com гэх мэтээр бичнэ үү.",
"email_needed": "Эхлээд имэйл хаягаа оруулна уу.",
"other_ways": "Нэвтрэх бусад арга",
"trouble": "Асуудал гарч байна уу?",
"trouble_cors": "Таны шуудангийн сервер энэ вэб сайтаас нэвтрэхийг зөвшөөрөх ёстой. Нэвтэрч чадахгүй бол шуудангийн серверээ хариуцдаг хүнээс зөвшөөрөхийг хүснэ үү.",
"provider_unsupported": "{provider} нь Bulwark шуудангийн сервертэй харилцдаг JMAP-ыг санал болгодоггүй тул Bulwark одоогоор түүнтэй шууд холбогдож чадахгүй."

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