Merge remote-tracking branch 'origin/main' into up/fix/cross-account-selection-key

# Conflicts:
#	components/email/email-list.tsx
#	stores/email-store.ts
This commit is contained in:
lucamzanon
2026-10-08 10:11:20 +02:00
201 changed files with 14920 additions and 1585 deletions
+6
View File
@@ -302,6 +302,12 @@ LOGIN_COMPANY_NAME=Bulwark Webmail
# URL for the company website link on the login page.
LOGIN_WEBSITE_URL=https://bulwarkmail.org
# Where users get the source code of this build, linked from Settings >
# About. Bulwark is AGPL-3.0-only: if you run a modified build, set this to
# the source of exactly what you deploy (your fork, at the deployed commit).
# Unset links to the upstream repository. Only http(s) URLs are used.
# SOURCE_CODE_URL=https://git.example.com/acme/webmail/tree/v1.2.3
# Cap the login logo's rendered size. Any CSS length ("120px", "8rem").
# Unset means the logo renders at its natural size.
# LOGIN_LOGO_MAX_HEIGHT=96px
+21
View File
@@ -0,0 +1,21 @@
## What and why
<!-- What changes for the user, and why. A few sentences are enough. -->
## Related issues
<!-- "Fixes #123". For a change that needed agreement first, link the issue where the approach was agreed. -->
## Checklist
- [ ] The change fits [What fits in Bulwark](https://bulwarkmail.org/docs/development/contributing#what-fits-in-bulwark) (core, not plugin material)
- [ ] It needed no issue first, or the approach was agreed in the linked issue (over ~500 lines excluding locales and tests, a new runtime dependency, or security-sensitive code: [details](https://bulwarkmail.org/docs/development/contributing#before-you-write-code))
- [ ] `npm run typecheck && npm run lint && npx vitest run` pass, and new logic has tests
- [ ] New user-facing text goes through translations
- [ ] Works in Bulwark Lite, or is hidden there
- [ ] UI changes checked at phone width and in the light and dark theme
- [ ] Documentation updated in the [website repository](https://github.com/bulwarkmail/website/tree/main/docs), or not needed
## Screenshots
<!-- For UI changes: desktop and phone width. -->
+73
View File
@@ -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
+98
View File
@@ -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.
+12
View File
@@ -4,6 +4,18 @@ Bug reports, feature requests, translations and patches are all welcome.
The full guide is in the documentation under [Contributing](https://bulwarkmail.org/docs/development/contributing). It covers the development setup, the test suites, translations and right-to-left layouts, code style, and how pull requests are reviewed.
## What fits
Read [What fits in Bulwark](https://bulwarkmail.org/docs/development/contributing#what-fits-in-bulwark) before you start on a feature. In short:
- Bulwark is built for Stalwart. Changes for other JMAP servers are welcome if they cost Stalwart users nothing.
- Core is for what most people expect from a mail, calendar, contacts and files client. Niche workflows and integrations with other services belong in plugins.
- Not in core: IMAP or other non-JMAP backends, integrations with other services, Stalwart server administration, and changes to the look that go beyond themes.
- New behaviour gets one good default. If deployments really need different behaviour, add an admin setting, not a per-user toggle.
- Features must work in Bulwark Lite, or be hidden there, and UI changes must work at phone width.
Open an issue and agree on the approach before you write a change that is larger than about 500 lines (not counting locales and tests), adds a runtime dependency, or touches sign-in, sessions, the HTML sanitizer, the plugin sandbox, outgoing-request guards or admin routes. Deliver large features as a series of pull requests that can each be reviewed on their own. Pull requests with review feedback unanswered for 30 days are closed.
To get a development server running:
```bash
+1 -1
View File
@@ -1 +1 @@
1.12.0
1.13.0
+1
View File
@@ -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.' },
+2 -2
View File
@@ -163,10 +163,10 @@ export function SettingsTab() {
<SettingsSection title="Document Editing (WOPI)">
<TextSetting label="WOPI Client URL" configKey="wopiClientUrl" value={currentValue('wopiClientUrl') as string} source={config.wopiClientUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://office.example.com" />
<TextSetting label="WOPI Host URL Override" configKey="wopiHostUrl" value={currentValue('wopiHostUrl') as string} source={config.wopiHostUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://mail.example.com" />
<TextSetting label="WOPI Host URL Override" configKey="wopiHostUrl" value={currentValue('wopiHostUrl') as string} source={config.wopiHostUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="http://webmail:3000" />
<div className="px-4 py-2.5">
<p className="text-xs text-muted-foreground leading-relaxed">
Point the client URL at a WOPI-compatible office suite (Collabora Online, OnlyOffice, EuroOffice, …) to let users edit documents in Files. The host override is only needed when the editor reaches this webmail under a different address than the browser does (e.g. Docker networks). The editor must also allow this webmail as a WOPI host.
Point the client URL at a WOPI-compatible office suite (Collabora Online, OnlyOffice, EuroOffice, …) to let users edit documents in Files. The client URL may be an internal address: the browser loads the editor from the address its discovery advertises. The host override is only needed when the editor reaches this webmail under a different address than the browser does (e.g. Docker networks); give the address alone, the webmail&apos;s base path is added to it. The editor must also allow this webmail as a WOPI host.
</p>
</div>
</SettingsSection>
+119 -77
View File
@@ -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;
+2
View File
@@ -4,6 +4,7 @@ import { configManager } from '@/lib/admin/config-manager';
import { parseJmapServers, redactJmapServers } from '@/lib/admin/jmap-servers';
import { hasSessionSecret } from '@/lib/auth/session-secret';
import { getOauthScopes } from '@/lib/oauth/tokens';
import { httpUrlOrEmpty } from '@/lib/config-validation';
import {
matchDomainBranding,
parseDomainBranding,
@@ -95,6 +96,7 @@ export async function GET(request: NextRequest) {
autoSsoEnabled: configManager.get<boolean>('autoSsoEnabled', false),
embeddedMode: !!allowedFrameAncestors && allowedFrameAncestors !== "'none'",
parentOrigin: configManager.get<string>('parentOrigin', ''),
sourceCodeUrl: httpUrlOrEmpty(configManager.get<string>('sourceCodeUrl', '')),
},
{
// Runtime settings can change without new asset URLs. Prevent stale
+1 -1
View File
@@ -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: [],
+33 -3
View File
@@ -161,10 +161,16 @@ export async function GET(request: NextRequest) {
};
const inboxBody = inboxData.methodResponses.find(
([method]) => method === 'Mailbox/query',
([method, , callId]) => method === 'Mailbox/query' && callId === 'mb',
)?.[1] as { ids?: string[] } | undefined;
const inboxId = inboxBody?.ids?.[0];
// A method-level JMAP error still has HTTP 200. Do not turn it into
// an empty Inbox: the service worker would silently discard the push.
if (!Array.isArray(inboxBody?.ids)) {
return NextResponse.json({ error: 'JMAP mailbox query failed' }, { status: 502 });
}
const inboxId = inboxBody.ids[0];
const emailProperties = ['id', 'threadId', 'from', 'subject', 'preview', 'receivedAt'];
if (!inboxId && !requestedEmailId) {
@@ -239,6 +245,21 @@ export async function GET(request: NextRequest) {
methodResponses: [string, Record<string, unknown>, string][];
};
// Missing/failed method responses are preview failures, not proof that
// there is no unread mail. A non-2xx response lets the SW show its fallback.
// The message the server named, once read, is a complete preview on its
// own, whatever became of the Inbox lookups next to it.
const resultOf = (name: string, id: string) =>
data.methodResponses.find(([method, , callId]) => method === name && callId === id)?.[1];
const failed = (methodCalls as [string, unknown, string][]).some(([name, , id]) => {
const result = resultOf(name, id);
return !result || (name === 'Email/query' ? !Array.isArray(result.ids) : !Array.isArray(result.list));
});
const deliveredList = resultOf('Email/get', 'delivered')?.list;
if (failed && !(Array.isArray(deliveredList) && deliveredList.length > 0)) {
return NextResponse.json({ error: 'JMAP email query failed' }, { status: 502 });
}
type EmailLite = {
id: string;
threadId: string;
@@ -253,7 +274,9 @@ export async function GET(request: NextRequest) {
let unreadTotal = 0;
for (const [method, body, callId] of data.methodResponses) {
if (method === 'Email/query') {
unreadTotal = ((body as { total?: number }).total) ?? 0;
// A server that leaves out the optional total still lists the ids.
const query = body as { total?: number; ids?: unknown[] };
unreadTotal = typeof query.total === 'number' ? query.total : (query.ids?.length ?? 0);
}
if (method === 'Email/get') {
const list = (body as { list?: EmailLite[] }).list ?? [];
@@ -270,6 +293,13 @@ export async function GET(request: NextRequest) {
unreadTotal = Math.max(unreadTotal, 1);
}
email = delivered;
} else if (requestedEmailId) {
// The server named the message that arrived and it could not be read -
// deleted since, or filed where this login cannot see it. "Newest unread
// in the Inbox" is then some other message, and announcing it would be
// confidently wrong. Name no message: the unread total still comes back,
// so the SW posts its generic toast instead of staying silent.
email = null;
}
// Some servers build the preview from HTML without skipping <style>; a
+5 -1
View File
@@ -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';
+2 -2
View File
@@ -40,7 +40,7 @@ export async function GET(
SupportsUpdate: false,
SupportsLocks: false,
Version: payload.fileId,
PostMessageOrigin: payload.origin,
PostMessageOrigin: payload.postMessageOrigin || payload.origin,
});
}
@@ -61,7 +61,7 @@ export async function GET(
SupportsLocks: false,
LastModifiedTime: node.modified,
Version: node.blobId,
PostMessageOrigin: auth.payload.origin,
PostMessageOrigin: auth.payload.postMessageOrigin || auth.payload.origin,
});
} catch (error) {
logger.error('WOPI CheckFileInfo failed', {
+5 -4
View File
@@ -9,11 +9,13 @@ import { getWopiActions, buildWopiActionUrl } from '@/lib/wopi/discovery';
import { getWopiFileNode, probeBlob } from '@/lib/wopi/files';
import { mintWopiToken, wopiDocumentId, type WopiTokenPayload } from '@/lib/wopi/token';
import { wopiBrowserBinding } from '@/lib/wopi/revocation';
import { wopiBrowserOrigin, wopiHostBase } from '@/lib/wopi/origin';
/**
* POST /api/wopi/launch
* { fileId: string, accountId?: string } - a Files node
* { blobId: string, name: string, type?: string, accountId?: string } - a mail attachment
* plus an optional `lang`, the webmail's locale, for the editor's own UI.
*
* Mints a WOPI access token scoped to one document and returns the editor
* URL to POST it to (#425). Attachments always open read-only (#1047).
@@ -108,9 +110,7 @@ export async function POST(request: NextRequest) {
// Where the editor reaches this webmail. Deployments where the editor
// sees a different host than the browser (docker networks, split DNS)
// override it via wopiHostUrl.
const hostBase =
configManager.get<string>('wopiHostUrl', '').trim().replace(/\/+$/, '') ||
request.nextUrl.origin;
const hostBase = wopiHostBase(request, configManager.get<string>('wopiHostUrl', ''));
const tokenPayload = {
serverUrl,
authHeader: creds.authHeader,
@@ -119,6 +119,7 @@ export async function POST(request: NextRequest) {
...document,
canWrite: editable,
origin: request.nextUrl.origin,
postMessageOrigin: wopiBrowserOrigin(request),
// Signing out of this slot in this browser revokes the token.
bid: wopiBrowserBinding(await cookies()),
slot: creds.slot,
@@ -127,7 +128,7 @@ export async function POST(request: NextRequest) {
const { token, expiresAt } = mintWopiToken(tokenPayload);
return NextResponse.json({
url: buildWopiActionUrl(urlsrc, wopiSrc),
url: buildWopiActionUrl(urlsrc, wopiSrc, str('lang')),
accessToken: token,
accessTokenTtl: expiresAt,
readOnly: !editable,
+47
View File
@@ -199,10 +199,57 @@ option {
color: var(--color-popover-foreground);
}
/* The group labels (the From dropdown's accounts) are painted the same way.
Without colours of their own they kept the control's text colour on the
popup's base, which a transparent control leaves light: in dark themes,
light text on a white bar. */
optgroup {
background-color: var(--color-popover);
color: var(--color-popover-foreground);
}
.dark select {
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;
@@ -0,0 +1,156 @@
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
import { CalendarWeekView } from '../calendar-week-view';
import { CalendarDayView } from '../calendar-day-view';
import { useSettingsStore } from '@/stores/settings-store';
import type { CalendarEvent, Calendar } from '@/lib/jmap/types';
const calendars = [{ id: 'cal-1', name: 'Work', color: '#123456' }] as unknown as Calendar[];
function makeEvent(id: string, start: string, duration: string, showWithoutTime = false): CalendarEvent {
return {
id,
'@type': 'Event',
uid: id,
title: 'Event ' + id,
start,
duration,
showWithoutTime,
calendarIds: { 'cal-1': true },
} as unknown as CalendarEvent;
}
type WeekProps = React.ComponentProps<typeof CalendarWeekView>;
function renderWeek(overrides: Partial<WeekProps> = {}) {
const props: WeekProps = {
focus: { date: new Date(2026, 8, 9), nonce: 0 },
windowKey: 'week:2026-09-07',
// Monday 7 Sep to Sunday 13 Sep 2026
rangeStart: new Date(2026, 8, 7),
rangeEnd: new Date(2026, 8, 13),
selectedDate: new Date(2026, 8, 9),
events: [],
calendars,
onSelectDate: vi.fn(),
onSelectEvent: vi.fn(),
onCreateAtTime: vi.fn(),
...overrides,
};
return render(<CalendarWeekView {...props} />);
}
function eventBlock(id: string): HTMLElement | null {
// The positioned wrapper around the card, not the card itself
return screen.queryByText('Event ' + id)?.closest<HTMLElement>('[class*="group/event"]') ?? null;
}
const initialSettings = useSettingsStore.getState();
describe('calendar display hours and working days (#1164)', () => {
beforeEach(() => {
useSettingsStore.setState(initialSettings, true);
});
afterEach(() => {
cleanup();
});
it('shows 08:00 to 20:00 by default', () => {
renderWeek();
expect(screen.getAllByRole('gridcell')).toHaveLength(7 * 12);
expect(screen.getByText('08:00')).toBeInTheDocument();
expect(screen.queryByText('07:00')).toBeNull();
expect(screen.queryByText('20:00')).toBeNull();
});
it('shows the whole day when the limit is off', () => {
useSettingsStore.setState({ calendarLimitHours: false });
renderWeek();
expect(screen.getAllByRole('gridcell')).toHaveLength(7 * 24);
expect(document.querySelector('[data-all-hours-toggle]')).toBeNull();
});
it('positions events from the first visible hour and cuts them at the edges', () => {
renderWeek({
events: [
makeEvent('morning', '2026-09-09T07:00:00', 'PT2H'),
makeEvent('noon', '2026-09-09T12:00:00', 'PT1H'),
],
});
// 07:00-09:00 shows as 08:00-09:00 at the top of the grid
expect(eventBlock('morning')?.style.top).toBe('0px');
expect(eventBlock('morning')?.style.height).toBe('60px');
expect(eventBlock('noon')?.style.top).toBe('240px');
});
it('marks hidden events and shows the whole day from the marker', () => {
renderWeek({
events: [
makeEvent('early', '2026-09-09T06:00:00', 'PT1H'),
makeEvent('late', '2026-09-09T21:00:00', 'PT1H'),
makeEvent('later', '2026-09-09T22:00:00', 'PT1H'),
],
});
expect(eventBlock('early')).toBeNull();
const before = document.querySelector<HTMLElement>('[data-hidden-events="before"]');
const after = document.querySelector<HTMLElement>('[data-hidden-events="after"]');
expect(before).toHaveTextContent('1');
expect(after).toHaveTextContent('2');
fireEvent.click(before!);
expect(screen.getAllByRole('gridcell')).toHaveLength(7 * 24);
expect(eventBlock('early')?.style.top).toBe('360px');
expect(document.querySelector('[data-hidden-events]')).toBeNull();
// ...and back to the configured hours
const toggle = document.querySelector<HTMLElement>('[data-all-hours-toggle]')!;
expect(toggle).toHaveAttribute('aria-pressed', 'true');
fireEvent.click(toggle);
expect(screen.getAllByRole('gridcell')).toHaveLength(7 * 12);
});
it('leaves non-working days out of the week view', () => {
useSettingsStore.setState({ calendarHideNonWorkingDays: true, calendarWorkingDays: [1, 2, 3, 4, 5] });
renderWeek({
events: [
makeEvent('weekend', '2026-09-12T10:00:00', 'PT1H'),
// Friday to Monday, all day: still drawn on the Friday column
makeEvent('trip', '2026-09-11', 'P4D', true),
],
rangeEnd: new Date(2026, 8, 14),
});
const headers = screen.getAllByRole('columnheader');
expect(headers.map((h) => h.getAttribute('data-day'))).toEqual([
'2026-09-07', '2026-09-08', '2026-09-09', '2026-09-10', '2026-09-11', '2026-09-14',
]);
expect(eventBlock('weekend')).toBeNull();
const trip = screen.getByText('Event trip').closest<HTMLElement>('.absolute')!;
// Column 5 of 6, two columns wide (jsdom rounds the percentages)
expect(trip.style.left).toBe('calc(66.6667% + 1px)');
expect(trip.style.width).toBe('calc(33.3333% - 2px)');
});
it('applies the visible hours to the day view too', () => {
render(
<CalendarDayView
focus={{ date: new Date(2026, 8, 9), nonce: 0 }}
windowKey="day:2026-09-09"
rangeStart={new Date(2026, 8, 9)}
rangeEnd={new Date(2026, 8, 9)}
selectedDate={new Date(2026, 8, 9)}
events={[
makeEvent('early', '2026-09-09T06:00:00', 'PT1H'),
makeEvent('noon', '2026-09-09T12:00:00', 'PT1H'),
]}
calendars={calendars}
onSelectEvent={vi.fn()}
onCreateAtTime={vi.fn()}
/>,
);
expect(screen.getAllByRole('gridcell')).toHaveLength(12);
expect(eventBlock('early')).toBeNull();
expect(eventBlock('noon')?.style.top).toBe(`${4 * 64}px`);
expect(document.querySelector('[data-hidden-events="before"]')).toHaveTextContent('1');
});
});
+6 -37
View File
@@ -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) {
+74 -25
View File
@@ -8,10 +8,13 @@ import { cn } from "@/lib/utils";
import { Check } from "@/components/icons";
import { EventCard } from "./event-card";
import { QuickEventInput } from "./quick-event-input";
import { AllHoursToggle, HiddenEventsIndicator } from "./display-hours-controls";
import { formatSnapTime, getEventDayBounds, getPrimaryCalendarId, isTimedEventFullDayOnDate, layoutOverlappingEvents } from "@/lib/calendar-utils";
import { clipToDisplayHours, partitionByDisplayHours } from "@/lib/calendar-display-range";
import { displayNow, isDisplayToday } from "@/lib/timezone";
import { groupTasksByDueDay } from "@/lib/calendar-tasks";
import type { CalendarEvent, Calendar, CalendarTask } from "@/lib/jmap/types";
import { useDisplayHours } from "@/hooks/use-display-hours";
import { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions";
import { useScrollWindow, getScrollStart, setScrollStart, scrollToStart } from "@/hooks/use-scroll-window";
import { dayKey, type ScrollWindowViewProps } from "@/lib/calendar-scroll-window";
@@ -150,16 +153,24 @@ export function CalendarDayView({
const tasksByDay = useMemo(() => groupTasksByDueDay(tasks), [tasks]);
const {
hours, canToggle: canToggleHours, configured: configuredHours, showAllHours, toggleAllHours, revealMinutes,
} = useDisplayHours({ scrollRef: rootRef, hourHeight: HOUR_HEIGHT });
const firstHour = hours.startMinutes / 60;
const visibleHours = HOURS.slice(firstHour, hours.endMinutes / 60);
const gridHeight = visibleHours.length * HOUR_HEIGHT;
// Column layouts are the costly part of a render; with months of columns
// they must not be redone on every scroll-driven re-render.
const layoutByDay = useMemo(() => {
const map = new Map<string, ReturnType<typeof layoutOverlappingEvents>>();
const map = new Map<string, ReturnType<typeof partitionByDisplayHours> & { layouts: ReturnType<typeof layoutOverlappingEvents> }>();
for (const day of days) {
const key = format(day, "yyyy-MM-dd");
map.set(key, layoutOverlappingEvents(eventsByDay.get(key)?.timed ?? [], day));
const partition = partitionByDisplayHours(eventsByDay.get(key)?.timed ?? [], day, hours);
map.set(key, { ...partition, layouts: layoutOverlappingEvents(partition.visible, day) });
}
return map;
}, [days, eventsByDay]);
}, [days, eventsByDay, hours]);
const hasAllDayArea = useMemo(
() => days.some((day) => {
@@ -169,13 +180,6 @@ export function CalendarDayView({
[days, eventsByDay, tasksByDay],
);
useEffect(() => {
if (rootRef.current) {
const now = displayNow();
rootRef.current.scrollTop = Math.max(0, (now.getHours() - 1) * HOUR_HEIGHT);
}
}, []);
const scrollToFocus = useCallback(() => {
const root = rootRef.current;
if (!root) return;
@@ -253,6 +257,8 @@ export function CalendarDayView({
dropTarget, handleColumnDragOver, handleColumnDragLeave, handleColumnDrop,
} = useTimeGridInteractions({
hourHeight: HOUR_HEIGHT,
gridStartMinutes: hours.startMinutes,
gridEndMinutes: hours.endMinutes,
calendars,
onCreateRange: onCreateAtTime,
errorMessages: {
@@ -289,7 +295,16 @@ export function CalendarDayView({
<div ref={endSentinelRef} data-testid="day-end-sentinel" className="absolute inset-y-0 end-0 w-px pointer-events-none" />
<div className="sticky top-0 z-50 flex border-b border-border bg-background">
<div className={gutterClass} />
<div className={cn(gutterClass, "flex items-end justify-center pb-1")}>
{canToggleHours && (
<AllHoursToggle
showAllHours={showAllHours}
configured={configuredHours}
timeFormat={timeFormat}
onToggle={toggleAllHours}
/>
)}
</div>
<div className="grid" style={columnsStyle}>
{days.map((day) => {
const key = format(day, "yyyy-MM-dd");
@@ -385,16 +400,22 @@ export function CalendarDayView({
</div>
<div>
<div className="flex relative" style={{ height: 24 * HOUR_HEIGHT }}>
<div className="flex relative" style={{ height: gridHeight }}>
<div className={gutterClass}>
{HOURS.map((h) => (
{visibleHours.map((h) => (
<div
key={h}
className="relative text-muted-foreground text-end pe-2"
style={{ height: HOUR_HEIGHT }}
>
{h > 0 && (
<span className={cn("absolute top-0 right-2 -translate-y-1/2 leading-none", isMobile ? "text-[10px]" : "text-xs")}>
{/* Midnight goes unlabelled; a later first hour is labelled
inside its row, where the header cannot cover it. */}
{(h > firstHour || firstHour > 0) && (
<span className={cn(
"absolute right-2 leading-none",
h > firstHour ? "top-0 -translate-y-1/2" : "top-1",
isMobile ? "text-[10px]" : "text-xs",
)}>
{formatHour(h)}
</span>
)}
@@ -406,7 +427,8 @@ export function CalendarDayView({
{days.map((day) => {
const key = format(day, "yyyy-MM-dd");
const today = isDisplayToday(day);
const layouted = layoutByDay.get(key) ?? [];
const dayLayout = layoutByDay.get(key);
const layouted = dayLayout?.layouts ?? [];
return (
<div
@@ -421,7 +443,7 @@ export function CalendarDayView({
onDragLeave={handleColumnDragLeave}
onDrop={(e) => handleColumnDrop(e, day)}
>
{HOURS.map((h) => (
{visibleHours.map((h) => (
<div
key={h}
role="gridcell"
@@ -434,10 +456,30 @@ export function CalendarDayView({
/>
))}
{dayLayout && dayLayout.before > 0 && (
<HiddenEventsIndicator
count={dayLayout.before}
direction="before"
onReveal={() => revealMinutes(dayLayout.firstBeforeMinutes ?? 0)}
/>
)}
{dayLayout && dayLayout.after > 0 && (
<HiddenEventsIndicator
count={dayLayout.after}
direction="after"
onReveal={() => revealMinutes(dayLayout.firstAfterMinutes ?? hours.endMinutes)}
/>
)}
{layouted.map(({ event: ev, column, totalColumns, startMinutes, endMinutes }) => {
const durMin = Math.max(15, endMinutes - startMinutes);
const baseTop = (startMinutes / 60) * HOUR_HEIGHT;
const baseHeight = Math.max(24, (durMin / 60) * HOUR_HEIGHT);
const clip = clipToDisplayHours(startMinutes, endMinutes, hours);
const baseHeight = Math.max(24, (Math.max(15, clip.endMinutes - clip.startMinutes) / 60) * HOUR_HEIGHT);
// Short events at the bottom edge stay inside the grid.
const baseTop = Math.max(0, Math.min(
((clip.startMinutes - hours.startMinutes) / 60) * HOUR_HEIGHT,
gridHeight - baseHeight,
));
const isResizing = resizeVisual?.eventId === ev.id;
const top = isResizing ? resizeVisual!.topPx : baseTop;
const height = isResizing ? resizeVisual!.heightPx : baseHeight;
@@ -463,6 +505,8 @@ export function CalendarDayView({
currentUserEmails={currentUserEmails}
draggable
/>
{/* An edge beyond the visible hours has nothing to grab. */}
{!clip.clippedStart && (
<div
data-resize-handle
className="absolute top-0 left-1 right-1 h-3 cursor-n-resize z-20 flex items-start justify-center opacity-0 group-hover/event:opacity-100 transition-opacity"
@@ -473,6 +517,8 @@ export function CalendarDayView({
>
<div className="w-8 h-1 rounded-full bg-foreground/30 mt-0.5" />
</div>
)}
{!clip.clippedEnd && (
<div
data-resize-handle
className="absolute bottom-0 left-1 right-1 h-3 cursor-s-resize z-20 flex items-end justify-center opacity-0 group-hover/event:opacity-100 transition-opacity"
@@ -483,14 +529,15 @@ export function CalendarDayView({
>
<div className="w-8 h-1 rounded-full bg-foreground/30 mb-0.5" />
</div>
)}
</div>
);
})}
{today && (
{today && nowMinutes >= hours.startMinutes && nowMinutes <= hours.endMinutes && (
<div
className="absolute left-0 right-0 z-20 pointer-events-none"
style={{ top: (nowMinutes / 60) * HOUR_HEIGHT }}
style={{ top: ((nowMinutes - hours.startMinutes) / 60) * HOUR_HEIGHT }}
>
<div className="flex items-center">
<div className="w-2.5 h-2.5 rounded-full bg-destructive -ms-1" />
@@ -511,7 +558,7 @@ export function CalendarDayView({
<div
className="absolute left-1 right-1 z-30 rounded-md pointer-events-none bg-primary/15 border-2 border-primary/30 border-dashed"
style={{
top: (dragCreate.startMinutes / 60) * HOUR_HEIGHT,
top: ((dragCreate.startMinutes - hours.startMinutes) / 60) * HOUR_HEIGHT,
height: ((dragCreate.endMinutes - dragCreate.startMinutes) / 60) * HOUR_HEIGHT,
}}
>
@@ -524,7 +571,7 @@ export function CalendarDayView({
{dropTarget?.dayKey === key && (
<div
className="absolute left-0 right-0 z-30 pointer-events-none"
style={{ top: (dropTarget.minutes / 60) * HOUR_HEIGHT }}
style={{ top: ((dropTarget.minutes - hours.startMinutes) / 60) * HOUR_HEIGHT }}
>
<div className="flex items-center">
<div className="w-2.5 h-2.5 rounded-full bg-primary -ms-1" />
@@ -541,14 +588,16 @@ export function CalendarDayView({
const startMin = pendingPreview.start.getHours() * 60 + pendingPreview.start.getMinutes();
const endMin = pendingPreview.end.getHours() * 60 + pendingPreview.end.getMinutes();
const durationMin = Math.max(15, endMin - startMin);
if (startMin + durationMin <= hours.startMinutes || startMin >= hours.endMinutes) return null;
const clip = clipToDisplayHours(startMin, startMin + durationMin, hours);
const cal = calendars.find(c => c.id === pendingPreview.calendarId);
const color = cal?.color || "hsl(var(--primary))";
return (
<div
className="absolute left-2 right-2 z-10 rounded-md pointer-events-none border-2 border-dashed overflow-hidden"
style={{
top: (startMin / 60) * HOUR_HEIGHT,
height: Math.max(24, (durationMin / 60) * HOUR_HEIGHT),
top: ((clip.startMinutes - hours.startMinutes) / 60) * HOUR_HEIGHT,
height: Math.max(24, ((clip.endMinutes - clip.startMinutes) / 60) * HOUR_HEIGHT),
borderColor: color,
backgroundColor: `${color}10`,
}}
+111 -41
View File
@@ -4,16 +4,20 @@ import { useMemo, useEffect, useLayoutEffect, useRef, useState, useCallback } fr
import { useTranslations } from "next-intl";
import { useDisplayDateFormatter } from "@/hooks/use-display-date-formatter";
import {
startOfWeek, format, isSameDay, eachDayOfInterval, differenceInCalendarDays,
startOfWeek, format, isSameDay, eachDayOfInterval,
} from "date-fns";
import { cn } from "@/lib/utils";
import { EventCard } from "./event-card";
import { QuickEventInput } from "./quick-event-input";
import { CalendarTaskChip } from "./task-chip";
import { AllHoursToggle, HiddenEventsIndicator } from "./display-hours-controls";
import { groupTasksByDueDay } from "@/lib/calendar-tasks";
import { buildTimedFullDayWeekSegments, buildWeekSegmentsRaw, formatSnapTime, getEventDayBounds, getPrimaryCalendarId, isTimedEventFullDayOnDate, layoutOverlappingEvents, packWeekSegments } from "@/lib/calendar-utils";
import { clipToDisplayHours, indexOfDayOnOrAfter, partitionByDisplayHours, remapSegmentsToShownDays, resolveWorkingDays } from "@/lib/calendar-display-range";
import { displayNow, isDisplayToday } from "@/lib/timezone";
import type { CalendarEvent, Calendar, CalendarTask } from "@/lib/jmap/types";
import { useSettingsStore } from "@/stores/settings-store";
import { useDisplayHours } from "@/hooks/use-display-hours";
import { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions";
import { useScrollWindow, getScrollStart, setScrollStart, scrollToStart } from "@/hooks/use-scroll-window";
import { dayKey, type ScrollWindowViewProps } from "@/lib/calendar-scroll-window";
@@ -87,18 +91,36 @@ export function CalendarWeekView({
const weekStart = (firstDayOfWeek === 0 ? 0 : firstDayOfWeek === 6 ? 6 : 1) as 0 | 1 | 6;
const gutterWidth = isMobile ? 40 : 56;
// One column per loaded day (#759). Seven columns fill the viewport on
// desktop; the strip scrolls sideways and widens at either end.
const days = useMemo(
// One column per loaded day (#759). A week's columns fill the viewport on
// desktop; the strip scrolls sideways and widens at either end. Days the
// user does not work on can be left out (#1164).
const allDays = useMemo(
() => eachDayOfInterval({ start: rangeStart, end: rangeEnd }),
[rangeStart, rangeEnd],
);
const hideNonWorkingDays = useSettingsStore((s) => s.calendarHideNonWorkingDays);
const workingDaysSetting = useSettingsStore((s) => s.calendarWorkingDays);
const workingDays = useMemo(
() => resolveWorkingDays(hideNonWorkingDays, workingDaysSetting),
[hideNonWorkingDays, workingDaysSetting],
);
const { days, shownIndexByDay } = useMemo(() => {
if (!workingDays) return { days: allDays, shownIndexByDay: null };
const shown: Date[] = [];
const indices = allDays.map((day) => {
if (!workingDays.has(day.getDay())) return -1;
shown.push(day);
return shown.length - 1;
});
return shown.length > 0 ? { days: shown, shownIndexByDay: indices } : { days: allDays, shownIndexByDay: null };
}, [allDays, workingDays]);
const colCount = days.length;
const columnsPerWeek = shownIndexByDay && workingDays ? workingDays.size : 7;
const measureColWidth = useCallback((root: HTMLElement | null) => {
if (isMobile || !root) return MOBILE_COL_WIDTH;
return Math.max(MIN_COL_WIDTH, Math.floor((root.clientWidth - gutterWidth) / 7));
}, [isMobile, gutterWidth]);
return Math.max(MIN_COL_WIDTH, Math.floor((root.clientWidth - gutterWidth) / columnsPerWeek));
}, [isMobile, gutterWidth, columnsPerWeek]);
const [colWidth, setColWidth] = useState(MOBILE_COL_WIDTH);
const [viewportWidth, setViewportWidth] = useState(0);
// First day in view, reported by the scroll handler below (#1293)
@@ -160,29 +182,40 @@ export function CalendarWeekView({
return timed;
}, [events]);
const {
hours, canToggle: canToggleHours, configured: configuredHours, showAllHours, toggleAllHours, revealMinutes,
} = useDisplayHours({ scrollRef: rootRef, hourHeight: HOUR_HEIGHT });
const firstHour = hours.startMinutes / 60;
const visibleHours = HOURS.slice(firstHour, hours.endMinutes / 60);
const gridHeight = visibleHours.length * HOUR_HEIGHT;
// Column layouts are the costly part of a render; with months of columns
// they must not be redone on every scroll-driven re-render.
const layoutByDay = useMemo(() => {
const map = new Map<string, ReturnType<typeof layoutOverlappingEvents>>();
const map = new Map<string, ReturnType<typeof partitionByDisplayHours> & { layouts: ReturnType<typeof layoutOverlappingEvents> }>();
for (const day of days) {
const key = format(day, "yyyy-MM-dd");
map.set(key, layoutOverlappingEvents(timedEvents.get(key) || [], day));
const partition = partitionByDisplayHours(timedEvents.get(key) || [], day, hours);
map.set(key, { ...partition, layouts: layoutOverlappingEvents(partition.visible, day) });
}
return map;
}, [days, timedEvents]);
}, [days, timedEvents, hours]);
// Built over the consecutive days and then moved onto the drawn columns,
// so an event across a hidden weekend still spans Friday to Monday.
const allDaySegments = useMemo(() => {
const explicitAllDay = buildWeekSegmentsRaw(
events.filter((event) => event.showWithoutTime),
days,
allDays,
);
const timedFullDay = buildTimedFullDayWeekSegments(
events.filter((event) => !event.showWithoutTime),
days,
allDays,
);
const segments = [...explicitAllDay, ...timedFullDay];
return packWeekSegments([...explicitAllDay, ...timedFullDay]);
}, [events, days]);
return packWeekSegments(shownIndexByDay ? remapSegmentsToShownDays(segments, shownIndexByDay) : segments);
}, [events, allDays, shownIndexByDay]);
const tasksByDay = useMemo(() => groupTasksByDueDay(tasks), [tasks]);
@@ -207,9 +240,9 @@ export function CalendarWeekView({
const tracked = visibleDayKey ? days.findIndex((d) => dayKey(d) === visibleDayKey) : -1;
if (tracked >= 0) return tracked;
const target = isMobile ? focus.date : startOfWeek(focus.date, { weekStartsOn: weekStart });
return Math.max(0, Math.min(days.length - 1, differenceInCalendarDays(target, rangeStart)));
}, [visibleDayKey, days, isMobile, focus.date, weekStart, rangeStart]);
const visibleColumns = isMobile ? Math.max(1, Math.ceil(viewportWidth / colWidth)) : 7;
return indexOfDayOnOrAfter(days, target);
}, [visibleDayKey, days, isMobile, focus.date, weekStart]);
const visibleColumns = isMobile ? Math.max(1, Math.ceil(viewportWidth / colWidth)) : columnsPerWeek;
// Max combined rows (events + tasks) across the visible days
const maxContentRows = useMemo(() => {
@@ -234,22 +267,14 @@ export function CalendarWeekView({
const visibleRows = isAllDayExpanded ? maxContentRows : Math.min(DEFAULT_ALL_DAY_MAX_ROWS, maxContentRows);
const allDayHeight = Math.max(28, visibleRows * 24 + 4);
useEffect(() => {
if (rootRef.current) {
const now = displayNow();
rootRef.current.scrollTop = Math.max(0, (now.getHours() - 1) * HOUR_HEIGHT);
}
}, []);
// Navigation aligns the focused week (the focused day itself on mobile,
// where fewer columns fit) with the start of the viewport.
const scrollToFocus = useCallback(() => {
const root = rootRef.current;
if (!root) return;
const target = isMobile ? focus.date : startOfWeek(focus.date, { weekStartsOn: weekStart });
const index = Math.max(0, Math.min(colCount - 1, differenceInCalendarDays(target, rangeStart)));
setScrollStart(root, "horizontal", index * colWidth);
}, [focus.date, isMobile, weekStart, colCount, rangeStart, colWidth]);
setScrollStart(root, "horizontal", indexOfDayOnOrAfter(days, target) * colWidth);
}, [focus.date, isMobile, weekStart, days, colWidth]);
useScrollWindow({
scrollRef: rootRef,
@@ -322,6 +347,8 @@ export function CalendarWeekView({
dropTarget, handleColumnDragOver, handleColumnDragLeave, handleColumnDrop,
} = useTimeGridInteractions({
hourHeight: HOUR_HEIGHT,
gridStartMinutes: hours.startMinutes,
gridEndMinutes: hours.endMinutes,
calendars,
onCreateRange: onCreateAtTime,
errorMessages: {
@@ -460,7 +487,16 @@ export function CalendarWeekView({
)}
<div className="flex border-b border-border" role="row">
<div className={gutterClass} />
<div className={cn(gutterClass, "flex items-end justify-center pb-1")}>
{canToggleHours && (
<AllHoursToggle
showAllHours={showAllHours}
configured={configuredHours}
timeFormat={timeFormat}
onToggle={toggleAllHours}
/>
)}
</div>
<div className="border-s border-border grid" style={columnsStyle}>
{days.map((day) => {
const todayCol = isDisplayToday(day);
@@ -498,16 +534,22 @@ export function CalendarWeekView({
</div>
<div>
<div className="flex relative" style={{ height: 24 * HOUR_HEIGHT }}>
<div className="flex relative" style={{ height: gridHeight }}>
<div className={gutterClass}>
{HOURS.map((h) => (
{visibleHours.map((h) => (
<div
key={h}
className="relative text-muted-foreground text-end pe-2"
style={{ height: HOUR_HEIGHT }}
>
{h > 0 && (
<span className={cn("absolute top-0 right-2 -translate-y-1/2 leading-none", isMobile ? "text-[9px]" : "text-[10px]")}>
{/* Midnight goes unlabelled; a later first hour is labelled
inside its row, where the header cannot cover it. */}
{(h > firstHour || firstHour > 0) && (
<span className={cn(
"absolute right-2 leading-none",
h > firstHour ? "top-0 -translate-y-1/2" : "top-1",
isMobile ? "text-[9px]" : "text-[10px]",
)}>
{formatHour(h)}
</span>
)}
@@ -519,7 +561,8 @@ export function CalendarWeekView({
{days.map((day) => {
const key = format(day, "yyyy-MM-dd");
const todayCol = isDisplayToday(day);
const layouted = layoutByDay.get(key) ?? [];
const dayLayout = layoutByDay.get(key);
const layouted = dayLayout?.layouts ?? [];
return (
<div
@@ -534,7 +577,7 @@ export function CalendarWeekView({
onDragLeave={handleColumnDragLeave}
onDrop={(e) => handleColumnDrop(e, day)}
>
{HOURS.map((h) => (
{visibleHours.map((h) => (
<div
key={h}
role="gridcell"
@@ -547,10 +590,30 @@ export function CalendarWeekView({
/>
))}
{dayLayout && dayLayout.before > 0 && (
<HiddenEventsIndicator
count={dayLayout.before}
direction="before"
onReveal={() => revealMinutes(dayLayout.firstBeforeMinutes ?? 0)}
/>
)}
{dayLayout && dayLayout.after > 0 && (
<HiddenEventsIndicator
count={dayLayout.after}
direction="after"
onReveal={() => revealMinutes(dayLayout.firstAfterMinutes ?? hours.endMinutes)}
/>
)}
{layouted.map(({ event: ev, column, totalColumns, startMinutes, endMinutes }) => {
const durMin = Math.max(15, endMinutes - startMinutes);
const baseTop = (startMinutes / 60) * HOUR_HEIGHT;
const baseHeight = Math.max(20, (durMin / 60) * HOUR_HEIGHT);
const clip = clipToDisplayHours(startMinutes, endMinutes, hours);
const baseHeight = Math.max(20, (Math.max(15, clip.endMinutes - clip.startMinutes) / 60) * HOUR_HEIGHT);
// Short events at the bottom edge stay inside the grid.
const baseTop = Math.max(0, Math.min(
((clip.startMinutes - hours.startMinutes) / 60) * HOUR_HEIGHT,
gridHeight - baseHeight,
));
const isResizing = resizeVisual?.eventId === ev.id;
const top = isResizing ? resizeVisual!.topPx : baseTop;
const height = isResizing ? resizeVisual!.heightPx : baseHeight;
@@ -576,6 +639,8 @@ export function CalendarWeekView({
currentUserEmails={currentUserEmails}
draggable
/>
{/* An edge beyond the visible hours has nothing to grab. */}
{!clip.clippedStart && (
<div
data-resize-handle
className="absolute top-0 left-1 right-1 h-3 cursor-n-resize z-20 flex items-start justify-center opacity-0 group-hover/event:opacity-100 transition-opacity"
@@ -586,6 +651,8 @@ export function CalendarWeekView({
>
<div className="w-8 h-1 rounded-full bg-foreground/30 mt-0.5" />
</div>
)}
{!clip.clippedEnd && (
<div
data-resize-handle
className="absolute bottom-0 left-1 right-1 h-3 cursor-s-resize z-20 flex items-end justify-center opacity-0 group-hover/event:opacity-100 transition-opacity"
@@ -596,14 +663,15 @@ export function CalendarWeekView({
>
<div className="w-8 h-1 rounded-full bg-foreground/30 mb-0.5" />
</div>
)}
</div>
);
})}
{todayCol && (
{todayCol && nowMinutes >= hours.startMinutes && nowMinutes <= hours.endMinutes && (
<div
className="absolute left-0 right-0 z-20 pointer-events-none"
style={{ top: (nowMinutes / 60) * HOUR_HEIGHT }}
style={{ top: ((nowMinutes - hours.startMinutes) / 60) * HOUR_HEIGHT }}
>
<div className="flex items-center">
<div className="w-2 h-2 rounded-full bg-destructive -ms-1" />
@@ -624,7 +692,7 @@ export function CalendarWeekView({
<div
className="absolute left-1 right-1 z-30 rounded-md pointer-events-none bg-primary/15 border-2 border-primary/30 border-dashed"
style={{
top: (dragCreate.startMinutes / 60) * HOUR_HEIGHT,
top: ((dragCreate.startMinutes - hours.startMinutes) / 60) * HOUR_HEIGHT,
height: ((dragCreate.endMinutes - dragCreate.startMinutes) / 60) * HOUR_HEIGHT,
}}
>
@@ -637,7 +705,7 @@ export function CalendarWeekView({
{dropTarget?.dayKey === key && (
<div
className="absolute left-0 right-0 z-30 pointer-events-none"
style={{ top: (dropTarget.minutes / 60) * HOUR_HEIGHT }}
style={{ top: ((dropTarget.minutes - hours.startMinutes) / 60) * HOUR_HEIGHT }}
>
<div className="flex items-center">
<div className="w-2 h-2 rounded-full bg-primary -ms-1" />
@@ -655,14 +723,16 @@ export function CalendarWeekView({
let endMin = pendingPreview.end.getHours() * 60 + pendingPreview.end.getMinutes();
if (endMin <= startMin) endMin = 1440;
const durationMin = Math.max(15, endMin - startMin);
if (startMin + durationMin <= hours.startMinutes || startMin >= hours.endMinutes) return null;
const clip = clipToDisplayHours(startMin, startMin + durationMin, hours);
const cal = calendars.find(c => c.id === pendingPreview.calendarId);
const color = cal?.color || "hsl(var(--primary))";
return (
<div
className="absolute left-1 right-1 z-10 rounded-md pointer-events-none border-2 border-dashed overflow-hidden"
style={{
top: (startMin / 60) * HOUR_HEIGHT,
height: Math.max(20, (durationMin / 60) * HOUR_HEIGHT),
top: ((clip.startMinutes - hours.startMinutes) / 60) * HOUR_HEIGHT,
height: Math.max(20, ((clip.endMinutes - clip.startMinutes) / 60) * HOUR_HEIGHT),
borderColor: color,
backgroundColor: `${color}10`,
}}
@@ -0,0 +1,78 @@
"use client";
import { useTranslations } from "next-intl";
import { cn } from "@/lib/utils";
import { ChevronDown, ChevronUp, FoldVertical, UnfoldVertical } from "@/components/icons";
import { formatDisplayHour, type DisplayHours } from "@/lib/calendar-display-range";
interface HiddenEventsIndicatorProps {
count: number;
direction: "before" | "after";
onReveal: () => void;
}
/**
* Marks a day column that has events above or below the visible hours
* (#1164); clicking it shows the whole day at the first of them.
*/
export function HiddenEventsIndicator({ count, direction, onReveal }: HiddenEventsIndicatorProps) {
const t = useTranslations("calendar.events");
const label = direction === "before" ? t("hidden_before", { count }) : t("hidden_after", { count });
const Icon = direction === "before" ? ChevronUp : ChevronDown;
return (
<button
type="button"
data-hidden-events={direction}
title={label}
aria-label={label}
// The column starts drag-to-create on pointer down.
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); onReveal(); }}
className={cn(
"absolute inset-x-0 mx-auto w-fit z-20 flex items-center gap-0.5 rounded-full",
"bg-muted/90 px-1.5 py-0.5 text-[10px] leading-none text-muted-foreground",
"hover:bg-accent hover:text-foreground transition-colors cursor-pointer",
direction === "before" ? "top-0.5" : "bottom-0.5",
)}
>
<Icon className="h-3 w-3" />
<span>{count}</span>
</button>
);
}
interface AllHoursToggleProps {
showAllHours: boolean;
configured: DisplayHours;
timeFormat: "12h" | "24h";
onToggle: () => void;
className?: string;
}
/** Switches a time grid between the configured hours and the whole day. */
export function AllHoursToggle({ showAllHours, configured, timeFormat, onToggle, className }: AllHoursToggleProps) {
const t = useTranslations("calendar.events");
const label = showAllHours
? t("show_display_hours", {
start: formatDisplayHour(configured.startMinutes / 60, timeFormat),
end: formatDisplayHour(configured.endMinutes / 60, timeFormat),
})
: t("show_all_hours");
const Icon = showAllHours ? FoldVertical : UnfoldVertical;
return (
<button
type="button"
data-all-hours-toggle
aria-pressed={showAllHours}
aria-label={label}
title={label}
onClick={onToggle}
className={cn(
"flex items-center justify-center rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors cursor-pointer",
className,
)}
>
<Icon className="h-3.5 w-3.5" />
</button>
);
}
+3 -23
View File
@@ -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);
+3
View File
@@ -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');
});
});
@@ -0,0 +1,98 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import type { Email, Mailbox } from '@/lib/jmap/types';
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
vi.mock('../rules-menu', () => ({ RulesContextSubMenu: () => null }));
vi.mock('@/hooks/use-copy-link', () => ({ useCopyLink: () => vi.fn() }));
const { EmailContextMenu } = await import('../email-context-menu');
const rights = { mayAddItems: true } as Mailbox['myRights'];
function mailbox(id: string, extra: Partial<Mailbox> = {}): Mailbox {
return { id, name: id, role: undefined, parentId: undefined, myRights: rights, ...extra } as unknown as Mailbox;
}
const email = { id: 'e1', keywords: { $seen: true }, mailboxIds: { inbox: true } } as unknown as Email;
const ownMailboxes = [mailbox('inbox', { role: 'inbox', name: 'Inbox' }), mailbox('archive', { role: 'archive', name: 'Archive' })];
const otherMailboxes = [
mailbox('b-inbox', { role: 'inbox', name: 'Inbox' }),
mailbox('b-drafts', { role: 'drafts', name: 'Drafts' }),
mailbox('b-work', { name: 'Work' }),
mailbox('b-readonly', { name: 'Read only', myRights: { mayAddItems: false } as Mailbox['myRights'] }),
];
function renderMenu(props: Partial<React.ComponentProps<typeof EmailContextMenu>> = {}) {
return render(
<EmailContextMenu
email={email}
position={{ x: 0, y: 0 }}
isOpen
onClose={() => {}}
menuRef={{ current: null }}
mailboxes={ownMailboxes}
selectedMailbox="inbox"
{...props}
/>,
);
}
describe('Copy to entry of the message menu', () => {
it('is hidden without another connected account', () => {
renderMenu({ onCopyToAccount: vi.fn(), copyTargets: [] });
expect(screen.queryByTestId('ctx-copy-to')).toBeNull();
});
it('lists the writable folders of the other accounts and copies into the picked one', () => {
const onCopyToAccount = vi.fn();
renderMenu({
onCopyToAccount,
copyTargets: [{ accountId: 'local-B', label: 'bob@example.org', mailboxes: otherMailboxes }],
});
fireEvent.mouseEnter(screen.getByTestId('ctx-copy-to').parentElement!);
expect(screen.getByText('bob@example.org')).toBeTruthy();
expect(screen.getByTestId('copy-to:local-B:b-inbox')).toBeTruthy();
expect(screen.getByTestId('copy-to:local-B:b-work')).toBeTruthy();
expect(screen.queryByTestId('copy-to:local-B:b-drafts')).toBeNull();
expect(screen.queryByTestId('copy-to:local-B:b-readonly')).toBeNull();
fireEvent.click(screen.getByTestId('copy-to:local-B:b-work'));
expect(onCopyToAccount).toHaveBeenCalledWith('local-B', 'b-work');
});
it('leaves Move to on the current account', () => {
renderMenu({
onCopyToAccount: vi.fn(),
copyTargets: [{ accountId: 'local-B', label: 'bob@example.org', mailboxes: otherMailboxes }],
});
fireEvent.mouseEnter(screen.getByTestId('ctx-move-to').parentElement!);
expect(screen.getByTestId('move-to:archive')).toBeTruthy();
expect(screen.queryByTestId('move-to:b-work')).toBeNull();
});
it('offers the shared account folders first for a message in a shared mailbox (#1149)', () => {
const sharedBox = (id: string, name: string, role?: string) =>
mailbox(`S:${id}`, { name, role, originalId: id, accountId: 'S', accountName: 'info@example.org', isShared: true });
const mailboxes = [
mailbox('inbox', { role: 'inbox', name: 'Inbox', accountId: 'A', accountName: 'me@example.org' }),
mailbox('junk', { role: 'junk', name: 'Junk', accountId: 'A', accountName: 'me@example.org' }),
sharedBox('inbox', 'Inbox', 'inbox'),
sharedBox('junk', 'Junk', 'junk'),
];
const onMoveToMailbox = vi.fn();
renderMenu({
email: { id: 'e1', keywords: { $seen: true }, mailboxIds: { 'S:inbox': true } } as unknown as Email,
mailboxes,
selectedMailbox: 'S:inbox',
onMoveToMailbox,
});
fireEvent.mouseEnter(screen.getByTestId('ctx-move-to').parentElement!);
const items = screen.getAllByTestId(/^move-to:/).map((el) => el.getAttribute('data-testid'));
expect(items).toEqual(['move-to:S:junk', 'move-to:inbox', 'move-to:junk']);
fireEvent.click(screen.getByTestId('move-to:S:junk'));
expect(onMoveToMailbox).toHaveBeenCalledWith('S:junk');
});
});
@@ -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 });
});
});
@@ -53,6 +53,15 @@ describe('TagPicker', () => {
expect(onToggle).toHaveBeenCalledWith('work/clients');
});
it('draws a tag only part of a selection carries as mixed', () => {
render(<TagPicker selectedIds={['work']} partialIds={['personal', 'from-elsewhere']} onToggle={() => {}} />);
expect(screen.getByText('Work').closest('button')).toHaveAttribute('aria-checked', 'true');
expect(screen.getByText('Personal').closest('button')).toHaveAttribute('aria-checked', 'mixed');
// An undefined tag on part of the selection is still listed, so it can come off.
expect(screen.getByText('from-elsewhere').closest('button')).toHaveAttribute('aria-checked', 'mixed');
});
it('lists a tag it has no definition for, so it can be taken off', () => {
const onToggle = vi.fn();
const { rerender } = render(<TagPicker selectedIds={['from-elsewhere']} onToggle={onToggle} />);
+106
View File
@@ -0,0 +1,106 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { Button } from "@/components/ui/button";
import { Tag } from "@/components/icons";
import { TagPicker } from "./tag-picker";
const TAG_PICKER_WIDTH = 224;
/**
* The tag entry of the selection toolbar: opens the shared tag picker for
* every selected message at once. (#1077)
*
* The picker is portalled to the body because the toolbar clips its overflow
* to animate open and shut.
*/
export function BatchTagButton({
title,
selectedIds,
partialIds,
onToggle,
active,
disabled,
}: {
title: string;
/** Tags every selected message carries. */
selectedIds: string[];
/** Tags only some of them carry. */
partialIds: string[];
onToggle: (tagId: string) => void;
/** Whether there is a selection to tag; the picker shuts without one. */
active: boolean;
disabled?: boolean;
}) {
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
const pickerRef = useRef<HTMLDivElement | null>(null);
const buttonRef = useRef<HTMLButtonElement | null>(null);
const close = useCallback(() => setPos(null), []);
const isOpen = pos !== null && active;
useEffect(() => {
if (!pos) return;
const onPointerDown = (event: PointerEvent) => {
const target = event.target as Node;
// The anchor is left to its own click handler, which toggles the picker
// shut - closing here first would let that click reopen it.
if (buttonRef.current?.contains(target)) return;
if (!pickerRef.current?.contains(target)) close();
};
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") close();
};
document.addEventListener("pointerdown", onPointerDown, true);
document.addEventListener("keydown", onKeyDown);
window.addEventListener("resize", close);
return () => {
document.removeEventListener("pointerdown", onPointerDown, true);
document.removeEventListener("keydown", onKeyDown);
window.removeEventListener("resize", close);
};
}, [pos, close]);
return (
<>
<Button
ref={buttonRef}
variant="ghost"
size="sm"
data-testid="batch-tag"
onClick={(event) => {
const rect = event.currentTarget.getBoundingClientRect();
setPos((open) =>
open
? null
: {
top: rect.bottom + 4,
left: Math.max(8, Math.min(rect.left, window.innerWidth - TAG_PICKER_WIDTH - 8)),
},
);
}}
title={title}
aria-label={title}
aria-haspopup="menu"
aria-expanded={isOpen}
disabled={disabled}
className="hover:bg-accent transition-colors disabled:opacity-50"
>
<Tag className="w-4 h-4" />
</Button>
{isOpen &&
createPortal(
<div
ref={pickerRef}
data-testid="batch-tag-picker"
role="menu"
className="fixed z-50 w-56 max-w-[18rem] rounded-lg border border-border bg-popover py-1 shadow-lg"
style={{ top: pos.top, left: pos.left }}
>
<TagPicker selectedIds={selectedIds} partialIds={partialIds} onToggle={onToggle} />
</div>,
document.body,
)}
</>
);
}
+82 -4
View File
@@ -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";
@@ -46,7 +49,7 @@ import { appendPlainTextSignature, getPlainTextSignature, plainTextBodyHasSignat
import { findComposeIdentityId, findDraftIdentityId, findReplyIdentityId, resolveReplyFrom } from "@/lib/reply-identity";
import { buildReplyRecipients, isSelfSent } from "@/lib/reply-recipients";
import { computeReplyThreadingHeaders, type ReplyThreadingHeaders } from "@/lib/email-threading";
import { RecipientsRejectedError, RequestTimeoutError, ScheduleTooLateError, formatRejectedRecipients } from "@/lib/jmap/client";
import { RecipientsRejectedError, RequestTimeoutError, ScheduleTooLateError, SendUnconfirmedError, formatRejectedRecipients } from "@/lib/jmap/client";
import {
rewriteCidImagesForEditor,
replaceInlineImagePlaceholders,
@@ -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,
@@ -2607,8 +2662,9 @@ export function EmailComposer({
// A timeout is not a clean failure: the submission may have reached the
// server and gone out, with only the answer lost. Saying "send failed"
// would invite a re-send and a duplicate, so point at Sent instead (#702).
// The same goes for a send the server answered without confirming it.
toast.error(
err instanceof RequestTimeoutError
err instanceof RequestTimeoutError || err instanceof SendUnconfirmedError
? t('send_timeout')
: err instanceof ScheduleTooLateError
? t('schedule_send_too_late')
@@ -2739,6 +2795,7 @@ export function EmailComposer({
if (
showTemplatePicker ||
filesPicker ||
showSaveAsTemplate ||
showScheduleDialog ||
showAttachmentWarning ||
@@ -3338,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"
@@ -3506,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
+115 -63
View File
@@ -35,10 +35,12 @@ import {
Paperclip,
Link as LinkIcon,
MessagesSquare,
Copy,
} from "@/components/icons";
import { buildMailPath } from "@/lib/deep-links";
import { useCopyLink } from "@/hooks/use-copy-link";
import { buildMailboxTree, MailboxNode } from "@/lib/utils";
import { buildMoveTargets, resolveMoveOwnerAccountId } from "@/lib/move-targets";
import { localizeMailboxName } from "@/lib/mailbox-label";
import { getEmailTagIds } from "@/lib/thread-utils";
import { TagPicker } from "./tag-picker";
@@ -49,6 +51,15 @@ interface Position {
y: number;
}
/** Another connected account the "Copy to" entry can copy into. */
export interface CopyTargetAccount {
/** The login (AccountEntry.id). */
accountId: string;
label: string;
/** That account's own folders, as its client returns them. */
mailboxes: Mailbox[];
}
interface EmailContextMenuProps {
email: Email;
position: Position;
@@ -85,11 +96,22 @@ interface EmailContextMenuProps {
onRescheduleScheduled?: () => void;
// Batch actions
onBatchMarkAsRead?: (read: boolean) => void;
/** Tags every selected message carries, and those only some of them do. */
batchTagIds?: string[];
batchPartialTagIds?: string[];
/** Takes the tag off when the whole selection has it, else puts it on all. */
onBatchToggleTag?: (tagId: string) => void;
onBatchDelete?: () => void;
onBatchArchive?: () => void;
onBatchMoveToMailbox?: (mailboxId: string) => void;
onBatchMarkAsSpam?: () => void;
onBatchUndoSpam?: () => void;
/**
* Other connected accounts to offer under "Copy to". Copies the selection
* (or the single message) and keeps the originals. Hidden when empty.
*/
copyTargets?: CopyTargetAccount[];
onCopyToAccount?: (accountId: string, mailboxId: string) => void;
}
// Get mailbox icon based on role
@@ -136,6 +158,9 @@ export function EmailContextMenu({
onMarkAsSpam,
onUndoSpam,
onBatchMarkAsRead,
batchTagIds,
batchPartialTagIds,
onBatchToggleTag,
onBatchDelete,
onBatchArchive,
onBatchMoveToMailbox,
@@ -144,6 +169,8 @@ export function EmailContextMenu({
onEditDraft,
onCancelScheduledForEdit,
onRescheduleScheduled,
copyTargets,
onCopyToAccount,
}: EmailContextMenuProps) {
const t = useTranslations("context_menu");
const tSidebar = useTranslations("sidebar");
@@ -163,31 +190,67 @@ export function EmailContextMenu({
const isScheduled = email.isScheduled === true;
const canCancelScheduled = isScheduled && email.scheduledUndoStatus === 'pending';
// Build mailbox tree for move-to submenu with proper hierarchy
const moveTargetIds = new Set(
mailboxes
.filter(
(m) =>
m.id !== selectedMailbox &&
m.role !== "drafts" &&
!m.id.startsWith("shared-") &&
m.myRights?.mayAddItems
)
.map((m) => m.id)
);
const mailboxTree = buildMailboxTree(mailboxes);
// Move-to submenu: the message's own account first (#1149)
const { tree: moveTree, targetIds: moveTargetIds } = buildMoveTargets(mailboxes, {
currentMailboxId: selectedMailbox,
ownerAccountId: resolveMoveOwnerAccountId(email, mailboxes, selectedMailbox),
});
// Filter tree to only include branches that contain valid move targets
const filterTree = (nodes: MailboxNode[]): MailboxNode[] => {
// Filter tree to only include branches that contain valid targets
const filterTree = (nodes: MailboxNode[], targetIds: Set<string>): MailboxNode[] => {
return nodes.reduce<MailboxNode[]>((acc, node) => {
const filteredChildren = filterTree(node.children);
if (moveTargetIds.has(node.id) || filteredChildren.length > 0) {
const filteredChildren = filterTree(node.children, targetIds);
if (targetIds.has(node.id) || filteredChildren.length > 0) {
acc.push({ ...node, children: filteredChildren });
}
return acc;
}, []);
};
const moveTree = filterTree(mailboxTree);
// Folders of the other connected accounts a copy can land in.
const copyTrees = (copyTargets ?? [])
.map((target) => {
const targetIds = new Set(
target.mailboxes
.filter((m) => !m.isShared && m.role !== "drafts" && m.myRights?.mayAddItems)
.map((m) => m.id)
);
return { ...target, targetIds, tree: filterTree(buildMailboxTree(target.mailboxes), targetIds) };
})
.filter((target) => target.tree.length > 0);
const renderMailboxNodes = (
nodes: MailboxNode[],
targetIds: Set<string>,
testIdPrefix: string,
onPick: (mailboxId: string) => void,
): React.ReactNode =>
nodes.map((node) => {
const Icon = getMailboxIcon(node.role);
const nodeLabel = localizeMailboxName(node.role, node.name, (k) => tSidebar(`mailboxes.${k}`));
return (
<div key={node.id}>
{targetIds.has(node.id) ? (
<ContextMenuItem
icon={Icon}
label={nodeLabel}
testId={`${testIdPrefix}${node.id}`}
onClick={() => handleAction(() => onPick(node.id))}
/>
) : (
<div className="px-3 py-1.5 text-sm flex items-center gap-2 text-muted-foreground">
<Icon className="w-4 h-4 flex-shrink-0" />
<span>{nodeLabel}</span>
</div>
)}
{node.children.length > 0 && (
<div className="ps-4">
{renderMailboxNodes(node.children, targetIds, testIdPrefix, onPick)}
</div>
)}
</div>
);
});
const handleAction = (action: () => void) => {
action();
@@ -327,44 +390,25 @@ export function EmailContextMenu({
{/* Move to submenu */}
{moveTree.length > 0 && (
<ContextMenuSubMenu icon={FolderInput} label={t("move_to")} testId="ctx-move-to">
{(() => {
const renderNodes = (nodes: MailboxNode[]) => {
return nodes.map((node) => {
const Icon = getMailboxIcon(node.role);
const isTarget = moveTargetIds.has(node.id);
const nodeLabel = localizeMailboxName(node.role, node.name, (k) => tSidebar(`mailboxes.${k}`));
return (
<div key={node.id}>
{isTarget ? (
<ContextMenuItem
icon={Icon}
label={nodeLabel}
testId={`move-to:${node.id}`}
onClick={() =>
handleAction(() =>
showBatchActions
? onBatchMoveToMailbox?.(node.id)
: onMoveToMailbox?.(node.id)
)
}
/>
) : (
<div className="px-3 py-1.5 text-sm flex items-center gap-2 text-muted-foreground">
<Icon className="w-4 h-4 flex-shrink-0" />
<span>{nodeLabel}</span>
</div>
)}
{node.children.length > 0 && (
<div className="ps-4">
{renderNodes(node.children)}
</div>
)}
</div>
);
});
};
return renderNodes(moveTree);
})()}
{renderMailboxNodes(moveTree, moveTargetIds, "move-to:", (mailboxId) =>
showBatchActions
? onBatchMoveToMailbox?.(mailboxId)
: onMoveToMailbox?.(mailboxId)
)}
</ContextMenuSubMenu>
)}
{/* Copy to another connected account, keeping the originals */}
{onCopyToAccount && copyTrees.length > 0 && (
<ContextMenuSubMenu icon={Copy} label={t("copy_to_account")} testId="ctx-copy-to">
{copyTrees.map((target) => (
<div key={target.accountId}>
<ContextMenuHeader>{target.label}</ContextMenuHeader>
{renderMailboxNodes(target.tree, target.targetIds, `copy-to:${target.accountId}:`, (mailboxId) =>
onCopyToAccount(target.accountId, mailboxId)
)}
</div>
))}
</ContextMenuSubMenu>
)}
@@ -391,14 +435,22 @@ export function EmailContextMenu({
/>
)}
{/* Set tag submenu - only for single email */}
{!showBatchActions && (
<ContextMenuSubMenu icon={Tag} label={t("tag")}>
{/* Set tag submenu - the row's own tags, or the whole selection's (#1077) */}
{(!showBatchActions || onBatchToggleTag) && (
<ContextMenuSubMenu icon={Tag} label={t("tag")} testId="ctx-tag">
<div className="w-56 max-w-[18rem]">
<TagPicker
selectedIds={currentTagIds}
onToggle={(tagId) => onSetTag?.(tagId)}
/>
{showBatchActions ? (
<TagPicker
selectedIds={batchTagIds ?? []}
partialIds={batchPartialTagIds}
onToggle={(tagId) => onBatchToggleTag?.(tagId)}
/>
) : (
<TagPicker
selectedIds={currentTagIds}
onToggle={(tagId) => onSetTag?.(tagId)}
/>
)}
</div>
</ContextMenuSubMenu>
)}
+83 -2
View File
@@ -6,7 +6,8 @@ import type { Attachment } from "@/lib/jmap/types";
import type { LoadListAttachments } from "@/lib/list-attachments";
import { listRowShowsChips } from "./attachment-chips";
import { listVerificationCode } from "@/lib/verification-code";
import { EmailContextMenu } from "./email-context-menu";
import { EmailContextMenu, type CopyTargetAccount } from "./email-context-menu";
import { BatchTagButton } from "./batch-tag-button";
import { cn, cleanPreview } from "@/lib/utils";
import { Trash2, Mail, MailX, MailOpen, Loader2, SearchX, AlertTriangle, CalendarClock, ShieldCheck } from "@/components/icons";
import { useState, useEffect, useLayoutEffect, useRef, useCallback, useMemo } from "react";
@@ -14,10 +15,11 @@ import { Button } from "@/components/ui/button";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { useEmailStore, ArchiveMailboxNotFoundError } from "@/stores/email-store";
import { useAuthStore } from "@/stores/auth-store";
import { useAccountStore } from "@/stores/account-store";
import { useSettingsStore } from "@/stores/settings-store";
import { useUIStore } from "@/stores/ui-store";
import { useMessageListTabsStore } from "@/stores/message-list-tabs-store";
import { emailKeyFor, groupEmailsByThread, sortThreadGroups, threadKeyFor } from "@/lib/thread-utils";
import { emailKeyFor, groupEmailsByThread, sortThreadGroups, threadKeyFor, getEmailTagIds } from "@/lib/thread-utils";
import { useContextMenu } from "@/hooks/use-context-menu";
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
import { useTranslations } from "next-intl";
@@ -104,6 +106,7 @@ export function EmailList({
selectAllEmails: _selectAllEmails,
clearSelection,
batchMarkAsRead,
batchSetTag,
batchDelete,
batchMoveToMailbox,
batchArchive,
@@ -194,6 +197,35 @@ export function EmailList({
}, [contextMenuEmail, selectedEmailKeys, emails, threadGroups, threadEmailsCache]);
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
// "Copy to" lists the folders of the other connected accounts. Only with
// more than one account connected; the menu's own account is left out.
const accounts = useAccountStore((state) => state.accounts);
const accountMailboxes = useEmailStore((state) => state.accountMailboxes);
const fetchAccountMailboxes = useEmailStore((state) => state.fetchAccountMailboxes);
const copyEmailsToAccount = useEmailStore((state) => state.copyEmailsToAccount);
const copySourceLogin = contextMenuEmail
? contextMenuEmail.sourceClientAccountId ?? viewingAccountId ?? activeAccountId
: null;
const copyAccounts = useMemo(() => {
if (!contextMenu.isOpen) return [];
const connected = useAuthStore.getState().getAllConnectedClients();
if (connected.size < 2) return [];
return accounts.filter((account) => account.id !== copySourceLogin && connected.has(account.id));
}, [contextMenu.isOpen, accounts, copySourceLogin]);
useEffect(() => {
for (const account of copyAccounts) {
if (accountMailboxes[account.id]) continue;
const accountClient = useAuthStore.getState().getClientForAccount(account.id);
if (accountClient) void fetchAccountMailboxes(accountClient, account.id);
}
}, [copyAccounts, accountMailboxes, fetchAccountMailboxes]);
const copyTargets = useMemo<CopyTargetAccount[]>(
() => copyAccounts
.filter((account) => accountMailboxes[account.id])
.map((account) => ({ accountId: account.id, label: account.label || account.email || account.id, mailboxes: accountMailboxes[account.id] })),
[copyAccounts, accountMailboxes],
);
const [isProcessing, setIsProcessing] = useState(false);
const parentRef = useRef<HTMLDivElement>(null);
const batchToolbarRef = useRef<HTMLDivElement>(null);
@@ -331,6 +363,32 @@ export function EmailList({
}
};
// What the tag picker shows for the selection: a tag on every selected
// message is checked, one on only some of them is drawn as partial.
const selectionTags = useMemo(() => {
const selected = emails.filter((email) => selectedEmailKeys.has(emailKeyFor(email)));
const counts = new Map<string, number>();
for (const email of selected) {
for (const tagId of getEmailTagIds(email.keywords)) counts.set(tagId, (counts.get(tagId) ?? 0) + 1);
}
const all: string[] = [];
const some: string[] = [];
for (const [tagId, count] of counts) (count === selected.length ? all : some).push(tagId);
return { all, some };
}, [emails, selectedEmailKeys]);
// A tag every selected message has comes off; any other goes onto all of
// them. The selection stays, so several tags can be changed in a row.
const handleBatchToggleTag = async (tagId: string) => {
if (!client) return;
try {
await batchSetTag(client, tagId, !selectionTags.all.includes(tagId));
} catch (error) {
console.error("Failed to tag emails:", error);
toast.error(tNotifications('error_updating'));
}
};
const handleBatchUndoSpam = async () => {
if (!client || isProcessing) return;
setIsProcessing(true);
@@ -509,6 +567,15 @@ export function EmailList({
<Mail className="w-4 h-4" />
)}
</Button>
<BatchTagButton
key={hasSelection ? 'selection' : 'none'}
title={tContextMenu('tag')}
selectedIds={selectionTags.all}
partialIds={selectionTags.some}
onToggle={handleBatchToggleTag}
active={hasSelection && !isScheduledView}
disabled={isProcessing}
/>
{effectiveMailboxRole === 'junk' && (
<Button
variant="ghost"
@@ -736,7 +803,21 @@ export function EmailList({
onEditDraft={() => onEditDraft?.(contextMenuEmail!)}
onCancelScheduledForEdit={onCancelScheduledForEdit ? () => onCancelScheduledForEdit(contextMenuEmail!) : undefined}
onRescheduleScheduled={onRescheduleScheduled ? () => onRescheduleScheduled(contextMenuEmail!) : undefined}
copyTargets={copyTargets}
onCopyToAccount={async (accountId, mailboxId) => {
const ids = selectedEmailKeys.has(emailKeyFor(contextMenuEmail!)) && selectedEmailKeys.size > 1
? emails.filter((email) => selectedEmailKeys.has(emailKeyFor(email))).map((email) => email.id)
: [contextMenuEmail!.id];
const target = accountMailboxes[accountId]?.find((mb) => mb.id === mailboxId);
await runBatchEmailAction(() => copyEmailsToAccount(ids, accountId, target?.originalId ?? mailboxId), {
success: tNotifications('emails_copied', { count: ids.length }),
error: tNotifications('copy_failed'),
});
}}
onBatchMarkAsRead={(read) => client && batchMarkAsRead(client, read)}
batchTagIds={selectionTags.all}
batchPartialTagIds={selectionTags.some}
onBatchToggleTag={handleBatchToggleTag}
onBatchDelete={async () => {
if (!client) return;
const count = selectedEmailKeys.size;
+82 -44
View File
@@ -14,14 +14,15 @@ import { buildContactsPath, buildMailPath } from "@/lib/deep-links";
import { useCopyLink } from "@/hooks/use-copy-link";
import { Button } from "@/components/ui/button";
import { Avatar } from "@/components/ui/avatar";
import { formatFileSize, cn, buildMailboxTree, MailboxNode, formatDateTime, generateUUID } from "@/lib/utils";
import { formatFileSize, cn, MailboxNode, formatDateTime, generateUUID } from "@/lib/utils";
import { buildMoveTargets, resolveMoveOwnerAccountId } from "@/lib/move-targets";
import { emailDisplayDate } from "@/lib/email-date";
import { TagBadge } from "./tag-badge";
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 {
@@ -131,6 +132,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;
@@ -600,7 +603,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;
}
@@ -637,6 +640,7 @@ function SidebarSection({ icon: Icon, title, children }: { icon: React.Component
export function EmailViewer({
email,
blobSource,
isLoading = false,
onReply,
onReplyAll,
@@ -766,18 +770,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
@@ -1018,32 +1021,12 @@ export function EmailViewer({
}, [detailSidebarWidth]);
// Build mailbox tree for move-to dropdown
const moveTargetIds = useMemo(() => new Set(
mailboxes
.filter(
(m) =>
m.id !== selectedMailbox &&
m.role !== "drafts" &&
!m.id.startsWith("shared-") &&
m.myRights?.mayAddItems
)
.map((m) => m.id)
), [mailboxes, selectedMailbox]);
const moveTree = useMemo(() => {
const tree = buildMailboxTree(mailboxes);
const filterTree = (nodes: MailboxNode[]): MailboxNode[] => {
return nodes.reduce<MailboxNode[]>((acc, node) => {
const filteredChildren = filterTree(node.children);
if (moveTargetIds.has(node.id) || filteredChildren.length > 0) {
acc.push({ ...node, children: filteredChildren });
}
return acc;
}, []);
};
return filterTree(tree);
}, [mailboxes, moveTargetIds]);
// Move-to dropdown: the message's own account first (#1149)
const moveOwnerAccountId = resolveMoveOwnerAccountId(email, mailboxes, selectedMailbox);
const { tree: moveTree, targetIds: moveTargetIds } = useMemo(
() => buildMoveTargets(mailboxes, { currentMailboxId: selectedMailbox, ownerAccountId: moveOwnerAccountId }),
[mailboxes, selectedMailbox, moveOwnerAccountId],
);
// Get mailbox icon based on role
const getMoveMailboxIcon = (role?: string) => {
@@ -1387,7 +1370,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) {
@@ -1479,7 +1462,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(
@@ -1586,7 +1569,7 @@ export function EmailViewer({
};
}
if (!client || !email?.attachments) {
if (!blobClient || !email?.attachments) {
setCidBlobUrls({});
return;
}
@@ -2679,9 +2662,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;
@@ -3039,6 +3024,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"];
@@ -4530,6 +4528,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 && (
@@ -4835,12 +4847,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">
@@ -4866,7 +4902,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;
+14 -4
View File
@@ -2,7 +2,7 @@
import { useMemo, useState } from "react";
import { useTranslations } from "next-intl";
import { Check, Search } from "@/components/icons";
import { Check, Minus, Search } from "@/components/icons";
import { cn } from "@/lib/utils";
import { useSettingsStore } from "@/stores/settings-store";
import { buildKeywordTree, type KeywordNode } from "@/lib/keyword-nesting";
@@ -11,6 +11,8 @@ import { useKeywordFormat } from "@/hooks/use-keyword-format";
/** Below this many tags a filter box costs more room than it saves. */
const SEARCH_THRESHOLD = 10;
const NO_IDS: string[] = [];
/**
* The list of tags to apply to a message.
*
@@ -25,10 +27,16 @@ const SEARCH_THRESHOLD = 10;
*/
export function TagPicker({
selectedIds,
partialIds = NO_IDS,
onToggle,
touch = false,
}: {
selectedIds: string[];
/**
* Tags only some of the messages carry, when the picker stands for a
* selection of several. Drawn with a dash instead of a check.
*/
partialIds?: string[];
onToggle: (tagId: string) => void;
/** Larger hit areas for the mobile sheet. */
touch?: boolean;
@@ -49,11 +57,11 @@ export function TagPicker({
*/
const unknownIds = useMemo(
() =>
selectedIds
[...selectedIds, ...partialIds]
.filter((id) => !keywords.some((keyword) => keyword.id === id))
.sort((a, b) => tagName(a).localeCompare(tagName(b))),
// `tagName` is rebuilt whenever the definitions or the nesting setting change.
[selectedIds, keywords, tagName],
[selectedIds, partialIds, keywords, tagName],
);
const showSearch = keywords.length + unknownIds.length >= SEARCH_THRESHOLD;
@@ -82,12 +90,13 @@ export function TagPicker({
const renderRow = (id: string, label: string) => {
const isActive = selectedIds.includes(id);
const isPartial = !isActive && partialIds.includes(id);
return (
<button
key={id}
type="button"
role="menuitemcheckbox"
aria-checked={isActive}
aria-checked={isActive ? true : isPartial ? "mixed" : false}
onClick={() => onToggle(id)}
className={cn(rowClass, isActive && "bg-accent font-medium")}
title={tagName(id)}
@@ -95,6 +104,7 @@ export function TagPicker({
<span className={cn("rounded-full flex-shrink-0", dotClass, tagColor(id).dot)} />
<span className="flex-1 min-w-0 truncate">{label}</span>
{isActive && <Check className={cn("ms-auto flex-shrink-0 text-foreground", checkClass)} />}
{isPartial && <Minus className={cn("ms-auto flex-shrink-0 text-muted-foreground", checkClass)} />}
</button>
);
};
@@ -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();
});
});
@@ -8,7 +8,7 @@ import { WopiEditor } from '@/components/files/wopi-editor';
// back 404, which left office editing permanently "unavailable" there. Both
// WOPI requests have to carry the mount prefix.
vi.mock('next-intl', () => ({ useTranslations: () => (key: string) => key }));
vi.mock('next-intl', () => ({ useTranslations: () => (key: string) => key, useLocale: () => 'de' }));
let fetchMock: ReturnType<typeof vi.fn>;
+112 -134
View File
@@ -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>
+130
View File
@@ -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`} />;
}
+311
View File
@@ -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>
);
}
+3
View File
@@ -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();
+36 -4
View File
@@ -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>
+4 -3
View File
@@ -1,7 +1,7 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useTranslations } from "next-intl";
import { useLocale, useTranslations } from "next-intl";
import { Loader2, X } from "@/components/icons";
import { getActiveAccountSlotHeaders } from "@/lib/auth/active-account-slot";
import { apiFetch } from "@/lib/browser-navigation";
@@ -38,6 +38,7 @@ interface LaunchData {
*/
export function WopiEditor({ target, accountId, slot, onClose }: WopiEditorProps) {
const t = useTranslations("files");
const locale = useLocale();
const [launch, setLaunch] = useState<LaunchData | null>(null);
const [error, setError] = useState<string | null>(null);
const [frameLoaded, setFrameLoaded] = useState(false);
@@ -46,8 +47,8 @@ export function WopiEditor({ target, accountId, slot, onClose }: WopiEditorProps
// Launch once per document; the object itself is rebuilt on every render.
const launchBody = JSON.stringify(
target.kind === "attachment"
? { blobId: target.blobId, name: target.name, type: target.type, size: target.size, accountId: accountId || undefined }
: { fileId: target.id, accountId: accountId || undefined },
? { blobId: target.blobId, name: target.name, type: target.type, size: target.size, accountId: accountId || undefined, lang: locale }
: { fileId: target.id, accountId: accountId || undefined, lang: locale },
);
useEffect(() => {
@@ -0,0 +1,93 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import React from 'react';
import type { FilterRule } from '@/lib/jmap/sieve-types';
import { FilterRuleModal } from '../filter-rule-modal';
vi.mock('@/stores/toast-store', () => ({ toast: { error: vi.fn(), success: vi.fn(), info: vi.fn() } }));
const rule: FilterRule = {
id: 'r1',
name: 'Alles markieren',
enabled: true,
matchType: 'all',
conditions: [{ field: 'from', comparator: 'address_is', value: 'anna@acme.com' }],
actions: [{ type: 'mark_read' }],
stopProcessing: false,
};
function openModal(props: Partial<React.ComponentProps<typeof FilterRuleModal>> = {}) {
const onSave = vi.fn();
render(<FilterRuleModal mailboxes={[]} initialRule={rule} onSave={onSave} onClose={vi.fn()} {...props} />);
return { onSave, saved: () => onSave.mock.calls[0][0] as FilterRule };
}
const fieldSelect = () => screen.getByLabelText('conditions') as HTMLSelectElement;
const comparatorSelect = () => screen.queryByLabelText('comparators.contains') as HTMLSelectElement | null;
const valueInput = () => screen.queryByPlaceholderText('value_placeholder_multi') as HTMLInputElement | null;
describe('the "all messages" condition', () => {
it('is offered, and asks for neither a comparator nor a value', () => {
const { onSave, saved } = openModal();
expect([...fieldSelect().options].map((o) => o.value)).toContain('all');
fireEvent.change(fieldSelect(), { target: { value: 'all' } });
expect(comparatorSelect()).toBeNull();
expect(valueInput()).toBeNull();
fireEvent.click(screen.getByText('save'));
expect(onSave).toHaveBeenCalledTimes(1);
expect(saved().conditions).toEqual([{ field: 'all', comparator: 'any', value: '' }]);
});
it('turns back into a regular condition when another field is chosen', () => {
openModal();
fireEvent.change(fieldSelect(), { target: { value: 'all' } });
fireEvent.change(fieldSelect(), { target: { value: 'subject' } });
expect(comparatorSelect()!.value).toBe('contains');
expect(valueInput()!.value).toBe('');
});
it('opens as it was saved, and keeps itself next to a blank row', () => {
const { saved } = openModal({ initialRule: { ...rule, conditions: [{ field: 'all', comparator: 'any', value: '' }] } });
expect(fieldSelect().value).toBe('all');
expect(comparatorSelect()).toBeNull();
expect(valueInput()).toBeNull();
// A second row left blank is dropped on save; this one is not.
fireEvent.click(screen.getByText('add_condition'));
fireEvent.click(screen.getByText('save'));
expect(saved().conditions).toEqual([{ field: 'all', comparator: 'any', value: '' }]);
});
it('drops the header name of a custom header condition it replaces', () => {
const { saved } = openModal({
initialRule: { ...rule, conditions: [{ field: 'header', comparator: 'contains', value: 'bar', headerName: 'X-Foo' }] },
});
fireEvent.change(fieldSelect(), { target: { value: 'all' } });
fireEvent.click(screen.getByText('save'));
expect(saved().conditions).toEqual([{ field: 'all', comparator: 'any', value: '' }]);
expect(saved().conditions[0]).not.toHaveProperty('headerName');
});
it('stays when a suggestion is added next to it', () => {
const { saved } = openModal({
initialRule: { ...rule, conditions: [{ field: 'all', comparator: 'any', value: '' }] },
suggestions: [{ id: 'subject', label: 'Betreff Rechnung', condition: { field: 'subject', comparator: 'contains', value: 'Rechnung' } }],
});
// Unlike a blank row, it takes no value and is not made way for.
fireEvent.click(screen.getByText('Betreff Rechnung'));
fireEvent.click(screen.getByText('save'));
expect(saved().conditions).toEqual([
{ field: 'all', comparator: 'any', value: '' },
{ field: 'subject', comparator: 'contains', value: 'Rechnung' },
]);
});
it('is never run on the messages already in the folder', () => {
openModal({ offerApplyToExisting: true });
const applyExisting = screen.getByLabelText('apply_existing') as HTMLInputElement;
expect(applyExisting.disabled).toBe(false);
fireEvent.change(fieldSelect(), { target: { value: 'all' } });
expect(fieldSelect().value).toBe('all');
expect(applyExisting.disabled).toBe(true);
});
});
@@ -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();
});
});
+173 -34
View File
@@ -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";
@@ -19,6 +19,9 @@ import type { Mailbox } from "@/lib/jmap/types";
import { buildMailboxTree, flattenMailboxTree, type MailboxNode, generateUUID, cn } from "@/lib/utils";
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";
@@ -36,14 +39,21 @@ interface FilterRuleModalProps {
mailboxes: Mailbox[];
/** Server cap on redirects per message (Sieve `maxNumberRedirects`). */
maxRedirects?: number | null;
/** Forward actions in the other enabled rules, which share that cap. */
otherForwards?: number;
/**
* Forwards a message can have collected when it reaches this rule, and
* the most it can still collect below it when the rule does not stop
* (see forwardsAround in lib/filters/forward-limit.ts).
*/
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;
}
const ALL_FIELDS: FilterConditionField[] = [
"from", "to", "cc", "subject", "header", "size", "body", "attachment",
"from", "to", "cc", "subject", "header", "size", "body", "attachment", "all",
];
const TEXT_COMPARATORS: FilterComparator[] = [
@@ -57,9 +67,13 @@ const SIZE_COMPARATORS: FilterComparator[] = ["greater_than", "less_than"];
const ATTACHMENT_COMPARATORS: FilterComparator[] = ["has_any", "has_type"];
// "All messages" has nothing to choose: its one comparator is not shown.
const ALL_MESSAGES_COMPARATORS: FilterComparator[] = ["any"];
function comparatorsFor(field: FilterConditionField): FilterComparator[] {
if (field === "size") return SIZE_COMPARATORS;
if (field === "attachment") return ATTACHMENT_COMPARATORS;
if (field === "all") return ALL_MESSAGES_COMPARATORS;
if (field === "from" || field === "to" || field === "cc") return ADDRESS_COMPARATORS;
return TEXT_COMPARATORS;
}
@@ -96,10 +110,46 @@ function isConditionValueEmpty(v: string | string[]): boolean {
return !v.trim();
}
// Conditions that test without a value: "has an attachment" and "all messages".
function takesNoValue(c: Pick<FilterCondition, "field" | "comparator">): boolean {
return c.field === "all" || (c.field === "attachment" && c.comparator === "has_any");
}
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,
@@ -107,7 +157,9 @@ export function FilterRuleModal({
offerApplyToExisting = false,
mailboxes,
maxRedirects,
otherForwards = 0,
forwardsBefore = 0,
forwardsAfter = 0,
periodsSupported = false,
onSave,
onClose,
}: FilterRuleModalProps) {
@@ -129,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 });
@@ -159,7 +217,11 @@ export function FilterRuleModal({
return "";
}, [mailboxPathMap]);
const forwardCount = actions.filter((a) => a.type === "forward").length;
// What counts is the most forwards one message can collect: those of the
// rules above that let it go on, this rule's, and those below unless this
// rule stops.
const forwardCount = ruleForwards({ actions });
const otherForwards = forwardsBefore + (ruleStops({ actions, stopProcessing }) ? 0 : forwardsAfter);
const forwardLimit = typeof maxRedirects === "number" && maxRedirects > 0 ? maxRedirects : null;
const forwardOverLimit = forwardLimit !== null && forwardCount + otherForwards > forwardLimit;
@@ -170,11 +232,11 @@ export function FilterRuleModal({
// comma the user just typed mid-edit.
const validConditions = useMemo(() => conditions
.filter((c) => {
if (c.field === "attachment" && c.comparator === "has_any") return true;
if (takesNoValue(c)) return true;
return !isConditionValueEmpty(c.value);
})
.map((c) => {
if (c.field === "attachment" && c.comparator === "has_any") return c;
if (takesNoValue(c)) return c;
if (c.field === "size") return c; // numeric, single-value only
if (typeof c.value !== "string") return c; // already structured
const parsed = inputStringToValue(c.value);
@@ -197,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();
@@ -218,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,
@@ -226,16 +309,21 @@ 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));
const applySuggestion = (suggestion: RuleSuggestion) => {
setConditions((prev) => {
// The blank row a rule starts with makes way for the suggestion.
const kept = prev.filter((c) => !(c.field !== "attachment" && isConditionValueEmpty(c.value)));
const kept = prev.filter((c) => !(c.field !== "attachment" && c.field !== "all" && isConditionValueEmpty(c.value)));
const replaceAt = suggestion.replaces ? kept.findIndex(suggestion.replaces) : -1;
if (replaceAt === -1) return [...kept, suggestion.condition];
return kept.map((c, i) => (i === replaceAt ? suggestion.condition : c));
@@ -260,9 +348,9 @@ export function FilterRuleModal({
if (updates.field && updates.field !== "header") {
delete updated.headerName;
}
// has_any takes no value; clear it so we don't leak old text into
// the generated Sieve.
if (updated.field === "attachment" && updated.comparator === "has_any") {
// has_any and "all messages" take no value; clear it so we don't
// leak old text into the generated Sieve.
if (takesNoValue(updated)) {
updated.value = "";
}
// Size is numeric, single value only - collapse any list to scalar.
@@ -426,24 +514,27 @@ export function FilterRuleModal({
/>
)}
<select
value={condition.comparator}
onChange={(e) =>
updateCondition(index, { comparator: e.target.value as FilterComparator })
}
className={selectClass}
aria-label={t("comparators.contains")}
>
{comparatorsFor(condition.field).map((c) => (
<option key={c} value={c}>
{t(`comparators.${c}`)}
</option>
))}
</select>
{condition.field !== "all" && (
<select
value={condition.comparator}
onChange={(e) =>
updateCondition(index, { comparator: e.target.value as FilterComparator })
}
className={selectClass}
aria-label={t("comparators.contains")}
>
{comparatorsFor(condition.field).map((c) => (
<option key={c} value={c}>
{t(`comparators.${c}`)}
</option>
))}
</select>
)}
{/* has_any takes no value; render a stub so the row layout
stays consistent but no input is editable. */}
{condition.field === "attachment" && condition.comparator === "has_any" ? (
{/* has_any and "all messages" take no value; render a stub
so the row layout stays consistent but no input is
editable. */}
{takesNoValue(condition) ? (
<div className="flex-1 min-w-[120px]" />
) : (
<Input
@@ -574,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}
@@ -664,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">
@@ -685,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>
+29
View File
@@ -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);
+2
View File
@@ -93,6 +93,7 @@ export {
IconFilter as Filter,
IconFlag as Flag,
IconFlask as FlaskConical,
IconFold as FoldVertical,
IconFolder as Folder,
IconFolderDown as FolderInput,
IconFolderOpen as FolderOpen,
@@ -222,6 +223,7 @@ export {
IconTypography as Type,
IconUnderline as Underline,
IconArrowBack as Undo,
IconArrowsVertical as UnfoldVertical,
IconLockOpen as Unlock,
IconUpload as Upload,
IconUser as User,
@@ -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, selectedEmailKeys: 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
View File
@@ -31,7 +31,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";
@@ -99,7 +99,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";
@@ -165,7 +166,6 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
const t = useTranslations();
const tCommon = useTranslations('common');
const tQuote = useTranslations('quote_header');
const { appName } = useConfig();
const mailLayout = useSettingsStore((state) => state.mailLayout);
// Phones present search full-screen from the header field rather than
// giving it a permanent second bar under the header.
@@ -197,7 +197,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);
@@ -945,37 +945,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
@@ -1643,17 +1642,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,
@@ -2046,7 +2041,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
@@ -2364,17 +2361,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 {
@@ -2416,6 +2419,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);
}
};
@@ -3146,20 +3153,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
@@ -3190,7 +3188,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(
@@ -3250,7 +3248,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(
@@ -3263,20 +3261,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');
}
@@ -3287,7 +3280,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;
@@ -3517,40 +3510,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) {
@@ -3567,9 +3531,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
}
@@ -4515,6 +4482,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
<ErrorBoundary fallback={EmailViewerErrorFallback}>
<EmailViewer
email={selectedEmail}
blobSource={resolveEmailBlobContext(selectedEmail, client)}
isLoading={isLoadingEmail}
onReply={handleReply}
onReplyAll={handleReplyAll}
+3 -1
View File
@@ -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,
@@ -1,9 +1,16 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import React from 'react';
import { VisualRuleSummary } from '../filter-settings';
import { RuleSummary, VisualRuleSummary } from '../filter-settings';
import type { FilterRule } from '@/lib/jmap/sieve-types';
// Keys as text, except one a locale leaves empty: Mongolian has no words of
// its own for "starts with" (nor for "ends with").
vi.mock('next-intl', () => {
const t = (key: string) => (key === 'comparators.starts_with' ? '' : key);
return { useTranslations: () => t, useLocale: () => 'en' };
});
const rule = (overrides: Partial<FilterRule>): FilterRule => ({
id: 'r1',
name: 'Rule',
@@ -67,4 +74,25 @@ describe('filter rule summary (expanded view)', () => {
'comparators.has_any',
]);
});
it('shows the value where a locale leaves the comparator empty', () => {
render(<VisualRuleSummary rule={rule({
conditions: [{ field: 'subject', comparator: 'starts_with', value: 'Rechnung' }],
})} />);
expect(valuesOf('subject').values).toEqual(['“Rechnung”']);
});
it('shows "all messages" on its own, without a comparator or a value', () => {
const all = rule({
conditions: [{ field: 'all', comparator: 'any', value: '' }],
actions: [{ type: 'mark_read' }],
});
const { unmount } = render(<VisualRuleSummary rule={all} />);
const chip = screen.getByText('condition_fields.all').parentElement!;
expect([...chip.children].map((el) => el.textContent)).toEqual(['condition_fields.all']);
unmount();
const { container } = render(<RuleSummary rule={all} />);
expect(container.textContent).toBe('condition_fields.all→action_types.mark_read');
});
});
@@ -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,153 @@
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 { FilterAction, FilterRule, 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 redirect limit on the settings page: the real page and rule dialog,
// with a server that allows one redirect per message, as Stalwart does.
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 forward: FilterAction = { type: 'forward', value: 'colleague@example.com' };
const rule = (id: string, name: string, actions: FilterAction[], stopProcessing: boolean): FilterRule => ({
id,
name,
enabled: true,
matchType: 'all',
conditions: [{ field: 'from', comparator: 'contains', value: 'reports@vendor.example' }],
actions,
stopProcessing,
});
// Forwards to a colleague, deletes silently, and stops processing.
const vendor = (stops = true) => rule('vendor', 'Vendor reports', [forward, { type: 'discard' }], stops);
const everyoneElse = rule('rest', 'Everyone else', [{ type: 'forward', value: 'office@example.com' }], false);
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 });
});
afterEach(() => {
useAuthStore.setState(initial.auth, true);
useEmailStore.setState(initial.email, true);
useFilterStore.setState(initial.filter, true);
});
function openSettings(rules: FilterRule[]) {
useFilterStore.setState({
rules,
isSupported: true,
isLoading: false,
isOpaque: false,
activeScriptId: 'script-1',
selectedAccountId: 'primary',
sieveCapabilities: CAPABILITIES,
selectAccount: vi.fn(async () => {}),
});
render(<FilterSettings />);
}
const forwardOption = (dialog: ReturnType<typeof within>) =>
dialog.getAllByRole('combobox', { name: 'actions' })[0].querySelector('option[value="forward"]') as HTMLOptionElement;
describe('the redirect limit on the settings page', () => {
it('offers forwarding in a new rule below one that forwards and stops', () => {
openSettings([vendor()]);
fireEvent.click(screen.getByText('add_rule'));
const dialog = within(screen.getByRole('dialog'));
expect(forwardOption(dialog).disabled).toBe(false);
expect(screen.queryByText('forward_limit')).toBeNull();
});
it('keeps forwarding locked below a rule that forwards and lets messages go on', () => {
openSettings([vendor(false)]);
fireEvent.click(screen.getByText('add_rule'));
expect(forwardOption(within(screen.getByRole('dialog'))).disabled).toBe(true);
});
it('says so in the list when the order lets one message collect too many', () => {
openSettings([everyoneElse, vendor()]);
expect(screen.getByText('forward_limit')).toBeInTheDocument();
});
it('says nothing in the list when the rule that stops comes first', () => {
openSettings([vendor(), everyoneElse]);
expect(screen.queryByText('forward_limit')).toBeNull();
});
it('says so in the list once a rule is switched on that lets one message collect too many', async () => {
openSettings([{ ...everyoneElse, enabled: false }, vendor()]);
expect(screen.queryByText('forward_limit')).toBeNull();
fireEvent.click(within(screen.getByRole('list', { name: 'rule_list' })).getAllByRole('switch')[0]);
// Saved, not put back.
await waitFor(() => expect(useFilterStore.getState().rawScript).toContain('redirect "office@example.com"'));
expect(screen.getByText('forward_limit')).toBeInTheDocument();
});
it('says so in the list once a rule is moved where one message collects too many', async () => {
openSettings([vendor(), everyoneElse]);
expect(screen.queryByText('forward_limit')).toBeNull();
const [vendorRow, restRow] = within(screen.getByRole('list', { name: 'rule_list' })).getAllByRole('listitem');
const dataTransfer = { setData: vi.fn(), effectAllowed: '', dropEffect: '' };
fireEvent.dragStart(restRow, { dataTransfer });
fireEvent.dragOver(vendorRow, { dataTransfer });
fireEvent.drop(vendorRow, { dataTransfer });
await waitFor(() => expect(useFilterStore.getState().rawScript).toMatch(/# Rule: Everyone else[\s\S]*# Rule: Vendor reports/));
expect(screen.getByText('forward_limit')).toBeInTheDocument();
});
it('counts the forward of an edited rule once', () => {
openSettings([vendor(), everyoneElse]);
fireEvent.click(screen.getByText('Everyone else'));
const dialog = within(screen.getByRole('dialog'));
expect(dialog.queryByText('forward_limit')).toBeNull();
});
it('does not count a forward behind a Stop action', () => {
const office: FilterAction = { type: 'forward', value: 'office@example.com' };
openSettings([rule('cut', 'Cut short', [forward, { type: 'stop' }, office], false)]);
fireEvent.click(screen.getByText('Cut short'));
expect(within(screen.getByRole('dialog')).queryByText('forward_limit')).toBeNull();
});
it('puts a new rule above the rules managed elsewhere', () => {
const archive: FilterAction = { type: 'forward', value: 'archive@example.org' };
openSettings([{ ...rule('ext', 'Managed elsewhere', [archive], true), origin: 'external' }]);
fireEvent.click(screen.getByText('add_rule'));
const dialog = within(screen.getByRole('dialog'));
// A message the new rule matches still reaches the one below it ...
expect(forwardOption(dialog).disabled).toBe(true);
// ... unless the new rule stops.
fireEvent.click(dialog.getByLabelText('stop_processing'));
expect(forwardOption(dialog).disabled).toBe(false);
});
it('warns in the dialog once the rule above stops no more', () => {
openSettings([vendor(), everyoneElse]);
fireEvent.click(screen.getByText('Vendor reports'));
const dialog = within(screen.getByRole('dialog'));
expect(dialog.queryByText('forward_limit')).toBeNull();
// Without its stop, a vendor report also reaches the rule below.
fireEvent.click(dialog.getByLabelText('stop_processing'));
expect(dialog.getByText('forward_limit')).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);
});
});
@@ -103,6 +103,20 @@ describe('NotificationSettings - pushNotifyInboxOnly', () => {
);
});
it('checks push again when the user comes back to the tab', async () => {
Object.defineProperty(document, 'visibilityState', { value: 'visible', configurable: true });
const { findByText } = render(<NotificationSettings />);
await findByText('email.inbox_only');
const check = webPush.isWebPushEnabled as ReturnType<typeof vi.fn>;
await waitFor(() => expect(check).toHaveBeenCalled());
const before = check.mock.calls.length;
// Push may have been turned off elsewhere (site settings, another tab) meanwhile.
await act(async () => { window.dispatchEvent(new Event('focus')); });
await waitFor(() => expect(check.mock.calls.length).toBeGreaterThan(before));
});
it('does not re-sync when push is disabled on this device', async () => {
(webPush.isWebPushEnabled as ReturnType<typeof vi.fn>).mockResolvedValueOnce(false);
render(<NotificationSettings />);
@@ -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();
});
});
+17 -9
View File
@@ -10,12 +10,13 @@ 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';
const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "0.0.0";
const GIT_COMMIT = process.env.NEXT_PUBLIC_GIT_COMMIT || "unknown";
const UPSTREAM_SOURCE_URL = "https://github.com/bulwarkmail/webmail";
function VersionUpdateTag() {
const status = useUpdateStore((s) => s.status);
@@ -54,7 +55,14 @@ export function AboutDataSettings() {
const tSettings = useTranslations('settings');
const { settingsSyncDisabled, updateSetting, resetToDefaults, exportSettings, importSettings } =
useSettingsStore();
const { settingsSyncEnabled } = useConfig();
// A modified build must offer its own source (AGPL-3.0 §13), so a
// configured URL replaces the upstream repository link.
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);
@@ -136,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>
@@ -157,12 +165,12 @@ export function AboutDataSettings() {
</div>
</button>
<a
href="https://github.com/bulwarkmail/webmail"
href={sourceCodeUrl || UPSTREAM_SOURCE_URL}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
GitHub <ExternalLink className="w-3 h-3" />
{sourceCodeUrl ? t('about.source_code') : 'GitHub'} <ExternalLink className="w-3 h-3" />
</a>
</div>
</div>
@@ -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('');
+109
View File
@@ -8,10 +8,17 @@ import { usePolicyStore } from '@/stores/policy-store';
import { useAuthStore } from '@/stores/auth-store';
import { toast } from '@/stores/toast-store';
import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
import { cn } from '@/lib/utils';
import { formatDisplayHour } from '@/lib/calendar-display-range';
// Indexed by Date.getDay(), which is how calendarWorkingDays stores days.
const DAY_KEYS = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'] as const;
const DAY_SHORT_KEYS = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'] as const;
export function CalendarSettings() {
const t = useTranslations('calendar.settings');
const tViews = useTranslations('calendar.views');
const tDays = useTranslations('calendar.days');
const { viewMode, setViewMode, participantIdentities, fetchParticipantIdentities, setDefaultParticipantIdentity } = useCalendarStore();
const client = useAuthStore((s) => s.client);
@@ -33,10 +40,44 @@ export function CalendarSettings() {
showTasksOnCalendar,
showBirthdayCalendar,
calendarHoverPreview,
calendarLimitHours,
calendarDayStartHour,
calendarDayEndHour,
calendarHideNonWorkingDays,
calendarWorkingDays,
timeFormat,
firstDayOfWeek,
updateSetting,
} = useSettingsStore();
const { isFeatureEnabled } = usePolicyStore();
// Visible hours (#1164). The end list only offers hours after the start,
// and moving the start past the end pushes the end along.
const hourOptions = (from: number, to: number) =>
Array.from({ length: to - from + 1 }, (_, i) => {
const hour = from + i;
return { value: String(hour), label: formatDisplayHour(hour, timeFormat) };
});
const setStartHour = (hour: number) => {
updateSetting('calendarDayStartHour', hour);
if (calendarDayEndHour <= hour) updateSetting('calendarDayEndHour', hour + 1);
};
// Working days in the order the user's week runs.
const weekOrder = Array.from({ length: 7 }, (_, i) => (firstDayOfWeek + i) % 7);
const workingDays = Array.isArray(calendarWorkingDays) ? calendarWorkingDays : [];
const toggleWorkingDay = (day: number) => {
const current = new Set(workingDays);
if (current.has(day)) {
// The week view needs at least one day to show.
if (current.size === 1) return;
current.delete(day);
} else {
current.add(day);
}
updateSetting('calendarWorkingDays', [...current].sort((a, b) => a - b));
};
return (
<SettingsSection title={t('title')}>
<SettingItem label={t('default_view')}>
@@ -92,6 +133,74 @@ export function CalendarSettings() {
/>
</SettingItem>
<SettingItem
label={t('limit_hours')}
description={t('limit_hours_desc')}
>
<ToggleSwitch
checked={calendarLimitHours}
onChange={(checked) => updateSetting('calendarLimitHours', checked)}
/>
</SettingItem>
{calendarLimitHours && (
<SettingItem label={t('visible_hours')}>
<div className="flex items-center gap-2">
<Select
value={String(calendarDayStartHour)}
onChange={(value) => setStartHour(parseInt(value, 10))}
options={hourOptions(0, 23)}
ariaLabel={t('visible_hours_start')}
/>
<span className="text-muted-foreground">–</span>
<Select
value={String(calendarDayEndHour)}
onChange={(value) => updateSetting('calendarDayEndHour', parseInt(value, 10))}
options={hourOptions(Math.min(23, calendarDayStartHour) + 1, 24)}
ariaLabel={t('visible_hours_end')}
/>
</div>
</SettingItem>
)}
<SettingItem
label={t('hide_non_working_days')}
description={t('hide_non_working_days_desc')}
>
<ToggleSwitch
checked={calendarHideNonWorkingDays}
onChange={(checked) => updateSetting('calendarHideNonWorkingDays', checked)}
/>
</SettingItem>
{calendarHideNonWorkingDays && (
<SettingItem label={t('working_days')}>
<div className="flex flex-wrap gap-1.5" role="group" aria-label={t('working_days')}>
{weekOrder.map((day) => {
const selected = workingDays.includes(day);
return (
<button
key={day}
type="button"
aria-pressed={selected}
aria-label={tDays(DAY_KEYS[day])}
title={tDays(DAY_KEYS[day])}
onClick={() => toggleWorkingDay(day)}
className={cn(
'px-2.5 py-1.5 text-xs rounded-md transition-colors duration-150',
selected
? 'bg-primary text-primary-foreground font-medium'
: 'bg-muted hover:bg-accent text-foreground'
)}
>
{tDays(DAY_SHORT_KEYS[day])}
</button>
);
})}
</div>
</SettingItem>
)}
<SettingItem
label={t('calendar_free_scroll')}
description={t('calendar_free_scroll_desc')}
+7 -2
View File
@@ -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">
+82 -9
View File
@@ -13,6 +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, 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 {
@@ -32,11 +35,34 @@ function isReadonlyRule(r: FilterRule): boolean {
return r.origin === "external" || r.origin === "opaque";
}
function RuleSummary({ rule }: { rule: FilterRule }) {
/**
* 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}`);
// "All messages" says it all: no comparator, no value.
if (c.field === "all") return field;
const comparator = t(`comparators.${c.comparator}`);
// has_any is a no-value test ("attachment is present"); appending
// `""` would look broken in the summary line.
@@ -82,12 +108,14 @@ 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");
@@ -100,7 +128,9 @@ export function VisualRuleSummary({ rule }: { rule: FilterRule }) {
</span>
{rule.conditions.map((c, i) => {
const field = t(`condition_fields.${c.field}`);
const comparator = t(`comparators.${c.comparator}`);
// "All messages" says it all: no comparator, no value.
const everyMessage = c.field === "all";
const comparator = everyMessage ? null : t(`comparators.${c.comparator}`);
return (
<span key={i} className="contents">
{i > 0 && (
@@ -108,8 +138,8 @@ export function VisualRuleSummary({ rule }: { rule: FilterRule }) {
)}
<span className="inline-flex min-w-0 max-w-full items-baseline gap-1 px-1.5 py-px rounded-sm bg-muted/60 text-foreground">
<span className="shrink-0 font-medium text-blue-600 dark:text-blue-400">{field}</span>
<span className="shrink-0 text-muted-foreground">{comparator}</span>
{!(c.field === "attachment" && c.comparator === "has_any") && (
{!everyMessage && <span className="shrink-0 text-muted-foreground">{comparator}</span>}
{!everyMessage && !(c.field === "attachment" && c.comparator === "has_any") && (
// The values of one condition stay in its chip, where "or"
// cannot be mistaken for the joiner between conditions. They
// wrap between each other under the first value, the "or"
@@ -152,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>
);
}
@@ -175,6 +219,7 @@ export function FilterSettings() {
rawScript,
vacationSettings,
includeVacation,
vacationForward,
sieveCapabilities,
selectAccount,
saveFilters,
@@ -248,6 +293,23 @@ export function FilterSettings() {
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
const draggedIndexRef = useRef<number | null>(null);
// A message can collect only so many forwards on this server. The rules run
// 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(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(
runOrder,
runOrder.length - rules.length +
(editedIndex >= 0 ? editedIndex : rules.filter((r) => !isReadonlyRule(r)).length),
editedIndex >= 0,
);
useEffect(() => {
if (client && isSupported) {
// Always pass a concrete account id (managed shared account, or the
@@ -413,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">
@@ -424,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">
@@ -516,6 +583,12 @@ export function FilterSettings() {
</div>
)}
{!isOpaque && tooManyForwards && (
<p className="text-xs text-amber-600 dark:text-amber-400">
{t("forward_limit", { count: redirectLimit })}
</p>
)}
{!isOpaque && rules.length > 0 && (
<div className="space-y-1" role="list" aria-label={t("rule_list")}>
{rules.map((rule, index) => {
@@ -702,9 +775,9 @@ export function FilterSettings() {
rule={editingRule}
mailboxes={mailboxes}
maxRedirects={sieveCapabilities?.maxNumberRedirects}
otherForwards={rules
.filter((r) => r.enabled && r.id !== editingRule?.id)
.reduce((n, r) => n + r.actions.filter((a) => a.type === "forward").length, 0)}
periodsSupported={supportsPeriods(sieveCapabilities?.sieveExtensions)}
forwardsBefore={forwardsOfEdited.before}
forwardsAfter={forwardsOfEdited.after}
onSave={handleSaveRule}
onClose={() => {
setShowRuleModal(false);
+165 -3
View File
@@ -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">
+26 -6
View File
@@ -80,16 +80,37 @@ export function NotificationSettings() {
const activeRelayLabel =
relayOptions.find((option) => option.url === activeRelayUrl)?.label ?? activeRelayUrl;
const busy = pushStatus.kind === 'busy';
useEffect(() => {
if (!supported) return;
if (!client) return;
const accountId = client.getAccountId();
if (!accountId) return;
void (async () => {
const enabled = await isWebPushEnabled(accountId);
setPushStatus(enabled ? { kind: 'enabled' } : { kind: 'idle' });
})();
}, [supported, client]);
let cancelled = false;
const refreshStatus = async () => {
try {
const enabled = await isWebPushEnabled(accountId);
if (!cancelled) {
setPushStatus(enabled ? { kind: 'enabled' } : { kind: 'idle' });
}
} catch {
if (!cancelled) setPushStatus({ kind: 'idle' });
}
};
const onVisible = () => {
if (document.visibilityState === 'visible') void refreshStatus();
};
if (pushStatus.kind === 'busy' || pushStatus.kind === 'error') return;
void refreshStatus();
window.addEventListener('focus', onVisible);
document.addEventListener('visibilitychange', onVisible);
return () => {
cancelled = true;
window.removeEventListener('focus', onVisible);
document.removeEventListener('visibilitychange', onVisible);
};
}, [supported, client, pushStatus.kind]);
// The device list is worth loading even when push is off on this browser -
// revoking a stale registration left on another device is exactly what you
@@ -108,7 +129,6 @@ export function NotificationSettings() {
void refreshDevices();
}, [refreshDevices]);
const busy = pushStatus.kind === 'busy';
const pushEnabled = pushStatus.kind === 'enabled';
// In the static Lite build push is off by design (no service worker, see
// components/service-worker-registration.tsx), not a browser limitation.
+2
View File
@@ -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();
+235 -35
View File
@@ -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,46 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { ContextMenu, ContextMenuSubMenu, ContextMenuItem } from '../context-menu';
function rect(left: number, top: number, width: number, height: number): DOMRect {
return { left, top, width, height, right: left + width, bottom: top + height, x: left, y: top, toJSON: () => ({}) } as DOMRect;
}
afterEach(() => vi.restoreAllMocks());
function openSubmenu(subOrigin: { left: number; top: number }) {
// Parent menu at (400, 200); the entry spans 400..600 x 240..272. The
// submenu, before it is placed, sits at the origin `position: fixed`
// resolves against.
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
if (this.getAttribute('role') === 'menu' && this.textContent === 'Spam') {
return rect(subOrigin.left, subOrigin.top, 180, 120);
}
if ((this.firstElementChild as HTMLElement | null)?.dataset.testid === 'sub') return rect(400, 240, 200, 32);
return rect(400, 200, 200, 300);
});
render(
<ContextMenu isOpen position={{ x: 400, y: 200 }} onClose={() => {}}>
<ContextMenuSubMenu label="Move to" testId="sub">
<ContextMenuItem label="Spam" onClick={() => {}} />
</ContextMenuSubMenu>
</ContextMenu>,
);
fireEvent.mouseEnter(screen.getByTestId('sub').parentElement!);
return screen.getByText('Spam').closest('[role="menu"]') as HTMLElement;
}
describe('ContextMenuSubMenu placement', () => {
it('opens beside its entry', () => {
const sub = openSubmenu({ left: 0, top: 0 });
expect(sub.style.left).toBe('600px');
expect(sub.style.top).toBe('240px');
});
it('stays beside its entry when a theme makes the parent menu the containing block (#1149)', () => {
// A backdrop-filter on the parent menu moves the fixed origin to (400, 200).
const sub = openSubmenu({ left: 400, top: 200 });
expect(sub.style.left).toBe('200px');
expect(sub.style.top).toBe('40px');
});
});
+7 -1
View File
@@ -225,7 +225,13 @@ export function ContextMenuSubMenu({
}
top = Math.max(VIEWPORT_MARGIN, top);
setSubMenuPos({ x: left, y: top });
// The submenu is measured while it still sits at left/top 0, so its rect
// is the origin `position: fixed` resolves against. That is the viewport,
// unless a theme gives the parent menu a backdrop-filter, filter or
// transform: then the parent is the containing block and viewport
// coordinates land offset by the parent's own position, far from the
// entry (#1149).
setSubMenuPos({ x: left - subRect.left, y: top - subRect.top });
}, [isOpen]);
useEffect(() => {
+110
View File
@@ -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');
});
});
+90
View File
@@ -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);
});
});
@@ -53,6 +53,37 @@ describe("useTimeGridInteractions slot clicks (#435)", () => {
expect(onCreateRange).toHaveBeenCalledTimes(1);
});
it("drag-to-create measures from the first visible hour (#1164)", () => {
const onCreateRange = vi.fn();
const { result } = renderHook(() =>
useTimeGridInteractions({
hourHeight: 60, calendars: [], onCreateRange, errorMessages,
gridStartMinutes: 480, gridEndMinutes: 1200,
}),
);
const day = new Date(2026, 8, 3);
const column = {
getBoundingClientRect: () => ({ top: 100 }),
setPointerCapture: vi.fn(),
releasePointerCapture: vi.fn(),
};
const pointer = (clientY: number) => ({
pointerType: "mouse", button: 0, pointerId: 1, clientY,
target: { closest: () => null }, currentTarget: column,
}) as unknown as React.PointerEvent<HTMLDivElement>;
// 60 px below the grid top is 09:00 on an 08:00 grid; past the bottom
// clamps to the last visible minute.
act(() => result.current.handleGridPointerDown(pointer(160), "2026-09-03", day));
act(() => result.current.handleGridPointerMove(pointer(400)));
expect(result.current.dragCreate).toEqual({ dayKey: "2026-09-03", startMinutes: 540, endMinutes: 780 });
act(() => result.current.handleGridPointerUp(pointer(2000)));
const [start, end] = onCreateRange.mock.calls[0];
expect(start).toEqual(new Date(2026, 8, 3, 9, 0, 0, 0));
expect(end).toEqual(new Date(2026, 8, 3, 20, 0, 0, 0));
});
it("a 23:00 click ends at midnight of the next day", () => {
const { result, onCreateRange } = renderInteractions();
const day = new Date(2026, 8, 3);
+4
View File
@@ -44,6 +44,7 @@ export interface ConfigData {
jmapServerAutoPickByDomain: boolean;
embeddedMode: boolean;
parentOrigin: string;
sourceCodeUrl: string;
}
interface AppConfig extends ConfigData {
@@ -183,6 +184,7 @@ export function useConfig(): AppConfig {
jmapServerAutoPickByDomain: configCache?.jmapServerAutoPickByDomain || false,
embeddedMode: configCache?.embeddedMode || false,
parentOrigin: configCache?.parentOrigin || '',
sourceCodeUrl: configCache?.sourceCodeUrl || '',
isLoading: !configCache,
error: null,
});
@@ -226,6 +228,7 @@ export function useConfig(): AppConfig {
jmapServerAutoPickByDomain: configCache.jmapServerAutoPickByDomain || false,
embeddedMode: configCache.embeddedMode,
parentOrigin: configCache.parentOrigin,
sourceCodeUrl: configCache.sourceCodeUrl,
isLoading: false,
error: null,
});
@@ -270,6 +273,7 @@ export function useConfig(): AppConfig {
jmapServerAutoPickByDomain: data.jmapServerAutoPickByDomain || false,
embeddedMode: data.embeddedMode,
parentOrigin: data.parentOrigin,
sourceCodeUrl: data.sourceCodeUrl,
isLoading: false,
error: null,
});
+76
View File
@@ -0,0 +1,76 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type RefObject } from "react";
import { useSettingsStore } from "@/stores/settings-store";
import { displayNow } from "@/lib/timezone";
import { FULL_DAY_HOURS, resolveDisplayHours, type DisplayHours } from "@/lib/calendar-display-range";
interface UseDisplayHoursOptions {
scrollRef: RefObject<HTMLElement | null>;
hourHeight: number;
}
/**
* The hours a time grid draws (#1164): the configured range, or the whole
* day while the user has asked to see it. Owns the grid's vertical scroll
* position too - it opens an hour before now, and keeps the same hours in
* place when the range changes underneath it.
*/
export function useDisplayHours({ scrollRef, hourHeight }: UseDisplayHoursOptions) {
const limitHours = useSettingsStore((s) => s.calendarLimitHours);
const startHour = useSettingsStore((s) => s.calendarDayStartHour);
const endHour = useSettingsStore((s) => s.calendarDayEndHour);
const configured = useMemo(
() => resolveDisplayHours(limitHours, startHour, endHour),
[limitHours, startHour, endHour],
);
const [showAllHours, setShowAllHours] = useState(false);
const hours: DisplayHours = showAllHours ? FULL_DAY_HOURS : configured;
const initialHoursRef = useRef(hours);
useEffect(() => {
const root = scrollRef.current;
if (!root) return;
const { startMinutes, endMinutes } = initialHoursRef.current;
const now = displayNow();
const nowMinutes = now.getHours() * 60 + now.getMinutes();
// Outside the visible hours (an evening look at a working-hours grid)
// the day's first hour is more use than its last.
if (nowMinutes < startMinutes || nowMinutes >= endMinutes) {
root.scrollTop = 0;
return;
}
root.scrollTop = Math.max(0, ((now.getHours() - 1) * 60 - startMinutes) / 60 * hourHeight);
}, [scrollRef, hourHeight]);
const revealRef = useRef<number | null>(null);
const renderedStartRef = useRef(hours.startMinutes);
useLayoutEffect(() => {
const root = scrollRef.current;
const prev = renderedStartRef.current;
renderedStartRef.current = hours.startMinutes;
const reveal = revealRef.current;
revealRef.current = null;
if (!root) return;
if (reveal !== null) {
root.scrollTop = Math.max(0, ((reveal - hours.startMinutes) / 60 - 0.5) * hourHeight);
} else if (prev !== hours.startMinutes) {
root.scrollTop = Math.max(0, root.scrollTop + ((prev - hours.startMinutes) / 60) * hourHeight);
}
}, [scrollRef, hourHeight, hours.startMinutes]);
const toggleAllHours = useCallback(() => setShowAllHours((v) => !v), []);
/** Shows the whole day and scrolls to `minutes` from midnight. */
const revealMinutes = useCallback((minutes: number) => {
revealRef.current = minutes;
setShowAllHours(true);
}, []);
return {
hours,
/** Whether a range is configured at all, i.e. the toggle has anything to do. */
canToggle: configured.restricted,
configured,
showAllHours,
toggleAllHours,
revealMinutes,
};
}
+52
View File
@@ -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
View File
@@ -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;
}
+21 -11
View File
@@ -47,6 +47,13 @@ interface UseTimeGridInteractionsOptions {
error: string;
};
isMobile?: boolean;
/**
* The minutes from midnight at the top and bottom of the grid, when it
* shows only part of the day (#1164). Pointer positions and the drawn
* offsets are relative to `gridStartMinutes`.
*/
gridStartMinutes?: number;
gridEndMinutes?: number;
}
export function useTimeGridInteractions({
@@ -55,11 +62,13 @@ export function useTimeGridInteractions({
onCreateRange,
errorMessages,
isMobile,
gridStartMinutes = 0,
gridEndMinutes = 1440,
}: UseTimeGridInteractionsOptions) {
const snapToMinutes = useCallback((clientY: number, containerTop: number): number => {
const raw = ((clientY - containerTop) / hourHeight) * 60;
return Math.max(0, Math.min(1440, Math.round(raw / 15) * 15));
}, [hourHeight]);
const raw = gridStartMinutes + ((clientY - containerTop) / hourHeight) * 60;
return Math.max(gridStartMinutes, Math.min(gridEndMinutes, Math.round(raw / 15) * 15));
}, [hourHeight, gridStartMinutes, gridEndMinutes]);
const wasDragging = useRef(false);
@@ -161,11 +170,12 @@ export function useTimeGridInteractions({
const newDuration = Math.max(15, ref.originalDurationMinutes + deltaMinutes);
return { startMinutes: ref.originalStartMinutes, durationMinutes: newDuration };
}
// Top edge: move start, keep end fixed. Clamp so duration stays >= 15 and start >= 0.
// Top edge: move start, keep end fixed. Clamp so duration stays >= 15 and
// start stays on the grid.
let newStart = ref.originalStartMinutes + deltaMinutes;
newStart = Math.max(0, Math.min(originalEnd - 15, newStart));
newStart = Math.max(Math.min(gridStartMinutes, ref.originalStartMinutes), Math.min(originalEnd - 15, newStart));
return { startMinutes: newStart, durationMinutes: originalEnd - newStart };
}, [hourHeight]);
}, [hourHeight, gridStartMinutes]);
const handleResizePointerDown = useCallback((
eventId: string,
@@ -193,12 +203,12 @@ export function useTimeGridInteractions({
const { startMinutes, durationMinutes } = computeResize(resizeRef.current, e.clientY);
setResizeVisual({
eventId: resizeRef.current.eventId,
topPx: (startMinutes / 60) * hourHeight,
topPx: ((startMinutes - gridStartMinutes) / 60) * hourHeight,
heightPx: (durationMinutes / 60) * hourHeight,
startMinutes,
durationMinutes,
});
}, [hourHeight, computeResize]);
}, [hourHeight, computeResize, gridStartMinutes]);
const handleResizePointerUp = useCallback(async (e: PointerEvent) => {
const resize = resizeRef.current;
@@ -335,9 +345,9 @@ export function useTimeGridInteractions({
const snapDragMinutes = useCallback((e: DragEvent<HTMLDivElement>): number => {
const rect = e.currentTarget.getBoundingClientRect();
const y = e.clientY - rect.top;
const raw = (y / hourHeight) * 60;
return Math.max(0, Math.min(1425, Math.round(raw / 15) * 15));
}, [hourHeight]);
const raw = gridStartMinutes + (y / hourHeight) * 60;
return Math.max(gridStartMinutes, Math.min(gridEndMinutes - 15, Math.round(raw / 15) * 15));
}, [hourHeight, gridStartMinutes, gridEndMinutes]);
const handleColumnDragOver = useCallback((e: DragEvent<HTMLDivElement>, dayKey: string) => {
if (!e.dataTransfer.types.includes("application/x-calendar-event")) return;
+20 -7
View File
@@ -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
+2
View File
@@ -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"}
+7 -12
View File
@@ -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);
+139 -1
View File
@@ -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,162 @@
import { describe, expect, it } from "vitest";
import {
FULL_DAY_HOURS,
clipToDisplayHours,
formatDisplayHour,
indexOfDayOnOrAfter,
partitionByDisplayHours,
remapSegmentsToShownDays,
resolveDisplayHours,
resolveWorkingDays,
} from "@/lib/calendar-display-range";
import type { CalendarWeekSegment } from "@/lib/calendar-utils";
import type { CalendarEvent } from "@/lib/jmap/types";
function timed(id: string, start: string, duration: string): CalendarEvent {
return {
id, "@type": "Event", uid: id, title: id, start, duration,
showWithoutTime: false, calendarIds: { cal: true },
} as unknown as CalendarEvent;
}
const WORK_HOURS = resolveDisplayHours(true, 8, 20);
describe("resolveDisplayHours (#1164)", () => {
it("restricts the grid to the configured hours", () => {
expect(WORK_HOURS).toEqual({ startMinutes: 480, endMinutes: 1200, restricted: true });
});
it("shows the whole day when switched off or set to the whole day", () => {
expect(resolveDisplayHours(false, 8, 20)).toBe(FULL_DAY_HOURS);
expect(resolveDisplayHours(true, 0, 24)).toBe(FULL_DAY_HOURS);
});
it.each([
[20, 8], [8, 8], [-1, 20], [8, 25], [8.5, 20], ["8", 20], [null, undefined],
])("falls back to the whole day for a malformed range %s-%s", (start, end) => {
expect(resolveDisplayHours(true, start, end)).toBe(FULL_DAY_HOURS);
});
});
describe("resolveWorkingDays (#1164)", () => {
it("returns the selected weekdays", () => {
expect(resolveWorkingDays(true, [1, 2, 3, 4, 5])).toEqual(new Set([1, 2, 3, 4, 5]));
});
it("shows every day when off, empty, complete or malformed", () => {
expect(resolveWorkingDays(false, [1, 2])).toBeNull();
expect(resolveWorkingDays(true, [])).toBeNull();
expect(resolveWorkingDays(true, [0, 1, 2, 3, 4, 5, 6])).toBeNull();
expect(resolveWorkingDays(true, "1,2")).toBeNull();
expect(resolveWorkingDays(true, [7, -1, 1.5])).toBeNull();
});
it("ignores entries that are not weekdays", () => {
expect(resolveWorkingDays(true, [1, 9, "2", 3])).toEqual(new Set([1, 3]));
});
});
describe("partitionByDisplayHours (#1164)", () => {
const day = new Date(2026, 8, 9);
it("counts events above and below the visible hours instead of drawing them", () => {
const result = partitionByDisplayHours([
timed("early", "2026-09-09T06:00:00", "PT1H"),
timed("ends-at-start", "2026-09-09T07:00:00", "PT1H"),
timed("straddles-start", "2026-09-09T07:30:00", "PT1H"),
timed("zero-at-start", "2026-09-09T08:00:00", "PT0M"),
timed("ends-at-end", "2026-09-09T19:30:00", "PT30M"),
timed("late", "2026-09-09T21:00:00", "PT1H"),
timed("starts-at-end", "2026-09-09T20:00:00", "PT30M"),
], day, WORK_HOURS);
expect(result.visible.map((e) => e.id)).toEqual(["straddles-start", "zero-at-start", "ends-at-end"]);
expect(result.before).toBe(2);
expect(result.firstBeforeMinutes).toBe(360);
expect(result.after).toBe(2);
expect(result.firstAfterMinutes).toBe(1200);
});
it("keeps every event when the whole day is shown", () => {
const events = [timed("early", "2026-09-09T06:00:00", "PT1H")];
const result = partitionByDisplayHours(events, day, FULL_DAY_HOURS);
expect(result.visible).toBe(events);
expect(result.before).toBe(0);
});
it("keeps an event spanning the visible hours from the previous night", () => {
const result = partitionByDisplayHours([timed("overnight", "2026-09-08T22:00:00", "PT12H")], day, WORK_HOURS);
expect(result.visible).toHaveLength(1);
});
});
describe("clipToDisplayHours (#1164)", () => {
it("cuts an event to the visible hours and reports which ends were cut", () => {
expect(clipToDisplayHours(420, 540, WORK_HOURS)).toEqual({
startMinutes: 480, endMinutes: 540, clippedStart: true, clippedEnd: false,
});
expect(clipToDisplayHours(1140, 1260, WORK_HOURS)).toEqual({
startMinutes: 1140, endMinutes: 1200, clippedStart: false, clippedEnd: true,
});
});
it("leaves a whole-day grid untouched", () => {
expect(clipToDisplayHours(0, 1440, FULL_DAY_HOURS)).toEqual({
startMinutes: 0, endMinutes: 1440, clippedStart: false, clippedEnd: false,
});
});
});
describe("remapSegmentsToShownDays (#1164)", () => {
// Mon 7 .. Sun 20 Sep 2026 with weekends hidden: columns Mon-Fri, Mon-Fri.
const shown = [0, 1, 2, 3, 4, -1, -1, 5, 6, 7, 8, 9, -1, -1];
const segment = (startIndex: number, span: number): CalendarWeekSegment => ({
event: timed("e", "2026-09-07T00:00:00", "P1D"),
startIndex, span, row: 0, continuesBefore: false, continuesAfter: false,
});
it("moves a segment across a hidden weekend onto the shown columns", () => {
// Fri 11 .. Mon 14 -> Fri column 4 .. Mon column 5
expect(remapSegmentsToShownDays([segment(4, 4)], shown)).toEqual([
expect.objectContaining({ startIndex: 4, span: 2, row: -1, continuesBefore: false, continuesAfter: false }),
]);
});
it("drops a segment that only covers hidden days", () => {
expect(remapSegmentsToShownDays([segment(5, 2)], shown)).toEqual([]);
});
it("marks a segment as continuing where hidden days were cut off", () => {
// Sat 12 .. Tue 15 -> Mon .. Tue, continuing before
expect(remapSegmentsToShownDays([segment(5, 4)], shown)).toEqual([
expect.objectContaining({ startIndex: 5, span: 2, continuesBefore: true, continuesAfter: false }),
]);
// Thu 17 .. Sun 20 -> Thu .. Fri, continuing after
expect(remapSegmentsToShownDays([segment(10, 4)], shown)).toEqual([
expect.objectContaining({ startIndex: 8, span: 2, continuesBefore: false, continuesAfter: true }),
]);
});
});
describe("indexOfDayOnOrAfter", () => {
const days = [new Date(2026, 8, 11), new Date(2026, 8, 14), new Date(2026, 8, 15)];
it("finds the next shown day for a hidden one", () => {
expect(indexOfDayOnOrAfter(days, new Date(2026, 8, 12, 15, 30))).toBe(1);
expect(indexOfDayOnOrAfter(days, new Date(2026, 8, 14))).toBe(1);
});
it("clamps to the loaded days", () => {
expect(indexOfDayOnOrAfter(days, new Date(2026, 8, 1))).toBe(0);
expect(indexOfDayOnOrAfter(days, new Date(2026, 9, 1))).toBe(2);
});
});
describe("formatDisplayHour", () => {
it("labels the end of the day as 24:00", () => {
expect(formatDisplayHour(8, "24h")).toBe("08:00");
expect(formatDisplayHour(24, "24h")).toBe("24:00");
expect(formatDisplayHour(24, "12h")).toBe("12:00 AM");
expect(formatDisplayHour(20, "12h")).toBe("8:00 PM");
});
});
+54
View File
@@ -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 });
});
});
+17 -1
View File
@@ -34,7 +34,7 @@ const MANAGED_ENV = [
'SESSION_SECRET_FILE', 'SETTINGS_SYNC_ENABLED', 'STALWART_FEATURES', 'DEV_MOCK_JMAP',
'FAVICON_URL', 'APP_LOGO_LIGHT_URL', 'APP_LOGO_DARK_URL', 'LOGIN_COMPANY_NAME',
'LOGIN_IMPRINT_URL', 'LOGIN_PRIVACY_POLICY_URL', 'LOGIN_WEBSITE_URL', 'DOMAIN_BRANDING',
'JMAP_SERVERS', 'JMAP_SERVER_AUTO_PICK_BY_DOMAIN',
'JMAP_SERVERS', 'JMAP_SERVER_AUTO_PICK_BY_DOMAIN', 'SOURCE_CODE_URL',
] as const;
describe('config API route', () => {
@@ -107,6 +107,7 @@ describe('config API route', () => {
expect(config.faviconUrl).toBe('/branding/Bulwark_Favicon.svg');
expect(config.appLogoLightUrl).toBe('');
expect(config.appLogoDarkUrl).toBe('');
expect(config.sourceCodeUrl).toBe('');
});
// The domain lists name every organisation served by this instance.
@@ -161,6 +162,21 @@ describe('config API route', () => {
expect(config.loginWebsiteUrl).toBe('https://acme.com');
});
it('publishes the source code URL for the About card (#1161)', async () => {
vi.stubEnv('SOURCE_CODE_URL', 'https://git.example.com/acme/webmail/tree/v1.2.3');
expect((await getConfig()).sourceCodeUrl).toBe('https://git.example.com/acme/webmail/tree/v1.2.3');
});
it.each(['javascript:alert(1)', 'data:text/html,x', '/relative/path', 'not a url'])(
'drops a source code URL that is not absolute http(s): %s',
async (value) => {
vi.stubEnv('SOURCE_CODE_URL', value);
expect((await getConfig()).sourceCodeUrl).toBe('');
},
);
it('should handle partial login customization', async () => {
vi.stubEnv('LOGIN_COMPANY_NAME', 'Partial Corp');
// Leave URLs unset
+15
View File
@@ -3,6 +3,7 @@ import {
buildFolderRef,
resolveFolderRef,
isFolderLinkOpen,
pathNamesMailFolder,
buildMailPath,
parseMailPath,
buildCalendarPath,
@@ -153,6 +154,20 @@ describe('isFolderLinkOpen', () => {
});
});
describe('pathNamesMailFolder', () => {
it('spots a folder link behind any mount and locale prefix', () => {
expect(pathNamesMailFolder('/mail/folder/trash')).toBe(true);
expect(pathNamesMailFolder('/en/mail/folder/inbox')).toBe(true);
expect(pathNamesMailFolder('/webmail/de/mail/folder/Mxyz/')).toBe(true);
});
it('is false for the bare app, other mail links and other surfaces', () => {
for (const path of ['/', '/en', '/en/mail', '/mail/folder/', '/en/mail/message/m1', '/en/calendar', '/email/folder/x']) {
expect(pathNamesMailFolder(path), path).toBe(false);
}
});
});
describe('mail paths', () => {
it('round-trips folder, message and thread links', () => {
const folder = buildMailPath({ mailboxId: 'c', emailId: null, threadId: null }, MAILBOXES);
+59
View File
@@ -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();
});
});
+1
View File
@@ -38,6 +38,7 @@ export function createConfig(overrides: Partial<ConfigData> = {}): ConfigData {
jmapServerAutoPickByDomain: false,
embeddedMode: false,
parentOrigin: '',
sourceCodeUrl: '',
...overrides,
};
}
@@ -29,9 +29,35 @@ describe('copyEmailAcrossAccounts', () => {
expect(calls.find(([m]) => m === 'Email/set')![1]).toEqual({ accountId: 'me', destroy: ['m1'] });
});
it('keeps the source when asked for a plain copy', async () => {
const { client, calls } = clientWithCopyResult({ created: { c: { id: 'new1' } } });
await expect(client.copyEmailAcrossAccounts('m1', 'me', 'grp', 'inbox', { keepOriginal: true })).resolves.toBe('new1');
expect(calls.some(([m]) => m === 'Email/copy')).toBe(true);
expect(calls.some(([m]) => m === 'Email/set')).toBe(false);
});
it('keeps the source when the copy fails', async () => {
const { client, calls } = clientWithCopyResult({ notCreated: { c: { type: 'overQuota' } } });
await expect(client.copyEmailAcrossAccounts('m1', 'me', 'grp', 'inbox')).rejects.toThrow('overQuota');
expect(calls.some(([m]) => m === 'Email/set')).toBe(false);
});
});
describe('importRawEmail', () => {
it('keeps the original date of a message carried over from another account', async () => {
const client = new JMAPClient('https://jmap.example.com', 'user@example.com', 'pass');
Object.assign(client, { accountId: 'me' });
vi.spyOn(client, 'uploadBlob').mockResolvedValue({ blobId: 'b1' } as never);
const request = vi.spyOn(client as unknown as { request: (c: Call[]) => Promise<unknown> }, 'request')
.mockResolvedValue({ methodResponses: [['Email/import', { created: { 'smime-import': { id: 'n1' } } }, '0']] });
await client.importRawEmail(new Blob(['raw']), { inbox: true }, { $seen: true }, undefined, '2024-03-01T10:00:00Z');
const emails = (request.mock.calls[0][0][0][1] as { emails: Record<string, Record<string, unknown>> }).emails;
expect(emails['smime-import'].receivedAt).toBe('2024-03-01T10:00:00Z');
// Without a date the server stamps the import time, as before.
await client.importRawEmail(new Blob(['raw']), { inbox: true });
const plain = (request.mock.calls[1][0][0][1] as { emails: Record<string, Record<string, unknown>> }).emails;
expect(plain['smime-import']).not.toHaveProperty('receivedAt');
});
});
+21 -2
View File
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { JMAPClient } from '../jmap/client';
import { JMAPClient, SendUnconfirmedError } from '../jmap/client';
/**
* #849: replacing a draft (autosave, send) used to bundle the destroy of the
@@ -29,7 +29,7 @@ interface CapturedRequest { methodCalls: JMAPMethodCall[] }
* Email/set create come back as notCreated; `failSubmission` does the same
* for EmailSubmission/set.
*/
function mockFlow({ failCreate = false, failSubmission = false } = {}) {
function mockFlow({ failCreate = false, failSubmission = false, methodError = false, missingSubmission = false, emptyErrors = false } = {}) {
const captured: CapturedRequest[] = [];
vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => {
const body = JSON.parse((init as { body: string }).body) as CapturedRequest;
@@ -56,11 +56,14 @@ function mockFlow({ failCreate = false, failSubmission = false } = {}) {
result.created = { [key]: { id: 'email-new-1' } };
}
}
if (emptyErrors) result.notCreated = {};
if (args.destroy) {
result.destroyed = args.destroy;
}
methodResponses.push(['Email/set', result, callId]);
} else if (method === 'EmailSubmission/set') {
if (missingSubmission) continue;
if (methodError) { methodResponses.push(['error', { type: 'serverFail', description: 'Google unavailable; draft retained' }, callId]); continue; }
methodResponses.push(['EmailSubmission/set',
failSubmission
? { notCreated: { '1': { type: 'forbiddenFrom', description: 'not allowed' } } }
@@ -230,3 +233,19 @@ describe('draft replace lifecycle (#849)', () => {
]);
});
});
describe('compose failure recovery', () => {
beforeEach(() => { vi.restoreAllMocks(); vi.spyOn(console, 'error').mockImplementation(() => {}); });
it.each([{ methodError: true }, { missingSubmission: true }])('does not claim success or delete the old draft without confirmation: %j', async options => {
const client = createClient(); const captured = mockFlow(options);
await expect(client.sendEmail(['bob@example.com'], 'Subject', 'body', undefined, undefined, 'identity-1', 'user@example.com', 'old-draft')).rejects.toThrow(
// Only the missing submission is unconfirmed; a method error is a plain failure.
options.missingSubmission ? SendUnconfirmedError : Error,
);
expect(emailSetCalls(captured).some(call => call.destroy)).toBe(false);
});
it('accepts empty notCreated maps as success', async () => {
const client = createClient(); mockFlow({ emptyErrors: true });
expect(await client.createDraft(['bob@example.com'], 'Subject', 'body')).toBe('email-new-1');
});
});
+10
View File
@@ -32,6 +32,16 @@ describe('sendMethodErrors', () => {
expect(filing).toEqual({ type: 'serverFail' });
});
it('reports a refused message create instead of the dangling reference it causes', () => {
const refused = { type: 'invalidProperties', description: 'Invalid blobId', properties: ['attachments'] };
const { failure } = sendMethodErrors([
['Email/set', { created: {}, notCreated: { 'send-1': refused } }, '0'],
['error', { type: 'invalidResultReference', description: 'Invalid reference to non-existing object "send-1" from "1"' }, '1'],
['error', { type: 'invalidResultReference' }, 'deliveryStatus'],
]);
expect(failure).toEqual(refused);
});
it('treats a submission that created nothing as not sent', () => {
const { failure } = sendMethodErrors([
['EmailSubmission/set', { created: {}, notCreated: { 1: { type: 'forbiddenFrom' } } }, '1'],
+21
View File
@@ -147,6 +147,27 @@ describe('JMAPClient request limits', () => {
expect(sent.map(calls => calls.length)).toEqual([4, 4, 4, 4, 2]);
});
it('leaves the excluded folders out of both counts (#1156)', async () => {
const client = await connectedClient({ maxCallsInRequest: 16 });
const sent = recordRequests((methodCalls) => ({
methodResponses: methodCalls.map(([, , callId]) => ['Email/query', { total: 0 }, callId]),
}));
await client.getTagCounts(['work'], undefined, ['trash', 'junk']);
const [total, unread] = sent[0].map(([, args]) => (args as { filter: unknown }).filter);
expect(total).toEqual({
operator: 'AND',
conditions: [{ hasKeyword: '$label:work' }, { inMailboxOtherThan: ['trash', 'junk'] }],
});
expect(unread).toEqual({
operator: 'AND',
conditions: [
{ hasKeyword: '$label:work' }, { notKeyword: '$seen' }, { inMailboxOtherThan: ['trash', 'junk'] },
],
});
});
});
describe('getCategoryUnreadCounts', () => {
@@ -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');
});
});
+7
View File
@@ -63,6 +63,13 @@ describe('applyLiteConfig (config.json for the static build)', () => {
expect(applyLiteConfig('garbage').appName).toBe('Webmail');
});
it('keeps an http(s) source code URL and drops any other scheme', () => {
expect(applyLiteConfig({}).sourceCodeUrl).toBe('');
expect(applyLiteConfig({ sourceCodeUrl: 'https://git.example.com/acme/webmail' }).sourceCodeUrl)
.toBe('https://git.example.com/acme/webmail');
expect(applyLiteConfig({ sourceCodeUrl: 'javascript:alert(1)' }).sourceCodeUrl).toBe('');
});
it('keeps deployer-facing branding and login toggles', () => {
const config = applyLiteConfig({
appName: 'Acme Mail',
+74
View File
@@ -0,0 +1,74 @@
import { describe, it, expect } from 'vitest';
import type { Email, Mailbox } from '@/lib/jmap/types';
import type { MailboxNode } from '@/lib/utils';
import { buildMoveTargets, resolveMoveOwnerAccountId, OWN_ACCOUNT_MOVE_NODE_PREFIX } from '../move-targets';
const writable = { mayAddItems: true } as Mailbox['myRights'];
function own(id: string, name: string, role?: string): Mailbox {
return {
id, originalId: id, name, role, sortOrder: 0, totalEmails: 0, unreadEmails: 0, totalThreads: 0, unreadThreads: 0,
myRights: writable, isSubscribed: true, accountId: 'A', accountName: 'me@example.org', isShared: false,
} as Mailbox;
}
function shared(id: string, name: string, role?: string): Mailbox {
return {
id: `S:${id}`, originalId: id, name, role, sortOrder: 0, totalEmails: 0, unreadEmails: 0, totalThreads: 0, unreadThreads: 0,
myRights: writable, isSubscribed: true, accountId: 'S', accountName: 'info@example.org', isShared: true,
} as Mailbox;
}
// Same bare ids in both accounts, like a real Stalwart pair.
const mailboxes = [
own('a', 'Inbox', 'inbox'),
own('b', 'Junk', 'junk'),
own('c', 'BELEDİYE'),
shared('a', 'Inbox', 'inbox'),
shared('b', 'Junk', 'junk'),
shared('d', 'Trash', 'trash'),
];
const flat = (nodes: MailboxNode[]): string[] => nodes.flatMap((n) => [n.id, ...flat(n.children)]);
describe('buildMoveTargets', () => {
it('keeps the own folders first for a message of the own account', () => {
const { tree, targetIds } = buildMoveTargets(mailboxes, { currentMailboxId: 'a', ownerAccountId: 'A' });
expect(tree.map((n) => n.id)).toEqual(['b', 'c', 'shared-account-S']);
expect(targetIds.has('a')).toBe(false);
});
it('puts the shared account first for a message in a shared mailbox (#1149)', () => {
const { tree, targetIds } = buildMoveTargets(mailboxes, { currentMailboxId: 'S:a', ownerAccountId: 'S' });
expect(tree.map((n) => n.id)).toEqual(['shared-account-S', `${OWN_ACCOUNT_MOVE_NODE_PREFIX}A`]);
expect(tree[1].name).toBe('me@example.org');
expect(targetIds.has(tree[1].id)).toBe(false);
// The shared Junk is the first pickable folder, the open folder is not offered.
expect(flat(tree).filter((id) => targetIds.has(id))).toEqual(['S:b', 'S:d', 'a', 'b', 'c']);
});
it('leaves other shared accounts after the own folders', () => {
const other = { ...shared('x', 'Inbox', 'inbox'), id: 'T:x', accountId: 'T', accountName: 'team@example.org' };
const { tree } = buildMoveTargets([...mailboxes, other], { currentMailboxId: 'S:a', ownerAccountId: 'S' });
expect(tree.map((n) => n.id)).toEqual(['shared-account-S', `${OWN_ACCOUNT_MOVE_NODE_PREFIX}A`, 'shared-account-T']);
});
});
describe('resolveMoveOwnerAccountId', () => {
it('reads the account from the namespaced folder the message is in', () => {
const email = { mailboxIds: { 'S:a': true } } as unknown as Email;
expect(resolveMoveOwnerAccountId(email, mailboxes, 'a')).toBe('S');
});
it('prefers the source stamp of unified and search views', () => {
const email = { sourceAccountId: 'S', mailboxIds: { a: true } } as unknown as Email;
expect(resolveMoveOwnerAccountId(email, mailboxes, 'a')).toBe('S');
});
it('falls back to the open folder', () => {
const email = { mailboxIds: { unknown: true } } as unknown as Email;
expect(resolveMoveOwnerAccountId(email, mailboxes, 'S:d')).toBe('S');
expect(resolveMoveOwnerAccountId(null, mailboxes, 'b')).toBe('A');
});
});
@@ -54,6 +54,12 @@ describe('native <select> option colours (#999)', () => {
expect(body).toMatch(/(?:^|[^-])color:\s*var\(--color-popover-foreground\)/);
});
it('pins the group labels to the popover tokens too', () => {
const body = ruleBody('optgroup');
expect(body).toMatch(/background-color:\s*var\(--color-popover\)/);
expect(body).toMatch(/(?:^|[^-])color:\s*var\(--color-popover-foreground\)/);
});
it('asks the browser for a dark popup in dark mode', () => {
expect(ruleBody('.dark select')).toMatch(/color-scheme:\s*dark/);
});
+77
View File
@@ -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"\](?!\))/);
}
});
});
+100 -2
View File
@@ -65,10 +65,10 @@ function mockJmap() {
});
}
async function callRoute(accountId: string) {
async function callRoute(accountId: string, emailId: string | null = null) {
const { GET } = await import('@/app/api/push/preview/route');
const request = {
nextUrl: { searchParams: { get: (k: string) => (k === 'accountId' ? accountId : null) } },
nextUrl: { searchParams: { get: (k: string) => (k === 'accountId' ? accountId : k === 'emailId' ? emailId : null) } },
};
const res = (await GET(request as unknown as Parameters<typeof GET>[0])) as unknown as {
status: number;
@@ -116,3 +116,101 @@ describe('push preview route account resolution', () => {
expect(status).toBe(401);
});
});
describe('push preview JMAP failures', () => {
beforeEach(() => {
vi.clearAllMocks();
mockJmap();
});
it.each([
['error', { type: 'unsupportedFilter' }, 'mb'],
['Mailbox/query', {}, 'mb'],
])('does not silence a push when mailbox lookup fails (%s)', async (method, body, id) => {
fetchJmapServer.mockReset()
.mockResolvedValueOnce(jsonResponse(SESSION))
.mockResolvedValueOnce(jsonResponse({ methodResponses: [[method, body, id]] }));
expect((await callRoute('a')).status).toBe(502);
});
it.each([
[['error', { type: 'serverFail' }, 'eq'], ['error', { type: 'resultReference' }, 'eg']],
[['Email/query', { ids: ['e1'], total: 1 }, 'eq'], ['error', { type: 'serverFail' }, 'eg']],
])('does not report zero unread on an email method failure', async (query, get) => {
fetchJmapServer.mockReset()
.mockResolvedValueOnce(jsonResponse(SESSION))
.mockResolvedValueOnce(jsonResponse({ methodResponses: [['Mailbox/query', { ids: ['inbox'] }, 'mb']] }))
.mockResolvedValueOnce(jsonResponse({ methodResponses: [query, get] }));
expect((await callRoute('a')).status).toBe(502);
});
it('still reports genuinely empty unread results', async () => {
fetchJmapServer.mockReset()
.mockResolvedValueOnce(jsonResponse(SESSION))
.mockResolvedValueOnce(jsonResponse({ methodResponses: [['Mailbox/query', { ids: ['inbox'] }, 'mb']] }))
.mockResolvedValueOnce(jsonResponse({ methodResponses: [
['Email/query', { ids: [], total: 0 }, 'eq'],
['Email/get', { list: [] }, 'eg'],
] }));
expect(await callRoute('a')).toEqual({ status: 200, body: { email: null, unreadTotal: 0, slot: 0 } });
});
// `total` is optional in a JMAP Email/query response.
it('counts the ids when the server leaves out the total', async () => {
fetchJmapServer.mockReset()
.mockResolvedValueOnce(jsonResponse(SESSION))
.mockResolvedValueOnce(jsonResponse({ methodResponses: [['Mailbox/query', { ids: ['inbox'] }, 'mb']] }))
.mockResolvedValueOnce(jsonResponse({ methodResponses: [
['Email/query', { ids: [] }, 'eq'],
['Email/get', { list: [] }, 'eg'],
] }));
expect(await callRoute('a')).toEqual({ status: 200, body: { email: null, unreadTotal: 0, slot: 0 } });
});
it('previews the named message even when the Inbox lookups fail', async () => {
fetchJmapServer.mockReset()
.mockResolvedValueOnce(jsonResponse(SESSION))
.mockResolvedValueOnce(jsonResponse({ methodResponses: [['Mailbox/query', { ids: ['inbox'] }, 'mb']] }))
.mockResolvedValueOnce(jsonResponse({ methodResponses: [
['error', { type: 'serverFail' }, 'eq'],
['error', { type: 'resultReference' }, 'eg'],
['Email/get', { list: [{ id: 'd', threadId: 'td' }] }, 'delivered'],
] }));
expect(await callRoute('a', 'd')).toEqual({
status: 200, body: { email: { id: 'd', threadId: 'td' }, unreadTotal: 1, slot: 0 },
});
});
});
it('previews a delivered message outside an empty Inbox', async () => {
fetchJmapServer.mockReset()
.mockResolvedValueOnce(jsonResponse(SESSION))
.mockResolvedValueOnce(jsonResponse({ methodResponses: [['Mailbox/query', { ids: ['inbox'] }, 'mb']] }))
.mockResolvedValueOnce(jsonResponse({ methodResponses: [
['Email/query', { ids: [], total: 0 }, 'eq'],
['Email/get', { list: [] }, 'eg'],
['Email/get', { list: [{ id: 'filed', threadId: 't2' }] }, 'delivered'],
] }));
expect(await callRoute('a', 'filed')).toEqual({
status: 200, body: { email: { id: 'filed', threadId: 't2' }, unreadTotal: 1, slot: 0 },
});
});
it('names no message when the delivered one cannot be read, rather than another', async () => {
// The push named a message - deleted since, or filed where this login
// cannot see it - and the Inbox happens to hold an older unread one. That
// older message is not what arrived.
fetchJmapServer.mockReset()
.mockResolvedValueOnce(jsonResponse(SESSION))
.mockResolvedValueOnce(jsonResponse({ methodResponses: [['Mailbox/query', { ids: ['inbox'] }, 'mb']] }))
.mockResolvedValueOnce(jsonResponse({ methodResponses: [
['Email/query', { ids: ['stale'], total: 3 }, 'eq'],
['Email/get', { list: [{ id: 'stale', threadId: 't9' }] }, 'eg'],
['Email/get', { list: [] }, 'delivered'],
] }));
expect(await callRoute('a', 'gone')).toEqual({
status: 200, body: { email: null, unreadTotal: 3, slot: 0 },
});
});
@@ -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']);
});
});
+52
View File
@@ -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');
});
});

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