mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
Merge remote-tracking branch 'origin/main' into up/feat/mountain-view-layout
# Conflicts: # components/mail/mail-app.tsx
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
name: Integration tests
|
||||
|
||||
# Runs the dockerised webmail + Stalwart Playwright suite in integration/
|
||||
# (see integration/README.md). It takes several minutes, so unlike the vitest
|
||||
# job in tests.yml it does not run on every push. It runs:
|
||||
#
|
||||
# - every night on main,
|
||||
# - on a pull request that carries the "integration" label (and again on
|
||||
# each push to it while the label stays on),
|
||||
# - on demand: gh workflow run integration.yml
|
||||
#
|
||||
# A fresh runner means a fresh Stalwart volume, so the declarative account
|
||||
# bootstrap always applies. Specs pinned with test.fail for known server bugs
|
||||
# count as passed when they fail as expected; only an unexpected result fails
|
||||
# the job.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "17 2 * * *"
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
types: [opened, reopened, synchronize, labeled]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
playwright:
|
||||
name: playwright
|
||||
# On pull requests, run only when the "integration" label is on. A labeled
|
||||
# event for some other label must not start (or cancel) a run.
|
||||
if: >-
|
||||
github.event_name != 'pull_request' ||
|
||||
(github.event.action == 'labeled' && github.event.label.name == 'integration') ||
|
||||
(github.event.action != 'labeled' && contains(github.event.pull_request.labels.*.name, 'integration'))
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
# Job-level, so a skipped run never cancels a real one.
|
||||
concurrency:
|
||||
group: integration-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
# The Playwright container runs the suite from this checkout, so it
|
||||
# needs @playwright/test in node_modules.
|
||||
run: npm ci
|
||||
|
||||
- name: Run integration suite
|
||||
run: bash integration/run-tests.sh
|
||||
|
||||
- name: Show container logs
|
||||
if: failure()
|
||||
working-directory: integration
|
||||
run: docker compose logs --no-color --timestamps
|
||||
|
||||
- name: Upload Playwright report
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: integration-report
|
||||
path: |
|
||||
integration/playwright-report/
|
||||
integration/test-results/
|
||||
retention-days: 14
|
||||
@@ -1,5 +1,103 @@
|
||||
# Changelog
|
||||
|
||||
## 1.13.0 (2026-10-07)
|
||||
|
||||
1.13.0 turns the vacation settings into an Out of Office page that can also forward mail, lets filter rules run only for a date range, and copies messages into another connected account. Files from the Files app can be attached in the composer, tags can be set on many messages at once and folders moved in bulk. Messages whose sender can't be verified now carry a warning. It also stops one account's settings and templates from carrying over to the next account signed in on the same browser.
|
||||
|
||||
Thank you for your donations:
|
||||
|
||||
- _You? [Become a sponsor!](https://github.com/sponsors/bulwarkmail)_
|
||||
|
||||
**One-time**
|
||||
|
||||
- Anonymous
|
||||
- Anonymous
|
||||
- Anonymous
|
||||
|
||||
**Monthly**
|
||||
|
||||
- [@naokton](https://github.com/naokton)
|
||||
- [@yerTools](https://github.com/yerTools)
|
||||
- [@jsaathof](https://github.com/jsaathof)
|
||||
- [@berkersal](https://github.com/berkersal)
|
||||
- [@NABarnes](https://github.com/NABarnes)
|
||||
- [@felixzieger](https://github.com/felixzieger)
|
||||
- [@pr0ton11](https://github.com/pr0ton11)
|
||||
- [@fpauser](https://github.com/fpauser)
|
||||
- [@proxforge](https://github.com/proxforge)
|
||||
- [@spss20](https://github.com/spss20)
|
||||
- [@elgringoYan](https://github.com/elgringoYan)
|
||||
- [@pauladams8](https://github.com/pauladams8)
|
||||
- [@djpriest](https://github.com/djpriest)
|
||||
- [@umakers](https://github.com/umakers)
|
||||
- [@zplizzi](https://github.com/zplizzi)
|
||||
- [@jeremiah](https://github.com/jeremiah)
|
||||
- [@Theoretisch1337](https://github.com/Theoretisch1337)
|
||||
- [@svandive](https://github.com/svandive)
|
||||
- [@HiltMundell](https://github.com/HiltMundell)
|
||||
|
||||
### Security
|
||||
|
||||
- **Settings**: Signing out and then signing in with another account in the same browser no longer shows the previous account's settings, tags and templates, and no longer saves them to the new account's synced settings (#1185)
|
||||
|
||||
### Features
|
||||
|
||||
- **Mail**: A message whose sender fails the From domain's checks, or that passes neither SPF nor DKIM, shows a warning above the message and a badge next to the sender. "Always trust this sender" is not offered for it
|
||||
- **Mail**: "Copy to account..." in the message menu files a copy in a folder of another connected account and leaves the original where it is. Copies keep their original date (#1150, thanks @lucamzanon)
|
||||
- **Mail**: The selection toolbar and the right-click menu put a tag on, or take it off, every selected message at once (#1077)
|
||||
- **Out of office**: The vacation settings are now an Out of Office page: the period on top, then the automatic reply and forwarding, each with its own switch. Forwarding works with or without the reply, never forwards spam and can keep a copy in the inbox. The reply can go to all senders, internal senders only or external senders only (#1152, thanks @dealerweb)
|
||||
- **Filters**: A rule can be limited to a period, and the rule list shows whether it is scheduled, active or expired (#1152, thanks @dealerweb)
|
||||
- **Filters**: The new "All messages" condition matches every message (#1154, thanks @dealerweb)
|
||||
- **Composer**: "Attach from Files" attaches files from the Files app, including files shared with you, without downloading and uploading them again (#1179)
|
||||
- **Composer**: Typing @ in the message body offers the To and Cc recipients and inserts the chosen one's first name. Bcc recipients are never offered. "Mention recipients with @" in the composer settings turns it off (#1134, thanks @dealerweb)
|
||||
- **Folders**: The folder settings can select several folders, with Shift+click for a range, and move them together under another folder or to the top level (#1173)
|
||||
- **Calendar**: The day and week views can be limited to working hours, and the week view can leave out non-working days. Events outside the visible hours are counted at the top and bottom of each day and open from there, and a button in the view shows all hours (#1164)
|
||||
- **Login**: `LOGIN_SHOW_TOKEN_LOGIN=true` offers "Sign in with an access token", for JMAP servers that take API tokens instead of passwords, such as Fastmail
|
||||
- **Login**: A server in `JMAP_SERVERS` whose `oauth` block has both a `clientId` and a `buttonLabel` gets its own sign-in button on the login page, even while OAuth is off globally. This allows a "Sign in with Google" button for a Gmail bridge next to servers that use passwords (#1135, thanks @lucamzanon)
|
||||
- **UI**: The browser tab title names the active account, as in "Inbox - alice@example.com - Webmail", and Settings, Calendar, Contacts and Files set their own title. Turning off "Subject in Tab Title" under Admin > Policy keeps message subjects out of the title, and with it out of the browser history (#1186, thanks @oguzyilmaznet)
|
||||
- **Unified**: While logins reconnect after a page load, the "All accounts" header shows how many are already counted, such as "4/10" (#1140, thanks @lucamzanon)
|
||||
- **Push**: A message that reaches several of your accounts rings once. For 30 seconds after an alert, notifications from your other accounts arrive silently (#1141, thanks @lucamzanon)
|
||||
- **Server**: `SOURCE_CODE_URL` points the source code link in Settings > About at the code of a modified build, as AGPL section 13 asks of anyone running one (#1161)
|
||||
|
||||
### Changes
|
||||
|
||||
- **Calendar**: The day and week views show 08:00 to 20:00 by default. "Limit visible hours" in the calendar settings changes the range or turns it off (#1164)
|
||||
- **Tags**: Tag views and their counts leave out messages in Trash and Spam (#1156)
|
||||
- **Mail**: In a shared mailbox, the Move menus list that mailbox's folders first and your own folders after them, under your account name (#1149)
|
||||
- **Dependencies**: sharp 0.35.5 and source-map-js 1.2.2 (npm audit)
|
||||
- **Docs**: CONTRIBUTING.md explains what belongs in core and what in a plugin, and which changes need an agreed issue before a pull request
|
||||
|
||||
### Fixes
|
||||
|
||||
- **Mail**: Attachments, inline images and .eml downloads of messages in shared folders are fetched from the account that owns them. On Cyrus they failed with "failed to find blob by id" (#1188, thanks @VasilisNtovolos)
|
||||
- **Mail**: Archive creates an Archive folder when the account has none, instead of only reporting that it is missing (#1176, thanks @cybersmurf)
|
||||
- **Mail**: Opening the webmail starts in the inbox instead of a folder from an earlier visit, such as Trash
|
||||
- **Mail**: Opening another folder, tag, account or search starts the message list at the top instead of where the last one was scrolled to (#1138, thanks @dealerweb)
|
||||
- **Mail**: Previews no longer start with a style sheet ("@media screen and ...") in the message list and in push notifications (#1136, thanks @lucamzanon)
|
||||
- **Search**: Searching from the unified All mail view finds sent mail too
|
||||
- **Send**: A draft saved without a subject reopens with an empty subject, so Send warns about it again instead of sending "(No Subject)" (#1189)
|
||||
- **Send**: When the server does not confirm that a message was sent, the draft is kept and the composer asks you to check Sent before sending again (#1019, thanks @lucamzanon)
|
||||
- **Send**: A message the server refuses shows the server's reason instead of "Invalid reference to non-existing object"
|
||||
- **Send**: A forward sent from another account's address no longer fails with `blobNotFound`. Its attachments and inline images are copied to the sending account first (#1142, thanks @lucamzanon)
|
||||
- **Push**: Push turns itself back on when the browser lost its subscription, retrying at most every 15 minutes (#1147, thanks @lucamzanon)
|
||||
- **Push**: The notification settings check push again when you come back to the tab, so push turned off in the browser or another tab no longer shows as on (#1148, thanks @lucamzanon)
|
||||
- **Push**: When the new message cannot be read, the notification no longer announces another unread message from the Inbox in its place, and a failed lookup shows a generic notification instead of none (#1041, thanks @lucamzanon)
|
||||
- **Filters**: "Stop processing further rules" also stops after "Delete silently" and "Reject", so later rules no longer file the message. Rules whose name contains double spaces, a tab or a trailing space no longer gain a copy on every save, and the copies earlier saves left are removed (#1153, thanks @dealerweb)
|
||||
- **Filters**: The forwarding limit counts the forwards one message can collect, instead of adding up every enabled rule, and warns when the rule order lets one message go over it (#1155, thanks @dealerweb)
|
||||
- **Filters**: A condition with several values wraps inside its card in the expanded rule view (#1139, thanks @dealerweb)
|
||||
- **Filters**: An open rule dialog or Sieve editor, and what was typed in it, survives a filter change from another tab or device (#1162, thanks @dealerweb)
|
||||
- **Templates**: A tab or device that had not reloaded no longer deletes templates created elsewhere when it saves its settings
|
||||
- **Calendar**: Duplicating an event keeps its meeting link (#1170)
|
||||
- **Calendar**: The Calendar Agenda plugin shows events from shared and group calendars (#1175, thanks @cybersmurf)
|
||||
- **Files**: Folders shared with you can be opened, and your own folders shared, in both folder layouts (#1181)
|
||||
- **Files**: Office editing works behind a reverse proxy where the editor and the browser reach the webmail at different addresses. The WOPI client URL may be an internal address (#1130)
|
||||
- **Login**: When Stalwart accepts a password and TOTP code but refuses to issue tokens, the login page points at the OAuth client settings instead of opening the browser's own sign-in dialog and reporting a wrong code
|
||||
- **Branding**: With `APP_NAME` set, the authenticator app entry for two-factor sign-in, the About card and the Files preview use the configured name and logo (#1177, thanks @cybersmurf)
|
||||
- **UI**: Text fields in dialogs keep the focus when the page behind updates, which happened every 30 seconds and on new mail (#1162, thanks @dealerweb)
|
||||
- **UI**: Buttons, menu items, tabs and other controls show the pointer cursor again (#1184, thanks @gwku)
|
||||
- **UI**: The account group labels in the From dropdown are readable in dark themes (#1157, thanks @dealerweb)
|
||||
- **Translations**: Czech is fully translated (#1174, thanks @cybersmurf), and missing placeholders are restored in Persian, Hebrew, Hungarian, Mongolian and Slovak (#1165, thanks @owgreen-dev)
|
||||
|
||||
## 1.12.0 (2026-09-30)
|
||||
|
||||
1.12.0 lets you create filter rules straight from a message, offers the code from sign-in mail for one-click copying, and gives a phone linked with "Link Mobile App" a sign-in of its own, for every kind of account. Search now leaves Spam and Trash out by default, and emptying an ordinary folder moves its mail to the Trash. Browsers with many logins start up much faster. It also contains three security fixes, so please update.
|
||||
|
||||
@@ -32,6 +32,7 @@ const FEATURE_GATE_LABELS: Partial<Record<keyof FeatureGates, { label: string; d
|
||||
debugModeEnabled: { label: 'Debug Mode', description: 'Allow users to enable debug/diagnostic mode' },
|
||||
folderIconsEnabled: { label: 'Folder Icons', description: 'Allow custom folder icon picker' },
|
||||
hoverActionsConfigEnabled: { label: 'Hover Actions Config', description: 'Allow users to customize email hover actions' },
|
||||
tabTitleSubjectEnabled: { label: 'Subject in Tab Title', description: 'Show the open message\'s subject in the browser tab title. Browsers also keep the title in history and screen shares show it; turn this off when mailboxes hold confidential correspondence, and the tab shows the folder instead.' },
|
||||
filesEnabled: { label: 'Files (WebDAV)', description: 'Enable file storage via WebDAV. WARNING: Large uploads can cause Stalwart/RocksDB instability. Not recommended for production.' },
|
||||
crossUnreadViewEnabled: { label: 'Unified Mailbox: Unread', description: 'Allow an "Unread" entry in the Unified Mailbox section that lists unread mail across the account and its shared folders (or every account when the cross-account sub-option is on). Honors the user\'s folder selection. Requires the matching per-user toggle in Settings → Appearance.' },
|
||||
crossStarredViewEnabled: { label: 'Unified Mailbox: Starred', description: 'Allow a "Starred" entry in the Unified Mailbox section that lists flagged/starred mail across the account and its shared folders (or every account when the cross-account sub-option is on). Honors the user\'s folder selection. Requires the matching per-user toggle in Settings → Appearance.' },
|
||||
|
||||
@@ -12,10 +12,15 @@ import type { CalendarEvent } from '@/lib/jmap/types';
|
||||
* POST /api/calendar-agenda
|
||||
*
|
||||
* Sidecar for the "Calendar Agenda" plugin. Resolves the caller's calendar
|
||||
* account from the stored Stalwart auth context, queries upcoming
|
||||
* accounts from the stored Stalwart auth context, queries upcoming
|
||||
* CalendarEvents over JMAP, expands recurring series server-side, and returns
|
||||
* a slim, structured-cloneable agenda the plugin can render directly.
|
||||
*
|
||||
* Events come from every account that has calendars: the primary calendar
|
||||
* account plus shared and group accounts (a family or team calendar), the
|
||||
* same set the calendar app shows. Ids are scoped by account, since event
|
||||
* and calendar ids repeat across accounts.
|
||||
*
|
||||
* Credentials never leave the server — the sandboxed plugin only ever sees
|
||||
* the resulting agenda DTOs.
|
||||
*
|
||||
@@ -35,7 +40,9 @@ const EVENT_PROPERTIES = [
|
||||
] as const;
|
||||
|
||||
interface AgendaEvent {
|
||||
/** Unique across accounts: `<accountId>:<event id>`. */
|
||||
id: string;
|
||||
accountId: string;
|
||||
uid: string | null;
|
||||
title: string;
|
||||
start: string;
|
||||
@@ -131,8 +138,8 @@ export async function POST(request: NextRequest) {
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'JMAP session fetch failed' }, { status: 502 });
|
||||
}
|
||||
const accountId = session.primaryAccounts?.[CALENDAR_CAP];
|
||||
if (!accountId) {
|
||||
const accountIds = calendarAccountIds(session);
|
||||
if (accountIds.length === 0) {
|
||||
// No calendar account for this user — return an empty agenda, not an error.
|
||||
return NextResponse.json({ events: [], generatedAt: new Date().toISOString() });
|
||||
}
|
||||
@@ -153,85 +160,94 @@ export async function POST(request: NextRequest) {
|
||||
const windowStart = new Date(now);
|
||||
windowStart.setHours(0, 0, 0, 0);
|
||||
|
||||
// ── 1) Query event IDs in range + load calendars (colours) ──
|
||||
const queryReq = {
|
||||
using,
|
||||
methodCalls: [
|
||||
[
|
||||
'CalendarEvent/query',
|
||||
{
|
||||
accountId,
|
||||
// Mirror the app's calendar store: an { after, before } window lets
|
||||
// Stalwart evaluate recurrence so masters with occurrences in range
|
||||
// are returned (a `before`-only filter can drop unbounded series).
|
||||
filter: { after: windowStart.toISOString(), before: horizon.toISOString() },
|
||||
limit: 1000,
|
||||
},
|
||||
'0',
|
||||
],
|
||||
[
|
||||
'Calendar/get',
|
||||
{ accountId, ids: null, properties: ['id', 'name', 'color'] },
|
||||
'c',
|
||||
],
|
||||
],
|
||||
};
|
||||
|
||||
const queryRes = await jmapPost(apiUrl, creds.authHeader, queryReq, fetchOptions);
|
||||
const queryResp = findResponse(queryRes, 'CalendarEvent/query', '0');
|
||||
if (!queryResp) {
|
||||
const err = findResponse(queryRes, 'error', '0');
|
||||
return NextResponse.json(
|
||||
{ error: (err?.description as string) || 'CalendarEvent/query failed' },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
const ids = (queryResp.ids as string[]) || [];
|
||||
|
||||
// ── 1–3) Per account: query ids in range, load calendars (colours),
|
||||
// fetch the events, normalize and expand recurrences server-side ──
|
||||
const calColors = new Map<string, { name: string; color: string | null }>();
|
||||
const calResp = findResponse(queryRes, 'Calendar/get', 'c');
|
||||
for (const cal of ((calResp?.list as Array<Record<string, unknown>>) || [])) {
|
||||
if (typeof cal.id === 'string') {
|
||||
calColors.set(cal.id, {
|
||||
name: typeof cal.name === 'string' ? cal.name : '',
|
||||
color: typeof cal.color === 'string' ? cal.color : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
const expanded: Array<CalendarEvent & { accountId: string }> = [];
|
||||
|
||||
if (ids.length === 0) {
|
||||
return NextResponse.json({ events: [], generatedAt: now.toISOString() });
|
||||
}
|
||||
|
||||
// ── 2) Fetch full event objects (batched) ──
|
||||
const raw: Array<Record<string, unknown>> = [];
|
||||
const BATCH = 100;
|
||||
for (let i = 0; i < ids.length; i += BATCH) {
|
||||
const batch = ids.slice(i, i + BATCH);
|
||||
const getRes = await jmapPost(apiUrl, creds.authHeader, {
|
||||
for (const accountId of accountIds) {
|
||||
const queryReq = {
|
||||
using,
|
||||
methodCalls: [
|
||||
['CalendarEvent/get', { accountId, ids: batch, properties: EVENT_PROPERTIES }, '0'],
|
||||
[
|
||||
'CalendarEvent/query',
|
||||
{
|
||||
accountId,
|
||||
// Mirror the app's calendar store: an { after, before } window lets
|
||||
// Stalwart evaluate recurrence so masters with occurrences in range
|
||||
// are returned (a `before`-only filter can drop unbounded series).
|
||||
filter: { after: windowStart.toISOString(), before: horizon.toISOString() },
|
||||
limit: 1000,
|
||||
},
|
||||
'0',
|
||||
],
|
||||
[
|
||||
'Calendar/get',
|
||||
{ accountId, ids: null, properties: ['id', 'name', 'color'] },
|
||||
'c',
|
||||
],
|
||||
],
|
||||
}, fetchOptions);
|
||||
const getResp = findResponse(getRes, 'CalendarEvent/get', '0');
|
||||
if (getResp?.list) raw.push(...(getResp.list as Array<Record<string, unknown>>));
|
||||
};
|
||||
|
||||
const queryRes = await jmapPost(apiUrl, creds.authHeader, queryReq, fetchOptions);
|
||||
const queryResp = findResponse(queryRes, 'CalendarEvent/query', '0');
|
||||
if (!queryResp) {
|
||||
const err = findResponse(queryRes, 'error', '0');
|
||||
if (accountId !== accountIds[0]) {
|
||||
// A shared account the caller can see but not read calendars from
|
||||
// (Stalwart answers forbidden/accountNotSupported) must not empty
|
||||
// the agenda for the accounts that do work.
|
||||
logger.debug('Calendar agenda: skipping account', {
|
||||
accountId,
|
||||
error: (err?.type as string) || 'CalendarEvent/query failed',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: (err?.description as string) || 'CalendarEvent/query failed' },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
const ids = (queryResp.ids as string[]) || [];
|
||||
|
||||
const calResp = findResponse(queryRes, 'Calendar/get', 'c');
|
||||
for (const cal of ((calResp?.list as Array<Record<string, unknown>>) || [])) {
|
||||
if (typeof cal.id === 'string') {
|
||||
calColors.set(scopedId(accountId, cal.id), {
|
||||
name: typeof cal.name === 'string' ? cal.name : '',
|
||||
color: typeof cal.color === 'string' ? cal.color : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (ids.length === 0) continue;
|
||||
|
||||
const raw: Array<Record<string, unknown>> = [];
|
||||
const BATCH = 100;
|
||||
for (let i = 0; i < ids.length; i += BATCH) {
|
||||
const batch = ids.slice(i, i + BATCH);
|
||||
const getRes = await jmapPost(apiUrl, creds.authHeader, {
|
||||
using,
|
||||
methodCalls: [
|
||||
['CalendarEvent/get', { accountId, ids: batch, properties: EVENT_PROPERTIES }, '0'],
|
||||
],
|
||||
}, fetchOptions);
|
||||
const getResp = findResponse(getRes, 'CalendarEvent/get', '0');
|
||||
if (getResp?.list) raw.push(...(getResp.list as Array<Record<string, unknown>>));
|
||||
}
|
||||
|
||||
const normalized = raw
|
||||
.map((e) => normalizeCalendarEventLike(e as Partial<CalendarEvent>))
|
||||
.filter((e) => (e['@type'] ?? 'Event') === 'Event')
|
||||
// Drop malformed events without a parseable start (would crash format()/
|
||||
// parseISO downstream) — mirrors the calendar store guard (#316).
|
||||
.filter((e) => typeof e.start === 'string' && e.start && !isNaN(parseISO(e.start).getTime())) as CalendarEvent[];
|
||||
|
||||
for (const e of expandRecurringEvents(normalized, windowStart.toISOString(), horizon.toISOString())) {
|
||||
expanded.push({ ...e, accountId });
|
||||
}
|
||||
}
|
||||
|
||||
// ── 3) Normalize + expand recurrences server-side ──
|
||||
const normalized = raw
|
||||
.map((e) => normalizeCalendarEventLike(e as Partial<CalendarEvent>))
|
||||
.filter((e) => (e['@type'] ?? 'Event') === 'Event')
|
||||
// Drop malformed events without a parseable start (would crash format()/
|
||||
// parseISO downstream) — mirrors the calendar store guard (#316).
|
||||
.filter((e) => typeof e.start === 'string' && e.start && !isNaN(parseISO(e.start).getTime())) as CalendarEvent[];
|
||||
|
||||
const expanded = expandRecurringEvents(
|
||||
normalized,
|
||||
windowStart.toISOString(),
|
||||
horizon.toISOString(),
|
||||
);
|
||||
|
||||
// ── 4) Keep ongoing/upcoming, sort, slice, map to DTOs ──
|
||||
const agenda: AgendaEvent[] = expanded
|
||||
.filter((e) => eventEnd(e).getTime() >= now.getTime())
|
||||
@@ -239,9 +255,10 @@ export async function POST(request: NextRequest) {
|
||||
.slice(0, limit)
|
||||
.map((e) => {
|
||||
const calId = firstCalendarId(e);
|
||||
const cal = calId ? calColors.get(calId) : undefined;
|
||||
const cal = calId ? calColors.get(scopedId(e.accountId, calId)) : undefined;
|
||||
return {
|
||||
id: String(e.id ?? ''),
|
||||
id: scopedId(e.accountId, String(e.id ?? '')),
|
||||
accountId: e.accountId,
|
||||
uid: e.uid ?? null,
|
||||
title: (e.title ?? '').trim() || '(no title)',
|
||||
start: eventStart(e).toISOString(),
|
||||
@@ -268,6 +285,31 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The accounts to read calendars from, primary first. Mirrors the client's
|
||||
* getCalendarCapableAccountIds(): shared and group accounts count even when
|
||||
* Stalwart does not advertise the calendar capability on them.
|
||||
*/
|
||||
function calendarAccountIds(session: {
|
||||
primaryAccounts?: Record<string, string>;
|
||||
accounts?: Record<string, unknown>;
|
||||
}): string[] {
|
||||
const primary = session.primaryAccounts?.[CALENDAR_CAP];
|
||||
const ids: string[] = primary ? [primary] : [];
|
||||
for (const [id, value] of Object.entries(session.accounts ?? {})) {
|
||||
if (id === primary || !value || typeof value !== 'object') continue;
|
||||
const account = value as { isPersonal?: unknown; accountCapabilities?: Record<string, unknown> };
|
||||
if (account.accountCapabilities?.[CALENDAR_CAP] || account.isPersonal === false) {
|
||||
ids.push(id);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
function scopedId(accountId: string, id: string): string {
|
||||
return `${accountId}:${id}`;
|
||||
}
|
||||
|
||||
function clampInt(value: unknown, min: number, max: number, fallback: number): number {
|
||||
const n = typeof value === 'number' ? value : Number(value);
|
||||
if (!Number.isFinite(n)) return fallback;
|
||||
|
||||
@@ -1773,7 +1773,7 @@ function handleEmailSet(args: MethodArgs, callId: string): MethodResult {
|
||||
from: (data.from as MockEmail['from']) || [{ name: 'Dev User', email: 'dev@localhost' }],
|
||||
to: (data.to as MockEmail['to']) || [],
|
||||
cc: (data.cc as MockEmail['cc']) || [],
|
||||
subject: (data.subject as string) || '(no subject)',
|
||||
subject: typeof data.subject === 'string' ? data.subject : '',
|
||||
preview: (previewText || (data.subject as string) || '').slice(0, 120),
|
||||
hasAttachment: false,
|
||||
textBody: [],
|
||||
|
||||
@@ -9,6 +9,7 @@ import { saveUserSettings, loadUserSettings, deleteUserSettings } from '@/lib/se
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { hasSessionSecret } from '@/lib/auth/session-secret';
|
||||
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
|
||||
import { mergeStoredTemplates } from '@/lib/template-merge';
|
||||
|
||||
function classifyError(error: unknown): { message: string; status: number } {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
@@ -176,7 +177,10 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
await saveUserSettings(accountName, serverUrl, filteredSettings);
|
||||
// Templates are merged into the stored blob rather than replaced, so a
|
||||
// stale tab or device cannot drop templates another one added.
|
||||
const stored = await loadUserSettings(accountName, serverUrl);
|
||||
await saveUserSettings(accountName, serverUrl, mergeStoredTemplates(stored, filteredSettings));
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
|
||||
@@ -212,6 +212,44 @@ optgroup {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/* Tailwind v4's preflight no longer gives buttons a pointer cursor, so most
|
||||
controls showed the arrow. Give everything clickable the pointer again, and
|
||||
disabled controls not-allowed, as the components that style it themselves
|
||||
already do. This sits in the base layer so a cursor utility on a component
|
||||
(cursor-grab, cursor-text, disabled:cursor-default) still wins. */
|
||||
@layer base {
|
||||
button:not(:disabled),
|
||||
select:not(:disabled),
|
||||
input:is([type="checkbox"], [type="radio"], [type="color"]):not(:disabled),
|
||||
summary,
|
||||
[role="button"]:not([aria-disabled="true"]),
|
||||
[role="tab"]:not([aria-disabled="true"]),
|
||||
[role="switch"]:not([aria-disabled="true"]),
|
||||
[role="checkbox"]:not([aria-disabled="true"]),
|
||||
[role="radio"]:not([aria-disabled="true"]),
|
||||
[role="option"]:not([aria-disabled="true"]),
|
||||
[role="menuitem"]:not([aria-disabled="true"]),
|
||||
[role="menuitemcheckbox"]:not([aria-disabled="true"]),
|
||||
[role="menuitemradio"]:not([aria-disabled="true"]) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:disabled,
|
||||
select:disabled,
|
||||
input:is([type="checkbox"], [type="radio"], [type="color"]):disabled,
|
||||
[role="button"][aria-disabled="true"],
|
||||
[role="tab"][aria-disabled="true"],
|
||||
[role="switch"][aria-disabled="true"],
|
||||
[role="checkbox"][aria-disabled="true"],
|
||||
[role="radio"][aria-disabled="true"],
|
||||
[role="option"][aria-disabled="true"],
|
||||
[role="menuitem"][aria-disabled="true"],
|
||||
[role="menuitemcheckbox"][aria-disabled="true"],
|
||||
[role="menuitemradio"][aria-disabled="true"] {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
/* Minimalist scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
|
||||
@@ -50,7 +50,7 @@ import { useIsEmbedded } from "@/hooks/use-is-embedded";
|
||||
import { useIsFocusedProTab, useIsPaneScoped } from "@/hooks/use-pane-context";
|
||||
import { useProMultiAccountCalendars } from "@/hooks/use-pro-multi-account-calendars";
|
||||
import { ResizeHandle } from "@/components/layout/resize-handle";
|
||||
import { sanitizeOutgoingCalendarEventData } from "@/lib/calendar-event-normalization";
|
||||
import { buildDuplicateEventData } from "@/lib/calendar-duplicate";
|
||||
import { filterTasksByCalendars } from "@/lib/calendar-tasks";
|
||||
import {
|
||||
baseEventStoreId,
|
||||
@@ -91,6 +91,7 @@ import {
|
||||
} from "@/lib/calendar-scroll-window";
|
||||
import { useLiteLinkSegments } from "@/hooks/use-lite-link-segments";
|
||||
import { findMeetingLink } from "@/lib/event-links";
|
||||
import { useDocumentTitle } from "@/hooks/use-document-title";
|
||||
|
||||
type PendingScopeAction =
|
||||
| { type: "edit"; event: CalendarEvent; updates: Partial<CalendarEvent>; sendScheduling?: boolean }
|
||||
@@ -111,6 +112,8 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
const linkSegments = useLiteLinkSegments('calendar', routeSegments);
|
||||
const router = useRouter();
|
||||
const t = useTranslations("calendar");
|
||||
const tSidebar = useTranslations("sidebar");
|
||||
useDocumentTitle(tSidebar("calendar"));
|
||||
const tWebcalAction = useTranslations("calendar.webcal_action");
|
||||
const tDeepLink = useTranslations("deep_link");
|
||||
const isMobile = useIsMobile();
|
||||
@@ -1171,24 +1174,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
|
||||
const handleDuplicateFromDetail = useCallback(async () => {
|
||||
if (!detailEvent || !client) return;
|
||||
const start = parseISO(detailEvent.start);
|
||||
const newStart = addDays(start, 1);
|
||||
const data = sanitizeOutgoingCalendarEventData<Partial<CalendarEvent>>({
|
||||
title: detailEvent.title,
|
||||
description: detailEvent.description,
|
||||
start: format(newStart, "yyyy-MM-dd'T'HH:mm:ss"),
|
||||
duration: detailEvent.duration,
|
||||
timeZone: detailEvent.timeZone,
|
||||
showWithoutTime: detailEvent.showWithoutTime,
|
||||
calendarIds: { ...detailEvent.calendarIds },
|
||||
status: "confirmed",
|
||||
freeBusyStatus: detailEvent.freeBusyStatus,
|
||||
privacy: detailEvent.privacy,
|
||||
});
|
||||
if (detailEvent.locations) data.locations = structuredClone(detailEvent.locations);
|
||||
if (detailEvent.recurrenceRules) data.recurrenceRules = structuredClone(detailEvent.recurrenceRules);
|
||||
if (detailEvent.alerts) data.alerts = structuredClone(detailEvent.alerts);
|
||||
if (detailEvent.participants) data.participants = structuredClone(detailEvent.participants);
|
||||
const data = buildDuplicateEventData(detailEvent);
|
||||
closeDetail();
|
||||
try {
|
||||
const created = await createEvent(client, data);
|
||||
@@ -1219,24 +1205,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
|
||||
const handleDuplicateContextMenu = useCallback(async (event: CalendarEvent) => {
|
||||
if (!client) { toast.error(t("notifications.event_error")); return; }
|
||||
const start = parseISO(event.start);
|
||||
const newStart = addDays(start, 1);
|
||||
const data = sanitizeOutgoingCalendarEventData<Partial<CalendarEvent>>({
|
||||
title: event.title,
|
||||
description: event.description,
|
||||
start: format(newStart, "yyyy-MM-dd'T'HH:mm:ss"),
|
||||
duration: event.duration,
|
||||
timeZone: event.timeZone,
|
||||
showWithoutTime: event.showWithoutTime,
|
||||
calendarIds: { ...event.calendarIds },
|
||||
status: "confirmed",
|
||||
freeBusyStatus: event.freeBusyStatus,
|
||||
privacy: event.privacy,
|
||||
});
|
||||
if (event.locations) data.locations = structuredClone(event.locations);
|
||||
if (event.recurrenceRules) data.recurrenceRules = structuredClone(event.recurrenceRules);
|
||||
if (event.alerts) data.alerts = structuredClone(event.alerts);
|
||||
if (event.participants) data.participants = structuredClone(event.participants);
|
||||
const data = buildDuplicateEventData(event);
|
||||
try {
|
||||
const created = await createEvent(client, data);
|
||||
if (created) {
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { LinkifiedText } from "@/components/ui/linkified-text";
|
||||
import { X, Trash2, Check, Users, CalendarDays, Copy, Pencil, Clock, MapPin, Video, Repeat, Bell, AlignLeft, Plus } from "@/components/icons";
|
||||
import { format, parseISO, addHours, addDays, isSameDay } from "date-fns";
|
||||
import { format, parseISO, addHours, isSameDay } from "date-fns";
|
||||
import type { CalendarEvent, Calendar, CalendarParticipant, CalendarEventAlert, CalendarRecurrenceRule } from "@/lib/jmap/types";
|
||||
import { RecurrenceEditor, buildRecurrenceSummary, isSimpleRecurrenceRule } from "./recurrence-editor";
|
||||
import { parseDuration, getEventColor } from "./event-card";
|
||||
@@ -28,6 +28,7 @@ import { canUserRsvp, getEventEditability, canCreateEventsIn } from "@/lib/calen
|
||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { generateUUID } from "@/lib/utils";
|
||||
import { buildDuplicateEventData } from "@/lib/calendar-duplicate";
|
||||
import { useFormatEventDate } from "@/hooks/use-format-event-date";
|
||||
import { useIsPaneScoped } from "@/hooks/use-pane-context";
|
||||
import { useContactNameResolver } from "@/hooks/use-contact-name-resolver";
|
||||
@@ -657,28 +658,7 @@ export function EventModal({
|
||||
|
||||
const handleDuplicate = useCallback(() => {
|
||||
if (!event || !onDuplicate) return;
|
||||
const start = getEventStartDate(event);
|
||||
const newStart = addDays(start, 1);
|
||||
const newUid = generateUUID();
|
||||
const data: Partial<CalendarEvent> = {
|
||||
uid: newUid,
|
||||
title: event.title,
|
||||
description: event.description,
|
||||
start: event.showWithoutTime ? format(newStart, "yyyy-MM-dd") : format(newStart, "yyyy-MM-dd'T'HH:mm:ss"),
|
||||
duration: event.duration,
|
||||
timeZone: event.timeZone,
|
||||
showWithoutTime: event.showWithoutTime,
|
||||
calendarIds: { ...event.calendarIds },
|
||||
status: "confirmed",
|
||||
freeBusyStatus: event.freeBusyStatus,
|
||||
privacy: event.privacy,
|
||||
};
|
||||
if (event.locations) data.locations = structuredClone(event.locations);
|
||||
if (event.virtualLocations) data.virtualLocations = structuredClone(event.virtualLocations);
|
||||
if (event.recurrenceRules) data.recurrenceRules = structuredClone(event.recurrenceRules);
|
||||
if (event.alerts) data.alerts = structuredClone(event.alerts);
|
||||
if (event.participants) data.participants = structuredClone(event.participants);
|
||||
onDuplicate(data);
|
||||
onDuplicate(buildDuplicateEventData(event));
|
||||
}, [event, onDuplicate]);
|
||||
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -43,6 +43,7 @@ import { consumePendingDeepLinkEntry, subscribePendingDeepLink } from "@/lib/dee
|
||||
import { useDeepLinkUrl } from "@/hooks/use-deep-link-url";
|
||||
import { useProInterfaceActive } from "@/components/pro/pro-interface-redirect";
|
||||
import { useLiteLinkSegments } from "@/hooks/use-lite-link-segments";
|
||||
import { useDocumentTitle } from "@/hooks/use-document-title";
|
||||
|
||||
type View =
|
||||
| "list"
|
||||
@@ -63,6 +64,8 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
|
||||
// Static Lite build: the route params are empty, read the link from the URL.
|
||||
const linkSegments = useLiteLinkSegments('contacts', routeSegments);
|
||||
const t = useTranslations("contacts");
|
||||
const tSidebar = useTranslations("sidebar");
|
||||
useDocumentTitle(tSidebar("contacts"));
|
||||
const contactsEnabled = usePolicyStore((s) => s.isFeatureEnabled('contactsEnabled'));
|
||||
const { client, isAuthenticated, logout, checkAuth, isLoading: authLoading } = useAuthStore();
|
||||
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
import { render, screen, fireEvent, act } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import React from 'react';
|
||||
import { EmailComposer } from '../email-composer';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import type { FileNode } from '@/lib/jmap/types';
|
||||
|
||||
// Attaching files from the Files app (#1179). The picker itself is covered by
|
||||
// file-picker-dialog.test.tsx; here it is a stub that hands back fixed nodes.
|
||||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
picked: [] as unknown[],
|
||||
beforeAttachmentUpload: vi.fn(async (_info: { name: string }) => true),
|
||||
toastError: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/files/file-picker-dialog', () => ({
|
||||
FilePickerDialog: ({ onPick }: { onPick: (nodes: unknown[]) => void }) =>
|
||||
React.createElement(
|
||||
'button',
|
||||
{ type: 'button', 'data-testid': 'fake-file-picker', onClick: () => onPick(hoisted.picked) },
|
||||
'pick',
|
||||
),
|
||||
}));
|
||||
|
||||
// ─── Heavy component mocks (mirrors composer-plugin-attachment-source.test.tsx) ─
|
||||
|
||||
vi.mock('@/components/email/rich-text-editor', () => ({
|
||||
RichTextEditor: () => React.createElement('div', { 'data-testid': 'rich-text-editor' }),
|
||||
}));
|
||||
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
|
||||
|
||||
vi.mock('@/components/identity/sub-address-helper', () => ({ SubAddressHelper: () => null }));
|
||||
vi.mock('@/components/templates/template-picker', () => ({ TemplatePicker: () => null }));
|
||||
vi.mock('@/components/templates/template-form', () => ({ TemplateForm: () => null }));
|
||||
vi.mock('@/components/files/file-preview-modal', () => ({ FilePreviewModal: () => null }));
|
||||
vi.mock('@/hooks/use-focus-trap', () => ({
|
||||
useFocusTrap: () => ({ current: null }),
|
||||
}));
|
||||
vi.mock('@/hooks/use-pro-multi-account-identities', () => ({
|
||||
useProMultiAccountIdentities: () => ({ enabled: false, groups: [], allIdentities: [] }),
|
||||
stripCrossAccountIdentityPrefix: (id: string) => ({ localAccountId: null, rawId: id }),
|
||||
}));
|
||||
|
||||
// ─── Store mocks ──────────────────────────────────────────────────────────────
|
||||
|
||||
vi.mock('@/stores/auth-store', () => {
|
||||
const state = {
|
||||
client: null,
|
||||
identities: [],
|
||||
primaryIdentity: null,
|
||||
isAuthenticated: false,
|
||||
isDemoMode: false,
|
||||
activeAccountId: null,
|
||||
connectionLost: false,
|
||||
getClientForAccount: () => undefined,
|
||||
getAllConnectedClients: () => new Map(),
|
||||
syncIdentities: () => {},
|
||||
refreshIdentities: async () => {},
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useAuthStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/identity-store', () => {
|
||||
const state = {
|
||||
identities: [{ id: 'id-me', email: 'me@example.com', name: 'Me' }],
|
||||
defaultIdentityId: 'id-me',
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useIdentityStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/account-store', () => {
|
||||
const state = { accounts: [], getAccountById: () => undefined };
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useAccountStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/email-store', () => {
|
||||
const state = {
|
||||
draftSaveEnabled: false,
|
||||
sendRawEmail: async () => ({ sent: true }),
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useEmailStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/settings-store', () => {
|
||||
const state = {
|
||||
timeFormat: '24h',
|
||||
plainTextMode: false,
|
||||
subAddressDelimiter: '+',
|
||||
autoSelectReplyIdentity: true,
|
||||
attachmentReminderEnabled: false,
|
||||
attachmentReminderKeywords: [],
|
||||
emptySubjectWarningEnabled: true,
|
||||
sendDelaySeconds: 0,
|
||||
signaturePosition: 'above_quote',
|
||||
signatureSeparatorEnabled: false,
|
||||
requestReadReceiptDefault: false,
|
||||
addTrustedSender: () => {},
|
||||
trustedSendersAddressBook: null,
|
||||
updateSetting: () => {},
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useSettingsStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/contact-store', () => {
|
||||
const state = {
|
||||
contacts: [],
|
||||
getAutocomplete: async () => [],
|
||||
addToTrustedSendersBook: async () => {},
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useContactStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/template-store', () => {
|
||||
const state = { templates: [], addTemplate: async () => {} };
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useTemplateStore: hook };
|
||||
});
|
||||
|
||||
// ─── Misc dependency mocks ────────────────────────────────────────────────────
|
||||
|
||||
vi.mock('@/stores/toast-store', () => ({
|
||||
toast: { info: () => {}, error: hoisted.toastError, success: () => {} },
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/plugin-hooks', () => ({
|
||||
emailHooks: {
|
||||
onComposerOpen: { call: async () => [] },
|
||||
onRecipientChange: { call: async () => [] },
|
||||
getRecipientSuggestions: { call: async () => [] },
|
||||
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
|
||||
onDraftChange: { emit: () => {} },
|
||||
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
|
||||
onBeforeEmailSend: { intercept: async () => true },
|
||||
onBeforeAttachmentUpload: { intercept: hoisted.beforeAttachmentUpload },
|
||||
onComposeSend: { intercept: async () => true },
|
||||
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
|
||||
},
|
||||
contactHooks: {
|
||||
search: { call: async () => [] },
|
||||
onProvideRecipientSuggestions: { transform: async (initial: unknown) => initial },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/email-sanitization', () => ({
|
||||
sanitizeSignatureHtml: (v: string) => v,
|
||||
sanitizeEmailHtml: (v: string) => v,
|
||||
parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/email-threading', () => ({
|
||||
computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }),
|
||||
}));
|
||||
vi.mock('@/lib/signature-utils', () => ({
|
||||
appendPlainTextSignature: (body: string) => body,
|
||||
getPlainTextSignature: () => '',
|
||||
plainTextBodyHasSignature: () => false,
|
||||
plainTextBodyWithoutSignature: (body: string) => body,
|
||||
}));
|
||||
vi.mock('@/lib/sub-addressing', () => ({ generateSubAddress: () => '' }));
|
||||
vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } }));
|
||||
vi.mock('@/components/email/quoted-html', () => ({
|
||||
buildQuotedHtmlBlock: () => '',
|
||||
serializeEditorContent: () => '',
|
||||
}));
|
||||
vi.mock('@/lib/template-utils', () => ({ substitutePlaceholders: (s: string) => s }));
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function fileNode(id: string, name: string, size: number): FileNode {
|
||||
return {
|
||||
id, name, size,
|
||||
parentId: null,
|
||||
type: 'application/pdf',
|
||||
blobId: `blob-${id}`,
|
||||
created: '2026-10-01T00:00:00Z',
|
||||
modified: '2026-10-01T00:00:00Z',
|
||||
};
|
||||
}
|
||||
|
||||
function filesClient(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
createDraft: vi.fn().mockResolvedValue('draft-1'),
|
||||
getEmail: vi.fn(),
|
||||
hasDelayedSend: () => false,
|
||||
getMaxDelayedSend: () => 0,
|
||||
supportsFiles: () => true,
|
||||
getMaxSizeAttachmentsPerEmail: () => 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function pickFromFiles(nodes: FileNode[]) {
|
||||
hoisted.picked = nodes;
|
||||
fireEvent.click(screen.getByTestId('composer-attach-from-files'));
|
||||
fireEvent.click(screen.getByTestId('fake-file-picker'));
|
||||
}
|
||||
|
||||
describe('attach from Files', () => {
|
||||
afterEach(() => {
|
||||
useAuthStore.setState({ client: null });
|
||||
vi.clearAllMocks();
|
||||
hoisted.beforeAttachmentUpload.mockImplementation(async () => true);
|
||||
});
|
||||
|
||||
it('only offers the button when the account has Files', () => {
|
||||
useAuthStore.setState({ client: filesClient({ supportsFiles: () => false }) as never });
|
||||
render(<EmailComposer onClose={vi.fn()} />);
|
||||
expect(screen.queryByTestId('composer-attach-from-files')).toBeNull();
|
||||
});
|
||||
|
||||
it('attaches the picked files by their blobId and saves them with the draft', async () => {
|
||||
const client = filesClient();
|
||||
useAuthStore.setState({ client: client as never });
|
||||
render(<EmailComposer onClose={vi.fn()} />);
|
||||
|
||||
pickFromFiles([fileNode('n1', 'report.pdf', 1200)]);
|
||||
expect(await screen.findByText('report.pdf')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('fake-file-picker')).toBeNull();
|
||||
|
||||
vi.useFakeTimers();
|
||||
fireEvent.change(screen.getByPlaceholderText(/subject/i), { target: { value: 'The report' } });
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(2500); });
|
||||
|
||||
expect(client.createDraft).toHaveBeenCalledTimes(1);
|
||||
expect(client.createDraft.mock.calls[0][8]).toEqual([
|
||||
expect.objectContaining({ blobId: 'blob-n1', name: 'report.pdf', type: 'application/pdf', size: 1200 }),
|
||||
]);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('leaves out a file a plugin refuses', async () => {
|
||||
hoisted.beforeAttachmentUpload.mockImplementation(async (info) => info.name !== 'setup.exe');
|
||||
useAuthStore.setState({ client: filesClient() as never });
|
||||
render(<EmailComposer onClose={vi.fn()} />);
|
||||
|
||||
pickFromFiles([fileNode('n1', 'setup.exe', 10), fileNode('n2', 'notes.pdf', 10)]);
|
||||
expect(await screen.findByText('notes.pdf')).toBeInTheDocument();
|
||||
expect(screen.queryByText('setup.exe')).toBeNull();
|
||||
});
|
||||
|
||||
it('stops at the per-message size limit and says so', async () => {
|
||||
useAuthStore.setState({ client: filesClient({ getMaxSizeAttachmentsPerEmail: () => 1500 }) as never });
|
||||
render(<EmailComposer onClose={vi.fn()} />);
|
||||
|
||||
pickFromFiles([fileNode('n1', 'small.pdf', 1000), fileNode('n2', 'big.pdf', 1000)]);
|
||||
expect(await screen.findByText('small.pdf')).toBeInTheDocument();
|
||||
expect(screen.queryByText('big.pdf')).toBeNull();
|
||||
expect(hoisted.toastError).toHaveBeenCalledWith('attachments_total_too_large');
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,8 @@
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { render, screen, fireEvent, waitFor, act, within } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import React from 'react';
|
||||
import { EmailComposer } from '../email-composer';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
|
||||
// ─── Heavy component mocks (mirrors reply-addressing.test.tsx) ────────────────
|
||||
|
||||
@@ -146,6 +147,7 @@ vi.mock('@/lib/plugin-hooks', () => ({
|
||||
getRecipientSuggestions: { call: async () => [] },
|
||||
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
|
||||
onDraftChange: { emit: () => {} },
|
||||
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
|
||||
onBeforeEmailSend: { intercept: async () => true },
|
||||
onComposeSend: { intercept: async () => true },
|
||||
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
|
||||
@@ -272,4 +274,37 @@ describe('composer empty subject warning', () => {
|
||||
await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1));
|
||||
expect(screen.queryByText('empty_subject.title')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// A draft saved with a "(No Subject)" placeholder came back with that as
|
||||
// its subject, so sending the resumed draft skipped the warning (#1189).
|
||||
it('saves a draft without a subject as an empty subject', async () => {
|
||||
const createDraft = vi.fn().mockResolvedValue('draft-8');
|
||||
useAuthStore.setState({
|
||||
client: {
|
||||
createDraft,
|
||||
getEmail: vi.fn().mockResolvedValue({ id: 'draft-8', attachments: [] }),
|
||||
hasDelayedSend: () => false,
|
||||
getMaxDelayedSend: () => 0,
|
||||
} as never,
|
||||
activeAccountId: 'acct-A' as never,
|
||||
});
|
||||
|
||||
const requestCloseRef = { current: null } as React.MutableRefObject<((afterClose?: () => void) => void) | null>;
|
||||
render(
|
||||
<EmailComposer
|
||||
initialData={{ ...DRAFT_WITHOUT_SUBJECT, subject: 'Lunch' }}
|
||||
requestCloseRef={requestCloseRef}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
);
|
||||
fireEvent.change(screen.getByDisplayValue('Lunch'), { target: { value: '' } });
|
||||
|
||||
act(() => requestCloseRef.current!(vi.fn()));
|
||||
const dialog = await screen.findByRole('alertdialog');
|
||||
fireEvent.click(within(dialog).getByText('save'));
|
||||
|
||||
await waitFor(() => expect(createDraft).toHaveBeenCalled());
|
||||
expect(createDraft.mock.calls[0][1]).toBe('');
|
||||
useAuthStore.setState({ client: null, activeAccountId: null });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useFocusTrap } from "@/hooks/use-focus-trap";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, BookmarkPlus, CalendarClock, ChevronDown, MailCheck, Search, Users, PackageCheck, LockKeyhole, Type } from "@/components/icons";
|
||||
import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, BookmarkPlus, CalendarClock, ChevronDown, MailCheck, Search, Users, PackageCheck, LockKeyhole, Type, HardDrive } from "@/components/icons";
|
||||
import { cn, formatFileSize, formatDateTime, generateUUID } from "@/lib/utils";
|
||||
import { debug } from "@/lib/debug";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
@@ -34,6 +34,9 @@ import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import { FilePreviewModal } from "@/components/files/file-preview-modal";
|
||||
import { FilePickerDialog } from "@/components/files/file-picker-dialog";
|
||||
import type { IJMAPClient } from "@/lib/jmap/client-interface";
|
||||
import type { FileNode } from "@/lib/jmap/types";
|
||||
import { useContactStore, getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
|
||||
import { useTemplateStore } from "@/stores/template-store";
|
||||
import { SubAddressHelper } from "@/components/identity/sub-address-helper";
|
||||
@@ -374,6 +377,7 @@ export function EmailComposer({
|
||||
|
||||
const { isFeatureEnabled } = usePolicyStore();
|
||||
const templatesEnabled = isFeatureEnabled('templatesEnabled');
|
||||
const filesEnabled = isFeatureEnabled('filesEnabled');
|
||||
|
||||
// The signature identity used when embedding the signature into the initial
|
||||
// body for "above quote" mode. Mirrors the signatureIdentity derivation
|
||||
@@ -686,6 +690,9 @@ export function EmailComposer({
|
||||
const [fromOverrideEmail, setFromOverrideEmail] = useState<string>(initialData?.fromOverrideEmail ?? '');
|
||||
const [fromOverrideName, setFromOverrideName] = useState<string>(initialData?.fromOverrideName ?? '');
|
||||
const [showTemplatePicker, setShowTemplatePicker] = useState(false);
|
||||
// The Files account being browsed for attachments, fixed when the picker
|
||||
// opens so the picked blobs are tagged with the login that owns them.
|
||||
const [filesPicker, setFilesPicker] = useState<{ client: IJMAPClient; accountId?: string } | null>(null);
|
||||
const [showSaveAsTemplate, setShowSaveAsTemplate] = useState(false);
|
||||
const [showCloseDialog, setShowCloseDialog] = useState(false);
|
||||
const [showAllAttachments, setShowAllAttachments] = useState(false);
|
||||
@@ -1861,6 +1868,51 @@ export function EmailComposer({
|
||||
]);
|
||||
}, []);
|
||||
|
||||
// Files from the Files app are already blobs on the server, so they are
|
||||
// attached by blobId with no download or upload (#1179). Stalwart resolves a
|
||||
// FileNode's blob in Email/set for its owner and for anyone it is shared
|
||||
// with. The same checks as for a local file still apply.
|
||||
const handleFilesPicked = useCallback(async (nodes: FileNode[]) => {
|
||||
const picker = filesPicker;
|
||||
setFilesPicker(null);
|
||||
if (!picker) return;
|
||||
|
||||
let picked: FileNode[] = [];
|
||||
for (const node of nodes) {
|
||||
const ok = await emailHooks.onBeforeAttachmentUpload.intercept({
|
||||
name: node.name,
|
||||
type: node.type || 'application/octet-stream',
|
||||
size: node.size,
|
||||
});
|
||||
if (ok && node.blobId) picked.push(node);
|
||||
}
|
||||
const maxTotal = picker.client.getMaxSizeAttachmentsPerEmail?.() ?? 0;
|
||||
if (maxTotal > 0) {
|
||||
let total = attachmentsRef.current.reduce((sum, att) => sum + (att.size || 0), 0);
|
||||
const fitting = picked.filter(node => {
|
||||
if (total + node.size > maxTotal) return false;
|
||||
total += node.size;
|
||||
return true;
|
||||
});
|
||||
if (fitting.length < picked.length) {
|
||||
toast.error(t('attachments_total_too_large', { max: formatFileSize(maxTotal) }));
|
||||
}
|
||||
picked = fitting;
|
||||
}
|
||||
if (picked.length === 0) return;
|
||||
|
||||
setAttachments(prev => [
|
||||
...prev,
|
||||
...picked.map((node): ComposerAttachment => ({
|
||||
name: node.name,
|
||||
type: node.type || 'application/octet-stream',
|
||||
size: node.size,
|
||||
blobId: node.blobId!,
|
||||
sourceAccountId: picker.accountId,
|
||||
})),
|
||||
]);
|
||||
}, [filesPicker, t]);
|
||||
|
||||
// Inline preview for composer attachments, reusing the message viewer's
|
||||
// FilePreviewModal (so previewability and the open-in-new-tab safety gate are
|
||||
// handled there). Prefer the local File - no network - and fall back to the
|
||||
@@ -1986,7 +2038,10 @@ export function EmailComposer({
|
||||
const previousDraftId = draftIdRef.current;
|
||||
let savedDraft : AlmostSavedDraft = {
|
||||
to: toAddresses,
|
||||
subject: subject || t('no_subject'),
|
||||
// Save an empty subject as empty. A "(No Subject)" placeholder here
|
||||
// came back as a real subject on reopen, and Send then skipped the
|
||||
// empty-subject warning (#1189).
|
||||
subject,
|
||||
body: draftTextBody,
|
||||
cc: ccAddresses,
|
||||
bcc: bccAddresses,
|
||||
@@ -2740,6 +2795,7 @@ export function EmailComposer({
|
||||
|
||||
if (
|
||||
showTemplatePicker ||
|
||||
filesPicker ||
|
||||
showSaveAsTemplate ||
|
||||
showScheduleDialog ||
|
||||
showAttachmentWarning ||
|
||||
@@ -3339,6 +3395,19 @@ export function EmailComposer({
|
||||
>
|
||||
<Paperclip className="w-4 h-4" />
|
||||
</Button>
|
||||
{filesEnabled && composerClient?.supportsFiles?.() && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setFilesPicker({ client: composerClient, accountId: composerAccountId })}
|
||||
className="h-9 w-9"
|
||||
title={t('attach_from_files')}
|
||||
aria-label={t('attach_from_files')}
|
||||
data-testid="composer-attach-from-files"
|
||||
>
|
||||
<HardDrive className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -3507,6 +3576,14 @@ export function EmailComposer({
|
||||
/>
|
||||
)}
|
||||
|
||||
{filesPicker && (
|
||||
<FilePickerDialog
|
||||
client={filesPicker.client}
|
||||
onClose={() => setFilesPicker(null)}
|
||||
onPick={handleFilesPicked}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showSaveAsTemplate && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4 animate-in fade-in duration-150">
|
||||
<div
|
||||
|
||||
@@ -22,7 +22,7 @@ import { TagPicker } from "./tag-picker";
|
||||
import { useMeasuredTagDisplay } from "@/hooks/use-tag-display";
|
||||
import { useKeywordFormat } from "@/hooks/use-keyword-format";
|
||||
import { getEmailTagIds } from "@/lib/thread-utils";
|
||||
import { getSecurityStatus, extractListHeaders } from "@/lib/email-headers";
|
||||
import { getSecurityStatus, extractListHeaders, getSenderVerification } from "@/lib/email-headers";
|
||||
import { emailToReadView } from "@/lib/plugin-projection";
|
||||
import { generateEmailSource } from "@/lib/email-source";
|
||||
import {
|
||||
@@ -133,6 +133,8 @@ const SUB_MENU_ITEM_SELECTOR = '[role="menuitem"],[role="menuitemcheckbox"],[rol
|
||||
|
||||
interface EmailViewerProps {
|
||||
email: Email | null;
|
||||
/** Captured message routing supplied by the standard mail UI. */
|
||||
blobSource?: { client: IJMAPClient | null; accountId?: string };
|
||||
isLoading?: boolean;
|
||||
onReply?: (draftText?: string) => void;
|
||||
onReplyAll?: () => void;
|
||||
@@ -602,7 +604,7 @@ function DraggableAttachmentChip({ attachment, client, accountId, enabled, downl
|
||||
getBlobUrl: async () => {
|
||||
if (attachment.blobId && client) {
|
||||
try {
|
||||
return await client.fetchBlobAsObjectUrl(attachment.blobId, attachment.name || undefined, attachment.type, accountId);
|
||||
return await client.fetchBlobAsObjectUrl(attachment.blobId, downloadName || attachment.name || undefined, attachment.type, accountId);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -639,6 +641,7 @@ function SidebarSection({ icon: Icon, title, children }: { icon: React.Component
|
||||
|
||||
export function EmailViewer({
|
||||
email,
|
||||
blobSource,
|
||||
isLoading = false,
|
||||
onReply,
|
||||
onReplyAll,
|
||||
@@ -778,18 +781,17 @@ export function EmailViewer({
|
||||
const { tabletListVisible } = useUIStore();
|
||||
const { identities, client, isDemoMode, activeAccountId } = useAuthStore();
|
||||
const activeAccount = useAccountStore((s) => s.accounts.find((a) => a.id === activeAccountId));
|
||||
// Blobs (inline images, drag-out, TNEF, embedded messages, thumbnails, bundle
|
||||
// downloads) are account-scoped. In the unified / All-Mail view the open
|
||||
// message may belong to another login (route to its client) or a delegated
|
||||
// shared account (same client, owner accountId in the URL). Resolve both from
|
||||
// the message's source so cross-account blob fetches don't 404 against the
|
||||
// active account.
|
||||
// The standard mail UI supplies message routing for preview/download,
|
||||
// inline images, export and attachment drag-out. Other viewer hosts keep
|
||||
// their existing fallback routing.
|
||||
const isUnifiedView = useEmailStore((s) => s.isUnifiedView);
|
||||
const blobClient = useMemo(() => {
|
||||
const legacyBlobClient = useMemo(() => {
|
||||
const scid = isUnifiedView ? email?.sourceClientAccountId : undefined;
|
||||
return (scid ? useAuthStore.getState().getClientForAccount(scid) : null) ?? client;
|
||||
}, [isUnifiedView, email?.sourceClientAccountId, client]);
|
||||
const blobAccountId = isUnifiedView ? email?.sourceAccountId : undefined;
|
||||
const legacyBlobAccountId = isUnifiedView ? email?.sourceAccountId : undefined;
|
||||
const blobClient = blobSource ? blobSource.client : legacyBlobClient;
|
||||
const blobAccountId = blobSource ? blobSource.accountId : legacyBlobAccountId;
|
||||
|
||||
// List-Unsubscribe mailto: send the message ourselves - this is a webmail
|
||||
// client, handing a mailto: URL to the OS mail handler goes nowhere for
|
||||
@@ -1379,7 +1381,7 @@ export function EmailViewer({
|
||||
|
||||
// TNEF (winmail.dat) detection and processing
|
||||
useEffect(() => {
|
||||
if (!email?.attachments || !client) return;
|
||||
if (!email?.attachments || !blobClient) return;
|
||||
|
||||
const tnefAtt = email.attachments.find(att => isTnefAttachment(att.name, att.type));
|
||||
if (!tnefAtt?.blobId) {
|
||||
@@ -1471,7 +1473,7 @@ export function EmailViewer({
|
||||
// often empty Word boilerplate and the real content is inside a message/rfc822
|
||||
// attachment. Detect this pattern and unwrap the embedded email.
|
||||
useEffect(() => {
|
||||
if (!email?.attachments || !client) return;
|
||||
if (!email?.attachments || !blobClient) return;
|
||||
|
||||
// Find message/rfc822 attachment
|
||||
const rfc822Att = email.attachments.find(
|
||||
@@ -1578,7 +1580,7 @@ export function EmailViewer({
|
||||
};
|
||||
}
|
||||
|
||||
if (!client || !email?.attachments) {
|
||||
if (!blobClient || !email?.attachments) {
|
||||
setCidBlobUrls({});
|
||||
return;
|
||||
}
|
||||
@@ -2671,9 +2673,11 @@ export function EmailViewer({
|
||||
|
||||
// Export email as .eml file
|
||||
const handleExportEmail = async () => {
|
||||
if (!email?.blobId || !client) return;
|
||||
const exportClient = blobSource ? blobSource.client : client;
|
||||
const exportAccountId = blobSource?.accountId;
|
||||
if (!email?.blobId || !exportClient) return;
|
||||
try {
|
||||
await client.downloadBlob(email.blobId, emailExportFilename(email, emailFilenameOptions), 'message/rfc822');
|
||||
await exportClient.downloadBlob(email.blobId, emailExportFilename(email, emailFilenameOptions), 'message/rfc822', exportAccountId);
|
||||
} catch {
|
||||
toast.error(tNotifications('export_email_error'));
|
||||
return;
|
||||
@@ -3031,6 +3035,19 @@ export function EmailViewer({
|
||||
}
|
||||
|
||||
const sender = email.from?.[0];
|
||||
const senderVerification = getSenderVerification(email.authenticationResults, sender?.email);
|
||||
const senderVerificationLabel = senderVerification?.status === 'failed'
|
||||
? t('sender_check.failed_label')
|
||||
: t('sender_check.unverified_label');
|
||||
const senderVerificationMessage = !senderVerification
|
||||
? ''
|
||||
: senderVerification.status === 'failed'
|
||||
? senderVerification.sentFrom
|
||||
? t('sender_check.failed_sent_from', { domain: senderVerification.domain, host: senderVerification.sentFrom })
|
||||
: t('sender_check.failed', { domain: senderVerification.domain })
|
||||
: senderVerification.sentFrom
|
||||
? t('sender_check.unverified_sent_from', { domain: senderVerification.domain, host: senderVerification.sentFrom })
|
||||
: t('sender_check.unverified', { domain: senderVerification.domain });
|
||||
const isStarred = email.keywords?.$flagged;
|
||||
const isUnread = !email.keywords?.$seen;
|
||||
const isImportant = email.keywords?.["$important"];
|
||||
@@ -4559,6 +4576,20 @@ export function EmailViewer({
|
||||
onViewContact={handleViewContactSidebar}
|
||||
className="text-sm text-start"
|
||||
/>
|
||||
{senderVerification && (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 px-1.5 py-0.5 rounded-md border text-xs whitespace-nowrap cursor-help",
|
||||
senderVerification.status === 'failed'
|
||||
? "bg-red-500/[0.07] border-red-500/30 text-red-700 dark:text-red-400"
|
||||
: "bg-amber-500/[0.07] border-amber-500/30 text-amber-700 dark:text-amber-400",
|
||||
)}
|
||||
title={senderVerificationMessage}
|
||||
>
|
||||
<AlertTriangle className="w-3 h-3 flex-shrink-0" />
|
||||
{senderVerificationLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Row>
|
||||
{replyToDifferent && (
|
||||
@@ -4864,12 +4895,36 @@ export function EmailViewer({
|
||||
)}
|
||||
|
||||
{/* Unified Notification Banner - External Content + Calendar Invitation + Read Receipt */}
|
||||
{((hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow') ||
|
||||
{(senderVerification ||
|
||||
(hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow') ||
|
||||
hasCalendarInvitation ||
|
||||
(readReceiptResponse === 'ask' && shouldOfferReadReceipt)) && (
|
||||
<div className="border-b border-border bg-muted/30 isolate">
|
||||
<div className="px-6 py-1.5">
|
||||
<div className="flex flex-col gap-3 isolate">
|
||||
{/* Sender the server's checks don't back */}
|
||||
{senderVerification && (
|
||||
<div className="flex items-start gap-3 py-1">
|
||||
<div className={cn(
|
||||
"w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0 shadow-sm",
|
||||
senderVerification.status === 'failed' ? "bg-destructive/15 text-destructive" : "bg-warning/15 text-warning",
|
||||
)}>
|
||||
<ShieldAlert className="w-5 h-5" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{senderVerificationLabel}
|
||||
</div>
|
||||
<div className="text-sm font-medium text-foreground break-words">
|
||||
{senderVerificationMessage}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground break-words">
|
||||
{t('sender_check.caution')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* External Content Controls */}
|
||||
{hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow' && (
|
||||
<div className="flex items-start gap-3 py-1">
|
||||
@@ -4895,7 +4950,9 @@ export function EmailViewer({
|
||||
{t('load_external_content')}
|
||||
</button>
|
||||
)}
|
||||
{email.from?.[0]?.email && (
|
||||
{/* Trusting a forged address would load remote content
|
||||
for the next forgery too. */}
|
||||
{email.from?.[0]?.email && !senderVerification && (
|
||||
<button
|
||||
onClick={() => {
|
||||
const senderEmail = email.from?.[0]?.email;
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { FileBrowser } from '../file-browser';
|
||||
import { useFileStore, type FileResource } from '@/stores/file-store';
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
import type { FileNode } from '@/lib/jmap/types';
|
||||
|
||||
vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } }));
|
||||
vi.mock('@/components/settings/share-collection-dialog', () => ({
|
||||
ShareCollectionDialog: ({ collectionName, shareWith }: { collectionName: string; shareWith?: Record<string, unknown> | null }) => (
|
||||
<div data-testid="share-dialog">{collectionName}:{Object.keys(shareWith ?? {}).join(',')}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// Shared folders must be reachable, and own folders shareable, in both
|
||||
// folder layouts (#1181).
|
||||
|
||||
function node(id: string, name: string, parentId: string | null, extra: Partial<FileNode> = {}): FileNode {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
parentId,
|
||||
type: 'd',
|
||||
blobId: null,
|
||||
size: 0,
|
||||
created: '2026-10-01T00:00:00Z',
|
||||
modified: '2026-10-01T00:00:00Z',
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
function resource(n: FileNode): FileResource {
|
||||
return {
|
||||
id: n.id,
|
||||
name: n.name,
|
||||
serverName: n.name,
|
||||
isDirectory: n.blobId == null,
|
||||
contentType: '',
|
||||
contentLength: n.size,
|
||||
lastModified: n.modified ?? '',
|
||||
blobId: n.blobId,
|
||||
parentId: n.parentId,
|
||||
shareWith: n.shareWith,
|
||||
isShared: n.isShared,
|
||||
} as FileResource;
|
||||
}
|
||||
|
||||
const OWN: FileNode[] = [
|
||||
node('f-docs', 'Documents', null),
|
||||
node('f-notes', 'notes.txt', null, { blobId: 'b-notes', type: 'text/plain', size: 10 }),
|
||||
];
|
||||
const SHARED = node('e:f-team', 'Team', null, { isShared: true, accountId: 'e', accountName: 'userb@example.org' });
|
||||
|
||||
function setLayout(folderLayout: 'inline' | 'sidebar') {
|
||||
localStorage.setItem('files-settings', JSON.stringify({ folderLayout }));
|
||||
}
|
||||
|
||||
function renderBrowser(opts: { resources?: FileResource[]; listByParentId?: (id: string | null) => Promise<FileResource[]> } = {}) {
|
||||
const client = {
|
||||
listAllFileNodesAcrossAccounts: vi.fn(async () => [...OWN, SHARED]),
|
||||
} as unknown as IJMAPClient;
|
||||
act(() => { useFileStore.setState({ client, sharedRoots: [] }); });
|
||||
const onNavigate = vi.fn();
|
||||
const noop = vi.fn(async () => {});
|
||||
const listByParentId = opts.listByParentId
|
||||
?? vi.fn(async (parentId: string | null) => OWN.filter(n => n.parentId === parentId).map(resource));
|
||||
render(
|
||||
<FileBrowser
|
||||
currentPath="/"
|
||||
resources={opts.resources ?? OWN.map(resource)}
|
||||
isLoading={false}
|
||||
error={null}
|
||||
selectedResources={new Set()}
|
||||
uploadProgress={null}
|
||||
clipboard={null}
|
||||
onNavigate={onNavigate}
|
||||
onCreateFolder={noop}
|
||||
onUploadFiles={noop}
|
||||
onUploadFolder={noop}
|
||||
onCancelUpload={vi.fn()}
|
||||
onDelete={noop}
|
||||
onBatchDelete={noop}
|
||||
onRename={noop}
|
||||
onDownload={noop}
|
||||
onBatchDownload={noop}
|
||||
onRefresh={noop}
|
||||
onSelectResource={vi.fn()}
|
||||
onToggleSelect={vi.fn()}
|
||||
onSelectAll={vi.fn()}
|
||||
onClearSelection={vi.fn()}
|
||||
onSetSelection={vi.fn()}
|
||||
onCut={vi.fn()}
|
||||
onCopy={vi.fn()}
|
||||
onPaste={noop}
|
||||
onMoveToFolder={noop}
|
||||
onMoveToParent={noop}
|
||||
onPreviewImage={vi.fn()}
|
||||
onPreviewFile={vi.fn()}
|
||||
onShowDetails={vi.fn()}
|
||||
onCreateTextFile={noop}
|
||||
onDuplicate={noop}
|
||||
getImageUrl={async () => ''}
|
||||
listPath={async () => []}
|
||||
listByParentId={listByParentId}
|
||||
favorites={[]}
|
||||
recentFiles={[]}
|
||||
onToggleFavorite={vi.fn()}
|
||||
showDetails={false}
|
||||
onToggleDetails={vi.fn()}
|
||||
detailResource={null}
|
||||
client={client}
|
||||
ownAccountId="a"
|
||||
sharingEnabled
|
||||
onShare={noop}
|
||||
/>,
|
||||
);
|
||||
return { onNavigate, listByParentId };
|
||||
}
|
||||
|
||||
describe('FileBrowser shared folders (#1181)', () => {
|
||||
beforeEach(() => localStorage.clear());
|
||||
afterEach(() => {
|
||||
act(() => { useFileStore.setState({ client: null, sharedRoots: [] }); });
|
||||
});
|
||||
|
||||
it('lists folders shared with the user at the root in the inline layout', async () => {
|
||||
setLayout('inline');
|
||||
const { onNavigate } = renderBrowser();
|
||||
const team = await screen.findByText('Team');
|
||||
expect(screen.getAllByText('shared_with_me').length).toBeGreaterThan(0);
|
||||
fireEvent.click(team);
|
||||
expect(onNavigate).toHaveBeenCalledWith('/Team', 'e:f-team');
|
||||
});
|
||||
|
||||
it('shows shared folders instead of the upload area when the own root is empty', async () => {
|
||||
setLayout('inline');
|
||||
renderBrowser({ resources: [] });
|
||||
expect(await screen.findByText('Team')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not repeat shared folders in the list in the sidebar layout', async () => {
|
||||
setLayout('sidebar');
|
||||
renderBrowser();
|
||||
// The tree lists it once; the file list does not.
|
||||
expect(await screen.findAllByText('Team')).toHaveLength(1);
|
||||
expect(screen.queryByRole('table')?.textContent ?? '').not.toContain('Team');
|
||||
});
|
||||
|
||||
it('shares an own folder from the tree in the sidebar layout', async () => {
|
||||
setLayout('sidebar');
|
||||
const shared = { ...OWN[0], shareWith: { p1: { mayRead: true } } } as unknown as FileNode;
|
||||
const listByParentId = vi.fn(async (parentId: string | null) =>
|
||||
parentId === null ? [resource(shared), resource(OWN[1])] : []);
|
||||
// Browsing another folder: the tree's folder is not in the listing.
|
||||
renderBrowser({ resources: [resource(OWN[1])], listByParentId });
|
||||
|
||||
const folder = await screen.findByText('Documents');
|
||||
fireEvent.contextMenu(folder);
|
||||
fireEvent.click(await screen.findByText('share'));
|
||||
|
||||
// The dialog reads the folder fresh, so it shows the current shares.
|
||||
expect(await screen.findByTestId('share-dialog')).toHaveTextContent('Documents:p1');
|
||||
});
|
||||
|
||||
it('offers no share menu on folders shared with the user', async () => {
|
||||
setLayout('sidebar');
|
||||
renderBrowser();
|
||||
const tree = (await screen.findByText('Team')).closest('div')!;
|
||||
fireEvent.contextMenu(within(tree).getByText('Team'));
|
||||
expect(screen.queryByText('share')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { FilePickerDialog } from '../file-picker-dialog';
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
import type { FileNode } from '@/lib/jmap/types';
|
||||
|
||||
vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } }));
|
||||
|
||||
// Picking files from the Files app for a message (#1179).
|
||||
|
||||
function node(id: string, name: string, parentId: string | null, extra: Partial<FileNode> = {}): FileNode {
|
||||
const isFolder = extra.blobId === null;
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
parentId,
|
||||
type: isFolder ? 'd' : 'application/pdf',
|
||||
blobId: `blob-${id}`,
|
||||
size: isFolder ? 0 : 1000,
|
||||
created: '2026-10-01T00:00:00Z',
|
||||
modified: '2026-10-01T00:00:00Z',
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
const TREE: FileNode[] = [
|
||||
node('f-docs', 'Documents', null, { blobId: null }),
|
||||
node('f-report', 'report.pdf', 'f-docs'),
|
||||
node('f-invoice', 'invoice.pdf', null),
|
||||
node('f-hidden', '.secret', null),
|
||||
node('e:f-plan', 'plan.pdf', null, { isShared: true, accountId: 'e', accountName: 'userb@example.org' }),
|
||||
];
|
||||
|
||||
function clientWith(list: () => Promise<FileNode[]>): IJMAPClient {
|
||||
return { listAllFileNodesAcrossAccounts: vi.fn(list) } as unknown as IJMAPClient;
|
||||
}
|
||||
|
||||
function renderPicker(list: () => Promise<FileNode[]> = async () => TREE) {
|
||||
const onPick = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
const client = clientWith(list);
|
||||
render(<FilePickerDialog client={client} onPick={onPick} onClose={onClose} />);
|
||||
return { onPick, onClose, client };
|
||||
}
|
||||
|
||||
describe('FilePickerDialog', () => {
|
||||
it('lists the root without hidden or shared nodes, and offers Shared with me', async () => {
|
||||
renderPicker();
|
||||
expect(await screen.findByText('invoice.pdf')).toBeInTheDocument();
|
||||
expect(screen.getByText('Documents')).toBeInTheDocument();
|
||||
expect(screen.getByText('shared_with_me')).toBeInTheDocument();
|
||||
expect(screen.queryByText('.secret')).toBeNull();
|
||||
expect(screen.queryByText('plan.pdf')).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the selection while browsing folders and hands back the picked nodes', async () => {
|
||||
const { onPick } = renderPicker();
|
||||
fireEvent.click(await screen.findByText('invoice.pdf'));
|
||||
fireEvent.click(screen.getByText('Documents'));
|
||||
fireEvent.click(await screen.findByText('report.pdf'));
|
||||
fireEvent.click(screen.getByText('breadcrumb_root'));
|
||||
fireEvent.click(screen.getByText('shared_with_me'));
|
||||
fireEvent.click(await screen.findByText('plan.pdf'));
|
||||
|
||||
fireEvent.click(screen.getByTestId('file-picker-attach'));
|
||||
expect(onPick).toHaveBeenCalledTimes(1);
|
||||
expect(onPick.mock.calls[0][0].map((n: FileNode) => n.id)).toEqual(['f-invoice', 'f-report', 'e:f-plan']);
|
||||
});
|
||||
|
||||
it('unselects a file on a second click and only enables Attach with a selection', async () => {
|
||||
renderPicker();
|
||||
const attach = screen.getByTestId('file-picker-attach');
|
||||
expect(attach).toBeDisabled();
|
||||
const row = (await screen.findByText('invoice.pdf')).closest('button')!;
|
||||
fireEvent.click(row);
|
||||
expect(row).toHaveAttribute('aria-checked', 'true');
|
||||
expect(attach).toBeEnabled();
|
||||
fireEvent.click(row);
|
||||
expect(row).toHaveAttribute('aria-checked', 'false');
|
||||
expect(attach).toBeDisabled();
|
||||
});
|
||||
|
||||
it('searches files in every folder and shows where each one lives', async () => {
|
||||
renderPicker();
|
||||
await screen.findByText('invoice.pdf');
|
||||
fireEvent.change(screen.getByPlaceholderText('search_placeholder'), { target: { value: 'RE' } });
|
||||
expect(screen.getByText('report.pdf')).toBeInTheDocument();
|
||||
expect(screen.getByText('Documents')).toBeInTheDocument();
|
||||
expect(screen.queryByText('invoice.pdf')).toBeNull();
|
||||
});
|
||||
|
||||
it('offers a retry when the listing fails', async () => {
|
||||
let calls = 0;
|
||||
renderPicker(async () => {
|
||||
calls += 1;
|
||||
if (calls === 1) throw new Error('boom');
|
||||
return TREE;
|
||||
});
|
||||
fireEvent.click(await screen.findByText('retry'));
|
||||
expect(await screen.findByText('invoice.pdf')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
+112
-134
@@ -7,10 +7,8 @@ import {
|
||||
Pencil, RefreshCw, Home, ChevronRight, MoreVertical,
|
||||
Search, ArrowUp, ArrowDown, X, LayoutGrid, LayoutList,
|
||||
Copy, Clipboard, Scissors, Info, Image as ImageIcon,
|
||||
FilePlus, CopyPlus, FileText, FileAudio, FileVideo,
|
||||
FilePlus, CopyPlus,
|
||||
AlertCircle, Star, Clock, FolderUp,
|
||||
FileArchive, FileSpreadsheet, Presentation, FileCode,
|
||||
Box, PenTool, Terminal as TerminalIcon, Database, Type as TypeIcon,
|
||||
Menu, Users, Share2, SquarePen,
|
||||
} from "@/components/icons";
|
||||
import { useIsDesktop } from "@/hooks/use-media-query";
|
||||
@@ -20,13 +18,14 @@ import { NewFolderDialog } from "@/components/files/new-folder-dialog";
|
||||
import { RenameDialog } from "@/components/files/rename-dialog";
|
||||
import { fileNameProblem } from "@/lib/file-name-rules";
|
||||
import { FileUploadArea } from "@/components/files/file-upload-area";
|
||||
import { getFileIconByName, isAudioFile, isImageFile, isPdfFile, isTextFile, isVideoFile } from "@/components/files/file-icons";
|
||||
import { loadFilesSettings } from "@/components/files/files-settings-dialog";
|
||||
import type { FolderLayout } from "@/components/files/files-settings-dialog";
|
||||
import { FolderTreeSidebar } from "@/components/files/folder-tree-sidebar";
|
||||
import { ResizeHandle } from "@/components/layout/resize-handle";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import { getDroppedFilesAndFolders } from "@/lib/webdav/drop-utils";
|
||||
import type { FileResource } from "@/stores/file-store";
|
||||
import { useFileStore, type FileResource } from "@/stores/file-store";
|
||||
import { ShareCollectionDialog } from "@/components/settings/share-collection-dialog";
|
||||
import type { IJMAPClient } from "@/lib/jmap/client-interface";
|
||||
import type { FileNodeRights } from "@/lib/jmap/types";
|
||||
@@ -113,132 +112,10 @@ interface FileBrowserProps {
|
||||
onShare?: (id: string, principalId: string, rights: FileNodeRights | null) => Promise<void>;
|
||||
}
|
||||
|
||||
const IMAGE_EXTENSIONS = new Set(["jpg", "jpeg", "png", "gif", "svg", "webp", "bmp", "ico", "avif"]);
|
||||
|
||||
function isImageFile(name: string): boolean {
|
||||
const ext = name.split(".").pop()?.toLowerCase() || "";
|
||||
return IMAGE_EXTENSIONS.has(ext);
|
||||
}
|
||||
|
||||
const TEXT_EXTENSIONS = new Set([
|
||||
"txt", "md", "markdown", "json", "xml", "html", "htm", "css", "js", "ts",
|
||||
"jsx", "tsx", "py", "rb", "java", "c", "cpp", "h", "hpp", "go", "rs",
|
||||
"sh", "bash", "zsh", "yaml", "yml", "toml", "ini", "cfg", "conf", "env",
|
||||
"log", "csv", "sql", "graphql", "vue", "svelte", "astro", "php", "pl",
|
||||
"swift", "kt", "scala", "r", "lua", "vim",
|
||||
]);
|
||||
|
||||
function isTextFile(name: string): boolean {
|
||||
const ext = name.split(".").pop()?.toLowerCase() || "";
|
||||
const baseName = name.toLowerCase();
|
||||
return TEXT_EXTENSIONS.has(ext) || ["dockerfile", "makefile", "readme", "license", "changelog"].includes(baseName);
|
||||
}
|
||||
|
||||
const AUDIO_EXTENSIONS = new Set(["mp3", "wav", "ogg", "flac", "aac", "m4a", "wma", "opus"]);
|
||||
function isAudioFile(name: string): boolean {
|
||||
const ext = name.split(".").pop()?.toLowerCase() || "";
|
||||
return AUDIO_EXTENSIONS.has(ext);
|
||||
}
|
||||
|
||||
const VIDEO_EXTENSIONS = new Set(["mp4", "webm", "ogv", "mov", "avi", "mkv", "m4v"]);
|
||||
function isVideoFile(name: string): boolean {
|
||||
const ext = name.split(".").pop()?.toLowerCase() || "";
|
||||
return VIDEO_EXTENSIONS.has(ext);
|
||||
}
|
||||
|
||||
const PDF_EXTENSIONS = new Set(["pdf"]);
|
||||
function isPdfFile(name: string): boolean {
|
||||
const ext = name.split(".").pop()?.toLowerCase() || "";
|
||||
return PDF_EXTENSIONS.has(ext);
|
||||
}
|
||||
|
||||
const VECTOR_EXTENSIONS = new Set(["svg", "ai", "eps", "ps", "sketch", "fig", "xd", "gvdesign"]);
|
||||
function isVectorFile(name: string): boolean {
|
||||
const ext = name.split(".").pop()?.toLowerCase() || "";
|
||||
return VECTOR_EXTENSIONS.has(ext);
|
||||
}
|
||||
|
||||
const THREE_D_EXTENSIONS = new Set([
|
||||
"obj", "fbx", "gltf", "glb", "stl", "3mf", "step", "stp", "iges", "igs",
|
||||
"blend", "3ds", "dae", "usdz", "usd", "usda", "usdc", "ply", "wrl",
|
||||
"c4d", "max", "ma", "mb", "dwg", "dxf",
|
||||
]);
|
||||
function is3DFile(name: string): boolean {
|
||||
const ext = name.split(".").pop()?.toLowerCase() || "";
|
||||
return THREE_D_EXTENSIONS.has(ext);
|
||||
}
|
||||
|
||||
const EXECUTABLE_EXTENSIONS = new Set([
|
||||
"exe", "msi", "dmg", "app", "appimage", "deb", "rpm", "snap", "flatpak",
|
||||
"bat", "cmd", "com", "scr", "ps1", "apk", "ipa", "jar", "run",
|
||||
]);
|
||||
function isExecutableFile(name: string): boolean {
|
||||
const ext = name.split(".").pop()?.toLowerCase() || "";
|
||||
return EXECUTABLE_EXTENSIONS.has(ext);
|
||||
}
|
||||
|
||||
const ARCHIVE_EXTENSIONS = new Set([
|
||||
"zip", "rar", "7z", "tar", "gz", "bz2", "xz", "zst", "lz", "lzma",
|
||||
"tgz", "tbz2", "txz", "cab", "iso", "img",
|
||||
]);
|
||||
function isArchiveFile(name: string): boolean {
|
||||
const ext = name.split(".").pop()?.toLowerCase() || "";
|
||||
return ARCHIVE_EXTENSIONS.has(ext);
|
||||
}
|
||||
|
||||
const SPREADSHEET_EXTENSIONS = new Set(["xls", "xlsx", "ods", "numbers", "tsv"]);
|
||||
function isSpreadsheetFile(name: string): boolean {
|
||||
const ext = name.split(".").pop()?.toLowerCase() || "";
|
||||
return SPREADSHEET_EXTENSIONS.has(ext);
|
||||
}
|
||||
|
||||
const PRESENTATION_EXTENSIONS = new Set(["ppt", "pptx", "odp", "key"]);
|
||||
function isPresentationFile(name: string): boolean {
|
||||
const ext = name.split(".").pop()?.toLowerCase() || "";
|
||||
return PRESENTATION_EXTENSIONS.has(ext);
|
||||
}
|
||||
|
||||
const WORD_DOCUMENT_EXTENSIONS = new Set(["doc", "docx", "odt", "rtf"]);
|
||||
function isWordDocumentFile(name: string): boolean {
|
||||
const ext = name.split(".").pop()?.toLowerCase() || "";
|
||||
return WORD_DOCUMENT_EXTENSIONS.has(ext);
|
||||
}
|
||||
|
||||
const FONT_EXTENSIONS = new Set(["ttf", "otf", "woff", "woff2", "eot"]);
|
||||
function isFontFile(name: string): boolean {
|
||||
const ext = name.split(".").pop()?.toLowerCase() || "";
|
||||
return FONT_EXTENSIONS.has(ext);
|
||||
}
|
||||
|
||||
const DATABASE_EXTENSIONS = new Set(["db", "sqlite", "sqlite3", "mdb", "accdb"]);
|
||||
function isDatabaseFile(name: string): boolean {
|
||||
const ext = name.split(".").pop()?.toLowerCase() || "";
|
||||
return DATABASE_EXTENSIONS.has(ext);
|
||||
}
|
||||
|
||||
function isPreviewable(name: string): boolean {
|
||||
return isImageFile(name) || isTextFile(name) || isPdfFile(name) || isAudioFile(name) || isVideoFile(name);
|
||||
}
|
||||
|
||||
function getFileIconByName(name: string, size: "sm" | "lg") {
|
||||
const cls = size === "sm" ? "w-5 h-5" : "w-10 h-10";
|
||||
if (isVectorFile(name)) return <PenTool className={`${cls} text-orange-500`} />;
|
||||
if (is3DFile(name)) return <Box className={`${cls} text-cyan-500`} />;
|
||||
if (isImageFile(name)) return <ImageIcon className={`${cls} text-emerald-500`} />;
|
||||
if (isAudioFile(name)) return <FileAudio className={`${cls} text-purple-500`} />;
|
||||
if (isVideoFile(name)) return <FileVideo className={`${cls} text-pink-500`} />;
|
||||
if (isArchiveFile(name)) return <FileArchive className={`${cls} text-amber-600`} />;
|
||||
if (isExecutableFile(name)) return <TerminalIcon className={`${cls} text-red-500`} />;
|
||||
if (isSpreadsheetFile(name)) return <FileSpreadsheet className={`${cls} text-green-600`} />;
|
||||
if (isPresentationFile(name)) return <Presentation className={`${cls} text-orange-600`} />;
|
||||
if (isWordDocumentFile(name)) return <FileText className={`${cls} text-blue-600`} />;
|
||||
if (isFontFile(name)) return <TypeIcon className={`${cls} text-indigo-500`} />;
|
||||
if (isDatabaseFile(name)) return <Database className={`${cls} text-slate-500`} />;
|
||||
if (isPdfFile(name)) return <FileText className={`${cls} text-red-600`} />;
|
||||
if (isTextFile(name)) return <FileCode className={`${cls} text-yellow-600`} />;
|
||||
return <File className={`${cls} text-muted-foreground`} />;
|
||||
}
|
||||
|
||||
function getFileIcon(resource: FileResource) {
|
||||
if (resource.isDirectory) {
|
||||
return <Folder className="w-5 h-5 text-blue-500" />;
|
||||
@@ -413,13 +290,38 @@ export function FileBrowser({
|
||||
const [isDraggingOver, setIsDraggingOver] = useState(false);
|
||||
// The share dialog is bound to a node id (not name) so its shareWith stays
|
||||
// live after a share refresh re-derives the resource list.
|
||||
const shareTarget = shareTargetId ? resources.find(r => r.id === shareTargetId) ?? null : null;
|
||||
// A folder shared from the tree (sidebar layout) is usually not among the
|
||||
// listed resources, so the dialog keeps its own copy and re-reads it after
|
||||
// each change.
|
||||
const [treeShareTarget, setTreeShareTarget] = useState<FileResource | null>(null);
|
||||
const shareTarget = shareTargetId
|
||||
? resources.find(r => r.id === shareTargetId)
|
||||
?? (treeShareTarget?.id === shareTargetId ? treeShareTarget : null)
|
||||
: null;
|
||||
// A node is shareable when the server supports JMAP Sharing, the viewer owns
|
||||
// it (not a shared-with-me node), and holds the mayShare right (owned nodes
|
||||
// report full rights; treat missing myRights as allowed).
|
||||
const canShare = useCallback((r: FileResource | null | undefined): boolean =>
|
||||
!!(sharingEnabled && onShare && client && r && !r.isShared && (r.myRights?.mayShare ?? true)),
|
||||
[sharingEnabled, onShare, client]);
|
||||
const reloadTreeShareTarget = useCallback(async (target: FileResource) => {
|
||||
const siblings = await listByParentId(target.parentId ?? null);
|
||||
setTreeShareTarget(siblings.find(r => r.id === target.id) ?? target);
|
||||
}, [listByParentId]);
|
||||
const handleTreeShare = useCallback((target: FileResource) => {
|
||||
setTreeShareTarget(target);
|
||||
setShareTargetId(target.id);
|
||||
// The tree caches its listing; fetch the current shares before editing.
|
||||
void reloadTreeShareTarget(target);
|
||||
}, [reloadTreeShareTarget]);
|
||||
// Folders other principals shared with the user. The sidebar layout lists
|
||||
// them in the folder tree; the inline layout lists them at the root (#1181).
|
||||
const storeClient = useFileStore(s => s.client);
|
||||
const sharedRoots = useFileStore(s => s.sharedRoots);
|
||||
const loadSharedRoots = useFileStore(s => s.loadSharedRoots);
|
||||
useEffect(() => {
|
||||
if (storeClient) void loadSharedRoots();
|
||||
}, [storeClient, loadSharedRoots]);
|
||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; name: string } | null>(null);
|
||||
const [emptyContextMenu, setEmptyContextMenu] = useState<{ x: number; y: number } | null>(null);
|
||||
const [showNewTextFile, setShowNewTextFile] = useState(false);
|
||||
@@ -543,6 +445,13 @@ export function FileBrowser({
|
||||
return sorted;
|
||||
}, [resources, searchQuery, sortKey, sortDir, folderLayout]);
|
||||
|
||||
const sharedFolders = useMemo(() => {
|
||||
if (folderLayout !== "inline" || currentPath !== "/" || accountPickerMode) return [];
|
||||
const q = searchQuery.toLowerCase();
|
||||
return sharedRoots.filter(r => r.isDirectory && (!q || r.name.toLowerCase().includes(q)));
|
||||
}, [folderLayout, currentPath, accountPickerMode, sharedRoots, searchQuery]);
|
||||
const openSharedFolder = (resource: FileResource) => onNavigate(`/${resource.name}`, resource.id);
|
||||
|
||||
// Build breadcrumb segments. In Pro mode an account is mounted "between"
|
||||
// Home and the account's filesystem - surfaced as a non-clickable label
|
||||
// (clicking the actual account again would be a no-op; Home detaches it).
|
||||
@@ -1265,6 +1174,8 @@ export function FileBrowser({
|
||||
onNavigate={onNavigate}
|
||||
listByParentId={listByParentId}
|
||||
width={288}
|
||||
canShare={canShare}
|
||||
onShare={handleTreeShare}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
@@ -1275,6 +1186,8 @@ export function FileBrowser({
|
||||
listByParentId={listByParentId}
|
||||
width={sidebarWidth}
|
||||
isResizing={isResizing}
|
||||
canShare={canShare}
|
||||
onShare={handleTreeShare}
|
||||
/>
|
||||
<ResizeHandle
|
||||
onResizeStart={() => { dragStartWidth.current = sidebarWidth; setIsResizing(true); }}
|
||||
@@ -1400,7 +1313,7 @@ export function FileBrowser({
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<p className="text-sm text-muted-foreground">{t("no_accounts")}</p>
|
||||
</div>
|
||||
) : resources.length === 0 && !searchQuery && currentPath === '/' ? (
|
||||
) : resources.length === 0 && sharedFolders.length === 0 && !searchQuery && currentPath === '/' ? (
|
||||
<FileUploadArea
|
||||
onUpload={async (files: File[]) => {
|
||||
setIsUploading(true);
|
||||
@@ -1452,9 +1365,9 @@ export function FileBrowser({
|
||||
<span className="text-xs text-muted-foreground truncate w-full text-center">..</span>
|
||||
</div>
|
||||
)}
|
||||
{displayResources.length === 0 && searchQuery ? (
|
||||
{displayResources.length === 0 && sharedFolders.length === 0 && searchQuery ? (
|
||||
<p className="px-4 py-8 text-center text-muted-foreground text-sm">{t("no_results")}</p>
|
||||
) : (
|
||||
) : displayResources.length === 0 ? null : (
|
||||
<div
|
||||
className="grid grid-cols-[repeat(auto-fill,minmax(7rem,1fr))] gap-2"
|
||||
onContextMenu={(e) => {
|
||||
@@ -1523,6 +1436,31 @@ export function FileBrowser({
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{sharedFolders.length > 0 && (
|
||||
<section aria-label={t("shared_with_me")} className={cn(displayResources.length > 0 && "mt-4")}>
|
||||
<h4 className="px-1 mb-2 text-xs font-medium text-muted-foreground uppercase tracking-wider flex items-center gap-1.5">
|
||||
<Share2 className="w-3 h-3" />
|
||||
{t("shared_with_me")}
|
||||
</h4>
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(7rem,1fr))] gap-2">
|
||||
{sharedFolders.map((resource) => (
|
||||
<button
|
||||
key={resource.id}
|
||||
type="button"
|
||||
onClick={() => openSharedFolder(resource)}
|
||||
title={resource.ownerName ? t("shared_by", { name: resource.ownerName }) : resource.name}
|
||||
className="flex flex-col items-center gap-2 p-3 rounded-lg cursor-pointer transition-colors hover:bg-muted/50"
|
||||
>
|
||||
{getGridIcon(resource)}
|
||||
<span className="text-xs truncate w-full text-center flex items-center justify-center gap-1">
|
||||
<span className="truncate">{resource.name}</span>
|
||||
<ShareBadge resource={resource} t={t} />
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
/* ======= LIST VIEW ======= */
|
||||
@@ -1605,7 +1543,7 @@ export function FileBrowser({
|
||||
<td />
|
||||
</tr>
|
||||
)}
|
||||
{displayResources.length === 0 && searchQuery ? (
|
||||
{displayResources.length === 0 && sharedFolders.length === 0 && searchQuery ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-4 py-8 text-center text-muted-foreground text-sm">
|
||||
{t("no_results")}
|
||||
@@ -1688,6 +1626,44 @@ export function FileBrowser({
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{sharedFolders.length > 0 && (
|
||||
<tr className="border-b border-border bg-muted/30">
|
||||
<th colSpan={4} scope="rowgroup" className="px-4 py-1.5 text-start text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Share2 className="w-3 h-3" />
|
||||
{t("shared_with_me")}
|
||||
</span>
|
||||
</th>
|
||||
</tr>
|
||||
)}
|
||||
{sharedFolders.map((resource) => (
|
||||
<tr
|
||||
key={resource.id}
|
||||
className="border-b border-border cursor-pointer transition-colors hover:bg-muted/50"
|
||||
onClick={() => openSharedFolder(resource)}
|
||||
title={resource.ownerName ? t("shared_by", { name: resource.ownerName }) : undefined}
|
||||
>
|
||||
<td className="px-4 py-2.5">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="w-4 h-4 shrink-0" />
|
||||
{getFileIcon(resource)}
|
||||
<button
|
||||
type="button"
|
||||
className="truncate text-start"
|
||||
onClick={(e) => { e.stopPropagation(); openSharedFolder(resource); }}
|
||||
>
|
||||
{resource.name}
|
||||
</button>
|
||||
<ShareBadge resource={resource} t={t} />
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-muted-foreground hidden md:table-cell">-</td>
|
||||
<td className="px-4 py-2.5 text-muted-foreground hidden lg:table-cell tabular-nums">
|
||||
{formatDate(resource.lastModified)}
|
||||
</td>
|
||||
<td className="px-2 py-2.5" />
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
@@ -2050,9 +2026,11 @@ export function FileBrowser({
|
||||
collectionName={shareTarget.name}
|
||||
shareWith={shareTarget.shareWith}
|
||||
ownAccountId={ownAccountId || ""}
|
||||
onShare={(principalId, rights) =>
|
||||
onShare(shareTarget.id, principalId, rights as FileNodeRights | null)}
|
||||
onClose={() => setShareTargetId(null)}
|
||||
onShare={async (principalId, rights) => {
|
||||
await onShare(shareTarget.id, principalId, rights as FileNodeRights | null);
|
||||
if (treeShareTarget?.id === shareTarget.id) await reloadTreeShareTarget(shareTarget);
|
||||
}}
|
||||
onClose={() => { setShareTargetId(null); setTreeShareTarget(null); }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import {
|
||||
File, Image as ImageIcon, FileText, FileAudio, FileVideo, FileArchive,
|
||||
FileSpreadsheet, Presentation, FileCode, Box, PenTool,
|
||||
Terminal as TerminalIcon, Database, Type as TypeIcon,
|
||||
} from "@/components/icons";
|
||||
|
||||
// File types by extension and the icon for each, shared by the Files app
|
||||
// and the composer's Files picker.
|
||||
|
||||
const IMAGE_EXTENSIONS = new Set(["jpg", "jpeg", "png", "gif", "svg", "webp", "bmp", "ico", "avif"]);
|
||||
|
||||
export function isImageFile(name: string): boolean {
|
||||
const ext = name.split(".").pop()?.toLowerCase() || "";
|
||||
return IMAGE_EXTENSIONS.has(ext);
|
||||
}
|
||||
|
||||
const TEXT_EXTENSIONS = new Set([
|
||||
"txt", "md", "markdown", "json", "xml", "html", "htm", "css", "js", "ts",
|
||||
"jsx", "tsx", "py", "rb", "java", "c", "cpp", "h", "hpp", "go", "rs",
|
||||
"sh", "bash", "zsh", "yaml", "yml", "toml", "ini", "cfg", "conf", "env",
|
||||
"log", "csv", "sql", "graphql", "vue", "svelte", "astro", "php", "pl",
|
||||
"swift", "kt", "scala", "r", "lua", "vim",
|
||||
]);
|
||||
|
||||
export function isTextFile(name: string): boolean {
|
||||
const ext = name.split(".").pop()?.toLowerCase() || "";
|
||||
const baseName = name.toLowerCase();
|
||||
return TEXT_EXTENSIONS.has(ext) || ["dockerfile", "makefile", "readme", "license", "changelog"].includes(baseName);
|
||||
}
|
||||
|
||||
const AUDIO_EXTENSIONS = new Set(["mp3", "wav", "ogg", "flac", "aac", "m4a", "wma", "opus"]);
|
||||
export function isAudioFile(name: string): boolean {
|
||||
const ext = name.split(".").pop()?.toLowerCase() || "";
|
||||
return AUDIO_EXTENSIONS.has(ext);
|
||||
}
|
||||
|
||||
const VIDEO_EXTENSIONS = new Set(["mp4", "webm", "ogv", "mov", "avi", "mkv", "m4v"]);
|
||||
export function isVideoFile(name: string): boolean {
|
||||
const ext = name.split(".").pop()?.toLowerCase() || "";
|
||||
return VIDEO_EXTENSIONS.has(ext);
|
||||
}
|
||||
|
||||
const PDF_EXTENSIONS = new Set(["pdf"]);
|
||||
export function isPdfFile(name: string): boolean {
|
||||
const ext = name.split(".").pop()?.toLowerCase() || "";
|
||||
return PDF_EXTENSIONS.has(ext);
|
||||
}
|
||||
|
||||
const VECTOR_EXTENSIONS = new Set(["svg", "ai", "eps", "ps", "sketch", "fig", "xd", "gvdesign"]);
|
||||
function isVectorFile(name: string): boolean {
|
||||
const ext = name.split(".").pop()?.toLowerCase() || "";
|
||||
return VECTOR_EXTENSIONS.has(ext);
|
||||
}
|
||||
|
||||
const THREE_D_EXTENSIONS = new Set([
|
||||
"obj", "fbx", "gltf", "glb", "stl", "3mf", "step", "stp", "iges", "igs",
|
||||
"blend", "3ds", "dae", "usdz", "usd", "usda", "usdc", "ply", "wrl",
|
||||
"c4d", "max", "ma", "mb", "dwg", "dxf",
|
||||
]);
|
||||
function is3DFile(name: string): boolean {
|
||||
const ext = name.split(".").pop()?.toLowerCase() || "";
|
||||
return THREE_D_EXTENSIONS.has(ext);
|
||||
}
|
||||
|
||||
const EXECUTABLE_EXTENSIONS = new Set([
|
||||
"exe", "msi", "dmg", "app", "appimage", "deb", "rpm", "snap", "flatpak",
|
||||
"bat", "cmd", "com", "scr", "ps1", "apk", "ipa", "jar", "run",
|
||||
]);
|
||||
function isExecutableFile(name: string): boolean {
|
||||
const ext = name.split(".").pop()?.toLowerCase() || "";
|
||||
return EXECUTABLE_EXTENSIONS.has(ext);
|
||||
}
|
||||
|
||||
const ARCHIVE_EXTENSIONS = new Set([
|
||||
"zip", "rar", "7z", "tar", "gz", "bz2", "xz", "zst", "lz", "lzma",
|
||||
"tgz", "tbz2", "txz", "cab", "iso", "img",
|
||||
]);
|
||||
function isArchiveFile(name: string): boolean {
|
||||
const ext = name.split(".").pop()?.toLowerCase() || "";
|
||||
return ARCHIVE_EXTENSIONS.has(ext);
|
||||
}
|
||||
|
||||
const SPREADSHEET_EXTENSIONS = new Set(["xls", "xlsx", "ods", "numbers", "tsv"]);
|
||||
function isSpreadsheetFile(name: string): boolean {
|
||||
const ext = name.split(".").pop()?.toLowerCase() || "";
|
||||
return SPREADSHEET_EXTENSIONS.has(ext);
|
||||
}
|
||||
|
||||
const PRESENTATION_EXTENSIONS = new Set(["ppt", "pptx", "odp", "key"]);
|
||||
function isPresentationFile(name: string): boolean {
|
||||
const ext = name.split(".").pop()?.toLowerCase() || "";
|
||||
return PRESENTATION_EXTENSIONS.has(ext);
|
||||
}
|
||||
|
||||
const WORD_DOCUMENT_EXTENSIONS = new Set(["doc", "docx", "odt", "rtf"]);
|
||||
function isWordDocumentFile(name: string): boolean {
|
||||
const ext = name.split(".").pop()?.toLowerCase() || "";
|
||||
return WORD_DOCUMENT_EXTENSIONS.has(ext);
|
||||
}
|
||||
|
||||
const FONT_EXTENSIONS = new Set(["ttf", "otf", "woff", "woff2", "eot"]);
|
||||
function isFontFile(name: string): boolean {
|
||||
const ext = name.split(".").pop()?.toLowerCase() || "";
|
||||
return FONT_EXTENSIONS.has(ext);
|
||||
}
|
||||
|
||||
const DATABASE_EXTENSIONS = new Set(["db", "sqlite", "sqlite3", "mdb", "accdb"]);
|
||||
function isDatabaseFile(name: string): boolean {
|
||||
const ext = name.split(".").pop()?.toLowerCase() || "";
|
||||
return DATABASE_EXTENSIONS.has(ext);
|
||||
}
|
||||
|
||||
export function getFileIconByName(name: string, size: "sm" | "lg") {
|
||||
const cls = size === "sm" ? "w-5 h-5" : "w-10 h-10";
|
||||
if (isVectorFile(name)) return <PenTool className={`${cls} text-orange-500`} />;
|
||||
if (is3DFile(name)) return <Box className={`${cls} text-cyan-500`} />;
|
||||
if (isImageFile(name)) return <ImageIcon className={`${cls} text-emerald-500`} />;
|
||||
if (isAudioFile(name)) return <FileAudio className={`${cls} text-purple-500`} />;
|
||||
if (isVideoFile(name)) return <FileVideo className={`${cls} text-pink-500`} />;
|
||||
if (isArchiveFile(name)) return <FileArchive className={`${cls} text-amber-600`} />;
|
||||
if (isExecutableFile(name)) return <TerminalIcon className={`${cls} text-red-500`} />;
|
||||
if (isSpreadsheetFile(name)) return <FileSpreadsheet className={`${cls} text-green-600`} />;
|
||||
if (isPresentationFile(name)) return <Presentation className={`${cls} text-orange-600`} />;
|
||||
if (isWordDocumentFile(name)) return <FileText className={`${cls} text-blue-600`} />;
|
||||
if (isFontFile(name)) return <TypeIcon className={`${cls} text-indigo-500`} />;
|
||||
if (isDatabaseFile(name)) return <Database className={`${cls} text-slate-500`} />;
|
||||
if (isPdfFile(name)) return <FileText className={`${cls} text-red-600`} />;
|
||||
if (isTextFile(name)) return <FileCode className={`${cls} text-yellow-600`} />;
|
||||
return <File className={`${cls} text-muted-foreground`} />;
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { AlertCircle, Check, ChevronRight, Folder, Loader2, Search, Users, X } from "@/components/icons";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useFocusTrap } from "@/hooks/use-focus-trap";
|
||||
import { debug } from "@/lib/debug";
|
||||
import { cn, formatFileSize } from "@/lib/utils";
|
||||
import { getFileIconByName } from "@/components/files/file-icons";
|
||||
import { loadFilesSettings } from "@/components/files/files-settings-dialog";
|
||||
import type { IJMAPClient } from "@/lib/jmap/client-interface";
|
||||
import type { FileNode } from "@/lib/jmap/types";
|
||||
|
||||
// The level that holds what other people share with the user. JMAP ids are
|
||||
// base64url (shared ones add an "accountId:" prefix), so "#" never collides.
|
||||
const SHARED_WITH_ME = "#shared";
|
||||
|
||||
const MAX_SEARCH_RESULTS = 200;
|
||||
|
||||
interface FilePickerDialogProps {
|
||||
/** Client of the account whose files are listed. */
|
||||
client: IJMAPClient;
|
||||
onClose: () => void;
|
||||
/** Called with the chosen files, never folders. */
|
||||
onPick: (files: FileNode[]) => void;
|
||||
}
|
||||
|
||||
// A FileNode is a folder iff it has no content blob (see isFolder in the file store).
|
||||
function isFolder(node: FileNode): boolean {
|
||||
return node.blobId == null;
|
||||
}
|
||||
|
||||
function byFolderThenName(a: FileNode, b: FileNode): number {
|
||||
if (isFolder(a) !== isFolder(b)) return isFolder(a) ? -1 : 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lets the user pick files from the Files app, for example to attach them to a
|
||||
* message (#1179). Lists the whole tree once and browses it locally, so it
|
||||
* leaves the Files app's own location and selection alone.
|
||||
*/
|
||||
export function FilePickerDialog({ client, onClose, onPick }: FilePickerDialogProps) {
|
||||
const t = useTranslations("files");
|
||||
const tComposer = useTranslations("email_composer");
|
||||
const [nodes, setNodes] = useState<FileNode[] | null>(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const [trail, setTrail] = useState<{ id: string; name: string }[]>([]);
|
||||
const [query, setQuery] = useState("");
|
||||
const [selected, setSelected] = useState<Map<string, FileNode>>(() => new Map());
|
||||
const [showHidden] = useState(() => loadFilesSettings().showHiddenFiles);
|
||||
|
||||
const modalRef = useFocusTrap({ isActive: true, onEscape: onClose, restoreFocus: true });
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
client.listAllFileNodesAcrossAccounts().then(
|
||||
(list) => {
|
||||
if (!cancelled) setNodes(list);
|
||||
},
|
||||
(error) => {
|
||||
debug.error("Failed to list files for the picker:", error);
|
||||
if (!cancelled) setFailed(true);
|
||||
},
|
||||
);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [client, attempt]);
|
||||
|
||||
const retry = () => {
|
||||
setNodes(null);
|
||||
setFailed(false);
|
||||
setAttempt((n) => n + 1);
|
||||
};
|
||||
|
||||
const byId = useMemo(() => new Map((nodes ?? []).map((n) => [n.id, n])), [nodes]);
|
||||
|
||||
// Shared nodes whose parent the user can't see, as in the Files app's
|
||||
// "Shared with me".
|
||||
const sharedRoots = useMemo(
|
||||
() => (nodes ?? []).filter((n) => n.isShared && (n.parentId == null || !byId.has(n.parentId))),
|
||||
[nodes, byId],
|
||||
);
|
||||
|
||||
const here = trail.length > 0 ? trail[trail.length - 1].id : null;
|
||||
const needle = query.trim().toLowerCase();
|
||||
|
||||
const entries = useMemo(() => {
|
||||
if (!nodes) return [];
|
||||
if (needle) {
|
||||
return nodes
|
||||
.filter((n) => !isFolder(n) && n.name.toLowerCase().includes(needle))
|
||||
.filter((n) => showHidden || !n.name.startsWith("."))
|
||||
.sort(byFolderThenName)
|
||||
.slice(0, MAX_SEARCH_RESULTS);
|
||||
}
|
||||
const level = here === SHARED_WITH_ME
|
||||
? sharedRoots
|
||||
: nodes.filter((n) => (n.parentId ?? null) === here && (here !== null || !n.isShared));
|
||||
return level.filter((n) => showHidden || !n.name.startsWith(".")).sort(byFolderThenName);
|
||||
}, [nodes, needle, here, sharedRoots, showHidden]);
|
||||
|
||||
// Where a search hit lives, e.g. "userb@example.org / Projects".
|
||||
const folderPath = (node: FileNode): string => {
|
||||
const names: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
let parent = node.parentId ? byId.get(node.parentId) : undefined;
|
||||
while (parent && !seen.has(parent.id)) {
|
||||
seen.add(parent.id);
|
||||
names.unshift(parent.name);
|
||||
parent = parent.parentId ? byId.get(parent.parentId) : undefined;
|
||||
}
|
||||
if (node.isShared && node.accountName) names.unshift(node.accountName);
|
||||
return names.join(" / ");
|
||||
};
|
||||
|
||||
const openFolder = (id: string, name: string) => {
|
||||
setTrail((prev) => [...prev, { id, name }]);
|
||||
setQuery("");
|
||||
};
|
||||
|
||||
const toggle = (node: FileNode) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Map(prev);
|
||||
if (next.has(node.id)) next.delete(node.id);
|
||||
else next.set(node.id, node);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const folderRow = (key: string, name: string, icon: ReactNode, onOpen: () => void, detail?: string) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={onOpen}
|
||||
className="w-full flex items-center gap-3 px-3 py-2 rounded-md text-start text-sm hover:bg-muted transition-colors"
|
||||
>
|
||||
{icon}
|
||||
<span className="flex-1 min-w-0">
|
||||
<span className="block truncate">{name}</span>
|
||||
{detail && <span className="block truncate text-xs text-muted-foreground">{detail}</span>}
|
||||
</span>
|
||||
<ChevronRight className="w-4 h-4 shrink-0 text-muted-foreground rtl:rotate-180" />
|
||||
</button>
|
||||
);
|
||||
|
||||
const fileRow = (node: FileNode, detail?: string) => {
|
||||
const checked = selected.has(node.id);
|
||||
return (
|
||||
<button
|
||||
key={node.id}
|
||||
type="button"
|
||||
role="checkbox"
|
||||
aria-checked={checked}
|
||||
onClick={() => toggle(node)}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-3 px-3 py-2 rounded-md text-start text-sm transition-colors",
|
||||
checked ? "bg-primary/10" : "hover:bg-muted",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"w-4 h-4 rounded border flex items-center justify-center shrink-0",
|
||||
checked ? "bg-primary border-primary text-primary-foreground" : "border-border",
|
||||
)}
|
||||
>
|
||||
{checked && <Check className="w-3 h-3" />}
|
||||
</span>
|
||||
{getFileIconByName(node.name, "sm")}
|
||||
<span className="flex-1 min-w-0">
|
||||
<span className="block truncate">{node.name}</span>
|
||||
{detail && <span className="block truncate text-xs text-muted-foreground">{detail}</span>}
|
||||
</span>
|
||||
<span className="shrink-0 text-xs text-muted-foreground tabular-nums">{formatFileSize(node.size)}</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const sharedBy = (node: FileNode) =>
|
||||
here === SHARED_WITH_ME && node.accountName ? t("shared_by", { name: node.accountName }) : undefined;
|
||||
|
||||
let body: ReactNode;
|
||||
if (failed) {
|
||||
body = (
|
||||
<div className="flex flex-col items-center gap-3 py-10 text-sm text-muted-foreground">
|
||||
<AlertCircle className="w-6 h-6" />
|
||||
<p>{tComposer("files_picker.load_failed")}</p>
|
||||
<Button variant="outline" size="sm" onClick={retry}>{t("retry")}</Button>
|
||||
</div>
|
||||
);
|
||||
} else if (!nodes) {
|
||||
body = (
|
||||
<div className="flex justify-center py-10 text-muted-foreground">
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
} else if (entries.length === 0 && !(here === null && !needle && sharedRoots.length > 0)) {
|
||||
body = (
|
||||
<p className="py-10 text-center text-sm text-muted-foreground">
|
||||
{needle ? t("no_results") : here === null ? t("empty_state_title") : tComposer("files_picker.empty_folder")}
|
||||
</p>
|
||||
);
|
||||
} else {
|
||||
body = (
|
||||
<div className="space-y-0.5">
|
||||
{here === null && !needle && sharedRoots.length > 0 && folderRow(
|
||||
SHARED_WITH_ME,
|
||||
t("shared_with_me"),
|
||||
<Users className="w-5 h-5 shrink-0 text-blue-500" />,
|
||||
() => openFolder(SHARED_WITH_ME, t("shared_with_me")),
|
||||
)}
|
||||
{entries.map((node) =>
|
||||
isFolder(node)
|
||||
? folderRow(
|
||||
node.id,
|
||||
node.name,
|
||||
<Folder className="w-5 h-5 shrink-0 text-blue-500" />,
|
||||
() => openFolder(node.id, node.name),
|
||||
sharedBy(node),
|
||||
)
|
||||
: fileRow(node, needle ? folderPath(node) : sharedBy(node)),
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4 animate-in fade-in duration-150">
|
||||
<div
|
||||
ref={modalRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="file-picker-title"
|
||||
className="bg-background border border-border rounded-lg shadow-xl w-full max-w-lg max-h-[80vh] flex flex-col overflow-hidden animate-in zoom-in-95 duration-200"
|
||||
data-testid="file-picker-dialog"
|
||||
>
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
||||
<h2 id="file-picker-title" className="text-sm font-semibold text-foreground">
|
||||
{tComposer("attach_from_files")}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label={t("cancel")}
|
||||
className="p-1.5 rounded-md hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-2 border-b border-border space-y-2">
|
||||
<div className="relative">
|
||||
<Search className="absolute start-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={t("search_placeholder")}
|
||||
className="ps-9 h-9"
|
||||
/>
|
||||
</div>
|
||||
{!needle && (
|
||||
<nav className="flex items-center gap-1 text-sm min-w-0 overflow-x-auto" aria-label={t("path")}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTrail([])}
|
||||
className={cn(
|
||||
"shrink-0 px-1.5 py-0.5 rounded hover:bg-muted",
|
||||
trail.length === 0 ? "font-medium text-foreground" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{t("breadcrumb_root")}
|
||||
</button>
|
||||
{trail.map((crumb, i) => (
|
||||
<span key={crumb.id} className="flex items-center gap-1 min-w-0">
|
||||
<ChevronRight className="w-3.5 h-3.5 shrink-0 text-muted-foreground rtl:rotate-180" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTrail(trail.slice(0, i + 1))}
|
||||
className={cn(
|
||||
"truncate max-w-[10rem] px-1.5 py-0.5 rounded hover:bg-muted",
|
||||
i === trail.length - 1 ? "font-medium text-foreground" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{crumb.name}
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</nav>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-[12rem] overflow-y-auto p-2">{body}</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 px-4 py-3 border-t border-border">
|
||||
<Button variant="outline" onClick={onClose}>{t("cancel")}</Button>
|
||||
<Button
|
||||
onClick={() => onPick([...selected.values()])}
|
||||
disabled={selected.size === 0}
|
||||
data-testid="file-picker-attach"
|
||||
>
|
||||
{tComposer("files_picker.attach", { count: selected.size })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -38,6 +38,7 @@ import { consumePendingDeepLinkEntry, subscribePendingDeepLink } from "@/lib/dee
|
||||
import { useDeepLinkUrl } from "@/hooks/use-deep-link-url";
|
||||
import { useProInterfaceActive } from "@/components/pro/pro-interface-redirect";
|
||||
import { useLiteLinkSegments } from "@/hooks/use-lite-link-segments";
|
||||
import { useDocumentTitle } from "@/hooks/use-document-title";
|
||||
|
||||
export interface FilesAppProps {
|
||||
/** Path segments after `/files` - the folder path, one segment per level. */
|
||||
@@ -49,6 +50,8 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
|
||||
const linkSegments = useLiteLinkSegments('files', routeSegments);
|
||||
const router = useRouter();
|
||||
const t = useTranslations("files");
|
||||
const tSidebar = useTranslations("sidebar");
|
||||
useDocumentTitle(tSidebar("files"));
|
||||
const tDeepLink = useTranslations("deep_link");
|
||||
const filesEnabled = usePolicyStore((s) => s.isFeatureEnabled('filesEnabled'));
|
||||
const { isAuthenticated, logout, checkAuth, isLoading: authLoading, client } = useAuthStore();
|
||||
|
||||
@@ -11,12 +11,15 @@ import {
|
||||
Share2,
|
||||
} from "@/components/icons";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ContextMenu, ContextMenuItem } from "@/components/ui/context-menu";
|
||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||
import { useFileStore, type FileResource } from "@/stores/file-store";
|
||||
|
||||
interface FolderNode {
|
||||
id: string;
|
||||
name: string;
|
||||
path: string;
|
||||
resource: FileResource;
|
||||
}
|
||||
|
||||
interface FolderTreeSidebarProps {
|
||||
@@ -25,13 +28,17 @@ interface FolderTreeSidebarProps {
|
||||
listByParentId: (parentId: string | null) => Promise<FileResource[]>;
|
||||
width?: number;
|
||||
isResizing?: boolean;
|
||||
/** Whether the viewer may share this folder; gates the tree's context menu. */
|
||||
canShare?: (resource: FileResource) => boolean;
|
||||
onShare?: (resource: FileResource) => void;
|
||||
}
|
||||
|
||||
export function FolderTreeSidebar({ currentPath, onNavigate, listByParentId, width = 256, isResizing }: FolderTreeSidebarProps) {
|
||||
export function FolderTreeSidebar({ currentPath, onNavigate, listByParentId, width = 256, isResizing, canShare, onShare }: FolderTreeSidebarProps) {
|
||||
const t = useTranslations("files");
|
||||
const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<FileResource>();
|
||||
const client = useFileStore(s => s.client);
|
||||
// Loaded by the file browser, which shows them in either layout.
|
||||
const sharedRoots = useFileStore(s => s.sharedRoots);
|
||||
const loadSharedRoots = useFileStore(s => s.loadSharedRoots);
|
||||
const [rootChildren, setRootChildren] = useState<FolderNode[] | null>(null);
|
||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set(["root"]));
|
||||
const [loadingIds, setLoadingIds] = useState<Set<string>>(new Set());
|
||||
@@ -58,6 +65,7 @@ export function FolderTreeSidebar({ currentPath, onNavigate, listByParentId, wid
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
path: folderPath,
|
||||
resource: r,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -84,8 +92,6 @@ export function FolderTreeSidebar({ currentPath, onNavigate, listByParentId, wid
|
||||
useEffect(() => {
|
||||
if (client) {
|
||||
loadChildren(null, "/");
|
||||
// Discover folders shared with the user by other principals.
|
||||
loadSharedRoots();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [client]);
|
||||
@@ -132,6 +138,14 @@ export function FolderTreeSidebar({ currentPath, onNavigate, listByParentId, wid
|
||||
onNavigate(path, id);
|
||||
}, [onNavigate]);
|
||||
|
||||
// Folders are not listed in the file list in this layout, so the tree is
|
||||
// the only place to reach a folder's actions. Leave the browser menu alone
|
||||
// when there is nothing to offer.
|
||||
const handleFolderContextMenu = useCallback((e: React.MouseEvent, resource: FileResource) => {
|
||||
if (!onShare || !canShare?.(resource)) return;
|
||||
openContextMenu(e, resource);
|
||||
}, [canShare, onShare, openContextMenu]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -182,6 +196,7 @@ export function FolderTreeSidebar({ currentPath, onNavigate, listByParentId, wid
|
||||
onToggleExpand={handleToggleExpand}
|
||||
onFolderClick={handleFolderClick}
|
||||
onLoadChildren={loadChildren}
|
||||
onContextMenu={handleFolderContextMenu}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
@@ -220,6 +235,19 @@ export function FolderTreeSidebar({ currentPath, onNavigate, listByParentId, wid
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{onShare && (
|
||||
<ContextMenu ref={menuRef} isOpen={contextMenu.isOpen} position={contextMenu.position} onClose={closeContextMenu}>
|
||||
<ContextMenuItem
|
||||
icon={Share2}
|
||||
label={t("share")}
|
||||
onClick={() => {
|
||||
if (contextMenu.data) onShare(contextMenu.data);
|
||||
closeContextMenu();
|
||||
}}
|
||||
/>
|
||||
</ContextMenu>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -234,6 +262,7 @@ function FolderTreeItem({
|
||||
onToggleExpand,
|
||||
onFolderClick,
|
||||
onLoadChildren,
|
||||
onContextMenu,
|
||||
}: {
|
||||
node: FolderNode;
|
||||
depth: number;
|
||||
@@ -244,6 +273,7 @@ function FolderTreeItem({
|
||||
onToggleExpand: (folderId: string, folderPath: string) => void;
|
||||
onFolderClick: (path: string, id: string | null) => void;
|
||||
onLoadChildren: (parentId: string, parentPath: string) => Promise<void>;
|
||||
onContextMenu: (e: React.MouseEvent, resource: FileResource) => void;
|
||||
}) {
|
||||
const isExpanded = expandedIds.has(node.id);
|
||||
const isSelected = currentPath === node.path;
|
||||
@@ -272,6 +302,7 @@ function FolderTreeItem({
|
||||
: "hover:bg-muted text-foreground",
|
||||
depth === 0 && "font-medium"
|
||||
)}
|
||||
onContextMenu={(e) => onContextMenu(e, node.resource)}
|
||||
>
|
||||
{/* Expand/collapse chevron */}
|
||||
{hasChildren ? (
|
||||
@@ -327,6 +358,7 @@ function FolderTreeItem({
|
||||
onToggleExpand={onToggleExpand}
|
||||
onFolderClick={onFolderClick}
|
||||
onLoadChildren={onLoadChildren}
|
||||
onContextMenu={onContextMenu}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, expect, it, vi } from 'vitest';
|
||||
import React from 'react';
|
||||
import type { FilterRule } from '@/lib/jmap/sieve-types';
|
||||
import { getBrowserTimeZone } from '@/lib/timezone';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { FilterRuleModal } from '../filter-rule-modal';
|
||||
|
||||
vi.mock('@/stores/toast-store', () => ({ toast: { error: vi.fn(), success: vi.fn(), info: vi.fn() } }));
|
||||
|
||||
// The browser runs on Berlin time, the user's setting says New York. Set
|
||||
// before the imports run: the app reads the browser zone once and keeps it.
|
||||
const ORIGINAL_TZ = vi.hoisted(() => {
|
||||
const original = process.env.TZ;
|
||||
process.env.TZ = 'Europe/Berlin';
|
||||
return original;
|
||||
});
|
||||
beforeAll(() => {
|
||||
expect(getBrowserTimeZone()).toBe('Europe/Berlin');
|
||||
});
|
||||
afterAll(() => {
|
||||
if (ORIGINAL_TZ === undefined) delete process.env.TZ;
|
||||
else process.env.TZ = ORIGINAL_TZ;
|
||||
});
|
||||
|
||||
const initialSettings = useSettingsStore.getState();
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({ timeZone: 'America/New_York' });
|
||||
});
|
||||
afterEach(() => {
|
||||
useSettingsStore.setState(initialSettings, true);
|
||||
});
|
||||
|
||||
const rule: FilterRule = {
|
||||
id: 'r1',
|
||||
name: 'Urlaub',
|
||||
enabled: true,
|
||||
matchType: 'all',
|
||||
conditions: [{ field: 'from', comparator: 'contains', value: '@' }],
|
||||
actions: [{ type: 'forward', value: 'kollege@example.com' }],
|
||||
stopProcessing: false,
|
||||
};
|
||||
|
||||
it('reads a time the browser zone skips at its DST change on the clock of the user zone', () => {
|
||||
const onSave = vi.fn();
|
||||
render(<FilterRuleModal mailboxes={[]} rule={rule} periodsSupported onSave={onSave} onClose={vi.fn()} />);
|
||||
fireEvent.click(screen.getByLabelText('period_toggle'));
|
||||
// 02:30 on 29 March does not exist in Berlin; in New York it is 02:30 EDT.
|
||||
fireEvent.change(screen.getByLabelText('period_end'), { target: { value: '2026-03-29T02:30' } });
|
||||
fireEvent.click(screen.getByText('save'));
|
||||
expect(onSave).toHaveBeenCalledTimes(1);
|
||||
expect((onSave.mock.calls[0][0] as FilterRule).activeUntil).toBe('2026-03-29T06:30:00.000Z');
|
||||
});
|
||||
@@ -0,0 +1,263 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import React from 'react';
|
||||
import type { FilterRule } from '@/lib/jmap/sieve-types';
|
||||
import { getBrowserTimeZone } from '@/lib/timezone';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { FilterRuleModal } from '../filter-rule-modal';
|
||||
import { RuleSummary, VisualRuleSummary } from '@/components/settings/filter-settings';
|
||||
|
||||
const toastError = vi.hoisted(() => vi.fn());
|
||||
vi.mock('@/stores/toast-store', () => ({ toast: { error: toastError, success: vi.fn(), info: vi.fn() } }));
|
||||
|
||||
const rule = (extra: Partial<FilterRule> = {}): FilterRule => ({
|
||||
id: 'r1',
|
||||
name: 'Urlaub',
|
||||
enabled: true,
|
||||
matchType: 'all',
|
||||
conditions: [{ field: 'from', comparator: 'contains', value: '@' }],
|
||||
actions: [{ type: 'forward', value: 'kollege@example.com' }, { type: 'discard' }],
|
||||
stopProcessing: false,
|
||||
...extra,
|
||||
});
|
||||
|
||||
// The browser reports UTC while the user's time zone setting says Berlin, so
|
||||
// the times show which of the two the dialog and the list follow. Set before
|
||||
// the imports run: the app reads the browser zone once and keeps it.
|
||||
const ORIGINAL_TZ = vi.hoisted(() => {
|
||||
const original = process.env.TZ;
|
||||
process.env.TZ = 'UTC';
|
||||
return original;
|
||||
});
|
||||
beforeAll(() => {
|
||||
expect(getBrowserTimeZone()).toBe('UTC');
|
||||
});
|
||||
afterAll(() => {
|
||||
if (ORIGINAL_TZ === undefined) delete process.env.TZ;
|
||||
else process.env.TZ = ORIGINAL_TZ;
|
||||
});
|
||||
|
||||
const initialSettings = useSettingsStore.getState();
|
||||
beforeEach(() => {
|
||||
toastError.mockClear();
|
||||
useSettingsStore.setState({ timeZone: 'Europe/Berlin', dateLocale: 'en-GB', timeFormat: '24h' });
|
||||
});
|
||||
afterEach(() => {
|
||||
useSettingsStore.setState(initialSettings, true);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
function openModal(props: Partial<React.ComponentProps<typeof FilterRuleModal>> = {}) {
|
||||
const onSave = vi.fn();
|
||||
render(<FilterRuleModal mailboxes={[]} onSave={onSave} onClose={vi.fn()} {...props} />);
|
||||
return { onSave, saved: () => onSave.mock.calls[0][0] as FilterRule };
|
||||
}
|
||||
|
||||
const toggle = () => screen.getByLabelText('period_toggle') as HTMLInputElement;
|
||||
const startInput = () => screen.getByLabelText('period_start') as HTMLInputElement;
|
||||
const endInput = () => screen.getByLabelText('period_end') as HTMLInputElement;
|
||||
const save = () => fireEvent.click(screen.getByText('save'));
|
||||
|
||||
describe('filter rule period (dialog)', () => {
|
||||
it('is offered only where the server supports it', () => {
|
||||
openModal({ rule: rule() });
|
||||
expect(screen.queryByLabelText('period_toggle')).toBeNull();
|
||||
});
|
||||
|
||||
it('saves the period as the UTC moments of the times entered in the user time zone', () => {
|
||||
const { onSave, saved } = openModal({ rule: rule(), periodsSupported: true });
|
||||
fireEvent.click(toggle());
|
||||
expect(screen.getByText('period_hint')).toBeInTheDocument();
|
||||
// Berlin: CEST (+02:00) on 5 Oct, CET (+01:00) after the switch on 25 Oct.
|
||||
fireEvent.change(startInput(), { target: { value: '2026-10-05T08:00' } });
|
||||
fireEvent.change(endInput(), { target: { value: '2026-10-26T18:00' } });
|
||||
save();
|
||||
expect(onSave).toHaveBeenCalledTimes(1);
|
||||
expect(saved()).toMatchObject({
|
||||
activeFrom: '2026-10-05T06:00:00.000Z',
|
||||
activeUntil: '2026-10-26T17:00:00.000Z',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the start open', () => {
|
||||
const { saved } = openModal({ rule: rule(), periodsSupported: true });
|
||||
fireEvent.click(toggle());
|
||||
fireEvent.change(endInput(), { target: { value: '2026-10-16T18:00' } });
|
||||
save();
|
||||
expect(saved().activeUntil).toBe('2026-10-16T16:00:00.000Z');
|
||||
expect(saved().activeFrom).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps the end open', () => {
|
||||
const { saved } = openModal({ rule: rule(), periodsSupported: true });
|
||||
fireEvent.click(toggle());
|
||||
fireEvent.change(startInput(), { target: { value: '2026-10-05T08:00' } });
|
||||
save();
|
||||
expect(saved().activeFrom).toBe('2026-10-05T06:00:00.000Z');
|
||||
expect(saved().activeUntil).toBeUndefined();
|
||||
});
|
||||
|
||||
it('asks for a start or an end when the period is on but empty', () => {
|
||||
const { onSave } = openModal({ rule: rule(), periodsSupported: true });
|
||||
fireEvent.click(toggle());
|
||||
save();
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
expect(toastError).toHaveBeenCalledWith('validation_period_empty');
|
||||
});
|
||||
|
||||
it('will not take a date without its time for an open end', () => {
|
||||
const { onSave } = openModal({ rule: rule(), periodsSupported: true });
|
||||
fireEvent.click(toggle());
|
||||
fireEvent.change(startInput(), { target: { value: '2026-10-05T08:00' } });
|
||||
// A browser reads a field it holds half filled in as empty, often without
|
||||
// an event; jsdom never holds one, so the field says so here.
|
||||
Object.defineProperty(endInput(), 'validity', { value: { badInput: true }, configurable: true });
|
||||
save();
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
expect(toastError).toHaveBeenCalledWith('validation_period_invalid');
|
||||
});
|
||||
|
||||
it('refuses a year it cannot store, rather than leave that end open', () => {
|
||||
const { onSave } = openModal({ rule: rule(), periodsSupported: true });
|
||||
fireEvent.click(toggle());
|
||||
fireEvent.change(startInput(), { target: { value: '2026-10-05T08:00' } });
|
||||
fireEvent.change(endInput(), { target: { value: '20266-10-16T18:00' } });
|
||||
save();
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
expect(toastError).toHaveBeenCalledWith('validation_period_invalid');
|
||||
|
||||
// The last minute of 9999 in New York is year 10000 in UTC already.
|
||||
toastError.mockClear();
|
||||
useSettingsStore.setState({ timeZone: 'America/New_York' });
|
||||
fireEvent.change(endInput(), { target: { value: '9999-12-31T23:59' } });
|
||||
save();
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
expect(toastError).toHaveBeenCalledWith('validation_period_invalid');
|
||||
});
|
||||
|
||||
it('offers no year past 9999', () => {
|
||||
openModal({ rule: rule(), periodsSupported: true });
|
||||
fireEvent.click(toggle());
|
||||
expect(startInput().max).toBe('9999-12-31T23:59');
|
||||
expect(endInput().max).toBe('9999-12-31T23:59');
|
||||
});
|
||||
|
||||
it('refuses an end that is not after the start', () => {
|
||||
const { onSave } = openModal({ rule: rule(), periodsSupported: true });
|
||||
fireEvent.click(toggle());
|
||||
fireEvent.change(startInput(), { target: { value: '2026-10-16T18:00' } });
|
||||
fireEvent.change(endInput(), { target: { value: '2026-10-16T18:00' } });
|
||||
save();
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
expect(toastError).toHaveBeenCalledWith('validation_period_order');
|
||||
});
|
||||
|
||||
it('shows a saved period in the user time zone', () => {
|
||||
openModal({
|
||||
rule: rule({ activeFrom: '2026-10-05T06:00:00.000Z', activeUntil: '2026-10-26T17:00:00.000Z' }),
|
||||
periodsSupported: true,
|
||||
});
|
||||
expect(toggle().checked).toBe(true);
|
||||
expect(startInput().value).toBe('2026-10-05T08:00');
|
||||
expect(endInput().value).toBe('2026-10-26T18:00');
|
||||
});
|
||||
|
||||
it('keeps a saved boundary exactly while it is left as shown', () => {
|
||||
// 08:00:30, shown as 08:00; and 02:30 CEST on the night the clocks go
|
||||
// back, when 02:30 comes twice.
|
||||
const period = { activeFrom: '2026-10-05T06:00:30.000Z', activeUntil: '2026-10-25T00:30:00.000Z' };
|
||||
const { saved } = openModal({ rule: rule(period), periodsSupported: true });
|
||||
expect(startInput().value).toBe('2026-10-05T08:00');
|
||||
expect(endInput().value).toBe('2026-10-25T02:30');
|
||||
save();
|
||||
expect(saved()).toMatchObject(period);
|
||||
});
|
||||
|
||||
it('reads a changed boundary from the input and keeps the other one', () => {
|
||||
const { saved } = openModal({
|
||||
rule: rule({ activeFrom: '2026-10-05T06:00:30.000Z', activeUntil: '2026-10-16T16:00:00.000Z' }),
|
||||
periodsSupported: true,
|
||||
});
|
||||
fireEvent.change(endInput(), { target: { value: '2026-10-18T12:00' } });
|
||||
save();
|
||||
expect(saved()).toMatchObject({
|
||||
activeFrom: '2026-10-05T06:00:30.000Z',
|
||||
activeUntil: '2026-10-18T10:00:00.000Z',
|
||||
});
|
||||
});
|
||||
|
||||
it('drops the period when it is switched off', () => {
|
||||
const { saved } = openModal({ rule: rule({ activeUntil: '2026-10-16T16:00:00.000Z' }), periodsSupported: true });
|
||||
fireEvent.click(toggle());
|
||||
save();
|
||||
expect(saved().activeFrom).toBeUndefined();
|
||||
expect(saved().activeUntil).toBeUndefined();
|
||||
});
|
||||
|
||||
it('shows a saved period where the server does not offer periods, so it can be removed', () => {
|
||||
const { saved } = openModal({ rule: rule({ activeUntil: '2026-10-16T16:00:00.000Z' }) });
|
||||
expect(toggle().checked).toBe(true);
|
||||
fireEvent.click(toggle());
|
||||
// The switch stays where it was, so a slip can be undone.
|
||||
expect(toggle().checked).toBe(false);
|
||||
save();
|
||||
expect(saved().activeUntil).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not run a rule with a period on existing messages', () => {
|
||||
openModal({
|
||||
initialRule: rule({ actions: [{ type: 'mark_read' }] }),
|
||||
offerApplyToExisting: true,
|
||||
periodsSupported: true,
|
||||
});
|
||||
const applyExisting = screen.getByLabelText('apply_existing') as HTMLInputElement;
|
||||
expect(applyExisting.disabled).toBe(false);
|
||||
fireEvent.click(toggle());
|
||||
expect(applyExisting.disabled).toBe(true);
|
||||
expect(screen.getByText('apply_existing_period')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('filter rule period (list)', () => {
|
||||
// 14:00 in Berlin
|
||||
beforeEach(() => vi.useFakeTimers({ now: new Date('2026-10-10T12:00:00.000Z'), toFake: ['Date'] }));
|
||||
|
||||
const periods = [
|
||||
['scheduled', { activeFrom: '2026-10-12T06:00:00.000Z', activeUntil: '2026-10-16T16:00:00.000Z' }, '12 Oct 2026, 08:00 – 16 Oct 2026, 18:00'],
|
||||
['active', { activeFrom: '2026-10-05T06:00:00.000Z', activeUntil: '2026-10-16T16:00:00.000Z' }, '5 Oct 2026, 08:00 – 16 Oct 2026, 18:00'],
|
||||
['expired', { activeFrom: '2026-10-01T06:00:00.000Z', activeUntil: '2026-10-09T16:00:00.000Z' }, '1 Oct 2026, 08:00 – 9 Oct 2026, 18:00'],
|
||||
['active', { activeUntil: '2026-10-16T16:00:00.000Z' }, '… – 16 Oct 2026, 18:00'],
|
||||
['scheduled', { activeFrom: '2026-10-12T06:00:00.000Z' }, '12 Oct 2026, 08:00 – …'],
|
||||
] as const;
|
||||
|
||||
it.each(periods)('shows a %s period in the detailed view', (status, period, range) => {
|
||||
render(<VisualRuleSummary rule={rule(period)} />);
|
||||
expect(screen.getByText('period_title')).toBeInTheDocument();
|
||||
expect(screen.getByText(range)).toBeInTheDocument();
|
||||
expect(screen.getByText(`(period_status_${status})`)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each(periods)('shows a %s period in the compact view', (status, period, range) => {
|
||||
render(<RuleSummary rule={rule(period)} />);
|
||||
expect(screen.getByText(`· ${range} (period_status_${status})`)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the period of a rule that is off without saying it is active', () => {
|
||||
const off = rule({ enabled: false, activeFrom: '2026-10-05T06:00:00.000Z', activeUntil: '2026-10-16T16:00:00.000Z' });
|
||||
const { unmount } = render(<VisualRuleSummary rule={off} />);
|
||||
expect(screen.getByText('5 Oct 2026, 08:00 – 16 Oct 2026, 18:00')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/period_status_/)).toBeNull();
|
||||
unmount();
|
||||
render(<RuleSummary rule={off} />);
|
||||
expect(screen.getByText('· 5 Oct 2026, 08:00 – 16 Oct 2026, 18:00')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/period_status_/)).toBeNull();
|
||||
});
|
||||
|
||||
it('shows no period for a rule without one', () => {
|
||||
const { unmount } = render(<VisualRuleSummary rule={rule()} />);
|
||||
expect(screen.queryByText('period_title')).toBeNull();
|
||||
unmount();
|
||||
const { container } = render(<RuleSummary rule={rule()} />);
|
||||
expect(container.textContent).not.toContain('·');
|
||||
});
|
||||
});
|
||||
@@ -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 type { FilterRule, VacationForward } from '@/lib/jmap/sieve-types';
|
||||
import { generateScript } from '@/lib/sieve/generator';
|
||||
import { mockStalwartAccount } from '@/lib/filters/__tests__/sieve-mock';
|
||||
import { useQuickRuleStore } from '@/stores/quick-rule-store';
|
||||
import { QuickRuleHost } from '../quick-rule-host';
|
||||
|
||||
vi.mock('@/stores/toast-store', () => ({ toast: { error: vi.fn(), success: vi.fn(), info: vi.fn() } }));
|
||||
|
||||
const EXTENSIONS = ['fileinto', 'imap4flags', 'include', 'envelope', 'copy', 'date', 'relational', 'spamtestplus', 'comparator-i;ascii-numeric'];
|
||||
|
||||
const forwardRule = (id: string): FilterRule => ({
|
||||
id,
|
||||
name: `Rule ${id}`,
|
||||
enabled: true,
|
||||
matchType: 'all',
|
||||
conditions: [{ field: 'from', comparator: 'contains', value: 'chef@example.com' }],
|
||||
actions: [{ type: 'forward', value: 'kollege@example.com' }],
|
||||
stopProcessing: false,
|
||||
});
|
||||
|
||||
/** An account that allows one redirect per message, as Stalwart does. */
|
||||
const account = (rules: FilterRule[], vacationForward?: VacationForward) => mockStalwartAccount('b', [
|
||||
{ name: 'filters', content: generateScript(rules, undefined, { extensions: EXTENSIONS, vacationForward }), isActive: true },
|
||||
], EXTENSIONS);
|
||||
|
||||
function openEditor(server: ReturnType<typeof account>, mode: 'prefill' | 'edit', rule: FilterRule) {
|
||||
useQuickRuleStore.getState().openEditor({
|
||||
mode,
|
||||
target: {
|
||||
client: server.client,
|
||||
clientAccountId: null,
|
||||
key: 'login/b',
|
||||
accountId: 'b',
|
||||
sieveAccountId: 'b',
|
||||
shared: false,
|
||||
supportsSieve: true,
|
||||
mailboxes: [],
|
||||
},
|
||||
rule,
|
||||
suggestions: [],
|
||||
sourceMailboxId: null,
|
||||
});
|
||||
render(<QuickRuleHost />);
|
||||
}
|
||||
|
||||
/** Once the account's script has been read and counted. */
|
||||
async function counted(server: ReturnType<typeof account>) {
|
||||
await waitFor(() => expect(server.client.getSieveScriptContent).toHaveBeenCalled());
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
useQuickRuleStore.getState().closeEditor();
|
||||
});
|
||||
|
||||
describe('a rule made from a message, against the redirect limit', () => {
|
||||
it('counts the vacation forwarding that keeps a copy', async () => {
|
||||
const server = account([], { enabled: true, to: 'vertretung@example.com', keepCopy: true });
|
||||
openEditor(server, 'prefill', forwardRule('new'));
|
||||
expect(await screen.findByText('forward_limit')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('leaves out vacation forwarding without a copy: it stops the message before any rule', async () => {
|
||||
const server = account([], { enabled: true, to: 'vertretung@example.com', keepCopy: false });
|
||||
openEditor(server, 'prefill', forwardRule('new'));
|
||||
await counted(server);
|
||||
expect(screen.queryByText('forward_limit')).toBeNull();
|
||||
});
|
||||
|
||||
it('leaves out the forwards of the rule being edited', async () => {
|
||||
const edited = forwardRule('edited');
|
||||
const server = account([edited]);
|
||||
openEditor(server, 'edit', edited);
|
||||
await counted(server);
|
||||
expect(screen.queryByText('forward_limit')).toBeNull();
|
||||
});
|
||||
|
||||
it('counts the forwards of the other rules', async () => {
|
||||
const edited = forwardRule('edited');
|
||||
const server = account([edited, forwardRule('other')]);
|
||||
openEditor(server, 'edit', edited);
|
||||
expect(await screen.findByText('forward_limit')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('leaves out a rule above that forwards and stops: its messages never get here', async () => {
|
||||
const edited = forwardRule('edited');
|
||||
const server = account([{ ...forwardRule('above'), stopProcessing: true }, edited]);
|
||||
openEditor(server, 'edit', edited);
|
||||
await counted(server);
|
||||
expect(screen.queryByText('forward_limit')).toBeNull();
|
||||
});
|
||||
|
||||
it('puts a new rule first, ahead of a rule that forwards and stops', async () => {
|
||||
const server = account([{ ...forwardRule('below'), stopProcessing: true }]);
|
||||
openEditor(server, 'prefill', forwardRule('new'));
|
||||
// A message both match gets the new rule's forward first ...
|
||||
expect(await screen.findByText('forward_limit')).toBeInTheDocument();
|
||||
// ... unless the new rule stops.
|
||||
fireEvent.click(screen.getByLabelText('stop_processing'));
|
||||
expect(screen.queryByText('forward_limit')).toBeNull();
|
||||
});
|
||||
|
||||
it('takes an edited rule no longer there for one saved first again', async () => {
|
||||
const server = account([{ ...forwardRule('below'), stopProcessing: true }]);
|
||||
openEditor(server, 'edit', forwardRule('gone'));
|
||||
expect(await screen.findByText('forward_limit')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import { useState, useCallback, useMemo, useRef } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -20,6 +20,8 @@ import { buildMailboxTree, flattenMailboxTree, type MailboxNode, generateUUID, c
|
||||
import type { RuleSuggestion } from "@/lib/filters/quick-rules";
|
||||
import { retroactiveSupport } from "@/lib/filters/retroactive";
|
||||
import { ruleForwards, ruleStops } from "@/lib/filters/forward-limit";
|
||||
import { hasPeriod, isPeriodBoundary } from "@/lib/sieve/period";
|
||||
import { fromWallClock, getEffectiveTimeZone, getWallClock } from "@/lib/timezone";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useKeywordFormat } from "@/hooks/use-keyword-format";
|
||||
|
||||
@@ -44,6 +46,8 @@ interface FilterRuleModalProps {
|
||||
*/
|
||||
forwardsBefore?: number;
|
||||
forwardsAfter?: number;
|
||||
/** The server can confine a rule to a period (Sieve "date" and "relational"). */
|
||||
periodsSupported?: boolean;
|
||||
onSave: (rule: FilterRule, options?: { applyToExisting: boolean }) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
@@ -115,6 +119,37 @@ function makeEmptyAction(): FilterAction {
|
||||
return { type: "move", value: "" };
|
||||
}
|
||||
|
||||
// A period boundary as the "yyyy-mm-ddThh:mm" a datetime-local input shows,
|
||||
// read on the clock of the user's time zone - the one dates are shown in.
|
||||
function toPeriodInput(boundary: string | undefined): string {
|
||||
if (!isPeriodBoundary(boundary)) return "";
|
||||
const w = getWallClock(new Date(boundary), getEffectiveTimeZone());
|
||||
const pad = (n: number, len = 2) => String(n).padStart(len, "0");
|
||||
return `${pad(w.year, 4)}-${pad(w.month)}-${pad(w.day)}T${pad(w.hour)}:${pad(w.minute)}`;
|
||||
}
|
||||
|
||||
// The latest moment the period fields offer: a later year has no room in
|
||||
// the four digits a stored boundary has.
|
||||
const PERIOD_INPUT_MAX = "9999-12-31T23:59";
|
||||
|
||||
// The input's wall-clock time in the user's time zone, as the UTC moment the
|
||||
// rule stores: undefined for an empty field, null for one that holds no
|
||||
// usable moment (a five-digit year). A boundary left as the dialog showed it
|
||||
// is kept exactly: the input shows whole minutes, so reading it back would
|
||||
// drop a saved second, or pick the other one of the two moments in the hour
|
||||
// a DST switch repeats.
|
||||
function fromPeriodInput(value: string, saved: string | undefined): string | null | undefined {
|
||||
if (!value) return undefined;
|
||||
if (saved !== undefined && value === toPeriodInput(saved)) return saved;
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?$/.exec(value);
|
||||
if (!m) return null;
|
||||
const [year, month, day, hour, minute, second] = m.slice(1).map((part) => Number(part ?? 0));
|
||||
const moment = fromWallClock({ year, month, day, hour, minute, second }, getEffectiveTimeZone());
|
||||
if (Number.isNaN(moment.getTime())) return null;
|
||||
const boundary = moment.toISOString();
|
||||
return isPeriodBoundary(boundary) ? boundary : null;
|
||||
}
|
||||
|
||||
export function FilterRuleModal({
|
||||
rule,
|
||||
initialRule,
|
||||
@@ -124,6 +159,7 @@ export function FilterRuleModal({
|
||||
maxRedirects,
|
||||
forwardsBefore = 0,
|
||||
forwardsAfter = 0,
|
||||
periodsSupported = false,
|
||||
onSave,
|
||||
onClose,
|
||||
}: FilterRuleModalProps) {
|
||||
@@ -145,6 +181,12 @@ export function FilterRuleModal({
|
||||
const [includeSpam, setIncludeSpam] = useState(start?.includeSpam ?? false);
|
||||
const [usedSuggestions, setUsedSuggestions] = useState<ReadonlySet<string>>(new Set());
|
||||
const [applyToExisting, setApplyToExisting] = useState(false);
|
||||
const hadPeriod = !!start && hasPeriod(start);
|
||||
const [periodOn, setPeriodOn] = useState(hadPeriod);
|
||||
const [periodStart, setPeriodStart] = useState(toPeriodInput(start?.activeFrom));
|
||||
const [periodEnd, setPeriodEnd] = useState(toPeriodInput(start?.activeUntil));
|
||||
const periodStartRef = useRef<HTMLInputElement>(null);
|
||||
const periodEndRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const modalRef = useFocusTrap({ isActive: true, onEscape: onClose });
|
||||
|
||||
@@ -217,9 +259,12 @@ export function FilterRuleModal({
|
||||
// the conditions decide.
|
||||
const canApplyToExisting = useMemo(() => {
|
||||
if (!offerApplyToExisting) return false;
|
||||
// A rule with a period acts on mail as it arrives within it, which says
|
||||
// nothing about the mail already there.
|
||||
if (periodOn) return false;
|
||||
const checkActions: FilterAction[] = validActions.length > 0 ? validActions : [{ type: "mark_read" }];
|
||||
return retroactiveSupport({ conditions: validConditions, actions: checkActions }).ok;
|
||||
}, [offerApplyToExisting, validConditions, validActions]);
|
||||
}, [offerApplyToExisting, validConditions, validActions, periodOn]);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
const trimmedName = name.trim();
|
||||
@@ -238,6 +283,24 @@ export function FilterRuleModal({
|
||||
return;
|
||||
}
|
||||
|
||||
const activeFrom = periodOn ? fromPeriodInput(periodStart, start?.activeFrom) : undefined;
|
||||
const activeUntil = periodOn ? fromPeriodInput(periodEnd, start?.activeUntil) : undefined;
|
||||
// A field the browser holds half filled in (a date without its time)
|
||||
// reads as empty, and must not pass for an open end of the period.
|
||||
const halfFilled = !!periodStartRef.current?.validity.badInput || !!periodEndRef.current?.validity.badInput;
|
||||
if (activeFrom === null || activeUntil === null || (periodOn && halfFilled)) {
|
||||
toast.error(t("validation_period_invalid"));
|
||||
return;
|
||||
}
|
||||
if (periodOn && !activeFrom && !activeUntil) {
|
||||
toast.error(t("validation_period_empty"));
|
||||
return;
|
||||
}
|
||||
if (activeFrom && activeUntil && Date.parse(activeUntil) <= Date.parse(activeFrom)) {
|
||||
toast.error(t("validation_period_order"));
|
||||
return;
|
||||
}
|
||||
|
||||
onSave({
|
||||
id: start?.id || generateUUID(),
|
||||
name: trimmedName,
|
||||
@@ -246,9 +309,14 @@ export function FilterRuleModal({
|
||||
conditions: validConditions,
|
||||
actions: validActions,
|
||||
stopProcessing,
|
||||
...(includeSpam && validActions.some((a) => ACTIONS_WITH_MAILBOX.has(a.type)) ? { includeSpam: true } : {}),
|
||||
// Each optional field this dialog controls is set, to undefined when it
|
||||
// is off: the settings page merges the result into the stored rule, and
|
||||
// a field left out would keep its old value there.
|
||||
includeSpam: includeSpam && validActions.some((a) => ACTIONS_WITH_MAILBOX.has(a.type)) ? true : undefined,
|
||||
activeFrom,
|
||||
activeUntil,
|
||||
}, { applyToExisting: applyToExisting && canApplyToExisting });
|
||||
}, [name, matchType, validConditions, validActions, stopProcessing, includeSpam, start, onSave, t, applyToExisting, canApplyToExisting]);
|
||||
}, [name, matchType, validConditions, validActions, stopProcessing, includeSpam, periodOn, periodStart, periodEnd, start, onSave, t, applyToExisting, canApplyToExisting]);
|
||||
|
||||
const visibleSuggestions = suggestions.filter((s) => !usedSuggestions.has(s.id));
|
||||
|
||||
@@ -597,7 +665,7 @@ export function FilterRuleModal({
|
||||
type="email"
|
||||
className="flex-1 min-w-[180px]"
|
||||
/>
|
||||
<label className="flex items-center gap-1.5 text-sm text-foreground">
|
||||
<label className="flex items-center gap-1.5 text-sm text-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!action.keepCopy}
|
||||
@@ -687,6 +755,54 @@ export function FilterRuleModal({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Offered where the server supports it; a rule that already has a
|
||||
period always shows it, so it can be removed. */}
|
||||
{(periodsSupported || hadPeriod) && (
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="limitToPeriod"
|
||||
checked={periodOn}
|
||||
onChange={(e) => setPeriodOn(e.target.checked)}
|
||||
className="rounded border-input"
|
||||
/>
|
||||
<label htmlFor="limitToPeriod" className="text-sm text-foreground">
|
||||
{t("period_toggle")}
|
||||
</label>
|
||||
</div>
|
||||
{periodOn && (
|
||||
<div className="mt-2 ms-6 space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
|
||||
<label className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
{t("period_start")}
|
||||
<input
|
||||
ref={periodStartRef}
|
||||
type="datetime-local"
|
||||
max={PERIOD_INPUT_MAX}
|
||||
value={periodStart}
|
||||
onChange={(e) => setPeriodStart(e.target.value)}
|
||||
className="px-3 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150 hover:border-muted-foreground"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
{t("period_end")}
|
||||
<input
|
||||
ref={periodEndRef}
|
||||
type="datetime-local"
|
||||
max={PERIOD_INPUT_MAX}
|
||||
value={periodEnd}
|
||||
onChange={(e) => setPeriodEnd(e.target.value)}
|
||||
className="px-3 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150 hover:border-muted-foreground"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t("period_hint")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{offerApplyToExisting && (
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -708,7 +824,7 @@ export function FilterRuleModal({
|
||||
</div>
|
||||
{!canApplyToExisting && (
|
||||
<p id="applyToExistingHint" className="mt-1 ms-6 text-xs text-muted-foreground">
|
||||
{t("apply_existing_unsupported")}
|
||||
{periodOn ? t("apply_existing_period") : t("apply_existing_unsupported")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { FilterRuleModal } from "@/components/filters/filter-rule-modal";
|
||||
import { PromptDialog } from "@/components/ui/prompt-dialog";
|
||||
import { useQuickRuleText } from "@/components/email/rules-menu";
|
||||
import { useQuickRuleStore } from "@/stores/quick-rule-store";
|
||||
import { readAccountFilters } from "@/lib/filters/account-filters";
|
||||
import { forwardsAround, inRunOrder } from "@/lib/filters/forward-limit";
|
||||
import { createFolderAndRunPreset, saveEditorRule } from "@/lib/filters/quick-rule-flow";
|
||||
import { supportsPeriods } from "@/lib/sieve/period";
|
||||
|
||||
/**
|
||||
* Draws what a rule made from a message opens: the rule editor ("Create
|
||||
@@ -21,6 +25,28 @@ export function QuickRuleHost() {
|
||||
const closeEditor = useQuickRuleStore((s) => s.closeEditor);
|
||||
const closeNewFolder = useQuickRuleStore((s) => s.closeNewFolder);
|
||||
|
||||
// Where the rule runs among the account's rules, for the server's redirect
|
||||
// limit: "Create rule…" puts it first, an edit keeps it in place (one no
|
||||
// longer there goes first again), and the out of office forwarding runs
|
||||
// ahead of them all. See lib/filters/forward-limit.ts.
|
||||
const [forwards, setForwards] = useState({ before: 0, after: 0 });
|
||||
useEffect(() => {
|
||||
setForwards({ before: 0, after: 0 });
|
||||
if (!editor) return;
|
||||
let current = true;
|
||||
readAccountFilters(editor.target.client, editor.target.sieveAccountId)
|
||||
.then(({ parsed }) => {
|
||||
if (!current || parsed.isOpaque) return;
|
||||
const runOrder = inRunOrder(parsed.rules, parsed.vacationForward);
|
||||
const index = editor.mode === "edit" ? parsed.rules.findIndex((r) => r.id === editor.rule.id) : -1;
|
||||
setForwards(forwardsAround(runOrder, runOrder.length - parsed.rules.length + Math.max(index, 0), index >= 0));
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
current = false;
|
||||
};
|
||||
}, [editor]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{editor && (
|
||||
@@ -32,6 +58,9 @@ export function QuickRuleHost() {
|
||||
offerApplyToExisting={editor.mode === "prefill" && !!editor.sourceMailboxId}
|
||||
mailboxes={editor.target.mailboxes}
|
||||
maxRedirects={editor.target.client.getSieveCapabilities(editor.target.sieveAccountId)?.maxNumberRedirects}
|
||||
forwardsBefore={forwards.before}
|
||||
forwardsAfter={forwards.after}
|
||||
periodsSupported={supportsPeriods(editor.target.client.getSieveCapabilities(editor.target.sieveAccountId)?.sieveExtensions)}
|
||||
onSave={(rule, options) => {
|
||||
closeEditor();
|
||||
void saveEditorRule(editor, rule, options?.applyToExisting ?? false, text);
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
import React, { Suspense, lazy } from 'react';
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { Email, Mailbox } from '@/lib/jmap/types';
|
||||
import { JMAPClient } from '@/lib/jmap/client';
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useEmailStore } from '@/stores/email-store';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { usePluginStore } from '@/stores/plugin-store';
|
||||
import { useThemeStore } from '@/stores/theme-store';
|
||||
import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils';
|
||||
import { threadKeyFor } from '@/lib/thread-utils';
|
||||
|
||||
// Keep the real MailApp selection/download handlers and EmailViewer. Stub
|
||||
// unrelated shell UI and bootstrap requests so no server is contacted.
|
||||
vi.mock('next/dynamic', () => ({ default: (load: () => Promise<React.ComponentType<Record<string, unknown>>>) => {
|
||||
const Component = lazy(async () => ({ default: await load() }));
|
||||
return function Dynamic(props: Record<string, unknown>) {
|
||||
return <Suspense fallback={null}><Component {...props} /></Suspense>;
|
||||
};
|
||||
} }));
|
||||
vi.mock('@/components/email/email-composer', () => ({ EmailComposer: () => null }));
|
||||
vi.mock('@/components/layout/sidebar', () => ({ Sidebar: () => null }));
|
||||
vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => null }));
|
||||
vi.mock('@/components/layout/calendar-event-notification-toaster', () => ({ CalendarEventNotificationToaster: () => null }));
|
||||
vi.mock('@/components/layout/share-notification-toaster', () => ({ ShareNotificationToaster: () => null }));
|
||||
vi.mock('@/components/layout/mobile-header', () => ({ MobileHeader: () => null }));
|
||||
vi.mock('@/components/email/message-list-tabs', () => ({ MessageListTabs: () => null }));
|
||||
vi.mock('@/components/search/search-box', () => ({ SearchBox: () => null }));
|
||||
vi.mock('@/components/tour/tour-provider', () => ({ useTour: () => ({ activeTour: null, registerStep: vi.fn() }) }));
|
||||
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
|
||||
vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: () => [] }));
|
||||
vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Mail' }) }));
|
||||
vi.mock('@/hooks/use-identity-sync', () => ({ useIdentitySync: () => {} }));
|
||||
vi.mock('@/hooks/use-pro-multi-account-identities', () => ({ useProMultiAccountIdentities: () => [] }));
|
||||
vi.mock('@/hooks/use-pro-multi-account-mailboxes', () => ({ useProMultiAccountMailboxes: () => {} }));
|
||||
vi.mock('@/hooks/use-wopi-status', () => ({ useWopiStatus: () => null, canWopiOpen: () => false }));
|
||||
vi.mock('@/hooks/use-browser-navigation', () => ({ useBrowserNavigation: () => {} }));
|
||||
vi.mock('@/hooks/use-media-query', () => ({
|
||||
useDeviceDetection: () => ({ isMobile: false, isTablet: false, isDesktop: true }),
|
||||
useMediaQuery: () => false,
|
||||
useIsDesktop: () => true,
|
||||
}));
|
||||
vi.mock('@/lib/push-bindings', () => ({ reconcilePushBindings: () => {}, releasePushBindings: () => {} }));
|
||||
vi.mock('@/components/email/email-list', () => ({ EmailList: ({ emails, onEmailSelect, onOpenAttachment }: {
|
||||
emails: Email[]; onEmailSelect: (email: Email) => void; onOpenAttachment: (email: Email, attachment: NonNullable<Email['attachments']>[number]) => void;
|
||||
}) => <div>{emails.map((email, i) => <div key={i}>
|
||||
<button onClick={() => onEmailSelect(email)}>Select {i}</button>
|
||||
<button onClick={() => onOpenAttachment(email, email.attachments![0])}>List attachment {i}</button>
|
||||
</div>)}</div> }));
|
||||
vi.mock('@/components/files/file-preview-modal', () => ({ FilePreviewModal: ({ onDownload, getFileContent }: {
|
||||
onDownload: () => void; getFileContent: () => Promise<unknown>;
|
||||
}) => <div><button onClick={onDownload}>Preview download</button><button onClick={() => void getFileContent()}>Preview fetch</button></div> }));
|
||||
|
||||
const { MailApp } = await import('../mail-app');
|
||||
|
||||
const sharedMailbox = { id: 'nrichmond:inbox', originalId: 'inbox', name: 'Shared', isShared: true, accountId: 'nrichmond' } as Mailbox;
|
||||
const primaryMailbox = { id: 'inbox', name: 'Inbox', role: 'inbox' } as Mailbox;
|
||||
function message(overrides: Partial<Email> = {}): Email {
|
||||
return {
|
||||
id: 'email', threadId: 'thread', blobId: 'raw-blob', subject: 'Hello',
|
||||
mailboxIds: {}, keywords: { $seen: true }, size: 100,
|
||||
receivedAt: '2026-01-01T00:00:00Z', from: [{ email: 'sender@example.com' }], to: [],
|
||||
preview: 'Body', hasAttachment: true,
|
||||
textBody: [{ partId: '1', type: 'text/plain', size: 4 }], htmlBody: [],
|
||||
bodyValues: { '1': { value: 'Body', isEncodingProblem: false, isTruncated: false } },
|
||||
attachments: [{ id: '3', partId: '3', blobId: 'same-blob', name: 'attached.eml', type: 'message/rfc822', size: 50 }],
|
||||
...overrides,
|
||||
} as Email;
|
||||
}
|
||||
function makeClient(accountId: string) {
|
||||
return {
|
||||
getAccountId: () => accountId, getUsername: () => `${accountId}@example.com`,
|
||||
getSieveAccountId: () => null, hasDelayedSend: () => false, getSession: () => null,
|
||||
getEmail: vi.fn().mockResolvedValue(message()),
|
||||
downloadBlob: vi.fn().mockResolvedValue(undefined),
|
||||
fetchBlob: vi.fn().mockResolvedValue(new Blob(['image'], { type: 'image/png' })),
|
||||
};
|
||||
}
|
||||
const connectedClients: JMAPClient[] = [];
|
||||
afterEach(() => {
|
||||
connectedClients.splice(0).forEach(client => client.disconnect());
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
let primary: ReturnType<typeof makeClient>;
|
||||
let other: ReturnType<typeof makeClient>;
|
||||
const asClient = (c: ReturnType<typeof makeClient>) => c as unknown as IJMAPClient;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Unexpected network request')));
|
||||
usePluginStore.setState({ initializePlugins: vi.fn().mockResolvedValue(undefined) });
|
||||
useThemeStore.setState({ syncServerThemes: vi.fn().mockResolvedValue(undefined) });
|
||||
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} });
|
||||
URL.createObjectURL = vi.fn(() => 'blob:test');
|
||||
URL.revokeObjectURL = vi.fn();
|
||||
primary = makeClient('postal12345');
|
||||
other = makeClient('other-primary');
|
||||
useAuthStore.setState({
|
||||
isAuthenticated: true, isLoading: false, client: asClient(primary) as never,
|
||||
activeAccountId: 'login-primary', identities: [],
|
||||
getClientForAccount: (id) => (id === 'login-primary' ? asClient(primary) : id === 'login-other' ? asClient(other) : undefined) as never,
|
||||
getAllConnectedClients: () => new Map([['login-primary', asClient(primary)], ['login-other', asClient(other)]]) as never,
|
||||
});
|
||||
useSettingsStore.setState({ enableUnifiedMailbox: false, mailAttachmentAction: 'download', mailLayout: 'split', attachmentImagePreviewsEnabled: false, postExportAction: 'none', attachmentDownloadTemplate: '{filename}', filenameLowercase: false } as never);
|
||||
useEmailStore.setState({
|
||||
emails: [message()], selectedEmail: message(), selectedMailbox: sharedMailbox.id,
|
||||
mailboxes: [primaryMailbox, sharedMailbox], viewingAccountId: null, accountMailboxes: {},
|
||||
isUnifiedView: false, unifiedRole: null, isScheduledView: false, isLoadingEmail: false,
|
||||
isLoading: false, searchQuery: '', searchFilters: { ...DEFAULT_SEARCH_FILTERS }, searchMailboxId: '',
|
||||
selectedKeyword: null, selectedEmailIds: new Set(),
|
||||
fetchMailboxes: vi.fn().mockResolvedValue(undefined), fetchEmails: vi.fn().mockResolvedValue(undefined),
|
||||
fetchQuota: vi.fn().mockResolvedValue(undefined), fetchTagCounts: vi.fn().mockResolvedValue(undefined),
|
||||
refreshScheduledMetadata: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
});
|
||||
|
||||
async function downloadAttachment() {
|
||||
// The viewer renders responsive copies; any Download button exercises the
|
||||
// same production handler. The raw RFC822 part is the live reproduction.
|
||||
const buttons = await screen.findAllByTitle('download', {}, { timeout: 5000 });
|
||||
fireEvent.click(buttons[0]);
|
||||
}
|
||||
async function exportEmail() {
|
||||
fireEvent.click((await screen.findAllByRole('button', { name: 'more_actions' }))[0]);
|
||||
fireEvent.click((await screen.findAllByText('export_email'))[0]);
|
||||
}
|
||||
|
||||
async function connectDownloadClient(serverUrl: string, username: string, accountId: string) {
|
||||
vi.mocked(fetch).mockResolvedValueOnce(new Response(JSON.stringify({
|
||||
capabilities: { 'urn:ietf:params:jmap:core': {} },
|
||||
accounts: {
|
||||
[accountId]: { name: 'Primary', isPersonal: true, accountCapabilities: {} },
|
||||
nrichmond: { name: 'Shared', isPersonal: false, accountCapabilities: {} },
|
||||
},
|
||||
primaryAccounts: { 'urn:ietf:params:jmap:mail': accountId },
|
||||
apiUrl: `${serverUrl}/jmap/api`,
|
||||
downloadUrl: `${serverUrl}/download/{accountId}/{blobId}/{name}?accept={type}`,
|
||||
uploadUrl: `${serverUrl}/upload/{accountId}`, eventSourceUrl: '',
|
||||
}), { status: 200 }));
|
||||
const client = new JMAPClient(serverUrl, username, 'test-password');
|
||||
connectedClients.push(client);
|
||||
await client.connect();
|
||||
return client;
|
||||
}
|
||||
|
||||
describe('standard mail UI blob routing', () => {
|
||||
it.each([sharedMailbox.id, primaryMailbox.id])('uses the session downloadUrl for %s', async (mailboxId) => {
|
||||
const fetchMock = vi.mocked(fetch);
|
||||
const realClient = await connectDownloadClient('https://mail.example.com', 'test-login', 'postal12345');
|
||||
fetchMock.mockClear();
|
||||
fetchMock.mockResolvedValue(new Response('attachment', { status: 200 }));
|
||||
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {});
|
||||
useAuthStore.setState({
|
||||
client: realClient,
|
||||
getClientForAccount: id => id === 'login-primary' ? realClient : undefined,
|
||||
getAllConnectedClients: () => new Map([['login-primary', realClient]]),
|
||||
});
|
||||
useEmailStore.setState({ selectedMailbox: mailboxId });
|
||||
render(<MailApp />);
|
||||
await downloadAttachment();
|
||||
const owner = mailboxId === sharedMailbox.id ? 'nrichmond' : 'postal12345';
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalled());
|
||||
expect(String(fetchMock.mock.calls[0][0])).toMatch(new RegExp(`/download/${owner}/same-blob/`));
|
||||
expect(String(fetchMock.mock.calls[0][0])).toContain('accept=message%2Frfc822');
|
||||
expect(fetchMock.mock.calls[0][1]?.headers).toEqual(expect.objectContaining({ Authorization: `Basic ${btoa('test-login:test-password')}` }));
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ label: 'direct shared folder', owner: 'nrichmond', login: 'login-primary', filename: 'attachment' },
|
||||
{ label: 'stamped shared message on another login', owner: 'nrichmond', login: 'login-other', filename: 'attachment' },
|
||||
{ label: 'primary account', owner: 'postal12345', login: 'login-primary', filename: 'attachment' },
|
||||
{ label: 'formatted unnamed attachment', owner: 'nrichmond', login: 'login-primary', filename: 'hello-attachment', formatted: true },
|
||||
])('routes Chromium hover and Download for $label', async ({ owner, login, filename, formatted }) => {
|
||||
// Enable the real browser capability gate before the viewer mounts.
|
||||
vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue('Mozilla/5.0 Chrome/130.0.0.0');
|
||||
const primaryClient = await connectDownloadClient('https://primary.example.com', 'primary-login', 'postal12345');
|
||||
const secondaryClient = await connectDownloadClient('https://secondary.example.com', 'secondary-login', 'secondary-primary');
|
||||
useAuthStore.setState({
|
||||
client: primaryClient,
|
||||
getClientForAccount: id => id === 'login-primary' ? primaryClient : id === 'login-other' ? secondaryClient : undefined,
|
||||
getAllConnectedClients: () => new Map([['login-primary', primaryClient], ['login-other', secondaryClient]]),
|
||||
});
|
||||
useSettingsStore.setState({
|
||||
attachmentDownloadTemplate: formatted ? '{subject}-{filename}' : '{filename}',
|
||||
filenameLowercase: !!formatted,
|
||||
});
|
||||
const email = message({
|
||||
attachments: [{ partId: '3', blobId: 'same-blob', type: 'message/rfc822', size: 50 }],
|
||||
...(login === 'login-other' ? { sourceClientAccountId: login, sourceAccountId: owner } : {}),
|
||||
});
|
||||
// message() has a nonempty outer body, so automatic RFC822 unwrapping
|
||||
// cannot contribute a third request to this interaction.
|
||||
useEmailStore.setState({ selectedEmail: email, selectedMailbox: owner === 'postal12345' ? primaryMailbox.id : sharedMailbox.id });
|
||||
const fetchMock = vi.mocked(fetch);
|
||||
fetchMock.mockClear();
|
||||
fetchMock.mockImplementation(async () => new Response('attachment', { status: 200, headers: { 'Content-Type': 'message/rfc822' } }));
|
||||
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {});
|
||||
render(<MailApp />);
|
||||
const downloadButton = (await screen.findAllByTitle('download', {}, { timeout: 5000 }))[0];
|
||||
const chip = downloadButton.closest('[draggable="true"]');
|
||||
expect(chip).not.toBeNull();
|
||||
|
||||
fireEvent.pointerOver(downloadButton, { pointerType: 'mouse' });
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
|
||||
await waitFor(() => expect(URL.createObjectURL).toHaveBeenCalledTimes(1));
|
||||
const host = login === 'login-other' ? 'https://secondary.example.com' : 'https://primary.example.com';
|
||||
const expectedUrl = `${host}/download/${owner}/same-blob/${filename}?accept=message%2Frfc822`;
|
||||
const authorization = `Basic ${btoa(`${login === 'login-other' ? 'secondary-login' : 'primary-login'}:test-password`)}`;
|
||||
expect(String(fetchMock.mock.calls[0][0])).toBe(expectedUrl);
|
||||
expect(fetchMock.mock.calls[0][1]?.headers).toEqual(expect.objectContaining({ Authorization: authorization }));
|
||||
|
||||
// Hover still prepares a working DownloadURL for a real drag start.
|
||||
const dataTransfer = { setData: vi.fn(), effectAllowed: '' };
|
||||
fireEvent.dragStart(chip!, { dataTransfer });
|
||||
expect(dataTransfer.setData).toHaveBeenCalledWith('DownloadURL', `message/rfc822:${filename}:blob:test`);
|
||||
expect(dataTransfer.effectAllowed).toBe('copyMove');
|
||||
|
||||
fireEvent.click(downloadButton);
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2));
|
||||
expect(fetchMock.mock.calls.map(([url]) => String(url))).toEqual([expectedUrl, expectedUrl]);
|
||||
expect(fetchMock.mock.calls[1][1]?.headers).toEqual(expect.objectContaining({ Authorization: authorization }));
|
||||
});
|
||||
|
||||
it('downloads a direct shared-folder message/rfc822 part through its owner', async () => {
|
||||
render(<MailApp />);
|
||||
await downloadAttachment();
|
||||
expect(primary.downloadBlob).toHaveBeenCalledWith('same-blob', expect.any(String), 'message/rfc822', 'nrichmond');
|
||||
expect(other.downloadBlob).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps primary-account downloads compatible', async () => {
|
||||
useEmailStore.setState({ selectedMailbox: 'inbox' });
|
||||
render(<MailApp />);
|
||||
await downloadAttachment();
|
||||
expect(primary.downloadBlob).toHaveBeenCalledWith('same-blob', expect.any(String), 'message/rfc822', undefined);
|
||||
});
|
||||
|
||||
it('exports the raw shared message through its owner', async () => {
|
||||
render(<MailApp />);
|
||||
await exportEmail();
|
||||
expect(primary.downloadBlob).toHaveBeenCalledWith('raw-blob', expect.stringMatching(/\.eml$/), 'message/rfc822', 'nrichmond');
|
||||
});
|
||||
|
||||
it.each([false, true])('routes Unified selection (automatic=%s) after Email/get replaces the stub', async (automatic) => {
|
||||
const stub = message({ bodyValues: undefined, accountId: 'nrichmond', accountLabel: 'Richmond', sourceClientAccountId: 'login-other', sourceAccountId: 'nrichmond' });
|
||||
useEmailStore.setState({ emails: [stub], selectedEmail: automatic ? stub : null, isUnifiedView: true, selectedMailbox: '' });
|
||||
render(<MailApp />);
|
||||
if (!automatic) fireEvent.click(screen.getByText('Select 0'));
|
||||
await waitFor(() => expect(other.getEmail).toHaveBeenCalledWith('email', 'nrichmond'));
|
||||
await waitFor(() => expect(useEmailStore.getState().selectedEmail?.bodyValues).toBeDefined());
|
||||
await downloadAttachment();
|
||||
await exportEmail();
|
||||
expect(other.downloadBlob).toHaveBeenCalledWith('same-blob', expect.any(String), 'message/rfc822', 'nrichmond');
|
||||
expect(other.downloadBlob).toHaveBeenCalledWith('raw-blob', expect.any(String), 'message/rfc822', 'nrichmond');
|
||||
expect(primary.downloadBlob).not.toHaveBeenCalled();
|
||||
expect(useEmailStore.getState().selectedEmail).toMatchObject({ sourceClientAccountId: 'login-other', sourceAccountId: 'nrichmond', accountLabel: 'Richmond' });
|
||||
});
|
||||
|
||||
it('keeps preview fetch and download on the originating owner after navigation', async () => {
|
||||
useSettingsStore.setState({ mailAttachmentAction: 'preview' });
|
||||
const email = message({ attachments: [{ partId: '3', blobId: 'same-blob', name: 'photo.png', type: 'image/png', size: 50 }] });
|
||||
useEmailStore.setState({ selectedEmail: email });
|
||||
render(<MailApp />);
|
||||
fireEvent.click((await screen.findAllByText('photo.png'))[0]);
|
||||
await screen.findByText('Preview download');
|
||||
act(() => {
|
||||
useEmailStore.setState({ selectedMailbox: 'inbox', selectedEmail: message() });
|
||||
useAuthStore.setState({ client: asClient(other) as never, activeAccountId: 'login-other' });
|
||||
});
|
||||
fireEvent.click(screen.getByText('Preview fetch'));
|
||||
fireEvent.click(screen.getByText('Preview download'));
|
||||
expect(primary.fetchBlob).toHaveBeenCalledWith('same-blob', expect.any(String), 'image/png', 'nrichmond');
|
||||
expect(primary.downloadBlob).toHaveBeenCalledWith('same-blob', expect.any(String), 'image/png', 'nrichmond');
|
||||
expect(other.downloadBlob).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('honors stamps outside Unified view and isolates overlapping blob ids', async () => {
|
||||
const first = message({ sourceClientAccountId: 'login-other', sourceAccountId: 'owner-a' });
|
||||
const second = message({ sourceClientAccountId: 'login-primary', sourceAccountId: 'owner-b' });
|
||||
useEmailStore.setState({ emails: [first, second], selectedEmail: first });
|
||||
render(<MailApp />);
|
||||
await downloadAttachment();
|
||||
act(() => useEmailStore.setState({ selectedEmail: second }));
|
||||
await downloadAttachment();
|
||||
expect(other.downloadBlob).toHaveBeenCalledWith('same-blob', expect.any(String), 'message/rfc822', 'owner-a');
|
||||
expect(primary.downloadBlob).toHaveBeenCalledWith('same-blob', expect.any(String), 'message/rfc822', 'owner-b');
|
||||
});
|
||||
|
||||
it('keeps a direct shared-folder message unstamped so its thread key matches the row', async () => {
|
||||
useEmailStore.setState({ selectedEmail: message({ bodyValues: undefined }) });
|
||||
render(<MailApp />);
|
||||
await waitFor(() => expect(primary.getEmail).toHaveBeenCalledWith('email', 'nrichmond'));
|
||||
await waitFor(() => expect(useEmailStore.getState().selectedEmail?.bodyValues).toBeDefined());
|
||||
const selected = useEmailStore.getState().selectedEmail!;
|
||||
expect(threadKeyFor(selected)).toBe(threadKeyFor(useEmailStore.getState().emails[0]));
|
||||
await downloadAttachment();
|
||||
expect(primary.downloadBlob).toHaveBeenCalledWith('same-blob', expect.any(String), 'message/rfc822', 'nrichmond');
|
||||
});
|
||||
|
||||
it('keeps an unstamped all-folders search hit on the primary account', async () => {
|
||||
useEmailStore.setState({ searchQuery: 'Hello', searchMailboxId: '*' });
|
||||
render(<MailApp />);
|
||||
await downloadAttachment();
|
||||
expect(primary.downloadBlob).toHaveBeenCalledWith('same-blob', expect.any(String), 'message/rfc822', undefined);
|
||||
});
|
||||
|
||||
it('uses the browsing login for a direct shared folder on another login', async () => {
|
||||
useEmailStore.setState({ viewingAccountId: 'login-other', accountMailboxes: { 'login-other': [sharedMailbox] } });
|
||||
render(<MailApp />);
|
||||
await downloadAttachment();
|
||||
expect(other.downloadBlob).toHaveBeenCalledWith('same-blob', expect.any(String), 'message/rfc822', 'nrichmond');
|
||||
expect(primary.downloadBlob).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('downloads a list attachment using that row rather than the selected message', async () => {
|
||||
const row = message({ sourceClientAccountId: 'login-other', sourceAccountId: 'nrichmond' });
|
||||
useEmailStore.setState({ emails: [row], selectedEmail: message() });
|
||||
render(<MailApp />);
|
||||
fireEvent.click(screen.getByText('List attachment 0'));
|
||||
expect(other.downloadBlob).toHaveBeenCalledWith('same-blob', 'attached.eml', 'message/rfc822', 'nrichmond');
|
||||
expect(primary.downloadBlob).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not route a disconnected stamped login through the primary client', async () => {
|
||||
useEmailStore.setState({ selectedEmail: message({ sourceClientAccountId: 'missing-login', sourceAccountId: 'nrichmond' }) });
|
||||
render(<MailApp />);
|
||||
await downloadAttachment();
|
||||
await exportEmail();
|
||||
expect(primary.downloadBlob).not.toHaveBeenCalled();
|
||||
expect(other.downloadBlob).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fetches an automatically selected owner even when its email id overlaps the previous selection', async () => {
|
||||
const first = message({ sourceClientAccountId: 'login-primary', sourceAccountId: 'owner-a' });
|
||||
const second = message({ bodyValues: undefined, sourceClientAccountId: 'login-other', sourceAccountId: 'owner-b' });
|
||||
useEmailStore.setState({ emails: [first, second], selectedEmail: first, isUnifiedView: true });
|
||||
render(<MailApp />);
|
||||
await downloadAttachment();
|
||||
act(() => useEmailStore.getState().selectEmail(second));
|
||||
await waitFor(() => expect(other.getEmail).toHaveBeenCalledWith('email', 'owner-b'));
|
||||
await waitFor(() => expect(useEmailStore.getState().selectedEmail?.bodyValues).toBeDefined());
|
||||
await downloadAttachment();
|
||||
expect(other.downloadBlob).toHaveBeenCalledWith('same-blob', expect.any(String), 'message/rfc822', 'owner-b');
|
||||
});
|
||||
|
||||
it('fetches inline images with the shared owner', async () => {
|
||||
useEmailStore.setState({ selectedEmail: message({ attachments: [{ partId: 'image', blobId: 'same-blob', name: 'inline.png', cid: 'cid-image', type: 'image/png', size: 50 }] }) });
|
||||
render(<MailApp />);
|
||||
await waitFor(() => expect(primary.fetchBlob).toHaveBeenCalledWith('same-blob', 'inline.png', 'image/png', 'nrichmond'));
|
||||
});
|
||||
});
|
||||
+72
-104
@@ -32,7 +32,7 @@ import { usePolicyStore } from "@/stores/policy-store";
|
||||
import type { UnifiedAccountClient } from "@/lib/unified-mailbox";
|
||||
import { connectedAccountsGrew } from "@/lib/unified-mailbox";
|
||||
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
|
||||
import { useEmailStore, buildUnifiedAccountClients, invalidateUnifiedMailboxes, captureViewToken, ArchiveMailboxNotFoundError, findArchiveMailbox, resolveUnstampedEmailAccountId, emptyFolderMovesToTrash } from "@/stores/email-store";
|
||||
import { useEmailStore, buildUnifiedAccountClients, invalidateUnifiedMailboxes, captureViewToken, ArchiveMailboxNotFoundError, ensureArchiveMailbox, resolveUnstampedEmailAccountId, resolveEmailBlobContext, emptyFolderMovesToTrash } from "@/stores/email-store";
|
||||
import { groupSearchScopeFolders, SEARCH_SCOPE_ALL_FOLDERS } from "@/lib/search-scope-folders";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { formatRejectedRecipients, type JMAPClient } from "@/lib/jmap/client";
|
||||
@@ -104,7 +104,8 @@ import { useProMultiAccountIdentities } from "@/hooks/use-pro-multi-account-iden
|
||||
import { Filter, ChevronDown, X, Paperclip, Star, Mail, MailOpen, RotateCcw, PenSquare, PenLine, CheckSquare, Square, AlertTriangle, ArrowLeft } from "@/components/icons";
|
||||
import { ResizeHandle } from "@/components/layout/resize-handle";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useConfig } from "@/hooks/use-config";
|
||||
import { useDocumentTitle } from "@/hooks/use-document-title";
|
||||
import { mailTitleContext } from "@/lib/tab-title";
|
||||
import { usePluginStore } from "@/stores/plugin-store";
|
||||
import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot";
|
||||
import { useThemeStore } from "@/stores/theme-store";
|
||||
@@ -170,7 +171,6 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
const t = useTranslations();
|
||||
const tCommon = useTranslations('common');
|
||||
const tQuote = useTranslations('quote_header');
|
||||
const { appName } = useConfig();
|
||||
const mailLayout = useEffectiveMailLayout();
|
||||
const interfaceLayout = useSettingsStore((state) => state.interfaceLayout);
|
||||
// Phones present search full-screen from the header field rather than
|
||||
@@ -203,7 +203,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
const [conversationEmails, setConversationEmails] = useState<Email[]>([]);
|
||||
const [isLoadingConversation, setIsLoadingConversation] = useState(false);
|
||||
const [rateLimitSecondsLeft, setRateLimitSecondsLeft] = useState<number | null>(null);
|
||||
const [previewAttachment, setPreviewAttachment] = useState<{ blobId: string; name: string; type?: string; accountId?: string; clientAccountId?: string } | null>(null);
|
||||
const [previewAttachment, setPreviewAttachment] = useState<{ blobId: string; name: string; type?: string; accountId?: string; client: NonNullable<typeof client> } | null>(null);
|
||||
// Office attachments open read-only in the configured WOPI editor (#1047).
|
||||
const [officeAttachment, setOfficeAttachment] = useState<{ blobId: string; name: string; type?: string; size?: number; accountId: string; slot: number | null } | null>(null);
|
||||
const wopiStatus = useWopiStatus(true);
|
||||
@@ -1010,37 +1010,36 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
return () => clearTimeout(timer);
|
||||
}, [clearPendingUndoSend, pendingUndoSend]);
|
||||
|
||||
// Update page title based on context
|
||||
useEffect(() => {
|
||||
let title = appName;
|
||||
// Page title: "<context> - <account> - <app>" (see useDocumentTitle)
|
||||
// The subject is kept out until the policy has loaded: a gate that keeps
|
||||
// subjects out of browser history must not let one through while unknown.
|
||||
const subjectInTabTitle = usePolicyStore((s) => s.loaded && s.isFeatureEnabled('tabTitleSubjectEnabled'));
|
||||
const titleContext = useMemo(() => {
|
||||
// Composing email
|
||||
const composer = showComposer
|
||||
? {
|
||||
compose: t('email_composer.new_message'),
|
||||
reply: t('email_composer.reply'),
|
||||
replyAll: t('email_composer.reply_all'),
|
||||
forward: t('email_composer.forward'),
|
||||
}[composerMode] || t('email_composer.new_message')
|
||||
: null;
|
||||
|
||||
if (showComposer) {
|
||||
// Composing email
|
||||
const modeText = {
|
||||
compose: t('email_composer.new_message'),
|
||||
reply: t('email_composer.reply'),
|
||||
replyAll: t('email_composer.reply_all'),
|
||||
forward: t('email_composer.forward'),
|
||||
}[composerMode] || t('email_composer.new_message');
|
||||
title = `${modeText} - ${appName}`;
|
||||
} else if (selectedEmail) {
|
||||
// Reading email
|
||||
const subject = selectedEmail.subject || t('email_viewer.no_subject');
|
||||
title = `${subject} - ${appName}`;
|
||||
} else if (selectedMailbox && mailboxes.length > 0) {
|
||||
// Mailbox view
|
||||
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
|
||||
if (mailbox) {
|
||||
const mailboxName = localizeMailboxName(mailbox.role, mailbox.name, (k) => t(`sidebar.mailboxes.${k}`));
|
||||
const unreadCount = mailbox.unreadEmails || 0;
|
||||
title = unreadCount > 0
|
||||
? `${mailboxName} (${unreadCount}) - ${appName}`
|
||||
: `${mailboxName} - ${appName}`;
|
||||
}
|
||||
// Reading email
|
||||
const subject = selectedEmail ? selectedEmail.subject || t('email_viewer.no_subject') : null;
|
||||
|
||||
// Mailbox view
|
||||
let mailboxLine: string | null = null;
|
||||
const mailbox = selectedMailbox ? mailboxes.find(mb => mb.id === selectedMailbox) : undefined;
|
||||
if (mailbox) {
|
||||
const mailboxName = localizeMailboxName(mailbox.role, mailbox.name, (k) => t(`sidebar.mailboxes.${k}`));
|
||||
const unreadCount = mailbox.unreadEmails || 0;
|
||||
mailboxLine = unreadCount > 0 ? `${mailboxName} (${unreadCount})` : mailboxName;
|
||||
}
|
||||
|
||||
document.title = title;
|
||||
}, [showComposer, composerMode, selectedEmail, selectedMailbox, mailboxes, t, appName]);
|
||||
return mailTitleContext({ composer, subject, mailbox: mailboxLine }, { showSubject: subjectInTabTitle });
|
||||
}, [showComposer, composerMode, selectedEmail, selectedMailbox, mailboxes, t, subjectInTabTitle]);
|
||||
useDocumentTitle(titleContext);
|
||||
|
||||
// When this page is rendered inside the Pro shell as the Mail tab body,
|
||||
// we hoist every "show composer" intent into its own Pro tab and reset
|
||||
@@ -1708,17 +1707,13 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
// Skip when handleEmailSelect already started a fetch (it sets isLoadingEmail before
|
||||
// calling selectEmail on the stub), to avoid a duplicate request.
|
||||
if (!selectedEmail.bodyValues && !isLoadingEmail) {
|
||||
const perAccountClient = isUnifiedView && selectedEmail.sourceClientAccountId
|
||||
? useAuthStore.getState().getClientForAccount(selectedEmail.sourceClientAccountId)
|
||||
: undefined;
|
||||
const fetchClient = perAccountClient ?? client;
|
||||
setLoadingEmail(true);
|
||||
fetchEmailContent(fetchClient, selectedEmail.id).finally(() => {
|
||||
fetchEmailContent(client, selectedEmail.id).finally(() => {
|
||||
setLoadingEmail(false);
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedEmail?.id]);
|
||||
}, [selectedEmail?.id, selectedEmail?.sourceClientAccountId, selectedEmail?.sourceAccountId]);
|
||||
|
||||
// Opening a message hides the list on tablet, but plenty of paths clear the
|
||||
// reading pane without going through a "back" handler (delete/move/archive,
|
||||
@@ -2117,7 +2112,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
to: draft.to?.map(a => a.email).filter(Boolean).join(', ') || '',
|
||||
cc: draft.cc?.map(a => a.email).filter(Boolean).join(', ') || '',
|
||||
bcc: draft.bcc?.map(a => a.email).filter(Boolean).join(', ') || '',
|
||||
subject: draft.subject || '',
|
||||
// Drafts saved before #1189 stored an empty subject as the composer's
|
||||
// "(No Subject)" placeholder; reopen those with an empty field.
|
||||
subject: draft.subject && draft.subject !== t('email_composer.no_subject') ? draft.subject : '',
|
||||
body: htmlBody || bodyText,
|
||||
// Re-open in the format the draft was written in: a text-only draft
|
||||
// used to land raw in the rich-text editor (newlines collapsed) and an
|
||||
@@ -2435,17 +2432,23 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
|
||||
const currentMailboxes = readMailboxes();
|
||||
|
||||
// Scope like batchArchive: the owning account in unified view, otherwise
|
||||
// the selected shared folder's owner, otherwise the user's own archive. (#889)
|
||||
const archiveMailbox = findArchiveMailbox(currentMailboxes, selectedMailbox, archiveAccountId);
|
||||
if (!archiveMailbox) {
|
||||
toast.error(t('email_viewer.archive_mailbox_not_found'));
|
||||
return;
|
||||
}
|
||||
|
||||
const { archiveMode } = useSettingsStore.getState();
|
||||
|
||||
try {
|
||||
// Scope like batchArchive: the owning account in unified view, otherwise
|
||||
// the selected shared folder's owner, otherwise the user's own archive
|
||||
// (#889); created on first use when the account has none (#578).
|
||||
const archiveMailbox = await ensureArchiveMailbox({
|
||||
client: archiveClient,
|
||||
mailboxes: currentMailboxes,
|
||||
selectedMailboxId: selectedMailbox,
|
||||
accountId: archiveAccountId,
|
||||
refresh: async () => {
|
||||
await refreshMailboxes();
|
||||
return readMailboxes();
|
||||
},
|
||||
});
|
||||
|
||||
if (archiveMode === 'single') {
|
||||
await moveThreadToMailbox(client, emailToArchive.id, archiveMailbox.id);
|
||||
} else {
|
||||
@@ -2487,6 +2490,10 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
void refreshMailboxes();
|
||||
} catch (error) {
|
||||
console.error("Failed to archive email:", error);
|
||||
if (error instanceof ArchiveMailboxNotFoundError) {
|
||||
toast.error(t('email_viewer.archive_mailbox_not_found'));
|
||||
return;
|
||||
}
|
||||
toast.error(t('notifications.error_archiving'), error instanceof Error ? error.message : undefined);
|
||||
}
|
||||
};
|
||||
@@ -3229,20 +3236,11 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Blobs are scoped per JMAP account. In the unified/All-Mail view the open
|
||||
// message may belong to another login (route to its client) or to a delegated
|
||||
// shared account (same client, but the owner's accountId in the download URL).
|
||||
// Resolve both from the email's source so attachments on cross-account
|
||||
// messages can be viewed/downloaded instead of 404ing against the active
|
||||
// account.
|
||||
// Resolve from the originating message, including direct shared folders.
|
||||
const resolveBlobSource = useCallback((email: typeof selectedEmail) => {
|
||||
const clientAccountId = isUnifiedView ? email?.sourceClientAccountId : undefined;
|
||||
const blobClient = clientAccountId
|
||||
? (useAuthStore.getState().getClientForAccount(clientAccountId) ?? client)
|
||||
: client;
|
||||
const accountId = isUnifiedView ? email?.sourceAccountId : undefined;
|
||||
const { client: blobClient, accountId, clientAccountId } = resolveEmailBlobContext(email, client);
|
||||
return { blobClient, accountId, clientAccountId };
|
||||
}, [isUnifiedView, client]);
|
||||
}, [client]);
|
||||
|
||||
// Opens an attachment the built-in preview cannot render but the office
|
||||
// editor can (docx, odt, xlsx, ...). The launch route runs server-side with
|
||||
@@ -3273,7 +3271,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
try {
|
||||
const { mailAttachmentAction } = useSettingsStore.getState();
|
||||
if (mailAttachmentAction === 'preview' && isFilePreviewable(name, attachment.type)) {
|
||||
setPreviewAttachment({ blobId: attachment.blobId, name, type: attachment.type, accountId, clientAccountId });
|
||||
setPreviewAttachment({ blobId: attachment.blobId, name, type: attachment.type, accountId, client: blobClient });
|
||||
return;
|
||||
}
|
||||
if (mailAttachmentAction === 'preview' && openOfficeAttachment(
|
||||
@@ -3333,7 +3331,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
const { mailAttachmentAction } = useSettingsStore.getState();
|
||||
|
||||
if (!forceDownload && mailAttachmentAction === 'preview' && isFilePreviewable(name, type)) {
|
||||
setPreviewAttachment({ blobId, name, type, accountId, clientAccountId });
|
||||
setPreviewAttachment({ blobId, name, type, accountId, client: blobClient });
|
||||
return;
|
||||
}
|
||||
if (!forceDownload && mailAttachmentAction === 'preview' && openOfficeAttachment(
|
||||
@@ -3346,20 +3344,15 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
}
|
||||
};
|
||||
|
||||
const previewBlobClient = useCallback(() => {
|
||||
const id = previewAttachment?.clientAccountId;
|
||||
return id ? (useAuthStore.getState().getClientForAccount(id) ?? client) : client;
|
||||
}, [previewAttachment, client]);
|
||||
|
||||
const handlePreviewAttachmentDownload = useCallback(async () => {
|
||||
const c = previewBlobClient();
|
||||
const c = previewAttachment?.client;
|
||||
if (!c || !previewAttachment) return;
|
||||
|
||||
await c.downloadBlob(previewAttachment.blobId, previewAttachment.name, previewAttachment.type, previewAttachment.accountId);
|
||||
}, [previewBlobClient, previewAttachment]);
|
||||
}, [previewAttachment]);
|
||||
|
||||
const getPreviewAttachmentContent = useCallback(async () => {
|
||||
const c = previewBlobClient();
|
||||
const c = previewAttachment?.client;
|
||||
if (!c || !previewAttachment) {
|
||||
throw new Error('No attachment selected');
|
||||
}
|
||||
@@ -3370,7 +3363,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
blob,
|
||||
contentType: previewAttachment.type || blob.type || 'application/octet-stream',
|
||||
};
|
||||
}, [previewBlobClient, previewAttachment]);
|
||||
}, [previewAttachment]);
|
||||
|
||||
const handleQuickReply = async (body: string) => {
|
||||
if (!client || !selectedEmail) return;
|
||||
@@ -3605,40 +3598,11 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
|
||||
// Fetch the full content
|
||||
try {
|
||||
// Emails fetched through an aggregate view carry their source reference:
|
||||
// the login they are reachable through (`sourceClientAccountId`) and their
|
||||
// owning JMAP account (`sourceAccountId`). Honor the stamps whenever they
|
||||
// are present — not only while `isUnifiedView` is set, since a stamped list
|
||||
// can outlive that flag — so the fetch goes to the server that actually owns
|
||||
// the mail. Works uniformly for personal and shared/group sources: for
|
||||
// personal the owning account equals the client's primary (no-op). (#281)
|
||||
//
|
||||
// Unstamped emails belong to the account being browsed: the per-account
|
||||
// sidebar's `viewingAccountId` when set, otherwise the active login. A
|
||||
// shared folder on that client still needs its owner accountId. (#847)
|
||||
const sourceClientId = listEmail?.sourceClientAccountId;
|
||||
const sourceClient = sourceClientId
|
||||
? useAuthStore.getState().getClientForAccount(sourceClientId)
|
||||
: undefined;
|
||||
if (sourceClientId && !sourceClient) {
|
||||
// No silent fallback to the active client: with colliding ids that would
|
||||
// render another account's mail under this row.
|
||||
console.warn('[mail-app] No connected client for source account', sourceClientId);
|
||||
const { client: fetchClient, accountId } = resolveEmailBlobContext(listEmail ?? null, client);
|
||||
if (!fetchClient) {
|
||||
console.warn('[mail-app] No connected client for email source');
|
||||
return;
|
||||
}
|
||||
const fetchClient = sourceClient
|
||||
?? (viewingAccountId ? useAuthStore.getState().getClientForAccount(viewingAccountId) : undefined)
|
||||
?? client;
|
||||
|
||||
// During an unscoped search the hit is the primary account's, not the
|
||||
// selected shared folder's owner - see resolveUnstampedEmailAccountId. (#923)
|
||||
const accountId = listEmail?.sourceAccountId
|
||||
?? resolveUnstampedEmailAccountId({
|
||||
mailboxes: viewMailboxes,
|
||||
selectedMailbox,
|
||||
searchActive: !!searchQuery || !isFilterEmpty(searchFilters),
|
||||
searchMailboxId,
|
||||
});
|
||||
|
||||
const fullEmail = await fetchClient.getEmail(email.id, accountId);
|
||||
if (fullEmail) {
|
||||
@@ -3655,9 +3619,12 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
if (listEmail?.sourceClientAccountId) {
|
||||
fullEmail.accountId = listEmail.accountId;
|
||||
fullEmail.accountLabel = listEmail.accountLabel;
|
||||
fullEmail.sourceClientAccountId = listEmail.sourceClientAccountId;
|
||||
fullEmail.sourceAccountId = listEmail.sourceAccountId;
|
||||
fullEmail.sourceFolder = listEmail.sourceFolder;
|
||||
}
|
||||
// Only stamped rows are re-stamped. A direct-folder row stays
|
||||
// unstamped, since threadKeyFor scopes keys by these stamps.
|
||||
fullEmail.sourceAccountId = listEmail?.sourceAccountId;
|
||||
fullEmail.sourceClientAccountId = listEmail?.sourceClientAccountId;
|
||||
selectEmail(fullEmail);
|
||||
// Mark-as-read logic is now handled by useEffect
|
||||
}
|
||||
@@ -4657,6 +4624,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
<ErrorBoundary fallback={EmailViewerErrorFallback}>
|
||||
<EmailViewer
|
||||
email={selectedEmail}
|
||||
blobSource={resolveEmailBlobContext(selectedEmail, client)}
|
||||
isLoading={isLoadingEmail}
|
||||
onReply={handleReply}
|
||||
onReplyAll={handleReplyAll}
|
||||
|
||||
@@ -347,7 +347,9 @@ export function ProEmailView({ emailId, client: clientOverride, accountId, onLoa
|
||||
to: email.to?.map((a) => a.email).filter(Boolean).join(', ') || '',
|
||||
cc: email.cc?.map((a) => a.email).filter(Boolean).join(', ') || '',
|
||||
bcc: email.bcc?.map((a) => a.email).filter(Boolean).join(', ') || '',
|
||||
subject: email.subject || '',
|
||||
// Drafts saved before #1189 stored an empty subject as the composer's
|
||||
// "(No Subject)" placeholder; reopen those with an empty field.
|
||||
subject: email.subject && email.subject !== t('email_composer.no_subject') ? email.subject : '',
|
||||
body: htmlBody || bodyText,
|
||||
showCc: (email.cc?.length || 0) > 0,
|
||||
showBcc: (email.bcc?.length || 0) > 0,
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import React from 'react';
|
||||
import type { SieveCapabilities } from '@/lib/jmap/sieve-types';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useEmailStore } from '@/stores/email-store';
|
||||
import { useFilterStore } from '@/stores/filter-store';
|
||||
import { FilterSettings } from '../filter-settings';
|
||||
|
||||
// The real settings page and its dialogs, against what happens in the
|
||||
// background while the user types: the connection check every 30 seconds,
|
||||
// a mailbox refresh, and a reload of the filters (another device changed
|
||||
// the script).
|
||||
|
||||
vi.mock('@/stores/toast-store', () => ({ toast: { error: vi.fn(), success: vi.fn(), info: vi.fn() } }));
|
||||
|
||||
const CAPABILITIES: SieveCapabilities = {
|
||||
implementation: 'Stalwart',
|
||||
maxSizeScript: 102400,
|
||||
sieveExtensions: ['fileinto', 'mailbox', 'imap4flags', 'copy'],
|
||||
notificationMethods: [],
|
||||
externalLists: [],
|
||||
maxNumberRedirects: 1,
|
||||
};
|
||||
|
||||
const initial = {
|
||||
auth: useAuthStore.getState(),
|
||||
email: useEmailStore.getState(),
|
||||
filter: useFilterStore.getState(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
useAuthStore.setState({ client: { updateSieveScript: vi.fn(async () => {}), getSieveAccountId: () => 'primary' } as never });
|
||||
useEmailStore.setState({ fetchMailboxes: vi.fn(async () => {}) as never });
|
||||
useFilterStore.setState({
|
||||
rules: [],
|
||||
isSupported: true,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
isOpaque: false,
|
||||
activeScriptId: 'script-1',
|
||||
selectedAccountId: 'primary',
|
||||
sieveCapabilities: CAPABILITIES,
|
||||
selectAccount: vi.fn(async () => {}),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
useAuthStore.setState(initial.auth, true);
|
||||
useEmailStore.setState(initial.email, true);
|
||||
useFilterStore.setState(initial.filter, true);
|
||||
});
|
||||
|
||||
function typeNewRule() {
|
||||
render(<FilterSettings />);
|
||||
fireEvent.click(screen.getByText('add_rule'));
|
||||
const name = screen.getByPlaceholderText('rule_name_placeholder');
|
||||
name.focus();
|
||||
fireEvent.change(name, { target: { value: 'News' } });
|
||||
return name;
|
||||
}
|
||||
|
||||
describe('typing in a dialog on the filter page while things happen in the background', () => {
|
||||
it('keeps the focus in the rule name through the connection check', () => {
|
||||
const name = typeNewRule();
|
||||
// What the ping every 30 seconds does: it sets the connection state again.
|
||||
act(() => useAuthStore.setState({ connectionLost: false }));
|
||||
expect(document.activeElement).toBe(name);
|
||||
});
|
||||
|
||||
it('keeps the focus in the rule name through a mailbox refresh', () => {
|
||||
const name = typeNewRule();
|
||||
act(() => useEmailStore.setState({ mailboxes: [] }));
|
||||
expect(document.activeElement).toBe(name);
|
||||
});
|
||||
|
||||
it('keeps the focus in the Sieve editor through the connection check', () => {
|
||||
render(<FilterSettings />);
|
||||
fireEvent.click(screen.getByText('raw_editor'));
|
||||
const script = screen.getByLabelText('script_content');
|
||||
script.focus();
|
||||
act(() => useAuthStore.setState({ connectionLost: false }));
|
||||
expect(document.activeElement).toBe(script);
|
||||
});
|
||||
|
||||
it('keeps the rule dialog and what was typed through a reload of the filters', () => {
|
||||
typeNewRule();
|
||||
act(() => useFilterStore.setState({ isLoading: true }));
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
act(() => useFilterStore.setState({ isLoading: false }));
|
||||
expect(screen.getByPlaceholderText('rule_name_placeholder')).toHaveValue('News');
|
||||
});
|
||||
|
||||
it('keeps the Sieve editor and what was typed through a reload of the filters', () => {
|
||||
render(<FilterSettings />);
|
||||
fireEvent.click(screen.getByText('raw_editor'));
|
||||
fireEvent.change(screen.getByLabelText('script_content'), { target: { value: 'keep;' } });
|
||||
act(() => useFilterStore.setState({ isLoading: true }));
|
||||
act(() => useFilterStore.setState({ isLoading: false }));
|
||||
expect(screen.getByLabelText('script_content')).toHaveValue('keep;');
|
||||
});
|
||||
|
||||
it('keeps the rule dialog through a reload that fails', () => {
|
||||
typeNewRule();
|
||||
act(() => useFilterStore.setState({ error: 'offline' }));
|
||||
expect(screen.getByPlaceholderText('rule_name_placeholder')).toHaveValue('News');
|
||||
});
|
||||
|
||||
it('still shows the loading state when no dialog is open', () => {
|
||||
render(<FilterSettings />);
|
||||
act(() => useFilterStore.setState({ isLoading: true }));
|
||||
expect(screen.getByText('loading')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import React from 'react';
|
||||
import type { FilterRule, SieveCapabilities } from '@/lib/jmap/sieve-types';
|
||||
import { getBrowserTimeZone } from '@/lib/timezone';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useEmailStore } from '@/stores/email-store';
|
||||
import { useFilterStore } from '@/stores/filter-store';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { FilterSettings } from '../filter-settings';
|
||||
|
||||
// Editing a rule on the settings page: the real page, rule dialog, filter
|
||||
// store and script generator; only the server write is caught. The dialog
|
||||
// hands back the whole rule and the page merges it into the stored one, so
|
||||
// whatever the dialog switches off has to come through that merge.
|
||||
|
||||
const ORIGINAL_TZ = vi.hoisted(() => {
|
||||
const original = process.env.TZ;
|
||||
process.env.TZ = 'UTC';
|
||||
return original;
|
||||
});
|
||||
beforeAll(() => {
|
||||
expect(getBrowserTimeZone()).toBe('UTC');
|
||||
});
|
||||
afterAll(() => {
|
||||
if (ORIGINAL_TZ === undefined) delete process.env.TZ;
|
||||
else process.env.TZ = ORIGINAL_TZ;
|
||||
});
|
||||
|
||||
const toastError = vi.hoisted(() => vi.fn());
|
||||
vi.mock('@/stores/toast-store', () => ({ toast: { error: toastError, success: vi.fn(), info: vi.fn() } }));
|
||||
|
||||
const CAPABILITIES: SieveCapabilities = {
|
||||
implementation: 'Stalwart',
|
||||
maxSizeScript: 102400,
|
||||
sieveExtensions: ['fileinto', 'mailbox', 'mailboxid', 'imap4flags', 'date', 'relational', 'spamtest', 'spamtestplus', 'comparator-i;ascii-numeric'],
|
||||
notificationMethods: [],
|
||||
externalLists: [],
|
||||
maxNumberRedirects: 1,
|
||||
};
|
||||
|
||||
const rule = (extra: Partial<FilterRule> = {}): FilterRule => ({
|
||||
id: 'r1',
|
||||
name: 'ZRTEST',
|
||||
enabled: true,
|
||||
matchType: 'all',
|
||||
conditions: [{ field: 'from', comparator: 'contains', value: '@' }],
|
||||
actions: [{ type: 'star' }],
|
||||
stopProcessing: false,
|
||||
...extra,
|
||||
});
|
||||
|
||||
const initial = {
|
||||
auth: useAuthStore.getState(),
|
||||
email: useEmailStore.getState(),
|
||||
filter: useFilterStore.getState(),
|
||||
settings: useSettingsStore.getState(),
|
||||
};
|
||||
|
||||
let updateSieveScript: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
toastError.mockClear();
|
||||
updateSieveScript = vi.fn(async () => {});
|
||||
useSettingsStore.setState({ timeZone: 'Europe/Berlin', dateLocale: 'en-GB', timeFormat: '24h' });
|
||||
useAuthStore.setState({ client: { updateSieveScript, getSieveAccountId: () => 'primary' } as never });
|
||||
useEmailStore.setState({ fetchMailboxes: vi.fn(async () => {}) as never });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
useAuthStore.setState(initial.auth, true);
|
||||
useEmailStore.setState(initial.email, true);
|
||||
useFilterStore.setState(initial.filter, true);
|
||||
useSettingsStore.setState(initial.settings, true);
|
||||
});
|
||||
|
||||
function showSettings(stored: FilterRule) {
|
||||
useFilterStore.setState({
|
||||
rules: [stored],
|
||||
isSupported: true,
|
||||
isLoading: false,
|
||||
isOpaque: false,
|
||||
activeScriptId: 'script-1',
|
||||
selectedAccountId: 'primary',
|
||||
sieveCapabilities: CAPABILITIES,
|
||||
selectAccount: vi.fn(async () => {}),
|
||||
});
|
||||
render(<FilterSettings />);
|
||||
}
|
||||
|
||||
function openSettings(stored: FilterRule) {
|
||||
showSettings(stored);
|
||||
fireEvent.click(screen.getByText(stored.name));
|
||||
return within(screen.getByRole('dialog'));
|
||||
}
|
||||
|
||||
async function saveAndReadBack(dialog: ReturnType<typeof within>) {
|
||||
fireEvent.click(dialog.getByText('save'));
|
||||
await waitFor(() => expect(updateSieveScript).toHaveBeenCalledTimes(1));
|
||||
expect(toastError).not.toHaveBeenCalled();
|
||||
return { stored: useFilterStore.getState().rules[0], script: updateSieveScript.mock.calls[0][1] as string };
|
||||
}
|
||||
|
||||
describe('saving an edited rule on the settings page', () => {
|
||||
it('drops an end that was cleared, along with a moved start', async () => {
|
||||
// 1 Oct 05:14 - 07:14 in Berlin; then the start goes to 2 Oct 05:14 and
|
||||
// the end is cleared in the picker.
|
||||
const dialog = openSettings(rule({ activeFrom: '2026-10-01T03:14:00.000Z', activeUntil: '2026-10-01T05:14:00.000Z' }));
|
||||
fireEvent.change(dialog.getByLabelText('period_start'), { target: { value: '2026-10-02T05:14' } });
|
||||
fireEvent.change(dialog.getByLabelText('period_end'), { target: { value: '' } });
|
||||
const { stored, script } = await saveAndReadBack(dialog);
|
||||
|
||||
expect(stored.activeFrom).toBe('2026-10-02T03:14:00.000Z');
|
||||
expect(stored.activeUntil).toBeUndefined();
|
||||
expect(script).toContain('currentdate :zone "+0000" :value "ge" "time" "03:14:00"');
|
||||
expect(script).not.toContain('"le" "time"');
|
||||
expect(script).not.toContain('activeUntil');
|
||||
expect(screen.getByText(/2 Oct 2026, 05:14 – …/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('removes the period when it is switched off', async () => {
|
||||
const dialog = openSettings(rule({ activeFrom: '2026-10-01T03:14:00.000Z', activeUntil: '2026-10-01T05:14:00.000Z' }));
|
||||
fireEvent.click(dialog.getByLabelText('period_toggle'));
|
||||
const { stored, script } = await saveAndReadBack(dialog);
|
||||
|
||||
expect(stored.activeFrom).toBeUndefined();
|
||||
expect(stored.activeUntil).toBeUndefined();
|
||||
expect(script).not.toContain('currentdate');
|
||||
expect(script).not.toContain('"date"');
|
||||
});
|
||||
|
||||
it('lets a folder rule stop taking spam out of Junk again', async () => {
|
||||
const dialog = openSettings(rule({
|
||||
actions: [{ type: 'move', value: 'News', mailboxId: 'mb-news' }],
|
||||
includeSpam: true,
|
||||
}));
|
||||
fireEvent.click(dialog.getByLabelText('include_spam'));
|
||||
const { stored, script } = await saveAndReadBack(dialog);
|
||||
|
||||
expect(stored.includeSpam).toBeUndefined();
|
||||
expect(script).toContain('not spamtest :percent :value "ge"');
|
||||
expect(script).not.toContain('includeSpam');
|
||||
});
|
||||
|
||||
it('counts the vacation forwarding that keeps a copy against the redirect limit', () => {
|
||||
const forwarding = rule({ actions: [{ type: 'forward', value: 'chef@example.com' }] });
|
||||
const vacationForward = { enabled: true, to: 'kollege@example.com', keepCopy: true };
|
||||
// CAPABILITIES allows one redirect per message.
|
||||
const warningFor = (state: Record<string, unknown>) => {
|
||||
useFilterStore.setState(state);
|
||||
const dialog = openSettings(forwarding);
|
||||
const shown = dialog.queryByText('forward_limit') !== null;
|
||||
cleanup();
|
||||
return shown;
|
||||
};
|
||||
// With the auto-reply or without: forwarding runs either way.
|
||||
expect(warningFor({ includeVacation: true, vacationForward })).toBe(true);
|
||||
expect(warningFor({ includeVacation: false, vacationForward })).toBe(true);
|
||||
// Not when only forwarded (the rules do not run then), nor while it is off.
|
||||
expect(warningFor({ includeVacation: true, vacationForward: { ...vacationForward, keepCopy: false } })).toBe(false);
|
||||
expect(warningFor({ includeVacation: true, vacationForward: { ...vacationForward, enabled: false } })).toBe(false);
|
||||
});
|
||||
|
||||
it('counts the vacation forwarding that keeps a copy in the list of rules too', () => {
|
||||
const forwarding = rule({ actions: [{ type: 'forward', value: 'chef@example.com' }] });
|
||||
const vacationForward = { enabled: true, to: 'kollege@example.com', keepCopy: true };
|
||||
const listWarns = (state: Record<string, unknown>) => {
|
||||
useFilterStore.setState(state);
|
||||
showSettings(forwarding);
|
||||
const shown = screen.queryByText('forward_limit') !== null;
|
||||
cleanup();
|
||||
return shown;
|
||||
};
|
||||
expect(listWarns({ vacationForward })).toBe(true);
|
||||
// Only forwarded, a message never reaches the rules.
|
||||
expect(listWarns({ vacationForward: { ...vacationForward, keepCopy: false } })).toBe(false);
|
||||
expect(listWarns({ vacationForward: { ...vacationForward, enabled: false } })).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps what the dialog left alone', async () => {
|
||||
const period = { activeFrom: '2026-10-01T03:14:30.000Z', activeUntil: '2026-10-01T05:14:00.000Z' };
|
||||
const dialog = openSettings(rule({ ...period, actions: [{ type: 'move', value: 'News', mailboxId: 'mb-news' }], includeSpam: true }));
|
||||
const { stored } = await saveAndReadBack(dialog);
|
||||
|
||||
expect(stored).toMatchObject({ ...period, includeSpam: true, name: 'ZRTEST', enabled: true });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
import { render, screen, within, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { FolderSettings } from '../folder-settings';
|
||||
import { useEmailStore } from '@/stores/email-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import type { Mailbox } from '@/lib/jmap/types';
|
||||
|
||||
// #1173: tick several folders (Shift+click for a range) and move them under
|
||||
// one parent in a single step.
|
||||
|
||||
vi.mock('../settings-section', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../settings-section')>()),
|
||||
SettingsSection: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
function makeMailbox(overrides: Partial<Mailbox>): Mailbox {
|
||||
return {
|
||||
id: 'mb',
|
||||
name: 'Folder',
|
||||
sortOrder: 0,
|
||||
totalEmails: 0,
|
||||
unreadEmails: 0,
|
||||
totalThreads: 0,
|
||||
unreadThreads: 0,
|
||||
myRights: {
|
||||
mayReadItems: true,
|
||||
mayAddItems: true,
|
||||
mayRemoveItems: true,
|
||||
maySetSeen: true,
|
||||
maySetKeywords: true,
|
||||
mayCreateChild: true,
|
||||
mayRename: true,
|
||||
mayDelete: true,
|
||||
maySubmit: true,
|
||||
},
|
||||
isSubscribed: true,
|
||||
isShared: false,
|
||||
...overrides,
|
||||
} as Mailbox;
|
||||
}
|
||||
|
||||
const client = {};
|
||||
let moveMailboxes: ReturnType<typeof vi.fn>;
|
||||
|
||||
// The folder name appears in the row and in the role dropdowns; the row's
|
||||
// label is the span next to the checkbox.
|
||||
function rowFor(name: string): HTMLElement {
|
||||
const label = screen.getAllByText(name).find(el => el.tagName === 'SPAN')!;
|
||||
return label.parentElement!;
|
||||
}
|
||||
|
||||
const checkboxFor = (name: string) => within(rowFor(name)).getByRole('checkbox');
|
||||
const isTicked = (name: string) => checkboxFor(name).getAttribute('aria-checked') === 'true';
|
||||
|
||||
function destinationOptions(): string[] {
|
||||
const select = screen.getByRole('combobox', { name: 'move_to' });
|
||||
return within(select).getAllByRole('option').map(o => (o as HTMLOptionElement).value);
|
||||
}
|
||||
|
||||
describe('FolderSettings bulk move (#1173)', () => {
|
||||
beforeEach(() => {
|
||||
moveMailboxes = vi.fn().mockResolvedValue([]);
|
||||
useAuthStore.setState({ client } as never);
|
||||
useEmailStore.setState({
|
||||
mailboxes: [
|
||||
makeMailbox({ id: 'inbox', name: 'Inbox', role: 'inbox' }),
|
||||
makeMailbox({ id: 'a1', name: 'A1' }),
|
||||
makeMailbox({ id: 'a2', name: 'A2' }),
|
||||
makeMailbox({ id: 'a2-child', name: 'A2 child', parentId: 'a2' }),
|
||||
makeMailbox({ id: 'a3', name: 'A3' }),
|
||||
makeMailbox({ id: 'a4', name: 'A4' }),
|
||||
makeMailbox({ id: 'old', name: 'Old mailbox' }),
|
||||
],
|
||||
fetchMailboxes: vi.fn(),
|
||||
moveMailboxes,
|
||||
} as never);
|
||||
});
|
||||
|
||||
it('ticks the visible range on Shift+click', () => {
|
||||
render(<FolderSettings />);
|
||||
expect(screen.queryByRole('combobox', { name: 'move_to' })).toBeNull();
|
||||
|
||||
fireEvent.click(checkboxFor('A1'));
|
||||
fireEvent.click(checkboxFor('A3'), { shiftKey: true });
|
||||
|
||||
expect(['A1', 'A2', 'A3'].map(isTicked)).toEqual([true, true, true]);
|
||||
expect(['Inbox', 'A4', 'Old mailbox'].map(isTicked)).toEqual([false, false, false]);
|
||||
expect(screen.getByText('selected_count')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('unticks the range when the Shift+clicked folder was ticked', () => {
|
||||
render(<FolderSettings />);
|
||||
|
||||
fireEvent.click(checkboxFor('A1'));
|
||||
fireEvent.click(checkboxFor('A4'), { shiftKey: true });
|
||||
fireEvent.click(checkboxFor('A3'));
|
||||
fireEvent.click(checkboxFor('A2'), { shiftKey: true });
|
||||
|
||||
expect(['A1', 'A2', 'A3', 'A4'].map(isTicked)).toEqual([true, false, false, true]);
|
||||
});
|
||||
|
||||
it('moves the ticked folders and leaves them out of the destinations', async () => {
|
||||
render(<FolderSettings />);
|
||||
|
||||
fireEvent.click(checkboxFor('A1'));
|
||||
fireEvent.click(checkboxFor('A3'), { shiftKey: true });
|
||||
|
||||
// A2's child would put A2 inside itself.
|
||||
expect(destinationOptions()).toEqual(['', '__top__', 'inbox', 'a4', 'old']);
|
||||
|
||||
fireEvent.change(screen.getByRole('combobox', { name: 'move_to' }), { target: { value: 'old' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'move' }));
|
||||
|
||||
await waitFor(() => expect(moveMailboxes).toHaveBeenCalledWith(client, ['a1', 'a2', 'a3'], 'old'));
|
||||
await waitFor(() => expect(isTicked('A1')).toBe(false));
|
||||
});
|
||||
|
||||
it('moves a ticked subfolder together with its ticked parent', async () => {
|
||||
render(<FolderSettings />);
|
||||
|
||||
fireEvent.click(within(rowFor('A2')).getAllByRole('button')[0]); // expand
|
||||
fireEvent.click(checkboxFor('A2'));
|
||||
fireEvent.click(checkboxFor('A2 child'));
|
||||
fireEvent.change(screen.getByRole('combobox', { name: 'move_to' }), { target: { value: 'old' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'move' }));
|
||||
|
||||
await waitFor(() => expect(moveMailboxes).toHaveBeenCalledWith(client, ['a2'], 'old'));
|
||||
});
|
||||
|
||||
it('keeps the refused folders ticked', async () => {
|
||||
moveMailboxes.mockResolvedValue(['a2']);
|
||||
render(<FolderSettings />);
|
||||
|
||||
fireEvent.click(checkboxFor('A1'));
|
||||
fireEvent.click(checkboxFor('A2'), { shiftKey: true });
|
||||
fireEvent.change(screen.getByRole('combobox', { name: 'move_to' }), { target: { value: '__top__' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'move' }));
|
||||
|
||||
// Both are already top-level, so nothing needs to move.
|
||||
await waitFor(() => expect(screen.queryByRole('combobox', { name: 'move_to' })).toBeNull());
|
||||
expect(moveMailboxes).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(checkboxFor('A1'));
|
||||
fireEvent.click(checkboxFor('A2'), { shiftKey: true });
|
||||
fireEvent.change(screen.getByRole('combobox', { name: 'move_to' }), { target: { value: 'old' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'move' }));
|
||||
|
||||
await waitFor(() => expect(moveMailboxes).toHaveBeenCalledWith(client, ['a1', 'a2'], 'old'));
|
||||
await waitFor(() => expect(isTicked('A1')).toBe(false));
|
||||
expect(isTicked('A2')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,474 @@
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import React from 'react';
|
||||
import type { FilterRule } from '@/lib/jmap/sieve-types';
|
||||
import { generateScript } from '@/lib/sieve/generator';
|
||||
import { parseScript } from '@/lib/sieve/parser';
|
||||
import { STALWART_VACATION_SCRIPT, mockStalwartAccount } from '@/lib/filters/__tests__/sieve-mock';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useFilterStore } from '@/stores/filter-store';
|
||||
import { useIdentityStore } from '@/stores/identity-store';
|
||||
import { useManagedAccountStore } from '@/stores/managed-account-store';
|
||||
import { useVacationStore } from '@/stores/vacation-store';
|
||||
import { VacationSettings } from '../vacation-settings';
|
||||
|
||||
// The vacation card as it is used: the real card, vacation store, filter
|
||||
// sync and script generator, against a server that behaves like Stalwart
|
||||
// (turning the auto-reply on activates its own script and switches every
|
||||
// other script off).
|
||||
|
||||
const toast = vi.hoisted(() => ({ error: vi.fn(), success: vi.fn(), info: vi.fn() }));
|
||||
vi.mock('@/stores/toast-store', () => ({ toast }));
|
||||
|
||||
// next-intl hands out the same translate function on every render; the card's
|
||||
// validation effect depends on it, so the shared mock's fresh function per
|
||||
// call would make the effect run forever.
|
||||
vi.mock('next-intl', () => {
|
||||
const t = (key: string, values?: Record<string, unknown>) => (values ? `${key} ${Object.values(values).join(' ')}` : key);
|
||||
return { useTranslations: () => t, useLocale: () => 'en' };
|
||||
});
|
||||
|
||||
const EXTENSIONS = ['fileinto', 'mailbox', 'mailboxid', 'imap4flags', 'include', 'envelope', 'copy', 'date', 'relational', 'spamtestplus', 'comparator-i;ascii-numeric'];
|
||||
|
||||
const rule = (id: string, extra: Partial<FilterRule> = {}): FilterRule => ({
|
||||
id,
|
||||
name: `Rule ${id}`,
|
||||
enabled: true,
|
||||
matchType: 'all',
|
||||
conditions: [{ field: 'from', comparator: 'contains', value: `${id}@example.net` }],
|
||||
actions: [{ type: 'mark_read' }],
|
||||
stopProcessing: false,
|
||||
...extra,
|
||||
});
|
||||
|
||||
/** A Stalwart-like account whose active filters script holds `rules`, away 5-16 Oct. */
|
||||
const stalwart = (rules: FilterRule[], options: { extensions?: string[]; maxNumberRedirects?: number } = {}) => {
|
||||
const extensions = options.extensions ?? EXTENSIONS;
|
||||
return mockStalwartAccount('b', [
|
||||
{ name: 'filters', content: generateScript(rules, undefined, { extensions }), isActive: true },
|
||||
], extensions, {
|
||||
// Left out, the server allows one redirect, as Stalwart does by default.
|
||||
...(options.maxNumberRedirects !== undefined ? { maxNumberRedirects: options.maxNumberRedirects } : {}),
|
||||
vacation: {
|
||||
fromDate: '2026-10-05T06:00:00Z',
|
||||
toDate: '2026-10-16T16:00:00Z',
|
||||
subject: 'Abwesend',
|
||||
textBody: 'Ich bin nicht da.',
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const initial = {
|
||||
auth: useAuthStore.getState(),
|
||||
vacation: useVacationStore.getState(),
|
||||
filter: useFilterStore.getState(),
|
||||
identity: useIdentityStore.getState(),
|
||||
managed: useManagedAccountStore.getState(),
|
||||
};
|
||||
beforeEach(() => {
|
||||
toast.error.mockClear();
|
||||
toast.success.mockClear();
|
||||
});
|
||||
afterEach(() => {
|
||||
useAuthStore.setState(initial.auth, true);
|
||||
useVacationStore.setState(initial.vacation, true);
|
||||
useFilterStore.setState(initial.filter, true);
|
||||
useIdentityStore.setState(initial.identity, true);
|
||||
useManagedAccountStore.setState(initial.managed, true);
|
||||
});
|
||||
|
||||
async function openCard(server: ReturnType<typeof stalwart>) {
|
||||
useAuthStore.setState({ client: server.client as never });
|
||||
useVacationStore.setState({ isSupported: true });
|
||||
render(<VacationSettings />);
|
||||
await screen.findByText('status.label');
|
||||
}
|
||||
|
||||
const forwardSwitch = () => screen.getByRole('switch', { name: 'forward.enabled_label' });
|
||||
const keepSwitch = () => screen.getByRole('switch', { name: 'forward.keep_label' });
|
||||
const addressInput = () => screen.getByLabelText('forward.to_label') as HTMLInputElement;
|
||||
const saveButton = () => screen.getByText('save').closest('button') as HTMLButtonElement;
|
||||
|
||||
async function save(server: ReturnType<typeof stalwart>) {
|
||||
fireEvent.click(saveButton());
|
||||
await waitFor(() => expect(server.client.setVacationResponse).toHaveBeenCalled());
|
||||
await waitFor(() => expect(toast.success.mock.calls.length + toast.error.mock.calls.length).toBe(1));
|
||||
}
|
||||
|
||||
describe('forwarding in the vacation card', () => {
|
||||
it('is offered only where the server can run it next to the auto-reply', async () => {
|
||||
await openCard(stalwart([rule('a')], { extensions: ['fileinto', 'imap4flags'] }));
|
||||
expect(screen.queryByText('forward.title')).toBeNull();
|
||||
});
|
||||
|
||||
it('turns the auto-reply on with forwarding: only forwarded, in its period, ahead of the rules', async () => {
|
||||
const server = stalwart([rule('a')]);
|
||||
await openCard(server);
|
||||
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' } });
|
||||
await save(server);
|
||||
|
||||
expect(toast.success).toHaveBeenCalledWith('vacation_saved');
|
||||
expect(server.client.setVacationResponse.mock.calls[0][0]).not.toHaveProperty('forward');
|
||||
expect(server.active()).toBe('filters');
|
||||
const written = server.content('filters');
|
||||
expect(parseScript(written).vacationForward).toEqual({
|
||||
enabled: true,
|
||||
to: 'kollege@example.com',
|
||||
keepCopy: false,
|
||||
activeFrom: '2026-10-05T06:00:00Z',
|
||||
activeUntil: '2026-10-16T16:00:00Z',
|
||||
});
|
||||
expect(written).toContain('include :personal :optional "vacation";');
|
||||
expect(written).toMatch(/# Vacation forwarding\nif allof\(.*"2026-10-05".*"2026-10-16".*not spamtest[^\n]*\) \{\n {4}redirect "kollege@example\.com";\n {4}stop;\n\}/);
|
||||
expect(written.indexOf('# Vacation forwarding')).toBeLessThan(written.indexOf('# Rule: Rule a'));
|
||||
});
|
||||
|
||||
it('forwards without sending any auto-reply', async () => {
|
||||
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' } });
|
||||
await save(server);
|
||||
|
||||
expect(toast.success).toHaveBeenCalledWith('vacation_saved');
|
||||
expect(server.client.setVacationResponse.mock.calls[0][0]).toMatchObject({ isEnabled: false });
|
||||
expect(server.scripts.some((s) => s.name === 'vacation' && s.isActive)).toBe(false);
|
||||
expect(server.active()).toBe('filters');
|
||||
const written = server.content('filters');
|
||||
expect(written).not.toContain('include');
|
||||
expect(written).toMatch(/# Vacation forwarding\n[^\n]*\n {4}redirect "kollege@example\.com";\n {4}stop;\n\}/);
|
||||
});
|
||||
|
||||
it('keeps forwarding through a later save, also without any filter rule', async () => {
|
||||
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' } });
|
||||
await save(server);
|
||||
expect(server.active()).toBe('filters');
|
||||
toast.success.mockClear();
|
||||
|
||||
// Saving again turns Stalwart's own vacation script back on.
|
||||
fireEvent.change(screen.getByPlaceholderText('message.subject_placeholder'), { target: { value: 'Bis bald' } });
|
||||
fireEvent.click(saveButton());
|
||||
await waitFor(() => expect(toast.success).toHaveBeenCalledTimes(1));
|
||||
expect(server.client.setVacationResponse).toHaveBeenCalledTimes(2);
|
||||
expect(server.active()).toBe('filters');
|
||||
expect(server.content('filters')).toContain('# Vacation forwarding');
|
||||
});
|
||||
|
||||
it('keeps a copy here when asked to', async () => {
|
||||
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(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 () => {
|
||||
const server = stalwart([rule('a')]);
|
||||
await openCard(server);
|
||||
fireEvent.click(forwardSwitch());
|
||||
fireEvent.change(addressInput(), { target: { value: 'kollege' } });
|
||||
expect(screen.getByText('warnings.forward_address')).toBeInTheDocument();
|
||||
expect(saveButton().disabled).toBe(true);
|
||||
fireEvent.change(addressInput(), { target: { value: 'kollege@example.com' } });
|
||||
expect(screen.queryByText('warnings.forward_address')).toBeNull();
|
||||
expect(saveButton().disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('does not count an address typed and switched off again as a change', async () => {
|
||||
const server = stalwart([rule('a')]);
|
||||
await openCard(server);
|
||||
fireEvent.click(forwardSwitch());
|
||||
fireEvent.change(addressInput(), { target: { value: 'kollege' } });
|
||||
fireEvent.click(forwardSwitch());
|
||||
// Nothing a save would store is different, so there is nothing to save.
|
||||
expect(saveButton().disabled).toBe(true);
|
||||
});
|
||||
|
||||
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' } });
|
||||
expect(screen.queryByText(/^forward_limit/)).toBeNull();
|
||||
fireEvent.click(keepSwitch());
|
||||
expect(screen.getByText('forward_limit 1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the stored forwarding, stops it with the switch and keeps the address', async () => {
|
||||
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' } });
|
||||
await save(server);
|
||||
toast.success.mockClear();
|
||||
|
||||
// Back as stored after the save, as the server has it now.
|
||||
expect(useVacationStore.getState().forward).toMatchObject({ enabled: true, to: 'kollege@example.com' });
|
||||
expect(forwardSwitch().getAttribute('aria-checked')).toBe('true');
|
||||
expect(addressInput().value).toBe('kollege@example.com');
|
||||
expect(saveButton().disabled).toBe(true);
|
||||
|
||||
fireEvent.click(forwardSwitch());
|
||||
fireEvent.click(saveButton());
|
||||
await waitFor(() => expect(toast.success).toHaveBeenCalledTimes(1));
|
||||
const written = server.content('filters');
|
||||
expect(written).not.toContain('# Vacation forwarding');
|
||||
expect(written).not.toContain('redirect');
|
||||
expect(parseScript(written).vacationForward).toMatchObject({ enabled: false, to: 'kollege@example.com' });
|
||||
});
|
||||
|
||||
it('opens with the forwarding the server has', async () => {
|
||||
const server = stalwart([rule('a')]);
|
||||
const forward = { enabled: true, to: 'kollege@example.com', keepCopy: true };
|
||||
await server.client.updateSieveScript(
|
||||
server.scripts[0].id,
|
||||
generateScript([rule('a')], undefined, { extensions: EXTENSIONS, vacationForward: forward }),
|
||||
true,
|
||||
'b',
|
||||
);
|
||||
await openCard(server);
|
||||
expect(forwardSwitch().getAttribute('aria-checked')).toBe('true');
|
||||
expect(addressInput().value).toBe('kollege@example.com');
|
||||
expect(keepSwitch().getAttribute('aria-checked')).toBe('true');
|
||||
expect(saveButton().disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('says so when the auto-reply was saved but the forwarding was not', async () => {
|
||||
const server = stalwart([rule('a')]);
|
||||
await openCard(server);
|
||||
server.client.updateSieveScript.mockRejectedValueOnce(new Error('server said no'));
|
||||
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(toast.success).not.toHaveBeenCalled();
|
||||
// What was set stays, to be saved again.
|
||||
expect(forwardSwitch().getAttribute('aria-checked')).toBe('true');
|
||||
expect(saveButton().disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('saves only what was changed here, not forwarding another tab changed since', async () => {
|
||||
const server = stalwart([rule('a')]);
|
||||
const stored = (enabled: boolean) => generateScript([rule('a')], undefined, {
|
||||
extensions: EXTENSIONS,
|
||||
vacationForward: { enabled, to: 'kollege@example.com', keepCopy: false },
|
||||
});
|
||||
await server.client.updateSieveScript(server.scripts[0].id, stored(true), true, 'b');
|
||||
await openCard(server);
|
||||
expect(forwardSwitch().getAttribute('aria-checked')).toBe('true');
|
||||
|
||||
// Switched off elsewhere; here only the subject changes.
|
||||
await server.client.updateSieveScript(server.scripts[0].id, stored(false), true, 'b');
|
||||
fireEvent.change(screen.getByPlaceholderText('message.subject_placeholder'), { target: { value: 'Bis bald' } });
|
||||
await save(server);
|
||||
expect(toast.success).toHaveBeenCalledWith('vacation_saved');
|
||||
expect(parseScript(server.content('filters')).vacationForward).toMatchObject({ enabled: false });
|
||||
expect(server.content('filters')).not.toContain('redirect');
|
||||
});
|
||||
|
||||
it('says when stored forwarding does not run, and a save sets it right', async () => {
|
||||
const server = mockStalwartAccount('b', [
|
||||
{
|
||||
name: 'filters',
|
||||
content: generateScript([rule('a')], undefined, {
|
||||
extensions: EXTENSIONS,
|
||||
includeVacation: true,
|
||||
vacationForward: { enabled: true, to: 'kollege@example.com', keepCopy: false },
|
||||
}),
|
||||
isActive: false,
|
||||
},
|
||||
// Stalwart's own script took over: it answers, nothing is forwarded.
|
||||
{ name: 'vacation', content: STALWART_VACATION_SCRIPT, isActive: true },
|
||||
], EXTENSIONS);
|
||||
await openCard(server);
|
||||
expect(screen.getByText('warnings.not_running')).toBeInTheDocument();
|
||||
expect(saveButton().disabled).toBe(false);
|
||||
|
||||
await save(server);
|
||||
expect(toast.success).toHaveBeenCalledWith('vacation_saved');
|
||||
expect(server.active()).toBe('filters');
|
||||
expect(server.content('filters')).toContain('# Vacation forwarding');
|
||||
expect(screen.queryByText('warnings.not_running')).toBeNull();
|
||||
expect(saveButton().disabled).toBe(true);
|
||||
});
|
||||
|
||||
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' } });
|
||||
expect(saveButton().disabled).toBe(false);
|
||||
|
||||
// Neither account has forwarding stored, so no stored value changes.
|
||||
useAuthStore.setState({ activeAccountId: 'other-login' });
|
||||
await waitFor(() => expect(forwardSwitch().getAttribute('aria-checked')).toBe('false'));
|
||||
expect(saveButton().disabled).toBe(true);
|
||||
fireEvent.click(forwardSwitch());
|
||||
expect(addressInput().value).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the vacation period in the card', () => {
|
||||
const dateField = (index: number) => document.querySelectorAll('input[type="datetime-local"]')[index] as HTMLInputElement;
|
||||
// jsdom never holds a field half filled in: let it say so, as a browser does.
|
||||
const halfFilledIn = (input: HTMLInputElement, half: boolean) =>
|
||||
Object.defineProperty(input, 'validity', { value: { badInput: half }, configurable: true });
|
||||
|
||||
it('will not take a date without its time for no date at all', async () => {
|
||||
const server = stalwart([rule('a')]);
|
||||
await openCard(server);
|
||||
// A browser reports the end as empty once its time is cleared.
|
||||
halfFilledIn(dateField(1), true);
|
||||
fireEvent.change(dateField(1), { target: { value: '' } });
|
||||
expect(screen.getByText('warnings.invalid_date')).toBeInTheDocument();
|
||||
expect(saveButton().disabled).toBe(true);
|
||||
|
||||
halfFilledIn(dateField(1), false);
|
||||
fireEvent.change(dateField(1), { target: { value: '2026-10-17T18:00' } });
|
||||
expect(screen.queryByText('warnings.invalid_date')).toBeNull();
|
||||
expect(saveButton().disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('checks the fields again on save, which a field filled in only half may never have told', async () => {
|
||||
const server = stalwart([rule('a')]);
|
||||
await openCard(server);
|
||||
fireEvent.change(dateField(1), { target: { value: '' } });
|
||||
halfFilledIn(dateField(1), true);
|
||||
fireEvent.click(saveButton());
|
||||
expect(await screen.findByText('warnings.invalid_date')).toBeInTheDocument();
|
||||
expect(server.client.setVacationResponse).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('will not save a year with more than four digits', async () => {
|
||||
const server = stalwart([rule('a')]);
|
||||
await openCard(server);
|
||||
fireEvent.change(dateField(1), { target: { value: '20266-10-16T18:00' } });
|
||||
expect(dateField(1).value).toBe('20266-10-16T18:00');
|
||||
expect(screen.getByText('warnings.invalid_date')).toBeInTheDocument();
|
||||
expect(saveButton().disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps the dates the server has while their fields are left alone', async () => {
|
||||
const server = stalwart([rule('a')]);
|
||||
await openCard(server);
|
||||
fireEvent.change(screen.getByPlaceholderText('message.subject_placeholder'), { target: { value: 'Bis bald' } });
|
||||
await save(server);
|
||||
expect(server.client.setVacationResponse.mock.calls[0][0]).toMatchObject({
|
||||
fromDate: '2026-10-05T06:00:00Z',
|
||||
toDate: '2026-10-16T16:00:00Z',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('who gets the auto-reply, in the vacation card', () => {
|
||||
const identities = ['max@example.com', 'info@example.com', 'shop@example.org'];
|
||||
const withIdentities = () =>
|
||||
useIdentityStore.setState({ identities: identities.map((email, i) => ({ id: `i${i}`, name: '', email })) as never });
|
||||
const audienceSelect = () => screen.getByRole('combobox', { name: 'audience.label' }) as HTMLSelectElement;
|
||||
|
||||
it('is offered with the own domains taken from the identities', async () => {
|
||||
withIdentities();
|
||||
await openCard(stalwart([rule('a')]));
|
||||
expect(audienceSelect().value).toBe('all');
|
||||
expect(screen.getByText('audience.description example.com, example.org')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('is not offered without identities to tell the own domains', async () => {
|
||||
await openCard(stalwart([rule('a')]));
|
||||
expect(screen.queryByRole('combobox', { name: 'audience.label' })).toBeNull();
|
||||
});
|
||||
|
||||
it('is not offered on a shared account, whose domains the signed-in identities do not tell', async () => {
|
||||
withIdentities();
|
||||
useManagedAccountStore.setState({ managedAccountId: 'b' } as never);
|
||||
await openCard(stalwart([rule('a')]));
|
||||
expect(screen.queryByRole('combobox', { name: 'audience.label' })).toBeNull();
|
||||
});
|
||||
|
||||
it('answers external senders only', async () => {
|
||||
withIdentities();
|
||||
const server = stalwart([rule('a')]);
|
||||
await openCard(server);
|
||||
fireEvent.click(screen.getByRole('switch', { name: 'status.label' }));
|
||||
fireEvent.change(audienceSelect(), { target: { value: 'external' } });
|
||||
await save(server);
|
||||
|
||||
expect(toast.success).toHaveBeenCalledWith('vacation_saved');
|
||||
expect(server.active()).toBe('filters');
|
||||
const written = server.content('filters');
|
||||
expect(written).toContain([
|
||||
'# Vacation auto-reply',
|
||||
'if not envelope :domain :is "from" ["example.com", "example.org"] {',
|
||||
' include :personal :optional "vacation";',
|
||||
'}',
|
||||
].join('\n'));
|
||||
expect(parseScript(written).vacationAudience).toEqual({ only: 'external', domains: ['example.com', 'example.org'] });
|
||||
});
|
||||
|
||||
it('opens with the stored choice and goes back to everyone', async () => {
|
||||
withIdentities();
|
||||
const server = stalwart([rule('a')]);
|
||||
await server.client.updateSieveScript(
|
||||
server.scripts[0].id,
|
||||
generateScript([rule('a')], undefined, {
|
||||
extensions: EXTENSIONS,
|
||||
includeVacation: true,
|
||||
vacationAudience: { only: 'internal', domains: ['example.com', 'example.org'] },
|
||||
}),
|
||||
true,
|
||||
'b',
|
||||
);
|
||||
// The auto-reply is on, run from the filters script.
|
||||
await server.client.createSieveScript('vacation', STALWART_VACATION_SCRIPT, false, 'b');
|
||||
await openCard(server);
|
||||
expect(screen.getByRole('switch', { name: 'status.label' }).getAttribute('aria-checked')).toBe('true');
|
||||
expect(audienceSelect().value).toBe('internal');
|
||||
expect(saveButton().disabled).toBe(true);
|
||||
|
||||
fireEvent.change(audienceSelect(), { target: { value: 'all' } });
|
||||
await save(server);
|
||||
expect(server.active()).toBe('filters');
|
||||
const written = server.content('filters');
|
||||
expect(written).not.toContain('envelope :domain');
|
||||
expect(written).toContain('# Vacation auto-reply\ninclude :personal :optional "vacation";');
|
||||
expect(parseScript(written).vacationAudience).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
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 () => {
|
||||
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();
|
||||
|
||||
// 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.click(forwardSwitch());
|
||||
expect(within(forwardRow).getByText('status.active')).toBeInTheDocument();
|
||||
expect(within(replyRow).getByText('status.inactive')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -10,7 +10,7 @@ import { usePolicyStore } from '@/stores/policy-store';
|
||||
import { useUpdateStore } from '@/stores/update-store';
|
||||
import { ExternalLink } from '@/components/icons';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { getPathPrefix } from '@/lib/browser-navigation';
|
||||
import { getPathPrefix, withBasePath } from '@/lib/browser-navigation';
|
||||
import { clearCachedData } from '@/lib/clear-cached-data';
|
||||
import { SpamSiegeGame } from './spam-siege-game';
|
||||
|
||||
@@ -57,7 +57,12 @@ export function AboutDataSettings() {
|
||||
useSettingsStore();
|
||||
// A modified build must offer its own source (AGPL-3.0 §13), so a
|
||||
// configured URL replaces the upstream repository link.
|
||||
const { settingsSyncEnabled, sourceCodeUrl } = useConfig();
|
||||
const { settingsSyncEnabled, sourceCodeUrl, appName, appLogoLightUrl, appLogoDarkUrl } = useConfig();
|
||||
// A branded deployment (APP_NAME / APP_LOGO_*) shows its own name and logo
|
||||
// here; without them the card keeps the Bulwark mark and title. (#1160)
|
||||
const branded = appName && appName !== 'Webmail';
|
||||
const logoLight = withBasePath(appLogoLightUrl || appLogoDarkUrl) || `${getPathPrefix()}/branding/Bulwark_Logo_Color.svg`;
|
||||
const logoDark = withBasePath(appLogoDarkUrl || appLogoLightUrl) || `${getPathPrefix()}/branding/Bulwark_Logo_White.svg`;
|
||||
const [showResetConfirm, setShowResetConfirm] = useState(false);
|
||||
const [showRefreshConfirm, setShowRefreshConfirm] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -139,19 +144,19 @@ export function AboutDataSettings() {
|
||||
<button onClick={handleLogoClick} className="flex items-center gap-4 flex-1 text-start focus:outline-none group/about cursor-pointer" aria-label="About">
|
||||
<div className="shrink-0">
|
||||
<img
|
||||
src={`${getPathPrefix()}/branding/Bulwark_Logo_Color.svg`}
|
||||
alt="Bulwark"
|
||||
src={logoLight}
|
||||
alt={branded ? appName : 'Bulwark'}
|
||||
className="w-12 h-12 object-contain dark:hidden group-hover/about:scale-105 group-active/about:scale-95 transition-transform"
|
||||
/>
|
||||
<img
|
||||
src={`${getPathPrefix()}/branding/Bulwark_Logo_White.svg`}
|
||||
alt="Bulwark"
|
||||
src={logoDark}
|
||||
alt={branded ? appName : 'Bulwark'}
|
||||
className="w-12 h-12 object-contain hidden dark:block group-hover/about:scale-105 group-active/about:scale-95 transition-transform"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{t('about.title')}
|
||||
{branded ? appName : t('about.title')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground group-hover/about:translate-x-0.5 group-active/about:translate-y-px transition-transform">
|
||||
v{APP_VERSION} <span className="text-muted-foreground/60">({GIT_COMMIT})</span>
|
||||
|
||||
@@ -18,6 +18,7 @@ import { cn } from '@/lib/utils';
|
||||
import { sanitizeI18nHtml } from '@/lib/email-sanitization';
|
||||
import { IS_LITE } from '@/lib/lite';
|
||||
import { useConfig } from '@/hooks/use-config';
|
||||
import { totpIssuer } from '@/lib/totp-issuer';
|
||||
|
||||
function PasswordChangeSection() {
|
||||
const t = useTranslations('settings.security');
|
||||
@@ -195,9 +196,9 @@ function DisplayNameSection() {
|
||||
);
|
||||
}
|
||||
|
||||
function generateTotp(accountLabel: string): { totp: OTPAuth.TOTP; url: string } {
|
||||
function generateTotp(accountLabel: string, issuer: string): { totp: OTPAuth.TOTP; url: string } {
|
||||
const totp = new OTPAuth.TOTP({
|
||||
issuer: 'Stalwart',
|
||||
issuer,
|
||||
label: accountLabel || 'account',
|
||||
algorithm: 'SHA1',
|
||||
digits: 6,
|
||||
@@ -211,6 +212,7 @@ function TotpSection() {
|
||||
const t = useTranslations('settings.security');
|
||||
const { otpEnabled, enableTotp, disableTotp, isSaving, isLoadingAuth } = useAccountSecurityStore();
|
||||
const { client } = useAuthStore();
|
||||
const { appName } = useConfig();
|
||||
|
||||
const [setupUrl, setSetupUrl] = useState<string | null>(null);
|
||||
const [setupTotp, setSetupTotp] = useState<OTPAuth.TOTP | null>(null);
|
||||
@@ -230,7 +232,7 @@ function TotpSection() {
|
||||
}, [setupUrl]);
|
||||
|
||||
const startSetup = () => {
|
||||
const { totp, url } = generateTotp(client?.getUsername() ?? 'account');
|
||||
const { totp, url } = generateTotp(client?.getUsername() ?? 'account', totpIssuer(appName));
|
||||
setSetupTotp(totp);
|
||||
setSetupUrl(url);
|
||||
setPassword('');
|
||||
|
||||
@@ -7,6 +7,7 @@ import { SettingsSection, SettingItem, ToggleSwitch, RadioGroup } from "./settin
|
||||
import { loadFilesSettings, saveFilesSettings, type FilesSettings, type FolderLayout } from "@/components/files/files-settings-dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { withBasePath } from "@/lib/browser-navigation";
|
||||
import { useConfig } from "@/hooks/use-config";
|
||||
|
||||
interface SampleFile {
|
||||
name: string;
|
||||
@@ -57,8 +58,12 @@ function formatSize(bytes: number): string {
|
||||
}
|
||||
|
||||
function FilesSettingsPreview({ settings }: { settings: FilesSettings }) {
|
||||
// The sample thumbnail shows the configured logo on a branded deployment,
|
||||
// the bundled one otherwise. (#1160)
|
||||
const { appLogoLightUrl, appLogoDarkUrl } = useConfig();
|
||||
const thumbnailUrl = appLogoLightUrl || appLogoDarkUrl || "/branding/Bulwark_Logo_Color.png";
|
||||
const sortedFiles = useMemo(() => {
|
||||
let files = SAMPLE_FILES.filter((f) => {
|
||||
let files = SAMPLE_FILES.map((f) => (f.thumbnailUrl ? { ...f, thumbnailUrl } : f)).filter((f) => {
|
||||
if (!settings.showHiddenFiles && f.hidden) return false;
|
||||
if (settings.folderLayout === "sidebar" && f.isFolder) return false;
|
||||
return true;
|
||||
@@ -78,7 +83,7 @@ function FilesSettingsPreview({ settings }: { settings: FilesSettings }) {
|
||||
});
|
||||
|
||||
return files;
|
||||
}, [settings.showHiddenFiles, settings.folderLayout, settings.defaultSortKey, settings.defaultSortDir]);
|
||||
}, [settings.showHiddenFiles, settings.folderLayout, settings.defaultSortKey, settings.defaultSortDir, thumbnailUrl]);
|
||||
|
||||
const listView = (
|
||||
<div className="flex-1 min-w-0 overflow-hidden">
|
||||
|
||||
@@ -13,7 +13,9 @@ import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import type { FilterRule } from "@/lib/jmap/sieve-types";
|
||||
import type { Mailbox } from "@/lib/jmap/types";
|
||||
import { forwardsAround, worstCaseForwards } from "@/lib/filters/forward-limit";
|
||||
import { forwardsAround, inRunOrder, worstCaseForwards } from "@/lib/filters/forward-limit";
|
||||
import { isPeriodBoundary, periodStatus, supportsPeriods } from "@/lib/sieve/period";
|
||||
import { formatDateTime } from "@/lib/utils";
|
||||
import { useVacationStore } from "@/stores/vacation-store";
|
||||
import { useManagedAccountStore } from "@/stores/managed-account-store";
|
||||
import {
|
||||
@@ -33,8 +35,29 @@ function isReadonlyRule(r: FilterRule): boolean {
|
||||
return r.origin === "external" || r.origin === "opaque";
|
||||
}
|
||||
|
||||
/**
|
||||
* The rule's period ("5 Oct 2026, 08:00 – …") and, for a rule that is on,
|
||||
* where now falls in it; null without a period.
|
||||
*/
|
||||
function usePeriodLabel(rule: FilterRule): { range: string; status: string | null } | null {
|
||||
const t = useTranslations("settings.filters");
|
||||
const timeFormat = useSettingsStore((s) => s.timeFormat);
|
||||
const status = periodStatus(rule);
|
||||
if (!status) return null;
|
||||
const format = (boundary: string | undefined) => isPeriodBoundary(boundary)
|
||||
? formatDateTime(boundary, timeFormat, { day: "numeric", month: "short", year: "numeric" })
|
||||
: "…";
|
||||
return {
|
||||
range: `${format(rule.activeFrom)} – ${format(rule.activeUntil)}`,
|
||||
// A rule that is off does nothing in any period; "active" next to it
|
||||
// would say otherwise.
|
||||
status: rule.enabled ? t(`period_status_${status}`) : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function RuleSummary({ rule }: { rule: FilterRule }) {
|
||||
const t = useTranslations("settings.filters");
|
||||
const period = usePeriodLabel(rule);
|
||||
|
||||
const conditions = rule.conditions.slice(0, 2).map((c) => {
|
||||
const field = t(`condition_fields.${c.field}`);
|
||||
@@ -85,12 +108,14 @@ export function RuleSummary({ rule }: { rule: FilterRule }) {
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
{period && <span className="ms-1">· {period.range}{period.status && ` (${period.status})`}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function VisualRuleSummary({ rule }: { rule: FilterRule }) {
|
||||
const t = useTranslations("settings.filters");
|
||||
const period = usePeriodLabel(rule);
|
||||
|
||||
const joiner = rule.matchType === "all" ? t("and") : t("or");
|
||||
const matchLabel = rule.matchType === "all" ? t("match_all_conditions") : t("match_any_condition");
|
||||
@@ -157,6 +182,20 @@ export function VisualRuleSummary({ rule }: { rule: FilterRule }) {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{period && (
|
||||
<div className="flex items-baseline gap-1.5 flex-wrap">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-violet-500 dark:text-violet-400">
|
||||
{t("period_title")}
|
||||
</span>
|
||||
<span className="min-w-0 max-w-full px-1.5 py-px rounded-sm bg-muted/60 text-foreground wrap-anywhere">
|
||||
{period.range}
|
||||
</span>
|
||||
{period.status && (
|
||||
<span className="text-[10px] text-muted-foreground/60 italic">({period.status})</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -180,6 +219,7 @@ export function FilterSettings() {
|
||||
rawScript,
|
||||
vacationSettings,
|
||||
includeVacation,
|
||||
vacationForward,
|
||||
sieveCapabilities,
|
||||
selectAccount,
|
||||
saveFilters,
|
||||
@@ -254,16 +294,19 @@ export function FilterSettings() {
|
||||
const draggedIndexRef = useRef<number | null>(null);
|
||||
|
||||
// A message can collect only so many forwards on this server. The rules run
|
||||
// in the order listed, and one that stops ends the script for its messages,
|
||||
// so the order counts (see lib/filters/forward-limit.ts).
|
||||
// in the order listed, behind the out of office forwarding, and one that
|
||||
// stops ends the script for its messages, so the order counts (see
|
||||
// lib/filters/forward-limit.ts).
|
||||
const redirectLimit = sieveCapabilities?.maxNumberRedirects;
|
||||
const runOrder = inRunOrder(rules, vacationForward);
|
||||
const tooManyForwards =
|
||||
typeof redirectLimit === "number" && redirectLimit > 0 && worstCaseForwards(rules) > redirectLimit;
|
||||
typeof redirectLimit === "number" && redirectLimit > 0 && worstCaseForwards(runOrder) > redirectLimit;
|
||||
// The edited rule where it is; a new one goes below Bulwark's own rules.
|
||||
const editedIndex = editingRule ? rules.findIndex((r) => r.id === editingRule.id) : -1;
|
||||
const forwardsOfEdited = forwardsAround(
|
||||
rules,
|
||||
editedIndex >= 0 ? editedIndex : rules.filter((r) => !isReadonlyRule(r)).length,
|
||||
runOrder,
|
||||
runOrder.length - rules.length +
|
||||
(editedIndex >= 0 ? editedIndex : rules.filter((r) => !isReadonlyRule(r)).length),
|
||||
editedIndex >= 0,
|
||||
);
|
||||
|
||||
@@ -432,7 +475,12 @@ export function FilterSettings() {
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
// A reload in the background (another device or tab changed the script)
|
||||
// must not take away a dialog the user is typing in: while one is open,
|
||||
// the page keeps what it has.
|
||||
const editing = showRuleModal || showSieveEditor;
|
||||
|
||||
if (isLoading && !editing) {
|
||||
return (
|
||||
<SettingsSection title={t("title")} description={t("description")}>
|
||||
<div className="flex items-center gap-2 py-4 text-sm text-muted-foreground">
|
||||
@@ -443,7 +491,7 @@ export function FilterSettings() {
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
if (error && !editing) {
|
||||
return (
|
||||
<SettingsSection title={t("title")} description={t("description")}>
|
||||
<div className="text-sm text-red-600 dark:text-red-400 py-4">
|
||||
@@ -727,6 +775,7 @@ export function FilterSettings() {
|
||||
rule={editingRule}
|
||||
mailboxes={mailboxes}
|
||||
maxRedirects={sieveCapabilities?.maxNumberRedirects}
|
||||
periodsSupported={supportsPeriods(sieveCapabilities?.sieveExtensions)}
|
||||
forwardsBefore={forwardsOfEdited.before}
|
||||
forwardsAfter={forwardsOfEdited.after}
|
||||
onSave={handleSaveRule}
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
Inbox, Send, FileText, Trash, ShieldAlert, Archive,
|
||||
Star, Heart, Bookmark, Tag, Flag, Briefcase, Users,
|
||||
Bell, Zap, Globe, Lock, Eye, MessageSquare, Mail,
|
||||
AlertTriangle, NotebookPen, CalendarClock, BellOff,
|
||||
AlertTriangle, NotebookPen, CalendarClock, BellOff, CheckSquare, Square,
|
||||
type AppIcon,
|
||||
} from '@/components/icons';
|
||||
import { cn, buildMailboxTree, flattenMailboxTree, type MailboxNode } from '@/lib/utils';
|
||||
@@ -39,6 +39,9 @@ const INDENTATION_WIDTH = 16;
|
||||
|
||||
const STANDARD_ROLES = ['inbox', 'drafts', 'sent', 'trash', 'junk', 'archive'] as const;
|
||||
|
||||
// "Move to" value for the root of the folder tree.
|
||||
const TOP_LEVEL = '__top__';
|
||||
|
||||
const ROLE_ICONS: Record<string, AppIcon> = {
|
||||
inbox: Inbox,
|
||||
drafts: FileText,
|
||||
@@ -154,7 +157,7 @@ function SortableFolderRow({ id, title, children }: { id: string; title: string;
|
||||
export function FolderSettings() {
|
||||
const t = useTranslations('settings.folders');
|
||||
const { client } = useAuthStore();
|
||||
const { mailboxes, fetchMailboxes, createMailbox, renameMailbox, deleteMailbox, setMailboxRole, reorderMailboxes, moveMailbox } = useEmailStore();
|
||||
const { mailboxes, fetchMailboxes, createMailbox, renameMailbox, deleteMailbox, setMailboxRole, reorderMailboxes, moveMailbox, moveMailboxes } = useEmailStore();
|
||||
|
||||
const sensors = useSensors(
|
||||
// Small activation distance so clicking the row's buttons still works.
|
||||
@@ -189,6 +192,13 @@ export function FolderSettings() {
|
||||
const [overId, setOverId] = useState<string | null>(null);
|
||||
const [offsetLeft, setOffsetLeft] = useState(0);
|
||||
|
||||
// Folders ticked for a bulk move (#1173). Shift-click ticks or unticks the
|
||||
// visible rows between the last clicked checkbox and this one.
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const [selectionAnchor, setSelectionAnchor] = useState<string | null>(null);
|
||||
const [moveTarget, setMoveTarget] = useState('');
|
||||
const [isMoving, setIsMoving] = useState(false);
|
||||
|
||||
const ownMailboxes = mailboxes.filter(mb => !mb.isShared);
|
||||
const folderTree = buildMailboxTree(ownMailboxes);
|
||||
|
||||
@@ -263,6 +273,98 @@ export function FolderSettings() {
|
||||
});
|
||||
};
|
||||
|
||||
const selectedCount = ownMailboxes.filter(mb => selectedIds.has(mb.id)).length;
|
||||
|
||||
// A ticked folder inside another ticked folder travels with it, so only the
|
||||
// topmost ticked folders get a new parent; the rest keep their place.
|
||||
const moveRoots: MailboxNode[] = [];
|
||||
const collectMoveRoots = (nodes: MailboxNode[]) => {
|
||||
for (const n of nodes) {
|
||||
if (selectedIds.has(n.id)) moveRoots.push(n);
|
||||
else collectMoveRoots(n.children);
|
||||
}
|
||||
};
|
||||
collectMoveRoots(folderTree);
|
||||
|
||||
// A folder can't move into itself or below itself.
|
||||
const blockedTargets = new Set<string>();
|
||||
const blockSubtree = (n: MailboxNode) => {
|
||||
blockedTargets.add(n.id);
|
||||
n.children.forEach(blockSubtree);
|
||||
};
|
||||
moveRoots.forEach(blockSubtree);
|
||||
|
||||
const getFolderPath = (mb: { parentId?: string; name: string }): string => {
|
||||
const parts = [mb.name];
|
||||
const seen = new Set<string>();
|
||||
let parentId = mb.parentId;
|
||||
while (parentId && !seen.has(parentId)) {
|
||||
seen.add(parentId);
|
||||
const parent = ownMailboxes.find(p => p.id === parentId);
|
||||
if (!parent) break;
|
||||
parts.unshift(parent.name);
|
||||
parentId = parent.parentId;
|
||||
}
|
||||
return parts.join(' / ');
|
||||
};
|
||||
|
||||
const moveTargetOptions = flattenMailboxTree(folderTree)
|
||||
.filter(mb => !blockedTargets.has(mb.id) && !(mb.myRights && !mb.myRights.mayCreateChild))
|
||||
.map(mb => ({ value: mb.id, label: getFolderPath(mb) }));
|
||||
|
||||
const handleSelectClick = (id: string, e: React.MouseEvent) => {
|
||||
const tick = !selectedIds.has(id);
|
||||
let ids = [id];
|
||||
if (e.shiftKey && selectionAnchor) {
|
||||
const order = visibleItems.filter(i => i.node.myRights?.mayRename).map(i => i.id);
|
||||
const from = order.indexOf(selectionAnchor);
|
||||
const to = order.indexOf(id);
|
||||
if (from >= 0 && to >= 0) ids = order.slice(Math.min(from, to), Math.max(from, to) + 1);
|
||||
}
|
||||
setSelectedIds(prev => {
|
||||
const next = new Set(prev);
|
||||
for (const x of ids) {
|
||||
if (tick) next.add(x);
|
||||
else next.delete(x);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setSelectionAnchor(id);
|
||||
};
|
||||
|
||||
const clearSelection = () => {
|
||||
setSelectedIds(new Set());
|
||||
setSelectionAnchor(null);
|
||||
setMoveTarget('');
|
||||
};
|
||||
|
||||
const handleMoveSelected = async () => {
|
||||
if (!client || !moveTarget) return;
|
||||
const parentId = moveTarget === TOP_LEVEL ? null : moveTarget;
|
||||
const ids = moveRoots.filter(n => (n.parentId ?? null) !== parentId).map(n => n.id);
|
||||
if (ids.length === 0) {
|
||||
clearSelection();
|
||||
return;
|
||||
}
|
||||
setIsMoving(true);
|
||||
try {
|
||||
const failed = await moveMailboxes(client, ids, parentId);
|
||||
if (parentId) setExpandedFolders(prev => new Set(prev).add(parentId));
|
||||
if (failed.length === 0) {
|
||||
toast.success(t('folders_moved', { count: ids.length }));
|
||||
clearSelection();
|
||||
} else {
|
||||
toast.error(t('move_partial_error', { count: failed.length }));
|
||||
// Leave the refused folders ticked so they are easy to find.
|
||||
setSelectedIds(new Set(failed));
|
||||
}
|
||||
} catch {
|
||||
toast.error(t('move_error'));
|
||||
} finally {
|
||||
setIsMoving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getRoleMailboxId = (role: string): string => {
|
||||
const mb = ownMailboxes.find(m => m.role === role);
|
||||
return mb?.id ?? '';
|
||||
@@ -451,6 +553,7 @@ export function FolderSettings() {
|
||||
const mb = item.node;
|
||||
const hasChildren = mb.children.length > 0;
|
||||
const isExpanded = expandedFolders.has(item.id);
|
||||
const isSelected = selectedIds.has(item.id);
|
||||
// The dragged row previews its projected nesting depth, so a horizontal
|
||||
// drag shows which parent the folder will land under.
|
||||
const depth = activeDragId === item.id && projected ? projected.depth : item.depth;
|
||||
@@ -523,10 +626,36 @@ export function FolderSettings() {
|
||||
<div key={mb.id}>
|
||||
<SortableFolderRow id={mb.id} title={t('reorder')}>
|
||||
<div
|
||||
className="flex items-center justify-between py-2 px-3 rounded-md hover:bg-muted/50"
|
||||
className={cn(
|
||||
"flex items-center justify-between py-2 px-3 rounded-md hover:bg-muted/50",
|
||||
isSelected && "bg-muted",
|
||||
)}
|
||||
style={{ paddingLeft: 12 + depth * 16 }}
|
||||
>
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
{mb.myRights?.mayRename ? (
|
||||
<button
|
||||
type="button"
|
||||
role="checkbox"
|
||||
aria-checked={isSelected}
|
||||
aria-label={t('select_folder', { name: mb.name })}
|
||||
title={t('select_hint')}
|
||||
// Keep Shift+click from selecting the page text in between.
|
||||
onMouseDown={(e) => { if (e.shiftKey) e.preventDefault(); }}
|
||||
onClick={(e) => handleSelectClick(mb.id, e)}
|
||||
className={cn(
|
||||
"p-0.5 rounded flex-shrink-0 transition-colors hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
isSelected ? "text-primary" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{isSelected
|
||||
? <CheckSquare className="w-4 h-4" />
|
||||
: <Square className="w-4 h-4 opacity-60" />
|
||||
}
|
||||
</button>
|
||||
) : (
|
||||
<span className="w-5 flex-shrink-0" />
|
||||
)}
|
||||
{/* Expand/collapse toggle for folders with children */}
|
||||
{hasChildren ? (
|
||||
<button
|
||||
@@ -630,6 +759,39 @@ export function FolderSettings() {
|
||||
<div className="space-y-8">
|
||||
{/* Folder List - primary section */}
|
||||
<SettingsSection title={t('folder_list')} description={t('folder_list_description')}>
|
||||
{selectedCount > 0 && (
|
||||
<div className="sticky top-0 z-20 flex flex-wrap items-center gap-2 p-2.5 rounded-md border border-border bg-background">
|
||||
<span className="text-sm text-foreground me-auto">
|
||||
{t('selected_count', { count: selectedCount })}
|
||||
</span>
|
||||
<Select
|
||||
value={moveTarget}
|
||||
onChange={setMoveTarget}
|
||||
ariaLabel={t('move_to')}
|
||||
disabled={isMoving}
|
||||
className="min-w-0 max-w-full"
|
||||
options={[
|
||||
{ value: '', label: t('move_to') },
|
||||
{ value: TOP_LEVEL, label: t('move_to_top_level') },
|
||||
...moveTargetOptions,
|
||||
]}
|
||||
/>
|
||||
<button
|
||||
onClick={handleMoveSelected}
|
||||
disabled={isMoving || !moveTarget}
|
||||
className="px-3 py-1.5 text-xs font-medium bg-primary text-primary-foreground rounded-md hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{t('move')}
|
||||
</button>
|
||||
<button
|
||||
onClick={clearSelection}
|
||||
disabled={isMoving}
|
||||
className="px-3 py-1.5 text-xs bg-muted text-foreground rounded-md hover:bg-accent disabled:opacity-50"
|
||||
>
|
||||
{t('clear_selection')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-0.5">
|
||||
{folderTree.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
|
||||
@@ -102,6 +102,7 @@ import {
|
||||
tabSearchPaths,
|
||||
} from '@/lib/settings-search';
|
||||
import { useLiteLinkSegments } from '@/hooks/use-lite-link-segments';
|
||||
import { useDocumentTitle } from '@/hooks/use-document-title';
|
||||
|
||||
type Tab = SettingsSearchTab;
|
||||
|
||||
@@ -217,6 +218,7 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
|
||||
const router = useRouter();
|
||||
const t = useTranslations('settings');
|
||||
const tSidebar = useTranslations('sidebar');
|
||||
useDocumentTitle(tSidebar('settings'));
|
||||
const { client, isAuthenticated, logout, checkAuth, isLoading: authLoading } = useAuthStore();
|
||||
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
|
||||
const isEmbedded = useIsEmbedded();
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
|
||||
import { SettingsSection, SettingItem, ToggleSwitch, Select } from './settings-section';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { RichTextEditor } from '@/components/email/rich-text-editor';
|
||||
import { useVacationStore } from '@/stores/vacation-store';
|
||||
import { VacationFiltersError, useVacationStore, type VacationForwardSettings } from '@/stores/vacation-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useIdentityStore } from '@/stores/identity-store';
|
||||
import { useManagedAccountStore } from '@/stores/managed-account-store';
|
||||
import type { VacationForward } from '@/lib/jmap/sieve-types';
|
||||
import { isPeriodBoundary } from '@/lib/sieve/period';
|
||||
import { ownDomains } from '@/lib/sieve/vacation-audience';
|
||||
import { sanitizeEmailHtml } from '@/lib/email-sanitization';
|
||||
import { htmlToPlainText } from '@/lib/html-to-text';
|
||||
import { isValidEmail } from '@/lib/validation';
|
||||
import { Loader2, AlertTriangle, Eye, EyeOff } from '@/components/icons';
|
||||
import { toast } from '@/stores/toast-store';
|
||||
|
||||
@@ -19,17 +24,66 @@ function byteLength(value: string): number {
|
||||
return new TextEncoder().encode(value).length;
|
||||
}
|
||||
|
||||
/** A switch with its state written out next to it ("Active" / "Inactive"). */
|
||||
function StatusSwitch({ checked, onChange, activeLabel, inactiveLabel }: {
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
activeLabel: string;
|
||||
inactiveLabel: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`text-xs font-medium px-2 py-0.5 rounded-full ${
|
||||
checked
|
||||
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
}`}>
|
||||
{checked ? activeLabel : inactiveLabel}
|
||||
</span>
|
||||
<ToggleSwitch checked={checked} onChange={onChange} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function utcToLocalDatetime(utcIso: string): string {
|
||||
const d = new Date(utcIso);
|
||||
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())}`;
|
||||
}
|
||||
|
||||
// The latest moment the date fields offer: a later year has no room in the
|
||||
// four digits a stored date has.
|
||||
const DATE_INPUT_MAX = '9999-12-31T23:59';
|
||||
|
||||
/**
|
||||
* The moment a date field holds: '' when it is empty, null when it holds none
|
||||
* 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 {
|
||||
if (!input) return '';
|
||||
if (stored && input === utcToLocalDatetime(stored)) return stored;
|
||||
const moment = new Date(input);
|
||||
if (Number.isNaN(moment.getTime())) return null;
|
||||
const iso = moment.toISOString();
|
||||
return isPeriodBoundary(iso) ? iso : null;
|
||||
}
|
||||
|
||||
function sameForward(a: VacationForwardSettings | null, b: VacationForward | null): boolean {
|
||||
if (!a || !b) return a === b;
|
||||
return a.enabled === b.enabled && a.to === b.to && a.keepCopy === b.keepCopy;
|
||||
}
|
||||
|
||||
export function VacationSettings() {
|
||||
const t = useTranslations('settings.vacation');
|
||||
const tFilters = useTranslations('settings.filters');
|
||||
const tNotifications = useTranslations('notifications');
|
||||
const { client } = useAuthStore();
|
||||
const activeAccountId = useAuthStore((s) => s.activeAccountId);
|
||||
const managedAccountId = useManagedAccountStore((s) => s.managedAccountId);
|
||||
// What is typed here belongs to the account it was typed for: on a switch
|
||||
// the fields start over, even where both accounts store the same values.
|
||||
const accountKey = `${activeAccountId ?? ''}/${managedAccountId ?? ''}`;
|
||||
const {
|
||||
isEnabled,
|
||||
fromDate,
|
||||
@@ -37,6 +91,12 @@ export function VacationSettings() {
|
||||
subject,
|
||||
textBody,
|
||||
htmlBody,
|
||||
forward,
|
||||
forwardAvailable,
|
||||
audience,
|
||||
audienceAvailable,
|
||||
notRunning,
|
||||
otherForwards,
|
||||
isLoading,
|
||||
isSaving,
|
||||
error,
|
||||
@@ -46,14 +106,31 @@ export function VacationSettings() {
|
||||
} = useVacationStore();
|
||||
|
||||
const [localEnabled, setLocalEnabled] = useState(isEnabled);
|
||||
const [localFromDate, setLocalFromDate] = useState(fromDate || '');
|
||||
const [localToDate, setLocalToDate] = useState(toDate || '');
|
||||
// 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) : ''));
|
||||
// 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 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 || '');
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
const [validationWarnings, setValidationWarnings] = useState<string[]>([]);
|
||||
const [forwardEnabled, setForwardEnabled] = useState(forward?.enabled ?? false);
|
||||
const [forwardTo, setForwardTo] = useState(forward?.to ?? '');
|
||||
const [forwardKeep, setForwardKeep] = useState(forward?.keepCopy ?? false);
|
||||
const [audienceOnly, setAudienceOnly] = useState<'all' | 'internal' | 'external'>(audience?.only ?? 'all');
|
||||
// "Internal" are the account's own domains, as its identities give them.
|
||||
const identities = useIdentityStore((s) => s.identities);
|
||||
const domains = useMemo(() => ownDomains(identities.map((i) => i.email)), [identities]);
|
||||
|
||||
useEffect(() => {
|
||||
if (client && isSupported) {
|
||||
@@ -63,13 +140,62 @@ export function VacationSettings() {
|
||||
|
||||
useEffect(() => {
|
||||
setLocalEnabled(isEnabled);
|
||||
setLocalFromDate(fromDate || '');
|
||||
setLocalToDate(toDate || '');
|
||||
setFromInput(fromDate ? utcToLocalDatetime(fromDate) : '');
|
||||
setToInput(toDate ? utcToLocalDatetime(toDate) : '');
|
||||
setHalfFilled({ from: false, to: false });
|
||||
setLocalSubject(subject);
|
||||
setLocalTextBody(textBody);
|
||||
setHtmlEnabled(!!htmlBody);
|
||||
setLocalHtmlBody(htmlBody || '');
|
||||
}, [isEnabled, fromDate, toDate, subject, textBody, htmlBody]);
|
||||
}, [accountKey, isEnabled, fromDate, toDate, subject, textBody, htmlBody]);
|
||||
|
||||
useEffect(() => {
|
||||
setForwardEnabled(forward?.enabled ?? false);
|
||||
setForwardTo(forward?.to ?? '');
|
||||
setForwardKeep(forward?.keepCopy ?? false);
|
||||
}, [accountKey, forward]);
|
||||
|
||||
useEffect(() => {
|
||||
setAudienceOnly(audience?.only ?? 'all');
|
||||
}, [accountKey, audience]);
|
||||
|
||||
// Reads a date field as the browser holds it, half filled in or not.
|
||||
const readDateField = (field: 'from' | 'to', input: HTMLInputElement) => {
|
||||
(field === 'from' ? setFromInput : setToInput)(input.value);
|
||||
const half = input.validity.badInput;
|
||||
setHalfFilled((current) => (current[field] === half ? current : { ...current, [field]: half }));
|
||||
};
|
||||
|
||||
// Who gets the auto-reply as a save stores it: everyone, or the senders
|
||||
// from (or not from) the account's domains as they are now. The domains
|
||||
// come from the signed-in user's identities, so a managed (shared) account
|
||||
// offers no narrowing rather than one built from the wrong domains.
|
||||
const canNarrow = audienceAvailable && !managedAccountId && domains.length > 0;
|
||||
const audienceSettings = !canNarrow
|
||||
? undefined
|
||||
: audienceOnly === 'all' ? null : { only: audienceOnly, domains };
|
||||
const audienceChanged = canNarrow && (
|
||||
audienceOnly !== (audience?.only ?? 'all') ||
|
||||
(audienceOnly !== 'all' && domains.join(',') !== (audience?.domains ?? []).join(','))
|
||||
);
|
||||
|
||||
// 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);
|
||||
const forwardSettings = !forwardAvailable
|
||||
? undefined
|
||||
: forwardEnabled || isValidEmail(forwardTo)
|
||||
? { enabled: forwardEnabled, to: forwardTo, keepCopy: forwardKeep }
|
||||
: null;
|
||||
// Only what was changed here is sent: the rest stays as the server has it,
|
||||
// which another tab or device may have changed since this one loaded.
|
||||
const forwardChanged = forwardSettings !== undefined && !sameForward(forwardSettings, forward);
|
||||
// Kept here, the message goes on through the filter rules, whose forwards
|
||||
// share the server's limit with this one.
|
||||
const forwardLimit = client?.getSieveCapabilities(managedAccountId ?? undefined)?.maxNumberRedirects;
|
||||
const forwardOverLimit =
|
||||
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).
|
||||
@@ -100,6 +226,9 @@ export function VacationSettings() {
|
||||
const validate = useCallback(() => {
|
||||
const warnings: string[] = [];
|
||||
|
||||
if (datesUnusable) {
|
||||
warnings.push(t('warnings.invalid_date'));
|
||||
}
|
||||
if (localFromDate && localToDate && new Date(localToDate) <= new Date(localFromDate)) {
|
||||
warnings.push(t('warnings.end_before_start'));
|
||||
}
|
||||
@@ -122,9 +251,19 @@ export function VacationSettings() {
|
||||
warnings.push(t('warnings.body_too_long', { max: sizeLimits.body }));
|
||||
}
|
||||
|
||||
if (forwardInvalid) {
|
||||
warnings.push(t('warnings.forward_address'));
|
||||
}
|
||||
if (forwardOverLimit) {
|
||||
warnings.push(tFilters('forward_limit', { count: forwardLimit }));
|
||||
}
|
||||
if (notRunning) {
|
||||
warnings.push(t('warnings.not_running'));
|
||||
}
|
||||
|
||||
setValidationWarnings(warnings);
|
||||
return warnings;
|
||||
}, [localFromDate, localToDate, localEnabled, localTextBody, htmlEnabled, localHtmlBody, oversize, sizeLimits, t]);
|
||||
}, [datesUnusable, localFromDate, localToDate, localEnabled, localTextBody, htmlEnabled, localHtmlBody, oversize, sizeLimits, forwardInvalid, forwardOverLimit, forwardLimit, notRunning, t, tFilters]);
|
||||
|
||||
useEffect(() => {
|
||||
validate();
|
||||
@@ -136,14 +275,25 @@ export function VacationSettings() {
|
||||
(localToDate || null) !== (toDate || null) ||
|
||||
localSubject !== subject ||
|
||||
localTextBody !== textBody ||
|
||||
(htmlEnabled ? localHtmlBody : '') !== (htmlBody || '');
|
||||
(htmlEnabled ? localHtmlBody : '') !== (htmlBody || '') ||
|
||||
forwardChanged ||
|
||||
audienceChanged ||
|
||||
// Saving sets right what is stored but does not run.
|
||||
notRunning;
|
||||
|
||||
const hasBlockingError =
|
||||
datesUnusable ||
|
||||
!!(localFromDate && localToDate && new Date(localToDate) <= new Date(localFromDate)) ||
|
||||
oversize.subject || oversize.body;
|
||||
oversize.subject || oversize.body || forwardInvalid;
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!client) return;
|
||||
// Half filled in without a single event: only the field itself knows.
|
||||
const half = { from: !!fromRef.current?.validity.badInput, to: !!toRef.current?.validity.badInput };
|
||||
if (half.from || half.to) {
|
||||
setHalfFilled(half);
|
||||
return;
|
||||
}
|
||||
validate();
|
||||
if (hasBlockingError) return;
|
||||
|
||||
@@ -157,12 +307,16 @@ export function VacationSettings() {
|
||||
subject: localSubject,
|
||||
textBody,
|
||||
htmlBody: sanitizedHtml,
|
||||
forward: forwardChanged ? forwardSettings : undefined,
|
||||
audience: audienceChanged ? audienceSettings : undefined,
|
||||
}, managedAccountId ?? undefined);
|
||||
|
||||
toast.success(tNotifications('vacation_saved'));
|
||||
} catch (error) {
|
||||
console.error('Failed to save vacation response:', error);
|
||||
toast.error(tNotifications('vacation_save_failed'));
|
||||
toast.error(tNotifications(
|
||||
error instanceof VacationFiltersError ? 'vacation_filters_save_failed' : 'vacation_save_failed',
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -199,33 +353,20 @@ export function VacationSettings() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* The period is the absence: the auto-reply and forwarding both keep to it. */}
|
||||
<SettingsSection title={t('title')} description={t('description')}>
|
||||
<SettingItem
|
||||
label={t('status.label')}
|
||||
description={t('status.description')}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`text-xs font-medium px-2 py-0.5 rounded-full ${
|
||||
localEnabled
|
||||
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
}`}>
|
||||
{localEnabled ? t('status.active') : t('status.inactive')}
|
||||
</span>
|
||||
<ToggleSwitch checked={localEnabled} onChange={setLocalEnabled} />
|
||||
</div>
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title={t('date_range.title')} description={t('date_range.description')}>
|
||||
<SettingItem
|
||||
label={t('date_range.start')}
|
||||
description={t('date_range.start_description')}
|
||||
>
|
||||
<input
|
||||
ref={fromRef}
|
||||
type="datetime-local"
|
||||
value={localFromDate ? utcToLocalDatetime(localFromDate) : ''}
|
||||
onChange={(e) => setLocalFromDate(e.target.value ? new Date(e.target.value).toISOString() : '')}
|
||||
max={DATE_INPUT_MAX}
|
||||
value={fromInput}
|
||||
onChange={(e) => readDateField('from', e.currentTarget)}
|
||||
onBlur={(e) => readDateField('from', e.currentTarget)}
|
||||
aria-invalid={localFromDate === null || halfFilled.from}
|
||||
className="px-3 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150 hover:border-muted-foreground"
|
||||
/>
|
||||
</SettingItem>
|
||||
@@ -234,15 +375,43 @@ export function VacationSettings() {
|
||||
description={t('date_range.end_description')}
|
||||
>
|
||||
<input
|
||||
ref={toRef}
|
||||
type="datetime-local"
|
||||
value={localToDate ? utcToLocalDatetime(localToDate) : ''}
|
||||
onChange={(e) => setLocalToDate(e.target.value ? new Date(e.target.value).toISOString() : '')}
|
||||
max={DATE_INPUT_MAX}
|
||||
value={toInput}
|
||||
onChange={(e) => readDateField('to', e.currentTarget)}
|
||||
onBlur={(e) => readDateField('to', e.currentTarget)}
|
||||
aria-invalid={localToDate === null || halfFilled.to}
|
||||
className="px-3 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150 hover:border-muted-foreground"
|
||||
/>
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title={t('message.title')} description={t('message.description')}>
|
||||
<SettingsSection title={t('auto_reply.title')} description={t('status.description')}>
|
||||
<SettingItem label={t('status.label')}>
|
||||
<StatusSwitch
|
||||
checked={localEnabled}
|
||||
onChange={setLocalEnabled}
|
||||
activeLabel={t('status.active')}
|
||||
inactiveLabel={t('status.inactive')}
|
||||
/>
|
||||
</SettingItem>
|
||||
{canNarrow && (
|
||||
<SettingItem
|
||||
label={t('audience.label')}
|
||||
description={t('audience.description', { domains: domains.join(', ') })}
|
||||
>
|
||||
<Select
|
||||
value={audienceOnly}
|
||||
onChange={(value) => 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>
|
||||
)}
|
||||
<SettingItem
|
||||
label={t('message.subject_label')}
|
||||
description={t('message.subject_description')}
|
||||
@@ -332,6 +501,37 @@ export function VacationSettings() {
|
||||
);
|
||||
})()}
|
||||
|
||||
{forwardAvailable && (
|
||||
<SettingsSection title={t('forward.title')} description={t('forward.description')}>
|
||||
<SettingItem label={t('forward.enabled_label')}>
|
||||
<StatusSwitch
|
||||
checked={forwardEnabled}
|
||||
onChange={setForwardEnabled}
|
||||
activeLabel={t('status.active')}
|
||||
inactiveLabel={t('status.inactive')}
|
||||
/>
|
||||
</SettingItem>
|
||||
{forwardEnabled && (
|
||||
<>
|
||||
<SettingItem label={t('forward.to_label')}>
|
||||
<input
|
||||
type="email"
|
||||
value={forwardTo}
|
||||
onChange={(e) => setForwardTo(e.target.value)}
|
||||
placeholder={t('forward.to_placeholder')}
|
||||
aria-label={t('forward.to_label')}
|
||||
aria-invalid={forwardInvalid}
|
||||
className="w-64 px-3 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150 hover:border-muted-foreground"
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem label={t('forward.keep_label')} description={t('forward.keep_description')}>
|
||||
<ToggleSwitch checked={forwardKeep} onChange={setForwardKeep} />
|
||||
</SettingItem>
|
||||
</>
|
||||
)}
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
{validationWarnings.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{validationWarnings.map((warning, i) => (
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, render, renderHook, waitFor } from '@testing-library/react';
|
||||
|
||||
vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Acme Mail' }) }));
|
||||
|
||||
import { useDocumentTitle } from '@/hooks/use-document-title';
|
||||
import { useAccountStore, type AccountEntry } from '@/stores/account-store';
|
||||
import { PaneIdContext, ProTabFocusContext } from '@/hooks/use-pane-context';
|
||||
|
||||
const account = (id: string, email: string) => ({ id, email }) as AccountEntry;
|
||||
|
||||
/**
|
||||
* What Next does on a client-side navigation: it renders the root layout's
|
||||
* metadata <title> again, after the new surface has mounted.
|
||||
*/
|
||||
function rerenderMetadataTitle(title = 'Acme Mail'): void {
|
||||
document.head.querySelectorAll('title').forEach((el) => el.remove());
|
||||
const el = document.createElement('title');
|
||||
el.textContent = title;
|
||||
document.head.appendChild(el);
|
||||
}
|
||||
|
||||
/** A surface in a Pro shell tab, which stays mounted while hidden. */
|
||||
function TitleProbe({ context }: { context: string }) {
|
||||
useDocumentTitle(context);
|
||||
return null;
|
||||
}
|
||||
function ProTab({ context, focused }: { context: string; focused: boolean }) {
|
||||
return (
|
||||
<PaneIdContext.Provider value="main">
|
||||
<ProTabFocusContext.Provider value={focused}>
|
||||
<TitleProbe context={context} />
|
||||
</ProTabFocusContext.Provider>
|
||||
</PaneIdContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/** Drains MutationObserver callbacks. */
|
||||
async function settle(): Promise<void> {
|
||||
for (let i = 0; i < 10; i++) await Promise.resolve();
|
||||
}
|
||||
|
||||
describe('useDocumentTitle', () => {
|
||||
beforeEach(() => {
|
||||
document.title = '';
|
||||
useAccountStore.setState({
|
||||
accounts: [account('a', 'jane@example.com'), account('b', 'joe@example.com')],
|
||||
activeAccountId: 'a',
|
||||
});
|
||||
});
|
||||
|
||||
it('names the active account between the context and the app', () => {
|
||||
renderHook(() => useDocumentTitle('Calendar'));
|
||||
expect(document.title).toBe('Calendar - jane@example.com - Acme Mail');
|
||||
});
|
||||
|
||||
it('follows an account switch', () => {
|
||||
renderHook(() => useDocumentTitle('Calendar'));
|
||||
act(() => useAccountStore.setState({ activeAccountId: 'b' }));
|
||||
expect(document.title).toBe('Calendar - joe@example.com - Acme Mail');
|
||||
});
|
||||
|
||||
it('shows the account and the app when the surface adds no context', () => {
|
||||
renderHook(() => useDocumentTitle());
|
||||
expect(document.title).toBe('jane@example.com - Acme Mail');
|
||||
});
|
||||
|
||||
it('shows the context and the app when no account is in view', () => {
|
||||
useAccountStore.setState({ accounts: [], activeAccountId: null });
|
||||
renderHook(() => useDocumentTitle('Settings'));
|
||||
expect(document.title).toBe('Settings - Acme Mail');
|
||||
});
|
||||
|
||||
it('puts its title back when the metadata title is rendered again', async () => {
|
||||
renderHook(() => useDocumentTitle('Settings'));
|
||||
rerenderMetadataTitle();
|
||||
await waitFor(() => expect(document.title).toBe('Settings - jane@example.com - Acme Mail'));
|
||||
});
|
||||
|
||||
it('leaves the title to the surface that set it last', async () => {
|
||||
renderHook(() => useDocumentTitle('Inbox'));
|
||||
renderHook(() => useDocumentTitle('Settings'));
|
||||
rerenderMetadataTitle();
|
||||
await waitFor(() => expect(document.title).toBe('Settings - jane@example.com - Acme Mail'));
|
||||
await settle();
|
||||
expect(document.title).toBe('Settings - jane@example.com - Acme Mail');
|
||||
});
|
||||
|
||||
it('in the Pro shell, leaves the title to the focused tab', () => {
|
||||
const mail = render(<ProTab context="Inbox" focused />);
|
||||
const settings = render(<ProTab context="Settings" focused={false} />);
|
||||
expect(document.title).toBe('Inbox - jane@example.com - Acme Mail');
|
||||
|
||||
mail.rerender(<ProTab context="Inbox" focused={false} />);
|
||||
settings.rerender(<ProTab context="Settings" focused />);
|
||||
expect(document.title).toBe('Settings - jane@example.com - Acme Mail');
|
||||
|
||||
// New mail in the hidden Mail tab must not take the title back.
|
||||
mail.rerender(<ProTab context="Inbox (5)" focused={false} />);
|
||||
expect(document.title).toBe('Settings - jane@example.com - Acme Mail');
|
||||
});
|
||||
|
||||
it('stops re-applying once unmounted', async () => {
|
||||
const { unmount } = renderHook(() => useDocumentTitle('Settings'));
|
||||
unmount();
|
||||
rerenderMetadataTitle('Sign in');
|
||||
await settle();
|
||||
expect(document.title).toBe('Sign in');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import React, { useState } from 'react';
|
||||
import { useFocusTrap } from '../use-focus-trap';
|
||||
|
||||
function Dialog({ onEscape, note }: { onEscape: () => void; note?: string }) {
|
||||
const ref = useFocusTrap({ isActive: true, onEscape });
|
||||
return (
|
||||
<div ref={ref} role="dialog">
|
||||
<button type="button">close</button>
|
||||
<input aria-label="name" />
|
||||
{note && <p>{note}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Page({ open, onEscape, note }: { open: boolean; onEscape: () => void; note?: string }) {
|
||||
return (
|
||||
<>
|
||||
<button type="button">opener</button>
|
||||
{open && <Dialog onEscape={onEscape} note={note} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Like the identity and template managers: a list that gives way to a form,
|
||||
// taking the button that had the focus with it.
|
||||
function Manager() {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const ref = useFocusTrap({ isActive: true, onEscape: () => setEditing(false) });
|
||||
return (
|
||||
<div ref={ref} role="dialog">
|
||||
<button type="button">close</button>
|
||||
{editing
|
||||
? <input aria-label="field" />
|
||||
: <button type="button" onClick={() => setEditing(true)}>edit</button>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
describe('useFocusTrap', () => {
|
||||
it('moves the focus in when it opens and back to the opener when it closes', () => {
|
||||
const { rerender } = render(<Page open={false} onEscape={() => {}} />);
|
||||
screen.getByText('opener').focus();
|
||||
rerender(<Page open onEscape={() => {}} />);
|
||||
expect(document.activeElement).toBe(screen.getByText('close'));
|
||||
rerender(<Page open={false} onEscape={() => {}} />);
|
||||
expect(document.activeElement).toBe(screen.getByText('opener'));
|
||||
});
|
||||
|
||||
it('leaves the focus in the field while the page behind renders again', () => {
|
||||
// A background request renders the page again, and the dialog gets a
|
||||
// new onEscape: the field the user is typing in keeps the focus.
|
||||
const { rerender } = render(<Page open onEscape={() => {}} />);
|
||||
const field = screen.getByLabelText('name');
|
||||
field.focus();
|
||||
rerender(<Page open onEscape={() => {}} />);
|
||||
rerender(<Page open onEscape={() => {}} />);
|
||||
expect(document.activeElement).toBe(field);
|
||||
});
|
||||
|
||||
it('answers Escape with the onEscape of the latest render', () => {
|
||||
const first = vi.fn();
|
||||
const latest = vi.fn();
|
||||
const { rerender } = render(<Page open onEscape={first} />);
|
||||
rerender(<Page open onEscape={latest} />);
|
||||
fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' });
|
||||
expect(latest).toHaveBeenCalledTimes(1);
|
||||
expect(first).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps the focus in the dialog when a view change removes the element that had it', async () => {
|
||||
render(<Manager />);
|
||||
const edit = screen.getByText('edit');
|
||||
edit.focus();
|
||||
fireEvent.click(edit);
|
||||
await waitFor(() => expect(document.activeElement).toBe(screen.getByText('close')));
|
||||
});
|
||||
|
||||
it('leaves a focus the user moved away alone when the dialog changes', async () => {
|
||||
const { rerender } = render(<Page open onEscape={() => {}} />);
|
||||
const field = screen.getByLabelText('name');
|
||||
field.focus();
|
||||
field.blur();
|
||||
rerender(<Page open onEscape={() => {}} note="saved" />);
|
||||
await screen.findByText('saved');
|
||||
await Promise.resolve();
|
||||
expect(document.activeElement).toBe(document.body);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useConfig } from '@/hooks/use-config';
|
||||
import { useIsFocusedProTab, usePaneId } from '@/hooks/use-pane-context';
|
||||
import { useAccountStore } from '@/stores/account-store';
|
||||
import { formatTabTitle } from '@/lib/tab-title';
|
||||
|
||||
// The surface that set the title last owns it. One that is still mounted
|
||||
// (during a route transition) stops re-applying, so two titles never fight.
|
||||
let owner: symbol | null = null;
|
||||
|
||||
/**
|
||||
* Sets the browser-tab title for a surface: "<context> - <account> - <app>"
|
||||
* (see formatTabTitle). Pass a context that is already localized, or nothing
|
||||
* for a surface that has nothing to add.
|
||||
*
|
||||
* Next renders the root layout's metadata <title> again on every client-side
|
||||
* navigation, after the new surface has mounted, which puts the bare app name
|
||||
* back. So the title is re-applied whenever <head> changes and it is not ours.
|
||||
*/
|
||||
export function useDocumentTitle(context?: string | null): void {
|
||||
const { appName } = useConfig();
|
||||
// The account actually in view, so two windows on two accounts stay apart.
|
||||
const account = useAccountStore(
|
||||
(s) => s.accounts.find((a) => a.id === s.activeAccountId)?.email || null,
|
||||
);
|
||||
const title = formatTabTitle(context, account, appName);
|
||||
// The Pro shell keeps every opened tab mounted (hidden), so only the
|
||||
// focused tab may claim the title; outside Pro every surface is in view.
|
||||
const paneId = usePaneId();
|
||||
const focusedProTab = useIsFocusedProTab();
|
||||
const inView = paneId === null || focusedProTab;
|
||||
|
||||
useEffect(() => {
|
||||
if (!inView) return;
|
||||
const self = Symbol('document-title');
|
||||
owner = self;
|
||||
// Termination: setting the title is itself a <head> mutation; on that run
|
||||
// the title is already ours and nothing is written.
|
||||
const apply = () => {
|
||||
if (owner === self && document.title !== title) document.title = title;
|
||||
};
|
||||
apply();
|
||||
const observer = new MutationObserver(apply);
|
||||
observer.observe(document.head, { childList: true, subtree: true, characterData: true });
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
if (owner === self) owner = null;
|
||||
};
|
||||
}, [title, inView]);
|
||||
}
|
||||
+32
-3
@@ -13,6 +13,14 @@ export function useFocusTrap({
|
||||
}: UseFocusTrapOptions) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const previousActiveElement = useRef<HTMLElement | null>(null);
|
||||
// Callers pass a new onEscape on every render. The trap reads the latest
|
||||
// one from here instead of starting over for it: each new start moved the
|
||||
// focus back to the opener and then to the first element, out of the field
|
||||
// the user was typing in whenever the page behind rendered again.
|
||||
const onEscapeRef = useRef(onEscape);
|
||||
useEffect(() => {
|
||||
onEscapeRef.current = onEscape;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive || !containerRef.current) return;
|
||||
@@ -29,6 +37,13 @@ export function useFocusTrap({
|
||||
);
|
||||
};
|
||||
|
||||
// The element in the dialog that had the focus last.
|
||||
let lastFocused: HTMLElement | null = null;
|
||||
const handleFocusIn = (e: FocusEvent) => {
|
||||
lastFocused = e.target as HTMLElement;
|
||||
};
|
||||
container.addEventListener('focusin', handleFocusIn);
|
||||
|
||||
// Focus first element
|
||||
const focusableElements = getFocusableElements();
|
||||
const firstElement = focusableElements[0];
|
||||
@@ -39,8 +54,8 @@ export function useFocusTrap({
|
||||
// Handle Tab key to trap focus
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Handle Escape
|
||||
if (e.key === 'Escape' && onEscape) {
|
||||
onEscape();
|
||||
if (e.key === 'Escape' && onEscapeRef.current) {
|
||||
onEscapeRef.current();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -68,8 +83,22 @@ export function useFocusTrap({
|
||||
|
||||
container.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
// A view change in the dialog (a list giving way to a form) can remove
|
||||
// the element that had the focus, which then drops to the page behind,
|
||||
// out of the trap. Bring it back to the first element - only then, so a
|
||||
// focus the user moved on purpose stays where it is.
|
||||
const observer = new MutationObserver(() => {
|
||||
const active = document.activeElement;
|
||||
if (lastFocused && !lastFocused.isConnected && (!active || active === document.body)) {
|
||||
getFocusableElements()[0]?.focus();
|
||||
}
|
||||
});
|
||||
observer.observe(container, { childList: true, subtree: true });
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
container.removeEventListener('focusin', handleFocusIn);
|
||||
container.removeEventListener('keydown', handleKeyDown);
|
||||
|
||||
// Restore focus to previous element
|
||||
@@ -77,7 +106,7 @@ export function useFocusTrap({
|
||||
previousActiveElement.current.focus();
|
||||
}
|
||||
};
|
||||
}, [isActive, onEscape, restoreFocus]);
|
||||
}, [isActive, restoreFocus]);
|
||||
|
||||
return containerRef;
|
||||
}
|
||||
|
||||
+20
-7
@@ -44,6 +44,19 @@ integration/run-tests.sh 01-login
|
||||
bundles can't always be downloaded/installed on the host; the official
|
||||
`mcr.microsoft.com/playwright` image sidesteps that.
|
||||
|
||||
### In CI
|
||||
|
||||
The "Integration tests" workflow (`.github/workflows/integration.yml`) runs
|
||||
`run-tests.sh` every night on main, on pull requests that carry the
|
||||
`integration` label, and on demand:
|
||||
|
||||
```bash
|
||||
gh workflow run integration.yml
|
||||
```
|
||||
|
||||
A failed run uploads the Playwright report, traces and failure videos as the
|
||||
`integration-report` artifact and prints the container logs.
|
||||
|
||||
### Running against a host browser instead
|
||||
|
||||
If you *can* install Playwright browsers on your machine:
|
||||
@@ -106,13 +119,13 @@ because the UI behaviour is currently incomplete. Worth a look:
|
||||
runs — they follow the reconcile, not the optimistic path.
|
||||
- **`mark-as-spam` doesn't optimistically decrement the source counter** the
|
||||
way `delete` does; it settles after a reconcile.
|
||||
- **Reopening a draft resets the From selector** to the default identity even
|
||||
though the draft was saved with (and the server retains) the chosen sender.
|
||||
Pinned with `test.fail` in `07-drafts`.
|
||||
- **Cross-account moves (own ⇆ shared folder) don't relocate the message.** The
|
||||
"Move to" submenu offers the shared folder, but clicking it is a no-op.
|
||||
Shared ⇆ shared (same owner) moves work. Pinned with `test.fail` in
|
||||
`08-shared-moves`.
|
||||
- **Reopening a draft restores the chosen sender (fixed).** It used to reset
|
||||
the From selector to the default identity (`08-drafts`).
|
||||
- **Cross-account moves (fixed).** Moves between the own account and shared
|
||||
folders, and across owners, deliver the message, keep its read state and
|
||||
remove the source (`09-shared-moves`). The client destroys the source itself
|
||||
instead of using Stalwart's `onSuccessDestroyOriginal` (support.stalw.art
|
||||
#1150).
|
||||
- **Cross-account attachments & inline images (fixed).** Blobs are account-
|
||||
scoped, so viewing/downloading/previewing an attachment, rendering an inline
|
||||
`cid:` image, dragging out, and the bundle/S-MIME/TNEF/embedded-message
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
# IT_VIDEO=on record a video.webm for every test (not just failures);
|
||||
# also: off | retain-on-failure (default) | on-first-retry.
|
||||
# e.g. IT_VIDEO=on integration/run-tests.sh 01-login
|
||||
# CI passed through, so a CI run gets the config's one retry.
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
@@ -38,5 +39,6 @@ docker run --rm --network host \
|
||||
-e IT_NO_DOCKER=1 \
|
||||
-e HOME=/tmp \
|
||||
-e IT_VIDEO="${IT_VIDEO:-}" \
|
||||
-e CI="${CI:-}" \
|
||||
"${PW_IMAGE}" \
|
||||
npx playwright test -c playwright.integration.config.ts ${FILTER:+"$FILTER"}
|
||||
|
||||
@@ -18,11 +18,9 @@ import {
|
||||
* authoritative check is the server-side mailbox the message ends up in.
|
||||
*
|
||||
* Each cross-account case asserts delivery *and* that the read state survives
|
||||
* (Email/copy drops keywords unless carried). Removing the source, however, is
|
||||
* currently blocked by a Stalwart bug — onSuccessDestroyOriginal destroys the
|
||||
* copy's create-id instead of the source id, so the original is left behind
|
||||
* (support.stalw.art #1150). Those source-removal checks are pinned test.fail
|
||||
* until Stalwart ships the fix; same-account moves (Email/set) are unaffected.
|
||||
* (Email/copy drops keywords unless carried), and that the source is removed.
|
||||
* The client destroys the source itself once the copy exists rather than using
|
||||
* onSuccessDestroyOriginal, which Stalwart got wrong (support.stalw.art #1150).
|
||||
*/
|
||||
const { alice, bob, carol } = ACCOUNTS;
|
||||
const subj = (l: string) => `IT ${l} ${Date.now()}`;
|
||||
@@ -100,8 +98,8 @@ test.describe('Shared-folder moves', () => {
|
||||
expect(seenOf(inA), 'read state kept').toBe(true);
|
||||
});
|
||||
|
||||
// Cross-account cases: delivery + read state must hold (our fix); removing the
|
||||
// source is pinned test.fail below (Stalwart #1150).
|
||||
// Cross-account cases: delivery + read state must hold; removing the source
|
||||
// is checked below.
|
||||
test('cross-owner shared -> shared: delivers and keeps read state', async ({ page }) => {
|
||||
const s = subj('mv-a2c');
|
||||
await seedRead(teamA, s);
|
||||
@@ -134,12 +132,9 @@ test.describe('Shared-folder moves', () => {
|
||||
expect(seenOf(inOwn), 'read state kept').toBe(true);
|
||||
});
|
||||
|
||||
// Pinned failing: Stalwart's onSuccessDestroyOriginal leaves the original in
|
||||
// place on a cross-account copy (support.stalw.art #1150). Un-pin once fixed
|
||||
// upstream (our copyEmailAcrossAccounts already requests the destroy).
|
||||
// copyEmailAcrossAccounts destroys the source with its own Email/set once the
|
||||
// copy exists (see the #1150 note at the top).
|
||||
test.describe('source is removed after a cross-account move', () => {
|
||||
test.fail(true, 'blocked by Stalwart #1150 (onSuccessDestroyOriginal destroys wrong id)');
|
||||
|
||||
test('cross-owner shared -> shared', async ({ page }) => {
|
||||
const s = subj('rm-a2c');
|
||||
await seedRead(teamA, s);
|
||||
|
||||
@@ -27,14 +27,70 @@ const mockPost = postJmap as unknown as Mock;
|
||||
|
||||
const CALENDAR_CAP = 'urn:ietf:params:jmap:calendars';
|
||||
|
||||
function makeSession(capabilities: Record<string, unknown>) {
|
||||
function makeSession(capabilities: Record<string, unknown>, accounts?: Record<string, unknown>) {
|
||||
return {
|
||||
capabilities,
|
||||
primaryAccounts: { [CALENDAR_CAP]: 'acct-1' },
|
||||
accounts,
|
||||
apiUrl: 'https://mail.example.com/jmap/',
|
||||
};
|
||||
}
|
||||
|
||||
const BASE_CAPS = { 'urn:ietf:params:jmap:core': {}, [CALENDAR_CAP]: {} };
|
||||
|
||||
/** An event tomorrow, as Stalwart returns it (local date-time plus zone). */
|
||||
function upcomingEvent(id: string, title: string, calendarId: string) {
|
||||
const start = new Date(Date.now() + 24 * 60 * 60 * 1000);
|
||||
start.setSeconds(0, 0);
|
||||
return {
|
||||
id,
|
||||
'@type': 'Event',
|
||||
uid: `${id}@example.com`,
|
||||
title,
|
||||
calendarIds: { [calendarId]: true },
|
||||
start: start.toISOString().slice(0, 19),
|
||||
timeZone: 'Etc/UTC',
|
||||
duration: 'PT1H',
|
||||
};
|
||||
}
|
||||
|
||||
type Call = [string, Record<string, unknown>, string];
|
||||
|
||||
/**
|
||||
* Answers each JMAP request from a per-account fixture; `accountId` is read
|
||||
* off the method call so the test sees which account the route asked for.
|
||||
*/
|
||||
function answerPerAccount(fixtures: Record<string, {
|
||||
events?: ReturnType<typeof upcomingEvent>[];
|
||||
calendars?: Array<{ id: string; name: string; color: string | null }>;
|
||||
queryError?: string;
|
||||
}>) {
|
||||
mockPost.mockImplementation(async (_url: string, _auth: string, body: string) => {
|
||||
const { methodCalls } = JSON.parse(body) as { methodCalls: Call[] };
|
||||
const methodResponses = methodCalls.map(([name, args, callId]) => {
|
||||
const fixture = fixtures[args.accountId as string] ?? {};
|
||||
if (name === 'CalendarEvent/query') {
|
||||
if (fixture.queryError) return ['error', { type: fixture.queryError }, callId];
|
||||
return [name, { ids: (fixture.events ?? []).map((e) => e.id) }, callId];
|
||||
}
|
||||
if (name === 'Calendar/get') return [name, { list: fixture.calendars ?? [] }, callId];
|
||||
if (name === 'CalendarEvent/get') {
|
||||
const ids = args.ids as string[];
|
||||
return [name, { list: (fixture.events ?? []).filter((e) => ids.includes(e.id)) }, callId];
|
||||
}
|
||||
return ['error', { type: 'unknownMethod' }, callId];
|
||||
});
|
||||
return { ok: true, json: async () => ({ methodResponses }) };
|
||||
});
|
||||
}
|
||||
|
||||
function queriedAccountIds(): string[] {
|
||||
return mockPost.mock.calls.flatMap((call) => {
|
||||
const { methodCalls } = JSON.parse(call[2] as string) as { methodCalls: Call[] };
|
||||
return methodCalls.filter(([name]) => name === 'CalendarEvent/query').map(([, args]) => args.accountId as string);
|
||||
});
|
||||
}
|
||||
|
||||
function makeReq(): Parameters<typeof POST>[0] {
|
||||
return { method: 'POST', headers: new Headers(), json: async () => ({}) } as unknown as Parameters<typeof POST>[0];
|
||||
}
|
||||
@@ -86,3 +142,85 @@ describe('calendar-agenda route (principals:owner declaration)', () => {
|
||||
expect(using).toContain('urn:ietf:params:jmap:principals:owner');
|
||||
});
|
||||
});
|
||||
|
||||
describe('calendar-agenda route (shared and group accounts)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockCreds.mockResolvedValue({
|
||||
serverUrl: 'https://mail.example.com',
|
||||
username: 'user@example.com',
|
||||
authHeader: 'Basic abc',
|
||||
});
|
||||
});
|
||||
|
||||
it('includes events from a shared group account next to the primary account', async () => {
|
||||
mockSession.mockResolvedValue(makeSession(BASE_CAPS, {
|
||||
'acct-1': { name: 'user@example.com', isPersonal: true, accountCapabilities: { [CALENDAR_CAP]: {} } },
|
||||
// Stalwart does not advertise the calendar capability on group accounts
|
||||
'acct-family': { name: 'family@example.com', isPersonal: false, accountCapabilities: {} },
|
||||
}));
|
||||
answerPerAccount({
|
||||
'acct-1': {
|
||||
events: [upcomingEvent('ev-1', 'Dentist', 'cal-1')],
|
||||
calendars: [{ id: 'cal-1', name: 'Personal', color: '#3b82f6' }],
|
||||
},
|
||||
'acct-family': {
|
||||
events: [upcomingEvent('ev-1', 'Holiday', 'cal-1')],
|
||||
calendars: [{ id: 'cal-1', name: 'Family', color: '#a855f7' }],
|
||||
},
|
||||
});
|
||||
|
||||
const res = await POST(makeReq());
|
||||
expect(res.status).toBe(200);
|
||||
const { events } = await res.json() as { events: Array<Record<string, unknown>> };
|
||||
|
||||
expect(queriedAccountIds()).toEqual(['acct-1', 'acct-family']);
|
||||
expect(events.map((e) => e.title).sort()).toEqual(['Dentist', 'Holiday']);
|
||||
// Ids repeat across accounts, so each event is scoped by its account and
|
||||
// the colour comes from that account's calendar, not a same-named one.
|
||||
const holiday = events.find((e) => e.title === 'Holiday')!;
|
||||
expect(holiday.id).toBe('acct-family:ev-1');
|
||||
expect(holiday.accountId).toBe('acct-family');
|
||||
expect(holiday.color).toBe('#a855f7');
|
||||
expect(events.find((e) => e.title === 'Dentist')!.color).toBe('#3b82f6');
|
||||
});
|
||||
|
||||
it('leaves out personal accounts without calendars and the primary account itself', async () => {
|
||||
mockSession.mockResolvedValue(makeSession(BASE_CAPS, {
|
||||
'acct-1': { isPersonal: true, accountCapabilities: { [CALENDAR_CAP]: {} } },
|
||||
'acct-mail-only': { isPersonal: true, accountCapabilities: { 'urn:ietf:params:jmap:mail': {} } },
|
||||
'acct-shared': { isPersonal: true, accountCapabilities: { [CALENDAR_CAP]: {} } },
|
||||
}));
|
||||
answerPerAccount({});
|
||||
|
||||
const res = await POST(makeReq());
|
||||
expect(res.status).toBe(200);
|
||||
expect(queriedAccountIds()).toEqual(['acct-1', 'acct-shared']);
|
||||
});
|
||||
|
||||
it('skips a shared account whose calendars cannot be read instead of failing the agenda', async () => {
|
||||
mockSession.mockResolvedValue(makeSession(BASE_CAPS, {
|
||||
'acct-1': { isPersonal: true, accountCapabilities: { [CALENDAR_CAP]: {} } },
|
||||
'acct-team': { isPersonal: false },
|
||||
}));
|
||||
answerPerAccount({
|
||||
'acct-1': { events: [upcomingEvent('ev-1', 'Standup', 'cal-1')] },
|
||||
'acct-team': { queryError: 'forbidden' },
|
||||
});
|
||||
|
||||
const res = await POST(makeReq());
|
||||
expect(res.status).toBe(200);
|
||||
const { events } = await res.json() as { events: Array<Record<string, unknown>> };
|
||||
expect(events.map((e) => e.title)).toEqual(['Standup']);
|
||||
});
|
||||
|
||||
it('still reports a failure of the primary account', async () => {
|
||||
mockSession.mockResolvedValue(makeSession(BASE_CAPS, {
|
||||
'acct-1': { isPersonal: true, accountCapabilities: { [CALENDAR_CAP]: {} } },
|
||||
}));
|
||||
answerPerAccount({ 'acct-1': { queryError: 'serverFail' } });
|
||||
|
||||
const res = await POST(makeReq());
|
||||
expect(res.status).toBe(502);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { CalendarEvent } from '@/lib/jmap/types';
|
||||
import { buildDuplicateEventData } from '../calendar-duplicate';
|
||||
|
||||
function makeEvent(overrides: Partial<CalendarEvent> = {}): CalendarEvent {
|
||||
return {
|
||||
id: 'ev1',
|
||||
uid: 'original-uid',
|
||||
title: 'Standup',
|
||||
description: 'Daily sync',
|
||||
start: '2026-10-05T09:30:00',
|
||||
duration: 'PT30M',
|
||||
timeZone: 'Europe/Berlin',
|
||||
showWithoutTime: false,
|
||||
calendarIds: { cal1: true },
|
||||
status: 'tentative',
|
||||
...overrides,
|
||||
} as CalendarEvent;
|
||||
}
|
||||
|
||||
describe('buildDuplicateEventData', () => {
|
||||
it('keeps the meeting link (#1170)', () => {
|
||||
const virtualLocations = {
|
||||
v1: { '@type': 'VirtualLocation', name: 'Video call', uri: 'https://meet.example.org/abc' },
|
||||
} as unknown as CalendarEvent['virtualLocations'];
|
||||
const data = buildDuplicateEventData(makeEvent({ virtualLocations }));
|
||||
expect(data.virtualLocations).toEqual(virtualLocations);
|
||||
expect(data.virtualLocations).not.toBe(virtualLocations);
|
||||
});
|
||||
|
||||
it('moves a timed event one day later in its own time zone with a fresh uid', () => {
|
||||
const data = buildDuplicateEventData(makeEvent());
|
||||
expect(data).toMatchObject({
|
||||
title: 'Standup',
|
||||
start: '2026-10-06T09:30:00',
|
||||
duration: 'PT30M',
|
||||
timeZone: 'Europe/Berlin',
|
||||
calendarIds: { cal1: true },
|
||||
status: 'confirmed',
|
||||
});
|
||||
expect(data.uid).toBeTruthy();
|
||||
expect(data.uid).not.toBe('original-uid');
|
||||
expect(data.id).toBeUndefined();
|
||||
});
|
||||
|
||||
it('sends all-day copies as floating midnight starts', () => {
|
||||
const data = buildDuplicateEventData(makeEvent({
|
||||
start: '2026-10-05T00:00:00',
|
||||
duration: 'P1D',
|
||||
showWithoutTime: true,
|
||||
}));
|
||||
expect(data).toMatchObject({ start: '2026-10-06T00:00:00', showWithoutTime: true, timeZone: null });
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
parseSpamLLM,
|
||||
extractListHeaders,
|
||||
isAuthenticationSpoofed,
|
||||
getSenderVerification,
|
||||
} from '../email-headers';
|
||||
|
||||
describe('parseAuthenticationResults', () => {
|
||||
@@ -393,3 +394,61 @@ describe('parseAuthenticationResults - sender-controlled text', () => {
|
||||
expect(result.dmarc?.result).toBe('fail');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSenderVerification', () => {
|
||||
// Shape of a real phish: forged bank From, sent by a web host's PHP
|
||||
// mailer, no signature. mail-auth reports dmarc=none because neither SPF
|
||||
// nor DKIM passed, although the From domain publishes a DMARC record.
|
||||
const phish = parseAuthenticationResults(
|
||||
'mx.example.org;\r\n\tspf=none (mx.example.org: no SPF records found for www-data@web1.hoster.example) smtp.mailfrom=www-data@web1.hoster.example;\r\n\tiprev=permerror policy.iprev=203.0.113.7;\r\n\tdmarc=none header.from=bank.example policy.dmarc=none',
|
||||
);
|
||||
|
||||
it('flags a message that passes neither SPF nor DKIM', () => {
|
||||
expect(isAuthenticationSpoofed(phish)).toBe(false);
|
||||
expect(getSenderVerification(phish, 'support@bank.example')).toEqual({
|
||||
status: 'unverified',
|
||||
domain: 'bank.example',
|
||||
sentFrom: 'web1.hoster.example',
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves out the envelope host when it is the From domain', () => {
|
||||
const auth = parseAuthenticationResults('mx; spf=softfail smtp.mailfrom=billing@Bank.Example; dmarc=none header.from=bank.example');
|
||||
expect(getSenderVerification(auth, 'billing@bank.example')).toEqual({ status: 'unverified', domain: 'bank.example' });
|
||||
});
|
||||
|
||||
it('reports a DMARC fail as failed', () => {
|
||||
const auth = parseAuthenticationResults('mx; spf=pass smtp.mailfrom=bounce@evil.example; dmarc=fail header.from=bank.example');
|
||||
expect(getSenderVerification(auth, 'support@bank.example')).toEqual({
|
||||
status: 'failed',
|
||||
domain: 'bank.example',
|
||||
sentFrom: 'evil.example',
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts any passing SPF, DKIM or DMARC result', () => {
|
||||
const from = 'news@shop.example';
|
||||
expect(getSenderVerification(parseAuthenticationResults('mx; spf=pass smtp.mailfrom=bounces.esp.example'), from)).toBeNull();
|
||||
expect(getSenderVerification(parseAuthenticationResults('mx; spf=none smtp.mailfrom=x.example; dkim=pass header.d=esp.example'), from)).toBeNull();
|
||||
expect(getSenderVerification(parseAuthenticationResults('mx; dmarc=pass header.from=shop.example'), from)).toBeNull();
|
||||
});
|
||||
|
||||
it('counts a passing signature that is not the first one', () => {
|
||||
const auth = parseAuthenticationResults('mx; dkim=fail header.d=shop.example; dkim=pass header.d=esp.example; spf=none smtp.mailfrom=x.example');
|
||||
expect(auth.dkim?.result).toBe('fail');
|
||||
expect(auth.dkim?.all).toHaveLength(2);
|
||||
expect(getSenderVerification(auth, 'news@shop.example')).toBeNull();
|
||||
});
|
||||
|
||||
it('does not take a HELO pass for a MAIL FROM pass', () => {
|
||||
const auth = parseAuthenticationResults('mx; spf=pass smtp.helo=web1.hoster.example; spf=none smtp.mailfrom=www-data@web1.hoster.example');
|
||||
expect(getSenderVerification(auth, 'support@bank.example')?.status).toBe('unverified');
|
||||
});
|
||||
|
||||
it('says nothing without results or a From address', () => {
|
||||
expect(getSenderVerification(undefined, 'a@b.example')).toBeNull();
|
||||
expect(getSenderVerification({}, 'a@b.example')).toBeNull();
|
||||
expect(getSenderVerification({ iprev: { result: 'fail' } }, 'a@b.example')).toBeNull();
|
||||
expect(getSenderVerification(phish, undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { JMAPClient } from '../jmap/client';
|
||||
|
||||
// #1173: moving many folders at once goes through one Mailbox/set per
|
||||
// maxObjectsInSet chunk, and a refused folder doesn't fail the others.
|
||||
|
||||
function makeSession(maxObjectsInSet?: number) {
|
||||
return {
|
||||
capabilities: { 'urn:ietf:params:jmap:core': maxObjectsInSet ? { maxObjectsInSet } : {} },
|
||||
accounts: { 'acct-1': { name: 'test', isPersonal: true, accountCapabilities: {} } },
|
||||
primaryAccounts: { 'urn:ietf:params:jmap:mail': 'acct-1' },
|
||||
apiUrl: 'https://mail.example.com/jmap/api',
|
||||
downloadUrl: 'https://mail.example.com/jmap/download/{accountId}/{blobId}/{name}',
|
||||
uploadUrl: 'https://mail.example.com/jmap/upload/{accountId}/',
|
||||
eventSourceUrl: 'https://mail.example.com/jmap/eventsource',
|
||||
};
|
||||
}
|
||||
|
||||
function jsonResponse(body: unknown): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
describe('JMAPClient.updateMailboxes', () => {
|
||||
let fetchSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
fetchSpy = vi.spyOn(globalThis, 'fetch');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fetchSpy.mockRestore();
|
||||
});
|
||||
|
||||
async function connectedClient(maxObjectsInSet?: number): Promise<JMAPClient> {
|
||||
fetchSpy.mockResolvedValueOnce(jsonResponse(makeSession(maxObjectsInSet)));
|
||||
const client = JMAPClient.withBearer('https://mail.example.com', 'token123', 'user@test.com');
|
||||
await client.connect();
|
||||
fetchSpy.mockReset();
|
||||
return client;
|
||||
}
|
||||
|
||||
const sentUpdates = (): Record<string, unknown>[] => fetchSpy.mock.calls.map((call: unknown[]) => {
|
||||
const init = call[1] as RequestInit;
|
||||
const [name, args] = JSON.parse(init.body as string).methodCalls[0];
|
||||
expect(name).toBe('Mailbox/set');
|
||||
return args.update as Record<string, unknown>;
|
||||
});
|
||||
|
||||
it('sends every update in one Mailbox/set and returns the refused ids', async () => {
|
||||
const client = await connectedClient();
|
||||
fetchSpy.mockResolvedValueOnce(jsonResponse({
|
||||
methodResponses: [
|
||||
['Mailbox/set', { updated: { a: null, c: null }, notUpdated: { b: { type: 'invalidProperties' } } }, '0'],
|
||||
],
|
||||
}));
|
||||
|
||||
const failed = await client.updateMailboxes({
|
||||
a: { parentId: 'old' },
|
||||
b: { parentId: 'old' },
|
||||
c: { parentId: 'old' },
|
||||
});
|
||||
|
||||
expect(failed).toEqual({ b: 'invalidProperties' });
|
||||
expect(sentUpdates()).toEqual([
|
||||
{ a: { parentId: 'old' }, b: { parentId: 'old' }, c: { parentId: 'old' } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('splits the updates by maxObjectsInSet', async () => {
|
||||
const client = await connectedClient(2);
|
||||
fetchSpy.mockImplementation(async () => jsonResponse({
|
||||
methodResponses: [['Mailbox/set', { updated: {} }, '0']],
|
||||
}));
|
||||
|
||||
const failed = await client.updateMailboxes({
|
||||
a: { parentId: null },
|
||||
b: { parentId: null },
|
||||
c: { parentId: null },
|
||||
});
|
||||
|
||||
expect(failed).toEqual({});
|
||||
expect(sentUpdates().map(u => Object.keys(u))).toEqual([['a', 'b'], ['c']]);
|
||||
});
|
||||
|
||||
it('throws when the server rejects the whole call', async () => {
|
||||
const client = await connectedClient();
|
||||
fetchSpy.mockResolvedValueOnce(jsonResponse({
|
||||
methodResponses: [['error', { type: 'forbidden' }, '0']],
|
||||
}));
|
||||
|
||||
await expect(client.updateMailboxes({ a: { parentId: null } })).rejects.toThrow('forbidden');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
/**
|
||||
* Tailwind v4's preflight resets buttons to the default cursor, so buttons,
|
||||
* menu items, tabs and switches showed the arrow instead of the pointer.
|
||||
* globals.css gives them the pointer back from the base layer, where a cursor
|
||||
* utility on a component still overrides it.
|
||||
*
|
||||
* jsdom does not cascade layered stylesheets, so lock the stylesheet rules.
|
||||
* Only rules inside `@layer base` count: unlayered, they would beat the
|
||||
* utilities.
|
||||
*/
|
||||
const css = readFileSync(path.join(process.cwd(), 'app', 'globals.css'), 'utf8');
|
||||
|
||||
/** Return the bodies of every top-level `@layer base { ... }` block. */
|
||||
function baseLayers(): string[] {
|
||||
const bodies: string[] = [];
|
||||
let from = 0;
|
||||
for (;;) {
|
||||
const idx = css.indexOf('\n@layer base {', from);
|
||||
if (idx === -1) return bodies;
|
||||
const open = css.indexOf('{', idx);
|
||||
let depth = 0;
|
||||
let end = open;
|
||||
for (; end < css.length; end++) {
|
||||
if (css[end] === '{') depth++;
|
||||
if (css[end] === '}' && --depth === 0) break;
|
||||
}
|
||||
bodies.push(css.slice(open + 1, end));
|
||||
from = end;
|
||||
}
|
||||
}
|
||||
|
||||
/** Selectors of each rule in a block, keyed by the cursor the rule sets. */
|
||||
function cursorRules(block: string): Map<string, string[]> {
|
||||
const rules = new Map<string, string[]>();
|
||||
for (const m of block.matchAll(/([^{}]+)\{\s*cursor:\s*([a-z-]+);\s*\}/g)) {
|
||||
const selectors = m[1].split(',').map((s) => s.trim());
|
||||
rules.set(m[2], [...(rules.get(m[2]) ?? []), ...selectors]);
|
||||
}
|
||||
return rules;
|
||||
}
|
||||
|
||||
describe('pointer cursor on clickable controls', () => {
|
||||
const rules = cursorRules(baseLayers().join('\n'));
|
||||
const pointer = rules.get('pointer') ?? [];
|
||||
const notAllowed = rules.get('not-allowed') ?? [];
|
||||
|
||||
it.each([
|
||||
'button:not(:disabled)',
|
||||
'select:not(:disabled)',
|
||||
'[role="button"]:not([aria-disabled="true"])',
|
||||
'[role="tab"]:not([aria-disabled="true"])',
|
||||
'[role="switch"]:not([aria-disabled="true"])',
|
||||
'[role="option"]:not([aria-disabled="true"])',
|
||||
'[role="menuitem"]:not([aria-disabled="true"])',
|
||||
])('gives %s the pointer', (selector) => {
|
||||
expect(pointer).toContain(selector);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'button:disabled',
|
||||
'select:disabled',
|
||||
'[role="button"][aria-disabled="true"]',
|
||||
'[role="menuitem"][aria-disabled="true"]',
|
||||
])('gives %s not-allowed', (selector) => {
|
||||
expect(notAllowed).toContain(selector);
|
||||
});
|
||||
|
||||
it('never gives a disabled control the pointer', () => {
|
||||
for (const selector of pointer) {
|
||||
expect(selector).not.toMatch(/:disabled(?!\))|\[aria-disabled="true"\](?!\))/);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
// @vitest-environment node
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
// Every push carries the pushing tab's whole template list. The route used to
|
||||
// store it as is, so a tab or device that had not loaded since another one
|
||||
// added a template wiped that template for everyone. Templates are now merged
|
||||
// into the stored blob.
|
||||
|
||||
vi.mock('@/lib/auth/session-secret', () => ({
|
||||
getSessionSecret: () => 's'.repeat(64),
|
||||
hasSessionSecret: () => true,
|
||||
}));
|
||||
vi.mock('@/lib/logger', () => ({
|
||||
logger: { warn: () => {}, error: () => {}, info: () => {}, debug: () => {} },
|
||||
}));
|
||||
vi.mock('@/lib/admin/config-manager', () => ({
|
||||
configManager: {
|
||||
ensureLoaded: async () => {},
|
||||
get: (key: string, fallback: unknown) => (key === 'settingsSyncEnabled' ? true : fallback),
|
||||
getPolicy: () => ({ restrictions: {} }),
|
||||
},
|
||||
}));
|
||||
|
||||
const jar = new Map<string, string>();
|
||||
vi.mock('next/headers', () => ({
|
||||
cookies: async () => ({
|
||||
get: (name: string) => (jar.has(name) ? { name, value: jar.get(name)! } : undefined),
|
||||
set: (name: string, value: string) => { jar.set(name, value); },
|
||||
delete: (name: string) => { jar.delete(name); },
|
||||
getAll: () => [...jar].map(([name, value]) => ({ name, value })),
|
||||
}),
|
||||
}));
|
||||
|
||||
const store = new Map<string, Record<string, unknown>>();
|
||||
vi.mock('@/lib/settings-sync', () => ({
|
||||
loadUserSettings: async (username: string, serverUrl: string) => store.get(`${username}|${serverUrl}`) ?? null,
|
||||
saveUserSettings: async (username: string, serverUrl: string, settings: Record<string, unknown>) => {
|
||||
store.set(`${username}|${serverUrl}`, settings);
|
||||
},
|
||||
deleteUserSettings: async () => {},
|
||||
}));
|
||||
|
||||
const SERVER = 'https://mail.example.org';
|
||||
const USER = 'alice@example.org';
|
||||
const KEY = `${USER}|${SERVER}`;
|
||||
|
||||
function template(id: string, updatedAt = '2026-10-01T00:00:00.000Z') {
|
||||
return { id, name: id, subject: '', body: '', isHTML: false, category: '', isFavorite: false, createdAt: updatedAt, updatedAt };
|
||||
}
|
||||
|
||||
async function push(settings: Record<string, unknown>) {
|
||||
const { POST } = await import('@/app/api/settings/route');
|
||||
const res = await POST(new NextRequest('https://webmail.example/api/settings', {
|
||||
method: 'POST',
|
||||
headers: { 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ username: USER, serverUrl: SERVER, settings }),
|
||||
}));
|
||||
expect(res.status).toBe(200);
|
||||
return store.get(KEY)!;
|
||||
}
|
||||
|
||||
function ids(saved: Record<string, unknown>) {
|
||||
return (saved.templates as { id: string }[]).map((t) => t.id).sort();
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
store.clear();
|
||||
jar.clear();
|
||||
process.env.SETTINGS_SYNC_ENABLED = 'true';
|
||||
const { setStalwartAuthContextInStore } = await import('@/lib/stalwart/auth-context');
|
||||
setStalwartAuthContextInStore({
|
||||
get: (n: string) => (jar.has(n) ? { name: n, value: jar.get(n)! } : undefined),
|
||||
set: (n: string, v: string) => { jar.set(n, v); },
|
||||
delete: (n: string) => { jar.delete(n); },
|
||||
} as never, 0, { serverUrl: SERVER, authHeader: 'Bearer t', username: USER });
|
||||
});
|
||||
|
||||
describe('settings route merges templates into the stored blob', () => {
|
||||
it('keeps a template another device added when a stale client pushes', async () => {
|
||||
store.set(KEY, { theme: 'light', templates: [template('a'), template('b')], deletedTemplateIds: {} });
|
||||
|
||||
const saved = await push({ theme: 'dark', templates: [template('a')], deletedTemplateIds: {} });
|
||||
|
||||
expect(saved.theme).toBe('dark');
|
||||
expect(ids(saved)).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('still removes a template the pushing client deleted', async () => {
|
||||
store.set(KEY, { templates: [template('a'), template('b')], deletedTemplateIds: {} });
|
||||
|
||||
const saved = await push({ templates: [template('a')], deletedTemplateIds: { b: new Date().toISOString() } });
|
||||
|
||||
expect(ids(saved)).toEqual(['a']);
|
||||
expect(Object.keys(saved.deletedTemplateIds as object)).toEqual(['b']);
|
||||
});
|
||||
|
||||
it('takes the newer edit of the same template', async () => {
|
||||
store.set(KEY, { templates: [{ ...template('a', '2026-10-02T00:00:00.000Z'), name: 'newer' }], deletedTemplateIds: {} });
|
||||
|
||||
const saved = await push({ templates: [{ ...template('a'), name: 'older' }], deletedTemplateIds: {} });
|
||||
|
||||
expect((saved.templates as { name: string }[])[0].name).toBe('newer');
|
||||
});
|
||||
|
||||
it('keeps stored templates when a push carries none', async () => {
|
||||
store.set(KEY, { templates: [template('a')], deletedTemplateIds: {} });
|
||||
|
||||
const saved = await push({ theme: 'dark' });
|
||||
|
||||
expect(ids(saved)).toEqual(['a']);
|
||||
});
|
||||
|
||||
it('stores the first push as is', async () => {
|
||||
const saved = await push({ templates: [template('a')], deletedTemplateIds: {} });
|
||||
|
||||
expect(ids(saved)).toEqual(['a']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { formatTabTitle, mailTitleContext } from '../tab-title';
|
||||
|
||||
describe('formatTabTitle', () => {
|
||||
it('puts the context first, the account next and the app name last', () => {
|
||||
expect(formatTabTitle('Inbox (3)', 'jane@example.com', 'Acme Mail')).toBe(
|
||||
'Inbox (3) - jane@example.com - Acme Mail',
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves out a missing account', () => {
|
||||
expect(formatTabTitle('Inbox', null, 'Acme Mail')).toBe('Inbox - Acme Mail');
|
||||
expect(formatTabTitle('Inbox', '', 'Acme Mail')).toBe('Inbox - Acme Mail');
|
||||
});
|
||||
|
||||
it('leaves out a missing context', () => {
|
||||
expect(formatTabTitle(undefined, 'jane@example.com', 'Acme Mail')).toBe('jane@example.com - Acme Mail');
|
||||
expect(formatTabTitle('', 'jane@example.com', 'Acme Mail')).toBe('jane@example.com - Acme Mail');
|
||||
});
|
||||
|
||||
it('falls back to the app name alone', () => {
|
||||
expect(formatTabTitle(null, null, 'Acme Mail')).toBe('Acme Mail');
|
||||
});
|
||||
});
|
||||
|
||||
describe('mailTitleContext', () => {
|
||||
const view = { composer: 'Reply', subject: 'Quarterly report', mailbox: 'Inbox (3)' };
|
||||
|
||||
it('names the composer while it is open', () => {
|
||||
expect(mailTitleContext(view)).toBe('Reply');
|
||||
});
|
||||
|
||||
it('names the open message otherwise', () => {
|
||||
expect(mailTitleContext({ ...view, composer: null })).toBe('Quarterly report');
|
||||
});
|
||||
|
||||
it('names the mailbox when no message is open', () => {
|
||||
expect(mailTitleContext({ mailbox: 'Inbox (3)' })).toBe('Inbox (3)');
|
||||
});
|
||||
|
||||
it('has nothing to add before a mailbox is selected', () => {
|
||||
expect(mailTitleContext({})).toBeNull();
|
||||
});
|
||||
|
||||
it('names the mailbox instead of the open message when the subject is kept out', () => {
|
||||
expect(mailTitleContext({ ...view, composer: null }, { showSubject: false })).toBe('Inbox (3)');
|
||||
});
|
||||
|
||||
it('still names the composer when the subject is kept out', () => {
|
||||
expect(mailTitleContext(view, { showSubject: false })).toBe('Reply');
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
AUTO_TIME_ZONE,
|
||||
displayNow,
|
||||
fromDisplayDate,
|
||||
fromWallClock,
|
||||
fromZonedDisplayDate,
|
||||
getBrowserTimeZone,
|
||||
getTimeZoneOffsetMs,
|
||||
@@ -114,6 +115,15 @@ describe('wall-clock helpers', () => {
|
||||
expect(isNaN(toZonedDisplayDate(bad, 'Europe/Berlin').getTime())).toBe(true);
|
||||
expect(isNaN(fromZonedDisplayDate(bad, 'Europe/Berlin').getTime())).toBe(true);
|
||||
});
|
||||
|
||||
it('reads a wall-clock in a zone straight from its fields', () => {
|
||||
const at = (year: number, month: number, day: number, hour: number, minute: number, zone: string) =>
|
||||
fromWallClock({ year, month, day, hour, minute, second: 0 }, zone).toISOString();
|
||||
expect(at(2026, 10, 5, 8, 0, 'Europe/Berlin')).toBe('2026-10-05T06:00:00.000Z');
|
||||
expect(at(2026, 10, 26, 18, 0, 'Europe/Berlin')).toBe('2026-10-26T17:00:00.000Z');
|
||||
// 02:30 on 29 March is skipped in Berlin, but exists in New York.
|
||||
expect(at(2026, 3, 29, 2, 30, 'America/New_York')).toBe('2026-03-29T06:30:00.000Z');
|
||||
});
|
||||
});
|
||||
|
||||
describe('store-aware display dates', () => {
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { totpIssuer } from '../totp-issuer';
|
||||
|
||||
describe('totpIssuer', () => {
|
||||
it('uses the configured app name', () => {
|
||||
expect(totpIssuer('Acme Mail')).toBe('Acme Mail');
|
||||
});
|
||||
|
||||
it('keeps Stalwart on a stock install', () => {
|
||||
expect(totpIssuer('Webmail')).toBe('Stalwart');
|
||||
expect(totpIssuer('')).toBe('Stalwart');
|
||||
expect(totpIssuer(' ')).toBe('Stalwart');
|
||||
expect(totpIssuer(undefined)).toBe('Stalwart');
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
buildCrossFilter,
|
||||
getCrossUnreadTotal,
|
||||
fetchCrossViewEmails,
|
||||
searchCrossViewEmails,
|
||||
advancedSearchCrossViewEmails,
|
||||
resolveSourceFolderName,
|
||||
type UnifiedAccountClient,
|
||||
@@ -205,17 +206,79 @@ describe('fetchCrossViewEmails', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('advancedSearchCrossViewEmails', () => {
|
||||
it('ANDs the advanced filter onto the cross-view membership', async () => {
|
||||
const advancedSearchEmails = vi.fn().mockResolvedValue({ emails: [], total: 0, hasMore: false });
|
||||
const a = makeAccount({ accountId: 'a', mailboxes: [mb('inbox', 'inbox')] }, { advancedSearchEmails });
|
||||
describe('searchCrossViewEmails', () => {
|
||||
const folders = [mb('inbox', 'inbox'), mb('sent', 'sent'), mb('archive', 'archive'), mb('trash', 'trash'), mb('junk', 'junk')];
|
||||
|
||||
await advancedSearchCrossViewEmails([a], 'all', { hasKeyword: '$flagged' }, 50, 0);
|
||||
it('searches every folder but Trash and Junk from All mail, Sent included', async () => {
|
||||
const advancedSearchEmails = vi.fn().mockResolvedValue({
|
||||
emails: [{ id: 's1', receivedAt: '2026-01-01T10:00:00Z', mailboxIds: { sent: true } } as unknown as Email],
|
||||
total: 1,
|
||||
hasMore: false,
|
||||
});
|
||||
const a = makeAccount({ accountId: 'a', mailboxes: folders }, { advancedSearchEmails });
|
||||
|
||||
const result = await searchCrossViewEmails([a], 'all', 'invoice', 50, 0);
|
||||
|
||||
const [filter] = advancedSearchEmails.mock.calls[0];
|
||||
expect(filter).toEqual({
|
||||
operator: 'AND',
|
||||
conditions: [{ inMailbox: 'inbox' }, { hasKeyword: '$flagged' }],
|
||||
conditions: [{ text: 'invoice' }, { inMailboxOtherThan: ['trash', 'junk'] }],
|
||||
});
|
||||
expect(result.emails.map((e) => e.sourceFolder)).toEqual(['sent']);
|
||||
});
|
||||
|
||||
it('still narrows Starred to its own list', async () => {
|
||||
const advancedSearchEmails = vi.fn().mockResolvedValue({ emails: [], total: 0, hasMore: false });
|
||||
const a = makeAccount({ accountId: 'a', mailboxes: folders }, { advancedSearchEmails });
|
||||
|
||||
await searchCrossViewEmails([a], 'starred', 'invoice', 50, 0);
|
||||
|
||||
const [filter] = advancedSearchEmails.mock.calls[0];
|
||||
expect(filter).toEqual({
|
||||
operator: 'AND',
|
||||
conditions: [
|
||||
{ operator: 'AND', conditions: [{ inMailbox: 'inbox' }, { hasKeyword: '$flagged' }] },
|
||||
{ text: 'invoice' },
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('advancedSearchCrossViewEmails', () => {
|
||||
it('ANDs the advanced filter onto the Unread membership', async () => {
|
||||
const advancedSearchEmails = vi.fn().mockResolvedValue({ emails: [], total: 0, hasMore: false });
|
||||
const a = makeAccount({ accountId: 'a', mailboxes: [mb('inbox', 'inbox')] }, { advancedSearchEmails });
|
||||
|
||||
await advancedSearchCrossViewEmails([a], 'unread', { from: 'bob' }, 50, 0);
|
||||
|
||||
const [filter] = advancedSearchEmails.mock.calls[0];
|
||||
expect(filter).toEqual({
|
||||
operator: 'AND',
|
||||
conditions: [
|
||||
{ operator: 'AND', conditions: [{ inMailbox: 'inbox' }, { notKeyword: '$seen' }] },
|
||||
{ from: 'bob' },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('searches every folder but Trash and Junk from All mail', async () => {
|
||||
const advancedSearchEmails = vi.fn().mockResolvedValue({ emails: [], total: 0, hasMore: false });
|
||||
const shared = makeAccount(
|
||||
{
|
||||
accountId: 'owner-1',
|
||||
isShared: true,
|
||||
mailboxes: [mb('ns:inbox', 'inbox', 0, 'orig-inbox'), mb('ns:sent', 'sent', 0, 'orig-sent'), mb('ns:trash', 'trash', 0, 'orig-trash')],
|
||||
},
|
||||
{ advancedSearchEmails },
|
||||
);
|
||||
|
||||
await advancedSearchCrossViewEmails([shared], 'all', { from: 'bob' }, 50, 0);
|
||||
|
||||
const [filter, accountId] = advancedSearchEmails.mock.calls[0];
|
||||
expect(accountId).toBe('owner-1');
|
||||
expect(filter).toEqual({
|
||||
operator: 'AND',
|
||||
conditions: [{ from: 'bob' }, { inMailboxOtherThan: ['orig-trash'] }],
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@ export interface FeatureGates {
|
||||
debugModeEnabled: boolean;
|
||||
folderIconsEnabled: boolean;
|
||||
hoverActionsConfigEnabled: boolean;
|
||||
tabTitleSubjectEnabled: boolean;
|
||||
filesEnabled: boolean;
|
||||
contactsEnabled: boolean;
|
||||
/** @deprecated Folded into `crossAllViewEnabled`; normalized forward on policy load. */
|
||||
@@ -85,6 +86,7 @@ export const DEFAULT_FEATURE_GATES: FeatureGates = {
|
||||
debugModeEnabled: true,
|
||||
folderIconsEnabled: true,
|
||||
hoverActionsConfigEnabled: true,
|
||||
tabTitleSubjectEnabled: true,
|
||||
filesEnabled: true,
|
||||
contactsEnabled: true,
|
||||
allMailViewEnabled: false,
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { addDays, format, parseISO } from "date-fns";
|
||||
import type { CalendarEvent } from "@/lib/jmap/types";
|
||||
import { sanitizeOutgoingCalendarEventData } from "@/lib/calendar-event-normalization";
|
||||
import { generateUUID } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Create payload for a copy of `event` one day later. Shared by the editor,
|
||||
* the detail popover and the context menu - each used to build its own copy
|
||||
* and two of them dropped the meeting link (`virtualLocations`, #1170).
|
||||
* The start stays in the event's own wall clock and time zone.
|
||||
*/
|
||||
export function buildDuplicateEventData(event: CalendarEvent): Partial<CalendarEvent> {
|
||||
const newStart = addDays(parseISO(event.start), 1);
|
||||
const data: Partial<CalendarEvent> = {
|
||||
uid: generateUUID(),
|
||||
title: event.title,
|
||||
description: event.description,
|
||||
start: format(newStart, "yyyy-MM-dd'T'HH:mm:ss"),
|
||||
duration: event.duration,
|
||||
timeZone: event.timeZone,
|
||||
showWithoutTime: event.showWithoutTime,
|
||||
calendarIds: { ...event.calendarIds },
|
||||
status: "confirmed",
|
||||
freeBusyStatus: event.freeBusyStatus,
|
||||
privacy: event.privacy,
|
||||
};
|
||||
if (event.locations) data.locations = structuredClone(event.locations);
|
||||
if (event.virtualLocations) data.virtualLocations = structuredClone(event.virtualLocations);
|
||||
if (event.recurrenceRules) data.recurrenceRules = structuredClone(event.recurrenceRules);
|
||||
if (event.alerts) data.alerts = structuredClone(event.alerts);
|
||||
if (event.participants) data.participants = structuredClone(event.participants);
|
||||
return sanitizeOutgoingCalendarEventData(data);
|
||||
}
|
||||
+12
-1
@@ -140,10 +140,11 @@ export class DemoJMAPClient implements IJMAPClient {
|
||||
async getMailboxes(_accountId?: string): Promise<Mailbox[]> { return [...this.data.mailboxes]; }
|
||||
async getAllMailboxes(): Promise<Mailbox[]> { return [...this.data.mailboxes]; }
|
||||
|
||||
async createMailbox(name: string, parentId?: string, _accountId?: string): Promise<Mailbox> {
|
||||
async createMailbox(name: string, parentId?: string, _accountId?: string, options?: { role?: string }): Promise<Mailbox> {
|
||||
const mb: Mailbox = {
|
||||
id: generateDemoId('mailbox'),
|
||||
name,
|
||||
role: options?.role,
|
||||
sortOrder: 100,
|
||||
totalEmails: 0,
|
||||
unreadEmails: 0,
|
||||
@@ -162,6 +163,16 @@ export class DemoJMAPClient implements IJMAPClient {
|
||||
if (mb) Object.assign(mb, changes);
|
||||
}
|
||||
|
||||
async updateMailboxes(updates: Record<string, { name?: string; parentId?: string | null; role?: string | null; sortOrder?: number }>, _accountId?: string): Promise<Record<string, string>> {
|
||||
const failed: Record<string, string> = {};
|
||||
for (const [id, changes] of Object.entries(updates)) {
|
||||
const mb = this.data.mailboxes.find(m => m.id === id);
|
||||
if (mb) Object.assign(mb, changes);
|
||||
else failed[id] = 'notFound';
|
||||
}
|
||||
return failed;
|
||||
}
|
||||
|
||||
async deleteMailbox(mailboxId: string, _accountId?: string): Promise<void> {
|
||||
this.data.mailboxes = this.data.mailboxes.filter(m => m.id !== mailboxId);
|
||||
// Also remove emails in this mailbox
|
||||
|
||||
+66
-6
@@ -33,10 +33,62 @@ export function isAuthenticationSpoofed(auth?: AuthenticationResults): boolean {
|
||||
if (auth.dmarc?.result === 'fail') return true;
|
||||
// Otherwise a hard SPF fail with no valid DKIM signature means the sender
|
||||
// isn't authorized for the envelope domain.
|
||||
if (auth.spf?.result === 'fail' && auth.dkim?.result !== 'pass') return true;
|
||||
if (auth.spf?.result === 'fail' && !hasDkimPass(auth)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function hasDkimPass(auth: AuthenticationResults): boolean {
|
||||
return auth.dkim?.result === 'pass' || !!auth.dkim?.all?.some((entry) => entry.result === 'pass');
|
||||
}
|
||||
|
||||
function domainOf(address: string): string | undefined {
|
||||
const domain = address.slice(address.lastIndexOf('@') + 1).trim().toLowerCase().replace(/\.$/, '');
|
||||
return /^[^\s<>@]+\.[^\s<>@]+$/.test(domain) ? domain : undefined;
|
||||
}
|
||||
|
||||
export interface SenderVerification {
|
||||
/**
|
||||
* `failed`: the message fails the From domain's checks (see
|
||||
* isAuthenticationSpoofed). `unverified`: neither SPF nor DKIM passes, so
|
||||
* nothing ties the message to any domain, let alone the one in From.
|
||||
*/
|
||||
status: 'failed' | 'unverified';
|
||||
/** Domain of the visible From address. */
|
||||
domain: string;
|
||||
/** Envelope (MAIL FROM) host, when it differs from `domain`. */
|
||||
sentFrom?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the receiving server's checks back the visible From domain.
|
||||
* Returns null when they do, or when there are no results to judge by.
|
||||
*
|
||||
* DMARC alone can't answer this: mail-auth (Stalwart) only checks alignment
|
||||
* once SPF or DKIM passes, so a message that passes neither reports
|
||||
* `dmarc=none` even when the From domain publishes a policy. That is the
|
||||
* plainest kind of forgery, so it gets its own verdict here.
|
||||
*/
|
||||
export function getSenderVerification(
|
||||
auth: AuthenticationResults | undefined,
|
||||
fromEmail: string | undefined,
|
||||
): SenderVerification | null {
|
||||
if (!auth || !fromEmail || (!auth.spf && !auth.dkim && !auth.dmarc)) return null;
|
||||
const domain = domainOf(fromEmail);
|
||||
if (!domain) return null;
|
||||
|
||||
// DMARC only counts the MAIL FROM identity; a HELO pass proves nothing
|
||||
// about who wrote the message.
|
||||
const mailFrom = auth.spf?.all?.find((entry) => entry.identity === 'mailfrom');
|
||||
const spfPass = auth.spf?.all ? mailFrom?.result === 'pass' : auth.spf?.result === 'pass';
|
||||
const envelope = mailFrom?.domain ?? auth.spf?.domain;
|
||||
const envelopeDomain = envelope ? domainOf(envelope) : undefined;
|
||||
const sentFrom = envelopeDomain && envelopeDomain !== domain ? envelopeDomain : undefined;
|
||||
|
||||
if (isAuthenticationSpoofed(auth)) return { status: 'failed', domain, sentFrom };
|
||||
if (auth.dmarc?.result === 'pass' || spfPass || hasDkimPass(auth)) return null;
|
||||
return { status: 'unverified', domain, sentFrom };
|
||||
}
|
||||
|
||||
interface ResInfo {
|
||||
method: string;
|
||||
result: string;
|
||||
@@ -182,12 +234,20 @@ export function parseAuthenticationResults(headers: string | readonly string[]):
|
||||
};
|
||||
}
|
||||
|
||||
const dkim = own.find((info) => info.method === 'dkim');
|
||||
if (dkim) {
|
||||
// A message can carry several signatures (the author's domain and the
|
||||
// sending service's). The first one stays the headline; keep them all so
|
||||
// a pass further down still counts.
|
||||
const dkimResults = own
|
||||
.filter((info) => info.method === 'dkim')
|
||||
.map((info) => ({
|
||||
result: info.result as DkimResult,
|
||||
domain: info.props['header.d'],
|
||||
selector: info.props['header.s'],
|
||||
}));
|
||||
if (dkimResults.length > 0) {
|
||||
results.dkim = {
|
||||
result: dkim.result as DkimResult,
|
||||
domain: dkim.props['header.d'],
|
||||
selector: dkim.props['header.s'],
|
||||
...dkimResults[0],
|
||||
...(dkimResults.length > 1 ? { all: dkimResults } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { FilterAction, FilterRule, VacationForward } from '@/lib/jmap/sieve-types';
|
||||
import { generateScript } from '@/lib/sieve/generator';
|
||||
import { VACATION_FORWARD_MARKER } from '@/lib/sieve/vacation-forward';
|
||||
import { forwardsAround, inRunOrder, ruleForwards, ruleStops, worstCaseForwards } from '../forward-limit';
|
||||
|
||||
const forward: FilterAction = { type: 'forward', value: 'office@example.com' };
|
||||
const rule = (actions: FilterAction[], stopProcessing = false): FilterRule => ({
|
||||
id: `r-${Math.random()}`,
|
||||
name: 'R',
|
||||
enabled: true,
|
||||
matchType: 'all',
|
||||
conditions: [{ field: 'from', comparator: 'contains', value: 'a' }],
|
||||
actions,
|
||||
stopProcessing,
|
||||
});
|
||||
const vacationForward = (keepCopy: boolean, enabled = true): VacationForward => ({
|
||||
enabled,
|
||||
to: 'deputy@example.com',
|
||||
keepCopy,
|
||||
});
|
||||
|
||||
describe('the out of office forwarding in the run order', () => {
|
||||
const rules = [rule([forward])];
|
||||
|
||||
it('goes ahead of the rules, and stops unless it keeps a copy', () => {
|
||||
expect(inRunOrder(rules, vacationForward(true))).toEqual([
|
||||
{ enabled: true, actions: [{ type: 'forward', value: 'deputy@example.com' }], stopProcessing: false },
|
||||
...rules,
|
||||
]);
|
||||
expect(inRunOrder(rules, vacationForward(false))[0].stopProcessing).toBe(true);
|
||||
});
|
||||
|
||||
it('is left out while it is off', () => {
|
||||
expect(inRunOrder(rules, vacationForward(true, false))).toBe(rules);
|
||||
expect(inRunOrder(rules, null)).toBe(rules);
|
||||
expect(inRunOrder(rules, undefined)).toBe(rules);
|
||||
});
|
||||
|
||||
it('agrees with the script: one redirect ahead of every rule, and a stop unless a copy is kept', () => {
|
||||
for (const keepCopy of [true, false]) {
|
||||
const script = generateScript(rules, undefined, { vacationForward: vacationForward(keepCopy) });
|
||||
expect(script.indexOf(VACATION_FORWARD_MARKER)).toBeGreaterThan(-1);
|
||||
expect(script.indexOf(VACATION_FORWARD_MARKER)).toBeLessThan(script.indexOf('# Rule:'));
|
||||
const lines = script.split(VACATION_FORWARD_MARKER)[1].split('# Rule:')[0].split('\n').map((line) => line.trim());
|
||||
const [ahead] = inRunOrder([], vacationForward(keepCopy));
|
||||
expect(lines.filter((line) => line.startsWith('redirect ')).length, `keepCopy ${keepCopy}`).toBe(ruleForwards(ahead));
|
||||
expect(lines.includes('stop;'), `keepCopy ${keepCopy}`).toBe(ruleStops(ahead));
|
||||
}
|
||||
});
|
||||
|
||||
it('adds its forward to every rule only when it keeps a copy', () => {
|
||||
// A new rule goes behind Bulwark's rules, here behind the one rule.
|
||||
expect(forwardsAround(inRunOrder(rules, vacationForward(true)), 2, false)).toEqual({ before: 2, after: 0 });
|
||||
expect(forwardsAround(inRunOrder(rules, vacationForward(false)), 2, false)).toEqual({ before: 1, after: 0 });
|
||||
expect(worstCaseForwards(inRunOrder(rules, vacationForward(true)))).toBe(2);
|
||||
expect(worstCaseForwards(inRunOrder(rules, vacationForward(false)))).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -240,6 +240,15 @@ describe('applyQuickRule', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('leaves a rule with a period alone, since the new values would only act within it', () => {
|
||||
for (const period of [{ activeFrom: '2026-10-05T06:00:00.000Z' }, { activeUntil: '2026-10-16T16:00:00.000Z' }]) {
|
||||
const existing = [rule({ id: 'target', ...period })];
|
||||
// Neither merged into it nor counted as covered by it.
|
||||
expect(applyQuickRule(existing, candidate('bob@acme.com')).kind).toBe('created');
|
||||
expect(applyQuickRule(existing, candidate('anna@acme.com')).kind).toBe('created');
|
||||
}
|
||||
});
|
||||
|
||||
it('merges List-Id rules by header name, whatever its case', () => {
|
||||
const existing = [rule({ id: 'lists', conditions: [{ field: 'header', headerName: 'list-id', comparator: 'contains', value: 'a.example.org' }] })];
|
||||
const outcome = applyQuickRule(existing, rule({
|
||||
|
||||
@@ -144,6 +144,14 @@ describe('retroactiveSupport', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('never runs a rule with a period on old mail', () => {
|
||||
for (const period of [{ activeFrom: '2026-10-05T06:00:00.000Z' }, { activeUntil: '2026-10-16T16:00:00.000Z' }]) {
|
||||
const r = rule([{ field: 'from', comparator: 'address_is', value: 'anna@acme.com' }], [{ type: 'mark_read' }], period);
|
||||
expect(retroactiveSupport(r)).toEqual({ ok: false, reason: 'period' });
|
||||
expect(planRetroactive(r, [message()])).toEqual({ ids: [], steps: [] });
|
||||
}
|
||||
});
|
||||
|
||||
it('refuses conditions it cannot evaluate like Sieve', () => {
|
||||
for (const condition of [
|
||||
{ field: 'body', comparator: 'contains', value: 'x' },
|
||||
|
||||
@@ -80,3 +80,50 @@ export function mockSieveAccount(
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const STALWART_VACATION_SCRIPT = 'require "vacation";\nvacation "Ich bin nicht da.";\n';
|
||||
|
||||
interface VacationFields {
|
||||
fromDate: string | null;
|
||||
toDate: string | null;
|
||||
subject: string;
|
||||
textBody: string;
|
||||
htmlBody: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A Sieve account that behaves like Stalwart for VacationResponse: turning
|
||||
* the auto-reply on activates its own "vacation" script, which switches
|
||||
* every other script off, and it reads as on only while that script is the
|
||||
* active one. Like Stalwart, an update that leaves isEnabled out switches
|
||||
* that script off, and one redirect per message is the default limit.
|
||||
*/
|
||||
export function mockStalwartAccount(
|
||||
accountId: string,
|
||||
initial: Parameters<typeof mockSieveAccount>[1],
|
||||
extensions: string[],
|
||||
options: { maxNumberRedirects?: number; vacation?: Partial<VacationFields> } = {},
|
||||
) {
|
||||
const account = mockSieveAccount(accountId, initial, extensions);
|
||||
let vacation: VacationFields = { fromDate: null, toDate: null, subject: '', textBody: 'away', htmlBody: null, ...options.vacation };
|
||||
const maxNumberRedirects = 'maxNumberRedirects' in options ? options.maxNumberRedirects : 1;
|
||||
const client = Object.assign(account.client, {
|
||||
supportsSieve: () => true,
|
||||
hasAccountCapability: () => false,
|
||||
getSieveAccounts: () => [{ id: accountId, name: 'Me', isPrimary: true }],
|
||||
getSieveCapabilities: () => ({ sieveExtensions: extensions, maxNumberRedirects }) as never,
|
||||
getVacationResponse: vi.fn(async () => ({ ...vacation, isEnabled: account.active() === 'vacation' })),
|
||||
setVacationResponse: vi.fn(async (updates: Record<string, unknown>) => {
|
||||
const { isEnabled, ...fields } = updates;
|
||||
vacation = { ...vacation, ...fields };
|
||||
if (isEnabled === true) {
|
||||
const own = account.scripts.find((s) => s.name === 'vacation');
|
||||
if (own) await client.activateSieveScript(own.id, accountId);
|
||||
else await client.createSieveScript('vacation', STALWART_VACATION_SCRIPT, true, accountId);
|
||||
} else if (account.active() === 'vacation') {
|
||||
await client.deactivateSieveScript(accountId);
|
||||
}
|
||||
}),
|
||||
});
|
||||
return { ...account, client };
|
||||
}
|
||||
|
||||
@@ -94,7 +94,7 @@ export async function readAccountFilters(client: IJMAPClient, accountId: string)
|
||||
};
|
||||
}
|
||||
|
||||
/** The script for `rules`, keeping the account's vacation and external requires. */
|
||||
/** The script for `rules`, keeping the account's vacation, its forwarding and external requires. */
|
||||
export function renderFiltersScript(
|
||||
rules: FilterRule[],
|
||||
filters: Pick<AccountFilters, 'parsed' | 'includeVacation' | 'capabilities'>,
|
||||
@@ -102,6 +102,8 @@ export function renderFiltersScript(
|
||||
return generateScript(rules, filters.parsed.vacation, {
|
||||
externalRequires: filters.parsed.externalRequires,
|
||||
includeVacation: filters.includeVacation,
|
||||
vacationForward: filters.parsed.vacationForward,
|
||||
vacationAudience: filters.parsed.vacationAudience,
|
||||
extensions: filters.capabilities?.sieveExtensions,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { FilterRule } from '@/lib/jmap/sieve-types';
|
||||
import type { FilterRule, VacationForward } from '@/lib/jmap/sieve-types';
|
||||
|
||||
/*
|
||||
* A server lets one message trigger only so many redirects
|
||||
@@ -61,3 +61,13 @@ export function forwardsAround(
|
||||
const after = worstCaseForwards(rules.slice(replaces ? index + 1 : index));
|
||||
return { before, after };
|
||||
}
|
||||
|
||||
/**
|
||||
* The rules in the order the script runs them: forwarding from the out of
|
||||
* office card goes ahead of every rule, and ends the script unless it keeps
|
||||
* a copy here.
|
||||
*/
|
||||
export function inRunOrder(rules: RuleForwards[], forward: VacationForward | null | undefined): RuleForwards[] {
|
||||
if (!forward?.enabled) return rules;
|
||||
return [{ enabled: true, actions: [{ type: 'forward', value: forward.to }], stopProcessing: !forward.keepCopy }, ...rules];
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Email, Mailbox } from '@/lib/jmap/types';
|
||||
import type { FilterAction, FilterCondition, FilterRule } from '@/lib/jmap/sieve-types';
|
||||
import { hasPeriod } from '@/lib/sieve/period';
|
||||
|
||||
/**
|
||||
* Rules made from a message: "Always move messages from Anna to…", "Block
|
||||
@@ -350,7 +351,9 @@ export type QuickRuleOutcome =
|
||||
/**
|
||||
* Save a one-click rule without piling up near-duplicates: an enabled
|
||||
* Bulwark rule with a single condition of the same kind and the same actions
|
||||
* takes the new values into its value list and keeps its name and place.
|
||||
* takes the new values into its value list and keeps its name and place. A
|
||||
* rule with a period only acts within it, and the new values would too, so
|
||||
* it is never the one.
|
||||
* `added` is the part of the rule that is new (the candidate itself, or the
|
||||
* merged rule narrowed to the added values), which is what the "apply to
|
||||
* existing messages" pass runs.
|
||||
@@ -361,6 +364,7 @@ export function applyQuickRule(rules: FilterRule[], candidate: FilterRule): Quic
|
||||
? rules.find(r =>
|
||||
isBulwarkRule(r)
|
||||
&& r.enabled
|
||||
&& !hasPeriod(r)
|
||||
&& r.conditions.length === 1
|
||||
&& sameConditionKind(r.conditions[0], condition)
|
||||
&& sameActions(r.actions, candidate.actions))
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { EmailAddress } from '@/lib/jmap/types';
|
||||
import type { FilterAction, FilterCondition, FilterRule } from '@/lib/jmap/sieve-types';
|
||||
import { hasPeriod } from '@/lib/sieve/period';
|
||||
import { unfoldHeader } from './quick-rules';
|
||||
|
||||
/**
|
||||
@@ -38,7 +39,9 @@ export type RetroSupport =
|
||||
/** A condition the client cannot evaluate like Sieve does (body, size, attachment). */
|
||||
| { ok: false; reason: 'condition' }
|
||||
/** An action that must never run on old mail (forward, reject, discard) or has no meaning there (keep). */
|
||||
| { ok: false; reason: 'action' };
|
||||
| { ok: false; reason: 'action' }
|
||||
/** A period: the rule acts on mail as it arrives within it, which says nothing about the mail already there. */
|
||||
| { ok: false; reason: 'period' };
|
||||
|
||||
/** A header condition the client can evaluate: which header it reads. */
|
||||
function conditionHeader(condition: FilterCondition): string | null {
|
||||
@@ -55,7 +58,10 @@ function usesAddressTest(condition: FilterCondition): boolean {
|
||||
&& ADDRESS_FIELDS.has(condition.field);
|
||||
}
|
||||
|
||||
export function retroactiveSupport(rule: Pick<FilterRule, 'conditions' | 'actions'>): RetroSupport {
|
||||
export function retroactiveSupport(
|
||||
rule: Pick<FilterRule, 'conditions' | 'actions' | 'activeFrom' | 'activeUntil'>,
|
||||
): RetroSupport {
|
||||
if (hasPeriod(rule)) return { ok: false, reason: 'period' };
|
||||
if (rule.conditions.length === 0) return { ok: false, reason: 'condition' };
|
||||
for (const condition of rule.conditions) {
|
||||
if (!TEXT_COMPARATORS.has(condition.comparator) || !conditionHeader(condition)) {
|
||||
|
||||
@@ -137,8 +137,11 @@ export interface IJMAPClient {
|
||||
getMailboxChanges?(sinceState: string, accountId?: string, maxChanges?: number): Promise<CollectionChanges | null>;
|
||||
/** Email/changes since `sinceState`; null when the server cannot compute the delta. */
|
||||
getEmailChanges?(sinceState: string, accountId?: string, maxChanges?: number): Promise<CollectionChanges | null>;
|
||||
createMailbox(name: string, parentId?: string, accountId?: string): Promise<Mailbox>;
|
||||
createMailbox(name: string, parentId?: string, accountId?: string, options?: { role?: string }): Promise<Mailbox>;
|
||||
updateMailbox(mailboxId: string, changes: { name?: string; parentId?: string | null; role?: string | null; sortOrder?: number }, accountId?: string): Promise<void>;
|
||||
// Many folder updates in as few Mailbox/set calls as the server allows.
|
||||
// Refused folders don't stop the rest: returns their ids with the SetError type.
|
||||
updateMailboxes(updates: Record<string, { name?: string; parentId?: string | null; role?: string | null; sortOrder?: number }>, accountId?: string): Promise<Record<string, string>>;
|
||||
// `removeEmails` destroys the folder's messages too (onDestroyRemoveEmails,
|
||||
// RFC 8621 §2.5) instead of failing with mailboxHasEmail.
|
||||
deleteMailbox(mailboxId: string, accountId?: string, options?: { removeEmails?: boolean }): Promise<void>;
|
||||
|
||||
+27
-1
@@ -3167,7 +3167,7 @@ export class JMAPClient implements IJMAPClient {
|
||||
this.assertEmailSetSucceeded(response, "mark as not spam");
|
||||
}
|
||||
|
||||
async createMailbox(name: string, parentId?: string, accountId?: string): Promise<Mailbox> {
|
||||
async createMailbox(name: string, parentId?: string, accountId?: string, options?: { role?: string }): Promise<Mailbox> {
|
||||
const targetAccountId = accountId || this.accountId;
|
||||
const createId = `new-${Date.now()}`;
|
||||
// Subscribe explicitly: IMAP clients that list folders via LSUB
|
||||
@@ -3177,6 +3177,9 @@ export class JMAPClient implements IJMAPClient {
|
||||
if (parentId) {
|
||||
createData.parentId = parentId;
|
||||
}
|
||||
if (options?.role) {
|
||||
createData.role = options.role;
|
||||
}
|
||||
|
||||
const response = await this.request([
|
||||
["Mailbox/set", {
|
||||
@@ -3205,6 +3208,7 @@ export class JMAPClient implements IJMAPClient {
|
||||
id: created.id,
|
||||
name,
|
||||
parentId,
|
||||
role: options?.role,
|
||||
sortOrder: 0,
|
||||
totalEmails: 0,
|
||||
unreadEmails: 0,
|
||||
@@ -3233,6 +3237,28 @@ export class JMAPClient implements IJMAPClient {
|
||||
}
|
||||
}
|
||||
|
||||
async updateMailboxes(
|
||||
updates: Record<string, { name?: string; parentId?: string | null; role?: string | null; sortOrder?: number }>,
|
||||
accountId?: string,
|
||||
): Promise<Record<string, string>> {
|
||||
const targetAccountId = accountId || this.accountId;
|
||||
const failed: Record<string, string> = {};
|
||||
for (const batch of batched(Object.entries(updates), this.getMaxObjectsInSet())) {
|
||||
const response = await this.request([
|
||||
["Mailbox/set", { accountId: targetAccountId, update: Object.fromEntries(batch) }, "0"],
|
||||
]);
|
||||
const [name, result] = response.methodResponses?.[0] ?? [];
|
||||
if (name === 'error') {
|
||||
throw new Error(result?.description || result?.type || 'Failed to update mailboxes');
|
||||
}
|
||||
const notUpdated = (result?.notUpdated ?? {}) as Record<string, { type?: string }>;
|
||||
for (const [id, err] of Object.entries(notUpdated)) {
|
||||
failed[id] = err?.type || 'unknown';
|
||||
}
|
||||
}
|
||||
return failed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroys a folder. By default the server refuses a non-empty folder
|
||||
* (`mailboxHasEmail`); with `removeEmails` the messages that are only in
|
||||
|
||||
+50
-1
@@ -92,6 +92,15 @@ export interface FilterRule {
|
||||
* folder rule does not pull spam out of Junk.
|
||||
*/
|
||||
includeSpam?: boolean;
|
||||
/**
|
||||
* The rule only acts on mail that arrives from `activeFrom` until
|
||||
* `activeUntil`, both inclusive, either one open-ended. Absolute moments
|
||||
* as ISO 8601 UTC ("2026-10-05T06:00:00.000Z"), so the script does not
|
||||
* depend on the time zone of whoever saves it next. Compiled to Sieve
|
||||
* `currentdate` tests (RFC 5260), which need "date" and "relational".
|
||||
*/
|
||||
activeFrom?: string;
|
||||
activeUntil?: string;
|
||||
origin?: FilterOrigin;
|
||||
originLabel?: string;
|
||||
rawBlock?: string;
|
||||
@@ -103,10 +112,50 @@ export interface VacationSieveConfig {
|
||||
textBody: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Forwarding set up in the vacation card: mail goes on to one address, with
|
||||
* or without the auto-reply, in the vacation's period. It is not a filter
|
||||
* rule and the filter list does not show it; it runs ahead of the rules.
|
||||
*/
|
||||
export interface VacationForward {
|
||||
/** The forwarding switch, independent of the auto-reply's. */
|
||||
enabled: boolean;
|
||||
/** The one address mail is forwarded to. */
|
||||
to: string;
|
||||
/**
|
||||
* Keep the message in this mailbox too (`redirect :copy`). Otherwise it
|
||||
* is only forwarded, and no filter rule runs on it.
|
||||
*/
|
||||
keepCopy: boolean;
|
||||
/** The auto-reply's period when it was saved, as for FilterRule.activeFrom. */
|
||||
activeFrom?: string;
|
||||
activeUntil?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Who gets the auto-reply when not everyone should: only senders from the
|
||||
* account's own domains, or only senders from elsewhere. It is decided on
|
||||
* the From address while the auto-reply runs from the filters script.
|
||||
*/
|
||||
export interface VacationAudience {
|
||||
only: 'internal' | 'external';
|
||||
/** The account's own domains (from its identities) when it was saved. */
|
||||
domains: string[];
|
||||
}
|
||||
|
||||
export interface FilterMetadata {
|
||||
version: 1;
|
||||
/**
|
||||
* 2 once a rule has a period or the vacation's forwarding or recipients are
|
||||
* stored. Older builds only take version 1 as their own and would write
|
||||
* such a script back without these fields; anything else they leave alone.
|
||||
*/
|
||||
version: 1 | 2;
|
||||
rules: FilterRule[];
|
||||
vacation?: VacationSieveConfig;
|
||||
/** See VacationAudience; absent when everyone gets the auto-reply. */
|
||||
vacationAudience?: VacationAudience;
|
||||
/** See VacationForward; kept while switched off, so the card remembers it. */
|
||||
vacationForward?: VacationForward;
|
||||
/**
|
||||
* The script runs the server-managed "vacation" script via `include`.
|
||||
* Servers like Stalwart keep one active script, so a VacationResponse
|
||||
|
||||
@@ -163,6 +163,15 @@ export interface AuthenticationResults {
|
||||
result: 'pass' | 'fail' | 'policy' | 'neutral' | 'temperror' | 'permerror';
|
||||
domain?: string;
|
||||
selector?: string;
|
||||
/**
|
||||
* Every DKIM result when the message carried more than one signature;
|
||||
* `result` above is the first.
|
||||
*/
|
||||
all?: Array<{
|
||||
result: 'pass' | 'fail' | 'policy' | 'neutral' | 'temperror' | 'permerror';
|
||||
domain?: string;
|
||||
selector?: string;
|
||||
}>;
|
||||
};
|
||||
dmarc?: {
|
||||
result: 'pass' | 'fail' | 'none';
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { FilterRule, VacationSieveConfig } from '@/lib/jmap/sieve-types';
|
||||
import { generateScript, type GenerateOptions } from '../generator';
|
||||
import { parseScript } from '../parser';
|
||||
|
||||
const EXTENSIONS = [
|
||||
'fileinto', 'mailbox', 'mailboxid', 'imap4flags', 'include', 'copy', 'date', 'relational',
|
||||
'spamtestplus', 'comparator-i;ascii-numeric', 'body', 'vacation', 'envelope', 'reject',
|
||||
];
|
||||
|
||||
const rules: FilterRule[] = [
|
||||
{
|
||||
id: 'r1', name: 'Boersen', enabled: true, matchType: 'any',
|
||||
conditions: [
|
||||
{ field: 'from', comparator: 'contains', value: ['autoscout24', 'mobile.de'] },
|
||||
{ field: 'subject', comparator: 'starts_with', value: 'Anfrage' },
|
||||
],
|
||||
actions: [{ type: 'move', value: 'INBOX/Boersen', mailboxId: 'mb-1' }, { type: 'mark_read' }],
|
||||
stopProcessing: true,
|
||||
},
|
||||
{
|
||||
id: 'r2', name: 'Chef', enabled: true, matchType: 'all',
|
||||
conditions: [{ field: 'from', comparator: 'address_is', value: 'chef@example.com' }],
|
||||
actions: [{ type: 'forward', value: 'kollege@example.com', keepCopy: true }, { type: 'star' }],
|
||||
stopProcessing: false,
|
||||
},
|
||||
{
|
||||
id: 'r3', name: 'Gross', enabled: false, matchType: 'all',
|
||||
conditions: [{ field: 'size', comparator: 'greater_than', value: '1000000' }],
|
||||
actions: [{ type: 'discard' }],
|
||||
stopProcessing: false, includeSpam: true,
|
||||
},
|
||||
];
|
||||
const external: FilterRule = {
|
||||
id: 'ext-1', name: 'Hand', enabled: true, matchType: 'all', conditions: [], actions: [], stopProcessing: false,
|
||||
origin: 'external', rawBlock: '# Hand\nif header :contains "subject" "hand" {\n keep;\n}',
|
||||
};
|
||||
const vacation: VacationSieveConfig = { isEnabled: true, subject: 'Weg', textBody: 'Bin weg' };
|
||||
|
||||
const RULE_BLOCKS = [
|
||||
'# Rule: Boersen',
|
||||
'if allof(anyof(header :contains "From" ["autoscout24", "mobile.de"], header :matches "Subject" "Anfrage*"), not spamtest :percent :value "ge" :comparator "i;ascii-numeric" "50") {',
|
||||
' addflag "\\\\Seen";',
|
||||
' fileinto :mailboxid "mb-1" "INBOX/Boersen";',
|
||||
' stop;',
|
||||
'}',
|
||||
'',
|
||||
'# Rule: Chef',
|
||||
'if address :is "From" "chef@example.com" {',
|
||||
' addflag "\\\\Flagged";',
|
||||
' redirect :copy "kollege@example.com";',
|
||||
'}',
|
||||
'',
|
||||
];
|
||||
|
||||
// What the generator wrote for these before rules had periods and the
|
||||
// vacation card had forwarding and reply recipients, taken from it as it was
|
||||
// then (0409bde8). Every account's script is rewritten on its next save, so a
|
||||
// script without the new fields must still come out byte for byte the same.
|
||||
const BEFORE: Record<string, { args: [FilterRule[], VacationSieveConfig | undefined, GenerateOptions]; script: string }> = {
|
||||
'rules next to the vacation include': {
|
||||
args: [rules, undefined, { includeVacation: true, extensions: EXTENSIONS }],
|
||||
script: [
|
||||
'/* @metadata:begin',
|
||||
JSON.stringify({ version: 1, rules, includeVacation: true }),
|
||||
'@metadata:end */',
|
||||
'',
|
||||
'require ["comparator-i;ascii-numeric", "copy", "fileinto", "imap4flags", "include", "mailbox", "mailboxid", "relational", "spamtestplus"];',
|
||||
'',
|
||||
'# Vacation auto-reply',
|
||||
'include :personal :optional "vacation";',
|
||||
'',
|
||||
...RULE_BLOCKS,
|
||||
].join('\n'),
|
||||
},
|
||||
'rules, an external block and the vacation written in': {
|
||||
args: [[...rules, external], vacation, { externalRequires: ['body'], extensions: EXTENSIONS }],
|
||||
script: [
|
||||
'/* @metadata:begin',
|
||||
JSON.stringify({ version: 1, rules, vacation }),
|
||||
'@metadata:end */',
|
||||
'',
|
||||
'require ["body", "comparator-i;ascii-numeric", "copy", "fileinto", "imap4flags", "mailbox", "mailboxid", "relational", "spamtestplus", "vacation"];',
|
||||
'',
|
||||
'# Vacation auto-reply',
|
||||
'vacation :subject "Weg" "Bin weg";',
|
||||
'',
|
||||
...RULE_BLOCKS,
|
||||
'# --- External rules (managed outside Bulwark) ---',
|
||||
'# Hand',
|
||||
'if header :contains "subject" "hand" {',
|
||||
' keep;',
|
||||
'}',
|
||||
'',
|
||||
].join('\n'),
|
||||
},
|
||||
'only the vacation include': {
|
||||
args: [[], undefined, { includeVacation: true, extensions: EXTENSIONS }],
|
||||
script: [
|
||||
'/* @metadata:begin',
|
||||
'{"version":1,"rules":[],"includeVacation":true}',
|
||||
'@metadata:end */',
|
||||
'',
|
||||
'require ["include"];',
|
||||
'',
|
||||
'# Vacation auto-reply',
|
||||
'include :personal :optional "vacation";',
|
||||
'',
|
||||
].join('\n'),
|
||||
},
|
||||
};
|
||||
|
||||
describe('a script without periods, forwarding or reply recipients', () => {
|
||||
it('comes out exactly as before these existed', () => {
|
||||
for (const [name, { args: [r, v, options], script }] of Object.entries(BEFORE)) {
|
||||
expect(generateScript(r, v, options), name).toBe(script);
|
||||
// The stores pass "none" for both.
|
||||
expect(generateScript(r, v, { ...options, vacationForward: null, vacationAudience: null }), name).toBe(script);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('the metadata version', () => {
|
||||
const period = { activeFrom: '2026-10-05T06:00:00.000Z', activeUntil: '2026-10-16T16:00:00.000Z' };
|
||||
const versionOf = (script: string) => (JSON.parse(script.split('\n')[1]) as { version: number }).version;
|
||||
const generate = (r: FilterRule[], options: GenerateOptions = {}) =>
|
||||
generateScript(r, undefined, { extensions: EXTENSIONS, ...options });
|
||||
|
||||
it('is 2 once an older build would drop something, so that it leaves the script alone', () => {
|
||||
// An older build takes version 1 as its own and writes it back without
|
||||
// what it does not know: the rule would forward for good, the auto-reply
|
||||
// would go to everyone.
|
||||
expect(versionOf(generate([{ ...rules[1], ...period }]))).toBe(2);
|
||||
// A rule that is off keeps its period for when it is on again.
|
||||
expect(versionOf(generate([{ ...rules[2], activeUntil: period.activeUntil }]))).toBe(2);
|
||||
expect(versionOf(generate([], { vacationForward: { enabled: true, to: 'kollege@example.com', keepCopy: false } }))).toBe(2);
|
||||
expect(versionOf(generate([], { includeVacation: true, vacationAudience: { only: 'internal', domains: ['example.com'] } }))).toBe(2);
|
||||
expect(versionOf(generate(rules, { includeVacation: true }))).toBe(1);
|
||||
});
|
||||
|
||||
it('stays 1 for a forward that is off, so turning it on and off again does not lock older editors out', () => {
|
||||
expect(versionOf(generate([], { vacationForward: { enabled: false, to: 'kollege@example.com', keepCopy: false } }))).toBe(1);
|
||||
});
|
||||
|
||||
it('reads version 2 back as its own', () => {
|
||||
const options: GenerateOptions = {
|
||||
includeVacation: true,
|
||||
vacationForward: { enabled: true, to: 'kollege@example.com', keepCopy: true, ...period },
|
||||
vacationAudience: { only: 'external', domains: ['example.com'] },
|
||||
};
|
||||
const script = generate([{ ...rules[1], ...period }], options);
|
||||
const parsed = parseScript(script);
|
||||
expect(parsed.isOpaque).toBe(false);
|
||||
expect(generate(parsed.rules, {
|
||||
includeVacation: parsed.includeVacation,
|
||||
vacationForward: parsed.vacationForward,
|
||||
vacationAudience: parsed.vacationAudience,
|
||||
})).toBe(script);
|
||||
});
|
||||
|
||||
it('takes a version it does not know for a script edited by hand', () => {
|
||||
expect(parseScript('/* @metadata:begin\n{"version":3,"rules":[]}\n@metadata:end */').isOpaque).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -43,7 +43,7 @@ describe('parseScript', () => {
|
||||
});
|
||||
|
||||
it('returns isOpaque for version mismatch', () => {
|
||||
const script = '/* @metadata:begin\n{"version":2,"rules":[]}\n@metadata:end */';
|
||||
const script = '/* @metadata:begin\n{"version":3,"rules":[]}\n@metadata:end */';
|
||||
const result = parseScript(script);
|
||||
expect(result.isOpaque).toBe(true);
|
||||
expect(result.rules).toEqual([]);
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { FilterRule } from '@/lib/jmap/sieve-types';
|
||||
import { generateScript } from '../generator';
|
||||
import { parseScript } from '../parser';
|
||||
import { isPeriodBoundary, periodStatus, periodTests, supportsPeriods } from '../period';
|
||||
|
||||
/**
|
||||
* Evaluates the Sieve tests periodTests() writes, following RFC 5260: in zone
|
||||
* "+0000" the "date" part is yyyy-mm-dd and "time" is hh:mm:ss, and :value
|
||||
* compares them as strings (the default comparator). It reads only that
|
||||
* subset, so it checks the logic, not a server's parser - the scripts were
|
||||
* also run through Stalwart's own interpreter (sieve-rs) by hand.
|
||||
*/
|
||||
function evaluate(test: string, now: Date): boolean {
|
||||
const split = (inner: string) => {
|
||||
const parts: string[] = [];
|
||||
let depth = 0;
|
||||
let quoted = false;
|
||||
let start = 0;
|
||||
for (let i = 0; i < inner.length; i++) {
|
||||
const ch = inner[i];
|
||||
if (ch === '"') quoted = !quoted;
|
||||
else if (!quoted && ch === '(') depth++;
|
||||
else if (!quoted && ch === ')') depth--;
|
||||
else if (!quoted && depth === 0 && ch === ',') {
|
||||
parts.push(inner.slice(start, i).trim());
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
parts.push(inner.slice(start).trim());
|
||||
return parts;
|
||||
};
|
||||
const group = test.match(/^(anyof|allof)\((.*)\)$/);
|
||||
if (group) {
|
||||
const results = split(group[2]).map((t) => evaluate(t, now));
|
||||
return group[1] === 'anyof' ? results.some(Boolean) : results.every(Boolean);
|
||||
}
|
||||
const current = test.match(/^currentdate :zone "\+0000" (?::is|:value "(gt|ge|lt|le)") "(date|time)" "([^"]+)"$/);
|
||||
if (!current) throw new Error(`not a period test: ${test}`);
|
||||
const [, op, part, key] = current;
|
||||
const iso = now.toISOString();
|
||||
const value = part === 'date' ? iso.slice(0, 10) : iso.slice(11, 19);
|
||||
switch (op) {
|
||||
case undefined: return value === key;
|
||||
case 'gt': return value > key;
|
||||
case 'ge': return value >= key;
|
||||
case 'lt': return value < key;
|
||||
default: return value <= key;
|
||||
}
|
||||
}
|
||||
|
||||
const inPeriod = (period: Pick<FilterRule, 'activeFrom' | 'activeUntil'>, at: string) =>
|
||||
periodTests(period)!.every((t) => evaluate(t, new Date(at)));
|
||||
|
||||
describe('period boundaries', () => {
|
||||
it('accepts ISO moments with a zone only', () => {
|
||||
expect(isPeriodBoundary('2026-10-05T06:00:00.000Z')).toBe(true);
|
||||
expect(isPeriodBoundary('2026-10-05T08:00+02:00')).toBe(true);
|
||||
// Without a zone, Date.parse would read local time: browser-dependent.
|
||||
expect(isPeriodBoundary('2026-10-05T08:00')).toBe(false);
|
||||
expect(isPeriodBoundary('2026-10-05')).toBe(false);
|
||||
expect(isPeriodBoundary('2026-13-45T08:00:00Z')).toBe(false);
|
||||
expect(isPeriodBoundary('')).toBe(false);
|
||||
expect(isPeriodBoundary(null)).toBe(false);
|
||||
expect(isPeriodBoundary(1_790_000_000_000)).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses a moment whose year in UTC has no room in four digits', () => {
|
||||
// Year 10000 in UTC: Sieve's "date" part would read "+010000-01-01".
|
||||
expect(isPeriodBoundary('9999-12-31T23:59:59-01:00')).toBe(false);
|
||||
expect(isPeriodBoundary('+010000-01-01T00:59:59.000Z')).toBe(false);
|
||||
expect(isPeriodBoundary('9999-12-31T23:59:59Z')).toBe(true);
|
||||
});
|
||||
|
||||
it('needs the date and relational extensions', () => {
|
||||
expect(supportsPeriods(['fileinto', 'date', 'relational'])).toBe(true);
|
||||
expect(supportsPeriods(['fileinto', 'date'])).toBe(false);
|
||||
expect(supportsPeriods(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('gives no tests without a period and null for an unusable boundary', () => {
|
||||
expect(periodTests({})).toEqual([]);
|
||||
expect(periodTests({ activeFrom: '2026-10-05T08:00' })).toBeNull();
|
||||
expect(periodTests({ activeFrom: '2026-10-05T06:00:00Z', activeUntil: 'soon' })).toBeNull();
|
||||
});
|
||||
|
||||
it('writes the start and end as UTC date and time tests', () => {
|
||||
expect(periodTests({ activeFrom: '2026-10-05T08:00:00+02:00', activeUntil: '2026-10-16T16:00:00.000Z' })).toEqual([
|
||||
'anyof(currentdate :zone "+0000" :value "gt" "date" "2026-10-05", allof(currentdate :zone "+0000" :is "date" "2026-10-05", currentdate :zone "+0000" :value "ge" "time" "06:00:00"))',
|
||||
'anyof(currentdate :zone "+0000" :value "lt" "date" "2026-10-16", allof(currentdate :zone "+0000" :is "date" "2026-10-16", currentdate :zone "+0000" :value "le" "time" "16:00:00"))',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('period logic (RFC 5260 semantics)', () => {
|
||||
const period = { activeFrom: '2026-10-05T06:00:00.000Z', activeUntil: '2026-10-16T16:00:00.000Z' };
|
||||
|
||||
it.each([
|
||||
['one second before the start', '2026-10-05T05:59:59Z', false],
|
||||
['the day before, later than the start time', '2026-10-04T23:00:00Z', false],
|
||||
['the start', '2026-10-05T06:00:00Z', true],
|
||||
['later on the first day', '2026-10-05T23:59:59Z', true],
|
||||
['the next day, earlier than the start time', '2026-10-06T00:00:00Z', true],
|
||||
['the middle', '2026-10-10T12:00:00Z', true],
|
||||
['the last day, earlier than the end time', '2026-10-16T05:00:00Z', true],
|
||||
['the end', '2026-10-16T16:00:00Z', true],
|
||||
['one second after the end', '2026-10-16T16:00:01Z', false],
|
||||
['the day after, earlier than the end time', '2026-10-17T00:00:00Z', false],
|
||||
])('%s: %s -> %s', (_label, at, expected) => {
|
||||
expect(inPeriod(period, at)).toBe(expected);
|
||||
});
|
||||
|
||||
it('leaves either end open', () => {
|
||||
expect(inPeriod({ activeFrom: period.activeFrom }, '2099-01-01T00:00:00Z')).toBe(true);
|
||||
expect(inPeriod({ activeFrom: period.activeFrom }, '2026-10-05T05:59:59Z')).toBe(false);
|
||||
expect(inPeriod({ activeUntil: period.activeUntil }, '2000-01-01T00:00:00Z')).toBe(true);
|
||||
expect(inPeriod({ activeUntil: period.activeUntil }, '2026-10-16T16:00:01Z')).toBe(false);
|
||||
});
|
||||
|
||||
it('spans a new year', () => {
|
||||
const newYear = { activeFrom: '2026-12-31T23:30:00Z', activeUntil: '2027-01-01T00:30:00Z' };
|
||||
expect(inPeriod(newYear, '2026-12-31T23:29:59Z')).toBe(false);
|
||||
expect(inPeriod(newYear, '2026-12-31T23:30:00Z')).toBe(true);
|
||||
expect(inPeriod(newYear, '2027-01-01T00:00:00Z')).toBe(true);
|
||||
expect(inPeriod(newYear, '2027-01-01T00:30:00Z')).toBe(true);
|
||||
expect(inPeriod(newYear, '2027-01-01T00:30:01Z')).toBe(false);
|
||||
});
|
||||
|
||||
it('spans the switch from summer to winter time (Berlin, 25 Oct 2026)', () => {
|
||||
// 24 Oct 22:00 CEST until 26 Oct 08:00 CET, written with their offsets.
|
||||
const overSwitch = { activeFrom: '2026-10-24T22:00:00+02:00', activeUntil: '2026-10-26T08:00:00+01:00' };
|
||||
expect(inPeriod(overSwitch, '2026-10-24T19:59:59Z')).toBe(false);
|
||||
expect(inPeriod(overSwitch, '2026-10-24T20:00:00Z')).toBe(true);
|
||||
expect(inPeriod(overSwitch, '2026-10-25T01:30:00Z')).toBe(true);
|
||||
expect(inPeriod(overSwitch, '2026-10-26T07:00:00Z')).toBe(true);
|
||||
expect(inPeriod(overSwitch, '2026-10-26T07:00:01Z')).toBe(false);
|
||||
});
|
||||
|
||||
it('never acts when the period ends before it starts', () => {
|
||||
const inverted = { activeFrom: '2026-10-16T00:00:00Z', activeUntil: '2026-10-05T00:00:00Z' };
|
||||
for (const at of ['2026-10-01T00:00:00Z', '2026-10-10T00:00:00Z', '2026-10-20T00:00:00Z']) {
|
||||
expect(inPeriod(inverted, at)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('reports where a moment falls', () => {
|
||||
const now = Date.parse('2026-10-10T12:00:00Z');
|
||||
expect(periodStatus({}, now)).toBeNull();
|
||||
expect(periodStatus(period, now)).toBe('active');
|
||||
expect(periodStatus({ activeFrom: '2026-10-11T00:00:00Z' }, now)).toBe('scheduled');
|
||||
expect(periodStatus({ activeUntil: '2026-10-09T00:00:00Z' }, now)).toBe('expired');
|
||||
});
|
||||
});
|
||||
|
||||
const rule = (extra: Partial<FilterRule> = {}): FilterRule => ({
|
||||
id: 'r1',
|
||||
name: 'Urlaub',
|
||||
enabled: true,
|
||||
matchType: 'all',
|
||||
conditions: [{ field: 'from', comparator: 'contains', value: '@' }],
|
||||
actions: [{ type: 'forward', value: 'kollege@example.com' }, { type: 'discard' }],
|
||||
stopProcessing: false,
|
||||
...extra,
|
||||
});
|
||||
|
||||
const PERIOD = { activeFrom: '2026-10-05T06:00:00.000Z', activeUntil: '2026-10-16T16:00:00.000Z' };
|
||||
const EXTENSIONS = ['fileinto', 'mailbox', 'mailboxid', 'spamtestplus', 'relational', 'comparator-i;ascii-numeric', 'date', 'copy'];
|
||||
|
||||
describe('generator with periods', () => {
|
||||
it('confines the rule to its period and requires date and relational', () => {
|
||||
const script = generateScript([rule(PERIOD)], undefined, { extensions: EXTENSIONS });
|
||||
const [from, until] = periodTests(PERIOD)!;
|
||||
expect(script).toContain('require ["date", "relational"];');
|
||||
expect(script).toContain(`if allof(${from}, ${until}, header :contains "From" "@") {`);
|
||||
expect(script).toContain(' redirect "kollege@example.com";');
|
||||
});
|
||||
|
||||
it('keeps the conditions and the spam guard intact inside the period', () => {
|
||||
const any = generateScript([rule({
|
||||
...PERIOD,
|
||||
matchType: 'any',
|
||||
conditions: [
|
||||
{ field: 'from', comparator: 'contains', value: 'a' },
|
||||
{ field: 'subject', comparator: 'contains', value: ['b', 'c'] },
|
||||
],
|
||||
actions: [{ type: 'move', value: 'F', mailboxId: 'mb' }],
|
||||
})], undefined, { extensions: EXTENSIONS });
|
||||
const [from, until] = periodTests(PERIOD)!;
|
||||
expect(any).toContain(`if allof(${from}, ${until}, allof(anyof(header :contains "From" "a", header :contains "Subject" ["b", "c"]), not spamtest :percent :value "ge" :comparator "i;ascii-numeric" "50")) {`);
|
||||
expect(any).toContain('require ["comparator-i;ascii-numeric", "date", "fileinto", "mailbox", "mailboxid", "relational", "spamtestplus"];');
|
||||
});
|
||||
|
||||
it('writes an open-ended period as one test', () => {
|
||||
const script = generateScript([rule({ activeUntil: PERIOD.activeUntil })]);
|
||||
const [until] = periodTests({ activeUntil: PERIOD.activeUntil })!;
|
||||
expect(script).toContain(`if allof(${until}, header :contains "From" "@") {`);
|
||||
});
|
||||
|
||||
it('drops a rule whose period is unusable instead of running it for good', () => {
|
||||
const script = generateScript([
|
||||
rule({ activeUntil: '2026-10-16T16:00' }),
|
||||
rule({ id: 'r2', name: 'Other', actions: [{ type: 'star' }] }),
|
||||
]);
|
||||
expect(script).not.toContain('redirect');
|
||||
expect(script).toContain('# Rule: Other');
|
||||
expect(script).not.toContain('"date"');
|
||||
});
|
||||
|
||||
it('adds no requirement for a disabled rule with a period', () => {
|
||||
const script = generateScript([rule({ ...PERIOD, enabled: false })]);
|
||||
expect(script).not.toContain('require');
|
||||
expect(script).not.toContain('currentdate');
|
||||
});
|
||||
|
||||
it('stores the period in the metadata and reads it back unchanged', () => {
|
||||
const rules = [rule(PERIOD), rule({ id: 'r2', name: 'Open', activeFrom: PERIOD.activeFrom })];
|
||||
const script = generateScript(rules, undefined, { extensions: EXTENSIONS });
|
||||
const parsed = parseScript(script);
|
||||
expect(parsed.isOpaque).toBe(false);
|
||||
expect(parsed.rules).toEqual(rules);
|
||||
// Writing the rules read back gives the same script, byte for byte.
|
||||
expect(generateScript(parsed.rules, undefined, { extensions: EXTENSIONS })).toBe(script);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parser with periods', () => {
|
||||
const withMetadata = (r: Record<string, unknown>) =>
|
||||
`/* @metadata:begin\n${JSON.stringify({ version: 1, rules: [r] })}\n@metadata:end */\n`;
|
||||
|
||||
it('leaves a script with an unreadable period alone (treated as hand-edited)', () => {
|
||||
for (const activeUntil of ['2026-10-16T16:00', '', null, 42]) {
|
||||
const parsed = parseScript(withMetadata({ ...rule(), activeUntil }));
|
||||
expect(parsed.isOpaque).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('reads rules without a period as before', () => {
|
||||
const parsed = parseScript(withMetadata({ ...rule() }));
|
||||
expect(parsed.isOpaque).toBe(false);
|
||||
expect(parsed.rules[0]).not.toHaveProperty('activeFrom');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { FilterRule, VacationAudience } from '@/lib/jmap/sieve-types';
|
||||
import { generateScript } from '../generator';
|
||||
import { parseScript } from '../parser';
|
||||
import { ownDomains } from '../vacation-audience';
|
||||
|
||||
const EXTENSIONS = ['fileinto', 'imap4flags', 'include', 'envelope'];
|
||||
const INCLUDE = 'include :personal :optional "vacation";';
|
||||
|
||||
const rule: FilterRule = {
|
||||
id: 'r1',
|
||||
name: 'Boersen',
|
||||
enabled: true,
|
||||
matchType: 'all',
|
||||
conditions: [{ field: 'from', comparator: 'contains', value: 'autoscout24' }],
|
||||
actions: [{ type: 'mark_read' }],
|
||||
stopProcessing: false,
|
||||
};
|
||||
|
||||
const audience = (only: VacationAudience['only'], domains = ['example.com', 'example.org']): VacationAudience => ({ only, domains });
|
||||
|
||||
const generate = (vacationAudience: VacationAudience | null, includeVacation = true) =>
|
||||
generateScript([rule], undefined, { includeVacation, vacationAudience, extensions: EXTENSIONS });
|
||||
|
||||
/** The auto-reply block: its marker comment up to the first rule. */
|
||||
function replyBlock(script: string): string {
|
||||
const start = script.indexOf('# Vacation auto-reply');
|
||||
return script.slice(start, script.indexOf('\n\n', start));
|
||||
}
|
||||
|
||||
describe('who gets the auto-reply (generator)', () => {
|
||||
it('answers only senders from the own domains', () => {
|
||||
// Judged by the envelope sender, whom the reply goes to: a From header
|
||||
// naming a colleague on mail from outside gets no reply meant for colleagues.
|
||||
const script = generate(audience('internal'));
|
||||
expect(replyBlock(script)).toBe([
|
||||
'# Vacation auto-reply',
|
||||
'if envelope :domain :is "from" ["example.com", "example.org"] {',
|
||||
` ${INCLUDE}`,
|
||||
'}',
|
||||
].join('\n'));
|
||||
expect(script).toMatch(/^require \["envelope", "imap4flags", "include"\];$/m);
|
||||
});
|
||||
|
||||
it('answers only senders from elsewhere', () => {
|
||||
expect(replyBlock(generate(audience('external')))).toBe([
|
||||
'# Vacation auto-reply',
|
||||
'if not envelope :domain :is "from" ["example.com", "example.org"] {',
|
||||
` ${INCLUDE}`,
|
||||
'}',
|
||||
].join('\n'));
|
||||
});
|
||||
|
||||
it('answers everyone when no one is singled out', () => {
|
||||
// Byte for byte as before the choice existed: see generator-compat.test.ts.
|
||||
const script = generate(null);
|
||||
expect(replyBlock(script)).toBe(['# Vacation auto-reply', INCLUDE].join('\n'));
|
||||
expect(script).not.toContain('vacationAudience');
|
||||
});
|
||||
|
||||
it('keeps the choice while the auto-reply is off', () => {
|
||||
const script = generate(audience('external'), false);
|
||||
expect(script).not.toContain('include');
|
||||
expect(script).not.toContain('envelope');
|
||||
expect(parseScript(script).vacationAudience).toEqual(audience('external'));
|
||||
});
|
||||
|
||||
it('never writes a choice it cannot read back', () => {
|
||||
for (const unusable of [
|
||||
audience('external', []),
|
||||
audience('external', ['example.com"; discard; "']),
|
||||
{ only: 'everyone', domains: ['example.com'] } as unknown as VacationAudience,
|
||||
]) {
|
||||
const script = generate(unusable);
|
||||
expect(replyBlock(script)).toBe(['# Vacation auto-reply', INCLUDE].join('\n'));
|
||||
expect(script).not.toContain('vacationAudience');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('who gets the auto-reply (parser)', () => {
|
||||
it('reads its own narrowed include back as its own', () => {
|
||||
const readAndWrite = (script: string) => {
|
||||
const parsed = parseScript(script);
|
||||
return {
|
||||
parsed,
|
||||
script: generateScript(parsed.rules, parsed.vacation, {
|
||||
includeVacation: parsed.includeVacation,
|
||||
vacationAudience: parsed.vacationAudience,
|
||||
externalRequires: parsed.externalRequires,
|
||||
extensions: EXTENSIONS,
|
||||
}),
|
||||
};
|
||||
};
|
||||
const first = readAndWrite(generate(audience('internal')));
|
||||
expect(first.parsed.isOpaque).toBe(false);
|
||||
expect(first.parsed.includeVacation).toBe(true);
|
||||
expect(first.parsed.vacationAudience).toEqual(audience('internal'));
|
||||
expect(first.parsed.rules.map((r) => r.origin ?? 'bulwark')).toEqual(['bulwark']);
|
||||
expect(readAndWrite(first.script).script).toBe(first.script);
|
||||
expect(first.script.split(INCLUDE).length).toBe(2);
|
||||
});
|
||||
|
||||
it('treats a choice it cannot read as a hand-edited script', () => {
|
||||
const script = generate(audience('internal'));
|
||||
for (const [from, to] of [
|
||||
['"only":"internal"', '"only":"intern"'],
|
||||
['"domains":["example.com","example.org"]', '"domains":[]'],
|
||||
['"domains":["example.com","example.org"]', '"domains":["example com"]'],
|
||||
]) {
|
||||
expect(script).toContain(from);
|
||||
expect(parseScript(script.replace(from, to)).isOpaque).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('ownDomains', () => {
|
||||
it('takes each domain of the addresses once, lower-cased, skipping what is not one', () => {
|
||||
expect(ownDomains(['Max@Example.com', 'info@example.com', 'shop@example.org', 'broken', 'x@localhost'])).toEqual([
|
||||
'example.com',
|
||||
'example.org',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { FilterRule, VacationForward } from '@/lib/jmap/sieve-types';
|
||||
import { generateScript } from '../generator';
|
||||
import { parseScript } from '../parser';
|
||||
import { periodTests } from '../period';
|
||||
|
||||
const EXTENSIONS = ['fileinto', 'imap4flags', 'include', 'copy', 'date', 'relational', 'spamtestplus', 'comparator-i;ascii-numeric'];
|
||||
const SPAM_GUARD = 'not spamtest :percent :value "ge" :comparator "i;ascii-numeric" "50"';
|
||||
|
||||
const forward = (extra: Partial<VacationForward> = {}): VacationForward => ({
|
||||
enabled: true,
|
||||
to: 'kollege@example.com',
|
||||
keepCopy: false,
|
||||
activeFrom: '2026-10-05T06:00:00.000Z',
|
||||
activeUntil: '2026-10-16T16:00:00.000Z',
|
||||
...extra,
|
||||
});
|
||||
|
||||
const rule: FilterRule = {
|
||||
id: 'r1',
|
||||
name: 'Boersen',
|
||||
enabled: true,
|
||||
matchType: 'all',
|
||||
conditions: [{ field: 'from', comparator: 'contains', value: 'autoscout24' }],
|
||||
actions: [{ type: 'mark_read' }],
|
||||
stopProcessing: false,
|
||||
};
|
||||
|
||||
const generate = (vacationForward: VacationForward | null, includeVacation = true, extensions = EXTENSIONS) =>
|
||||
generateScript([rule], undefined, { includeVacation, vacationForward, extensions });
|
||||
|
||||
/** The lines of the forwarding block, from its marker to its closing brace. */
|
||||
function forwardBlock(script: string): string[] | null {
|
||||
const lines = script.split('\n');
|
||||
const start = lines.indexOf('# Vacation forwarding');
|
||||
if (start === -1) return null;
|
||||
return lines.slice(start, lines.indexOf('}', start) + 1);
|
||||
}
|
||||
|
||||
describe('vacation forwarding (generator)', () => {
|
||||
it('forwards ahead of the rules, within the period, without spam, and stops', () => {
|
||||
const script = generate(forward());
|
||||
const [from, until] = periodTests(forward())!;
|
||||
expect(forwardBlock(script)).toEqual([
|
||||
'# Vacation forwarding',
|
||||
`if allof(${from}, ${until}, ${SPAM_GUARD}) {`,
|
||||
' redirect "kollege@example.com";',
|
||||
' stop;',
|
||||
'}',
|
||||
]);
|
||||
// After the auto-reply runs, before any rule can file the message.
|
||||
expect(script.indexOf('include :personal :optional "vacation";')).toBeLessThan(script.indexOf('# Vacation forwarding'));
|
||||
expect(script.indexOf('# Vacation forwarding')).toBeLessThan(script.indexOf('# Rule: Boersen'));
|
||||
expect(script).toMatch(/^require \["comparator-i;ascii-numeric", "date", "imap4flags", "include", "relational", "spamtestplus"\];$/m);
|
||||
});
|
||||
|
||||
it('keeps a copy and lets the rules run when asked to', () => {
|
||||
const script = generate(forward({ keepCopy: true }));
|
||||
const block = forwardBlock(script)!;
|
||||
expect(block).toContain(' redirect :copy "kollege@example.com";');
|
||||
expect(block).not.toContain(' stop;');
|
||||
expect(script).toMatch(/^require \[.*"copy".*\];$/m);
|
||||
});
|
||||
|
||||
it('forwards without a period when the vacation has none', () => {
|
||||
expect(forwardBlock(generate(forward({ activeFrom: undefined, activeUntil: undefined })))![1]).toBe(`if ${SPAM_GUARD} {`);
|
||||
const noSpamTest = generate(forward({ activeFrom: undefined, activeUntil: undefined }), true, ['include']);
|
||||
expect(forwardBlock(noSpamTest)![1]).toBe('if true {');
|
||||
});
|
||||
|
||||
it('forwards without the auto-reply too', () => {
|
||||
const script = generate(forward(), false);
|
||||
expect(script).not.toContain('include');
|
||||
expect(forwardBlock(script)).toEqual(forwardBlock(generate(forward())));
|
||||
expect(script.indexOf('# Vacation forwarding')).toBeLessThan(script.indexOf('# Rule: Boersen'));
|
||||
expect(parseScript(script).vacationForward).toEqual(forward());
|
||||
});
|
||||
|
||||
it('does not forward while forwarding is switched off', () => {
|
||||
const script = generate(forward({ enabled: false }));
|
||||
expect(forwardBlock(script)).toBeNull();
|
||||
expect(script).not.toContain('redirect');
|
||||
expect(parseScript(script).vacationForward).toEqual(forward({ enabled: false }));
|
||||
});
|
||||
|
||||
it('never writes forwarding it cannot read back, and never forwards without its period', () => {
|
||||
for (const unusable of [
|
||||
forward({ to: 'not an address' }),
|
||||
forward({ activeUntil: '16.10.2026' }),
|
||||
{ ...forward(), keepCopy: 'yes' } as unknown as VacationForward,
|
||||
]) {
|
||||
const script = generate(unusable);
|
||||
expect(forwardBlock(script)).toBeNull();
|
||||
expect(script).not.toContain('redirect');
|
||||
expect(script).not.toContain('vacationForward');
|
||||
}
|
||||
});
|
||||
|
||||
it('writes no forwarding when there is none', () => {
|
||||
// Byte for byte as before forwarding existed: see generator-compat.test.ts.
|
||||
const script = generate(null);
|
||||
expect(forwardBlock(script)).toBeNull();
|
||||
expect(script).not.toContain('redirect');
|
||||
expect(script).not.toContain('vacationForward');
|
||||
});
|
||||
});
|
||||
|
||||
describe('vacation forwarding (parser)', () => {
|
||||
it('reads its own block back as its own, not as an external rule', () => {
|
||||
const external = '# Hand\nif header :contains "subject" "hand" {\n keep;\n}\n';
|
||||
const readAndWrite = (script: string) => {
|
||||
const parsed = parseScript(script);
|
||||
return {
|
||||
parsed,
|
||||
script: generateScript(parsed.rules, parsed.vacation, {
|
||||
includeVacation: parsed.includeVacation,
|
||||
vacationForward: parsed.vacationForward,
|
||||
externalRequires: parsed.externalRequires,
|
||||
extensions: EXTENSIONS,
|
||||
}),
|
||||
};
|
||||
};
|
||||
const first = readAndWrite(`${generate(forward())}\n${external}`);
|
||||
expect(first.parsed.isOpaque).toBe(false);
|
||||
expect(first.parsed.vacationForward).toEqual(forward());
|
||||
expect(first.parsed.rules.map((r) => r.origin ?? 'bulwark')).toEqual(['bulwark', 'external']);
|
||||
// Read and written again, nothing is lost or doubled. (Each pass adds two
|
||||
// blank lines before the external rules; that is older than forwarding.)
|
||||
const second = readAndWrite(first.script);
|
||||
const blankRuns = (script: string) => script.replace(/\n{3,}/g, '\n\n');
|
||||
expect(blankRuns(second.script)).toBe(blankRuns(first.script));
|
||||
expect(second.parsed.vacationForward).toEqual(forward());
|
||||
expect(second.script.split('# Vacation forwarding').length).toBe(2);
|
||||
expect(second.script.split('if header :contains "subject" "hand"').length).toBe(2);
|
||||
});
|
||||
|
||||
it('treats forwarding it cannot read as a hand-edited script', () => {
|
||||
const script = generate(forward());
|
||||
for (const [from, to] of [
|
||||
['"to":"kollege@example.com"', '"to":"kollege"'],
|
||||
['"activeUntil":"2026-10-16T16:00:00.000Z"', '"activeUntil":"bald"'],
|
||||
['"enabled":true', '"enabled":"true"'],
|
||||
]) {
|
||||
expect(script).toContain(from);
|
||||
expect(parseScript(script.replace(from, to)).isOpaque).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps a block with the marker as an external rule when the metadata has no forwarding', () => {
|
||||
const handWritten = '# Vacation forwarding\nif true {\n redirect "x@example.com";\n}\n';
|
||||
const parsed = parseScript(`${generate(null)}\n${handWritten}`);
|
||||
expect(parsed.vacationForward).toBeUndefined();
|
||||
// Kept as someone else's block (it reads as neither rule nor Bulwark's).
|
||||
expect(parsed.rules.some((r) => r.origin === 'opaque' && r.rawBlock?.includes('redirect "x@example.com"'))).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps a block of someone else that only looks like its own', () => {
|
||||
const team = '# Vacation forwarding of the team inbox (set up by IT)\nif address :is "to" "team@example.com" {\n redirect :copy "archive@example.com";\n}\n';
|
||||
const exact = '# Vacation forwarding\nif true {\n redirect "x@example.com";\n}\n';
|
||||
const kept = (script: string, raw: string) =>
|
||||
parseScript(script).rules.some((r) => r.origin !== undefined && r.origin !== 'bulwark' && r.rawBlock?.includes(raw));
|
||||
// A comment that only begins like the marker, while forwarding is on.
|
||||
expect(kept(`${generate(forward())}\n${team}`, 'archive@example.com')).toBe(true);
|
||||
// The exact marker while forwarding is off: Bulwark wrote no block then.
|
||||
expect(kept(`${generate(forward({ enabled: false }))}\n${exact}`, 'x@example.com')).toBe(true);
|
||||
// Its own block it still takes back, and writes once.
|
||||
const own = parseScript(generate(forward()));
|
||||
expect(own.rules.every((r) => (r.origin ?? 'bulwark') === 'bulwark')).toBe(true);
|
||||
});
|
||||
});
|
||||
+94
-3
@@ -1,5 +1,8 @@
|
||||
import type { FilterRule, FilterCondition, FilterAction, FilterMetadata, VacationSieveConfig } from '@/lib/jmap/sieve-types';
|
||||
import type { FilterRule, FilterCondition, FilterAction, FilterMetadata, VacationAudience, VacationForward, VacationSieveConfig } from '@/lib/jmap/sieve-types';
|
||||
import { debug } from '@/lib/debug';
|
||||
import { PERIOD_REQUIRES, hasPeriod, periodTests } from '@/lib/sieve/period';
|
||||
import { VACATION_FORWARD_MARKER, isValidVacationForward, normalizeVacationForward } from '@/lib/sieve/vacation-forward';
|
||||
import { isValidVacationAudience, normalizeVacationAudience } from '@/lib/sieve/vacation-audience';
|
||||
|
||||
const HEADER_MAP: Record<string, string> = {
|
||||
from: 'From',
|
||||
@@ -162,7 +165,7 @@ function generateActions(actions: FilterAction[], useMailboxId: boolean): string
|
||||
const SPAM_GUARD = 'not spamtest :percent :value "ge" :comparator "i;ascii-numeric" "50"';
|
||||
const SPAM_GUARD_REQUIRES = ['spamtestplus', 'relational', 'comparator-i;ascii-numeric'];
|
||||
|
||||
function supportsSpamGuard(extensions: string[] | undefined): boolean {
|
||||
export function supportsSpamGuard(extensions: string[] | undefined): boolean {
|
||||
return ['spamtestplus', 'relational'].every(e => extensions?.includes(e));
|
||||
}
|
||||
|
||||
@@ -194,6 +197,9 @@ function computeRequires(
|
||||
if (needsSpamGuard(rule, serverExtensions)) {
|
||||
for (const e of SPAM_GUARD_REQUIRES) extensions.add(e);
|
||||
}
|
||||
if (periodTests(rule)?.length) {
|
||||
for (const e of PERIOD_REQUIRES) extensions.add(e);
|
||||
}
|
||||
for (const condition of rule.conditions) {
|
||||
if (condition.field === 'body') extensions.add('body');
|
||||
if (condition.field === 'attachment') extensions.add('mime');
|
||||
@@ -241,6 +247,8 @@ function stripRuleForMetadata(r: FilterRule): Omit<FilterRule, 'origin' | 'origi
|
||||
actions: r.actions,
|
||||
stopProcessing: r.stopProcessing,
|
||||
...(r.includeSpam ? { includeSpam: true } : {}),
|
||||
...(r.activeFrom !== undefined ? { activeFrom: r.activeFrom } : {}),
|
||||
...(r.activeUntil !== undefined ? { activeUntil: r.activeUntil } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -257,6 +265,16 @@ export interface GenerateOptions {
|
||||
* while the filters stay active.
|
||||
*/
|
||||
includeVacation?: boolean;
|
||||
/**
|
||||
* Forwarding from the vacation card. Always kept in the metadata; it runs
|
||||
* while it is switched on, with or without the auto-reply.
|
||||
*/
|
||||
vacationForward?: VacationForward | null;
|
||||
/**
|
||||
* Who gets the auto-reply when not everyone should. Always kept in the
|
||||
* metadata; it narrows the vacation include (`includeVacation`).
|
||||
*/
|
||||
vacationAudience?: VacationAudience | null;
|
||||
/**
|
||||
* The server's `sieveExtensions`. Folder moves use `:mailboxid` when it
|
||||
* lists "mailboxid"; without it they target the folder path only.
|
||||
@@ -290,6 +308,28 @@ export function generateScript(
|
||||
if (options.includeVacation) {
|
||||
metadata.includeVacation = true;
|
||||
}
|
||||
if (options.vacationForward) {
|
||||
if (isValidVacationForward(options.vacationForward)) {
|
||||
metadata.vacationForward = normalizeVacationForward(options.vacationForward);
|
||||
} else {
|
||||
debug.warn('filters', 'Dropping unusable vacation forwarding');
|
||||
}
|
||||
}
|
||||
if (options.vacationAudience) {
|
||||
if (isValidVacationAudience(options.vacationAudience)) {
|
||||
metadata.vacationAudience = normalizeVacationAudience(options.vacationAudience);
|
||||
} else {
|
||||
debug.warn('filters', 'Dropping unusable vacation audience');
|
||||
}
|
||||
}
|
||||
// An older build would take a version 1 script as its own and write it back
|
||||
// without what it does not know: a rule without its period forwards for
|
||||
// good, the auto-reply goes to everyone. It leaves version 2 alone. A
|
||||
// forward that is off only loses its remembered address that way, so it
|
||||
// stays version 1: the native app still edits version 1 only.
|
||||
if (metadata.rules.some(hasPeriod) || metadata.vacationForward?.enabled || metadata.vacationAudience) {
|
||||
metadata.version = 2;
|
||||
}
|
||||
// The JSON sits inside a /* ... */ comment: a "*/" in any string (a rule
|
||||
// name, a condition value) would end the comment and turn the rest into
|
||||
// live Sieve. JSON reads "\/" back as "/", so the metadata is unchanged.
|
||||
@@ -304,6 +344,19 @@ export function generateScript(
|
||||
const useMailboxId = options.extensions?.includes('mailboxid') ?? false;
|
||||
const bulwarkRequires = computeRequires(bulwarkRules, vacation, useMailboxId, options.extensions);
|
||||
if (options.includeVacation) bulwarkRequires.push('include');
|
||||
if (options.includeVacation && metadata.vacationAudience) bulwarkRequires.push('envelope');
|
||||
|
||||
// Forwarding from the vacation card runs whenever it is switched on, with
|
||||
// or without the auto-reply, ahead of every rule. A period it cannot read
|
||||
// would let it forward for good, so then it does not run.
|
||||
const forward = metadata.vacationForward?.enabled ? metadata.vacationForward : null;
|
||||
const forwardPeriod = forward ? periodTests(forward) : null;
|
||||
const forwardSpamGuard = supportsSpamGuard(options.extensions);
|
||||
if (forward && forwardPeriod) {
|
||||
if (forward.keepCopy) bulwarkRequires.push('copy');
|
||||
if (forwardPeriod.length > 0) bulwarkRequires.push(...PERIOD_REQUIRES);
|
||||
if (forwardSpamGuard) bulwarkRequires.push(...SPAM_GUARD_REQUIRES);
|
||||
}
|
||||
const externalRequires = options.externalRequires ?? [];
|
||||
const allRequires = [...new Set([...bulwarkRequires, ...externalRequires])].sort();
|
||||
|
||||
@@ -316,7 +369,20 @@ export function generateScript(
|
||||
// the parser does not re-import it as an external rule.
|
||||
lines.push('');
|
||||
lines.push('# Vacation auto-reply');
|
||||
lines.push(`include :personal :optional "${VACATION_SCRIPT_NAME}";`);
|
||||
const include = `include :personal :optional "${VACATION_SCRIPT_NAME}";`;
|
||||
const audience = metadata.vacationAudience;
|
||||
if (audience) {
|
||||
// Only senders from (or not from) the account's own domains. The reply
|
||||
// goes to the envelope sender, so that is the address to judge: a From
|
||||
// header can name a colleague on mail from anywhere, and the reply
|
||||
// meant for colleagues would go to whoever sent it.
|
||||
const domains = audience.domains.map(d => `"${escapeString(d)}"`).join(', ');
|
||||
lines.push(`if ${audience.only === 'external' ? 'not ' : ''}envelope :domain :is "from" [${domains}] {`);
|
||||
lines.push(` ${include}`);
|
||||
lines.push('}');
|
||||
} else {
|
||||
lines.push(include);
|
||||
}
|
||||
}
|
||||
|
||||
if (vacation?.isEnabled) {
|
||||
@@ -330,6 +396,19 @@ export function generateScript(
|
||||
lines.push(`vacation ${vacationParts.join(' ')};`);
|
||||
}
|
||||
|
||||
if (forward && forwardPeriod) {
|
||||
// Spam is not passed on: it stays here for the server's own handling.
|
||||
const tests = [...forwardPeriod, ...(forwardSpamGuard ? [SPAM_GUARD] : [])];
|
||||
const condition = tests.length === 0 ? 'true' : tests.length === 1 ? tests[0] : `allof(${tests.join(', ')})`;
|
||||
lines.push('');
|
||||
lines.push(VACATION_FORWARD_MARKER);
|
||||
lines.push(`if ${condition} {`);
|
||||
lines.push(` redirect ${forward.keepCopy ? ':copy ' : ''}"${escapeString(forward.to)}";`);
|
||||
// Without a copy kept here, no rule below may file it either.
|
||||
if (!forward.keepCopy) lines.push(' stop;');
|
||||
lines.push('}');
|
||||
}
|
||||
|
||||
const enabledBulwarkRules = bulwarkRules.filter(r => r.enabled);
|
||||
|
||||
for (const rule of enabledBulwarkRules) {
|
||||
@@ -337,6 +416,13 @@ export function generateScript(
|
||||
debug.warn('filters', `Skipping rule "${rule.name}": empty conditions or actions`);
|
||||
continue;
|
||||
}
|
||||
// Without its period a rule would act all the time - a forwarding rule
|
||||
// would forward for good - so one whose period is unusable is dropped.
|
||||
const period = periodTests(rule);
|
||||
if (period === null) {
|
||||
debug.warn('filters', `Skipping rule "${rule.name}": unusable period`);
|
||||
continue;
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
// A line break in the name would end the comment and start a command.
|
||||
@@ -360,6 +446,11 @@ export function generateScript(
|
||||
: `allof(${conditionStr}, ${SPAM_GUARD})`;
|
||||
}
|
||||
|
||||
// The period comes first: outside it, nothing else is evaluated.
|
||||
if (period.length > 0) {
|
||||
conditionStr = `allof(${[...period, conditionStr].join(', ')})`;
|
||||
}
|
||||
|
||||
const actionLines = generateActions(rule.actions, useMailboxId);
|
||||
|
||||
// discard and reject only cancel the implicit keep (RFC 5228 4.4,
|
||||
|
||||
+30
-1
@@ -5,9 +5,14 @@ import type {
|
||||
FilterConditionField,
|
||||
FilterMetadata,
|
||||
FilterRule,
|
||||
VacationAudience,
|
||||
VacationForward,
|
||||
VacationSieveConfig,
|
||||
} from '@/lib/jmap/sieve-types';
|
||||
import { debug } from '@/lib/debug';
|
||||
import { isPeriodBoundary } from '@/lib/sieve/period';
|
||||
import { VACATION_FORWARD_MARKER_RE, isValidVacationForward } from '@/lib/sieve/vacation-forward';
|
||||
import { isValidVacationAudience } from '@/lib/sieve/vacation-audience';
|
||||
|
||||
export interface ParseResult {
|
||||
rules: FilterRule[];
|
||||
@@ -16,6 +21,10 @@ export interface ParseResult {
|
||||
externalRequires: string[];
|
||||
/** The script runs the server's "vacation" script via `include`. */
|
||||
includeVacation?: boolean;
|
||||
/** Forwarding from the vacation card, on or off. */
|
||||
vacationForward?: VacationForward;
|
||||
/** Who gets the auto-reply, when not everyone. */
|
||||
vacationAudience?: VacationAudience;
|
||||
}
|
||||
|
||||
const OPAQUE: ParseResult = { rules: [], isOpaque: true, externalRequires: [] };
|
||||
@@ -64,6 +73,12 @@ function isValidRule(rule: unknown): rule is FilterRule {
|
||||
typeof r.stopProcessing !== 'boolean'
|
||||
) return false;
|
||||
|
||||
// A period that cannot be read would be dropped on the next save and the
|
||||
// rule would act all the time; treating the script as hand-edited leaves
|
||||
// it untouched instead.
|
||||
if (r.activeFrom !== undefined && !isPeriodBoundary(r.activeFrom)) return false;
|
||||
if (r.activeUntil !== undefined && !isPeriodBoundary(r.activeUntil)) return false;
|
||||
|
||||
return r.conditions.every(isValidCondition) && r.actions.every(isValidAction);
|
||||
}
|
||||
|
||||
@@ -812,12 +827,21 @@ export function parseScript(content: string): ParseResult {
|
||||
return OPAQUE;
|
||||
}
|
||||
|
||||
if (!metadata || metadata.version !== 1) return OPAQUE;
|
||||
// Version 2 only marks fields older builds do not know (see FilterMetadata).
|
||||
if (!metadata || (metadata.version !== 1 && metadata.version !== 2)) return OPAQUE;
|
||||
if (!Array.isArray(metadata.rules)) return OPAQUE;
|
||||
|
||||
for (const rule of metadata.rules) {
|
||||
if (!isValidRule(rule)) return OPAQUE;
|
||||
}
|
||||
// Forwarding that cannot be read could be written back without its
|
||||
// period; as with a rule, the script then counts as edited by hand.
|
||||
if (metadata.vacationForward !== undefined && !isValidVacationForward(metadata.vacationForward)) {
|
||||
return OPAQUE;
|
||||
}
|
||||
if (metadata.vacationAudience !== undefined && !isValidVacationAudience(metadata.vacationAudience)) {
|
||||
return OPAQUE;
|
||||
}
|
||||
|
||||
// Scan the portion AFTER the metadata block for external rules. A prior
|
||||
// Bulwark save may have emitted its "External rules" header here; strip
|
||||
@@ -858,6 +882,9 @@ export function parseScript(content: string): ParseResult {
|
||||
if (bulwarkRules.some(b => oneLine(b.name) === name)) return false;
|
||||
}
|
||||
if (/#\s*Vacation auto-reply/i.test(raw)) return false;
|
||||
// The generator writes the forwarding block from the metadata again.
|
||||
// Only written while switched on, so only then is such a block Bulwark's.
|
||||
if (metadata.vacationForward?.enabled && VACATION_FORWARD_MARKER_RE.test(raw)) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
@@ -875,6 +902,8 @@ export function parseScript(content: string): ParseResult {
|
||||
vacation: metadata.vacation,
|
||||
externalRequires,
|
||||
...(metadata.includeVacation === true ? { includeVacation: true } : {}),
|
||||
...(metadata.vacationForward ? { vacationForward: metadata.vacationForward } : {}),
|
||||
...(metadata.vacationAudience ? { vacationAudience: metadata.vacationAudience } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { FilterRule } from '@/lib/jmap/sieve-types';
|
||||
|
||||
/**
|
||||
* Time periods for filter rules: "only act on mail that arrives from ...
|
||||
* until ...". Sieve has no rule scheduling of its own; the date extension
|
||||
* (RFC 5260) lets a rule test the moment the message is being filtered.
|
||||
*/
|
||||
|
||||
/** Sieve extensions a period needs: `currentdate` and `:value` comparisons. */
|
||||
export const PERIOD_REQUIRES = ['date', 'relational'] as const;
|
||||
|
||||
export function supportsPeriods(extensions: readonly string[] | undefined | null): boolean {
|
||||
return PERIOD_REQUIRES.every((e) => extensions?.includes(e));
|
||||
}
|
||||
|
||||
// A moment with its zone spelled out. Without one, Date.parse reads the value
|
||||
// as local time, which would make the script depend on the browser saving it.
|
||||
const ISO_WITH_ZONE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d{1,3})?)?(?:Z|[+-]\d{2}:\d{2})$/;
|
||||
|
||||
/** Whether `value` is a usable period boundary (see FilterRule.activeFrom). */
|
||||
export function isPeriodBoundary(value: unknown): value is string {
|
||||
if (typeof value !== 'string' || !ISO_WITH_ZONE.test(value)) return false;
|
||||
const time = Date.parse(value);
|
||||
// In UTC the zone can push it past year 9999 ("9999-12-31T23:59-01:00"),
|
||||
// which the four-digit year of Sieve's "date" part cannot hold.
|
||||
return !Number.isNaN(time) && /^\d{4}-/.test(new Date(time).toISOString());
|
||||
}
|
||||
|
||||
export function hasPeriod(rule: Pick<FilterRule, 'activeFrom' | 'activeUntil'>): boolean {
|
||||
return rule.activeFrom !== undefined || rule.activeUntil !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The boundary as RFC 5260 "date" (yyyy-mm-dd) and "time" (hh:mm:ss) parts in
|
||||
* UTC. The RFC fixes both formats, so comparing them as strings works on any
|
||||
* server - unlike "iso8601", whose zone suffix is up to the implementation.
|
||||
*/
|
||||
function utcParts(boundary: string): { date: string; time: string } {
|
||||
const iso = new Date(Date.parse(boundary)).toISOString();
|
||||
return { date: iso.slice(0, 10), time: iso.slice(11, 19) };
|
||||
}
|
||||
|
||||
const NOW = 'currentdate :zone "+0000"';
|
||||
|
||||
/** now >= boundary: a later day, or the same day at or after the time. */
|
||||
function atOrAfter(boundary: string): string {
|
||||
const { date, time } = utcParts(boundary);
|
||||
return `anyof(${NOW} :value "gt" "date" "${date}", allof(${NOW} :is "date" "${date}", ${NOW} :value "ge" "time" "${time}"))`;
|
||||
}
|
||||
|
||||
/** now <= boundary: an earlier day, or the same day at or before the time. */
|
||||
function atOrBefore(boundary: string): string {
|
||||
const { date, time } = utcParts(boundary);
|
||||
return `anyof(${NOW} :value "lt" "date" "${date}", allof(${NOW} :is "date" "${date}", ${NOW} :value "le" "time" "${time}"))`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Sieve tests that confine `rule` to its period: none without a period,
|
||||
* null when a boundary is unusable. A caller must then drop the rule rather
|
||||
* than run it without its period - a forwarding rule would otherwise forward
|
||||
* for good.
|
||||
*/
|
||||
export function periodTests(rule: Pick<FilterRule, 'activeFrom' | 'activeUntil'>): string[] | null {
|
||||
const tests: string[] = [];
|
||||
if (rule.activeFrom !== undefined) {
|
||||
if (!isPeriodBoundary(rule.activeFrom)) return null;
|
||||
tests.push(atOrAfter(rule.activeFrom));
|
||||
}
|
||||
if (rule.activeUntil !== undefined) {
|
||||
if (!isPeriodBoundary(rule.activeUntil)) return null;
|
||||
tests.push(atOrBefore(rule.activeUntil));
|
||||
}
|
||||
return tests;
|
||||
}
|
||||
|
||||
export type PeriodStatus = 'scheduled' | 'active' | 'expired';
|
||||
|
||||
/** Where `now` falls in the rule's period; null for a rule without one. */
|
||||
export function periodStatus(
|
||||
rule: Pick<FilterRule, 'activeFrom' | 'activeUntil'>,
|
||||
now: number = Date.now(),
|
||||
): PeriodStatus | null {
|
||||
if (!hasPeriod(rule)) return null;
|
||||
if (isPeriodBoundary(rule.activeFrom) && now < Date.parse(rule.activeFrom)) return 'scheduled';
|
||||
if (isPeriodBoundary(rule.activeUntil) && now > Date.parse(rule.activeUntil)) return 'expired';
|
||||
return 'active';
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { VacationAudience } from '@/lib/jmap/sieve-types';
|
||||
|
||||
// A host name in the form the identities give it: labels of letters, digits
|
||||
// and inner hyphens, at least two of them.
|
||||
const DOMAIN = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+$/;
|
||||
|
||||
/** Whether `value` is an audience Bulwark can write back as it reads. */
|
||||
export function isValidVacationAudience(value: unknown): value is VacationAudience {
|
||||
if (!value || typeof value !== 'object') return false;
|
||||
const a = value as Record<string, unknown>;
|
||||
if (a.only !== 'internal' && a.only !== 'external') return false;
|
||||
return Array.isArray(a.domains) && a.domains.length > 0
|
||||
&& a.domains.every((d) => typeof d === 'string' && DOMAIN.test(d));
|
||||
}
|
||||
|
||||
/** The fields Bulwark stores, in a fixed order. */
|
||||
export function normalizeVacationAudience(audience: VacationAudience): VacationAudience {
|
||||
return { only: audience.only, domains: audience.domains };
|
||||
}
|
||||
|
||||
/** The domains of the given addresses, lower-cased, each once, in order. */
|
||||
export function ownDomains(addresses: string[]): string[] {
|
||||
const domains: string[] = [];
|
||||
for (const address of addresses) {
|
||||
const at = address.lastIndexOf('@');
|
||||
const domain = at === -1 ? '' : address.slice(at + 1).trim().toLowerCase();
|
||||
if (DOMAIN.test(domain) && !domains.includes(domain)) domains.push(domain);
|
||||
}
|
||||
return domains;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { VacationForward } from '@/lib/jmap/sieve-types';
|
||||
import { isValidEmail } from '@/lib/validation';
|
||||
import { isPeriodBoundary } from '@/lib/sieve/period';
|
||||
|
||||
/**
|
||||
* Forwarding from the vacation card (see VacationForward). The generator
|
||||
* writes it as its own block after the vacation include, marked by this
|
||||
* comment; the parser knows the block by it and leaves it to the generator.
|
||||
*/
|
||||
export const VACATION_FORWARD_MARKER = '# Vacation forwarding';
|
||||
|
||||
/**
|
||||
* The marker as the parser looks for it in a block: the whole line, so a
|
||||
* comment of someone's own that only begins like it is not taken for it.
|
||||
*/
|
||||
export const VACATION_FORWARD_MARKER_RE = /^[ \t]*# Vacation forwarding[ \t]*$/m;
|
||||
|
||||
/** Whether `value` is forwarding Bulwark can write back as it reads. */
|
||||
export function isValidVacationForward(value: unknown): value is VacationForward {
|
||||
if (!value || typeof value !== 'object') return false;
|
||||
const f = value as Record<string, unknown>;
|
||||
if (typeof f.enabled !== 'boolean' || typeof f.keepCopy !== 'boolean') return false;
|
||||
if (typeof f.to !== 'string' || !isValidEmail(f.to)) return false;
|
||||
if (f.activeFrom !== undefined && !isPeriodBoundary(f.activeFrom)) return false;
|
||||
if (f.activeUntil !== undefined && !isPeriodBoundary(f.activeUntil)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The forwarding with the vacation's period, whatever period it had: it runs
|
||||
* while the vacation does (an empty date leaves that end open).
|
||||
*/
|
||||
export function withVacationPeriod(
|
||||
forward: Pick<VacationForward, 'enabled' | 'to' | 'keepCopy'>,
|
||||
period: { from: string | null; until: string | null },
|
||||
): VacationForward {
|
||||
return {
|
||||
enabled: forward.enabled,
|
||||
to: forward.to,
|
||||
keepCopy: forward.keepCopy,
|
||||
...(period.from ? { activeFrom: period.from } : {}),
|
||||
...(period.until ? { activeUntil: period.until } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** The fields Bulwark stores, in a fixed order. */
|
||||
export function normalizeVacationForward(forward: VacationForward): VacationForward {
|
||||
return {
|
||||
enabled: forward.enabled,
|
||||
to: forward.to,
|
||||
keepCopy: forward.keepCopy,
|
||||
...(forward.activeFrom !== undefined ? { activeFrom: forward.activeFrom } : {}),
|
||||
...(forward.activeUntil !== undefined ? { activeUntil: forward.activeUntil } : {}),
|
||||
};
|
||||
}
|
||||
@@ -8,7 +8,8 @@ import { fileStorage } from '@/lib/plugin-storage';
|
||||
* Kept on purpose: device preferences (`settings-storage`, `theme-storage`,
|
||||
* `locale-storage`, the Files view settings) and `template-storage`. With
|
||||
* settings sync off they exist only here, so deleting them would destroy
|
||||
* the user's own work rather than a cache.
|
||||
* the user's own work rather than a cache. With sync on, the settings store
|
||||
* resets settings and templates itself (forgetSyncedSettings).
|
||||
*/
|
||||
const SIGNED_OUT_STORAGE_KEYS = [
|
||||
'email-snapshot',
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
// Browser-tab titles read "<context> - <account> - <app>", the order Gmail and
|
||||
// Outlook Web use. Browsers cut a long title from the end, so the part that
|
||||
// changes (folder, unread count, subject) goes first and the app name last.
|
||||
// The account sits in between, so two mailboxes open side by side stay apart
|
||||
// in the tab strip and in session restore. Empty parts are left out. (#1159)
|
||||
export function formatTabTitle(
|
||||
context: string | null | undefined,
|
||||
account: string | null | undefined,
|
||||
appName: string,
|
||||
): string {
|
||||
return [context, account, appName].filter(Boolean).join(' - ');
|
||||
}
|
||||
|
||||
/** What the mail view is showing, already localized. */
|
||||
export interface MailTitleView {
|
||||
/** Composer label while the composer is open ("New message", "Reply", ...). */
|
||||
composer?: string | null;
|
||||
/** Subject of the open message. */
|
||||
subject?: string | null;
|
||||
/** Selected mailbox, with its unread count when there is one. */
|
||||
mailbox?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The context part of the mail view's tab title: composer, then message, then
|
||||
* mailbox. The subject does not stay in the tab strip - browsers keep it in
|
||||
* history and in the session-restore list, and screen shares show it - so an
|
||||
* operator can keep it out (`tabTitleSubjectEnabled`). The mailbox line is
|
||||
* shown instead, so the tab still says where you are. (#1159)
|
||||
*/
|
||||
export function mailTitleContext(
|
||||
view: MailTitleView,
|
||||
{ showSubject = true }: { showSubject?: boolean } = {},
|
||||
): string | null {
|
||||
return view.composer || (showSubject ? view.subject : null) || view.mailbox || null;
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import type { EmailTemplate } from './template-types';
|
||||
|
||||
/**
|
||||
* Tombstones older than this are pruned from the synced blob. A device that
|
||||
* stays offline longer than this may resurrect a deletion - the accepted
|
||||
* trade-off for keeping the blob bounded.
|
||||
*/
|
||||
export const TEMPLATE_TOMBSTONE_TTL_MS = 90 * 24 * 60 * 60 * 1000;
|
||||
|
||||
export interface SyncedTemplateState<T extends { id: string; updatedAt: string } = EmailTemplate> {
|
||||
templates: T[];
|
||||
/** Template id -> ISO time it was deleted. */
|
||||
deletedTemplateIds: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges a synced template state into the local one. Per template id the
|
||||
* newer `updatedAt` wins (ties keep the local copy); a deletion tombstone
|
||||
* beats a template unless the template was edited after the deletion, which
|
||||
* resurrects it and clears the tombstone. Merging (rather than replacing)
|
||||
* keeps a stale per-account blob from wiping templates created under another
|
||||
* account or on another device.
|
||||
*/
|
||||
export function mergeSyncedTemplates<T extends { id: string; updatedAt: string }>(
|
||||
local: SyncedTemplateState<T>,
|
||||
incoming: SyncedTemplateState<T>,
|
||||
now: Date = new Date()
|
||||
): SyncedTemplateState<T> {
|
||||
const cutoff = now.getTime() - TEMPLATE_TOMBSTONE_TTL_MS;
|
||||
|
||||
// Union of tombstones, newest deletion time per id, pruned by TTL.
|
||||
const tombstones: Record<string, string> = {};
|
||||
for (const source of [local.deletedTemplateIds, incoming.deletedTemplateIds]) {
|
||||
for (const [id, deletedAt] of Object.entries(source)) {
|
||||
if (Date.parse(deletedAt) < cutoff) continue;
|
||||
if (!tombstones[id] || Date.parse(deletedAt) > Date.parse(tombstones[id])) {
|
||||
tombstones[id] = deletedAt;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const byId = new Map<string, T>();
|
||||
for (const t of local.templates) byId.set(t.id, t);
|
||||
for (const t of incoming.templates) {
|
||||
const existing = byId.get(t.id);
|
||||
if (!existing || Date.parse(t.updatedAt) > Date.parse(existing.updatedAt)) {
|
||||
byId.set(t.id, t);
|
||||
}
|
||||
}
|
||||
|
||||
const templates: T[] = [];
|
||||
for (const t of byId.values()) {
|
||||
const deletedAt = tombstones[t.id];
|
||||
if (deletedAt !== undefined) {
|
||||
if (Date.parse(t.updatedAt) <= Date.parse(deletedAt)) continue;
|
||||
delete tombstones[t.id];
|
||||
}
|
||||
templates.push(t);
|
||||
}
|
||||
|
||||
return { templates, deletedTemplateIds: tombstones };
|
||||
}
|
||||
|
||||
type RawTemplate = Record<string, unknown> & { id: string; updatedAt: string };
|
||||
|
||||
function rawTemplates(value: unknown): RawTemplate[] | null {
|
||||
if (!Array.isArray(value)) return null;
|
||||
return value.filter(
|
||||
(t): t is RawTemplate =>
|
||||
typeof t === 'object' && t !== null &&
|
||||
typeof (t as Record<string, unknown>).id === 'string' &&
|
||||
typeof (t as Record<string, unknown>).updatedAt === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
function rawTombstones(value: unknown): Record<string, string> {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) return {};
|
||||
const out: Record<string, string> = {};
|
||||
for (const [id, deletedAt] of Object.entries(value as Record<string, unknown>)) {
|
||||
if (typeof deletedAt === 'string' && !Number.isNaN(Date.parse(deletedAt))) out[id] = deletedAt;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-side counterpart of the client merge, applied when a settings blob
|
||||
* is saved. A push carries the pushing tab's whole template list, so without
|
||||
* this a tab or device that has not loaded since another one added a template
|
||||
* overwrites the stored blob and the template is gone for every device that
|
||||
* loads afterwards. A push without a `templates` array (a client whose
|
||||
* template store had not registered yet, or an older build) keeps the stored
|
||||
* templates instead of dropping them.
|
||||
*
|
||||
* Returns the blob to store; the template entries are passed through as they
|
||||
* are, clients validate them when they apply a blob.
|
||||
*/
|
||||
export function mergeStoredTemplates(
|
||||
stored: Record<string, unknown> | null,
|
||||
incoming: Record<string, unknown>
|
||||
): Record<string, unknown> {
|
||||
const storedTemplates = stored ? rawTemplates(stored.templates) : null;
|
||||
if (!storedTemplates) return incoming;
|
||||
|
||||
const incomingTemplates = rawTemplates(incoming.templates);
|
||||
if (!incomingTemplates) {
|
||||
return {
|
||||
...incoming,
|
||||
templates: stored!.templates,
|
||||
deletedTemplateIds: stored!.deletedTemplateIds ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
const merged = mergeSyncedTemplates(
|
||||
{ templates: storedTemplates, deletedTemplateIds: rawTombstones(stored!.deletedTemplateIds) },
|
||||
{ templates: incomingTemplates, deletedTemplateIds: rawTombstones(incoming.deletedTemplateIds) }
|
||||
);
|
||||
return { ...incoming, ...merged };
|
||||
}
|
||||
+7
-60
@@ -166,18 +166,13 @@ export interface ImportResult {
|
||||
// below, synced templates keep their ids so the same template converges to a
|
||||
// single entry across devices instead of duplicating on every server load.
|
||||
|
||||
/**
|
||||
* Tombstones older than this are pruned from the synced blob. A device that
|
||||
* stays offline longer than this may resurrect a deletion - the accepted
|
||||
* trade-off for keeping the blob bounded.
|
||||
*/
|
||||
export const TEMPLATE_TOMBSTONE_TTL_MS = 90 * 24 * 60 * 60 * 1000;
|
||||
|
||||
export interface SyncedTemplateState {
|
||||
templates: EmailTemplate[];
|
||||
/** Template id -> ISO time it was deleted. */
|
||||
deletedTemplateIds: Record<string, string>;
|
||||
}
|
||||
// The merge itself lives in template-merge so the settings API route can use
|
||||
// it without pulling DOMPurify into the server bundle.
|
||||
export {
|
||||
mergeSyncedTemplates,
|
||||
TEMPLATE_TOMBSTONE_TTL_MS,
|
||||
type SyncedTemplateState,
|
||||
} from './template-merge';
|
||||
|
||||
/**
|
||||
* Structurally validates a `templates` value from a synced settings blob or
|
||||
@@ -235,54 +230,6 @@ export function parseTemplateTombstones(value: unknown): Record<string, string>
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges a synced template state into the local one. Per template id the
|
||||
* newer `updatedAt` wins (ties keep the local copy); a deletion tombstone
|
||||
* beats a template unless the template was edited after the deletion, which
|
||||
* resurrects it and clears the tombstone. Merging (rather than replacing)
|
||||
* keeps a stale per-account blob from wiping templates created under another
|
||||
* account or on another device.
|
||||
*/
|
||||
export function mergeSyncedTemplates(
|
||||
local: SyncedTemplateState,
|
||||
incoming: SyncedTemplateState,
|
||||
now: Date = new Date()
|
||||
): SyncedTemplateState {
|
||||
const cutoff = now.getTime() - TEMPLATE_TOMBSTONE_TTL_MS;
|
||||
|
||||
// Union of tombstones, newest deletion time per id, pruned by TTL.
|
||||
const tombstones: Record<string, string> = {};
|
||||
for (const source of [local.deletedTemplateIds, incoming.deletedTemplateIds]) {
|
||||
for (const [id, deletedAt] of Object.entries(source)) {
|
||||
if (Date.parse(deletedAt) < cutoff) continue;
|
||||
if (!tombstones[id] || Date.parse(deletedAt) > Date.parse(tombstones[id])) {
|
||||
tombstones[id] = deletedAt;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const byId = new Map<string, EmailTemplate>();
|
||||
for (const t of local.templates) byId.set(t.id, t);
|
||||
for (const t of incoming.templates) {
|
||||
const existing = byId.get(t.id);
|
||||
if (!existing || Date.parse(t.updatedAt) > Date.parse(existing.updatedAt)) {
|
||||
byId.set(t.id, t);
|
||||
}
|
||||
}
|
||||
|
||||
const templates: EmailTemplate[] = [];
|
||||
for (const t of byId.values()) {
|
||||
const deletedAt = tombstones[t.id];
|
||||
if (deletedAt !== undefined) {
|
||||
if (Date.parse(t.updatedAt) <= Date.parse(deletedAt)) continue;
|
||||
delete tombstones[t.id];
|
||||
}
|
||||
templates.push(t);
|
||||
}
|
||||
|
||||
return { templates, deletedTemplateIds: tombstones };
|
||||
}
|
||||
|
||||
export function importTemplates(json: string): ImportResult {
|
||||
const errors: string[] = [];
|
||||
|
||||
|
||||
@@ -194,6 +194,20 @@ export function fromZonedDisplayDate(wall: Date, timeZone: string): Date {
|
||||
return new Date(asUtc - getTimeZoneOffsetMs(new Date(guess), timeZone));
|
||||
}
|
||||
|
||||
/**
|
||||
* The instant at which the clock in `timeZone` reads `w`. Unlike
|
||||
* fromZonedDisplayDate it never goes through a Date's local fields, so a
|
||||
* time that the browser's own zone skips at a DST change is not shifted.
|
||||
*/
|
||||
export function fromWallClock(w: WallClock, timeZone: string): Date {
|
||||
const d = new Date(0);
|
||||
d.setUTCFullYear(w.year, w.month - 1, w.day);
|
||||
d.setUTCHours(w.hour, w.minute, w.second, 0);
|
||||
const asUtc = d.getTime();
|
||||
const guess = asUtc - getTimeZoneOffsetMs(new Date(asUtc), timeZone);
|
||||
return new Date(asUtc - getTimeZoneOffsetMs(new Date(guess), timeZone));
|
||||
}
|
||||
|
||||
/**
|
||||
* Instant -> display date for the calendar grid. Identity unless the user's
|
||||
* time zone override differs from the browser zone.
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
// The issuer is what the authenticator app lists the entry under. A branded
|
||||
// deployment (APP_NAME) uses its own name; a stock install has the default
|
||||
// "Webmail", which says nothing in a list of entries, so it keeps naming the
|
||||
// account's server. (#1160)
|
||||
export function totpIssuer(appName: string | undefined | null): string {
|
||||
const name = appName?.trim();
|
||||
return name && name !== 'Webmail' ? name : 'Stalwart';
|
||||
}
|
||||
+15
-1
@@ -496,6 +496,12 @@ export async function fetchCrossViewEmails(
|
||||
/**
|
||||
* Text search within a cross-account view: the view filter AND a free-text
|
||||
* condition, fanned out across accounts.
|
||||
*
|
||||
* Searching from "All mail" is the exception: it searches every folder of
|
||||
* every account except Trash and Junk, the standard search panel's default
|
||||
* scope. The All mail list leaves Sent, Archive and Drafts out (or whatever
|
||||
* the folder picker excludes), and narrowing the search to that list hid
|
||||
* every sent reply from it. Unread and Starred still narrow to their list.
|
||||
*/
|
||||
export async function searchCrossViewEmails(
|
||||
accounts: UnifiedAccountClient[],
|
||||
@@ -504,6 +510,9 @@ export async function searchCrossViewEmails(
|
||||
limit: number,
|
||||
position: FanOutPosition,
|
||||
): Promise<UnifiedFetchResult> {
|
||||
if (view === 'all') {
|
||||
return searchAcrossAccounts(accounts, query, limit, position, { excludeTrashAndJunk: true });
|
||||
}
|
||||
return fanOutCrossQuery(accounts, (account, jmapAccountId, ids) =>
|
||||
account.client.advancedSearchEmails(
|
||||
{ operator: 'AND', conditions: [buildCrossFilter(view, ids), { text: query }] },
|
||||
@@ -517,7 +526,9 @@ export async function searchCrossViewEmails(
|
||||
* Like `searchCrossViewEmails`, but applies an advanced filter (text + field
|
||||
* conditions from `buildJMAPFilter`, built WITHOUT an `inMailbox` clause) on top
|
||||
* of the cross-view membership. `extraFilter` may be empty ({}), in which case
|
||||
* only the membership filter is used (equivalent to a plain browse).
|
||||
* only the membership filter is used (equivalent to a plain browse). A
|
||||
* non-empty filter on "All mail" searches every folder except Trash and Junk,
|
||||
* as in `searchCrossViewEmails`.
|
||||
*/
|
||||
export async function advancedSearchCrossViewEmails(
|
||||
accounts: UnifiedAccountClient[],
|
||||
@@ -527,6 +538,9 @@ export async function advancedSearchCrossViewEmails(
|
||||
position: FanOutPosition,
|
||||
): Promise<UnifiedFetchResult> {
|
||||
const hasExtra = Object.keys(extraFilter).length > 0;
|
||||
if (view === 'all' && hasExtra) {
|
||||
return advancedSearchAcrossAccounts(accounts, extraFilter, limit, position, { excludeTrashAndJunk: true });
|
||||
}
|
||||
return fanOutCrossQuery(accounts, (account, jmapAccountId, ids) => {
|
||||
const membership = buildCrossFilter(view, ids);
|
||||
const filter = hasExtra
|
||||
|
||||
+68
-13
@@ -348,6 +348,15 @@
|
||||
"external_content_warning": "تم حظر الصور والمحتوى الخارجي",
|
||||
"load_external_content": "تحميل الصور",
|
||||
"trust_sender": "الوثوق بهذا المرسل دائمًا",
|
||||
"sender_check": {
|
||||
"unverified_label": "مرسل غير موثّق",
|
||||
"failed_label": "فشل التحقق من المرسل",
|
||||
"unverified": "تدّعي هذه الرسالة أنها من {domain}، لكن لا شيء يؤكد ذلك. فهي لا تحمل توقيعًا صالحًا من {domain} ولم تأتِ من خادم مخوّل من قِبل {domain}.",
|
||||
"unverified_sent_from": "تدّعي هذه الرسالة أنها من {domain}، لكنها أُرسلت من {host} ولا تحمل توقيعًا صالحًا من {domain}.",
|
||||
"failed": "تدّعي هذه الرسالة أنها من {domain}، لكنها لم تجتز فحوصات المرسل الخاصة بهذا النطاق. قد تكون مزوّرة.",
|
||||
"failed_sent_from": "تدّعي هذه الرسالة أنها من {domain}، لكنها أُرسلت من {host} ولم تجتز فحوصات المرسل الخاصة بـ {domain}. قد تكون مزوّرة.",
|
||||
"caution": "توخَّ الحذر مع الروابط والمرفقات وطلبات كلمات المرور أو بيانات الدفع."
|
||||
},
|
||||
"back_to_list": "العودة إلى القائمة",
|
||||
"view_contact": "عرض جهة الاتصال",
|
||||
"message_details": "تفاصيل الرسالة",
|
||||
@@ -630,6 +639,12 @@
|
||||
"attach": "إرفاق",
|
||||
"attach_photos": "الصور ومقاطع الفيديو",
|
||||
"attach_files": "ملفات",
|
||||
"attach_from_files": "إرفاق من الملفات",
|
||||
"files_picker": {
|
||||
"attach": "{count, plural, =0 {إرفاق} one {إرفاق ملف واحد} other {إرفاق # ملفات}}",
|
||||
"empty_folder": "هذا المجلد فارغ",
|
||||
"load_failed": "تعذّر تحميل ملفاتك"
|
||||
},
|
||||
"discard": "تجاهل",
|
||||
"discard_draft_title": "تجاهل المسودة؟",
|
||||
"discard_draft_confirm": "لديك تغييرات غير محفوظة. هل تريد تجاهل هذه المسودة؟",
|
||||
@@ -841,8 +856,8 @@
|
||||
"identity_delete_failed": "فشل حذف الهوية: {error}",
|
||||
"identity_unauthorized": "غير مخوّل لك الإرسال من عنوان البريد الإلكتروني هذا",
|
||||
"identity_not_found": "الهوية غير موجودة",
|
||||
"vacation_saved": "تم حفظ إعدادات الرد التلقائي أثناء الغياب",
|
||||
"vacation_save_failed": "فشل حفظ إعدادات الرد التلقائي أثناء الغياب",
|
||||
"vacation_saved": "تم حفظ إعدادات الغياب",
|
||||
"vacation_save_failed": "فشل حفظ إعدادات الغياب",
|
||||
"filters_saved": "تم حفظ المرشحات بنجاح",
|
||||
"filters_save_failed": "فشل حفظ المرشحات",
|
||||
"filters_deleted": "تم حذف قاعدة المرشح",
|
||||
@@ -864,7 +879,8 @@
|
||||
"rule_undone": "تم التراجع عن تغيير القاعدة",
|
||||
"rule_undo_failed": "تعذّر التراجع عن تغيير القاعدة",
|
||||
"rule_undo_conflict": "تغيّرت مرشحاتك منذ ذلك الحين. تراجع عن التغيير من إعدادات المرشحات.",
|
||||
"rule_folder_failed": "تعذّر إنشاء المجلد"
|
||||
"rule_folder_failed": "تعذّر إنشاء المجلد",
|
||||
"vacation_filters_save_failed": "تم حفظ إعدادات الغياب، لكن تعذّر حفظ إعادة التوجيه ومستلمي الرد"
|
||||
},
|
||||
"date": {
|
||||
"today": "اليوم",
|
||||
@@ -952,7 +968,7 @@
|
||||
"privacy": "الخصوصية والأمان",
|
||||
"account": "الحساب",
|
||||
"identities": "الهويات",
|
||||
"vacation": "الرد التلقائي أثناء الغياب",
|
||||
"vacation": "الغياب",
|
||||
"advanced": "متقدم",
|
||||
"calendar": "التقويم",
|
||||
"filters": "المرشحات",
|
||||
@@ -1837,28 +1853,24 @@
|
||||
}
|
||||
},
|
||||
"vacation": {
|
||||
"title": "الرد التلقائي أثناء الغياب",
|
||||
"description": "الرد تلقائيًا على الرسائل الواردة أثناء غيابك",
|
||||
"title": "الغياب",
|
||||
"description": "التعامل تلقائيًا مع الرسائل الواردة أثناء غيابك",
|
||||
"loading": "جارٍ تحميل إعدادات الغياب...",
|
||||
"not_supported": "خادم بريدك لا يدعم ردود الغياب.",
|
||||
"fetch_error": "فشل تحميل إعدادات الغياب. يرجى المحاولة مرة أخرى.",
|
||||
"status": {
|
||||
"label": "الرد التلقائي أثناء الغياب",
|
||||
"label": "إرسال رد تلقائي",
|
||||
"description": "إرسال رد تلقائي إلى من يرسل إليك بريدًا",
|
||||
"active": "مفعّل",
|
||||
"inactive": "معطّل"
|
||||
},
|
||||
"date_range": {
|
||||
"title": "النطاق الزمني",
|
||||
"description": "يمكنك اختياريًا تحديد الرد التلقائي بفترة معينة",
|
||||
"start": "تاريخ البدء",
|
||||
"start_description": "اتركه فارغًا لعدم وجود حد للبدء",
|
||||
"end": "تاريخ الانتهاء",
|
||||
"end_description": "اتركه فارغًا لعدم وجود حد للانتهاء"
|
||||
},
|
||||
"message": {
|
||||
"title": "رسالة الرد التلقائي",
|
||||
"description": "الرسالة التي ستُرسل كرد",
|
||||
"subject_label": "الموضوع",
|
||||
"subject_description": "سطر موضوع الرد التلقائي",
|
||||
"subject_placeholder": "خارج المكتب",
|
||||
@@ -1878,10 +1890,32 @@
|
||||
"saving": "جارٍ الحفظ...",
|
||||
"warnings": {
|
||||
"end_before_start": "يجب أن يكون تاريخ الانتهاء بعد تاريخ البدء",
|
||||
"invalid_date": "أدخل تاريخ بدء وانتهاء صالحين مع الوقت، أو اترك الحقل فارغًا",
|
||||
"start_in_past": "تاريخ البدء في الماضي",
|
||||
"empty_body": "نص الرسالة فارغ - سيتلقى المستلمون ردًا فارغًا",
|
||||
"subject_too_long": "الموضوع طويل جدًا بالنسبة للخادم (بحد أقصى {max} بايت).",
|
||||
"body_too_long": "الرسالة طويلة جدًا بالنسبة للخادم (بحد أقصى {max} بايت بما في ذلك التنسيق)."
|
||||
"body_too_long": "الرسالة طويلة جدًا بالنسبة للخادم (بحد أقصى {max} بايت بما في ذلك التنسيق).",
|
||||
"forward_address": "أدخل عنوانًا صالحًا لإعادة التوجيه إليه",
|
||||
"not_running": "إعادة التوجيه أو مستلمو الرد محفوظون لكنهم غير مفعّلين الآن. احفظ لإعادة تفعيلهم."
|
||||
},
|
||||
"forward": {
|
||||
"title": "إعادة التوجيه",
|
||||
"description": "تُعيد توجيه الرسائل الواردة إلى عنوان آخر، مع الرد التلقائي أو بدونه، خلال الفترة أعلاه (بدون تاريخ انتهاء، حتى يتم إيقافها). لا تتم إعادة توجيه الرسائل غير المرغوب فيها.",
|
||||
"enabled_label": "إعادة توجيه الرسائل",
|
||||
"to_label": "إعادة التوجيه إلى",
|
||||
"to_placeholder": "email@example.com",
|
||||
"keep_label": "الإبقاء في الوارد",
|
||||
"keep_description": "وإلا فستتم إعادة توجيه الرسالة فقط: لن تبقى في صندوق البريد هذا، ولن تُطبَّق عليها أي قاعدة تصفية."
|
||||
},
|
||||
"audience": {
|
||||
"label": "الرد على",
|
||||
"description": "المرسلون الداخليون: {domains}",
|
||||
"all": "جميع المرسلين",
|
||||
"internal": "المرسلون الداخليون فقط",
|
||||
"external": "المرسلون الخارجيون فقط"
|
||||
},
|
||||
"auto_reply": {
|
||||
"title": "الرد التلقائي"
|
||||
}
|
||||
},
|
||||
"folders": {
|
||||
@@ -1906,6 +1940,15 @@
|
||||
"reorder": "اسحب لإعادة الترتيب",
|
||||
"reorder_error": "فشل في إعادة ترتيب المجلدات",
|
||||
"move_error": "فشل في نقل المجلد",
|
||||
"select_folder": "تحديد {name}",
|
||||
"select_hint": "تحديد. Shift+نقر يحدد كل المجلدات حتى آخر مجلد نقرت عليه.",
|
||||
"selected_count": "{count, plural, one {# مجلد} other {# مجلدات}} محددة",
|
||||
"move_to": "نقل إلى...",
|
||||
"move_to_top_level": "المستوى الأعلى",
|
||||
"move": "نقل",
|
||||
"clear_selection": "إلغاء التحديد",
|
||||
"folders_moved": "{count, plural, one {تم نقل # مجلد} other {تم نقل # مجلدات}}",
|
||||
"move_partial_error": "{count, plural, one {تعذّر نقل # مجلد} other {تعذّر نقل # مجلدات}}",
|
||||
"subfolder_of": "داخل {name}",
|
||||
"subfolder_name": "اسم المجلد الفرعي",
|
||||
"new_folder_name": "اسم المجلد",
|
||||
@@ -2250,7 +2293,19 @@
|
||||
"suggest_list": "List-Id",
|
||||
"suggest_domain": "@{domain}",
|
||||
"apply_existing": "طبّقها أيضًا على الرسائل الموجودة في هذا المجلد",
|
||||
"apply_existing_unsupported": "لا يمكن تطبيق القواعد على الرسائل الموجودة إلا إذا كانت تفحص المرسل أو المستلمين أو الموضوع أو الترويسات، وتنقل أو تنسخ أو تميّز كمقروءة أو بنجمة أو بوسم."
|
||||
"apply_existing_unsupported": "لا يمكن تطبيق القواعد على الرسائل الموجودة إلا إذا كانت تفحص المرسل أو المستلمين أو الموضوع أو الترويسات، وتنقل أو تنسخ أو تميّز كمقروءة أو بنجمة أو بوسم.",
|
||||
"period_title": "النطاق الزمني",
|
||||
"period_start": "تاريخ البدء",
|
||||
"period_end": "تاريخ الانتهاء",
|
||||
"validation_period_order": "يجب أن يكون تاريخ الانتهاء بعد تاريخ البدء",
|
||||
"period_toggle": "نشطة فقط خلال فترة زمنية",
|
||||
"period_hint": "تنطبق على الرسائل التي تصل خلال هذه الفترة. اترك حقلًا فارغًا لإبقاء ذلك الطرف مفتوحًا.",
|
||||
"validation_period_empty": "أدخل تاريخ بدء أو انتهاء للفترة",
|
||||
"validation_period_invalid": "أدخل تاريخًا صالحًا مع الوقت للفترة، أو اترك الحقل فارغًا",
|
||||
"apply_existing_period": "القاعدة ذات الفترة الزمنية تعمل فقط على الرسائل التي تصل خلالها، لذا لا يمكن تطبيقها على الرسائل الموجودة.",
|
||||
"period_status_scheduled": "مجدولة",
|
||||
"period_status_active": "نشطة",
|
||||
"period_status_expired": "منتهية"
|
||||
},
|
||||
"templates": {
|
||||
"title": "قوالب البريد",
|
||||
|
||||
+68
-13
@@ -348,6 +348,15 @@
|
||||
"external_content_warning": "S'han bloquejat les imatges i el contingut extern",
|
||||
"load_external_content": "Carrega les imatges",
|
||||
"trust_sender": "Confia sempre en aquest remitent",
|
||||
"sender_check": {
|
||||
"unverified_label": "Remitent no verificat",
|
||||
"failed_label": "La verificació del remitent ha fallat",
|
||||
"unverified": "Aquest missatge diu que és de {domain}, però res no ho confirma. No té cap signatura vàlida de {domain} i no prové de cap servidor autoritzat per {domain}.",
|
||||
"unverified_sent_from": "Aquest missatge diu que és de {domain}, però s'ha enviat des de {host} i no té cap signatura vàlida de {domain}.",
|
||||
"failed": "Aquest missatge diu que és de {domain}, però no ha superat les comprovacions de remitent d'aquest domini. Podria estar falsificat.",
|
||||
"failed_sent_from": "Aquest missatge diu que és de {domain}, però s'ha enviat des de {host} i no ha superat les comprovacions de remitent de {domain}. Podria estar falsificat.",
|
||||
"caution": "Aneu amb compte amb els enllaços, els fitxers adjunts i les sol·licituds de contrasenyes o dades de pagament."
|
||||
},
|
||||
"back_to_list": "Torna a la llista",
|
||||
"view_contact": "Mostra el contacte",
|
||||
"message_details": "Detalls del missatge",
|
||||
@@ -630,6 +639,12 @@
|
||||
"attach": "Adjunta",
|
||||
"attach_photos": "Fotos i vídeos",
|
||||
"attach_files": "Fitxers",
|
||||
"attach_from_files": "Adjunta des de Fitxers",
|
||||
"files_picker": {
|
||||
"attach": "{count, plural, =0 {Adjunta} one {Adjunta 1 fitxer} other {Adjunta # fitxers}}",
|
||||
"empty_folder": "Aquesta carpeta és buida",
|
||||
"load_failed": "No s'han pogut carregar els teus fitxers"
|
||||
},
|
||||
"discard": "Descarta",
|
||||
"discard_draft_title": "Voleu descartar l'esborrany?",
|
||||
"discard_draft_confirm": "Teniu canvis sense desar. Voleu descartar aquest esborrany?",
|
||||
@@ -841,8 +856,8 @@
|
||||
"identity_delete_failed": "No s'ha pogut suprimir la identitat: {error}",
|
||||
"identity_unauthorized": "No teniu autorització per enviar des d'aquesta adreça electrònica",
|
||||
"identity_not_found": "No s'ha trobat la identitat",
|
||||
"vacation_saved": "S'ha desat la configuració de la resposta automàtica",
|
||||
"vacation_save_failed": "No s'ha pogut desar la configuració de la resposta automàtica",
|
||||
"vacation_saved": "S'ha desat la configuració d'absència",
|
||||
"vacation_save_failed": "No s'ha pogut desar la configuració d'absència",
|
||||
"filters_saved": "Filtres desats correctament",
|
||||
"filters_save_failed": "No s'han pogut desar els filtres",
|
||||
"filters_deleted": "Regla de filtre suprimida",
|
||||
@@ -864,7 +879,8 @@
|
||||
"rule_undone": "S'ha desfet el canvi de la regla",
|
||||
"rule_undo_failed": "No s'ha pogut desfer el canvi de la regla",
|
||||
"rule_undo_conflict": "Els teus filtres han canviat des d'aleshores. Desfés el canvi a la configuració dels filtres.",
|
||||
"rule_folder_failed": "No s'ha pogut crear la carpeta"
|
||||
"rule_folder_failed": "No s'ha pogut crear la carpeta",
|
||||
"vacation_filters_save_failed": "S'ha desat la configuració d'absència, però no s'han pogut desar el reenviament ni els destinataris de la resposta"
|
||||
},
|
||||
"date": {
|
||||
"today": "Avui",
|
||||
@@ -952,7 +968,7 @@
|
||||
"privacy": "Privadesa i seguretat",
|
||||
"account": "Compte",
|
||||
"identities": "Identitats",
|
||||
"vacation": "Resposta automàtica",
|
||||
"vacation": "Absència",
|
||||
"advanced": "Avançat",
|
||||
"calendar": "Calendari",
|
||||
"filters": "Filtres",
|
||||
@@ -1837,28 +1853,24 @@
|
||||
}
|
||||
},
|
||||
"vacation": {
|
||||
"title": "Resposta automàtica",
|
||||
"description": "Respon automàticament als correus entrants mentre esteu fora",
|
||||
"title": "Absència",
|
||||
"description": "Gestiona automàticament els correus entrants mentre esteu fora",
|
||||
"loading": "Carregant la configuració de la resposta automàtica...",
|
||||
"not_supported": "El servidor de correu no admet respostes automàtiques.",
|
||||
"fetch_error": "No s'ha pogut carregar la configuració de la resposta automàtica. Torneu-ho a provar.",
|
||||
"status": {
|
||||
"label": "Resposta automàtica",
|
||||
"label": "Respon automàticament",
|
||||
"description": "Envia una resposta automàtica a les persones que us envien correu",
|
||||
"active": "Activa",
|
||||
"inactive": "Inactiva"
|
||||
},
|
||||
"date_range": {
|
||||
"title": "Interval de dates",
|
||||
"description": "Opcionalment, limiteu la resposta automàtica a un període específic",
|
||||
"start": "Data d'inici",
|
||||
"start_description": "Deixeu-ho buit per no establir cap límit inicial",
|
||||
"end": "Data de finalització",
|
||||
"end_description": "Deixeu-ho buit per no establir cap límit final"
|
||||
},
|
||||
"message": {
|
||||
"title": "Missatge de resposta automàtica",
|
||||
"description": "El missatge que s'enviarà com a resposta",
|
||||
"subject_label": "Assumpte",
|
||||
"subject_description": "Línia d'assumpte de la resposta automàtica",
|
||||
"subject_placeholder": "Fora de l'oficina",
|
||||
@@ -1878,10 +1890,32 @@
|
||||
"saving": "Desant...",
|
||||
"warnings": {
|
||||
"end_before_start": "La data de finalització ha de ser posterior a la data d'inici",
|
||||
"invalid_date": "Introdueix una data d'inici i de finalització vàlides amb l'hora, o deixa el camp buit",
|
||||
"start_in_past": "La data d'inici és al passat",
|
||||
"empty_body": "El cos del missatge està buit - els destinataris rebran una resposta en blanc",
|
||||
"subject_too_long": "L'assumpte és massa llarg per al servidor (com a màxim {max} bytes).",
|
||||
"body_too_long": "El missatge és massa llarg per al servidor (com a màxim {max} bytes, amb el format inclòs)."
|
||||
"body_too_long": "El missatge és massa llarg per al servidor (com a màxim {max} bytes, amb el format inclòs).",
|
||||
"forward_address": "Introdueix una adreça vàlida per al reenviament",
|
||||
"not_running": "El reenviament o els destinataris de la resposta estan desats però ara no estan actius. Desa per tornar-los a activar."
|
||||
},
|
||||
"forward": {
|
||||
"title": "Reenviament",
|
||||
"description": "Reenvia els missatges entrants a una altra adreça, amb resposta automàtica o sense, dins del període indicat a dalt (sense data de finalització, fins que es desactivi). El correu brossa no es reenvia.",
|
||||
"enabled_label": "Reenvia els missatges",
|
||||
"to_label": "Reenvia a",
|
||||
"to_placeholder": "correu@exemple.com",
|
||||
"keep_label": "Mantén a la safata d'entrada",
|
||||
"keep_description": "Si no, el missatge només es reenvia: no es conserva en aquesta bústia i no s'hi aplica cap regla de filtre."
|
||||
},
|
||||
"audience": {
|
||||
"label": "Respon a",
|
||||
"description": "Remitents interns: {domains}",
|
||||
"all": "Tots els remitents",
|
||||
"internal": "Només remitents interns",
|
||||
"external": "Només remitents externs"
|
||||
},
|
||||
"auto_reply": {
|
||||
"title": "Resposta automàtica"
|
||||
}
|
||||
},
|
||||
"folders": {
|
||||
@@ -1906,6 +1940,15 @@
|
||||
"reorder": "Arrossegueu per reordenar",
|
||||
"reorder_error": "No s'han pogut reordenar les carpetes",
|
||||
"move_error": "No s'ha pogut moure la carpeta",
|
||||
"select_folder": "Selecciona {name}",
|
||||
"select_hint": "Selecciona. Maj+clic selecciona totes les carpetes fins a l'última on has fet clic.",
|
||||
"selected_count": "{count, plural, one {# carpeta seleccionada} other {# carpetes seleccionades}}",
|
||||
"move_to": "Mou a...",
|
||||
"move_to_top_level": "Nivell superior",
|
||||
"move": "Mou",
|
||||
"clear_selection": "Esborra la selecció",
|
||||
"folders_moved": "{count, plural, one {# carpeta moguda} other {# carpetes mogudes}}",
|
||||
"move_partial_error": "{count, plural, one {No s'ha pogut moure # carpeta} other {No s'han pogut moure # carpetes}}",
|
||||
"subfolder_of": "Dins de {name}",
|
||||
"subfolder_name": "Nom de la subcarpeta",
|
||||
"new_folder_name": "Nom de la carpeta",
|
||||
@@ -2250,7 +2293,19 @@
|
||||
"suggest_list": "List-Id",
|
||||
"suggest_domain": "@{domain}",
|
||||
"apply_existing": "Aplica-ho també als missatges que ja hi ha en aquesta carpeta",
|
||||
"apply_existing_unsupported": "Només les regles que comproven el remitent, els destinataris, l'assumpte o les capçaleres, i que mouen, copien, marquen com a llegit, destaquen o etiqueten, es poden aplicar a missatges existents."
|
||||
"apply_existing_unsupported": "Només les regles que comproven el remitent, els destinataris, l'assumpte o les capçaleres, i que mouen, copien, marquen com a llegit, destaquen o etiqueten, es poden aplicar a missatges existents.",
|
||||
"period_title": "Interval de dates",
|
||||
"period_start": "Data d'inici",
|
||||
"period_end": "Data de finalització",
|
||||
"validation_period_order": "La data de finalització ha de ser posterior a la data d'inici",
|
||||
"period_toggle": "Activa només durant un període",
|
||||
"period_hint": "S'aplica als missatges que arriben durant aquest període. Deixa un camp buit per no limitar aquell extrem.",
|
||||
"validation_period_empty": "Introdueix una data d'inici o de finalització per al període",
|
||||
"validation_period_invalid": "Introdueix una data vàlida amb l'hora per al període, o deixa el camp buit",
|
||||
"apply_existing_period": "Una regla amb període només actua sobre els missatges que arriben dins d'aquest període, de manera que no es pot aplicar als missatges existents.",
|
||||
"period_status_scheduled": "programada",
|
||||
"period_status_active": "activa",
|
||||
"period_status_expired": "caducada"
|
||||
},
|
||||
"templates": {
|
||||
"title": "Plantilles de correu",
|
||||
|
||||
+108
-53
@@ -152,8 +152,8 @@
|
||||
"unified_all_unread": "Vše nepřečtené",
|
||||
"unified_all_starred": "Vše s hvězdičkou",
|
||||
"unified_all_mail": "Veškerá pošta",
|
||||
"remove_account": "Remove account",
|
||||
"remove_account_confirm": "Remove {account} from this device? You can add it back later."
|
||||
"remove_account": "Odebrat účet",
|
||||
"remove_account_confirm": "Odebrat účet {account} z tohoto zařízení? Později ho můžete znovu přidat."
|
||||
},
|
||||
"protocol_handlers": {
|
||||
"title": "Výchozí aplikace",
|
||||
@@ -308,7 +308,7 @@
|
||||
"forward": "Přeposlat",
|
||||
"delete": "Odstranit",
|
||||
"archive": "Archivovat",
|
||||
"archive_mailbox_not_found": "Archive mailbox not found - cannot archive email",
|
||||
"archive_mailbox_not_found": "Složka Archiv nebyla nalezena – e-mail nelze archivovat",
|
||||
"star": "Označit hvězdičkou",
|
||||
"unstar": "Odebrat hvězdičku",
|
||||
"mark_unread": "Označit jako nepřečtené",
|
||||
@@ -348,6 +348,15 @@
|
||||
"external_content_warning": "Obrázky a externí obsah byly zablokovány",
|
||||
"load_external_content": "Načíst obrázky",
|
||||
"trust_sender": "Vždy důvěřovat tomuto odesílateli",
|
||||
"sender_check": {
|
||||
"unverified_label": "Neověřený odesílatel",
|
||||
"failed_label": "Kontrola odesílatele selhala",
|
||||
"unverified": "Tato zpráva tvrdí, že pochází z domény {domain}, ale nic to nepotvrzuje. Nemá platný podpis domény {domain} a nepřišla ze serveru, který doména {domain} povoluje.",
|
||||
"unverified_sent_from": "Tato zpráva tvrdí, že pochází z domény {domain}, ale byla odeslána z domény {host} a nemá platný podpis domény {domain}.",
|
||||
"failed": "Tato zpráva tvrdí, že pochází z domény {domain}, ale neprošla kontrolami odesílatele této domény. Může být podvržená.",
|
||||
"failed_sent_from": "Tato zpráva tvrdí, že pochází z domény {domain}, ale byla odeslána z domény {host} a neprošla kontrolami odesílatele domény {domain}. Může být podvržená.",
|
||||
"caution": "Buďte opatrní u odkazů, příloh a žádostí o hesla nebo platební údaje."
|
||||
},
|
||||
"back_to_list": "Zpět na seznam",
|
||||
"view_contact": "Zobrazit kontakt",
|
||||
"message_details": "Podrobnosti zprávy",
|
||||
@@ -543,7 +552,7 @@
|
||||
"recipients_routing": "Příjemci a směrování",
|
||||
"authentication_security": "Ověřování a zabezpečení",
|
||||
"identifiers_threading": "Identifikátory a vlákna",
|
||||
"mailing_list": "Mailing list",
|
||||
"mailing_list": "Diskusní skupina",
|
||||
"message_properties": "Vlastnosti zprávy",
|
||||
"sent": "Odesláno",
|
||||
"received": "Přijato",
|
||||
@@ -628,8 +637,14 @@
|
||||
"send": "Odeslat",
|
||||
"cancel": "Zrušit",
|
||||
"attach": "Připojit",
|
||||
"attach_photos": "Photos & Videos",
|
||||
"attach_files": "Files",
|
||||
"attach_photos": "Fotky a videa",
|
||||
"attach_files": "Soubory",
|
||||
"attach_from_files": "Připojit ze Souborů",
|
||||
"files_picker": {
|
||||
"attach": "{count, plural, =0 {Připojit} one {Připojit 1 soubor} few {Připojit # soubory} other {Připojit # souborů}}",
|
||||
"empty_folder": "Tato složka je prázdná",
|
||||
"load_failed": "Vaše soubory se nepodařilo načíst"
|
||||
},
|
||||
"discard": "Zahodit",
|
||||
"discard_draft_title": "Zahodit koncept?",
|
||||
"discard_draft_confirm": "Máte neuložené změny. Chcete tento koncept zahodit?",
|
||||
@@ -841,8 +856,8 @@
|
||||
"identity_delete_failed": "Odstranění identity selhalo: {error}",
|
||||
"identity_unauthorized": "Nemáte oprávnění odesílat z této e-mailové adresy",
|
||||
"identity_not_found": "Identita nenalezena",
|
||||
"vacation_saved": "Nastavení automatické odpovědi bylo uloženo",
|
||||
"vacation_save_failed": "Uložení nastavení automatické odpovědi selhalo",
|
||||
"vacation_saved": "Nastavení nepřítomnosti bylo uloženo",
|
||||
"vacation_save_failed": "Uložení nastavení nepřítomnosti selhalo",
|
||||
"filters_saved": "Filtry úspěšně uloženy",
|
||||
"filters_save_failed": "Uložení filtrů selhalo",
|
||||
"filters_deleted": "Pravidlo filtru bylo odstraněno",
|
||||
@@ -864,7 +879,8 @@
|
||||
"rule_undone": "Změna pravidla vrácena",
|
||||
"rule_undo_failed": "Změnu pravidla se nepodařilo vrátit",
|
||||
"rule_undo_conflict": "Vaše filtry se mezitím změnily. Vraťte změnu v nastavení filtrů.",
|
||||
"rule_folder_failed": "Složku se nepodařilo vytvořit"
|
||||
"rule_folder_failed": "Složku se nepodařilo vytvořit",
|
||||
"vacation_filters_save_failed": "Nastavení nepřítomnosti bylo uloženo, ale přeposílání a příjemce odpovědi se uložit nepodařilo"
|
||||
},
|
||||
"date": {
|
||||
"today": "Dnes",
|
||||
@@ -949,7 +965,7 @@
|
||||
"privacy": "Soukromí a bezpečnost",
|
||||
"account": "Účet",
|
||||
"identities": "Identity",
|
||||
"vacation": "Automatická odpověď",
|
||||
"vacation": "Nepřítomnost",
|
||||
"advanced": "Pokročilé",
|
||||
"calendar": "Kalendář",
|
||||
"filters": "Filtry",
|
||||
@@ -1106,8 +1122,8 @@
|
||||
"label": "Sjednocená schránka",
|
||||
"description": "Zobrazovat sloučené složky (Doručené, Odeslané atd.) ze všech připojených účtů",
|
||||
"cross_account": {
|
||||
"label": "Across all accounts",
|
||||
"description": "Merge the unified mailbox across every connected account instead of staying within the active account."
|
||||
"label": "Napříč všemi účty",
|
||||
"description": "Sloučit sjednocenou schránku napříč všemi připojenými účty místo omezení na aktivní účet."
|
||||
},
|
||||
"include_group": {
|
||||
"label": "Zahrnout skupinové schránky",
|
||||
@@ -1117,20 +1133,20 @@
|
||||
"all_mail": {
|
||||
"folders_label": "Folders in All Mail",
|
||||
"folders_description": "Choose which folders are merged into the All Mail view.",
|
||||
"account_hint": "Applies to {account}.",
|
||||
"no_folders": "No folders available."
|
||||
"account_hint": "Platí pro účet {account}.",
|
||||
"no_folders": "Nejsou k dispozici žádné složky."
|
||||
},
|
||||
"colorful_sidebar_icons": {
|
||||
"label": "Barevné ikony postranního panelu",
|
||||
"description": "Obarví ikony složek a štítků podle typu (modré Doručené, červený Spam, zelené Odeslané atd.). Vypněte pro jednobarevný postranní panel."
|
||||
},
|
||||
"tint_list_rows": {
|
||||
"label": "Tint List Rows by Tag Color",
|
||||
"description": "Shade each message row with its first tag color. Disable to keep rows plain; tag dots and chips still show the color."
|
||||
"label": "Podbarvit řádky barvou štítku",
|
||||
"description": "Podbarvit každý řádek zprávy barvou jejího prvního štítku. Po vypnutí zůstanou řádky bílé; tečky a chipy štítků barvu dál zobrazují."
|
||||
},
|
||||
"tint_list_rows_by_account": {
|
||||
"label": "Tint List Rows by Account Color",
|
||||
"description": "In the unified view, shade each row with its account color instead of showing the account dot. Takes precedence over the tag tint; tags keep their color on their chips."
|
||||
"label": "Podbarvit řádky barvou účtu",
|
||||
"description": "Ve sjednoceném zobrazení podbarvit každý řádek barvou účtu místo zobrazení tečky účtu. Má přednost před podbarvením štítkem; štítky si barvu na chipech ponechají."
|
||||
},
|
||||
"show_folder_total_count": {
|
||||
"label": "Zobrazit celkový počet zpráv",
|
||||
@@ -1226,8 +1242,8 @@
|
||||
"enabled_desc": "Zobrazovat oznámení při příchodu nových e-mailů",
|
||||
"sound": "Zvuk oznámení",
|
||||
"sound_desc": "Přehrát zvukové upozornění při příchodu nových e-mailů",
|
||||
"inbox_only": "Inbox only",
|
||||
"inbox_only_desc": "Only notify for mail that lands in the Inbox; skip mail a filter files into another folder"
|
||||
"inbox_only": "Jen Doručené",
|
||||
"inbox_only_desc": "Upozorňovat jen na poštu, která přijde do Doručených; poštu, kterou filtr uloží do jiné složky, přeskočit"
|
||||
},
|
||||
"calendar": {
|
||||
"title": "Oznámení kalendáře",
|
||||
@@ -1256,7 +1272,7 @@
|
||||
"status_busy": "Pracuji…",
|
||||
"status_inactive": "Není povoleno na tomto zařízení",
|
||||
"status_unsupported": "Tento prohlížeč nepodporuje Web Push",
|
||||
"status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.",
|
||||
"status_lite": "Web Push není v Bulwark Lite k dispozici; vyžaduje server Bulwark.",
|
||||
"title": "Oznámení na pozadí",
|
||||
"devices_title": "Registrovaná zařízení",
|
||||
"devices_desc": "Všechna zařízení registrovaná pro příjem oznamení na pozadí u tohoto účtu. Odvolání okamžitě zastaví jejich příjem.",
|
||||
@@ -1296,12 +1312,12 @@
|
||||
"preview_older": "Starší:"
|
||||
},
|
||||
"date_locale": {
|
||||
"label": "Date format region",
|
||||
"description": "How numeric dates are ordered (day, month, year)",
|
||||
"auto": "Automatic (match language)",
|
||||
"iso": "ISO 8601 (YYYY-MM-DD)",
|
||||
"dmy": "Day/Month/Year",
|
||||
"mdy": "Month/Day/Year"
|
||||
"label": "Region formátu data",
|
||||
"description": "Pořadí částí číselného data (den, měsíc, rok)",
|
||||
"auto": "Automaticky (podle jazyka)",
|
||||
"iso": "ISO 8601 (RRRR-MM-DD)",
|
||||
"dmy": "Den/měsíc/rok",
|
||||
"mdy": "Měsíc/den/rok"
|
||||
},
|
||||
"time_format": {
|
||||
"label": "Formát času",
|
||||
@@ -1316,10 +1332,10 @@
|
||||
"monday": "Pondělí"
|
||||
},
|
||||
"time_zone": {
|
||||
"label": "Time zone",
|
||||
"description": "Show email and calendar times in this time zone instead of the one reported by your browser",
|
||||
"auto": "Automatic ({zone})",
|
||||
"preview_now": "Now:"
|
||||
"label": "Časové pásmo",
|
||||
"description": "Zobrazovat časy e-mailů a kalendáře v tomto časovém pásmu místo pásma hlášeného prohlížečem",
|
||||
"auto": "Automaticky ({zone})",
|
||||
"preview_now": "Nyní:"
|
||||
}
|
||||
},
|
||||
"email_behavior": {
|
||||
@@ -1626,7 +1642,7 @@
|
||||
"username_label": "Uživatelské jméno",
|
||||
"account_type_label": "Typ účtu",
|
||||
"auth_method_label": "Ověřování",
|
||||
"auth_method_oauth": "Single Sign-On (OAuth/OIDC)",
|
||||
"auth_method_oauth": "Jednotné přihlášení (OAuth/OIDC)",
|
||||
"auth_method_basic": "Heslo",
|
||||
"auth_method_token": "Přístupový token",
|
||||
"demo_account": "Demo účet",
|
||||
@@ -1830,28 +1846,24 @@
|
||||
}
|
||||
},
|
||||
"vacation": {
|
||||
"title": "Automatická odpověď",
|
||||
"description": "Během vaší nepřítomnosti bude automaticky odpovídáno na příchozí e-maily",
|
||||
"title": "Nepřítomnost",
|
||||
"description": "Během vaší nepřítomnosti se na příchozí e-maily bude reagovat automaticky",
|
||||
"loading": "Načítání nastavení automatické odpovědi...",
|
||||
"not_supported": "Váš poštovní server nepodporuje automatické odpovědi v nepřítomnosti.",
|
||||
"fetch_error": "Nepodařilo se načíst nastavení automatické odpovědi. Zkuste to znovu.",
|
||||
"status": {
|
||||
"label": "Automatická odpověď",
|
||||
"label": "Odpovídat automaticky",
|
||||
"description": "Odesílat automatickou odpověď lidem, kteří vám napíší",
|
||||
"active": "Aktivní",
|
||||
"inactive": "Neaktivní"
|
||||
},
|
||||
"date_range": {
|
||||
"title": "Časové období",
|
||||
"description": "Volitelně omezte automatickou odpověď na určité období",
|
||||
"start": "Datum zahájení",
|
||||
"start_description": "Nechte prázdné pro spuštění ihned",
|
||||
"end": "Datum ukončení",
|
||||
"end_description": "Nechte prázdné pro běh bez ukončení"
|
||||
},
|
||||
"message": {
|
||||
"title": "Zpráva automatické odpovědi",
|
||||
"description": "Zpráva, která bude odeslána jako odpověď",
|
||||
"subject_label": "Předmět",
|
||||
"subject_description": "Předmět automatické odpovědi",
|
||||
"subject_placeholder": "Mimo kancelář",
|
||||
@@ -1871,10 +1883,32 @@
|
||||
"saving": "Ukládání...",
|
||||
"warnings": {
|
||||
"end_before_start": "Datum ukončení musí být po datu zahájení",
|
||||
"invalid_date": "Zadejte platné datum zahájení a ukončení včetně času, nebo pole nechte prázdné",
|
||||
"start_in_past": "Datum zahájení je v minulosti",
|
||||
"empty_body": "Tělo zprávy je prázdné - příjemci obdrží prázdnou odpověď",
|
||||
"subject_too_long": "Předmět je pro server příliš dlouhý (nejvýše {max} bajtů).",
|
||||
"body_too_long": "Zpráva je pro server příliš dlouhá (nejvýše {max} bajtů včetně formátování)."
|
||||
"body_too_long": "Zpráva je pro server příliš dlouhá (nejvýše {max} bajtů včetně formátování).",
|
||||
"forward_address": "Zadejte platnou adresu pro přeposílání",
|
||||
"not_running": "Přeposílání nebo příjemci odpovědi jsou uloženi, ale právě nejsou aktivní. Uložením je znovu zapnete."
|
||||
},
|
||||
"forward": {
|
||||
"title": "Přeposílání",
|
||||
"description": "Přeposílá příchozí zprávy na jinou adresu, s automatickou odpovědí i bez ní, v období uvedeném výše (bez data ukončení, dokud ho nevypnete). Spam se nepřeposílá.",
|
||||
"enabled_label": "Přeposílat zprávy",
|
||||
"to_label": "Přeposlat na",
|
||||
"to_placeholder": "email@example.com",
|
||||
"keep_label": "Ponechat v Doručených",
|
||||
"keep_description": "Jinak se zpráva pouze přepošle: nezůstane v této schránce a nepoužije se na ni žádné pravidlo filtru."
|
||||
},
|
||||
"audience": {
|
||||
"label": "Odpovídat",
|
||||
"description": "Interní odesílatelé: {domains}",
|
||||
"all": "Všem odesílatelům",
|
||||
"internal": "Jen interním odesílatelům",
|
||||
"external": "Jen externím odesílatelům"
|
||||
},
|
||||
"auto_reply": {
|
||||
"title": "Automatická odpověď"
|
||||
}
|
||||
},
|
||||
"folders": {
|
||||
@@ -1891,14 +1925,23 @@
|
||||
"role_trash": "Koš",
|
||||
"role_junk": "Spam / Nevyžádaná pošta",
|
||||
"role_archive": "Archiv",
|
||||
"role_scheduled": "Scheduled",
|
||||
"role_snoozed": "Snoozed",
|
||||
"role_scheduled": "Naplánováno",
|
||||
"role_snoozed": "Odloženo",
|
||||
"role_none": "Žádná",
|
||||
"create_folder": "Vytvořit složku",
|
||||
"create_subfolder": "Vytvořit podsložku",
|
||||
"reorder": "Přetažením změníte pořadí",
|
||||
"reorder_error": "Nepodařilo se změnit pořadí složek",
|
||||
"move_error": "Nepodařilo se přesunout složku",
|
||||
"select_folder": "Vybrat {name}",
|
||||
"select_hint": "Vybrat. Shift+kliknutí vybere všechny složky až po naposledy kliknutou.",
|
||||
"selected_count": "{count, plural, one {Vybrána # složka} few {Vybrány # složky} other {Vybráno # složek}}",
|
||||
"move_to": "Přesunout do...",
|
||||
"move_to_top_level": "Nejvyšší úroveň",
|
||||
"move": "Přesunout",
|
||||
"clear_selection": "Zrušit výběr",
|
||||
"folders_moved": "{count, plural, one {Přesunuta # složka} few {Přesunuty # složky} other {Přesunuto # složek}}",
|
||||
"move_partial_error": "{count, plural, one {# složku} few {# složky} other {# složek}} se nepodařilo přesunout",
|
||||
"subfolder_of": "Ve složce {name}",
|
||||
"subfolder_name": "Název podsložky",
|
||||
"new_folder_name": "Název složky",
|
||||
@@ -1965,9 +2008,9 @@
|
||||
"button": "Zobrazit zkratky"
|
||||
},
|
||||
"refresh_cache": {
|
||||
"label": "Refresh cached data",
|
||||
"description": "Reload contacts, calendars, and folders from the server. Keeps your accounts and sessions — fixes a stale or wrong view without signing out.",
|
||||
"button": "Refresh"
|
||||
"label": "Obnovit data z mezipaměti",
|
||||
"description": "Znovu načíst kontakty, kalendáře a složky ze serveru. Účty a přihlášení zůstanou zachovány – opraví zastaralé nebo chybné zobrazení bez odhlášení.",
|
||||
"button": "Obnovit"
|
||||
},
|
||||
"reset_settings": {
|
||||
"label": "Resetovat nastavení",
|
||||
@@ -2243,7 +2286,19 @@
|
||||
"suggest_list": "List-Id",
|
||||
"suggest_domain": "@{domain}",
|
||||
"apply_existing": "Použít i na zprávy, které už v této složce jsou",
|
||||
"apply_existing_unsupported": "Na existující zprávy lze použít jen pravidla, která kontrolují odesílatele, příjemce, předmět nebo hlavičky a která přesouvají, kopírují, označují jako přečtené, hvězdičkou nebo štítkem."
|
||||
"apply_existing_unsupported": "Na existující zprávy lze použít jen pravidla, která kontrolují odesílatele, příjemce, předmět nebo hlavičky a která přesouvají, kopírují, označují jako přečtené, hvězdičkou nebo štítkem.",
|
||||
"period_title": "Časové období",
|
||||
"period_start": "Datum zahájení",
|
||||
"period_end": "Datum ukončení",
|
||||
"validation_period_order": "Datum ukončení musí být po datu zahájení",
|
||||
"period_toggle": "Aktivní jen v určitém období",
|
||||
"period_hint": "Platí pro zprávy, které přijdou v tomto období. Prázdné pole ponechá daný konec otevřený.",
|
||||
"validation_period_empty": "Zadejte datum zahájení nebo ukončení období",
|
||||
"validation_period_invalid": "Zadejte pro období platné datum včetně času, nebo pole nechte prázdné",
|
||||
"apply_existing_period": "Pravidlo s obdobím působí jen na zprávy, které v něm přijdou, a proto ho nelze použít na existující zprávy.",
|
||||
"period_status_scheduled": "naplánováno",
|
||||
"period_status_active": "aktivní",
|
||||
"period_status_expired": "vypršelo"
|
||||
},
|
||||
"templates": {
|
||||
"title": "E-mailové šablony",
|
||||
@@ -2849,11 +2904,11 @@
|
||||
"search_members": "Hledat kontakty k přidání...",
|
||||
"no_members": "Tato skupina nemá žádné členy",
|
||||
"member_count": "{count, plural, =0 {Žádní členové} one {1 člen} few {# členové} other {# členů}}",
|
||||
"send_email": "Send email to group",
|
||||
"send_email": "Poslat e-mail skupině",
|
||||
"send_email_to": "To",
|
||||
"send_email_cc": "Cc",
|
||||
"send_email_bcc": "Bcc",
|
||||
"no_member_emails": "This group has no members with an email address."
|
||||
"no_member_emails": "Tato skupina nemá žádné členy s e-mailovou adresou."
|
||||
},
|
||||
"import": {
|
||||
"title": "Importovat kontakty",
|
||||
@@ -3525,10 +3580,10 @@
|
||||
"duplicate": "Duplikovat",
|
||||
"duplicate_success": "Úspěšně duplikováno",
|
||||
"duplicate_error": "Duplikování selhalo",
|
||||
"share": "Share",
|
||||
"shared": "Shared",
|
||||
"shared_with_me": "Shared with me",
|
||||
"shared_by": "Shared by {name}",
|
||||
"share": "Sdílet",
|
||||
"shared": "Sdíleno",
|
||||
"shared_with_me": "Sdíleno se mnou",
|
||||
"shared_by": "Sdílí {name}",
|
||||
"create_file_success": "Soubor byl vytvořen",
|
||||
"create_file_error": "Vytvoření souboru selhalo",
|
||||
"favorites": "Oblíbené",
|
||||
@@ -3809,7 +3864,7 @@
|
||||
"account_error": "Nepodařilo se prohledat {account}",
|
||||
"recent_searches": "Nedávná hledání",
|
||||
"recurring": "Opakovaná",
|
||||
"no_subject": "(no subject)",
|
||||
"no_subject": "(bez předmětu)",
|
||||
"empty_hint": "Hledejte poštu, kontakty, události a soubory ve všech účtech. Zkuste from:, subject:, has:attachment, after:2026-01-01.",
|
||||
"flat_by_date": "Ploše podle data",
|
||||
"grouped": "Seskupené",
|
||||
|
||||
+68
-13
@@ -348,6 +348,15 @@
|
||||
"external_content_warning": "Billeder og eksternt indhold er blevet blokeret",
|
||||
"load_external_content": "Indlæs billeder",
|
||||
"trust_sender": "Stol altid på denne afsender",
|
||||
"sender_check": {
|
||||
"unverified_label": "Ubekræftet afsender",
|
||||
"failed_label": "Afsenderkontrol mislykkedes",
|
||||
"unverified": "Denne besked udgiver sig for at være fra {domain}, men intet bekræfter det. Den har ingen gyldig signatur fra {domain} og kom ikke fra en server, som {domain} har godkendt.",
|
||||
"unverified_sent_from": "Denne besked udgiver sig for at være fra {domain}, men den blev sendt fra {host} og har ingen gyldig signatur fra {domain}.",
|
||||
"failed": "Denne besked udgiver sig for at være fra {domain}, men den bestod ikke domænets afsenderkontrol. Den kan være forfalsket.",
|
||||
"failed_sent_from": "Denne besked udgiver sig for at være fra {domain}, men den blev sendt fra {host} og bestod ikke afsenderkontrollen for {domain}. Den kan være forfalsket.",
|
||||
"caution": "Vær forsigtig med links, vedhæftede filer og anmodninger om adgangskoder eller betalingsoplysninger."
|
||||
},
|
||||
"back_to_list": "Tilbage til liste",
|
||||
"view_contact": "Vis kontakt",
|
||||
"message_details": "Beskeddetaljer",
|
||||
@@ -630,6 +639,12 @@
|
||||
"attach": "Vedhæft",
|
||||
"attach_photos": "Fotos & videoer",
|
||||
"attach_files": "Filer",
|
||||
"attach_from_files": "Vedhæft fra Filer",
|
||||
"files_picker": {
|
||||
"attach": "{count, plural, =0 {Vedhæft} one {Vedhæft 1 fil} other {Vedhæft # filer}}",
|
||||
"empty_folder": "Denne mappe er tom",
|
||||
"load_failed": "Dine filer kunne ikke indlæses"
|
||||
},
|
||||
"discard": "Kassér",
|
||||
"discard_draft_title": "Kassér kladde?",
|
||||
"discard_draft_confirm": "Du har ugemte ændringer. Vil du kassere denne kladde?",
|
||||
@@ -841,8 +856,8 @@
|
||||
"identity_delete_failed": "Kunne ikke slette identitet: {error}",
|
||||
"identity_unauthorized": "Du er ikke autoriseret til at sende fra denne e-mailadresse",
|
||||
"identity_not_found": "Identitet ikke fundet",
|
||||
"vacation_saved": "Fraværsbesked-indstillinger gemt",
|
||||
"vacation_save_failed": "Kunne ikke gemme fraværsbesked-indstillinger",
|
||||
"vacation_saved": "Fraværsindstillinger gemt",
|
||||
"vacation_save_failed": "Kunne ikke gemme fraværsindstillinger",
|
||||
"filters_saved": "Filtre gemt succesfuldt",
|
||||
"filters_save_failed": "Kunne ikke gemme filtre",
|
||||
"filters_deleted": "Filterregel slettet",
|
||||
@@ -864,7 +879,8 @@
|
||||
"rule_undone": "Regelændring fortrudt",
|
||||
"rule_undo_failed": "Regelændringen kunne ikke fortrydes",
|
||||
"rule_undo_conflict": "Dine filtre er ændret siden. Fortryd ændringen i filterindstillingerne.",
|
||||
"rule_folder_failed": "Mappen kunne ikke oprettes"
|
||||
"rule_folder_failed": "Mappen kunne ikke oprettes",
|
||||
"vacation_filters_save_failed": "Fraværsindstillinger gemt, men videresendelse og svarmodtagere kunne ikke gemmes"
|
||||
},
|
||||
"date": {
|
||||
"today": "I dag",
|
||||
@@ -952,7 +968,7 @@
|
||||
"privacy": "Privatliv & sikkerhed",
|
||||
"account": "Konto",
|
||||
"identities": "Identiteter",
|
||||
"vacation": "Fraværsbeskeder",
|
||||
"vacation": "Fravær",
|
||||
"advanced": "Avanceret",
|
||||
"calendar": "Kalender",
|
||||
"filters": "Filtre",
|
||||
@@ -1833,28 +1849,24 @@
|
||||
}
|
||||
},
|
||||
"vacation": {
|
||||
"title": "Fraværsbeskeder",
|
||||
"description": "Svar automatisk på indkommende e-mails, mens du er væk",
|
||||
"title": "Fravær",
|
||||
"description": "Håndter indkommende e-mails automatisk, mens du er væk",
|
||||
"loading": "Indlæser ferieindstillinger...",
|
||||
"not_supported": "Din mailserver understøtter ikke fraværsbeskeder.",
|
||||
"fetch_error": "Kunne ikke indlæse ferieindstillinger. Prøv igen.",
|
||||
"status": {
|
||||
"label": "Fraværsbeskeder",
|
||||
"label": "Svar automatisk",
|
||||
"description": "Send et automatisk svar til personer, der skriver til dig",
|
||||
"active": "Aktiv",
|
||||
"inactive": "Inaktiv"
|
||||
},
|
||||
"date_range": {
|
||||
"title": "Datoperiode",
|
||||
"description": "Begræns eventuelt auto-svaret til en bestemt periode",
|
||||
"start": "Startdato",
|
||||
"start_description": "Lad være tom for ingen startbegrænsning",
|
||||
"end": "Slutdato",
|
||||
"end_description": "Lad være tom for ingen slutbegrænsning"
|
||||
},
|
||||
"message": {
|
||||
"title": "Auto-svar-besked",
|
||||
"description": "Beskeden der sendes som svar",
|
||||
"subject_label": "Emne",
|
||||
"subject_description": "Emnelinje for auto-svaret",
|
||||
"subject_placeholder": "Fraværende",
|
||||
@@ -1874,10 +1886,32 @@
|
||||
"saving": "Gemmer...",
|
||||
"warnings": {
|
||||
"end_before_start": "Slutdato skal være efter startdato",
|
||||
"invalid_date": "Angiv en gyldig start- og slutdato med klokkeslæt, eller lad feltet stå tomt",
|
||||
"start_in_past": "Startdato er tidligere end i dag",
|
||||
"empty_body": "Beskedteksten er tom - modtagere vil modtage et tomt svar",
|
||||
"subject_too_long": "Emnet er for langt til serveren (højst {max} byte).",
|
||||
"body_too_long": "Beskeden er for lang til serveren (højst {max} byte inkl. formatering)."
|
||||
"body_too_long": "Beskeden er for lang til serveren (højst {max} byte inkl. formatering).",
|
||||
"forward_address": "Angiv en gyldig adresse at videresende til",
|
||||
"not_running": "Videresendelse eller svarmodtagere er gemt, men er ikke aktive lige nu. Gem for at slå dem til igen."
|
||||
},
|
||||
"forward": {
|
||||
"title": "Videresendelse",
|
||||
"description": "Videresender indgående beskeder til en anden adresse, med eller uden fraværsbesked, i perioden ovenfor (uden slutdato, indtil den slås fra). Spam videresendes ikke.",
|
||||
"enabled_label": "Videresend beskeder",
|
||||
"to_label": "Videresend til",
|
||||
"to_placeholder": "email@eksempel.dk",
|
||||
"keep_label": "Behold i indbakke",
|
||||
"keep_description": "Ellers bliver beskeden kun videresendt: Den bliver ikke i denne postkasse, og ingen filterregel køres på den."
|
||||
},
|
||||
"audience": {
|
||||
"label": "Svar til",
|
||||
"description": "Interne afsendere: {domains}",
|
||||
"all": "Alle afsendere",
|
||||
"internal": "Kun interne afsendere",
|
||||
"external": "Kun eksterne afsendere"
|
||||
},
|
||||
"auto_reply": {
|
||||
"title": "Automatisk svar"
|
||||
}
|
||||
},
|
||||
"folders": {
|
||||
@@ -1902,6 +1936,15 @@
|
||||
"reorder": "Træk for at ændre rækkefølgen",
|
||||
"reorder_error": "Kunne ikke ændre rækkefølgen af mapper",
|
||||
"move_error": "Kunne ikke flytte mappen",
|
||||
"select_folder": "Vælg {name}",
|
||||
"select_hint": "Vælg. Shift+klik vælger alle mapper op til den sidste, du klikkede på.",
|
||||
"selected_count": "{count, plural, one {# mappe} other {# mapper}} valgt",
|
||||
"move_to": "Flyt til...",
|
||||
"move_to_top_level": "Øverste niveau",
|
||||
"move": "Flyt",
|
||||
"clear_selection": "Ryd markering",
|
||||
"folders_moved": "{count, plural, one {# mappe flyttet} other {# mapper flyttet}}",
|
||||
"move_partial_error": "{count, plural, one {# mappe} other {# mapper}} kunne ikke flyttes",
|
||||
"subfolder_of": "Inde i {name}",
|
||||
"subfolder_name": "Undermappenavn",
|
||||
"new_folder_name": "Mappenavn",
|
||||
@@ -2246,7 +2289,19 @@
|
||||
"suggest_list": "List-Id",
|
||||
"suggest_domain": "@{domain}",
|
||||
"apply_existing": "Anvend også på beskeder, der allerede ligger i denne mappe",
|
||||
"apply_existing_unsupported": "Kun regler, der tjekker afsender, modtagere, emne eller headere, og som flytter, kopierer, markerer som læst, stjernemarkerer eller tagger, kan anvendes på eksisterende beskeder."
|
||||
"apply_existing_unsupported": "Kun regler, der tjekker afsender, modtagere, emne eller headere, og som flytter, kopierer, markerer som læst, stjernemarkerer eller tagger, kan anvendes på eksisterende beskeder.",
|
||||
"period_title": "Datoperiode",
|
||||
"period_start": "Startdato",
|
||||
"period_end": "Slutdato",
|
||||
"validation_period_order": "Slutdato skal være efter startdato",
|
||||
"period_toggle": "Kun aktiv i en periode",
|
||||
"period_hint": "Gælder for beskeder, der ankommer i denne periode. Lad et felt stå tomt for at holde den ende åben.",
|
||||
"validation_period_empty": "Angiv en start- eller slutdato for perioden",
|
||||
"validation_period_invalid": "Angiv en gyldig dato med klokkeslæt for perioden, eller lad feltet stå tomt",
|
||||
"apply_existing_period": "En regel med en periode virker kun på beskeder, der ankommer i perioden, og kan derfor ikke køres på eksisterende beskeder.",
|
||||
"period_status_scheduled": "planlagt",
|
||||
"period_status_active": "aktiv",
|
||||
"period_status_expired": "udløbet"
|
||||
},
|
||||
"templates": {
|
||||
"title": "E-mail-skabeloner",
|
||||
|
||||
+68
-13
@@ -348,6 +348,15 @@
|
||||
"external_content_warning": "Bilder und externe Inhalte wurden blockiert",
|
||||
"load_external_content": "Bilder laden",
|
||||
"trust_sender": "Diesem Absender immer vertrauen",
|
||||
"sender_check": {
|
||||
"unverified_label": "Absender nicht bestätigt",
|
||||
"failed_label": "Absenderprüfung fehlgeschlagen",
|
||||
"unverified": "Diese Nachricht gibt vor, von {domain} zu kommen, aber nichts bestätigt das. Sie trägt keine gültige Signatur von {domain} und kam nicht von einem Server, den {domain} zulässt.",
|
||||
"unverified_sent_from": "Diese Nachricht gibt vor, von {domain} zu kommen, wurde aber von {host} verschickt und trägt keine gültige Signatur von {domain}.",
|
||||
"failed": "Diese Nachricht gibt vor, von {domain} zu kommen, hat aber die Absenderprüfung dieser Domain nicht bestanden. Sie ist möglicherweise gefälscht.",
|
||||
"failed_sent_from": "Diese Nachricht gibt vor, von {domain} zu kommen, wurde aber von {host} verschickt und hat die Absenderprüfung von {domain} nicht bestanden. Sie ist möglicherweise gefälscht.",
|
||||
"caution": "Seien Sie vorsichtig mit Links, Anhängen und Fragen nach Passwörtern oder Zahlungsdaten."
|
||||
},
|
||||
"back_to_list": "Zurück zur Liste",
|
||||
"view_contact": "Kontakt anzeigen",
|
||||
"message_details": "Nachrichtendetails",
|
||||
@@ -630,6 +639,12 @@
|
||||
"attach": "Anhängen",
|
||||
"attach_photos": "Fotos & Videos",
|
||||
"attach_files": "Dateien",
|
||||
"attach_from_files": "Aus Dateien anhängen",
|
||||
"files_picker": {
|
||||
"attach": "{count, plural, =0 {Anhängen} one {1 Datei anhängen} other {# Dateien anhängen}}",
|
||||
"empty_folder": "Dieser Ordner ist leer",
|
||||
"load_failed": "Ihre Dateien konnten nicht geladen werden"
|
||||
},
|
||||
"discard": "Verwerfen",
|
||||
"discard_draft_title": "Entwurf verwerfen?",
|
||||
"discard_draft_confirm": "Sie haben ungespeicherte Änderungen. Möchten Sie diesen Entwurf verwerfen?",
|
||||
@@ -841,8 +856,8 @@
|
||||
"identity_delete_failed": "Identität löschen fehlgeschlagen: {error}",
|
||||
"identity_unauthorized": "Sie sind nicht autorisiert, von dieser E-Mail-Adresse zu senden",
|
||||
"identity_not_found": "Identität nicht gefunden",
|
||||
"vacation_saved": "Abwesenheitsnotiz-Einstellungen gespeichert",
|
||||
"vacation_save_failed": "Fehler beim Speichern der Abwesenheitsnotiz-Einstellungen",
|
||||
"vacation_saved": "Abwesenheits-Einstellungen gespeichert",
|
||||
"vacation_save_failed": "Fehler beim Speichern der Abwesenheits-Einstellungen",
|
||||
"filters_saved": "Filter erfolgreich gespeichert",
|
||||
"filters_save_failed": "Fehler beim Speichern der Filter",
|
||||
"filters_deleted": "Filterregel gelöscht",
|
||||
@@ -864,7 +879,8 @@
|
||||
"rule_undone": "Regeländerung rückgängig gemacht",
|
||||
"rule_undo_failed": "Die Regeländerung konnte nicht rückgängig gemacht werden",
|
||||
"rule_undo_conflict": "Ihre Filter wurden inzwischen geändert. Machen Sie die Änderung in den Filter-Einstellungen rückgängig.",
|
||||
"rule_folder_failed": "Der Ordner konnte nicht erstellt werden"
|
||||
"rule_folder_failed": "Der Ordner konnte nicht erstellt werden",
|
||||
"vacation_filters_save_failed": "Abwesenheits-Einstellungen gespeichert, Weiterleitung und Antwortempfänger konnten aber nicht gespeichert werden"
|
||||
},
|
||||
"date": {
|
||||
"today": "Heute",
|
||||
@@ -949,7 +965,7 @@
|
||||
"privacy": "Datenschutz & Sicherheit",
|
||||
"account": "Konto",
|
||||
"identities": "Identitäten",
|
||||
"vacation": "Abwesenheitsnotiz",
|
||||
"vacation": "Abwesenheit",
|
||||
"advanced": "Erweitert",
|
||||
"calendar": "Kalender",
|
||||
"filters": "Filter",
|
||||
@@ -1830,28 +1846,24 @@
|
||||
}
|
||||
},
|
||||
"vacation": {
|
||||
"title": "Abwesenheitsnotiz",
|
||||
"description": "Automatisch auf eingehende E-Mails antworten, während Sie abwesend sind",
|
||||
"title": "Abwesenheit",
|
||||
"description": "Automatisch auf eingehende E-Mails reagieren, während Sie abwesend sind",
|
||||
"loading": "Abwesenheitseinstellungen werden geladen...",
|
||||
"not_supported": "Ihr Mailserver unterstützt keine Abwesenheitsantworten.",
|
||||
"fetch_error": "Fehler beim Laden der Abwesenheitseinstellungen. Bitte versuchen Sie es erneut.",
|
||||
"status": {
|
||||
"label": "Abwesenheitsnotiz",
|
||||
"label": "Automatisch antworten",
|
||||
"description": "Senden Sie eine automatische Antwort an Personen, die Ihnen eine E-Mail senden",
|
||||
"active": "Aktiv",
|
||||
"inactive": "Inaktiv"
|
||||
},
|
||||
"date_range": {
|
||||
"title": "Zeitraum",
|
||||
"description": "Optional die automatische Antwort auf einen bestimmten Zeitraum beschränken",
|
||||
"start": "Startdatum",
|
||||
"start_description": "Leer lassen für kein Startlimit",
|
||||
"end": "Enddatum",
|
||||
"end_description": "Leer lassen für kein Endlimit"
|
||||
},
|
||||
"message": {
|
||||
"title": "Automatische Antwortnachricht",
|
||||
"description": "Die Nachricht, die als Antwort gesendet wird",
|
||||
"subject_label": "Betreff",
|
||||
"subject_description": "Betreffzeile der automatischen Antwort",
|
||||
"subject_placeholder": "Abwesenheitsnotiz",
|
||||
@@ -1871,10 +1883,32 @@
|
||||
"saving": "Speichern...",
|
||||
"warnings": {
|
||||
"end_before_start": "Das Enddatum muss nach dem Startdatum liegen",
|
||||
"invalid_date": "Bitte ein gültiges Start- und Enddatum mit Uhrzeit angeben oder das Feld leer lassen",
|
||||
"start_in_past": "Das Startdatum liegt in der Vergangenheit",
|
||||
"empty_body": "Der Nachrichtentext ist leer - Empfänger erhalten eine leere Antwort",
|
||||
"subject_too_long": "Der Betreff ist für den Server zu lang (höchstens {max} Byte).",
|
||||
"body_too_long": "Die Nachricht ist für den Server zu lang (höchstens {max} Byte, inklusive Formatierung)."
|
||||
"body_too_long": "Die Nachricht ist für den Server zu lang (höchstens {max} Byte, inklusive Formatierung).",
|
||||
"forward_address": "Bitte eine gültige Adresse für die Weiterleitung angeben",
|
||||
"not_running": "Weiterleitung oder Antwortempfänger sind gespeichert, aber gerade nicht aktiv. Speichern aktiviert sie wieder."
|
||||
},
|
||||
"forward": {
|
||||
"title": "Weiterleitung",
|
||||
"description": "Leitet eingehende Nachrichten an eine andere Adresse weiter, mit oder ohne automatische Antwort, im Zeitraum oben (ohne Enddatum, bis sie ausgeschaltet wird). Spam wird nicht weitergeleitet.",
|
||||
"enabled_label": "Nachrichten weiterleiten",
|
||||
"to_label": "Weiterleiten an",
|
||||
"to_placeholder": "email@beispiel.de",
|
||||
"keep_label": "Im Posteingang behalten",
|
||||
"keep_description": "Sonst wird die Nachricht nur weitergeleitet: Sie bleibt nicht in diesem Postfach, und keine Filterregel läuft für sie."
|
||||
},
|
||||
"audience": {
|
||||
"label": "Antworten an",
|
||||
"description": "Interne Absender: {domains}",
|
||||
"all": "Alle Absender",
|
||||
"internal": "Nur interne Absender",
|
||||
"external": "Nur externe Absender"
|
||||
},
|
||||
"auto_reply": {
|
||||
"title": "Automatische Antwort"
|
||||
}
|
||||
},
|
||||
"folders": {
|
||||
@@ -1899,6 +1933,15 @@
|
||||
"reorder": "Zum Neuanordnen ziehen",
|
||||
"reorder_error": "Ordner konnten nicht neu angeordnet werden",
|
||||
"move_error": "Ordner konnte nicht verschoben werden",
|
||||
"select_folder": "{name} auswählen",
|
||||
"select_hint": "Auswählen. Umschalt+Klick wählt alle Ordner bis zum zuletzt angeklickten aus.",
|
||||
"selected_count": "{count, plural, one {# Ordner} other {# Ordner}} ausgewählt",
|
||||
"move_to": "Verschieben nach...",
|
||||
"move_to_top_level": "Oberste Ebene",
|
||||
"move": "Verschieben",
|
||||
"clear_selection": "Auswahl aufheben",
|
||||
"folders_moved": "{count, plural, one {# Ordner verschoben} other {# Ordner verschoben}}",
|
||||
"move_partial_error": "{count, plural, one {# Ordner konnte} other {# Ordner konnten}} nicht verschoben werden",
|
||||
"subfolder_of": "In {name}",
|
||||
"subfolder_name": "Name des Unterordners",
|
||||
"new_folder_name": "Ordnername",
|
||||
@@ -2243,7 +2286,19 @@
|
||||
"suggest_list": "List-Id",
|
||||
"suggest_domain": "@{domain}",
|
||||
"apply_existing": "Auch auf vorhandene Nachrichten in diesem Ordner anwenden",
|
||||
"apply_existing_unsupported": "Nur Regeln, die Absender, Empfänger, Betreff oder Kopfzeilen prüfen und verschieben, kopieren, als gelesen markieren, mit Stern markieren oder mit Label versehen, lassen sich auf vorhandene Nachrichten anwenden."
|
||||
"apply_existing_unsupported": "Nur Regeln, die Absender, Empfänger, Betreff oder Kopfzeilen prüfen und verschieben, kopieren, als gelesen markieren, mit Stern markieren oder mit Label versehen, lassen sich auf vorhandene Nachrichten anwenden.",
|
||||
"period_title": "Zeitraum",
|
||||
"period_start": "Startdatum",
|
||||
"period_end": "Enddatum",
|
||||
"validation_period_order": "Das Enddatum muss nach dem Startdatum liegen",
|
||||
"period_toggle": "Nur in einem Zeitraum aktiv",
|
||||
"period_hint": "Gilt für Nachrichten, die in diesem Zeitraum eintreffen. Ein leeres Feld lässt den Zeitraum an dieser Seite offen.",
|
||||
"validation_period_empty": "Bitte ein Start- oder Enddatum für den Zeitraum angeben",
|
||||
"validation_period_invalid": "Bitte für den Zeitraum ein gültiges Datum mit Uhrzeit angeben oder das Feld leer lassen",
|
||||
"apply_existing_period": "Eine Regel mit Zeitraum wirkt nur auf Nachrichten, die in diesem Zeitraum eintreffen, und lässt sich daher nicht auf vorhandene Nachrichten anwenden.",
|
||||
"period_status_scheduled": "geplant",
|
||||
"period_status_active": "aktiv",
|
||||
"period_status_expired": "abgelaufen"
|
||||
},
|
||||
"templates": {
|
||||
"title": "E-Mail-Vorlagen",
|
||||
|
||||
+68
-13
@@ -348,6 +348,15 @@
|
||||
"external_content_warning": "Images and external content have been blocked",
|
||||
"load_external_content": "Load images",
|
||||
"trust_sender": "Always trust this sender",
|
||||
"sender_check": {
|
||||
"unverified_label": "Unverified sender",
|
||||
"failed_label": "Sender check failed",
|
||||
"unverified": "This message claims to be from {domain}, but nothing confirms that. It has no valid signature from {domain} and didn't come from a server {domain} authorizes.",
|
||||
"unverified_sent_from": "This message claims to be from {domain}, but it was sent from {host} and has no valid signature from {domain}.",
|
||||
"failed": "This message claims to be from {domain}, but it failed that domain's sender checks. It may be forged.",
|
||||
"failed_sent_from": "This message claims to be from {domain}, but it was sent from {host} and failed {domain}'s sender checks. It may be forged.",
|
||||
"caution": "Be careful with links, attachments and requests for passwords or payment details."
|
||||
},
|
||||
"back_to_list": "Back to list",
|
||||
"view_contact": "View contact",
|
||||
"message_details": "Message Details",
|
||||
@@ -630,6 +639,12 @@
|
||||
"attach": "Attach",
|
||||
"attach_photos": "Photos & Videos",
|
||||
"attach_files": "Files",
|
||||
"attach_from_files": "Attach from Files",
|
||||
"files_picker": {
|
||||
"attach": "{count, plural, =0 {Attach} one {Attach 1 file} other {Attach # files}}",
|
||||
"empty_folder": "This folder is empty",
|
||||
"load_failed": "Your files could not be loaded"
|
||||
},
|
||||
"discard": "Discard",
|
||||
"discard_draft_title": "Discard draft?",
|
||||
"discard_draft_confirm": "You have unsaved changes. Do you want to discard this draft?",
|
||||
@@ -841,8 +856,8 @@
|
||||
"identity_delete_failed": "Failed to delete identity: {error}",
|
||||
"identity_unauthorized": "You are not authorized to send from this email address",
|
||||
"identity_not_found": "Identity not found",
|
||||
"vacation_saved": "Vacation responder settings saved",
|
||||
"vacation_save_failed": "Failed to save vacation responder settings",
|
||||
"vacation_saved": "Out of office settings saved",
|
||||
"vacation_save_failed": "Failed to save out of office settings",
|
||||
"filters_saved": "Filters saved successfully",
|
||||
"filters_save_failed": "Failed to save filters",
|
||||
"filters_deleted": "Filter rule deleted",
|
||||
@@ -864,7 +879,8 @@
|
||||
"rule_undone": "Rule change undone",
|
||||
"rule_undo_failed": "Could not undo the rule change",
|
||||
"rule_undo_conflict": "Your filters have changed since. Undo the change in Filters settings.",
|
||||
"rule_folder_failed": "Could not create the folder"
|
||||
"rule_folder_failed": "Could not create the folder",
|
||||
"vacation_filters_save_failed": "Out of office settings saved, but the forwarding and reply recipients could not be saved"
|
||||
},
|
||||
"date": {
|
||||
"today": "Today",
|
||||
@@ -952,7 +968,7 @@
|
||||
"privacy": "Privacy & Security",
|
||||
"account": "Account",
|
||||
"identities": "Identities",
|
||||
"vacation": "Vacation Responder",
|
||||
"vacation": "Out of Office",
|
||||
"advanced": "Advanced",
|
||||
"calendar": "Calendar",
|
||||
"filters": "Filters",
|
||||
@@ -1837,28 +1853,24 @@
|
||||
}
|
||||
},
|
||||
"vacation": {
|
||||
"title": "Vacation Responder",
|
||||
"description": "Automatically reply to incoming emails while you're away",
|
||||
"title": "Out of Office",
|
||||
"description": "Automatically handle incoming emails while you're away",
|
||||
"loading": "Loading vacation settings...",
|
||||
"not_supported": "Your mail server does not support vacation responses.",
|
||||
"fetch_error": "Failed to load vacation settings. Please try again.",
|
||||
"status": {
|
||||
"label": "Vacation Responder",
|
||||
"label": "Reply automatically",
|
||||
"description": "Send an automatic reply to people who email you",
|
||||
"active": "Active",
|
||||
"inactive": "Inactive"
|
||||
},
|
||||
"date_range": {
|
||||
"title": "Date Range",
|
||||
"description": "Optionally limit the auto-reply to a specific period",
|
||||
"start": "Start Date",
|
||||
"start_description": "Leave empty for no start limit",
|
||||
"end": "End Date",
|
||||
"end_description": "Leave empty for no end limit"
|
||||
},
|
||||
"message": {
|
||||
"title": "Auto-Reply Message",
|
||||
"description": "The message that will be sent as a reply",
|
||||
"subject_label": "Subject",
|
||||
"subject_description": "Subject line of the auto-reply",
|
||||
"subject_placeholder": "Out of Office",
|
||||
@@ -1878,10 +1890,32 @@
|
||||
"saving": "Saving...",
|
||||
"warnings": {
|
||||
"end_before_start": "End date must be after start date",
|
||||
"invalid_date": "Enter a valid start and end date with a time, or leave the field empty",
|
||||
"start_in_past": "Start date is in the past",
|
||||
"empty_body": "Message body is empty - recipients will receive a blank reply",
|
||||
"subject_too_long": "The subject is too long for the server (at most {max} bytes).",
|
||||
"body_too_long": "The message is too long for the server (at most {max} bytes, formatting included)."
|
||||
"body_too_long": "The message is too long for the server (at most {max} bytes, formatting included).",
|
||||
"forward_address": "Enter a valid address to forward to",
|
||||
"not_running": "Forwarding or the reply recipients are saved but not active right now. Save to turn them back on."
|
||||
},
|
||||
"forward": {
|
||||
"title": "Forwarding",
|
||||
"description": "Passes incoming messages on to another address, with or without the auto-reply, within the period above (without an end date, until switched off). Spam is not forwarded.",
|
||||
"enabled_label": "Forward messages",
|
||||
"to_label": "Forward to",
|
||||
"to_placeholder": "email@example.com",
|
||||
"keep_label": "Keep in inbox",
|
||||
"keep_description": "Otherwise the message is only forwarded: it is not kept in this mailbox, and no filter rule runs on it."
|
||||
},
|
||||
"audience": {
|
||||
"label": "Reply to",
|
||||
"description": "Internal senders: {domains}",
|
||||
"all": "All senders",
|
||||
"internal": "Internal senders only",
|
||||
"external": "External senders only"
|
||||
},
|
||||
"auto_reply": {
|
||||
"title": "Automatic Reply"
|
||||
}
|
||||
},
|
||||
"folders": {
|
||||
@@ -1906,6 +1940,15 @@
|
||||
"reorder": "Drag up or down to reorder, left or right to change the parent folder",
|
||||
"reorder_error": "Failed to reorder folders",
|
||||
"move_error": "Failed to move folder",
|
||||
"select_folder": "Select {name}",
|
||||
"select_hint": "Select. Shift+click selects every folder up to the last one you clicked.",
|
||||
"selected_count": "{count, plural, one {# folder} other {# folders}} selected",
|
||||
"move_to": "Move to...",
|
||||
"move_to_top_level": "Top level",
|
||||
"move": "Move",
|
||||
"clear_selection": "Clear selection",
|
||||
"folders_moved": "{count, plural, one {# folder moved} other {# folders moved}}",
|
||||
"move_partial_error": "{count, plural, one {# folder} other {# folders}} could not be moved",
|
||||
"subfolder_of": "Inside {name}",
|
||||
"subfolder_name": "Subfolder name",
|
||||
"new_folder_name": "Folder name",
|
||||
@@ -2250,7 +2293,19 @@
|
||||
"suggest_list": "List-Id",
|
||||
"suggest_domain": "@{domain}",
|
||||
"apply_existing": "Also apply to existing messages in this folder",
|
||||
"apply_existing_unsupported": "Only rules that check the sender, recipients, subject or headers, and that move, copy, mark as read, star or tag, can run on existing messages."
|
||||
"apply_existing_unsupported": "Only rules that check the sender, recipients, subject or headers, and that move, copy, mark as read, star or tag, can run on existing messages.",
|
||||
"period_title": "Date Range",
|
||||
"period_start": "Start Date",
|
||||
"period_end": "End Date",
|
||||
"validation_period_order": "End date must be after start date",
|
||||
"period_toggle": "Only active during a period",
|
||||
"period_hint": "Applies to messages that arrive during this period. Leave a field empty to keep that end open.",
|
||||
"validation_period_empty": "Enter a start or end date for the period",
|
||||
"validation_period_invalid": "Enter a valid date with a time for the period, or leave the field empty",
|
||||
"apply_existing_period": "A rule with a period acts only on messages that arrive within it, so it cannot run on existing messages.",
|
||||
"period_status_scheduled": "scheduled",
|
||||
"period_status_active": "active",
|
||||
"period_status_expired": "expired"
|
||||
},
|
||||
"templates": {
|
||||
"title": "Email Templates",
|
||||
|
||||
+68
-13
@@ -348,6 +348,15 @@
|
||||
"external_content_warning": "Las imágenes y el contenido externo han sido bloqueados",
|
||||
"load_external_content": "Cargar imágenes",
|
||||
"trust_sender": "Confiar siempre en este remitente",
|
||||
"sender_check": {
|
||||
"unverified_label": "Remitente no verificado",
|
||||
"failed_label": "Falló la verificación del remitente",
|
||||
"unverified": "Este mensaje dice proceder de {domain}, pero nada lo confirma. No tiene una firma válida de {domain} ni procede de un servidor autorizado por {domain}.",
|
||||
"unverified_sent_from": "Este mensaje dice proceder de {domain}, pero se envió desde {host} y no tiene una firma válida de {domain}.",
|
||||
"failed": "Este mensaje dice proceder de {domain}, pero no superó las comprobaciones de remitente de ese dominio. Podría estar falsificado.",
|
||||
"failed_sent_from": "Este mensaje dice proceder de {domain}, pero se envió desde {host} y no superó las comprobaciones de remitente de {domain}. Podría estar falsificado.",
|
||||
"caution": "Tenga cuidado con los enlaces, los archivos adjuntos y las solicitudes de contraseñas o datos de pago."
|
||||
},
|
||||
"back_to_list": "Volver a la lista",
|
||||
"view_contact": "Ver contacto",
|
||||
"message_details": "Detalles del Mensaje",
|
||||
@@ -630,6 +639,12 @@
|
||||
"attach": "Adjuntar",
|
||||
"attach_photos": "Photos & Videos",
|
||||
"attach_files": "Files",
|
||||
"attach_from_files": "Adjuntar desde Archivos",
|
||||
"files_picker": {
|
||||
"attach": "{count, plural, =0 {Adjuntar} one {Adjuntar 1 archivo} other {Adjuntar # archivos}}",
|
||||
"empty_folder": "Esta carpeta está vacía",
|
||||
"load_failed": "No se pudieron cargar tus archivos"
|
||||
},
|
||||
"discard": "Descartar",
|
||||
"discard_draft_title": "¿Descartar borrador?",
|
||||
"discard_draft_confirm": "Tiene cambios sin guardar. ¿Desea descartar este borrador?",
|
||||
@@ -841,8 +856,8 @@
|
||||
"identity_delete_failed": "Error al eliminar identidad: {error}",
|
||||
"identity_unauthorized": "No está autorizado para enviar desde esta dirección de correo",
|
||||
"identity_not_found": "Identidad no encontrada",
|
||||
"vacation_saved": "Configuración de respuesta automática guardada",
|
||||
"vacation_save_failed": "Error al guardar la configuración de respuesta automática",
|
||||
"vacation_saved": "Configuración de ausencia guardada",
|
||||
"vacation_save_failed": "Error al guardar la configuración de ausencia",
|
||||
"filters_saved": "Filtros guardados correctamente",
|
||||
"filters_save_failed": "Error al guardar los filtros",
|
||||
"filters_deleted": "Regla de filtrado eliminada",
|
||||
@@ -864,7 +879,8 @@
|
||||
"rule_undone": "Cambio de regla deshecho",
|
||||
"rule_undo_failed": "No se pudo deshacer el cambio de la regla",
|
||||
"rule_undo_conflict": "Tus filtros han cambiado desde entonces. Deshaz el cambio en la configuración de filtros.",
|
||||
"rule_folder_failed": "No se pudo crear la carpeta"
|
||||
"rule_folder_failed": "No se pudo crear la carpeta",
|
||||
"vacation_filters_save_failed": "Configuración de ausencia guardada, pero no se pudieron guardar el reenvío ni los destinatarios de la respuesta"
|
||||
},
|
||||
"date": {
|
||||
"today": "Hoy",
|
||||
@@ -949,7 +965,7 @@
|
||||
"privacy": "Privacidad y Seguridad",
|
||||
"account": "Cuenta",
|
||||
"identities": "Identidades",
|
||||
"vacation": "Respuesta automática",
|
||||
"vacation": "Ausencia",
|
||||
"advanced": "Avanzado",
|
||||
"calendar": "Calendario",
|
||||
"filters": "Filtros",
|
||||
@@ -1830,28 +1846,24 @@
|
||||
}
|
||||
},
|
||||
"vacation": {
|
||||
"title": "Respuesta automática",
|
||||
"description": "Responder automáticamente a los correos entrantes mientras está ausente",
|
||||
"title": "Ausencia",
|
||||
"description": "Gestionar automáticamente los correos entrantes mientras está ausente",
|
||||
"loading": "Cargando configuración de respuesta automática...",
|
||||
"not_supported": "Su servidor de correo no admite respuestas automáticas de ausencia.",
|
||||
"fetch_error": "Error al cargar la configuración de respuesta automática. Inténtelo de nuevo.",
|
||||
"status": {
|
||||
"label": "Respuesta automática",
|
||||
"label": "Responder automáticamente",
|
||||
"description": "Enviar una respuesta automática a quienes le envíen un correo",
|
||||
"active": "Activo",
|
||||
"inactive": "Inactivo"
|
||||
},
|
||||
"date_range": {
|
||||
"title": "Período",
|
||||
"description": "Opcionalmente limitar la respuesta automática a un período específico",
|
||||
"start": "Fecha de inicio",
|
||||
"start_description": "Dejar vacío para sin límite de inicio",
|
||||
"end": "Fecha de fin",
|
||||
"end_description": "Dejar vacío para sin límite de fin"
|
||||
},
|
||||
"message": {
|
||||
"title": "Mensaje de respuesta automática",
|
||||
"description": "El mensaje que se enviará como respuesta",
|
||||
"subject_label": "Asunto",
|
||||
"subject_description": "Línea de asunto de la respuesta automática",
|
||||
"subject_placeholder": "Fuera de la oficina",
|
||||
@@ -1871,10 +1883,32 @@
|
||||
"saving": "Guardando...",
|
||||
"warnings": {
|
||||
"end_before_start": "La fecha de fin debe ser posterior a la fecha de inicio",
|
||||
"invalid_date": "Introduce una fecha de inicio y de fin válidas con hora o deja el campo vacío",
|
||||
"start_in_past": "La fecha de inicio está en el pasado",
|
||||
"empty_body": "El cuerpo del mensaje está vacío - los destinatarios recibirán una respuesta en blanco",
|
||||
"subject_too_long": "El asunto es demasiado largo para el servidor (máximo {max} bytes).",
|
||||
"body_too_long": "El mensaje es demasiado largo para el servidor (máximo {max} bytes, formato incluido)."
|
||||
"body_too_long": "El mensaje es demasiado largo para el servidor (máximo {max} bytes, formato incluido).",
|
||||
"forward_address": "Introduce una dirección válida para el reenvío",
|
||||
"not_running": "El reenvío o los destinatarios de la respuesta están guardados, pero ahora no están activos. Guarda para volver a activarlos."
|
||||
},
|
||||
"forward": {
|
||||
"title": "Reenvío",
|
||||
"description": "Reenvía los mensajes entrantes a otra dirección, con o sin respuesta automática, dentro del período indicado arriba (sin fecha de fin, hasta que se desactive). El correo no deseado no se reenvía.",
|
||||
"enabled_label": "Reenviar mensajes",
|
||||
"to_label": "Reenviar a",
|
||||
"to_placeholder": "correo@ejemplo.com",
|
||||
"keep_label": "Mantener en la bandeja de entrada",
|
||||
"keep_description": "Si no, el mensaje solo se reenvía: no se conserva en este buzón y no se le aplica ninguna regla de filtro."
|
||||
},
|
||||
"audience": {
|
||||
"label": "Responder a",
|
||||
"description": "Remitentes internos: {domains}",
|
||||
"all": "Todos los remitentes",
|
||||
"internal": "Solo remitentes internos",
|
||||
"external": "Solo remitentes externos"
|
||||
},
|
||||
"auto_reply": {
|
||||
"title": "Respuesta automática"
|
||||
}
|
||||
},
|
||||
"folders": {
|
||||
@@ -1899,6 +1933,15 @@
|
||||
"reorder": "Arrastra para reordenar",
|
||||
"reorder_error": "No se pudieron reordenar las carpetas",
|
||||
"move_error": "No se pudo mover la carpeta",
|
||||
"select_folder": "Seleccionar {name}",
|
||||
"select_hint": "Seleccionar. Mayús+clic selecciona todas las carpetas hasta la última en la que hiciste clic.",
|
||||
"selected_count": "{count, plural, one {# carpeta seleccionada} other {# carpetas seleccionadas}}",
|
||||
"move_to": "Mover a...",
|
||||
"move_to_top_level": "Nivel superior",
|
||||
"move": "Mover",
|
||||
"clear_selection": "Borrar selección",
|
||||
"folders_moved": "{count, plural, one {# carpeta movida} other {# carpetas movidas}}",
|
||||
"move_partial_error": "{count, plural, one {No se pudo mover # carpeta} other {No se pudieron mover # carpetas}}",
|
||||
"subfolder_of": "Dentro de {name}",
|
||||
"subfolder_name": "Nombre de la subcarpeta",
|
||||
"new_folder_name": "Nombre de la carpeta",
|
||||
@@ -2243,7 +2286,19 @@
|
||||
"suggest_list": "List-Id",
|
||||
"suggest_domain": "@{domain}",
|
||||
"apply_existing": "Aplicar también a los mensajes que ya hay en esta carpeta",
|
||||
"apply_existing_unsupported": "Solo las reglas que comprueban el remitente, los destinatarios, el asunto o los encabezados, y que mueven, copian, marcan como leído, destacan o etiquetan, se pueden aplicar a mensajes existentes."
|
||||
"apply_existing_unsupported": "Solo las reglas que comprueban el remitente, los destinatarios, el asunto o los encabezados, y que mueven, copian, marcan como leído, destacan o etiquetan, se pueden aplicar a mensajes existentes.",
|
||||
"period_title": "Período",
|
||||
"period_start": "Fecha de inicio",
|
||||
"period_end": "Fecha de fin",
|
||||
"validation_period_order": "La fecha de fin debe ser posterior a la fecha de inicio",
|
||||
"period_toggle": "Activa solo durante un período",
|
||||
"period_hint": "Se aplica a los mensajes que llegan durante este período. Deja un campo vacío para no limitar ese extremo.",
|
||||
"validation_period_empty": "Introduce una fecha de inicio o de fin para el período",
|
||||
"validation_period_invalid": "Introduce una fecha válida con hora para el período o deja el campo vacío",
|
||||
"apply_existing_period": "Una regla con período solo actúa sobre los mensajes que llegan dentro de él, por lo que no se puede aplicar a los mensajes existentes.",
|
||||
"period_status_scheduled": "programada",
|
||||
"period_status_active": "activa",
|
||||
"period_status_expired": "caducada"
|
||||
},
|
||||
"templates": {
|
||||
"title": "Plantillas de correo",
|
||||
|
||||
+71
-16
@@ -348,6 +348,15 @@
|
||||
"external_content_warning": "تصاویر و محتوای خارجی مسدود شدهاند",
|
||||
"load_external_content": "بارگذاری تصاویر",
|
||||
"trust_sender": "همیشه به این فرستنده اعتماد کن",
|
||||
"sender_check": {
|
||||
"unverified_label": "فرستنده تأییدنشده",
|
||||
"failed_label": "بررسی فرستنده ناموفق بود",
|
||||
"unverified": "این پیام ادعا میکند از {domain} است، اما هیچ چیزی این را تأیید نمیکند. امضای معتبری از {domain} ندارد و از سروری که {domain} مجاز دانسته ارسال نشده است.",
|
||||
"unverified_sent_from": "این پیام ادعا میکند از {domain} است، اما از {host} ارسال شده و امضای معتبری از {domain} ندارد.",
|
||||
"failed": "این پیام ادعا میکند از {domain} است، اما در بررسیهای فرستنده برای این دامنه رد شده است. ممکن است جعلی باشد.",
|
||||
"failed_sent_from": "این پیام ادعا میکند از {domain} است، اما از {host} ارسال شده و در بررسیهای فرستنده برای {domain} رد شده است. ممکن است جعلی باشد.",
|
||||
"caution": "مراقب پیوندها، پیوستها و درخواستهای رمز عبور یا اطلاعات پرداخت باشید."
|
||||
},
|
||||
"back_to_list": "بازگشت به لیست",
|
||||
"view_contact": "مشاهده مخاطب",
|
||||
"message_details": "جزئیات پیام",
|
||||
@@ -630,6 +639,12 @@
|
||||
"attach": "پیوست",
|
||||
"attach_photos": "عکس و ویدیو",
|
||||
"attach_files": "فایلها",
|
||||
"attach_from_files": "پیوست از فایلها",
|
||||
"files_picker": {
|
||||
"attach": "{count, plural, =0 {پیوست} one {پیوست ۱ فایل} other {پیوست # فایل}}",
|
||||
"empty_folder": "این پوشه خالی است",
|
||||
"load_failed": "فایلهای شما بارگیری نشد"
|
||||
},
|
||||
"discard": "دور انداختن",
|
||||
"discard_draft_title": "دور انداختن پیشنویس؟",
|
||||
"discard_draft_confirm": "تغییرات ذخیره نشده دارید. پیشنویس دور انداخته شود؟",
|
||||
@@ -841,8 +856,8 @@
|
||||
"identity_delete_failed": "حذف هویت ناموفق بود: {error}",
|
||||
"identity_unauthorized": "شما مجاز به ارسال از این آدرس ایمیل نیستید",
|
||||
"identity_not_found": "هویت یافت نشد",
|
||||
"vacation_saved": "تنظیمات پاسخگوی خودکار ذخیره شد",
|
||||
"vacation_save_failed": "ذخیره تنظیمات پاسخگوی خودکار ناموفق بود",
|
||||
"vacation_saved": "تنظیمات غیبت ذخیره شد",
|
||||
"vacation_save_failed": "ذخیره تنظیمات غیبت ناموفق بود",
|
||||
"filters_saved": "فیلترها با موفقیت ذخیره شدند",
|
||||
"filters_save_failed": "ذخیره فیلترها ناموفق بود",
|
||||
"filters_deleted": "قانون فیلتر حذف شد",
|
||||
@@ -864,7 +879,8 @@
|
||||
"rule_undone": "تغییر قانون بازگردانده شد",
|
||||
"rule_undo_failed": "بازگردانی تغییر قانون ممکن نشد",
|
||||
"rule_undo_conflict": "فیلترهای شما از آن زمان تغییر کردهاند. تغییر را در تنظیمات فیلترها بازگردانید.",
|
||||
"rule_folder_failed": "ایجاد پوشه ممکن نشد"
|
||||
"rule_folder_failed": "ایجاد پوشه ممکن نشد",
|
||||
"vacation_filters_save_failed": "تنظیمات غیبت ذخیره شد، اما ذخیره بازارسال و گیرندگان پاسخ ناموفق بود"
|
||||
},
|
||||
"date": {
|
||||
"today": "امروز",
|
||||
@@ -952,7 +968,7 @@
|
||||
"privacy": "حریم خصوصی و امنیت",
|
||||
"account": "حساب",
|
||||
"identities": "هویتها",
|
||||
"vacation": "پاسخگوی خودکار",
|
||||
"vacation": "غیبت",
|
||||
"advanced": "پیشرفته",
|
||||
"calendar": "تقویم",
|
||||
"filters": "فیلترها",
|
||||
@@ -1435,7 +1451,7 @@
|
||||
},
|
||||
"sub_address_delimiter": {
|
||||
"label": "جداکننده زیرآدرس",
|
||||
"description": "کاراکتر جداکننده",
|
||||
"description": "کاراکتری که نام کاربری را از برچسب زیرآدرس جدا میکند. با جداکنندهای که سرور ایمیل شما استفاده میکند مطابقت دهید (مثلاً user{delimiter}tag@domain.com).",
|
||||
"option": "{delimiter} (user{delimiter}tag@domain.com)",
|
||||
"custom": "سفارشی…",
|
||||
"custom_input_label": "کاراکتر سفارشی"
|
||||
@@ -1515,7 +1531,7 @@
|
||||
},
|
||||
"default_mail_program": {
|
||||
"label": "برنامه ایمیل پیشفرض",
|
||||
"description": "ثبت برای mailto:",
|
||||
"description": "{appName} را بهعنوان برنامهٔ پیشفرض ایمیل برای پیوندهای mailto: ثبت کنید",
|
||||
"button": "تنظیم به عنوان پیشفرض",
|
||||
"success": "ثبت شد",
|
||||
"error": "پشتیبانی نمیشود"
|
||||
@@ -1837,28 +1853,24 @@
|
||||
}
|
||||
},
|
||||
"vacation": {
|
||||
"title": "پاسخگوی خودکار",
|
||||
"description": "پاسخ خودکار به ایمیلهای ورودی در زمان غیبت",
|
||||
"title": "غیبت",
|
||||
"description": "رسیدگی خودکار به ایمیلهای ورودی در زمان غیبت",
|
||||
"loading": "در حال بارگذاری تنظیمات پاسخگوی خودکار...",
|
||||
"not_supported": "سرور ایمیل شما از پاسخ خودکار پشتیبانی نمیکند.",
|
||||
"fetch_error": "بارگذاری تنظیمات پاسخگوی خودکار ناموفق بود. لطفاً دوباره تلاش کنید.",
|
||||
"status": {
|
||||
"label": "پاسخگوی خودکار",
|
||||
"label": "ارسال پاسخ خودکار",
|
||||
"description": "ارسال پاسخ خودکار به افرادی که به شما ایمیل میزنند",
|
||||
"active": "فعال",
|
||||
"inactive": "غیرفعال"
|
||||
},
|
||||
"date_range": {
|
||||
"title": "بازه زمانی",
|
||||
"description": "محدود کردن پاسخ خودکار به یک بازه زمانی خاص",
|
||||
"start": "تاریخ شروع",
|
||||
"start_description": "برای بدون محدودیت شروع خالی بگذارید",
|
||||
"end": "تاریخ پایان",
|
||||
"end_description": "برای بدون محدودیت پایان خالی بگذارید"
|
||||
},
|
||||
"message": {
|
||||
"title": "پیام پاسخ خودکار",
|
||||
"description": "پیامی که به عنوان پاسخ ارسال خواهد شد",
|
||||
"subject_label": "موضوع",
|
||||
"subject_description": "خط موضوع پاسخ خودکار",
|
||||
"subject_placeholder": "خارج از دفتر",
|
||||
@@ -1878,10 +1890,32 @@
|
||||
"saving": "در حال ذخیره...",
|
||||
"warnings": {
|
||||
"end_before_start": "تاریخ پایان باید بعد از تاریخ شروع باشد",
|
||||
"invalid_date": "تاریخ شروع و پایان معتبر را همراه با ساعت وارد کنید یا فیلد را خالی بگذارید",
|
||||
"start_in_past": "تاریخ شروع در گذشته است",
|
||||
"empty_body": "بدنه پیام خالی است - گیرندگان پاسخ خالی دریافت میکنند",
|
||||
"subject_too_long": "موضوع برای سرور بیش از حد طولانی است (حداکثر {max} بایت).",
|
||||
"body_too_long": "پیام برای سرور بیش از حد طولانی است (حداکثر {max} بایت، همراه با قالببندی)."
|
||||
"body_too_long": "پیام برای سرور بیش از حد طولانی است (حداکثر {max} بایت، همراه با قالببندی).",
|
||||
"forward_address": "یک نشانی معتبر برای بازارسال وارد کنید",
|
||||
"not_running": "بازارسال یا گیرندگان پاسخ ذخیره شدهاند اما اکنون فعال نیستند. برای فعالسازی دوباره ذخیره کنید."
|
||||
},
|
||||
"forward": {
|
||||
"title": "بازارسال",
|
||||
"description": "پیامهای ورودی را با یا بدون پاسخ خودکار، در بازه زمانی بالا به نشانی دیگری بازارسال میکند (بدون تاریخ پایان، تا زمانی که خاموش شود). هرزنامه بازارسال نمیشود.",
|
||||
"enabled_label": "بازارسال پیامها",
|
||||
"to_label": "ارسال به",
|
||||
"to_placeholder": "email@example.com",
|
||||
"keep_label": "نگه داشتن در صندوق ورودی",
|
||||
"keep_description": "در غیر این صورت پیام فقط بازارسال میشود: در این صندوق پستی نمیماند و هیچ قانون فیلتری روی آن اجرا نمیشود."
|
||||
},
|
||||
"audience": {
|
||||
"label": "پاسخ به",
|
||||
"description": "فرستندگان داخلی: {domains}",
|
||||
"all": "همه فرستندگان",
|
||||
"internal": "فقط فرستندگان داخلی",
|
||||
"external": "فقط فرستندگان خارجی"
|
||||
},
|
||||
"auto_reply": {
|
||||
"title": "پاسخ خودکار"
|
||||
}
|
||||
},
|
||||
"folders": {
|
||||
@@ -1906,6 +1940,15 @@
|
||||
"reorder": "برای تغییر ترتیب بکشید",
|
||||
"reorder_error": "تغییر ترتیب پوشهها ناموفق بود",
|
||||
"move_error": "انتقال پوشه ناموفق بود",
|
||||
"select_folder": "انتخاب {name}",
|
||||
"select_hint": "انتخاب. Shift+کلیک همه پوشهها را تا آخرین پوشهای که کلیک کردید انتخاب میکند.",
|
||||
"selected_count": "{count, plural, one {# پوشه} other {# پوشه}} انتخاب شد",
|
||||
"move_to": "انتقال به...",
|
||||
"move_to_top_level": "سطح بالا",
|
||||
"move": "انتقال",
|
||||
"clear_selection": "پاک کردن انتخاب",
|
||||
"folders_moved": "{count, plural, one {# پوشه منتقل شد} other {# پوشه منتقل شد}}",
|
||||
"move_partial_error": "{count, plural, one {# پوشه} other {# پوشه}} منتقل نشد",
|
||||
"subfolder_of": "داخل {name}",
|
||||
"subfolder_name": "نام زیرپوشه",
|
||||
"new_folder_name": "نام پوشه",
|
||||
@@ -2061,7 +2104,7 @@
|
||||
},
|
||||
"attachment_template": {
|
||||
"label": "نام فایل پیوست",
|
||||
"description": "قالب استفاده شده هنگام دانلود یا کشیدن پیوست."
|
||||
"description": "قالبی که هنگام دانلود یا بیرون کشیدن پیوست استفاده میشود. اگر '{filename}' و '{ext}' را حذف کنید، پسوند اصلی فایل حفظ میشود."
|
||||
},
|
||||
"bundle_template": {
|
||||
"label": "نام فایل .zip چند ایمیل",
|
||||
@@ -2250,7 +2293,19 @@
|
||||
"suggest_list": "List-Id",
|
||||
"suggest_domain": "@{domain}",
|
||||
"apply_existing": "روی پیامهایی که از قبل در این پوشه هستند هم اعمال شود",
|
||||
"apply_existing_unsupported": "فقط قانونهایی که فرستنده، گیرندگان، موضوع یا سرایندها را بررسی میکنند و پیام را منتقل، کپی، خواندهشده، ستارهدار یا برچسبدار میکنند، روی پیامهای موجود اعمال میشوند."
|
||||
"apply_existing_unsupported": "فقط قانونهایی که فرستنده، گیرندگان، موضوع یا سرایندها را بررسی میکنند و پیام را منتقل، کپی، خواندهشده، ستارهدار یا برچسبدار میکنند، روی پیامهای موجود اعمال میشوند.",
|
||||
"period_title": "بازه زمانی",
|
||||
"period_start": "تاریخ شروع",
|
||||
"period_end": "تاریخ پایان",
|
||||
"validation_period_order": "تاریخ پایان باید بعد از تاریخ شروع باشد",
|
||||
"period_toggle": "فقط در یک بازه زمانی فعال باشد",
|
||||
"period_hint": "برای پیامهایی اعمال میشود که در این بازه میرسند. برای باز گذاشتن هر طرف، فیلد آن را خالی بگذارید.",
|
||||
"validation_period_empty": "تاریخ شروع یا پایان بازه را وارد کنید",
|
||||
"validation_period_invalid": "برای بازه یک تاریخ معتبر همراه با ساعت وارد کنید یا فیلد را خالی بگذارید",
|
||||
"apply_existing_period": "قانونی که بازه زمانی دارد فقط روی پیامهایی عمل میکند که در آن بازه میرسند، بنابراین روی پیامهای موجود اجرا نمیشود.",
|
||||
"period_status_scheduled": "زمانبندیشده",
|
||||
"period_status_active": "فعال",
|
||||
"period_status_expired": "منقضیشده"
|
||||
},
|
||||
"templates": {
|
||||
"title": "قالبهای ایمیل",
|
||||
|
||||
+68
-13
@@ -348,6 +348,15 @@
|
||||
"external_content_warning": "Les images et le contenu externe ont été bloqués",
|
||||
"load_external_content": "Charger les images",
|
||||
"trust_sender": "Toujours faire confiance à cet expéditeur",
|
||||
"sender_check": {
|
||||
"unverified_label": "Expéditeur non vérifié",
|
||||
"failed_label": "Échec de la vérification de l'expéditeur",
|
||||
"unverified": "Ce message prétend venir de {domain}, mais rien ne le confirme. Il ne porte aucune signature valide de {domain} et ne provient pas d'un serveur autorisé par {domain}.",
|
||||
"unverified_sent_from": "Ce message prétend venir de {domain}, mais il a été envoyé depuis {host} et ne porte aucune signature valide de {domain}.",
|
||||
"failed": "Ce message prétend venir de {domain}, mais il a échoué aux vérifications d'expéditeur de ce domaine. Il est peut-être falsifié.",
|
||||
"failed_sent_from": "Ce message prétend venir de {domain}, mais il a été envoyé depuis {host} et a échoué aux vérifications d'expéditeur de {domain}. Il est peut-être falsifié.",
|
||||
"caution": "Méfiez-vous des liens, des pièces jointes et des demandes de mots de passe ou d'informations de paiement."
|
||||
},
|
||||
"back_to_list": "Retour à la liste",
|
||||
"view_contact": "Voir le contact",
|
||||
"message_details": "Détails du message",
|
||||
@@ -630,6 +639,12 @@
|
||||
"attach": "Joindre",
|
||||
"attach_photos": "Photos & Videos",
|
||||
"attach_files": "Files",
|
||||
"attach_from_files": "Joindre depuis Fichiers",
|
||||
"files_picker": {
|
||||
"attach": "{count, plural, =0 {Joindre} one {Joindre 1 fichier} other {Joindre # fichiers}}",
|
||||
"empty_folder": "Ce dossier est vide",
|
||||
"load_failed": "Impossible de charger vos fichiers"
|
||||
},
|
||||
"discard": "Supprimer",
|
||||
"discard_draft_title": "Supprimer le brouillon ?",
|
||||
"discard_draft_confirm": "Vous avez des modifications non enregistrées. Voulez-vous supprimer ce brouillon ?",
|
||||
@@ -841,8 +856,8 @@
|
||||
"identity_delete_failed": "Échec de la suppression de l'identité: {error}",
|
||||
"identity_unauthorized": "Vous n'êtes pas autorisé à envoyer depuis cette adresse email",
|
||||
"identity_not_found": "Identité introuvable",
|
||||
"vacation_saved": "Paramètres du répondeur d'absence enregistrés",
|
||||
"vacation_save_failed": "Échec de l'enregistrement des paramètres du répondeur d'absence",
|
||||
"vacation_saved": "Paramètres d'absence enregistrés",
|
||||
"vacation_save_failed": "Échec de l'enregistrement des paramètres d'absence",
|
||||
"filters_saved": "Filtres enregistrés avec succès",
|
||||
"filters_save_failed": "Échec de l'enregistrement des filtres",
|
||||
"filters_deleted": "Règle de filtrage supprimée",
|
||||
@@ -864,7 +879,8 @@
|
||||
"rule_undone": "Modification de la règle annulée",
|
||||
"rule_undo_failed": "Impossible d'annuler la modification de la règle",
|
||||
"rule_undo_conflict": "Vos filtres ont changé entre-temps. Annulez la modification dans les réglages des filtres.",
|
||||
"rule_folder_failed": "Impossible de créer le dossier"
|
||||
"rule_folder_failed": "Impossible de créer le dossier",
|
||||
"vacation_filters_save_failed": "Paramètres d'absence enregistrés, mais le transfert et les destinataires de la réponse n'ont pas pu être enregistrés"
|
||||
},
|
||||
"date": {
|
||||
"today": "Aujourd'hui",
|
||||
@@ -949,7 +965,7 @@
|
||||
"privacy": "Confidentialité et sécurité",
|
||||
"account": "Compte",
|
||||
"identities": "Identités",
|
||||
"vacation": "Répondeur d'absence",
|
||||
"vacation": "Absence",
|
||||
"advanced": "Avancé",
|
||||
"calendar": "Calendrier",
|
||||
"filters": "Filtres",
|
||||
@@ -1830,28 +1846,24 @@
|
||||
}
|
||||
},
|
||||
"vacation": {
|
||||
"title": "Répondeur d'absence",
|
||||
"description": "Répondre automatiquement aux emails entrants pendant votre absence",
|
||||
"title": "Absence",
|
||||
"description": "Traiter automatiquement les emails entrants pendant votre absence",
|
||||
"loading": "Chargement des paramètres d'absence...",
|
||||
"not_supported": "Votre serveur de messagerie ne prend pas en charge les réponses d'absence.",
|
||||
"fetch_error": "Échec du chargement des paramètres d'absence. Veuillez réessayer.",
|
||||
"status": {
|
||||
"label": "Répondeur d'absence",
|
||||
"label": "Répondre automatiquement",
|
||||
"description": "Envoyer une réponse automatique aux personnes qui vous envoient un email",
|
||||
"active": "Actif",
|
||||
"inactive": "Inactif"
|
||||
},
|
||||
"date_range": {
|
||||
"title": "Période",
|
||||
"description": "Limiter optionnellement la réponse automatique à une période spécifique",
|
||||
"start": "Date de début",
|
||||
"start_description": "Laisser vide pour aucune limite de début",
|
||||
"end": "Date de fin",
|
||||
"end_description": "Laisser vide pour aucune limite de fin"
|
||||
},
|
||||
"message": {
|
||||
"title": "Message de réponse automatique",
|
||||
"description": "Le message qui sera envoyé en réponse",
|
||||
"subject_label": "Objet",
|
||||
"subject_description": "Ligne d'objet de la réponse automatique",
|
||||
"subject_placeholder": "Absence du bureau",
|
||||
@@ -1871,10 +1883,32 @@
|
||||
"saving": "Enregistrement...",
|
||||
"warnings": {
|
||||
"end_before_start": "La date de fin doit être postérieure à la date de début",
|
||||
"invalid_date": "Indiquez une date de début et de fin valides avec l'heure, ou laissez le champ vide",
|
||||
"start_in_past": "La date de début est dans le passé",
|
||||
"empty_body": "Le corps du message est vide - les destinataires recevront une réponse vide",
|
||||
"subject_too_long": "L'objet est trop long pour le serveur ({max} octets au maximum).",
|
||||
"body_too_long": "Le message est trop long pour le serveur ({max} octets au maximum, mise en forme comprise)."
|
||||
"body_too_long": "Le message est trop long pour le serveur ({max} octets au maximum, mise en forme comprise).",
|
||||
"forward_address": "Indiquez une adresse valide pour le transfert",
|
||||
"not_running": "Le transfert ou les destinataires de la réponse sont enregistrés mais pas actifs pour le moment. Enregistrez pour les réactiver."
|
||||
},
|
||||
"forward": {
|
||||
"title": "Transfert",
|
||||
"description": "Transfère les messages entrants à une autre adresse, avec ou sans réponse automatique, pendant la période ci-dessus (sans date de fin, jusqu'à sa désactivation). Les spams ne sont pas transférés.",
|
||||
"enabled_label": "Transférer les messages",
|
||||
"to_label": "Transférer à",
|
||||
"to_placeholder": "email@exemple.com",
|
||||
"keep_label": "Conserver dans la boîte de réception",
|
||||
"keep_description": "Sinon, le message est seulement transféré : il n'est pas conservé dans cette boîte aux lettres et aucune règle de filtre ne s'y applique."
|
||||
},
|
||||
"audience": {
|
||||
"label": "Répondre à",
|
||||
"description": "Expéditeurs internes : {domains}",
|
||||
"all": "Tous les expéditeurs",
|
||||
"internal": "Expéditeurs internes uniquement",
|
||||
"external": "Expéditeurs externes uniquement"
|
||||
},
|
||||
"auto_reply": {
|
||||
"title": "Réponse automatique"
|
||||
}
|
||||
},
|
||||
"folders": {
|
||||
@@ -1899,6 +1933,15 @@
|
||||
"reorder": "Glissez pour réorganiser",
|
||||
"reorder_error": "Échec de la réorganisation des dossiers",
|
||||
"move_error": "Échec du déplacement du dossier",
|
||||
"select_folder": "Sélectionner {name}",
|
||||
"select_hint": "Sélectionner. Maj+clic sélectionne tous les dossiers jusqu'au dernier sur lequel vous avez cliqué.",
|
||||
"selected_count": "{count, plural, one {# dossier sélectionné} other {# dossiers sélectionnés}}",
|
||||
"move_to": "Déplacer vers...",
|
||||
"move_to_top_level": "Niveau supérieur",
|
||||
"move": "Déplacer",
|
||||
"clear_selection": "Effacer la sélection",
|
||||
"folders_moved": "{count, plural, one {# dossier déplacé} other {# dossiers déplacés}}",
|
||||
"move_partial_error": "{count, plural, one {# dossier n'a pas pu être déplacé} other {# dossiers n'ont pas pu être déplacés}}",
|
||||
"subfolder_of": "Dans {name}",
|
||||
"subfolder_name": "Nom du sous-dossier",
|
||||
"new_folder_name": "Nom du dossier",
|
||||
@@ -2243,7 +2286,19 @@
|
||||
"suggest_list": "List-Id",
|
||||
"suggest_domain": "@{domain}",
|
||||
"apply_existing": "Appliquer aussi aux messages déjà présents dans ce dossier",
|
||||
"apply_existing_unsupported": "Seules les règles qui vérifient l'expéditeur, les destinataires, l'objet ou les en-têtes, et qui déplacent, copient, marquent comme lu, suivent ou étiquettent, peuvent s'appliquer aux messages existants."
|
||||
"apply_existing_unsupported": "Seules les règles qui vérifient l'expéditeur, les destinataires, l'objet ou les en-têtes, et qui déplacent, copient, marquent comme lu, suivent ou étiquettent, peuvent s'appliquer aux messages existants.",
|
||||
"period_title": "Période",
|
||||
"period_start": "Date de début",
|
||||
"period_end": "Date de fin",
|
||||
"validation_period_order": "La date de fin doit être postérieure à la date de début",
|
||||
"period_toggle": "Active uniquement pendant une période",
|
||||
"period_hint": "S'applique aux messages qui arrivent pendant cette période. Laissez un champ vide pour ne pas limiter ce côté.",
|
||||
"validation_period_empty": "Indiquez une date de début ou de fin pour la période",
|
||||
"validation_period_invalid": "Indiquez une date valide avec l'heure pour la période, ou laissez le champ vide",
|
||||
"apply_existing_period": "Une règle avec une période n'agit que sur les messages qui arrivent pendant celle-ci ; elle ne peut donc pas s'appliquer aux messages existants.",
|
||||
"period_status_scheduled": "planifiée",
|
||||
"period_status_active": "active",
|
||||
"period_status_expired": "expirée"
|
||||
},
|
||||
"templates": {
|
||||
"title": "Modèles d'e-mails",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user