Login header customization for white-label deployments, all defaults
preserve current behaviour:
- LOGIN_LOGO_MAX_HEIGHT / LOGIN_LOGO_MAX_WIDTH (any CSS length): the logo
box is otherwise a fixed 64x64 (w-16/h-16), which fits a wide wordmark to
~13px tall. When either is set, the fixed box is dropped and the logo
renders at the configured size.
- LOGIN_SHOW_HEADING / LOGIN_SHOW_SUBTITLE (default true): hide the
{appName} heading and/or the subtitle when the logo already reads as the
brand (e.g. a wordmark) and they'd be redundant.
Applied to the standard login header; wired through the existing config
registry (CONFIG_ENV_MAP) -> /api/config -> useConfig.
Refs #519.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Compose recipient fields only suggested existing contacts and directory
users, so people you had emailed before but never saved as a contact
never came up. This adds an Outlook-Web-style suggestion flow.
On startup the Sent folder is read once (metadata only) to build a cache
of addresses you have written to; those are merged into the autocomplete
after contacts and directory principals, deduped, contacts winning.
When the recipient is not in the cache, the dropdown offers a "search the
server" row that queries the Sent folder on demand. That lookup fetches
only the to/cc fields (no subject, body or attachments) and returns the
matching addresses, deduped.
New strings are added to all 20 locales.
The service worker hard-coded the notification icon and badge to the bundled /icon-192x192.png, so push notifications always showed the default Bulwark logo even when an admin had configured a custom PWA/favicon icon (which the manifest already honors via /api/pwa-icon).
Point both notifications at /api/pwa-icon/192, and make that endpoint fall back to the bundled default icon instead of returning 404 when no custom icon is set - so it always returns an app icon and the service worker (which can't run the custom-vs-default check itself) has a single stable URL.
Tags applied to a shared/group-mailbox message did not persist. Custom
keywords (:*), and were written via Email/set
against the reaching client's primary account instead of the email's
owning account, so the server returned notUpdated without an error and
the change was lost on the next reload.
toggleStar already threaded an accountId through (#281); the keyword
methods did not. Add an optional accountId to updateEmailKeywords and
setKeyword and resolve it at the call sites from the email's source
account (sourceClientAccountId / sourceAccountId), matching the existing
delete/archive routing. Personal sources resolve to the account itself,
so behavior there is unchanged.
Starting a new message while viewing a specific mailbox/account now defaults
the From identity to that mailbox instead of the global primary identity, so
composing from info@ sends as info@. Mirrors the existing reply-time identity
match and rides the same autoSelectReplyIdentity setting; reply/replyAll/forward
keep resolving from the original recipients. Matches exact then +tag-stripped.
Extracts findComposeIdentityId into lib/reply-identity.ts with unit tests.
Gmail-style: marking the currently-open message unread returns to the message
list instead of staying in the reading pane (where the viewer's auto-mark-read
would just flip it back to read). Gated on the returnToListAfterAction setting
added in #477 (default on); when off, you stay in the viewer.
Only the single-message viewer, and only on mark-unread (read === false).
Per review: gate the return-to-list behaviour behind a setting,
returnToListAfterAction, defaulting to true (the Gmail/Yahoo default). When off,
deleting the open message keeps the previous auto-advance-to-next behaviour.
Adds the setting to the store (persisted), a toggle under Reading settings, and
i18n keys across all locales (English; non-English need translation). The same
setting will govern mark-as-unread (#468).
Deleting from inside an open message advanced to the next email. Gmail (and most
clients) return you to the message list instead. In the viewer's onDelete,
deselect first (handleMobileBack) so the store's remove-and-advance sees no
selection and won't auto-open the next message, then delete the captured email.
Returning to the list immediately also avoids a flash of the next email.
Scoped to the single-message viewer; list and keyboard deletes (which keep
auto-advance) are unchanged. Consistent with the mark-unread-returns-to-list
behaviour.
Add cross-account aggregate mail views and make group/shared (delegated)
accounts first-class in every aggregate view. (The unified mailbox, the "All
Mail" view, and "include group inboxes" already exist on main; this branch adds
the cross-account views and the shared-account correctness work.)
New views (admin-gated + per-user toggle, nested under Unified Mailbox):
- Cross-account "All accounts": All unread / All starred / All mail across every
connected account, including shared/group folders. Each list labels the source
folder of every message.
Source reference on aggregated emails (the core of the shared-account work):
- Replace the overloaded `accountId` with two explicit, always-set fields:
`sourceClientAccountId` (the login the mail is reachable through) and
`sourceAccountId` (the owning JMAP account). `accountId` stays display-only.
- Resolution is branch-free everywhere: pick the client by sourceClientAccountId,
pass sourceAccountId as the JMAP accountId (no-op for personal), read the
owner's mailbox list cached by JMAP id. No capability scan.
Shared/group-account correctness across all aggregate views:
- Route open (click + auto-fetch), thread/conversation open + reply-refresh,
mark read, star, move, delete (account-scoped trash), archive (owner-routed
createMailbox / fetchAccountMailboxes), and spam + undo via the source ref.
- Add accountId params to toggleStar / batchMarkAsRead / batchDeleteEmails /
createMailbox where missing.
- Fix local unread/total counter math for shared folders via emailInMailbox()
(matches namespaced shared ids and bare own ids).
- Keep the unified/cross virtual selection on background mailbox refresh (no
jump back to inbox after deleting in All Drafts/Junk).
Junk UX:
- In "All Junk" the spam action becomes "not spam" in the viewer, context menu,
and list hover icons; undo routes shared mail back to its own inbox.
Admin:
- Policy gates crossUnread/Starred/AllViewEnabled, each noting the matching
per-user toggle (allMailViewEnabled clarified too).
i18n / docs / tests:
- locales (19): cross-view labels + descriptions and hover not_spam, translated
in all shipped languages.
- FEATURES.md + README.md document the new views and group-account support.
- Tests for shared-account routing (single + batch + undoSpam), decoration, and
unified-selection preservation.
Cleans up the lint warnings the pre-commit hook surfaces, without any
behavioral change:
- Remove unused imports/vars/destructured props (parseISO, useEffect,
format, durMin, roles, daysInYear, ALLOWED_PLUGIN_FILES, continuesBefore,
isPushConnected, isSelected) and the now-unused parseDuration import.
- Drop three stale `// eslint-disable-next-line no-undef` directives that
no longer suppress anything (browser-navigation, smime/crypto-engine).
- recurrence-expansion.test.ts: replace 39 `as any` casts with a cast-only
`rule()` helper for partial recurrence-rule fixtures, typed access to
utcStart/utcEnd (now on CalendarEvent), and the source's
`Partial<CalendarEvent> & { excluded?: boolean }` for the excluded
override. No defaults are injected, so the expansion logic sees the same
partial rules as before (35 tests still green).
Remaining: 7 react-hooks/exhaustive-deps warnings are left as-is — adding
the missing deps changes effect/memo timing and needs per-hook review, not
a mechanical fix. tsc --noEmit clean; eslint 0 errors / 7 warnings.
Lets a per-brand authorize host front a single canonical issuer, so the
IdP token's `iss` stays constant for downstream validation while login
branding varies per domain. Discovery, token exchange and refresh keep
using OAUTH_ISSUER_URL.
Adds a "Send email to group" action (To / Cc / Bcc) that opens the composer
pre-filled with the group's members in the chosen field, preserving each
member's display name. It is available both in the group context menu (between
"Edit Group" and "Delete") and in the group detail panel's header (shown when
the group has at least one member with an email). The single-contact "Send
email" button in the contact detail panel uses the same path.
Routing is internal, not via mailto:. Contacts is its own route and the composer
lives in the mail route, so the handoff stashes the recipients
(savePendingMailto) and does a client-side router.push("/"); the main route's
existing consumePendingMailto effect opens the composer in the current account.
This avoids the OS mailto handler (which could open a different mail app) and
the protocol round-trip's full-page reload, which dropped the in-memory
per-account JMAP clients of a multi-account session (a logout).
- contacts/page.tsx: openComposeInApp(recipients, field) shared helper;
handleComposeGroupFromSidebar (deduped "Name <email>" members, empty -> toast)
and handleComposeContact; wired to the sidebar, group detail, contact detail.
- contact-group-detail.tsx: onComposeGroup(field) prop + To/Cc/Bcc header control
(shown when the group has emailable members).
- contacts-sidebar.tsx: onComposeGroup(groupId, field) prop + "Send email to
group" submenu between Edit and Delete.
- contact-detail.tsx: onCompose() prop; the button is no longer a mailto: link.
- mailto.ts: recipient splitter is quote-aware (reuses the composer's
splitRecipients) so a comma in a display name survives — still useful for real
OS mailto: links.
- i18n: contacts.groups.send_email{,_to,_cc,_bcc} and no_member_emails across all
locales.
Display names round-trip via formatRecipient -> parseRecipientList.
Give email-002 ("Project Update - Q1 Review") a sender and CC with
"Lastname, Firstname" display names so Reply/Reply-All in dev mode
exercises the comma-in-name recipient case end to end.
Clicking an email address in the viewer already shows a contact detail
sidebar. An "Edit" button now appears there (for known contacts) that
navigates directly to the contact edit form via the existing URL-param
intent system (?contactId=…&view=edit), removing the need to open the
Contacts page manually and search for the contact.
In the dark theme --color-border was #262626, identical to --color-secondary/--color-muted. The global `* { border-color: var(--color-border) }` rule therefore rendered borders invisible on those surfaces - e.g. the folder sidebar's right border and the account header's bottom border vanished in dark mode.
Set --color-border to rgba(128, 128, 128, 0.3) (the same neutral the navigation rail already uses inline) so borders stay visible and consistent across all dark surfaces (background, secondary, card, popover).
The collapsed sidebar wrapper was hard-coded to 64px while the sidebar itself is w-12 (48px), leaving a 16px empty strip on its right edge. Match the wrapper to the sidebar's own width.
The folder and tag section gears in the sidebar deep-linked into Settings by writing the persisted `settings-active-tab` localStorage key, so the chosen section became the permanent default the main Settings button opened on - indefinitely.
Compounding it, the desktop Settings tab list called setActiveTab directly without persisting, so normal navigation never updated the default and the hijacked value could never self-correct.
Fix: section gears now write a one-shot sessionStorage key that is consumed on mount (transient deep-link, no persistence); desktop tab clicks go through handleTabSelect like the mobile list, so the last-used tab is saved consistently. Stale/removed tab IDs are still caught by the existing effectiveActiveTab fallback.