Merge branch 'feat/do-uxs-vacation' into feat/do-ux-settings-fixes

This commit is contained in:
Linus Rath
2026-11-04 08:33:14 +01:00
41 changed files with 1380 additions and 358 deletions
@@ -44,8 +44,7 @@ describe('identity signature editor and the organisation signature', () => {
const locked = screen.getByTestId('identity-locked-signature');
expect(locked.innerHTML).toContain('Alice<br>Phone: +49 1');
expect(locked.querySelector('[data-org-locked]')).not.toBeNull();
expect(screen.queryByLabelText('text_signature_label')).toBeNull();
expect(screen.queryByLabelText('html_signature_label')).toBeNull();
expect(screen.queryByText('signature_label')).toBeNull();
// Saving leaves the identity's own signature as it is.
fireEvent.submit(locked.closest('form')!);
@@ -58,13 +57,13 @@ describe('identity signature editor and the organisation signature', () => {
setPolicy({ hidden: true });
render(<IdentityForm identity={ALICE} onSave={vi.fn()} onCancel={() => {}} />);
expect(screen.queryByTestId('identity-locked-signature')).toBeNull();
expect(screen.queryByLabelText('text_signature_label')).toBeNull();
expect(screen.queryByText('signature_label')).toBeNull();
});
it('keeps the editors, with a hint, when not restricted', () => {
it('keeps the editor, with a hint, when not restricted', () => {
setPolicy();
render(<IdentityForm identity={ALICE} onSave={vi.fn()} onCancel={() => {}} />);
expect(screen.getByLabelText('text_signature_label')).toBeInTheDocument();
expect(screen.getByText('signature_label')).toBeInTheDocument();
expect(screen.getByTestId('identity-org-signature-default')).toHaveTextContent('identity_default');
expect(screen.queryByTestId('identity-locked-signature')).toBeNull();
});
@@ -0,0 +1,96 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import React from 'react';
import { IdentityForm } from '../identity-form';
import { useSettingsStore } from '@/stores/settings-store';
import type { Identity } from '@/lib/jmap/types';
// The signature in the identity editor: one field with the composer's
// formatting editor, its plain-text part generated, "Edit as HTML" only with
// advanced settings on, a size line only near the server's limit, and a
// plain text box for someone who writes plain text only.
// The editor hands the form its HTML as typed.
vi.mock('@/components/email/rich-text-editor', () => ({
RichTextEditor: ({ content, onChange }: { content: string; onChange: (html: string) => void }) => (
<textarea data-testid="signature-rich-editor" value={content} onChange={(e) => onChange(e.target.value)} />
),
}));
const ANN = { id: 'id-ann', name: 'Ann', email: 'ann@example.org', mayDelete: true, textSignature: '', htmlSignature: '' } as Identity;
const editor = () => screen.getByTestId('signature-rich-editor') as HTMLTextAreaElement;
const submit = () => fireEvent.submit(document.getElementById('identity-name')!.closest('form')!);
afterEach(() => {
useSettingsStore.setState({ showAdvancedSettings: false, plainTextMode: false });
});
describe('the signature field', () => {
it('is one formatted field, and a save sends the HTML with a plain text made from it', async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
render(<IdentityForm identity={ANN} onSave={onSave} onCancel={() => {}} />);
// No box for typing HTML, and no second text box.
expect(screen.queryByLabelText('html_signature_label')).toBeNull();
expect(screen.queryByLabelText('text_signature_label')).toBeNull();
expect(screen.getByText('signature_label')).toBeInTheDocument();
fireEvent.change(editor(), { target: { value: '<p>Ann Lee</p><p><a href="https://ann.example">ann.example</a></p>' } });
submit();
await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
const sent = onSave.mock.calls[0][0];
expect(sent.htmlSignature).toBe('<p>Ann Lee</p><p><a href="https://ann.example">ann.example</a></p>');
expect(sent.textSignature).toMatch(/^Ann Lee\s+ann\.example/);
});
it('opens a text-only signature in the editor', () => {
render(<IdentityForm identity={{ ...ANN, textSignature: 'Cheers,\nAnn' }} onSave={vi.fn()} onCancel={() => {}} />);
expect(editor().value).toBe('<p>Cheers,<br>Ann</p>');
});
it('offers Edit as HTML only with advanced settings on', async () => {
const { unmount } = render(<IdentityForm identity={{ ...ANN, htmlSignature: '<p>Ann</p>' }} onSave={vi.fn()} onCancel={() => {}} />);
expect(screen.queryByRole('button', { name: 'edit_as_html' })).toBeNull();
unmount();
useSettingsStore.setState({ showAdvancedSettings: true });
const onSave = vi.fn().mockResolvedValue(undefined);
render(<IdentityForm identity={{ ...ANN, htmlSignature: '<p>Ann</p>' }} onSave={onSave} onCancel={() => {}} />);
fireEvent.click(screen.getByRole('button', { name: 'edit_as_html' }));
const source = screen.getByLabelText('html_source_label') as HTMLTextAreaElement;
expect(source.value).toBe('<p>Ann</p>');
fireEvent.change(source, { target: { value: '<p>Ann <i>Lee</i></p>' } });
submit();
await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
expect(onSave.mock.calls[0][0]).toMatchObject({ htmlSignature: '<p>Ann <i>Lee</i></p>', textSignature: expect.stringMatching(/^Ann\s+Lee$/) });
});
it('shows the size only near the server limit, and will not save a signature that is too long', async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
render(<IdentityForm identity={ANN} onSave={onSave} onCancel={() => {}} />);
fireEvent.change(editor(), { target: { value: '<p>Ann</p>' } });
expect(screen.queryByTestId('signature-size')).toBeNull();
fireEvent.change(editor(), { target: { value: `<p>${'a'.repeat(1900)}</p>` } });
expect(screen.getByTestId('signature-size').textContent).toMatch(/^signature_size/);
fireEvent.change(editor(), { target: { value: `<p>${'a'.repeat(2100)}</p>` } });
expect(screen.getByTestId('signature-size').textContent).toMatch(/^signature_too_long/);
submit();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(onSave).not.toHaveBeenCalled();
});
it('is a plain text box for someone who writes plain text only, and keeps the HTML version', async () => {
useSettingsStore.setState({ plainTextMode: true });
const onSave = vi.fn().mockResolvedValue(undefined);
render(<IdentityForm identity={{ ...ANN, htmlSignature: '<p><b>Ann</b></p>', textSignature: 'Ann' }} onSave={onSave} onCancel={() => {}} />);
expect(screen.queryByTestId('signature-rich-editor')).toBeNull();
const box = screen.getByLabelText('signature_plain_label') as HTMLTextAreaElement;
expect(box.value).toBe('Ann');
fireEvent.change(box, { target: { value: 'Ann Lee' } });
submit();
await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
expect(onSave.mock.calls[0][0]).toMatchObject({ textSignature: 'Ann Lee', htmlSignature: '<p><b>Ann</b></p>' });
});
});
+21 -99
View File
@@ -10,31 +10,8 @@ import { getEmailValidationError, validateEmailList } from '@/lib/validation';
import { getPlainTextSignature } from '@/lib/signature-utils';
import { useOrgSignatureSetup } from '@/hooks/use-org-signature';
import { OrgLockedNote } from '@/components/settings/org-locked-note';
// Stalwarts JMAP Identity/set caps signature fields at 2047 UTF-8 bytes
const SIGNATURE_MAX_BYTES = 2047;
const utf8Encoder = new TextEncoder();
function utf8ByteLength(s: string): number {
return utf8Encoder.encode(s).length;
}
function truncateToUtf8Bytes(s: string, maxBytes: number): string {
if (utf8ByteLength(s) <= maxBytes) return s;
let lo = 0;
let hi = s.length;
while (lo < hi) {
const mid = (lo + hi + 1) >>> 1;
if (utf8ByteLength(s.slice(0, mid)) <= maxBytes) lo = mid;
else hi = mid - 1;
}
// Don't split a surrogate pair: if we landed right after a high surrogate, back off one code unit.
if (lo > 0) {
const prev = s.charCodeAt(lo - 1);
if (prev >= 0xD800 && prev <= 0xDBFF) lo -= 1;
}
return s.slice(0, lo);
}
import { useSettingsStore } from '@/stores/settings-store';
import { SignatureEditor, SIGNATURE_MAX_BYTES, signatureBytes } from './signature-editor';
interface IdentityFormData {
name: string;
@@ -56,9 +33,10 @@ interface IdentityFormProps {
export function IdentityForm({ identity, onSave, onCancel, onDirtyChange }: IdentityFormProps) {
const t = useTranslations('identities.form');
const tValidation = useTranslations('identities.validation_errors');
const tDisplay = useTranslations('identities.display');
const tOrg = useTranslations('org_signature');
const isEditing = !!identity;
// Composing > Plain text only: the signature is plain text too.
const plainTextMode = useSettingsStore((s) => s.plainTextMode);
const [formData, setFormData] = useState<IdentityFormData>({
name: identity?.name || '',
@@ -133,6 +111,11 @@ export function IdentityForm({ identity, onSave, onCancel, onDirtyChange }: Iden
}
}
// Too long for the server: the field says so under it; nothing is cut off.
if (signatureEditable && signatureBytes(formData.htmlSignature ?? '', formData.textSignature ?? '', plainTextMode) > SIGNATURE_MAX_BYTES) {
newErrors.signature = 'too_long';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
@@ -303,58 +286,20 @@ export function IdentityForm({ identity, onSave, onCancel, onDirtyChange }: Iden
/>
)}
{signatureEditable && (<>
{/* Text Signature */}
<div>
<label htmlFor="identity-text-sig" className="block text-sm font-medium mb-1">
{t('text_signature_label')}
</label>
<textarea
id="identity-text-sig"
value={formData.textSignature ?? ''}
onChange={(e) => setFormData({ ...formData, textSignature: truncateToUtf8Bytes(e.target.value, SIGNATURE_MAX_BYTES) })}
rows={3}
{signatureEditable && (
<SignatureEditor
html={formData.htmlSignature ?? ''}
text={formData.textSignature ?? ''}
plainOnly={plainTextMode}
disabled={isSubmitting}
aria-label={t('text_signature_label')}
aria-describedby="identity-text-sig-counter"
className="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground transition-all duration-200 placeholder:text-muted-foreground hover:border-muted-foreground focus-visible:outline-hidden focus-visible:border-ring disabled:cursor-not-allowed disabled:opacity-50"
onChange={(next) => setFormData({ ...formData, ...next })}
note={org.signature ? (
<p className="text-xs text-muted-foreground mt-1" data-testid="identity-org-signature-default">
{tOrg('identity_default')}
</p>
) : null}
/>
<SignatureByteCounter id="identity-text-sig-counter" value={formData.textSignature || ''} />
{org.signature && (
<p className="text-xs text-muted-foreground mt-1" data-testid="identity-org-signature-default">
{tOrg('identity_default')}
</p>
)}
</div>
{/* HTML Signature */}
<div>
<label htmlFor="identity-html-sig" className="block text-sm font-medium mb-1">
{t('html_signature_label')}
</label>
<textarea
id="identity-html-sig"
value={formData.htmlSignature ?? ''}
onChange={(e) => setFormData({ ...formData, htmlSignature: truncateToUtf8Bytes(e.target.value, SIGNATURE_MAX_BYTES) })}
rows={5}
disabled={isSubmitting}
aria-label={t('html_signature_label')}
aria-describedby="identity-html-sig-counter"
className="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground font-mono transition-all duration-200 placeholder:text-muted-foreground hover:border-muted-foreground focus-visible:outline-hidden focus-visible:border-ring disabled:cursor-not-allowed disabled:opacity-50"
/>
<SignatureByteCounter id="identity-html-sig-counter" value={formData.htmlSignature || ''} />
{formData.htmlSignature && (
<div className="mt-2 p-2 border rounded bg-muted">
<div className="text-xs text-muted-foreground mb-1">{tDisplay('preview')}</div>
<div
dangerouslySetInnerHTML={{
__html: sanitizeSignatureHtmlForDisplay(formData.htmlSignature)
}}
/>
</div>
)}
</div>
</>)}
)}
{/* Actions */}
<div className="flex justify-end gap-2 pt-4">
@@ -399,26 +344,3 @@ function LockedSignature({ html, text }: { html: string; text: string }) {
</div>
);
}
function SignatureByteCounter({ id, value }: { id: string; value: string }) {
const t = useTranslations('identities.form');
const bytes = utf8ByteLength(value);
const atLimit = bytes >= SIGNATURE_MAX_BYTES;
const nearLimit = !atLimit && bytes >= Math.floor(SIGNATURE_MAX_BYTES * 0.9);
const tone = atLimit
? 'text-destructive'
: nearLimit
? 'text-amber-600 dark:text-amber-400'
: 'text-muted-foreground';
return (
<p
id={id}
className={`text-xs mt-1 tabular-nums ${tone}`}
role="status"
aria-live="polite"
>
{t('signature_byte_counter', { bytes, max: SIGNATURE_MAX_BYTES })}
{atLimit && <span className="ms-1">{t('signature_byte_limit_reached')}</span>}
</p>
);
}
+168
View File
@@ -0,0 +1,168 @@
'use client';
import { useId, useState, type ReactNode } from 'react';
import { useTranslations } from 'next-intl';
import { RichTextEditor } from '@/components/email/rich-text-editor';
import { sanitizeSignatureHtml, sanitizeSignatureHtmlForDisplay } from '@/lib/email-sanitization';
import { htmlToPlainText } from '@/lib/html-to-text';
import { textToParagraphHtml } from '@/lib/text-to-paragraph-html';
import { useSettingsStore } from '@/stores/settings-store';
import { cn } from '@/lib/utils';
/** Stalwart's JMAP Identity/set caps each signature field at 2047 UTF-8 bytes. */
export const SIGNATURE_MAX_BYTES = 2047;
/** The size shows from here on, so it is there before the limit is reached. */
const SHOW_SIZE_FROM = Math.floor(SIGNATURE_MAX_BYTES * 0.9);
const utf8Encoder = new TextEncoder();
const utf8ByteLength = (value: string) => utf8Encoder.encode(value).length;
/** The plain-text part a save sends with a formatted signature. */
export function textFromSignatureHtml(html: string): string {
const clean = sanitizeSignatureHtml(html);
return clean ? htmlToPlainText(clean).trim() : '';
}
/** Whether a formatted signature holds anything: text, or a picture. */
function hasSignatureContent(html: string): boolean {
const clean = sanitizeSignatureHtml(html);
return !!clean && (!!htmlToPlainText(clean).trim() || /<img\b/i.test(clean));
}
/**
* The bytes the larger of the two fields takes as a save sends them: the
* sanitized HTML and the plain text. A plain-text-only signature sends its
* text alone.
*/
export function signatureBytes(html: string, text: string, plainOnly: boolean): number {
const textBytes = utf8ByteLength(text);
if (plainOnly) return textBytes;
return Math.max(textBytes, utf8ByteLength(html.trim() ? sanitizeSignatureHtml(html) : ''));
}
export interface SignatureChange {
htmlSignature?: string;
textSignature?: string;
}
/**
* One signature field. With formatting it is the composer's editor (bold,
* links, lists, tables); the plain-text version is generated from it on every
* change, so the two can't drift apart. "Edit as HTML" (shown with advanced
* settings on) swaps in the HTML source. Someone who writes plain text only
* (Composing) gets a plain text box, which leaves a stored HTML version alone.
*/
export function SignatureEditor({ html, text, plainOnly, disabled, onChange, note }: {
html: string;
text: string;
plainOnly: boolean;
disabled?: boolean;
onChange: (change: SignatureChange) => void;
/** A line under the field (the organisation's default signature). */
note?: ReactNode;
}) {
const t = useTranslations('uxs_vacation');
const showAdvanced = useSettingsStore((s) => s.showAdvancedSettings);
const [source, setSource] = useState(false);
const labelId = useId();
const sizeId = useId();
const sourceId = useId();
const bytes = signatureBytes(html, text, plainOnly);
const tooLong = bytes > SIGNATURE_MAX_BYTES;
const sizeShown = bytes >= SHOW_SIZE_FROM;
const editHtml = (value: string) => {
const kept = hasSignatureContent(value) ? value : '';
onChange({ htmlSignature: kept, textSignature: kept ? textFromSignatureHtml(kept) : '' });
};
return (
<div>
<div className="flex items-center justify-between gap-2 mb-1">
<span id={labelId} className="block text-sm font-medium">
{plainOnly ? t('signature_plain_label') : t('signature_label')}
</span>
{!plainOnly && (showAdvanced || source) && (
<button
type="button"
className="text-xs text-primary hover:underline"
onClick={() => setSource((on) => !on)}
aria-pressed={source}
disabled={disabled}
>
{source ? t('edit_formatted') : t('edit_as_html')}
</button>
)}
</div>
{plainOnly ? (
<>
<textarea
value={text}
onChange={(e) => onChange({ textSignature: e.target.value })}
rows={4}
disabled={disabled}
aria-labelledby={labelId}
aria-describedby={sizeShown ? sizeId : undefined}
aria-invalid={tooLong}
className={cn(
'flex w-full rounded-md border bg-background px-3 py-2 text-sm text-foreground transition-all duration-200 hover:border-muted-foreground focus-visible:outline-hidden focus-visible:border-ring disabled:cursor-not-allowed disabled:opacity-50',
tooLong ? 'border-destructive' : 'border-input',
)}
/>
<p className="text-xs text-muted-foreground mt-1">{t('signature_plain_description')}</p>
</>
) : source ? (
<>
<textarea
id={sourceId}
value={html}
onChange={(e) => editHtml(e.target.value)}
rows={6}
disabled={disabled}
aria-label={t('html_source_label')}
aria-describedby={sizeShown ? sizeId : undefined}
aria-invalid={tooLong}
className={cn(
'flex w-full rounded-md border bg-background px-3 py-2 text-sm text-foreground font-mono transition-all duration-200 hover:border-muted-foreground focus-visible:outline-hidden focus-visible:border-ring disabled:cursor-not-allowed disabled:opacity-50',
tooLong ? 'border-destructive' : 'border-input',
)}
/>
{html.trim() && (
<div
className="mt-2 p-2 border rounded bg-muted text-sm break-words min-w-0"
data-testid="signature-source-preview"
dangerouslySetInnerHTML={{ __html: sanitizeSignatureHtmlForDisplay(html) }}
/>
)}
</>
) : (
<div className={cn('rounded-md border overflow-hidden', tooLong ? 'border-destructive' : 'border-input')}>
<RichTextEditor
content={html || textToParagraphHtml(text)}
onChange={editHtml}
ariaLabel={t('signature_label')}
ariaInvalid={tooLong}
ariaDescribedBy={sizeShown ? sizeId : undefined}
/>
</div>
)}
{sizeShown && (
<p
id={sizeId}
data-testid="signature-size"
role="status"
aria-live="polite"
className={cn('text-xs mt-1 tabular-nums', tooLong ? 'text-destructive' : 'text-amber-600 dark:text-amber-400')}
>
{tooLong
? t('signature_too_long', { bytes, max: SIGNATURE_MAX_BYTES })
: t('signature_size', { bytes, max: SIGNATURE_MAX_BYTES })}
</p>
)}
{note}
</div>
);
}
@@ -112,8 +112,8 @@ describe('forwarding in the vacation card', () => {
expect(screen.getByText('forward.title')).toBeInTheDocument();
fireEvent.click(screen.getByRole('switch', { name: 'status.label' }));
fireEvent.click(forwardSwitch());
fireEvent.change(addressInput(), { target: { value: 'kollege@example.com' } });
fireEvent.click(forwardSwitch());
await save(server);
expect(saved()).toBe(true);
@@ -136,8 +136,8 @@ describe('forwarding in the vacation card', () => {
const server = stalwart([rule('a')]);
await openCard(server);
// The auto-reply stays off; only forwarding is switched on.
fireEvent.click(forwardSwitch());
fireEvent.change(addressInput(), { target: { value: 'kollege@example.com' } });
fireEvent.click(forwardSwitch());
await save(server);
expect(saved()).toBe(true);
@@ -153,8 +153,8 @@ describe('forwarding in the vacation card', () => {
const server = stalwart([]);
await openCard(server);
fireEvent.click(screen.getByRole('switch', { name: 'status.label' }));
fireEvent.click(forwardSwitch());
fireEvent.change(addressInput(), { target: { value: 'kollege@example.com' } });
fireEvent.click(forwardSwitch());
await save(server);
expect(server.active()).toBe('filters');
@@ -170,27 +170,46 @@ describe('forwarding in the vacation card', () => {
const server = stalwart([rule('a')]);
await openCard(server);
fireEvent.click(screen.getByRole('switch', { name: 'status.label' }));
fireEvent.click(forwardSwitch());
fireEvent.change(addressInput(), { target: { value: 'kollege@example.com' } });
fireEvent.click(forwardSwitch());
fireEvent.click(keepSwitch());
await save(server);
expect(server.content('filters')).toContain(' redirect :copy "kollege@example.com";');
expect(server.content('filters')).not.toContain(' stop;');
});
it('will not save forwarding without a usable address', async () => {
it('will not switch forwarding on without a usable address, and says to enter one', async () => {
const server = stalwart([rule('a')]);
await openCard(server);
// The address field is there before forwarding is on.
expect(addressInput().value).toBe('');
fireEvent.click(forwardSwitch());
expect(forwardSwitch().getAttribute('aria-checked')).toBe('false');
expect(screen.getByText('enter_address_first')).toBeInTheDocument();
fireEvent.change(addressInput(), { target: { value: 'kollege' } });
expect(screen.getByText('warnings.forward_address')).toBeInTheDocument();
fireEvent.click(forwardSwitch());
expect(forwardSwitch().getAttribute('aria-checked')).toBe('false');
await settle();
expect(server.client.setVacationResponse).not.toHaveBeenCalled();
fireEvent.change(addressInput(), { target: { value: 'kollege@example.com' } });
expect(screen.queryByText('warnings.forward_address')).toBeNull();
expect(screen.queryByText('enter_address_first')).toBeNull();
fireEvent.click(forwardSwitch());
expect(forwardSwitch().getAttribute('aria-checked')).toBe('true');
await save(server);
});
it('will not save forwarding that is on once its address is made unusable', async () => {
const server = stalwart([rule('a')]);
await openCard(server);
fireEvent.change(addressInput(), { target: { value: 'kollege@example.com' } });
fireEvent.click(forwardSwitch());
await save(server);
fireEvent.change(addressInput(), { target: { value: 'kollege' } });
expect(screen.getByText('warnings.forward_address')).toBeInTheDocument();
await settle();
expect(server.client.setVacationResponse).toHaveBeenCalledTimes(1);
});
it('does not count an address typed and switched off again as a change', async () => {
const server = stalwart([rule('a')]);
await openCard(server);
@@ -205,8 +224,8 @@ describe('forwarding in the vacation card', () => {
it('warns that a forwarding rule is skipped when a copy is kept and the server allows one redirect', async () => {
const server = stalwart([rule('f', { actions: [{ type: 'forward', value: 'chef@example.com' }] })], { maxNumberRedirects: 1 });
await openCard(server);
fireEvent.click(forwardSwitch());
fireEvent.change(addressInput(), { target: { value: 'kollege@example.com' } });
fireEvent.click(forwardSwitch());
expect(screen.queryByText(/^forward_limit/)).toBeNull();
fireEvent.click(keepSwitch());
expect(screen.getByText('forward_limit 1')).toBeInTheDocument();
@@ -216,8 +235,8 @@ describe('forwarding in the vacation card', () => {
const server = stalwart([rule('a')]);
await openCard(server);
fireEvent.click(screen.getByRole('switch', { name: 'status.label' }));
fireEvent.click(forwardSwitch());
fireEvent.change(addressInput(), { target: { value: 'kollege@example.com' } });
fireEvent.click(forwardSwitch());
await save(server);
// Back as stored after the save, as the server has it now.
@@ -256,9 +275,9 @@ describe('forwarding in the vacation card', () => {
const server = stalwart([rule('a')]);
await openCard(server);
server.client.updateSieveScript.mockRejectedValueOnce(new Error('server said no'));
fireEvent.change(addressInput(), { target: { value: 'kollege@example.com' } });
fireEvent.click(screen.getByRole('switch', { name: 'status.label' }));
fireEvent.click(forwardSwitch());
fireEvent.change(addressInput(), { target: { value: 'kollege@example.com' } });
await save(server);
expect(toast.error).toHaveBeenCalledWith('vacation_filters_save_failed');
expect(saved()).toBe(false);
@@ -318,8 +337,8 @@ describe('forwarding in the vacation card', () => {
it('starts over on another account, even where both store the same', async () => {
const server = stalwart([rule('a')]);
await openCard(server);
fireEvent.click(forwardSwitch());
fireEvent.change(addressInput(), { target: { value: 'chef@a.example' } });
fireEvent.click(forwardSwitch());
// Neither account has forwarding stored, so no stored value changes.
act(() => { useAuthStore.setState({ activeAccountId: 'other-login' }); });
@@ -406,7 +425,7 @@ describe('who gets the auto-reply, in the vacation card', () => {
withIdentities();
await openCard(stalwart([rule('a')]));
expect(audienceSelect().value).toBe('all');
expect(screen.getByText('audience.description example.com, example.org')).toBeInTheDocument();
expect(screen.getByRole('option', { name: 'audience_internal example.com, example.org' })).toBeInTheDocument();
});
it('is not offered without identities to tell the own domains', async () => {
@@ -511,20 +530,25 @@ describe('saving the out-of-office card', () => {
describe('layout of the out-of-office card', () => {
const follows = (a: Node, b: Node) => (a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0;
it('puts the period first, then the auto-reply and forwarding, each with its own switch and state', async () => {
it('puts the switch first, then the optional dates, the message and forwarding, each with its own switch and state', async () => {
await openCard(stalwart([rule('a')]));
const [page, reply, forwarding] = ['title', 'auto_reply.title', 'forward.title'].map((key) => screen.getByText(key));
const start = screen.getByText('date_range.start');
expect(follows(page, start)).toBe(true);
expect(follows(start, reply)).toBe(true);
expect(follows(reply, forwarding)).toBe(true);
expect(screen.queryByText('date_range.title')).toBeNull();
const [page, message, forwarding] = ['title', 'message_title', 'forward.title'].map((key) => screen.getByText(key));
const replySwitch = screen.getByRole('switch', { name: 'status.label' });
const [from, until] = ['from_label', 'until_label'].map((key) => screen.getByText(key));
expect(follows(page, replySwitch)).toBe(true);
expect(follows(replySwitch, from)).toBe(true);
expect(follows(from, until)).toBe(true);
expect(follows(until, message)).toBe(true);
expect(follows(message, forwarding)).toBe(true);
// One message editor, no second "formatted message" box.
expect(screen.queryByText('message.html_label')).toBeNull();
// Two switches, each saying whether it is on, independent of the other.
const replyRow = screen.getByRole('switch', { name: 'status.label' }).parentElement!;
const forwardRow = forwardSwitch().parentElement!;
expect(within(replyRow).getByText('status.inactive')).toBeInTheDocument();
expect(within(forwardRow).getByText('status.inactive')).toBeInTheDocument();
fireEvent.change(addressInput(), { target: { value: 'kollege@example.com' } });
fireEvent.click(forwardSwitch());
expect(within(forwardRow).getByText('status.active')).toBeInTheDocument();
expect(within(replyRow).getByText('status.inactive')).toBeInTheDocument();
@@ -1,108 +0,0 @@
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import React from 'react';
import { mockStalwartAccount } from '@/lib/filters/__tests__/sieve-mock';
import { useAuthStore } from '@/stores/auth-store';
import { useVacationStore } from '@/stores/vacation-store';
import { VacationSettings } from '../vacation-settings';
// The HTML reply and its plain-text part in the real vacation card: a save
// derives the text from the HTML when the Body is left blank, and what the
// server then holds must not look like a change still waiting to be saved.
const toast = vi.hoisted(() => ({ error: vi.fn(), success: vi.fn(), info: vi.fn() }));
vi.mock('@/stores/toast-store', () => ({ toast }));
vi.mock('next-intl', () => {
const t = (key: string, values?: Record<string, unknown>) => (values ? `${key} ${Object.values(values).join(' ')}` : key);
return { useTranslations: () => t, useLocale: () => 'en' };
});
// The editor hands the card its HTML as typed.
vi.mock('@/components/email/rich-text-editor', () => ({
RichTextEditor: ({ content, onChange }: { content: string; onChange: (html: string) => void }) => (
<textarea data-testid="html-editor" value={content} onChange={(e) => onChange(e.target.value)} />
),
}));
const initial = { auth: useAuthStore.getState(), vacation: useVacationStore.getState() };
afterEach(() => {
useAuthStore.setState(initial.auth, true);
useVacationStore.setState(initial.vacation, true);
});
type Server = ReturnType<typeof mockStalwartAccount>;
async function openCard(server: Server) {
useAuthStore.setState({ client: server.client as never });
useVacationStore.setState({ isSupported: true });
render(<VacationSettings />);
await waitFor(() => expect(useVacationStore.getState().isLoading).toBe(false));
await screen.findByText('status.label');
}
const body = () => screen.getByPlaceholderText('message.body_placeholder') as HTMLTextAreaElement;
const htmlSwitch = () => screen.getByRole('switch', { name: 'message.html_label' });
const editor = () => screen.getByTestId('html-editor') as HTMLTextAreaElement;
const sends = (server: Server) => server.client.setVacationResponse.mock.calls.length;
const lastSent = (server: Server) =>
server.client.setVacationResponse.mock.calls[sends(server) - 1][0] as { textBody: string; htmlBody: string | null };
async function saved(server: Server, count: number) {
await waitFor(() => expect(sends(server)).toBe(count), { timeout: 3000 });
await waitFor(() => expect(useVacationStore.getState().isSaving).toBe(false));
}
/** Long enough for a save that was due to have started, twice over. */
const settle = () => act(() => new Promise((resolve) => setTimeout(resolve, 2500)));
describe('HTML auto-reply and its plain-text part', () => {
it('saves an HTML reply with a blank Body once, with the text derived from it', async () => {
const server = mockStalwartAccount('b', [], ['vacation'], { vacation: { textBody: '' } });
await openCard(server);
fireEvent.click(htmlSwitch());
fireEvent.change(editor(), { target: { value: '<p>Away until Monday.</p>' } });
await saved(server, 1);
expect(lastSent(server)).toMatchObject({ htmlBody: '<p>Away until Monday.</p>', textBody: 'Away until Monday.' });
await settle();
expect(sends(server)).toBe(1);
expect(screen.getByRole('status').textContent).toMatch(/saved/);
}, 15_000);
it('saves HTML the sanitizer rewrites once', async () => {
const server = mockStalwartAccount('b', [], ['vacation'], { vacation: { textBody: '' } });
await openCard(server);
fireEvent.change(body(), { target: { value: 'Away.' } });
fireEvent.click(htmlSwitch());
fireEvent.change(editor(), { target: { value: '<p>Away<br/>until Monday.</p>' } });
await saved(server, 1);
expect(lastSent(server).htmlBody).toBe('<p>Away<br>until Monday.</p>');
await settle();
expect(sends(server)).toBe(1);
}, 15_000);
it('derives the text again after a reload, and keeps it when HTML is switched off', async () => {
const server = mockStalwartAccount('b', [], ['vacation'], {
vacation: { htmlBody: '<p>Back on 5 May.</p>', textBody: 'Back on 5 May.' },
});
await openCard(server);
expect(body().value).toBe('');
fireEvent.change(editor(), { target: { value: '<p>Back on 12 May.</p>' } });
await saved(server, 1);
expect(lastSent(server)).toMatchObject({ htmlBody: '<p>Back on 12 May.</p>', textBody: 'Back on 12 May.' });
fireEvent.click(htmlSwitch());
expect(body().value).toBe('Back on 12 May.');
await saved(server, 2);
expect(lastSent(server)).toMatchObject({ htmlBody: null, textBody: 'Back on 12 May.' });
}, 15_000);
it('keeps a plain-text part the user wrote', async () => {
const server = mockStalwartAccount('b', [], ['vacation'], {
vacation: { htmlBody: '<p>Back on 5 May.</p>', textBody: 'Back soon.' },
});
await openCard(server);
expect(body().value).toBe('Back soon.');
fireEvent.change(editor(), { target: { value: '<p>Back on 12 May.</p>' } });
await saved(server, 1);
expect(lastSent(server)).toMatchObject({ htmlBody: '<p>Back on 12 May.</p>', textBody: 'Back soon.' });
}, 15_000);
});
@@ -0,0 +1,138 @@
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import React from 'react';
import { mockStalwartAccount } from '@/lib/filters/__tests__/sieve-mock';
import { useAuthStore } from '@/stores/auth-store';
import { useVacationStore } from '@/stores/vacation-store';
import { VacationSettings } from '../vacation-settings';
// The out-of-office message in the real vacation card: one editor with
// formatting, whose plain-text part a save derives; real starting text for a
// reply never written; and no way to switch on (or keep on) a blank reply.
const toast = vi.hoisted(() => ({ error: vi.fn(), success: vi.fn(), info: vi.fn() }));
vi.mock('@/stores/toast-store', () => ({ toast }));
vi.mock('next-intl', () => {
const t = (key: string, values?: Record<string, unknown>) => (values ? `${key} ${Object.values(values).join(' ')}` : key);
return { useTranslations: () => t, useLocale: () => 'en' };
});
// The editor hands the card its HTML as typed.
vi.mock('@/components/email/rich-text-editor', () => ({
RichTextEditor: ({ content, onChange }: { content: string; onChange: (html: string) => void }) => (
<textarea data-testid="message-editor" value={content} onChange={(e) => onChange(e.target.value)} />
),
}));
const initial = { auth: useAuthStore.getState(), vacation: useVacationStore.getState() };
afterEach(() => {
useAuthStore.setState(initial.auth, true);
useVacationStore.setState(initial.vacation, true);
});
type Server = ReturnType<typeof mockStalwartAccount>;
async function openCard(server: Server) {
useAuthStore.setState({ client: server.client as never });
useVacationStore.setState({ isSupported: true });
render(<VacationSettings />);
await waitFor(() => expect(useVacationStore.getState().isLoading).toBe(false));
await screen.findByText('status.label');
}
const editor = () => screen.getByTestId('message-editor') as HTMLTextAreaElement;
const subject = () => screen.getByRole('textbox', { name: 'message.subject_label' }) as HTMLInputElement;
const replySwitch = () => screen.getByRole('switch', { name: 'status.label' });
const sends = (server: Server) => server.client.setVacationResponse.mock.calls.length;
const lastSent = (server: Server) =>
server.client.setVacationResponse.mock.calls[sends(server) - 1][0] as {
isEnabled: boolean; subject: string; textBody: string; htmlBody: string | null;
};
async function saved(server: Server, count: number) {
await waitFor(() => expect(sends(server)).toBe(count), { timeout: 3000 });
await waitFor(() => expect(useVacationStore.getState().isSaving).toBe(false));
}
/** Long enough for a save that was due to have started, twice over. */
const settle = () => act(() => new Promise((resolve) => setTimeout(resolve, 2500)));
const blank = () => mockStalwartAccount('b', [], ['vacation'], { vacation: { subject: '', textBody: '', htmlBody: null } });
describe('the out-of-office message', () => {
it('starts a reply never written with real text, not a placeholder, and saves it only once used', async () => {
const server = blank();
await openCard(server);
expect(subject().value).toBe('default_subject');
expect(editor().value).toBe('<p>default_message</p>');
// Opening the page saves nothing.
await settle();
expect(sends(server)).toBe(0);
fireEvent.click(replySwitch());
await saved(server, 1);
expect(lastSent(server)).toMatchObject({
isEnabled: true,
subject: 'default_subject',
htmlBody: '<p>default_message</p>',
textBody: 'default_message',
});
}, 15_000);
it('keeps the switch off while the message is empty, and says to write one', async () => {
const server = blank();
await openCard(server);
fireEvent.change(editor(), { target: { value: '<p></p>' } });
fireEvent.click(replySwitch());
expect(replySwitch().getAttribute('aria-checked')).toBe('false');
expect(screen.getByText('write_message_first')).toBeInTheDocument();
await settle();
expect(server.client.setVacationResponse.mock.calls.some(([sent]) => (sent as { isEnabled: boolean }).isEnabled)).toBe(false);
fireEvent.change(editor(), { target: { value: '<p>Back Monday.</p>' } });
expect(screen.queryByText('write_message_first')).toBeNull();
fireEvent.click(replySwitch());
expect(replySwitch().getAttribute('aria-checked')).toBe('true');
}, 15_000);
it('does not save a reply that is on once its text is deleted', async () => {
const server = blank();
await openCard(server);
fireEvent.click(replySwitch());
await saved(server, 1);
fireEvent.change(editor(), { target: { value: '<p></p>' } });
expect(screen.getByText('write_message_first')).toBeInTheDocument();
await settle();
expect(sends(server)).toBe(1);
}, 15_000);
it('saves a formatted message once, with the plain-text part derived from it', async () => {
const server = mockStalwartAccount('b', [], ['vacation'], { vacation: { textBody: 'away' } });
await openCard(server);
// A stored text-only reply opens in the editor.
expect(editor().value).toBe('<p>away</p>');
fireEvent.change(editor(), { target: { value: '<p>Away until <b>Monday</b></p>' } });
await saved(server, 1);
expect(lastSent(server)).toMatchObject({ htmlBody: '<p>Away until <b>Monday</b></p>', textBody: 'Away until Monday' });
await settle();
expect(sends(server)).toBe(1);
expect(screen.getByRole('status').textContent).toMatch(/saved/);
}, 15_000);
it('saves HTML the sanitizer rewrites once', async () => {
const server = mockStalwartAccount('b', [], ['vacation'], { vacation: { textBody: 'away' } });
await openCard(server);
fireEvent.change(editor(), { target: { value: '<p>Away<br/>until Monday.</p>' } });
await saved(server, 1);
expect(lastSent(server).htmlBody).toBe('<p>Away<br>until Monday.</p>');
await settle();
expect(sends(server)).toBe(1);
}, 15_000);
it('switches a stored reply on without rewriting a message nobody edited', async () => {
const server = mockStalwartAccount('b', [], ['vacation'], { vacation: { htmlBody: '<p>Back on 5 May.</p>', textBody: 'Back soon.' } });
await openCard(server);
expect(editor().value).toBe('<p>Back on 5 May.</p>');
fireEvent.click(replySwitch());
await saved(server, 1);
expect(lastSent(server)).toMatchObject({ isEnabled: true, htmlBody: '<p>Back on 5 May.</p>', textBody: 'Back soon.' });
}, 15_000);
});
@@ -22,6 +22,13 @@ vi.mock('next-intl', () => {
const t = (key: string, values?: Record<string, unknown>) => (values ? `${key} ${Object.values(values).join(' ')}` : key);
return { useTranslations: () => t, useLocale: () => 'en-GB' };
});
// The message editor hands the card its HTML as typed.
vi.mock('@/components/email/rich-text-editor', () => ({
RichTextEditor: ({ content, onChange }: { content: string; onChange: (html: string) => void }) => (
<textarea data-testid="message-editor" value={content} onChange={(e) => onChange(e.target.value)} />
),
}));
const editor = () => screen.getByTestId('message-editor') as HTMLTextAreaElement;
const TEMPLATE: VacationTemplate = {
id: 'holiday',
@@ -78,7 +85,7 @@ describe('out-of-office templates', () => {
fireEvent.change(screen.getByTestId('vacation-template-select'), { target: { value: 'holiday' } });
await settle();
expect(server.client.setVacationResponse).not.toHaveBeenCalled();
expect((screen.getByPlaceholderText('message.body_placeholder') as HTMLTextAreaElement).value).toBe('Old text');
expect(editor().value).toBe('<p>Old text</p>');
// Applying without a deputy refuses and says why.
fireEvent.click(screen.getByTestId('vacation-template-apply'));
@@ -92,11 +99,11 @@ describe('out-of-office templates', () => {
fireEvent.click(screen.getByTestId('vacation-template-apply'));
// ICU versions differ in commas, not in the day.
const filled = (screen.getByPlaceholderText('message.body_placeholder') as HTMLTextAreaElement).value;
expect(filled).toMatch(/^I am away until Monday,? 4 January 2027\.\nPlease write to bob@example\.org\.\nAlice Example$/);
// The text template goes into the one message editor, line by line.
expect(editor().value).toMatch(/^<p>I am away until Monday,? 4 January 2027\.<br>Please write to bob@example\.org\.<br>Alice Example<\/p>$/);
await waitFor(() => expect(server.client.setVacationResponse).toHaveBeenCalledTimes(1), { timeout: 3000 });
const sent = server.client.setVacationResponse.mock.calls[0][0] as { subject: string; textBody: string };
expect(sent.textBody).toBe(filled);
expect(sent.textBody).toMatch(/^I am away until Monday,? 4 January 2027\.\nPlease write to bob@example\.org\.\nAlice Example$/);
expect(sent.subject).toMatch(/^Away until Monday,? 4 January 2027$/);
});
@@ -122,7 +129,6 @@ describe('out-of-office templates', () => {
expect((inputs[1] as HTMLInputElement).value).toBe('2027-01-04T08:00');
fireEvent.change(screen.getByTestId('vacation-template-select'), { target: { value: 'holiday' } });
fireEvent.click(screen.getByTestId('vacation-template-apply'));
expect((screen.getByPlaceholderText('message.body_placeholder') as HTMLTextAreaElement).value)
.toMatch(/^Back Monday,? 4 January 2027 at 0?8:00$/);
expect(editor().value).toMatch(/^<p>Back Monday,? 4 January 2027 at 0?8:00<\/p>$/);
});
});
+159 -117
View File
@@ -25,6 +25,7 @@ import { usePolicyStore } from '@/stores/policy-store';
import { useSettingsStore } from '@/stores/settings-store';
import { companyName } from '@/lib/org/signature-state';
import { fromWallClock, getWallClock, parseLocalDateTime, resolveTimeZone } from '@/lib/timezone';
import { textToParagraphHtml } from '@/lib/text-to-paragraph-html';
const STALWART_VACATION_LIMITS = { subject: 511, body: 2047 };
@@ -42,6 +43,16 @@ function byteLength(value: string): number {
return new TextEncoder().encode(value).length;
}
/** Whether HTML holds any text a recipient would read. */
function hasReadableText(html: string | null | undefined): boolean {
return !!html && !!htmlToPlainText(html).trim();
}
/** A reply the server holds with nothing in it: off, no subject, no message. */
function isBlankReply(stored: { isEnabled: boolean; subject: string; textBody: string; htmlBody: string | null }): boolean {
return !stored.isEnabled && !stored.subject.trim() && !stored.textBody.trim() && !hasReadableText(stored.htmlBody);
}
/** A switch with its state written out next to it ("Active" / "Inactive"). */
function StatusSwitch({ checked, onChange, activeLabel, inactiveLabel }: {
checked: boolean;
@@ -100,6 +111,7 @@ function sameForward(a: VacationForwardSettings | null, b: VacationForward | nul
export function VacationSettings() {
const t = useTranslations('settings.vacation');
const tx = useTranslations('uxs_vacation');
const tFilters = useTranslations('settings.filters');
const tNotifications = useTranslations('notifications');
const locale = useLocale();
@@ -159,9 +171,15 @@ export function VacationSettings() {
}, [timeZone]);
const datesUnusable = localFromDate === null || localToDate === null || halfFilled.from || halfFilled.to;
const [localSubject, setLocalSubject] = useState(subject);
const [localTextBody, setLocalTextBody] = useState(textBody);
const [htmlEnabled, setHtmlEnabled] = useState(!!htmlBody);
const [localHtmlBody, setLocalHtmlBody] = useState(htmlBody || '');
// One message editor with formatting. What it holds is HTML; the plain-text
// part a save sends is derived from it. Until the message is edited (or
// filled with the starting text) a save sends the stored parts unchanged.
const [localBody, setLocalBody] = useState(() => htmlBody || textToParagraphHtml(textBody));
const [bodyEdited, setBodyEdited] = useState(false);
// The user tried to switch the reply on with no message, or forwarding on
// with no address: said under the field until it is filled in.
const [needMessage, setNeedMessage] = useState(false);
const [needAddress, setNeedAddress] = useState(false);
const [showPreview, setShowPreview] = useState(false);
const [forwardEnabled, setForwardEnabled] = useState(forward?.enabled ?? false);
const [forwardTo, setForwardTo] = useState(forward?.to ?? '');
@@ -214,14 +232,19 @@ export function VacationSettings() {
setToInput(stored.toDate ? utcToLocalDatetime(stored.toDate, timeZone) : '');
setInputZone(timeZone);
setHalfFilled({ from: false, to: false });
setLocalSubject(stored.subject);
// Text a save derived from the HTML leaves the Body blank, so the next
// HTML edit derives it again instead of keeping the old wording.
setLocalTextBody(stored.htmlBody && stored.textBody.trim() === textFromHtml(sanitizeEmailHtml(stored.htmlBody)).trim()
? ''
: stored.textBody);
setHtmlEnabled(!!stored.htmlBody);
setLocalHtmlBody(stored.htmlBody || '');
setNeedMessage(false);
if (isBlankReply(stored)) {
// Nothing written yet: start with real text in the user's language,
// which they can change, so switching on never sends a blank reply.
// Saved with the first change the user makes, not by opening the page.
setLocalSubject(tx('default_subject'));
setLocalBody(textToParagraphHtml(tx('default_message')));
setBodyEdited(true);
} else {
setLocalSubject(stored.subject);
setLocalBody(stored.htmlBody || textToParagraphHtml(stored.textBody));
setBodyEdited(false);
}
}
// Forwarding a save didn't send takes what the server has now; one it
// sent stays as set here, also when its filters part failed and it
@@ -230,6 +253,7 @@ export function VacationSettings() {
setForwardEnabled(stored.forward?.enabled ?? false);
setForwardTo(stored.forward?.to ?? '');
setForwardKeep(stored.forward?.keepCopy ?? false);
setNeedAddress(false);
}
setAudienceOnly(stored.audience?.only ?? 'all');
// The time zone is read when the fields load, not followed while typing.
@@ -253,9 +277,15 @@ export function VacationSettings() {
touched.current = true;
saveDelay.current = SAVE_AFTER_TYPING;
if (filled.subject) setLocalSubject(filled.subject);
setLocalTextBody(filled.text);
setHtmlEnabled(!!filled.html);
setLocalHtmlBody(filled.html);
setLocalBody(filled.html || textToParagraphHtml(filled.text));
setBodyEdited(true);
};
const editBody = (html: string) => {
touched.current = true;
saveDelay.current = SAVE_AFTER_TYPING;
setLocalBody(html);
setBodyEdited(true);
};
// Reads a date field as the browser holds it, half filled in or not.
@@ -283,9 +313,11 @@ export function VacationSettings() {
// Forwarding as a save stores it: an address that is off is kept for next
// time if it is usable, and dropped otherwise.
const forwardInvalid = forwardAvailable && forwardEnabled && !isValidEmail(forwardTo);
// The address field shows while forwarding is off too, so typing one
// there before switching on is not a change of its own.
const forwardSettings = !forwardAvailable
? undefined
: forwardEnabled || isValidEmail(forwardTo)
: forwardEnabled || (forward && isValidEmail(forwardTo))
? { enabled: forwardEnabled, to: forwardTo, keepCopy: forwardKeep }
: null;
// Only what was changed here is sent: the rest stays as the server has it,
@@ -298,16 +330,15 @@ export function VacationSettings() {
forwardAvailable && forwardEnabled && forwardKeep &&
typeof forwardLimit === 'number' && forwardLimit > 0 && 1 + otherForwards > forwardLimit;
// What a save sends: the sanitized HTML and a plain-text part, derived
// from the HTML when the user left it blank (for clients without HTML).
// What a save sends: the sanitized HTML and the plain-text part derived
// from it (for mail apps without HTML). A message nobody edited goes as
// the server has it, so switching the reply on rewrites nothing.
const payload = useMemo(() => {
const sanitizedHtml =
htmlEnabled && htmlToPlainText(localHtmlBody).trim()
? sanitizeEmailHtml(localHtmlBody)
: null;
const text = localTextBody.trim() || !sanitizedHtml ? localTextBody : textFromHtml(sanitizedHtml);
return { sanitizedHtml, textBody: text };
}, [htmlEnabled, localHtmlBody, localTextBody]);
if (!bodyEdited) return { sanitizedHtml: htmlBody || null, textBody };
const sanitizedHtml = hasReadableText(localBody) ? sanitizeEmailHtml(localBody) : null;
return { sanitizedHtml, textBody: sanitizedHtml ? textFromHtml(sanitizedHtml) : '' };
}, [bodyEdited, localBody, htmlBody, textBody]);
const bodyEmpty = !payload.textBody.trim() && !hasReadableText(payload.sanitizedHtml);
// Stalwart refuses a subject of 512 bytes or more and a text or HTML body
// of 2048 bytes or more, answering only "Field could not be set."
@@ -333,10 +364,11 @@ export function VacationSettings() {
const fromWarning = !fromError && localFromDate && localFromDate !== fromDate && new Date(localFromDate) < todayStart
? t('warnings.start_in_past')
: null;
const hasHtmlContent = htmlEnabled && !!htmlToPlainText(localHtmlBody).trim();
const bodyWarning = oversize.body && sizeLimits
// An empty message never goes live: the switch won't turn on, and a reply
// that is on can't be saved with its text deleted.
const bodyError = oversize.body && sizeLimits
? t('warnings.body_too_long', { max: sizeLimits.body })
: localEnabled && !localTextBody.trim() && !hasHtmlContent ? t('warnings.empty_body') : null;
: (localEnabled || needMessage) && bodyEmpty ? tx('write_message_first') : null;
// The bodies are compared as a save sends them: the server keeps the
// sanitized HTML and the text derived from it, never what was typed.
@@ -353,7 +385,24 @@ export function VacationSettings() {
const hasBlockingError =
datesUnusable ||
!!(localFromDate && localToDate && new Date(localToDate) <= new Date(localFromDate)) ||
oversize.subject || oversize.body || forwardInvalid;
oversize.subject || oversize.body || forwardInvalid || (localEnabled && bodyEmpty);
const switchReply = (on: boolean) => {
if (on && bodyEmpty) {
setNeedMessage(true);
return;
}
setNeedMessage(false);
changeNow(setLocalEnabled)(on);
};
const switchForward = (on: boolean) => {
if (on && !isValidEmail(forwardTo)) {
setNeedAddress(true);
return;
}
setNeedAddress(false);
changeNow(setForwardEnabled)(on);
};
// What a save would store, to tell one change from the next.
const snapshot = JSON.stringify([
@@ -361,6 +410,9 @@ export function VacationSettings() {
]);
// A save that failed or was stopped isn't tried again until something changes.
const [refusedSnapshot, setRefusedSnapshot] = useState<string | null>(null);
// The same, at once: a timer set while the failed save's last render was
// on its way must not send it again.
const refusedRef = useRef<string | null>(null);
const [savedOnce, setSavedOnce] = useState(false);
const savingSnapshot = useRef<string | null>(null);
@@ -398,9 +450,13 @@ export function VacationSettings() {
// false: a plugin stopped the change and is expected to have said why.
if (saved) setSavedOnce(true);
else setRefusedSnapshot(sent);
else {
refusedRef.current = sent;
setRefusedSnapshot(sent);
}
} catch (error) {
console.error('Failed to save vacation response:', error);
refusedRef.current = sent;
setRefusedSnapshot(sent);
toast.error(tNotifications(
error instanceof VacationFiltersError ? 'vacation_filters_save_failed' : 'vacation_save_failed',
@@ -426,7 +482,10 @@ export function VacationSettings() {
useEffect(() => {
if (!canAutoSave || isSaving || !touched.current) return;
const timer = setTimeout(() => { void saveRef.current(); }, saveDelay.current);
const due = snapshot;
const timer = setTimeout(() => {
if (refusedRef.current !== due) void saveRef.current();
}, saveDelay.current);
return () => clearTimeout(timer);
}, [canAutoSave, isSaving, snapshot]);
@@ -479,11 +538,33 @@ export function VacationSettings() {
</Button>
</div>
)}
{/* The period is the absence: the auto-reply and forwarding both keep to it. */}
<SettingsSection title={t('title')} description={t('description')}>
{/* The switch first, then who gets it and when; the period applies to forwarding too. */}
<SettingsSection id="reply" title={t('title')} description={t('status.description')}>
<SettingItem label={t('status.label')}>
<StatusSwitch
checked={localEnabled}
onChange={switchReply}
activeLabel={t('status.active')}
inactiveLabel={t('status.inactive')}
/>
</SettingItem>
{!localEnabled && <VacationFilterWarning accountId={managedAccountId ?? undefined} />}
{canNarrow && (
<SettingItem label={t('audience.label')}>
<Select
value={audienceOnly}
onChange={(value) => changeNow(setAudienceOnly)(value as 'all' | 'internal' | 'external')}
options={[
{ value: 'all', label: tx('audience_all') },
{ value: 'internal', label: tx('audience_internal', { domains: domains.join(', ') }) },
{ value: 'external', label: tx('audience_external') },
]}
/>
</SettingItem>
)}
<SettingItem
label={t('date_range.start')}
description={t('date_range.start_description')}
label={tx('from_label')}
description={tx('from_description')}
>
<input
ref={fromRef}
@@ -492,6 +573,7 @@ export function VacationSettings() {
value={fromInput}
onChange={(e) => readDateField('from', e.currentTarget)}
onBlur={(e) => readDateField('from', e.currentTarget)}
aria-label={tx('from_label')}
aria-invalid={!!fromError}
aria-describedby={fromError ? 'vacation-from-error' : undefined}
className={cn(DATE_FIELD, fromError && 'border-destructive hover:border-destructive')}
@@ -500,8 +582,8 @@ export function VacationSettings() {
<FieldMessage id="vacation-from-error" text={fromError} />
<FieldMessage text={fromWarning} warning />
<SettingItem
label={t('date_range.end')}
description={t('date_range.end_description')}
label={tx('until_label')}
description={tx('until_description')}
>
<input
ref={toRef}
@@ -510,6 +592,7 @@ export function VacationSettings() {
value={toInput}
onChange={(e) => readDateField('to', e.currentTarget)}
onBlur={(e) => readDateField('to', e.currentTarget)}
aria-label={tx('until_label')}
aria-invalid={!!toError}
aria-describedby={toError ? 'vacation-to-error' : undefined}
className={cn(DATE_FIELD, toError && 'border-destructive hover:border-destructive')}
@@ -533,32 +616,7 @@ export function VacationSettings() {
onApply={applyTemplate}
/>
<SettingsSection id="reply" title={t('auto_reply.title')} description={t('status.description')}>
<SettingItem label={t('status.label')}>
<StatusSwitch
checked={localEnabled}
onChange={changeNow(setLocalEnabled)}
activeLabel={t('status.active')}
inactiveLabel={t('status.inactive')}
/>
</SettingItem>
{!localEnabled && <VacationFilterWarning accountId={managedAccountId ?? undefined} />}
{canNarrow && (
<SettingItem
label={t('audience.label')}
description={t('audience.description', { domains: domains.join(', ') })}
>
<Select
value={audienceOnly}
onChange={(value) => changeNow(setAudienceOnly)(value as 'all' | 'internal' | 'external')}
options={[
{ value: 'all', label: t('audience.all') },
{ value: 'internal', label: t('audience.internal') },
{ value: 'external', label: t('audience.external') },
]}
/>
</SettingItem>
)}
<SettingsSection id="reply-message" title={tx('message_title')}>
<SettingItem
label={t('message.subject_label')}
description={t('message.subject_description')}
@@ -568,57 +626,36 @@ export function VacationSettings() {
value={localSubject}
onChange={(e) => changeTyped(setLocalSubject)(e.target.value)}
placeholder={t('message.subject_placeholder')}
aria-label={t('message.subject_label')}
aria-invalid={oversize.subject}
className="w-64 max-w-full px-3 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground placeholder:text-muted-foreground focus:outline-hidden transition-colors duration-150 hover:border-muted-foreground"
/>
</SettingItem>
<FieldMessage text={oversize.subject && sizeLimits ? t('warnings.subject_too_long', { max: sizeLimits.subject }) : null} />
<div className="py-3">
<label htmlFor="vacation-body" className="text-sm font-medium text-foreground block mb-1">
{t('message.body_label')}
</label>
<div className="py-3" data-search-label={tx('message_label')}>
<span id="vacation-body-label" className="text-sm font-medium text-foreground block mb-1">
{tx('message_label')}
</span>
<p className="text-xs text-muted-foreground mb-2">
{t('message.body_description')}
{tx('message_description')}
</p>
<textarea
id="vacation-body"
value={localTextBody}
onChange={(e) => changeTyped(setLocalTextBody)(e.target.value)}
placeholder={t('message.body_placeholder')}
rows={6}
className="w-full px-3 py-2 text-sm rounded-md bg-muted border border-border text-foreground placeholder:text-muted-foreground focus:outline-hidden transition-colors duration-150 hover:border-muted-foreground resize-y"
/>
<FieldMessage text={bodyWarning} warning={!oversize.body} />
</div>
<SettingItem
label={t('message.html_label')}
description={t('message.html_description')}
>
<ToggleSwitch
checked={htmlEnabled}
onChange={changeNow((on: boolean) => {
// Switched off, a blank Body keeps the text derived from the HTML.
if (!on && !localTextBody.trim()) setLocalTextBody(payload.textBody);
setHtmlEnabled(on);
})}
/>
</SettingItem>
{htmlEnabled && (
<div className="pb-3">
<div className="rounded-md border border-border overflow-hidden">
<RichTextEditor
content={localHtmlBody}
onChange={changeTyped(setLocalHtmlBody)}
placeholder={t('message.html_placeholder')}
/>
</div>
<div className={cn('rounded-md border overflow-hidden', bodyError ? 'border-destructive' : 'border-border')}>
<RichTextEditor
content={localBody}
onChange={editBody}
placeholder={tx('default_message')}
ariaLabel={tx('message_label')}
ariaInvalid={!!bodyError}
ariaDescribedBy={bodyError ? 'vacation-body-error' : undefined}
/>
</div>
)}
<FieldMessage id="vacation-body-error" text={bodyError} />
</div>
</SettingsSection>
{(() => {
const showHtmlPreview = htmlEnabled && !!htmlToPlainText(localHtmlBody).trim();
if (!localTextBody.trim() && !showHtmlPreview) return null;
const showHtmlPreview = hasReadableText(payload.sanitizedHtml);
if (bodyEmpty) return null;
return (
<SettingsSection title={t('preview.title')}>
<button
@@ -647,10 +684,10 @@ export function VacationSettings() {
window.open(anchor.href, '_blank', 'noopener,noreferrer');
}
}}
dangerouslySetInnerHTML={{ __html: sanitizeEmailHtml(localHtmlBody) }}
dangerouslySetInnerHTML={{ __html: payload.sanitizedHtml ?? '' }}
/>
) : (
<p className="text-sm text-muted-foreground whitespace-pre-wrap">{localTextBody}</p>
<p className="text-sm text-muted-foreground whitespace-pre-wrap">{payload.textBody}</p>
)}
</div>
)}
@@ -663,25 +700,30 @@ export function VacationSettings() {
<SettingItem label={t('forward.enabled_label')}>
<StatusSwitch
checked={forwardEnabled}
onChange={changeNow(setForwardEnabled)}
onChange={switchForward}
activeLabel={t('status.active')}
inactiveLabel={t('status.inactive')}
/>
</SettingItem>
{/* The address shows while forwarding is off, so it can be filled in before switching on. */}
<SettingItem label={t('forward.to_label')}>
<input
type="email"
value={forwardTo}
onChange={(e) => changeTyped(setForwardTo)(e.target.value)}
placeholder={t('forward.to_placeholder')}
aria-label={t('forward.to_label')}
aria-invalid={forwardInvalid || (needAddress && !isValidEmail(forwardTo))}
className="w-64 max-w-full px-3 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground placeholder:text-muted-foreground focus:outline-hidden transition-colors duration-150 hover:border-muted-foreground"
/>
</SettingItem>
<FieldMessage
text={forwardInvalid
? t('warnings.forward_address')
: needAddress && !isValidEmail(forwardTo) ? tx('enter_address_first') : null}
/>
{forwardEnabled && (
<>
<SettingItem label={t('forward.to_label')}>
<input
type="email"
value={forwardTo}
onChange={(e) => changeTyped(setForwardTo)(e.target.value)}
placeholder={t('forward.to_placeholder')}
aria-label={t('forward.to_label')}
aria-invalid={forwardInvalid}
className="w-64 max-w-full px-3 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground placeholder:text-muted-foreground focus:outline-hidden transition-colors duration-150 hover:border-muted-foreground"
/>
</SettingItem>
<FieldMessage text={forwardInvalid ? t('warnings.forward_address') : null} />
<SettingItem label={t('forward.keep_label')} description={t('forward.keep_description')}>
<ToggleSwitch checked={forwardKeep} onChange={changeNow(setForwardKeep)} />
</SettingItem>
+4 -4
View File
@@ -268,12 +268,12 @@ export const tabSearchEntries: Record<Tab, SettingsSearchEntry[]> = {
{ label: 'settings.email_behavior.signatures_link.label' },
],
vacation: [
{ label: 'settings.vacation.date_range.start' },
{ label: 'settings.vacation.date_range.end' },
{ label: 'settings.vacation.auto_reply.title' },
{ label: 'uxs_vacation.from_label' },
{ label: 'uxs_vacation.until_label' },
{ label: 'uxs_vacation.message_title' },
{ label: 'settings.vacation.status.label' },
{ label: 'settings.vacation.message.subject_label' },
{ label: 'settings.vacation.message.html_label' },
{ label: 'uxs_vacation.message_label' },
{ label: 'settings.vacation.forward.title' },
{ label: 'settings.vacation.forward.enabled_label' },
],
+15
View File
@@ -0,0 +1,15 @@
import { escapeHtml } from '@/lib/email-sanitization';
/**
* Plain text as the formatting editor takes it: a paragraph per block of
* lines, a line break per single newline, everything escaped. Used where a
* text-only out-of-office reply or signature opens in the editor.
*/
export function textToParagraphHtml(text: string): string {
const trimmed = text.replace(/\r\n?/g, '\n').trim();
if (!trimmed) return '';
return trimmed
.split(/\n{2,}/)
.map((block) => `<p>${escapeHtml(block).replace(/\n/g, '<br>')}</p>`)
.join('');
}
+24
View File
@@ -1342,6 +1342,30 @@
"apply": "استخدام هذا النص",
"apply_hint": "يستبدل الموضوع والنص أدناه."
},
"uxs_vacation": {
"default_subject": "خارج المكتب",
"default_message": "شكرًا على رسالتك. أنا خارج المكتب حاليًا وسأرد عند عودتي.",
"write_message_first": "اكتب رسالة أولًا. بدونها سيتلقى الناس ردًا فارغًا.",
"enter_address_first": "أدخل أولًا العنوان الذي ستُعاد التوجيه إليه.",
"message_title": "ردّك",
"message_label": "الرسالة",
"message_description": "ما يتلقاه من يراسلك. يمكنك استخدام التنسيق والروابط.",
"from_label": "من (اختياري)",
"from_description": "اتركه فارغًا للبدء فورًا.",
"until_label": "حتى (اختياري)",
"until_description": "اتركه فارغًا لمواصلة الرد حتى توقفه.",
"audience_all": "الجميع",
"audience_internal": "مؤسستي فقط ({domains})",
"audience_external": "من هم خارج مؤسستي فقط",
"signature_label": "التوقيع",
"signature_plain_label": "التوقيع (نص عادي)",
"signature_plain_description": "أنت تكتب نصًا عاديًا فقط (الكتابة)، لذا توقيعك نص عادي أيضًا.",
"edit_as_html": "تحرير بتنسيق HTML",
"edit_formatted": "العودة إلى محرر التنسيق",
"html_source_label": "التوقيع بتنسيق HTML",
"signature_size": "مستخدم {bytes} من {max} بايت",
"signature_too_long": "طويل جدًا بالنسبة للخادم ({bytes} من {max} بايت). اجعله أقصر."
},
"retention_notice": {
"line": "تُحذف الرسائل هنا بعد {count, plural, one {يوم واحد} two {يومين} few {# أيام} many {# يومًا} other {# يوم}} من نقلها إلى هنا."
},
+24
View File
@@ -1342,6 +1342,30 @@
"apply": "Utilitza aquest text",
"apply_hint": "Substitueix l'assumpte i el text de sota."
},
"uxs_vacation": {
"default_subject": "Fora de l'oficina",
"default_message": "Gràcies pel teu correu. Soc fora de l'oficina i respondré quan torni.",
"write_message_first": "Escriu primer un missatge. Sense missatge, la gent rebria una resposta buida.",
"enter_address_first": "Escriu primer l'adreça a la qual reenviar.",
"message_title": "La teva resposta",
"message_label": "Missatge",
"message_description": "El que rep qui t'escriu. Pots fer servir format i enllaços.",
"from_label": "Des de (opcional)",
"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.",
"audience_all": "Tothom",
"audience_internal": "Només la meva organització ({domains})",
"audience_external": "Només persones de fora de la meva organització",
"signature_label": "Signatura",
"signature_plain_label": "Signatura (text sense format)",
"signature_plain_description": "Escrius només text sense format (Redacció), així que la signatura també ho és.",
"edit_as_html": "Edita com a HTML",
"edit_formatted": "Torna a l'editor amb format",
"html_source_label": "Signatura en HTML",
"signature_size": "{bytes} de {max} bytes utilitzats",
"signature_too_long": "Massa llarga per al servidor ({bytes} de {max} bytes). Escurça-la."
},
"retention_notice": {
"line": "Els missatges d'aquí s'eliminen {count, plural, one {# dia} other {# dies}} després de moure'ls aquí."
},
+24
View File
@@ -1342,6 +1342,30 @@
"apply": "Použít tento text",
"apply_hint": "Nahradí předmět a text níže."
},
"uxs_vacation": {
"default_subject": "Mimo kancelář",
"default_message": "Děkuji za váš e-mail. Jsem mimo kancelář a odpovím, až se vrátím.",
"write_message_first": "Nejdřív napište zprávu. Bez ní by lidé dostali prázdnou odpověď.",
"enter_address_first": "Nejdřív zadejte adresu, na kterou se má přeposílat.",
"message_title": "Vaše odpověď",
"message_label": "Zpráva",
"message_description": "Co dostanou lidé, kteří vám napíšou. Můžete použít formátování a odkazy.",
"from_label": "Od (nepovinné)",
"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.",
"audience_all": "Všem",
"audience_internal": "Jen mé organizaci ({domains})",
"audience_external": "Jen lidem mimo mou organizaci",
"signature_label": "Podpis",
"signature_plain_label": "Podpis (prostý text)",
"signature_plain_description": "Píšete jen prostý text (Psaní), takže i váš podpis je prostý text.",
"edit_as_html": "Upravit jako HTML",
"edit_formatted": "Zpět do editoru s formátováním",
"html_source_label": "Podpis jako HTML",
"signature_size": "Využito {bytes} z {max} bajtů",
"signature_too_long": "Pro server příliš dlouhý ({bytes} z {max} bajtů). Zkraťte ho."
},
"retention_notice": {
"line": "Zprávy zde se smažou {count, plural, one {# den} few {# dny} many {# dne} other {# dní}} poté, co sem byly přesunuty."
},
+24
View File
@@ -1342,6 +1342,30 @@
"apply": "Brug denne tekst",
"apply_hint": "Erstatter emnet og teksten nedenfor."
},
"uxs_vacation": {
"default_subject": "Ikke på kontoret",
"default_message": "Tak for din mail. Jeg er ikke på kontoret og svarer, når jeg er tilbage.",
"write_message_first": "Skriv en besked først. Uden den ville folk få et tomt svar.",
"enter_address_first": "Indtast først den adresse, der skal videresendes til.",
"message_title": "Dit svar",
"message_label": "Besked",
"message_description": "Det, folk får, når de skriver til dig. Du kan bruge formatering og links.",
"from_label": "Fra (valgfrit)",
"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.",
"audience_all": "Alle",
"audience_internal": "Kun min organisation ({domains})",
"audience_external": "Kun folk uden for min organisation",
"signature_label": "Signatur",
"signature_plain_label": "Signatur (ren tekst)",
"signature_plain_description": "Du skriver kun ren tekst (Skrivning), så din signatur er også ren tekst.",
"edit_as_html": "Rediger som HTML",
"edit_formatted": "Tilbage til editoren med formatering",
"html_source_label": "Signatur som HTML",
"signature_size": "{bytes} af {max} byte brugt",
"signature_too_long": "For lang til serveren ({bytes} af {max} byte). Gør den kortere."
},
"retention_notice": {
"line": "Beskeder her slettes {count, plural, one {# dag} other {# dage}} efter, at de blev flyttet hertil."
},
+24
View File
@@ -1342,6 +1342,30 @@
"apply": "Diesen Text verwenden",
"apply_hint": "Ersetzt Betreff und Text unten."
},
"uxs_vacation": {
"default_subject": "Abwesend",
"default_message": "Vielen Dank für Ihre E-Mail. Ich bin zurzeit nicht im Büro und antworte, sobald ich zurück bin.",
"write_message_first": "Schreiben Sie zuerst eine Nachricht. Ohne Text bekämen die Leute eine leere Antwort.",
"enter_address_first": "Geben Sie zuerst die Adresse ein, an die weitergeleitet wird.",
"message_title": "Ihre Antwort",
"message_label": "Nachricht",
"message_description": "Das bekommen Leute, die Ihnen schreiben. Sie können Formatierung und Links verwenden.",
"from_label": "Von (optional)",
"from_description": "Leer lassen, um sofort zu beginnen.",
"until_label": "Bis (optional)",
"until_description": "Leer lassen, um weiter zu antworten, bis Sie es ausschalten.",
"audience_all": "Allen",
"audience_internal": "Nur meiner Organisation ({domains})",
"audience_external": "Nur Leuten außerhalb meiner Organisation",
"signature_label": "Signatur",
"signature_plain_label": "Signatur (nur Text)",
"signature_plain_description": "Sie schreiben nur einfachen Text (Verfassen), daher ist auch Ihre Signatur einfacher Text.",
"edit_as_html": "Als HTML bearbeiten",
"edit_formatted": "Zurück zum Editor mit Formatierung",
"html_source_label": "Signatur als HTML",
"signature_size": "{bytes} von {max} Byte belegt",
"signature_too_long": "Zu lang für den Server ({bytes} von {max} Byte). Kürzen Sie die Signatur."
},
"retention_notice": {
"line": "Nachrichten hier werden {count, plural, one {# Tag} other {# Tage}} nach dem Verschieben hierher gelöscht."
},
+24
View File
@@ -1342,6 +1342,30 @@
"apply": "Use this text",
"apply_hint": "Replaces the subject and text below."
},
"uxs_vacation": {
"default_subject": "Out of office",
"default_message": "Thank you for your email. I'm out of the office and will reply when I'm back.",
"write_message_first": "Write a message first. Without one, people would get an empty reply.",
"enter_address_first": "Enter the address to forward to first.",
"message_title": "Your reply",
"message_label": "Message",
"message_description": "What people get when they write to you. You can use formatting and links.",
"from_label": "From (optional)",
"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.",
"audience_all": "Everyone",
"audience_internal": "Only my organisation ({domains})",
"audience_external": "Only people outside my organisation",
"signature_label": "Signature",
"signature_plain_label": "Signature (plain text)",
"signature_plain_description": "You write plain text only (Composing), so your signature is plain text too.",
"edit_as_html": "Edit as HTML",
"edit_formatted": "Back to the formatting editor",
"html_source_label": "Signature as HTML",
"signature_size": "{bytes} of {max} bytes used",
"signature_too_long": "Too long for the server ({bytes} of {max} bytes). Make it shorter."
},
"retention_notice": {
"line": "Messages here are deleted {count, plural, one {# day} other {# days}} after they were moved here."
},
+24
View File
@@ -1342,6 +1342,30 @@
"apply": "Usar este texto",
"apply_hint": "Sustituye el asunto y el texto de abajo."
},
"uxs_vacation": {
"default_subject": "Fuera de la oficina",
"default_message": "Gracias por tu correo. Estoy fuera de la oficina y responderé cuando vuelva.",
"write_message_first": "Escribe primero un mensaje. Sin él, la gente recibiría una respuesta vacía.",
"enter_address_first": "Escribe primero la dirección a la que reenviar.",
"message_title": "Tu respuesta",
"message_label": "Mensaje",
"message_description": "Lo que recibe quien te escribe. Puedes usar formato y enlaces.",
"from_label": "Desde (opcional)",
"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.",
"audience_all": "Todos",
"audience_internal": "Solo mi organización ({domains})",
"audience_external": "Solo personas de fuera de mi organización",
"signature_label": "Firma",
"signature_plain_label": "Firma (texto sin formato)",
"signature_plain_description": "Escribes solo texto sin formato (Redacción), así que tu firma también lo es.",
"edit_as_html": "Editar como HTML",
"edit_formatted": "Volver al editor con formato",
"html_source_label": "Firma en HTML",
"signature_size": "{bytes} de {max} bytes usados",
"signature_too_long": "Demasiado larga para el servidor ({bytes} de {max} bytes). Acórtala."
},
"retention_notice": {
"line": "Los mensajes de aquí se eliminan {count, plural, one {# día} other {# días}} después de moverlos aquí."
},
+24
View File
@@ -1342,6 +1342,30 @@
"apply": "Kasuta seda teksti",
"apply_hint": "Asendab allpool oleva teema ja teksti."
},
"uxs_vacation": {
"default_subject": "Kontorist eemal",
"default_message": "Aitäh kirja eest. Olen kontorist eemal ja vastan, kui tagasi olen.",
"write_message_first": "Kirjuta esmalt sõnum. Ilma selleta saaksid inimesed tühja vastuse.",
"enter_address_first": "Sisesta esmalt aadress, kuhu edasi saata.",
"message_title": "Sinu vastus",
"message_label": "Sõnum",
"message_description": "Seda saavad need, kes sulle kirjutavad. Võid kasutada vormindust ja linke.",
"from_label": "Alates (valikuline)",
"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.",
"audience_all": "Kõigile",
"audience_internal": "Ainult minu organisatsioonile ({domains})",
"audience_external": "Ainult inimestele väljastpoolt minu organisatsiooni",
"signature_label": "Allkiri",
"signature_plain_label": "Allkiri (lihttekst)",
"signature_plain_description": "Kirjutad ainult lihtteksti (Kirjutamine), seega on ka allkiri lihttekst.",
"edit_as_html": "Muuda HTML-ina",
"edit_formatted": "Tagasi vormindusega redaktorisse",
"html_source_label": "Allkiri HTML-ina",
"signature_size": "Kasutatud {bytes} / {max} baiti",
"signature_too_long": "Serveri jaoks liiga pikk ({bytes} / {max} baiti). Tee lühemaks."
},
"retention_notice": {
"line": "Siin olevad kirjad kustutatakse {count, plural, one {# päev} other {# päeva}} pärast siia teisaldamist."
},
+24
View File
@@ -1342,6 +1342,30 @@
"apply": "استفاده از این متن",
"apply_hint": "موضوع و متن زیر را جایگزین می‌کند."
},
"uxs_vacation": {
"default_subject": "خارج از دفتر",
"default_message": "از ایمیل شما سپاسگزارم. در حال حاضر خارج از دفتر هستم و پس از بازگشت پاسخ می‌دهم.",
"write_message_first": "اول یک پیام بنویسید. بدون آن، افراد پاسخی خالی دریافت می‌کنند.",
"enter_address_first": "اول نشانی‌ای را که باید به آن هدایت شود وارد کنید.",
"message_title": "پاسخ شما",
"message_label": "پیام",
"message_description": "آنچه افرادی که به شما می‌نویسند دریافت می‌کنند. می‌توانید از قالب‌بندی و پیوند استفاده کنید.",
"from_label": "از (اختیاری)",
"from_description": "برای شروع فوری خالی بگذارید.",
"until_label": "تا (اختیاری)",
"until_description": "خالی بگذارید تا پاسخ دادن تا زمانی که خاموشش کنید ادامه یابد.",
"audience_all": "همه",
"audience_internal": "فقط سازمان من ({domains})",
"audience_external": "فقط افراد بیرون از سازمان من",
"signature_label": "امضا",
"signature_plain_label": "امضا (متن ساده)",
"signature_plain_description": "شما فقط متن ساده می‌نویسید (نوشتن)، پس امضای شما هم متن ساده است.",
"edit_as_html": "ویرایش به‌صورت HTML",
"edit_formatted": "بازگشت به ویرایشگر قالب‌بندی",
"html_source_label": "امضا به‌صورت HTML",
"signature_size": "{bytes} از {max} بایت استفاده شده",
"signature_too_long": "برای سرور بیش از حد طولانی است ({bytes} از {max} بایت). کوتاه‌ترش کنید."
},
"retention_notice": {
"line": "پیام‌های اینجا {count, plural, one {# روز} other {# روز}} پس از انتقال به اینجا حذف می‌شوند."
},
+24
View File
@@ -1342,6 +1342,30 @@
"apply": "Utiliser ce texte",
"apply_hint": "Remplace l'objet et le texte ci-dessous."
},
"uxs_vacation": {
"default_subject": "Absent du bureau",
"default_message": "Merci pour votre message. Je suis absent du bureau et répondrai à mon retour.",
"write_message_first": "Écrivez d'abord un message. Sans lui, les gens recevraient une réponse vide.",
"enter_address_first": "Saisissez d'abord l'adresse vers laquelle transférer.",
"message_title": "Votre réponse",
"message_label": "Message",
"message_description": "Ce que reçoivent les personnes qui vous écrivent. Vous pouvez utiliser la mise en forme et des liens.",
"from_label": "Du (facultatif)",
"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.",
"audience_all": "Tout le monde",
"audience_internal": "Seulement mon organisation ({domains})",
"audience_external": "Seulement les personnes hors de mon organisation",
"signature_label": "Signature",
"signature_plain_label": "Signature (texte brut)",
"signature_plain_description": "Vous écrivez uniquement en texte brut (Rédaction), votre signature l'est donc aussi.",
"edit_as_html": "Modifier en HTML",
"edit_formatted": "Revenir à l'éditeur avec mise en forme",
"html_source_label": "Signature en HTML",
"signature_size": "{bytes} octets utilisés sur {max}",
"signature_too_long": "Trop longue pour le serveur ({bytes} octets sur {max}). Raccourcissez-la."
},
"retention_notice": {
"line": "Les messages ici sont supprimés {count, plural, one {# jour} other {# jours}} après y avoir été déplacés."
},
+24
View File
@@ -1285,6 +1285,30 @@
"apply": "שימוש בטקסט הזה",
"apply_hint": "מחליף את הנושא והטקסט שלמטה."
},
"uxs_vacation": {
"default_subject": "מחוץ למשרד",
"default_message": "תודה על הודעתך. אני מחוץ למשרד ואענה כשאחזור.",
"write_message_first": "כתבו קודם הודעה. בלעדיה אנשים יקבלו תשובה ריקה.",
"enter_address_first": "הזינו קודם את הכתובת להעברה.",
"message_title": "התשובה שלך",
"message_label": "הודעה",
"message_description": "מה שמקבלים מי שכותבים אליך. אפשר להשתמש בעיצוב ובקישורים.",
"from_label": "מ־ (לא חובה)",
"from_description": "השאירו ריק כדי להתחיל מיד.",
"until_label": "עד (לא חובה)",
"until_description": "השאירו ריק כדי להמשיך להשיב עד שתכבו.",
"audience_all": "כולם",
"audience_internal": "רק הארגון שלי ({domains})",
"audience_external": "רק אנשים מחוץ לארגון שלי",
"signature_label": "חתימה",
"signature_plain_label": "חתימה (טקסט פשוט)",
"signature_plain_description": "את/ה כותב/ת רק טקסט פשוט (הרכבה), ולכן גם החתימה היא טקסט פשוט.",
"edit_as_html": "עריכה כ־HTML",
"edit_formatted": "חזרה לעורך העיצוב",
"html_source_label": "חתימה כ־HTML",
"signature_size": "בשימוש {bytes} מתוך {max} בתים",
"signature_too_long": "ארוכה מדי עבור השרת ({bytes} מתוך {max} בתים). קצרו אותה."
},
"retention_notice": {
"line": "ההודעות כאן נמחקות {count, plural, one {יום אחד} two {יומיים} other {# ימים}} אחרי שהועברו לכאן."
},
+24
View File
@@ -1342,6 +1342,30 @@
"apply": "Ennek a szövegnek a használata",
"apply_hint": "Lecseréli a lenti tárgyat és szöveget."
},
"uxs_vacation": {
"default_subject": "Házon kívül",
"default_message": "Köszönöm a levelét. Jelenleg nem vagyok az irodában, visszatérésem után válaszolok.",
"write_message_first": "Előbb írjon üzenetet. Nélküle az emberek üres választ kapnának.",
"enter_address_first": "Előbb adja meg a címet, ahová továbbítson.",
"message_title": "Az Ön válasza",
"message_label": "Üzenet",
"message_description": "Ezt kapják, akik írnak Önnek. Használhat formázást és hivatkozásokat.",
"from_label": "Ettől (nem kötelező)",
"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.",
"audience_all": "Mindenkinek",
"audience_internal": "Csak a szervezetemnek ({domains})",
"audience_external": "Csak a szervezetemen kívülieknek",
"signature_label": "Aláírás",
"signature_plain_label": "Aláírás (egyszerű szöveg)",
"signature_plain_description": "Csak egyszerű szöveget ír (Írás), ezért az aláírása is egyszerű szöveg.",
"edit_as_html": "Szerkesztés HTML-ként",
"edit_formatted": "Vissza a formázó szerkesztőhöz",
"html_source_label": "Aláírás HTML-ként",
"signature_size": "{bytes} / {max} bájt felhasználva",
"signature_too_long": "Túl hosszú a szervernek ({bytes} / {max} bájt). Rövidítse le."
},
"retention_notice": {
"line": "Az itt lévő üzenetek az ide helyezésük után {count, plural, one {# nappal} other {# nappal}} törlődnek."
},
+24
View File
@@ -1342,6 +1342,30 @@
"apply": "Usa questo testo",
"apply_hint": "Sostituisce l'oggetto e il testo qui sotto."
},
"uxs_vacation": {
"default_subject": "Fuori ufficio",
"default_message": "Grazie per la tua email. Sono fuori ufficio e risponderò al mio rientro.",
"write_message_first": "Scrivi prima un messaggio. Senza, le persone riceverebbero una risposta vuota.",
"enter_address_first": "Inserisci prima l'indirizzo a cui inoltrare.",
"message_title": "La tua risposta",
"message_label": "Messaggio",
"message_description": "Ciò che riceve chi ti scrive. Puoi usare formattazione e link.",
"from_label": "Dal (facoltativo)",
"from_description": "Lascia vuoto per iniziare subito.",
"until_label": "Al (facoltativo)",
"until_description": "Lascia vuoto per continuare a rispondere finché non lo disattivi.",
"audience_all": "Tutti",
"audience_internal": "Solo la mia organizzazione ({domains})",
"audience_external": "Solo persone esterne alla mia organizzazione",
"signature_label": "Firma",
"signature_plain_label": "Firma (testo semplice)",
"signature_plain_description": "Scrivi solo testo semplice (Composizione), quindi anche la firma è testo semplice.",
"edit_as_html": "Modifica come HTML",
"edit_formatted": "Torna all'editor con formattazione",
"html_source_label": "Firma in HTML",
"signature_size": "{bytes} di {max} byte usati",
"signature_too_long": "Troppo lunga per il server ({bytes} di {max} byte). Accorciala."
},
"retention_notice": {
"line": "I messaggi qui vengono eliminati {count, plural, one {# giorno} other {# giorni}} dopo essere stati spostati qui."
},
+24
View File
@@ -1342,6 +1342,30 @@
"apply": "このテキストを使う",
"apply_hint": "下の件名と本文を置き換えます。"
},
"uxs_vacation": {
"default_subject": "不在",
"default_message": "メールをありがとうございます。現在不在のため、戻り次第お返事します。",
"write_message_first": "先にメッセージを入力してください。入力しないと、空の返信が送られます。",
"enter_address_first": "先に転送先のアドレスを入力してください。",
"message_title": "返信内容",
"message_label": "メッセージ",
"message_description": "あなたにメールを送った人が受け取る内容です。書式やリンクを使えます。",
"from_label": "開始(任意)",
"from_description": "空欄にするとすぐに始まります。",
"until_label": "終了(任意)",
"until_description": "空欄にすると、オフにするまで返信を続けます。",
"audience_all": "全員",
"audience_internal": "自分の組織のみ({domains})",
"audience_external": "組織外の人のみ",
"signature_label": "署名",
"signature_plain_label": "署名(プレーンテキスト)",
"signature_plain_description": "プレーンテキストのみで作成する設定(作成)のため、署名もプレーンテキストです。",
"edit_as_html": "HTML で編集",
"edit_formatted": "書式エディターに戻る",
"html_source_label": "HTML の署名",
"signature_size": "{max} バイト中 {bytes} バイト使用",
"signature_too_long": "サーバーには長すぎます({max} バイト中 {bytes} バイト)。短くしてください。"
},
"retention_notice": {
"line": "ここにあるメッセージは、移動されてから{count, plural, other {#日}}後に削除されます。"
},
+24
View File
@@ -1342,6 +1342,30 @@
"apply": "이 내용 사용",
"apply_hint": "아래 제목과 본문을 바꿉니다."
},
"uxs_vacation": {
"default_subject": "부재중",
"default_message": "이메일 감사합니다. 지금은 자리에 없으며 돌아오면 답장드리겠습니다.",
"write_message_first": "먼저 메시지를 작성하세요. 메시지가 없으면 빈 답장이 보내집니다.",
"enter_address_first": "먼저 전달할 주소를 입력하세요.",
"message_title": "답장 내용",
"message_label": "메시지",
"message_description": "나에게 메일을 보낸 사람이 받는 내용입니다. 서식과 링크를 쓸 수 있습니다.",
"from_label": "시작 (선택)",
"from_description": "비워 두면 바로 시작합니다.",
"until_label": "종료 (선택)",
"until_description": "비워 두면 끌 때까지 계속 답장합니다.",
"audience_all": "모든 사람",
"audience_internal": "내 조직만 ({domains})",
"audience_external": "내 조직 밖의 사람만",
"signature_label": "서명",
"signature_plain_label": "서명 (일반 텍스트)",
"signature_plain_description": "일반 텍스트로만 작성하도록 설정되어 있어(작성) 서명도 일반 텍스트입니다.",
"edit_as_html": "HTML로 편집",
"edit_formatted": "서식 편집기로 돌아가기",
"html_source_label": "HTML 서명",
"signature_size": "{max}바이트 중 {bytes}바이트 사용",
"signature_too_long": "서버에 비해 너무 깁니다({max}바이트 중 {bytes}바이트). 줄여 주세요."
},
"retention_notice": {
"line": "여기 있는 메일은 이곳으로 옮겨진 지 {count, plural, other {#일}} 후에 삭제됩니다."
},
+24
View File
@@ -1342,6 +1342,30 @@
"apply": "Izmantot šo tekstu",
"apply_hint": "Aizstāj tālāk esošo tēmu un tekstu."
},
"uxs_vacation": {
"default_subject": "Ārpus biroja",
"default_message": "Paldies par vēstuli. Esmu ārpus biroja un atbildēšu, kad atgriezīšos.",
"write_message_first": "Vispirms uzrakstiet ziņojumu. Bez tā cilvēki saņemtu tukšu atbildi.",
"enter_address_first": "Vispirms ievadiet adresi, uz kuru pārsūtīt.",
"message_title": "Jūsu atbilde",
"message_label": "Ziņojums",
"message_description": "To saņem tie, kas jums raksta. Varat izmantot formatējumu un saites.",
"from_label": "No (neobligāti)",
"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.",
"audience_all": "Visiem",
"audience_internal": "Tikai manai organizācijai ({domains})",
"audience_external": "Tikai cilvēkiem ārpus manas organizācijas",
"signature_label": "Paraksts",
"signature_plain_label": "Paraksts (vienkāršs teksts)",
"signature_plain_description": "Jūs rakstāt tikai vienkāršu tekstu (Rakstīšana), tāpēc arī paraksts ir vienkāršs teksts.",
"edit_as_html": "Rediģēt kā HTML",
"edit_formatted": "Atpakaļ uz formatēšanas redaktoru",
"html_source_label": "Paraksts kā HTML",
"signature_size": "Izmantoti {bytes} no {max} baitiem",
"signature_too_long": "Serverim par garu ({bytes} no {max} baitiem). Saīsiniet to."
},
"retention_notice": {
"line": "Šeit esošie ziņojumi tiek dzēsti {count, plural, zero {# dienas} one {# dienu} other {# dienas}} pēc to pārvietošanas šeit."
},
+24
View File
@@ -1342,6 +1342,30 @@
"apply": "Энэ текстийг ашиглах",
"apply_hint": "Доорх гарчиг болон текстийг солино."
},
"uxs_vacation": {
"default_subject": "Ажлаас гадуур",
"default_message": "Имэйл илгээсэнд баярлалаа. Би одоогоор ажлаас гадуур байгаа бөгөөд буцаж ирээд хариулна.",
"write_message_first": "Эхлээд мессеж бичнэ үү. Үгүй бол хүмүүс хоосон хариу авна.",
"enter_address_first": "Эхлээд дамжуулах хаягаа оруулна уу.",
"message_title": "Таны хариу",
"message_label": "Мессеж",
"message_description": "Танд бичсэн хүмүүсийн авах зүйл. Хэлбэржүүлэлт, холбоос ашиглаж болно.",
"from_label": "Эхлэх (заавал биш)",
"from_description": "Шууд эхлүүлэхийн тулд хоосон үлдээнэ үү.",
"until_label": "Дуусах (заавал биш)",
"until_description": "Унтраах хүртэл хариулсаар байхын тулд хоосон үлдээнэ үү.",
"audience_all": "Бүгдэд",
"audience_internal": "Зөвхөн миний байгууллагад ({domains})",
"audience_external": "Зөвхөн байгууллагаас гадуурх хүмүүст",
"signature_label": "Гарын үсэг",
"signature_plain_label": "Гарын үсэг (энгийн текст)",
"signature_plain_description": "Та зөвхөн энгийн текстээр бичдэг (Зохиоглох) тул гарын үсэг тань ч энгийн текст байна.",
"edit_as_html": "HTML хэлбэрээр засах",
"edit_formatted": "Хэлбэржүүлэх засварлагч руу буцах",
"html_source_label": "HTML гарын үсэг",
"signature_size": "{max} байтаас {bytes} байт ашигласан",
"signature_too_long": "Серверт хэт урт байна ({max} байтаас {bytes} байт). Богиносгоно уу."
},
"retention_notice": {
"line": "Энд байгаа захиа энд шилжүүлснээс хойш {count, plural, other {# хоногийн}} дараа устна."
},
+24
View File
@@ -1342,6 +1342,30 @@
"apply": "Bruk denne teksten",
"apply_hint": "Erstatter emnet og teksten nedenfor."
},
"uxs_vacation": {
"default_subject": "Ikke på kontoret",
"default_message": "Takk for e-posten. Jeg er ikke på kontoret og svarer når jeg er tilbake.",
"write_message_first": "Skriv en melding først. Uten den ville folk fått et tomt svar.",
"enter_address_first": "Skriv først inn adressen det skal videresendes til.",
"message_title": "Svaret ditt",
"message_label": "Melding",
"message_description": "Dette får de som skriver til deg. Du kan bruke formatering og lenker.",
"from_label": "Fra (valgfritt)",
"from_description": "La feltet stå tomt for å starte med en gang.",
"until_label": "Til (valgfritt)",
"until_description": "La feltet stå tomt for å fortsette å svare til du slår det av.",
"audience_all": "Alle",
"audience_internal": "Bare organisasjonen min ({domains})",
"audience_external": "Bare folk utenfor organisasjonen min",
"signature_label": "Signatur",
"signature_plain_label": "Signatur (ren tekst)",
"signature_plain_description": "Du skriver bare ren tekst (Skriving), så signaturen din er også ren tekst.",
"edit_as_html": "Rediger som HTML",
"edit_formatted": "Tilbake til redigeringsverktøyet med formatering",
"html_source_label": "Signatur som HTML",
"signature_size": "{bytes} av {max} byte brukt",
"signature_too_long": "For lang for serveren ({bytes} av {max} byte). Gjør den kortere."
},
"retention_notice": {
"line": "Meldinger her slettes {count, plural, one {# dag} other {# dager}} etter at de ble flyttet hit."
},
+24
View File
@@ -1342,6 +1342,30 @@
"apply": "Deze tekst gebruiken",
"apply_hint": "Vervangt het onderwerp en de tekst hieronder."
},
"uxs_vacation": {
"default_subject": "Afwezig",
"default_message": "Bedankt voor je e-mail. Ik ben niet op kantoor en antwoord zodra ik terug ben.",
"write_message_first": "Schrijf eerst een bericht. Zonder bericht krijgen mensen een leeg antwoord.",
"enter_address_first": "Vul eerst het adres in om naar door te sturen.",
"message_title": "Je antwoord",
"message_label": "Bericht",
"message_description": "Wat mensen krijgen als ze je mailen. Je kunt opmaak en links gebruiken.",
"from_label": "Vanaf (optioneel)",
"from_description": "Laat leeg om meteen te beginnen.",
"until_label": "Tot (optioneel)",
"until_description": "Laat leeg om te blijven antwoorden tot je het uitzet.",
"audience_all": "Iedereen",
"audience_internal": "Alleen mijn organisatie ({domains})",
"audience_external": "Alleen mensen buiten mijn organisatie",
"signature_label": "Handtekening",
"signature_plain_label": "Handtekening (platte tekst)",
"signature_plain_description": "Je schrijft alleen platte tekst (Opstellen), dus je handtekening is ook platte tekst.",
"edit_as_html": "Bewerken als HTML",
"edit_formatted": "Terug naar de editor met opmaak",
"html_source_label": "Handtekening als HTML",
"signature_size": "{bytes} van {max} bytes gebruikt",
"signature_too_long": "Te lang voor de server ({bytes} van {max} bytes). Maak hem korter."
},
"retention_notice": {
"line": "Berichten hier worden {count, plural, one {# dag} other {# dagen}} nadat ze hierheen zijn verplaatst verwijderd."
},
+24
View File
@@ -1342,6 +1342,30 @@
"apply": "Użyj tego tekstu",
"apply_hint": "Zastępuje temat i tekst poniżej."
},
"uxs_vacation": {
"default_subject": "Poza biurem",
"default_message": "Dziękuję za wiadomość. Jestem poza biurem i odpowiem po powrocie.",
"write_message_first": "Najpierw napisz wiadomość. Bez niej ludzie dostaliby pustą odpowiedź.",
"enter_address_first": "Najpierw wpisz adres, na który przekazywać.",
"message_title": "Twoja odpowiedź",
"message_label": "Wiadomość",
"message_description": "To dostaną osoby, które do Ciebie piszą. Możesz używać formatowania i linków.",
"from_label": "Od (opcjonalnie)",
"from_description": "Zostaw puste, aby zacząć od razu.",
"until_label": "Do (opcjonalnie)",
"until_description": "Zostaw puste, aby odpowiadać, dopóki tego nie wyłączysz.",
"audience_all": "Wszystkim",
"audience_internal": "Tylko mojej organizacji ({domains})",
"audience_external": "Tylko osobom spoza mojej organizacji",
"signature_label": "Podpis",
"signature_plain_label": "Podpis (zwykły tekst)",
"signature_plain_description": "Piszesz tylko zwykłym tekstem (Tworzenie), więc podpis też jest zwykłym tekstem.",
"edit_as_html": "Edytuj jako HTML",
"edit_formatted": "Wróć do edytora z formatowaniem",
"html_source_label": "Podpis w HTML",
"signature_size": "Użyto {bytes} z {max} bajtów",
"signature_too_long": "Za długi dla serwera ({bytes} z {max} bajtów). Skróć go."
},
"retention_notice": {
"line": "Wiadomości są tu usuwane {count, plural, one {# dzień} few {# dni} many {# dni} other {# dnia}} po przeniesieniu."
},
+24
View File
@@ -1342,6 +1342,30 @@
"apply": "Usar este texto",
"apply_hint": "Substitui o assunto e o texto abaixo."
},
"uxs_vacation": {
"default_subject": "Fora do escritório",
"default_message": "Obrigado pelo seu e-mail. Estou fora do escritório e responderei quando voltar.",
"write_message_first": "Escreva primeiro uma mensagem. Sem ela, as pessoas receberiam uma resposta vazia.",
"enter_address_first": "Introduza primeiro o endereço para onde reencaminhar.",
"message_title": "A sua resposta",
"message_label": "Mensagem",
"message_description": "O que recebem as pessoas que lhe escrevem. Pode usar formatação e ligações.",
"from_label": "De (opcional)",
"from_description": "Deixe vazio para começar já.",
"until_label": "Até (opcional)",
"until_description": "Deixe vazio para continuar a responder até desligar.",
"audience_all": "Todos",
"audience_internal": "Só a minha organização ({domains})",
"audience_external": "Só pessoas de fora da minha organização",
"signature_label": "Assinatura",
"signature_plain_label": "Assinatura (texto simples)",
"signature_plain_description": "Escreve apenas texto simples (Composição), por isso a assinatura também é texto simples.",
"edit_as_html": "Editar como HTML",
"edit_formatted": "Voltar ao editor com formatação",
"html_source_label": "Assinatura em HTML",
"signature_size": "{bytes} de {max} bytes usados",
"signature_too_long": "Demasiado longa para o servidor ({bytes} de {max} bytes). Encurte-a."
},
"retention_notice": {
"line": "As mensagens aqui são eliminadas {count, plural, one {# dia} other {# dias}} depois de serem movidas para cá."
},
+24
View File
@@ -1342,6 +1342,30 @@
"apply": "Folosiți acest text",
"apply_hint": "Înlocuiește subiectul și textul de mai jos."
},
"uxs_vacation": {
"default_subject": "În afara biroului",
"default_message": "Vă mulțumesc pentru e-mail. Sunt în afara biroului și voi răspunde când mă întorc.",
"write_message_first": "Scrieți mai întâi un mesaj. Fără el, oamenii ar primi un răspuns gol.",
"enter_address_first": "Introduceți mai întâi adresa către care se redirecționează.",
"message_title": "Răspunsul dvs.",
"message_label": "Mesaj",
"message_description": "Ce primesc cei care vă scriu. Puteți folosi formatare și linkuri.",
"from_label": "De la (opțional)",
"from_description": "Lăsați gol pentru a începe imediat.",
"until_label": "Până la (opțional)",
"until_description": "Lăsați gol pentru a răspunde până îl dezactivați.",
"audience_all": "Tuturor",
"audience_internal": "Doar organizației mele ({domains})",
"audience_external": "Doar persoanelor din afara organizației mele",
"signature_label": "Semnătură",
"signature_plain_label": "Semnătură (text simplu)",
"signature_plain_description": "Scrieți doar text simplu (Redactarea), deci și semnătura este text simplu.",
"edit_as_html": "Editați ca HTML",
"edit_formatted": "Înapoi la editorul cu formatare",
"html_source_label": "Semnătură HTML",
"signature_size": "{bytes} din {max} octeți folosiți",
"signature_too_long": "Prea lungă pentru server ({bytes} din {max} octeți). Scurtați-o."
},
"retention_notice": {
"line": "Mesajele de aici sunt șterse la {count, plural, one {# zi} few {# zile} other {# de zile}} după ce au fost mutate aici."
},
+24
View File
@@ -1342,6 +1342,30 @@
"apply": "Использовать этот текст",
"apply_hint": "Заменяет тему и текст ниже."
},
"uxs_vacation": {
"default_subject": "Нет на месте",
"default_message": "Спасибо за письмо. Меня сейчас нет на месте, я отвечу, когда вернусь.",
"write_message_first": "Сначала напишите сообщение. Без него люди получат пустой ответ.",
"enter_address_first": "Сначала введите адрес для пересылки.",
"message_title": "Ваш ответ",
"message_label": "Сообщение",
"message_description": "Это получат те, кто вам напишет. Можно использовать форматирование и ссылки.",
"from_label": "С (необязательно)",
"from_description": "Оставьте пустым, чтобы начать сразу.",
"until_label": "По (необязательно)",
"until_description": "Оставьте пустым, чтобы отвечать, пока вы это не выключите.",
"audience_all": "Всем",
"audience_internal": "Только моей организации ({domains})",
"audience_external": "Только тем, кто вне моей организации",
"signature_label": "Подпись",
"signature_plain_label": "Подпись (простой текст)",
"signature_plain_description": "Вы пишете только простым текстом (Написание), поэтому и подпись — простой текст.",
"edit_as_html": "Изменить как HTML",
"edit_formatted": "Вернуться к редактору с форматированием",
"html_source_label": "Подпись в HTML",
"signature_size": "Использовано {bytes} из {max} байт",
"signature_too_long": "Слишком длинная для сервера ({bytes} из {max} байт). Сократите её."
},
"retention_notice": {
"line": "Письма здесь удаляются через {count, plural, one {# день} few {# дня} many {# дней} other {# дня}} после перемещения сюда."
},
+24
View File
@@ -1342,6 +1342,30 @@
"apply": "Použiť tento text",
"apply_hint": "Nahradí predmet a text nižšie."
},
"uxs_vacation": {
"default_subject": "Mimo kancelárie",
"default_message": "Ďakujem za váš e-mail. Som mimo kancelárie a odpoviem, keď sa vrátim.",
"write_message_first": "Najprv napíšte správu. Bez nej by ľudia dostali prázdnu odpoveď.",
"enter_address_first": "Najprv zadajte adresu, na ktorú sa má preposielať.",
"message_title": "Vaša odpoveď",
"message_label": "Správa",
"message_description": "Toto dostanú ľudia, ktorí vám napíšu. Môžete použiť formátovanie a odkazy.",
"from_label": "Od (nepovinné)",
"from_description": "Nechajte prázdne, aby sa začalo hneď.",
"until_label": "Do (nepovinné)",
"until_description": "Nechajte prázdne, aby sa odpovedalo, kým to nevypnete.",
"audience_all": "Všetkým",
"audience_internal": "Len mojej organizácii ({domains})",
"audience_external": "Len ľuďom mimo mojej organizácie",
"signature_label": "Podpis",
"signature_plain_label": "Podpis (obyčajný text)",
"signature_plain_description": "Píšete len obyčajný text (Písanie), takže aj váš podpis je obyčajný text.",
"edit_as_html": "Upraviť ako HTML",
"edit_formatted": "Späť do editora s formátovaním",
"html_source_label": "Podpis ako HTML",
"signature_size": "Využitých {bytes} z {max} bajtov",
"signature_too_long": "Pre server príliš dlhý ({bytes} z {max} bajtov). Skráťte ho."
},
"retention_notice": {
"line": "Správy tu sa vymažú {count, plural, one {# deň} few {# dni} many {# dňa} other {# dní}} po presunutí sem."
},
+24
View File
@@ -709,6 +709,30 @@
"apply": "ใช้ข้อความนี้",
"apply_hint": "แทนที่หัวเรื่องและข้อความด้านล่าง"
},
"uxs_vacation": {
"default_subject": "ไม่อยู่ที่สำนักงาน",
"default_message": "ขอบคุณสำหรับอีเมล ตอนนี้ฉันไม่อยู่ที่สำนักงาน และจะตอบกลับเมื่อกลับมา",
"write_message_first": "เขียนข้อความก่อน ถ้าไม่มีข้อความ ผู้คนจะได้รับคำตอบว่างเปล่า",
"enter_address_first": "ใส่ที่อยู่ที่จะส่งต่อไปก่อน",
"message_title": "คำตอบของคุณ",
"message_label": "ข้อความ",
"message_description": "สิ่งที่คนที่เขียนถึงคุณจะได้รับ ใช้การจัดรูปแบบและลิงก์ได้",
"from_label": "ตั้งแต่ (ไม่บังคับ)",
"from_description": "เว้นว่างไว้เพื่อเริ่มทันที",
"until_label": "จนถึง (ไม่บังคับ)",
"until_description": "เว้นว่างไว้เพื่อตอบกลับต่อไปจนกว่าคุณจะปิด",
"audience_all": "ทุกคน",
"audience_internal": "เฉพาะองค์กรของฉัน ({domains})",
"audience_external": "เฉพาะคนนอกองค์กรของฉัน",
"signature_label": "ลายเซ็น",
"signature_plain_label": "ลายเซ็น (ข้อความธรรมดา)",
"signature_plain_description": "คุณเขียนเป็นข้อความธรรมดาเท่านั้น (การเขียน) ลายเซ็นจึงเป็นข้อความธรรมดาด้วย",
"edit_as_html": "แก้ไขเป็น HTML",
"edit_formatted": "กลับไปที่ตัวแก้ไขแบบจัดรูปแบบ",
"html_source_label": "ลายเซ็นแบบ HTML",
"signature_size": "ใช้ไป {bytes} จาก {max} ไบต์",
"signature_too_long": "ยาวเกินไปสำหรับเซิร์ฟเวอร์ ({bytes} จาก {max} ไบต์) โปรดทำให้สั้นลง"
},
"retention_notice": {
"line": "ข้อความที่นี่จะถูกลบ {count, plural, other {# วัน}} หลังจากย้ายมาที่นี่"
},
+24
View File
@@ -1342,6 +1342,30 @@
"apply": "Bu metni kullan",
"apply_hint": "Aşağıdaki konu ve metnin yerine geçer."
},
"uxs_vacation": {
"default_subject": "Ofis dışında",
"default_message": "E-postanız için teşekkürler. Şu an ofis dışındayım, döndüğümde yanıt vereceğim.",
"write_message_first": "Önce bir ileti yazın. İleti olmadan insanlar boş bir yanıt alır.",
"enter_address_first": "Önce iletilecek adresi girin.",
"message_title": "Yanıtınız",
"message_label": "İleti",
"message_description": "Size yazan kişilerin alacağı metin. Biçimlendirme ve bağlantı kullanabilirsiniz.",
"from_label": "Başlangıç (isteğe bağlı)",
"from_description": "Hemen başlamak için boş bırakın.",
"until_label": "Bitiş (isteğe bağlı)",
"until_description": "Siz kapatana kadar yanıt vermeye devam etmek için boş bırakın.",
"audience_all": "Herkese",
"audience_internal": "Yalnızca kuruluşuma ({domains})",
"audience_external": "Yalnızca kuruluşum dışındakilere",
"signature_label": "İmza",
"signature_plain_label": "İmza (düz metin)",
"signature_plain_description": "Yalnızca düz metin yazıyorsunuz (Yazma), bu yüzden imzanız da düz metin.",
"edit_as_html": "HTML olarak düzenle",
"edit_formatted": "Biçimlendirme düzenleyicisine dön",
"html_source_label": "HTML imza",
"signature_size": "{max} bayttan {bytes} bayt kullanıldı",
"signature_too_long": "Sunucu için çok uzun ({max} bayttan {bytes} bayt). Kısaltın."
},
"retention_notice": {
"line": "Buradaki iletiler buraya taşındıktan {count, plural, one {# gün} other {# gün}} sonra silinir."
},
+24
View File
@@ -1342,6 +1342,30 @@
"apply": "Використати цей текст",
"apply_hint": "Замінює тему й текст нижче."
},
"uxs_vacation": {
"default_subject": "Немає на місці",
"default_message": "Дякую за лист. Мене зараз немає на місці, я відповім, коли повернуся.",
"write_message_first": "Спершу напишіть повідомлення. Без нього люди отримають порожню відповідь.",
"enter_address_first": "Спершу введіть адресу для пересилання.",
"message_title": "Ваша відповідь",
"message_label": "Повідомлення",
"message_description": "Це отримають ті, хто вам напише. Можна використовувати форматування та посилання.",
"from_label": "З (необов'язково)",
"from_description": "Залиште порожнім, щоб почати одразу.",
"until_label": "До (необов'язково)",
"until_description": "Залиште порожнім, щоб відповідати, доки ви це не вимкнете.",
"audience_all": "Усім",
"audience_internal": "Лише моїй організації ({domains})",
"audience_external": "Лише тим, хто поза моєю організацією",
"signature_label": "Підпис",
"signature_plain_label": "Підпис (звичайний текст)",
"signature_plain_description": "Ви пишете лише звичайним текстом (Написання), тому й підпис — звичайний текст.",
"edit_as_html": "Редагувати як HTML",
"edit_formatted": "Повернутися до редактора з форматуванням",
"html_source_label": "Підпис у HTML",
"signature_size": "Використано {bytes} з {max} байт",
"signature_too_long": "Задовгий для сервера ({bytes} з {max} байт). Скоротіть його."
},
"retention_notice": {
"line": "Листи тут видаляються через {count, plural, one {# день} few {# дні} many {# днів} other {# дня}} після переміщення сюди."
},
+24
View File
@@ -1354,6 +1354,30 @@
"apply": "Dùng nội dung này",
"apply_hint": "Thay thế tiêu đề và nội dung bên dưới."
},
"uxs_vacation": {
"default_subject": "Vắng mặt",
"default_message": "Cảm ơn bạn đã gửi thư. Tôi đang vắng mặt và sẽ trả lời khi quay lại.",
"write_message_first": "Hãy viết thư trước. Nếu không, mọi người sẽ nhận được thư trả lời trống.",
"enter_address_first": "Hãy nhập địa chỉ cần chuyển tiếp đến trước.",
"message_title": "Thư trả lời của bạn",
"message_label": "Nội dung",
"message_description": "Những gì người gửi thư cho bạn sẽ nhận được. Bạn có thể dùng định dạng và liên kết.",
"from_label": "Từ (không bắt buộc)",
"from_description": "Để trống để bắt đầu ngay.",
"until_label": "Đến (không bắt buộc)",
"until_description": "Để trống để tiếp tục trả lời cho đến khi bạn tắt.",
"audience_all": "Mọi người",
"audience_internal": "Chỉ tổ chức của tôi ({domains})",
"audience_external": "Chỉ người ngoài tổ chức của tôi",
"signature_label": "Chữ ký",
"signature_plain_label": "Chữ ký (văn bản thuần)",
"signature_plain_description": "Bạn chỉ viết văn bản thuần (Soạn thư), nên chữ ký cũng là văn bản thuần.",
"edit_as_html": "Sửa dưới dạng HTML",
"edit_formatted": "Quay lại trình soạn thảo có định dạng",
"html_source_label": "Chữ ký dạng HTML",
"signature_size": "Đã dùng {bytes}/{max} byte",
"signature_too_long": "Quá dài đối với máy chủ ({bytes}/{max} byte). Hãy rút ngắn."
},
"retention_notice": {
"line": "Thư ở đây sẽ bị xóa {count, plural, other {# ngày}} sau khi được chuyển vào đây."
},
+24
View File
@@ -1342,6 +1342,30 @@
"apply": "使用此文字",
"apply_hint": "取代下方的主旨和內文。"
},
"uxs_vacation": {
"default_subject": "外出",
"default_message": "感謝您的來信。我目前不在辦公室,回來後會盡快回覆。",
"write_message_first": "請先寫一則訊息。沒有訊息的話,對方會收到空白回覆。",
"enter_address_first": "請先輸入要轉寄到的地址。",
"message_title": "您的回覆",
"message_label": "訊息",
"message_description": "寫信給您的人會收到的內容。可以使用格式和連結。",
"from_label": "開始(選填)",
"from_description": "留空即立即開始。",
"until_label": "結束(選填)",
"until_description": "留空即持續回覆,直到您關閉。",
"audience_all": "所有人",
"audience_internal": "僅限我的組織({domains})",
"audience_external": "僅限組織外的人",
"signature_label": "簽名",
"signature_plain_label": "簽名(純文字)",
"signature_plain_description": "您設定為只寫純文字(撰寫),所以簽名也是純文字。",
"edit_as_html": "以 HTML 編輯",
"edit_formatted": "返回格式編輯器",
"html_source_label": "HTML 簽名",
"signature_size": "已用 {bytes} / {max} 位元組",
"signature_too_long": "對伺服器來說太長({bytes} / {max} 位元組)。請縮短。"
},
"retention_notice": {
"line": "此處的郵件會在移入 {count, plural, other {# 天}} 後刪除。"
},
+24
View File
@@ -1342,6 +1342,30 @@
"apply": "使用此文字",
"apply_hint": "替换下方的主题和正文。"
},
"uxs_vacation": {
"default_subject": "外出",
"default_message": "感谢您的来信。我目前不在办公室,回来后会尽快回复。",
"write_message_first": "请先写一条消息。没有消息的话,对方会收到空白回复。",
"enter_address_first": "请先输入要转发到的地址。",
"message_title": "您的回复",
"message_label": "消息",
"message_description": "给您写信的人会收到的内容。可以使用格式和链接。",
"from_label": "开始(可选)",
"from_description": "留空则立即开始。",
"until_label": "结束(可选)",
"until_description": "留空则一直回复,直到您关闭。",
"audience_all": "所有人",
"audience_internal": "仅限我的组织({domains})",
"audience_external": "仅限组织外的人",
"signature_label": "签名",
"signature_plain_label": "签名(纯文本)",
"signature_plain_description": "您设置为只写纯文本(撰写),所以签名也是纯文本。",
"edit_as_html": "以 HTML 编辑",
"edit_formatted": "返回格式编辑器",
"html_source_label": "HTML 签名",
"signature_size": "已用 {bytes} / {max} 字节",
"signature_too_long": "对服务器来说太长({bytes} / {max} 字节)。请缩短。"
},
"retention_notice": {
"line": "此处的邮件会在移入 {count, plural, other {# 天}} 后删除。"
},