enableWebPush reused any existing browser PushSubscription that exposed an
applicationServerKey, without comparing that key to the relay's. A push
subscription is bound to the key it was created with. So a browser that
subscribed against another relay, or before this relay rotated its keys,
kept a subscription the push service rejects on every send. Mozilla
answers 401 "VAPID public key mismatch". isWebPushEnabled still reports
push as on, so nothing prompts the user to fix it.
Compare the key bytes and resubscribe on a mismatch. Because
resyncWebPush runs enableWebPush on app start, affected browsers repair
themselves the next time the app opens.
Claude-Session: https://claude.ai/code/session_019KwXZEwxijHRbfobfXmcK2
Some senders ship the images their HTML body embeds via cid: as
application/octet-stream with no Content-Disposition and no filename.
The body rendered them fine, but the "hide inline images" check only
recognised parts declared inline with an image/* type, so they also
showed up as nameless "Attachment" chips next to the real files.
Both viewers now scan the HTML they actually render for cid: references
(the same pattern the renderer rewrites to blob URLs) and hide a
referenced part when it is an image or generically typed. An explicit
attachment disposition always keeps its chip, as does a referenced part
with a real non-image type, since that chip is its only download. Plain
text rendering references nothing, so nothing is hidden there. The
shared rule lives in lib/attachment-visibility.ts so the desktop viewer,
the thread view and the decrypted-message path cannot drift apart.
isHttp2Available() only inspected "resource" timing entries, which can be
empty right after a fresh login or session restore, before any subresource
finishes loading. On an HTTP/2 page this made getMaxAccounts() fall back to
the HTTP/1.1 cap of five accounts. Include the "navigation" timing entry,
whose nextHopProtocol is known immediately, so h2/h3 is recognized on first
load. Environments without timing data still return false (no h2 assumed).
The browser paints a native <select>'s option list itself, so the
bg-muted / text-foreground classes on the control never reach it. Themes
whose --color-muted is translucent (Aurora Glass dark uses
rgba(255,255,255,0.05)) composited to a light popup with near-white
text, leaving every unselected option almost invisible.
Pin option background/colour to the popover tokens, which every built-in
theme defines as an opaque surface, and set color-scheme: dark on
selects under .dark so Safari (which ignores option colours) renders a
dark popup too. Fixes all ~40 native selects at once.
Adds a stylesheet test that locks the rules and asserts every built-in
theme's --color-popover stays opaque in each variant.
OAuth authorization-server metadata lives at the origin root (RFC 8414), but
getRequiredConfig() derived the discovery URL straight from JMAP_SERVER_URL. When
that is the session URL (for example https://mail.example.com/jmap/session, a
common setup so the client can skip the 307 redirect a preflighted CORS request
cannot follow), discoverOAuth fetched
https://mail.example.com/jmap/session/.well-known/oauth-authorization-server,
got a 404, and token refresh failed with "OAuth token endpoint not found", so the
session was dropped after one access-token lifetime.
Strip a trailing session path (/jmap/session or /.well-known/jmap, plus any query
or fragment) before discovery. A plain server root is unchanged, and only the
session suffix is removed, so a real base path prefix is kept.
Adds oauth-discovery-base.test.ts driving getRequiredConfig; the normalization
cases fail without the fix.
CalDAV-originated tasks can retain uppercase, underscore-separated, or single-l status spellings in CalendarEvent responses. Normalize known spellings at JMAP response boundaries so task filters and completion controls see canonical JSCalendar values while unknown future values remain intact.
Work around the upstream conversion defect tracked at https://github.com/stalwartlabs/calcard/issues/20.
On a shared, group or aliased mailbox every reply, reply-all and forward
went out as `identities[0]` - the account owner - whichever mailbox was
open. `autoSelectReplyIdentity` fused two behaviours: matching one of the
user's OWN identities, which never rewrites `From:`, and the domain
catch-all, which puts an address they have not configured into `From:`.
Only the first is safe for everyone, so it is now unconditional; the
rewrite stays behind the setting, which is what its description promises.
`findReplyIdentityId` already implemented that match and was referenced
only from tests.
`forward` is admitted to the match - it was excluded, so a forward from a
shared mailbox went out as the owner - but never takes the rewrite: a reply
continues a thread whose participants know the addressing, while a forward
introduces a rewritten From to a recipient the user just typed. Quick reply
matches own identities only, having no From row to show or correct a
rewrite, and now falls back to the primary's signature as the composer
does, so a shared-mailbox quick reply is still signed.
`findReplyIdentityId` now scans recipients in To, Cc, Bcc order rather than
scanning identities, and prefers an untagged identity over a differently
tagged sibling. Scanning identities let their order decide, and
`sortIdentities` puts the login's own address first - so `To: team@,
Cc: you@` replied as you, the case this is meant to fix.
Automatic selection is restricted to the active account: `composerClient`
follows the chosen identity, and a reply or forward still carries blobIds
only its own account's server can resolve. The dropdown still offers every
account's identities to pick by hand.
New messages are deliberately left gated: `composeFromAccountEmail` falls
back to `AccountEntry.email`, written once at first login, so resolving it
unconditionally would override a user-chosen default sender identity (#507).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three bugs when viewing an event's participants (#738):
1. Phantom participant. Stalwart can emit the organizer both as the
ORGANIZER-derived participant and as an ATTENDEE line, so the same
address rendered twice — "4 participants" for three people.
getParticipantList() now folds same-address entries into the
first-seen one (real RSVP beats needs-action on merge), and
getParticipantCount() counts addresses so event-card and agenda
badges agree.
2. Bare email for a known contact. The organizer's display name is
dropped on Stalwart's iCalendar round-trip, so the organizer
rendered as a bare address despite having a contact card with a
备注 name. New useContactNameResolver() hook fills missing
participant names from the contact store (fill-only — event data
stays authoritative), threaded through the panel, the hover
popover, the RSVP view, and the organizer name written on save.
3. Organizer status. The organizer with no explicit status read as
待处理; they owe no reply, so they now display as accepted, and the
panel's status badge no longer repeats the organizer label the
(组织者) suffix already carries.
Co-Authored-By: Claude <noreply@anthropic.com>
Stalwart Mail Server strictly validates JSCalendar task properties on CalendarEvent/set and rejects updates containing top-level progressUpdated with invalidProperties (RFC 8984 §5.2.2 only defined progressUpdated on Participant objects, obsoleted in JSCalendar 2.0).
- Omit progressUpdated from toggleTaskComplete in stores/task-store.ts
- Remove progressUpdated from CALENDAR_TASK_PROPERTIES in lib/jmap/client.ts
- Defensively strip progressUpdated in updateCalendarEvent and createCalendarTask
- Make progressUpdated optional in CalendarTask interface
- Add unit tests for task-store and JMAP client task methods
Every existing plugin slot renders wherever it's placed in the page (a
toolbar row, an attachment's hover-action bar, etc.) and is constrained
by that spot's own CSS - fine for small controls, but there's no way
for a plugin to show a large custom UI (a graphical file browser, a
multi-step wizard) since even a position:fixed overlay rendered from
inside such a slot is clipped to the surrounding row's own layout, not
the real page (confirmed live building the webdav-storage plugin: a
folder browser rendered but was squeezed into an unusable sliver).
Adds:
- host-dialog.ts: new 'custom' DialogKind + awaitCustomDialog(), reusing
the existing confirm/alert/prompt queue plumbing.
- host-api.ts: 'ui.openDialog' API method.
- runtime.tsx: sandbox-side api.ui.openDialog(opts) proxy.
- plugin-types.ts: new 'plugin-dialog' SlotName - a plugin implements
this slot with its actual custom UI.
- plugin-dialog-host.tsx: renders the requested slot via the existing
PluginIframeSlot INSIDE the dialog's real app-root fixed overlay
(position: fixed; inset: 0, above everything) instead of the
confirm/prompt form, for kind: 'custom'. Resolves via a generic
onResult callback in extraProps (marshalled the same way
composer-attachment-source's onAttach already is - no new plumbing
needed there).
A plugin calls api.ui.openDialog({ slot: 'plugin-dialog', extraProps,
title, width }) and awaits the Promise, which resolves to whatever its
own slot component passes to props.onResult(), or null if the user
closes the dialog without doing so.
Two new extension points for third-party attachment/cloud-storage plugins
(e.g. a WebDAV/Nextcloud file browser), neither of which the plugin API
currently supports:
- attachment-actions: a new slot rendered into every attachment's hover
action row (all six render paths: beside-sender/below-header/mobile,
each in its compact and expanded-popup form), with the AttachmentInfo
(name, type, size, blobId, emailId) passed through extraProps. Lets a
plugin offer a 'save this attachment elsewhere' action next to the
existing download/preview buttons - previously the only per-attachment
hook was the read-only onAttachmentDownload observer.
- composer-attachment-source: a new slot rendered in the composer toolbar
next to the existing composer-toolbar slot, with an onAttach(upload)
callback in extraProps. Lets a plugin that has already uploaded a file to
the JMAP server itself (api.jmap.uploadBlob) - e.g. a file picked from an
external cloud storage browser - have it added to the current draft as a
real attachment. Previously there was no way for a plugin to add an
attachment to the draft at all; the closest existing mechanism
(onBeforeBlobUpload's ExternalAttachmentResult) only replaces a locally
picked file with body text/a link, it cannot add a brand-new attachment
from an external source.
Both slots follow the existing PluginSlot pattern (calendar-event-actions):
render nothing when no plugin offers the slot, so this is purely additive.
Motivating use case: the existing nextcloud-attachments community plugin
uploads local files to Nextcloud and appends a share link to the message
body - useful for large files, but there was no way for a plugin to do the
inverse (attach a file that already lives in cloud storage as a real binary
attachment, not a link) or to let attachment recipients save an incoming
attachment back out to their own cloud storage. These two slots make both
directions possible without touching plugin-store or the sandbox bridge -
existing slot/extraProps plumbing (see calendar-event-actions) already
supports arbitrary POJO props.
* adds "canRSVP" in addition to the existing "getEventEditability"
* wires calendar to leverage "canRSVP" before editability checks
* adds tests for RSVP
Renaming a tag changes its id - the id is a slug of the display name -
and with it the `$label:` keyword the tag is stored under. The rename
migrated the keyword on every message but left Sieve rules naming the old
id, in both the generated `addflag` line and the metadata block the visual
editor reads back. The rule then went on tagging new mail under an id no
definition names any more: the tag renders as its raw id in grey and a
search for the renamed tag misses everything that arrived since, which
reads as the filter having quietly stopped working.
`renameKeywordInRules` rewrites every `add_label` action naming the old id,
disabled rules included. An external rule reaches the script through its
verbatim `rawBlock`, so that is rewritten too - but only the operand of an
`addflag`, since a test comparing a header against the same literal is the
rule's own logic rather than the tag it applies.
The store fetches, rewrites and uploads the account's active script on its
own rather than through fetchFilters/saveFilters, so a rename from the tag
settings cannot disturb which account an open filter view is showing; that
view is updated in place when it shows the same account. The write still
runs through the same plugin pipeline a save does, or a plugin's managed
section would be dropped from the script this activates. A script that is
not the active one is left alone entirely: writing to it would activate it,
and a tag rename must not switch someone's filters back on.
Whatever still names the old tag in the script that ends up being written -
a rule kept verbatim, a test comparing against the keyword, a section a
plugin just grafted back on - is counted, and the tag settings warn that
some rules still use the old tag, because only the user can fix those.
Mail delivered between the message migration and the rewrite was still
tagged by the old rule, so a successful rewrite is followed by one more
migration sweep, which no rewritten rule can race any more.
After a reload the unified mailbox showed only the active account's mail;
other accounts appeared only once visited. Intermittent (~1 in 14), and
not a timeout - nothing on this path uses setTimeout/AbortSignal/race.
checkAuth awaits only the active account and restores the rest in a
fire-and-forget background loop, registering each client only after
connect() resolves. buildUnifiedAccountClients drops accounts with no
live client (`if (!c) continue`). And isConnected was persisted, so it
rehydrated stale-true: the account passed the isConnected filter while
failing the client lookup, and connectedAccountsSignature already looked
complete at mount so nothing re-fired - and the effects keyed on it only
recompute counts, never the list.
Clear isConnected/hasError on rehydrate so both the filter and the
signature describe this session, and refetch the open unified/cross list
when an account joins the connected set (growth only, not on first
render, not while a search owns the list). Extracted
connectedAccountsGrew so the membership check is testable without
rendering MailApp.
Verified the reset cannot block reconnection: checkAuth reads the full
account list and never filters on isConnected.
Fixes#950
Large HTML emails (e.g. image-only reports with a data:image src cut
mid-tag by the 256 KB body cap) rendered completely blank because
Email.bodyValues[partId].isTruncated was declared but never acted on.
On truncation, issue one follow-up Email/get with a much larger cap
for the parts actually shown, batched across a thread instead of one
refetch per message.
Closes#884
A message with attachments showed a paperclip and nothing else, so the
only way to reach a file was to open the message, scroll to the bottom
and find it. The list rows never had the information to do better:
EMAIL_LIST_PROPERTIES asked for hasAttachment, a boolean.
Request attachments alongside it and render the files as chips under the
preview, each one opening that file directly. Chips honour the existing
mailAttachmentAction setting, so they preview or download exactly as the
viewer's attachment list does.
Inline parts are filtered out. A single Outlook message routinely carries
half a dozen cid: signature spacers of under 200 bytes; chipping those
would bury the one file the reader actually wants. Only parts that are
neither inline nor cid-referenced, and that carry a filename, are shown —
two at a time, with the remainder collapsed into a count.
Downloads resolve the blob source from the row's own message rather than
the selected one, so they stay correct in the unified inbox where each
row can belong to a different account.