diff --git a/.env.example b/.env.example index fac2b79bf..baf9a0043 100644 --- a/.env.example +++ b/.env.example @@ -302,6 +302,12 @@ LOGIN_COMPANY_NAME=Bulwark Webmail # URL for the company website link on the login page. LOGIN_WEBSITE_URL=https://bulwarkmail.org +# Where users get the source code of this build, linked from Settings > +# About. Bulwark is AGPL-3.0-only: if you run a modified build, set this to +# the source of exactly what you deploy (your fork, at the deployed commit). +# Unset links to the upstream repository. Only http(s) URLs are used. +# SOURCE_CODE_URL=https://git.example.com/acme/webmail/tree/v1.2.3 + # Cap the login logo's rendered size. Any CSS length ("120px", "8rem"). # Unset means the logo renders at its natural size. # LOGIN_LOGO_MAX_HEIGHT=96px diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 000000000..827c40e31 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,21 @@ +## What and why + + + +## Related issues + + + +## Checklist + +- [ ] The change fits [What fits in Bulwark](https://bulwarkmail.org/docs/development/contributing#what-fits-in-bulwark) (core, not plugin material) +- [ ] It needed no issue first, or the approach was agreed in the linked issue (over ~500 lines excluding locales and tests, a new runtime dependency, or security-sensitive code: [details](https://bulwarkmail.org/docs/development/contributing#before-you-write-code)) +- [ ] `npm run typecheck && npm run lint && npx vitest run` pass, and new logic has tests +- [ ] New user-facing text goes through translations +- [ ] Works in Bulwark Lite, or is hidden there +- [ ] UI changes checked at phone width and in the light and dark theme +- [ ] Documentation updated in the [website repository](https://github.com/bulwarkmail/website/tree/main/docs), or not needed + +## Screenshots + + 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/CONTRIBUTING.md b/CONTRIBUTING.md index 93b184e8e..ff3c3fb6a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,6 +4,18 @@ Bug reports, feature requests, translations and patches are all welcome. The full guide is in the documentation under [Contributing](https://bulwarkmail.org/docs/development/contributing). It covers the development setup, the test suites, translations and right-to-left layouts, code style, and how pull requests are reviewed. +## What fits + +Read [What fits in Bulwark](https://bulwarkmail.org/docs/development/contributing#what-fits-in-bulwark) before you start on a feature. In short: + +- Bulwark is built for Stalwart. Changes for other JMAP servers are welcome if they cost Stalwart users nothing. +- Core is for what most people expect from a mail, calendar, contacts and files client. Niche workflows and integrations with other services belong in plugins. +- Not in core: IMAP or other non-JMAP backends, integrations with other services, Stalwart server administration, and changes to the look that go beyond themes. +- New behaviour gets one good default. If deployments really need different behaviour, add an admin setting, not a per-user toggle. +- Features must work in Bulwark Lite, or be hidden there, and UI changes must work at phone width. + +Open an issue and agree on the approach before you write a change that is larger than about 500 lines (not counting locales and tests), adds a runtime dependency, or touches sign-in, sessions, the HTML sanitizer, the plugin sandbox, outgoing-request guards or admin routes. Deliver large features as a series of pull requests that can each be reviewed on their own. Pull requests with review feedback unanswered for 30 days are closed. + To get a development server running: ```bash 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 29c1a6a56..bab81c8ad 100644 --- a/app/(main)/admin/_tabs/policy.tsx +++ b/app/(main)/admin/_tabs/policy.tsx @@ -32,6 +32,7 @@ const FEATURE_GATE_LABELS: Partial - +

- Point the client URL at a WOPI-compatible office suite (Collabora Online, OnlyOffice, EuroOffice, …) to let users edit documents in Files. The host override is only needed when the editor reaches this webmail under a different address than the browser does (e.g. Docker networks). The editor must also allow this webmail as a WOPI host. + Point the client URL at a WOPI-compatible office suite (Collabora Online, OnlyOffice, EuroOffice, …) to let users edit documents in Files. The client URL may be an internal address: the browser loads the editor from the address its discovery advertises. The host override is only needed when the editor reaches this webmail under a different address than the browser does (e.g. Docker networks); give the address alone, the webmail's base path is added to it. The editor must also allow this webmail as a WOPI host.

diff --git a/app/api/calendar-agenda/route.ts b/app/api/calendar-agenda/route.ts index 4415a0ed6..7c2f199ca 100644 --- a/app/api/calendar-agenda/route.ts +++ b/app/api/calendar-agenda/route.ts @@ -12,10 +12,15 @@ import type { CalendarEvent } from '@/lib/jmap/types'; * POST /api/calendar-agenda * * Sidecar for the "Calendar Agenda" plugin. Resolves the caller's calendar - * account from the stored Stalwart auth context, queries upcoming + * accounts from the stored Stalwart auth context, queries upcoming * CalendarEvents over JMAP, expands recurring series server-side, and returns * a slim, structured-cloneable agenda the plugin can render directly. * + * Events come from every account that has calendars: the primary calendar + * account plus shared and group accounts (a family or team calendar), the + * same set the calendar app shows. Ids are scoped by account, since event + * and calendar ids repeat across accounts. + * * Credentials never leave the server — the sandboxed plugin only ever sees * the resulting agenda DTOs. * @@ -35,7 +40,9 @@ const EVENT_PROPERTIES = [ ] as const; interface AgendaEvent { + /** Unique across accounts: `:`. */ 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/config/route.ts b/app/api/config/route.ts index 5afcba1cf..1f01fa83e 100644 --- a/app/api/config/route.ts +++ b/app/api/config/route.ts @@ -4,6 +4,7 @@ import { configManager } from '@/lib/admin/config-manager'; import { parseJmapServers, redactJmapServers } from '@/lib/admin/jmap-servers'; import { hasSessionSecret } from '@/lib/auth/session-secret'; import { getOauthScopes } from '@/lib/oauth/tokens'; +import { httpUrlOrEmpty } from '@/lib/config-validation'; import { matchDomainBranding, parseDomainBranding, @@ -95,6 +96,7 @@ export async function GET(request: NextRequest) { autoSsoEnabled: configManager.get('autoSsoEnabled', false), embeddedMode: !!allowedFrameAncestors && allowedFrameAncestors !== "'none'", parentOrigin: configManager.get('parentOrigin', ''), + sourceCodeUrl: httpUrlOrEmpty(configManager.get('sourceCodeUrl', '')), }, { // Runtime settings can change without new asset URLs. Prevent stale 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/push/preview/route.ts b/app/api/push/preview/route.ts index f58e2243e..bf19ccdcc 100644 --- a/app/api/push/preview/route.ts +++ b/app/api/push/preview/route.ts @@ -161,10 +161,16 @@ export async function GET(request: NextRequest) { }; const inboxBody = inboxData.methodResponses.find( - ([method]) => method === 'Mailbox/query', + ([method, , callId]) => method === 'Mailbox/query' && callId === 'mb', )?.[1] as { ids?: string[] } | undefined; - const inboxId = inboxBody?.ids?.[0]; + // A method-level JMAP error still has HTTP 200. Do not turn it into + // an empty Inbox: the service worker would silently discard the push. + if (!Array.isArray(inboxBody?.ids)) { + return NextResponse.json({ error: 'JMAP mailbox query failed' }, { status: 502 }); + } + + const inboxId = inboxBody.ids[0]; const emailProperties = ['id', 'threadId', 'from', 'subject', 'preview', 'receivedAt']; if (!inboxId && !requestedEmailId) { @@ -239,6 +245,21 @@ export async function GET(request: NextRequest) { methodResponses: [string, Record, string][]; }; + // Missing/failed method responses are preview failures, not proof that + // there is no unread mail. A non-2xx response lets the SW show its fallback. + // The message the server named, once read, is a complete preview on its + // own, whatever became of the Inbox lookups next to it. + const resultOf = (name: string, id: string) => + data.methodResponses.find(([method, , callId]) => method === name && callId === id)?.[1]; + const failed = (methodCalls as [string, unknown, string][]).some(([name, , id]) => { + const result = resultOf(name, id); + return !result || (name === 'Email/query' ? !Array.isArray(result.ids) : !Array.isArray(result.list)); + }); + const deliveredList = resultOf('Email/get', 'delivered')?.list; + if (failed && !(Array.isArray(deliveredList) && deliveredList.length > 0)) { + return NextResponse.json({ error: 'JMAP email query failed' }, { status: 502 }); + } + type EmailLite = { id: string; threadId: string; @@ -253,7 +274,9 @@ export async function GET(request: NextRequest) { let unreadTotal = 0; for (const [method, body, callId] of data.methodResponses) { if (method === 'Email/query') { - unreadTotal = ((body as { total?: number }).total) ?? 0; + // A server that leaves out the optional total still lists the ids. + const query = body as { total?: number; ids?: unknown[] }; + unreadTotal = typeof query.total === 'number' ? query.total : (query.ids?.length ?? 0); } if (method === 'Email/get') { const list = (body as { list?: EmailLite[] }).list ?? []; @@ -270,6 +293,13 @@ export async function GET(request: NextRequest) { unreadTotal = Math.max(unreadTotal, 1); } email = delivered; + } else if (requestedEmailId) { + // The server named the message that arrived and it could not be read - + // deleted since, or filed where this login cannot see it. "Newest unread + // in the Inbox" is then some other message, and announcing it would be + // confidently wrong. Name no message: the unread total still comes back, + // so the SW posts its generic toast instead of staying silent. + email = null; } // Some servers build the preview from HTML without skipping