mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-02 00:14:23 +00:00
feat: offer admin out-of-office templates and show the absence dates in the user's time zone
This commit is contained in:
@@ -1,13 +1,239 @@
|
||||
'use client';
|
||||
|
||||
import { useId, useState } from 'react';
|
||||
import { AlertTriangle, ChevronDown, ChevronRight, Plus, RotateCcw, Trash2 } from '@/components/icons';
|
||||
import { SettingsSection } from '@/components/settings/settings-section';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type { VacationTemplate } from '@/lib/admin/organisation-policy';
|
||||
import { sanitizeEmailHtml } from '@/lib/email-sanitization';
|
||||
import { cn, generateUUID } from '@/lib/utils';
|
||||
import { fillVacationTemplate, VACATION_PLACEHOLDERS } from '@/lib/vacation-templates';
|
||||
import type { OrgPolicyDraft } from '../use-org-policy-draft';
|
||||
|
||||
/** Same cap as the sanitiser (organisation-policy.ts). */
|
||||
const MAX_LIBRARY = 200;
|
||||
/** What Stalwart takes for an out-of-office reply (bytes); a longer one can't be saved by users. */
|
||||
export const STALWART_LIMITS = { subject: 511, body: 2047 };
|
||||
const DOCS_URL = 'https://bulwarkmail.org/docs/guides/organisation/out-of-office';
|
||||
|
||||
const inputClass =
|
||||
'block w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground ' +
|
||||
'placeholder:text-muted-foreground hover:border-muted-foreground focus-visible:outline-hidden focus-visible:border-ring';
|
||||
const labelClass = 'mb-1 block text-xs font-medium text-foreground';
|
||||
|
||||
const PLACEHOLDER_HELP: Record<(typeof VACATION_PLACEHOLDERS)[number], string> = {
|
||||
name: 'the user’s name',
|
||||
email: 'their address',
|
||||
company: 'the company name from Branding',
|
||||
start: 'the first day away',
|
||||
end: 'the last day away (or the return)',
|
||||
deputy: 'the address of a colleague the user types in when picking the template',
|
||||
deputy_name: 'that colleague’s name',
|
||||
};
|
||||
|
||||
const EXAMPLE = {
|
||||
name: 'Alex Example', email: 'alex@example.com', company: 'Example Ltd',
|
||||
start: 'Monday 21 December 2026', end: 'Monday 4 January 2027',
|
||||
deputy: 'sam@example.com', deputy_name: 'Sam Example',
|
||||
};
|
||||
|
||||
const bytes = (value: string) => new TextEncoder().encode(value).length;
|
||||
|
||||
/** Over Stalwart's limits once filled in with a typical name and dates (the placeholders grow a little). */
|
||||
export function vacationTemplateTooLong(template: VacationTemplate): { subject: boolean; body: boolean } {
|
||||
const filled = fillVacationTemplate(template, EXAMPLE);
|
||||
return {
|
||||
subject: bytes(filled.subject) > STALWART_LIMITS.subject,
|
||||
body: bytes(filled.text) > STALWART_LIMITS.body || bytes(filled.html) > STALWART_LIMITS.body,
|
||||
};
|
||||
}
|
||||
|
||||
function VacationTemplateEditor({ template, onChange, onRemove }: {
|
||||
template: VacationTemplate;
|
||||
onChange: (template: VacationTemplate) => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const id = useId();
|
||||
const [open, setOpen] = useState(!template.name);
|
||||
const [html, setHtml] = useState(!!template.html);
|
||||
const set = (patch: Partial<VacationTemplate>) => onChange({ ...template, ...patch });
|
||||
const tooLong = vacationTemplateTooLong(template);
|
||||
const preview = fillVacationTemplate(template, EXAMPLE);
|
||||
return (
|
||||
<li className="rounded-md border border-border" data-testid="org-vacation-template-item">
|
||||
<div className="flex items-center gap-2 p-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(!open)}
|
||||
aria-expanded={open}
|
||||
className="flex min-w-0 flex-1 items-center gap-2 rounded px-1 py-1 text-start text-sm hover:bg-muted"
|
||||
>
|
||||
{open ? <ChevronDown className="h-4 w-4 shrink-0" /> : <ChevronRight className="h-4 w-4 shrink-0" />}
|
||||
<span className={cn('truncate font-medium', !template.name && 'text-muted-foreground')}>{template.name || 'New reply'}</span>
|
||||
{template.category && <span className="max-w-[40%] shrink-0 truncate rounded-full bg-primary/10 px-2 py-0.5 text-xs text-primary">{template.category}</span>}
|
||||
</button>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={onRemove} className="h-8 shrink-0 px-2 hover:text-red-600" aria-label={`Remove ${template.name || 'reply'}`}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{!template.name.trim() && (
|
||||
<p className="px-3 pb-2 text-xs text-red-600 dark:text-red-400">A reply without a name is not saved. Give it one.</p>
|
||||
)}
|
||||
{open && (
|
||||
<div className="space-y-3 border-t border-border p-3">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<label htmlFor={`${id}-name`} className={labelClass}>Name</label>
|
||||
<input id={`${id}-name`} data-testid="org-vacation-template-name" className={inputClass} value={template.name} maxLength={200} onChange={(e) => set({ name: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor={`${id}-category`} className={labelClass}>Category or language</label>
|
||||
<input id={`${id}-category`} className={inputClass} value={template.category} maxLength={100} placeholder="Holiday, Parental leave, Deutsch…" onChange={(e) => set({ category: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor={`${id}-subject`} className={labelClass}>Subject</label>
|
||||
<input id={`${id}-subject`} data-testid="org-vacation-template-subject" className={inputClass} value={template.subject} maxLength={511} placeholder="Out of office until {end}" onChange={(e) => set({ subject: e.target.value })} />
|
||||
{tooLong.subject && <p className="mt-1 text-xs text-red-600 dark:text-red-400">Longer than Stalwart takes (511 bytes). Users couldn't save it.</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor={`${id}-text`} className={labelClass}>Text</label>
|
||||
<textarea
|
||||
id={`${id}-text`}
|
||||
data-testid="org-vacation-template-text"
|
||||
rows={7}
|
||||
className={inputClass}
|
||||
value={template.text}
|
||||
placeholder={'Thank you for your message. I am away until {end}.\nFor urgent matters, please write to {deputy_name} at {deputy}.\n\n{name}\n{company}'}
|
||||
onChange={(e) => set({ text: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<label className="flex cursor-pointer items-center gap-2 text-sm text-foreground">
|
||||
<input type="checkbox" className="rounded border-input" checked={html} onChange={(e) => { setHtml(e.target.checked); if (!e.target.checked) set({ html: '' }); }} />
|
||||
Also an HTML version
|
||||
</label>
|
||||
{html && (
|
||||
<div>
|
||||
<label htmlFor={`${id}-html`} className={labelClass}>HTML</label>
|
||||
<textarea id={`${id}-html`} rows={6} className={cn(inputClass, 'font-mono text-xs')} value={template.html} onChange={(e) => set({ html: e.target.value })} />
|
||||
<p className="mt-1 text-xs text-muted-foreground">Placeholders work here too, also in links such as href="mailto:{'{deputy}'}".</p>
|
||||
</div>
|
||||
)}
|
||||
{tooLong.body && (
|
||||
<p className="flex items-start gap-1.5 text-xs text-red-600 dark:text-red-400">
|
||||
<AlertTriangle className="mt-px h-3.5 w-3.5 shrink-0" aria-hidden="true" />
|
||||
Longer than Stalwart takes for a reply (2047 bytes, text and HTML each). Users couldn't save it.
|
||||
</p>
|
||||
)}
|
||||
<div>
|
||||
<p className={labelClass}>Preview with example values</p>
|
||||
<div className="rounded-md border border-border bg-background p-3" data-testid="org-vacation-template-preview">
|
||||
{preview.subject && <p className="mb-2 text-sm font-medium">{preview.subject}</p>}
|
||||
{preview.html
|
||||
? <div className="text-sm [&_a]:text-primary [&_a]:underline" dangerouslySetInnerHTML={{ __html: sanitizeEmailHtml(preview.html) }} />
|
||||
: preview.text ? <p className="whitespace-pre-wrap text-sm">{preview.text}</p> : <p className="text-sm text-muted-foreground">No text</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Placeholder from the enterprise-pack foundation, replaced by package D:
|
||||
* Out-of-office reply texts users can pick.
|
||||
* Organisation > Templates > Out-of-office replies (enterprise pack item
|
||||
* 11): reply texts users pick in Settings > Out of office
|
||||
* (policy.json `organisation.vacationTemplates.library`). A scope's
|
||||
* replies add to Everyone's by id; the same id replaces Everyone's reply
|
||||
* for that scope.
|
||||
*/
|
||||
export function VacationTemplatesSection({ draft }: { draft: OrgPolicyDraft }) {
|
||||
void draft;
|
||||
return <SettingsSection id="vacation-templates" title="Out-of-office replies" description="Not set up yet.">{null}</SettingsSection>;
|
||||
const library = draft.orgValue('vacationTemplates', 'library');
|
||||
const inScope = !!draft.scope;
|
||||
const own: VacationTemplate[] = inScope ? (library.own ? library.value : []) : library.value;
|
||||
const everyone = library.everyone;
|
||||
const ownIds = new Set(own.map((t) => t.id));
|
||||
|
||||
const setOwn = (next: VacationTemplate[]) => {
|
||||
if (inScope && next.length === 0) draft.clearOrgValue('vacationTemplates', 'library');
|
||||
else draft.setOrgValue('vacationTemplates', 'library', next);
|
||||
};
|
||||
const add = () => setOwn([...own, { id: generateUUID(), name: '', category: '', subject: '', text: '', html: '' }]);
|
||||
const change = (index: number, template: VacationTemplate) => setOwn(own.map((t, i) => (i === index ? template : t)));
|
||||
const remove = (index: number) => setOwn(own.filter((_, i) => i !== index));
|
||||
const replaceInScope = (template: VacationTemplate) => setOwn([...own, { ...template }]);
|
||||
const total = new Set([...everyone.map((t) => t.id), ...own.map((t) => t.id)]).size;
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
id="vacation-templates"
|
||||
title="Out-of-office replies"
|
||||
description="Users pick one of these under Settings > Out of office, Bulwark fills in the placeholders, and they can change the text before it is switched on."
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-md border border-border bg-muted/30 px-3 py-2 text-xs text-muted-foreground" data-testid="org-vacation-placeholders">
|
||||
<p className="mb-1">
|
||||
Placeholders use single braces, like the organisation signature (not the {'{{double}}'} braces of mail templates).
|
||||
A line whose placeholders are all empty is left out, so "I am back on {'{end}'}" disappears when the user sets no end.
|
||||
</p>
|
||||
<ul className="grid grid-cols-1 gap-x-4 sm:grid-cols-2">
|
||||
{VACATION_PLACEHOLDERS.map((key) => (
|
||||
<li key={key}><code className="text-foreground">{`{${key}}`}</code>: {PLACEHOLDER_HELP[key]}</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mt-1">Dates are written in the user's language and time zone. Stalwart takes at most 511 bytes of subject and 2047 bytes of text.</p>
|
||||
</div>
|
||||
|
||||
{inScope && (
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-medium text-foreground">From Everyone</p>
|
||||
{everyone.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Everyone has no replies.</p>
|
||||
) : (
|
||||
<ul className="space-y-1.5" data-testid="org-vacation-template-inherited">
|
||||
{everyone.map((template) => (
|
||||
<li key={template.id} className="flex flex-wrap items-center justify-between gap-2 rounded-md border border-dashed border-border px-3 py-2 text-sm">
|
||||
<span className="min-w-0 truncate">{template.name}</span>
|
||||
{ownIds.has(template.id) ? (
|
||||
<span className="text-xs text-muted-foreground">Changed for this scope</span>
|
||||
) : (
|
||||
<button type="button" onClick={() => replaceInScope(template)} className="inline-flex items-center gap-1 text-xs text-muted-foreground underline-offset-2 hover:underline">
|
||||
<RotateCcw className="h-3 w-3" /> Change for this scope
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<p className="mt-4 mb-1 text-sm font-medium text-foreground">For this scope</p>
|
||||
<p className="mb-2 text-xs text-muted-foreground">
|
||||
Users in this scope get Everyone's replies and these. A reply changed here replaces Everyone's for this scope only.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{own.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground" data-testid="org-vacation-template-empty">
|
||||
{inScope ? 'Nothing added for this scope.' : 'No replies yet. Users write their own.'}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-2" data-testid="org-vacation-template-list">
|
||||
{own.map((template, index) => (
|
||||
<VacationTemplateEditor key={template.id} template={template} onChange={(t) => change(index, t)} onRemove={() => remove(index)} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button type="button" variant="outline" size="sm" onClick={add} disabled={total >= MAX_LIBRARY} data-testid="org-vacation-template-add">
|
||||
<Plus className="me-1 h-4 w-4" /> Add reply
|
||||
</Button>
|
||||
{total >= MAX_LIBRARY && <span className="text-xs text-muted-foreground">A library holds at most {MAX_LIBRARY} replies.</span>}
|
||||
<a href={DOCS_URL} target="_blank" rel="noopener noreferrer" className="text-xs font-medium text-primary underline-offset-2 hover:underline">
|
||||
How out-of-office templates work
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
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 { DEFAULT_POLICY } from '@/lib/admin/types';
|
||||
import { DEFAULT_ORGANISATION_POLICY, type VacationTemplate } from '@/lib/admin/organisation-policy';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useIdentityStore } from '@/stores/identity-store';
|
||||
import { usePolicyStore } from '@/stores/policy-store';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { useVacationStore } from '@/stores/vacation-store';
|
||||
import { VacationSettings } from '../vacation-settings';
|
||||
|
||||
// Out-of-office templates from the admin library, in the real vacation card
|
||||
// with its auto-save: picking a template with a deputy saves nothing until
|
||||
// the deputy is typed and the user applies it; the dates are shown and
|
||||
// filled in the user's time zone.
|
||||
|
||||
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-GB' };
|
||||
});
|
||||
|
||||
const TEMPLATE: VacationTemplate = {
|
||||
id: 'holiday',
|
||||
name: 'Holiday',
|
||||
category: 'Standard',
|
||||
subject: 'Away until {end}',
|
||||
text: 'I am away until {end}.\nPlease write to {deputy}.\n{name}',
|
||||
html: '',
|
||||
};
|
||||
|
||||
const initial = {
|
||||
auth: useAuthStore.getState(),
|
||||
vacation: useVacationStore.getState(),
|
||||
identity: useIdentityStore.getState(),
|
||||
policy: usePolicyStore.getState(),
|
||||
timeZone: useSettingsStore.getState().timeZone,
|
||||
};
|
||||
afterEach(() => {
|
||||
useAuthStore.setState(initial.auth, true);
|
||||
useVacationStore.setState(initial.vacation, true);
|
||||
useIdentityStore.setState(initial.identity, true);
|
||||
usePolicyStore.setState(initial.policy, true);
|
||||
useSettingsStore.setState({ timeZone: initial.timeZone });
|
||||
});
|
||||
|
||||
async function openCard(library: VacationTemplate[], timeZone = 'Europe/Berlin') {
|
||||
const server = mockStalwartAccount('b', [], ['vacation'], {
|
||||
vacation: { fromDate: '2026-12-21T08:00:00Z', toDate: '2027-01-03T23:00:00Z', subject: 'Old', textBody: 'Old text' },
|
||||
});
|
||||
useSettingsStore.setState({ timeZone });
|
||||
usePolicyStore.setState({
|
||||
policy: { ...DEFAULT_POLICY, organisation: { ...DEFAULT_ORGANISATION_POLICY, vacationTemplates: { library } } },
|
||||
});
|
||||
useIdentityStore.setState({ identities: [{ id: 'i', name: 'Alice Example', email: 'alice@example.org' } as never] });
|
||||
useAuthStore.setState({ client: server.client as never, username: 'alice@example.org' });
|
||||
useVacationStore.setState({ isSupported: true });
|
||||
render(<VacationSettings />);
|
||||
await waitFor(() => expect(useVacationStore.getState().isLoading).toBe(false));
|
||||
await screen.findByText('status.label');
|
||||
return server;
|
||||
}
|
||||
|
||||
/** Long enough for a save that was due to have started. */
|
||||
const settle = () => act(() => new Promise((resolve) => setTimeout(resolve, 1100)));
|
||||
|
||||
describe('out-of-office templates', () => {
|
||||
it('shows no picker when the organisation has no templates', async () => {
|
||||
await openCard([]);
|
||||
expect(screen.queryByTestId('vacation-template-picker')).toBeNull();
|
||||
});
|
||||
|
||||
it('saves nothing until the deputy is filled in, then fills every placeholder', async () => {
|
||||
const server = await openCard([TEMPLATE]);
|
||||
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');
|
||||
|
||||
// Applying without a deputy refuses and says why.
|
||||
fireEvent.click(screen.getByTestId('vacation-template-apply'));
|
||||
expect(screen.getByText('deputy_missing')).toBeTruthy();
|
||||
await settle();
|
||||
expect(server.client.setVacationResponse).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.change(screen.getByTestId('vacation-template-deputy'), { target: { value: 'bob@example.org' } });
|
||||
await settle();
|
||||
expect(server.client.setVacationResponse).not.toHaveBeenCalled();
|
||||
|
||||
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$/);
|
||||
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.subject).toMatch(/^Away until Monday,? 4 January 2027$/);
|
||||
});
|
||||
|
||||
it('shows and fills the dates in the time zone set in Bulwark, not the browser’s', async () => {
|
||||
await openCard([{ ...TEMPLATE, text: 'Back {end}', subject: '' }], 'Asia/Tokyo');
|
||||
const inputs = document.querySelectorAll('input[type="datetime-local"]');
|
||||
expect((inputs[0] as HTMLInputElement).value).toBe('2026-12-21T17:00');
|
||||
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$/);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { SettingsSection, SettingItem, ToggleSwitch, Select } from './settings-section';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { RichTextEditor } from '@/components/email/rich-text-editor';
|
||||
@@ -19,6 +19,12 @@ import { Loader2, AlertTriangle, Eye, EyeOff, Check } from '@/components/icons';
|
||||
import { toast } from '@/stores/toast-store';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { VacationCalendarBlock, VacationFilterWarning } from './vacation-calendar-block';
|
||||
import { VacationTemplatePicker } from './vacation-template-picker';
|
||||
import type { FilledVacationTemplate } from '@/lib/vacation-templates';
|
||||
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';
|
||||
|
||||
const STALWART_VACATION_LIMITS = { subject: 511, body: 2047 };
|
||||
|
||||
@@ -52,10 +58,11 @@ function StatusSwitch({ checked, onChange, activeLabel, inactiveLabel }: {
|
||||
);
|
||||
}
|
||||
|
||||
function utcToLocalDatetime(utcIso: string): string {
|
||||
const d = new Date(utcIso);
|
||||
/** A stored moment as the date field shows it: the wall clock in the user's time zone (Settings > Language and region). */
|
||||
function utcToLocalDatetime(utcIso: string, timeZone: string): string {
|
||||
const w = getWallClock(new Date(utcIso), timeZone);
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
return `${w.year}-${pad(w.month)}-${pad(w.day)}T${pad(w.hour)}:${pad(w.minute)}`;
|
||||
}
|
||||
|
||||
const DATE_FIELD =
|
||||
@@ -70,10 +77,12 @@ const DATE_INPUT_MAX = '9999-12-31T23:59';
|
||||
* that can be stored (a five-digit year). A field left as it was loaded keeps
|
||||
* the stored value exactly.
|
||||
*/
|
||||
function fieldMoment(input: string, stored: string | null): string | null {
|
||||
function fieldMoment(input: string, stored: string | null, timeZone: string): string | null {
|
||||
if (!input) return '';
|
||||
if (stored && input === utcToLocalDatetime(stored)) return stored;
|
||||
const moment = new Date(input);
|
||||
if (stored && input === utcToLocalDatetime(stored, timeZone)) return stored;
|
||||
const wall = parseLocalDateTime(input);
|
||||
if (!wall) return null;
|
||||
const moment = fromWallClock(wall, timeZone);
|
||||
if (Number.isNaN(moment.getTime())) return null;
|
||||
const iso = moment.toISOString();
|
||||
return isPeriodBoundary(iso) ? iso : null;
|
||||
@@ -88,6 +97,10 @@ export function VacationSettings() {
|
||||
const t = useTranslations('settings.vacation');
|
||||
const tFilters = useTranslations('settings.filters');
|
||||
const tNotifications = useTranslations('notifications');
|
||||
const locale = useLocale();
|
||||
// The dates are typed and shown in the user's time zone, which may differ from the browser's.
|
||||
const timeZoneSetting = useSettingsStore((s) => s.timeZone);
|
||||
const timeZone = useMemo(() => resolveTimeZone(timeZoneSetting), [timeZoneSetting]);
|
||||
const { client } = useAuthStore();
|
||||
const activeAccountId = useAuthStore((s) => s.activeAccountId);
|
||||
const managedAccountId = useManagedAccountStore((s) => s.managedAccountId);
|
||||
@@ -117,16 +130,16 @@ export function VacationSettings() {
|
||||
|
||||
const [localEnabled, setLocalEnabled] = useState(isEnabled);
|
||||
// The date fields as typed ("yyyy-mm-ddThh:mm" on the browser's clock).
|
||||
const [fromInput, setFromInput] = useState(() => (fromDate ? utcToLocalDatetime(fromDate) : ''));
|
||||
const [toInput, setToInput] = useState(() => (toDate ? utcToLocalDatetime(toDate) : ''));
|
||||
const [fromInput, setFromInput] = useState(() => (fromDate ? utcToLocalDatetime(fromDate, timeZone) : ''));
|
||||
const [toInput, setToInput] = useState(() => (toDate ? utcToLocalDatetime(toDate, timeZone) : ''));
|
||||
// A field the browser holds half filled in (a date without its time) reads
|
||||
// as empty, without an event when it was empty before: told apart here, so
|
||||
// it does not pass for "no date", which leaves that end of the period open.
|
||||
const [halfFilled, setHalfFilled] = useState({ from: false, to: false });
|
||||
const fromRef = useRef<HTMLInputElement>(null);
|
||||
const toRef = useRef<HTMLInputElement>(null);
|
||||
const localFromDate = fieldMoment(fromInput, fromDate);
|
||||
const localToDate = fieldMoment(toInput, toDate);
|
||||
const localFromDate = fieldMoment(fromInput, fromDate, timeZone);
|
||||
const localToDate = fieldMoment(toInput, toDate, timeZone);
|
||||
const datesUnusable = localFromDate === null || localToDate === null || halfFilled.from || halfFilled.to;
|
||||
const [localSubject, setLocalSubject] = useState(subject);
|
||||
const [localTextBody, setLocalTextBody] = useState(textBody);
|
||||
@@ -141,6 +154,21 @@ export function VacationSettings() {
|
||||
const identities = useIdentityStore((s) => s.identities);
|
||||
const domains = useMemo(() => ownDomains(identities.map((i) => i.email)), [identities]);
|
||||
|
||||
// The organisation's reply texts (Organisation > Templates), filled in for
|
||||
// the account this page is for: a managed account's own address, never the
|
||||
// signed-in user's name.
|
||||
const vacationLibrary = usePolicyStore((s) => s.policy.organisation?.vacationTemplates?.library);
|
||||
const managedAccount = useManagedAccountStore((s) => s.managedAccount);
|
||||
const username = useAuthStore((s) => s.username);
|
||||
const templateSender = useMemo(() => {
|
||||
if (managedAccountId) {
|
||||
const address = managedAccount?.name?.includes('@') ? managedAccount.name : '';
|
||||
return { name: '', email: address };
|
||||
}
|
||||
const own = identities.find((i) => i.email?.toLowerCase() === username?.toLowerCase()) ?? identities[0];
|
||||
return { name: own?.name ?? '', email: own?.email ?? username ?? '' };
|
||||
}, [managedAccountId, managedAccount, identities, username]);
|
||||
|
||||
useEffect(() => {
|
||||
if (client && isSupported) {
|
||||
void fetchVacationResponse(client, managedAccountId ?? undefined);
|
||||
@@ -165,8 +193,8 @@ export function VacationSettings() {
|
||||
if (!sent || sent.reply !== storedReply) {
|
||||
touched.current = false;
|
||||
setLocalEnabled(stored.isEnabled);
|
||||
setFromInput(stored.fromDate ? utcToLocalDatetime(stored.fromDate) : '');
|
||||
setToInput(stored.toDate ? utcToLocalDatetime(stored.toDate) : '');
|
||||
setFromInput(stored.fromDate ? utcToLocalDatetime(stored.fromDate, timeZone) : '');
|
||||
setToInput(stored.toDate ? utcToLocalDatetime(stored.toDate, timeZone) : '');
|
||||
setHalfFilled({ from: false, to: false });
|
||||
setLocalSubject(stored.subject);
|
||||
setLocalTextBody(stored.textBody);
|
||||
@@ -182,6 +210,8 @@ export function VacationSettings() {
|
||||
setForwardKeep(stored.forward?.keepCopy ?? false);
|
||||
}
|
||||
setAudienceOnly(stored.audience?.only ?? 'all');
|
||||
// The time zone is read when the fields load, not followed while typing.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [accountKey, isLoading, storedReply, forward, audience]);
|
||||
|
||||
// How long the next save waits, set by the control that changed.
|
||||
@@ -197,6 +227,15 @@ export function VacationSettings() {
|
||||
set(value);
|
||||
};
|
||||
|
||||
const applyTemplate = (filled: FilledVacationTemplate) => {
|
||||
touched.current = true;
|
||||
saveDelay.current = SAVE_AFTER_TYPING;
|
||||
if (filled.subject) setLocalSubject(filled.subject);
|
||||
setLocalTextBody(filled.text);
|
||||
setHtmlEnabled(!!filled.html);
|
||||
setLocalHtmlBody(filled.html);
|
||||
};
|
||||
|
||||
// Reads a date field as the browser holds it, half filled in or not.
|
||||
const readDateField = (field: 'from' | 'to', input: HTMLInputElement) => {
|
||||
touched.current = true;
|
||||
@@ -459,6 +498,20 @@ export function VacationSettings() {
|
||||
<VacationCalendarBlock hasDates={!!fromInput && !!toInput} accountId={managedAccountId ?? undefined} />
|
||||
</SettingsSection>
|
||||
|
||||
<VacationTemplatePicker
|
||||
library={vacationLibrary ?? []}
|
||||
base={{
|
||||
name: templateSender.name,
|
||||
email: templateSender.email,
|
||||
company: companyName(),
|
||||
fromDate: localFromDate || null,
|
||||
toDate: localToDate || null,
|
||||
locale,
|
||||
timeZone,
|
||||
}}
|
||||
onApply={applyTemplate}
|
||||
/>
|
||||
|
||||
<SettingsSection id="reply" title={t('auto_reply.title')} description={t('status.description')}>
|
||||
<SettingItem label={t('status.label')}>
|
||||
<StatusSwitch
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
'use client';
|
||||
|
||||
import { useId, useMemo, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type { VacationTemplate } from '@/lib/admin/organisation-policy';
|
||||
import { isValidEmail } from '@/lib/validation';
|
||||
import {
|
||||
deputyFieldsNeeded,
|
||||
fillVacationTemplate,
|
||||
sortedVacationTemplates,
|
||||
vacationValues,
|
||||
type FilledVacationTemplate,
|
||||
type VacationFillInput,
|
||||
} from '@/lib/vacation-templates';
|
||||
import { SettingsSection } from './settings-section';
|
||||
|
||||
const FIELD =
|
||||
'w-full sm: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';
|
||||
|
||||
/**
|
||||
* "Use a template" on the out-of-office page: the organisation's reply
|
||||
* texts. Picking one asks for the deputy first when the text names one,
|
||||
* and only "Use this text" fills the fields, so nothing half-filled is
|
||||
* saved by the page's auto-save.
|
||||
*/
|
||||
export function VacationTemplatePicker({ library, base, onApply }: {
|
||||
library: readonly VacationTemplate[];
|
||||
/** Everything but the deputy, from the account and the dates on the page. */
|
||||
base: Omit<VacationFillInput, 'deputy' | 'deputyName'>;
|
||||
onApply: (filled: FilledVacationTemplate) => void;
|
||||
}) {
|
||||
const t = useTranslations('vacation_templates');
|
||||
const id = useId();
|
||||
const templates = useMemo(() => sortedVacationTemplates(library), [library]);
|
||||
const [selectedId, setSelectedId] = useState('');
|
||||
const [deputy, setDeputy] = useState('');
|
||||
const [deputyName, setDeputyName] = useState('');
|
||||
const [tried, setTried] = useState(false);
|
||||
const selected = templates.find((tpl) => tpl.id === selectedId) ?? null;
|
||||
const needs = selected ? deputyFieldsNeeded(selected) : { address: false, name: false };
|
||||
const addressError = needs.address && !isValidEmail(deputy.trim());
|
||||
const nameError = needs.name && !deputyName.trim();
|
||||
|
||||
if (templates.length === 0) return null;
|
||||
|
||||
const categories = [...new Set(templates.map((tpl) => tpl.category))];
|
||||
|
||||
const apply = () => {
|
||||
if (!selected) return;
|
||||
if (addressError || nameError) {
|
||||
setTried(true);
|
||||
return;
|
||||
}
|
||||
onApply(fillVacationTemplate(selected, vacationValues({ ...base, deputy, deputyName })));
|
||||
setSelectedId('');
|
||||
setTried(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsSection id="vacation-templates" title={t('title')} description={t('description')}>
|
||||
<div className="space-y-3 py-2" data-testid="vacation-template-picker">
|
||||
<div>
|
||||
<label htmlFor={`${id}-pick`} className="mb-1 block text-sm font-medium text-foreground">{t('pick_label')}</label>
|
||||
<select
|
||||
id={`${id}-pick`}
|
||||
data-testid="vacation-template-select"
|
||||
value={selectedId}
|
||||
onChange={(e) => { setSelectedId(e.target.value); setTried(false); }}
|
||||
className={FIELD}
|
||||
>
|
||||
<option value="">{t('pick_placeholder')}</option>
|
||||
{categories.map((category) => {
|
||||
const options = templates.filter((tpl) => tpl.category === category).map((tpl) => (
|
||||
<option key={tpl.id} value={tpl.id}>{tpl.name}</option>
|
||||
));
|
||||
return category ? <optgroup key={category} label={category}>{options}</optgroup> : options;
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
{selected && (
|
||||
<>
|
||||
{needs.address && (
|
||||
<div>
|
||||
<label htmlFor={`${id}-deputy`} className="mb-1 block text-sm font-medium text-foreground">{t('deputy_label')}</label>
|
||||
<input
|
||||
id={`${id}-deputy`}
|
||||
type="email"
|
||||
data-testid="vacation-template-deputy"
|
||||
value={deputy}
|
||||
onChange={(e) => setDeputy(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); apply(); } }}
|
||||
placeholder={t('deputy_placeholder')}
|
||||
aria-invalid={tried && addressError}
|
||||
aria-describedby={tried && addressError ? `${id}-deputy-error` : `${id}-deputy-hint`}
|
||||
className={FIELD}
|
||||
/>
|
||||
{tried && addressError
|
||||
? <p id={`${id}-deputy-error`} className="mt-1 text-xs text-red-600 dark:text-red-400">{t('deputy_missing')}</p>
|
||||
: <p id={`${id}-deputy-hint`} className="mt-1 text-xs text-muted-foreground">{t('deputy_hint')}</p>}
|
||||
</div>
|
||||
)}
|
||||
{needs.name && (
|
||||
<div>
|
||||
<label htmlFor={`${id}-deputy-name`} className="mb-1 block text-sm font-medium text-foreground">{t('deputy_name_label')}</label>
|
||||
<input
|
||||
id={`${id}-deputy-name`}
|
||||
type="text"
|
||||
data-testid="vacation-template-deputy-name"
|
||||
value={deputyName}
|
||||
onChange={(e) => setDeputyName(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); apply(); } }}
|
||||
aria-invalid={tried && nameError}
|
||||
className={FIELD}
|
||||
/>
|
||||
{tried && nameError && <p className="mt-1 text-xs text-red-600 dark:text-red-400">{t('deputy_name_missing')}</p>}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button type="button" size="sm" onClick={apply} data-testid="vacation-template-apply">{t('apply')}</Button>
|
||||
<span className="text-xs text-muted-foreground">{t('apply_hint')}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { VacationTemplate } from '@/lib/admin/organisation-policy';
|
||||
import { fillTextTemplate } from '@/lib/org-signature';
|
||||
import {
|
||||
deputyFieldsNeeded,
|
||||
fillVacationTemplate,
|
||||
formatAbsenceDate,
|
||||
vacationValues,
|
||||
} from '@/lib/vacation-templates';
|
||||
|
||||
const template = (patch: Partial<VacationTemplate>): VacationTemplate => ({
|
||||
id: 't', name: 'Holiday', category: '', subject: '', text: '', html: '', ...patch,
|
||||
});
|
||||
|
||||
const values = (patch: Partial<Parameters<typeof vacationValues>[0]> = {}) => vacationValues({
|
||||
name: 'Alice Example',
|
||||
email: 'alice@example.org',
|
||||
company: 'Example GmbH',
|
||||
fromDate: '2026-12-21T08:00:00Z',
|
||||
toDate: '2027-01-03T23:00:00Z',
|
||||
deputy: 'bob@example.org',
|
||||
deputyName: 'Bob',
|
||||
locale: 'en-GB',
|
||||
timeZone: 'Europe/Berlin',
|
||||
...patch,
|
||||
});
|
||||
|
||||
describe('fillVacationTemplate', () => {
|
||||
it('fills every placeholder in subject, text and HTML', () => {
|
||||
const filled = fillVacationTemplate(template({
|
||||
subject: 'Out of office until {end}',
|
||||
text: 'Hello,\n\nI am away from {start} to {end}.\nPlease write to {deputy_name} at {deputy}.\n\n{name}\n{company}',
|
||||
html: '<p>Write to <a href="mailto:{deputy}">{deputy_name}</a>.</p><p>{name}, {email}</p>',
|
||||
}), values());
|
||||
// ICU versions differ in commas and leading zeros, not in the day.
|
||||
expect(filled.subject).toMatch(/^Out of office until Monday,? 4 January 2027$/);
|
||||
expect(filled.text).toMatch(new RegExp(
|
||||
'^Hello,\\n\\nI am away from Monday,? 21 December 2026 at 0?9:00 to Monday,? 4 January 2027\\.\\n' +
|
||||
'Please write to Bob at bob@example\\.org\\.\\n\\nAlice Example\\nExample GmbH$',
|
||||
));
|
||||
expect(filled.html).toBe('<p>Write to <a href="mailto:bob@example.org">Bob</a>.</p><p>Alice Example, alice@example.org</p>');
|
||||
});
|
||||
|
||||
it('drops a line whose placeholders are all empty, and closes up the subject', () => {
|
||||
const filled = fillVacationTemplate(template({
|
||||
subject: 'Away until {end}',
|
||||
text: 'I am away.\nI am back on {end}.\nRegards',
|
||||
}), values({ toDate: null }));
|
||||
expect(filled.subject).toBe('Away until');
|
||||
expect(filled.text).toBe('I am away.\nRegards');
|
||||
});
|
||||
|
||||
it('escapes typed values in HTML', () => {
|
||||
const filled = fillVacationTemplate(template({ html: '<p>{deputy_name}</p>' }), values({ deputyName: '<img src=x onerror=alert(1)>' }));
|
||||
expect(filled.html).toBe('<p><img src=x onerror=alert(1)></p>');
|
||||
});
|
||||
|
||||
it('leaves other braces alone', () => {
|
||||
expect(fillVacationTemplate(template({ text: 'Ticket {id} for {name}' }), values()).text).toBe('Ticket {id} for Alice Example');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deputyFieldsNeeded', () => {
|
||||
it('asks for the address when the template names a deputy, and for the name only when it uses it', () => {
|
||||
expect(deputyFieldsNeeded(template({ text: 'Write to {deputy}.' }))).toEqual({ address: true, name: false });
|
||||
expect(deputyFieldsNeeded(template({ html: '<p>{deputy_name}</p>' }))).toEqual({ address: true, name: true });
|
||||
expect(deputyFieldsNeeded(template({ subject: 'Away', text: 'Back on {end}' }))).toEqual({ address: false, name: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatAbsenceDate', () => {
|
||||
it('formats in the given time zone and language, the day alone at midnight', () => {
|
||||
expect(formatAbsenceDate('2027-01-03T23:00:00Z', 'en-GB', 'Europe/Berlin')).toMatch(/^Monday,? 4 January 2027$/);
|
||||
expect(formatAbsenceDate('2027-01-03T23:00:00Z', 'en-GB', 'America/New_York')).toMatch(/^Sunday,? 3 January 2027 at 18:00$/);
|
||||
expect(formatAbsenceDate('2027-01-03T23:00:00Z', 'de', 'Europe/Berlin')).toBe('Montag, 4. Januar 2027');
|
||||
expect(formatAbsenceDate(null, 'en', 'UTC')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the signature filler with its own keys', () => {
|
||||
it('still fills only the signature placeholders by default', () => {
|
||||
expect(fillTextTemplate('{name} {deputy}', { name: 'A', deputy: 'b@x' } as never)).toBe('A {deputy}');
|
||||
});
|
||||
});
|
||||
+45
-31
@@ -57,12 +57,20 @@ export function sanitizeSignatureDetails(raw: unknown): SignatureDetails {
|
||||
return out;
|
||||
}
|
||||
|
||||
const PLACEHOLDER_RE = /\{(name|email|identity|company|domain|title|department|phone|mobile)\}/g;
|
||||
/** Values for a template's placeholders, by key; missing and empty are the same. */
|
||||
export type PlaceholderValues = Readonly<Partial<Record<string, string>>>;
|
||||
|
||||
/** A regex that finds `{key}` for the given keys (word characters only). */
|
||||
export function placeholderPattern(keys: readonly string[]): RegExp {
|
||||
return new RegExp(`\\{(${keys.map((k) => k.replace(/[^\w]/g, '')).filter(Boolean).join('|')})\\}`, 'g');
|
||||
}
|
||||
|
||||
const PLACEHOLDER_RE = placeholderPattern(SIGNATURE_PLACEHOLDERS);
|
||||
|
||||
/** The placeholders a template uses, each once, in the order they appear. */
|
||||
export function placeholdersIn(template: string): SignaturePlaceholder[] {
|
||||
const found = new Set<SignaturePlaceholder>();
|
||||
for (const match of template.matchAll(PLACEHOLDER_RE)) found.add(match[1] as SignaturePlaceholder);
|
||||
export function placeholdersIn<K extends string = SignaturePlaceholder>(template: string, re: RegExp = PLACEHOLDER_RE): K[] {
|
||||
const found = new Set<K>();
|
||||
for (const match of template.matchAll(re)) found.add(match[1] as K);
|
||||
return [...found];
|
||||
}
|
||||
|
||||
@@ -81,24 +89,29 @@ export function signatureFieldsToAsk(signature: OrganisationPolicy['signature'])
|
||||
return SIGNATURE_FIELDS.filter((field) => used.has(field));
|
||||
}
|
||||
|
||||
const valueOf = (values: SignatureValues, key: string) => (values[key as SignaturePlaceholder] ?? '').trim();
|
||||
const valueOf = (values: PlaceholderValues, key: string) => (values[key] ?? '').trim();
|
||||
|
||||
function fillString(template: string, values: SignatureValues): string {
|
||||
return template.replace(PLACEHOLDER_RE, (_, key: string) => valueOf(values, key));
|
||||
function fillString(template: string, values: PlaceholderValues, re: RegExp): string {
|
||||
return template.replace(re, (_, key: string) => valueOf(values, key));
|
||||
}
|
||||
|
||||
/** True when the text has placeholders and every one of them is empty. */
|
||||
function allPlaceholdersEmpty(keys: string[], values: SignatureValues): boolean {
|
||||
function allPlaceholdersEmpty(keys: string[], values: PlaceholderValues): boolean {
|
||||
return keys.length > 0 && keys.every((key) => !valueOf(values, key));
|
||||
}
|
||||
|
||||
/** Fills a plain-text template; lines whose placeholders are all empty are dropped. */
|
||||
export function fillTextTemplate(template: string, values: SignatureValues): string {
|
||||
/**
|
||||
* Fills a plain-text template; lines whose placeholders are all empty are
|
||||
* dropped. `keys` are the placeholders it knows (the signature's by
|
||||
* default); any other `{word}` stays as written.
|
||||
*/
|
||||
export function fillTextTemplate(template: string, values: PlaceholderValues, keys: readonly string[] = SIGNATURE_PLACEHOLDERS): string {
|
||||
const re = keys === SIGNATURE_PLACEHOLDERS ? PLACEHOLDER_RE : placeholderPattern(keys);
|
||||
return template
|
||||
.replace(/\r\n?/g, '\n')
|
||||
.split('\n')
|
||||
.filter((line) => !allPlaceholdersEmpty(placeholdersIn(line), values))
|
||||
.map((line) => fillString(line, values))
|
||||
.filter((line) => !allPlaceholdersEmpty(placeholdersIn(line, re), values))
|
||||
.map((line) => fillString(line, values, re))
|
||||
.join('\n')
|
||||
.trim();
|
||||
}
|
||||
@@ -115,15 +128,15 @@ const isElement = (node: Node): node is Element => node.nodeType === 1;
|
||||
const isBlock = (node: Node) => isElement(node) && BLOCK_TAGS.has(node.tagName);
|
||||
|
||||
/** Placeholders in the text and the attributes of `nodes`. */
|
||||
function placeholdersOfNodes(nodes: Node[]): string[] {
|
||||
function placeholdersOfNodes(nodes: Node[], re: RegExp): string[] {
|
||||
const keys: string[] = [];
|
||||
const visit = (node: Node) => {
|
||||
if (node.nodeType === 3) {
|
||||
keys.push(...placeholdersIn(node.textContent ?? ''));
|
||||
keys.push(...placeholdersIn(node.textContent ?? '', re));
|
||||
return;
|
||||
}
|
||||
if (!isElement(node)) return;
|
||||
for (const attr of Array.from(node.attributes)) keys.push(...placeholdersIn(attr.value));
|
||||
for (const attr of Array.from(node.attributes)) keys.push(...placeholdersIn(attr.value, re));
|
||||
node.childNodes.forEach(visit);
|
||||
};
|
||||
nodes.forEach(visit);
|
||||
@@ -171,14 +184,14 @@ function linesOf(block: Element): Line[] {
|
||||
}
|
||||
|
||||
/** The placeholders of a line: in its text, its media, and the attributes of the inline wrappers around it. */
|
||||
function placeholdersOfLine(line: Line, block: Element): string[] {
|
||||
const keys = placeholdersOfNodes(line.leaves);
|
||||
function placeholdersOfLine(line: Line, block: Element, re: RegExp): string[] {
|
||||
const keys = placeholdersOfNodes(line.leaves, re);
|
||||
const wrappers = new Set<Element>();
|
||||
for (const leaf of line.leaves) {
|
||||
for (let parent = leaf.parentElement; parent && parent !== block; parent = parent.parentElement) wrappers.add(parent);
|
||||
}
|
||||
for (const wrapper of wrappers) {
|
||||
for (const attr of Array.from(wrapper.attributes)) keys.push(...placeholdersIn(attr.value));
|
||||
for (const attr of Array.from(wrapper.attributes)) keys.push(...placeholdersIn(attr.value, re));
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
@@ -201,10 +214,10 @@ function removeWithEmptyWrappers(node: Node, block: Element): void {
|
||||
* placeholders and is empty afterwards goes too, so "<p>{mobile}</p>"
|
||||
* leaves no empty paragraph.
|
||||
*/
|
||||
function dropEmptyLines(el: Element, values: SignatureValues, isRoot: boolean): void {
|
||||
if (placeholdersOfNodes([el]).length === 0) return;
|
||||
function dropEmptyLines(el: Element, values: PlaceholderValues, isRoot: boolean, re: RegExp): void {
|
||||
if (placeholdersOfNodes([el], re).length === 0) return;
|
||||
// A table row is one line: "Phone: | {phone}" goes as a whole.
|
||||
if (el.tagName === 'TR' && allPlaceholdersEmpty(placeholdersOfNodes([el]), values)) {
|
||||
if (el.tagName === 'TR' && allPlaceholdersEmpty(placeholdersOfNodes([el], re), values)) {
|
||||
el.remove();
|
||||
return;
|
||||
}
|
||||
@@ -217,15 +230,15 @@ function dropEmptyLines(el: Element, values: SignatureValues, isRoot: boolean):
|
||||
}
|
||||
};
|
||||
collect(el);
|
||||
nested.forEach((child) => dropEmptyLines(child, values, false));
|
||||
nested.forEach((child) => dropEmptyLines(child, values, false, re));
|
||||
|
||||
const lines = linesOf(el);
|
||||
lines.forEach((line, index) => {
|
||||
if (!allPlaceholdersEmpty(placeholdersOfLine(line, el), values)) return;
|
||||
if (!allPlaceholdersEmpty(placeholdersOfLine(line, el, re), values)) return;
|
||||
let kept = false;
|
||||
for (const leaf of line.leaves) {
|
||||
const media = isElement(leaf) && MEDIA_TAGS.has(leaf.tagName);
|
||||
if (media && placeholdersOfNodes([leaf]).length === 0) kept = true;
|
||||
if (media && placeholdersOfNodes([leaf], re).length === 0) kept = true;
|
||||
else removeWithEmptyWrappers(leaf, el);
|
||||
}
|
||||
// An image left on the line keeps its line break.
|
||||
@@ -248,16 +261,16 @@ function dropEmptyLines(el: Element, values: SignatureValues, isRoot: boolean):
|
||||
if (!isRoot && !hasVisibleContent(el)) el.remove();
|
||||
}
|
||||
|
||||
function fillNodes(root: Element, values: SignatureValues): void {
|
||||
function fillNodes(root: Element, values: PlaceholderValues, re: RegExp): void {
|
||||
const walk = (node: Node) => {
|
||||
if (node.nodeType === 3) {
|
||||
const text = node.textContent ?? '';
|
||||
if (placeholdersIn(text).length > 0) node.textContent = fillString(text, values);
|
||||
if (placeholdersIn(text, re).length > 0) node.textContent = fillString(text, values, re);
|
||||
return;
|
||||
}
|
||||
if (!isElement(node)) return;
|
||||
for (const attr of Array.from(node.attributes)) {
|
||||
if (placeholdersIn(attr.value).length > 0) node.setAttribute(attr.name, fillString(attr.value, values));
|
||||
if (placeholdersIn(attr.value, re).length > 0) node.setAttribute(attr.name, fillString(attr.value, values, re));
|
||||
}
|
||||
node.childNodes.forEach(walk);
|
||||
};
|
||||
@@ -269,14 +282,15 @@ function fillNodes(root: Element, values: SignatureValues): void {
|
||||
* lines whose placeholders are all empty are dropped. The result still goes
|
||||
* through the signature sanitizer wherever it is inserted.
|
||||
*/
|
||||
export function fillHtmlTemplate(template: string, values: SignatureValues): string {
|
||||
export function fillHtmlTemplate(template: string, values: PlaceholderValues, keys: readonly string[] = SIGNATURE_PLACEHOLDERS): string {
|
||||
const re = keys === SIGNATURE_PLACEHOLDERS ? PLACEHOLDER_RE : placeholderPattern(keys);
|
||||
if (typeof DOMParser === 'undefined') {
|
||||
// Server render: no DOM. Nothing calls this there for real; escape at least.
|
||||
return template.replace(PLACEHOLDER_RE, (_, key: string) => escapeHtml(valueOf(values, key))).trim();
|
||||
return template.replace(re, (_, key: string) => escapeHtml(valueOf(values, key))).trim();
|
||||
}
|
||||
const doc = new DOMParser().parseFromString(`<!doctype html><html><body>${template}</body></html>`, 'text/html');
|
||||
dropEmptyLines(doc.body, values, true);
|
||||
fillNodes(doc.body, values);
|
||||
dropEmptyLines(doc.body, values, true, re);
|
||||
fillNodes(doc.body, values, re);
|
||||
return doc.body.innerHTML.trim();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Out-of-office templates from the admin library (policy.json
|
||||
* `organisation.vacationTemplates.library`, enterprise pack item 11). A
|
||||
* template is filled in when the user picks it, with the same placeholder
|
||||
* style and empty-line rule as the organisation signature
|
||||
* (lib/org-signature.ts): `{name}`, `{email}`, `{company}`, `{start}`,
|
||||
* `{end}`, `{deputy}`, `{deputy_name}`. The deputy is typed by the user
|
||||
* before anything is filled, so a half-filled reply is never saved.
|
||||
*/
|
||||
|
||||
import type { VacationTemplate } from '@/lib/admin/organisation-policy';
|
||||
import { fillHtmlTemplate, fillTextTemplate, placeholderPattern, placeholdersIn, type PlaceholderValues } from '@/lib/org-signature';
|
||||
import { getWallClock } from '@/lib/timezone';
|
||||
|
||||
export const VACATION_PLACEHOLDERS = ['name', 'email', 'company', 'start', 'end', 'deputy', 'deputy_name'] as const;
|
||||
export type VacationPlaceholder = (typeof VACATION_PLACEHOLDERS)[number];
|
||||
export type VacationValues = Partial<Record<VacationPlaceholder, string>>;
|
||||
|
||||
const RE = placeholderPattern(VACATION_PLACEHOLDERS);
|
||||
|
||||
/** The placeholders a template uses anywhere (subject, text or HTML). */
|
||||
export function vacationPlaceholdersIn(template: Pick<VacationTemplate, 'subject' | 'text' | 'html'>): VacationPlaceholder[] {
|
||||
const found = new Set<string>([
|
||||
...placeholdersIn(template.subject, RE),
|
||||
...placeholdersIn(template.text, RE),
|
||||
...placeholdersIn(template.html, RE),
|
||||
]);
|
||||
return VACATION_PLACEHOLDERS.filter((key) => found.has(key));
|
||||
}
|
||||
|
||||
/** What the user has to type before the template can be filled: the deputy's address, and name if the text uses it. */
|
||||
export function deputyFieldsNeeded(template: Pick<VacationTemplate, 'subject' | 'text' | 'html'>): { address: boolean; name: boolean } {
|
||||
const used = vacationPlaceholdersIn(template);
|
||||
const name = used.includes('deputy_name');
|
||||
return { address: used.includes('deputy') || name, name };
|
||||
}
|
||||
|
||||
/**
|
||||
* A date of the absence for the reply, in the user's language and time
|
||||
* zone: the day alone when it is midnight there ("back on Monday 9
|
||||
* November"), else day and time.
|
||||
*/
|
||||
export function formatAbsenceDate(iso: string | null | undefined, locale: string, timeZone: string): string {
|
||||
if (!iso) return '';
|
||||
const date = new Date(iso);
|
||||
if (Number.isNaN(date.getTime())) return '';
|
||||
const wall = getWallClock(date, timeZone);
|
||||
const midnight = wall.hour === 0 && wall.minute === 0;
|
||||
try {
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
weekday: 'long', day: 'numeric', month: 'long', year: 'numeric',
|
||||
...(midnight ? {} : { hour: 'numeric', minute: '2-digit' }),
|
||||
timeZone,
|
||||
}).format(date);
|
||||
} catch {
|
||||
return date.toISOString().slice(0, 16).replace('T', ' ');
|
||||
}
|
||||
}
|
||||
|
||||
export interface VacationFillInput {
|
||||
name: string;
|
||||
email: string;
|
||||
company: string;
|
||||
fromDate: string | null;
|
||||
toDate: string | null;
|
||||
deputy: string;
|
||||
deputyName: string;
|
||||
locale: string;
|
||||
timeZone: string;
|
||||
}
|
||||
|
||||
export function vacationValues(input: VacationFillInput): VacationValues {
|
||||
return {
|
||||
name: input.name.trim(),
|
||||
email: input.email.trim(),
|
||||
company: input.company.trim(),
|
||||
start: formatAbsenceDate(input.fromDate, input.locale, input.timeZone),
|
||||
end: formatAbsenceDate(input.toDate, input.locale, input.timeZone),
|
||||
deputy: input.deputy.trim(),
|
||||
deputy_name: input.deputyName.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
export interface FilledVacationTemplate {
|
||||
subject: string;
|
||||
text: string;
|
||||
/** '' when the template has no HTML version. */
|
||||
html: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The template filled in. The subject is one line: its empty placeholders
|
||||
* go and the spaces around them close up; in the text and HTML a line
|
||||
* whose placeholders are all empty is dropped ("I'm back on {end}" with no
|
||||
* end date).
|
||||
*/
|
||||
export function fillVacationTemplate(template: VacationTemplate, values: VacationValues): FilledVacationTemplate {
|
||||
const v = values as PlaceholderValues;
|
||||
const subject = template.subject
|
||||
.replace(RE, (_, key: string) => (v[key] ?? '').trim())
|
||||
.replace(/\s{2,}/g, ' ')
|
||||
.replace(/\s+([,.;:!?)])/g, '$1')
|
||||
.trim();
|
||||
return {
|
||||
subject,
|
||||
text: template.text.trim() ? fillTextTemplate(template.text, v, VACATION_PLACEHOLDERS) : '',
|
||||
html: template.html.trim() ? fillHtmlTemplate(template.html, v, VACATION_PLACEHOLDERS) : '',
|
||||
};
|
||||
}
|
||||
|
||||
/** The library sorted for the picker: by category, then name. */
|
||||
export function sortedVacationTemplates(library: readonly VacationTemplate[]): VacationTemplate[] {
|
||||
return [...library].sort((a, b) => (a.category || '').localeCompare(b.category || '') || a.name.localeCompare(b.name));
|
||||
}
|
||||
@@ -1308,6 +1308,20 @@
|
||||
"severity_warning": "تحذير",
|
||||
"severity_critical": "هام"
|
||||
},
|
||||
"vacation_templates": {
|
||||
"title": "قوالب مؤسستك",
|
||||
"description": "ابدأ من رد كتبته مؤسستك. يمكنك تغيير النص بعد ذلك.",
|
||||
"pick_label": "استخدام قالب",
|
||||
"pick_placeholder": "اختر قالبًا",
|
||||
"deputy_label": "عنوان البديل",
|
||||
"deputy_placeholder": "colleague@example.com",
|
||||
"deputy_hint": "يذكر الرد هذا الزميل للأمور العاجلة.",
|
||||
"deputy_missing": "أدخل عنوان بديلك أولًا.",
|
||||
"deputy_name_label": "اسم البديل",
|
||||
"deputy_name_missing": "أدخل اسم بديلك أولًا.",
|
||||
"apply": "استخدام هذا النص",
|
||||
"apply_hint": "يستبدل الموضوع والنص أدناه."
|
||||
},
|
||||
"third_party": {
|
||||
"push_needs_relay": "تحتاج الإشعارات الفورية إلى خادم ترحيل تديره مؤسستك، ولم يُعدّ أي خادم. يستمر ظهور البريد الجديد ما دام التطبيق مفتوحًا.",
|
||||
"relay_desc": "لا تُعرض إلا خوادم الترحيل الخاصة بمؤسستك، لأن هذا الخادم لا يتصل بخدمات خارجية.",
|
||||
|
||||
@@ -1308,6 +1308,20 @@
|
||||
"severity_warning": "Advertència",
|
||||
"severity_critical": "Important"
|
||||
},
|
||||
"vacation_templates": {
|
||||
"title": "Plantilles de l'organització",
|
||||
"description": "Comenceu amb una resposta que ha escrit la vostra organització. Després podeu canviar el text.",
|
||||
"pick_label": "Utilitza una plantilla",
|
||||
"pick_placeholder": "Trieu una plantilla",
|
||||
"deputy_label": "Adreça del suplent",
|
||||
"deputy_placeholder": "company@example.com",
|
||||
"deputy_hint": "La resposta indica aquesta persona per als assumptes urgents.",
|
||||
"deputy_missing": "Primer introduïu l’adreça del vostre suplent.",
|
||||
"deputy_name_label": "Nom del suplent",
|
||||
"deputy_name_missing": "Primer introduïu el nom del vostre suplent.",
|
||||
"apply": "Utilitza aquest text",
|
||||
"apply_hint": "Substitueix l'assumpte i el text de sota."
|
||||
},
|
||||
"third_party": {
|
||||
"push_needs_relay": "Les notificacions push necessiten un repetidor push gestionat per la vostra organització, i no n'hi ha cap de configurat. El correu nou es continua mostrant mentre l'aplicació està oberta.",
|
||||
"relay_desc": "Només s'ofereixen els repetidors propis de la vostra organització, perquè aquest servidor no contacta amb serveis externs.",
|
||||
|
||||
@@ -1308,6 +1308,20 @@
|
||||
"severity_warning": "Upozornění",
|
||||
"severity_critical": "Důležité"
|
||||
},
|
||||
"vacation_templates": {
|
||||
"title": "Šablony organizace",
|
||||
"description": "Začněte odpovědí, kterou připravila vaše organizace. Text pak můžete upravit.",
|
||||
"pick_label": "Použít šablonu",
|
||||
"pick_placeholder": "Vyberte šablonu",
|
||||
"deputy_label": "Adresa zástupce",
|
||||
"deputy_placeholder": "kolega@example.com",
|
||||
"deputy_hint": "Odpověď uvede tohoto kolegu pro naléhavé záležitosti.",
|
||||
"deputy_missing": "Nejdřív zadejte adresu svého zástupce.",
|
||||
"deputy_name_label": "Jméno zástupce",
|
||||
"deputy_name_missing": "Nejdřív zadejte jméno svého zástupce.",
|
||||
"apply": "Použít tento text",
|
||||
"apply_hint": "Nahradí předmět a text níže."
|
||||
},
|
||||
"third_party": {
|
||||
"push_needs_relay": "Push oznámení potřebují push relay provozovaný vaší organizací a žádný není nastaven. Nová pošta se dál zobrazuje, dokud je aplikace otevřená.",
|
||||
"relay_desc": "Nabízejí se jen vlastní relaye vaší organizace, protože tento server nekontaktuje externí služby.",
|
||||
|
||||
@@ -1308,6 +1308,20 @@
|
||||
"severity_warning": "Advarsel",
|
||||
"severity_critical": "Vigtigt"
|
||||
},
|
||||
"vacation_templates": {
|
||||
"title": "Organisationens skabeloner",
|
||||
"description": "Start med et svar, din organisation har skrevet. Du kan ændre teksten bagefter.",
|
||||
"pick_label": "Brug en skabelon",
|
||||
"pick_placeholder": "Vælg en skabelon",
|
||||
"deputy_label": "Stedfortræderens adresse",
|
||||
"deputy_placeholder": "kollega@example.com",
|
||||
"deputy_hint": "Svaret nævner denne kollega ved hastesager.",
|
||||
"deputy_missing": "Indtast først din stedfortræders adresse.",
|
||||
"deputy_name_label": "Stedfortræderens navn",
|
||||
"deputy_name_missing": "Indtast først din stedfortræders navn.",
|
||||
"apply": "Brug denne tekst",
|
||||
"apply_hint": "Erstatter emnet og teksten nedenfor."
|
||||
},
|
||||
"third_party": {
|
||||
"push_needs_relay": "Push-notifikationer kræver et push-relæ, som din organisation driver, og der er ikke sat noget op. Ny mail vises stadig, mens appen er åben.",
|
||||
"relay_desc": "Kun din organisations egne relæer tilbydes, fordi denne server ikke kontakter eksterne tjenester.",
|
||||
|
||||
@@ -1308,6 +1308,20 @@
|
||||
"severity_warning": "Warnung",
|
||||
"severity_critical": "Wichtig"
|
||||
},
|
||||
"vacation_templates": {
|
||||
"title": "Vorlagen Ihrer Organisation",
|
||||
"description": "Beginnen Sie mit einer Antwort, die Ihre Organisation vorbereitet hat. Den Text können Sie danach ändern.",
|
||||
"pick_label": "Vorlage verwenden",
|
||||
"pick_placeholder": "Vorlage auswählen",
|
||||
"deputy_label": "Adresse der Vertretung",
|
||||
"deputy_placeholder": "kollegin@example.com",
|
||||
"deputy_hint": "Die Antwort nennt diese Person für dringende Anliegen.",
|
||||
"deputy_missing": "Geben Sie zuerst die Adresse Ihrer Vertretung ein.",
|
||||
"deputy_name_label": "Name der Vertretung",
|
||||
"deputy_name_missing": "Geben Sie zuerst den Namen Ihrer Vertretung ein.",
|
||||
"apply": "Diesen Text verwenden",
|
||||
"apply_hint": "Ersetzt Betreff und Text unten."
|
||||
},
|
||||
"third_party": {
|
||||
"push_needs_relay": "Push-Benachrichtigungen brauchen ein Push-Relay Ihrer Organisation, und es ist keines eingerichtet. Neue E-Mails werden weiterhin angezeigt, solange die App geöffnet ist.",
|
||||
"relay_desc": "Es werden nur die eigenen Relays Ihrer Organisation angeboten, weil dieser Server keine externen Dienste kontaktiert.",
|
||||
|
||||
@@ -1308,6 +1308,20 @@
|
||||
"severity_warning": "Warning",
|
||||
"severity_critical": "Important"
|
||||
},
|
||||
"vacation_templates": {
|
||||
"title": "Organisation templates",
|
||||
"description": "Start from a reply your organisation wrote. You can change the text afterwards.",
|
||||
"pick_label": "Use a template",
|
||||
"pick_placeholder": "Choose a template",
|
||||
"deputy_label": "Deputy's address",
|
||||
"deputy_placeholder": "colleague@example.com",
|
||||
"deputy_hint": "The reply names this colleague for urgent matters.",
|
||||
"deputy_missing": "Enter your deputy's address first.",
|
||||
"deputy_name_label": "Deputy's name",
|
||||
"deputy_name_missing": "Enter your deputy's name first.",
|
||||
"apply": "Use this text",
|
||||
"apply_hint": "Replaces the subject and text below."
|
||||
},
|
||||
"third_party": {
|
||||
"push_needs_relay": "Push notifications need a push relay run by your organisation, and none is set up. New mail still shows while the app is open.",
|
||||
"relay_desc": "Only your organisation's own relays are offered, because this server doesn't contact outside services.",
|
||||
|
||||
@@ -1308,6 +1308,20 @@
|
||||
"severity_warning": "Advertencia",
|
||||
"severity_critical": "Importante"
|
||||
},
|
||||
"vacation_templates": {
|
||||
"title": "Plantillas de la organización",
|
||||
"description": "Empiece con una respuesta que escribió su organización. Después puede cambiar el texto.",
|
||||
"pick_label": "Usar una plantilla",
|
||||
"pick_placeholder": "Elija una plantilla",
|
||||
"deputy_label": "Dirección del suplente",
|
||||
"deputy_placeholder": "colega@example.com",
|
||||
"deputy_hint": "La respuesta indica a esta persona para asuntos urgentes.",
|
||||
"deputy_missing": "Escriba primero la dirección de su suplente.",
|
||||
"deputy_name_label": "Nombre del suplente",
|
||||
"deputy_name_missing": "Escriba primero el nombre de su suplente.",
|
||||
"apply": "Usar este texto",
|
||||
"apply_hint": "Sustituye el asunto y el texto de abajo."
|
||||
},
|
||||
"third_party": {
|
||||
"push_needs_relay": "Las notificaciones push necesitan un relay push gestionado por tu organización, y no hay ninguno configurado. El correo nuevo se sigue mostrando mientras la aplicación está abierta.",
|
||||
"relay_desc": "Solo se ofrecen los relays propios de tu organización, porque este servidor no contacta con servicios externos.",
|
||||
|
||||
@@ -1308,6 +1308,20 @@
|
||||
"severity_warning": "Hoiatus",
|
||||
"severity_critical": "Oluline"
|
||||
},
|
||||
"vacation_templates": {
|
||||
"title": "Organisatsiooni mallid",
|
||||
"description": "Alusta vastusest, mille sinu organisatsioon on kirjutanud. Teksti saad hiljem muuta.",
|
||||
"pick_label": "Kasuta malli",
|
||||
"pick_placeholder": "Vali mall",
|
||||
"deputy_label": "Asendaja aadress",
|
||||
"deputy_placeholder": "kolleeg@example.com",
|
||||
"deputy_hint": "Vastus nimetab selle kolleegi kiireloomuliste asjade jaoks.",
|
||||
"deputy_missing": "Sisesta kõigepealt oma asendaja aadress.",
|
||||
"deputy_name_label": "Asendaja nimi",
|
||||
"deputy_name_missing": "Sisesta kõigepealt oma asendaja nimi.",
|
||||
"apply": "Kasuta seda teksti",
|
||||
"apply_hint": "Asendab allpool oleva teema ja teksti."
|
||||
},
|
||||
"third_party": {
|
||||
"push_needs_relay": "Tõuketeavitused vajavad sinu organisatsiooni hallatavat push-releed, kuid ühtegi pole seadistatud. Uued kirjad kuvatakse endiselt, kuni rakendus on avatud.",
|
||||
"relay_desc": "Pakutakse ainult sinu organisatsiooni enda releesid, sest see server ei võta ühendust väliste teenustega.",
|
||||
|
||||
@@ -1308,6 +1308,20 @@
|
||||
"severity_warning": "هشدار",
|
||||
"severity_critical": "مهم"
|
||||
},
|
||||
"vacation_templates": {
|
||||
"title": "قالبهای سازمان",
|
||||
"description": "از پاسخی که سازمان شما نوشته شروع کنید. بعداً میتوانید متن را تغییر دهید.",
|
||||
"pick_label": "استفاده از قالب",
|
||||
"pick_placeholder": "یک قالب انتخاب کنید",
|
||||
"deputy_label": "نشانی جانشین",
|
||||
"deputy_placeholder": "colleague@example.com",
|
||||
"deputy_hint": "پاسخ این همکار را برای کارهای فوری نام میبرد.",
|
||||
"deputy_missing": "ابتدا نشانی جانشین خود را وارد کنید.",
|
||||
"deputy_name_label": "نام جانشین",
|
||||
"deputy_name_missing": "ابتدا نام جانشین خود را وارد کنید.",
|
||||
"apply": "استفاده از این متن",
|
||||
"apply_hint": "موضوع و متن زیر را جایگزین میکند."
|
||||
},
|
||||
"third_party": {
|
||||
"push_needs_relay": "اعلانهای push به یک رلهٔ push نیاز دارند که سازمان شما اداره کند، و هیچکدام راهاندازی نشده است. تا وقتی برنامه باز است، ایمیلهای جدید همچنان نمایش داده میشوند.",
|
||||
"relay_desc": "فقط رلههای خود سازمان شما پیشنهاد میشوند، چون این سرور با سرویسهای بیرونی تماس نمیگیرد.",
|
||||
|
||||
@@ -1308,6 +1308,20 @@
|
||||
"severity_warning": "Avertissement",
|
||||
"severity_critical": "Important"
|
||||
},
|
||||
"vacation_templates": {
|
||||
"title": "Modèles de l'organisation",
|
||||
"description": "Partez d'une réponse rédigée par votre organisation. Vous pouvez modifier le texte ensuite.",
|
||||
"pick_label": "Utiliser un modèle",
|
||||
"pick_placeholder": "Choisissez un modèle",
|
||||
"deputy_label": "Adresse du suppléant",
|
||||
"deputy_placeholder": "collegue@example.com",
|
||||
"deputy_hint": "La réponse indique cette personne pour les urgences.",
|
||||
"deputy_missing": "Saisissez d'abord l'adresse de votre suppléant.",
|
||||
"deputy_name_label": "Nom du suppléant",
|
||||
"deputy_name_missing": "Saisissez d'abord le nom de votre suppléant.",
|
||||
"apply": "Utiliser ce texte",
|
||||
"apply_hint": "Remplace l'objet et le texte ci-dessous."
|
||||
},
|
||||
"third_party": {
|
||||
"push_needs_relay": "Les notifications push nécessitent un relais push géré par votre organisation, et aucun n'est configuré. Les nouveaux messages s'affichent toujours tant que l'application est ouverte.",
|
||||
"relay_desc": "Seuls les relais de votre organisation sont proposés, car ce serveur ne contacte pas de services extérieurs.",
|
||||
|
||||
@@ -1273,6 +1273,20 @@
|
||||
"severity_warning": "אזהרה",
|
||||
"severity_critical": "חשוב"
|
||||
},
|
||||
"vacation_templates": {
|
||||
"title": "תבניות של הארגון",
|
||||
"description": "התחילו מתשובה שהארגון שלכם כתב. אפשר לשנות את הטקסט אחר כך.",
|
||||
"pick_label": "שימוש בתבנית",
|
||||
"pick_placeholder": "בחרו תבנית",
|
||||
"deputy_label": "כתובת הממלא מקום",
|
||||
"deputy_placeholder": "colleague@example.com",
|
||||
"deputy_hint": "התשובה מפנה לעמית הזה בעניינים דחופים.",
|
||||
"deputy_missing": "הזינו קודם את כתובת הממלא מקום.",
|
||||
"deputy_name_label": "שם הממלא מקום",
|
||||
"deputy_name_missing": "הזינו קודם את שם הממלא מקום.",
|
||||
"apply": "שימוש בטקסט הזה",
|
||||
"apply_hint": "מחליף את הנושא והטקסט שלמטה."
|
||||
},
|
||||
"third_party": {
|
||||
"push_needs_relay": "התראות Push דורשות ממסר Push שהארגון שלך מפעיל, ולא הוגדר אף אחד. דואר חדש עדיין מוצג כל עוד האפליקציה פתוחה.",
|
||||
"relay_desc": "מוצעים רק הממסרים של הארגון שלך, כי השרת הזה לא פונה לשירותים חיצוניים.",
|
||||
|
||||
@@ -1308,6 +1308,20 @@
|
||||
"severity_warning": "Figyelmeztetés",
|
||||
"severity_critical": "Fontos"
|
||||
},
|
||||
"vacation_templates": {
|
||||
"title": "A szervezet sablonjai",
|
||||
"description": "Kezdjen a szervezete által megírt válasszal. A szöveget utána módosíthatja.",
|
||||
"pick_label": "Sablon használata",
|
||||
"pick_placeholder": "Válasszon sablont",
|
||||
"deputy_label": "A helyettes címe",
|
||||
"deputy_placeholder": "kollega@example.com",
|
||||
"deputy_hint": "A válasz sürgős ügyekben ezt a kollégát nevezi meg.",
|
||||
"deputy_missing": "Előbb adja meg a helyettese címét.",
|
||||
"deputy_name_label": "A helyettes neve",
|
||||
"deputy_name_missing": "Előbb adja meg a helyettese nevét.",
|
||||
"apply": "Ennek a szövegnek a használata",
|
||||
"apply_hint": "Lecseréli a lenti tárgyat és szöveget."
|
||||
},
|
||||
"third_party": {
|
||||
"push_needs_relay": "A push-értesítésekhez a szervezeted által üzemeltetett push relé kell, és nincs beállítva egy sem. Az új levelek továbbra is megjelennek, amíg az alkalmazás nyitva van.",
|
||||
"relay_desc": "Csak a szervezeted saját reléi választhatók, mert ez a szerver nem kapcsolódik külső szolgáltatásokhoz.",
|
||||
|
||||
@@ -1308,6 +1308,20 @@
|
||||
"severity_warning": "Attenzione",
|
||||
"severity_critical": "Importante"
|
||||
},
|
||||
"vacation_templates": {
|
||||
"title": "Modelli dell'organizzazione",
|
||||
"description": "Parti da una risposta scritta dalla tua organizzazione. Puoi modificare il testo dopo.",
|
||||
"pick_label": "Usa un modello",
|
||||
"pick_placeholder": "Scegli un modello",
|
||||
"deputy_label": "Indirizzo del sostituto",
|
||||
"deputy_placeholder": "collega@example.com",
|
||||
"deputy_hint": "La risposta indica questa persona per le questioni urgenti.",
|
||||
"deputy_missing": "Inserisci prima l'indirizzo del tuo sostituto.",
|
||||
"deputy_name_label": "Nome del sostituto",
|
||||
"deputy_name_missing": "Inserisci prima il nome del tuo sostituto.",
|
||||
"apply": "Usa questo testo",
|
||||
"apply_hint": "Sostituisce l'oggetto e il testo qui sotto."
|
||||
},
|
||||
"third_party": {
|
||||
"push_needs_relay": "Le notifiche push richiedono un relay push gestito dalla tua organizzazione, e non ne è configurato nessuno. La nuova posta compare comunque finché l'app è aperta.",
|
||||
"relay_desc": "Vengono proposti solo i relay della tua organizzazione, perché questo server non contatta servizi esterni.",
|
||||
|
||||
@@ -1308,6 +1308,20 @@
|
||||
"severity_warning": "警告",
|
||||
"severity_critical": "重要"
|
||||
},
|
||||
"vacation_templates": {
|
||||
"title": "組織のテンプレート",
|
||||
"description": "組織が用意した返信から始めます。テキストは後で変更できます。",
|
||||
"pick_label": "テンプレートを使う",
|
||||
"pick_placeholder": "テンプレートを選択",
|
||||
"deputy_label": "代理の人のアドレス",
|
||||
"deputy_placeholder": "colleague@example.com",
|
||||
"deputy_hint": "返信には、急ぎの用件の連絡先としてこの人が記載されます。",
|
||||
"deputy_missing": "先に代理の人のアドレスを入力してください。",
|
||||
"deputy_name_label": "代理の人の名前",
|
||||
"deputy_name_missing": "先に代理の人の名前を入力してください。",
|
||||
"apply": "このテキストを使う",
|
||||
"apply_hint": "下の件名と本文を置き換えます。"
|
||||
},
|
||||
"third_party": {
|
||||
"push_needs_relay": "プッシュ通知には組織が運用するプッシュリレーが必要ですが、設定されていません。アプリを開いている間は、新着メールが引き続き表示されます。",
|
||||
"relay_desc": "このサーバーは外部サービスに接続しないため、組織独自のリレーのみが表示されます。",
|
||||
|
||||
@@ -1308,6 +1308,20 @@
|
||||
"severity_warning": "경고",
|
||||
"severity_critical": "중요"
|
||||
},
|
||||
"vacation_templates": {
|
||||
"title": "조직 템플릿",
|
||||
"description": "조직에서 작성한 답장으로 시작하세요. 나중에 내용을 바꿀 수 있습니다.",
|
||||
"pick_label": "템플릿 사용",
|
||||
"pick_placeholder": "템플릿 선택",
|
||||
"deputy_label": "대리인 주소",
|
||||
"deputy_placeholder": "colleague@example.com",
|
||||
"deputy_hint": "답장에서 급한 용건은 이 동료에게 연락하도록 안내합니다.",
|
||||
"deputy_missing": "먼저 대리인의 주소를 입력하세요.",
|
||||
"deputy_name_label": "대리인 이름",
|
||||
"deputy_name_missing": "먼저 대리인의 이름을 입력하세요.",
|
||||
"apply": "이 내용 사용",
|
||||
"apply_hint": "아래 제목과 본문을 바꿉니다."
|
||||
},
|
||||
"third_party": {
|
||||
"push_needs_relay": "푸시 알림을 받으려면 조직에서 운영하는 푸시 릴레이가 필요한데, 설정된 릴레이가 없어요. 앱이 열려 있는 동안에는 새 메일이 계속 표시돼요.",
|
||||
"relay_desc": "이 서버는 외부 서비스에 연결하지 않으므로 조직 자체 릴레이만 표시돼요.",
|
||||
|
||||
@@ -1308,6 +1308,20 @@
|
||||
"severity_warning": "Brīdinājums",
|
||||
"severity_critical": "Svarīgi"
|
||||
},
|
||||
"vacation_templates": {
|
||||
"title": "Organizācijas veidnes",
|
||||
"description": "Sāciet ar atbildi, ko uzrakstījusi jūsu organizācija. Tekstu pēc tam varat mainīt.",
|
||||
"pick_label": "Izmantot veidni",
|
||||
"pick_placeholder": "Izvēlieties veidni",
|
||||
"deputy_label": "Aizvietotāja adrese",
|
||||
"deputy_placeholder": "kolegis@example.com",
|
||||
"deputy_hint": "Atbildē šis kolēģis būs norādīts steidzamiem jautājumiem.",
|
||||
"deputy_missing": "Vispirms ievadiet sava aizvietotāja adresi.",
|
||||
"deputy_name_label": "Aizvietotāja vārds",
|
||||
"deputy_name_missing": "Vispirms ievadiet sava aizvietotāja vārdu.",
|
||||
"apply": "Izmantot šo tekstu",
|
||||
"apply_hint": "Aizstāj tālāk esošo tēmu un tekstu."
|
||||
},
|
||||
"third_party": {
|
||||
"push_needs_relay": "Push paziņojumiem vajadzīgs jūsu organizācijas uzturēts push relejs, un neviens nav iestatīts. Jauns pasts joprojām tiek rādīts, kamēr lietotne ir atvērta.",
|
||||
"relay_desc": "Tiek piedāvāti tikai jūsu organizācijas pašas releji, jo šis serveris nesazinās ar ārējiem pakalpojumiem.",
|
||||
|
||||
@@ -1308,6 +1308,20 @@
|
||||
"severity_warning": "Анхааруулга",
|
||||
"severity_critical": "Чухал"
|
||||
},
|
||||
"vacation_templates": {
|
||||
"title": "Байгууллагын загварууд",
|
||||
"description": "Байгууллагын бичсэн хариултаас эхэлнэ үү. Дараа нь текстийг өөрчилж болно.",
|
||||
"pick_label": "Загвар ашиглах",
|
||||
"pick_placeholder": "Загвар сонгох",
|
||||
"deputy_label": "Орлогчийн хаяг",
|
||||
"deputy_placeholder": "colleague@example.com",
|
||||
"deputy_hint": "Хариултад яаралтай асуудлаар энэ хамт ажиллагчийг заана.",
|
||||
"deputy_missing": "Эхлээд орлогчийнхоо хаягийг оруулна уу.",
|
||||
"deputy_name_label": "Орлогчийн нэр",
|
||||
"deputy_name_missing": "Эхлээд орлогчийнхоо нэрийг оруулна уу.",
|
||||
"apply": "Энэ текстийг ашиглах",
|
||||
"apply_hint": "Доорх гарчиг болон текстийг солино."
|
||||
},
|
||||
"third_party": {
|
||||
"push_needs_relay": "Push мэдэгдэлд таны байгууллагын ажиллуулдаг push буухиа хэрэгтэй, гэвч нэг ч тохируулагдаагүй байна. Апп нээлттэй байх үед шинэ имэйл харагдсаар байна.",
|
||||
"relay_desc": "Энэ сервер гадны үйлчилгээтэй холбогддоггүй тул зөвхөн таны байгууллагын өөрийн буухиануудыг санал болгож байна.",
|
||||
|
||||
@@ -1308,6 +1308,20 @@
|
||||
"severity_warning": "Advarsel",
|
||||
"severity_critical": "Viktig"
|
||||
},
|
||||
"vacation_templates": {
|
||||
"title": "Organisasjonens maler",
|
||||
"description": "Start med et svar organisasjonen din har skrevet. Du kan endre teksten etterpå.",
|
||||
"pick_label": "Bruk en mal",
|
||||
"pick_placeholder": "Velg en mal",
|
||||
"deputy_label": "Stedfortrederens adresse",
|
||||
"deputy_placeholder": "kollega@example.com",
|
||||
"deputy_hint": "Svaret nevner denne kollegaen for hastesaker.",
|
||||
"deputy_missing": "Skriv inn stedfortrederens adresse først.",
|
||||
"deputy_name_label": "Stedfortrederens navn",
|
||||
"deputy_name_missing": "Skriv inn stedfortrederens navn først.",
|
||||
"apply": "Bruk denne teksten",
|
||||
"apply_hint": "Erstatter emnet og teksten nedenfor."
|
||||
},
|
||||
"third_party": {
|
||||
"push_needs_relay": "Push-varsler trenger et push-relé som organisasjonen din driver, og det er ikke satt opp noe. Ny e-post vises fortsatt mens appen er åpen.",
|
||||
"relay_desc": "Bare organisasjonens egne reléer tilbys, fordi denne serveren ikke kontakter eksterne tjenester.",
|
||||
|
||||
@@ -1308,6 +1308,20 @@
|
||||
"severity_warning": "Waarschuwing",
|
||||
"severity_critical": "Belangrijk"
|
||||
},
|
||||
"vacation_templates": {
|
||||
"title": "Sjablonen van de organisatie",
|
||||
"description": "Begin met een antwoord dat uw organisatie heeft geschreven. U kunt de tekst daarna aanpassen.",
|
||||
"pick_label": "Sjabloon gebruiken",
|
||||
"pick_placeholder": "Kies een sjabloon",
|
||||
"deputy_label": "Adres van de vervanger",
|
||||
"deputy_placeholder": "collega@example.com",
|
||||
"deputy_hint": "Het antwoord noemt deze collega voor dringende zaken.",
|
||||
"deputy_missing": "Vul eerst het adres van uw vervanger in.",
|
||||
"deputy_name_label": "Naam van de vervanger",
|
||||
"deputy_name_missing": "Vul eerst de naam van uw vervanger in.",
|
||||
"apply": "Deze tekst gebruiken",
|
||||
"apply_hint": "Vervangt het onderwerp en de tekst hieronder."
|
||||
},
|
||||
"third_party": {
|
||||
"push_needs_relay": "Pushmeldingen hebben een pushrelay van je organisatie nodig, en er is er geen ingesteld. Nieuwe e-mail wordt nog steeds getoond zolang de app open is.",
|
||||
"relay_desc": "Alleen de eigen relays van je organisatie worden aangeboden, omdat deze server geen externe diensten benadert.",
|
||||
|
||||
@@ -1308,6 +1308,20 @@
|
||||
"severity_warning": "Ostrzeżenie",
|
||||
"severity_critical": "Ważne"
|
||||
},
|
||||
"vacation_templates": {
|
||||
"title": "Szablony organizacji",
|
||||
"description": "Zacznij od odpowiedzi przygotowanej przez Twoją organizację. Tekst możesz potem zmienić.",
|
||||
"pick_label": "Użyj szablonu",
|
||||
"pick_placeholder": "Wybierz szablon",
|
||||
"deputy_label": "Adres zastępcy",
|
||||
"deputy_placeholder": "kolega@example.com",
|
||||
"deputy_hint": "Odpowiedź wskaże tę osobę w pilnych sprawach.",
|
||||
"deputy_missing": "Najpierw wpisz adres swojego zastępcy.",
|
||||
"deputy_name_label": "Imię i nazwisko zastępcy",
|
||||
"deputy_name_missing": "Najpierw wpisz imię i nazwisko zastępcy.",
|
||||
"apply": "Użyj tego tekstu",
|
||||
"apply_hint": "Zastępuje temat i tekst poniżej."
|
||||
},
|
||||
"third_party": {
|
||||
"push_needs_relay": "Powiadomienia push wymagają przekaźnika push prowadzonego przez Twoją organizację, a żaden nie jest skonfigurowany. Nowe wiadomości nadal pojawiają się, gdy aplikacja jest otwarta.",
|
||||
"relay_desc": "Dostępne są tylko własne przekaźniki Twojej organizacji, ponieważ ten serwer nie łączy się z usługami zewnętrznymi.",
|
||||
|
||||
@@ -1308,6 +1308,20 @@
|
||||
"severity_warning": "Atenção",
|
||||
"severity_critical": "Importante"
|
||||
},
|
||||
"vacation_templates": {
|
||||
"title": "Modelos da organização",
|
||||
"description": "Comece por uma resposta escrita pela sua organização. Pode alterar o texto depois.",
|
||||
"pick_label": "Usar um modelo",
|
||||
"pick_placeholder": "Escolha um modelo",
|
||||
"deputy_label": "Endereço do substituto",
|
||||
"deputy_placeholder": "colega@example.com",
|
||||
"deputy_hint": "A resposta indica este colega para assuntos urgentes.",
|
||||
"deputy_missing": "Introduza primeiro o endereço do seu substituto.",
|
||||
"deputy_name_label": "Nome do substituto",
|
||||
"deputy_name_missing": "Introduza primeiro o nome do seu substituto.",
|
||||
"apply": "Usar este texto",
|
||||
"apply_hint": "Substitui o assunto e o texto abaixo."
|
||||
},
|
||||
"third_party": {
|
||||
"push_needs_relay": "As notificações push precisam de um relay push mantido pela sua organização, e nenhum está configurado. Os novos e-mails continuam aparecendo enquanto o app estiver aberto.",
|
||||
"relay_desc": "Só são oferecidos os relays da própria organização, porque este servidor não entra em contato com serviços externos.",
|
||||
|
||||
@@ -1308,6 +1308,20 @@
|
||||
"severity_warning": "Avertisment",
|
||||
"severity_critical": "Important"
|
||||
},
|
||||
"vacation_templates": {
|
||||
"title": "Șabloanele organizației",
|
||||
"description": "Porniți de la un răspuns scris de organizația dvs. Puteți schimba textul după aceea.",
|
||||
"pick_label": "Folosiți un șablon",
|
||||
"pick_placeholder": "Alegeți un șablon",
|
||||
"deputy_label": "Adresa înlocuitorului",
|
||||
"deputy_placeholder": "coleg@example.com",
|
||||
"deputy_hint": "Răspunsul îl indică pe acest coleg pentru probleme urgente.",
|
||||
"deputy_missing": "Introduceți mai întâi adresa înlocuitorului.",
|
||||
"deputy_name_label": "Numele înlocuitorului",
|
||||
"deputy_name_missing": "Introduceți mai întâi numele înlocuitorului.",
|
||||
"apply": "Folosiți acest text",
|
||||
"apply_hint": "Înlocuiește subiectul și textul de mai jos."
|
||||
},
|
||||
"third_party": {
|
||||
"push_needs_relay": "Notificările push au nevoie de un releu push administrat de organizația dvs., iar niciunul nu este configurat. E-mailurile noi apar în continuare cât timp aplicația este deschisă.",
|
||||
"relay_desc": "Sunt oferite doar releele proprii ale organizației dvs., deoarece acest server nu contactează servicii externe.",
|
||||
|
||||
@@ -1308,6 +1308,20 @@
|
||||
"severity_warning": "Предупреждение",
|
||||
"severity_critical": "Важно"
|
||||
},
|
||||
"vacation_templates": {
|
||||
"title": "Шаблоны организации",
|
||||
"description": "Начните с ответа, который подготовила ваша организация. Текст можно изменить потом.",
|
||||
"pick_label": "Использовать шаблон",
|
||||
"pick_placeholder": "Выберите шаблон",
|
||||
"deputy_label": "Адрес заместителя",
|
||||
"deputy_placeholder": "colleague@example.com",
|
||||
"deputy_hint": "В ответе этот коллега будет указан для срочных вопросов.",
|
||||
"deputy_missing": "Сначала введите адрес заместителя.",
|
||||
"deputy_name_label": "Имя заместителя",
|
||||
"deputy_name_missing": "Сначала введите имя заместителя.",
|
||||
"apply": "Использовать этот текст",
|
||||
"apply_hint": "Заменяет тему и текст ниже."
|
||||
},
|
||||
"third_party": {
|
||||
"push_needs_relay": "Для push-уведомлений нужен push-релей вашей организации, а он не настроен. Новые письма по-прежнему видны, пока приложение открыто.",
|
||||
"relay_desc": "Предлагаются только собственные релеи вашей организации, потому что этот сервер не обращается к внешним службам.",
|
||||
|
||||
@@ -1308,6 +1308,20 @@
|
||||
"severity_warning": "Upozornenie",
|
||||
"severity_critical": "Dôležité"
|
||||
},
|
||||
"vacation_templates": {
|
||||
"title": "Šablóny organizácie",
|
||||
"description": "Začnite odpoveďou, ktorú pripravila vaša organizácia. Text potom môžete zmeniť.",
|
||||
"pick_label": "Použiť šablónu",
|
||||
"pick_placeholder": "Vyberte šablónu",
|
||||
"deputy_label": "Adresa zástupcu",
|
||||
"deputy_placeholder": "kolega@example.com",
|
||||
"deputy_hint": "Odpoveď uvedie tohto kolegu pre naliehavé veci.",
|
||||
"deputy_missing": "Najprv zadajte adresu svojho zástupcu.",
|
||||
"deputy_name_label": "Meno zástupcu",
|
||||
"deputy_name_missing": "Najprv zadajte meno svojho zástupcu.",
|
||||
"apply": "Použiť tento text",
|
||||
"apply_hint": "Nahradí predmet a text nižšie."
|
||||
},
|
||||
"third_party": {
|
||||
"push_needs_relay": "Push oznámenia potrebujú push relay prevádzkovaný vašou organizáciou a žiadny nie je nastavený. Nová pošta sa naďalej zobrazuje, kým je aplikácia otvorená.",
|
||||
"relay_desc": "Ponúkajú sa iba vlastné relaye vašej organizácie, pretože tento server nekontaktuje externé služby.",
|
||||
|
||||
@@ -656,6 +656,20 @@
|
||||
"severity_warning": "คำเตือน",
|
||||
"severity_critical": "สำคัญ"
|
||||
},
|
||||
"vacation_templates": {
|
||||
"title": "เทมเพลตขององค์กร",
|
||||
"description": "เริ่มจากคำตอบที่องค์กรของคุณเขียนไว้ คุณแก้ไขข้อความได้ภายหลัง",
|
||||
"pick_label": "ใช้เทมเพลต",
|
||||
"pick_placeholder": "เลือกเทมเพลต",
|
||||
"deputy_label": "ที่อยู่ของผู้ทำหน้าที่แทน",
|
||||
"deputy_placeholder": "colleague@example.com",
|
||||
"deputy_hint": "คำตอบจะระบุเพื่อนร่วมงานคนนี้สำหรับเรื่องด่วน",
|
||||
"deputy_missing": "ป้อนที่อยู่ของผู้ทำหน้าที่แทนก่อน",
|
||||
"deputy_name_label": "ชื่อของผู้ทำหน้าที่แทน",
|
||||
"deputy_name_missing": "ป้อนชื่อของผู้ทำหน้าที่แทนก่อน",
|
||||
"apply": "ใช้ข้อความนี้",
|
||||
"apply_hint": "แทนที่หัวเรื่องและข้อความด้านล่าง"
|
||||
},
|
||||
"third_party": {
|
||||
"push_needs_relay": "การแจ้งเตือนแบบพุชต้องใช้ push relay ที่องค์กรของคุณดูแล แต่ยังไม่ได้ตั้งค่าไว้ อีเมลใหม่จะยังแสดงขณะที่เปิดแอปอยู่",
|
||||
"relay_desc": "มีให้เลือกเฉพาะรีเลย์ขององค์กรคุณเอง เนื่องจากเซิร์ฟเวอร์นี้ไม่ติดต่อบริการภายนอก",
|
||||
|
||||
@@ -1308,6 +1308,20 @@
|
||||
"severity_warning": "Uyarı",
|
||||
"severity_critical": "Önemli"
|
||||
},
|
||||
"vacation_templates": {
|
||||
"title": "Kuruluş şablonları",
|
||||
"description": "Kuruluşunuzun yazdığı bir yanıtla başlayın. Metni sonra değiştirebilirsiniz.",
|
||||
"pick_label": "Şablon kullan",
|
||||
"pick_placeholder": "Bir şablon seçin",
|
||||
"deputy_label": "Vekilin adresi",
|
||||
"deputy_placeholder": "meslektas@example.com",
|
||||
"deputy_hint": "Yanıt, acil işler için bu kişiyi belirtir.",
|
||||
"deputy_missing": "Önce vekilinizin adresini girin.",
|
||||
"deputy_name_label": "Vekilin adı",
|
||||
"deputy_name_missing": "Önce vekilinizin adını girin.",
|
||||
"apply": "Bu metni kullan",
|
||||
"apply_hint": "Aşağıdaki konu ve metnin yerine geçer."
|
||||
},
|
||||
"third_party": {
|
||||
"push_needs_relay": "Anlık bildirimler için kuruluşunuzun işlettiği bir push rölesi gerekir ve hiçbiri ayarlanmamış. Uygulama açıkken yeni postalar yine de görünür.",
|
||||
"relay_desc": "Bu sunucu dış hizmetlerle bağlantı kurmadığı için yalnızca kuruluşunuzun kendi röleleri sunulur.",
|
||||
|
||||
@@ -1308,6 +1308,20 @@
|
||||
"severity_warning": "Попередження",
|
||||
"severity_critical": "Важливо"
|
||||
},
|
||||
"vacation_templates": {
|
||||
"title": "Шаблони організації",
|
||||
"description": "Почніть з відповіді, яку підготувала ваша організація. Текст можна змінити потім.",
|
||||
"pick_label": "Використати шаблон",
|
||||
"pick_placeholder": "Виберіть шаблон",
|
||||
"deputy_label": "Адреса заступника",
|
||||
"deputy_placeholder": "colleague@example.com",
|
||||
"deputy_hint": "У відповіді цей колега буде вказаний для термінових питань.",
|
||||
"deputy_missing": "Спершу введіть адресу заступника.",
|
||||
"deputy_name_label": "Ім'я заступника",
|
||||
"deputy_name_missing": "Спершу введіть ім'я заступника.",
|
||||
"apply": "Використати цей текст",
|
||||
"apply_hint": "Замінює тему й текст нижче."
|
||||
},
|
||||
"third_party": {
|
||||
"push_needs_relay": "Для push-сповіщень потрібне push-реле вашої організації, а його не налаштовано. Нові листи й далі з'являються, поки застосунок відкритий.",
|
||||
"relay_desc": "Пропонуються лише власні реле вашої організації, бо цей сервер не звертається до зовнішніх служб.",
|
||||
|
||||
@@ -1320,6 +1320,20 @@
|
||||
"severity_warning": "Cảnh báo",
|
||||
"severity_critical": "Quan trọng"
|
||||
},
|
||||
"vacation_templates": {
|
||||
"title": "Mẫu của tổ chức",
|
||||
"description": "Bắt đầu từ một thư trả lời do tổ chức của bạn soạn. Bạn có thể sửa nội dung sau đó.",
|
||||
"pick_label": "Dùng mẫu",
|
||||
"pick_placeholder": "Chọn mẫu",
|
||||
"deputy_label": "Địa chỉ người thay thế",
|
||||
"deputy_placeholder": "colleague@example.com",
|
||||
"deputy_hint": "Thư trả lời sẽ nêu người này cho các việc khẩn.",
|
||||
"deputy_missing": "Hãy nhập địa chỉ người thay thế trước.",
|
||||
"deputy_name_label": "Tên người thay thế",
|
||||
"deputy_name_missing": "Hãy nhập tên người thay thế trước.",
|
||||
"apply": "Dùng nội dung này",
|
||||
"apply_hint": "Thay thế tiêu đề và nội dung bên dưới."
|
||||
},
|
||||
"third_party": {
|
||||
"push_needs_relay": "Thông báo đẩy cần có một chuyển tiếp đẩy do tổ chức của bạn điều hành và chưa có cài đặt nào. Thư mới vẫn hiển thị khi ứng dụng đang mở.",
|
||||
"relay_desc": "Chỉ các rơle riêng của tổ chức bạn mới được cung cấp vì máy chủ này không liên hệ với các dịch vụ bên ngoài.",
|
||||
|
||||
@@ -1308,6 +1308,20 @@
|
||||
"severity_warning": "警告",
|
||||
"severity_critical": "重要"
|
||||
},
|
||||
"vacation_templates": {
|
||||
"title": "組織範本",
|
||||
"description": "從組織撰寫的回覆開始。之後可以修改文字。",
|
||||
"pick_label": "使用範本",
|
||||
"pick_placeholder": "選擇範本",
|
||||
"deputy_label": "代理人地址",
|
||||
"deputy_placeholder": "colleague@example.com",
|
||||
"deputy_hint": "回覆會寫明緊急事務請聯絡這位同事。",
|
||||
"deputy_missing": "請先輸入代理人的地址。",
|
||||
"deputy_name_label": "代理人姓名",
|
||||
"deputy_name_missing": "請先輸入代理人的姓名。",
|
||||
"apply": "使用此文字",
|
||||
"apply_hint": "取代下方的主旨和內文。"
|
||||
},
|
||||
"third_party": {
|
||||
"push_needs_relay": "推送通知需要由您的組織執行的推送中繼,但尚未設定。應用程式開啟時,新郵件仍會顯示。",
|
||||
"relay_desc": "由於此伺服器不會連線至外部服務,因此只提供您組織自己的中繼。",
|
||||
|
||||
@@ -1308,6 +1308,20 @@
|
||||
"severity_warning": "警告",
|
||||
"severity_critical": "重要"
|
||||
},
|
||||
"vacation_templates": {
|
||||
"title": "组织模板",
|
||||
"description": "从组织编写的回复开始。之后可以修改文字。",
|
||||
"pick_label": "使用模板",
|
||||
"pick_placeholder": "选择模板",
|
||||
"deputy_label": "代理人地址",
|
||||
"deputy_placeholder": "colleague@example.com",
|
||||
"deputy_hint": "回复会写明紧急事务请联系这位同事。",
|
||||
"deputy_missing": "请先输入代理人的地址。",
|
||||
"deputy_name_label": "代理人姓名",
|
||||
"deputy_name_missing": "请先输入代理人的姓名。",
|
||||
"apply": "使用此文字",
|
||||
"apply_hint": "替换下方的主题和正文。"
|
||||
},
|
||||
"third_party": {
|
||||
"push_needs_relay": "推送通知需要由您的组织运行的推送中继,但尚未设置。应用打开时,新邮件仍会显示。",
|
||||
"relay_desc": "由于此服务器不连接外部服务,因此只提供您组织自己的中继。",
|
||||
|
||||
Reference in New Issue
Block a user