Commit Graph
235 Commits
Author SHA1 Message Date
Linus Rath 58ddd01cf7 Merge pull request #792 from dev-hive-kazniisa/fix/shared-mailbox-folder-management
fix(email-store): route shared-folder management to the owner account
2026-08-17 12:18:24 +02:00
Linus Rath 344542337a fix: don't export OG_WIDTH/OG_HEIGHT from the og-image route 2026-08-17 00:18:59 +02:00
Linus Rath 117bf4a3f2 fix: import vCard dates as RFC 9553 PartialDate and map common X- extensions #224 2026-08-17 00:09:30 +02:00
Linus Rath d9f9ed10cd fix: add embedded custom app origins to CSP frame-src #787 2026-08-16 23:48:58 +02:00
Linus Rath 22aa328405 feat: branded OpenGraph/Twitter link previews with generated card image 2026-08-16 23:40:25 +02:00
Linus Rath b37313af95 fix: recreate the push subscription on re-register and add per-device revoke #841 2026-08-16 23:35:34 +02:00
Linus Rath eac56ca3bf fix: enforce external media preference on plugin-rendered bodies #797 2026-08-16 23:23:27 +02:00
Linus Rath 5b8ddc352b feat: pick push relay from admin-defined list instead of a URL field 2026-08-16 23:04:22 +02:00
Linus Rath 5a42b3252b fix: iOS Safari zooms the viewport on every input focus #838 2026-08-16 23:02:27 +02:00
Linus Rath 48727489b1 Merge pull request #793 from brambuijs/fix/unclosed-angle-recipient-split
fix: split recipient lists whose angle brackets never close
2026-08-16 22:26:32 +02:00
Linus Rath abc800eab5 fix: do not drop recipients while recovering unclosed angle runs
Review follow-ups on the unclosed-angle split:

- The paste path fell back to splitMailbox for every entry, which keeps the
  last angle run only and folds everything ahead of it into a display name.
  `Ann <a@x.com Bob <b@y.com` became one chip and a@x.com disappeared without
  even landing in `invalid`. Take that path only when the prefix is not an
  address itself.
- The new separator branch ignored `inGroup`, so an unclosed `<` inside RFC
  5322 group syntax shattered the group its sibling branch keeps intact.
- angleRunCloses now scans quote-aware, so a `>` inside a quoted display name
  no longer counts as closing the run, and it starts past `from` so the helper
  is correct whether it gets the separator or the opening `<`.
2026-08-16 22:25:15 +02:00
Linus Rath 83026fe621 fix: keep already written text when applying a template in compose #540 2026-08-16 22:20:49 +02:00
Linus Rath e44f35a203 fix: reuse cached access token on session restore #552 2026-08-16 22:20:07 +02:00
Linus Rath b76ce0d119 fix: folders containing a system folder name disappear from the sidebar #771 2026-08-16 22:11:26 +02:00
Linus Rath fc0ab57247 Merge pull request #835 from hildebrandttk/fix/hide-tasks-only-calendars
fix(calendar): hide tasks-only calendars from the event calendar UI
2026-08-16 21:59:36 +02:00
Linus Rath 265aeca57c Merge pull request #836 from hildebrandttk/fix/config-route-test-env-isolation
test(config-route): isolate env with vi.stubEnv, fix secret-file race
2026-08-16 21:59:26 +02:00
Stefan Hildebrandt 5fc32c4e89 test(config-route): isolate env with vi.stubEnv, fix secret-file race
The suite intermittently failed "settingsSync ... SESSION_SECRET_FILE" under
full parallel load (passed in isolation). Two causes:

- env vars were mutated directly on the shared process.env, so other test
  files in the same worker could leak in. Use vi.stubEnv + unstubAllEnvs so
  each test's env is isolated and auto-restored, and stub the managed vars to
  undefined in beforeEach for a clean baseline.
- both SESSION_SECRET_FILE tests wrote a shared relative ./session-secret and
  removed it with a fire-and-forget async unlink; under load the pending
  unlink from the first test fired during the second (after write, before
  read), so the file was missing. Use a unique temp file per test, removed
  synchronously in afterEach.

Verified: 17/17, stable across repeated runs and with hostile env
(APP_NAME/SETTINGS_SYNC_ENABLED/SESSION_SECRET) set in the real environment.
2026-08-16 21:32:23 +02:00
Linus Rath ffff165a4b Merge pull request #800 from rotterp/fix/shared-folder-account-context
fix: a directly-selected shared folder has no account context (tags, compose identity)
2026-08-16 21:27:18 +02:00
Linus Rath 60abbc332e Merge pull request #832 from hildebrandttk/fix/sse-single-stream-guard
fix(jmap): keep exactly one SSE stream per client and coalesce state …
2026-08-16 21:27:01 +02:00
Linus Rath eff3869612 Merge pull request #834 from hildebrandttk/fix/recurring-single-instance-override
fix(calendar): edit a single recurring occurrence via a one-shot over…
2026-08-16 21:26:46 +02:00
Stefan Hildebrandt b5e71e7b84 fix(calendar): edit a single recurring occurrence via a one-shot override patch
The "This event only" edit path built nested JMAP patch pointers
(recurrenceOverrides/<recurrenceId>/<field>). On the first edit of an
occurrence the override object doesn't exist yet, and JMAP pointers can't
create a missing intermediate object, so Stalwart returned notUpdated and
the edit failed with two error toasts.

Build the override as a single pointer recurrenceOverrides/<recurrenceId>
whose value is the occurrence's field set (dropping identity/series keys),
mirroring the working "delete this occurrence" path. Extracted as the pure,
tested helper buildRecurrenceOverridePatch.

Closes #774.
2026-08-16 21:24:52 +02:00
Stefan Hildebrandt 9c084d9d63 fix(calendar): hide tasks-only calendars from the event calendar UI
A calendar holding only tasks (VTODO, e.g. from tasks.org) appeared in the
event calendar UI as an empty calendar. Stalwart's Calendar/get exposes no
supported-component set, so tasks-only-ness is derived from the calendar's
objects (mirroring the existing event/task split): a calendar is tasks-only
iff it holds >=1 object and every object is a task.

- getTasksOnlyCalendarIds scans an account's objects (CalendarEvent/query +
  minimal /get), early-exits once every calendar has an event, only marks
  after a full scan, and fails open on the cap or any error.
- getAllCalendars/getCalendars annotate calendars with isTasksOnly.
- Such calendars stay in the store for the tasks view (task picker, task-list
  grouping) but are filtered from the event sidebar and default selection.

Verified against Stalwart v0.16.16: the tasks-only calendar is flagged, the
default (with an event) is not.

Closes #761.
2026-08-16 21:18:57 +02:00
Stefan Hildebrandt ffdc51ef70 fix(reply): honour an external Reply-To even on a self-sent message
The self-sent handling from #703 (reply to your own thread message ->
address the original recipients, ignore Reply-To) was entered whenever
From matched one of the user's identities. Contact-form / helpdesk mail
commonly arrives From the user's own address with the real correspondent
in Reply-To, so that branch ignored the Reply-To and looped the reply
back to the user's own To.

Only take the self-sent branch when there is no EXTERNAL Reply-To: a
Reply-To resolving to a non-own address is an explicit RFC 5322 directive
and now wins even for self-sent messages (#776). A Reply-To that is only
our own address stays ignored for self-sent (#703 preserved).

Closes #776.
2026-08-16 21:12:21 +02:00
Stefan Hildebrandt 26e8c5797f fix(jmap): keep exactly one SSE stream per client and coalesce state polls
connectSSE() overwrote sseAbortController with a new controller without
aborting the previous one, so re-running setupPushNotifications (which
happens per connected account and on fairly frequent effect changes)
orphaned SSE streams. readSSEStream only unwinds on abort/done, so each
orphan held its HTTP/1.1 socket forever; with multiple accounts this
starved normal JMAP POSTs, which queued and timed out (~30s).

- connectSSE: abort any prior stream (+ its ping monitor / pending
  reconnect) before opening a new one -> exactly one live SSE per client.
- startPollingFallback: bail if a polling interval already exists.
- checkForStateChanges: skip while a poll is already in flight
  (stateCheckInFlight) so overlapping triggers don't stack POSTs.

Closes #781.
2026-08-16 21:09:13 +02:00
Linus Rath 5ab5749306 Merge pull request #802 from dealerweb/fix/keep-alive-test-mock
test: serve a fresh mock response per fetch in the keep-alive test
2026-08-16 13:28:21 +02:00
Linus Rath 8e3b4e71a1 fix: open right conversations in shared mailbox #814 2026-08-16 02:19:04 +02:00
Linus Rath e6597542fd fix: include email templates in cross-device settings sync #825 2026-08-16 02:09:38 +02:00
Linus Rath 89139b4055 fix: keep the signature in saved drafts 2026-08-16 01:50:39 +02:00
Linus Rath 6dc344f547 Merge pull request #811 from hildebrandttk/fix/files-stale-data-on-account-switch
fix(files): reset the account-scoped Files drive on every account switch
2026-08-13 23:59:12 +02:00
Linus Rath 0f3cf0e7ea Merge pull request #807 from hildebrandttk/feat/event-editor-end-follows-start
feat(calendar): move the end with the start, keeping the event's length
2026-08-13 23:57:37 +02:00
Stefan Hildebrandt 5c06ae8bc2 fix(files): reset the account-scoped Files drive on every account switch
The Files store was the only account-scoped store never wired into the
account lifecycle: account-state-manager clears email/contacts/calendar/
filter/identity/vacation on a switch, but not files. Because the Files
store is a global that outlives the FilesApp component, the previous
account's client + resources lingered in memory and resurfaced whenever
the drive was reopened after a switch - most visibly when the switch
happened on the mail view (FilesApp unmounted), the common normal-shell
flow that the FilesApp re-init effect alone does not cover.

Clear the Files store centrally in clearAllStores() alongside the other
account-scoped stores, so the reset happens on every switch path.

Tests:
- lib/__tests__/account-state-manager.test.ts: assert clearAllStores()
  resets the Files drive (client, resources, pathStack, selection,
  supportsFiles) with no cross-account leak.
- integration/tests/13-files-account-switch.spec.ts (dockerized Stalwart,
  JMAP FileNode): seeds a distinct top-level folder in alice's and bob's
  drives and asserts the on-screen drive swaps on a switch with no stale
  folder - both switching off the Files view (mail -> switch -> open
  Files) and on it (rail account switcher). Verified: passes with the fix,
  fails (path 1) without it. New FileNode seeding helpers in helpers/jmap.ts;
  openFiles/openMailView/driveEntry nav helpers in helpers/app.ts.
2026-08-13 23:01:41 +02:00
Stefan Hildebrandt fa76bb895d feat(calendar): move the end with the start, keeping the event's length
Editing an event's start now shifts the end by the same amount, preserving the
current duration (end - start). Moving the start forward never leaves the end
before it, and moving it either way never silently changes the length. Guards
against an already-negative-duration state.

Extracted a pure `shiftedEnd(oldStart, oldEnd, newStart)` for the start
date/time onChange handlers. +7 unit tests. tsc + eslint clean.
2026-08-13 21:24:09 +02:00
Ctaoist b09d5c857d fix: make label reordering atomic 2026-08-13 21:17:05 +08:00
dealerweb 556b320e26 test: serve a fresh mock response per fetch in the keep-alive test
The keep-alive test parked one shared Response instance in
mockResolvedValue, but a Response body is single-use - as soon as more
than one request lands in the 30s window the second read dies with
"Body is unusable" and the ping reports a lost connection. Serve a
fresh Response per call via mockImplementation, the pattern the rest
of this file already uses.
2026-08-13 11:57:38 +02:00
Patrick RotterandClaude Opus 5 a971d5a119 fix: preselect the shared folder identity for new messages
Composing a new message while a shared/group folder was selected started
it as the reaching login's primary address instead of the shared one,
even when a matching send-as identity existed.

`composeFromAccountEmail` was resolved from
`getAccountById(viewingAccountId ?? activeAccountId)`. A delegated
mailbox has no separate login and no AccountEntry, and
`setViewingAccount` is only ever called with `null`, so selecting a
shared folder leaves both ids pointing at the primary account:
`findComposeIdentityId` was handed the wrong address and correctly
matched the primary identity.

Resolve the address from the selected mailbox instead, falling back to
the account lookup for own folders. `accountName` is the JMAP
`Account.name`, which RFC 8620 describes as "e.g., the email address of
the account"; a server that puts a human label there produces no
identity match and the composer keeps the primary identity, which is the
previous behaviour. Replies are unaffected - `composeFromAccountEmail`
is only read when `mode === 'compose'`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 10:42:55 +02:00
Ctaoist 6e792f8834 feat: expose label reordering to extensions 2026-08-13 10:59:45 +08:00
Linus Rath c6f3fffab5 Merge pull request #717 from dealerweb/fix/data-uri-allowlist-media-tags
fix: apply the data: URI allowlist to media tags too
2026-08-12 18:09:21 +02:00
Linus Rath 1cfbc48a9d Merge pull request #784 from zisisvakras/fix/relative-namespace-i18n
fix: scope separate translation hooks instead of relative namespace p…
2026-08-12 18:08:38 +02:00
Linus Rath 8ca553f92a Merge pull request #789 from ctaoist/mail-gateway
feat: expose label settings to extensions
2026-08-12 18:08:12 +02:00
dealerweb b11c3e8932 fix: apply the data: URI allowlist to srcset candidates too
DOMPurify URI-tests srcset as a single string, so an allowed first
candidate waved every later one through - an SVG data: URI could ride
in behind a raster candidate on <img srcset> and <picture><source>.
Check every data: occurrence against the raster allowlist and drop the
whole attribute on one disallowed hit, including the external blocker's
data-blocked-srcset stash, which shares the hook pass with no ordering
guarantee. The token is captured through the metadata-ending comma so
the plain (non-base64) raster form keeps the same verdict as in src.
2026-08-12 17:47:51 +02:00
zisisvakras 07ce2d0139 fix: scope separate translation hooks instead of relative namespace paths
next-intl has no "../" relative-namespace escape. A key like
t('../context_menu.not_spam') is not resolved relative to anything -
it is concatenated literally into the lookup key and always misses,
throwing MISSING_MESSAGE. email-list.tsx, about-data-settings.tsx,
and account-settings.tsx all used this pattern to reach keys outside
their own translation namespace.

Fixed by calling useTranslations() again for each additional
namespace needed, which is the supported way to reach sibling
namespaces, instead of trying to walk out of the current one.

The translations test already verifies that every key referenced in
source resolves in the en locale, but its extractor skipped any key
starting with a dot, which is exactly the shape of the broken calls,
so this class of bug never failed CI. Since no legitimate key can
start with a dot, the exclusion is removed. With it gone the test
reports all thirteen call sites fixed here when run against the old
code, and passes with the fix, so a reintroduced relative path fails
CI with the offending file listed.
2026-08-12 17:53:35 +03:00
Ctaoist 01ef9240d4 refactor: generalize JMAP keyword capability 2026-08-12 22:47:00 +08:00
zisisvakras 44360024bc fix: check the specific capability a request declares, not a broader one
contactUsing(), calendarUsing(), and fileUsing() checked the base
"principals" capability but then declared the separate
"principals:owner" sub-capability (RFC 9670) in the request's "using"
array. RFC 8620 section 3.3 requires a server to reject the whole
request if it declares a capability the server does not advertise, so
a server that supports base principals without the owner
sub-capability rejects every one of these requests with
unknownCapability.

Fixed to check the specific sub-capability being declared instead of
assuming it from the broader one. The calendar agenda route had the
same mismatch and is fixed the same way. Note that RFC 9670 section
1.5.2 places principals:owner in accountCapabilities rather than the
session capabilities, so on compliant servers the sub-capability is
simply never declared, which is the intent. Nothing is lost by
omitting it.

The file share test asserted the old behavior by expecting the owner
capability to be declared against a server that advertises only base
principals. It now asserts the capability is omitted in that case and
declared only when the server advertises it. The same pair of
assertions is added for the contacts, calendars, and agenda route
request builders, so each site this commit touches is covered against
regression.
2026-08-12 17:34:20 +03:00
Bram Buijs 149e16ec4a fix: split recipient lists whose angle brackets never close
A `<` with no matching `>` latched splitRecipients into "inside an
address" for the rest of the string, so every later separator was kept
literal and the whole list collapsed into a single entry. Pasting

    Ap Reinders <ap@x.com, Erwin Beets <erwin@x.com, jaco@x.com

therefore dropped ap@ and erwin@ into `invalid` and kept only jaco@.
The text left behind in the input can then be committed as one chip
whose address is the entire list, which reaches the server as a single
malformed mailbox and gets the message rejected at DATA:

    550 (0.0.0) 'Invalid To email address'

Treat a separator as a separator when the open angle run cannot close
before the next one opens, and fall back to splitMailbox so a
`Name <email` entry keeps its display name instead of falling apart
into bare words.
2026-08-12 16:17:39 +02:00
Ctaoist cf89eaad27 feat: expose gateway keywords to extensions 2026-08-12 21:40:02 +08:00
KazNIISA IT c516ba98d0 fix(email-store): route shared-folder management to the owner account
Folder management performed on a shared/group account was sent to the
logged-in user's primary JMAP account instead of the mailbox owner's
account. Subfolder creation therefore failed with invalidProperties for
parentId, root folders could be created silently in the personal account,
and rename, delete, role, and reorder targeted the wrong mailbox tree.

Shared mailboxes use namespaced store ids to avoid cross-account
collisions, while Mailbox/set requires the owner's accountId and the bare
server mailbox id. Resolve that mutation context centrally: recover the
mailbox originalId and owner account, use a directly connected owner
client when one exists, otherwise reach delegated/group accounts through
the active client with an explicit accountId.

Thread the account target through create, update, and delete JMAP methods
and all folder-management actions. Scope role replacement to the same
owner account, and expose explicit root-folder creation from shared
account headers without making personal root creation depend on the
currently selected folder.

Add unit coverage for JMAP request scoping, shared store routing, role
isolation, direct-client handling, and reorder. Add a Stalwart integration
scenario that creates root and nested group folders, renames them, and
deletes them through the UI.
2026-08-12 18:11:43 +05:00
Ctaoist ddfd9e43e6 feat: expose JMAP keyword helpers to extensions 2026-08-12 20:43:55 +08:00
zisisvakras 37f830daa7 fix: resolve relative session URLs without corrupting URI templates
RFC 8620 does not require Session object URLs to be absolute, but
rewriteSessionUrl() called new URL(url) with no base, which throws on a
relative apiUrl or downloadUrl. The throw was silently caught and the
raw relative string returned, so requests resolved against the app's
own origin instead of the JMAP server.

Resolving with the URL parser and reading back href or pathname is not
an option: downloadUrl, uploadUrl and eventSourceUrl are RFC 6570
level 1 URI Templates (RFC 8620 section 2) whose literal {accountId},
{blobId}, {name} and {type} placeholders would be percent-encoded to
%7B/%7D, silently defeating the string replace() expansion at every
call site. Instead, anchor the path, query and fragment of the original
string at the server origin, which also covers network-path references
(//host/path) and does not fold a path prefix on the configured server
URL into relative resolution.

An empty URL is returned untouched: rewriting it into the bare server
origin would make it truthy and defeat the "not available" guards on
apiUrl and downloadUrl downstream.
2026-08-12 15:10:37 +03:00
Ctaoist f2c379b929 feat: expose label settings to extensions 2026-08-12 15:38:05 +08:00
Stefan Hildebrandt 06784581aa fix(calendar): exclude subscription/read-only calendars from event creation (#762)
The rights-first resolver already made EXISTING events in subscription /
read-only calendars read-only, but the create path still offered them:
event-modal's calendar dropdown listed every calendar. iCal subscriptions are
real Stalwart calendars (client-side feed written into a created calendar) with
writable myRights, so they appeared as selectable create destinations.

- canCreateEventsIn(calendar, isSubscriptionCalendar): writable AND not a
  subscription. Exported from lib/calendar-editability.
- event-modal: the calendar picker now lists only creatable calendars (plus the
  event's own calendar when editing), and new events never default into a
  subscription / read-only calendar.

+4 unit tests. tsc + eslint clean; calendar-editability suite 30 green.
2026-08-10 12:05:44 +02:00