Compare commits

...
19 Commits
Author SHA1 Message Date
Linus Rath d1c5dba7d7 chore: update version to 1.6.1 2026-05-04 12:34:53 +02:00
Linus Rath 8c50abe221 fix: synchronize mobile submenu view with browser history for better navigation 2026-05-04 12:31:51 +02:00
Linus Rath 07367a8a5d fix: update email viewer styles to improve overflow handling 2026-05-04 12:27:44 +02:00
Linus Rath 1a50788c91 fix: ensure cookieSlot consistency during account updates in auth store 2026-05-04 12:09:58 +02:00
Linus Rath 0e06bfe273 Merge branch 'main' of https://github.com/bulwarkmail/webmail 2026-05-04 11:25:49 +02:00
Linus Rath f68e41d81a fix: enhance sharing functionality by renaming state 2026-05-04 11:24:05 +02:00
MaxwellandLinus Rath 8b164c556e fix: thread per-account cookie slot through OAuth flows
The multi-account refresh-token cookie slot wiring was half-implemented:
every account's refresh token ended up on slot 0, so "+ Add Account"
silently clobbered the previous account's `jmap_rt` cookie. On page
refresh, only the most-recently-added account had a working refresh
token; the others bounced to login.

Three coordinated changes:

1. `app/[locale]/login/page.tsx` (handleOAuthLogin): write the next-free
   cookie slot to `sessionStorage['oauth_cookie_slot']` before redirecting
   to the IdP. `loginWithOAuth` already reads this key but it was never
   written, so it always defaulted to 0.

2. `stores/auth-store.ts` (loginWithOAuth): distinguish "no value set"
   (`rawSlot === null`) from "value is 0". Previously
   `parseInt(getItem(...) || '0')` collapsed both cases, making the
   `getNextCookieSlot()` fallback unreachable.

3. `stores/auth-store.ts` (loginWithServerSso) +
   `app/api/auth/sso/complete/route.ts`: pass the slot through the body of
   the POST and use it for `refreshTokenCookieName(slot)`. Same pattern as
   the existing `/api/auth/token POST` that already accepts a slot. The
   server defaults to 0 for back-compat with any caller that omits it.

After the fix, signing in with multiple accounts produces distinct
`jmap_rt`, `jmap_rt_1`, `jmap_rt_2`, ... cookies (matching the cookieSlot
field in account-store) and all accounts survive a page refresh.

Repro before the fix:
- Sign in with one account, refresh — works.
- Click "+ Add Account", sign in with a second account, refresh — second
  account vanishes from the dropdown; switching to the first account in
  the dropdown still shows the second account's identity in the From box.
2026-05-04 11:22:45 +02:00
Linus Rath 2e1f53c899 Merge branch 'main' of https://github.com/bulwarkmail/webmail 2026-05-03 20:07:20 +02:00
Linus Rath a6d2efaf74 feat: sanitize identity display name to prevent invalid From headers 2026-05-03 20:06:41 +02:00
Luis Felipe MarzagaoandLinus Rath 01cd9644ed i18n: update mailbox context menu across 12 locales 2026-05-03 11:01:39 +02:00
Linus Rath 1521826d37 feat: add functionality to automatically add recipients to trusted senders when replying 2026-05-02 23:50:50 +02:00
Linus Rath 0d218d0d2a fix: square the colored left marker on calendar events 2026-05-02 23:41:08 +02:00
Linus Rath 9777dd655c feat: add share indicators for calendars and contacts, update JMAP capabilities #244 2026-05-02 23:29:21 +02:00
Linus Rath f970fd1822 feat: add plugin hooks for compose, attachments, search, lifecycle, and routing 2026-05-02 21:27:56 +02:00
Linus Rath 5e096240b3 feat: refresh update status on every dev reload 2026-05-02 13:23:58 +02:00
Linus Rath bc97a1ac10 feat: make update notice non-dismissible 2026-05-02 13:07:34 +02:00
Linus Rath 4594fb2572 revert: restore VERSION to correct value 2026-05-02 01:59:27 +02:00
Linus Rath 5319562c94 feat: add update-available detection 2026-05-02 01:58:30 +02:00
Linus Rath 599fa66822 fix: show git commit in About instead of "unknown" 2026-05-02 00:28:08 +02:00
55 changed files with 2078 additions and 534 deletions
@@ -50,6 +50,8 @@ jobs:
context: .
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
GIT_COMMIT=${{ github.sha }}
outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
cache-from: type=gha,scope=${{ matrix.platform }}
cache-to: type=gha,mode=max,scope=${{ matrix.platform }}
+2
View File
@@ -78,6 +78,8 @@ jobs:
context: .
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
GIT_COMMIT=${{ github.sha }}
outputs: type=image,name=${{ needs.prepare.outputs.image_name }},push-by-digest=true,name-canonical=true,push=true
cache-from: type=gha,scope=${{ matrix.platform }}
cache-to: type=gha,mode=max,scope=${{ matrix.platform }}
+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
+4
View File
@@ -8,6 +8,10 @@ ENV NEXT_TELEMETRY_DISABLED=1
# at build time, so it cannot be changed without rebuilding.
ARG NEXT_PUBLIC_BASE_PATH=
ENV NEXT_PUBLIC_BASE_PATH=$NEXT_PUBLIC_BASE_PATH
# Commit SHA shown in the About screen. .dockerignore excludes .git, so
# `git rev-parse` inside the build can't find it — CI must pass it in.
ARG GIT_COMMIT=unknown
ENV GIT_COMMIT=$GIT_COMMIT
RUN npx next build --webpack
FROM node:24-alpine AS runner
+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
+54 -4
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";
@@ -16,6 +17,7 @@ import { AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Monitor,
import { discoverOAuth, type OAuthMetadata } from "@/lib/oauth/discovery";
import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce";
import { OAUTH_SCOPES } from "@/lib/oauth/tokens";
import { useUpdateStore, selectBanner } from "@/stores/update-store";
const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "0.0.0";
const GIT_COMMIT = process.env.NEXT_PUBLIC_GIT_COMMIT || "unknown";
@@ -28,7 +30,12 @@ const THEME_OPTIONS = [
function VersionBadge() {
const [copied, setCopied] = useState(false);
const versionInfo = `Version: ${APP_VERSION}\nBuild: ${GIT_COMMIT}`;
const banner = useUpdateStore(useShallow(selectBanner));
const startPolling = useUpdateStore((s) => s.startPolling);
useEffect(() => { startPolling(); }, [startPolling]);
const versionInfo = `Version: ${APP_VERSION}\nBuild: ${GIT_COMMIT}${banner?.latest ? `\nLatest: ${banner.latest}` : ""}`;
const handleCopy = () => {
navigator.clipboard.writeText(versionInfo).then(() => {
@@ -37,16 +44,49 @@ function VersionBadge() {
});
};
const isRed = banner?.variant === "red";
const triggerText = !banner
? `v${APP_VERSION}`
: banner.severity === "security"
? "Security update available"
: banner.severity === "deprecated"
? "Version no longer supported"
: "New version available";
const triggerColor = !banner
? "text-muted-foreground/40"
: isRed
? "text-red-600/80 dark:text-red-400/80 hover:text-red-600 dark:hover:text-red-400"
: "text-amber-600/80 dark:text-amber-400/80 hover:text-amber-600 dark:hover:text-amber-400";
const triggerClass = cn(
"peer text-center text-xs transition-colors",
triggerColor,
banner?.url ? "cursor-pointer underline-offset-2 hover:underline" : "cursor-default",
);
const trigger = banner?.url ? (
<a href={banner.url} target="_blank" rel="noopener noreferrer" className={triggerClass}>
{triggerText}
</a>
) : (
<p className={triggerClass}>{triggerText}</p>
);
return (
<div className="relative inline-flex justify-center">
<p className="peer text-center text-xs text-muted-foreground/40 cursor-default">
v{APP_VERSION}
</p>
{trigger}
<div className="absolute top-full left-1/2 -translate-x-1/2 mt-1.5 px-3 py-2 rounded-md bg-popover text-popover-foreground text-xs shadow-md border border-border opacity-0 peer-hover:opacity-100 hover:opacity-100 transition-opacity whitespace-nowrap z-10">
<div className="flex items-center gap-2">
<div className="space-y-0.5">
<p>Version: <span className="font-medium">{APP_VERSION}</span></p>
<p>Build: <span className="font-medium">{GIT_COMMIT}</span></p>
{banner?.latest && (
<p>Latest: <span className="font-medium">{banner.latest}</span></p>
)}
{banner?.advisory && (
<p className="text-red-500 dark:text-red-400">{banner.advisory}</p>
)}
</div>
<button
onClick={handleCopy}
@@ -421,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);
+130 -3
View File
@@ -1,6 +1,7 @@
"use client";
import { useEffect, useState, useRef, useMemo, useCallback } from "react";
import { usePathname } from "next/navigation";
import { useTranslations } from "next-intl";
import { Sidebar } from "@/components/layout/sidebar";
import { EmailList } from "@/components/email/email-list";
@@ -58,6 +59,26 @@ import { Button } from "@/components/ui/button";
import { useConfig } from "@/hooks/use-config";
import { usePluginStore } from "@/stores/plugin-store";
import { useThemeStore } from "@/stores/theme-store";
import { appLifecycleHooks, uiHooks, routerHooks, toastHooks, emailHooks } from "@/lib/plugin-hooks";
import type { EmailReadView } from "@/lib/plugin-types";
function emailToReadView(email: Email): EmailReadView {
return {
id: email.id,
threadId: email.threadId,
mailboxIds: Object.keys(email.mailboxIds || {}).filter(k => email.mailboxIds[k]),
from: (email.from || []).map(a => ({ name: a.name || '', email: a.email })),
to: (email.to || []).map(a => ({ name: a.name || '', email: a.email })),
cc: (email.cc || []).map(a => ({ name: a.name || '', email: a.email })),
subject: email.subject || '',
receivedAt: email.receivedAt,
isRead: !!email.keywords?.['$seen'],
isFlagged: !!email.keywords?.['$flagged'],
hasAttachment: email.hasAttachment,
preview: email.preview || '',
keywords: Object.keys(email.keywords || {}).filter(k => email.keywords[k]),
};
}
export default function Home() {
@@ -115,6 +136,88 @@ export default function Home() {
return () => clearInterval(timer);
}, [isRateLimited, rateLimitUntil]);
// Plugin hooks: window-level lifecycle + selection + service-worker messages.
// One effect because the listeners share a registration / cleanup window.
useEffect(() => {
if (typeof window === 'undefined') return;
const onFocus = () => { appLifecycleHooks.onWindowFocus.emit(); };
const onBlur = () => { appLifecycleHooks.onWindowBlur.emit(); };
const onOnline = () => { appLifecycleHooks.onOnline.emit(); };
const onOffline = () => { appLifecycleHooks.onOffline.emit(); };
let selectionTimer: ReturnType<typeof setTimeout> | null = null;
const onSelectionChange = () => {
if (selectionTimer) clearTimeout(selectionTimer);
selectionTimer = setTimeout(() => {
const sel = document.getSelection();
const text = sel?.toString() ?? '';
if (!text) return;
const anchorNode = sel?.anchorNode as Node | null;
const anchorEl = (anchorNode?.nodeType === Node.ELEMENT_NODE
? anchorNode as Element
: anchorNode?.parentElement) ?? null;
let source: 'email-body' | 'composer' | 'task-detail' | 'event-detail' | 'other' = 'other';
let emailId: string | undefined;
if (anchorEl) {
if (anchorEl.closest('[data-plugin-source="email-body"], iframe.email-body, .email-viewer-body')) {
source = 'email-body';
const idEl = anchorEl.closest('[data-email-id]') as HTMLElement | null;
emailId = idEl?.dataset.emailId;
} else if (anchorEl.closest('[data-plugin-source="composer"], .email-composer')) {
source = 'composer';
} else if (anchorEl.closest('[data-plugin-source="task-detail"]')) {
source = 'task-detail';
} else if (anchorEl.closest('[data-plugin-source="event-detail"]')) {
source = 'event-detail';
}
}
uiHooks.onTextSelectionChange.emit({ text, source, emailId });
}, 150);
};
const onSwMessage = (e: MessageEvent) => {
const msg = e.data as { kind?: string; tag?: string; data?: unknown } | null;
if (msg && msg.kind === 'notificationclick' && typeof msg.tag === 'string') {
toastHooks.onNotificationClick.emit({ tag: msg.tag, data: msg.data });
}
};
window.addEventListener('focus', onFocus);
window.addEventListener('blur', onBlur);
window.addEventListener('online', onOnline);
window.addEventListener('offline', onOffline);
document.addEventListener('selectionchange', onSelectionChange);
if (typeof navigator !== 'undefined' && navigator.serviceWorker) {
navigator.serviceWorker.addEventListener('message', onSwMessage);
}
return () => {
window.removeEventListener('focus', onFocus);
window.removeEventListener('blur', onBlur);
window.removeEventListener('online', onOnline);
window.removeEventListener('offline', onOffline);
document.removeEventListener('selectionchange', onSelectionChange);
if (selectionTimer) clearTimeout(selectionTimer);
if (typeof navigator !== 'undefined' && navigator.serviceWorker) {
navigator.serviceWorker.removeEventListener('message', onSwMessage);
}
};
}, []);
// Plugin hooks: route navigation. Tracks Next.js pathname transitions.
const pathname = usePathname();
const prevPathnameRef = useRef<string | null>(null);
useEffect(() => {
if (!pathname) return;
const from = prevPathnameRef.current;
if (from === pathname) return;
if (from !== null) {
routerHooks.onRouteLeave.emit({ path: from });
routerHooks.onNavigate.emit({ path: pathname, from });
}
routerHooks.onRouteEnter.emit({ path: pathname });
prevPathnameRef.current = pathname;
}, [pathname]);
// Mobile/tablet responsive hooks
const { isMobile, isTablet } = useDeviceDetection();
const { activeView, sidebarOpen, setSidebarOpen, setActiveView, tabletListVisible, setTabletListVisible, sidebarWidth, emailListWidth, setSidebarWidth, setEmailListWidth, persistColumnWidths, sidebarCollapsed, resetSidebarWidth, resetEmailListWidth } = useUIStore();
@@ -835,7 +938,15 @@ export default function Home() {
}
};
const handleReply = (draftText?: string) => {
const handleReply = async (draftText?: string) => {
if (selectedEmail) {
const ok = await emailHooks.onBeforeReply.intercept({
originalEmailId: selectedEmail.id,
originalEmail: emailToReadView(selectedEmail),
mode: 'reply' as const,
});
if (!ok) return;
}
setComposerDraftText(draftText || "");
setComposerMode('reply');
setShowComposer(true);
@@ -894,13 +1005,29 @@ export default function Home() {
if (isMobile) setActiveView('viewer');
};
const handleReplyAll = () => {
const handleReplyAll = async () => {
if (selectedEmail) {
const ok = await emailHooks.onBeforeReplyAll.intercept({
originalEmailId: selectedEmail.id,
originalEmail: emailToReadView(selectedEmail),
mode: 'reply-all' as const,
});
if (!ok) return;
}
setComposerMode('replyAll');
setShowComposer(true);
if (isMobile) setActiveView('viewer');
};
const handleForward = () => {
const handleForward = async () => {
if (selectedEmail) {
const ok = await emailHooks.onBeforeForward.intercept({
originalEmailId: selectedEmail.id,
originalEmail: emailToReadView(selectedEmail),
mode: 'forward' as const,
});
if (!ok) return;
}
setComposerMode('forward');
setShowComposer(true);
if (isMobile) setActiveView('viewer');
+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" />
+27 -4
View File
@@ -15,6 +15,7 @@ import {
Puzzle,
SwatchBook,
Activity,
Package,
Mail,
Calendar,
BookUser,
@@ -30,6 +31,7 @@ import { useThemeStore } from '@/stores/theme-store';
import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
import { useAuthStore } from '@/stores/auth-store';
import { useUpdateStore, selectHasUpdate } from '@/stores/update-store';
import { apiFetch } from '@/lib/browser-navigation';
const NAV_GROUPS = [
@@ -59,6 +61,7 @@ const NAV_GROUPS = [
{
label: 'System',
items: [
{ href: '/admin/version', label: 'Version', icon: Package },
{ href: '/admin/telemetry', label: 'Telemetry', icon: Activity },
{ href: '/admin/logs', label: 'Audit Log', icon: ScrollText },
],
@@ -78,6 +81,13 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
? (appLogoDarkUrl || appLogoLightUrl || loginLogoDarkUrl)
: (appLogoLightUrl || appLogoDarkUrl || loginLogoLightUrl);
// Match the navigation rail: red for security/deprecated, amber for normal.
const hasUpdate = useUpdateStore(selectHasUpdate);
const updateSeverity = useUpdateStore((s) => s.status?.severity);
const startUpdatePolling = useUpdateStore((s) => s.startPolling);
useEffect(() => { startUpdatePolling(); }, [startUpdatePolling]);
const updateImportant = updateSeverity === 'security' || updateSeverity === 'deprecated';
useEffect(() => {
setMobileNavOpen(false);
}, [pathname]);
@@ -170,6 +180,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
</div>
{group.items.map(({ href, label, icon: Icon }) => {
const active = href === '/admin' ? pathname === '/admin' : pathname.startsWith(href);
const showDot = href === '/admin/version' && hasUpdate;
return (
<Link
key={href}
@@ -181,10 +192,22 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
: 'hover:bg-muted text-foreground'
)}
>
<Icon className={cn(
'w-4 h-4 shrink-0',
active ? 'text-accent-foreground' : 'text-muted-foreground'
)} />
<span className="relative shrink-0">
<Icon className={cn(
'w-4 h-4',
active ? 'text-accent-foreground' : 'text-muted-foreground'
)} />
{showDot && (
<span
className={cn(
'absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full ring-2',
active ? 'ring-accent' : 'ring-background',
updateImportant ? 'bg-red-500' : 'bg-amber-500',
)}
aria-label={updateImportant ? 'Important update available' : 'Update available'}
/>
)}
</span>
{label}
</Link>
);
+237
View File
@@ -0,0 +1,237 @@
'use client';
import { useEffect, useState } from 'react';
import {
Loader2,
RefreshCw,
CheckCircle2,
AlertTriangle,
ShieldAlert,
ExternalLink,
} from 'lucide-react';
import { SettingsSection, SettingItem } from '@/components/settings/settings-section';
import { apiFetch } from '@/lib/browser-navigation';
import type { UpdateStatus, UpdateSeverity } from '@/lib/version-check/types';
interface VersionAdminStatus {
current: string;
build: string;
endpoint: string;
defaultEndpoint: string;
disabledByEnv: boolean;
lastCheckedAt: string | null;
lastSuccessAt: string | null;
nextScheduledAt: string | null;
status: UpdateStatus | null;
}
function timeAgo(iso: string | null): string {
if (!iso) return 'never';
const d = Date.now() - new Date(iso).getTime();
if (d < 0) return new Date(iso).toLocaleString();
const m = Math.floor(d / 60000);
if (m < 1) return 'just now';
if (m < 60) return `${m} min ago`;
const h = Math.floor(m / 60);
if (h < 48) return `${h} hours ago`;
return `${Math.floor(h / 24)} days ago`;
}
function severityChip(severity: UpdateSeverity) {
switch (severity) {
case 'security':
return {
label: 'Security update',
className: 'bg-red-500/10 text-red-700 dark:text-red-300 border-red-500/30',
Icon: ShieldAlert,
};
case 'deprecated':
return {
label: 'Deprecated',
className: 'bg-red-500/10 text-red-700 dark:text-red-300 border-red-500/30',
Icon: ShieldAlert,
};
case 'normal':
return {
label: 'Update available',
className: 'bg-amber-500/10 text-amber-700 dark:text-amber-300 border-amber-500/30',
Icon: AlertTriangle,
};
case 'unknown':
return {
label: 'Unknown',
className: 'bg-muted text-muted-foreground border-border',
Icon: AlertTriangle,
};
case 'none':
default:
return {
label: 'Up to date',
className: 'bg-emerald-500/10 text-emerald-700 dark:text-emerald-300 border-emerald-500/30',
Icon: CheckCircle2,
};
}
}
export default function AdminVersionPage() {
const [data, setData] = useState<VersionAdminStatus | null>(null);
const [loading, setLoading] = useState(true);
const [checking, setChecking] = useState(false);
const [checkResult, setCheckResult] = useState<{ ok: boolean; msg: string } | null>(null);
async function refresh(): Promise<void> {
setLoading(true);
try {
const r = await apiFetch('/api/admin/version');
if (!r.ok) throw new Error('failed to load');
setData((await r.json()) as VersionAdminStatus);
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
}
useEffect(() => { void refresh(); }, []);
async function checkNow(): Promise<void> {
setChecking(true);
setCheckResult(null);
try {
const r = await apiFetch('/api/admin/version', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ action: 'check-now' }),
});
const j = (await r.json().catch(() => ({}))) as { ok?: boolean; error?: string };
setCheckResult({
ok: !!j.ok,
msg: j.ok ? 'Update check completed.' : `Failed: ${j.error ?? 'unknown'}`,
});
await refresh();
} finally {
setChecking(false);
}
}
if (loading || !data) {
return (
<div className="p-8 flex items-center gap-2 text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" /> loading
</div>
);
}
const status = data.status;
const chip = severityChip(status?.severity ?? 'none');
const ChipIcon = chip.Icon;
const releaseUrl = status?.url ?? null;
const newer = status?.latest && status.latest !== data.current ? status.latest : null;
return (
<div className="space-y-6">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<h1 className="text-2xl font-semibold text-foreground">Version</h1>
<p className="text-sm text-muted-foreground mt-1">
Hourly check against the Bulwark version server. Severity is decided server-side and
disable with <code>BULWARK_UPDATE_CHECK=off</code>.
</p>
</div>
<button
type="button"
disabled={checking}
onClick={() => void checkNow()}
className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm"
>
{checking ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCw className="w-4 h-4" />}
Check now
</button>
</div>
{checkResult && (
<div
className={`text-sm rounded-md px-3 py-2 ${
checkResult.ok
? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300'
: 'bg-destructive/10 text-destructive'
}`}
>
{checkResult.msg}
</div>
)}
<SettingsSection title="Status">
<SettingItem label="Severity">
<span
className={`inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-xs font-medium ${chip.className}`}
>
<ChipIcon className="h-3 w-3" />
{chip.label}
</span>
</SettingItem>
<SettingItem label="Running" description={data.build !== 'unknown' ? `Build ${data.build}` : undefined}>
<span className="text-sm font-mono text-foreground">{data.current}</span>
</SettingItem>
{newer && (
<SettingItem label="Latest release">
{releaseUrl ? (
<a
href={releaseUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-sm font-mono text-foreground hover:underline"
>
{newer} <ExternalLink className="w-3 h-3" />
</a>
) : (
<span className="text-sm font-mono text-foreground">{newer}</span>
)}
</SettingItem>
)}
{status?.advisory && (
<SettingItem label="Advisory">
<span className="text-sm font-mono text-red-600 dark:text-red-400">{status.advisory}</span>
</SettingItem>
)}
</SettingsSection>
<SettingsSection title="Schedule" description="Hourly polling with ±5 minute jitter.">
<SettingItem label="Last checked">
<span className="text-sm text-foreground">{timeAgo(data.lastCheckedAt)}</span>
</SettingItem>
<SettingItem label="Last success">
<span className="text-sm text-foreground">{timeAgo(data.lastSuccessAt)}</span>
</SettingItem>
<SettingItem label="Next scheduled">
<span className="text-sm text-foreground">{timeAgo(data.nextScheduledAt)}</span>
</SettingItem>
{status?.checkedAt && (
<SettingItem label="Server timestamp" description="When the server last refreshed its release list.">
<span className="text-sm text-foreground">{new Date(status.checkedAt).toLocaleString()}</span>
</SettingItem>
)}
</SettingsSection>
<SettingsSection title="Source">
<SettingItem
label="Endpoint"
description={data.endpoint === data.defaultEndpoint ? 'Default endpoint.' : `Default: ${data.defaultEndpoint}`}
>
<a
href={data.endpoint}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-sm text-foreground hover:underline break-all"
>
{data.endpoint} <ExternalLink className="w-3 h-3 shrink-0" />
</a>
</SettingItem>
<SettingItem label="Disabled by env" description="Set BULWARK_UPDATE_CHECK=off to disable.">
<span className={`text-sm font-medium ${data.disabledByEnv ? 'text-amber-600 dark:text-amber-400' : 'text-muted-foreground'}`}>
{data.disabledByEnv ? 'Yes' : 'No'}
</span>
</SettingItem>
</SettingsSection>
</div>
);
}
+66
View File
@@ -0,0 +1,66 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireAdminAuth } from '@/lib/admin/session';
import { logger } from '@/lib/logger';
import {
loadState,
checkOnce,
effectiveEndpoint,
disabledByEnv,
DEFAULT_VERSION_ENDPOINT,
} from '@/lib/version-check';
/**
* GET /api/admin/version
* Returns the cached update status, last check times, and effective config.
*/
export async function GET() {
try {
const auth = await requireAdminAuth();
if ('error' in auth) return auth.error;
const state = await loadState();
return NextResponse.json(
{
current: process.env.NEXT_PUBLIC_APP_VERSION || '0.0.0',
build: process.env.NEXT_PUBLIC_GIT_COMMIT || 'unknown',
endpoint: effectiveEndpoint(state),
defaultEndpoint: DEFAULT_VERSION_ENDPOINT,
disabledByEnv: disabledByEnv(),
lastCheckedAt: state.lastCheckedAt,
lastSuccessAt: state.lastSuccessAt,
nextScheduledAt: state.nextScheduledAt,
status: state.status,
},
{ headers: { 'Cache-Control': 'no-store' } },
);
} catch (err) {
logger.error('version admin GET error', {
error: err instanceof Error ? err.message : 'unknown',
});
return NextResponse.json({ error: 'failed' }, { status: 500 });
}
}
/**
* POST /api/admin/version
* { action: 'check-now' } — force a fresh upstream fetch.
*/
export async function POST(req: NextRequest) {
try {
const auth = await requireAdminAuth();
if ('error' in auth) return auth.error;
const body = (await req.json().catch(() => null)) as { action?: string } | null;
if (!body || body.action !== 'check-now') {
return NextResponse.json({ error: 'unknown action' }, { status: 400 });
}
const result = await checkOnce({ reason: 'admin-trigger' });
return NextResponse.json(result);
} catch (err) {
logger.error('version admin POST error', {
error: err instanceof Error ? err.message : 'unknown',
});
return NextResponse.json({ error: 'failed' }, { status: 500 });
}
}
+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());
}
+31
View File
@@ -0,0 +1,31 @@
import { NextResponse } from 'next/server';
import { checkOnce, loadState } from '@/lib/version-check';
// Public endpoint that returns the latest cached update status. Fed by the
// background scheduler started in instrumentation.node.ts; in production we
// never trigger a fresh upstream fetch from this route so an unauthenticated
// client can't use it to amplify traffic to the version server.
//
// In development we force a fresh fetch on every hit so changes to the
// version server's overrides take effect on the next page reload instead of
// requiring a dev-server restart. The 5s upstream timeout in fetchStatus
// caps the worst-case latency added to a dev reload.
export async function GET() {
if (process.env.NODE_ENV === 'development') {
await checkOnce({ reason: 'dev-reload' });
}
const state = await loadState();
return NextResponse.json(
{
status: state.status,
lastCheckedAt: state.lastCheckedAt,
lastSuccessAt: state.lastSuccessAt,
},
{
headers: {
'Cache-Control': 'no-store',
},
},
);
}
@@ -144,6 +144,12 @@ export function CalendarSidebarPanel({
{cal.id === BIRTHDAY_CALENDAR_ID && (
<Cake className="w-3 h-3 text-muted-foreground flex-shrink-0" />
)}
{!cal.isShared && Object.keys(cal.shareWith || {}).length > 0 && (
<Users
className="w-3 h-3 text-muted-foreground flex-shrink-0"
aria-label={tMgmt('share')}
/>
)}
</button>
</div>
);
+2 -3
View File
@@ -150,9 +150,8 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
aria-label={ariaLabel}
{...dragProps}
className={cn(
"w-full h-full text-left rounded px-1.5 py-0.5 text-xs overflow-hidden",
"w-full h-full text-left rounded-r px-1.5 py-0.5 text-xs overflow-hidden",
"hover:opacity-90 transition-opacity cursor-pointer",
continuesBefore && "rounded-l-sm",
continuesAfter && "rounded-r-sm",
continuesBefore && "-ml-0.5",
continuesAfter && "pr-2",
@@ -182,7 +181,7 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
{...dragProps}
data-calendar-event
className={cn(
"w-full h-full text-left rounded px-1.5 py-0.5 text-xs overflow-hidden",
"w-full h-full text-left rounded-r px-1.5 py-0.5 text-xs overflow-hidden",
"hover:opacity-90 transition-opacity cursor-pointer",
isSelected && "ring-2 ring-primary",
isBeingDragged && "opacity-50",
+47
View File
@@ -22,6 +22,8 @@ import { PluginSlot } from "@/components/plugins/plugin-slot";
import { useSettingsStore } from "@/stores/settings-store";
import { generateUUID } from "@/lib/utils";
import { useFormatEventDate } from "@/hooks/use-format-event-date";
import { calendarHooks } from "@/lib/plugin-hooks";
import type { ConflictWarning } from "@/lib/plugin-types";
export interface PendingEventPreview {
start: Date;
@@ -242,6 +244,31 @@ export function EventModal({
const [sendInvitations, setSendInvitations] = useState(true);
const participantInputRef = useRef<ParticipantInputHandle>(null);
// Plugin transform: collect conflict warnings for the current event form.
// Re-runs (debounced) whenever fields that affect scheduling change.
const [pluginConflictWarnings, setPluginConflictWarnings] = useState<ConflictWarning[]>([]);
useEffect(() => {
let cancelled = false;
const t = setTimeout(async () => {
const startStr = allDay ? `${startDate}T00:00:00` : `${startDate}T${startTime}:00`;
const endStr = allDay ? `${endDate}T23:59:59` : `${endDate}T${endTime}:00`;
const warnings = await calendarHooks.onCheckEventConflicts.transform([] as ConflictWarning[], {
event: {
title,
description,
start: startStr,
end: endStr,
isAllDay: allDay,
location,
virtualLocation,
calendarId,
},
});
if (!cancelled) setPluginConflictWarnings(warnings);
}, 250);
return () => { cancelled = true; clearTimeout(t); };
}, [title, description, startDate, startTime, endDate, endTime, allDay, location, virtualLocation, calendarId]);
// Report live preview to parent for grid outline
useEffect(() => {
if (!onPreviewChange || isEdit) return;
@@ -923,6 +950,26 @@ export function EventModal({
)}
</div>
{pluginConflictWarnings.length > 0 && (
<div className="space-y-1.5">
{pluginConflictWarnings.map(w => (
<div
key={w.key}
className={
w.severity === 'error'
? 'text-sm rounded-md border border-destructive/50 bg-destructive/10 text-destructive px-3 py-2'
: w.severity === 'info'
? 'text-sm rounded-md border border-border bg-muted/40 text-muted-foreground px-3 py-2'
: 'text-sm rounded-md border border-yellow-500/50 bg-yellow-500/10 text-yellow-700 dark:text-yellow-300 px-3 py-2'
}
title={w.message}
>
{w.message}
</div>
))}
</div>
)}
{calendars.length > 1 && (
<div>
<label className="text-sm font-medium mb-1 block">{t("form.calendar_select")}</label>
+7 -1
View File
@@ -685,7 +685,13 @@ function AddressBookItem({
>
<Book className="w-4 h-4 flex-shrink-0" />
<span className="truncate">{book.name}</span>
<span className="ml-auto text-xs text-muted-foreground tabular-nums">
{!book.isShared && Object.keys(book.shareWith || {}).length > 0 && (
<Users className="w-3 h-3 text-muted-foreground flex-shrink-0 ml-auto" />
)}
<span className={cn(
"text-xs text-muted-foreground tabular-nums",
!(!book.isShared && Object.keys(book.shareWith || {}).length > 0) && "ml-auto"
)}>
{contactCount}
</span>
</button>
+75 -8
View File
@@ -10,6 +10,8 @@ import { cn, formatFileSize, formatDateTime, generateUUID } from "@/lib/utils";
import { debug } from "@/lib/debug";
import { toast } from "@/stores/toast-store";
import { sanitizeEmailHtml } from "@/lib/email-sanitization";
import { emailHooks, contactHooks } from "@/lib/plugin-hooks";
import type { OutgoingEmail, RecipientSuggestion } from "@/lib/plugin-types";
import { useAuthStore } from "@/stores/auth-store";
import { useIdentityStore } from "@/stores/identity-store";
import { useAccountStore } from "@/stores/account-store";
@@ -326,6 +328,9 @@ export function EmailComposer({
? `<div>${getPlainTextSignature(currentIdentity).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>')}</div>`
: '';
const getAutocomplete = useContactStore((s) => s.getAutocomplete);
const addToTrustedSendersBook = useContactStore((s) => s.addToTrustedSendersBook);
const addTrustedSender = useSettingsStore((s) => s.addTrustedSender);
const trustedSendersAddressBook = useSettingsStore((s) => s.trustedSendersAddressBook);
const addTemplate = useTemplateStore((s) => s.addTemplate);
const sendRawEmail = useEmailStore((s) => s.sendRawEmail);
const smimeStore = useSmimeStore();
@@ -446,10 +451,13 @@ export function EmailComposer({
return;
}
autocompleteTimeoutRef.current = setTimeout(() => {
const results = getAutocomplete(lastPart);
setAutocompleteResults(results);
setActiveAutoField(results.length > 0 ? field : null);
autocompleteTimeoutRef.current = setTimeout(async () => {
const localResults = getAutocomplete(lastPart);
// Let plugins contribute extra suggestions (Slack handles, GitHub, CRM, …).
const initial: RecipientSuggestion[] = localResults.map(r => ({ name: r.name, email: r.email }));
const merged = await contactHooks.onProvideRecipientSuggestions.transform(initial, { query: lastPart });
setAutocompleteResults(merged.map(s => ({ name: s.name, email: s.email })));
setActiveAutoField(merged.length > 0 ? field : null);
setAutoSelectedIndex(-1);
}, 200);
}, [getAutocomplete]);
@@ -559,6 +567,19 @@ export function EmailComposer({
const addFiles = useCallback(async (files: File[]) => {
if (!client || files.length === 0) return;
// Let plugins veto each upload before it's queued.
const allowedFiles: File[] = [];
for (const file of files) {
const ok = await emailHooks.onBeforeAttachmentUpload.intercept({
name: file.name,
type: file.type || 'application/octet-stream',
size: file.size,
});
if (ok) allowedFiles.push(file);
}
if (allowedFiles.length === 0) return;
files = allowedFiles;
const newAttachments: ComposerAttachment[] = files.map(file => {
const controller = new AbortController();
return {
@@ -587,6 +608,12 @@ export function EmailComposer({
: att
)
);
emailHooks.onAfterAttachmentUpload.emit({
name: file.name,
type: file.type || 'application/octet-stream',
size: file.size,
blobId,
});
} catch (error) {
if (controller?.signal.aborted) continue;
debug.error(`Failed to upload ${file.name}:`, error);
@@ -782,6 +809,19 @@ export function EmailComposer({
// Set new timeout for auto-save (2 seconds after last change)
saveTimeoutRef.current = setTimeout(() => {
// Plugin observers (AI assist, grammar, …) get a debounced snapshot here.
emailHooks.onDraftChange.emit({
to: to.split(',').map(s => s.trim()).filter(Boolean),
cc: cc.split(',').map(s => s.trim()).filter(Boolean),
bcc: bcc.split(',').map(s => s.trim()).filter(Boolean),
subject,
htmlBody: plainTextMode ? '' : body,
textBody: plainTextMode ? body : htmlToPlainText(body),
identityId: selectedIdentityId || '',
attachments: attachments
.filter(a => a.blobId && !a.uploading && !a.error)
.map(a => ({ name: a.name, type: a.type || 'application/octet-stream', size: a.size })),
});
saveDraft();
}, 2000);
@@ -1065,21 +1105,48 @@ export function EmailComposer({
.map(att => ({ blobId: att.blobId!, name: att.name, type: att.type || 'application/octet-stream', size: att.size }));
uploadedAttachments.push(...inlineAttachments);
await onSend?.({
// Let plugins (signatures, link-rewriting, encryption, AI rewrite, …)
// transform the outgoing message immediately before submission.
const transformInput: OutgoingEmail = {
to: toAddresses,
cc: ccAddresses,
bcc: bccAddresses,
subject,
body: finalBody,
htmlBody: finalHtmlBody,
htmlBody: finalHtmlBody || '',
textBody: finalBody,
identityId: currentIdentity?.id || '',
attachments: uploadedAttachments.map(a => ({ name: a.name, type: a.type, size: a.size })),
inReplyTo: threadingHeaders?.inReplyTo?.[0],
};
const outgoing = await emailHooks.onTransformOutgoingEmail.transform(transformInput);
await onSend?.({
to: outgoing.to,
cc: outgoing.cc,
bcc: outgoing.bcc,
subject: outgoing.subject,
body: outgoing.textBody,
htmlBody: outgoing.htmlBody || undefined,
draftId: finalDraftId || undefined,
fromEmail,
fromName: currentIdentity?.name || undefined,
identityId: currentIdentity?.id,
identityId: outgoing.identityId || currentIdentity?.id,
attachments: uploadedAttachments.length > 0 ? uploadedAttachments : undefined,
inReplyTo: threadingHeaders?.inReplyTo,
references: threadingHeaders?.references,
});
if (mode === 'reply' || mode === 'replyAll') {
for (const recipient of [...outgoing.to, ...outgoing.cc].filter(Boolean)) {
if (trustedSendersAddressBook && client) {
addToTrustedSendersBook(client, recipient).catch(err => {
debug.error('Failed to add trusted sender to address book:', err);
});
} else {
addTrustedSender(recipient);
}
}
}
}
setTo("");
+51 -7
View File
@@ -93,6 +93,8 @@ import type { TnefAttachment } from "@/lib/tnef";
import { PluginSlot } from "@/components/plugins/plugin-slot";
import { usePluginStore } from "@/stores/plugin-store";
import { ResizeHandle } from "@/components/layout/resize-handle";
import { emailHooks, uiHooks } from "@/lib/plugin-hooks";
import type { AttachmentInfo, AttachmentPreview } from "@/lib/plugin-types";
interface EmailViewerProps {
email: Email | null;
@@ -2474,11 +2476,20 @@ export function EmailViewer({
return emailContent;
}, [cidBlobUrls, emailContent, smimeDecryptedHtml, smimeDecryptedText, tnefHtml, tnefText, embeddedEmailHtml, embeddedEmailText]);
const handleEffectiveAttachmentOpen = useCallback((attachment: EffectiveAttachment) => {
const handleEffectiveAttachmentOpen = useCallback(async (attachment: EffectiveAttachment) => {
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
const info: AttachmentInfo = {
name: attachment.name || '',
type: attachment.type,
size: attachment.size,
blobId: attachment.blobId,
emailId: email?.id,
};
if (attachment.blobId && onDownloadAttachment) {
emailHooks.onAttachmentDownload.emit(info);
onDownloadAttachment(attachment.blobId, attachment.name || 'download', attachment.type);
return;
}
@@ -2493,8 +2504,10 @@ export function EmailViewer({
const objectUrl = URL.createObjectURL(blob);
if (opensPreview) {
window.open(objectUrl, '_blank', 'noopener,noreferrer');
const transformed = await emailHooks.onAttachmentPreview.transform({ previewUrl: objectUrl } as AttachmentPreview, info);
window.open(transformed.previewUrl || objectUrl, '_blank', 'noopener,noreferrer');
} else {
emailHooks.onAttachmentDownload.emit(info);
const anchor = document.createElement('a');
anchor.href = objectUrl;
anchor.download = attachment.name || 'download';
@@ -2521,8 +2534,10 @@ export function EmailViewer({
const objectUrl = URL.createObjectURL(blob);
if (opensPreview) {
window.open(objectUrl, '_blank', 'noopener,noreferrer');
const transformed = await emailHooks.onAttachmentPreview.transform({ previewUrl: objectUrl } as AttachmentPreview, info);
window.open(transformed.previewUrl || objectUrl, '_blank', 'noopener,noreferrer');
} else {
emailHooks.onAttachmentDownload.emit(info);
const anchor = document.createElement('a');
anchor.href = objectUrl;
anchor.download = attachment.name || 'download';
@@ -2532,9 +2547,17 @@ export function EmailViewer({
}
setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000);
}, [mailAttachmentAction, onDownloadAttachment]);
}, [mailAttachmentAction, onDownloadAttachment, email?.id]);
const handleEffectiveAttachmentDownload = useCallback((attachment: EffectiveAttachment) => {
const info: AttachmentInfo = {
name: attachment.name || '',
type: attachment.type,
size: attachment.size,
blobId: attachment.blobId,
emailId: email?.id,
};
emailHooks.onAttachmentDownload.emit(info);
if (attachment.blobId && onDownloadAttachment) {
onDownloadAttachment(attachment.blobId, attachment.name || 'download', attachment.type, true);
return;
@@ -2570,7 +2593,7 @@ export function EmailViewer({
anchor.click();
anchor.remove();
setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000);
}, [onDownloadAttachment]);
}, [onDownloadAttachment, email?.id]);
// Pre-fetch object URLs for image attachments so their actual contents can be
// rendered as thumbnails inside the chip. Skips images larger than 10 MB.
@@ -2786,6 +2809,27 @@ export function EmailViewer({
a.setAttribute('rel', 'noopener noreferrer');
});
// Plugin intercept: let plugins cancel or rewrite external links inside
// the email body before navigation happens. Bound on the iframe doc so
// it survives DOM mutations from dark-mode pass below.
const onLinkClick = async (ev: Event) => {
const targetEl = (ev.target as Element | null)?.closest?.('a[href]') as HTMLAnchorElement | null;
if (!targetEl) return;
const href = targetEl.getAttribute('href') || '';
if (!href || href.startsWith('#') || href.startsWith('mailto:')) return;
ev.preventDefault();
ev.stopPropagation();
const ctx = {
href,
target: targetEl.getAttribute('target') ?? undefined,
emailId: email?.id,
};
const ok = await uiHooks.onBeforeExternalLink.intercept(ctx);
if (!ok) return;
window.open(ctx.href, '_blank', 'noopener,noreferrer');
};
doc.addEventListener('click', onLinkClick, true);
// Dark mode: re-invert elements with stylesheet-defined background images
// (CSS attribute selectors only catch inline styles, not <style> block rules)
if (isDark && !emailHasNativeDarkMode) {
@@ -2813,7 +2857,7 @@ export function EmailViewer({
} catch {
// Cross-origin restrictions - iframe will still display content
}
}, [isDark, emailHasNativeDarkMode]);
}, [isDark, emailHasNativeDarkMode, email?.id]);
// Export email as .eml file
const handleExportEmail = async () => {
@@ -3837,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)' }}>
+28 -2
View File
@@ -16,6 +16,7 @@ import { useSettingsStore } from "@/stores/settings-store";
import { usePolicyStore } from "@/stores/policy-store";
import { useAuthStore } from "@/stores/auth-store";
import { useAccountStore } from "@/stores/account-store";
import { useUpdateStore, selectHasUpdate } from "@/stores/update-store";
import { getActiveAccountSlotHeaders } from "@/lib/auth/active-account-slot";
import { getInitials, MAX_ACCOUNTS } from "@/lib/account-utils";
import { cn, formatFileSize } from "@/lib/utils";
@@ -175,6 +176,11 @@ export function NavigationRail({
const visibleSidebarApps = sidebarAppsEnabled ? sidebarApps : [];
const inboxUnread = mailboxes.find(m => m.role === "inbox")?.unreadEmails || 0;
const [isStalwartAdmin, setIsStalwartAdmin] = useState(false);
const hasUpdate = useUpdateStore(selectHasUpdate);
const updateSeverity = useUpdateStore((s) => s.status?.severity);
const startUpdatePolling = useUpdateStore((s) => s.startPolling);
useEffect(() => { startUpdatePolling(); }, [startUpdatePolling]);
const updateImportant = updateSeverity === 'security' || updateSeverity === 'deprecated';
// Account list for rail
const accounts = useAccountStore((s) => s.accounts);
@@ -345,7 +351,18 @@ export function NavigationRail({
"text-muted-foreground hover:text-foreground"
)}
>
<Shield className="w-5 h-5" />
<span className="relative">
<Shield className="w-5 h-5" />
{hasUpdate && (
<span
className={cn(
"absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full ring-2 ring-background",
updateImportant ? "bg-red-500" : "bg-amber-500",
)}
aria-label={updateImportant ? "Important update available" : "Update available"}
/>
)}
</span>
<span className="text-[10px] font-medium leading-tight truncate max-w-full">{t("admin") || "Admin"}</span>
</a>
)}
@@ -515,10 +532,19 @@ export function NavigationRail({
{isStalwartAdmin && (
<a
href="/admin"
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted relative"
title={t("admin") || "Admin"}
>
<Shield className="w-[18px] h-[18px]" />
{hasUpdate && (
<span
className={cn(
"absolute top-2 right-2 w-2 h-2 rounded-full ring-2 ring-background",
updateImportant ? "bg-red-500" : "bg-amber-500",
)}
aria-label={updateImportant ? "Important update available" : "Update available"}
/>
)}
</a>
)}
+35 -1
View File
@@ -1,18 +1,51 @@
"use client";
import { useState, useRef } from 'react';
import { useState, useRef, useEffect } from 'react';
import { useTranslations } from 'next-intl';
import { useSettingsStore } from '@/stores/settings-store';
import { useConfig } from '@/hooks/use-config';
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
import { Button } from '@/components/ui/button';
import { usePolicyStore } from '@/stores/policy-store';
import { useUpdateStore } from '@/stores/update-store';
import { ExternalLink } from 'lucide-react';
import { cn } from '@/lib/utils';
import { SpamSiegeGame } from './spam-siege-game';
const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "0.0.0";
const GIT_COMMIT = process.env.NEXT_PUBLIC_GIT_COMMIT || "unknown";
function VersionUpdateTag() {
const status = useUpdateStore((s) => s.status);
const startPolling = useUpdateStore((s) => s.startPolling);
useEffect(() => {
startPolling();
}, [startPolling]);
if (!status?.updateAvailable) return null;
if (status.severity === 'unknown' || status.severity === 'none') return null;
const important = status.severity === 'security' || status.severity === 'deprecated';
const label =
status.severity === 'security' ? 'security'
: status.severity === 'deprecated' ? 'deprecated'
: status.latest ?? 'update';
return (
<span
className={cn(
"ml-2 inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium align-middle",
important
? "bg-red-500/15 text-red-700 dark:text-red-300"
: "bg-amber-500/15 text-amber-700 dark:text-amber-300",
)}
>
{important ? label : `update: ${label}`}
</span>
);
}
export function AboutDataSettings() {
const t = useTranslations('settings.advanced');
const tCommon = useTranslations('common');
@@ -106,6 +139,7 @@ export function AboutDataSettings() {
</p>
<p className="text-xs text-muted-foreground group-hover/about:translate-x-0.5 group-active/about:translate-y-px transition-transform">
v{APP_VERSION} <span className="text-muted-foreground/60">({GIT_COMMIT})</span>
<VersionUpdateTag />
</p>
</div>
</button>
@@ -139,6 +139,19 @@ export function AddressBookManagementSettings() {
{t("default")}
</span>
)}
{(() => {
const shareCount = Object.keys(book.shareWith || {}).length;
if (shareCount === 0 || book.isShared) return null;
return (
<span
className="flex items-center gap-1 text-xs text-muted-foreground bg-muted px-2 py-0.5 rounded-full"
title={t("share")}
>
<Users className="w-3 h-3" />
{shareCount}
</span>
);
})()}
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
{canRename && (
<button
@@ -516,6 +516,20 @@ export function CalendarManagementSettings() {
</span>
)}
{(() => {
const shareCount = Object.keys(cal.shareWith || {}).length;
if (shareCount === 0 || cal.isShared) return null;
return (
<span
className="flex items-center gap-1 text-xs text-muted-foreground bg-muted px-2 py-0.5 rounded-full"
title={t('share')}
>
<Users className="w-3 h-3" />
{shareCount}
</span>
);
})()}
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
type="button"
+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"));
+7 -37
View File
@@ -2,49 +2,12 @@ import { readFileSync } from "fs";
import { configManager } from "./lib/admin/config-manager";
import { initAdminPassword } from "./lib/admin/password";
const VERSION_CHECK_URL =
"https://raw.githubusercontent.com/bulwarkmail/webmail/main/VERSION";
const SEMVER_RE = /^\d+\.\d+\.\d+$/;
function compareVersions(current: string, remote: string): number {
const a = current.split(".").map(Number);
const b = remote.split(".").map(Number);
for (let i = 0; i < 3; i++) {
if ((b[i] ?? 0) > (a[i] ?? 0)) return 1;
if ((b[i] ?? 0) < (a[i] ?? 0)) return -1;
}
return 0;
}
const pkg = JSON.parse(
readFileSync(`${process.cwd()}/package.json`, "utf-8")
);
const current: string = pkg.version ?? "0.0.0";
console.info(`Bulwark Webmail v${current}`);
if (process.env.NODE_ENV === "production") {
fetch(VERSION_CHECK_URL, {
cache: "no-store",
signal: AbortSignal.timeout(5000),
})
.then((res) => {
if (!res.ok) return;
return res.text();
})
.then((text) => {
if (!text) return;
const remote = text.trim();
if (!SEMVER_RE.test(remote)) return;
if (compareVersions(current, remote) > 0) {
console.info(
`Update available: v${remote} - https://github.com/bulwarkmail/webmail`
);
}
})
.catch(() => {});
}
// Initialize admin config and password bootstrap
configManager.load()
.then(() => initAdminPassword())
@@ -59,6 +22,13 @@ configManager.load()
markProcessStart();
await startScheduler();
})
.then(async () => {
// Hourly check against version.telemetry.bulwarkmail.org. Disable with
// BULWARK_UPDATE_CHECK=off or override the endpoint with
// BULWARK_UPDATE_CHECK_URL.
const { startScheduler } = await import("./lib/version-check");
await startScheduler();
})
.catch((err) => {
console.warn("Admin dashboard init skipped:", err instanceof Error ? err.message : err);
});
+32 -5
View File
@@ -300,6 +300,16 @@ function stripMessageIdBrackets(id: string): string {
return id.trim().replace(/^<+/, '').replace(/>+$/, '').trim();
}
// Some servers (notably Stalwart) return Identity.name in RFC 5322 mailbox
// form: `Display Name <addr@example.com>`. Re-emitting that as the JMAP
// from.name field produces a doubled From header (`"Name <addr>" <addr>`)
// whose display-name is invalid per RFC 5322 §3.4 and gets rejected by the
// submission validator — the email then sits forever in Drafts.
function sanitizeIdentityDisplayName(name: string | undefined | null): string {
if (!name) return '';
return name.replace(/\s*<[^>]*>\s*$/, '').trim();
}
export class JMAPClient implements IJMAPClient {
private static readonly RATE_LIMIT_TOAST_THROTTLE_MS = 10_000;
@@ -1754,7 +1764,8 @@ export class JMAPClient implements IJMAPClient {
]);
if (response.methodResponses?.[0]?.[0] === "Identity/get") {
return (response.methodResponses[0][1].list || []) as Identity[];
const list = (response.methodResponses[0][1].list || []) as Identity[];
return list.map((id) => ({ ...id, name: sanitizeIdentityDisplayName(id.name) }));
}
return [];
@@ -1964,8 +1975,9 @@ export class JMAPClient implements IJMAPClient {
attachments?: { blobId: string; type: string; name: string; disposition: string; cid?: string }[];
}
const sanitizedFromName = sanitizeIdentityDisplayName(fromName);
const emailData: EmailDraft = {
from: [{ ...(fromName ? { name: fromName } : {}), email: fromEmail || this.username }],
from: [{ ...(sanitizedFromName ? { name: sanitizedFromName } : {}), email: fromEmail || this.username }],
to: to.map(email => ({ email })),
cc: cc?.map(email => ({ email })),
bcc: bcc?.map(email => ({ email })),
@@ -2087,9 +2099,10 @@ export class JMAPClient implements IJMAPClient {
const normalizedInReplyTo = inReplyTo?.map(stripMessageIdBrackets).filter(Boolean);
const normalizedReferences = references?.map(stripMessageIdBrackets).filter(Boolean);
const sanitizedFromName = sanitizeIdentityDisplayName(fromName);
// Always create a new email with the final body content
const emailCreate: Record<string, unknown> = {
from: [{ ...(fromName ? { name: fromName } : {}), email: fromEmail || this.username }],
from: [{ ...(sanitizedFromName ? { name: sanitizedFromName } : {}), email: fromEmail || this.username }],
replyTo: identityReplyTo?.length ? identityReplyTo : undefined,
to: to.map(email => ({ email })),
cc: cc?.map(email => ({ email })),
@@ -3079,11 +3092,19 @@ export class JMAPClient implements IJMAPClient {
}
private contactUsing(): string[] {
return ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:contacts"];
const using = ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:contacts"];
if (this.hasCapability("urn:ietf:params:jmap:principals")) {
using.push("urn:ietf:params:jmap:principals:owner");
}
return using;
}
private calendarUsing(): string[] {
return ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:calendars"];
const using = ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:calendars"];
if (this.hasCapability("urn:ietf:params:jmap:principals")) {
using.push("urn:ietf:params:jmap:principals:owner");
}
return using;
}
private getCalendarCapableAccountIds(): string[] {
@@ -3293,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");
}
}
/**
@@ -3318,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(
+54 -2
View File
@@ -23,7 +23,7 @@ import {
taskHooks, templateHooks, smimeHooks, vacationHooks,
uiHooks, themeHooks, toastHooks, dragDropHooks,
keyboardHooks, appLifecycleHooks, accountSecurityHooks,
sidebarAppHooks, avatarHooks, renderHooks,
sidebarAppHooks, avatarHooks, renderHooks, routerHooks,
} from './plugin-hooks';
import { createPluginI18n } from './plugin-i18n';
import { toast as appToast } from '@/stores/toast-store';
@@ -187,6 +187,22 @@ export interface PluginHooksAPI {
onQuotaChange: (handler: (...args: unknown[]) => unknown) => Disposable;
/** Intercept - receives MailtoContext, return false to prevent the system mail client */
onMailtoIntercept: (handler: (ctx: import('./plugin-types').MailtoContext) => boolean | void | Promise<boolean | void>) => Disposable;
/** Transform - receives the OutgoingEmail and returns a (possibly modified) copy */
onTransformOutgoingEmail: (handler: (email: import('./plugin-types').OutgoingEmail) => import('./plugin-types').OutgoingEmail | void | Promise<import('./plugin-types').OutgoingEmail | void>) => Disposable;
/** Intercept - receives ReplyContext, return false to cancel */
onBeforeReply: (handler: (ctx: import('./plugin-types').ReplyContext) => boolean | void | Promise<boolean | void>) => Disposable;
onBeforeReplyAll: (handler: (ctx: import('./plugin-types').ReplyContext) => boolean | void | Promise<boolean | void>) => Disposable;
onBeforeForward: (handler: (ctx: import('./plugin-types').ReplyContext) => boolean | void | Promise<boolean | void>) => Disposable;
/** Intercept - receives AttachmentInfo, return false to refuse the upload */
onBeforeAttachmentUpload: (handler: (info: import('./plugin-types').AttachmentInfo) => boolean | void | Promise<boolean | void>) => Disposable;
onAfterAttachmentUpload: (handler: (info: import('./plugin-types').AttachmentInfo) => void) => Disposable;
onAttachmentDownload: (handler: (info: import('./plugin-types').AttachmentInfo) => void) => Disposable;
/** Transform - receives AttachmentPreview, may return a modified preview */
onAttachmentPreview: (handler: (preview: import('./plugin-types').AttachmentPreview, info: import('./plugin-types').AttachmentInfo) => import('./plugin-types').AttachmentPreview | void | Promise<import('./plugin-types').AttachmentPreview | void>) => Disposable;
/** Transform - receives ExternalSearchResult[] and returns an extended array */
onProvideSearchResults: (handler: (results: import('./plugin-types').ExternalSearchResult[], ctx: { query: string; filters: import('./plugin-types').SearchFilters }) => import('./plugin-types').ExternalSearchResult[] | void | Promise<import('./plugin-types').ExternalSearchResult[] | void>) => Disposable;
/** Observer - debounced snapshot of the composer draft */
onDraftChange: (handler: (draft: import('./plugin-types').DraftView) => void) => Disposable;
// Calendar
onCalendarEventOpen: (handler: (...args: unknown[]) => unknown) => Disposable;
onBeforeEventCreate: (handler: (...args: unknown[]) => unknown) => Disposable;
@@ -204,6 +220,8 @@ export interface PluginHooksAPI {
onICalSubscriptionChange: (handler: (...args: unknown[]) => unknown) => Disposable;
onCalendarAlert: (handler: (...args: unknown[]) => unknown) => Disposable;
onCalendarAlertAcknowledge: (handler: (...args: unknown[]) => unknown) => Disposable;
/** Transform - receives ConflictWarning[] and returns an extended array */
onCheckEventConflicts: (handler: (warnings: import('./plugin-types').ConflictWarning[], ctx: { event: import('./plugin-types').CalendarEventFormView }) => import('./plugin-types').ConflictWarning[] | void | Promise<import('./plugin-types').ConflictWarning[] | void>) => Disposable;
// Calendar Form
onCalendarEventFormOpen: (handler: (...args: unknown[]) => unknown) => Disposable;
onCalendarEventFormSave: (handler: (...args: unknown[]) => unknown) => Disposable;
@@ -220,6 +238,8 @@ export interface PluginHooksAPI {
onContactGroupChange: (handler: (...args: unknown[]) => unknown) => Disposable;
onContactGroupMemberChange: (handler: (...args: unknown[]) => unknown) => Disposable;
onContactMove: (handler: (...args: unknown[]) => unknown) => Disposable;
/** Transform - receives RecipientSuggestion[] and returns an extended array */
onProvideRecipientSuggestions: (handler: (suggestions: import('./plugin-types').RecipientSuggestion[], ctx: { query: string }) => import('./plugin-types').RecipientSuggestion[] | void | Promise<import('./plugin-types').RecipientSuggestion[] | void>) => Disposable;
// Files
onFileNavigate: (handler: (...args: unknown[]) => unknown) => Disposable;
onBeforeFileUpload: (handler: (...args: unknown[]) => unknown) => Disposable;
@@ -297,6 +317,10 @@ export interface PluginHooksAPI {
onColumnResize: (handler: (...args: unknown[]) => unknown) => Disposable;
onMobileBack: (handler: () => void) => Disposable;
onMobileViewSwitch: (handler: (...args: unknown[]) => unknown) => Disposable;
/** Intercept - receives ExternalLinkContext, return false to cancel navigation */
onBeforeExternalLink: (handler: (ctx: import('./plugin-types').ExternalLinkContext) => boolean | void | Promise<boolean | void>) => Disposable;
/** Observer - debounced text-selection change */
onTextSelectionChange: (handler: (ctx: import('./plugin-types').SelectionContext) => void) => Disposable;
// Theme
onThemeChange: (handler: (...args: unknown[]) => unknown) => Disposable;
onCustomThemeChange: (handler: (...args: unknown[]) => unknown) => Disposable;
@@ -305,6 +329,8 @@ export interface PluginHooksAPI {
onToastShow: (handler: (...args: unknown[]) => unknown) => Disposable;
onToastDismiss: (handler: (...args: unknown[]) => unknown) => Disposable;
onBrowserNotification: (handler: (...args: unknown[]) => unknown) => Disposable;
/** Observer fired when an OS-level notification is clicked */
onNotificationClick: (handler: (ctx: { tag: string; data?: unknown }) => void) => Disposable;
// Drag & Drop
onDragStart: (handler: (...args: unknown[]) => unknown) => Disposable;
onDragEnd: (handler: (...args: unknown[]) => unknown) => Disposable;
@@ -320,6 +346,12 @@ export interface PluginHooksAPI {
onBeforeUnload: (handler: () => void) => Disposable;
onAppError: (handler: (...args: unknown[]) => unknown) => Disposable;
onInterval: (handler: () => void, intervalMs: number) => Disposable;
/** Observer - browser window focus / blur */
onWindowFocus: (handler: () => void) => Disposable;
onWindowBlur: (handler: () => void) => Disposable;
/** Observer - network connectivity transitions */
onOnline: (handler: () => void) => Disposable;
onOffline: (handler: () => void) => Disposable;
// Account Security
onPasswordChange: (handler: () => void) => Disposable;
onTotpChange: (handler: (...args: unknown[]) => unknown) => Disposable;
@@ -335,6 +367,11 @@ export interface PluginHooksAPI {
// Render - transform hook for email list row badges
// Handler: (badges: EmailListBadge[], ctx: { emailId: string; email: EmailReadView }) => EmailListBadge[]
onEmailListItemRender: (handler: (...args: unknown[]) => unknown) => Disposable;
// Router
/** Observer - fired on every in-app navigation. RouteContext.from holds the previous path. */
onNavigate: (handler: (ctx: import('./plugin-types').RouteContext) => void) => Disposable;
onRouteEnter: (handler: (ctx: import('./plugin-types').RouteContext) => void) => Disposable;
onRouteLeave: (handler: (ctx: import('./plugin-types').RouteContext) => void) => Disposable;
}
// --- Permission mapping for hooks ----------------------------
@@ -350,7 +387,13 @@ const HOOK_PERMISSIONS: Record<string, Permission> = {
onEmailSelectionChange: 'email:read', onNewEmailReceived: 'email:read',
onPushConnectionChange: 'email:read', onQuotaChange: 'email:read',
onMailtoIntercept: 'email:read', onEmailListItemRender: 'email:read',
onBeforeReply: 'email:read', onBeforeReplyAll: 'email:read',
onBeforeForward: 'email:read', onAttachmentDownload: 'email:read',
onAttachmentPreview: 'email:read', onProvideSearchResults: 'email:read',
onDraftChange: 'email:read',
onBeforeAttachmentUpload: 'email:write', onAfterAttachmentUpload: 'email:write',
onBeforeEmailSend: 'email:send', onAfterEmailSend: 'email:send',
onTransformOutgoingEmail: 'email:send',
onBeforeEmailDelete: 'email:write', onAfterEmailDelete: 'email:write',
onBeforeEmailMove: 'email:write', onAfterEmailMove: 'email:write',
onEmailArchive: 'email:write', onEmailUnarchive: 'email:write',
@@ -362,6 +405,7 @@ const HOOK_PERMISSIONS: Record<string, Permission> = {
onCalendarEventOpen: 'calendar:read', onCalendarDateChange: 'calendar:read',
onCalendarViewChange: 'calendar:read', onCalendarVisibilityToggle: 'calendar:read',
onCalendarAlert: 'calendar:read', onCalendarAlertAcknowledge: 'calendar:read',
onCheckEventConflicts: 'calendar:read',
onCalendarEventFormOpen: 'calendar:read', onCalendarEventFormSave: 'calendar:write',
onBeforeEventCreate: 'calendar:write', onAfterEventCreate: 'calendar:write',
onBeforeEventUpdate: 'calendar:write', onAfterEventUpdate: 'calendar:write',
@@ -370,6 +414,7 @@ const HOOK_PERMISSIONS: Record<string, Permission> = {
onCalendarChange: 'calendar:write', onICalSubscriptionChange: 'calendar:write',
// Contacts
onContactOpen: 'contacts:read', onContactSelectionChange: 'contacts:read',
onProvideRecipientSuggestions: 'contacts:read',
onBeforeContactCreate: 'contacts:write', onAfterContactCreate: 'contacts:write',
onBeforeContactUpdate: 'contacts:write', onAfterContactUpdate: 'contacts:write',
onBeforeContactDelete: 'contacts:write', onAfterContactDelete: 'contacts:write',
@@ -419,12 +464,13 @@ const HOOK_PERMISSIONS: Record<string, Permission> = {
onSidebarCollapse: 'ui:observe', onDeviceTypeChange: 'ui:observe',
onColumnResize: 'ui:observe', onMobileBack: 'ui:observe',
onMobileViewSwitch: 'ui:observe',
onBeforeExternalLink: 'ui:observe', onTextSelectionChange: 'ui:observe',
// Theme
onThemeChange: 'ui:observe', onCustomThemeChange: 'ui:observe',
onLocaleChange: 'ui:observe',
// Toast
onToastShow: 'ui:observe', onToastDismiss: 'ui:observe',
onBrowserNotification: 'ui:observe',
onBrowserNotification: 'ui:observe', onNotificationClick: 'ui:observe',
// Drag & Drop
onDragStart: 'ui:observe', onDragEnd: 'ui:observe',
onEmailDrop: 'ui:observe', onTagDrop: 'ui:observe',
@@ -435,6 +481,8 @@ const HOOK_PERMISSIONS: Record<string, Permission> = {
onAppReady: 'app:lifecycle', onVisibilityChange: 'app:lifecycle',
onBeforeUnload: 'app:lifecycle', onAppError: 'app:lifecycle',
onInterval: 'app:lifecycle',
onWindowFocus: 'app:lifecycle', onWindowBlur: 'app:lifecycle',
onOnline: 'app:lifecycle', onOffline: 'app:lifecycle',
// Account Security
onPasswordChange: 'security:read', onTotpChange: 'security:read',
onAppPasswordChange: 'security:read', onEncryptionChange: 'security:read',
@@ -444,6 +492,8 @@ const HOOK_PERMISSIONS: Record<string, Permission> = {
onSidebarAppChange: 'ui:observe',
// Avatar
onAvatarResolve: 'email:read',
// Router
onNavigate: 'ui:observe', onRouteEnter: 'ui:observe', onRouteLeave: 'ui:observe',
};
// Map hook names → actual HookBus instances
@@ -494,6 +544,8 @@ const HOOK_BUSES: Record<string, { register: (pluginId: string, handler: (...arg
...Object.fromEntries(Object.entries(avatarHooks)),
// Render
...Object.fromEntries(Object.entries(renderHooks)),
// Router
...Object.fromEntries(Object.entries(routerHooks)),
};
// --- Slot registration bridge --------------------------------
+70 -1
View File
@@ -207,6 +207,38 @@ export const emailHooks = {
// Intercept hook - fired when a mailto: link is clicked.
// Return false to prevent the browser from opening the system mail client.
onMailtoIntercept: new HookBus(),
// Transform hook - fires after onBeforeEmailSend has not cancelled,
// immediately before the message is handed to the JMAP submission. Handlers
// receive an OutgoingEmail and return a modified copy (or undefined to pass
// through). Use to inject signatures, scrub tracking pixels from forwarded
// bodies, encrypt content, or rewrite links.
onTransformOutgoingEmail: new HookBus(),
// Intercept hooks fired when the user clicks Reply / Reply-All / Forward.
// Handler receives a ReplyContext; return false to cancel.
onBeforeReply: new HookBus(),
onBeforeReplyAll: new HookBus(),
onBeforeForward: new HookBus(),
// Intercept hook fired before a file is added to the composer as an
// attachment. Handler receives AttachmentInfo (size/type/name only - the
// raw file is not exposed). Return false to refuse the upload.
onBeforeAttachmentUpload: new HookBus(),
// Observer fired after an attachment has been uploaded and its blobId is
// available. Handler receives AttachmentInfo with `blobId` populated.
onAfterAttachmentUpload: new HookBus(),
// Observer fired when the user downloads an attachment from a message.
onAttachmentDownload: new HookBus(),
// Transform hook - lets plugins replace the preview URL or supply a custom
// renderer for an attachment. Initial value: AttachmentPreview, second
// argument: AttachmentInfo.
onAttachmentPreview: new HookBus(),
// Transform hook - lets plugins contribute additional results to the global
// search panel. Initial value: ExternalSearchResult[]. Second argument:
// { query: string, filters: SearchFilters }.
onProvideSearchResults: new HookBus(),
// Observer fired (debounced) when the composer draft body, subject, or
// recipients change. Handler receives a DraftView snapshot. Use for AI
// assistants, grammar checkers, etc.
onDraftChange: new HookBus(),
};
// §7.2 Calendar Hooks
@@ -227,6 +259,10 @@ export const calendarHooks = {
onICalSubscriptionChange: new HookBus(),
onCalendarAlert: new HookBus(),
onCalendarAlertAcknowledge: new HookBus(),
// Transform hook - fires when the event form is open and start/end change.
// Initial value: ConflictWarning[], second argument: { event: CalendarEventFormView }.
// Plugins return an extended array; the form renders each warning inline.
onCheckEventConflicts: new HookBus(),
};
// §7.2b Calendar Form Hooks (UI integration)
@@ -249,6 +285,10 @@ export const contactHooks = {
onContactGroupChange: new HookBus(),
onContactGroupMemberChange: new HookBus(),
onContactMove: new HookBus(),
// Transform hook - lets plugins contribute extra recipient suggestions to
// the composer's autocomplete. Initial value: RecipientSuggestion[],
// second argument: { query: string }.
onProvideRecipientSuggestions: new HookBus(),
};
// §7.4 File Hooks
@@ -358,6 +398,14 @@ export const uiHooks = {
onColumnResize: new HookBus(),
onMobileBack: new HookBus(),
onMobileViewSwitch: new HookBus(),
// Intercept hook - fires when the user clicks an external link inside the
// app (typically inside an email body iframe). Handler receives
// ExternalLinkContext; return false to cancel the navigation. Mutate
// `href` in place to rewrite (e.g. strip UTM params, route via a proxy).
onBeforeExternalLink: new HookBus(),
// Observer (debounced) fired when the user changes the active text
// selection inside an app surface. Receives SelectionContext.
onTextSelectionChange: new HookBus(),
};
// §7.14 Theme Hooks
@@ -384,6 +432,10 @@ export const toastHooks = {
onToastShow: new HookBus(),
onToastDismiss: new HookBus(),
onBrowserNotification: new HookBus(),
// Observer fired when the user clicks an OS-level browser notification
// dispatched by the host. Handler receives { tag: string, data?: unknown }
// matching the original notification options.
onNotificationClick: new HookBus(),
};
// §7.16 Drag & Drop Hooks
@@ -408,6 +460,14 @@ export const appLifecycleHooks = {
onBeforeUnload: new HookBus(),
onAppError: new HookBus(),
onInterval: new HookBus(),
// Observer fired when the browser window receives focus / blur. Useful for
// refresh-on-focus behaviour (re-poll, recheck staleness, pause timers).
onWindowFocus: new HookBus(),
onWindowBlur: new HookBus(),
// Observer fired when network connectivity transitions. Mirrors the
// navigator online / offline events.
onOnline: new HookBus(),
onOffline: new HookBus(),
};
// §7.19 Account Security Hooks
@@ -433,6 +493,15 @@ export const avatarHooks = {
onAvatarResolve: new HookBus(),
};
// §7.23 Router Hooks
// Observers fired by the app router. Handlers receive a RouteContext; on
// onNavigate the previous path is exposed via `from`.
export const routerHooks = {
onNavigate: new HookBus(),
onRouteEnter: new HookBus(),
onRouteLeave: new HookBus(),
};
// §7.22 Render Hooks
export const renderHooks = {
// Transform hook - runs for each visible email list row.
@@ -451,7 +520,7 @@ const allHookGroups = [
taskHooks, templateHooks, smimeHooks, vacationHooks,
uiHooks, themeHooks, toastHooks, dragDropHooks,
keyboardHooks, appLifecycleHooks, accountSecurityHooks, sidebarAppHooks,
avatarHooks, renderHooks,
avatarHooks, renderHooks, routerHooks,
];
export function removeAllPluginHooks(pluginId: string): void {
+131
View File
@@ -522,6 +522,137 @@ export interface MailtoContext {
body?: string;
}
/**
* Passed to onTransformOutgoingEmail handlers as a transform value.
* Handlers receive the email about to be sent and return a (possibly mutated)
* copy. Use to inject signatures, rewrite links, strip tracking pixels from
* forwards, encrypt the body, etc. Return undefined to pass through unchanged.
*/
export interface OutgoingEmail {
to: string[];
cc: string[];
bcc: string[];
subject: string;
htmlBody: string;
textBody: string;
identityId: string;
attachments: { name: string; type: string; size: number }[];
/** Original message id when this is a reply or forward */
inReplyTo?: string;
/** Free-form custom headers added by the composer or earlier handlers */
headers?: Record<string, string>;
}
/**
* Passed to onBeforeReply / onBeforeReplyAll / onBeforeForward intercept hooks.
* Return false to cancel the operation before the composer opens.
*/
export interface ReplyContext {
originalEmailId: string;
originalEmail: EmailReadView;
mode: 'reply' | 'reply-all' | 'forward';
}
/**
* Describes an attachment crossing an attachment hook (upload, download, preview).
*/
export interface AttachmentInfo {
name: string;
type: string;
size: number;
/** JMAP blob id, when known (download / preview / after-upload) */
blobId?: string;
/** The email this attachment belongs to (download / preview) */
emailId?: string;
}
/**
* Initial value passed to the onAttachmentPreview transform hook. A handler
* may return a different `previewUrl` (e.g. a proxied/sanitised URL) or a
* React component descriptor identified by `customRenderer`. Return undefined
* to pass through.
*/
export interface AttachmentPreview {
previewUrl?: string;
/** Optional plugin-supplied renderer key. The host resolves the renderer. */
customRenderer?: string;
}
/**
* Passed to onBeforeExternalLink intercept handlers when the user clicks a
* link that would navigate away from the app (typically inside an email body).
* Return false to cancel the navigation. Mutate `href` to rewrite it.
*/
export interface ExternalLinkContext {
href: string;
/** Anchor target ('_blank', '_self', etc.) when set */
target?: string;
/** Email currently in view, when the click came from an email body */
emailId?: string;
}
/**
* Passed to onTextSelectionChange observer when the user selects text inside
* the app. Source identifies which surface produced the selection so plugins
* can scope themselves (e.g. translate-on-select only inside emails).
*/
export interface SelectionContext {
text: string;
source: 'email-body' | 'composer' | 'task-detail' | 'event-detail' | 'other';
emailId?: string;
}
/**
* Returned by onCheckEventConflicts transform handlers. The form UI renders
* each warning as an inline notice next to the event time fields.
*/
export interface ConflictWarning {
/** Stable unique key per warning, used as React key */
key: string;
/** Short message — e.g. "Conflicts with: Team Standup" */
message: string;
severity?: 'info' | 'warning' | 'error';
}
/**
* Returned by onProvideSearchResults transform handlers. Plugins extend the
* initial array with their own results (CRM hits, Slack messages, etc.).
* The host renders these in a grouped section below native email results.
*/
export interface ExternalSearchResult {
/** Stable unique key */
key: string;
title: string;
snippet: string;
/** Plugin-handled action when the result row is clicked */
onClick: () => void;
/** Optional source label, e.g. "Slack", "Notion" */
source?: string;
}
/**
* Returned by onProvideRecipientSuggestions transform handlers. Lets plugins
* contribute non-contact suggestions (Slack handles, GitHub usernames, etc.)
* to the recipient autocomplete in the composer.
*/
export interface RecipientSuggestion {
name: string;
email: string;
/** Optional source label rendered as a small tag */
source?: string;
avatarUrl?: string;
}
/**
* Passed to router hooks (onNavigate, onRouteEnter, onRouteLeave).
* Paths are app-internal, e.g. "/mail/inbox", "/calendar".
*/
export interface RouteContext {
path: string;
/** Previous path (only on onNavigate) */
from?: string;
}
// ─── Plugin i18n API ─────────────────────────────────────────
/**
+82
View File
@@ -0,0 +1,82 @@
import { logger } from '@/lib/logger';
import type { UpdateStatus, UpdateSeverity } from './types';
const SEVERITIES: ReadonlySet<UpdateSeverity> = new Set([
'normal', 'security', 'deprecated', 'none', 'unknown',
]);
function isString(v: unknown): v is string {
return typeof v === 'string';
}
function isNullableString(v: unknown): v is string | null {
return v === null || typeof v === 'string';
}
// Validate the response from the version server before we trust it. Returns
// null on any malformed field so a hostile or buggy upstream can't poison the
// UI with arbitrary strings (the URL, in particular, is rendered in <a href>).
export function parseStatus(raw: unknown): UpdateStatus | null {
if (!raw || typeof raw !== 'object') return null;
const r = raw as Record<string, unknown>;
if (r.schema !== 1) return null;
if (!isString(r.current)) return null;
if (!isNullableString(r.latest)) return null;
if (typeof r.updateAvailable !== 'boolean') return null;
if (!isString(r.severity) || !SEVERITIES.has(r.severity as UpdateSeverity)) return null;
if (!isNullableString(r.url)) return null;
if (!isNullableString(r.advisory)) return null;
if (!isString(r.checkedAt)) return null;
// Only http(s) URLs are renderable; reject anything else so we don't end
// up with a javascript: link in the banner.
if (r.url && !/^https?:\/\//i.test(r.url)) return null;
return {
schema: 1,
current: r.current,
latest: r.latest,
updateAvailable: r.updateAvailable,
severity: r.severity as UpdateSeverity,
url: r.url,
advisory: r.advisory,
checkedAt: r.checkedAt,
};
}
export async function fetchStatus(
endpoint: string,
currentVersion: string,
): Promise<{ ok: true; status: UpdateStatus } | { ok: false; error: string }> {
if (!endpoint) return { ok: false, error: 'endpoint blank' };
if (!currentVersion) return { ok: false, error: 'current version blank' };
// Build the URL safely — never inject the version as a raw path component.
let url: URL;
try {
url = new URL(endpoint);
} catch {
return { ok: false, error: 'endpoint not a URL' };
}
url.searchParams.set('v', currentVersion);
try {
const res = await fetch(url, {
method: 'GET',
headers: { accept: 'application/json' },
cache: 'no-store',
signal: AbortSignal.timeout(5000),
});
if (!res.ok) {
return { ok: false, error: `HTTP ${res.status}` };
}
const body: unknown = await res.json();
const parsed = parseStatus(body);
if (!parsed) return { ok: false, error: 'malformed response' };
return { ok: true, status: parsed };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
logger.warn('version-check: fetch failed', { error: msg });
return { ok: false, error: msg };
}
}
+7
View File
@@ -0,0 +1,7 @@
export { startScheduler, stopScheduler, checkOnce } from './sender';
export { loadState, saveState, effectiveEndpoint, disabledByEnv } from './state';
export { fetchStatus, parseStatus } from './fetcher';
export type {
UpdateStatus, UpdateSeverity, VersionCheckStateFile,
} from './types';
export { DEFAULT_VERSION_ENDPOINT } from './types';
+100
View File
@@ -0,0 +1,100 @@
import { logger } from '@/lib/logger';
import { disabledByEnv, effectiveEndpoint, loadState, saveState } from './state';
import { fetchStatus } from './fetcher';
import type { UpdateStatus } from './types';
const HOUR_MS = 60 * 60 * 1000;
const JITTER_MS = 5 * 60 * 1000; // ± 5 min, keeps containers from syncing
const FIRST_DELAY_MS = 30 * 1000; // first check ~30s after boot
const FAILURE_BACKOFF_MS = 15 * 60 * 1000; // after a failed fetch, retry in 15 min
let currentTimer: NodeJS.Timeout | null = null;
function jitteredDelay(base: number): number {
const j = (Math.random() * 2 - 1) * JITTER_MS;
return Math.max(60_000, base + j);
}
function getCurrentVersion(): string {
return (process.env.NEXT_PUBLIC_APP_VERSION || '').trim();
}
export async function checkOnce(opts?: { reason?: string }): Promise<{
ok: boolean;
status?: UpdateStatus;
error?: string;
}> {
if (disabledByEnv()) return { ok: false, error: 'disabled by env' };
const state = await loadState();
const endpoint = effectiveEndpoint(state);
if (!endpoint) return { ok: false, error: 'endpoint blank' };
const current = getCurrentVersion();
if (!current) return { ok: false, error: 'current version unset' };
const now = new Date().toISOString();
const result = await fetchStatus(endpoint, current);
const next = await loadState();
next.lastCheckedAt = now;
if (result.ok) {
next.lastSuccessAt = now;
next.status = result.status;
}
await saveState(next);
logger.info('version-check: ran', {
ok: result.ok,
severity: result.ok ? result.status.severity : null,
reason: opts?.reason ?? 'scheduled',
});
if (result.ok) return { ok: true, status: result.status };
return { ok: false, error: result.error };
}
async function scheduleNext(delayMs: number): Promise<void> {
if (currentTimer) clearTimeout(currentTimer);
const at = new Date(Date.now() + delayMs).toISOString();
const state = await loadState();
state.nextScheduledAt = at;
await saveState(state);
currentTimer = setTimeout(() => { void tick(); }, delayMs);
// Don't keep the process alive just for this.
currentTimer.unref?.();
}
async function tick(): Promise<void> {
const result = await checkOnce({ reason: 'scheduled' });
const delay = result.ok ? jitteredDelay(HOUR_MS) : FAILURE_BACKOFF_MS;
await scheduleNext(delay);
}
// Idempotent — safe to call from instrumentation hot-reload in dev.
export async function startScheduler(): Promise<void> {
if (disabledByEnv()) {
logger.info('version-check: scheduler not started (disabled by env)');
return;
}
const state = await loadState();
if (!effectiveEndpoint(state)) {
logger.info('version-check: scheduler not started (no endpoint)');
return;
}
// If a previous schedule was still in the future, honor it (don't blast on
// every restart). Cap at one hour so a wildly-in-the-future timestamp can't
// permanently silence the check.
let delay = FIRST_DELAY_MS;
if (state.nextScheduledAt) {
const remaining = new Date(state.nextScheduledAt).getTime() - Date.now();
if (remaining > 0) delay = Math.min(remaining, HOUR_MS + JITTER_MS);
}
await scheduleNext(delay);
logger.info('version-check: scheduler started', { nextInMs: delay });
}
export async function stopScheduler(): Promise<void> {
if (currentTimer) clearTimeout(currentTimer);
currentTimer = null;
}
+62
View File
@@ -0,0 +1,62 @@
import { readFile, writeFile, mkdir, rename } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import path from 'node:path';
import { logger } from '@/lib/logger';
import type { VersionCheckStateFile } from './types';
import { DEFAULT_VERSION_ENDPOINT } from './types';
function getDir(): string {
return process.env.VERSION_CHECK_DATA_DIR ||
path.join(process.cwd(), 'data', 'version-check');
}
function statePath(): string { return path.join(getDir(), 'state.json'); }
const DEFAULTS: VersionCheckStateFile = {
endpoint: DEFAULT_VERSION_ENDPOINT,
lastCheckedAt: null,
lastSuccessAt: null,
nextScheduledAt: null,
status: null,
};
export async function ensureDir(): Promise<void> {
if (!existsSync(getDir())) await mkdir(getDir(), { recursive: true });
}
export async function loadState(): Promise<VersionCheckStateFile> {
await ensureDir();
try {
const raw = await readFile(statePath(), 'utf8');
const parsed = JSON.parse(raw) as Partial<VersionCheckStateFile>;
return { ...DEFAULTS, ...parsed };
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
logger.warn('version-check: state read failed', {
error: err instanceof Error ? err.message : String(err),
});
}
return { ...DEFAULTS };
}
}
export async function saveState(state: VersionCheckStateFile): Promise<void> {
await ensureDir();
const tmp = statePath() + '.tmp';
await writeFile(tmp, JSON.stringify(state, null, 2), 'utf8');
await rename(tmp, statePath());
}
export function disabledByEnv(): boolean {
const v = (process.env.BULWARK_UPDATE_CHECK ?? '').toLowerCase();
if (v === 'off' || v === 'false' || v === '0' || v === 'no') return true;
return false;
}
export function effectiveEndpoint(state: VersionCheckStateFile): string {
// Env var wins over state file so an operator can override at runtime
// without editing on-disk state. An explicit empty value disables the check.
const envUrl = process.env.BULWARK_UPDATE_CHECK_URL;
if (envUrl !== undefined) return envUrl.trim();
return state.endpoint || DEFAULT_VERSION_ENDPOINT;
}
+25
View File
@@ -0,0 +1,25 @@
// Update-status payload returned by the version server. Mirrors
// repos/dashboard/version-server/src/registry.ts LookupResult.
export type UpdateSeverity = 'normal' | 'security' | 'deprecated' | 'none' | 'unknown';
export interface UpdateStatus {
schema: 1;
current: string;
latest: string | null;
updateAvailable: boolean;
severity: UpdateSeverity;
url: string | null;
advisory: string | null;
checkedAt: string;
}
export interface VersionCheckStateFile {
endpoint: string;
lastCheckedAt: string | null;
lastSuccessAt: string | null;
nextScheduledAt: string | null;
status: UpdateStatus | null;
}
export const DEFAULT_VERSION_ENDPOINT = 'https://version.telemetry.bulwarkmail.org/';
+2
View File
@@ -494,6 +494,8 @@
"send": "Send",
"cancel": "Cancel",
"attach": "Attach",
"attach_photos": "Photos & Videos",
"attach_files": "Files",
"discard": "Discard",
"discard_draft_title": "Discard draft?",
"discard_draft_confirm": "You have unsaved changes. Do you want to discard this draft?",
+35 -35
View File
@@ -1670,41 +1670,41 @@
"edit_draft": "Editar borrador"
},
"mailbox_context_menu": {
"mark_folder_read": "Mark folder as read",
"mark_folder_tree_read": "Mark folder & subfolders as read",
"mark_all_folders_read": "Mark all folders as read",
"new_subfolder": "New subfolder...",
"new_folder": "New folder...",
"rename": "Rename...",
"empty_folder": "Empty folder",
"empty_folder_generic": "Empty folder",
"delete_folder": "Delete folder",
"refresh": "Refresh",
"mark_all_confirm_title": "Mark all folders as read",
"mark_all_confirm_message": "Mark every unread message in your personal account as read?",
"delete_confirm_title": "Delete folder",
"delete_confirm_message": "Permanently delete the folder \"{name}\"? This action cannot be undone.",
"prompt_new_subfolder": "Enter a name for the new subfolder.",
"prompt_new_folder": "Enter a name for the new folder.",
"prompt_rename": "Enter a new name for this folder.",
"toast_marked_read": "Folder marked as read",
"toast_marked_read_count": "Marked {count, plural, one {1 message} other {# messages}} as read",
"toast_already_read": "No unread messages",
"toast_marked_all_read": "All folders marked as read",
"toast_emptied": "Folder emptied",
"toast_folder_created": "Folder created",
"toast_folder_renamed": "Folder renamed",
"toast_folder_deleted": "Folder deleted",
"toast_error_mark_read": "Failed to mark as read",
"toast_error_empty": "Failed to empty folder",
"toast_error_create": "Failed to create folder",
"toast_error_rename": "Failed to rename folder",
"toast_error_delete": "Failed to delete folder",
"toast_error_delete_has_children": "Folder has subfolders. Remove them first.",
"toast_error_delete_has_email": "Folder is not empty. Empty it first.",
"placeholder_folder_name": "Folder name",
"create": "Create",
"rename_confirm": "Rename"
"mark_folder_read": "Marcar carpeta como leída",
"mark_folder_tree_read": "Marcar carpeta y subcarpetas como leídas",
"mark_all_folders_read": "Marcar todas las carpetas como leídas",
"new_subfolder": "Nueva subcarpeta...",
"new_folder": "Nueva carpeta...",
"rename": "Renombrar...",
"empty_folder": "Vaciar carpeta",
"empty_folder_generic": "Vaciar carpeta",
"delete_folder": "Eliminar carpeta",
"refresh": "Actualizar",
"mark_all_confirm_title": "Marcar todas las carpetas como leídas",
"mark_all_confirm_message": "¿Marcar todos los mensajes no leídos de su cuenta personal como leídos?",
"delete_confirm_title": "Eliminar carpeta",
"delete_confirm_message": "¿Eliminar permanentemente la carpeta \"{name}\"? Esta acción no se puede deshacer.",
"prompt_new_subfolder": "Ingrese un nombre para la nueva subcarpeta.",
"prompt_new_folder": "Ingrese un nombre para la nueva carpeta.",
"prompt_rename": "Ingrese un nuevo nombre para esta carpeta.",
"toast_marked_read": "Carpeta marcada como leída",
"toast_marked_read_count": "Se marcaron {count, plural, one {1 mensaje} other {# mensajes}} como leídos",
"toast_already_read": "No hay mensajes no leídos",
"toast_marked_all_read": "Todas las carpetas marcadas como leídas",
"toast_emptied": "Carpeta vaciada",
"toast_folder_created": "Carpeta creada",
"toast_folder_renamed": "Carpeta renombrada",
"toast_folder_deleted": "Carpeta eliminada",
"toast_error_mark_read": "Error al marcar como leído",
"toast_error_empty": "Error al vaciar la carpeta",
"toast_error_create": "Error al crear la carpeta",
"toast_error_rename": "Error al renombrar la carpeta",
"toast_error_delete": "Error al eliminar la carpeta",
"toast_error_delete_has_children": "La carpeta tiene subcarpetas. Elimínelas primero.",
"toast_error_delete_has_email": "La carpeta no está vacía. Vacíela primero.",
"placeholder_folder_name": "Nombre de carpeta",
"create": "Crear",
"rename_confirm": "Renombrar"
},
"shortcuts": {
"title": "Atajos de Teclado",
+35 -35
View File
@@ -1670,41 +1670,41 @@
"edit_draft": "Modifier le brouillon"
},
"mailbox_context_menu": {
"mark_folder_read": "Mark folder as read",
"mark_folder_tree_read": "Mark folder & subfolders as read",
"mark_all_folders_read": "Mark all folders as read",
"new_subfolder": "New subfolder...",
"new_folder": "New folder...",
"rename": "Rename...",
"empty_folder": "Empty folder",
"empty_folder_generic": "Empty folder",
"delete_folder": "Delete folder",
"refresh": "Refresh",
"mark_all_confirm_title": "Mark all folders as read",
"mark_all_confirm_message": "Mark every unread message in your personal account as read?",
"delete_confirm_title": "Delete folder",
"delete_confirm_message": "Permanently delete the folder \"{name}\"? This action cannot be undone.",
"prompt_new_subfolder": "Enter a name for the new subfolder.",
"prompt_new_folder": "Enter a name for the new folder.",
"prompt_rename": "Enter a new name for this folder.",
"toast_marked_read": "Folder marked as read",
"toast_marked_read_count": "Marked {count, plural, one {1 message} other {# messages}} as read",
"toast_already_read": "No unread messages",
"toast_marked_all_read": "All folders marked as read",
"toast_emptied": "Folder emptied",
"toast_folder_created": "Folder created",
"toast_folder_renamed": "Folder renamed",
"toast_folder_deleted": "Folder deleted",
"toast_error_mark_read": "Failed to mark as read",
"toast_error_empty": "Failed to empty folder",
"toast_error_create": "Failed to create folder",
"toast_error_rename": "Failed to rename folder",
"toast_error_delete": "Failed to delete folder",
"toast_error_delete_has_children": "Folder has subfolders. Remove them first.",
"toast_error_delete_has_email": "Folder is not empty. Empty it first.",
"placeholder_folder_name": "Folder name",
"create": "Create",
"rename_confirm": "Rename"
"mark_folder_read": "Marquer le dossier comme lu",
"mark_folder_tree_read": "Marquer le dossier et les sous-dossiers comme lus",
"mark_all_folders_read": "Marquer tous les dossiers comme lus",
"new_subfolder": "Nouveau sous-dossier...",
"new_folder": "Nouveau dossier...",
"rename": "Renommer...",
"empty_folder": "Vider le dossier",
"empty_folder_generic": "Vider le dossier",
"delete_folder": "Supprimer le dossier",
"refresh": "Actualiser",
"mark_all_confirm_title": "Marquer tous les dossiers comme lus",
"mark_all_confirm_message": "Marquer tous les messages non lus de votre compte personnel comme lus ?",
"delete_confirm_title": "Supprimer le dossier",
"delete_confirm_message": "Supprimer définitivement le dossier \"{name}\" ? Cette action est irréversible.",
"prompt_new_subfolder": "Entrez un nom pour le nouveau sous-dossier.",
"prompt_new_folder": "Entrez un nom pour le nouveau dossier.",
"prompt_rename": "Entrez un nouveau nom pour ce dossier.",
"toast_marked_read": "Dossier marqué comme lu",
"toast_marked_read_count": "{count, plural, one {1 message marqué} other {# messages marqués}} comme lu(s)",
"toast_already_read": "Aucun message non lu",
"toast_marked_all_read": "Tous les dossiers marqués comme lus",
"toast_emptied": "Dossier vidé",
"toast_folder_created": "Dossier créé",
"toast_folder_renamed": "Dossier renommé",
"toast_folder_deleted": "Dossier supprimé",
"toast_error_mark_read": "Échec du marquage comme lu",
"toast_error_empty": "Échec du vidage du dossier",
"toast_error_create": "Échec de la création du dossier",
"toast_error_rename": "Échec du renommage du dossier",
"toast_error_delete": "Échec de la suppression du dossier",
"toast_error_delete_has_children": "Le dossier contient des sous-dossiers. Supprimez-les d'abord.",
"toast_error_delete_has_email": "Le dossier n'est pas vide. Videz-le d'abord.",
"placeholder_folder_name": "Nom du dossier",
"create": "Créer",
"rename_confirm": "Renommer"
},
"shortcuts": {
"title": "Raccourcis clavier",
+35 -35
View File
@@ -1670,41 +1670,41 @@
"edit_draft": "Modifica bozza"
},
"mailbox_context_menu": {
"mark_folder_read": "Mark folder as read",
"mark_folder_tree_read": "Mark folder & subfolders as read",
"mark_all_folders_read": "Mark all folders as read",
"new_subfolder": "New subfolder...",
"new_folder": "New folder...",
"rename": "Rename...",
"empty_folder": "Empty folder",
"empty_folder_generic": "Empty folder",
"delete_folder": "Delete folder",
"refresh": "Refresh",
"mark_all_confirm_title": "Mark all folders as read",
"mark_all_confirm_message": "Mark every unread message in your personal account as read?",
"delete_confirm_title": "Delete folder",
"delete_confirm_message": "Permanently delete the folder \"{name}\"? This action cannot be undone.",
"prompt_new_subfolder": "Enter a name for the new subfolder.",
"prompt_new_folder": "Enter a name for the new folder.",
"prompt_rename": "Enter a new name for this folder.",
"toast_marked_read": "Folder marked as read",
"toast_marked_read_count": "Marked {count, plural, one {1 message} other {# messages}} as read",
"toast_already_read": "No unread messages",
"toast_marked_all_read": "All folders marked as read",
"toast_emptied": "Folder emptied",
"toast_folder_created": "Folder created",
"toast_folder_renamed": "Folder renamed",
"toast_folder_deleted": "Folder deleted",
"toast_error_mark_read": "Failed to mark as read",
"toast_error_empty": "Failed to empty folder",
"toast_error_create": "Failed to create folder",
"toast_error_rename": "Failed to rename folder",
"toast_error_delete": "Failed to delete folder",
"toast_error_delete_has_children": "Folder has subfolders. Remove them first.",
"toast_error_delete_has_email": "Folder is not empty. Empty it first.",
"placeholder_folder_name": "Folder name",
"create": "Create",
"rename_confirm": "Rename"
"mark_folder_read": "Segna cartella come letta",
"mark_folder_tree_read": "Segna cartella e sottocartelle come lette",
"mark_all_folders_read": "Segna tutte le cartelle come lette",
"new_subfolder": "Nuova sottocartella...",
"new_folder": "Nuova cartella...",
"rename": "Rinomina...",
"empty_folder": "Svuota cartella",
"empty_folder_generic": "Svuota cartella",
"delete_folder": "Elimina cartella",
"refresh": "Aggiorna",
"mark_all_confirm_title": "Segna tutte le cartelle come lette",
"mark_all_confirm_message": "Segnare tutti i messaggi non letti del tuo account personale come letti?",
"delete_confirm_title": "Elimina cartella",
"delete_confirm_message": "Eliminare definitivamente la cartella \"{name}\"? Questa azione non può essere annullata.",
"prompt_new_subfolder": "Inserisci un nome per la nuova sottocartella.",
"prompt_new_folder": "Inserisci un nome per la nuova cartella.",
"prompt_rename": "Inserisci un nuovo nome per questa cartella.",
"toast_marked_read": "Cartella segnata come letta",
"toast_marked_read_count": "Segnati {count, plural, one {1 messaggio} other {# messaggi}} come letti",
"toast_already_read": "Nessun messaggio non letto",
"toast_marked_all_read": "Tutte le cartelle segnate come lette",
"toast_emptied": "Cartella svuotata",
"toast_folder_created": "Cartella creata",
"toast_folder_renamed": "Cartella rinominata",
"toast_folder_deleted": "Cartella eliminata",
"toast_error_mark_read": "Impossibile segnare come letto",
"toast_error_empty": "Impossibile svuotare la cartella",
"toast_error_create": "Impossibile creare la cartella",
"toast_error_rename": "Impossibile rinominare la cartella",
"toast_error_delete": "Impossibile eliminare la cartella",
"toast_error_delete_has_children": "La cartella contiene sottocartelle. Rimuoverle prima.",
"toast_error_delete_has_email": "La cartella non è vuota. Svuotarla prima.",
"placeholder_folder_name": "Nome cartella",
"create": "Crea",
"rename_confirm": "Rinomina"
},
"shortcuts": {
"title": "Scorciatoie da tastiera",
+35 -35
View File
@@ -1670,41 +1670,41 @@
"edit_draft": "下書きを編集"
},
"mailbox_context_menu": {
"mark_folder_read": "Mark folder as read",
"mark_folder_tree_read": "Mark folder & subfolders as read",
"mark_all_folders_read": "Mark all folders as read",
"new_subfolder": "New subfolder...",
"new_folder": "New folder...",
"rename": "Rename...",
"empty_folder": "Empty folder",
"empty_folder_generic": "Empty folder",
"delete_folder": "Delete folder",
"refresh": "Refresh",
"mark_all_confirm_title": "Mark all folders as read",
"mark_all_confirm_message": "Mark every unread message in your personal account as read?",
"delete_confirm_title": "Delete folder",
"delete_confirm_message": "Permanently delete the folder \"{name}\"? This action cannot be undone.",
"prompt_new_subfolder": "Enter a name for the new subfolder.",
"prompt_new_folder": "Enter a name for the new folder.",
"prompt_rename": "Enter a new name for this folder.",
"toast_marked_read": "Folder marked as read",
"toast_marked_read_count": "Marked {count, plural, one {1 message} other {# messages}} as read",
"toast_already_read": "No unread messages",
"toast_marked_all_read": "All folders marked as read",
"toast_emptied": "Folder emptied",
"toast_folder_created": "Folder created",
"toast_folder_renamed": "Folder renamed",
"toast_folder_deleted": "Folder deleted",
"toast_error_mark_read": "Failed to mark as read",
"toast_error_empty": "Failed to empty folder",
"toast_error_create": "Failed to create folder",
"toast_error_rename": "Failed to rename folder",
"toast_error_delete": "Failed to delete folder",
"toast_error_delete_has_children": "Folder has subfolders. Remove them first.",
"toast_error_delete_has_email": "Folder is not empty. Empty it first.",
"placeholder_folder_name": "Folder name",
"create": "Create",
"rename_confirm": "Rename"
"mark_folder_read": "フォルダーを既読にする",
"mark_folder_tree_read": "フォルダーとサブフォルダーを既読にする",
"mark_all_folders_read": "すべてのフォルダーを既読にする",
"new_subfolder": "新しいサブフォルダー...",
"new_folder": "新しいフォルダー...",
"rename": "名前を変更...",
"empty_folder": "フォルダーを空にする",
"empty_folder_generic": "フォルダーを空にする",
"delete_folder": "フォルダーを削除",
"refresh": "更新",
"mark_all_confirm_title": "すべてのフォルダーを既読にする",
"mark_all_confirm_message": "個人アカウントのすべての未読メッセージを既読にしますか?",
"delete_confirm_title": "フォルダーを削除",
"delete_confirm_message": "フォルダー \"{name}\" を完全に削除しますか?この操作は元に戻せません。",
"prompt_new_subfolder": "新しいサブフォルダーの名前を入力してください。",
"prompt_new_folder": "新しいフォルダーの名前を入力してください。",
"prompt_rename": "このフォルダーの新しい名前を入力してください。",
"toast_marked_read": "フォルダーを既読にしました",
"toast_marked_read_count": "{count, plural, one {1件のメッセージ} other {#件のメッセージ}}を既読にしました",
"toast_already_read": "未読メッセージはありません",
"toast_marked_all_read": "すべてのフォルダーを既読にしました",
"toast_emptied": "フォルダーを空にしました",
"toast_folder_created": "フォルダーを作成しました",
"toast_folder_renamed": "フォルダー名を変更しました",
"toast_folder_deleted": "フォルダーを削除しました",
"toast_error_mark_read": "既読にできませんでした",
"toast_error_empty": "フォルダーを空にできませんでした",
"toast_error_create": "フォルダーを作成できませんでした",
"toast_error_rename": "フォルダー名を変更できませんでした",
"toast_error_delete": "フォルダーを削除できませんでした",
"toast_error_delete_has_children": "フォルダーにサブフォルダーがあります。先に削除してください。",
"toast_error_delete_has_email": "フォルダーが空ではありません。先に空にしてください。",
"placeholder_folder_name": "フォルダー名",
"create": "作成",
"rename_confirm": "名前を変更"
},
"shortcuts": {
"title": "キーボードショートカット",
+35 -35
View File
@@ -1670,41 +1670,41 @@
"edit_draft": "임시보관 메일 수정"
},
"mailbox_context_menu": {
"mark_folder_read": "Mark folder as read",
"mark_folder_tree_read": "Mark folder & subfolders as read",
"mark_all_folders_read": "Mark all folders as read",
"new_subfolder": "New subfolder...",
"new_folder": "New folder...",
"rename": "Rename...",
"empty_folder": "Empty folder",
"empty_folder_generic": "Empty folder",
"delete_folder": "Delete folder",
"refresh": "Refresh",
"mark_all_confirm_title": "Mark all folders as read",
"mark_all_confirm_message": "Mark every unread message in your personal account as read?",
"delete_confirm_title": "Delete folder",
"delete_confirm_message": "Permanently delete the folder \"{name}\"? This action cannot be undone.",
"prompt_new_subfolder": "Enter a name for the new subfolder.",
"prompt_new_folder": "Enter a name for the new folder.",
"prompt_rename": "Enter a new name for this folder.",
"toast_marked_read": "Folder marked as read",
"toast_marked_read_count": "Marked {count, plural, one {1 message} other {# messages}} as read",
"toast_already_read": "No unread messages",
"toast_marked_all_read": "All folders marked as read",
"toast_emptied": "Folder emptied",
"toast_folder_created": "Folder created",
"toast_folder_renamed": "Folder renamed",
"toast_folder_deleted": "Folder deleted",
"toast_error_mark_read": "Failed to mark as read",
"toast_error_empty": "Failed to empty folder",
"toast_error_create": "Failed to create folder",
"toast_error_rename": "Failed to rename folder",
"toast_error_delete": "Failed to delete folder",
"toast_error_delete_has_children": "Folder has subfolders. Remove them first.",
"toast_error_delete_has_email": "Folder is not empty. Empty it first.",
"placeholder_folder_name": "Folder name",
"create": "Create",
"rename_confirm": "Rename"
"mark_folder_read": "폴더를 읽음으로 표시",
"mark_folder_tree_read": "폴더 및 하위 폴더를 읽음으로 표시",
"mark_all_folders_read": "모든 폴더를 읽음으로 표시",
"new_subfolder": "새 하위 폴더...",
"new_folder": "새 폴더...",
"rename": "이름 바꾸기...",
"empty_folder": "폴더 비우기",
"empty_folder_generic": "폴더 비우기",
"delete_folder": "폴더 삭제",
"refresh": "새로 고침",
"mark_all_confirm_title": "모든 폴더를 읽음으로 표시",
"mark_all_confirm_message": "개인 계정의 모든 읽지 않은 메시지를 읽음으로 표시하시겠습니까?",
"delete_confirm_title": "폴더 삭제",
"delete_confirm_message": "폴더 \"{name}\"을(를) 영구적으로 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.",
"prompt_new_subfolder": "새 하위 폴더의 이름을 입력하세요.",
"prompt_new_folder": "새 폴더의 이름을 입력하세요.",
"prompt_rename": "이 폴더의 새 이름을 입력하세요.",
"toast_marked_read": "폴더를 읽음으로 표시했습니다",
"toast_marked_read_count": "{count, plural, one {메시지 1개} other {메시지 #개}}을(를) 읽음으로 표시했습니다",
"toast_already_read": "읽지 않은 메시지가 없습니다",
"toast_marked_all_read": "모든 폴더를 읽음으로 표시했습니다",
"toast_emptied": "폴더를 비웠습니다",
"toast_folder_created": "폴더가 생성되었습니다",
"toast_folder_renamed": "폴더 이름이 변경되었습니다",
"toast_folder_deleted": "폴더가 삭제되었습니다",
"toast_error_mark_read": "읽음으로 표시하지 못했습니다",
"toast_error_empty": "폴더를 비우지 못했습니다",
"toast_error_create": "폴더를 생성하지 못했습니다",
"toast_error_rename": "폴더 이름을 변경하지 못했습니다",
"toast_error_delete": "폴더를 삭제하지 못했습니다",
"toast_error_delete_has_children": "폴더에 하위 폴더가 있습니다. 먼저 제거하세요.",
"toast_error_delete_has_email": "폴더가 비어 있지 않습니다. 먼저 비우세요.",
"placeholder_folder_name": "폴더 이름",
"create": "만들기",
"rename_confirm": "이름 바꾸기"
},
"shortcuts": {
"title": "단축키",
+35 -35
View File
@@ -1670,41 +1670,41 @@
"edit_draft": "Rediģēt melnrakstu"
},
"mailbox_context_menu": {
"mark_folder_read": "Mark folder as read",
"mark_folder_tree_read": "Mark folder & subfolders as read",
"mark_all_folders_read": "Mark all folders as read",
"new_subfolder": "New subfolder...",
"new_folder": "New folder...",
"rename": "Rename...",
"empty_folder": "Empty folder",
"empty_folder_generic": "Empty folder",
"delete_folder": "Delete folder",
"refresh": "Refresh",
"mark_all_confirm_title": "Mark all folders as read",
"mark_all_confirm_message": "Mark every unread message in your personal account as read?",
"delete_confirm_title": "Delete folder",
"delete_confirm_message": "Permanently delete the folder \"{name}\"? This action cannot be undone.",
"prompt_new_subfolder": "Enter a name for the new subfolder.",
"prompt_new_folder": "Enter a name for the new folder.",
"prompt_rename": "Enter a new name for this folder.",
"toast_marked_read": "Folder marked as read",
"toast_marked_read_count": "Marked {count, plural, one {1 message} other {# messages}} as read",
"toast_already_read": "No unread messages",
"toast_marked_all_read": "All folders marked as read",
"toast_emptied": "Folder emptied",
"toast_folder_created": "Folder created",
"toast_folder_renamed": "Folder renamed",
"toast_folder_deleted": "Folder deleted",
"toast_error_mark_read": "Failed to mark as read",
"toast_error_empty": "Failed to empty folder",
"toast_error_create": "Failed to create folder",
"toast_error_rename": "Failed to rename folder",
"toast_error_delete": "Failed to delete folder",
"toast_error_delete_has_children": "Folder has subfolders. Remove them first.",
"toast_error_delete_has_email": "Folder is not empty. Empty it first.",
"placeholder_folder_name": "Folder name",
"create": "Create",
"rename_confirm": "Rename"
"mark_folder_read": "Atzīmēt mapi kā lasītu",
"mark_folder_tree_read": "Atzīmēt mapi un apakšmapes kā lasītas",
"mark_all_folders_read": "Atzīmēt visas mapes kā lasītas",
"new_subfolder": "Jauna apakšmape...",
"new_folder": "Jauna mape...",
"rename": "Pārsaukt...",
"empty_folder": "Iztukšot mapi",
"empty_folder_generic": "Iztukšot mapi",
"delete_folder": "Dzēst mapi",
"refresh": "Atjaunināt",
"mark_all_confirm_title": "Atzīmēt visas mapes kā lasītas",
"mark_all_confirm_message": "Atzīmēt visas nelasītās ziņas jūsu personīgajā kontā kā lasītas?",
"delete_confirm_title": "Dzēst mapi",
"delete_confirm_message": "Neatgriezeniski dzēst mapi \"{name}\"? Šo darbību nevar atsaukt.",
"prompt_new_subfolder": "Ievadiet nosaukumu jaunajai apakšmapei.",
"prompt_new_folder": "Ievadiet nosaukumu jaunajai mapei.",
"prompt_rename": "Ievadiet jaunu nosaukumu šai mapei.",
"toast_marked_read": "Mape atzīmēta kā lasīta",
"toast_marked_read_count": "{count, plural, one {1 ziņa atzīmēta} other {# ziņas atzīmētas}} kā lasītas",
"toast_already_read": "Nav nelasītu ziņu",
"toast_marked_all_read": "Visas mapes atzīmētas kā lasītas",
"toast_emptied": "Mape iztukšota",
"toast_folder_created": "Mape izveidota",
"toast_folder_renamed": "Mape pārsaukta",
"toast_folder_deleted": "Mape dzēsta",
"toast_error_mark_read": "Neizdevās atzīmēt kā lasītu",
"toast_error_empty": "Neizdevās iztukšot mapi",
"toast_error_create": "Neizdevās izveidot mapi",
"toast_error_rename": "Neizdevās pārsaukt mapi",
"toast_error_delete": "Neizdevās dzēst mapi",
"toast_error_delete_has_children": "Mapei ir apakšmapes. Vispirms noņemiet tās.",
"toast_error_delete_has_email": "Mape nav tukša. Vispirms iztukšojiet to.",
"placeholder_folder_name": "Mapes nosaukums",
"create": "Izveidot",
"rename_confirm": "Pārsaukt"
},
"shortcuts": {
"title": "Īsinājumtaustiņi",
+35 -35
View File
@@ -1670,41 +1670,41 @@
"edit_draft": "Concept bewerken"
},
"mailbox_context_menu": {
"mark_folder_read": "Mark folder as read",
"mark_folder_tree_read": "Mark folder & subfolders as read",
"mark_all_folders_read": "Mark all folders as read",
"new_subfolder": "New subfolder...",
"new_folder": "New folder...",
"rename": "Rename...",
"empty_folder": "Empty folder",
"empty_folder_generic": "Empty folder",
"delete_folder": "Delete folder",
"refresh": "Refresh",
"mark_all_confirm_title": "Mark all folders as read",
"mark_all_confirm_message": "Mark every unread message in your personal account as read?",
"delete_confirm_title": "Delete folder",
"delete_confirm_message": "Permanently delete the folder \"{name}\"? This action cannot be undone.",
"prompt_new_subfolder": "Enter a name for the new subfolder.",
"prompt_new_folder": "Enter a name for the new folder.",
"prompt_rename": "Enter a new name for this folder.",
"toast_marked_read": "Folder marked as read",
"toast_marked_read_count": "Marked {count, plural, one {1 message} other {# messages}} as read",
"toast_already_read": "No unread messages",
"toast_marked_all_read": "All folders marked as read",
"toast_emptied": "Folder emptied",
"toast_folder_created": "Folder created",
"toast_folder_renamed": "Folder renamed",
"toast_folder_deleted": "Folder deleted",
"toast_error_mark_read": "Failed to mark as read",
"toast_error_empty": "Failed to empty folder",
"toast_error_create": "Failed to create folder",
"toast_error_rename": "Failed to rename folder",
"toast_error_delete": "Failed to delete folder",
"toast_error_delete_has_children": "Folder has subfolders. Remove them first.",
"toast_error_delete_has_email": "Folder is not empty. Empty it first.",
"placeholder_folder_name": "Folder name",
"create": "Create",
"rename_confirm": "Rename"
"mark_folder_read": "Map markeren als gelezen",
"mark_folder_tree_read": "Map en submappen markeren als gelezen",
"mark_all_folders_read": "Alle mappen markeren als gelezen",
"new_subfolder": "Nieuwe submap...",
"new_folder": "Nieuwe map...",
"rename": "Hernoemen...",
"empty_folder": "Map leegmaken",
"empty_folder_generic": "Map leegmaken",
"delete_folder": "Map verwijderen",
"refresh": "Vernieuwen",
"mark_all_confirm_title": "Alle mappen markeren als gelezen",
"mark_all_confirm_message": "Alle ongelezen berichten in uw persoonlijke account als gelezen markeren?",
"delete_confirm_title": "Map verwijderen",
"delete_confirm_message": "Map \"{name}\" definitief verwijderen? Deze actie kan niet ongedaan worden gemaakt.",
"prompt_new_subfolder": "Voer een naam in voor de nieuwe submap.",
"prompt_new_folder": "Voer een naam in voor de nieuwe map.",
"prompt_rename": "Voer een nieuwe naam in voor deze map.",
"toast_marked_read": "Map gemarkeerd als gelezen",
"toast_marked_read_count": "{count, plural, one {1 bericht} other {# berichten}} gemarkeerd als gelezen",
"toast_already_read": "Geen ongelezen berichten",
"toast_marked_all_read": "Alle mappen gemarkeerd als gelezen",
"toast_emptied": "Map leeggemaakt",
"toast_folder_created": "Map aangemaakt",
"toast_folder_renamed": "Map hernoemd",
"toast_folder_deleted": "Map verwijderd",
"toast_error_mark_read": "Markeren als gelezen mislukt",
"toast_error_empty": "Map leegmaken mislukt",
"toast_error_create": "Map aanmaken mislukt",
"toast_error_rename": "Map hernoemen mislukt",
"toast_error_delete": "Map verwijderen mislukt",
"toast_error_delete_has_children": "Map bevat submappen. Verwijder deze eerst.",
"toast_error_delete_has_email": "Map is niet leeg. Maak deze eerst leeg.",
"placeholder_folder_name": "Mapnaam",
"create": "Aanmaken",
"rename_confirm": "Hernoemen"
},
"shortcuts": {
"title": "Sneltoetsen",
+35 -35
View File
@@ -1670,41 +1670,41 @@
"edit_draft": "Edytuj szkic"
},
"mailbox_context_menu": {
"mark_folder_read": "Mark folder as read",
"mark_folder_tree_read": "Mark folder & subfolders as read",
"mark_all_folders_read": "Mark all folders as read",
"new_subfolder": "New subfolder...",
"new_folder": "New folder...",
"rename": "Rename...",
"empty_folder": "Empty folder",
"empty_folder_generic": "Empty folder",
"delete_folder": "Delete folder",
"refresh": "Refresh",
"mark_all_confirm_title": "Mark all folders as read",
"mark_all_confirm_message": "Mark every unread message in your personal account as read?",
"delete_confirm_title": "Delete folder",
"delete_confirm_message": "Permanently delete the folder \"{name}\"? This action cannot be undone.",
"prompt_new_subfolder": "Enter a name for the new subfolder.",
"prompt_new_folder": "Enter a name for the new folder.",
"prompt_rename": "Enter a new name for this folder.",
"toast_marked_read": "Folder marked as read",
"toast_marked_read_count": "Marked {count, plural, one {1 message} other {# messages}} as read",
"toast_already_read": "No unread messages",
"toast_marked_all_read": "All folders marked as read",
"toast_emptied": "Folder emptied",
"toast_folder_created": "Folder created",
"toast_folder_renamed": "Folder renamed",
"toast_folder_deleted": "Folder deleted",
"toast_error_mark_read": "Failed to mark as read",
"toast_error_empty": "Failed to empty folder",
"toast_error_create": "Failed to create folder",
"toast_error_rename": "Failed to rename folder",
"toast_error_delete": "Failed to delete folder",
"toast_error_delete_has_children": "Folder has subfolders. Remove them first.",
"toast_error_delete_has_email": "Folder is not empty. Empty it first.",
"placeholder_folder_name": "Folder name",
"create": "Create",
"rename_confirm": "Rename"
"mark_folder_read": "Oznacz folder jako przeczytany",
"mark_folder_tree_read": "Oznacz folder i podfoldery jako przeczytane",
"mark_all_folders_read": "Oznacz wszystkie foldery jako przeczytane",
"new_subfolder": "Nowy podfolder...",
"new_folder": "Nowy folder...",
"rename": "Zmień nazwę...",
"empty_folder": "Opróżnij folder",
"empty_folder_generic": "Opróżnij folder",
"delete_folder": "Usuń folder",
"refresh": "Odśwież",
"mark_all_confirm_title": "Oznacz wszystkie foldery jako przeczytane",
"mark_all_confirm_message": "Oznaczyć wszystkie nieprzeczytane wiadomości na koncie osobistym jako przeczytane?",
"delete_confirm_title": "Usuń folder",
"delete_confirm_message": "Trwale usunąć folder \"{name}\"? Tej operacji nie można cofnąć.",
"prompt_new_subfolder": "Podaj nazwę nowego podfolderu.",
"prompt_new_folder": "Podaj nazwę nowego folderu.",
"prompt_rename": "Podaj nową nazwę tego folderu.",
"toast_marked_read": "Folder oznaczony jako przeczytany",
"toast_marked_read_count": "Oznaczono {count, plural, one {1 wiadomość} other {# wiadomości}} jako przeczytane",
"toast_already_read": "Brak nieprzeczytanych wiadomości",
"toast_marked_all_read": "Wszystkie foldery oznaczone jako przeczytane",
"toast_emptied": "Folder opróżniony",
"toast_folder_created": "Folder utworzony",
"toast_folder_renamed": "Nazwa folderu zmieniona",
"toast_folder_deleted": "Folder usunięty",
"toast_error_mark_read": "Nie udało się oznaczyć jako przeczytane",
"toast_error_empty": "Nie udało się opróżnić folderu",
"toast_error_create": "Nie udało się utworzyć folderu",
"toast_error_rename": "Nie udało się zmienić nazwy folderu",
"toast_error_delete": "Nie udało się usunąć folderu",
"toast_error_delete_has_children": "Folder zawiera podfoldery. Najpierw je usuń.",
"toast_error_delete_has_email": "Folder nie jest pusty. Najpierw go opróżnij.",
"placeholder_folder_name": "Nazwa folderu",
"create": "Utwórz",
"rename_confirm": "Zmień nazwę"
},
"shortcuts": {
"title": "Skróty klawiszowe",
+35 -35
View File
@@ -1670,41 +1670,41 @@
"edit_draft": "Editar rascunho"
},
"mailbox_context_menu": {
"mark_folder_read": "Mark folder as read",
"mark_folder_tree_read": "Mark folder & subfolders as read",
"mark_all_folders_read": "Mark all folders as read",
"new_subfolder": "New subfolder...",
"new_folder": "New folder...",
"rename": "Rename...",
"empty_folder": "Empty folder",
"empty_folder_generic": "Empty folder",
"delete_folder": "Delete folder",
"refresh": "Refresh",
"mark_all_confirm_title": "Mark all folders as read",
"mark_all_confirm_message": "Mark every unread message in your personal account as read?",
"delete_confirm_title": "Delete folder",
"delete_confirm_message": "Permanently delete the folder \"{name}\"? This action cannot be undone.",
"prompt_new_subfolder": "Enter a name for the new subfolder.",
"prompt_new_folder": "Enter a name for the new folder.",
"prompt_rename": "Enter a new name for this folder.",
"toast_marked_read": "Folder marked as read",
"toast_marked_read_count": "Marked {count, plural, one {1 message} other {# messages}} as read",
"toast_already_read": "No unread messages",
"toast_marked_all_read": "All folders marked as read",
"toast_emptied": "Folder emptied",
"toast_folder_created": "Folder created",
"toast_folder_renamed": "Folder renamed",
"toast_folder_deleted": "Folder deleted",
"toast_error_mark_read": "Failed to mark as read",
"toast_error_empty": "Failed to empty folder",
"toast_error_create": "Failed to create folder",
"toast_error_rename": "Failed to rename folder",
"toast_error_delete": "Failed to delete folder",
"toast_error_delete_has_children": "Folder has subfolders. Remove them first.",
"toast_error_delete_has_email": "Folder is not empty. Empty it first.",
"placeholder_folder_name": "Folder name",
"create": "Create",
"rename_confirm": "Rename"
"mark_folder_read": "Marcar pasta como lida",
"mark_folder_tree_read": "Marcar pasta e subpastas como lidas",
"mark_all_folders_read": "Marcar todas as pastas como lidas",
"new_subfolder": "Nova subpasta...",
"new_folder": "Nova pasta...",
"rename": "Renomear...",
"empty_folder": "Esvaziar pasta",
"empty_folder_generic": "Esvaziar pasta",
"delete_folder": "Excluir pasta",
"refresh": "Atualizar",
"mark_all_confirm_title": "Marcar todas as pastas como lidas",
"mark_all_confirm_message": "Marcar todas as mensagens não lidas da sua conta pessoal como lidas?",
"delete_confirm_title": "Excluir pasta",
"delete_confirm_message": "Excluir permanentemente a pasta \"{name}\"? Esta ação não pode ser desfeita.",
"prompt_new_subfolder": "Digite um nome para a nova subpasta.",
"prompt_new_folder": "Digite um nome para a nova pasta.",
"prompt_rename": "Digite um novo nome para esta pasta.",
"toast_marked_read": "Pasta marcada como lida",
"toast_marked_read_count": "{count, plural, one {1 mensagem marcada como lida} other {# mensagens marcadas como lidas}}",
"toast_already_read": "Nenhuma mensagem não lida",
"toast_marked_all_read": "Todas as pastas marcadas como lidas",
"toast_emptied": "Pasta esvaziada",
"toast_folder_created": "Pasta criada",
"toast_folder_renamed": "Pasta renomeada",
"toast_folder_deleted": "Pasta excluída",
"toast_error_mark_read": "Falha ao marcar como lida",
"toast_error_empty": "Falha ao esvaziar a pasta",
"toast_error_create": "Falha ao criar a pasta",
"toast_error_rename": "Falha ao renomear a pasta",
"toast_error_delete": "Falha ao excluir a pasta",
"toast_error_delete_has_children": "A pasta contém subpastas. Remova-as primeiro.",
"toast_error_delete_has_email": "A pasta não está vazia. Esvazie-a primeiro.",
"placeholder_folder_name": "Nome da pasta",
"create": "Criar",
"rename_confirm": "Renomear"
},
"shortcuts": {
"title": "Atalhos de Teclado",
+35 -35
View File
@@ -1670,41 +1670,41 @@
"edit_draft": "Редактировать черновик"
},
"mailbox_context_menu": {
"mark_folder_read": "Mark folder as read",
"mark_folder_tree_read": "Mark folder & subfolders as read",
"mark_all_folders_read": "Mark all folders as read",
"new_subfolder": "New subfolder...",
"new_folder": "New folder...",
"rename": "Rename...",
"empty_folder": "Empty folder",
"empty_folder_generic": "Empty folder",
"delete_folder": "Delete folder",
"refresh": "Refresh",
"mark_all_confirm_title": "Mark all folders as read",
"mark_all_confirm_message": "Mark every unread message in your personal account as read?",
"delete_confirm_title": "Delete folder",
"delete_confirm_message": "Permanently delete the folder \"{name}\"? This action cannot be undone.",
"prompt_new_subfolder": "Enter a name for the new subfolder.",
"prompt_new_folder": "Enter a name for the new folder.",
"prompt_rename": "Enter a new name for this folder.",
"toast_marked_read": "Folder marked as read",
"toast_marked_read_count": "Marked {count, plural, one {1 message} other {# messages}} as read",
"toast_already_read": "No unread messages",
"toast_marked_all_read": "All folders marked as read",
"toast_emptied": "Folder emptied",
"toast_folder_created": "Folder created",
"toast_folder_renamed": "Folder renamed",
"toast_folder_deleted": "Folder deleted",
"toast_error_mark_read": "Failed to mark as read",
"toast_error_empty": "Failed to empty folder",
"toast_error_create": "Failed to create folder",
"toast_error_rename": "Failed to rename folder",
"toast_error_delete": "Failed to delete folder",
"toast_error_delete_has_children": "Folder has subfolders. Remove them first.",
"toast_error_delete_has_email": "Folder is not empty. Empty it first.",
"placeholder_folder_name": "Folder name",
"create": "Create",
"rename_confirm": "Rename"
"mark_folder_read": "Отметить папку как прочитанную",
"mark_folder_tree_read": "Отметить папку и вложенные папки как прочитанные",
"mark_all_folders_read": "Отметить все папки как прочитанные",
"new_subfolder": "Новая вложенная папка...",
"new_folder": "Новая папка...",
"rename": "Переименовать...",
"empty_folder": "Очистить папку",
"empty_folder_generic": "Очистить папку",
"delete_folder": "Удалить папку",
"refresh": "Обновить",
"mark_all_confirm_title": "Отметить все папки как прочитанные",
"mark_all_confirm_message": "Отметить все непрочитанные сообщения в вашем личном аккаунте как прочитанные?",
"delete_confirm_title": "Удалить папку",
"delete_confirm_message": "Безвозвратно удалить папку \"{name}\"? Это действие нельзя отменить.",
"prompt_new_subfolder": "Введите имя для новой вложенной папки.",
"prompt_new_folder": "Введите имя для новой папки.",
"prompt_rename": "Введите новое имя для этой папки.",
"toast_marked_read": "Папка отмечена как прочитанная",
"toast_marked_read_count": "Отмечено {count, plural, one {1 сообщение} few {# сообщения} other {# сообщений}} как прочитанные",
"toast_already_read": "Нет непрочитанных сообщений",
"toast_marked_all_read": "Все папки отмечены как прочитанные",
"toast_emptied": "Папка очищена",
"toast_folder_created": "Папка создана",
"toast_folder_renamed": "Папка переименована",
"toast_folder_deleted": "Папка удалена",
"toast_error_mark_read": "Не удалось отметить как прочитанное",
"toast_error_empty": "Не удалось очистить папку",
"toast_error_create": "Не удалось создать папку",
"toast_error_rename": "Не удалось переименовать папку",
"toast_error_delete": "Не удалось удалить папку",
"toast_error_delete_has_children": "Папка содержит вложенные папки. Сначала удалите их.",
"toast_error_delete_has_email": "Папка не пуста. Сначала очистите её.",
"placeholder_folder_name": "Имя папки",
"create": "Создать",
"rename_confirm": "Переименовать"
},
"shortcuts": {
"title": "Сочетания клавиш",
+35 -35
View File
@@ -1670,41 +1670,41 @@
"edit_draft": "Редагувати чернетку"
},
"mailbox_context_menu": {
"mark_folder_read": "Mark folder as read",
"mark_folder_tree_read": "Mark folder & subfolders as read",
"mark_all_folders_read": "Mark all folders as read",
"new_subfolder": "New subfolder...",
"new_folder": "New folder...",
"rename": "Rename...",
"empty_folder": "Empty folder",
"empty_folder_generic": "Empty folder",
"delete_folder": "Delete folder",
"refresh": "Refresh",
"mark_all_confirm_title": "Mark all folders as read",
"mark_all_confirm_message": "Mark every unread message in your personal account as read?",
"delete_confirm_title": "Delete folder",
"delete_confirm_message": "Permanently delete the folder \"{name}\"? This action cannot be undone.",
"prompt_new_subfolder": "Enter a name for the new subfolder.",
"prompt_new_folder": "Enter a name for the new folder.",
"prompt_rename": "Enter a new name for this folder.",
"toast_marked_read": "Folder marked as read",
"toast_marked_read_count": "Marked {count, plural, one {1 message} other {# messages}} as read",
"toast_already_read": "No unread messages",
"toast_marked_all_read": "All folders marked as read",
"toast_emptied": "Folder emptied",
"toast_folder_created": "Folder created",
"toast_folder_renamed": "Folder renamed",
"toast_folder_deleted": "Folder deleted",
"toast_error_mark_read": "Failed to mark as read",
"toast_error_empty": "Failed to empty folder",
"toast_error_create": "Failed to create folder",
"toast_error_rename": "Failed to rename folder",
"toast_error_delete": "Failed to delete folder",
"toast_error_delete_has_children": "Folder has subfolders. Remove them first.",
"toast_error_delete_has_email": "Folder is not empty. Empty it first.",
"placeholder_folder_name": "Folder name",
"create": "Create",
"rename_confirm": "Rename"
"mark_folder_read": "Позначити папку як прочитану",
"mark_folder_tree_read": "Позначити папку та вкладені папки як прочитані",
"mark_all_folders_read": "Позначити всі папки як прочитані",
"new_subfolder": "Нова вкладена папка...",
"new_folder": "Нова папка...",
"rename": "Перейменувати...",
"empty_folder": "Очистити папку",
"empty_folder_generic": "Очистити папку",
"delete_folder": "Видалити папку",
"refresh": "Оновити",
"mark_all_confirm_title": "Позначити всі папки як прочитані",
"mark_all_confirm_message": "Позначити всі непрочитані повідомлення у вашому особистому акаунті як прочитані?",
"delete_confirm_title": "Видалити папку",
"delete_confirm_message": "Остаточно видалити папку \"{name}\"? Цю дію неможливо скасувати.",
"prompt_new_subfolder": "Введіть ім'я для нової вкладеної папки.",
"prompt_new_folder": "Введіть ім'я для нової папки.",
"prompt_rename": "Введіть нове ім'я для цієї папки.",
"toast_marked_read": "Папку позначено як прочитану",
"toast_marked_read_count": "Позначено {count, plural, one {1 повідомлення} few {# повідомлення} other {# повідомлень}} як прочитані",
"toast_already_read": "Немає непрочитаних повідомлень",
"toast_marked_all_read": "Всі папки позначені як прочитані",
"toast_emptied": "Папку очищено",
"toast_folder_created": "Папку створено",
"toast_folder_renamed": "Папку перейменовано",
"toast_folder_deleted": "Папку видалено",
"toast_error_mark_read": "Не вдалося позначити як прочитане",
"toast_error_empty": "Не вдалося очистити папку",
"toast_error_create": "Не вдалося створити папку",
"toast_error_rename": "Не вдалося перейменувати папку",
"toast_error_delete": "Не вдалося видалити папку",
"toast_error_delete_has_children": "Папка містить вкладені папки. Спочатку видаліть їх.",
"toast_error_delete_has_email": "Папка не порожня. Спочатку очистіть її.",
"placeholder_folder_name": "Ім'я папки",
"create": "Створити",
"rename_confirm": "Перейменувати"
},
"shortcuts": {
"title": "Комбінації клавіш",
+35 -35
View File
@@ -1670,41 +1670,41 @@
"edit_draft": "编辑草稿"
},
"mailbox_context_menu": {
"mark_folder_read": "Mark folder as read",
"mark_folder_tree_read": "Mark folder & subfolders as read",
"mark_all_folders_read": "Mark all folders as read",
"new_subfolder": "New subfolder...",
"new_folder": "New folder...",
"rename": "Rename...",
"empty_folder": "Empty folder",
"empty_folder_generic": "Empty folder",
"delete_folder": "Delete folder",
"refresh": "Refresh",
"mark_all_confirm_title": "Mark all folders as read",
"mark_all_confirm_message": "Mark every unread message in your personal account as read?",
"delete_confirm_title": "Delete folder",
"delete_confirm_message": "Permanently delete the folder \"{name}\"? This action cannot be undone.",
"prompt_new_subfolder": "Enter a name for the new subfolder.",
"prompt_new_folder": "Enter a name for the new folder.",
"prompt_rename": "Enter a new name for this folder.",
"toast_marked_read": "Folder marked as read",
"toast_marked_read_count": "Marked {count, plural, one {1 message} other {# messages}} as read",
"toast_already_read": "No unread messages",
"toast_marked_all_read": "All folders marked as read",
"toast_emptied": "Folder emptied",
"toast_folder_created": "Folder created",
"toast_folder_renamed": "Folder renamed",
"toast_folder_deleted": "Folder deleted",
"toast_error_mark_read": "Failed to mark as read",
"toast_error_empty": "Failed to empty folder",
"toast_error_create": "Failed to create folder",
"toast_error_rename": "Failed to rename folder",
"toast_error_delete": "Failed to delete folder",
"toast_error_delete_has_children": "Folder has subfolders. Remove them first.",
"toast_error_delete_has_email": "Folder is not empty. Empty it first.",
"placeholder_folder_name": "Folder name",
"create": "Create",
"rename_confirm": "Rename"
"mark_folder_read": "将文件夹标记为已读",
"mark_folder_tree_read": "将文件夹及子文件夹标记为已读",
"mark_all_folders_read": "将所有文件夹标记为已读",
"new_subfolder": "新建子文件夹...",
"new_folder": "新建文件夹...",
"rename": "重命名...",
"empty_folder": "清空文件夹",
"empty_folder_generic": "清空文件夹",
"delete_folder": "删除文件夹",
"refresh": "刷新",
"mark_all_confirm_title": "将所有文件夹标记为已读",
"mark_all_confirm_message": "将您个人账户中的所有未读邮件标记为已读?",
"delete_confirm_title": "删除文件夹",
"delete_confirm_message": "永久删除文件夹 \"{name}\"?此操作无法撤销。",
"prompt_new_subfolder": "请输入新子文件夹的名称。",
"prompt_new_folder": "请输入新文件夹的名称。",
"prompt_rename": "请输入此文件夹的新名称。",
"toast_marked_read": "文件夹已标记为已读",
"toast_marked_read_count": "已将 {count, plural, one {1 封邮件} other {# 封邮件}} 标记为已读",
"toast_already_read": "没有未读邮件",
"toast_marked_all_read": "所有文件夹已标记为已读",
"toast_emptied": "文件夹已清空",
"toast_folder_created": "文件夹已创建",
"toast_folder_renamed": "文件夹已重命名",
"toast_folder_deleted": "文件夹已删除",
"toast_error_mark_read": "标记为已读失败",
"toast_error_empty": "清空文件夹失败",
"toast_error_create": "创建文件夹失败",
"toast_error_rename": "重命名文件夹失败",
"toast_error_delete": "删除文件夹失败",
"toast_error_delete_has_children": "文件夹包含子文件夹,请先将其删除。",
"toast_error_delete_has_email": "文件夹不为空,请先清空它。",
"placeholder_folder_name": "文件夹名称",
"create": "创建",
"rename_confirm": "重命名"
},
"shortcuts": {
"title": "键盘快捷键",
+14 -5
View File
@@ -4,11 +4,20 @@ import { execSync } from "child_process";
import { readFileSync } from "fs";
import { join } from "path";
let gitCommitHash = "unknown";
try {
gitCommitHash = execSync("git rev-parse --short HEAD").toString().trim();
} catch {
// git not available
// Prefer an explicit build arg (passed in by CI / Docker, where .git is
// excluded from the build context) and fall back to `git rev-parse` for
// local builds.
let gitCommitHash = process.env.GIT_COMMIT?.trim() || "";
if (!gitCommitHash) {
try {
gitCommitHash = execSync("git rev-parse --short HEAD").toString().trim();
} catch {
gitCommitHash = "unknown";
}
}
// Normalise full 40-char SHAs (e.g. ${{ github.sha }}) to the short form.
if (/^[0-9a-f]{40}$/i.test(gitCommitHash)) {
gitCommitHash = gitCommitHash.slice(0, 7);
}
let appVersion = "0.0.0";
+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",
+10
View File
@@ -122,6 +122,7 @@ async function handlePush(event) {
async function handleNotificationClick(event) {
const data = event.notification.data || {};
const tag = event.notification.tag || "";
const targetUrl = buildClickUrl(data);
const allClients = await self.clients.matchAll({
@@ -129,6 +130,15 @@ async function handleNotificationClick(event) {
includeUncontrolled: true,
});
// Notify any in-app clients so plugins listening on toastHooks.onNotificationClick fire.
for (const client of allClients) {
try {
client.postMessage({ kind: "notificationclick", tag, data });
} catch (_) {
// Closed or detached client - ignore.
}
}
for (const client of allClients) {
// Reuse an existing tab whenever possible - users on desktop browsers
// get annoyed when each notification opens a fresh window.
+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,
+11
View File
@@ -6,6 +6,7 @@ import { useSettingsStore } from "@/stores/settings-store";
import { useCalendarStore } from "@/stores/calendar-store";
import { SearchFilters, DEFAULT_SEARCH_FILTERS, buildJMAPFilter, isFilterEmpty } from "@/lib/jmap/search-utils";
import { emailHooks } from "@/lib/plugin-hooks";
import type { ExternalSearchResult } from "@/lib/plugin-types";
import { fetchUnifiedEmails, fetchUnifiedMailboxCounts, type UnifiedAccountClient, type UnifiedMailboxCounts } from "@/lib/unified-mailbox";
import { useAuthStore } from "@/stores/auth-store";
import { useAccountStore } from "@/stores/account-store";
@@ -42,6 +43,8 @@ interface EmailStore {
searchFilters: SearchFilters;
isAdvancedSearchOpen: boolean;
searchAbortController: AbortController | null;
/** Plugin-contributed search results (CRM hits, Slack messages, etc.) populated by emailHooks.onProvideSearchResults. */
externalSearchResults: ExternalSearchResult[];
// Unified mailbox state
isUnifiedView: boolean;
@@ -216,6 +219,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
searchFilters: { ...DEFAULT_SEARCH_FILTERS },
isAdvancedSearchOpen: false,
searchAbortController: null,
externalSearchResults: [],
// Unified mailbox state
isUnifiedView: false,
@@ -971,8 +975,10 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
// Get emails per page from settings
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
const result = await client.searchEmails(query, jmapMailboxId, accountId, emailsPerPage, 0);
const externals = await emailHooks.onProvideSearchResults.transform([] as ExternalSearchResult[], { query, filters: get().searchFilters });
set({
emails: result.emails,
externalSearchResults: externals,
hasMoreEmails: result.hasMore,
totalEmails: result.total,
isLoading: false
@@ -982,6 +988,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
error: error instanceof Error ? error.message : "Failed to search emails",
isLoading: false,
emails: [],
externalSearchResults: [],
hasMoreEmails: false,
totalEmails: 0
});
@@ -1016,8 +1023,11 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
if (controller.signal.aborted) return;
const externals = await emailHooks.onProvideSearchResults.transform([] as ExternalSearchResult[], { query: searchQuery, filters: searchFilters });
set({
emails: result.emails,
externalSearchResults: externals,
hasMoreEmails: result.hasMore,
totalEmails: result.total,
isLoading: false,
@@ -1029,6 +1039,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
error: error instanceof Error ? error.message : "Failed to search emails",
isLoading: false,
emails: [],
externalSearchResults: [],
hasMoreEmails: false,
totalEmails: 0,
searchAbortController: null,
+118
View File
@@ -0,0 +1,118 @@
import { create } from 'zustand';
import { apiFetch } from '@/lib/browser-navigation';
import type { UpdateStatus, UpdateSeverity } from '@/lib/version-check/types';
const POLL_INTERVAL_MS = 15 * 60 * 1000;
interface UpdateState {
status: UpdateStatus | null;
loading: boolean;
lastFetchedAt: number | null;
fetchStatus: () => Promise<void>;
startPolling: () => void;
stopPolling: () => void;
}
let pollTimer: ReturnType<typeof setInterval> | null = null;
let inFlight: Promise<void> | null = null;
interface ApiResponse {
status: UpdateStatus | null;
lastCheckedAt: string | null;
lastSuccessAt: string | null;
}
export const useUpdateStore = create<UpdateState>()((set, get) => ({
status: null,
loading: false,
lastFetchedAt: null,
fetchStatus: async () => {
if (inFlight) return inFlight;
set({ loading: true });
inFlight = (async () => {
try {
const res = await apiFetch('/api/system/update-status');
if (!res.ok) return;
const body = (await res.json()) as ApiResponse;
set({
status: body.status,
lastFetchedAt: Date.now(),
});
} catch {
// Silent — banner just won't appear, no need to disrupt the UI.
} finally {
set({ loading: false });
inFlight = null;
}
})();
return inFlight;
},
startPolling: () => {
if (pollTimer) return;
void get().fetchStatus();
pollTimer = setInterval(() => {
void get().fetchStatus();
}, POLL_INTERVAL_MS);
},
stopPolling: () => {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
},
}));
// Selectors. Keep them outside the store creator so components subscribing
// to a single derived value don't re-render on unrelated state changes.
export type BannerVariant = 'amber' | 'red';
export interface BannerInfo {
variant: BannerVariant;
severity: UpdateSeverity;
latest: string | null;
url: string | null;
advisory: string | null;
}
export function selectBanner(s: UpdateState): BannerInfo | null {
const st = s.status;
if (!st || !st.updateAvailable) return null;
if (st.severity === 'none' || st.severity === 'unknown') return null;
if (st.severity === 'security') {
return {
variant: 'red',
severity: 'security',
latest: st.latest,
url: st.url,
advisory: st.advisory,
};
}
if (st.severity === 'deprecated') {
return {
variant: 'red',
severity: 'deprecated',
latest: st.latest,
url: st.url,
advisory: null,
};
}
return {
variant: 'amber',
severity: 'normal',
latest: st.latest,
url: st.url,
advisory: null,
};
}
// Used by the admin shield + admin sidebar to show a dot when an update is
// available. Mirrors selectBanner's "should we show something" logic.
export function selectHasUpdate(s: UpdateState): boolean {
return !!s.status?.updateAvailable && s.status.severity !== 'unknown';
}