Merge branch 'main' into feature/scheduled-send
This commit is contained in:
@@ -1,5 +1,28 @@
|
|||||||
# Changelog
|
# 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)
|
## 1.6.0 (2026-05-01)
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.ar
|
|||||||
|
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](https://discord.gg/tYCujymGrT)
|
[](https://discord.gg/tYCujymGrT)
|
||||||
[](CHANGELOG.md)
|
[](CHANGELOG.md)
|
||||||
[](https://ghcr.io/bulwarkmail/webmail)
|
[](https://ghcr.io/bulwarkmail/webmail)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useTranslations } from "next-intl";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
|
import { useAccountStore } from "@/stores/account-store";
|
||||||
import { useThemeStore } from "@/stores/theme-store";
|
import { useThemeStore } from "@/stores/theme-store";
|
||||||
import { useShallow } from "zustand/react/shallow";
|
import { useShallow } from "zustand/react/shallow";
|
||||||
import { useConfig } from "@/hooks/use-config";
|
import { useConfig } from "@/hooks/use-config";
|
||||||
@@ -460,6 +461,16 @@ export default function LoginPage() {
|
|||||||
sessionStorage.setItem("oauth_add_account_mode", "true");
|
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);
|
const authUrl = new URL(oauthMetadata.authorization_endpoint);
|
||||||
authUrl.searchParams.set("response_type", "code");
|
authUrl.searchParams.set("response_type", "code");
|
||||||
authUrl.searchParams.set("client_id", oauthClientId);
|
authUrl.searchParams.set("client_id", oauthClientId);
|
||||||
|
|||||||
@@ -213,6 +213,22 @@ export default function SettingsPage() {
|
|||||||
}
|
}
|
||||||
}, [initialCheckDone, isAuthenticated, authLoading]);
|
}, [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) {
|
if (!isAuthenticated) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -321,7 +337,7 @@ export default function SettingsPage() {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => setMobileShowContent(false)}
|
onClick={() => window.history.back()}
|
||||||
className="h-10 w-10"
|
className="h-10 w-10"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="w-5 h-5" />
|
<ArrowLeft className="w-5 h-5" />
|
||||||
|
|||||||
@@ -13,12 +13,18 @@ export async function POST(request: NextRequest) {
|
|||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { code, state } = await request.json();
|
const { code, state, slot: bodySlot } = await request.json();
|
||||||
|
|
||||||
if (!code || !state) {
|
if (!code || !state) {
|
||||||
return NextResponse.json({ error: 'Missing code or state' }, { status: 400 });
|
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
|
// Read and decrypt the pending SSO cookie
|
||||||
const pendingCookie = cookieStore.get(SSO_PENDING_COOKIE)?.value;
|
const pendingCookie = cookieStore.get(SSO_PENDING_COOKIE)?.value;
|
||||||
if (!pendingCookie) {
|
if (!pendingCookie) {
|
||||||
@@ -58,9 +64,9 @@ export async function POST(request: NextRequest) {
|
|||||||
// Exchange code for tokens
|
// Exchange code for tokens
|
||||||
const tokens = await exchangeCodeForTokens(code, codeVerifier, redirectUri);
|
const tokens = await exchangeCodeForTokens(code, codeVerifier, redirectUri);
|
||||||
|
|
||||||
// Store refresh token
|
// Store refresh token in the per-account cookie slot.
|
||||||
if (tokens.refresh_token) {
|
if (tokens.refresh_token) {
|
||||||
const cookieName = refreshTokenCookieName(0);
|
const cookieName = refreshTokenCookieName(slot);
|
||||||
cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions());
|
cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3881,7 +3881,7 @@ export function EmailViewer({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Email Content Area */}
|
{/* 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) === */}
|
{/* === SENDER INFO (Desktop) === */}
|
||||||
<div className="hidden lg:block bg-background border-b border-border px-6" style={{ paddingBlock: 'var(--density-header-py)' }}>
|
<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 t = useTranslations("sharing");
|
||||||
const tCommon = useTranslations("common");
|
const tCommon = useTranslations("common");
|
||||||
const modalRef = useRef<HTMLDivElement>(null);
|
const modalRef = useRef<HTMLDivElement>(null);
|
||||||
const [principals, setPrincipals] = useState<Principal[]>([]);
|
const [allPrincipals, setAllPrincipals] = useState<Principal[]>([]);
|
||||||
const [loadingPrincipals, setLoadingPrincipals] = useState(true);
|
const [loadingPrincipals, setLoadingPrincipals] = useState(true);
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [savingId, setSavingId] = useState<string | null>(null);
|
const [savingId, setSavingId] = useState<string | null>(null);
|
||||||
@@ -91,23 +91,28 @@ export function ShareCollectionDialog({
|
|||||||
setLoadingPrincipals(true);
|
setLoadingPrincipals(true);
|
||||||
client.getPrincipals().then((list) => {
|
client.getPrincipals().then((list) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
// Exclude the user themselves and any principal that already has a share
|
setAllPrincipals(list);
|
||||||
const existing = new Set(Object.keys(shareWith || {}));
|
|
||||||
const filtered = list.filter((p) => p.id !== ownAccountId && !existing.has(p.id));
|
|
||||||
setPrincipals(filtered);
|
|
||||||
setLoadingPrincipals(false);
|
setLoadingPrincipals(false);
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
if (!cancelled) setLoadingPrincipals(false);
|
if (!cancelled) setLoadingPrincipals(false);
|
||||||
});
|
});
|
||||||
return () => { cancelled = true; };
|
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 allPrincipalsById = useMemo(() => {
|
||||||
const map = new Map<string, Principal>();
|
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;
|
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
|
// Close on Escape, focus trap, click outside
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -155,8 +160,6 @@ export function ShareCollectionDialog({
|
|||||||
setSavingId(principal.id);
|
setSavingId(principal.id);
|
||||||
try {
|
try {
|
||||||
await onShare(principal.id, rights);
|
await onShare(principal.id, rights);
|
||||||
// Move principal out of the "to add" list
|
|
||||||
setPrincipals((prev) => prev.filter((p) => p.id !== principal.id));
|
|
||||||
setShowAdd(false);
|
setShowAdd(false);
|
||||||
setSearch("");
|
setSearch("");
|
||||||
toast.success(t("share_added"));
|
toast.success(t("share_added"));
|
||||||
|
|||||||
@@ -3314,6 +3314,9 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
const err = result.notUpdated[calendarId];
|
const err = result.notUpdated[calendarId];
|
||||||
throw new Error(err.description || "Failed to update calendar share");
|
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];
|
const err = result.notUpdated[addressBookId];
|
||||||
throw new Error(err.description || "Failed to update address book share");
|
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(
|
private async fetchPaginatedContacts(
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "bulwark-webmail",
|
"name": "bulwark-webmail",
|
||||||
"version": "1.6.0",
|
"version": "1.6.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "bulwark-webmail",
|
"name": "bulwark-webmail",
|
||||||
"version": "1.6.0",
|
"version": "1.6.1",
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-virtual": "^3.13.24",
|
"@tanstack/react-virtual": "^3.13.24",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "bulwark-webmail",
|
"name": "bulwark-webmail",
|
||||||
"version": "1.6.0",
|
"version": "1.6.1",
|
||||||
"description": "Bulwark Webmail - a modern webmail client built for Stalwart Mail Server",
|
"description": "Bulwark Webmail - a modern webmail client built for Stalwart Mail Server",
|
||||||
"author": "Bulwark Webmail <bulwark@rbm.systems>",
|
"author": "Bulwark Webmail <bulwark@rbm.systems>",
|
||||||
"license": "AGPL-3.0-only",
|
"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 });
|
set({ isLoading: true, error: null, isRateLimited: false, rateLimitUntil: null });
|
||||||
|
|
||||||
try {
|
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 accountStore = useAccountStore.getState();
|
||||||
const pendingSlot = typeof window !== 'undefined'
|
const rawSlot = typeof window !== 'undefined'
|
||||||
? parseInt(sessionStorage.getItem('oauth_cookie_slot') || '0', 10)
|
? sessionStorage.getItem('oauth_cookie_slot')
|
||||||
: 0;
|
: null;
|
||||||
const slot = pendingSlot >= 0 && pendingSlot <= 4 ? pendingSlot : accountStore.getNextCookieSlot();
|
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}`, {
|
const tokenRes = await apiFetch(`/api/auth/token?slot=${slot}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -659,6 +668,12 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
hasError: false,
|
hasError: false,
|
||||||
isDefault: accountStore.accounts.length === 0,
|
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);
|
accountStore.setActiveAccount(accountId);
|
||||||
|
|
||||||
await syncStalwartAuthContext(serverUrl, username, client.getAuthHeader(), slot);
|
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 });
|
set({ isLoading: true, error: null, isRateLimited: false, rateLimitUntil: null });
|
||||||
|
|
||||||
try {
|
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', {
|
const ssoRes = await apiFetch('/api/auth/sso/complete', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
body: JSON.stringify({ code, state }),
|
body: JSON.stringify({ code, state, slot }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!ssoRes.ok) {
|
if (!ssoRes.ok) {
|
||||||
@@ -741,8 +763,6 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
throw new Error('Server URL not configured');
|
throw new Error('Server URL not configured');
|
||||||
}
|
}
|
||||||
|
|
||||||
const accountStore = useAccountStore.getState();
|
|
||||||
|
|
||||||
const refreshFn = get().refreshAccessToken;
|
const refreshFn = get().refreshAccessToken;
|
||||||
const client = JMAPClient.withBearer(ssoServerUrl, access_token, '', () => refreshFn());
|
const client = JMAPClient.withBearer(ssoServerUrl, access_token, '', () => refreshFn());
|
||||||
await client.connect();
|
await client.connect();
|
||||||
@@ -779,10 +799,13 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
hasError: false,
|
hasError: false,
|
||||||
isDefault: accountStore.accounts.length === 0,
|
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);
|
accountStore.setActiveAccount(accountId);
|
||||||
|
|
||||||
const cookieSlot = accountStore.getAccountById(accountId)?.cookieSlot ?? 0;
|
await syncStalwartAuthContext(ssoServerUrl, username, client.getAuthHeader(), slot);
|
||||||
await syncStalwartAuthContext(ssoServerUrl, username, client.getAuthHeader(), cookieSlot);
|
|
||||||
|
|
||||||
set({
|
set({
|
||||||
isAuthenticated: true,
|
isAuthenticated: true,
|
||||||
|
|||||||
Reference in New Issue
Block a user