Merge branch 'main' into feature/scheduled-send
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -12,7 +12,7 @@ A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.ar
|
||||
|
||||
[](LICENSE)
|
||||
[](https://discord.gg/tYCujymGrT)
|
||||
[](CHANGELOG.md)
|
||||
[](CHANGELOG.md)
|
||||
[](https://ghcr.io/bulwarkmail/webmail)
|
||||
|
||||
</div>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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)' }}>
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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(
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user