diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml new file mode 100644 index 000000000..67bab7c2c --- /dev/null +++ b/.github/workflows/integration.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b7777999..daada1fed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/VERSION b/VERSION index 0eed1a29e..feaae22ba 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.12.0 +1.13.0 diff --git a/app/(main)/admin/_tabs/policy.tsx b/app/(main)/admin/_tabs/policy.tsx index b7aeb7c7b..994f57134 100644 --- a/app/(main)/admin/_tabs/policy.tsx +++ b/app/(main)/admin/_tabs/policy.tsx @@ -32,6 +32,7 @@ const FEATURE_GATE_LABELS: Partial:`. */ 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(); - const calResp = findResponse(queryRes, 'Calendar/get', 'c'); - for (const cal of ((calResp?.list as Array>) || [])) { - 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 = []; - if (ids.length === 0) { - return NextResponse.json({ events: [], generatedAt: now.toISOString() }); - } - - // ── 2) Fetch full event objects (batched) ── - const raw: Array> = []; - 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>)); + }; + + 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>) || [])) { + 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> = []; + 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>)); + } + + const normalized = raw + .map((e) => normalizeCalendarEventLike(e as Partial)) + .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)) - .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; + accounts?: Record; +}): 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 }; + 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; diff --git a/app/api/dev-jmap/[...path]/route.ts b/app/api/dev-jmap/[...path]/route.ts index 00b2c1572..f4069ded6 100644 --- a/app/api/dev-jmap/[...path]/route.ts +++ b/app/api/dev-jmap/[...path]/route.ts @@ -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: [], diff --git a/app/api/settings/route.ts b/app/api/settings/route.ts index d3ff0a0e5..265f7bc9d 100644 --- a/app/api/settings/route.ts +++ b/app/api/settings/route.ts @@ -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'; diff --git a/app/globals.css b/app/globals.css index 8336675a0..5df163f6d 100644 --- a/app/globals.css +++ b/app/globals.css @@ -212,6 +212,44 @@ optgroup { color-scheme: dark; } +/* Tailwind v4's preflight no longer gives buttons a pointer cursor, so most + controls showed the arrow. Give everything clickable the pointer again, and + disabled controls not-allowed, as the components that style it themselves + already do. This sits in the base layer so a cursor utility on a component + (cursor-grab, cursor-text, disabled:cursor-default) still wins. */ +@layer base { + button:not(:disabled), + select:not(:disabled), + input:is([type="checkbox"], [type="radio"], [type="color"]):not(:disabled), + summary, + [role="button"]:not([aria-disabled="true"]), + [role="tab"]:not([aria-disabled="true"]), + [role="switch"]:not([aria-disabled="true"]), + [role="checkbox"]:not([aria-disabled="true"]), + [role="radio"]:not([aria-disabled="true"]), + [role="option"]:not([aria-disabled="true"]), + [role="menuitem"]:not([aria-disabled="true"]), + [role="menuitemcheckbox"]:not([aria-disabled="true"]), + [role="menuitemradio"]:not([aria-disabled="true"]) { + cursor: pointer; + } + + button:disabled, + select:disabled, + input:is([type="checkbox"], [type="radio"], [type="color"]):disabled, + [role="button"][aria-disabled="true"], + [role="tab"][aria-disabled="true"], + [role="switch"][aria-disabled="true"], + [role="checkbox"][aria-disabled="true"], + [role="radio"][aria-disabled="true"], + [role="option"][aria-disabled="true"], + [role="menuitem"][aria-disabled="true"], + [role="menuitemcheckbox"][aria-disabled="true"], + [role="menuitemradio"][aria-disabled="true"] { + cursor: not-allowed; + } +} + /* Minimalist scrollbar */ ::-webkit-scrollbar { width: 8px; diff --git a/components/calendar/calendar-app.tsx b/components/calendar/calendar-app.tsx index b24f5016a..cfbce5667 100644 --- a/components/calendar/calendar-app.tsx +++ b/components/calendar/calendar-app.tsx @@ -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; 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>({ - 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>({ - 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) { diff --git a/components/calendar/event-modal.tsx b/components/calendar/event-modal.tsx index 7108cdf59..e2eccfe67 100644 --- a/components/calendar/event-modal.tsx +++ b/components/calendar/event-modal.tsx @@ -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 = { - 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(null); diff --git a/components/contacts/contacts-app.tsx b/components/contacts/contacts-app.tsx index 2a82490da..011a4afd0 100644 --- a/components/contacts/contacts-app.tsx +++ b/components/contacts/contacts-app.tsx @@ -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(); diff --git a/components/email/__tests__/composer-attach-from-files.test.tsx b/components/email/__tests__/composer-attach-from-files.test.tsx new file mode 100644 index 000000000..13f5232f1 --- /dev/null +++ b/components/email/__tests__/composer-attach-from-files.test.tsx @@ -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) => 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) => 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) => 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) => 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) => 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) => 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) => 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 = {}) { + 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(); + 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(); + + 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(); + + 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(); + + 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'); + }); +}); diff --git a/components/email/__tests__/empty-subject-warning.test.tsx b/components/email/__tests__/empty-subject-warning.test.tsx index 43a55dab4..6d4d09496 100644 --- a/components/email/__tests__/empty-subject-warning.test.tsx +++ b/components/email/__tests__/empty-subject-warning.test.tsx @@ -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( + + ); + 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 }); + }); }); diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index e34180cb6..ab9f926b0 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -5,7 +5,7 @@ import { useFocusTrap } from "@/hooks/use-focus-trap"; import { useTranslations } from "next-intl"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, BookmarkPlus, CalendarClock, ChevronDown, MailCheck, Search, Users, PackageCheck, LockKeyhole, Type } from "@/components/icons"; +import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, BookmarkPlus, CalendarClock, ChevronDown, MailCheck, Search, Users, PackageCheck, LockKeyhole, Type, HardDrive } from "@/components/icons"; import { cn, formatFileSize, formatDateTime, generateUUID } from "@/lib/utils"; import { debug } from "@/lib/debug"; import { toast } from "@/stores/toast-store"; @@ -34,6 +34,9 @@ import { useSettingsStore } from "@/stores/settings-store"; import { PluginSlot } from "@/components/plugins/plugin-slot"; import { Avatar } from "@/components/ui/avatar"; import { FilePreviewModal } from "@/components/files/file-preview-modal"; +import { FilePickerDialog } from "@/components/files/file-picker-dialog"; +import type { IJMAPClient } from "@/lib/jmap/client-interface"; +import type { FileNode } from "@/lib/jmap/types"; import { useContactStore, getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store"; import { useTemplateStore } from "@/stores/template-store"; import { SubAddressHelper } from "@/components/identity/sub-address-helper"; @@ -374,6 +377,7 @@ export function EmailComposer({ const { isFeatureEnabled } = usePolicyStore(); const templatesEnabled = isFeatureEnabled('templatesEnabled'); + const filesEnabled = isFeatureEnabled('filesEnabled'); // The signature identity used when embedding the signature into the initial // body for "above quote" mode. Mirrors the signatureIdentity derivation @@ -686,6 +690,9 @@ export function EmailComposer({ const [fromOverrideEmail, setFromOverrideEmail] = useState(initialData?.fromOverrideEmail ?? ''); const [fromOverrideName, setFromOverrideName] = useState(initialData?.fromOverrideName ?? ''); const [showTemplatePicker, setShowTemplatePicker] = useState(false); + // The Files account being browsed for attachments, fixed when the picker + // opens so the picked blobs are tagged with the login that owns them. + const [filesPicker, setFilesPicker] = useState<{ client: IJMAPClient; accountId?: string } | null>(null); const [showSaveAsTemplate, setShowSaveAsTemplate] = useState(false); const [showCloseDialog, setShowCloseDialog] = useState(false); const [showAllAttachments, setShowAllAttachments] = useState(false); @@ -1861,6 +1868,51 @@ export function EmailComposer({ ]); }, []); + // Files from the Files app are already blobs on the server, so they are + // attached by blobId with no download or upload (#1179). Stalwart resolves a + // FileNode's blob in Email/set for its owner and for anyone it is shared + // with. The same checks as for a local file still apply. + const handleFilesPicked = useCallback(async (nodes: FileNode[]) => { + const picker = filesPicker; + setFilesPicker(null); + if (!picker) return; + + let picked: FileNode[] = []; + for (const node of nodes) { + const ok = await emailHooks.onBeforeAttachmentUpload.intercept({ + name: node.name, + type: node.type || 'application/octet-stream', + size: node.size, + }); + if (ok && node.blobId) picked.push(node); + } + const maxTotal = picker.client.getMaxSizeAttachmentsPerEmail?.() ?? 0; + if (maxTotal > 0) { + let total = attachmentsRef.current.reduce((sum, att) => sum + (att.size || 0), 0); + const fitting = picked.filter(node => { + if (total + node.size > maxTotal) return false; + total += node.size; + return true; + }); + if (fitting.length < picked.length) { + toast.error(t('attachments_total_too_large', { max: formatFileSize(maxTotal) })); + } + picked = fitting; + } + if (picked.length === 0) return; + + setAttachments(prev => [ + ...prev, + ...picked.map((node): ComposerAttachment => ({ + name: node.name, + type: node.type || 'application/octet-stream', + size: node.size, + blobId: node.blobId!, + sourceAccountId: picker.accountId, + })), + ]); + }, [filesPicker, t]); + // Inline preview for composer attachments, reusing the message viewer's // FilePreviewModal (so previewability and the open-in-new-tab safety gate are // handled there). Prefer the local File - no network - and fall back to the @@ -1986,7 +2038,10 @@ export function EmailComposer({ const previousDraftId = draftIdRef.current; let savedDraft : AlmostSavedDraft = { to: toAddresses, - subject: subject || t('no_subject'), + // Save an empty subject as empty. A "(No Subject)" placeholder here + // came back as a real subject on reopen, and Send then skipped the + // empty-subject warning (#1189). + subject, body: draftTextBody, cc: ccAddresses, bcc: bccAddresses, @@ -2740,6 +2795,7 @@ export function EmailComposer({ if ( showTemplatePicker || + filesPicker || showSaveAsTemplate || showScheduleDialog || showAttachmentWarning || @@ -3339,6 +3395,19 @@ export function EmailComposer({ > + {filesEnabled && composerClient?.supportsFiles?.() && ( + + )} )} - {email.from?.[0]?.email && ( + {/* Trusting a forged address would load remote content + for the next forgery too. */} + {email.from?.[0]?.email && !senderVerification && ( + ))} + + + )} ) : ( /* ======= LIST VIEW ======= */ @@ -1605,7 +1543,7 @@ export function FileBrowser({ )} - {displayResources.length === 0 && searchQuery ? ( + {displayResources.length === 0 && sharedFolders.length === 0 && searchQuery ? ( {t("no_results")} @@ -1688,6 +1626,44 @@ export function FileBrowser({ ))} + {sharedFolders.length > 0 && ( + + + + + {t("shared_with_me")} + + + + )} + {sharedFolders.map((resource) => ( + openSharedFolder(resource)} + title={resource.ownerName ? t("shared_by", { name: resource.ownerName }) : undefined} + > + +
+
+ {getFileIcon(resource)} + + +
+ + - + + {formatDate(resource.lastModified)} + + + + ))} )} @@ -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); }} /> )}
diff --git a/components/files/file-icons.tsx b/components/files/file-icons.tsx new file mode 100644 index 000000000..471a03d61 --- /dev/null +++ b/components/files/file-icons.tsx @@ -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 ; + if (is3DFile(name)) return ; + if (isImageFile(name)) return ; + if (isAudioFile(name)) return ; + if (isVideoFile(name)) return ; + if (isArchiveFile(name)) return ; + if (isExecutableFile(name)) return ; + if (isSpreadsheetFile(name)) return ; + if (isPresentationFile(name)) return ; + if (isWordDocumentFile(name)) return ; + if (isFontFile(name)) return ; + if (isDatabaseFile(name)) return ; + if (isPdfFile(name)) return ; + if (isTextFile(name)) return ; + return ; +} diff --git a/components/files/file-picker-dialog.tsx b/components/files/file-picker-dialog.tsx new file mode 100644 index 000000000..3e1d46581 --- /dev/null +++ b/components/files/file-picker-dialog.tsx @@ -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(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>(() => 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(); + 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) => ( + + ); + + const fileRow = (node: FileNode, detail?: string) => { + const checked = selected.has(node.id); + return ( + + ); + }; + + const sharedBy = (node: FileNode) => + here === SHARED_WITH_ME && node.accountName ? t("shared_by", { name: node.accountName }) : undefined; + + let body: ReactNode; + if (failed) { + body = ( +
+ +

{tComposer("files_picker.load_failed")}

+ +
+ ); + } else if (!nodes) { + body = ( +
+ +
+ ); + } else if (entries.length === 0 && !(here === null && !needle && sharedRoots.length > 0)) { + body = ( +

+ {needle ? t("no_results") : here === null ? t("empty_state_title") : tComposer("files_picker.empty_folder")} +

+ ); + } else { + body = ( +
+ {here === null && !needle && sharedRoots.length > 0 && folderRow( + SHARED_WITH_ME, + t("shared_with_me"), + , + () => openFolder(SHARED_WITH_ME, t("shared_with_me")), + )} + {entries.map((node) => + isFolder(node) + ? folderRow( + node.id, + node.name, + , + () => openFolder(node.id, node.name), + sharedBy(node), + ) + : fileRow(node, needle ? folderPath(node) : sharedBy(node)), + )} +
+ ); + } + + return ( +
+
+
+

+ {tComposer("attach_from_files")} +

+ +
+ +
+
+ + setQuery(e.target.value)} + placeholder={t("search_placeholder")} + className="ps-9 h-9" + /> +
+ {!needle && ( + + )} +
+ +
{body}
+ +
+ + +
+
+
+ ); +} diff --git a/components/files/files-app.tsx b/components/files/files-app.tsx index 0a15510ec..3ea94c2eb 100644 --- a/components/files/files-app.tsx +++ b/components/files/files-app.tsx @@ -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(); diff --git a/components/files/folder-tree-sidebar.tsx b/components/files/folder-tree-sidebar.tsx index af8339c69..d6810a820 100644 --- a/components/files/folder-tree-sidebar.tsx +++ b/components/files/folder-tree-sidebar.tsx @@ -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; 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(); 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(null); const [expandedIds, setExpandedIds] = useState>(new Set(["root"])); const [loadingIds, setLoadingIds] = useState>(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 (
)) )} @@ -220,6 +235,19 @@ export function FolderTreeSidebar({ currentPath, onNavigate, listByParentId, wid
)} + + {onShare && ( + + { + if (contextMenu.data) onShare(contextMenu.data); + closeContextMenu(); + }} + /> + + )} ); } @@ -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; + 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} /> ))} diff --git a/components/filters/__tests__/filter-rule-modal-dst.test.tsx b/components/filters/__tests__/filter-rule-modal-dst.test.tsx new file mode 100644 index 000000000..0924bf93c --- /dev/null +++ b/components/filters/__tests__/filter-rule-modal-dst.test.tsx @@ -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(); + 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'); +}); diff --git a/components/filters/__tests__/filter-rule-modal-period.test.tsx b/components/filters/__tests__/filter-rule-modal-period.test.tsx new file mode 100644 index 000000000..0109630bb --- /dev/null +++ b/components/filters/__tests__/filter-rule-modal-period.test.tsx @@ -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 => ({ + 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> = {}) { + const onSave = vi.fn(); + render(); + 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(); + 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(); + 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(); + expect(screen.getByText('5 Oct 2026, 08:00 – 16 Oct 2026, 18:00')).toBeInTheDocument(); + expect(screen.queryByText(/period_status_/)).toBeNull(); + unmount(); + render(); + 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(); + expect(screen.queryByText('period_title')).toBeNull(); + unmount(); + const { container } = render(); + expect(container.textContent).not.toContain('·'); + }); +}); diff --git a/components/filters/__tests__/quick-rule-host.test.tsx b/components/filters/__tests__/quick-rule-host.test.tsx new file mode 100644 index 000000000..1d6d55acf --- /dev/null +++ b/components/filters/__tests__/quick-rule-host.test.tsx @@ -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, 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(); +} + +/** Once the account's script has been read and counted. */ +async function counted(server: ReturnType) { + 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(); + }); +}); diff --git a/components/filters/filter-rule-modal.tsx b/components/filters/filter-rule-modal.tsx index bf4703127..3fca0201f 100644 --- a/components/filters/filter-rule-modal.tsx +++ b/components/filters/filter-rule-modal.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useCallback, useMemo } from "react"; +import { useState, useCallback, useMemo, useRef } from "react"; import { useTranslations } from "next-intl"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -20,6 +20,8 @@ import { buildMailboxTree, flattenMailboxTree, type MailboxNode, generateUUID, c import type { RuleSuggestion } from "@/lib/filters/quick-rules"; import { retroactiveSupport } from "@/lib/filters/retroactive"; import { ruleForwards, ruleStops } from "@/lib/filters/forward-limit"; +import { hasPeriod, isPeriodBoundary } from "@/lib/sieve/period"; +import { fromWallClock, getEffectiveTimeZone, getWallClock } from "@/lib/timezone"; import { useSettingsStore } from "@/stores/settings-store"; import { useKeywordFormat } from "@/hooks/use-keyword-format"; @@ -44,6 +46,8 @@ interface FilterRuleModalProps { */ forwardsBefore?: number; forwardsAfter?: number; + /** The server can confine a rule to a period (Sieve "date" and "relational"). */ + periodsSupported?: boolean; onSave: (rule: FilterRule, options?: { applyToExisting: boolean }) => void; onClose: () => void; } @@ -115,6 +119,37 @@ function makeEmptyAction(): FilterAction { return { type: "move", value: "" }; } +// A period boundary as the "yyyy-mm-ddThh:mm" a datetime-local input shows, +// read on the clock of the user's time zone - the one dates are shown in. +function toPeriodInput(boundary: string | undefined): string { + if (!isPeriodBoundary(boundary)) return ""; + const w = getWallClock(new Date(boundary), getEffectiveTimeZone()); + const pad = (n: number, len = 2) => String(n).padStart(len, "0"); + return `${pad(w.year, 4)}-${pad(w.month)}-${pad(w.day)}T${pad(w.hour)}:${pad(w.minute)}`; +} + +// The latest moment the period fields offer: a later year has no room in +// the four digits a stored boundary has. +const PERIOD_INPUT_MAX = "9999-12-31T23:59"; + +// The input's wall-clock time in the user's time zone, as the UTC moment the +// rule stores: undefined for an empty field, null for one that holds no +// usable moment (a five-digit year). A boundary left as the dialog showed it +// is kept exactly: the input shows whole minutes, so reading it back would +// drop a saved second, or pick the other one of the two moments in the hour +// a DST switch repeats. +function fromPeriodInput(value: string, saved: string | undefined): string | null | undefined { + if (!value) return undefined; + if (saved !== undefined && value === toPeriodInput(saved)) return saved; + const m = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?$/.exec(value); + if (!m) return null; + const [year, month, day, hour, minute, second] = m.slice(1).map((part) => Number(part ?? 0)); + const moment = fromWallClock({ year, month, day, hour, minute, second }, getEffectiveTimeZone()); + if (Number.isNaN(moment.getTime())) return null; + const boundary = moment.toISOString(); + return isPeriodBoundary(boundary) ? boundary : null; +} + export function FilterRuleModal({ rule, initialRule, @@ -124,6 +159,7 @@ export function FilterRuleModal({ maxRedirects, forwardsBefore = 0, forwardsAfter = 0, + periodsSupported = false, onSave, onClose, }: FilterRuleModalProps) { @@ -145,6 +181,12 @@ export function FilterRuleModal({ const [includeSpam, setIncludeSpam] = useState(start?.includeSpam ?? false); const [usedSuggestions, setUsedSuggestions] = useState>(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(null); + const periodEndRef = useRef(null); const modalRef = useFocusTrap({ isActive: true, onEscape: onClose }); @@ -217,9 +259,12 @@ export function FilterRuleModal({ // the conditions decide. const canApplyToExisting = useMemo(() => { if (!offerApplyToExisting) return false; + // A rule with a period acts on mail as it arrives within it, which says + // nothing about the mail already there. + if (periodOn) return false; const checkActions: FilterAction[] = validActions.length > 0 ? validActions : [{ type: "mark_read" }]; return retroactiveSupport({ conditions: validConditions, actions: checkActions }).ok; - }, [offerApplyToExisting, validConditions, validActions]); + }, [offerApplyToExisting, validConditions, validActions, periodOn]); const handleSave = useCallback(() => { const trimmedName = name.trim(); @@ -238,6 +283,24 @@ export function FilterRuleModal({ return; } + const activeFrom = periodOn ? fromPeriodInput(periodStart, start?.activeFrom) : undefined; + const activeUntil = periodOn ? fromPeriodInput(periodEnd, start?.activeUntil) : undefined; + // A field the browser holds half filled in (a date without its time) + // reads as empty, and must not pass for an open end of the period. + const halfFilled = !!periodStartRef.current?.validity.badInput || !!periodEndRef.current?.validity.badInput; + if (activeFrom === null || activeUntil === null || (periodOn && halfFilled)) { + toast.error(t("validation_period_invalid")); + return; + } + if (periodOn && !activeFrom && !activeUntil) { + toast.error(t("validation_period_empty")); + return; + } + if (activeFrom && activeUntil && Date.parse(activeUntil) <= Date.parse(activeFrom)) { + toast.error(t("validation_period_order")); + return; + } + onSave({ id: start?.id || generateUUID(), name: trimmedName, @@ -246,9 +309,14 @@ export function FilterRuleModal({ conditions: validConditions, actions: validActions, stopProcessing, - ...(includeSpam && validActions.some((a) => ACTIONS_WITH_MAILBOX.has(a.type)) ? { includeSpam: true } : {}), + // Each optional field this dialog controls is set, to undefined when it + // is off: the settings page merges the result into the stored rule, and + // a field left out would keep its old value there. + includeSpam: includeSpam && validActions.some((a) => ACTIONS_WITH_MAILBOX.has(a.type)) ? true : undefined, + activeFrom, + activeUntil, }, { applyToExisting: applyToExisting && canApplyToExisting }); - }, [name, matchType, validConditions, validActions, stopProcessing, includeSpam, start, onSave, t, applyToExisting, canApplyToExisting]); + }, [name, matchType, validConditions, validActions, stopProcessing, includeSpam, periodOn, periodStart, periodEnd, start, onSave, t, applyToExisting, canApplyToExisting]); const visibleSuggestions = suggestions.filter((s) => !usedSuggestions.has(s.id)); @@ -597,7 +665,7 @@ export function FilterRuleModal({ type="email" className="flex-1 min-w-[180px]" /> -