mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-05 00:14:18 +00:00
Merge branch 'feat/do-uxs-vacation' into feat/do-ux-settings-fixes
This commit is contained in:
@@ -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>' });
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>$/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' },
|
||||
],
|
||||
|
||||
@@ -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('');
|
||||
}
|
||||
@@ -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 {# يوم}} من نقلها إلى هنا."
|
||||
},
|
||||
|
||||
@@ -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í."
|
||||
},
|
||||
|
||||
@@ -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."
|
||||
},
|
||||
|
||||
@@ -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."
|
||||
},
|
||||
|
||||
@@ -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."
|
||||
},
|
||||
|
||||
@@ -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."
|
||||
},
|
||||
|
||||
@@ -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í."
|
||||
},
|
||||
|
||||
@@ -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."
|
||||
},
|
||||
|
||||
@@ -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 {# روز}} پس از انتقال به اینجا حذف میشوند."
|
||||
},
|
||||
|
||||
@@ -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."
|
||||
},
|
||||
|
||||
@@ -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 {# ימים}} אחרי שהועברו לכאן."
|
||||
},
|
||||
|
||||
@@ -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."
|
||||
},
|
||||
|
||||
@@ -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."
|
||||
},
|
||||
|
||||
@@ -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 {#日}}後に削除されます。"
|
||||
},
|
||||
|
||||
@@ -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 {#일}} 후에 삭제됩니다."
|
||||
},
|
||||
|
||||
@@ -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."
|
||||
},
|
||||
|
||||
@@ -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 {# хоногийн}} дараа устна."
|
||||
},
|
||||
|
||||
@@ -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."
|
||||
},
|
||||
|
||||
@@ -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."
|
||||
},
|
||||
|
||||
@@ -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."
|
||||
},
|
||||
|
||||
@@ -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á."
|
||||
},
|
||||
|
||||
@@ -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."
|
||||
},
|
||||
|
||||
@@ -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 {# дня}} после перемещения сюда."
|
||||
},
|
||||
|
||||
@@ -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."
|
||||
},
|
||||
|
||||
@@ -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 {# วัน}} หลังจากย้ายมาที่นี่"
|
||||
},
|
||||
|
||||
@@ -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."
|
||||
},
|
||||
|
||||
@@ -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 {# дня}} після переміщення сюди."
|
||||
},
|
||||
|
||||
@@ -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."
|
||||
},
|
||||
|
||||
@@ -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 {# 天}} 後刪除。"
|
||||
},
|
||||
|
||||
@@ -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 {# 天}} 后删除。"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user