Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1c5dba7d7 | ||
|
|
8c50abe221 | ||
|
|
07367a8a5d | ||
|
|
1a50788c91 | ||
|
|
0e06bfe273 | ||
|
|
f68e41d81a | ||
|
|
8b164c556e | ||
|
|
2e1f53c899 | ||
|
|
a6d2efaf74 | ||
|
|
01cd9644ed | ||
|
|
1521826d37 | ||
|
|
0d218d0d2a | ||
|
|
9777dd655c | ||
|
|
f970fd1822 | ||
|
|
5e096240b3 | ||
|
|
bc97a1ac10 | ||
|
|
4594fb2572 | ||
|
|
5319562c94 | ||
|
|
599fa66822 |
@@ -50,6 +50,8 @@ jobs:
|
|||||||
context: .
|
context: .
|
||||||
platforms: ${{ matrix.platform }}
|
platforms: ${{ matrix.platform }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
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
|
outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
|
||||||
cache-from: type=gha,scope=${{ matrix.platform }}
|
cache-from: type=gha,scope=${{ matrix.platform }}
|
||||||
cache-to: type=gha,mode=max,scope=${{ matrix.platform }}
|
cache-to: type=gha,mode=max,scope=${{ matrix.platform }}
|
||||||
|
|||||||
@@ -78,6 +78,8 @@ jobs:
|
|||||||
context: .
|
context: .
|
||||||
platforms: ${{ matrix.platform }}
|
platforms: ${{ matrix.platform }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
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
|
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-from: type=gha,scope=${{ matrix.platform }}
|
||||||
cache-to: type=gha,mode=max,scope=${{ matrix.platform }}
|
cache-to: type=gha,mode=max,scope=${{ matrix.platform }}
|
||||||
|
|||||||
@@ -1,5 +1,28 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 1.6.1 (2026-05-04)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Updates**: Update-available detection with non-dismissible notice and dev-reload refresh
|
||||||
|
- **Plugins**: New plugin hooks for compose, attachments, search, lifecycle, and routing
|
||||||
|
- **Sharing**: Share indicators for calendars and contacts, updated JMAP capabilities (#244)
|
||||||
|
- **Mail**: Auto-add recipients to trusted senders when replying
|
||||||
|
- **Identity**: Sanitize identity display name to prevent invalid `From` headers
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- **Mobile**: Synchronize mobile submenu view with browser history for better navigation
|
||||||
|
- **Viewer**: Update email viewer styles to improve overflow handling
|
||||||
|
- **Auth**: Ensure `cookieSlot` consistency during account updates in auth store
|
||||||
|
- **Auth**: Thread per-account cookie slot through OAuth flows
|
||||||
|
- **Calendar**: Square the colored left marker on calendar events
|
||||||
|
- **About**: Show git commit in About instead of "unknown"
|
||||||
|
|
||||||
|
### i18n
|
||||||
|
|
||||||
|
- Update mailbox context menu translations across 12 locales
|
||||||
|
|
||||||
## 1.6.0 (2026-05-01)
|
## 1.6.0 (2026-05-01)
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ ENV NEXT_TELEMETRY_DISABLED=1
|
|||||||
# at build time, so it cannot be changed without rebuilding.
|
# at build time, so it cannot be changed without rebuilding.
|
||||||
ARG NEXT_PUBLIC_BASE_PATH=
|
ARG NEXT_PUBLIC_BASE_PATH=
|
||||||
ENV NEXT_PUBLIC_BASE_PATH=$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
|
RUN npx next build --webpack
|
||||||
|
|
||||||
FROM node:24-alpine AS runner
|
FROM node:24-alpine AS runner
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.ar
|
|||||||
|
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](https://discord.gg/tYCujymGrT)
|
[](https://discord.gg/tYCujymGrT)
|
||||||
[](CHANGELOG.md)
|
[](CHANGELOG.md)
|
||||||
[](https://ghcr.io/bulwarkmail/webmail)
|
[](https://ghcr.io/bulwarkmail/webmail)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useTranslations } from "next-intl";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
|
import { useAccountStore } from "@/stores/account-store";
|
||||||
import { useThemeStore } from "@/stores/theme-store";
|
import { useThemeStore } from "@/stores/theme-store";
|
||||||
import { useShallow } from "zustand/react/shallow";
|
import { useShallow } from "zustand/react/shallow";
|
||||||
import { useConfig } from "@/hooks/use-config";
|
import { useConfig } from "@/hooks/use-config";
|
||||||
@@ -16,6 +17,7 @@ import { AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Monitor,
|
|||||||
import { discoverOAuth, type OAuthMetadata } from "@/lib/oauth/discovery";
|
import { discoverOAuth, type OAuthMetadata } from "@/lib/oauth/discovery";
|
||||||
import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce";
|
import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce";
|
||||||
import { OAUTH_SCOPES } from "@/lib/oauth/tokens";
|
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 APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "0.0.0";
|
||||||
const GIT_COMMIT = process.env.NEXT_PUBLIC_GIT_COMMIT || "unknown";
|
const GIT_COMMIT = process.env.NEXT_PUBLIC_GIT_COMMIT || "unknown";
|
||||||
@@ -28,7 +30,12 @@ const THEME_OPTIONS = [
|
|||||||
|
|
||||||
function VersionBadge() {
|
function VersionBadge() {
|
||||||
const [copied, setCopied] = useState(false);
|
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 = () => {
|
const handleCopy = () => {
|
||||||
navigator.clipboard.writeText(versionInfo).then(() => {
|
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 (
|
return (
|
||||||
<div className="relative inline-flex justify-center">
|
<div className="relative inline-flex justify-center">
|
||||||
<p className="peer text-center text-xs text-muted-foreground/40 cursor-default">
|
{trigger}
|
||||||
v{APP_VERSION}
|
|
||||||
</p>
|
|
||||||
<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="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="flex items-center gap-2">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<p>Version: <span className="font-medium">{APP_VERSION}</span></p>
|
<p>Version: <span className="font-medium">{APP_VERSION}</span></p>
|
||||||
<p>Build: <span className="font-medium">{GIT_COMMIT}</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>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={handleCopy}
|
onClick={handleCopy}
|
||||||
@@ -421,6 +461,16 @@ export default function LoginPage() {
|
|||||||
sessionStorage.setItem("oauth_add_account_mode", "true");
|
sessionStorage.setItem("oauth_add_account_mode", "true");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Persist the next-free cookie slot so loginWithOAuth (in stores/auth-store.ts)
|
||||||
|
// writes the refresh token to the correct per-account jmap_rt_<slot> cookie.
|
||||||
|
// loginWithOAuth reads this key but it was previously never written, so every
|
||||||
|
// OAuth account collapsed onto slot 0 and clobbered earlier accounts' refresh
|
||||||
|
// tokens. getNextCookieSlot() returns 0 when no accounts exist (correct for
|
||||||
|
// first sign-in) and the lowest unused slot otherwise (correct for "+ Add
|
||||||
|
// Account").
|
||||||
|
const nextSlot = useAccountStore.getState().getNextCookieSlot();
|
||||||
|
sessionStorage.setItem("oauth_cookie_slot", nextSlot.toString());
|
||||||
|
|
||||||
const authUrl = new URL(oauthMetadata.authorization_endpoint);
|
const authUrl = new URL(oauthMetadata.authorization_endpoint);
|
||||||
authUrl.searchParams.set("response_type", "code");
|
authUrl.searchParams.set("response_type", "code");
|
||||||
authUrl.searchParams.set("client_id", oauthClientId);
|
authUrl.searchParams.set("client_id", oauthClientId);
|
||||||
|
|||||||
+130
-3
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState, useRef, useMemo, useCallback } from "react";
|
import { useEffect, useState, useRef, useMemo, useCallback } from "react";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { Sidebar } from "@/components/layout/sidebar";
|
import { Sidebar } from "@/components/layout/sidebar";
|
||||||
import { EmailList } from "@/components/email/email-list";
|
import { EmailList } from "@/components/email/email-list";
|
||||||
@@ -58,6 +59,26 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { useConfig } from "@/hooks/use-config";
|
import { useConfig } from "@/hooks/use-config";
|
||||||
import { usePluginStore } from "@/stores/plugin-store";
|
import { usePluginStore } from "@/stores/plugin-store";
|
||||||
import { useThemeStore } from "@/stores/theme-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() {
|
export default function Home() {
|
||||||
@@ -115,6 +136,88 @@ export default function Home() {
|
|||||||
return () => clearInterval(timer);
|
return () => clearInterval(timer);
|
||||||
}, [isRateLimited, rateLimitUntil]);
|
}, [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
|
// Mobile/tablet responsive hooks
|
||||||
const { isMobile, isTablet } = useDeviceDetection();
|
const { isMobile, isTablet } = useDeviceDetection();
|
||||||
const { activeView, sidebarOpen, setSidebarOpen, setActiveView, tabletListVisible, setTabletListVisible, sidebarWidth, emailListWidth, setSidebarWidth, setEmailListWidth, persistColumnWidths, sidebarCollapsed, resetSidebarWidth, resetEmailListWidth } = useUIStore();
|
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 || "");
|
setComposerDraftText(draftText || "");
|
||||||
setComposerMode('reply');
|
setComposerMode('reply');
|
||||||
setShowComposer(true);
|
setShowComposer(true);
|
||||||
@@ -894,13 +1005,29 @@ export default function Home() {
|
|||||||
if (isMobile) setActiveView('viewer');
|
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');
|
setComposerMode('replyAll');
|
||||||
setShowComposer(true);
|
setShowComposer(true);
|
||||||
if (isMobile) setActiveView('viewer');
|
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');
|
setComposerMode('forward');
|
||||||
setShowComposer(true);
|
setShowComposer(true);
|
||||||
if (isMobile) setActiveView('viewer');
|
if (isMobile) setActiveView('viewer');
|
||||||
|
|||||||
@@ -213,6 +213,22 @@ export default function SettingsPage() {
|
|||||||
}
|
}
|
||||||
}, [initialCheckDone, isAuthenticated, authLoading]);
|
}, [initialCheckDone, isAuthenticated, authLoading]);
|
||||||
|
|
||||||
|
// Sync the mobile submenu view with browser history so the system back
|
||||||
|
// button (or gesture) returns to the settings list before exiting /settings.
|
||||||
|
useEffect(() => {
|
||||||
|
if (isDesktop) return;
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
if (!mobileShowContent) return;
|
||||||
|
|
||||||
|
window.history.pushState({ __settingsSubmenu: true }, '');
|
||||||
|
|
||||||
|
const handlePop = () => {
|
||||||
|
setMobileShowContent(false);
|
||||||
|
};
|
||||||
|
window.addEventListener('popstate', handlePop);
|
||||||
|
return () => window.removeEventListener('popstate', handlePop);
|
||||||
|
}, [isDesktop, mobileShowContent]);
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -321,7 +337,7 @@ export default function SettingsPage() {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => setMobileShowContent(false)}
|
onClick={() => window.history.back()}
|
||||||
className="h-10 w-10"
|
className="h-10 w-10"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="w-5 h-5" />
|
<ArrowLeft className="w-5 h-5" />
|
||||||
|
|||||||
+27
-4
@@ -15,6 +15,7 @@ import {
|
|||||||
Puzzle,
|
Puzzle,
|
||||||
SwatchBook,
|
SwatchBook,
|
||||||
Activity,
|
Activity,
|
||||||
|
Package,
|
||||||
Mail,
|
Mail,
|
||||||
Calendar,
|
Calendar,
|
||||||
BookUser,
|
BookUser,
|
||||||
@@ -30,6 +31,7 @@ import { useThemeStore } from '@/stores/theme-store';
|
|||||||
import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
|
import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
|
||||||
|
|
||||||
import { useAuthStore } from '@/stores/auth-store';
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
|
import { useUpdateStore, selectHasUpdate } from '@/stores/update-store';
|
||||||
import { apiFetch } from '@/lib/browser-navigation';
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
|
||||||
const NAV_GROUPS = [
|
const NAV_GROUPS = [
|
||||||
@@ -59,6 +61,7 @@ const NAV_GROUPS = [
|
|||||||
{
|
{
|
||||||
label: 'System',
|
label: 'System',
|
||||||
items: [
|
items: [
|
||||||
|
{ href: '/admin/version', label: 'Version', icon: Package },
|
||||||
{ href: '/admin/telemetry', label: 'Telemetry', icon: Activity },
|
{ href: '/admin/telemetry', label: 'Telemetry', icon: Activity },
|
||||||
{ href: '/admin/logs', label: 'Audit Log', icon: ScrollText },
|
{ href: '/admin/logs', label: 'Audit Log', icon: ScrollText },
|
||||||
],
|
],
|
||||||
@@ -78,6 +81,13 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
? (appLogoDarkUrl || appLogoLightUrl || loginLogoDarkUrl)
|
? (appLogoDarkUrl || appLogoLightUrl || loginLogoDarkUrl)
|
||||||
: (appLogoLightUrl || appLogoDarkUrl || loginLogoLightUrl);
|
: (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(() => {
|
useEffect(() => {
|
||||||
setMobileNavOpen(false);
|
setMobileNavOpen(false);
|
||||||
}, [pathname]);
|
}, [pathname]);
|
||||||
@@ -170,6 +180,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
</div>
|
</div>
|
||||||
{group.items.map(({ href, label, icon: Icon }) => {
|
{group.items.map(({ href, label, icon: Icon }) => {
|
||||||
const active = href === '/admin' ? pathname === '/admin' : pathname.startsWith(href);
|
const active = href === '/admin' ? pathname === '/admin' : pathname.startsWith(href);
|
||||||
|
const showDot = href === '/admin/version' && hasUpdate;
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={href}
|
key={href}
|
||||||
@@ -181,10 +192,22 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
: 'hover:bg-muted text-foreground'
|
: 'hover:bg-muted text-foreground'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon className={cn(
|
<span className="relative shrink-0">
|
||||||
'w-4 h-4 shrink-0',
|
<Icon className={cn(
|
||||||
active ? 'text-accent-foreground' : 'text-muted-foreground'
|
'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}
|
{label}
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,12 +13,18 @@ export async function POST(request: NextRequest) {
|
|||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { code, state } = await request.json();
|
const { code, state, slot: bodySlot } = await request.json();
|
||||||
|
|
||||||
if (!code || !state) {
|
if (!code || !state) {
|
||||||
return NextResponse.json({ error: 'Missing code or state' }, { status: 400 });
|
return NextResponse.json({ error: 'Missing code or state' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Per-account refresh-token cookie slot. Without this the route hardcoded
|
||||||
|
// slot 0, so the "+ Add Account" flow overwrote the first account's
|
||||||
|
// refresh-token cookie. Default to 0 for back-compat with any caller that
|
||||||
|
// omits slot. Mirrors the validation in /api/auth/token POST.
|
||||||
|
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : 0;
|
||||||
|
|
||||||
// Read and decrypt the pending SSO cookie
|
// Read and decrypt the pending SSO cookie
|
||||||
const pendingCookie = cookieStore.get(SSO_PENDING_COOKIE)?.value;
|
const pendingCookie = cookieStore.get(SSO_PENDING_COOKIE)?.value;
|
||||||
if (!pendingCookie) {
|
if (!pendingCookie) {
|
||||||
@@ -58,9 +64,9 @@ export async function POST(request: NextRequest) {
|
|||||||
// Exchange code for tokens
|
// Exchange code for tokens
|
||||||
const tokens = await exchangeCodeForTokens(code, codeVerifier, redirectUri);
|
const tokens = await exchangeCodeForTokens(code, codeVerifier, redirectUri);
|
||||||
|
|
||||||
// Store refresh token
|
// Store refresh token in the per-account cookie slot.
|
||||||
if (tokens.refresh_token) {
|
if (tokens.refresh_token) {
|
||||||
const cookieName = refreshTokenCookieName(0);
|
const cookieName = refreshTokenCookieName(slot);
|
||||||
cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions());
|
cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 && (
|
{cal.id === BIRTHDAY_CALENDAR_ID && (
|
||||||
<Cake className="w-3 h-3 text-muted-foreground flex-shrink-0" />
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -150,9 +150,8 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
|
|||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
{...dragProps}
|
{...dragProps}
|
||||||
className={cn(
|
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",
|
"hover:opacity-90 transition-opacity cursor-pointer",
|
||||||
continuesBefore && "rounded-l-sm",
|
|
||||||
continuesAfter && "rounded-r-sm",
|
continuesAfter && "rounded-r-sm",
|
||||||
continuesBefore && "-ml-0.5",
|
continuesBefore && "-ml-0.5",
|
||||||
continuesAfter && "pr-2",
|
continuesAfter && "pr-2",
|
||||||
@@ -182,7 +181,7 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
|
|||||||
{...dragProps}
|
{...dragProps}
|
||||||
data-calendar-event
|
data-calendar-event
|
||||||
className={cn(
|
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",
|
"hover:opacity-90 transition-opacity cursor-pointer",
|
||||||
isSelected && "ring-2 ring-primary",
|
isSelected && "ring-2 ring-primary",
|
||||||
isBeingDragged && "opacity-50",
|
isBeingDragged && "opacity-50",
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ import { PluginSlot } from "@/components/plugins/plugin-slot";
|
|||||||
import { useSettingsStore } from "@/stores/settings-store";
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
import { generateUUID } from "@/lib/utils";
|
import { generateUUID } from "@/lib/utils";
|
||||||
import { useFormatEventDate } from "@/hooks/use-format-event-date";
|
import { useFormatEventDate } from "@/hooks/use-format-event-date";
|
||||||
|
import { calendarHooks } from "@/lib/plugin-hooks";
|
||||||
|
import type { ConflictWarning } from "@/lib/plugin-types";
|
||||||
|
|
||||||
export interface PendingEventPreview {
|
export interface PendingEventPreview {
|
||||||
start: Date;
|
start: Date;
|
||||||
@@ -242,6 +244,31 @@ export function EventModal({
|
|||||||
const [sendInvitations, setSendInvitations] = useState(true);
|
const [sendInvitations, setSendInvitations] = useState(true);
|
||||||
const participantInputRef = useRef<ParticipantInputHandle>(null);
|
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
|
// Report live preview to parent for grid outline
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!onPreviewChange || isEdit) return;
|
if (!onPreviewChange || isEdit) return;
|
||||||
@@ -923,6 +950,26 @@ export function EventModal({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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 && (
|
{calendars.length > 1 && (
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm font-medium mb-1 block">{t("form.calendar_select")}</label>
|
<label className="text-sm font-medium mb-1 block">{t("form.calendar_select")}</label>
|
||||||
|
|||||||
@@ -685,7 +685,13 @@ function AddressBookItem({
|
|||||||
>
|
>
|
||||||
<Book className="w-4 h-4 flex-shrink-0" />
|
<Book className="w-4 h-4 flex-shrink-0" />
|
||||||
<span className="truncate">{book.name}</span>
|
<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}
|
{contactCount}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import { cn, formatFileSize, formatDateTime, generateUUID } from "@/lib/utils";
|
|||||||
import { debug } from "@/lib/debug";
|
import { debug } from "@/lib/debug";
|
||||||
import { toast } from "@/stores/toast-store";
|
import { toast } from "@/stores/toast-store";
|
||||||
import { sanitizeEmailHtml } from "@/lib/email-sanitization";
|
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 { useAuthStore } from "@/stores/auth-store";
|
||||||
import { useIdentityStore } from "@/stores/identity-store";
|
import { useIdentityStore } from "@/stores/identity-store";
|
||||||
import { useAccountStore } from "@/stores/account-store";
|
import { useAccountStore } from "@/stores/account-store";
|
||||||
@@ -326,6 +328,9 @@ export function EmailComposer({
|
|||||||
? `<div>${getPlainTextSignature(currentIdentity).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>')}</div>`
|
? `<div>${getPlainTextSignature(currentIdentity).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>')}</div>`
|
||||||
: '';
|
: '';
|
||||||
const getAutocomplete = useContactStore((s) => s.getAutocomplete);
|
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 addTemplate = useTemplateStore((s) => s.addTemplate);
|
||||||
const sendRawEmail = useEmailStore((s) => s.sendRawEmail);
|
const sendRawEmail = useEmailStore((s) => s.sendRawEmail);
|
||||||
const smimeStore = useSmimeStore();
|
const smimeStore = useSmimeStore();
|
||||||
@@ -446,10 +451,13 @@ export function EmailComposer({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
autocompleteTimeoutRef.current = setTimeout(() => {
|
autocompleteTimeoutRef.current = setTimeout(async () => {
|
||||||
const results = getAutocomplete(lastPart);
|
const localResults = getAutocomplete(lastPart);
|
||||||
setAutocompleteResults(results);
|
// Let plugins contribute extra suggestions (Slack handles, GitHub, CRM, …).
|
||||||
setActiveAutoField(results.length > 0 ? field : null);
|
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);
|
setAutoSelectedIndex(-1);
|
||||||
}, 200);
|
}, 200);
|
||||||
}, [getAutocomplete]);
|
}, [getAutocomplete]);
|
||||||
@@ -559,6 +567,19 @@ export function EmailComposer({
|
|||||||
const addFiles = useCallback(async (files: File[]) => {
|
const addFiles = useCallback(async (files: File[]) => {
|
||||||
if (!client || files.length === 0) return;
|
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 newAttachments: ComposerAttachment[] = files.map(file => {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
return {
|
return {
|
||||||
@@ -587,6 +608,12 @@ export function EmailComposer({
|
|||||||
: att
|
: att
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
emailHooks.onAfterAttachmentUpload.emit({
|
||||||
|
name: file.name,
|
||||||
|
type: file.type || 'application/octet-stream',
|
||||||
|
size: file.size,
|
||||||
|
blobId,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (controller?.signal.aborted) continue;
|
if (controller?.signal.aborted) continue;
|
||||||
debug.error(`Failed to upload ${file.name}:`, error);
|
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)
|
// Set new timeout for auto-save (2 seconds after last change)
|
||||||
saveTimeoutRef.current = setTimeout(() => {
|
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();
|
saveDraft();
|
||||||
}, 2000);
|
}, 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 }));
|
.map(att => ({ blobId: att.blobId!, name: att.name, type: att.type || 'application/octet-stream', size: att.size }));
|
||||||
uploadedAttachments.push(...inlineAttachments);
|
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,
|
to: toAddresses,
|
||||||
cc: ccAddresses,
|
cc: ccAddresses,
|
||||||
bcc: bccAddresses,
|
bcc: bccAddresses,
|
||||||
subject,
|
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,
|
draftId: finalDraftId || undefined,
|
||||||
fromEmail,
|
fromEmail,
|
||||||
fromName: currentIdentity?.name || undefined,
|
fromName: currentIdentity?.name || undefined,
|
||||||
identityId: currentIdentity?.id,
|
identityId: outgoing.identityId || currentIdentity?.id,
|
||||||
attachments: uploadedAttachments.length > 0 ? uploadedAttachments : undefined,
|
attachments: uploadedAttachments.length > 0 ? uploadedAttachments : undefined,
|
||||||
inReplyTo: threadingHeaders?.inReplyTo,
|
inReplyTo: threadingHeaders?.inReplyTo,
|
||||||
references: threadingHeaders?.references,
|
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("");
|
setTo("");
|
||||||
|
|||||||
@@ -93,6 +93,8 @@ import type { TnefAttachment } from "@/lib/tnef";
|
|||||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||||
import { usePluginStore } from "@/stores/plugin-store";
|
import { usePluginStore } from "@/stores/plugin-store";
|
||||||
import { ResizeHandle } from "@/components/layout/resize-handle";
|
import { ResizeHandle } from "@/components/layout/resize-handle";
|
||||||
|
import { emailHooks, uiHooks } from "@/lib/plugin-hooks";
|
||||||
|
import type { AttachmentInfo, AttachmentPreview } from "@/lib/plugin-types";
|
||||||
|
|
||||||
interface EmailViewerProps {
|
interface EmailViewerProps {
|
||||||
email: Email | null;
|
email: Email | null;
|
||||||
@@ -2474,11 +2476,20 @@ export function EmailViewer({
|
|||||||
return emailContent;
|
return emailContent;
|
||||||
}, [cidBlobUrls, emailContent, smimeDecryptedHtml, smimeDecryptedText, tnefHtml, tnefText, embeddedEmailHtml, embeddedEmailText]);
|
}, [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 isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
|
||||||
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
|
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) {
|
if (attachment.blobId && onDownloadAttachment) {
|
||||||
|
emailHooks.onAttachmentDownload.emit(info);
|
||||||
onDownloadAttachment(attachment.blobId, attachment.name || 'download', attachment.type);
|
onDownloadAttachment(attachment.blobId, attachment.name || 'download', attachment.type);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -2493,8 +2504,10 @@ export function EmailViewer({
|
|||||||
const objectUrl = URL.createObjectURL(blob);
|
const objectUrl = URL.createObjectURL(blob);
|
||||||
|
|
||||||
if (opensPreview) {
|
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 {
|
} else {
|
||||||
|
emailHooks.onAttachmentDownload.emit(info);
|
||||||
const anchor = document.createElement('a');
|
const anchor = document.createElement('a');
|
||||||
anchor.href = objectUrl;
|
anchor.href = objectUrl;
|
||||||
anchor.download = attachment.name || 'download';
|
anchor.download = attachment.name || 'download';
|
||||||
@@ -2521,8 +2534,10 @@ export function EmailViewer({
|
|||||||
const objectUrl = URL.createObjectURL(blob);
|
const objectUrl = URL.createObjectURL(blob);
|
||||||
|
|
||||||
if (opensPreview) {
|
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 {
|
} else {
|
||||||
|
emailHooks.onAttachmentDownload.emit(info);
|
||||||
const anchor = document.createElement('a');
|
const anchor = document.createElement('a');
|
||||||
anchor.href = objectUrl;
|
anchor.href = objectUrl;
|
||||||
anchor.download = attachment.name || 'download';
|
anchor.download = attachment.name || 'download';
|
||||||
@@ -2532,9 +2547,17 @@ export function EmailViewer({
|
|||||||
}
|
}
|
||||||
|
|
||||||
setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000);
|
setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000);
|
||||||
}, [mailAttachmentAction, onDownloadAttachment]);
|
}, [mailAttachmentAction, onDownloadAttachment, email?.id]);
|
||||||
|
|
||||||
const handleEffectiveAttachmentDownload = useCallback((attachment: EffectiveAttachment) => {
|
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) {
|
if (attachment.blobId && onDownloadAttachment) {
|
||||||
onDownloadAttachment(attachment.blobId, attachment.name || 'download', attachment.type, true);
|
onDownloadAttachment(attachment.blobId, attachment.name || 'download', attachment.type, true);
|
||||||
return;
|
return;
|
||||||
@@ -2570,7 +2593,7 @@ export function EmailViewer({
|
|||||||
anchor.click();
|
anchor.click();
|
||||||
anchor.remove();
|
anchor.remove();
|
||||||
setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000);
|
setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000);
|
||||||
}, [onDownloadAttachment]);
|
}, [onDownloadAttachment, email?.id]);
|
||||||
|
|
||||||
// Pre-fetch object URLs for image attachments so their actual contents can be
|
// 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.
|
// rendered as thumbnails inside the chip. Skips images larger than 10 MB.
|
||||||
@@ -2786,6 +2809,27 @@ export function EmailViewer({
|
|||||||
a.setAttribute('rel', 'noopener noreferrer');
|
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
|
// Dark mode: re-invert elements with stylesheet-defined background images
|
||||||
// (CSS attribute selectors only catch inline styles, not <style> block rules)
|
// (CSS attribute selectors only catch inline styles, not <style> block rules)
|
||||||
if (isDark && !emailHasNativeDarkMode) {
|
if (isDark && !emailHasNativeDarkMode) {
|
||||||
@@ -2813,7 +2857,7 @@ export function EmailViewer({
|
|||||||
} catch {
|
} catch {
|
||||||
// Cross-origin restrictions - iframe will still display content
|
// Cross-origin restrictions - iframe will still display content
|
||||||
}
|
}
|
||||||
}, [isDark, emailHasNativeDarkMode]);
|
}, [isDark, emailHasNativeDarkMode, email?.id]);
|
||||||
|
|
||||||
// Export email as .eml file
|
// Export email as .eml file
|
||||||
const handleExportEmail = async () => {
|
const handleExportEmail = async () => {
|
||||||
@@ -3837,7 +3881,7 @@ export function EmailViewer({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Email Content Area */}
|
{/* Email Content Area */}
|
||||||
<div className={cn("flex-1 overflow-auto bg-muted/30", isMobile && "pb-16")}>
|
<div className={cn("flex-1 overflow-auto overscroll-contain bg-muted/30", isMobile && "pb-16")}>
|
||||||
|
|
||||||
{/* === SENDER INFO (Desktop) === */}
|
{/* === SENDER INFO (Desktop) === */}
|
||||||
<div className="hidden lg:block bg-background border-b border-border px-6" style={{ paddingBlock: 'var(--density-header-py)' }}>
|
<div className="hidden lg:block bg-background border-b border-border px-6" style={{ paddingBlock: 'var(--density-header-py)' }}>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { useSettingsStore } from "@/stores/settings-store";
|
|||||||
import { usePolicyStore } from "@/stores/policy-store";
|
import { usePolicyStore } from "@/stores/policy-store";
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
import { useAccountStore } from "@/stores/account-store";
|
import { useAccountStore } from "@/stores/account-store";
|
||||||
|
import { useUpdateStore, selectHasUpdate } from "@/stores/update-store";
|
||||||
import { getActiveAccountSlotHeaders } from "@/lib/auth/active-account-slot";
|
import { getActiveAccountSlotHeaders } from "@/lib/auth/active-account-slot";
|
||||||
import { getInitials, MAX_ACCOUNTS } from "@/lib/account-utils";
|
import { getInitials, MAX_ACCOUNTS } from "@/lib/account-utils";
|
||||||
import { cn, formatFileSize } from "@/lib/utils";
|
import { cn, formatFileSize } from "@/lib/utils";
|
||||||
@@ -175,6 +176,11 @@ export function NavigationRail({
|
|||||||
const visibleSidebarApps = sidebarAppsEnabled ? sidebarApps : [];
|
const visibleSidebarApps = sidebarAppsEnabled ? sidebarApps : [];
|
||||||
const inboxUnread = mailboxes.find(m => m.role === "inbox")?.unreadEmails || 0;
|
const inboxUnread = mailboxes.find(m => m.role === "inbox")?.unreadEmails || 0;
|
||||||
const [isStalwartAdmin, setIsStalwartAdmin] = useState(false);
|
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
|
// Account list for rail
|
||||||
const accounts = useAccountStore((s) => s.accounts);
|
const accounts = useAccountStore((s) => s.accounts);
|
||||||
@@ -345,7 +351,18 @@ export function NavigationRail({
|
|||||||
"text-muted-foreground hover:text-foreground"
|
"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>
|
<span className="text-[10px] font-medium leading-tight truncate max-w-full">{t("admin") || "Admin"}</span>
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
@@ -515,10 +532,19 @@ export function NavigationRail({
|
|||||||
{isStalwartAdmin && (
|
{isStalwartAdmin && (
|
||||||
<a
|
<a
|
||||||
href="/admin"
|
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"}
|
title={t("admin") || "Admin"}
|
||||||
>
|
>
|
||||||
<Shield className="w-[18px] h-[18px]" />
|
<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>
|
</a>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,51 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useRef } from 'react';
|
import { useState, useRef, useEffect } from 'react';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { useSettingsStore } from '@/stores/settings-store';
|
import { useSettingsStore } from '@/stores/settings-store';
|
||||||
import { useConfig } from '@/hooks/use-config';
|
import { useConfig } from '@/hooks/use-config';
|
||||||
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
|
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { usePolicyStore } from '@/stores/policy-store';
|
import { usePolicyStore } from '@/stores/policy-store';
|
||||||
|
import { useUpdateStore } from '@/stores/update-store';
|
||||||
import { ExternalLink } from 'lucide-react';
|
import { ExternalLink } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
import { SpamSiegeGame } from './spam-siege-game';
|
import { SpamSiegeGame } from './spam-siege-game';
|
||||||
|
|
||||||
const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "0.0.0";
|
const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "0.0.0";
|
||||||
const GIT_COMMIT = process.env.NEXT_PUBLIC_GIT_COMMIT || "unknown";
|
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() {
|
export function AboutDataSettings() {
|
||||||
const t = useTranslations('settings.advanced');
|
const t = useTranslations('settings.advanced');
|
||||||
const tCommon = useTranslations('common');
|
const tCommon = useTranslations('common');
|
||||||
@@ -106,6 +139,7 @@ export function AboutDataSettings() {
|
|||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground group-hover/about:translate-x-0.5 group-active/about:translate-y-px transition-transform">
|
<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>
|
v{APP_VERSION} <span className="text-muted-foreground/60">({GIT_COMMIT})</span>
|
||||||
|
<VersionUpdateTag />
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -139,6 +139,19 @@ export function AddressBookManagementSettings() {
|
|||||||
{t("default")}
|
{t("default")}
|
||||||
</span>
|
</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">
|
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
{canRename && (
|
{canRename && (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -516,6 +516,20 @@ export function CalendarManagementSettings() {
|
|||||||
</span>
|
</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">
|
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ export function ShareCollectionDialog({
|
|||||||
const t = useTranslations("sharing");
|
const t = useTranslations("sharing");
|
||||||
const tCommon = useTranslations("common");
|
const tCommon = useTranslations("common");
|
||||||
const modalRef = useRef<HTMLDivElement>(null);
|
const modalRef = useRef<HTMLDivElement>(null);
|
||||||
const [principals, setPrincipals] = useState<Principal[]>([]);
|
const [allPrincipals, setAllPrincipals] = useState<Principal[]>([]);
|
||||||
const [loadingPrincipals, setLoadingPrincipals] = useState(true);
|
const [loadingPrincipals, setLoadingPrincipals] = useState(true);
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [savingId, setSavingId] = useState<string | null>(null);
|
const [savingId, setSavingId] = useState<string | null>(null);
|
||||||
@@ -91,23 +91,28 @@ export function ShareCollectionDialog({
|
|||||||
setLoadingPrincipals(true);
|
setLoadingPrincipals(true);
|
||||||
client.getPrincipals().then((list) => {
|
client.getPrincipals().then((list) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
// Exclude the user themselves and any principal that already has a share
|
setAllPrincipals(list);
|
||||||
const existing = new Set(Object.keys(shareWith || {}));
|
|
||||||
const filtered = list.filter((p) => p.id !== ownAccountId && !existing.has(p.id));
|
|
||||||
setPrincipals(filtered);
|
|
||||||
setLoadingPrincipals(false);
|
setLoadingPrincipals(false);
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
if (!cancelled) setLoadingPrincipals(false);
|
if (!cancelled) setLoadingPrincipals(false);
|
||||||
});
|
});
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [client, ownAccountId, shareWith]);
|
}, [client]);
|
||||||
|
|
||||||
// Map principal id -> Principal for displayed shares
|
// Map of every fetched principal by id, used for name/description lookups in
|
||||||
|
// the shared list. Must include principals that already have a share so the
|
||||||
|
// list shows their name rather than the raw id.
|
||||||
const allPrincipalsById = useMemo(() => {
|
const allPrincipalsById = useMemo(() => {
|
||||||
const map = new Map<string, Principal>();
|
const map = new Map<string, Principal>();
|
||||||
for (const p of principals) map.set(p.id, p);
|
for (const p of allPrincipals) map.set(p.id, p);
|
||||||
return map;
|
return map;
|
||||||
}, [principals]);
|
}, [allPrincipals]);
|
||||||
|
|
||||||
|
// Principals available to add: exclude self and anyone already shared with.
|
||||||
|
const principals = useMemo(() => {
|
||||||
|
const existing = new Set(Object.keys(shareWith || {}));
|
||||||
|
return allPrincipals.filter((p) => p.id !== ownAccountId && !existing.has(p.id));
|
||||||
|
}, [allPrincipals, ownAccountId, shareWith]);
|
||||||
|
|
||||||
// Close on Escape, focus trap, click outside
|
// Close on Escape, focus trap, click outside
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -155,8 +160,6 @@ export function ShareCollectionDialog({
|
|||||||
setSavingId(principal.id);
|
setSavingId(principal.id);
|
||||||
try {
|
try {
|
||||||
await onShare(principal.id, rights);
|
await onShare(principal.id, rights);
|
||||||
// Move principal out of the "to add" list
|
|
||||||
setPrincipals((prev) => prev.filter((p) => p.id !== principal.id));
|
|
||||||
setShowAdd(false);
|
setShowAdd(false);
|
||||||
setSearch("");
|
setSearch("");
|
||||||
toast.success(t("share_added"));
|
toast.success(t("share_added"));
|
||||||
|
|||||||
+7
-37
@@ -2,49 +2,12 @@ import { readFileSync } from "fs";
|
|||||||
import { configManager } from "./lib/admin/config-manager";
|
import { configManager } from "./lib/admin/config-manager";
|
||||||
import { initAdminPassword } from "./lib/admin/password";
|
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(
|
const pkg = JSON.parse(
|
||||||
readFileSync(`${process.cwd()}/package.json`, "utf-8")
|
readFileSync(`${process.cwd()}/package.json`, "utf-8")
|
||||||
);
|
);
|
||||||
const current: string = pkg.version ?? "0.0.0";
|
const current: string = pkg.version ?? "0.0.0";
|
||||||
console.info(`Bulwark Webmail v${current}`);
|
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
|
// Initialize admin config and password bootstrap
|
||||||
configManager.load()
|
configManager.load()
|
||||||
.then(() => initAdminPassword())
|
.then(() => initAdminPassword())
|
||||||
@@ -59,6 +22,13 @@ configManager.load()
|
|||||||
markProcessStart();
|
markProcessStart();
|
||||||
await startScheduler();
|
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) => {
|
.catch((err) => {
|
||||||
console.warn("Admin dashboard init skipped:", err instanceof Error ? err.message : err);
|
console.warn("Admin dashboard init skipped:", err instanceof Error ? err.message : err);
|
||||||
});
|
});
|
||||||
|
|||||||
+32
-5
@@ -300,6 +300,16 @@ function stripMessageIdBrackets(id: string): string {
|
|||||||
return id.trim().replace(/^<+/, '').replace(/>+$/, '').trim();
|
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 {
|
export class JMAPClient implements IJMAPClient {
|
||||||
private static readonly RATE_LIMIT_TOAST_THROTTLE_MS = 10_000;
|
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") {
|
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 [];
|
return [];
|
||||||
@@ -1964,8 +1975,9 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
attachments?: { blobId: string; type: string; name: string; disposition: string; cid?: string }[];
|
attachments?: { blobId: string; type: string; name: string; disposition: string; cid?: string }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sanitizedFromName = sanitizeIdentityDisplayName(fromName);
|
||||||
const emailData: EmailDraft = {
|
const emailData: EmailDraft = {
|
||||||
from: [{ ...(fromName ? { name: fromName } : {}), email: fromEmail || this.username }],
|
from: [{ ...(sanitizedFromName ? { name: sanitizedFromName } : {}), email: fromEmail || this.username }],
|
||||||
to: to.map(email => ({ email })),
|
to: to.map(email => ({ email })),
|
||||||
cc: cc?.map(email => ({ email })),
|
cc: cc?.map(email => ({ email })),
|
||||||
bcc: bcc?.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 normalizedInReplyTo = inReplyTo?.map(stripMessageIdBrackets).filter(Boolean);
|
||||||
const normalizedReferences = references?.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
|
// Always create a new email with the final body content
|
||||||
const emailCreate: Record<string, unknown> = {
|
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,
|
replyTo: identityReplyTo?.length ? identityReplyTo : undefined,
|
||||||
to: to.map(email => ({ email })),
|
to: to.map(email => ({ email })),
|
||||||
cc: cc?.map(email => ({ email })),
|
cc: cc?.map(email => ({ email })),
|
||||||
@@ -3079,11 +3092,19 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private contactUsing(): string[] {
|
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[] {
|
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[] {
|
private getCalendarCapableAccountIds(): string[] {
|
||||||
@@ -3293,6 +3314,9 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
const err = result.notUpdated[calendarId];
|
const err = result.notUpdated[calendarId];
|
||||||
throw new Error(err.description || "Failed to update calendar share");
|
throw new Error(err.description || "Failed to update calendar share");
|
||||||
}
|
}
|
||||||
|
if (!result?.updated || !(calendarId in result.updated)) {
|
||||||
|
throw new Error("Server did not confirm the share update");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -3318,6 +3342,9 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
const err = result.notUpdated[addressBookId];
|
const err = result.notUpdated[addressBookId];
|
||||||
throw new Error(err.description || "Failed to update address book share");
|
throw new Error(err.description || "Failed to update address book share");
|
||||||
}
|
}
|
||||||
|
if (!result?.updated || !(addressBookId in result.updated)) {
|
||||||
|
throw new Error("Server did not confirm the share update");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async fetchPaginatedContacts(
|
private async fetchPaginatedContacts(
|
||||||
|
|||||||
+54
-2
@@ -23,7 +23,7 @@ import {
|
|||||||
taskHooks, templateHooks, smimeHooks, vacationHooks,
|
taskHooks, templateHooks, smimeHooks, vacationHooks,
|
||||||
uiHooks, themeHooks, toastHooks, dragDropHooks,
|
uiHooks, themeHooks, toastHooks, dragDropHooks,
|
||||||
keyboardHooks, appLifecycleHooks, accountSecurityHooks,
|
keyboardHooks, appLifecycleHooks, accountSecurityHooks,
|
||||||
sidebarAppHooks, avatarHooks, renderHooks,
|
sidebarAppHooks, avatarHooks, renderHooks, routerHooks,
|
||||||
} from './plugin-hooks';
|
} from './plugin-hooks';
|
||||||
import { createPluginI18n } from './plugin-i18n';
|
import { createPluginI18n } from './plugin-i18n';
|
||||||
import { toast as appToast } from '@/stores/toast-store';
|
import { toast as appToast } from '@/stores/toast-store';
|
||||||
@@ -187,6 +187,22 @@ export interface PluginHooksAPI {
|
|||||||
onQuotaChange: (handler: (...args: unknown[]) => unknown) => Disposable;
|
onQuotaChange: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||||
/** Intercept - receives MailtoContext, return false to prevent the system mail client */
|
/** Intercept - receives MailtoContext, return false to prevent the system mail client */
|
||||||
onMailtoIntercept: (handler: (ctx: import('./plugin-types').MailtoContext) => boolean | void | Promise<boolean | void>) => Disposable;
|
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
|
// Calendar
|
||||||
onCalendarEventOpen: (handler: (...args: unknown[]) => unknown) => Disposable;
|
onCalendarEventOpen: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||||
onBeforeEventCreate: (handler: (...args: unknown[]) => unknown) => Disposable;
|
onBeforeEventCreate: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||||
@@ -204,6 +220,8 @@ export interface PluginHooksAPI {
|
|||||||
onICalSubscriptionChange: (handler: (...args: unknown[]) => unknown) => Disposable;
|
onICalSubscriptionChange: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||||
onCalendarAlert: (handler: (...args: unknown[]) => unknown) => Disposable;
|
onCalendarAlert: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||||
onCalendarAlertAcknowledge: (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
|
// Calendar Form
|
||||||
onCalendarEventFormOpen: (handler: (...args: unknown[]) => unknown) => Disposable;
|
onCalendarEventFormOpen: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||||
onCalendarEventFormSave: (handler: (...args: unknown[]) => unknown) => Disposable;
|
onCalendarEventFormSave: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||||
@@ -220,6 +238,8 @@ export interface PluginHooksAPI {
|
|||||||
onContactGroupChange: (handler: (...args: unknown[]) => unknown) => Disposable;
|
onContactGroupChange: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||||
onContactGroupMemberChange: (handler: (...args: unknown[]) => unknown) => Disposable;
|
onContactGroupMemberChange: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||||
onContactMove: (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
|
// Files
|
||||||
onFileNavigate: (handler: (...args: unknown[]) => unknown) => Disposable;
|
onFileNavigate: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||||
onBeforeFileUpload: (handler: (...args: unknown[]) => unknown) => Disposable;
|
onBeforeFileUpload: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||||
@@ -297,6 +317,10 @@ export interface PluginHooksAPI {
|
|||||||
onColumnResize: (handler: (...args: unknown[]) => unknown) => Disposable;
|
onColumnResize: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||||
onMobileBack: (handler: () => void) => Disposable;
|
onMobileBack: (handler: () => void) => Disposable;
|
||||||
onMobileViewSwitch: (handler: (...args: unknown[]) => unknown) => 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
|
// Theme
|
||||||
onThemeChange: (handler: (...args: unknown[]) => unknown) => Disposable;
|
onThemeChange: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||||
onCustomThemeChange: (handler: (...args: unknown[]) => unknown) => Disposable;
|
onCustomThemeChange: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||||
@@ -305,6 +329,8 @@ export interface PluginHooksAPI {
|
|||||||
onToastShow: (handler: (...args: unknown[]) => unknown) => Disposable;
|
onToastShow: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||||
onToastDismiss: (handler: (...args: unknown[]) => unknown) => Disposable;
|
onToastDismiss: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||||
onBrowserNotification: (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
|
// Drag & Drop
|
||||||
onDragStart: (handler: (...args: unknown[]) => unknown) => Disposable;
|
onDragStart: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||||
onDragEnd: (handler: (...args: unknown[]) => unknown) => Disposable;
|
onDragEnd: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||||
@@ -320,6 +346,12 @@ export interface PluginHooksAPI {
|
|||||||
onBeforeUnload: (handler: () => void) => Disposable;
|
onBeforeUnload: (handler: () => void) => Disposable;
|
||||||
onAppError: (handler: (...args: unknown[]) => unknown) => Disposable;
|
onAppError: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||||
onInterval: (handler: () => void, intervalMs: number) => 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
|
// Account Security
|
||||||
onPasswordChange: (handler: () => void) => Disposable;
|
onPasswordChange: (handler: () => void) => Disposable;
|
||||||
onTotpChange: (handler: (...args: unknown[]) => unknown) => Disposable;
|
onTotpChange: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||||
@@ -335,6 +367,11 @@ export interface PluginHooksAPI {
|
|||||||
// Render - transform hook for email list row badges
|
// Render - transform hook for email list row badges
|
||||||
// Handler: (badges: EmailListBadge[], ctx: { emailId: string; email: EmailReadView }) => EmailListBadge[]
|
// Handler: (badges: EmailListBadge[], ctx: { emailId: string; email: EmailReadView }) => EmailListBadge[]
|
||||||
onEmailListItemRender: (handler: (...args: unknown[]) => unknown) => Disposable;
|
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 ----------------------------
|
// --- Permission mapping for hooks ----------------------------
|
||||||
@@ -350,7 +387,13 @@ const HOOK_PERMISSIONS: Record<string, Permission> = {
|
|||||||
onEmailSelectionChange: 'email:read', onNewEmailReceived: 'email:read',
|
onEmailSelectionChange: 'email:read', onNewEmailReceived: 'email:read',
|
||||||
onPushConnectionChange: 'email:read', onQuotaChange: 'email:read',
|
onPushConnectionChange: 'email:read', onQuotaChange: 'email:read',
|
||||||
onMailtoIntercept: 'email:read', onEmailListItemRender: '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',
|
onBeforeEmailSend: 'email:send', onAfterEmailSend: 'email:send',
|
||||||
|
onTransformOutgoingEmail: 'email:send',
|
||||||
onBeforeEmailDelete: 'email:write', onAfterEmailDelete: 'email:write',
|
onBeforeEmailDelete: 'email:write', onAfterEmailDelete: 'email:write',
|
||||||
onBeforeEmailMove: 'email:write', onAfterEmailMove: 'email:write',
|
onBeforeEmailMove: 'email:write', onAfterEmailMove: 'email:write',
|
||||||
onEmailArchive: 'email:write', onEmailUnarchive: 'email:write',
|
onEmailArchive: 'email:write', onEmailUnarchive: 'email:write',
|
||||||
@@ -362,6 +405,7 @@ const HOOK_PERMISSIONS: Record<string, Permission> = {
|
|||||||
onCalendarEventOpen: 'calendar:read', onCalendarDateChange: 'calendar:read',
|
onCalendarEventOpen: 'calendar:read', onCalendarDateChange: 'calendar:read',
|
||||||
onCalendarViewChange: 'calendar:read', onCalendarVisibilityToggle: 'calendar:read',
|
onCalendarViewChange: 'calendar:read', onCalendarVisibilityToggle: 'calendar:read',
|
||||||
onCalendarAlert: 'calendar:read', onCalendarAlertAcknowledge: 'calendar:read',
|
onCalendarAlert: 'calendar:read', onCalendarAlertAcknowledge: 'calendar:read',
|
||||||
|
onCheckEventConflicts: 'calendar:read',
|
||||||
onCalendarEventFormOpen: 'calendar:read', onCalendarEventFormSave: 'calendar:write',
|
onCalendarEventFormOpen: 'calendar:read', onCalendarEventFormSave: 'calendar:write',
|
||||||
onBeforeEventCreate: 'calendar:write', onAfterEventCreate: 'calendar:write',
|
onBeforeEventCreate: 'calendar:write', onAfterEventCreate: 'calendar:write',
|
||||||
onBeforeEventUpdate: 'calendar:write', onAfterEventUpdate: 'calendar:write',
|
onBeforeEventUpdate: 'calendar:write', onAfterEventUpdate: 'calendar:write',
|
||||||
@@ -370,6 +414,7 @@ const HOOK_PERMISSIONS: Record<string, Permission> = {
|
|||||||
onCalendarChange: 'calendar:write', onICalSubscriptionChange: 'calendar:write',
|
onCalendarChange: 'calendar:write', onICalSubscriptionChange: 'calendar:write',
|
||||||
// Contacts
|
// Contacts
|
||||||
onContactOpen: 'contacts:read', onContactSelectionChange: 'contacts:read',
|
onContactOpen: 'contacts:read', onContactSelectionChange: 'contacts:read',
|
||||||
|
onProvideRecipientSuggestions: 'contacts:read',
|
||||||
onBeforeContactCreate: 'contacts:write', onAfterContactCreate: 'contacts:write',
|
onBeforeContactCreate: 'contacts:write', onAfterContactCreate: 'contacts:write',
|
||||||
onBeforeContactUpdate: 'contacts:write', onAfterContactUpdate: 'contacts:write',
|
onBeforeContactUpdate: 'contacts:write', onAfterContactUpdate: 'contacts:write',
|
||||||
onBeforeContactDelete: 'contacts:write', onAfterContactDelete: 'contacts:write',
|
onBeforeContactDelete: 'contacts:write', onAfterContactDelete: 'contacts:write',
|
||||||
@@ -419,12 +464,13 @@ const HOOK_PERMISSIONS: Record<string, Permission> = {
|
|||||||
onSidebarCollapse: 'ui:observe', onDeviceTypeChange: 'ui:observe',
|
onSidebarCollapse: 'ui:observe', onDeviceTypeChange: 'ui:observe',
|
||||||
onColumnResize: 'ui:observe', onMobileBack: 'ui:observe',
|
onColumnResize: 'ui:observe', onMobileBack: 'ui:observe',
|
||||||
onMobileViewSwitch: 'ui:observe',
|
onMobileViewSwitch: 'ui:observe',
|
||||||
|
onBeforeExternalLink: 'ui:observe', onTextSelectionChange: 'ui:observe',
|
||||||
// Theme
|
// Theme
|
||||||
onThemeChange: 'ui:observe', onCustomThemeChange: 'ui:observe',
|
onThemeChange: 'ui:observe', onCustomThemeChange: 'ui:observe',
|
||||||
onLocaleChange: 'ui:observe',
|
onLocaleChange: 'ui:observe',
|
||||||
// Toast
|
// Toast
|
||||||
onToastShow: 'ui:observe', onToastDismiss: 'ui:observe',
|
onToastShow: 'ui:observe', onToastDismiss: 'ui:observe',
|
||||||
onBrowserNotification: 'ui:observe',
|
onBrowserNotification: 'ui:observe', onNotificationClick: 'ui:observe',
|
||||||
// Drag & Drop
|
// Drag & Drop
|
||||||
onDragStart: 'ui:observe', onDragEnd: 'ui:observe',
|
onDragStart: 'ui:observe', onDragEnd: 'ui:observe',
|
||||||
onEmailDrop: 'ui:observe', onTagDrop: 'ui:observe',
|
onEmailDrop: 'ui:observe', onTagDrop: 'ui:observe',
|
||||||
@@ -435,6 +481,8 @@ const HOOK_PERMISSIONS: Record<string, Permission> = {
|
|||||||
onAppReady: 'app:lifecycle', onVisibilityChange: 'app:lifecycle',
|
onAppReady: 'app:lifecycle', onVisibilityChange: 'app:lifecycle',
|
||||||
onBeforeUnload: 'app:lifecycle', onAppError: 'app:lifecycle',
|
onBeforeUnload: 'app:lifecycle', onAppError: 'app:lifecycle',
|
||||||
onInterval: 'app:lifecycle',
|
onInterval: 'app:lifecycle',
|
||||||
|
onWindowFocus: 'app:lifecycle', onWindowBlur: 'app:lifecycle',
|
||||||
|
onOnline: 'app:lifecycle', onOffline: 'app:lifecycle',
|
||||||
// Account Security
|
// Account Security
|
||||||
onPasswordChange: 'security:read', onTotpChange: 'security:read',
|
onPasswordChange: 'security:read', onTotpChange: 'security:read',
|
||||||
onAppPasswordChange: 'security:read', onEncryptionChange: 'security:read',
|
onAppPasswordChange: 'security:read', onEncryptionChange: 'security:read',
|
||||||
@@ -444,6 +492,8 @@ const HOOK_PERMISSIONS: Record<string, Permission> = {
|
|||||||
onSidebarAppChange: 'ui:observe',
|
onSidebarAppChange: 'ui:observe',
|
||||||
// Avatar
|
// Avatar
|
||||||
onAvatarResolve: 'email:read',
|
onAvatarResolve: 'email:read',
|
||||||
|
// Router
|
||||||
|
onNavigate: 'ui:observe', onRouteEnter: 'ui:observe', onRouteLeave: 'ui:observe',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Map hook names → actual HookBus instances
|
// 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)),
|
...Object.fromEntries(Object.entries(avatarHooks)),
|
||||||
// Render
|
// Render
|
||||||
...Object.fromEntries(Object.entries(renderHooks)),
|
...Object.fromEntries(Object.entries(renderHooks)),
|
||||||
|
// Router
|
||||||
|
...Object.fromEntries(Object.entries(routerHooks)),
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- Slot registration bridge --------------------------------
|
// --- Slot registration bridge --------------------------------
|
||||||
|
|||||||
+70
-1
@@ -207,6 +207,38 @@ export const emailHooks = {
|
|||||||
// Intercept hook - fired when a mailto: link is clicked.
|
// Intercept hook - fired when a mailto: link is clicked.
|
||||||
// Return false to prevent the browser from opening the system mail client.
|
// Return false to prevent the browser from opening the system mail client.
|
||||||
onMailtoIntercept: new HookBus(),
|
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
|
// §7.2 Calendar Hooks
|
||||||
@@ -227,6 +259,10 @@ export const calendarHooks = {
|
|||||||
onICalSubscriptionChange: new HookBus(),
|
onICalSubscriptionChange: new HookBus(),
|
||||||
onCalendarAlert: new HookBus(),
|
onCalendarAlert: new HookBus(),
|
||||||
onCalendarAlertAcknowledge: 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)
|
// §7.2b Calendar Form Hooks (UI integration)
|
||||||
@@ -249,6 +285,10 @@ export const contactHooks = {
|
|||||||
onContactGroupChange: new HookBus(),
|
onContactGroupChange: new HookBus(),
|
||||||
onContactGroupMemberChange: new HookBus(),
|
onContactGroupMemberChange: new HookBus(),
|
||||||
onContactMove: 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
|
// §7.4 File Hooks
|
||||||
@@ -358,6 +398,14 @@ export const uiHooks = {
|
|||||||
onColumnResize: new HookBus(),
|
onColumnResize: new HookBus(),
|
||||||
onMobileBack: new HookBus(),
|
onMobileBack: new HookBus(),
|
||||||
onMobileViewSwitch: 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
|
// §7.14 Theme Hooks
|
||||||
@@ -384,6 +432,10 @@ export const toastHooks = {
|
|||||||
onToastShow: new HookBus(),
|
onToastShow: new HookBus(),
|
||||||
onToastDismiss: new HookBus(),
|
onToastDismiss: new HookBus(),
|
||||||
onBrowserNotification: 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
|
// §7.16 Drag & Drop Hooks
|
||||||
@@ -408,6 +460,14 @@ export const appLifecycleHooks = {
|
|||||||
onBeforeUnload: new HookBus(),
|
onBeforeUnload: new HookBus(),
|
||||||
onAppError: new HookBus(),
|
onAppError: new HookBus(),
|
||||||
onInterval: 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
|
// §7.19 Account Security Hooks
|
||||||
@@ -433,6 +493,15 @@ export const avatarHooks = {
|
|||||||
onAvatarResolve: new HookBus(),
|
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
|
// §7.22 Render Hooks
|
||||||
export const renderHooks = {
|
export const renderHooks = {
|
||||||
// Transform hook - runs for each visible email list row.
|
// Transform hook - runs for each visible email list row.
|
||||||
@@ -451,7 +520,7 @@ const allHookGroups = [
|
|||||||
taskHooks, templateHooks, smimeHooks, vacationHooks,
|
taskHooks, templateHooks, smimeHooks, vacationHooks,
|
||||||
uiHooks, themeHooks, toastHooks, dragDropHooks,
|
uiHooks, themeHooks, toastHooks, dragDropHooks,
|
||||||
keyboardHooks, appLifecycleHooks, accountSecurityHooks, sidebarAppHooks,
|
keyboardHooks, appLifecycleHooks, accountSecurityHooks, sidebarAppHooks,
|
||||||
avatarHooks, renderHooks,
|
avatarHooks, renderHooks, routerHooks,
|
||||||
];
|
];
|
||||||
|
|
||||||
export function removeAllPluginHooks(pluginId: string): void {
|
export function removeAllPluginHooks(pluginId: string): void {
|
||||||
|
|||||||
@@ -522,6 +522,137 @@ export interface MailtoContext {
|
|||||||
body?: string;
|
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 ─────────────────────────────────────────
|
// ─── Plugin i18n API ─────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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/';
|
||||||
@@ -494,6 +494,8 @@
|
|||||||
"send": "Send",
|
"send": "Send",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"attach": "Attach",
|
"attach": "Attach",
|
||||||
|
"attach_photos": "Photos & Videos",
|
||||||
|
"attach_files": "Files",
|
||||||
"discard": "Discard",
|
"discard": "Discard",
|
||||||
"discard_draft_title": "Discard draft?",
|
"discard_draft_title": "Discard draft?",
|
||||||
"discard_draft_confirm": "You have unsaved changes. Do you want to discard this draft?",
|
"discard_draft_confirm": "You have unsaved changes. Do you want to discard this draft?",
|
||||||
|
|||||||
+35
-35
@@ -1670,41 +1670,41 @@
|
|||||||
"edit_draft": "Editar borrador"
|
"edit_draft": "Editar borrador"
|
||||||
},
|
},
|
||||||
"mailbox_context_menu": {
|
"mailbox_context_menu": {
|
||||||
"mark_folder_read": "Mark folder as read",
|
"mark_folder_read": "Marcar carpeta como leída",
|
||||||
"mark_folder_tree_read": "Mark folder & subfolders as read",
|
"mark_folder_tree_read": "Marcar carpeta y subcarpetas como leídas",
|
||||||
"mark_all_folders_read": "Mark all folders as read",
|
"mark_all_folders_read": "Marcar todas las carpetas como leídas",
|
||||||
"new_subfolder": "New subfolder...",
|
"new_subfolder": "Nueva subcarpeta...",
|
||||||
"new_folder": "New folder...",
|
"new_folder": "Nueva carpeta...",
|
||||||
"rename": "Rename...",
|
"rename": "Renombrar...",
|
||||||
"empty_folder": "Empty folder",
|
"empty_folder": "Vaciar carpeta",
|
||||||
"empty_folder_generic": "Empty folder",
|
"empty_folder_generic": "Vaciar carpeta",
|
||||||
"delete_folder": "Delete folder",
|
"delete_folder": "Eliminar carpeta",
|
||||||
"refresh": "Refresh",
|
"refresh": "Actualizar",
|
||||||
"mark_all_confirm_title": "Mark all folders as read",
|
"mark_all_confirm_title": "Marcar todas las carpetas como leídas",
|
||||||
"mark_all_confirm_message": "Mark every unread message in your personal account as read?",
|
"mark_all_confirm_message": "¿Marcar todos los mensajes no leídos de su cuenta personal como leídos?",
|
||||||
"delete_confirm_title": "Delete folder",
|
"delete_confirm_title": "Eliminar carpeta",
|
||||||
"delete_confirm_message": "Permanently delete the folder \"{name}\"? This action cannot be undone.",
|
"delete_confirm_message": "¿Eliminar permanentemente la carpeta \"{name}\"? Esta acción no se puede deshacer.",
|
||||||
"prompt_new_subfolder": "Enter a name for the new subfolder.",
|
"prompt_new_subfolder": "Ingrese un nombre para la nueva subcarpeta.",
|
||||||
"prompt_new_folder": "Enter a name for the new folder.",
|
"prompt_new_folder": "Ingrese un nombre para la nueva carpeta.",
|
||||||
"prompt_rename": "Enter a new name for this folder.",
|
"prompt_rename": "Ingrese un nuevo nombre para esta carpeta.",
|
||||||
"toast_marked_read": "Folder marked as read",
|
"toast_marked_read": "Carpeta marcada como leída",
|
||||||
"toast_marked_read_count": "Marked {count, plural, one {1 message} other {# messages}} as read",
|
"toast_marked_read_count": "Se marcaron {count, plural, one {1 mensaje} other {# mensajes}} como leídos",
|
||||||
"toast_already_read": "No unread messages",
|
"toast_already_read": "No hay mensajes no leídos",
|
||||||
"toast_marked_all_read": "All folders marked as read",
|
"toast_marked_all_read": "Todas las carpetas marcadas como leídas",
|
||||||
"toast_emptied": "Folder emptied",
|
"toast_emptied": "Carpeta vaciada",
|
||||||
"toast_folder_created": "Folder created",
|
"toast_folder_created": "Carpeta creada",
|
||||||
"toast_folder_renamed": "Folder renamed",
|
"toast_folder_renamed": "Carpeta renombrada",
|
||||||
"toast_folder_deleted": "Folder deleted",
|
"toast_folder_deleted": "Carpeta eliminada",
|
||||||
"toast_error_mark_read": "Failed to mark as read",
|
"toast_error_mark_read": "Error al marcar como leído",
|
||||||
"toast_error_empty": "Failed to empty folder",
|
"toast_error_empty": "Error al vaciar la carpeta",
|
||||||
"toast_error_create": "Failed to create folder",
|
"toast_error_create": "Error al crear la carpeta",
|
||||||
"toast_error_rename": "Failed to rename folder",
|
"toast_error_rename": "Error al renombrar la carpeta",
|
||||||
"toast_error_delete": "Failed to delete folder",
|
"toast_error_delete": "Error al eliminar la carpeta",
|
||||||
"toast_error_delete_has_children": "Folder has subfolders. Remove them first.",
|
"toast_error_delete_has_children": "La carpeta tiene subcarpetas. Elimínelas primero.",
|
||||||
"toast_error_delete_has_email": "Folder is not empty. Empty it first.",
|
"toast_error_delete_has_email": "La carpeta no está vacía. Vacíela primero.",
|
||||||
"placeholder_folder_name": "Folder name",
|
"placeholder_folder_name": "Nombre de carpeta",
|
||||||
"create": "Create",
|
"create": "Crear",
|
||||||
"rename_confirm": "Rename"
|
"rename_confirm": "Renombrar"
|
||||||
},
|
},
|
||||||
"shortcuts": {
|
"shortcuts": {
|
||||||
"title": "Atajos de Teclado",
|
"title": "Atajos de Teclado",
|
||||||
|
|||||||
+35
-35
@@ -1670,41 +1670,41 @@
|
|||||||
"edit_draft": "Modifier le brouillon"
|
"edit_draft": "Modifier le brouillon"
|
||||||
},
|
},
|
||||||
"mailbox_context_menu": {
|
"mailbox_context_menu": {
|
||||||
"mark_folder_read": "Mark folder as read",
|
"mark_folder_read": "Marquer le dossier comme lu",
|
||||||
"mark_folder_tree_read": "Mark folder & subfolders as read",
|
"mark_folder_tree_read": "Marquer le dossier et les sous-dossiers comme lus",
|
||||||
"mark_all_folders_read": "Mark all folders as read",
|
"mark_all_folders_read": "Marquer tous les dossiers comme lus",
|
||||||
"new_subfolder": "New subfolder...",
|
"new_subfolder": "Nouveau sous-dossier...",
|
||||||
"new_folder": "New folder...",
|
"new_folder": "Nouveau dossier...",
|
||||||
"rename": "Rename...",
|
"rename": "Renommer...",
|
||||||
"empty_folder": "Empty folder",
|
"empty_folder": "Vider le dossier",
|
||||||
"empty_folder_generic": "Empty folder",
|
"empty_folder_generic": "Vider le dossier",
|
||||||
"delete_folder": "Delete folder",
|
"delete_folder": "Supprimer le dossier",
|
||||||
"refresh": "Refresh",
|
"refresh": "Actualiser",
|
||||||
"mark_all_confirm_title": "Mark all folders as read",
|
"mark_all_confirm_title": "Marquer tous les dossiers comme lus",
|
||||||
"mark_all_confirm_message": "Mark every unread message in your personal account as read?",
|
"mark_all_confirm_message": "Marquer tous les messages non lus de votre compte personnel comme lus ?",
|
||||||
"delete_confirm_title": "Delete folder",
|
"delete_confirm_title": "Supprimer le dossier",
|
||||||
"delete_confirm_message": "Permanently delete the folder \"{name}\"? This action cannot be undone.",
|
"delete_confirm_message": "Supprimer définitivement le dossier \"{name}\" ? Cette action est irréversible.",
|
||||||
"prompt_new_subfolder": "Enter a name for the new subfolder.",
|
"prompt_new_subfolder": "Entrez un nom pour le nouveau sous-dossier.",
|
||||||
"prompt_new_folder": "Enter a name for the new folder.",
|
"prompt_new_folder": "Entrez un nom pour le nouveau dossier.",
|
||||||
"prompt_rename": "Enter a new name for this folder.",
|
"prompt_rename": "Entrez un nouveau nom pour ce dossier.",
|
||||||
"toast_marked_read": "Folder marked as read",
|
"toast_marked_read": "Dossier marqué comme lu",
|
||||||
"toast_marked_read_count": "Marked {count, plural, one {1 message} other {# messages}} as read",
|
"toast_marked_read_count": "{count, plural, one {1 message marqué} other {# messages marqués}} comme lu(s)",
|
||||||
"toast_already_read": "No unread messages",
|
"toast_already_read": "Aucun message non lu",
|
||||||
"toast_marked_all_read": "All folders marked as read",
|
"toast_marked_all_read": "Tous les dossiers marqués comme lus",
|
||||||
"toast_emptied": "Folder emptied",
|
"toast_emptied": "Dossier vidé",
|
||||||
"toast_folder_created": "Folder created",
|
"toast_folder_created": "Dossier créé",
|
||||||
"toast_folder_renamed": "Folder renamed",
|
"toast_folder_renamed": "Dossier renommé",
|
||||||
"toast_folder_deleted": "Folder deleted",
|
"toast_folder_deleted": "Dossier supprimé",
|
||||||
"toast_error_mark_read": "Failed to mark as read",
|
"toast_error_mark_read": "Échec du marquage comme lu",
|
||||||
"toast_error_empty": "Failed to empty folder",
|
"toast_error_empty": "Échec du vidage du dossier",
|
||||||
"toast_error_create": "Failed to create folder",
|
"toast_error_create": "Échec de la création du dossier",
|
||||||
"toast_error_rename": "Failed to rename folder",
|
"toast_error_rename": "Échec du renommage du dossier",
|
||||||
"toast_error_delete": "Failed to delete folder",
|
"toast_error_delete": "Échec de la suppression du dossier",
|
||||||
"toast_error_delete_has_children": "Folder has subfolders. Remove them first.",
|
"toast_error_delete_has_children": "Le dossier contient des sous-dossiers. Supprimez-les d'abord.",
|
||||||
"toast_error_delete_has_email": "Folder is not empty. Empty it first.",
|
"toast_error_delete_has_email": "Le dossier n'est pas vide. Videz-le d'abord.",
|
||||||
"placeholder_folder_name": "Folder name",
|
"placeholder_folder_name": "Nom du dossier",
|
||||||
"create": "Create",
|
"create": "Créer",
|
||||||
"rename_confirm": "Rename"
|
"rename_confirm": "Renommer"
|
||||||
},
|
},
|
||||||
"shortcuts": {
|
"shortcuts": {
|
||||||
"title": "Raccourcis clavier",
|
"title": "Raccourcis clavier",
|
||||||
|
|||||||
+35
-35
@@ -1670,41 +1670,41 @@
|
|||||||
"edit_draft": "Modifica bozza"
|
"edit_draft": "Modifica bozza"
|
||||||
},
|
},
|
||||||
"mailbox_context_menu": {
|
"mailbox_context_menu": {
|
||||||
"mark_folder_read": "Mark folder as read",
|
"mark_folder_read": "Segna cartella come letta",
|
||||||
"mark_folder_tree_read": "Mark folder & subfolders as read",
|
"mark_folder_tree_read": "Segna cartella e sottocartelle come lette",
|
||||||
"mark_all_folders_read": "Mark all folders as read",
|
"mark_all_folders_read": "Segna tutte le cartelle come lette",
|
||||||
"new_subfolder": "New subfolder...",
|
"new_subfolder": "Nuova sottocartella...",
|
||||||
"new_folder": "New folder...",
|
"new_folder": "Nuova cartella...",
|
||||||
"rename": "Rename...",
|
"rename": "Rinomina...",
|
||||||
"empty_folder": "Empty folder",
|
"empty_folder": "Svuota cartella",
|
||||||
"empty_folder_generic": "Empty folder",
|
"empty_folder_generic": "Svuota cartella",
|
||||||
"delete_folder": "Delete folder",
|
"delete_folder": "Elimina cartella",
|
||||||
"refresh": "Refresh",
|
"refresh": "Aggiorna",
|
||||||
"mark_all_confirm_title": "Mark all folders as read",
|
"mark_all_confirm_title": "Segna tutte le cartelle come lette",
|
||||||
"mark_all_confirm_message": "Mark every unread message in your personal account as read?",
|
"mark_all_confirm_message": "Segnare tutti i messaggi non letti del tuo account personale come letti?",
|
||||||
"delete_confirm_title": "Delete folder",
|
"delete_confirm_title": "Elimina cartella",
|
||||||
"delete_confirm_message": "Permanently delete the folder \"{name}\"? This action cannot be undone.",
|
"delete_confirm_message": "Eliminare definitivamente la cartella \"{name}\"? Questa azione non può essere annullata.",
|
||||||
"prompt_new_subfolder": "Enter a name for the new subfolder.",
|
"prompt_new_subfolder": "Inserisci un nome per la nuova sottocartella.",
|
||||||
"prompt_new_folder": "Enter a name for the new folder.",
|
"prompt_new_folder": "Inserisci un nome per la nuova cartella.",
|
||||||
"prompt_rename": "Enter a new name for this folder.",
|
"prompt_rename": "Inserisci un nuovo nome per questa cartella.",
|
||||||
"toast_marked_read": "Folder marked as read",
|
"toast_marked_read": "Cartella segnata come letta",
|
||||||
"toast_marked_read_count": "Marked {count, plural, one {1 message} other {# messages}} as read",
|
"toast_marked_read_count": "Segnati {count, plural, one {1 messaggio} other {# messaggi}} come letti",
|
||||||
"toast_already_read": "No unread messages",
|
"toast_already_read": "Nessun messaggio non letto",
|
||||||
"toast_marked_all_read": "All folders marked as read",
|
"toast_marked_all_read": "Tutte le cartelle segnate come lette",
|
||||||
"toast_emptied": "Folder emptied",
|
"toast_emptied": "Cartella svuotata",
|
||||||
"toast_folder_created": "Folder created",
|
"toast_folder_created": "Cartella creata",
|
||||||
"toast_folder_renamed": "Folder renamed",
|
"toast_folder_renamed": "Cartella rinominata",
|
||||||
"toast_folder_deleted": "Folder deleted",
|
"toast_folder_deleted": "Cartella eliminata",
|
||||||
"toast_error_mark_read": "Failed to mark as read",
|
"toast_error_mark_read": "Impossibile segnare come letto",
|
||||||
"toast_error_empty": "Failed to empty folder",
|
"toast_error_empty": "Impossibile svuotare la cartella",
|
||||||
"toast_error_create": "Failed to create folder",
|
"toast_error_create": "Impossibile creare la cartella",
|
||||||
"toast_error_rename": "Failed to rename folder",
|
"toast_error_rename": "Impossibile rinominare la cartella",
|
||||||
"toast_error_delete": "Failed to delete folder",
|
"toast_error_delete": "Impossibile eliminare la cartella",
|
||||||
"toast_error_delete_has_children": "Folder has subfolders. Remove them first.",
|
"toast_error_delete_has_children": "La cartella contiene sottocartelle. Rimuoverle prima.",
|
||||||
"toast_error_delete_has_email": "Folder is not empty. Empty it first.",
|
"toast_error_delete_has_email": "La cartella non è vuota. Svuotarla prima.",
|
||||||
"placeholder_folder_name": "Folder name",
|
"placeholder_folder_name": "Nome cartella",
|
||||||
"create": "Create",
|
"create": "Crea",
|
||||||
"rename_confirm": "Rename"
|
"rename_confirm": "Rinomina"
|
||||||
},
|
},
|
||||||
"shortcuts": {
|
"shortcuts": {
|
||||||
"title": "Scorciatoie da tastiera",
|
"title": "Scorciatoie da tastiera",
|
||||||
|
|||||||
+35
-35
@@ -1670,41 +1670,41 @@
|
|||||||
"edit_draft": "下書きを編集"
|
"edit_draft": "下書きを編集"
|
||||||
},
|
},
|
||||||
"mailbox_context_menu": {
|
"mailbox_context_menu": {
|
||||||
"mark_folder_read": "Mark folder as read",
|
"mark_folder_read": "フォルダーを既読にする",
|
||||||
"mark_folder_tree_read": "Mark folder & subfolders as read",
|
"mark_folder_tree_read": "フォルダーとサブフォルダーを既読にする",
|
||||||
"mark_all_folders_read": "Mark all folders as read",
|
"mark_all_folders_read": "すべてのフォルダーを既読にする",
|
||||||
"new_subfolder": "New subfolder...",
|
"new_subfolder": "新しいサブフォルダー...",
|
||||||
"new_folder": "New folder...",
|
"new_folder": "新しいフォルダー...",
|
||||||
"rename": "Rename...",
|
"rename": "名前を変更...",
|
||||||
"empty_folder": "Empty folder",
|
"empty_folder": "フォルダーを空にする",
|
||||||
"empty_folder_generic": "Empty folder",
|
"empty_folder_generic": "フォルダーを空にする",
|
||||||
"delete_folder": "Delete folder",
|
"delete_folder": "フォルダーを削除",
|
||||||
"refresh": "Refresh",
|
"refresh": "更新",
|
||||||
"mark_all_confirm_title": "Mark all folders as read",
|
"mark_all_confirm_title": "すべてのフォルダーを既読にする",
|
||||||
"mark_all_confirm_message": "Mark every unread message in your personal account as read?",
|
"mark_all_confirm_message": "個人アカウントのすべての未読メッセージを既読にしますか?",
|
||||||
"delete_confirm_title": "Delete folder",
|
"delete_confirm_title": "フォルダーを削除",
|
||||||
"delete_confirm_message": "Permanently delete the folder \"{name}\"? This action cannot be undone.",
|
"delete_confirm_message": "フォルダー \"{name}\" を完全に削除しますか?この操作は元に戻せません。",
|
||||||
"prompt_new_subfolder": "Enter a name for the new subfolder.",
|
"prompt_new_subfolder": "新しいサブフォルダーの名前を入力してください。",
|
||||||
"prompt_new_folder": "Enter a name for the new folder.",
|
"prompt_new_folder": "新しいフォルダーの名前を入力してください。",
|
||||||
"prompt_rename": "Enter a new name for this folder.",
|
"prompt_rename": "このフォルダーの新しい名前を入力してください。",
|
||||||
"toast_marked_read": "Folder marked as read",
|
"toast_marked_read": "フォルダーを既読にしました",
|
||||||
"toast_marked_read_count": "Marked {count, plural, one {1 message} other {# messages}} as read",
|
"toast_marked_read_count": "{count, plural, one {1件のメッセージ} other {#件のメッセージ}}を既読にしました",
|
||||||
"toast_already_read": "No unread messages",
|
"toast_already_read": "未読メッセージはありません",
|
||||||
"toast_marked_all_read": "All folders marked as read",
|
"toast_marked_all_read": "すべてのフォルダーを既読にしました",
|
||||||
"toast_emptied": "Folder emptied",
|
"toast_emptied": "フォルダーを空にしました",
|
||||||
"toast_folder_created": "Folder created",
|
"toast_folder_created": "フォルダーを作成しました",
|
||||||
"toast_folder_renamed": "Folder renamed",
|
"toast_folder_renamed": "フォルダー名を変更しました",
|
||||||
"toast_folder_deleted": "Folder deleted",
|
"toast_folder_deleted": "フォルダーを削除しました",
|
||||||
"toast_error_mark_read": "Failed to mark as read",
|
"toast_error_mark_read": "既読にできませんでした",
|
||||||
"toast_error_empty": "Failed to empty folder",
|
"toast_error_empty": "フォルダーを空にできませんでした",
|
||||||
"toast_error_create": "Failed to create folder",
|
"toast_error_create": "フォルダーを作成できませんでした",
|
||||||
"toast_error_rename": "Failed to rename folder",
|
"toast_error_rename": "フォルダー名を変更できませんでした",
|
||||||
"toast_error_delete": "Failed to delete folder",
|
"toast_error_delete": "フォルダーを削除できませんでした",
|
||||||
"toast_error_delete_has_children": "Folder has subfolders. Remove them first.",
|
"toast_error_delete_has_children": "フォルダーにサブフォルダーがあります。先に削除してください。",
|
||||||
"toast_error_delete_has_email": "Folder is not empty. Empty it first.",
|
"toast_error_delete_has_email": "フォルダーが空ではありません。先に空にしてください。",
|
||||||
"placeholder_folder_name": "Folder name",
|
"placeholder_folder_name": "フォルダー名",
|
||||||
"create": "Create",
|
"create": "作成",
|
||||||
"rename_confirm": "Rename"
|
"rename_confirm": "名前を変更"
|
||||||
},
|
},
|
||||||
"shortcuts": {
|
"shortcuts": {
|
||||||
"title": "キーボードショートカット",
|
"title": "キーボードショートカット",
|
||||||
|
|||||||
+35
-35
@@ -1670,41 +1670,41 @@
|
|||||||
"edit_draft": "임시보관 메일 수정"
|
"edit_draft": "임시보관 메일 수정"
|
||||||
},
|
},
|
||||||
"mailbox_context_menu": {
|
"mailbox_context_menu": {
|
||||||
"mark_folder_read": "Mark folder as read",
|
"mark_folder_read": "폴더를 읽음으로 표시",
|
||||||
"mark_folder_tree_read": "Mark folder & subfolders as read",
|
"mark_folder_tree_read": "폴더 및 하위 폴더를 읽음으로 표시",
|
||||||
"mark_all_folders_read": "Mark all folders as read",
|
"mark_all_folders_read": "모든 폴더를 읽음으로 표시",
|
||||||
"new_subfolder": "New subfolder...",
|
"new_subfolder": "새 하위 폴더...",
|
||||||
"new_folder": "New folder...",
|
"new_folder": "새 폴더...",
|
||||||
"rename": "Rename...",
|
"rename": "이름 바꾸기...",
|
||||||
"empty_folder": "Empty folder",
|
"empty_folder": "폴더 비우기",
|
||||||
"empty_folder_generic": "Empty folder",
|
"empty_folder_generic": "폴더 비우기",
|
||||||
"delete_folder": "Delete folder",
|
"delete_folder": "폴더 삭제",
|
||||||
"refresh": "Refresh",
|
"refresh": "새로 고침",
|
||||||
"mark_all_confirm_title": "Mark all folders as read",
|
"mark_all_confirm_title": "모든 폴더를 읽음으로 표시",
|
||||||
"mark_all_confirm_message": "Mark every unread message in your personal account as read?",
|
"mark_all_confirm_message": "개인 계정의 모든 읽지 않은 메시지를 읽음으로 표시하시겠습니까?",
|
||||||
"delete_confirm_title": "Delete folder",
|
"delete_confirm_title": "폴더 삭제",
|
||||||
"delete_confirm_message": "Permanently delete the folder \"{name}\"? This action cannot be undone.",
|
"delete_confirm_message": "폴더 \"{name}\"을(를) 영구적으로 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.",
|
||||||
"prompt_new_subfolder": "Enter a name for the new subfolder.",
|
"prompt_new_subfolder": "새 하위 폴더의 이름을 입력하세요.",
|
||||||
"prompt_new_folder": "Enter a name for the new folder.",
|
"prompt_new_folder": "새 폴더의 이름을 입력하세요.",
|
||||||
"prompt_rename": "Enter a new name for this folder.",
|
"prompt_rename": "이 폴더의 새 이름을 입력하세요.",
|
||||||
"toast_marked_read": "Folder marked as read",
|
"toast_marked_read": "폴더를 읽음으로 표시했습니다",
|
||||||
"toast_marked_read_count": "Marked {count, plural, one {1 message} other {# messages}} as read",
|
"toast_marked_read_count": "{count, plural, one {메시지 1개} other {메시지 #개}}을(를) 읽음으로 표시했습니다",
|
||||||
"toast_already_read": "No unread messages",
|
"toast_already_read": "읽지 않은 메시지가 없습니다",
|
||||||
"toast_marked_all_read": "All folders marked as read",
|
"toast_marked_all_read": "모든 폴더를 읽음으로 표시했습니다",
|
||||||
"toast_emptied": "Folder emptied",
|
"toast_emptied": "폴더를 비웠습니다",
|
||||||
"toast_folder_created": "Folder created",
|
"toast_folder_created": "폴더가 생성되었습니다",
|
||||||
"toast_folder_renamed": "Folder renamed",
|
"toast_folder_renamed": "폴더 이름이 변경되었습니다",
|
||||||
"toast_folder_deleted": "Folder deleted",
|
"toast_folder_deleted": "폴더가 삭제되었습니다",
|
||||||
"toast_error_mark_read": "Failed to mark as read",
|
"toast_error_mark_read": "읽음으로 표시하지 못했습니다",
|
||||||
"toast_error_empty": "Failed to empty folder",
|
"toast_error_empty": "폴더를 비우지 못했습니다",
|
||||||
"toast_error_create": "Failed to create folder",
|
"toast_error_create": "폴더를 생성하지 못했습니다",
|
||||||
"toast_error_rename": "Failed to rename folder",
|
"toast_error_rename": "폴더 이름을 변경하지 못했습니다",
|
||||||
"toast_error_delete": "Failed to delete folder",
|
"toast_error_delete": "폴더를 삭제하지 못했습니다",
|
||||||
"toast_error_delete_has_children": "Folder has subfolders. Remove them first.",
|
"toast_error_delete_has_children": "폴더에 하위 폴더가 있습니다. 먼저 제거하세요.",
|
||||||
"toast_error_delete_has_email": "Folder is not empty. Empty it first.",
|
"toast_error_delete_has_email": "폴더가 비어 있지 않습니다. 먼저 비우세요.",
|
||||||
"placeholder_folder_name": "Folder name",
|
"placeholder_folder_name": "폴더 이름",
|
||||||
"create": "Create",
|
"create": "만들기",
|
||||||
"rename_confirm": "Rename"
|
"rename_confirm": "이름 바꾸기"
|
||||||
},
|
},
|
||||||
"shortcuts": {
|
"shortcuts": {
|
||||||
"title": "단축키",
|
"title": "단축키",
|
||||||
|
|||||||
+35
-35
@@ -1670,41 +1670,41 @@
|
|||||||
"edit_draft": "Rediģēt melnrakstu"
|
"edit_draft": "Rediģēt melnrakstu"
|
||||||
},
|
},
|
||||||
"mailbox_context_menu": {
|
"mailbox_context_menu": {
|
||||||
"mark_folder_read": "Mark folder as read",
|
"mark_folder_read": "Atzīmēt mapi kā lasītu",
|
||||||
"mark_folder_tree_read": "Mark folder & subfolders as read",
|
"mark_folder_tree_read": "Atzīmēt mapi un apakšmapes kā lasītas",
|
||||||
"mark_all_folders_read": "Mark all folders as read",
|
"mark_all_folders_read": "Atzīmēt visas mapes kā lasītas",
|
||||||
"new_subfolder": "New subfolder...",
|
"new_subfolder": "Jauna apakšmape...",
|
||||||
"new_folder": "New folder...",
|
"new_folder": "Jauna mape...",
|
||||||
"rename": "Rename...",
|
"rename": "Pārsaukt...",
|
||||||
"empty_folder": "Empty folder",
|
"empty_folder": "Iztukšot mapi",
|
||||||
"empty_folder_generic": "Empty folder",
|
"empty_folder_generic": "Iztukšot mapi",
|
||||||
"delete_folder": "Delete folder",
|
"delete_folder": "Dzēst mapi",
|
||||||
"refresh": "Refresh",
|
"refresh": "Atjaunināt",
|
||||||
"mark_all_confirm_title": "Mark all folders as read",
|
"mark_all_confirm_title": "Atzīmēt visas mapes kā lasītas",
|
||||||
"mark_all_confirm_message": "Mark every unread message in your personal account as read?",
|
"mark_all_confirm_message": "Atzīmēt visas nelasītās ziņas jūsu personīgajā kontā kā lasītas?",
|
||||||
"delete_confirm_title": "Delete folder",
|
"delete_confirm_title": "Dzēst mapi",
|
||||||
"delete_confirm_message": "Permanently delete the folder \"{name}\"? This action cannot be undone.",
|
"delete_confirm_message": "Neatgriezeniski dzēst mapi \"{name}\"? Šo darbību nevar atsaukt.",
|
||||||
"prompt_new_subfolder": "Enter a name for the new subfolder.",
|
"prompt_new_subfolder": "Ievadiet nosaukumu jaunajai apakšmapei.",
|
||||||
"prompt_new_folder": "Enter a name for the new folder.",
|
"prompt_new_folder": "Ievadiet nosaukumu jaunajai mapei.",
|
||||||
"prompt_rename": "Enter a new name for this folder.",
|
"prompt_rename": "Ievadiet jaunu nosaukumu šai mapei.",
|
||||||
"toast_marked_read": "Folder marked as read",
|
"toast_marked_read": "Mape atzīmēta kā lasīta",
|
||||||
"toast_marked_read_count": "Marked {count, plural, one {1 message} other {# messages}} as read",
|
"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": "No unread messages",
|
"toast_already_read": "Nav nelasītu ziņu",
|
||||||
"toast_marked_all_read": "All folders marked as read",
|
"toast_marked_all_read": "Visas mapes atzīmētas kā lasītas",
|
||||||
"toast_emptied": "Folder emptied",
|
"toast_emptied": "Mape iztukšota",
|
||||||
"toast_folder_created": "Folder created",
|
"toast_folder_created": "Mape izveidota",
|
||||||
"toast_folder_renamed": "Folder renamed",
|
"toast_folder_renamed": "Mape pārsaukta",
|
||||||
"toast_folder_deleted": "Folder deleted",
|
"toast_folder_deleted": "Mape dzēsta",
|
||||||
"toast_error_mark_read": "Failed to mark as read",
|
"toast_error_mark_read": "Neizdevās atzīmēt kā lasītu",
|
||||||
"toast_error_empty": "Failed to empty folder",
|
"toast_error_empty": "Neizdevās iztukšot mapi",
|
||||||
"toast_error_create": "Failed to create folder",
|
"toast_error_create": "Neizdevās izveidot mapi",
|
||||||
"toast_error_rename": "Failed to rename folder",
|
"toast_error_rename": "Neizdevās pārsaukt mapi",
|
||||||
"toast_error_delete": "Failed to delete folder",
|
"toast_error_delete": "Neizdevās dzēst mapi",
|
||||||
"toast_error_delete_has_children": "Folder has subfolders. Remove them first.",
|
"toast_error_delete_has_children": "Mapei ir apakšmapes. Vispirms noņemiet tās.",
|
||||||
"toast_error_delete_has_email": "Folder is not empty. Empty it first.",
|
"toast_error_delete_has_email": "Mape nav tukša. Vispirms iztukšojiet to.",
|
||||||
"placeholder_folder_name": "Folder name",
|
"placeholder_folder_name": "Mapes nosaukums",
|
||||||
"create": "Create",
|
"create": "Izveidot",
|
||||||
"rename_confirm": "Rename"
|
"rename_confirm": "Pārsaukt"
|
||||||
},
|
},
|
||||||
"shortcuts": {
|
"shortcuts": {
|
||||||
"title": "Īsinājumtaustiņi",
|
"title": "Īsinājumtaustiņi",
|
||||||
|
|||||||
+35
-35
@@ -1670,41 +1670,41 @@
|
|||||||
"edit_draft": "Concept bewerken"
|
"edit_draft": "Concept bewerken"
|
||||||
},
|
},
|
||||||
"mailbox_context_menu": {
|
"mailbox_context_menu": {
|
||||||
"mark_folder_read": "Mark folder as read",
|
"mark_folder_read": "Map markeren als gelezen",
|
||||||
"mark_folder_tree_read": "Mark folder & subfolders as read",
|
"mark_folder_tree_read": "Map en submappen markeren als gelezen",
|
||||||
"mark_all_folders_read": "Mark all folders as read",
|
"mark_all_folders_read": "Alle mappen markeren als gelezen",
|
||||||
"new_subfolder": "New subfolder...",
|
"new_subfolder": "Nieuwe submap...",
|
||||||
"new_folder": "New folder...",
|
"new_folder": "Nieuwe map...",
|
||||||
"rename": "Rename...",
|
"rename": "Hernoemen...",
|
||||||
"empty_folder": "Empty folder",
|
"empty_folder": "Map leegmaken",
|
||||||
"empty_folder_generic": "Empty folder",
|
"empty_folder_generic": "Map leegmaken",
|
||||||
"delete_folder": "Delete folder",
|
"delete_folder": "Map verwijderen",
|
||||||
"refresh": "Refresh",
|
"refresh": "Vernieuwen",
|
||||||
"mark_all_confirm_title": "Mark all folders as read",
|
"mark_all_confirm_title": "Alle mappen markeren als gelezen",
|
||||||
"mark_all_confirm_message": "Mark every unread message in your personal account as read?",
|
"mark_all_confirm_message": "Alle ongelezen berichten in uw persoonlijke account als gelezen markeren?",
|
||||||
"delete_confirm_title": "Delete folder",
|
"delete_confirm_title": "Map verwijderen",
|
||||||
"delete_confirm_message": "Permanently delete the folder \"{name}\"? This action cannot be undone.",
|
"delete_confirm_message": "Map \"{name}\" definitief verwijderen? Deze actie kan niet ongedaan worden gemaakt.",
|
||||||
"prompt_new_subfolder": "Enter a name for the new subfolder.",
|
"prompt_new_subfolder": "Voer een naam in voor de nieuwe submap.",
|
||||||
"prompt_new_folder": "Enter a name for the new folder.",
|
"prompt_new_folder": "Voer een naam in voor de nieuwe map.",
|
||||||
"prompt_rename": "Enter a new name for this folder.",
|
"prompt_rename": "Voer een nieuwe naam in voor deze map.",
|
||||||
"toast_marked_read": "Folder marked as read",
|
"toast_marked_read": "Map gemarkeerd als gelezen",
|
||||||
"toast_marked_read_count": "Marked {count, plural, one {1 message} other {# messages}} as read",
|
"toast_marked_read_count": "{count, plural, one {1 bericht} other {# berichten}} gemarkeerd als gelezen",
|
||||||
"toast_already_read": "No unread messages",
|
"toast_already_read": "Geen ongelezen berichten",
|
||||||
"toast_marked_all_read": "All folders marked as read",
|
"toast_marked_all_read": "Alle mappen gemarkeerd als gelezen",
|
||||||
"toast_emptied": "Folder emptied",
|
"toast_emptied": "Map leeggemaakt",
|
||||||
"toast_folder_created": "Folder created",
|
"toast_folder_created": "Map aangemaakt",
|
||||||
"toast_folder_renamed": "Folder renamed",
|
"toast_folder_renamed": "Map hernoemd",
|
||||||
"toast_folder_deleted": "Folder deleted",
|
"toast_folder_deleted": "Map verwijderd",
|
||||||
"toast_error_mark_read": "Failed to mark as read",
|
"toast_error_mark_read": "Markeren als gelezen mislukt",
|
||||||
"toast_error_empty": "Failed to empty folder",
|
"toast_error_empty": "Map leegmaken mislukt",
|
||||||
"toast_error_create": "Failed to create folder",
|
"toast_error_create": "Map aanmaken mislukt",
|
||||||
"toast_error_rename": "Failed to rename folder",
|
"toast_error_rename": "Map hernoemen mislukt",
|
||||||
"toast_error_delete": "Failed to delete folder",
|
"toast_error_delete": "Map verwijderen mislukt",
|
||||||
"toast_error_delete_has_children": "Folder has subfolders. Remove them first.",
|
"toast_error_delete_has_children": "Map bevat submappen. Verwijder deze eerst.",
|
||||||
"toast_error_delete_has_email": "Folder is not empty. Empty it first.",
|
"toast_error_delete_has_email": "Map is niet leeg. Maak deze eerst leeg.",
|
||||||
"placeholder_folder_name": "Folder name",
|
"placeholder_folder_name": "Mapnaam",
|
||||||
"create": "Create",
|
"create": "Aanmaken",
|
||||||
"rename_confirm": "Rename"
|
"rename_confirm": "Hernoemen"
|
||||||
},
|
},
|
||||||
"shortcuts": {
|
"shortcuts": {
|
||||||
"title": "Sneltoetsen",
|
"title": "Sneltoetsen",
|
||||||
|
|||||||
+35
-35
@@ -1670,41 +1670,41 @@
|
|||||||
"edit_draft": "Edytuj szkic"
|
"edit_draft": "Edytuj szkic"
|
||||||
},
|
},
|
||||||
"mailbox_context_menu": {
|
"mailbox_context_menu": {
|
||||||
"mark_folder_read": "Mark folder as read",
|
"mark_folder_read": "Oznacz folder jako przeczytany",
|
||||||
"mark_folder_tree_read": "Mark folder & subfolders as read",
|
"mark_folder_tree_read": "Oznacz folder i podfoldery jako przeczytane",
|
||||||
"mark_all_folders_read": "Mark all folders as read",
|
"mark_all_folders_read": "Oznacz wszystkie foldery jako przeczytane",
|
||||||
"new_subfolder": "New subfolder...",
|
"new_subfolder": "Nowy podfolder...",
|
||||||
"new_folder": "New folder...",
|
"new_folder": "Nowy folder...",
|
||||||
"rename": "Rename...",
|
"rename": "Zmień nazwę...",
|
||||||
"empty_folder": "Empty folder",
|
"empty_folder": "Opróżnij folder",
|
||||||
"empty_folder_generic": "Empty folder",
|
"empty_folder_generic": "Opróżnij folder",
|
||||||
"delete_folder": "Delete folder",
|
"delete_folder": "Usuń folder",
|
||||||
"refresh": "Refresh",
|
"refresh": "Odśwież",
|
||||||
"mark_all_confirm_title": "Mark all folders as read",
|
"mark_all_confirm_title": "Oznacz wszystkie foldery jako przeczytane",
|
||||||
"mark_all_confirm_message": "Mark every unread message in your personal account as read?",
|
"mark_all_confirm_message": "Oznaczyć wszystkie nieprzeczytane wiadomości na koncie osobistym jako przeczytane?",
|
||||||
"delete_confirm_title": "Delete folder",
|
"delete_confirm_title": "Usuń folder",
|
||||||
"delete_confirm_message": "Permanently delete the folder \"{name}\"? This action cannot be undone.",
|
"delete_confirm_message": "Trwale usunąć folder \"{name}\"? Tej operacji nie można cofnąć.",
|
||||||
"prompt_new_subfolder": "Enter a name for the new subfolder.",
|
"prompt_new_subfolder": "Podaj nazwę nowego podfolderu.",
|
||||||
"prompt_new_folder": "Enter a name for the new folder.",
|
"prompt_new_folder": "Podaj nazwę nowego folderu.",
|
||||||
"prompt_rename": "Enter a new name for this folder.",
|
"prompt_rename": "Podaj nową nazwę tego folderu.",
|
||||||
"toast_marked_read": "Folder marked as read",
|
"toast_marked_read": "Folder oznaczony jako przeczytany",
|
||||||
"toast_marked_read_count": "Marked {count, plural, one {1 message} other {# messages}} as read",
|
"toast_marked_read_count": "Oznaczono {count, plural, one {1 wiadomość} other {# wiadomości}} jako przeczytane",
|
||||||
"toast_already_read": "No unread messages",
|
"toast_already_read": "Brak nieprzeczytanych wiadomości",
|
||||||
"toast_marked_all_read": "All folders marked as read",
|
"toast_marked_all_read": "Wszystkie foldery oznaczone jako przeczytane",
|
||||||
"toast_emptied": "Folder emptied",
|
"toast_emptied": "Folder opróżniony",
|
||||||
"toast_folder_created": "Folder created",
|
"toast_folder_created": "Folder utworzony",
|
||||||
"toast_folder_renamed": "Folder renamed",
|
"toast_folder_renamed": "Nazwa folderu zmieniona",
|
||||||
"toast_folder_deleted": "Folder deleted",
|
"toast_folder_deleted": "Folder usunięty",
|
||||||
"toast_error_mark_read": "Failed to mark as read",
|
"toast_error_mark_read": "Nie udało się oznaczyć jako przeczytane",
|
||||||
"toast_error_empty": "Failed to empty folder",
|
"toast_error_empty": "Nie udało się opróżnić folderu",
|
||||||
"toast_error_create": "Failed to create folder",
|
"toast_error_create": "Nie udało się utworzyć folderu",
|
||||||
"toast_error_rename": "Failed to rename folder",
|
"toast_error_rename": "Nie udało się zmienić nazwy folderu",
|
||||||
"toast_error_delete": "Failed to delete folder",
|
"toast_error_delete": "Nie udało się usunąć folderu",
|
||||||
"toast_error_delete_has_children": "Folder has subfolders. Remove them first.",
|
"toast_error_delete_has_children": "Folder zawiera podfoldery. Najpierw je usuń.",
|
||||||
"toast_error_delete_has_email": "Folder is not empty. Empty it first.",
|
"toast_error_delete_has_email": "Folder nie jest pusty. Najpierw go opróżnij.",
|
||||||
"placeholder_folder_name": "Folder name",
|
"placeholder_folder_name": "Nazwa folderu",
|
||||||
"create": "Create",
|
"create": "Utwórz",
|
||||||
"rename_confirm": "Rename"
|
"rename_confirm": "Zmień nazwę"
|
||||||
},
|
},
|
||||||
"shortcuts": {
|
"shortcuts": {
|
||||||
"title": "Skróty klawiszowe",
|
"title": "Skróty klawiszowe",
|
||||||
|
|||||||
+35
-35
@@ -1670,41 +1670,41 @@
|
|||||||
"edit_draft": "Editar rascunho"
|
"edit_draft": "Editar rascunho"
|
||||||
},
|
},
|
||||||
"mailbox_context_menu": {
|
"mailbox_context_menu": {
|
||||||
"mark_folder_read": "Mark folder as read",
|
"mark_folder_read": "Marcar pasta como lida",
|
||||||
"mark_folder_tree_read": "Mark folder & subfolders as read",
|
"mark_folder_tree_read": "Marcar pasta e subpastas como lidas",
|
||||||
"mark_all_folders_read": "Mark all folders as read",
|
"mark_all_folders_read": "Marcar todas as pastas como lidas",
|
||||||
"new_subfolder": "New subfolder...",
|
"new_subfolder": "Nova subpasta...",
|
||||||
"new_folder": "New folder...",
|
"new_folder": "Nova pasta...",
|
||||||
"rename": "Rename...",
|
"rename": "Renomear...",
|
||||||
"empty_folder": "Empty folder",
|
"empty_folder": "Esvaziar pasta",
|
||||||
"empty_folder_generic": "Empty folder",
|
"empty_folder_generic": "Esvaziar pasta",
|
||||||
"delete_folder": "Delete folder",
|
"delete_folder": "Excluir pasta",
|
||||||
"refresh": "Refresh",
|
"refresh": "Atualizar",
|
||||||
"mark_all_confirm_title": "Mark all folders as read",
|
"mark_all_confirm_title": "Marcar todas as pastas como lidas",
|
||||||
"mark_all_confirm_message": "Mark every unread message in your personal account as read?",
|
"mark_all_confirm_message": "Marcar todas as mensagens não lidas da sua conta pessoal como lidas?",
|
||||||
"delete_confirm_title": "Delete folder",
|
"delete_confirm_title": "Excluir pasta",
|
||||||
"delete_confirm_message": "Permanently delete the folder \"{name}\"? This action cannot be undone.",
|
"delete_confirm_message": "Excluir permanentemente a pasta \"{name}\"? Esta ação não pode ser desfeita.",
|
||||||
"prompt_new_subfolder": "Enter a name for the new subfolder.",
|
"prompt_new_subfolder": "Digite um nome para a nova subpasta.",
|
||||||
"prompt_new_folder": "Enter a name for the new folder.",
|
"prompt_new_folder": "Digite um nome para a nova pasta.",
|
||||||
"prompt_rename": "Enter a new name for this folder.",
|
"prompt_rename": "Digite um novo nome para esta pasta.",
|
||||||
"toast_marked_read": "Folder marked as read",
|
"toast_marked_read": "Pasta marcada como lida",
|
||||||
"toast_marked_read_count": "Marked {count, plural, one {1 message} other {# messages}} as read",
|
"toast_marked_read_count": "{count, plural, one {1 mensagem marcada como lida} other {# mensagens marcadas como lidas}}",
|
||||||
"toast_already_read": "No unread messages",
|
"toast_already_read": "Nenhuma mensagem não lida",
|
||||||
"toast_marked_all_read": "All folders marked as read",
|
"toast_marked_all_read": "Todas as pastas marcadas como lidas",
|
||||||
"toast_emptied": "Folder emptied",
|
"toast_emptied": "Pasta esvaziada",
|
||||||
"toast_folder_created": "Folder created",
|
"toast_folder_created": "Pasta criada",
|
||||||
"toast_folder_renamed": "Folder renamed",
|
"toast_folder_renamed": "Pasta renomeada",
|
||||||
"toast_folder_deleted": "Folder deleted",
|
"toast_folder_deleted": "Pasta excluída",
|
||||||
"toast_error_mark_read": "Failed to mark as read",
|
"toast_error_mark_read": "Falha ao marcar como lida",
|
||||||
"toast_error_empty": "Failed to empty folder",
|
"toast_error_empty": "Falha ao esvaziar a pasta",
|
||||||
"toast_error_create": "Failed to create folder",
|
"toast_error_create": "Falha ao criar a pasta",
|
||||||
"toast_error_rename": "Failed to rename folder",
|
"toast_error_rename": "Falha ao renomear a pasta",
|
||||||
"toast_error_delete": "Failed to delete folder",
|
"toast_error_delete": "Falha ao excluir a pasta",
|
||||||
"toast_error_delete_has_children": "Folder has subfolders. Remove them first.",
|
"toast_error_delete_has_children": "A pasta contém subpastas. Remova-as primeiro.",
|
||||||
"toast_error_delete_has_email": "Folder is not empty. Empty it first.",
|
"toast_error_delete_has_email": "A pasta não está vazia. Esvazie-a primeiro.",
|
||||||
"placeholder_folder_name": "Folder name",
|
"placeholder_folder_name": "Nome da pasta",
|
||||||
"create": "Create",
|
"create": "Criar",
|
||||||
"rename_confirm": "Rename"
|
"rename_confirm": "Renomear"
|
||||||
},
|
},
|
||||||
"shortcuts": {
|
"shortcuts": {
|
||||||
"title": "Atalhos de Teclado",
|
"title": "Atalhos de Teclado",
|
||||||
|
|||||||
+35
-35
@@ -1670,41 +1670,41 @@
|
|||||||
"edit_draft": "Редактировать черновик"
|
"edit_draft": "Редактировать черновик"
|
||||||
},
|
},
|
||||||
"mailbox_context_menu": {
|
"mailbox_context_menu": {
|
||||||
"mark_folder_read": "Mark folder as read",
|
"mark_folder_read": "Отметить папку как прочитанную",
|
||||||
"mark_folder_tree_read": "Mark folder & subfolders as read",
|
"mark_folder_tree_read": "Отметить папку и вложенные папки как прочитанные",
|
||||||
"mark_all_folders_read": "Mark all folders as read",
|
"mark_all_folders_read": "Отметить все папки как прочитанные",
|
||||||
"new_subfolder": "New subfolder...",
|
"new_subfolder": "Новая вложенная папка...",
|
||||||
"new_folder": "New folder...",
|
"new_folder": "Новая папка...",
|
||||||
"rename": "Rename...",
|
"rename": "Переименовать...",
|
||||||
"empty_folder": "Empty folder",
|
"empty_folder": "Очистить папку",
|
||||||
"empty_folder_generic": "Empty folder",
|
"empty_folder_generic": "Очистить папку",
|
||||||
"delete_folder": "Delete folder",
|
"delete_folder": "Удалить папку",
|
||||||
"refresh": "Refresh",
|
"refresh": "Обновить",
|
||||||
"mark_all_confirm_title": "Mark all folders as read",
|
"mark_all_confirm_title": "Отметить все папки как прочитанные",
|
||||||
"mark_all_confirm_message": "Mark every unread message in your personal account as read?",
|
"mark_all_confirm_message": "Отметить все непрочитанные сообщения в вашем личном аккаунте как прочитанные?",
|
||||||
"delete_confirm_title": "Delete folder",
|
"delete_confirm_title": "Удалить папку",
|
||||||
"delete_confirm_message": "Permanently delete the folder \"{name}\"? This action cannot be undone.",
|
"delete_confirm_message": "Безвозвратно удалить папку \"{name}\"? Это действие нельзя отменить.",
|
||||||
"prompt_new_subfolder": "Enter a name for the new subfolder.",
|
"prompt_new_subfolder": "Введите имя для новой вложенной папки.",
|
||||||
"prompt_new_folder": "Enter a name for the new folder.",
|
"prompt_new_folder": "Введите имя для новой папки.",
|
||||||
"prompt_rename": "Enter a new name for this folder.",
|
"prompt_rename": "Введите новое имя для этой папки.",
|
||||||
"toast_marked_read": "Folder marked as read",
|
"toast_marked_read": "Папка отмечена как прочитанная",
|
||||||
"toast_marked_read_count": "Marked {count, plural, one {1 message} other {# messages}} as read",
|
"toast_marked_read_count": "Отмечено {count, plural, one {1 сообщение} few {# сообщения} other {# сообщений}} как прочитанные",
|
||||||
"toast_already_read": "No unread messages",
|
"toast_already_read": "Нет непрочитанных сообщений",
|
||||||
"toast_marked_all_read": "All folders marked as read",
|
"toast_marked_all_read": "Все папки отмечены как прочитанные",
|
||||||
"toast_emptied": "Folder emptied",
|
"toast_emptied": "Папка очищена",
|
||||||
"toast_folder_created": "Folder created",
|
"toast_folder_created": "Папка создана",
|
||||||
"toast_folder_renamed": "Folder renamed",
|
"toast_folder_renamed": "Папка переименована",
|
||||||
"toast_folder_deleted": "Folder deleted",
|
"toast_folder_deleted": "Папка удалена",
|
||||||
"toast_error_mark_read": "Failed to mark as read",
|
"toast_error_mark_read": "Не удалось отметить как прочитанное",
|
||||||
"toast_error_empty": "Failed to empty folder",
|
"toast_error_empty": "Не удалось очистить папку",
|
||||||
"toast_error_create": "Failed to create folder",
|
"toast_error_create": "Не удалось создать папку",
|
||||||
"toast_error_rename": "Failed to rename folder",
|
"toast_error_rename": "Не удалось переименовать папку",
|
||||||
"toast_error_delete": "Failed to delete folder",
|
"toast_error_delete": "Не удалось удалить папку",
|
||||||
"toast_error_delete_has_children": "Folder has subfolders. Remove them first.",
|
"toast_error_delete_has_children": "Папка содержит вложенные папки. Сначала удалите их.",
|
||||||
"toast_error_delete_has_email": "Folder is not empty. Empty it first.",
|
"toast_error_delete_has_email": "Папка не пуста. Сначала очистите её.",
|
||||||
"placeholder_folder_name": "Folder name",
|
"placeholder_folder_name": "Имя папки",
|
||||||
"create": "Create",
|
"create": "Создать",
|
||||||
"rename_confirm": "Rename"
|
"rename_confirm": "Переименовать"
|
||||||
},
|
},
|
||||||
"shortcuts": {
|
"shortcuts": {
|
||||||
"title": "Сочетания клавиш",
|
"title": "Сочетания клавиш",
|
||||||
|
|||||||
+35
-35
@@ -1670,41 +1670,41 @@
|
|||||||
"edit_draft": "Редагувати чернетку"
|
"edit_draft": "Редагувати чернетку"
|
||||||
},
|
},
|
||||||
"mailbox_context_menu": {
|
"mailbox_context_menu": {
|
||||||
"mark_folder_read": "Mark folder as read",
|
"mark_folder_read": "Позначити папку як прочитану",
|
||||||
"mark_folder_tree_read": "Mark folder & subfolders as read",
|
"mark_folder_tree_read": "Позначити папку та вкладені папки як прочитані",
|
||||||
"mark_all_folders_read": "Mark all folders as read",
|
"mark_all_folders_read": "Позначити всі папки як прочитані",
|
||||||
"new_subfolder": "New subfolder...",
|
"new_subfolder": "Нова вкладена папка...",
|
||||||
"new_folder": "New folder...",
|
"new_folder": "Нова папка...",
|
||||||
"rename": "Rename...",
|
"rename": "Перейменувати...",
|
||||||
"empty_folder": "Empty folder",
|
"empty_folder": "Очистити папку",
|
||||||
"empty_folder_generic": "Empty folder",
|
"empty_folder_generic": "Очистити папку",
|
||||||
"delete_folder": "Delete folder",
|
"delete_folder": "Видалити папку",
|
||||||
"refresh": "Refresh",
|
"refresh": "Оновити",
|
||||||
"mark_all_confirm_title": "Mark all folders as read",
|
"mark_all_confirm_title": "Позначити всі папки як прочитані",
|
||||||
"mark_all_confirm_message": "Mark every unread message in your personal account as read?",
|
"mark_all_confirm_message": "Позначити всі непрочитані повідомлення у вашому особистому акаунті як прочитані?",
|
||||||
"delete_confirm_title": "Delete folder",
|
"delete_confirm_title": "Видалити папку",
|
||||||
"delete_confirm_message": "Permanently delete the folder \"{name}\"? This action cannot be undone.",
|
"delete_confirm_message": "Остаточно видалити папку \"{name}\"? Цю дію неможливо скасувати.",
|
||||||
"prompt_new_subfolder": "Enter a name for the new subfolder.",
|
"prompt_new_subfolder": "Введіть ім'я для нової вкладеної папки.",
|
||||||
"prompt_new_folder": "Enter a name for the new folder.",
|
"prompt_new_folder": "Введіть ім'я для нової папки.",
|
||||||
"prompt_rename": "Enter a new name for this folder.",
|
"prompt_rename": "Введіть нове ім'я для цієї папки.",
|
||||||
"toast_marked_read": "Folder marked as read",
|
"toast_marked_read": "Папку позначено як прочитану",
|
||||||
"toast_marked_read_count": "Marked {count, plural, one {1 message} other {# messages}} as read",
|
"toast_marked_read_count": "Позначено {count, plural, one {1 повідомлення} few {# повідомлення} other {# повідомлень}} як прочитані",
|
||||||
"toast_already_read": "No unread messages",
|
"toast_already_read": "Немає непрочитаних повідомлень",
|
||||||
"toast_marked_all_read": "All folders marked as read",
|
"toast_marked_all_read": "Всі папки позначені як прочитані",
|
||||||
"toast_emptied": "Folder emptied",
|
"toast_emptied": "Папку очищено",
|
||||||
"toast_folder_created": "Folder created",
|
"toast_folder_created": "Папку створено",
|
||||||
"toast_folder_renamed": "Folder renamed",
|
"toast_folder_renamed": "Папку перейменовано",
|
||||||
"toast_folder_deleted": "Folder deleted",
|
"toast_folder_deleted": "Папку видалено",
|
||||||
"toast_error_mark_read": "Failed to mark as read",
|
"toast_error_mark_read": "Не вдалося позначити як прочитане",
|
||||||
"toast_error_empty": "Failed to empty folder",
|
"toast_error_empty": "Не вдалося очистити папку",
|
||||||
"toast_error_create": "Failed to create folder",
|
"toast_error_create": "Не вдалося створити папку",
|
||||||
"toast_error_rename": "Failed to rename folder",
|
"toast_error_rename": "Не вдалося перейменувати папку",
|
||||||
"toast_error_delete": "Failed to delete folder",
|
"toast_error_delete": "Не вдалося видалити папку",
|
||||||
"toast_error_delete_has_children": "Folder has subfolders. Remove them first.",
|
"toast_error_delete_has_children": "Папка містить вкладені папки. Спочатку видаліть їх.",
|
||||||
"toast_error_delete_has_email": "Folder is not empty. Empty it first.",
|
"toast_error_delete_has_email": "Папка не порожня. Спочатку очистіть її.",
|
||||||
"placeholder_folder_name": "Folder name",
|
"placeholder_folder_name": "Ім'я папки",
|
||||||
"create": "Create",
|
"create": "Створити",
|
||||||
"rename_confirm": "Rename"
|
"rename_confirm": "Перейменувати"
|
||||||
},
|
},
|
||||||
"shortcuts": {
|
"shortcuts": {
|
||||||
"title": "Комбінації клавіш",
|
"title": "Комбінації клавіш",
|
||||||
|
|||||||
+35
-35
@@ -1670,41 +1670,41 @@
|
|||||||
"edit_draft": "编辑草稿"
|
"edit_draft": "编辑草稿"
|
||||||
},
|
},
|
||||||
"mailbox_context_menu": {
|
"mailbox_context_menu": {
|
||||||
"mark_folder_read": "Mark folder as read",
|
"mark_folder_read": "将文件夹标记为已读",
|
||||||
"mark_folder_tree_read": "Mark folder & subfolders as read",
|
"mark_folder_tree_read": "将文件夹及子文件夹标记为已读",
|
||||||
"mark_all_folders_read": "Mark all folders as read",
|
"mark_all_folders_read": "将所有文件夹标记为已读",
|
||||||
"new_subfolder": "New subfolder...",
|
"new_subfolder": "新建子文件夹...",
|
||||||
"new_folder": "New folder...",
|
"new_folder": "新建文件夹...",
|
||||||
"rename": "Rename...",
|
"rename": "重命名...",
|
||||||
"empty_folder": "Empty folder",
|
"empty_folder": "清空文件夹",
|
||||||
"empty_folder_generic": "Empty folder",
|
"empty_folder_generic": "清空文件夹",
|
||||||
"delete_folder": "Delete folder",
|
"delete_folder": "删除文件夹",
|
||||||
"refresh": "Refresh",
|
"refresh": "刷新",
|
||||||
"mark_all_confirm_title": "Mark all folders as read",
|
"mark_all_confirm_title": "将所有文件夹标记为已读",
|
||||||
"mark_all_confirm_message": "Mark every unread message in your personal account as read?",
|
"mark_all_confirm_message": "将您个人账户中的所有未读邮件标记为已读?",
|
||||||
"delete_confirm_title": "Delete folder",
|
"delete_confirm_title": "删除文件夹",
|
||||||
"delete_confirm_message": "Permanently delete the folder \"{name}\"? This action cannot be undone.",
|
"delete_confirm_message": "永久删除文件夹 \"{name}\"?此操作无法撤销。",
|
||||||
"prompt_new_subfolder": "Enter a name for the new subfolder.",
|
"prompt_new_subfolder": "请输入新子文件夹的名称。",
|
||||||
"prompt_new_folder": "Enter a name for the new folder.",
|
"prompt_new_folder": "请输入新文件夹的名称。",
|
||||||
"prompt_rename": "Enter a new name for this folder.",
|
"prompt_rename": "请输入此文件夹的新名称。",
|
||||||
"toast_marked_read": "Folder marked as read",
|
"toast_marked_read": "文件夹已标记为已读",
|
||||||
"toast_marked_read_count": "Marked {count, plural, one {1 message} other {# messages}} as read",
|
"toast_marked_read_count": "已将 {count, plural, one {1 封邮件} other {# 封邮件}} 标记为已读",
|
||||||
"toast_already_read": "No unread messages",
|
"toast_already_read": "没有未读邮件",
|
||||||
"toast_marked_all_read": "All folders marked as read",
|
"toast_marked_all_read": "所有文件夹已标记为已读",
|
||||||
"toast_emptied": "Folder emptied",
|
"toast_emptied": "文件夹已清空",
|
||||||
"toast_folder_created": "Folder created",
|
"toast_folder_created": "文件夹已创建",
|
||||||
"toast_folder_renamed": "Folder renamed",
|
"toast_folder_renamed": "文件夹已重命名",
|
||||||
"toast_folder_deleted": "Folder deleted",
|
"toast_folder_deleted": "文件夹已删除",
|
||||||
"toast_error_mark_read": "Failed to mark as read",
|
"toast_error_mark_read": "标记为已读失败",
|
||||||
"toast_error_empty": "Failed to empty folder",
|
"toast_error_empty": "清空文件夹失败",
|
||||||
"toast_error_create": "Failed to create folder",
|
"toast_error_create": "创建文件夹失败",
|
||||||
"toast_error_rename": "Failed to rename folder",
|
"toast_error_rename": "重命名文件夹失败",
|
||||||
"toast_error_delete": "Failed to delete folder",
|
"toast_error_delete": "删除文件夹失败",
|
||||||
"toast_error_delete_has_children": "Folder has subfolders. Remove them first.",
|
"toast_error_delete_has_children": "文件夹包含子文件夹,请先将其删除。",
|
||||||
"toast_error_delete_has_email": "Folder is not empty. Empty it first.",
|
"toast_error_delete_has_email": "文件夹不为空,请先清空它。",
|
||||||
"placeholder_folder_name": "Folder name",
|
"placeholder_folder_name": "文件夹名称",
|
||||||
"create": "Create",
|
"create": "创建",
|
||||||
"rename_confirm": "Rename"
|
"rename_confirm": "重命名"
|
||||||
},
|
},
|
||||||
"shortcuts": {
|
"shortcuts": {
|
||||||
"title": "键盘快捷键",
|
"title": "键盘快捷键",
|
||||||
|
|||||||
+14
-5
@@ -4,11 +4,20 @@ import { execSync } from "child_process";
|
|||||||
import { readFileSync } from "fs";
|
import { readFileSync } from "fs";
|
||||||
import { join } from "path";
|
import { join } from "path";
|
||||||
|
|
||||||
let gitCommitHash = "unknown";
|
// Prefer an explicit build arg (passed in by CI / Docker, where .git is
|
||||||
try {
|
// excluded from the build context) and fall back to `git rev-parse` for
|
||||||
gitCommitHash = execSync("git rev-parse --short HEAD").toString().trim();
|
// local builds.
|
||||||
} catch {
|
let gitCommitHash = process.env.GIT_COMMIT?.trim() || "";
|
||||||
// git not available
|
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";
|
let appVersion = "0.0.0";
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "bulwark-webmail",
|
"name": "bulwark-webmail",
|
||||||
"version": "1.6.0",
|
"version": "1.6.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "bulwark-webmail",
|
"name": "bulwark-webmail",
|
||||||
"version": "1.6.0",
|
"version": "1.6.1",
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-virtual": "^3.13.24",
|
"@tanstack/react-virtual": "^3.13.24",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "bulwark-webmail",
|
"name": "bulwark-webmail",
|
||||||
"version": "1.6.0",
|
"version": "1.6.1",
|
||||||
"description": "Bulwark Webmail - a modern webmail client built for Stalwart Mail Server",
|
"description": "Bulwark Webmail - a modern webmail client built for Stalwart Mail Server",
|
||||||
"author": "Bulwark Webmail <bulwark@rbm.systems>",
|
"author": "Bulwark Webmail <bulwark@rbm.systems>",
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
|
|||||||
@@ -122,6 +122,7 @@ async function handlePush(event) {
|
|||||||
|
|
||||||
async function handleNotificationClick(event) {
|
async function handleNotificationClick(event) {
|
||||||
const data = event.notification.data || {};
|
const data = event.notification.data || {};
|
||||||
|
const tag = event.notification.tag || "";
|
||||||
const targetUrl = buildClickUrl(data);
|
const targetUrl = buildClickUrl(data);
|
||||||
|
|
||||||
const allClients = await self.clients.matchAll({
|
const allClients = await self.clients.matchAll({
|
||||||
@@ -129,6 +130,15 @@ async function handleNotificationClick(event) {
|
|||||||
includeUncontrolled: true,
|
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) {
|
for (const client of allClients) {
|
||||||
// Reuse an existing tab whenever possible - users on desktop browsers
|
// Reuse an existing tab whenever possible - users on desktop browsers
|
||||||
// get annoyed when each notification opens a fresh window.
|
// get annoyed when each notification opens a fresh window.
|
||||||
|
|||||||
+34
-11
@@ -601,12 +601,21 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
set({ isLoading: true, error: null, isRateLimited: false, rateLimitUntil: null });
|
set({ isLoading: true, error: null, isRateLimited: false, rateLimitUntil: null });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Determine slot for this account (use slot from sessionStorage if re-adding)
|
// Determine slot for this account (use slot from sessionStorage if re-adding).
|
||||||
|
// Note: `parseInt(getItem(...) || '0')` collapses "no value set" and
|
||||||
|
// "value is 0" into the same case, so the fallback to getNextCookieSlot()
|
||||||
|
// never fired for the common "+ Add Account" path — every OAuth account
|
||||||
|
// ended up on slot 0 and overwrote earlier accounts' refresh-token cookies.
|
||||||
|
// Distinguishing rawSlot === null from a parsed 0 fixes that. The page
|
||||||
|
// also writes oauth_cookie_slot before redirecting to the IdP.
|
||||||
const accountStore = useAccountStore.getState();
|
const accountStore = useAccountStore.getState();
|
||||||
const pendingSlot = typeof window !== 'undefined'
|
const rawSlot = typeof window !== 'undefined'
|
||||||
? parseInt(sessionStorage.getItem('oauth_cookie_slot') || '0', 10)
|
? sessionStorage.getItem('oauth_cookie_slot')
|
||||||
: 0;
|
: null;
|
||||||
const slot = pendingSlot >= 0 && pendingSlot <= 4 ? pendingSlot : accountStore.getNextCookieSlot();
|
const pendingSlot = rawSlot !== null ? parseInt(rawSlot, 10) : NaN;
|
||||||
|
const slot = !isNaN(pendingSlot) && pendingSlot >= 0 && pendingSlot <= 4
|
||||||
|
? pendingSlot
|
||||||
|
: accountStore.getNextCookieSlot();
|
||||||
|
|
||||||
const tokenRes = await apiFetch(`/api/auth/token?slot=${slot}`, {
|
const tokenRes = await apiFetch(`/api/auth/token?slot=${slot}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -659,6 +668,12 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
hasError: false,
|
hasError: false,
|
||||||
isDefault: accountStore.accounts.length === 0,
|
isDefault: accountStore.accounts.length === 0,
|
||||||
});
|
});
|
||||||
|
// The refresh-token cookie was written to `slot`. Force the stored
|
||||||
|
// cookieSlot to match: addAccount preserves the prior slot when
|
||||||
|
// re-adding an existing account, and recomputes via getNextCookieSlot
|
||||||
|
// for new accounts (which may disagree if another tab claimed a slot
|
||||||
|
// mid-flow). Either way, the cookie's slot is the source of truth.
|
||||||
|
accountStore.updateAccount(accountId, { cookieSlot: slot });
|
||||||
accountStore.setActiveAccount(accountId);
|
accountStore.setActiveAccount(accountId);
|
||||||
|
|
||||||
await syncStalwartAuthContext(serverUrl, username, client.getAuthHeader(), slot);
|
await syncStalwartAuthContext(serverUrl, username, client.getAuthHeader(), slot);
|
||||||
@@ -718,12 +733,19 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
set({ isLoading: true, error: null, isRateLimited: false, rateLimitUntil: null });
|
set({ isLoading: true, error: null, isRateLimited: false, rateLimitUntil: null });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Server-side SSO: the server holds the PKCE verifier in an encrypted cookie
|
// Server-side SSO: the server holds the PKCE verifier in an encrypted cookie.
|
||||||
|
// Pass the next-free cookie slot so /api/auth/sso/complete writes the refresh
|
||||||
|
// token to the correct per-account jmap_rt_<slot> cookie. Without this the
|
||||||
|
// route hardcoded slot 0, which broke "+ Add Account" by overwriting the
|
||||||
|
// first account's refresh-token cookie.
|
||||||
|
const accountStore = useAccountStore.getState();
|
||||||
|
const slot = accountStore.getNextCookieSlot();
|
||||||
|
|
||||||
const ssoRes = await apiFetch('/api/auth/sso/complete', {
|
const ssoRes = await apiFetch('/api/auth/sso/complete', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
body: JSON.stringify({ code, state }),
|
body: JSON.stringify({ code, state, slot }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!ssoRes.ok) {
|
if (!ssoRes.ok) {
|
||||||
@@ -741,8 +763,6 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
throw new Error('Server URL not configured');
|
throw new Error('Server URL not configured');
|
||||||
}
|
}
|
||||||
|
|
||||||
const accountStore = useAccountStore.getState();
|
|
||||||
|
|
||||||
const refreshFn = get().refreshAccessToken;
|
const refreshFn = get().refreshAccessToken;
|
||||||
const client = JMAPClient.withBearer(ssoServerUrl, access_token, '', () => refreshFn());
|
const client = JMAPClient.withBearer(ssoServerUrl, access_token, '', () => refreshFn());
|
||||||
await client.connect();
|
await client.connect();
|
||||||
@@ -779,10 +799,13 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
hasError: false,
|
hasError: false,
|
||||||
isDefault: accountStore.accounts.length === 0,
|
isDefault: accountStore.accounts.length === 0,
|
||||||
});
|
});
|
||||||
|
// The refresh-token cookie was written to `slot` by /api/auth/sso/complete.
|
||||||
|
// Force the stored cookieSlot to match — see loginWithOAuth above for the
|
||||||
|
// re-add and concurrent-tab cases this guards against.
|
||||||
|
accountStore.updateAccount(accountId, { cookieSlot: slot });
|
||||||
accountStore.setActiveAccount(accountId);
|
accountStore.setActiveAccount(accountId);
|
||||||
|
|
||||||
const cookieSlot = accountStore.getAccountById(accountId)?.cookieSlot ?? 0;
|
await syncStalwartAuthContext(ssoServerUrl, username, client.getAuthHeader(), slot);
|
||||||
await syncStalwartAuthContext(ssoServerUrl, username, client.getAuthHeader(), cookieSlot);
|
|
||||||
|
|
||||||
set({
|
set({
|
||||||
isAuthenticated: true,
|
isAuthenticated: true,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useSettingsStore } from "@/stores/settings-store";
|
|||||||
import { useCalendarStore } from "@/stores/calendar-store";
|
import { useCalendarStore } from "@/stores/calendar-store";
|
||||||
import { SearchFilters, DEFAULT_SEARCH_FILTERS, buildJMAPFilter, isFilterEmpty } from "@/lib/jmap/search-utils";
|
import { SearchFilters, DEFAULT_SEARCH_FILTERS, buildJMAPFilter, isFilterEmpty } from "@/lib/jmap/search-utils";
|
||||||
import { emailHooks } from "@/lib/plugin-hooks";
|
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 { fetchUnifiedEmails, fetchUnifiedMailboxCounts, type UnifiedAccountClient, type UnifiedMailboxCounts } from "@/lib/unified-mailbox";
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
import { useAccountStore } from "@/stores/account-store";
|
import { useAccountStore } from "@/stores/account-store";
|
||||||
@@ -42,6 +43,8 @@ interface EmailStore {
|
|||||||
searchFilters: SearchFilters;
|
searchFilters: SearchFilters;
|
||||||
isAdvancedSearchOpen: boolean;
|
isAdvancedSearchOpen: boolean;
|
||||||
searchAbortController: AbortController | null;
|
searchAbortController: AbortController | null;
|
||||||
|
/** Plugin-contributed search results (CRM hits, Slack messages, etc.) populated by emailHooks.onProvideSearchResults. */
|
||||||
|
externalSearchResults: ExternalSearchResult[];
|
||||||
|
|
||||||
// Unified mailbox state
|
// Unified mailbox state
|
||||||
isUnifiedView: boolean;
|
isUnifiedView: boolean;
|
||||||
@@ -216,6 +219,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
|||||||
searchFilters: { ...DEFAULT_SEARCH_FILTERS },
|
searchFilters: { ...DEFAULT_SEARCH_FILTERS },
|
||||||
isAdvancedSearchOpen: false,
|
isAdvancedSearchOpen: false,
|
||||||
searchAbortController: null,
|
searchAbortController: null,
|
||||||
|
externalSearchResults: [],
|
||||||
|
|
||||||
// Unified mailbox state
|
// Unified mailbox state
|
||||||
isUnifiedView: false,
|
isUnifiedView: false,
|
||||||
@@ -971,8 +975,10 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
|||||||
// Get emails per page from settings
|
// Get emails per page from settings
|
||||||
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
|
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
|
||||||
const result = await client.searchEmails(query, jmapMailboxId, accountId, emailsPerPage, 0);
|
const result = await client.searchEmails(query, jmapMailboxId, accountId, emailsPerPage, 0);
|
||||||
|
const externals = await emailHooks.onProvideSearchResults.transform([] as ExternalSearchResult[], { query, filters: get().searchFilters });
|
||||||
set({
|
set({
|
||||||
emails: result.emails,
|
emails: result.emails,
|
||||||
|
externalSearchResults: externals,
|
||||||
hasMoreEmails: result.hasMore,
|
hasMoreEmails: result.hasMore,
|
||||||
totalEmails: result.total,
|
totalEmails: result.total,
|
||||||
isLoading: false
|
isLoading: false
|
||||||
@@ -982,6 +988,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
|||||||
error: error instanceof Error ? error.message : "Failed to search emails",
|
error: error instanceof Error ? error.message : "Failed to search emails",
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
emails: [],
|
emails: [],
|
||||||
|
externalSearchResults: [],
|
||||||
hasMoreEmails: false,
|
hasMoreEmails: false,
|
||||||
totalEmails: 0
|
totalEmails: 0
|
||||||
});
|
});
|
||||||
@@ -1016,8 +1023,11 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
|||||||
|
|
||||||
if (controller.signal.aborted) return;
|
if (controller.signal.aborted) return;
|
||||||
|
|
||||||
|
const externals = await emailHooks.onProvideSearchResults.transform([] as ExternalSearchResult[], { query: searchQuery, filters: searchFilters });
|
||||||
|
|
||||||
set({
|
set({
|
||||||
emails: result.emails,
|
emails: result.emails,
|
||||||
|
externalSearchResults: externals,
|
||||||
hasMoreEmails: result.hasMore,
|
hasMoreEmails: result.hasMore,
|
||||||
totalEmails: result.total,
|
totalEmails: result.total,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
@@ -1029,6 +1039,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
|||||||
error: error instanceof Error ? error.message : "Failed to search emails",
|
error: error instanceof Error ? error.message : "Failed to search emails",
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
emails: [],
|
emails: [],
|
||||||
|
externalSearchResults: [],
|
||||||
hasMoreEmails: false,
|
hasMoreEmails: false,
|
||||||
totalEmails: 0,
|
totalEmails: 0,
|
||||||
searchAbortController: null,
|
searchAbortController: null,
|
||||||
|
|||||||
@@ -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';
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user