Merge branch 'main' into feature/scheduled-send

This commit is contained in:
Lucas Gaitzsch
2026-05-04 15:46:50 +02:00
12 changed files with 120 additions and 32 deletions
+23
View File
@@ -1,5 +1,28 @@
# Changelog
## 1.6.1 (2026-05-04)
### Features
- **Updates**: Update-available detection with non-dismissible notice and dev-reload refresh
- **Plugins**: New plugin hooks for compose, attachments, search, lifecycle, and routing
- **Sharing**: Share indicators for calendars and contacts, updated JMAP capabilities (#244)
- **Mail**: Auto-add recipients to trusted senders when replying
- **Identity**: Sanitize identity display name to prevent invalid `From` headers
### Fixes
- **Mobile**: Synchronize mobile submenu view with browser history for better navigation
- **Viewer**: Update email viewer styles to improve overflow handling
- **Auth**: Ensure `cookieSlot` consistency during account updates in auth store
- **Auth**: Thread per-account cookie slot through OAuth flows
- **Calendar**: Square the colored left marker on calendar events
- **About**: Show git commit in About instead of "unknown"
### i18n
- Update mailbox context menu translations across 12 locales
## 1.6.0 (2026-05-01)
### Features
+1 -1
View File
@@ -12,7 +12,7 @@ A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.ar
[![License: AGPL v3](https://img.shields.io/badge/license-AGPL%20v3-blue.svg?logo=gnu&logoColor=white)](LICENSE)
[![Discord](https://img.shields.io/discord/1482128142939455674?color=7289da&label=discord&logo=discord&logoColor=white)](https://discord.gg/tYCujymGrT)
[![Version](https://img.shields.io/badge/version-1.6.0-green.svg?logo=git&logoColor=white)](CHANGELOG.md)
[![Version](https://img.shields.io/badge/version-1.6.1-green.svg?logo=git&logoColor=white)](CHANGELOG.md)
[![Docker](https://img.shields.io/badge/docker-ghcr.io%2Fbulwarkmail%2Fwebmail-blue?logo=docker&logoColor=white)](https://ghcr.io/bulwarkmail/webmail)
</div>
+1 -1
View File
@@ -1 +1 @@
1.6.0
1.6.1
+11
View File
@@ -7,6 +7,7 @@ import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useAuthStore } from "@/stores/auth-store";
import { useAccountStore } from "@/stores/account-store";
import { useThemeStore } from "@/stores/theme-store";
import { useShallow } from "zustand/react/shallow";
import { useConfig } from "@/hooks/use-config";
@@ -460,6 +461,16 @@ export default function LoginPage() {
sessionStorage.setItem("oauth_add_account_mode", "true");
}
// Persist the next-free cookie slot so loginWithOAuth (in stores/auth-store.ts)
// writes the refresh token to the correct per-account jmap_rt_<slot> cookie.
// loginWithOAuth reads this key but it was previously never written, so every
// OAuth account collapsed onto slot 0 and clobbered earlier accounts' refresh
// tokens. getNextCookieSlot() returns 0 when no accounts exist (correct for
// first sign-in) and the lowest unused slot otherwise (correct for "+ Add
// Account").
const nextSlot = useAccountStore.getState().getNextCookieSlot();
sessionStorage.setItem("oauth_cookie_slot", nextSlot.toString());
const authUrl = new URL(oauthMetadata.authorization_endpoint);
authUrl.searchParams.set("response_type", "code");
authUrl.searchParams.set("client_id", oauthClientId);
+17 -1
View File
@@ -213,6 +213,22 @@ export default function SettingsPage() {
}
}, [initialCheckDone, isAuthenticated, authLoading]);
// Sync the mobile submenu view with browser history so the system back
// button (or gesture) returns to the settings list before exiting /settings.
useEffect(() => {
if (isDesktop) return;
if (typeof window === 'undefined') return;
if (!mobileShowContent) return;
window.history.pushState({ __settingsSubmenu: true }, '');
const handlePop = () => {
setMobileShowContent(false);
};
window.addEventListener('popstate', handlePop);
return () => window.removeEventListener('popstate', handlePop);
}, [isDesktop, mobileShowContent]);
if (!isAuthenticated) {
return null;
}
@@ -321,7 +337,7 @@ export default function SettingsPage() {
<Button
variant="ghost"
size="icon"
onClick={() => setMobileShowContent(false)}
onClick={() => window.history.back()}
className="h-10 w-10"
>
<ArrowLeft className="w-5 h-5" />
+9 -3
View File
@@ -13,12 +13,18 @@ export async function POST(request: NextRequest) {
const cookieStore = await cookies();
try {
const { code, state } = await request.json();
const { code, state, slot: bodySlot } = await request.json();
if (!code || !state) {
return NextResponse.json({ error: 'Missing code or state' }, { status: 400 });
}
// Per-account refresh-token cookie slot. Without this the route hardcoded
// slot 0, so the "+ Add Account" flow overwrote the first account's
// refresh-token cookie. Default to 0 for back-compat with any caller that
// omits slot. Mirrors the validation in /api/auth/token POST.
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : 0;
// Read and decrypt the pending SSO cookie
const pendingCookie = cookieStore.get(SSO_PENDING_COOKIE)?.value;
if (!pendingCookie) {
@@ -58,9 +64,9 @@ export async function POST(request: NextRequest) {
// Exchange code for tokens
const tokens = await exchangeCodeForTokens(code, codeVerifier, redirectUri);
// Store refresh token
// Store refresh token in the per-account cookie slot.
if (tokens.refresh_token) {
const cookieName = refreshTokenCookieName(0);
const cookieName = refreshTokenCookieName(slot);
cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions());
}
+1 -1
View File
@@ -3881,7 +3881,7 @@ export function EmailViewer({
)}
{/* Email Content Area */}
<div className={cn("flex-1 overflow-auto bg-muted/30", isMobile && "pb-16")}>
<div className={cn("flex-1 overflow-auto overscroll-contain bg-muted/30", isMobile && "pb-16")}>
{/* === SENDER INFO (Desktop) === */}
<div className="hidden lg:block bg-background border-b border-border px-6" style={{ paddingBlock: 'var(--density-header-py)' }}>
+14 -11
View File
@@ -79,7 +79,7 @@ export function ShareCollectionDialog({
const t = useTranslations("sharing");
const tCommon = useTranslations("common");
const modalRef = useRef<HTMLDivElement>(null);
const [principals, setPrincipals] = useState<Principal[]>([]);
const [allPrincipals, setAllPrincipals] = useState<Principal[]>([]);
const [loadingPrincipals, setLoadingPrincipals] = useState(true);
const [search, setSearch] = useState("");
const [savingId, setSavingId] = useState<string | null>(null);
@@ -91,23 +91,28 @@ export function ShareCollectionDialog({
setLoadingPrincipals(true);
client.getPrincipals().then((list) => {
if (cancelled) return;
// Exclude the user themselves and any principal that already has a share
const existing = new Set(Object.keys(shareWith || {}));
const filtered = list.filter((p) => p.id !== ownAccountId && !existing.has(p.id));
setPrincipals(filtered);
setAllPrincipals(list);
setLoadingPrincipals(false);
}).catch(() => {
if (!cancelled) setLoadingPrincipals(false);
});
return () => { cancelled = true; };
}, [client, ownAccountId, shareWith]);
}, [client]);
// Map principal id -> Principal for displayed shares
// Map of every fetched principal by id, used for name/description lookups in
// the shared list. Must include principals that already have a share so the
// list shows their name rather than the raw id.
const allPrincipalsById = useMemo(() => {
const map = new Map<string, Principal>();
for (const p of principals) map.set(p.id, p);
for (const p of allPrincipals) map.set(p.id, p);
return map;
}, [principals]);
}, [allPrincipals]);
// Principals available to add: exclude self and anyone already shared with.
const principals = useMemo(() => {
const existing = new Set(Object.keys(shareWith || {}));
return allPrincipals.filter((p) => p.id !== ownAccountId && !existing.has(p.id));
}, [allPrincipals, ownAccountId, shareWith]);
// Close on Escape, focus trap, click outside
useEffect(() => {
@@ -155,8 +160,6 @@ export function ShareCollectionDialog({
setSavingId(principal.id);
try {
await onShare(principal.id, rights);
// Move principal out of the "to add" list
setPrincipals((prev) => prev.filter((p) => p.id !== principal.id));
setShowAdd(false);
setSearch("");
toast.success(t("share_added"));
+6
View File
@@ -3314,6 +3314,9 @@ export class JMAPClient implements IJMAPClient {
const err = result.notUpdated[calendarId];
throw new Error(err.description || "Failed to update calendar share");
}
if (!result?.updated || !(calendarId in result.updated)) {
throw new Error("Server did not confirm the share update");
}
}
/**
@@ -3339,6 +3342,9 @@ export class JMAPClient implements IJMAPClient {
const err = result.notUpdated[addressBookId];
throw new Error(err.description || "Failed to update address book share");
}
if (!result?.updated || !(addressBookId in result.updated)) {
throw new Error("Server did not confirm the share update");
}
}
private async fetchPaginatedContacts(
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "bulwark-webmail",
"version": "1.6.0",
"version": "1.6.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bulwark-webmail",
"version": "1.6.0",
"version": "1.6.1",
"license": "AGPL-3.0-only",
"dependencies": {
"@tanstack/react-virtual": "^3.13.24",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "bulwark-webmail",
"version": "1.6.0",
"version": "1.6.1",
"description": "Bulwark Webmail - a modern webmail client built for Stalwart Mail Server",
"author": "Bulwark Webmail <bulwark@rbm.systems>",
"license": "AGPL-3.0-only",
+34 -11
View File
@@ -601,12 +601,21 @@ export const useAuthStore = create<AuthState>()(
set({ isLoading: true, error: null, isRateLimited: false, rateLimitUntil: null });
try {
// Determine slot for this account (use slot from sessionStorage if re-adding)
// Determine slot for this account (use slot from sessionStorage if re-adding).
// Note: `parseInt(getItem(...) || '0')` collapses "no value set" and
// "value is 0" into the same case, so the fallback to getNextCookieSlot()
// never fired for the common "+ Add Account" path — every OAuth account
// ended up on slot 0 and overwrote earlier accounts' refresh-token cookies.
// Distinguishing rawSlot === null from a parsed 0 fixes that. The page
// also writes oauth_cookie_slot before redirecting to the IdP.
const accountStore = useAccountStore.getState();
const pendingSlot = typeof window !== 'undefined'
? parseInt(sessionStorage.getItem('oauth_cookie_slot') || '0', 10)
: 0;
const slot = pendingSlot >= 0 && pendingSlot <= 4 ? pendingSlot : accountStore.getNextCookieSlot();
const rawSlot = typeof window !== 'undefined'
? sessionStorage.getItem('oauth_cookie_slot')
: null;
const pendingSlot = rawSlot !== null ? parseInt(rawSlot, 10) : NaN;
const slot = !isNaN(pendingSlot) && pendingSlot >= 0 && pendingSlot <= 4
? pendingSlot
: accountStore.getNextCookieSlot();
const tokenRes = await apiFetch(`/api/auth/token?slot=${slot}`, {
method: 'POST',
@@ -659,6 +668,12 @@ export const useAuthStore = create<AuthState>()(
hasError: false,
isDefault: accountStore.accounts.length === 0,
});
// The refresh-token cookie was written to `slot`. Force the stored
// cookieSlot to match: addAccount preserves the prior slot when
// re-adding an existing account, and recomputes via getNextCookieSlot
// for new accounts (which may disagree if another tab claimed a slot
// mid-flow). Either way, the cookie's slot is the source of truth.
accountStore.updateAccount(accountId, { cookieSlot: slot });
accountStore.setActiveAccount(accountId);
await syncStalwartAuthContext(serverUrl, username, client.getAuthHeader(), slot);
@@ -718,12 +733,19 @@ export const useAuthStore = create<AuthState>()(
set({ isLoading: true, error: null, isRateLimited: false, rateLimitUntil: null });
try {
// Server-side SSO: the server holds the PKCE verifier in an encrypted cookie
// Server-side SSO: the server holds the PKCE verifier in an encrypted cookie.
// Pass the next-free cookie slot so /api/auth/sso/complete writes the refresh
// token to the correct per-account jmap_rt_<slot> cookie. Without this the
// route hardcoded slot 0, which broke "+ Add Account" by overwriting the
// first account's refresh-token cookie.
const accountStore = useAccountStore.getState();
const slot = accountStore.getNextCookieSlot();
const ssoRes = await apiFetch('/api/auth/sso/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ code, state }),
body: JSON.stringify({ code, state, slot }),
});
if (!ssoRes.ok) {
@@ -741,8 +763,6 @@ export const useAuthStore = create<AuthState>()(
throw new Error('Server URL not configured');
}
const accountStore = useAccountStore.getState();
const refreshFn = get().refreshAccessToken;
const client = JMAPClient.withBearer(ssoServerUrl, access_token, '', () => refreshFn());
await client.connect();
@@ -779,10 +799,13 @@ export const useAuthStore = create<AuthState>()(
hasError: false,
isDefault: accountStore.accounts.length === 0,
});
// The refresh-token cookie was written to `slot` by /api/auth/sso/complete.
// Force the stored cookieSlot to match — see loginWithOAuth above for the
// re-add and concurrent-tab cases this guards against.
accountStore.updateAccount(accountId, { cookieSlot: slot });
accountStore.setActiveAccount(accountId);
const cookieSlot = accountStore.getAccountById(accountId)?.cookieSlot ?? 0;
await syncStalwartAuthContext(ssoServerUrl, username, client.getAuthHeader(), cookieSlot);
await syncStalwartAuthContext(ssoServerUrl, username, client.getAuthHeader(), slot);
set({
isAuthenticated: true,