Compare commits

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

Three coordinated changes:

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

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

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

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

Repro before the fix:
- Sign in with one account, refresh — works.
- Click "+ Add Account", sign in with a second account, refresh — second
  account vanishes from the dropdown; switching to the first account in
  the dropdown still shows the second account's identity in the From box.
2026-05-04 11:22:45 +02:00
Linus Rath 2e1f53c899 Merge branch 'main' of https://github.com/bulwarkmail/webmail 2026-05-03 20:07:20 +02:00
Linus Rath a6d2efaf74 feat: sanitize identity display name to prevent invalid From headers 2026-05-03 20:06:41 +02:00
Luis Felipe MarzagaoandLinus Rath 01cd9644ed i18n: update mailbox context menu across 12 locales 2026-05-03 11:01:39 +02:00
Linus Rath 1521826d37 feat: add functionality to automatically add recipients to trusted senders when replying 2026-05-02 23:50:50 +02:00
Linus Rath 0d218d0d2a fix: square the colored left marker on calendar events 2026-05-02 23:41:08 +02:00
Linus Rath 9777dd655c feat: add share indicators for calendars and contacts, update JMAP capabilities #244 2026-05-02 23:29:21 +02:00
Linus Rath f970fd1822 feat: add plugin hooks for compose, attachments, search, lifecycle, and routing 2026-05-02 21:27:56 +02:00
Linus Rath 5e096240b3 feat: refresh update status on every dev reload 2026-05-02 13:23:58 +02:00
Linus Rath bc97a1ac10 feat: make update notice non-dismissible 2026-05-02 13:07:34 +02:00
Linus Rath 4594fb2572 revert: restore VERSION to correct value 2026-05-02 01:59:27 +02:00
Linus Rath 5319562c94 feat: add update-available detection 2026-05-02 01:58:30 +02:00
Linus Rath 599fa66822 fix: show git commit in About instead of "unknown" 2026-05-02 00:28:08 +02:00
Linus Rath 8041700668 chore: update version to 1.6.0 2026-05-01 22:02:57 +02:00
Linus Rath bade68a8b8 i18n: add missing email viewer detail and authentication translations 2026-05-01 22:02:14 +02:00
Linus Rath 4be7176802 chore: update version to 1.6.0 2026-05-01 21:56:53 +02:00
Linus Rath 8813533958 fix: respect per-email dark mode toggle when always-light setting is on 2026-05-01 21:42:02 +02:00
Linus Rath affa239d75 Merge branch 'main' of https://github.com/bulwarkmail/webmail 2026-05-01 21:35:53 +02:00
Linus Rath 5d292fa43f fix: scroll apps list in navigation rail to prevent overflow 2026-05-01 21:35:29 +02:00
Linus Rath 878df6bb49 refactor: enhance path rendering in mailbox context menu 2026-05-01 21:26:03 +02:00
Linus Rath 607a9584fd refactor: implement path shortening for mailbox context menu 2026-05-01 21:25:31 +02:00
Linus Rath b8e2bfd793 fix: show full path in mailbox context menu header 2026-05-01 21:15:47 +02:00
Linus Rath 4ad6d37877 fix: clamp context submenu inside viewport 2026-05-01 21:03:54 +02:00
Linus Rath d7c29b7bec refactor: rework mobile mail viewer toolbar 2026-05-01 21:00:34 +02:00
Luis Felipe MarzagaoandLinus Rath b86bc541ab i18n: add missing keys accross 14 locales 2026-05-01 20:37:51 +02:00
Luis Felipe MarzagaoandLinus Rath 8c74e01a40 fix: add useTranslations for "selected emails" and "cancel" on email list batch operations 2026-05-01 20:37:51 +02:00
Linus Rath 231a9017d2 refactor: make settings panel mobile friendly 2026-05-01 20:30:15 +02:00
Linus Rath 9a5bb78b18 refactor: make admin panel mobile friendly 2026-05-01 20:22:29 +02:00
Linus Rath eecf16daa2 fix: stop silently destroying emails when trash mailbox isnt found #195 2026-05-01 20:01:13 +02:00
Vadim BelovandLinus Rath 210150a02e Fix push preview JMAP query
Resolve the Inbox mailbox id before running Email/query.

The previous query passed a JMAP result reference object directly into the inMailbox filter, which can make the preview endpoint return 502 and cause push notifications to fall back to the generic “New mail” text.
2026-05-01 19:53:25 +02:00
Linus Rath e50691d6c4 fix: navigate tour to mailbox when starting from another page 2026-05-01 18:30:51 +02:00
Linus Rath 089963b1b3 refactor: redesign expanded details panel 2026-05-01 18:22:06 +02:00
Linus Rath 4af952613a fix: prevent context menu jump and animation on open 2026-05-01 17:43:07 +02:00
Linus Rath 0d9fa0285f fix: prevent context menu from clipping below viewport 2026-05-01 17:28:40 +02:00
Linus Rath 683fe75864 i18n: translate SPF/DKIM/DMARC tooltips 2026-05-01 17:25:52 +02:00
Linus Rath e9c9be84ad fix: preserve list scroll position when tagging an email 2026-05-01 17:18:11 +02:00
Linus Rath 7822a363dd fix: render below-header overflow popup outside clipped row 2026-05-01 17:10:17 +02:00
Linus Rath 1e535e96a2 feat: image attachment thumbnails and preview chips 2026-05-01 17:03:45 +02:00
Linus Rath 32135ddb95 fix: collapse below-header attachments to single row with overflow pill 2026-05-01 16:29:38 +02:00
Linus Rath 841513e510 feat: support subpath deployment with NEXT_PUBLIC_BASE_PATH environment variable 2026-05-01 14:57:49 +02:00
Linus RathandGitHub 5964b2e456 Update README 2026-05-01 10:06:49 +02:00
86 changed files with 4370 additions and 1379 deletions
@@ -50,6 +50,8 @@ jobs:
context: .
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
GIT_COMMIT=${{ github.sha }}
outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
cache-from: type=gha,scope=${{ matrix.platform }}
cache-to: type=gha,mode=max,scope=${{ matrix.platform }}
+2
View File
@@ -78,6 +78,8 @@ jobs:
context: .
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
GIT_COMMIT=${{ github.sha }}
outputs: type=image,name=${{ needs.prepare.outputs.image_name }},push-by-digest=true,name-canonical=true,push=true
cache-from: type=gha,scope=${{ matrix.platform }}
cache-to: type=gha,mode=max,scope=${{ matrix.platform }}
+55
View File
@@ -1,5 +1,60 @@
# Changelog
## 1.6.1 (2026-05-04)
### Features
- **Updates**: Update-available detection with non-dismissible notice and dev-reload refresh
- **Plugins**: New plugin hooks for compose, attachments, search, lifecycle, and routing
- **Sharing**: Share indicators for calendars and contacts, updated JMAP capabilities (#244)
- **Mail**: Auto-add recipients to trusted senders when replying
- **Identity**: Sanitize identity display name to prevent invalid `From` headers
### Fixes
- **Mobile**: Synchronize mobile submenu view with browser history for better navigation
- **Viewer**: Update email viewer styles to improve overflow handling
- **Auth**: Ensure `cookieSlot` consistency during account updates in auth store
- **Auth**: Thread per-account cookie slot through OAuth flows
- **Calendar**: Square the colored left marker on calendar events
- **About**: Show git commit in About instead of "unknown"
### i18n
- Update mailbox context menu translations across 12 locales
## 1.6.0 (2026-05-01)
### Features
- **Deployment**: Subpath deployment support via `NEXT_PUBLIC_BASE_PATH` environment variable
- **Mail**: Image attachment thumbnails and preview chips
- **Mobile**: Reworked mobile mail viewer toolbar
- **Mobile**: Mobile-friendly settings panel
- **Mobile**: Mobile-friendly admin panel
- **Mail**: Redesigned expanded details panel
- **Mailbox**: Show full path in mailbox context menu header with intelligent path shortening
### Fixes
- **Viewer**: Respect per-email dark mode toggle when "always show in light mode" is on
- **Navigation**: Scroll apps list in navigation rail to prevent overflow
- **Context menu**: Clamp submenu inside viewport
- **Context menu**: Prevent context menu from clipping below viewport
- **Context menu**: Prevent jump and animation on open
- **Mail**: Stop silently destroying emails when trash mailbox isn't found (#195)
- **Mail**: Preserve list scroll position when tagging an email
- **Mail**: Render below-header overflow popup outside clipped row
- **Mail**: Collapse below-header attachments to single row with overflow pill
- **Push**: Fix push preview JMAP query
- **Tour**: Navigate tour to mailbox when starting from another page
- **i18n**: Add `useTranslations` for "selected emails" and "cancel" on email list batch operations
### i18n
- Translate SPF/DKIM/DMARC tooltips
- Add missing keys across 14 locales
## 1.5.4 (2026-05-01)
### Features
+8
View File
@@ -4,6 +4,14 @@ COPY package.json package-lock.json ./
RUN npm ci
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
# Optional: serve under a subpath like /webmail. Baked into emitted asset URLs
# at build time, so it cannot be changed without rebuilding.
ARG NEXT_PUBLIC_BASE_PATH=
ENV NEXT_PUBLIC_BASE_PATH=$NEXT_PUBLIC_BASE_PATH
# Commit SHA shown in the About screen. .dockerignore excludes .git, so
# `git rev-parse` inside the build can't find it — CI must pass it in.
ARG GIT_COMMIT=unknown
ENV GIT_COMMIT=$GIT_COMMIT
RUN npx next build --webpack
FROM node:24-alpine AS runner
+22 -4
View File
@@ -12,7 +12,7 @@ A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.ar
[![License: AGPL v3](https://img.shields.io/badge/license-AGPL%20v3-blue.svg?logo=gnu&logoColor=white)](LICENSE)
[![Discord](https://img.shields.io/discord/1482128142939455674?color=7289da&label=discord&logo=discord&logoColor=white)](https://discord.gg/tYCujymGrT)
[![Version](https://img.shields.io/badge/version-1.5.4-green.svg?logo=git&logoColor=white)](CHANGELOG.md)
[![Version](https://img.shields.io/badge/version-1.6.1-green.svg?logo=git&logoColor=white)](CHANGELOG.md)
[![Docker](https://img.shields.io/badge/docker-ghcr.io%2Fbulwarkmail%2Fwebmail-blue?logo=docker&logoColor=white)](https://ghcr.io/bulwarkmail/webmail)
</div>
@@ -53,11 +53,9 @@ A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.ar
</tr>
</table>
> **Anonymous telemetry is on by default** since 1.5.3. Each instance sends a daily heartbeat (version, platform, bucketed account counts, feature toggles - no message data, no PII). Disable from **Admin → Telemetry**, by setting `BULWARK_TELEMETRY=off`, or by clearing the endpoint. Full schema: [privacy notice](https://bulwarkmail.org/docs/legal/privacy/telemetry).
## Overview
Bulwark is a full webmail suite not just an inbox. It bundles the four apps most self-hosters end up wanting on the same login:
Bulwark is a full webmail suite, not just an inbox. It bundles the four apps most self-hosters end up wanting on the same login:
- **Mail** threading, unified inbox, full-text search, Sieve filters, S/MIME, templates
- **Calendar** month/week/day/agenda, recurring events, iMIP invitations, CalDAV subscriptions
@@ -219,6 +217,26 @@ LOG_LEVEL=info # error | warn | info | debug
</details>
<details>
<summary>Subpath / reverse proxy mount</summary>
To serve the webmail at a subpath (e.g. `https://example.com/webmail`):
```env
NEXT_PUBLIC_BASE_PATH=/webmail
NEXT_PUBLIC_LOCALE_PREFIX=always # avoids next-intl rewrite loops
```
Unlike most other variables, `NEXT_PUBLIC_BASE_PATH` is read at **build time** because Next.js bakes it into emitted asset URLs. To use it with the published Docker image, build your own image with the variable set:
```bash
docker build --build-arg NEXT_PUBLIC_BASE_PATH=/webmail -t bulwark-webmail .
```
Then point your reverse proxy at the container without stripping the prefix - the app expects to receive requests under `/webmail/...` and serves all routes (`/webmail/api/...`, `/webmail/_next/static/...`, `/webmail/sw.js`, etc.) accordingly.
</details>
## Keyboard Shortcuts
| Key | Action |
+1 -1
View File
@@ -1 +1 @@
1.5.4
1.6.1
+56 -5
View File
@@ -7,6 +7,7 @@ import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useAuthStore } from "@/stores/auth-store";
import { useAccountStore } from "@/stores/account-store";
import { useThemeStore } from "@/stores/theme-store";
import { useShallow } from "zustand/react/shallow";
import { useConfig } from "@/hooks/use-config";
@@ -16,6 +17,7 @@ import { AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Monitor,
import { discoverOAuth, type OAuthMetadata } from "@/lib/oauth/discovery";
import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce";
import { OAUTH_SCOPES } from "@/lib/oauth/tokens";
import { useUpdateStore, selectBanner } from "@/stores/update-store";
const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "0.0.0";
const GIT_COMMIT = process.env.NEXT_PUBLIC_GIT_COMMIT || "unknown";
@@ -28,7 +30,12 @@ const THEME_OPTIONS = [
function VersionBadge() {
const [copied, setCopied] = useState(false);
const versionInfo = `Version: ${APP_VERSION}\nBuild: ${GIT_COMMIT}`;
const banner = useUpdateStore(useShallow(selectBanner));
const startPolling = useUpdateStore((s) => s.startPolling);
useEffect(() => { startPolling(); }, [startPolling]);
const versionInfo = `Version: ${APP_VERSION}\nBuild: ${GIT_COMMIT}${banner?.latest ? `\nLatest: ${banner.latest}` : ""}`;
const handleCopy = () => {
navigator.clipboard.writeText(versionInfo).then(() => {
@@ -37,16 +44,49 @@ function VersionBadge() {
});
};
const isRed = banner?.variant === "red";
const triggerText = !banner
? `v${APP_VERSION}`
: banner.severity === "security"
? "Security update available"
: banner.severity === "deprecated"
? "Version no longer supported"
: "New version available";
const triggerColor = !banner
? "text-muted-foreground/40"
: isRed
? "text-red-600/80 dark:text-red-400/80 hover:text-red-600 dark:hover:text-red-400"
: "text-amber-600/80 dark:text-amber-400/80 hover:text-amber-600 dark:hover:text-amber-400";
const triggerClass = cn(
"peer text-center text-xs transition-colors",
triggerColor,
banner?.url ? "cursor-pointer underline-offset-2 hover:underline" : "cursor-default",
);
const trigger = banner?.url ? (
<a href={banner.url} target="_blank" rel="noopener noreferrer" className={triggerClass}>
{triggerText}
</a>
) : (
<p className={triggerClass}>{triggerText}</p>
);
return (
<div className="relative inline-flex justify-center">
<p className="peer text-center text-xs text-muted-foreground/40 cursor-default">
v{APP_VERSION}
</p>
{trigger}
<div className="absolute top-full left-1/2 -translate-x-1/2 mt-1.5 px-3 py-2 rounded-md bg-popover text-popover-foreground text-xs shadow-md border border-border opacity-0 peer-hover:opacity-100 hover:opacity-100 transition-opacity whitespace-nowrap z-10">
<div className="flex items-center gap-2">
<div className="space-y-0.5">
<p>Version: <span className="font-medium">{APP_VERSION}</span></p>
<p>Build: <span className="font-medium">{GIT_COMMIT}</span></p>
{banner?.latest && (
<p>Latest: <span className="font-medium">{banner.latest}</span></p>
)}
{banner?.advisory && (
<p className="text-red-500 dark:text-red-400">{banner.advisory}</p>
)}
</div>
<button
onClick={handleCopy}
@@ -411,7 +451,8 @@ export default function LoginPage() {
const verifier = generateCodeVerifier();
const challenge = await generateCodeChallenge(verifier);
const state = generateState();
const redirectUri = `${window.location.origin}/${params.locale}/auth/callback`;
const prefix = getPathPrefix(params.locale as string);
const redirectUri = `${window.location.origin}${prefix}/${params.locale}/auth/callback`;
sessionStorage.setItem("oauth_code_verifier", verifier);
sessionStorage.setItem("oauth_state", state);
@@ -420,6 +461,16 @@ export default function LoginPage() {
sessionStorage.setItem("oauth_add_account_mode", "true");
}
// Persist the next-free cookie slot so loginWithOAuth (in stores/auth-store.ts)
// writes the refresh token to the correct per-account jmap_rt_<slot> cookie.
// loginWithOAuth reads this key but it was previously never written, so every
// OAuth account collapsed onto slot 0 and clobbered earlier accounts' refresh
// tokens. getNextCookieSlot() returns 0 when no accounts exist (correct for
// first sign-in) and the lowest unused slot otherwise (correct for "+ Add
// Account").
const nextSlot = useAccountStore.getState().getNextCookieSlot();
sessionStorage.setItem("oauth_cookie_slot", nextSlot.toString());
const authUrl = new URL(oauthMetadata.authorization_endpoint);
authUrl.searchParams.set("response_type", "code");
authUrl.searchParams.set("client_id", oauthClientId);
+146 -9
View File
@@ -1,6 +1,7 @@
"use client";
import { useEffect, useState, useRef, useMemo, useCallback } from "react";
import { usePathname } from "next/navigation";
import { useTranslations } from "next-intl";
import { Sidebar } from "@/components/layout/sidebar";
import { EmailList } from "@/components/email/email-list";
@@ -58,6 +59,26 @@ import { Button } from "@/components/ui/button";
import { useConfig } from "@/hooks/use-config";
import { usePluginStore } from "@/stores/plugin-store";
import { useThemeStore } from "@/stores/theme-store";
import { appLifecycleHooks, uiHooks, routerHooks, toastHooks, emailHooks } from "@/lib/plugin-hooks";
import type { EmailReadView } from "@/lib/plugin-types";
function emailToReadView(email: Email): EmailReadView {
return {
id: email.id,
threadId: email.threadId,
mailboxIds: Object.keys(email.mailboxIds || {}).filter(k => email.mailboxIds[k]),
from: (email.from || []).map(a => ({ name: a.name || '', email: a.email })),
to: (email.to || []).map(a => ({ name: a.name || '', email: a.email })),
cc: (email.cc || []).map(a => ({ name: a.name || '', email: a.email })),
subject: email.subject || '',
receivedAt: email.receivedAt,
isRead: !!email.keywords?.['$seen'],
isFlagged: !!email.keywords?.['$flagged'],
hasAttachment: email.hasAttachment,
preview: email.preview || '',
keywords: Object.keys(email.keywords || {}).filter(k => email.keywords[k]),
};
}
export default function Home() {
@@ -115,6 +136,88 @@ export default function Home() {
return () => clearInterval(timer);
}, [isRateLimited, rateLimitUntil]);
// Plugin hooks: window-level lifecycle + selection + service-worker messages.
// One effect because the listeners share a registration / cleanup window.
useEffect(() => {
if (typeof window === 'undefined') return;
const onFocus = () => { appLifecycleHooks.onWindowFocus.emit(); };
const onBlur = () => { appLifecycleHooks.onWindowBlur.emit(); };
const onOnline = () => { appLifecycleHooks.onOnline.emit(); };
const onOffline = () => { appLifecycleHooks.onOffline.emit(); };
let selectionTimer: ReturnType<typeof setTimeout> | null = null;
const onSelectionChange = () => {
if (selectionTimer) clearTimeout(selectionTimer);
selectionTimer = setTimeout(() => {
const sel = document.getSelection();
const text = sel?.toString() ?? '';
if (!text) return;
const anchorNode = sel?.anchorNode as Node | null;
const anchorEl = (anchorNode?.nodeType === Node.ELEMENT_NODE
? anchorNode as Element
: anchorNode?.parentElement) ?? null;
let source: 'email-body' | 'composer' | 'task-detail' | 'event-detail' | 'other' = 'other';
let emailId: string | undefined;
if (anchorEl) {
if (anchorEl.closest('[data-plugin-source="email-body"], iframe.email-body, .email-viewer-body')) {
source = 'email-body';
const idEl = anchorEl.closest('[data-email-id]') as HTMLElement | null;
emailId = idEl?.dataset.emailId;
} else if (anchorEl.closest('[data-plugin-source="composer"], .email-composer')) {
source = 'composer';
} else if (anchorEl.closest('[data-plugin-source="task-detail"]')) {
source = 'task-detail';
} else if (anchorEl.closest('[data-plugin-source="event-detail"]')) {
source = 'event-detail';
}
}
uiHooks.onTextSelectionChange.emit({ text, source, emailId });
}, 150);
};
const onSwMessage = (e: MessageEvent) => {
const msg = e.data as { kind?: string; tag?: string; data?: unknown } | null;
if (msg && msg.kind === 'notificationclick' && typeof msg.tag === 'string') {
toastHooks.onNotificationClick.emit({ tag: msg.tag, data: msg.data });
}
};
window.addEventListener('focus', onFocus);
window.addEventListener('blur', onBlur);
window.addEventListener('online', onOnline);
window.addEventListener('offline', onOffline);
document.addEventListener('selectionchange', onSelectionChange);
if (typeof navigator !== 'undefined' && navigator.serviceWorker) {
navigator.serviceWorker.addEventListener('message', onSwMessage);
}
return () => {
window.removeEventListener('focus', onFocus);
window.removeEventListener('blur', onBlur);
window.removeEventListener('online', onOnline);
window.removeEventListener('offline', onOffline);
document.removeEventListener('selectionchange', onSelectionChange);
if (selectionTimer) clearTimeout(selectionTimer);
if (typeof navigator !== 'undefined' && navigator.serviceWorker) {
navigator.serviceWorker.removeEventListener('message', onSwMessage);
}
};
}, []);
// Plugin hooks: route navigation. Tracks Next.js pathname transitions.
const pathname = usePathname();
const prevPathnameRef = useRef<string | null>(null);
useEffect(() => {
if (!pathname) return;
const from = prevPathnameRef.current;
if (from === pathname) return;
if (from !== null) {
routerHooks.onRouteLeave.emit({ path: from });
routerHooks.onNavigate.emit({ path: pathname, from });
}
routerHooks.onRouteEnter.emit({ path: pathname });
prevPathnameRef.current = pathname;
}, [pathname]);
// Mobile/tablet responsive hooks
const { isMobile, isTablet } = useDeviceDetection();
const { activeView, sidebarOpen, setSidebarOpen, setActiveView, tabletListVisible, setTabletListVisible, sidebarWidth, emailListWidth, setSidebarWidth, setEmailListWidth, persistColumnWidths, sidebarCollapsed, resetSidebarWidth, resetEmailListWidth } = useUIStore();
@@ -139,6 +242,7 @@ export default function Home() {
deleteEmail,
markAsRead,
toggleStar,
setEmailKeywordsLocal,
moveToMailbox,
moveThreadToMailbox,
searchEmails,
@@ -834,7 +938,15 @@ export default function Home() {
}
};
const handleReply = (draftText?: string) => {
const handleReply = async (draftText?: string) => {
if (selectedEmail) {
const ok = await emailHooks.onBeforeReply.intercept({
originalEmailId: selectedEmail.id,
originalEmail: emailToReadView(selectedEmail),
mode: 'reply' as const,
});
if (!ok) return;
}
setComposerDraftText(draftText || "");
setComposerMode('reply');
setShowComposer(true);
@@ -893,13 +1005,29 @@ export default function Home() {
if (isMobile) setActiveView('viewer');
};
const handleReplyAll = () => {
const handleReplyAll = async () => {
if (selectedEmail) {
const ok = await emailHooks.onBeforeReplyAll.intercept({
originalEmailId: selectedEmail.id,
originalEmail: emailToReadView(selectedEmail),
mode: 'reply-all' as const,
});
if (!ok) return;
}
setComposerMode('replyAll');
setShowComposer(true);
if (isMobile) setActiveView('viewer');
};
const handleForward = () => {
const handleForward = async () => {
if (selectedEmail) {
const ok = await emailHooks.onBeforeForward.intercept({
originalEmailId: selectedEmail.id,
originalEmail: emailToReadView(selectedEmail),
mode: 'forward' as const,
});
if (!ok) return;
}
setComposerMode('forward');
setShowComposer(true);
if (isMobile) setActiveView('viewer');
@@ -931,13 +1059,24 @@ export default function Home() {
}
} else {
// Not in trash: always move to trash
const trashMailbox = mailboxes.find(m => m.role === 'trash' && !m.isShared);
const trashMailbox =
mailboxes.find(m => m.role === 'trash' && !m.isShared) ??
mailboxes.find(m => {
if (m.isShared) return false;
const lower = m.name.toLowerCase();
return lower.includes('trash') || lower.includes('deleted');
});
if (trashMailbox) {
try {
await moveToMailbox(client, emailToDelete.id, trashMailbox.id);
} catch (error) {
console.error("Failed to move email to trash:", error);
const { toast } = await import('sonner');
toast.error(error instanceof Error ? error.message : 'Failed to move email to trash');
}
} else {
const { toast } = await import('sonner');
toast.error('Trash mailbox not found - cannot move email to trash');
}
}
};
@@ -1085,11 +1224,9 @@ export default function Home() {
// Update email keywords via JMAP
await client.updateEmailKeywords(emailId, keywords);
// Update local state
selectEmail(email.id === selectedEmail?.id ? { ...email, keywords } : selectedEmail);
// Refresh emails list to show color in list
await fetchEmails(client, selectedMailbox);
// Patch the email in place so the list keeps its scroll/pagination state
// instead of being reset to the first page by a full refetch.
setEmailKeywordsLocal(emailId, keywords);
// Refresh tag counts
fetchTagCounts(client);
+20 -15
View File
@@ -213,6 +213,22 @@ export default function SettingsPage() {
}
}, [initialCheckDone, isAuthenticated, authLoading]);
// Sync the mobile submenu view with browser history so the system back
// button (or gesture) returns to the settings list before exiting /settings.
useEffect(() => {
if (isDesktop) return;
if (typeof window === 'undefined') return;
if (!mobileShowContent) return;
window.history.pushState({ __settingsSubmenu: true }, '');
const handlePop = () => {
setMobileShowContent(false);
};
window.addEventListener('popstate', handlePop);
return () => window.removeEventListener('popstate', handlePop);
}, [isDesktop, mobileShowContent]);
if (!isAuthenticated) {
return null;
}
@@ -321,7 +337,7 @@ export default function SettingsPage() {
<Button
variant="ghost"
size="icon"
onClick={() => setMobileShowContent(false)}
onClick={() => window.history.back()}
className="h-10 w-10"
>
<ArrowLeft className="w-5 h-5" />
@@ -330,9 +346,7 @@ export default function SettingsPage() {
</div>
<div className="flex-1 overflow-y-auto p-4">
<div className="bg-card border border-border rounded-lg p-4">
{renderTabContent()}
</div>
{renderTabContent()}
</div>
<NavigationRail
@@ -515,17 +529,8 @@ export default function SettingsPage() {
/>
<div className="flex-1 overflow-y-auto">
<div className="max-w-3xl mx-auto p-8">
<div className="mb-6">
<div className="flex items-center gap-2.5 mb-2">
<SettingsIcon className="w-6 h-6 text-muted-foreground" />
<h1 className="text-2xl font-semibold text-foreground">{t('title')}</h1>
</div>
</div>
<div className="bg-card border border-border rounded-lg p-6">
{renderTabContent()}
</div>
<div className="max-w-3xl mx-auto px-6 py-6">
{renderTabContent()}
</div>
</div>
</>
+12 -12
View File
@@ -129,8 +129,8 @@ export default function AdminAuthPage() {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<h1 className="text-2xl font-semibold text-foreground">Authentication</h1>
<p className="text-sm text-muted-foreground mt-1">OAuth, SSO, and session configuration</p>
</div>
@@ -154,7 +154,7 @@ export default function AdminAuthPage() {
{/* Auto-setup */}
<div className="rounded-lg border border-primary/30 bg-primary/5 p-4">
<div className="flex items-start justify-between gap-4">
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3 sm:gap-4">
<div className="min-w-0">
<div className="flex items-center gap-2">
<Sparkles className="w-4 h-4 text-primary shrink-0" />
@@ -314,7 +314,7 @@ function Text({ label, description, configKey, value, source, onChange, onRevert
onChange: (k: string, v: unknown) => void; onRevert: (k: string) => void; placeholder?: string; type?: string;
}) {
return (
<div className="px-4 py-3 flex items-center justify-between gap-4">
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm text-foreground">{label}</span>
@@ -322,11 +322,11 @@ function Text({ label, description, configKey, value, source, onChange, onRevert
</div>
{description && <p className="text-xs text-muted-foreground mt-0.5">{description}</p>}
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 w-full sm:w-auto">
<input type={type} value={value ?? ''} onChange={(e) => onChange(configKey, e.target.value)} placeholder={placeholder}
className="h-8 w-64 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" />
className="h-8 w-full sm:w-64 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" />
{source === 'admin' && (
<button onClick={() => onRevert(configKey)} className="text-muted-foreground hover:text-foreground" title="Revert"><RotateCcw className="w-3.5 h-3.5" /></button>
<button onClick={() => onRevert(configKey)} className="shrink-0 text-muted-foreground hover:text-foreground" title="Revert"><RotateCcw className="w-3.5 h-3.5" /></button>
)}
</div>
</div>
@@ -338,7 +338,7 @@ function Toggle({ label, description, configKey, value, source, onChange, onReve
onChange: (k: string, v: unknown) => void; onRevert: (k: string) => void;
}) {
return (
<div className="px-4 py-3 flex items-center justify-between gap-4">
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm text-foreground">{label}</span>
@@ -346,7 +346,7 @@ function Toggle({ label, description, configKey, value, source, onChange, onReve
</div>
{description && <p className="text-xs text-muted-foreground mt-0.5">{description}</p>}
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 shrink-0">
<button onClick={() => onChange(configKey, !value)}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${value ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${value ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
@@ -364,12 +364,12 @@ function Select({ label, configKey, value, source, options, onChange, onRevert }
onChange: (k: string, v: unknown) => void; onRevert: (k: string) => void;
}) {
return (
<div className="px-4 py-3 flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="flex items-center gap-2 min-w-0">
<span className="text-sm text-foreground">{label}</span>
<SourceBadge source={source} />
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 shrink-0">
<select value={value ?? ''} onChange={(e) => onChange(configKey, e.target.value)}
className="h-8 rounded-md border border-input bg-background px-2.5 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
{options.map(o => <option key={o} value={o}>{o}</option>)}
+10 -10
View File
@@ -162,8 +162,8 @@ export default function AdminBrandingPage() {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<h1 className="text-2xl font-semibold text-foreground">Branding</h1>
<p className="text-sm text-muted-foreground mt-1">Customize logos, favicon, and company information</p>
</div>
@@ -193,22 +193,22 @@ export default function AdminBrandingPage() {
<div className="divide-y divide-border">
{IMAGE_FIELDS.map(field => (
<div key={field.key} className="px-4 py-3">
<div className="flex items-center justify-between gap-4">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="flex items-center gap-2 min-w-0">
<label className="text-sm text-foreground whitespace-nowrap">{field.label}</label>
<label className="text-sm text-foreground">{field.label}</label>
{config[field.key]?.source === 'admin' && (
<span className="text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary">
{isUploadedFile(field.key) ? 'uploaded' : 'admin'}
</span>
)}
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 w-full sm:w-auto">
<input
type="text"
value={currentValue(field.key)}
onChange={(e) => handleChange(field.key, e.target.value)}
placeholder="Enter URL or upload a file"
className="h-8 w-64 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
className="h-8 w-full sm:w-64 min-w-0 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
<input
ref={el => { fileInputRefs.current[field.key] = el; }}
@@ -270,20 +270,20 @@ export default function AdminBrandingPage() {
</div>
<div className="divide-y divide-border">
{TEXT_FIELDS.map(field => (
<div key={field.key} className="px-4 py-3 flex items-center justify-between gap-4">
<div key={field.key} className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="flex items-center gap-2 min-w-0">
<label className="text-sm text-foreground whitespace-nowrap">{field.label}</label>
<label className="text-sm text-foreground">{field.label}</label>
{config[field.key]?.source === 'admin' && (
<span className="text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary">admin</span>
)}
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 w-full sm:w-auto">
<input
type="text"
value={currentValue(field.key)}
onChange={(e) => handleChange(field.key, e.target.value)}
placeholder={field.key.includes('Url') ? 'https://...' : 'Enter value'}
className="h-8 w-72 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
className="h-8 w-full sm:w-72 min-w-0 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
{config[field.key]?.source === 'admin' && (
<button onClick={() => handleRevert(field.key)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
+224 -63
View File
@@ -15,12 +15,15 @@ import {
Puzzle,
SwatchBook,
Activity,
Package,
Mail,
Calendar,
BookUser,
HardDrive,
ArrowLeft,
Store,
Menu,
X,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { useConfig } from '@/hooks/use-config';
@@ -28,6 +31,7 @@ import { useThemeStore } from '@/stores/theme-store';
import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
import { useAuthStore } from '@/stores/auth-store';
import { useUpdateStore, selectHasUpdate } from '@/stores/update-store';
import { apiFetch } from '@/lib/browser-navigation';
const NAV_GROUPS = [
@@ -57,6 +61,7 @@ const NAV_GROUPS = [
{
label: 'System',
items: [
{ href: '/admin/version', label: 'Version', icon: Package },
{ href: '/admin/telemetry', label: 'Telemetry', icon: Activity },
{ href: '/admin/logs', label: 'Audit Log', icon: ScrollText },
],
@@ -69,12 +74,33 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
const [authenticated, setAuthenticated] = useState<boolean | null>(null);
const [authError, setAuthError] = useState<string | null>(null);
const [isStalwartAdmin, setIsStalwartAdmin] = useState(false);
const [mobileNavOpen, setMobileNavOpen] = useState(false);
const { appLogoLightUrl, appLogoDarkUrl, loginLogoLightUrl, loginLogoDarkUrl } = useConfig();
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
const logoUrl = resolvedTheme === 'dark'
? (appLogoDarkUrl || appLogoLightUrl || loginLogoDarkUrl)
: (appLogoLightUrl || appLogoDarkUrl || loginLogoLightUrl);
// Match the navigation rail: red for security/deprecated, amber for normal.
const hasUpdate = useUpdateStore(selectHasUpdate);
const updateSeverity = useUpdateStore((s) => s.status?.severity);
const startUpdatePolling = useUpdateStore((s) => s.startPolling);
useEffect(() => { startUpdatePolling(); }, [startUpdatePolling]);
const updateImportant = updateSeverity === 'security' || updateSeverity === 'deprecated';
useEffect(() => {
setMobileNavOpen(false);
}, [pathname]);
useEffect(() => {
if (!mobileNavOpen) return;
const previous = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = previous;
};
}, [mobileNavOpen]);
useEffect(() => {
if (pathname === '/admin/login') return;
let cancelled = false;
@@ -140,10 +166,90 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
return <>{children}</>;
}
const navContent = (
<>
<div className="flex-1 overflow-y-auto py-2">
<div className="px-2 space-y-0.5">
{NAV_GROUPS.map((group, groupIndex) => (
<div key={group.label}>
{groupIndex > 0 && <div className="mx-1 my-2 border-t border-border" />}
<div className="px-3 pt-2.5 pb-1">
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
{group.label}
</span>
</div>
{group.items.map(({ href, label, icon: Icon }) => {
const active = href === '/admin' ? pathname === '/admin' : pathname.startsWith(href);
const showDot = href === '/admin/version' && hasUpdate;
return (
<Link
key={href}
href={href}
className={cn(
'w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
active
? 'bg-accent text-accent-foreground font-medium'
: 'hover:bg-muted text-foreground'
)}
>
<span className="relative shrink-0">
<Icon className={cn(
'w-4 h-4',
active ? 'text-accent-foreground' : 'text-muted-foreground'
)} />
{showDot && (
<span
className={cn(
'absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full ring-2',
active ? 'ring-accent' : 'ring-background',
updateImportant ? 'bg-red-500' : 'bg-amber-500',
)}
aria-label={updateImportant ? 'Important update available' : 'Update available'}
/>
)}
</span>
{label}
</Link>
);
})}
</div>
))}
</div>
</div>
<div className="px-2 py-2 border-t border-border space-y-0.5 shrink-0">
{!isStalwartAdmin && (
<Link
href="/admin/change-password"
className={cn(
'w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
pathname === '/admin/change-password'
? 'bg-accent text-accent-foreground font-medium'
: 'hover:bg-muted text-foreground'
)}
>
<KeyRound className={cn(
'w-4 h-4 shrink-0',
pathname === '/admin/change-password' ? 'text-accent-foreground' : 'text-muted-foreground'
)} />
Change Password
</Link>
)}
<button
onClick={handleLogout}
className="w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5 hover:bg-muted text-foreground"
>
<LogOut className="w-4 h-4 shrink-0 text-muted-foreground" />
Sign out
</button>
</div>
</>
);
return (
<div className="min-h-screen flex bg-background">
{/* Slim webmail nav rail */}
<nav className="w-14 bg-secondary flex flex-col items-center py-3 gap-2 border-r border-border sticky top-0 h-screen shrink-0">
{/* Slim webmail nav rail (desktop only) */}
<nav className="hidden md:flex w-14 bg-secondary flex-col items-center py-3 gap-2 border-r border-border sticky top-0 h-screen shrink-0">
{logoUrl ? (
<img src={logoUrl} alt="" className="w-7 h-7 object-contain mb-2" />
) : (
@@ -191,8 +297,8 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
</div>
</nav>
{/* Admin Sidebar */}
<aside className="w-60 border-r border-border bg-secondary flex flex-col sticky top-0 h-screen">
{/* Admin Sidebar (desktop only) */}
<aside className="hidden md:flex w-60 border-r border-border bg-secondary flex-col sticky top-0 h-screen">
<div className="h-14 flex items-center px-4 border-b border-border shrink-0">
{logoUrl ? (
<img src={logoUrl} alt="" className="w-5 h-5 object-contain mr-2" />
@@ -201,74 +307,71 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
)}
<span className="font-semibold text-sm text-foreground">Admin Panel</span>
</div>
{navContent}
</aside>
<div className="flex-1 overflow-y-auto py-2">
<div className="px-2 space-y-0.5">
{NAV_GROUPS.map((group, groupIndex) => (
<div key={group.label}>
{groupIndex > 0 && <div className="mx-1 my-2 border-t border-border" />}
<div className="px-3 pt-2.5 pb-1">
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
{group.label}
</span>
</div>
{group.items.map(({ href, label, icon: Icon }) => {
const active = href === '/admin' ? pathname === '/admin' : pathname.startsWith(href);
return (
<Link
key={href}
href={href}
className={cn(
'w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
active
? 'bg-accent text-accent-foreground font-medium'
: 'hover:bg-muted text-foreground'
)}
>
<Icon className={cn(
'w-4 h-4 shrink-0',
active ? 'text-accent-foreground' : 'text-muted-foreground'
)} />
{label}
</Link>
);
})}
</div>
))}
{/* Mobile drawer overlay */}
{mobileNavOpen && (
<div
className="md:hidden fixed inset-0 z-40 bg-black/50 backdrop-blur-sm"
onClick={() => setMobileNavOpen(false)}
aria-hidden="true"
/>
)}
{/* Mobile drawer */}
<aside
className={cn(
'md:hidden fixed inset-y-0 left-0 z-50 w-72 max-w-[85vw] border-r border-border bg-secondary flex flex-col transition-transform duration-200 ease-out',
mobileNavOpen ? 'translate-x-0' : '-translate-x-full'
)}
aria-label="Admin navigation"
aria-hidden={!mobileNavOpen}
>
<div className="h-14 flex items-center justify-between px-3 border-b border-border shrink-0">
<div className="flex items-center min-w-0">
{logoUrl ? (
<img src={logoUrl} alt="" className="w-5 h-5 object-contain mr-2" />
) : (
<Shield className="w-5 h-5 text-primary mr-2" />
)}
<span className="font-semibold text-sm text-foreground truncate">Admin Panel</span>
</div>
</div>
<div className="px-2 py-2 border-t border-border space-y-0.5 shrink-0">
{!isStalwartAdmin && (
<Link
href="/admin/change-password"
className={cn(
'w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
pathname === '/admin/change-password'
? 'bg-accent text-accent-foreground font-medium'
: 'hover:bg-muted text-foreground'
)}
>
<KeyRound className={cn(
'w-4 h-4 shrink-0',
pathname === '/admin/change-password' ? 'text-accent-foreground' : 'text-muted-foreground'
)} />
Change Password
</Link>
)}
<button
onClick={handleLogout}
className="w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5 hover:bg-muted text-foreground"
type="button"
onClick={() => setMobileNavOpen(false)}
className="flex items-center justify-center w-9 h-9 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
aria-label="Close navigation"
>
<LogOut className="w-4 h-4 shrink-0 text-muted-foreground" />
Sign out
<X className="w-5 h-5" />
</button>
</div>
{navContent}
</aside>
{/* Main content */}
<main className="flex-1 overflow-auto">
<div className="max-w-4xl mx-auto p-6">
<main className="flex-1 min-w-0 overflow-x-hidden">
{/* Mobile header */}
<div className="md:hidden sticky top-0 z-30 h-14 flex items-center gap-2 px-3 border-b border-border bg-background">
<button
type="button"
onClick={() => setMobileNavOpen(true)}
className="flex items-center justify-center w-9 h-9 rounded-md text-foreground hover:bg-muted transition-colors"
aria-label="Open navigation"
>
<Menu className="w-5 h-5" />
</button>
<div className="flex items-center min-w-0">
{logoUrl ? (
<img src={logoUrl} alt="" className="w-5 h-5 object-contain mr-2" />
) : (
<Shield className="w-5 h-5 text-primary mr-2" />
)}
<span className="font-semibold text-sm text-foreground truncate">Admin Panel</span>
</div>
</div>
<div className="max-w-4xl mx-auto p-4 md:p-6 pb-[calc(4rem+env(safe-area-inset-bottom))] md:pb-6">
{authError ? (
<div className="rounded-lg border border-destructive/40 bg-destructive/10 p-4 text-sm text-destructive">
<p className="font-medium">Admin authentication failed</p>
@@ -283,6 +386,64 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
) : null}
</div>
</main>
{/* Mobile bottom nav (main webmail nav) */}
<nav
className="md:hidden fixed inset-x-0 bottom-0 z-30 flex items-center bg-background border-t border-border pb-[env(safe-area-inset-bottom)]"
aria-label="Main navigation"
>
<a
href="/"
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] transition-colors duration-150 text-muted-foreground hover:text-foreground"
title="Mail"
>
<Mail className="w-5 h-5" />
<span className="text-[10px] font-medium leading-tight truncate max-w-full">Mail</span>
</a>
<a
href="/calendar"
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] transition-colors duration-150 text-muted-foreground hover:text-foreground"
title="Calendar"
>
<Calendar className="w-5 h-5" />
<span className="text-[10px] font-medium leading-tight truncate max-w-full">Calendar</span>
</a>
<a
href="/contacts"
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] transition-colors duration-150 text-muted-foreground hover:text-foreground"
title="Contacts"
>
<BookUser className="w-5 h-5" />
<span className="text-[10px] font-medium leading-tight truncate max-w-full">Contacts</span>
</a>
<a
href="/files"
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] transition-colors duration-150 text-muted-foreground hover:text-foreground"
title="Files"
>
<HardDrive className="w-5 h-5" />
<span className="text-[10px] font-medium leading-tight truncate max-w-full">Files</span>
</a>
<div
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] text-primary"
title="Admin"
aria-current="page"
>
<div className="relative">
<Shield className="w-5 h-5" />
<span className="absolute -bottom-1 left-1/2 -translate-x-1/2 w-4 h-0.5 rounded-full bg-primary" />
</div>
<span className="text-[10px] font-medium leading-tight truncate max-w-full">Admin</span>
</div>
<a
href="/settings"
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] transition-colors duration-150 text-muted-foreground hover:text-foreground"
title="Settings"
>
<Settings className="w-5 h-5" />
<span className="text-[10px] font-medium leading-tight truncate max-w-full">Settings</span>
</a>
</nav>
</div>
);
}
+36 -8
View File
@@ -33,8 +33,8 @@ export default function AdminLogsPage() {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<h1 className="text-2xl font-semibold text-foreground">Audit Log</h1>
<p className="text-sm text-muted-foreground mt-1">{total} total entries</p>
</div>
@@ -52,7 +52,7 @@ export default function AdminLogsPage() {
<select
value={actionFilter}
onChange={(e) => { setActionFilter(e.target.value); setPage(1); }}
className="h-8 rounded-md border border-input bg-background px-2.5 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
className="h-8 w-full sm:w-auto rounded-md border border-input bg-background px-2.5 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<option value="">All actions</option>
<option value="admin.login">Login</option>
@@ -66,15 +66,43 @@ export default function AdminLogsPage() {
</select>
</div>
{/* Table */}
<div className="border border-border rounded-lg overflow-hidden">
{/* Mobile cards */}
<div className="sm:hidden space-y-2">
{loading && entries.length === 0 ? (
<div className="rounded-lg border border-border px-4 py-8 text-center text-sm text-muted-foreground">Loading...</div>
) : entries.length === 0 ? (
<div className="rounded-lg border border-border px-4 py-8 text-center text-sm text-muted-foreground">No entries found</div>
) : (
entries.map((entry, i) => (
<div key={i} className="rounded-lg border border-border p-3 space-y-1.5">
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-mono px-2 py-0.5 rounded bg-muted text-muted-foreground truncate">
{entry.action}
</span>
<span className="text-[11px] text-muted-foreground whitespace-nowrap">
{new Date(entry.ts).toLocaleString()}
</span>
</div>
<div className="text-xs text-foreground break-words">
{formatDetail(entry.detail)}
</div>
<div className="text-[11px] text-muted-foreground font-mono">
{entry.ip}
</div>
</div>
))
)}
</div>
{/* Desktop table */}
<div className="hidden sm:block border border-border rounded-lg overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-muted/30">
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Time</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Action</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground whitespace-nowrap">Time</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground whitespace-nowrap">Action</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Details</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">IP</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground whitespace-nowrap">IP</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
+36 -34
View File
@@ -210,46 +210,48 @@ export default function MarketplacePreviewPage() {
</Link>
{/* Header */}
<div className="flex items-start gap-4">
<div className="w-14 h-14 rounded-lg bg-muted flex items-center justify-center shrink-0">
{isPlugin ? (
<Puzzle className="w-7 h-7 text-muted-foreground" />
) : (
<SwatchBook className="w-7 h-7 text-muted-foreground" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h1 className="text-2xl font-semibold text-foreground truncate">{ext.name}</h1>
{ext.featured && <Star className="w-4 h-4 text-warning fill-warning shrink-0" />}
{data.installed && (
<span className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-md bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400 font-medium">
<Check className="w-3 h-3" /> Installed
</span>
<div className="flex flex-col gap-4 sm:flex-row sm:items-start">
<div className="flex items-start gap-4 flex-1 min-w-0">
<div className="w-14 h-14 rounded-lg bg-muted flex items-center justify-center shrink-0">
{isPlugin ? (
<Puzzle className="w-7 h-7 text-muted-foreground" />
) : (
<SwatchBook className="w-7 h-7 text-muted-foreground" />
)}
</div>
<div className="flex items-center gap-2 mt-1 text-sm text-muted-foreground flex-wrap">
<span className={`text-[10px] px-1.5 py-0.5 rounded font-medium ${
isPlugin
? 'bg-blue-100 text-blue-700 dark:bg-blue-950/30 dark:text-blue-400'
: 'bg-purple-100 text-purple-700 dark:bg-purple-950/30 dark:text-purple-400'
}`}>
{isPlugin ? (ext.pluginType || 'plugin') : 'theme'}
</span>
{ext.author && (
<span>by {ext.author.displayName}</span>
)}
{ext.latestVersion && <span>v{ext.latestVersion}</span>}
{ext.license && <span>{ext.license}</span>}
<span className="inline-flex items-center gap-1">
<Download className="w-3 h-3" />
{ext.totalDownloads.toLocaleString()}
</span>
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<h1 className="text-2xl font-semibold text-foreground break-words min-w-0">{ext.name}</h1>
{ext.featured && <Star className="w-4 h-4 text-warning fill-warning shrink-0" />}
{data.installed && (
<span className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-md bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400 font-medium">
<Check className="w-3 h-3" /> Installed
</span>
)}
</div>
<div className="flex items-center gap-2 mt-1 text-sm text-muted-foreground flex-wrap">
<span className={`text-[10px] px-1.5 py-0.5 rounded font-medium ${
isPlugin
? 'bg-blue-100 text-blue-700 dark:bg-blue-950/30 dark:text-blue-400'
: 'bg-purple-100 text-purple-700 dark:bg-purple-950/30 dark:text-purple-400'
}`}>
{isPlugin ? (ext.pluginType || 'plugin') : 'theme'}
</span>
{ext.author && (
<span>by {ext.author.displayName}</span>
)}
{ext.latestVersion && <span>v{ext.latestVersion}</span>}
{ext.license && <span>{ext.license}</span>}
<span className="inline-flex items-center gap-1">
<Download className="w-3 h-3" />
{ext.totalDownloads.toLocaleString()}
</span>
</div>
</div>
</div>
{/* Action buttons */}
<div className="flex items-center gap-2 shrink-0">
<div className="flex flex-wrap items-center gap-2 shrink-0">
{data.installed ? (
<>
<Link
+3 -3
View File
@@ -142,8 +142,8 @@ export default function AdminMarketplacePage() {
)}
{/* Search & Filters */}
<div className="flex items-center gap-3">
<div className="relative flex-1">
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
<div className="relative flex-1 min-w-0">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<input
type="text"
@@ -153,7 +153,7 @@ export default function AdminMarketplacePage() {
className="w-full h-9 pl-9 pr-3 rounded-md border border-input bg-background text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring/20 focus:border-ring"
/>
</div>
<div className="flex items-center gap-1 rounded-md border border-input bg-background p-0.5">
<div className="flex items-center gap-1 rounded-md border border-input bg-background p-0.5 self-start sm:self-auto">
{(['all', 'plugin', 'theme'] as const).map((t) => (
<button
key={t}
+9 -9
View File
@@ -262,12 +262,12 @@ export default function AdminPluginsPage() {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<h1 className="text-2xl font-semibold text-foreground">Plugins</h1>
<p className="text-sm text-muted-foreground mt-1">Manage plugins and plugin policy for all users</p>
</div>
<div className="flex items-center gap-2">
<div className="flex flex-wrap items-center gap-2">
{policyDirty && (
<button
onClick={handleSavePolicy}
@@ -309,7 +309,7 @@ export default function AdminPluginsPage() {
<p className="text-xs text-muted-foreground mt-0.5">Control plugin availability for users</p>
</div>
<div className="divide-y divide-border">
<div className="px-4 py-3 flex items-center justify-between gap-4">
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div>
<span className="text-sm text-foreground">Plugins Enabled</span>
<p className="text-xs text-muted-foreground mt-0.5">Allow the plugin system to load and run plugins for users</p>
@@ -320,7 +320,7 @@ export default function AdminPluginsPage() {
</button>
</div>
<div className="px-4 py-3 flex items-center justify-between gap-4">
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div>
<span className="text-sm text-foreground">User Plugin Uploads</span>
<p className="text-xs text-muted-foreground mt-0.5">Allow users to upload plugin ZIP files in Settings</p>
@@ -331,7 +331,7 @@ export default function AdminPluginsPage() {
</button>
</div>
<div className="px-4 py-3 flex items-center justify-between gap-4">
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div>
<span className="text-sm text-foreground">Require Admin Approval</span>
<p className="text-xs text-muted-foreground mt-0.5">User-uploaded plugins must be approved by an admin before they can be enabled</p>
@@ -344,7 +344,7 @@ export default function AdminPluginsPage() {
{/* Force enable / disable all */}
{plugins.length > 0 && (
<div className="px-4 py-3 flex items-center justify-between gap-4">
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div>
<span className="text-sm text-foreground">Force Enable / Disable All</span>
<p className="text-xs text-muted-foreground mt-0.5">Bulk toggle all deployed plugins at once</p>
@@ -388,9 +388,9 @@ export default function AdminPluginsPage() {
) : (
<div className="divide-y divide-border">
{plugins.map(plugin => (
<div key={plugin.id} className="px-4 py-4 flex items-center justify-between gap-4">
<div key={plugin.id} className="px-4 py-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="text-sm font-medium text-foreground">{plugin.name}</span>
<span className="text-xs text-muted-foreground">v{plugin.version}</span>
<span className={`text-xs px-1.5 py-0.5 rounded ${plugin.enabled ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400' : 'bg-muted text-muted-foreground'}`}>
+7 -7
View File
@@ -132,8 +132,8 @@ export default function AdminPolicyPage() {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<h1 className="text-2xl font-semibold text-foreground">User Policy</h1>
<p className="text-sm text-muted-foreground mt-1">Control which features and settings users can access</p>
</div>
@@ -170,13 +170,13 @@ export default function AdminPolicyPage() {
const { label, description } = meta;
const enabled = policy.features[key];
return (
<div key={key} className="px-4 py-3 flex items-center justify-between gap-4">
<div>
<div key={key} className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="min-w-0">
<span className="text-sm text-foreground">{label}</span>
<p className="text-xs text-muted-foreground mt-0.5">{description}</p>
</div>
<button onClick={() => toggleFeature(key)}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${enabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
className={`shrink-0 relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${enabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${enabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
</button>
</div>
@@ -195,9 +195,9 @@ export default function AdminPolicyPage() {
{RESTRICTABLE_SETTINGS.filter(s => s.category === category).map(setting => {
const restriction = policy.restrictions[setting.key] || {};
return (
<div key={setting.key} className="px-4 py-3 flex items-center justify-between gap-4">
<div key={setting.key} className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<span className="text-sm text-foreground">{setting.label}</span>
<div className="flex items-center gap-3">
<div className="flex items-center gap-3 shrink-0">
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer">
<input type="checkbox" checked={!!restriction.locked} onChange={() => toggleLocked(setting.key)}
className="rounded border-input" />
+12 -12
View File
@@ -86,8 +86,8 @@ export default function AdminSettingsPage() {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<h1 className="text-2xl font-semibold text-foreground">Server Settings</h1>
<p className="text-sm text-muted-foreground mt-1">General server configuration</p>
</div>
@@ -166,21 +166,21 @@ function TextSetting({ label, configKey, value, source, onChange, onRevert, plac
onChange: (key: string, value: unknown) => void; onRevert: (key: string) => void; placeholder?: string;
}) {
return (
<div className="px-4 py-3 flex items-center justify-between gap-4">
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="flex items-center gap-2 min-w-0">
<label className="text-sm text-foreground whitespace-nowrap">{label}</label>
<label className="text-sm text-foreground">{label}</label>
<SourceBadge source={source} />
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 w-full sm:w-auto">
<input
type="text"
value={value ?? ''}
onChange={(e) => onChange(configKey, e.target.value)}
placeholder={placeholder}
className="h-8 w-64 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
className="h-8 w-full sm:w-64 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
{source === 'admin' && (
<button onClick={() => onRevert(configKey)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
<button onClick={() => onRevert(configKey)} className="shrink-0 text-muted-foreground hover:text-foreground" title="Revert to default">
<RotateCcw className="w-3.5 h-3.5" />
</button>
)}
@@ -194,7 +194,7 @@ function ToggleSetting({ label, description, configKey, value, source, onChange,
onChange: (key: string, value: unknown) => void; onRevert: (key: string) => void;
}) {
return (
<div className="px-4 py-3 flex items-center justify-between gap-4">
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm text-foreground">{label}</span>
@@ -202,7 +202,7 @@ function ToggleSetting({ label, description, configKey, value, source, onChange,
</div>
{description && <p className="text-xs text-muted-foreground mt-0.5">{description}</p>}
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 shrink-0">
<button
onClick={() => onChange(configKey, !value)}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${value ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}
@@ -224,12 +224,12 @@ function SelectSetting({ label, configKey, value, source, options, onChange, onR
onChange: (key: string, value: unknown) => void; onRevert: (key: string) => void;
}) {
return (
<div className="px-4 py-3 flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="flex items-center gap-2 min-w-0">
<span className="text-sm text-foreground">{label}</span>
<SourceBadge source={source} />
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 shrink-0">
<select
value={value ?? ''}
onChange={(e) => onChange(configKey, e.target.value)}
+9 -9
View File
@@ -114,7 +114,7 @@ export default function AdminTelemetryPage() {
const isOn = status.consent === 'on';
return (
<div className="max-w-3xl mx-auto p-6 space-y-6">
<div className="space-y-6">
<header className="space-y-2">
<h1 className="text-2xl font-semibold">Anonymous Usage Stats</h1>
<p className="text-sm text-muted-foreground">
@@ -133,8 +133,8 @@ export default function AdminTelemetryPage() {
</header>
<section className="rounded-lg border p-4 space-y-3">
<div className="flex items-center justify-between">
<div>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div className="min-w-0">
<div className="font-medium">Status</div>
<div className="text-sm text-muted-foreground">
{status.consent === 'pending' && 'Initialising - no heartbeats sent yet.'}
@@ -195,19 +195,19 @@ export default function AdminTelemetryPage() {
Where heartbeats are sent. Defaults to the project's collector. Point at your own collector
(open source at <code>bulwarkmail/dashboard</code>) or clear this field to disable sending.
</p>
<div className="flex gap-2">
<div className="flex flex-col sm:flex-row gap-2">
<input
type="url"
value={endpointDraft}
onChange={(e) => setEndpointDraft(e.target.value)}
placeholder={status.defaultEndpoint}
className="flex-1 px-3 py-1.5 rounded-md border bg-background"
className="flex-1 min-w-0 px-3 py-1.5 rounded-md border bg-background"
/>
<button
type="button"
disabled={busy === 'endpoint' || endpointDraft === status.endpoint}
onClick={() => void saveEndpoint()}
className="px-3 py-1.5 rounded-md border bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 inline-flex items-center gap-1"
className="shrink-0 px-3 py-1.5 rounded-md border bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 inline-flex items-center justify-center gap-1"
>
<Save className="h-4 w-4" /> Save
</button>
@@ -215,8 +215,8 @@ export default function AdminTelemetryPage() {
</section>
<section className="rounded-lg border p-4 space-y-3">
<div className="flex items-center justify-between">
<div>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div className="min-w-0">
<div className="font-medium">Payload preview</div>
<div className="text-sm text-muted-foreground">
Exactly what the next heartbeat would send from this install, right now.
@@ -226,7 +226,7 @@ export default function AdminTelemetryPage() {
type="button"
disabled={busy === 'send' || !isOn}
onClick={() => void sendNow()}
className="px-3 py-1.5 rounded-md border bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 inline-flex items-center gap-1"
className="shrink-0 px-3 py-1.5 rounded-md border bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 inline-flex items-center gap-1"
>
<Send className="h-4 w-4" /> Send now
</button>
+17 -17
View File
@@ -307,12 +307,12 @@ export default function AdminThemesPage() {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<h1 className="text-2xl font-semibold text-foreground">Themes</h1>
<p className="text-sm text-muted-foreground mt-1">Manage themes and theme policy for all users</p>
</div>
<div className="flex items-center gap-2">
<div className="flex flex-wrap items-center gap-2">
{policyDirty && (
<button
onClick={handleSavePolicy}
@@ -356,37 +356,37 @@ export default function AdminThemesPage() {
<div className="divide-y divide-border">
{/* Master toggle */}
<div className="px-4 py-3 flex items-center justify-between gap-4">
<div>
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="min-w-0">
<span className="text-sm text-foreground">Themes Enabled</span>
<p className="text-xs text-muted-foreground mt-0.5">Allow users to select and apply themes</p>
</div>
<button onClick={toggleThemesEnabled}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${themesEnabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
className={`shrink-0 relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${themesEnabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${themesEnabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
</button>
</div>
{/* User uploads toggle */}
<div className="px-4 py-3 flex items-center justify-between gap-4">
<div>
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="min-w-0">
<span className="text-sm text-foreground">User Theme Uploads</span>
<p className="text-xs text-muted-foreground mt-0.5">Allow users to upload their own theme files</p>
</div>
<button onClick={toggleUserThemeUploads}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${userThemesEnabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
className={`shrink-0 relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${userThemesEnabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${userThemesEnabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
</button>
</div>
{/* Force enable / disable all */}
{themes.length > 0 && (
<div className="px-4 py-3 flex items-center justify-between gap-4">
<div>
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="min-w-0">
<span className="text-sm text-foreground">Force Enable / Disable All</span>
<p className="text-xs text-muted-foreground mt-0.5">Bulk toggle all deployed themes at once</p>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 shrink-0">
<button
onClick={forceEnableAll}
className="inline-flex items-center gap-1.5 h-7 px-3 rounded-md bg-emerald-600 text-white text-xs font-medium hover:bg-emerald-700 transition-colors"
@@ -407,15 +407,15 @@ export default function AdminThemesPage() {
{/* Default Theme */}
<div className="px-4 py-3">
<div className="flex items-center justify-between gap-4">
<div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="min-w-0">
<span className="text-sm text-foreground">Default Theme</span>
<p className="text-xs text-muted-foreground mt-0.5">Theme applied when users have not chosen one</p>
</div>
<select
value={policy.themePolicy?.defaultThemeId || ''}
onChange={(e) => setDefaultTheme(e.target.value || null)}
className="h-8 px-2 rounded-md border border-input bg-background text-sm text-foreground"
className="h-8 px-2 w-full sm:w-auto shrink-0 rounded-md border border-input bg-background text-sm text-foreground"
>
<option value="">System Default</option>
<optgroup label="Built-in">
@@ -498,9 +498,9 @@ export default function AdminThemesPage() {
) : (
<div className="divide-y divide-border">
{themes.map(theme => (
<div key={theme.id} className="px-4 py-4 flex items-center justify-between gap-4">
<div key={theme.id} className="px-4 py-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="text-sm font-medium text-foreground">{theme.name}</span>
<span className="text-xs text-muted-foreground">v{theme.version}</span>
<span className={`text-xs px-1.5 py-0.5 rounded ${theme.enabled ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400' : 'bg-muted text-muted-foreground'}`}>
+237
View File
@@ -0,0 +1,237 @@
'use client';
import { useEffect, useState } from 'react';
import {
Loader2,
RefreshCw,
CheckCircle2,
AlertTriangle,
ShieldAlert,
ExternalLink,
} from 'lucide-react';
import { SettingsSection, SettingItem } from '@/components/settings/settings-section';
import { apiFetch } from '@/lib/browser-navigation';
import type { UpdateStatus, UpdateSeverity } from '@/lib/version-check/types';
interface VersionAdminStatus {
current: string;
build: string;
endpoint: string;
defaultEndpoint: string;
disabledByEnv: boolean;
lastCheckedAt: string | null;
lastSuccessAt: string | null;
nextScheduledAt: string | null;
status: UpdateStatus | null;
}
function timeAgo(iso: string | null): string {
if (!iso) return 'never';
const d = Date.now() - new Date(iso).getTime();
if (d < 0) return new Date(iso).toLocaleString();
const m = Math.floor(d / 60000);
if (m < 1) return 'just now';
if (m < 60) return `${m} min ago`;
const h = Math.floor(m / 60);
if (h < 48) return `${h} hours ago`;
return `${Math.floor(h / 24)} days ago`;
}
function severityChip(severity: UpdateSeverity) {
switch (severity) {
case 'security':
return {
label: 'Security update',
className: 'bg-red-500/10 text-red-700 dark:text-red-300 border-red-500/30',
Icon: ShieldAlert,
};
case 'deprecated':
return {
label: 'Deprecated',
className: 'bg-red-500/10 text-red-700 dark:text-red-300 border-red-500/30',
Icon: ShieldAlert,
};
case 'normal':
return {
label: 'Update available',
className: 'bg-amber-500/10 text-amber-700 dark:text-amber-300 border-amber-500/30',
Icon: AlertTriangle,
};
case 'unknown':
return {
label: 'Unknown',
className: 'bg-muted text-muted-foreground border-border',
Icon: AlertTriangle,
};
case 'none':
default:
return {
label: 'Up to date',
className: 'bg-emerald-500/10 text-emerald-700 dark:text-emerald-300 border-emerald-500/30',
Icon: CheckCircle2,
};
}
}
export default function AdminVersionPage() {
const [data, setData] = useState<VersionAdminStatus | null>(null);
const [loading, setLoading] = useState(true);
const [checking, setChecking] = useState(false);
const [checkResult, setCheckResult] = useState<{ ok: boolean; msg: string } | null>(null);
async function refresh(): Promise<void> {
setLoading(true);
try {
const r = await apiFetch('/api/admin/version');
if (!r.ok) throw new Error('failed to load');
setData((await r.json()) as VersionAdminStatus);
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
}
useEffect(() => { void refresh(); }, []);
async function checkNow(): Promise<void> {
setChecking(true);
setCheckResult(null);
try {
const r = await apiFetch('/api/admin/version', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ action: 'check-now' }),
});
const j = (await r.json().catch(() => ({}))) as { ok?: boolean; error?: string };
setCheckResult({
ok: !!j.ok,
msg: j.ok ? 'Update check completed.' : `Failed: ${j.error ?? 'unknown'}`,
});
await refresh();
} finally {
setChecking(false);
}
}
if (loading || !data) {
return (
<div className="p-8 flex items-center gap-2 text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" /> loading
</div>
);
}
const status = data.status;
const chip = severityChip(status?.severity ?? 'none');
const ChipIcon = chip.Icon;
const releaseUrl = status?.url ?? null;
const newer = status?.latest && status.latest !== data.current ? status.latest : null;
return (
<div className="space-y-6">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<h1 className="text-2xl font-semibold text-foreground">Version</h1>
<p className="text-sm text-muted-foreground mt-1">
Hourly check against the Bulwark version server. Severity is decided server-side and
disable with <code>BULWARK_UPDATE_CHECK=off</code>.
</p>
</div>
<button
type="button"
disabled={checking}
onClick={() => void checkNow()}
className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm"
>
{checking ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCw className="w-4 h-4" />}
Check now
</button>
</div>
{checkResult && (
<div
className={`text-sm rounded-md px-3 py-2 ${
checkResult.ok
? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300'
: 'bg-destructive/10 text-destructive'
}`}
>
{checkResult.msg}
</div>
)}
<SettingsSection title="Status">
<SettingItem label="Severity">
<span
className={`inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-xs font-medium ${chip.className}`}
>
<ChipIcon className="h-3 w-3" />
{chip.label}
</span>
</SettingItem>
<SettingItem label="Running" description={data.build !== 'unknown' ? `Build ${data.build}` : undefined}>
<span className="text-sm font-mono text-foreground">{data.current}</span>
</SettingItem>
{newer && (
<SettingItem label="Latest release">
{releaseUrl ? (
<a
href={releaseUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-sm font-mono text-foreground hover:underline"
>
{newer} <ExternalLink className="w-3 h-3" />
</a>
) : (
<span className="text-sm font-mono text-foreground">{newer}</span>
)}
</SettingItem>
)}
{status?.advisory && (
<SettingItem label="Advisory">
<span className="text-sm font-mono text-red-600 dark:text-red-400">{status.advisory}</span>
</SettingItem>
)}
</SettingsSection>
<SettingsSection title="Schedule" description="Hourly polling with ±5 minute jitter.">
<SettingItem label="Last checked">
<span className="text-sm text-foreground">{timeAgo(data.lastCheckedAt)}</span>
</SettingItem>
<SettingItem label="Last success">
<span className="text-sm text-foreground">{timeAgo(data.lastSuccessAt)}</span>
</SettingItem>
<SettingItem label="Next scheduled">
<span className="text-sm text-foreground">{timeAgo(data.nextScheduledAt)}</span>
</SettingItem>
{status?.checkedAt && (
<SettingItem label="Server timestamp" description="When the server last refreshed its release list.">
<span className="text-sm text-foreground">{new Date(status.checkedAt).toLocaleString()}</span>
</SettingItem>
)}
</SettingsSection>
<SettingsSection title="Source">
<SettingItem
label="Endpoint"
description={data.endpoint === data.defaultEndpoint ? 'Default endpoint.' : `Default: ${data.defaultEndpoint}`}
>
<a
href={data.endpoint}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-sm text-foreground hover:underline break-all"
>
{data.endpoint} <ExternalLink className="w-3 h-3 shrink-0" />
</a>
</SettingItem>
<SettingItem label="Disabled by env" description="Set BULWARK_UPDATE_CHECK=off to disable.">
<span className={`text-sm font-medium ${data.disabledByEnv ? 'text-amber-600 dark:text-amber-400' : 'text-muted-foreground'}`}>
{data.disabledByEnv ? 'Yes' : 'No'}
</span>
</SettingItem>
</SettingsSection>
</div>
);
}
+66
View File
@@ -0,0 +1,66 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireAdminAuth } from '@/lib/admin/session';
import { logger } from '@/lib/logger';
import {
loadState,
checkOnce,
effectiveEndpoint,
disabledByEnv,
DEFAULT_VERSION_ENDPOINT,
} from '@/lib/version-check';
/**
* GET /api/admin/version
* Returns the cached update status, last check times, and effective config.
*/
export async function GET() {
try {
const auth = await requireAdminAuth();
if ('error' in auth) return auth.error;
const state = await loadState();
return NextResponse.json(
{
current: process.env.NEXT_PUBLIC_APP_VERSION || '0.0.0',
build: process.env.NEXT_PUBLIC_GIT_COMMIT || 'unknown',
endpoint: effectiveEndpoint(state),
defaultEndpoint: DEFAULT_VERSION_ENDPOINT,
disabledByEnv: disabledByEnv(),
lastCheckedAt: state.lastCheckedAt,
lastSuccessAt: state.lastSuccessAt,
nextScheduledAt: state.nextScheduledAt,
status: state.status,
},
{ headers: { 'Cache-Control': 'no-store' } },
);
} catch (err) {
logger.error('version admin GET error', {
error: err instanceof Error ? err.message : 'unknown',
});
return NextResponse.json({ error: 'failed' }, { status: 500 });
}
}
/**
* POST /api/admin/version
* { action: 'check-now' } — force a fresh upstream fetch.
*/
export async function POST(req: NextRequest) {
try {
const auth = await requireAdminAuth();
if ('error' in auth) return auth.error;
const body = (await req.json().catch(() => null)) as { action?: string } | null;
if (!body || body.action !== 'check-now') {
return NextResponse.json({ error: 'unknown action' }, { status: 400 });
}
const result = await checkOnce({ reason: 'admin-trigger' });
return NextResponse.json(result);
} catch (err) {
logger.error('version admin POST error', {
error: err instanceof Error ? err.message : 'unknown',
});
return NextResponse.json({ error: 'failed' }, { status: 500 });
}
}
+9 -3
View File
@@ -13,12 +13,18 @@ export async function POST(request: NextRequest) {
const cookieStore = await cookies();
try {
const { code, state } = await request.json();
const { code, state, slot: bodySlot } = await request.json();
if (!code || !state) {
return NextResponse.json({ error: 'Missing code or state' }, { status: 400 });
}
// Per-account refresh-token cookie slot. Without this the route hardcoded
// slot 0, so the "+ Add Account" flow overwrote the first account's
// refresh-token cookie. Default to 0 for back-compat with any caller that
// omits slot. Mirrors the validation in /api/auth/token POST.
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : 0;
// Read and decrypt the pending SSO cookie
const pendingCookie = cookieStore.get(SSO_PENDING_COOKIE)?.value;
if (!pendingCookie) {
@@ -58,9 +64,9 @@ export async function POST(request: NextRequest) {
// Exchange code for tokens
const tokens = await exchangeCodeForTokens(code, codeVerifier, redirectUri);
// Store refresh token
// Store refresh token in the per-account cookie slot.
if (tokens.refresh_token) {
const cookieName = refreshTokenCookieName(0);
const cookieName = refreshTokenCookieName(slot);
cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions());
}
+45 -9
View File
@@ -40,17 +40,53 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: 'Incomplete JMAP session' }, { status: 502 });
}
// Find the inbox, then pull the most recent unread message in it. We use
// a single batched JMAP request with back-references so this round-trip
// is one POST regardless of how many messages exist.
const inboxRes = await fetch(apiUrl, {
method: 'POST',
headers: {
Authorization: creds.authHeader,
'Content-Type': 'application/json',
},
body: JSON.stringify({
using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail'],
methodCalls: [
[
'Mailbox/query',
{ accountId, filter: { role: 'inbox' }, limit: 1 },
'mb',
],
],
}),
});
if (!inboxRes.ok) {
return NextResponse.json({ error: 'JMAP mailbox query failed' }, { status: 502 });
}
const inboxData = (await inboxRes.json()) as {
methodResponses: [string, Record<string, unknown>, string][];
};
const inboxBody = inboxData.methodResponses.find(
([method]) => method === 'Mailbox/query',
)?.[1] as { ids?: string[] } | undefined;
const inboxId = inboxBody?.ids?.[0];
if (!inboxId) {
return NextResponse.json({
email: null,
unreadTotal: 0,
}, {
headers: {
'Cache-Control': 'no-store',
},
});
}
// Pull the most recent unread message from the resolved Inbox mailbox.
const requestBody = {
using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail'],
methodCalls: [
[
'Mailbox/query',
{ accountId, filter: { role: 'inbox' }, limit: 1 },
'mb',
],
[
'Email/query',
{
@@ -58,7 +94,7 @@ export async function GET(request: NextRequest) {
filter: {
operator: 'AND',
conditions: [
{ inMailbox: { resultOf: 'mb', name: 'Mailbox/query', path: '/ids/0' } },
{ inMailbox: inboxId },
{ notKeyword: '$seen' },
],
},
+31
View File
@@ -0,0 +1,31 @@
import { NextResponse } from 'next/server';
import { checkOnce, loadState } from '@/lib/version-check';
// Public endpoint that returns the latest cached update status. Fed by the
// background scheduler started in instrumentation.node.ts; in production we
// never trigger a fresh upstream fetch from this route so an unauthenticated
// client can't use it to amplify traffic to the version server.
//
// In development we force a fresh fetch on every hit so changes to the
// version server's overrides take effect on the next page reload instead of
// requiring a dev-server restart. The 5s upstream timeout in fetchStatus
// caps the worst-case latency added to a dev reload.
export async function GET() {
if (process.env.NODE_ENV === 'development') {
await checkOnce({ reason: 'dev-reload' });
}
const state = await loadState();
return NextResponse.json(
{
status: state.status,
lastCheckedAt: state.lastCheckedAt,
lastSuccessAt: state.lastSuccessAt,
},
{
headers: {
'Cache-Control': 'no-store',
},
},
);
}
+9
View File
@@ -522,6 +522,15 @@ body {
}
}
.scroll-hidden::-webkit-scrollbar {
display: none;
}
.scroll-hidden {
-ms-overflow-style: none;
scrollbar-width: none;
}
/* Mobile backdrop blur support */
@supports (backdrop-filter: blur(8px)) {
.mobile-backdrop {
+20 -14
View File
@@ -2,6 +2,12 @@ import type { MetadataRoute } from "next";
export const dynamic = "force-dynamic";
// Manifest paths must include the deployment subpath - browsers resolve them
// against the document origin, not the manifest's location, and Next.js does
// not auto-prefix string literals inside MetadataRoute payloads.
const BASE_PATH = (process.env.NEXT_PUBLIC_BASE_PATH ?? "").replace(/\/+$/, "");
const withBase = (p: string) => `${BASE_PATH}${p}`;
export default function manifest(): MetadataRoute.Manifest {
const appName =
process.env.APP_NAME ||
@@ -21,26 +27,26 @@ export default function manifest(): MetadataRoute.Manifest {
const icons: MetadataRoute.Manifest["icons"] = hasCustomIcon
? [
{ src: "/api/pwa-icon/192", sizes: "192x192", type: "image/png", purpose: "any" },
{ src: "/api/pwa-icon/512", sizes: "512x512", type: "image/png", purpose: "any" },
{ src: "/api/pwa-icon/192", sizes: "192x192", type: "image/png", purpose: "maskable" },
{ src: "/api/pwa-icon/512", sizes: "512x512", type: "image/png", purpose: "maskable" },
{ src: withBase("/api/pwa-icon/192"), sizes: "192x192", type: "image/png", purpose: "any" },
{ src: withBase("/api/pwa-icon/512"), sizes: "512x512", type: "image/png", purpose: "any" },
{ src: withBase("/api/pwa-icon/192"), sizes: "192x192", type: "image/png", purpose: "maskable" },
{ src: withBase("/api/pwa-icon/512"), sizes: "512x512", type: "image/png", purpose: "maskable" },
]
: [
{ src: "/icon-192x192.png", sizes: "192x192", type: "image/png", purpose: "any" },
{ src: "/icon-512x512.png", sizes: "512x512", type: "image/png", purpose: "any" },
{ src: "/icon-maskable-light-192x192.png", sizes: "192x192", type: "image/png", purpose: "maskable" },
{ src: "/icon-maskable-light-512x512.png", sizes: "512x512", type: "image/png", purpose: "maskable" },
{ src: "/icon-maskable-dark-192x192.png", sizes: "192x192", type: "image/png", purpose: "maskable" },
{ src: "/icon-maskable-dark-512x512.png", sizes: "512x512", type: "image/png", purpose: "maskable" },
{ src: withBase("/icon-192x192.png"), sizes: "192x192", type: "image/png", purpose: "any" },
{ src: withBase("/icon-512x512.png"), sizes: "512x512", type: "image/png", purpose: "any" },
{ src: withBase("/icon-maskable-light-192x192.png"), sizes: "192x192", type: "image/png", purpose: "maskable" },
{ src: withBase("/icon-maskable-light-512x512.png"), sizes: "512x512", type: "image/png", purpose: "maskable" },
{ src: withBase("/icon-maskable-dark-192x192.png"), sizes: "192x192", type: "image/png", purpose: "maskable" },
{ src: withBase("/icon-maskable-dark-512x512.png"), sizes: "512x512", type: "image/png", purpose: "maskable" },
];
return {
name: appName,
short_name: shortName,
description,
start_url: "/",
scope: "/",
start_url: withBase("/"),
scope: withBase("/"),
display: "standalone",
orientation: "portrait-primary",
theme_color: themeColor,
@@ -48,8 +54,8 @@ export default function manifest(): MetadataRoute.Manifest {
icons,
categories: ["productivity"],
screenshots: [
{ src: "/screenshot-540x720.png", sizes: "540x720", type: "image/png" },
{ src: "/screenshot-1280x720.png", sizes: "1280x720", type: "image/png" },
{ src: withBase("/screenshot-540x720.png"), sizes: "540x720", type: "image/png" },
{ src: withBase("/screenshot-1280x720.png"), sizes: "1280x720", type: "image/png" },
],
};
}
+16 -7
View File
@@ -2,34 +2,43 @@
import { useEffect } from "react";
import { useAuthStore } from "@/stores/auth-store";
import { getPathPrefix } from "@/lib/browser-navigation";
export default function NotFound() {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
useEffect(() => {
if (!isAuthenticated) {
// Don't redirect admin routes to the webmail login page
const isAdminRoute = window.location.pathname === '/admin' || window.location.pathname.startsWith('/admin/');
const prefix = getPathPrefix();
// Don't redirect admin routes to the webmail login page. Admin paths
// are mounted relative to the deployment prefix, so account for it.
const adminBase = `${prefix}/admin`;
const isAdminRoute = window.location.pathname === adminBase || window.location.pathname.startsWith(`${adminBase}/`);
if (!isAdminRoute) {
window.location.href = "/login";
window.location.href = `${prefix}/login`;
}
}
}, [isAuthenticated]);
if (!isAuthenticated) {
// Allow admin routes to render the 404 without redirecting
const isAdmin = typeof window !== 'undefined' &&
(window.location.pathname === '/admin' || window.location.pathname.startsWith('/admin/'));
let isAdmin = false;
if (typeof window !== 'undefined') {
const prefix = getPathPrefix();
const adminBase = `${prefix}/admin`;
isAdmin = window.location.pathname === adminBase || window.location.pathname.startsWith(`${adminBase}/`);
}
if (!isAdmin) return null;
}
const prefix = typeof window !== 'undefined' ? getPathPrefix() : '';
return (
<div className="min-h-screen flex items-center justify-center bg-background">
<div className="text-center max-w-md px-4">
<h1 className="text-4xl font-bold text-foreground mb-2">404</h1>
<p className="text-muted-foreground mb-6">This page could not be found.</p>
<a
href="/"
href={`${prefix}/`}
className="inline-flex items-center px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:opacity-90 transition-opacity"
>
Go home
@@ -144,6 +144,12 @@ export function CalendarSidebarPanel({
{cal.id === BIRTHDAY_CALENDAR_ID && (
<Cake className="w-3 h-3 text-muted-foreground flex-shrink-0" />
)}
{!cal.isShared && Object.keys(cal.shareWith || {}).length > 0 && (
<Users
className="w-3 h-3 text-muted-foreground flex-shrink-0"
aria-label={tMgmt('share')}
/>
)}
</button>
</div>
);
+2 -3
View File
@@ -150,9 +150,8 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
aria-label={ariaLabel}
{...dragProps}
className={cn(
"w-full h-full text-left rounded px-1.5 py-0.5 text-xs overflow-hidden",
"w-full h-full text-left rounded-r px-1.5 py-0.5 text-xs overflow-hidden",
"hover:opacity-90 transition-opacity cursor-pointer",
continuesBefore && "rounded-l-sm",
continuesAfter && "rounded-r-sm",
continuesBefore && "-ml-0.5",
continuesAfter && "pr-2",
@@ -182,7 +181,7 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
{...dragProps}
data-calendar-event
className={cn(
"w-full h-full text-left rounded px-1.5 py-0.5 text-xs overflow-hidden",
"w-full h-full text-left rounded-r px-1.5 py-0.5 text-xs overflow-hidden",
"hover:opacity-90 transition-opacity cursor-pointer",
isSelected && "ring-2 ring-primary",
isBeingDragged && "opacity-50",
+47
View File
@@ -22,6 +22,8 @@ import { PluginSlot } from "@/components/plugins/plugin-slot";
import { useSettingsStore } from "@/stores/settings-store";
import { generateUUID } from "@/lib/utils";
import { useFormatEventDate } from "@/hooks/use-format-event-date";
import { calendarHooks } from "@/lib/plugin-hooks";
import type { ConflictWarning } from "@/lib/plugin-types";
export interface PendingEventPreview {
start: Date;
@@ -242,6 +244,31 @@ export function EventModal({
const [sendInvitations, setSendInvitations] = useState(true);
const participantInputRef = useRef<ParticipantInputHandle>(null);
// Plugin transform: collect conflict warnings for the current event form.
// Re-runs (debounced) whenever fields that affect scheduling change.
const [pluginConflictWarnings, setPluginConflictWarnings] = useState<ConflictWarning[]>([]);
useEffect(() => {
let cancelled = false;
const t = setTimeout(async () => {
const startStr = allDay ? `${startDate}T00:00:00` : `${startDate}T${startTime}:00`;
const endStr = allDay ? `${endDate}T23:59:59` : `${endDate}T${endTime}:00`;
const warnings = await calendarHooks.onCheckEventConflicts.transform([] as ConflictWarning[], {
event: {
title,
description,
start: startStr,
end: endStr,
isAllDay: allDay,
location,
virtualLocation,
calendarId,
},
});
if (!cancelled) setPluginConflictWarnings(warnings);
}, 250);
return () => { cancelled = true; clearTimeout(t); };
}, [title, description, startDate, startTime, endDate, endTime, allDay, location, virtualLocation, calendarId]);
// Report live preview to parent for grid outline
useEffect(() => {
if (!onPreviewChange || isEdit) return;
@@ -923,6 +950,26 @@ export function EventModal({
)}
</div>
{pluginConflictWarnings.length > 0 && (
<div className="space-y-1.5">
{pluginConflictWarnings.map(w => (
<div
key={w.key}
className={
w.severity === 'error'
? 'text-sm rounded-md border border-destructive/50 bg-destructive/10 text-destructive px-3 py-2'
: w.severity === 'info'
? 'text-sm rounded-md border border-border bg-muted/40 text-muted-foreground px-3 py-2'
: 'text-sm rounded-md border border-yellow-500/50 bg-yellow-500/10 text-yellow-700 dark:text-yellow-300 px-3 py-2'
}
title={w.message}
>
{w.message}
</div>
))}
</div>
)}
{calendars.length > 1 && (
<div>
<label className="text-sm font-medium mb-1 block">{t("form.calendar_select")}</label>
+7 -1
View File
@@ -685,7 +685,13 @@ function AddressBookItem({
>
<Book className="w-4 h-4 flex-shrink-0" />
<span className="truncate">{book.name}</span>
<span className="ml-auto text-xs text-muted-foreground tabular-nums">
{!book.isShared && Object.keys(book.shareWith || {}).length > 0 && (
<Users className="w-3 h-3 text-muted-foreground flex-shrink-0 ml-auto" />
)}
<span className={cn(
"text-xs text-muted-foreground tabular-nums",
!(!book.isShared && Object.keys(book.shareWith || {}).length > 0) && "ml-auto"
)}>
{contactCount}
</span>
</button>
+75 -8
View File
@@ -10,6 +10,8 @@ import { cn, formatFileSize, formatDateTime, generateUUID } from "@/lib/utils";
import { debug } from "@/lib/debug";
import { toast } from "@/stores/toast-store";
import { sanitizeEmailHtml } from "@/lib/email-sanitization";
import { emailHooks, contactHooks } from "@/lib/plugin-hooks";
import type { OutgoingEmail, RecipientSuggestion } from "@/lib/plugin-types";
import { useAuthStore } from "@/stores/auth-store";
import { useIdentityStore } from "@/stores/identity-store";
import { useAccountStore } from "@/stores/account-store";
@@ -326,6 +328,9 @@ export function EmailComposer({
? `<div>${getPlainTextSignature(currentIdentity).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>')}</div>`
: '';
const getAutocomplete = useContactStore((s) => s.getAutocomplete);
const addToTrustedSendersBook = useContactStore((s) => s.addToTrustedSendersBook);
const addTrustedSender = useSettingsStore((s) => s.addTrustedSender);
const trustedSendersAddressBook = useSettingsStore((s) => s.trustedSendersAddressBook);
const addTemplate = useTemplateStore((s) => s.addTemplate);
const sendRawEmail = useEmailStore((s) => s.sendRawEmail);
const smimeStore = useSmimeStore();
@@ -446,10 +451,13 @@ export function EmailComposer({
return;
}
autocompleteTimeoutRef.current = setTimeout(() => {
const results = getAutocomplete(lastPart);
setAutocompleteResults(results);
setActiveAutoField(results.length > 0 ? field : null);
autocompleteTimeoutRef.current = setTimeout(async () => {
const localResults = getAutocomplete(lastPart);
// Let plugins contribute extra suggestions (Slack handles, GitHub, CRM, …).
const initial: RecipientSuggestion[] = localResults.map(r => ({ name: r.name, email: r.email }));
const merged = await contactHooks.onProvideRecipientSuggestions.transform(initial, { query: lastPart });
setAutocompleteResults(merged.map(s => ({ name: s.name, email: s.email })));
setActiveAutoField(merged.length > 0 ? field : null);
setAutoSelectedIndex(-1);
}, 200);
}, [getAutocomplete]);
@@ -559,6 +567,19 @@ export function EmailComposer({
const addFiles = useCallback(async (files: File[]) => {
if (!client || files.length === 0) return;
// Let plugins veto each upload before it's queued.
const allowedFiles: File[] = [];
for (const file of files) {
const ok = await emailHooks.onBeforeAttachmentUpload.intercept({
name: file.name,
type: file.type || 'application/octet-stream',
size: file.size,
});
if (ok) allowedFiles.push(file);
}
if (allowedFiles.length === 0) return;
files = allowedFiles;
const newAttachments: ComposerAttachment[] = files.map(file => {
const controller = new AbortController();
return {
@@ -587,6 +608,12 @@ export function EmailComposer({
: att
)
);
emailHooks.onAfterAttachmentUpload.emit({
name: file.name,
type: file.type || 'application/octet-stream',
size: file.size,
blobId,
});
} catch (error) {
if (controller?.signal.aborted) continue;
debug.error(`Failed to upload ${file.name}:`, error);
@@ -782,6 +809,19 @@ export function EmailComposer({
// Set new timeout for auto-save (2 seconds after last change)
saveTimeoutRef.current = setTimeout(() => {
// Plugin observers (AI assist, grammar, …) get a debounced snapshot here.
emailHooks.onDraftChange.emit({
to: to.split(',').map(s => s.trim()).filter(Boolean),
cc: cc.split(',').map(s => s.trim()).filter(Boolean),
bcc: bcc.split(',').map(s => s.trim()).filter(Boolean),
subject,
htmlBody: plainTextMode ? '' : body,
textBody: plainTextMode ? body : htmlToPlainText(body),
identityId: selectedIdentityId || '',
attachments: attachments
.filter(a => a.blobId && !a.uploading && !a.error)
.map(a => ({ name: a.name, type: a.type || 'application/octet-stream', size: a.size })),
});
saveDraft();
}, 2000);
@@ -1065,21 +1105,48 @@ export function EmailComposer({
.map(att => ({ blobId: att.blobId!, name: att.name, type: att.type || 'application/octet-stream', size: att.size }));
uploadedAttachments.push(...inlineAttachments);
await onSend?.({
// Let plugins (signatures, link-rewriting, encryption, AI rewrite, …)
// transform the outgoing message immediately before submission.
const transformInput: OutgoingEmail = {
to: toAddresses,
cc: ccAddresses,
bcc: bccAddresses,
subject,
body: finalBody,
htmlBody: finalHtmlBody,
htmlBody: finalHtmlBody || '',
textBody: finalBody,
identityId: currentIdentity?.id || '',
attachments: uploadedAttachments.map(a => ({ name: a.name, type: a.type, size: a.size })),
inReplyTo: threadingHeaders?.inReplyTo?.[0],
};
const outgoing = await emailHooks.onTransformOutgoingEmail.transform(transformInput);
await onSend?.({
to: outgoing.to,
cc: outgoing.cc,
bcc: outgoing.bcc,
subject: outgoing.subject,
body: outgoing.textBody,
htmlBody: outgoing.htmlBody || undefined,
draftId: finalDraftId || undefined,
fromEmail,
fromName: currentIdentity?.name || undefined,
identityId: currentIdentity?.id,
identityId: outgoing.identityId || currentIdentity?.id,
attachments: uploadedAttachments.length > 0 ? uploadedAttachments : undefined,
inReplyTo: threadingHeaders?.inReplyTo,
references: threadingHeaders?.references,
});
if (mode === 'reply' || mode === 'replyAll') {
for (const recipient of [...outgoing.to, ...outgoing.cc].filter(Boolean)) {
if (trustedSendersAddressBook && client) {
addToTrustedSendersBook(client, recipient).catch(err => {
debug.error('Failed to add trusted sender to address book:', err);
});
} else {
addTrustedSender(recipient);
}
}
}
}
setTo("");
+10 -2
View File
@@ -178,6 +178,14 @@ export function EmailList({
setIsProcessing(true);
try {
await batchDelete(client, isInTrash);
const storeError = useEmailStore.getState().error;
if (storeError) {
const { toast } = await import('sonner');
toast.error(storeError);
}
} catch (err) {
const { toast } = await import('sonner');
toast.error(err instanceof Error ? err.message : 'Failed to delete emails');
} finally {
setTimeout(() => setIsProcessing(false), 500);
}
@@ -275,7 +283,7 @@ export function EmailList({
<div className="px-4 py-2 border-b bg-accent/30 border-border flex items-center justify-between">
<div className="flex items-center gap-2 animate-in fade-in slide-in-from-left-3 duration-300">
<span className="text-sm font-medium text-foreground">
{selectedEmailIds.size} {selectedEmailIds.size === 1 ? 'email' : 'emails'} selected
{t('batch_actions.selected_messages', { count: selectedEmailIds.size })}
</span>
</div>
<div className="flex items-center gap-1 animate-in fade-in slide-in-from-right-3 duration-300">
@@ -330,7 +338,7 @@ export function EmailList({
disabled={isProcessing}
className="text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50"
>
Cancel
{t('batch_actions.clear_selection')}
</Button>
</div>
</div>
File diff suppressed because it is too large Load Diff
+48 -1
View File
@@ -2,6 +2,7 @@
import { useTranslations } from "next-intl";
import { Mailbox } from "@/lib/jmap/types";
import { getMailboxPath } from "@/lib/utils";
import {
ContextMenu,
ContextMenuItem,
@@ -10,8 +11,10 @@ import {
} from "@/components/ui/context-menu";
import {
CheckCheck,
ChevronRight,
MailOpen,
Mails,
MoreHorizontal,
Trash2,
FolderPlus,
Pencil,
@@ -28,12 +31,51 @@ export type MailboxContextTarget =
| { kind: "mailbox"; mailbox: Mailbox; hasChildren: boolean }
| { kind: "folders-section" };
const PATH_SEPARATOR = " ";
const MAX_PATH_LENGTH = 40;
const MAX_SEGMENT_LENGTH = 16;
function truncateSegment(name: string): string {
return name.length > MAX_SEGMENT_LENGTH
? `${name.slice(0, MAX_SEGMENT_LENGTH - 1)}`
: name;
}
function renderPathSegments(segments: string[]): React.ReactNode {
return (
<span className="inline-flex items-center gap-1 align-middle">
{segments.map((seg, i) => (
<span key={i} className="inline-flex items-center gap-1">
{i > 0 && <ChevronRight className="w-3.5 h-3.5 opacity-60" />}
{seg === "…" ? <MoreHorizontal className="w-3.5 h-3.5" /> : <span>{seg}</span>}
</span>
))}
</span>
);
}
function renderShortenedPath(fullPath: string): React.ReactNode {
const segments = fullPath.split(PATH_SEPARATOR);
if (fullPath.length <= MAX_PATH_LENGTH) {
return renderPathSegments(segments);
}
if (segments.length <= 2) {
return renderPathSegments(segments.map(truncateSegment));
}
return renderPathSegments([
truncateSegment(segments[0]),
"…",
truncateSegment(segments[segments.length - 1]),
]);
}
interface MailboxContextMenuProps {
target: MailboxContextTarget | null;
position: Position;
isOpen: boolean;
onClose: () => void;
menuRef: React.RefObject<HTMLDivElement | null>;
mailboxes: Mailbox[];
onMarkFolderRead?: (mailboxId: string) => void;
onMarkFolderTreeRead?: (mailboxId: string) => void;
onMarkAllFoldersRead?: () => void;
@@ -51,6 +93,7 @@ export function MailboxContextMenu({
isOpen,
onClose,
menuRef,
mailboxes,
onMarkFolderRead,
onMarkFolderTreeRead,
onMarkAllFoldersRead,
@@ -107,9 +150,13 @@ export function MailboxContextMenu({
const canSetSeen = mailbox.myRights?.maySetSeen !== false;
const canRemoveItems = mailbox.myRights?.mayRemoveItems !== false;
const fullPath = getMailboxPath(mailbox, mailboxes);
return (
<ContextMenu ref={menuRef} isOpen={isOpen} position={position} onClose={onClose}>
<ContextMenuHeader>{mailbox.name}</ContextMenuHeader>
<ContextMenuHeader>
<span title={fullPath}>{renderShortenedPath(fullPath)}</span>
</ContextMenuHeader>
<ContextMenuItem
icon={MailOpen}
+29 -3
View File
@@ -16,6 +16,7 @@ import { useSettingsStore } from "@/stores/settings-store";
import { usePolicyStore } from "@/stores/policy-store";
import { useAuthStore } from "@/stores/auth-store";
import { useAccountStore } from "@/stores/account-store";
import { useUpdateStore, selectHasUpdate } from "@/stores/update-store";
import { getActiveAccountSlotHeaders } from "@/lib/auth/active-account-slot";
import { getInitials, MAX_ACCOUNTS } from "@/lib/account-utils";
import { cn, formatFileSize } from "@/lib/utils";
@@ -175,6 +176,11 @@ export function NavigationRail({
const visibleSidebarApps = sidebarAppsEnabled ? sidebarApps : [];
const inboxUnread = mailboxes.find(m => m.role === "inbox")?.unreadEmails || 0;
const [isStalwartAdmin, setIsStalwartAdmin] = useState(false);
const hasUpdate = useUpdateStore(selectHasUpdate);
const updateSeverity = useUpdateStore((s) => s.status?.severity);
const startUpdatePolling = useUpdateStore((s) => s.startPolling);
useEffect(() => { startUpdatePolling(); }, [startUpdatePolling]);
const updateImportant = updateSeverity === 'security' || updateSeverity === 'deprecated';
// Account list for rail
const accounts = useAccountStore((s) => s.accounts);
@@ -345,7 +351,18 @@ export function NavigationRail({
"text-muted-foreground hover:text-foreground"
)}
>
<Shield className="w-5 h-5" />
<span className="relative">
<Shield className="w-5 h-5" />
{hasUpdate && (
<span
className={cn(
"absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full ring-2 ring-background",
updateImportant ? "bg-red-500" : "bg-amber-500",
)}
aria-label={updateImportant ? "Important update available" : "Update available"}
/>
)}
</span>
<span className="text-[10px] font-medium leading-tight truncate max-w-full">{t("admin") || "Admin"}</span>
</a>
)}
@@ -400,7 +417,7 @@ export function NavigationRail({
<nav
className={cn(
"flex flex-col",
"flex flex-col flex-1 min-h-0 overflow-y-auto scroll-hidden",
collapsed ? "items-center gap-1 py-3 px-1" : "gap-0.5 py-2 px-2",
)}
role="navigation"
@@ -515,10 +532,19 @@ export function NavigationRail({
{isStalwartAdmin && (
<a
href="/admin"
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted relative"
title={t("admin") || "Admin"}
>
<Shield className="w-[18px] h-[18px]" />
{hasUpdate && (
<span
className={cn(
"absolute top-2 right-2 w-2 h-2 rounded-full ring-2 ring-background",
updateImportant ? "bg-red-500" : "bg-amber-500",
)}
aria-label={updateImportant ? "Important update available" : "Update available"}
/>
)}
</a>
)}
+1
View File
@@ -1028,6 +1028,7 @@ export function Sidebar({
isOpen={mailboxContextMenu.isOpen}
onClose={closeMailboxContextMenu}
menuRef={mailboxMenuRef}
mailboxes={mailboxes}
onMarkFolderRead={onMarkFolderRead}
onMarkFolderTreeRead={onMarkFolderTreeRead}
onMarkAllFoldersRead={onMarkAllFoldersRead}
+3 -1
View File
@@ -2,6 +2,8 @@
import { useEffect } from "react";
const BASE_PATH = (process.env.NEXT_PUBLIC_BASE_PATH ?? "").replace(/\/+$/, "");
export function ServiceWorkerRegistration() {
useEffect(() => {
if (typeof window === "undefined" || !("serviceWorker" in navigator)) {
@@ -23,7 +25,7 @@ export function ServiceWorkerRegistration() {
}
navigator.serviceWorker
.register("/sw.js")
.register(`${BASE_PATH}/sw.js`, { scope: `${BASE_PATH}/` })
.then((registration) => {
console.log("Service Worker registered successfully:", registration);
})
+35 -1
View File
@@ -1,18 +1,51 @@
"use client";
import { useState, useRef } from 'react';
import { useState, useRef, useEffect } from 'react';
import { useTranslations } from 'next-intl';
import { useSettingsStore } from '@/stores/settings-store';
import { useConfig } from '@/hooks/use-config';
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
import { Button } from '@/components/ui/button';
import { usePolicyStore } from '@/stores/policy-store';
import { useUpdateStore } from '@/stores/update-store';
import { ExternalLink } from 'lucide-react';
import { cn } from '@/lib/utils';
import { SpamSiegeGame } from './spam-siege-game';
const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "0.0.0";
const GIT_COMMIT = process.env.NEXT_PUBLIC_GIT_COMMIT || "unknown";
function VersionUpdateTag() {
const status = useUpdateStore((s) => s.status);
const startPolling = useUpdateStore((s) => s.startPolling);
useEffect(() => {
startPolling();
}, [startPolling]);
if (!status?.updateAvailable) return null;
if (status.severity === 'unknown' || status.severity === 'none') return null;
const important = status.severity === 'security' || status.severity === 'deprecated';
const label =
status.severity === 'security' ? 'security'
: status.severity === 'deprecated' ? 'deprecated'
: status.latest ?? 'update';
return (
<span
className={cn(
"ml-2 inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium align-middle",
important
? "bg-red-500/15 text-red-700 dark:text-red-300"
: "bg-amber-500/15 text-amber-700 dark:text-amber-300",
)}
>
{important ? label : `update: ${label}`}
</span>
);
}
export function AboutDataSettings() {
const t = useTranslations('settings.advanced');
const tCommon = useTranslations('common');
@@ -106,6 +139,7 @@ export function AboutDataSettings() {
</p>
<p className="text-xs text-muted-foreground group-hover/about:translate-x-0.5 group-active/about:translate-y-px transition-transform">
v{APP_VERSION} <span className="text-muted-foreground/60">({GIT_COMMIT})</span>
<VersionUpdateTag />
</p>
</div>
</button>
@@ -139,6 +139,19 @@ export function AddressBookManagementSettings() {
{t("default")}
</span>
)}
{(() => {
const shareCount = Object.keys(book.shareWith || {}).length;
if (shareCount === 0 || book.isShared) return null;
return (
<span
className="flex items-center gap-1 text-xs text-muted-foreground bg-muted px-2 py-0.5 rounded-full"
title={t("share")}
>
<Users className="w-3 h-3" />
{shareCount}
</span>
);
})()}
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
{canRename && (
<button
@@ -516,6 +516,20 @@ export function CalendarManagementSettings() {
</span>
)}
{(() => {
const shareCount = Object.keys(cal.shareWith || {}).length;
if (shareCount === 0 || cal.isShared) return null;
return (
<span
className="flex items-center gap-1 text-xs text-muted-foreground bg-muted px-2 py-0.5 rounded-full"
title={t('share')}
>
<Users className="w-3 h-3" />
{shareCount}
</span>
);
})()}
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
type="button"
+2 -1
View File
@@ -6,6 +6,7 @@ import { useConfig } from '@/hooks/use-config';
import { useSettingsStore } from '@/stores/settings-store';
import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
import { Mail, X } from 'lucide-react';
import { getPathPrefix } from '@/lib/browser-navigation';
import {
SUPPORTED_SUB_ADDRESS_DELIMITERS,
isSupportedSubAddressDelimiter,
@@ -32,7 +33,7 @@ export function ComposingSettings() {
const handleSetDefaultMailProgram = useCallback(() => {
try {
if (typeof navigator !== 'undefined' && navigator.registerProtocolHandler) {
navigator.registerProtocolHandler('mailto', `${window.location.origin}/compose?mailto=%s`);
navigator.registerProtocolHandler('mailto', `${window.location.origin}${getPathPrefix()}/compose?mailto=%s`);
setDefaultMailStatus('success');
}
} catch {
+8
View File
@@ -34,6 +34,7 @@ export function ReadingSettings() {
hoverActionsMode,
hoverActionsCorner,
hideInlineImageAttachments,
attachmentImagePreviewsEnabled,
updateSetting,
} = useSettingsStore();
@@ -206,6 +207,13 @@ export function ReadingSettings() {
/>
</SettingItem>
<SettingItem label={t('attachment_image_previews.label')} description={t('attachment_image_previews.description')}>
<ToggleSwitch
checked={attachmentImagePreviewsEnabled}
onChange={(checked) => updateSetting('attachmentImagePreviewsEnabled', checked)}
/>
</SettingItem>
{isFeatureEnabled('hoverActionsConfigEnabled') && (
<div className="py-3 border-b border-border space-y-3">
<div>
+2 -2
View File
@@ -44,8 +44,8 @@ interface SettingItemProps {
export function SettingItem({ label, description, children, locked }: SettingItemProps) {
return (
<div className={cn("flex items-start justify-between py-3 border-b border-border last:border-0", locked && "opacity-60")}>
<div className="flex-1 pr-4">
<div className={cn("flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-4 py-3 border-b border-border last:border-0", locked && "opacity-60")}>
<div className="flex-1 min-w-0 sm:pr-4">
<div className="flex items-center gap-1.5">
<label className="text-sm font-medium text-foreground">{label}</label>
{locked && <Lock className="w-3 h-3 text-muted-foreground" aria-label="Managed by administrator" />}
+14 -11
View File
@@ -79,7 +79,7 @@ export function ShareCollectionDialog({
const t = useTranslations("sharing");
const tCommon = useTranslations("common");
const modalRef = useRef<HTMLDivElement>(null);
const [principals, setPrincipals] = useState<Principal[]>([]);
const [allPrincipals, setAllPrincipals] = useState<Principal[]>([]);
const [loadingPrincipals, setLoadingPrincipals] = useState(true);
const [search, setSearch] = useState("");
const [savingId, setSavingId] = useState<string | null>(null);
@@ -91,23 +91,28 @@ export function ShareCollectionDialog({
setLoadingPrincipals(true);
client.getPrincipals().then((list) => {
if (cancelled) return;
// Exclude the user themselves and any principal that already has a share
const existing = new Set(Object.keys(shareWith || {}));
const filtered = list.filter((p) => p.id !== ownAccountId && !existing.has(p.id));
setPrincipals(filtered);
setAllPrincipals(list);
setLoadingPrincipals(false);
}).catch(() => {
if (!cancelled) setLoadingPrincipals(false);
});
return () => { cancelled = true; };
}, [client, ownAccountId, shareWith]);
}, [client]);
// Map principal id -> Principal for displayed shares
// Map of every fetched principal by id, used for name/description lookups in
// the shared list. Must include principals that already have a share so the
// list shows their name rather than the raw id.
const allPrincipalsById = useMemo(() => {
const map = new Map<string, Principal>();
for (const p of principals) map.set(p.id, p);
for (const p of allPrincipals) map.set(p.id, p);
return map;
}, [principals]);
}, [allPrincipals]);
// Principals available to add: exclude self and anyone already shared with.
const principals = useMemo(() => {
const existing = new Set(Object.keys(shareWith || {}));
return allPrincipals.filter((p) => p.id !== ownAccountId && !existing.has(p.id));
}, [allPrincipals, ownAccountId, shareWith]);
// Close on Escape, focus trap, click outside
useEffect(() => {
@@ -155,8 +160,6 @@ export function ShareCollectionDialog({
setSavingId(principal.id);
try {
await onShare(principal.id, rights);
// Move principal out of the "to add" list
setPrincipals((prev) => prev.filter((p) => p.id !== principal.id));
setShowAdd(false);
setSearch("");
toast.success(t("share_added"));
+16 -5
View File
@@ -1,7 +1,7 @@
"use client";
import { createContext, useContext, useState, useCallback, useEffect, type ReactNode } from "react";
import { useRouter } from "@/i18n/navigation";
import { useRouter, usePathname } from "@/i18n/navigation";
import { useAuthStore } from "@/stores/auth-store";
import { useCalendarStore } from "@/stores/calendar-store";
import { useWebDAVStore } from "@/stores/webdav-store";
@@ -34,6 +34,7 @@ export function useTour() {
export function TourProvider({ children }: { children: ReactNode }) {
const router = useRouter();
const pathname = usePathname();
const { isDemoMode } = useAuthStore();
const { supportsCalendar } = useCalendarStore();
const { supportsWebDAV } = useWebDAVStore();
@@ -63,9 +64,16 @@ export function TourProvider({ children }: { children: ReactNode }) {
// If the resume step is beyond the current steps, start from 0
if (resumeStep >= steps.length) resumeStep = 0;
// Most steps live on the mailbox — navigate there (or to the step's specific page)
// so we don't start the tour on a page where the targets don't exist.
const targetPage = steps[resumeStep]?.page ?? "/";
if (pathname !== targetPage) {
router.push(targetPage);
}
setCurrentStep(resumeStep);
setIsActive(true);
}, [steps.length]);
}, [steps, pathname, router]);
const stopTour = useCallback(() => {
setIsActive(false);
@@ -90,14 +98,17 @@ export function TourProvider({ children }: { children: ReactNode }) {
}
const next = currentStep + 1;
const nextStepDef = steps[next];
const currentStepDef = steps[currentStep];
setCurrentStep(next);
try {
localStorage.setItem(TOUR_CURRENT_STEP_KEY, String(next));
} catch { /* */ }
// Navigate if the next step requires a different page
if (nextStepDef?.page) {
router.push(nextStepDef.page);
// Navigate when the next step is on a different page (treat "no page" as the mailbox)
const nextPage = nextStepDef?.page ?? "/";
const currentPage = currentStepDef?.page ?? "/";
if (nextPage !== currentPage) {
router.push(nextPage);
}
}, [currentStep, steps, completeTour, router]);
+90 -21
View File
@@ -1,6 +1,6 @@
"use client";
import { forwardRef, useState, useRef, useEffect } from "react";
import { forwardRef, useState, useRef, useEffect, useLayoutEffect } from "react";
import { createPortal } from "react-dom";
import { cn } from "@/lib/utils";
import { ChevronRight } from "lucide-react";
@@ -17,26 +17,72 @@ interface ContextMenuProps {
children: React.ReactNode;
}
const VIEWPORT_MARGIN = 10;
export const ContextMenu = forwardRef<HTMLDivElement, ContextMenuProps>(
({ isOpen, position, onClose: _onClose, children }, ref) => {
const [mounted, setMounted] = useState(false);
const [adjustedPosition, setAdjustedPosition] = useState<Position | null>(null);
const localRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
setMounted(true);
}, []);
// Measure the rendered menu and clamp it inside the viewport before the
// browser paints. We hide the element until this runs so the user never
// sees the menu jump from an unclamped position to a clamped one.
useLayoutEffect(() => {
if (!isOpen) {
setAdjustedPosition(null);
return;
}
const node = localRef.current;
if (!node) return;
const rect = node.getBoundingClientRect();
const vw = window.innerWidth;
const vh = window.innerHeight;
let x = position.x;
let y = position.y;
if (x + rect.width > vw - VIEWPORT_MARGIN) {
x = vw - rect.width - VIEWPORT_MARGIN;
}
if (y + rect.height > vh - VIEWPORT_MARGIN) {
y = vh - rect.height - VIEWPORT_MARGIN;
}
x = Math.max(VIEWPORT_MARGIN, x);
y = Math.max(VIEWPORT_MARGIN, y);
setAdjustedPosition({ x, y });
}, [isOpen, position.x, position.y]);
const setRefs = (node: HTMLDivElement | null) => {
localRef.current = node;
if (typeof ref === "function") {
ref(node);
} else if (ref) {
ref.current = node;
}
};
if (!mounted || !isOpen) return null;
const renderPosition = adjustedPosition ?? position;
const isPositioned = adjustedPosition !== null;
return createPortal(
<div
ref={ref}
ref={setRefs}
className={cn(
"fixed z-50 min-w-[200px] bg-background rounded-md shadow-lg border border-border",
"animate-in fade-in-0 zoom-in-95 duration-100"
"fixed z-50 min-w-[200px] bg-background rounded-md shadow-lg border border-border"
)}
style={{
left: position.x,
top: position.y,
left: renderPosition.x,
top: renderPosition.y,
visibility: isPositioned ? "visible" : "hidden",
}}
role="menu"
aria-orientation="vertical"
@@ -112,22 +158,41 @@ export function ContextMenuSubMenu({
children,
}: ContextMenuSubMenuProps) {
const [isOpen, setIsOpen] = useState(false);
const [subMenuPosition, setSubMenuPosition] = useState<"right" | "left">("right");
const [subMenuPos, setSubMenuPos] = useState<Position | null>(null);
const itemRef = useRef<HTMLDivElement>(null);
const subMenuRef = useRef<HTMLDivElement>(null);
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (isOpen && itemRef.current) {
const rect = itemRef.current.getBoundingClientRect();
const viewportWidth = window.innerWidth;
if (rect.right + 200 > viewportWidth - 10) {
setSubMenuPosition("left");
} else {
setSubMenuPosition("right");
}
useLayoutEffect(() => {
if (!isOpen) {
setSubMenuPos(null);
return;
}
const itemEl = itemRef.current;
const subEl = subMenuRef.current;
if (!itemEl || !subEl) return;
const itemRect = itemEl.getBoundingClientRect();
const subRect = subEl.getBoundingClientRect();
const vw = window.innerWidth;
const vh = window.innerHeight;
let left: number;
if (itemRect.right + subRect.width <= vw - VIEWPORT_MARGIN) {
left = itemRect.right;
} else if (itemRect.left - subRect.width >= VIEWPORT_MARGIN) {
left = itemRect.left - subRect.width;
} else {
left = Math.max(VIEWPORT_MARGIN, vw - subRect.width - VIEWPORT_MARGIN);
}
let top = itemRect.top;
if (top + subRect.height > vh - VIEWPORT_MARGIN) {
top = vh - subRect.height - VIEWPORT_MARGIN;
}
top = Math.max(VIEWPORT_MARGIN, top);
setSubMenuPos({ x: left, y: top });
}, [isOpen]);
useEffect(() => {
@@ -174,13 +239,17 @@ export function ContextMenuSubMenu({
<div
ref={subMenuRef}
className={cn(
"absolute top-0 min-w-[180px] bg-background rounded-md shadow-lg border border-border",
"animate-in fade-in-0 zoom-in-95 duration-100",
subMenuPosition === "right" ? "left-full" : "right-full"
"fixed z-50 min-w-[180px] bg-background rounded-md shadow-lg border border-border",
"animate-in fade-in-0 zoom-in-95 duration-100"
)}
style={{
left: subMenuPos?.x ?? 0,
top: subMenuPos?.y ?? 0,
visibility: subMenuPos ? "visible" : "hidden",
}}
role="menu"
>
<div className="py-1 max-h-[300px] overflow-y-auto">
<div className="py-1 max-h-[min(300px,calc(100vh-40px))] overflow-y-auto">
{children}
</div>
</div>
+3 -2
View File
@@ -11,6 +11,7 @@ import { useCalendarNotificationStore } from '@/stores/calendar-notification-sto
import { useToastStore } from '@/stores/toast-store';
import { getPendingAlerts, getPendingTaskAlerts, buildAlertKey } from '@/lib/calendar-alerts';
import { playNotificationSound } from '@/lib/notification-sound';
import { getPathPrefix } from '@/lib/browser-navigation';
import type { CalendarEvent } from '@/lib/jmap/types';
const CHECK_INTERVAL_MS = 60 * 1000;
@@ -68,7 +69,7 @@ export function useCalendarAlerts() {
message,
duration: 15000,
onClick: () => {
window.location.href = `/${locale}/calendar`;
window.location.href = `${getPathPrefix(locale)}/${locale}/calendar`;
},
});
}
@@ -97,7 +98,7 @@ export function useCalendarAlerts() {
message: taskMsg,
duration: 15000,
onClick: () => {
window.location.href = `/${locale}/calendar`;
window.location.href = `${getPathPrefix(locale)}/${locale}/calendar`;
},
});
}
+7 -6
View File
@@ -22,6 +22,7 @@ interface UseContextMenuReturn<T> {
const MENU_WIDTH = 200;
const MENU_HEIGHT = 320; // Approximate max height
const VIEWPORT_MARGIN = 10;
export function useContextMenu<T>(): UseContextMenuReturn<T> {
const [contextMenu, setContextMenu] = useState<ContextMenuState<T>>({
@@ -40,18 +41,18 @@ export function useContextMenu<T>(): UseContextMenuReturn<T> {
let y = clientY;
// Adjust for right edge
if (x + MENU_WIDTH > viewportWidth - 10) {
x = viewportWidth - MENU_WIDTH - 10;
if (x + MENU_WIDTH > viewportWidth - VIEWPORT_MARGIN) {
x = viewportWidth - MENU_WIDTH - VIEWPORT_MARGIN;
}
// Adjust for bottom edge
if (y + MENU_HEIGHT > viewportHeight - 10) {
y = viewportHeight - MENU_HEIGHT - 10;
if (y + MENU_HEIGHT > viewportHeight - VIEWPORT_MARGIN) {
y = viewportHeight - MENU_HEIGHT - VIEWPORT_MARGIN;
}
// Ensure minimum position
x = Math.max(10, x);
y = Math.max(10, y);
x = Math.max(VIEWPORT_MARGIN, x);
y = Math.max(VIEWPORT_MARGIN, y);
return { x, y };
}, []);
+7 -37
View File
@@ -2,49 +2,12 @@ import { readFileSync } from "fs";
import { configManager } from "./lib/admin/config-manager";
import { initAdminPassword } from "./lib/admin/password";
const VERSION_CHECK_URL =
"https://raw.githubusercontent.com/bulwarkmail/webmail/main/VERSION";
const SEMVER_RE = /^\d+\.\d+\.\d+$/;
function compareVersions(current: string, remote: string): number {
const a = current.split(".").map(Number);
const b = remote.split(".").map(Number);
for (let i = 0; i < 3; i++) {
if ((b[i] ?? 0) > (a[i] ?? 0)) return 1;
if ((b[i] ?? 0) < (a[i] ?? 0)) return -1;
}
return 0;
}
const pkg = JSON.parse(
readFileSync(`${process.cwd()}/package.json`, "utf-8")
);
const current: string = pkg.version ?? "0.0.0";
console.info(`Bulwark Webmail v${current}`);
if (process.env.NODE_ENV === "production") {
fetch(VERSION_CHECK_URL, {
cache: "no-store",
signal: AbortSignal.timeout(5000),
})
.then((res) => {
if (!res.ok) return;
return res.text();
})
.then((text) => {
if (!text) return;
const remote = text.trim();
if (!SEMVER_RE.test(remote)) return;
if (compareVersions(current, remote) > 0) {
console.info(
`Update available: v${remote} - https://github.com/bulwarkmail/webmail`
);
}
})
.catch(() => {});
}
// Initialize admin config and password bootstrap
configManager.load()
.then(() => initAdminPassword())
@@ -59,6 +22,13 @@ configManager.load()
markProcessStart();
await startScheduler();
})
.then(async () => {
// Hourly check against version.telemetry.bulwarkmail.org. Disable with
// BULWARK_UPDATE_CHECK=off or override the endpoint with
// BULWARK_UPDATE_CHECK_URL.
const { startScheduler } = await import("./lib/version-check");
await startScheduler();
})
.catch((err) => {
console.warn("Admin dashboard init skipped:", err instanceof Error ? err.message : err);
});
+15 -5
View File
@@ -8,17 +8,27 @@ export function replaceWindowLocation(url: string): void {
window.location.replace(url);
}
// Build-time constant injected by next.config.ts. When the app is built with
// NEXT_PUBLIC_BASE_PATH=/webmail, Next.js itself prefixes routes and assets;
// helpers below use the same value so client code stays consistent.
const STATIC_BASE_PATH = (process.env.NEXT_PUBLIC_BASE_PATH ?? '').replace(/\/+$/, '');
/**
* Returns the mount prefix from the current URL.
* When the app is served behind a reverse proxy at e.g. /bulwark,
* the browser sees /bulwark/en/login while Next.js sees /en/login.
* Returns the mount prefix the app is served at.
*
* If a locale is supplied (e.g. from route params) it is used directly;
* otherwise the first path segment that matches a known locale is used.
* Resolution order:
* 1. The build-time `NEXT_PUBLIC_BASE_PATH` constant (set in next.config.ts).
* 2. Runtime detection from `window.location.pathname` for legacy deploys
* where the reverse proxy mounts the app at a subpath without rebuilding.
*
* If a locale is supplied (e.g. from route params) it anchors the runtime
* detection; otherwise the first path segment that matches a known locale is
* used.
*
* Returns '' when there is no prefix.
*/
export function getPathPrefix(locale?: string): string {
if (STATIC_BASE_PATH) return STATIC_BASE_PATH;
if (typeof window === 'undefined') return '';
const segments = window.location.pathname.split('/').filter(Boolean);
+32 -5
View File
@@ -300,6 +300,16 @@ function stripMessageIdBrackets(id: string): string {
return id.trim().replace(/^<+/, '').replace(/>+$/, '').trim();
}
// Some servers (notably Stalwart) return Identity.name in RFC 5322 mailbox
// form: `Display Name <addr@example.com>`. Re-emitting that as the JMAP
// from.name field produces a doubled From header (`"Name <addr>" <addr>`)
// whose display-name is invalid per RFC 5322 §3.4 and gets rejected by the
// submission validator — the email then sits forever in Drafts.
function sanitizeIdentityDisplayName(name: string | undefined | null): string {
if (!name) return '';
return name.replace(/\s*<[^>]*>\s*$/, '').trim();
}
export class JMAPClient implements IJMAPClient {
private static readonly RATE_LIMIT_TOAST_THROTTLE_MS = 10_000;
@@ -1754,7 +1764,8 @@ export class JMAPClient implements IJMAPClient {
]);
if (response.methodResponses?.[0]?.[0] === "Identity/get") {
return (response.methodResponses[0][1].list || []) as Identity[];
const list = (response.methodResponses[0][1].list || []) as Identity[];
return list.map((id) => ({ ...id, name: sanitizeIdentityDisplayName(id.name) }));
}
return [];
@@ -1964,8 +1975,9 @@ export class JMAPClient implements IJMAPClient {
attachments?: { blobId: string; type: string; name: string; disposition: string; cid?: string }[];
}
const sanitizedFromName = sanitizeIdentityDisplayName(fromName);
const emailData: EmailDraft = {
from: [{ ...(fromName ? { name: fromName } : {}), email: fromEmail || this.username }],
from: [{ ...(sanitizedFromName ? { name: sanitizedFromName } : {}), email: fromEmail || this.username }],
to: to.map(email => ({ email })),
cc: cc?.map(email => ({ email })),
bcc: bcc?.map(email => ({ email })),
@@ -2087,9 +2099,10 @@ export class JMAPClient implements IJMAPClient {
const normalizedInReplyTo = inReplyTo?.map(stripMessageIdBrackets).filter(Boolean);
const normalizedReferences = references?.map(stripMessageIdBrackets).filter(Boolean);
const sanitizedFromName = sanitizeIdentityDisplayName(fromName);
// Always create a new email with the final body content
const emailCreate: Record<string, unknown> = {
from: [{ ...(fromName ? { name: fromName } : {}), email: fromEmail || this.username }],
from: [{ ...(sanitizedFromName ? { name: sanitizedFromName } : {}), email: fromEmail || this.username }],
replyTo: identityReplyTo?.length ? identityReplyTo : undefined,
to: to.map(email => ({ email })),
cc: cc?.map(email => ({ email })),
@@ -3079,11 +3092,19 @@ export class JMAPClient implements IJMAPClient {
}
private contactUsing(): string[] {
return ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:contacts"];
const using = ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:contacts"];
if (this.hasCapability("urn:ietf:params:jmap:principals")) {
using.push("urn:ietf:params:jmap:principals:owner");
}
return using;
}
private calendarUsing(): string[] {
return ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:calendars"];
const using = ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:calendars"];
if (this.hasCapability("urn:ietf:params:jmap:principals")) {
using.push("urn:ietf:params:jmap:principals:owner");
}
return using;
}
private getCalendarCapableAccountIds(): string[] {
@@ -3293,6 +3314,9 @@ export class JMAPClient implements IJMAPClient {
const err = result.notUpdated[calendarId];
throw new Error(err.description || "Failed to update calendar share");
}
if (!result?.updated || !(calendarId in result.updated)) {
throw new Error("Server did not confirm the share update");
}
}
/**
@@ -3318,6 +3342,9 @@ export class JMAPClient implements IJMAPClient {
const err = result.notUpdated[addressBookId];
throw new Error(err.description || "Failed to update address book share");
}
if (!result?.updated || !(addressBookId in result.updated)) {
throw new Error("Server did not confirm the share update");
}
}
private async fetchPaginatedContacts(
+54 -2
View File
@@ -23,7 +23,7 @@ import {
taskHooks, templateHooks, smimeHooks, vacationHooks,
uiHooks, themeHooks, toastHooks, dragDropHooks,
keyboardHooks, appLifecycleHooks, accountSecurityHooks,
sidebarAppHooks, avatarHooks, renderHooks,
sidebarAppHooks, avatarHooks, renderHooks, routerHooks,
} from './plugin-hooks';
import { createPluginI18n } from './plugin-i18n';
import { toast as appToast } from '@/stores/toast-store';
@@ -187,6 +187,22 @@ export interface PluginHooksAPI {
onQuotaChange: (handler: (...args: unknown[]) => unknown) => Disposable;
/** Intercept - receives MailtoContext, return false to prevent the system mail client */
onMailtoIntercept: (handler: (ctx: import('./plugin-types').MailtoContext) => boolean | void | Promise<boolean | void>) => Disposable;
/** Transform - receives the OutgoingEmail and returns a (possibly modified) copy */
onTransformOutgoingEmail: (handler: (email: import('./plugin-types').OutgoingEmail) => import('./plugin-types').OutgoingEmail | void | Promise<import('./plugin-types').OutgoingEmail | void>) => Disposable;
/** Intercept - receives ReplyContext, return false to cancel */
onBeforeReply: (handler: (ctx: import('./plugin-types').ReplyContext) => boolean | void | Promise<boolean | void>) => Disposable;
onBeforeReplyAll: (handler: (ctx: import('./plugin-types').ReplyContext) => boolean | void | Promise<boolean | void>) => Disposable;
onBeforeForward: (handler: (ctx: import('./plugin-types').ReplyContext) => boolean | void | Promise<boolean | void>) => Disposable;
/** Intercept - receives AttachmentInfo, return false to refuse the upload */
onBeforeAttachmentUpload: (handler: (info: import('./plugin-types').AttachmentInfo) => boolean | void | Promise<boolean | void>) => Disposable;
onAfterAttachmentUpload: (handler: (info: import('./plugin-types').AttachmentInfo) => void) => Disposable;
onAttachmentDownload: (handler: (info: import('./plugin-types').AttachmentInfo) => void) => Disposable;
/** Transform - receives AttachmentPreview, may return a modified preview */
onAttachmentPreview: (handler: (preview: import('./plugin-types').AttachmentPreview, info: import('./plugin-types').AttachmentInfo) => import('./plugin-types').AttachmentPreview | void | Promise<import('./plugin-types').AttachmentPreview | void>) => Disposable;
/** Transform - receives ExternalSearchResult[] and returns an extended array */
onProvideSearchResults: (handler: (results: import('./plugin-types').ExternalSearchResult[], ctx: { query: string; filters: import('./plugin-types').SearchFilters }) => import('./plugin-types').ExternalSearchResult[] | void | Promise<import('./plugin-types').ExternalSearchResult[] | void>) => Disposable;
/** Observer - debounced snapshot of the composer draft */
onDraftChange: (handler: (draft: import('./plugin-types').DraftView) => void) => Disposable;
// Calendar
onCalendarEventOpen: (handler: (...args: unknown[]) => unknown) => Disposable;
onBeforeEventCreate: (handler: (...args: unknown[]) => unknown) => Disposable;
@@ -204,6 +220,8 @@ export interface PluginHooksAPI {
onICalSubscriptionChange: (handler: (...args: unknown[]) => unknown) => Disposable;
onCalendarAlert: (handler: (...args: unknown[]) => unknown) => Disposable;
onCalendarAlertAcknowledge: (handler: (...args: unknown[]) => unknown) => Disposable;
/** Transform - receives ConflictWarning[] and returns an extended array */
onCheckEventConflicts: (handler: (warnings: import('./plugin-types').ConflictWarning[], ctx: { event: import('./plugin-types').CalendarEventFormView }) => import('./plugin-types').ConflictWarning[] | void | Promise<import('./plugin-types').ConflictWarning[] | void>) => Disposable;
// Calendar Form
onCalendarEventFormOpen: (handler: (...args: unknown[]) => unknown) => Disposable;
onCalendarEventFormSave: (handler: (...args: unknown[]) => unknown) => Disposable;
@@ -220,6 +238,8 @@ export interface PluginHooksAPI {
onContactGroupChange: (handler: (...args: unknown[]) => unknown) => Disposable;
onContactGroupMemberChange: (handler: (...args: unknown[]) => unknown) => Disposable;
onContactMove: (handler: (...args: unknown[]) => unknown) => Disposable;
/** Transform - receives RecipientSuggestion[] and returns an extended array */
onProvideRecipientSuggestions: (handler: (suggestions: import('./plugin-types').RecipientSuggestion[], ctx: { query: string }) => import('./plugin-types').RecipientSuggestion[] | void | Promise<import('./plugin-types').RecipientSuggestion[] | void>) => Disposable;
// Files
onFileNavigate: (handler: (...args: unknown[]) => unknown) => Disposable;
onBeforeFileUpload: (handler: (...args: unknown[]) => unknown) => Disposable;
@@ -297,6 +317,10 @@ export interface PluginHooksAPI {
onColumnResize: (handler: (...args: unknown[]) => unknown) => Disposable;
onMobileBack: (handler: () => void) => Disposable;
onMobileViewSwitch: (handler: (...args: unknown[]) => unknown) => Disposable;
/** Intercept - receives ExternalLinkContext, return false to cancel navigation */
onBeforeExternalLink: (handler: (ctx: import('./plugin-types').ExternalLinkContext) => boolean | void | Promise<boolean | void>) => Disposable;
/** Observer - debounced text-selection change */
onTextSelectionChange: (handler: (ctx: import('./plugin-types').SelectionContext) => void) => Disposable;
// Theme
onThemeChange: (handler: (...args: unknown[]) => unknown) => Disposable;
onCustomThemeChange: (handler: (...args: unknown[]) => unknown) => Disposable;
@@ -305,6 +329,8 @@ export interface PluginHooksAPI {
onToastShow: (handler: (...args: unknown[]) => unknown) => Disposable;
onToastDismiss: (handler: (...args: unknown[]) => unknown) => Disposable;
onBrowserNotification: (handler: (...args: unknown[]) => unknown) => Disposable;
/** Observer fired when an OS-level notification is clicked */
onNotificationClick: (handler: (ctx: { tag: string; data?: unknown }) => void) => Disposable;
// Drag & Drop
onDragStart: (handler: (...args: unknown[]) => unknown) => Disposable;
onDragEnd: (handler: (...args: unknown[]) => unknown) => Disposable;
@@ -320,6 +346,12 @@ export interface PluginHooksAPI {
onBeforeUnload: (handler: () => void) => Disposable;
onAppError: (handler: (...args: unknown[]) => unknown) => Disposable;
onInterval: (handler: () => void, intervalMs: number) => Disposable;
/** Observer - browser window focus / blur */
onWindowFocus: (handler: () => void) => Disposable;
onWindowBlur: (handler: () => void) => Disposable;
/** Observer - network connectivity transitions */
onOnline: (handler: () => void) => Disposable;
onOffline: (handler: () => void) => Disposable;
// Account Security
onPasswordChange: (handler: () => void) => Disposable;
onTotpChange: (handler: (...args: unknown[]) => unknown) => Disposable;
@@ -335,6 +367,11 @@ export interface PluginHooksAPI {
// Render - transform hook for email list row badges
// Handler: (badges: EmailListBadge[], ctx: { emailId: string; email: EmailReadView }) => EmailListBadge[]
onEmailListItemRender: (handler: (...args: unknown[]) => unknown) => Disposable;
// Router
/** Observer - fired on every in-app navigation. RouteContext.from holds the previous path. */
onNavigate: (handler: (ctx: import('./plugin-types').RouteContext) => void) => Disposable;
onRouteEnter: (handler: (ctx: import('./plugin-types').RouteContext) => void) => Disposable;
onRouteLeave: (handler: (ctx: import('./plugin-types').RouteContext) => void) => Disposable;
}
// --- Permission mapping for hooks ----------------------------
@@ -350,7 +387,13 @@ const HOOK_PERMISSIONS: Record<string, Permission> = {
onEmailSelectionChange: 'email:read', onNewEmailReceived: 'email:read',
onPushConnectionChange: 'email:read', onQuotaChange: 'email:read',
onMailtoIntercept: 'email:read', onEmailListItemRender: 'email:read',
onBeforeReply: 'email:read', onBeforeReplyAll: 'email:read',
onBeforeForward: 'email:read', onAttachmentDownload: 'email:read',
onAttachmentPreview: 'email:read', onProvideSearchResults: 'email:read',
onDraftChange: 'email:read',
onBeforeAttachmentUpload: 'email:write', onAfterAttachmentUpload: 'email:write',
onBeforeEmailSend: 'email:send', onAfterEmailSend: 'email:send',
onTransformOutgoingEmail: 'email:send',
onBeforeEmailDelete: 'email:write', onAfterEmailDelete: 'email:write',
onBeforeEmailMove: 'email:write', onAfterEmailMove: 'email:write',
onEmailArchive: 'email:write', onEmailUnarchive: 'email:write',
@@ -362,6 +405,7 @@ const HOOK_PERMISSIONS: Record<string, Permission> = {
onCalendarEventOpen: 'calendar:read', onCalendarDateChange: 'calendar:read',
onCalendarViewChange: 'calendar:read', onCalendarVisibilityToggle: 'calendar:read',
onCalendarAlert: 'calendar:read', onCalendarAlertAcknowledge: 'calendar:read',
onCheckEventConflicts: 'calendar:read',
onCalendarEventFormOpen: 'calendar:read', onCalendarEventFormSave: 'calendar:write',
onBeforeEventCreate: 'calendar:write', onAfterEventCreate: 'calendar:write',
onBeforeEventUpdate: 'calendar:write', onAfterEventUpdate: 'calendar:write',
@@ -370,6 +414,7 @@ const HOOK_PERMISSIONS: Record<string, Permission> = {
onCalendarChange: 'calendar:write', onICalSubscriptionChange: 'calendar:write',
// Contacts
onContactOpen: 'contacts:read', onContactSelectionChange: 'contacts:read',
onProvideRecipientSuggestions: 'contacts:read',
onBeforeContactCreate: 'contacts:write', onAfterContactCreate: 'contacts:write',
onBeforeContactUpdate: 'contacts:write', onAfterContactUpdate: 'contacts:write',
onBeforeContactDelete: 'contacts:write', onAfterContactDelete: 'contacts:write',
@@ -419,12 +464,13 @@ const HOOK_PERMISSIONS: Record<string, Permission> = {
onSidebarCollapse: 'ui:observe', onDeviceTypeChange: 'ui:observe',
onColumnResize: 'ui:observe', onMobileBack: 'ui:observe',
onMobileViewSwitch: 'ui:observe',
onBeforeExternalLink: 'ui:observe', onTextSelectionChange: 'ui:observe',
// Theme
onThemeChange: 'ui:observe', onCustomThemeChange: 'ui:observe',
onLocaleChange: 'ui:observe',
// Toast
onToastShow: 'ui:observe', onToastDismiss: 'ui:observe',
onBrowserNotification: 'ui:observe',
onBrowserNotification: 'ui:observe', onNotificationClick: 'ui:observe',
// Drag & Drop
onDragStart: 'ui:observe', onDragEnd: 'ui:observe',
onEmailDrop: 'ui:observe', onTagDrop: 'ui:observe',
@@ -435,6 +481,8 @@ const HOOK_PERMISSIONS: Record<string, Permission> = {
onAppReady: 'app:lifecycle', onVisibilityChange: 'app:lifecycle',
onBeforeUnload: 'app:lifecycle', onAppError: 'app:lifecycle',
onInterval: 'app:lifecycle',
onWindowFocus: 'app:lifecycle', onWindowBlur: 'app:lifecycle',
onOnline: 'app:lifecycle', onOffline: 'app:lifecycle',
// Account Security
onPasswordChange: 'security:read', onTotpChange: 'security:read',
onAppPasswordChange: 'security:read', onEncryptionChange: 'security:read',
@@ -444,6 +492,8 @@ const HOOK_PERMISSIONS: Record<string, Permission> = {
onSidebarAppChange: 'ui:observe',
// Avatar
onAvatarResolve: 'email:read',
// Router
onNavigate: 'ui:observe', onRouteEnter: 'ui:observe', onRouteLeave: 'ui:observe',
};
// Map hook names → actual HookBus instances
@@ -494,6 +544,8 @@ const HOOK_BUSES: Record<string, { register: (pluginId: string, handler: (...arg
...Object.fromEntries(Object.entries(avatarHooks)),
// Render
...Object.fromEntries(Object.entries(renderHooks)),
// Router
...Object.fromEntries(Object.entries(routerHooks)),
};
// --- Slot registration bridge --------------------------------
+70 -1
View File
@@ -207,6 +207,38 @@ export const emailHooks = {
// Intercept hook - fired when a mailto: link is clicked.
// Return false to prevent the browser from opening the system mail client.
onMailtoIntercept: new HookBus(),
// Transform hook - fires after onBeforeEmailSend has not cancelled,
// immediately before the message is handed to the JMAP submission. Handlers
// receive an OutgoingEmail and return a modified copy (or undefined to pass
// through). Use to inject signatures, scrub tracking pixels from forwarded
// bodies, encrypt content, or rewrite links.
onTransformOutgoingEmail: new HookBus(),
// Intercept hooks fired when the user clicks Reply / Reply-All / Forward.
// Handler receives a ReplyContext; return false to cancel.
onBeforeReply: new HookBus(),
onBeforeReplyAll: new HookBus(),
onBeforeForward: new HookBus(),
// Intercept hook fired before a file is added to the composer as an
// attachment. Handler receives AttachmentInfo (size/type/name only - the
// raw file is not exposed). Return false to refuse the upload.
onBeforeAttachmentUpload: new HookBus(),
// Observer fired after an attachment has been uploaded and its blobId is
// available. Handler receives AttachmentInfo with `blobId` populated.
onAfterAttachmentUpload: new HookBus(),
// Observer fired when the user downloads an attachment from a message.
onAttachmentDownload: new HookBus(),
// Transform hook - lets plugins replace the preview URL or supply a custom
// renderer for an attachment. Initial value: AttachmentPreview, second
// argument: AttachmentInfo.
onAttachmentPreview: new HookBus(),
// Transform hook - lets plugins contribute additional results to the global
// search panel. Initial value: ExternalSearchResult[]. Second argument:
// { query: string, filters: SearchFilters }.
onProvideSearchResults: new HookBus(),
// Observer fired (debounced) when the composer draft body, subject, or
// recipients change. Handler receives a DraftView snapshot. Use for AI
// assistants, grammar checkers, etc.
onDraftChange: new HookBus(),
};
// §7.2 Calendar Hooks
@@ -227,6 +259,10 @@ export const calendarHooks = {
onICalSubscriptionChange: new HookBus(),
onCalendarAlert: new HookBus(),
onCalendarAlertAcknowledge: new HookBus(),
// Transform hook - fires when the event form is open and start/end change.
// Initial value: ConflictWarning[], second argument: { event: CalendarEventFormView }.
// Plugins return an extended array; the form renders each warning inline.
onCheckEventConflicts: new HookBus(),
};
// §7.2b Calendar Form Hooks (UI integration)
@@ -249,6 +285,10 @@ export const contactHooks = {
onContactGroupChange: new HookBus(),
onContactGroupMemberChange: new HookBus(),
onContactMove: new HookBus(),
// Transform hook - lets plugins contribute extra recipient suggestions to
// the composer's autocomplete. Initial value: RecipientSuggestion[],
// second argument: { query: string }.
onProvideRecipientSuggestions: new HookBus(),
};
// §7.4 File Hooks
@@ -358,6 +398,14 @@ export const uiHooks = {
onColumnResize: new HookBus(),
onMobileBack: new HookBus(),
onMobileViewSwitch: new HookBus(),
// Intercept hook - fires when the user clicks an external link inside the
// app (typically inside an email body iframe). Handler receives
// ExternalLinkContext; return false to cancel the navigation. Mutate
// `href` in place to rewrite (e.g. strip UTM params, route via a proxy).
onBeforeExternalLink: new HookBus(),
// Observer (debounced) fired when the user changes the active text
// selection inside an app surface. Receives SelectionContext.
onTextSelectionChange: new HookBus(),
};
// §7.14 Theme Hooks
@@ -384,6 +432,10 @@ export const toastHooks = {
onToastShow: new HookBus(),
onToastDismiss: new HookBus(),
onBrowserNotification: new HookBus(),
// Observer fired when the user clicks an OS-level browser notification
// dispatched by the host. Handler receives { tag: string, data?: unknown }
// matching the original notification options.
onNotificationClick: new HookBus(),
};
// §7.16 Drag & Drop Hooks
@@ -408,6 +460,14 @@ export const appLifecycleHooks = {
onBeforeUnload: new HookBus(),
onAppError: new HookBus(),
onInterval: new HookBus(),
// Observer fired when the browser window receives focus / blur. Useful for
// refresh-on-focus behaviour (re-poll, recheck staleness, pause timers).
onWindowFocus: new HookBus(),
onWindowBlur: new HookBus(),
// Observer fired when network connectivity transitions. Mirrors the
// navigator online / offline events.
onOnline: new HookBus(),
onOffline: new HookBus(),
};
// §7.19 Account Security Hooks
@@ -433,6 +493,15 @@ export const avatarHooks = {
onAvatarResolve: new HookBus(),
};
// §7.23 Router Hooks
// Observers fired by the app router. Handlers receive a RouteContext; on
// onNavigate the previous path is exposed via `from`.
export const routerHooks = {
onNavigate: new HookBus(),
onRouteEnter: new HookBus(),
onRouteLeave: new HookBus(),
};
// §7.22 Render Hooks
export const renderHooks = {
// Transform hook - runs for each visible email list row.
@@ -451,7 +520,7 @@ const allHookGroups = [
taskHooks, templateHooks, smimeHooks, vacationHooks,
uiHooks, themeHooks, toastHooks, dragDropHooks,
keyboardHooks, appLifecycleHooks, accountSecurityHooks, sidebarAppHooks,
avatarHooks, renderHooks,
avatarHooks, renderHooks, routerHooks,
];
export function removeAllPluginHooks(pluginId: string): void {
+131
View File
@@ -522,6 +522,137 @@ export interface MailtoContext {
body?: string;
}
/**
* Passed to onTransformOutgoingEmail handlers as a transform value.
* Handlers receive the email about to be sent and return a (possibly mutated)
* copy. Use to inject signatures, rewrite links, strip tracking pixels from
* forwards, encrypt the body, etc. Return undefined to pass through unchanged.
*/
export interface OutgoingEmail {
to: string[];
cc: string[];
bcc: string[];
subject: string;
htmlBody: string;
textBody: string;
identityId: string;
attachments: { name: string; type: string; size: number }[];
/** Original message id when this is a reply or forward */
inReplyTo?: string;
/** Free-form custom headers added by the composer or earlier handlers */
headers?: Record<string, string>;
}
/**
* Passed to onBeforeReply / onBeforeReplyAll / onBeforeForward intercept hooks.
* Return false to cancel the operation before the composer opens.
*/
export interface ReplyContext {
originalEmailId: string;
originalEmail: EmailReadView;
mode: 'reply' | 'reply-all' | 'forward';
}
/**
* Describes an attachment crossing an attachment hook (upload, download, preview).
*/
export interface AttachmentInfo {
name: string;
type: string;
size: number;
/** JMAP blob id, when known (download / preview / after-upload) */
blobId?: string;
/** The email this attachment belongs to (download / preview) */
emailId?: string;
}
/**
* Initial value passed to the onAttachmentPreview transform hook. A handler
* may return a different `previewUrl` (e.g. a proxied/sanitised URL) or a
* React component descriptor identified by `customRenderer`. Return undefined
* to pass through.
*/
export interface AttachmentPreview {
previewUrl?: string;
/** Optional plugin-supplied renderer key. The host resolves the renderer. */
customRenderer?: string;
}
/**
* Passed to onBeforeExternalLink intercept handlers when the user clicks a
* link that would navigate away from the app (typically inside an email body).
* Return false to cancel the navigation. Mutate `href` to rewrite it.
*/
export interface ExternalLinkContext {
href: string;
/** Anchor target ('_blank', '_self', etc.) when set */
target?: string;
/** Email currently in view, when the click came from an email body */
emailId?: string;
}
/**
* Passed to onTextSelectionChange observer when the user selects text inside
* the app. Source identifies which surface produced the selection so plugins
* can scope themselves (e.g. translate-on-select only inside emails).
*/
export interface SelectionContext {
text: string;
source: 'email-body' | 'composer' | 'task-detail' | 'event-detail' | 'other';
emailId?: string;
}
/**
* Returned by onCheckEventConflicts transform handlers. The form UI renders
* each warning as an inline notice next to the event time fields.
*/
export interface ConflictWarning {
/** Stable unique key per warning, used as React key */
key: string;
/** Short message — e.g. "Conflicts with: Team Standup" */
message: string;
severity?: 'info' | 'warning' | 'error';
}
/**
* Returned by onProvideSearchResults transform handlers. Plugins extend the
* initial array with their own results (CRM hits, Slack messages, etc.).
* The host renders these in a grouped section below native email results.
*/
export interface ExternalSearchResult {
/** Stable unique key */
key: string;
title: string;
snippet: string;
/** Plugin-handled action when the result row is clicked */
onClick: () => void;
/** Optional source label, e.g. "Slack", "Notion" */
source?: string;
}
/**
* Returned by onProvideRecipientSuggestions transform handlers. Lets plugins
* contribute non-contact suggestions (Slack handles, GitHub usernames, etc.)
* to the recipient autocomplete in the composer.
*/
export interface RecipientSuggestion {
name: string;
email: string;
/** Optional source label rendered as a small tag */
source?: string;
avatarUrl?: string;
}
/**
* Passed to router hooks (onNavigate, onRouteEnter, onRouteLeave).
* Paths are app-internal, e.g. "/mail/inbox", "/calendar".
*/
export interface RouteContext {
path: string;
/** Previous path (only on onNavigate) */
from?: string;
}
// ─── Plugin i18n API ─────────────────────────────────────────
/**
+82
View File
@@ -0,0 +1,82 @@
import { logger } from '@/lib/logger';
import type { UpdateStatus, UpdateSeverity } from './types';
const SEVERITIES: ReadonlySet<UpdateSeverity> = new Set([
'normal', 'security', 'deprecated', 'none', 'unknown',
]);
function isString(v: unknown): v is string {
return typeof v === 'string';
}
function isNullableString(v: unknown): v is string | null {
return v === null || typeof v === 'string';
}
// Validate the response from the version server before we trust it. Returns
// null on any malformed field so a hostile or buggy upstream can't poison the
// UI with arbitrary strings (the URL, in particular, is rendered in <a href>).
export function parseStatus(raw: unknown): UpdateStatus | null {
if (!raw || typeof raw !== 'object') return null;
const r = raw as Record<string, unknown>;
if (r.schema !== 1) return null;
if (!isString(r.current)) return null;
if (!isNullableString(r.latest)) return null;
if (typeof r.updateAvailable !== 'boolean') return null;
if (!isString(r.severity) || !SEVERITIES.has(r.severity as UpdateSeverity)) return null;
if (!isNullableString(r.url)) return null;
if (!isNullableString(r.advisory)) return null;
if (!isString(r.checkedAt)) return null;
// Only http(s) URLs are renderable; reject anything else so we don't end
// up with a javascript: link in the banner.
if (r.url && !/^https?:\/\//i.test(r.url)) return null;
return {
schema: 1,
current: r.current,
latest: r.latest,
updateAvailable: r.updateAvailable,
severity: r.severity as UpdateSeverity,
url: r.url,
advisory: r.advisory,
checkedAt: r.checkedAt,
};
}
export async function fetchStatus(
endpoint: string,
currentVersion: string,
): Promise<{ ok: true; status: UpdateStatus } | { ok: false; error: string }> {
if (!endpoint) return { ok: false, error: 'endpoint blank' };
if (!currentVersion) return { ok: false, error: 'current version blank' };
// Build the URL safely — never inject the version as a raw path component.
let url: URL;
try {
url = new URL(endpoint);
} catch {
return { ok: false, error: 'endpoint not a URL' };
}
url.searchParams.set('v', currentVersion);
try {
const res = await fetch(url, {
method: 'GET',
headers: { accept: 'application/json' },
cache: 'no-store',
signal: AbortSignal.timeout(5000),
});
if (!res.ok) {
return { ok: false, error: `HTTP ${res.status}` };
}
const body: unknown = await res.json();
const parsed = parseStatus(body);
if (!parsed) return { ok: false, error: 'malformed response' };
return { ok: true, status: parsed };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
logger.warn('version-check: fetch failed', { error: msg });
return { ok: false, error: msg };
}
}
+7
View File
@@ -0,0 +1,7 @@
export { startScheduler, stopScheduler, checkOnce } from './sender';
export { loadState, saveState, effectiveEndpoint, disabledByEnv } from './state';
export { fetchStatus, parseStatus } from './fetcher';
export type {
UpdateStatus, UpdateSeverity, VersionCheckStateFile,
} from './types';
export { DEFAULT_VERSION_ENDPOINT } from './types';
+100
View File
@@ -0,0 +1,100 @@
import { logger } from '@/lib/logger';
import { disabledByEnv, effectiveEndpoint, loadState, saveState } from './state';
import { fetchStatus } from './fetcher';
import type { UpdateStatus } from './types';
const HOUR_MS = 60 * 60 * 1000;
const JITTER_MS = 5 * 60 * 1000; // ± 5 min, keeps containers from syncing
const FIRST_DELAY_MS = 30 * 1000; // first check ~30s after boot
const FAILURE_BACKOFF_MS = 15 * 60 * 1000; // after a failed fetch, retry in 15 min
let currentTimer: NodeJS.Timeout | null = null;
function jitteredDelay(base: number): number {
const j = (Math.random() * 2 - 1) * JITTER_MS;
return Math.max(60_000, base + j);
}
function getCurrentVersion(): string {
return (process.env.NEXT_PUBLIC_APP_VERSION || '').trim();
}
export async function checkOnce(opts?: { reason?: string }): Promise<{
ok: boolean;
status?: UpdateStatus;
error?: string;
}> {
if (disabledByEnv()) return { ok: false, error: 'disabled by env' };
const state = await loadState();
const endpoint = effectiveEndpoint(state);
if (!endpoint) return { ok: false, error: 'endpoint blank' };
const current = getCurrentVersion();
if (!current) return { ok: false, error: 'current version unset' };
const now = new Date().toISOString();
const result = await fetchStatus(endpoint, current);
const next = await loadState();
next.lastCheckedAt = now;
if (result.ok) {
next.lastSuccessAt = now;
next.status = result.status;
}
await saveState(next);
logger.info('version-check: ran', {
ok: result.ok,
severity: result.ok ? result.status.severity : null,
reason: opts?.reason ?? 'scheduled',
});
if (result.ok) return { ok: true, status: result.status };
return { ok: false, error: result.error };
}
async function scheduleNext(delayMs: number): Promise<void> {
if (currentTimer) clearTimeout(currentTimer);
const at = new Date(Date.now() + delayMs).toISOString();
const state = await loadState();
state.nextScheduledAt = at;
await saveState(state);
currentTimer = setTimeout(() => { void tick(); }, delayMs);
// Don't keep the process alive just for this.
currentTimer.unref?.();
}
async function tick(): Promise<void> {
const result = await checkOnce({ reason: 'scheduled' });
const delay = result.ok ? jitteredDelay(HOUR_MS) : FAILURE_BACKOFF_MS;
await scheduleNext(delay);
}
// Idempotent — safe to call from instrumentation hot-reload in dev.
export async function startScheduler(): Promise<void> {
if (disabledByEnv()) {
logger.info('version-check: scheduler not started (disabled by env)');
return;
}
const state = await loadState();
if (!effectiveEndpoint(state)) {
logger.info('version-check: scheduler not started (no endpoint)');
return;
}
// If a previous schedule was still in the future, honor it (don't blast on
// every restart). Cap at one hour so a wildly-in-the-future timestamp can't
// permanently silence the check.
let delay = FIRST_DELAY_MS;
if (state.nextScheduledAt) {
const remaining = new Date(state.nextScheduledAt).getTime() - Date.now();
if (remaining > 0) delay = Math.min(remaining, HOUR_MS + JITTER_MS);
}
await scheduleNext(delay);
logger.info('version-check: scheduler started', { nextInMs: delay });
}
export async function stopScheduler(): Promise<void> {
if (currentTimer) clearTimeout(currentTimer);
currentTimer = null;
}
+62
View File
@@ -0,0 +1,62 @@
import { readFile, writeFile, mkdir, rename } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import path from 'node:path';
import { logger } from '@/lib/logger';
import type { VersionCheckStateFile } from './types';
import { DEFAULT_VERSION_ENDPOINT } from './types';
function getDir(): string {
return process.env.VERSION_CHECK_DATA_DIR ||
path.join(process.cwd(), 'data', 'version-check');
}
function statePath(): string { return path.join(getDir(), 'state.json'); }
const DEFAULTS: VersionCheckStateFile = {
endpoint: DEFAULT_VERSION_ENDPOINT,
lastCheckedAt: null,
lastSuccessAt: null,
nextScheduledAt: null,
status: null,
};
export async function ensureDir(): Promise<void> {
if (!existsSync(getDir())) await mkdir(getDir(), { recursive: true });
}
export async function loadState(): Promise<VersionCheckStateFile> {
await ensureDir();
try {
const raw = await readFile(statePath(), 'utf8');
const parsed = JSON.parse(raw) as Partial<VersionCheckStateFile>;
return { ...DEFAULTS, ...parsed };
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
logger.warn('version-check: state read failed', {
error: err instanceof Error ? err.message : String(err),
});
}
return { ...DEFAULTS };
}
}
export async function saveState(state: VersionCheckStateFile): Promise<void> {
await ensureDir();
const tmp = statePath() + '.tmp';
await writeFile(tmp, JSON.stringify(state, null, 2), 'utf8');
await rename(tmp, statePath());
}
export function disabledByEnv(): boolean {
const v = (process.env.BULWARK_UPDATE_CHECK ?? '').toLowerCase();
if (v === 'off' || v === 'false' || v === '0' || v === 'no') return true;
return false;
}
export function effectiveEndpoint(state: VersionCheckStateFile): string {
// Env var wins over state file so an operator can override at runtime
// without editing on-disk state. An explicit empty value disables the check.
const envUrl = process.env.BULWARK_UPDATE_CHECK_URL;
if (envUrl !== undefined) return envUrl.trim();
return state.endpoint || DEFAULT_VERSION_ENDPOINT;
}
+25
View File
@@ -0,0 +1,25 @@
// Update-status payload returned by the version server. Mirrors
// repos/dashboard/version-server/src/registry.ts LookupResult.
export type UpdateSeverity = 'normal' | 'security' | 'deprecated' | 'none' | 'unknown';
export interface UpdateStatus {
schema: 1;
current: string;
latest: string | null;
updateAvailable: boolean;
severity: UpdateSeverity;
url: string | null;
advisory: string | null;
checkedAt: string;
}
export interface VersionCheckStateFile {
endpoint: string;
lastCheckedAt: string | null;
lastSuccessAt: string | null;
nextScheduledAt: string | null;
status: UpdateStatus | null;
}
export const DEFAULT_VERSION_ENDPOINT = 'https://version.telemetry.bulwarkmail.org/';
+8 -4
View File
@@ -9,6 +9,10 @@ import type { IJMAPClient } from '@/lib/jmap/client-interface';
const DEVICE_CLIENT_ID_KEY = 'bulwark.push.deviceClientId.v1';
const SUBSCRIPTION_ID_KEY = 'bulwark.push.subscriptionId.v1';
const BASE_PATH = (process.env.NEXT_PUBLIC_BASE_PATH ?? '').replace(/\/+$/, '');
const SW_SCOPE = `${BASE_PATH}/`;
const SW_URL = `${BASE_PATH}/sw.js`;
// Hosted relay so self-hosters don't need their own VAPID + Firebase setup.
// Override at build time via NEXT_PUBLIC_PUSH_RELAY_URL or at runtime by
// calling enableWebPush({ relayBaseUrl }) from the settings UI.
@@ -139,9 +143,9 @@ async function ensureServiceWorker(): Promise<ServiceWorkerRegistration> {
// The webmail's PWA already registers /sw.js for installability. If it
// hasn't been picked up yet (e.g. first load), kick it ourselves so the
// push handler is in place.
let registration = await navigator.serviceWorker.getRegistration('/');
let registration = await navigator.serviceWorker.getRegistration(SW_SCOPE);
if (!registration) {
registration = await navigator.serviceWorker.register('/sw.js');
registration = await navigator.serviceWorker.register(SW_URL, { scope: SW_SCOPE });
}
await navigator.serviceWorker.ready;
return registration;
@@ -336,7 +340,7 @@ export async function disableWebPush(params: DisableWebPushParams): Promise<void
}
if (typeof navigator !== 'undefined' && 'serviceWorker' in navigator) {
const registration = await navigator.serviceWorker.getRegistration('/');
const registration = await navigator.serviceWorker.getRegistration(SW_SCOPE);
const sub = await registration?.pushManager.getSubscription();
if (sub) await sub.unsubscribe().catch(() => undefined);
}
@@ -345,7 +349,7 @@ export async function disableWebPush(params: DisableWebPushParams): Promise<void
export async function isWebPushEnabled(): Promise<boolean> {
if (!isWebPushSupported()) return false;
if (Notification.permission !== 'granted') return false;
const registration = await navigator.serviceWorker.getRegistration('/');
const registration = await navigator.serviceWorker.getRegistration(SW_SCOPE);
if (!registration) return false;
const sub = await registration.pushManager.getSubscription();
return sub !== null && localStorage.getItem(SUBSCRIPTION_ID_KEY) !== null;
+50 -2
View File
@@ -184,6 +184,7 @@
"batch_actions": {
"select": "Vybrat zprávy",
"select_all": "Vybrat vše",
"selected_messages": "{count, plural, one {Vybraný 1 e-mail} other {Vybrané # e-maily}}",
"mark_read": "Označit jako přečtené",
"mark_unread": "Označit jako nepřečtené",
"delete": "Odstranit",
@@ -223,6 +224,11 @@
"unstar": "Odebrat hvězdičku",
"mark_unread": "Označit jako nepřečtené",
"mark_read": "Označit jako přečtené",
"unread": "Nepřečtené",
"read": "Přečtené",
"spam_short": "Spam",
"not_spam_short": "Není spam",
"move": "Přesunout",
"print": "Tisk",
"view_source": "Zobrazit zdrojový kód",
"export_email": "Exportovat jako .eml",
@@ -306,7 +312,20 @@
"fail": "DMARC selhalo",
"none": "Bez DMARC"
},
"spam_score": "Skóre spamu"
"spam_score": "Skóre spamu",
"tooltip_spf": "Sender Policy Framework: ověřuje, zda je odesílající server oprávněn odesílat e-maily jménem domény",
"tooltip_dkim": "DomainKeys Identified Mail: kryptografickým podpisem potvrzuje, že e-mail nebyl při přenosu změněn",
"tooltip_dmarc": "Domain-based Message Authentication, Reporting & Conformance: zajišťuje shodu SPF a DKIM s doménou odesílatele a stanoví zásadu pro selhání",
"policy": "Zásada",
"result": {
"pass": "Úspěšné",
"fail": "Selhalo",
"softfail": "Mírné selhání",
"neutral": "Neutrální",
"permerror": "Trvalá chyba",
"temperror": "Dočasná chyba",
"none": "Žádné"
}
},
"headers": {
"routing": "Směrování",
@@ -431,7 +450,32 @@
"collapse": "Skrýt podrobnosti"
},
"send": "Odeslat",
"more": "více"
"more": "více",
"details": {
"recipients_routing": "Příjemci a směrování",
"authentication_security": "Ověřování a zabezpečení",
"identifiers_threading": "Identifikátory a vlákna",
"mailing_list": "Mailing list",
"message_properties": "Vlastnosti zprávy",
"sent": "Odesláno",
"received": "Přijato",
"delivery_time": "Doba doručení",
"in_reply_to": "V odpovědi na",
"references": "Odkazy",
"thread_id": "ID vlákna",
"size": "Velikost",
"mime_type": "Typ MIME",
"attachments_summary": "{count} souborů · {size}",
"list_id": "ID seznamu",
"list_help": "Nápověda seznamu",
"list_post": "Příspěvek do seznamu",
"list_unsubscribe": "Odhlásit se",
"iprev": "Reverzní DNS",
"spam_status": "Stav spamu",
"ai_verdict": "Verdikt AI",
"account": "Účet",
"no_subject": "(bez předmětu)"
}
},
"email_composer": {
"new_message": "Nová zpráva",
@@ -1009,6 +1053,10 @@
"hide_inline_image_attachments": {
"label": "Skrýt vložené obrázky ze seznamu příloh",
"description": "Obrázky vložené přímo do textu zprávy se nezobrazí jako samostatné přílohy"
},
"attachment_image_previews": {
"label": "Zobrazit náhledy obrázků v přílohách",
"description": "Zobrazovat obrázkové přílohy jako miniatury místo obecných ikon souborů"
}
},
"composer": {
+50 -2
View File
@@ -184,6 +184,7 @@
"batch_actions": {
"select": "E-Mails auswählen",
"select_all": "Alle auswählen",
"selected_messages": "{count, plural, one {1 E-Mail} other {# E-Mails}} ausgewählt",
"mark_read": "Als gelesen markieren",
"mark_unread": "Als ungelesen markieren",
"delete": "Löschen",
@@ -223,6 +224,11 @@
"unstar": "Stern entfernen",
"mark_unread": "Als ungelesen markieren",
"mark_read": "Als gelesen markieren",
"unread": "Ungelesen",
"read": "Gelesen",
"spam_short": "Spam",
"not_spam_short": "Kein Spam",
"move": "Verschieben",
"print": "Drucken",
"view_source": "Quelltext anzeigen",
"export_email": "Als .eml exportieren",
@@ -304,7 +310,20 @@
"fail": "DMARC Fehlgeschlagen",
"none": "Kein DMARC"
},
"spam_score": "Spam-Bewertung"
"spam_score": "Spam-Bewertung",
"tooltip_spf": "Sender Policy Framework: Überprüft, ob der sendende Server berechtigt ist, E-Mails im Namen der Domain zu senden",
"tooltip_dkim": "DomainKeys Identified Mail: Bestätigt mittels kryptografischer Signatur, dass die E-Mail während der Übertragung nicht verändert wurde",
"tooltip_dmarc": "Domain-based Message Authentication, Reporting & Conformance: Stellt sicher, dass SPF und DKIM mit der Domain des Absenders übereinstimmen, und legt eine Richtlinie für Fehler fest",
"policy": "Richtlinie",
"result": {
"pass": "Bestanden",
"fail": "Fehlgeschlagen",
"softfail": "Weicher Fehlschlag",
"neutral": "Neutral",
"permerror": "Permanenter Fehler",
"temperror": "Vorübergehender Fehler",
"none": "Keine"
}
},
"headers": {
"routing": "Routing",
@@ -431,7 +450,32 @@
"previous": "Zurück",
"next": "Weiter",
"send": "Senden",
"more": "mehr"
"more": "mehr",
"details": {
"recipients_routing": "Empfänger & Weiterleitung",
"authentication_security": "Authentifizierung & Sicherheit",
"identifiers_threading": "Kennungen & Threading",
"mailing_list": "Mailingliste",
"message_properties": "Nachrichteneigenschaften",
"sent": "Gesendet",
"received": "Empfangen",
"delivery_time": "Zustellzeit",
"in_reply_to": "In-Reply-To",
"references": "Verweise",
"thread_id": "Thread-ID",
"size": "Größe",
"mime_type": "MIME-Typ",
"attachments_summary": "{count} Dateien · {size}",
"list_id": "Listen-ID",
"list_help": "Listenhilfe",
"list_post": "Listenbeitrag",
"list_unsubscribe": "Abmelden",
"iprev": "Reverse DNS",
"spam_status": "Spam-Status",
"ai_verdict": "KI-Urteil",
"account": "Konto",
"no_subject": "(kein Betreff)"
}
},
"email_composer": {
"new_message": "Neue Nachricht",
@@ -1009,6 +1053,10 @@
"hide_inline_image_attachments": {
"label": "Eingebettete Bilder in Anhangsliste ausblenden",
"description": "Im Nachrichtentext eingebettete Bilder werden nicht als separate Anhänge aufgeführt"
},
"attachment_image_previews": {
"label": "Bildvorschau in Anhängen anzeigen",
"description": "Bildanhänge als Miniaturansichten statt als generische Dateisymbole anzeigen"
}
},
"composer": {
+51 -1
View File
@@ -184,6 +184,7 @@
"batch_actions": {
"select": "Select emails",
"select_all": "Select all",
"selected_messages": "{count, plural, one {1 email} other {# emails}} selected",
"mark_read": "Mark as read",
"mark_unread": "Mark as unread",
"delete": "Delete",
@@ -223,6 +224,11 @@
"unstar": "Unstar",
"mark_unread": "Mark as unread",
"mark_read": "Mark as read",
"unread": "Unread",
"read": "Read",
"spam_short": "Spam",
"not_spam_short": "Not spam",
"move": "Move",
"print": "Print",
"view_source": "View source",
"export_email": "Export as .eml",
@@ -306,7 +312,45 @@
"fail": "DMARC Fail",
"none": "No DMARC"
},
"spam_score": "Spam Score"
"spam_score": "Spam Score",
"tooltip_spf": "Sender Policy Framework: Verifies that the sending server is authorized to send email on behalf of the domain",
"tooltip_dkim": "DomainKeys Identified Mail: Confirms the email was not altered in transit using a cryptographic signature",
"tooltip_dmarc": "Domain-based Message Authentication, Reporting & Conformance: Ensures SPF and DKIM align with the sender's domain and sets a policy for failures",
"policy": "Policy",
"result": {
"pass": "Pass",
"fail": "Fail",
"softfail": "Soft fail",
"neutral": "Neutral",
"permerror": "Permanent error",
"temperror": "Temporary error",
"none": "None"
}
},
"details": {
"recipients_routing": "Recipients & routing",
"authentication_security": "Authentication & security",
"identifiers_threading": "Identifiers & threading",
"mailing_list": "Mailing list",
"message_properties": "Message properties",
"sent": "Sent",
"received": "Received",
"delivery_time": "Delivery time",
"in_reply_to": "In-Reply-To",
"references": "References",
"thread_id": "Thread ID",
"size": "Size",
"mime_type": "MIME type",
"attachments_summary": "{count} files · {size}",
"list_id": "List ID",
"list_help": "List help",
"list_post": "List post",
"list_unsubscribe": "Unsubscribe",
"iprev": "Reverse DNS",
"spam_status": "Spam status",
"ai_verdict": "AI verdict",
"account": "Account",
"no_subject": "(no subject)"
},
"headers": {
"routing": "Routing",
@@ -450,6 +494,8 @@
"send": "Send",
"cancel": "Cancel",
"attach": "Attach",
"attach_photos": "Photos & Videos",
"attach_files": "Files",
"discard": "Discard",
"discard_draft_title": "Discard draft?",
"discard_draft_confirm": "You have unsaved changes. Do you want to discard this draft?",
@@ -1009,6 +1055,10 @@
"hide_inline_image_attachments": {
"label": "Hide inline images from attachments",
"description": "Images embedded in the message body are not listed as separate attachments"
},
"attachment_image_previews": {
"label": "Show image previews in attachments",
"description": "Render image attachments as thumbnail cards instead of generic file icons"
}
},
"composer": {
+85 -37
View File
@@ -184,6 +184,7 @@
"batch_actions": {
"select": "Seleccionar correos",
"select_all": "Seleccionar todo",
"selected_messages": "{count, plural, one {1 correo electrónico seleccionado} other {# correos electrónicos seleccionados}}",
"mark_read": "Marcar como leído",
"mark_unread": "Marcar como no leído",
"delete": "Eliminar",
@@ -223,6 +224,11 @@
"unstar": "Quitar destacado",
"mark_unread": "Marcar como no leído",
"mark_read": "Marcar como leído",
"unread": "No leído",
"read": "Leído",
"spam_short": "Spam",
"not_spam_short": "No spam",
"move": "Mover",
"print": "Imprimir",
"view_source": "Ver código fuente",
"export_email": "Exportar como .eml",
@@ -304,7 +310,20 @@
"fail": "DMARC Fallido",
"none": "Sin DMARC"
},
"spam_score": "Puntuación de Spam"
"spam_score": "Puntuación de Spam",
"tooltip_spf": "Sender Policy Framework: verifica que el servidor de envío esté autorizado a enviar correo en nombre del dominio",
"tooltip_dkim": "DomainKeys Identified Mail: confirma mediante una firma criptográfica que el correo no se alteró durante el transporte",
"tooltip_dmarc": "Domain-based Message Authentication, Reporting & Conformance: garantiza que SPF y DKIM se alineen con el dominio del remitente y establece una política para los fallos",
"policy": "Política",
"result": {
"pass": "Aprobado",
"fail": "Fallido",
"softfail": "Fallo leve",
"neutral": "Neutro",
"permerror": "Error permanente",
"temperror": "Error temporal",
"none": "Ninguno"
}
},
"headers": {
"routing": "Enrutamiento",
@@ -431,7 +450,32 @@
"previous": "Anterior",
"next": "Siguiente",
"send": "Enviar",
"more": "más"
"more": "más",
"details": {
"recipients_routing": "Destinatarios y enrutamiento",
"authentication_security": "Autenticación y seguridad",
"identifiers_threading": "Identificadores y conversación",
"mailing_list": "Lista de correo",
"message_properties": "Propiedades del mensaje",
"sent": "Enviado",
"received": "Recibido",
"delivery_time": "Tiempo de entrega",
"in_reply_to": "En respuesta a",
"references": "Referencias",
"thread_id": "ID de conversación",
"size": "Tamaño",
"mime_type": "Tipo MIME",
"attachments_summary": "{count} archivos · {size}",
"list_id": "ID de lista",
"list_help": "Ayuda de lista",
"list_post": "Publicación en lista",
"list_unsubscribe": "Darse de baja",
"iprev": "DNS inverso",
"spam_status": "Estado de spam",
"ai_verdict": "Veredicto de IA",
"account": "Cuenta",
"no_subject": "(sin asunto)"
}
},
"email_composer": {
"new_message": "Nuevo Mensaje",
@@ -1009,6 +1053,10 @@
"hide_inline_image_attachments": {
"label": "Ocultar imágenes incrustadas de los adjuntos",
"description": "Las imágenes incrustadas en el cuerpo del mensaje no se listan como adjuntos separados"
},
"attachment_image_previews": {
"label": "Mostrar vistas previas de imágenes en adjuntos",
"description": "Mostrar las imágenes adjuntas como tarjetas de miniatura en lugar de iconos de archivo genéricos"
}
},
"composer": {
@@ -1622,41 +1670,41 @@
"edit_draft": "Editar borrador"
},
"mailbox_context_menu": {
"mark_folder_read": "Mark folder as read",
"mark_folder_tree_read": "Mark folder & subfolders as read",
"mark_all_folders_read": "Mark all folders as read",
"new_subfolder": "New subfolder...",
"new_folder": "New folder...",
"rename": "Rename...",
"empty_folder": "Empty folder",
"empty_folder_generic": "Empty folder",
"delete_folder": "Delete folder",
"refresh": "Refresh",
"mark_all_confirm_title": "Mark all folders as read",
"mark_all_confirm_message": "Mark every unread message in your personal account as read?",
"delete_confirm_title": "Delete folder",
"delete_confirm_message": "Permanently delete the folder \"{name}\"? This action cannot be undone.",
"prompt_new_subfolder": "Enter a name for the new subfolder.",
"prompt_new_folder": "Enter a name for the new folder.",
"prompt_rename": "Enter a new name for this folder.",
"toast_marked_read": "Folder marked as read",
"toast_marked_read_count": "Marked {count, plural, one {1 message} other {# messages}} as read",
"toast_already_read": "No unread messages",
"toast_marked_all_read": "All folders marked as read",
"toast_emptied": "Folder emptied",
"toast_folder_created": "Folder created",
"toast_folder_renamed": "Folder renamed",
"toast_folder_deleted": "Folder deleted",
"toast_error_mark_read": "Failed to mark as read",
"toast_error_empty": "Failed to empty folder",
"toast_error_create": "Failed to create folder",
"toast_error_rename": "Failed to rename folder",
"toast_error_delete": "Failed to delete folder",
"toast_error_delete_has_children": "Folder has subfolders. Remove them first.",
"toast_error_delete_has_email": "Folder is not empty. Empty it first.",
"placeholder_folder_name": "Folder name",
"create": "Create",
"rename_confirm": "Rename"
"mark_folder_read": "Marcar carpeta como leída",
"mark_folder_tree_read": "Marcar carpeta y subcarpetas como leídas",
"mark_all_folders_read": "Marcar todas las carpetas como leídas",
"new_subfolder": "Nueva subcarpeta...",
"new_folder": "Nueva carpeta...",
"rename": "Renombrar...",
"empty_folder": "Vaciar carpeta",
"empty_folder_generic": "Vaciar carpeta",
"delete_folder": "Eliminar carpeta",
"refresh": "Actualizar",
"mark_all_confirm_title": "Marcar todas las carpetas como leídas",
"mark_all_confirm_message": "¿Marcar todos los mensajes no leídos de su cuenta personal como leídos?",
"delete_confirm_title": "Eliminar carpeta",
"delete_confirm_message": "¿Eliminar permanentemente la carpeta \"{name}\"? Esta acción no se puede deshacer.",
"prompt_new_subfolder": "Ingrese un nombre para la nueva subcarpeta.",
"prompt_new_folder": "Ingrese un nombre para la nueva carpeta.",
"prompt_rename": "Ingrese un nuevo nombre para esta carpeta.",
"toast_marked_read": "Carpeta marcada como leída",
"toast_marked_read_count": "Se marcaron {count, plural, one {1 mensaje} other {# mensajes}} como leídos",
"toast_already_read": "No hay mensajes no leídos",
"toast_marked_all_read": "Todas las carpetas marcadas como leídas",
"toast_emptied": "Carpeta vaciada",
"toast_folder_created": "Carpeta creada",
"toast_folder_renamed": "Carpeta renombrada",
"toast_folder_deleted": "Carpeta eliminada",
"toast_error_mark_read": "Error al marcar como leído",
"toast_error_empty": "Error al vaciar la carpeta",
"toast_error_create": "Error al crear la carpeta",
"toast_error_rename": "Error al renombrar la carpeta",
"toast_error_delete": "Error al eliminar la carpeta",
"toast_error_delete_has_children": "La carpeta tiene subcarpetas. Elimínelas primero.",
"toast_error_delete_has_email": "La carpeta no está vacía. Vacíela primero.",
"placeholder_folder_name": "Nombre de carpeta",
"create": "Crear",
"rename_confirm": "Renombrar"
},
"shortcuts": {
"title": "Atajos de Teclado",
+85 -37
View File
@@ -184,6 +184,7 @@
"batch_actions": {
"select": "Sélectionner les e-mails",
"select_all": "Tout sélectionner",
"selected_messages": "{count, plural, one {1 e-mail sélectionné} other {# e-mails sélectionnés}}",
"mark_read": "Marquer comme lu",
"mark_unread": "Marquer comme non lu",
"delete": "Supprimer",
@@ -223,6 +224,11 @@
"unstar": "Retirer des favoris",
"mark_unread": "Marquer comme non lu",
"mark_read": "Marquer comme lu",
"unread": "Non lu",
"read": "Lu",
"spam_short": "Spam",
"not_spam_short": "Non spam",
"move": "Déplacer",
"print": "Imprimer",
"view_source": "Voir la source",
"export_email": "Exporter en .eml",
@@ -304,7 +310,20 @@
"fail": "DMARC Échoué",
"none": "Pas de DMARC"
},
"spam_score": "Score de spam"
"spam_score": "Score de spam",
"tooltip_spf": "Sender Policy Framework : vérifie que le serveur d'envoi est autorisé à envoyer des e-mails au nom du domaine",
"tooltip_dkim": "DomainKeys Identified Mail : confirme que l'e-mail n'a pas été modifié en transit grâce à une signature cryptographique",
"tooltip_dmarc": "Domain-based Message Authentication, Reporting & Conformance : garantit l'alignement de SPF et DKIM avec le domaine de l'expéditeur et définit une politique en cas d'échec",
"policy": "Politique",
"result": {
"pass": "Validé",
"fail": "Échoué",
"softfail": "Échec partiel",
"neutral": "Neutre",
"permerror": "Erreur permanente",
"temperror": "Erreur temporaire",
"none": "Aucun"
}
},
"headers": {
"routing": "Routage",
@@ -431,7 +450,32 @@
"previous": "Précédent",
"next": "Suivant",
"send": "Envoyer",
"more": "plus"
"more": "plus",
"details": {
"recipients_routing": "Destinataires & routage",
"authentication_security": "Authentification & sécurité",
"identifiers_threading": "Identifiants & fil de discussion",
"mailing_list": "Liste de diffusion",
"message_properties": "Propriétés du message",
"sent": "Envoyé",
"received": "Reçu",
"delivery_time": "Délai de livraison",
"in_reply_to": "En-réponse-à",
"references": "Références",
"thread_id": "ID de fil",
"size": "Taille",
"mime_type": "Type MIME",
"attachments_summary": "{count} fichiers · {size}",
"list_id": "ID de liste",
"list_help": "Aide de liste",
"list_post": "Publication de liste",
"list_unsubscribe": "Se désinscrire",
"iprev": "DNS inversé",
"spam_status": "Statut spam",
"ai_verdict": "Verdict IA",
"account": "Compte",
"no_subject": "(sans objet)"
}
},
"email_composer": {
"new_message": "Nouveau message",
@@ -1009,6 +1053,10 @@
"hide_inline_image_attachments": {
"label": "Masquer les images intégrées des pièces jointes",
"description": "Les images intégrées au corps du message ne sont pas listées comme pièces jointes séparées"
},
"attachment_image_previews": {
"label": "Afficher les aperçus d'images dans les pièces jointes",
"description": "Afficher les images jointes sous forme de vignettes plutôt qu'avec des icônes de fichier génériques"
}
},
"composer": {
@@ -1622,41 +1670,41 @@
"edit_draft": "Modifier le brouillon"
},
"mailbox_context_menu": {
"mark_folder_read": "Mark folder as read",
"mark_folder_tree_read": "Mark folder & subfolders as read",
"mark_all_folders_read": "Mark all folders as read",
"new_subfolder": "New subfolder...",
"new_folder": "New folder...",
"rename": "Rename...",
"empty_folder": "Empty folder",
"empty_folder_generic": "Empty folder",
"delete_folder": "Delete folder",
"refresh": "Refresh",
"mark_all_confirm_title": "Mark all folders as read",
"mark_all_confirm_message": "Mark every unread message in your personal account as read?",
"delete_confirm_title": "Delete folder",
"delete_confirm_message": "Permanently delete the folder \"{name}\"? This action cannot be undone.",
"prompt_new_subfolder": "Enter a name for the new subfolder.",
"prompt_new_folder": "Enter a name for the new folder.",
"prompt_rename": "Enter a new name for this folder.",
"toast_marked_read": "Folder marked as read",
"toast_marked_read_count": "Marked {count, plural, one {1 message} other {# messages}} as read",
"toast_already_read": "No unread messages",
"toast_marked_all_read": "All folders marked as read",
"toast_emptied": "Folder emptied",
"toast_folder_created": "Folder created",
"toast_folder_renamed": "Folder renamed",
"toast_folder_deleted": "Folder deleted",
"toast_error_mark_read": "Failed to mark as read",
"toast_error_empty": "Failed to empty folder",
"toast_error_create": "Failed to create folder",
"toast_error_rename": "Failed to rename folder",
"toast_error_delete": "Failed to delete folder",
"toast_error_delete_has_children": "Folder has subfolders. Remove them first.",
"toast_error_delete_has_email": "Folder is not empty. Empty it first.",
"placeholder_folder_name": "Folder name",
"create": "Create",
"rename_confirm": "Rename"
"mark_folder_read": "Marquer le dossier comme lu",
"mark_folder_tree_read": "Marquer le dossier et les sous-dossiers comme lus",
"mark_all_folders_read": "Marquer tous les dossiers comme lus",
"new_subfolder": "Nouveau sous-dossier...",
"new_folder": "Nouveau dossier...",
"rename": "Renommer...",
"empty_folder": "Vider le dossier",
"empty_folder_generic": "Vider le dossier",
"delete_folder": "Supprimer le dossier",
"refresh": "Actualiser",
"mark_all_confirm_title": "Marquer tous les dossiers comme lus",
"mark_all_confirm_message": "Marquer tous les messages non lus de votre compte personnel comme lus ?",
"delete_confirm_title": "Supprimer le dossier",
"delete_confirm_message": "Supprimer définitivement le dossier \"{name}\" ? Cette action est irréversible.",
"prompt_new_subfolder": "Entrez un nom pour le nouveau sous-dossier.",
"prompt_new_folder": "Entrez un nom pour le nouveau dossier.",
"prompt_rename": "Entrez un nouveau nom pour ce dossier.",
"toast_marked_read": "Dossier marqué comme lu",
"toast_marked_read_count": "{count, plural, one {1 message marqué} other {# messages marqués}} comme lu(s)",
"toast_already_read": "Aucun message non lu",
"toast_marked_all_read": "Tous les dossiers marqués comme lus",
"toast_emptied": "Dossier vidé",
"toast_folder_created": "Dossier créé",
"toast_folder_renamed": "Dossier renommé",
"toast_folder_deleted": "Dossier supprimé",
"toast_error_mark_read": "Échec du marquage comme lu",
"toast_error_empty": "Échec du vidage du dossier",
"toast_error_create": "Échec de la création du dossier",
"toast_error_rename": "Échec du renommage du dossier",
"toast_error_delete": "Échec de la suppression du dossier",
"toast_error_delete_has_children": "Le dossier contient des sous-dossiers. Supprimez-les d'abord.",
"toast_error_delete_has_email": "Le dossier n'est pas vide. Videz-le d'abord.",
"placeholder_folder_name": "Nom du dossier",
"create": "Créer",
"rename_confirm": "Renommer"
},
"shortcuts": {
"title": "Raccourcis clavier",
+85 -37
View File
@@ -184,6 +184,7 @@
"batch_actions": {
"select": "Seleziona e-mail",
"select_all": "Seleziona tutto",
"selected_messages": "{count} email {count, plural, one {selezionata} other {selezionate}}",
"mark_read": "Segna come letto",
"mark_unread": "Segna come non letto",
"delete": "Elimina",
@@ -223,6 +224,11 @@
"unstar": "Rimuovi stella",
"mark_unread": "Segna come non letto",
"mark_read": "Segna come letto",
"unread": "Non letto",
"read": "Letto",
"spam_short": "Spam",
"not_spam_short": "Non spam",
"move": "Sposta",
"print": "Stampa",
"view_source": "Visualizza sorgente",
"export_email": "Esporta come .eml",
@@ -304,7 +310,20 @@
"fail": "DMARC fallito",
"none": "Nessun DMARC"
},
"spam_score": "Punteggio spam"
"spam_score": "Punteggio spam",
"tooltip_spf": "Sender Policy Framework: verifica che il server mittente sia autorizzato a inviare e-mail per conto del dominio",
"tooltip_dkim": "DomainKeys Identified Mail: conferma tramite firma crittografica che l'e-mail non è stata alterata durante il transito",
"tooltip_dmarc": "Domain-based Message Authentication, Reporting & Conformance: assicura che SPF e DKIM siano allineati al dominio del mittente e definisce una politica in caso di errore",
"policy": "Criterio",
"result": {
"pass": "Superato",
"fail": "Fallito",
"softfail": "Fallimento lieve",
"neutral": "Neutro",
"permerror": "Errore permanente",
"temperror": "Errore temporaneo",
"none": "Nessuno"
}
},
"headers": {
"routing": "Instradamento",
@@ -431,7 +450,32 @@
"previous": "Precedente",
"next": "Successivo",
"send": "Invia",
"more": "altri"
"more": "altri",
"details": {
"recipients_routing": "Destinatari e instradamento",
"authentication_security": "Autenticazione e sicurezza",
"identifiers_threading": "Identificatori e conversazione",
"mailing_list": "Mailing list",
"message_properties": "Proprietà del messaggio",
"sent": "Inviato",
"received": "Ricevuto",
"delivery_time": "Tempo di consegna",
"in_reply_to": "In-Reply-To",
"references": "Riferimenti",
"thread_id": "ID conversazione",
"size": "Dimensione",
"mime_type": "Tipo MIME",
"attachments_summary": "{count} file · {size}",
"list_id": "ID lista",
"list_help": "Guida lista",
"list_post": "Invio alla lista",
"list_unsubscribe": "Annulla iscrizione",
"iprev": "DNS inverso",
"spam_status": "Stato spam",
"ai_verdict": "Verdetto IA",
"account": "Account",
"no_subject": "(nessun oggetto)"
}
},
"email_composer": {
"new_message": "Nuovo messaggio",
@@ -1009,6 +1053,10 @@
"hide_inline_image_attachments": {
"label": "Nascondi le immagini inline dagli allegati",
"description": "Le immagini incorporate nel corpo del messaggio non vengono elencate come allegati separati"
},
"attachment_image_previews": {
"label": "Mostra anteprime delle immagini negli allegati",
"description": "Mostra gli allegati immagine come miniature anziché con icone di file generiche"
}
},
"composer": {
@@ -1622,41 +1670,41 @@
"edit_draft": "Modifica bozza"
},
"mailbox_context_menu": {
"mark_folder_read": "Mark folder as read",
"mark_folder_tree_read": "Mark folder & subfolders as read",
"mark_all_folders_read": "Mark all folders as read",
"new_subfolder": "New subfolder...",
"new_folder": "New folder...",
"rename": "Rename...",
"empty_folder": "Empty folder",
"empty_folder_generic": "Empty folder",
"delete_folder": "Delete folder",
"refresh": "Refresh",
"mark_all_confirm_title": "Mark all folders as read",
"mark_all_confirm_message": "Mark every unread message in your personal account as read?",
"delete_confirm_title": "Delete folder",
"delete_confirm_message": "Permanently delete the folder \"{name}\"? This action cannot be undone.",
"prompt_new_subfolder": "Enter a name for the new subfolder.",
"prompt_new_folder": "Enter a name for the new folder.",
"prompt_rename": "Enter a new name for this folder.",
"toast_marked_read": "Folder marked as read",
"toast_marked_read_count": "Marked {count, plural, one {1 message} other {# messages}} as read",
"toast_already_read": "No unread messages",
"toast_marked_all_read": "All folders marked as read",
"toast_emptied": "Folder emptied",
"toast_folder_created": "Folder created",
"toast_folder_renamed": "Folder renamed",
"toast_folder_deleted": "Folder deleted",
"toast_error_mark_read": "Failed to mark as read",
"toast_error_empty": "Failed to empty folder",
"toast_error_create": "Failed to create folder",
"toast_error_rename": "Failed to rename folder",
"toast_error_delete": "Failed to delete folder",
"toast_error_delete_has_children": "Folder has subfolders. Remove them first.",
"toast_error_delete_has_email": "Folder is not empty. Empty it first.",
"placeholder_folder_name": "Folder name",
"create": "Create",
"rename_confirm": "Rename"
"mark_folder_read": "Segna cartella come letta",
"mark_folder_tree_read": "Segna cartella e sottocartelle come lette",
"mark_all_folders_read": "Segna tutte le cartelle come lette",
"new_subfolder": "Nuova sottocartella...",
"new_folder": "Nuova cartella...",
"rename": "Rinomina...",
"empty_folder": "Svuota cartella",
"empty_folder_generic": "Svuota cartella",
"delete_folder": "Elimina cartella",
"refresh": "Aggiorna",
"mark_all_confirm_title": "Segna tutte le cartelle come lette",
"mark_all_confirm_message": "Segnare tutti i messaggi non letti del tuo account personale come letti?",
"delete_confirm_title": "Elimina cartella",
"delete_confirm_message": "Eliminare definitivamente la cartella \"{name}\"? Questa azione non può essere annullata.",
"prompt_new_subfolder": "Inserisci un nome per la nuova sottocartella.",
"prompt_new_folder": "Inserisci un nome per la nuova cartella.",
"prompt_rename": "Inserisci un nuovo nome per questa cartella.",
"toast_marked_read": "Cartella segnata come letta",
"toast_marked_read_count": "Segnati {count, plural, one {1 messaggio} other {# messaggi}} come letti",
"toast_already_read": "Nessun messaggio non letto",
"toast_marked_all_read": "Tutte le cartelle segnate come lette",
"toast_emptied": "Cartella svuotata",
"toast_folder_created": "Cartella creata",
"toast_folder_renamed": "Cartella rinominata",
"toast_folder_deleted": "Cartella eliminata",
"toast_error_mark_read": "Impossibile segnare come letto",
"toast_error_empty": "Impossibile svuotare la cartella",
"toast_error_create": "Impossibile creare la cartella",
"toast_error_rename": "Impossibile rinominare la cartella",
"toast_error_delete": "Impossibile eliminare la cartella",
"toast_error_delete_has_children": "La cartella contiene sottocartelle. Rimuoverle prima.",
"toast_error_delete_has_email": "La cartella non è vuota. Svuotarla prima.",
"placeholder_folder_name": "Nome cartella",
"create": "Crea",
"rename_confirm": "Rinomina"
},
"shortcuts": {
"title": "Scorciatoie da tastiera",
+85 -37
View File
@@ -184,6 +184,7 @@
"batch_actions": {
"select": "メールを選択",
"select_all": "すべて選択",
"selected_messages": "{count, plural, one {1} other {#}}通のメールが選択されました",
"mark_read": "既読にする",
"mark_unread": "未読にする",
"delete": "削除",
@@ -223,6 +224,11 @@
"unstar": "スターを外す",
"mark_unread": "未読にする",
"mark_read": "既読にする",
"unread": "未読",
"read": "既読",
"spam_short": "迷惑メール",
"not_spam_short": "迷惑メールでない",
"move": "移動",
"print": "印刷",
"view_source": "ソースを表示",
"export_email": ".emlとしてエクスポート",
@@ -304,7 +310,20 @@
"fail": "DMARC不合格",
"none": "DMARCなし"
},
"spam_score": "スパムスコア"
"spam_score": "スパムスコア",
"tooltip_spf": "Sender Policy Framework: 送信サーバーがそのドメインを代表してメールを送信する権限を持つことを確認します",
"tooltip_dkim": "DomainKeys Identified Mail: 暗号署名を用いて、メールが送信中に改ざんされていないことを確認します",
"tooltip_dmarc": "Domain-based Message Authentication, Reporting & Conformance: SPF と DKIM が送信者のドメインと一致することを確認し、失敗時のポリシーを定めます",
"policy": "ポリシー",
"result": {
"pass": "合格",
"fail": "不合格",
"softfail": "ソフト失敗",
"neutral": "中立",
"permerror": "永続的エラー",
"temperror": "一時的エラー",
"none": "なし"
}
},
"headers": {
"routing": "ルーティング",
@@ -431,7 +450,32 @@
"previous": "前へ",
"next": "次へ",
"send": "送信",
"more": "他"
"more": "他",
"details": {
"recipients_routing": "宛先とルーティング",
"authentication_security": "認証とセキュリティ",
"identifiers_threading": "識別子とスレッド",
"mailing_list": "メーリングリスト",
"message_properties": "メッセージのプロパティ",
"sent": "送信日時",
"received": "受信日時",
"delivery_time": "配信時間",
"in_reply_to": "返信先",
"references": "参照",
"thread_id": "スレッドID",
"size": "サイズ",
"mime_type": "MIMEタイプ",
"attachments_summary": "{count} ファイル · {size}",
"list_id": "リストID",
"list_help": "リストヘルプ",
"list_post": "リスト投稿",
"list_unsubscribe": "購読解除",
"iprev": "逆引きDNS",
"spam_status": "スパム状態",
"ai_verdict": "AIの判定",
"account": "アカウント",
"no_subject": "(件名なし)"
}
},
"email_composer": {
"new_message": "新規メッセージ",
@@ -1009,6 +1053,10 @@
"hide_inline_image_attachments": {
"label": "添付ファイル一覧からインライン画像を隠す",
"description": "本文に埋め込まれた画像を個別の添付ファイルとして表示しません"
},
"attachment_image_previews": {
"label": "添付ファイル内の画像プレビューを表示",
"description": "画像の添付ファイルを汎用ファイルアイコンではなくサムネイルカードとして表示します"
}
},
"composer": {
@@ -1622,41 +1670,41 @@
"edit_draft": "下書きを編集"
},
"mailbox_context_menu": {
"mark_folder_read": "Mark folder as read",
"mark_folder_tree_read": "Mark folder & subfolders as read",
"mark_all_folders_read": "Mark all folders as read",
"new_subfolder": "New subfolder...",
"new_folder": "New folder...",
"rename": "Rename...",
"empty_folder": "Empty folder",
"empty_folder_generic": "Empty folder",
"delete_folder": "Delete folder",
"refresh": "Refresh",
"mark_all_confirm_title": "Mark all folders as read",
"mark_all_confirm_message": "Mark every unread message in your personal account as read?",
"delete_confirm_title": "Delete folder",
"delete_confirm_message": "Permanently delete the folder \"{name}\"? This action cannot be undone.",
"prompt_new_subfolder": "Enter a name for the new subfolder.",
"prompt_new_folder": "Enter a name for the new folder.",
"prompt_rename": "Enter a new name for this folder.",
"toast_marked_read": "Folder marked as read",
"toast_marked_read_count": "Marked {count, plural, one {1 message} other {# messages}} as read",
"toast_already_read": "No unread messages",
"toast_marked_all_read": "All folders marked as read",
"toast_emptied": "Folder emptied",
"toast_folder_created": "Folder created",
"toast_folder_renamed": "Folder renamed",
"toast_folder_deleted": "Folder deleted",
"toast_error_mark_read": "Failed to mark as read",
"toast_error_empty": "Failed to empty folder",
"toast_error_create": "Failed to create folder",
"toast_error_rename": "Failed to rename folder",
"toast_error_delete": "Failed to delete folder",
"toast_error_delete_has_children": "Folder has subfolders. Remove them first.",
"toast_error_delete_has_email": "Folder is not empty. Empty it first.",
"placeholder_folder_name": "Folder name",
"create": "Create",
"rename_confirm": "Rename"
"mark_folder_read": "フォルダーを既読にする",
"mark_folder_tree_read": "フォルダーとサブフォルダーを既読にする",
"mark_all_folders_read": "すべてのフォルダーを既読にする",
"new_subfolder": "新しいサブフォルダー...",
"new_folder": "新しいフォルダー...",
"rename": "名前を変更...",
"empty_folder": "フォルダーを空にする",
"empty_folder_generic": "フォルダーを空にする",
"delete_folder": "フォルダーを削除",
"refresh": "更新",
"mark_all_confirm_title": "すべてのフォルダーを既読にする",
"mark_all_confirm_message": "個人アカウントのすべての未読メッセージを既読にしますか?",
"delete_confirm_title": "フォルダーを削除",
"delete_confirm_message": "フォルダー \"{name}\" を完全に削除しますか?この操作は元に戻せません。",
"prompt_new_subfolder": "新しいサブフォルダーの名前を入力してください。",
"prompt_new_folder": "新しいフォルダーの名前を入力してください。",
"prompt_rename": "このフォルダーの新しい名前を入力してください。",
"toast_marked_read": "フォルダーを既読にしました",
"toast_marked_read_count": "{count, plural, one {1件のメッセージ} other {#件のメッセージ}}を既読にしました",
"toast_already_read": "未読メッセージはありません",
"toast_marked_all_read": "すべてのフォルダーを既読にしました",
"toast_emptied": "フォルダーを空にしました",
"toast_folder_created": "フォルダーを作成しました",
"toast_folder_renamed": "フォルダー名を変更しました",
"toast_folder_deleted": "フォルダーを削除しました",
"toast_error_mark_read": "既読にできませんでした",
"toast_error_empty": "フォルダーを空にできませんでした",
"toast_error_create": "フォルダーを作成できませんでした",
"toast_error_rename": "フォルダー名を変更できませんでした",
"toast_error_delete": "フォルダーを削除できませんでした",
"toast_error_delete_has_children": "フォルダーにサブフォルダーがあります。先に削除してください。",
"toast_error_delete_has_email": "フォルダーが空ではありません。先に空にしてください。",
"placeholder_folder_name": "フォルダー名",
"create": "作成",
"rename_confirm": "名前を変更"
},
"shortcuts": {
"title": "キーボードショートカット",
+85 -37
View File
@@ -184,6 +184,7 @@
"batch_actions": {
"select": "메일 선택",
"select_all": "모두 선택",
"selected_messages": "이메일 {count, plural, one {1개} other {#건}} 선택됨",
"mark_read": "읽은 상태로 표시",
"mark_unread": "읽지 않은 상태로 표시",
"delete": "삭제",
@@ -223,6 +224,11 @@
"unstar": "별표 해제",
"mark_unread": "읽지 않은 상태로 표시",
"mark_read": "읽은 상태로 표시",
"unread": "안 읽음",
"read": "읽음",
"spam_short": "스팸",
"not_spam_short": "스팸 아님",
"move": "이동",
"print": "인쇄",
"view_source": "원본 보기",
"export_email": ".eml 파일로 내보내기",
@@ -306,7 +312,20 @@
"fail": "DMARC 실패",
"none": "DMARC 없음"
},
"spam_score": "스팸 점수"
"spam_score": "스팸 점수",
"tooltip_spf": "Sender Policy Framework: 발신 서버가 해당 도메인을 대신해 메일을 보낼 권한이 있는지 확인합니다",
"tooltip_dkim": "DomainKeys Identified Mail: 암호화 서명을 통해 메일이 전송 중에 변경되지 않았음을 확인합니다",
"tooltip_dmarc": "Domain-based Message Authentication, Reporting & Conformance: SPF와 DKIM이 발신자 도메인과 일치하는지 확인하고 실패에 대한 정책을 설정합니다",
"policy": "정책",
"result": {
"pass": "통과",
"fail": "실패",
"softfail": "경미한 실패",
"neutral": "중립",
"permerror": "영구 오류",
"temperror": "일시적 오류",
"none": "없음"
}
},
"headers": {
"routing": "라우팅",
@@ -431,7 +450,32 @@
"expand": "세부정보 표시"
},
"send": "보내기",
"more": "더보기"
"more": "더보기",
"details": {
"recipients_routing": "수신자 및 라우팅",
"authentication_security": "인증 및 보안",
"identifiers_threading": "식별자 및 스레드",
"mailing_list": "메일링 리스트",
"message_properties": "메시지 속성",
"sent": "보낸 시각",
"received": "받은 시각",
"delivery_time": "전달 시간",
"in_reply_to": "회신 대상",
"references": "참조",
"thread_id": "스레드 ID",
"size": "크기",
"mime_type": "MIME 타입",
"attachments_summary": "{count}개 파일 · {size}",
"list_id": "리스트 ID",
"list_help": "리스트 도움말",
"list_post": "리스트 게시",
"list_unsubscribe": "구독 해지",
"iprev": "역방향 DNS",
"spam_status": "스팸 상태",
"ai_verdict": "AI 판정",
"account": "계정",
"no_subject": "(제목 없음)"
}
},
"email_composer": {
"new_message": "새 메시지",
@@ -1009,6 +1053,10 @@
"hide_inline_image_attachments": {
"label": "첨부 파일 목록에서 인라인 이미지 숨기기",
"description": "메시지 본문에 포함된 이미지는 별도의 첨부 파일로 표시되지 않습니다"
},
"attachment_image_previews": {
"label": "첨부 파일에 이미지 미리보기 표시",
"description": "이미지 첨부 파일을 일반 파일 아이콘 대신 썸네일 카드로 표시합니다"
}
},
"composer": {
@@ -1622,41 +1670,41 @@
"edit_draft": "임시보관 메일 수정"
},
"mailbox_context_menu": {
"mark_folder_read": "Mark folder as read",
"mark_folder_tree_read": "Mark folder & subfolders as read",
"mark_all_folders_read": "Mark all folders as read",
"new_subfolder": "New subfolder...",
"new_folder": "New folder...",
"rename": "Rename...",
"empty_folder": "Empty folder",
"empty_folder_generic": "Empty folder",
"delete_folder": "Delete folder",
"refresh": "Refresh",
"mark_all_confirm_title": "Mark all folders as read",
"mark_all_confirm_message": "Mark every unread message in your personal account as read?",
"delete_confirm_title": "Delete folder",
"delete_confirm_message": "Permanently delete the folder \"{name}\"? This action cannot be undone.",
"prompt_new_subfolder": "Enter a name for the new subfolder.",
"prompt_new_folder": "Enter a name for the new folder.",
"prompt_rename": "Enter a new name for this folder.",
"toast_marked_read": "Folder marked as read",
"toast_marked_read_count": "Marked {count, plural, one {1 message} other {# messages}} as read",
"toast_already_read": "No unread messages",
"toast_marked_all_read": "All folders marked as read",
"toast_emptied": "Folder emptied",
"toast_folder_created": "Folder created",
"toast_folder_renamed": "Folder renamed",
"toast_folder_deleted": "Folder deleted",
"toast_error_mark_read": "Failed to mark as read",
"toast_error_empty": "Failed to empty folder",
"toast_error_create": "Failed to create folder",
"toast_error_rename": "Failed to rename folder",
"toast_error_delete": "Failed to delete folder",
"toast_error_delete_has_children": "Folder has subfolders. Remove them first.",
"toast_error_delete_has_email": "Folder is not empty. Empty it first.",
"placeholder_folder_name": "Folder name",
"create": "Create",
"rename_confirm": "Rename"
"mark_folder_read": "폴더를 읽음으로 표시",
"mark_folder_tree_read": "폴더 및 하위 폴더를 읽음으로 표시",
"mark_all_folders_read": "모든 폴더를 읽음으로 표시",
"new_subfolder": "새 하위 폴더...",
"new_folder": "새 폴더...",
"rename": "이름 바꾸기...",
"empty_folder": "폴더 비우기",
"empty_folder_generic": "폴더 비우기",
"delete_folder": "폴더 삭제",
"refresh": "새로 고침",
"mark_all_confirm_title": "모든 폴더를 읽음으로 표시",
"mark_all_confirm_message": "개인 계정의 모든 읽지 않은 메시지를 읽음으로 표시하시겠습니까?",
"delete_confirm_title": "폴더 삭제",
"delete_confirm_message": "폴더 \"{name}\"을(를) 영구적으로 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.",
"prompt_new_subfolder": "새 하위 폴더의 이름을 입력하세요.",
"prompt_new_folder": "새 폴더의 이름을 입력하세요.",
"prompt_rename": "이 폴더의 새 이름을 입력하세요.",
"toast_marked_read": "폴더를 읽음으로 표시했습니다",
"toast_marked_read_count": "{count, plural, one {메시지 1개} other {메시지 #개}}을(를) 읽음으로 표시했습니다",
"toast_already_read": "읽지 않은 메시지가 없습니다",
"toast_marked_all_read": "모든 폴더를 읽음으로 표시했습니다",
"toast_emptied": "폴더를 비웠습니다",
"toast_folder_created": "폴더가 생성되었습니다",
"toast_folder_renamed": "폴더 이름이 변경되었습니다",
"toast_folder_deleted": "폴더가 삭제되었습니다",
"toast_error_mark_read": "읽음으로 표시하지 못했습니다",
"toast_error_empty": "폴더를 비우지 못했습니다",
"toast_error_create": "폴더를 생성하지 못했습니다",
"toast_error_rename": "폴더 이름을 변경하지 못했습니다",
"toast_error_delete": "폴더를 삭제하지 못했습니다",
"toast_error_delete_has_children": "폴더에 하위 폴더가 있습니다. 먼저 제거하세요.",
"toast_error_delete_has_email": "폴더가 비어 있지 않습니다. 먼저 비우세요.",
"placeholder_folder_name": "폴더 이름",
"create": "만들기",
"rename_confirm": "이름 바꾸기"
},
"shortcuts": {
"title": "단축키",
+85 -37
View File
@@ -184,6 +184,7 @@
"batch_actions": {
"select": "Atlasīt vēstules",
"select_all": "Atlasīt visas",
"selected_messages": "{count, plural, one {Izvēlēta 1 e-pasta vēstule} other {Izvēlētas # e-pasta vēstules}}",
"mark_read": "Atzīmēt kā izlasītu",
"mark_unread": "Atzīmēt kā nelasītu",
"delete": "Dzēst",
@@ -223,6 +224,11 @@
"unstar": "Noņemt zvaigznīti",
"mark_unread": "Atzīmēt kā nelasītu",
"mark_read": "Atzīmēt kā izlasītu",
"unread": "Nelasīts",
"read": "Lasīts",
"spam_short": "Spams",
"not_spam_short": "Nav spams",
"move": "Pārvietot",
"print": "Drukāt",
"view_source": "Skatīt avota kodu",
"export_email": "Eksportēt kā .eml",
@@ -306,7 +312,20 @@
"fail": "DMARC nav izturēts",
"none": "DMARC nav atrasts"
},
"spam_score": "Mēstuļu vērtējums"
"spam_score": "Mēstuļu vērtējums",
"tooltip_spf": "Sender Policy Framework: pārbauda, vai sūtošajam serverim ir tiesības sūtīt e-pastu domēna vārdā",
"tooltip_dkim": "DomainKeys Identified Mail: ar kriptogrāfisku parakstu apstiprina, ka vēstule pārsūtīšanas laikā nav grozīta",
"tooltip_dmarc": "Domain-based Message Authentication, Reporting & Conformance: nodrošina, ka SPF un DKIM atbilst sūtītāja domēnam, un nosaka politiku kļūmju gadījumiem",
"policy": "Politika",
"result": {
"pass": "Izturēts",
"fail": "Nav izturēts",
"softfail": "Daļēja kļūme",
"neutral": "Neitrāli",
"permerror": "Pastāvīga kļūda",
"temperror": "Pagaidu kļūda",
"none": "Nav"
}
},
"headers": {
"routing": "Maršrutēšana",
@@ -431,7 +450,32 @@
"expand": "Rādīt detaļas"
},
"send": "Sūtīt",
"more": "vairāk"
"more": "vairāk",
"details": {
"recipients_routing": "Adresāti un maršrutēšana",
"authentication_security": "Autentifikācija un drošība",
"identifiers_threading": "Identifikatori un sarakste",
"mailing_list": "Adresātu saraksts",
"message_properties": "Vēstules īpašības",
"sent": "Nosūtīts",
"received": "Saņemts",
"delivery_time": "Piegādes laiks",
"in_reply_to": "Atbildot uz",
"references": "Atsauces",
"thread_id": "Sarakstes ID",
"size": "Lielums",
"mime_type": "MIME tips",
"attachments_summary": "{count} faili · {size}",
"list_id": "Saraksta ID",
"list_help": "Saraksta palīdzība",
"list_post": "Sūtīšana sarakstam",
"list_unsubscribe": "Atteikties",
"iprev": "Apgrieztais DNS",
"spam_status": "Mēstuļu statuss",
"ai_verdict": "AI vērtējums",
"account": "Konts",
"no_subject": "(bez temata)"
}
},
"email_composer": {
"new_message": "Jauns ziņojums",
@@ -1009,6 +1053,10 @@
"hide_inline_image_attachments": {
"label": "Slēpt iegultos attēlus no pielikumu saraksta",
"description": "Ziņojuma pamattekstā iegultie attēli netiek rādīti kā atsevišķi pielikumi"
},
"attachment_image_previews": {
"label": "Rādīt attēlu priekšskatījumus pielikumos",
"description": "Rādīt attēlu pielikumus kā sīktēlu kartītes, nevis vispārīgas failu ikonas"
}
},
"composer": {
@@ -1622,41 +1670,41 @@
"edit_draft": "Rediģēt melnrakstu"
},
"mailbox_context_menu": {
"mark_folder_read": "Mark folder as read",
"mark_folder_tree_read": "Mark folder & subfolders as read",
"mark_all_folders_read": "Mark all folders as read",
"new_subfolder": "New subfolder...",
"new_folder": "New folder...",
"rename": "Rename...",
"empty_folder": "Empty folder",
"empty_folder_generic": "Empty folder",
"delete_folder": "Delete folder",
"refresh": "Refresh",
"mark_all_confirm_title": "Mark all folders as read",
"mark_all_confirm_message": "Mark every unread message in your personal account as read?",
"delete_confirm_title": "Delete folder",
"delete_confirm_message": "Permanently delete the folder \"{name}\"? This action cannot be undone.",
"prompt_new_subfolder": "Enter a name for the new subfolder.",
"prompt_new_folder": "Enter a name for the new folder.",
"prompt_rename": "Enter a new name for this folder.",
"toast_marked_read": "Folder marked as read",
"toast_marked_read_count": "Marked {count, plural, one {1 message} other {# messages}} as read",
"toast_already_read": "No unread messages",
"toast_marked_all_read": "All folders marked as read",
"toast_emptied": "Folder emptied",
"toast_folder_created": "Folder created",
"toast_folder_renamed": "Folder renamed",
"toast_folder_deleted": "Folder deleted",
"toast_error_mark_read": "Failed to mark as read",
"toast_error_empty": "Failed to empty folder",
"toast_error_create": "Failed to create folder",
"toast_error_rename": "Failed to rename folder",
"toast_error_delete": "Failed to delete folder",
"toast_error_delete_has_children": "Folder has subfolders. Remove them first.",
"toast_error_delete_has_email": "Folder is not empty. Empty it first.",
"placeholder_folder_name": "Folder name",
"create": "Create",
"rename_confirm": "Rename"
"mark_folder_read": "Atzīmēt mapi kā lasītu",
"mark_folder_tree_read": "Atzīmēt mapi un apakšmapes kā lasītas",
"mark_all_folders_read": "Atzīmēt visas mapes kā lasītas",
"new_subfolder": "Jauna apakšmape...",
"new_folder": "Jauna mape...",
"rename": "Pārsaukt...",
"empty_folder": "Iztukšot mapi",
"empty_folder_generic": "Iztukšot mapi",
"delete_folder": "Dzēst mapi",
"refresh": "Atjaunināt",
"mark_all_confirm_title": "Atzīmēt visas mapes kā lasītas",
"mark_all_confirm_message": "Atzīmēt visas nelasītās ziņas jūsu personīgajā kontā kā lasītas?",
"delete_confirm_title": "Dzēst mapi",
"delete_confirm_message": "Neatgriezeniski dzēst mapi \"{name}\"? Šo darbību nevar atsaukt.",
"prompt_new_subfolder": "Ievadiet nosaukumu jaunajai apakšmapei.",
"prompt_new_folder": "Ievadiet nosaukumu jaunajai mapei.",
"prompt_rename": "Ievadiet jaunu nosaukumu šai mapei.",
"toast_marked_read": "Mape atzīmēta kā lasīta",
"toast_marked_read_count": "{count, plural, one {1 ziņa atzīmēta} other {# ziņas atzīmētas}} kā lasītas",
"toast_already_read": "Nav nelasītu ziņu",
"toast_marked_all_read": "Visas mapes atzīmētas kā lasītas",
"toast_emptied": "Mape iztukšota",
"toast_folder_created": "Mape izveidota",
"toast_folder_renamed": "Mape pārsaukta",
"toast_folder_deleted": "Mape dzēsta",
"toast_error_mark_read": "Neizdevās atzīmēt kā lasītu",
"toast_error_empty": "Neizdevās iztukšot mapi",
"toast_error_create": "Neizdevās izveidot mapi",
"toast_error_rename": "Neizdevās pārsaukt mapi",
"toast_error_delete": "Neizdevās dzēst mapi",
"toast_error_delete_has_children": "Mapei ir apakšmapes. Vispirms noņemiet tās.",
"toast_error_delete_has_email": "Mape nav tukša. Vispirms iztukšojiet to.",
"placeholder_folder_name": "Mapes nosaukums",
"create": "Izveidot",
"rename_confirm": "Pārsaukt"
},
"shortcuts": {
"title": "Īsinājumtaustiņi",
+85 -37
View File
@@ -185,6 +185,7 @@
"select": "E-mails selecteren",
"select_all": "Alles selecteren",
"mark_read": "Markeren als gelezen",
"selected_messages": "{count, plural, one {1 e-mail} other {# e-mails}} geselecteerd",
"mark_unread": "Markeren als ongelezen",
"delete": "Verwijderen",
"delete_confirm_title": "E-mails verwijderen",
@@ -223,6 +224,11 @@
"unstar": "Ster verwijderen",
"mark_unread": "Markeren als ongelezen",
"mark_read": "Markeren als gelezen",
"unread": "Ongelezen",
"read": "Gelezen",
"spam_short": "Spam",
"not_spam_short": "Geen spam",
"move": "Verplaatsen",
"print": "Afdrukken",
"view_source": "Bron bekijken",
"export_email": "Exporteren als .eml",
@@ -304,7 +310,20 @@
"fail": "DMARC Mislukt",
"none": "Geen DMARC"
},
"spam_score": "Spamscore"
"spam_score": "Spamscore",
"tooltip_spf": "Sender Policy Framework: controleert of de verzendende server gemachtigd is om e-mail namens het domein te verzenden",
"tooltip_dkim": "DomainKeys Identified Mail: bevestigt met een cryptografische handtekening dat de e-mail tijdens het transport niet is gewijzigd",
"tooltip_dmarc": "Domain-based Message Authentication, Reporting & Conformance: zorgt ervoor dat SPF en DKIM overeenkomen met het domein van de afzender en stelt een beleid in voor fouten",
"policy": "Beleid",
"result": {
"pass": "Geslaagd",
"fail": "Mislukt",
"softfail": "Zachte mislukking",
"neutral": "Neutraal",
"permerror": "Permanente fout",
"temperror": "Tijdelijke fout",
"none": "Geen"
}
},
"headers": {
"routing": "Routering",
@@ -431,7 +450,32 @@
"previous": "Vorige",
"next": "Volgende",
"send": "Verzenden",
"more": "meer"
"more": "meer",
"details": {
"recipients_routing": "Ontvangers & routing",
"authentication_security": "Authenticatie & beveiliging",
"identifiers_threading": "Identificatoren & threading",
"mailing_list": "Mailinglijst",
"message_properties": "Berichteigenschappen",
"sent": "Verzonden",
"received": "Ontvangen",
"delivery_time": "Bezorgtijd",
"in_reply_to": "In-Reply-To",
"references": "Verwijzingen",
"thread_id": "Thread-ID",
"size": "Grootte",
"mime_type": "MIME-type",
"attachments_summary": "{count} bestanden · {size}",
"list_id": "Lijst-ID",
"list_help": "Lijsthulp",
"list_post": "Lijstbericht",
"list_unsubscribe": "Afmelden",
"iprev": "Reverse DNS",
"spam_status": "Spamstatus",
"ai_verdict": "AI-oordeel",
"account": "Account",
"no_subject": "(geen onderwerp)"
}
},
"email_composer": {
"new_message": "Nieuw bericht",
@@ -1009,6 +1053,10 @@
"hide_inline_image_attachments": {
"label": "Inline-afbeeldingen verbergen uit bijlagenlijst",
"description": "Afbeeldingen die in de berichttekst zijn ingesloten worden niet als aparte bijlagen weergegeven"
},
"attachment_image_previews": {
"label": "Voorbeelden van afbeeldingen tonen in bijlagen",
"description": "Toon afbeeldingsbijlagen als miniatuurkaarten in plaats van generieke bestandspictogrammen"
}
},
"composer": {
@@ -1622,41 +1670,41 @@
"edit_draft": "Concept bewerken"
},
"mailbox_context_menu": {
"mark_folder_read": "Mark folder as read",
"mark_folder_tree_read": "Mark folder & subfolders as read",
"mark_all_folders_read": "Mark all folders as read",
"new_subfolder": "New subfolder...",
"new_folder": "New folder...",
"rename": "Rename...",
"empty_folder": "Empty folder",
"empty_folder_generic": "Empty folder",
"delete_folder": "Delete folder",
"refresh": "Refresh",
"mark_all_confirm_title": "Mark all folders as read",
"mark_all_confirm_message": "Mark every unread message in your personal account as read?",
"delete_confirm_title": "Delete folder",
"delete_confirm_message": "Permanently delete the folder \"{name}\"? This action cannot be undone.",
"prompt_new_subfolder": "Enter a name for the new subfolder.",
"prompt_new_folder": "Enter a name for the new folder.",
"prompt_rename": "Enter a new name for this folder.",
"toast_marked_read": "Folder marked as read",
"toast_marked_read_count": "Marked {count, plural, one {1 message} other {# messages}} as read",
"toast_already_read": "No unread messages",
"toast_marked_all_read": "All folders marked as read",
"toast_emptied": "Folder emptied",
"toast_folder_created": "Folder created",
"toast_folder_renamed": "Folder renamed",
"toast_folder_deleted": "Folder deleted",
"toast_error_mark_read": "Failed to mark as read",
"toast_error_empty": "Failed to empty folder",
"toast_error_create": "Failed to create folder",
"toast_error_rename": "Failed to rename folder",
"toast_error_delete": "Failed to delete folder",
"toast_error_delete_has_children": "Folder has subfolders. Remove them first.",
"toast_error_delete_has_email": "Folder is not empty. Empty it first.",
"placeholder_folder_name": "Folder name",
"create": "Create",
"rename_confirm": "Rename"
"mark_folder_read": "Map markeren als gelezen",
"mark_folder_tree_read": "Map en submappen markeren als gelezen",
"mark_all_folders_read": "Alle mappen markeren als gelezen",
"new_subfolder": "Nieuwe submap...",
"new_folder": "Nieuwe map...",
"rename": "Hernoemen...",
"empty_folder": "Map leegmaken",
"empty_folder_generic": "Map leegmaken",
"delete_folder": "Map verwijderen",
"refresh": "Vernieuwen",
"mark_all_confirm_title": "Alle mappen markeren als gelezen",
"mark_all_confirm_message": "Alle ongelezen berichten in uw persoonlijke account als gelezen markeren?",
"delete_confirm_title": "Map verwijderen",
"delete_confirm_message": "Map \"{name}\" definitief verwijderen? Deze actie kan niet ongedaan worden gemaakt.",
"prompt_new_subfolder": "Voer een naam in voor de nieuwe submap.",
"prompt_new_folder": "Voer een naam in voor de nieuwe map.",
"prompt_rename": "Voer een nieuwe naam in voor deze map.",
"toast_marked_read": "Map gemarkeerd als gelezen",
"toast_marked_read_count": "{count, plural, one {1 bericht} other {# berichten}} gemarkeerd als gelezen",
"toast_already_read": "Geen ongelezen berichten",
"toast_marked_all_read": "Alle mappen gemarkeerd als gelezen",
"toast_emptied": "Map leeggemaakt",
"toast_folder_created": "Map aangemaakt",
"toast_folder_renamed": "Map hernoemd",
"toast_folder_deleted": "Map verwijderd",
"toast_error_mark_read": "Markeren als gelezen mislukt",
"toast_error_empty": "Map leegmaken mislukt",
"toast_error_create": "Map aanmaken mislukt",
"toast_error_rename": "Map hernoemen mislukt",
"toast_error_delete": "Map verwijderen mislukt",
"toast_error_delete_has_children": "Map bevat submappen. Verwijder deze eerst.",
"toast_error_delete_has_email": "Map is niet leeg. Maak deze eerst leeg.",
"placeholder_folder_name": "Mapnaam",
"create": "Aanmaken",
"rename_confirm": "Hernoemen"
},
"shortcuts": {
"title": "Sneltoetsen",
+85 -37
View File
@@ -184,6 +184,7 @@
"batch_actions": {
"select": "Zaznacz wiadomości",
"select_all": "Zaznacz wszystkie",
"selected_messages": "Wybrano {count, plural, one {1 wiadomość} other {# wiadomości}} e-mail",
"mark_read": "Oznacz jako przeczytane",
"mark_unread": "Oznacz jako nieprzeczytane",
"delete": "Usuń",
@@ -223,6 +224,11 @@
"unstar": "Usuń gwiazdkę",
"mark_unread": "Oznacz jako nieprzeczytane",
"mark_read": "Oznacz jako przeczytane",
"unread": "Nieprzeczytane",
"read": "Przeczytane",
"spam_short": "Spam",
"not_spam_short": "Nie spam",
"move": "Przenieś",
"print": "Drukuj",
"view_source": "Pokaż źródło",
"export_email": "Eksportuj jako .eml",
@@ -306,7 +312,20 @@
"fail": "DMARC niezaliczony",
"none": "Brak DMARC"
},
"spam_score": "Ocena spamu"
"spam_score": "Ocena spamu",
"tooltip_spf": "Sender Policy Framework: sprawdza, czy serwer wysyłający jest uprawniony do wysyłania wiadomości w imieniu domeny",
"tooltip_dkim": "DomainKeys Identified Mail: potwierdza za pomocą podpisu kryptograficznego, że wiadomość nie została zmieniona w trakcie przesyłania",
"tooltip_dmarc": "Domain-based Message Authentication, Reporting & Conformance: zapewnia zgodność SPF i DKIM z domeną nadawcy i określa politykę dla niepowodzeń",
"policy": "Polityka",
"result": {
"pass": "Zaliczony",
"fail": "Niezaliczony",
"softfail": "Łagodny błąd",
"neutral": "Neutralny",
"permerror": "Błąd trwały",
"temperror": "Błąd tymczasowy",
"none": "Brak"
}
},
"headers": {
"routing": "Routing",
@@ -431,7 +450,32 @@
"expand": "Pokaż szczegóły"
},
"send": "Wyślij",
"more": "więcej"
"more": "więcej",
"details": {
"recipients_routing": "Odbiorcy i trasowanie",
"authentication_security": "Uwierzytelnianie i bezpieczeństwo",
"identifiers_threading": "Identyfikatory i wątkowanie",
"mailing_list": "Lista mailingowa",
"message_properties": "Właściwości wiadomości",
"sent": "Wysłano",
"received": "Odebrano",
"delivery_time": "Czas dostarczenia",
"in_reply_to": "W odpowiedzi na",
"references": "Odniesienia",
"thread_id": "ID wątku",
"size": "Rozmiar",
"mime_type": "Typ MIME",
"attachments_summary": "{count} plików · {size}",
"list_id": "ID listy",
"list_help": "Pomoc listy",
"list_post": "Wysyłanie do listy",
"list_unsubscribe": "Wypisz się",
"iprev": "Odwrotny DNS",
"spam_status": "Status spamu",
"ai_verdict": "Werdykt AI",
"account": "Konto",
"no_subject": "(brak tematu)"
}
},
"email_composer": {
"new_message": "Nowa wiadomość",
@@ -1009,6 +1053,10 @@
"hide_inline_image_attachments": {
"label": "Ukryj obrazy osadzone z listy załączników",
"description": "Obrazy osadzone w treści wiadomości nie są wyświetlane jako osobne załączniki"
},
"attachment_image_previews": {
"label": "Pokaż podgląd obrazów w załącznikach",
"description": "Wyświetlaj załączone obrazy jako miniatury zamiast ogólnych ikon plików"
}
},
"composer": {
@@ -1622,41 +1670,41 @@
"edit_draft": "Edytuj szkic"
},
"mailbox_context_menu": {
"mark_folder_read": "Mark folder as read",
"mark_folder_tree_read": "Mark folder & subfolders as read",
"mark_all_folders_read": "Mark all folders as read",
"new_subfolder": "New subfolder...",
"new_folder": "New folder...",
"rename": "Rename...",
"empty_folder": "Empty folder",
"empty_folder_generic": "Empty folder",
"delete_folder": "Delete folder",
"refresh": "Refresh",
"mark_all_confirm_title": "Mark all folders as read",
"mark_all_confirm_message": "Mark every unread message in your personal account as read?",
"delete_confirm_title": "Delete folder",
"delete_confirm_message": "Permanently delete the folder \"{name}\"? This action cannot be undone.",
"prompt_new_subfolder": "Enter a name for the new subfolder.",
"prompt_new_folder": "Enter a name for the new folder.",
"prompt_rename": "Enter a new name for this folder.",
"toast_marked_read": "Folder marked as read",
"toast_marked_read_count": "Marked {count, plural, one {1 message} other {# messages}} as read",
"toast_already_read": "No unread messages",
"toast_marked_all_read": "All folders marked as read",
"toast_emptied": "Folder emptied",
"toast_folder_created": "Folder created",
"toast_folder_renamed": "Folder renamed",
"toast_folder_deleted": "Folder deleted",
"toast_error_mark_read": "Failed to mark as read",
"toast_error_empty": "Failed to empty folder",
"toast_error_create": "Failed to create folder",
"toast_error_rename": "Failed to rename folder",
"toast_error_delete": "Failed to delete folder",
"toast_error_delete_has_children": "Folder has subfolders. Remove them first.",
"toast_error_delete_has_email": "Folder is not empty. Empty it first.",
"placeholder_folder_name": "Folder name",
"create": "Create",
"rename_confirm": "Rename"
"mark_folder_read": "Oznacz folder jako przeczytany",
"mark_folder_tree_read": "Oznacz folder i podfoldery jako przeczytane",
"mark_all_folders_read": "Oznacz wszystkie foldery jako przeczytane",
"new_subfolder": "Nowy podfolder...",
"new_folder": "Nowy folder...",
"rename": "Zmień nazwę...",
"empty_folder": "Opróżnij folder",
"empty_folder_generic": "Opróżnij folder",
"delete_folder": "Usuń folder",
"refresh": "Odśwież",
"mark_all_confirm_title": "Oznacz wszystkie foldery jako przeczytane",
"mark_all_confirm_message": "Oznaczyć wszystkie nieprzeczytane wiadomości na koncie osobistym jako przeczytane?",
"delete_confirm_title": "Usuń folder",
"delete_confirm_message": "Trwale usunąć folder \"{name}\"? Tej operacji nie można cofnąć.",
"prompt_new_subfolder": "Podaj nazwę nowego podfolderu.",
"prompt_new_folder": "Podaj nazwę nowego folderu.",
"prompt_rename": "Podaj nową nazwę tego folderu.",
"toast_marked_read": "Folder oznaczony jako przeczytany",
"toast_marked_read_count": "Oznaczono {count, plural, one {1 wiadomość} other {# wiadomości}} jako przeczytane",
"toast_already_read": "Brak nieprzeczytanych wiadomości",
"toast_marked_all_read": "Wszystkie foldery oznaczone jako przeczytane",
"toast_emptied": "Folder opróżniony",
"toast_folder_created": "Folder utworzony",
"toast_folder_renamed": "Nazwa folderu zmieniona",
"toast_folder_deleted": "Folder usunięty",
"toast_error_mark_read": "Nie udało się oznaczyć jako przeczytane",
"toast_error_empty": "Nie udało się opróżnić folderu",
"toast_error_create": "Nie udało się utworzyć folderu",
"toast_error_rename": "Nie udało się zmienić nazwy folderu",
"toast_error_delete": "Nie udało się usunąć folderu",
"toast_error_delete_has_children": "Folder zawiera podfoldery. Najpierw je usuń.",
"toast_error_delete_has_email": "Folder nie jest pusty. Najpierw go opróżnij.",
"placeholder_folder_name": "Nazwa folderu",
"create": "Utwórz",
"rename_confirm": "Zmień nazwę"
},
"shortcuts": {
"title": "Skróty klawiszowe",
+85 -37
View File
@@ -184,6 +184,7 @@
"batch_actions": {
"select": "Selecionar e-mails",
"select_all": "Selecionar tudo",
"selected_messages": "{count, plural, one {1 e-mail selecionado} other {# e-mails selecionados}}",
"mark_read": "Marcar como lido",
"mark_unread": "Marcar como não lido",
"delete": "Excluir",
@@ -223,6 +224,11 @@
"unstar": "Remover Estrela",
"mark_unread": "Marcar como não lido",
"mark_read": "Marcar como lido",
"unread": "Não lido",
"read": "Lido",
"spam_short": "Spam",
"not_spam_short": "Não spam",
"move": "Mover",
"print": "Imprimir",
"view_source": "Ver código-fonte",
"export_email": "Exportar como .eml",
@@ -304,7 +310,20 @@
"fail": "DMARC Falhou",
"none": "Sem DMARC"
},
"spam_score": "Pontuação de Spam"
"spam_score": "Pontuação de Spam",
"tooltip_spf": "Sender Policy Framework: verifica se o servidor de envio está autorizado a enviar e-mails em nome do domínio",
"tooltip_dkim": "DomainKeys Identified Mail: confirma, por meio de assinatura criptográfica, que o e-mail não foi alterado durante o transporte",
"tooltip_dmarc": "Domain-based Message Authentication, Reporting & Conformance: garante que SPF e DKIM estejam alinhados com o domínio do remetente e define uma política para falhas",
"policy": "Política",
"result": {
"pass": "Aprovado",
"fail": "Falhou",
"softfail": "Falha parcial",
"neutral": "Neutro",
"permerror": "Erro permanente",
"temperror": "Erro temporário",
"none": "Nenhum"
}
},
"headers": {
"routing": "Roteamento",
@@ -431,7 +450,32 @@
"previous": "Anterior",
"next": "Próximo",
"send": "Enviar",
"more": "mais"
"more": "mais",
"details": {
"recipients_routing": "Destinatários e roteamento",
"authentication_security": "Autenticação e segurança",
"identifiers_threading": "Identificadores e conversação",
"mailing_list": "Lista de discussão",
"message_properties": "Propriedades da mensagem",
"sent": "Enviado",
"received": "Recebido",
"delivery_time": "Tempo de entrega",
"in_reply_to": "Em resposta a",
"references": "Referências",
"thread_id": "ID da conversa",
"size": "Tamanho",
"mime_type": "Tipo MIME",
"attachments_summary": "{count} arquivos · {size}",
"list_id": "ID da lista",
"list_help": "Ajuda da lista",
"list_post": "Postagem na lista",
"list_unsubscribe": "Cancelar inscrição",
"iprev": "DNS reverso",
"spam_status": "Status de spam",
"ai_verdict": "Veredito da IA",
"account": "Conta",
"no_subject": "(sem assunto)"
}
},
"email_composer": {
"new_message": "Nova Mensagem",
@@ -1009,6 +1053,10 @@
"hide_inline_image_attachments": {
"label": "Ocultar imagens incorporadas dos anexos",
"description": "As imagens incorporadas no corpo da mensagem não são listadas como anexos separados"
},
"attachment_image_previews": {
"label": "Mostrar prévias de imagens em anexos",
"description": "Renderizar anexos de imagem como miniaturas em vez de ícones genéricos de arquivo"
}
},
"composer": {
@@ -1622,41 +1670,41 @@
"edit_draft": "Editar rascunho"
},
"mailbox_context_menu": {
"mark_folder_read": "Mark folder as read",
"mark_folder_tree_read": "Mark folder & subfolders as read",
"mark_all_folders_read": "Mark all folders as read",
"new_subfolder": "New subfolder...",
"new_folder": "New folder...",
"rename": "Rename...",
"empty_folder": "Empty folder",
"empty_folder_generic": "Empty folder",
"delete_folder": "Delete folder",
"refresh": "Refresh",
"mark_all_confirm_title": "Mark all folders as read",
"mark_all_confirm_message": "Mark every unread message in your personal account as read?",
"delete_confirm_title": "Delete folder",
"delete_confirm_message": "Permanently delete the folder \"{name}\"? This action cannot be undone.",
"prompt_new_subfolder": "Enter a name for the new subfolder.",
"prompt_new_folder": "Enter a name for the new folder.",
"prompt_rename": "Enter a new name for this folder.",
"toast_marked_read": "Folder marked as read",
"toast_marked_read_count": "Marked {count, plural, one {1 message} other {# messages}} as read",
"toast_already_read": "No unread messages",
"toast_marked_all_read": "All folders marked as read",
"toast_emptied": "Folder emptied",
"toast_folder_created": "Folder created",
"toast_folder_renamed": "Folder renamed",
"toast_folder_deleted": "Folder deleted",
"toast_error_mark_read": "Failed to mark as read",
"toast_error_empty": "Failed to empty folder",
"toast_error_create": "Failed to create folder",
"toast_error_rename": "Failed to rename folder",
"toast_error_delete": "Failed to delete folder",
"toast_error_delete_has_children": "Folder has subfolders. Remove them first.",
"toast_error_delete_has_email": "Folder is not empty. Empty it first.",
"placeholder_folder_name": "Folder name",
"create": "Create",
"rename_confirm": "Rename"
"mark_folder_read": "Marcar pasta como lida",
"mark_folder_tree_read": "Marcar pasta e subpastas como lidas",
"mark_all_folders_read": "Marcar todas as pastas como lidas",
"new_subfolder": "Nova subpasta...",
"new_folder": "Nova pasta...",
"rename": "Renomear...",
"empty_folder": "Esvaziar pasta",
"empty_folder_generic": "Esvaziar pasta",
"delete_folder": "Excluir pasta",
"refresh": "Atualizar",
"mark_all_confirm_title": "Marcar todas as pastas como lidas",
"mark_all_confirm_message": "Marcar todas as mensagens não lidas da sua conta pessoal como lidas?",
"delete_confirm_title": "Excluir pasta",
"delete_confirm_message": "Excluir permanentemente a pasta \"{name}\"? Esta ação não pode ser desfeita.",
"prompt_new_subfolder": "Digite um nome para a nova subpasta.",
"prompt_new_folder": "Digite um nome para a nova pasta.",
"prompt_rename": "Digite um novo nome para esta pasta.",
"toast_marked_read": "Pasta marcada como lida",
"toast_marked_read_count": "{count, plural, one {1 mensagem marcada como lida} other {# mensagens marcadas como lidas}}",
"toast_already_read": "Nenhuma mensagem não lida",
"toast_marked_all_read": "Todas as pastas marcadas como lidas",
"toast_emptied": "Pasta esvaziada",
"toast_folder_created": "Pasta criada",
"toast_folder_renamed": "Pasta renomeada",
"toast_folder_deleted": "Pasta excluída",
"toast_error_mark_read": "Falha ao marcar como lida",
"toast_error_empty": "Falha ao esvaziar a pasta",
"toast_error_create": "Falha ao criar a pasta",
"toast_error_rename": "Falha ao renomear a pasta",
"toast_error_delete": "Falha ao excluir a pasta",
"toast_error_delete_has_children": "A pasta contém subpastas. Remova-as primeiro.",
"toast_error_delete_has_email": "A pasta não está vazia. Esvazie-a primeiro.",
"placeholder_folder_name": "Nome da pasta",
"create": "Criar",
"rename_confirm": "Renomear"
},
"shortcuts": {
"title": "Atalhos de Teclado",
+85 -37
View File
@@ -184,6 +184,7 @@
"batch_actions": {
"select": "Выбрать письма",
"select_all": "Выбрать все",
"selected_messages": "Выбрано {count, plural, one {1 письмо} other {# письма}}",
"mark_read": "Отметить прочитанными",
"mark_unread": "Отметить непрочитанными",
"delete": "Удалить",
@@ -223,6 +224,11 @@
"unstar": "Снять пометку",
"mark_unread": "Отметить непрочитанным",
"mark_read": "Отметить прочитанным",
"unread": "Не прочитано",
"read": "Прочитано",
"spam_short": "Спам",
"not_spam_short": "Не спам",
"move": "Переместить",
"print": "Распечатать",
"view_source": "Просмотреть исходный код",
"export_email": "Экспортировать как .eml",
@@ -306,7 +312,20 @@
"fail": "DMARC не прошёл",
"none": "DMARC отсутствует"
},
"spam_score": "Оценка спама"
"spam_score": "Оценка спама",
"tooltip_spf": "Sender Policy Framework: проверяет, имеет ли отправляющий сервер право отправлять почту от имени домена",
"tooltip_dkim": "DomainKeys Identified Mail: подтверждает с помощью криптографической подписи, что письмо не было изменено при передаче",
"tooltip_dmarc": "Domain-based Message Authentication, Reporting & Conformance: обеспечивает согласованность SPF и DKIM с доменом отправителя и устанавливает политику для сбоев",
"policy": "Политика",
"result": {
"pass": "Пройдено",
"fail": "Не пройдено",
"softfail": "Частичный сбой",
"neutral": "Нейтрально",
"permerror": "Постоянная ошибка",
"temperror": "Временная ошибка",
"none": "Нет"
}
},
"headers": {
"routing": "Маршрутизация",
@@ -431,7 +450,32 @@
"expand": "Показать детали"
},
"send": "Отправить",
"more": "ещё"
"more": "ещё",
"details": {
"recipients_routing": "Получатели и маршрутизация",
"authentication_security": "Аутентификация и безопасность",
"identifiers_threading": "Идентификаторы и обсуждения",
"mailing_list": "Список рассылки",
"message_properties": "Свойства сообщения",
"sent": "Отправлено",
"received": "Получено",
"delivery_time": "Время доставки",
"in_reply_to": "В ответ на",
"references": "Ссылки",
"thread_id": "ID обсуждения",
"size": "Размер",
"mime_type": "MIME-тип",
"attachments_summary": "{count} файлов · {size}",
"list_id": "ID списка",
"list_help": "Справка списка",
"list_post": "Публикация в список",
"list_unsubscribe": "Отписаться",
"iprev": "Обратный DNS",
"spam_status": "Статус спама",
"ai_verdict": "Вердикт ИИ",
"account": "Аккаунт",
"no_subject": "(без темы)"
}
},
"email_composer": {
"new_message": "Новое письмо",
@@ -1009,6 +1053,10 @@
"hide_inline_image_attachments": {
"label": "Скрывать встроенные изображения из вложений",
"description": "Изображения, встроенные в тело сообщения, не отображаются как отдельные вложения"
},
"attachment_image_previews": {
"label": "Показывать миниатюры изображений во вложениях",
"description": "Отображать вложенные изображения в виде миниатюр вместо обычных значков файлов"
}
},
"composer": {
@@ -1622,41 +1670,41 @@
"edit_draft": "Редактировать черновик"
},
"mailbox_context_menu": {
"mark_folder_read": "Mark folder as read",
"mark_folder_tree_read": "Mark folder & subfolders as read",
"mark_all_folders_read": "Mark all folders as read",
"new_subfolder": "New subfolder...",
"new_folder": "New folder...",
"rename": "Rename...",
"empty_folder": "Empty folder",
"empty_folder_generic": "Empty folder",
"delete_folder": "Delete folder",
"refresh": "Refresh",
"mark_all_confirm_title": "Mark all folders as read",
"mark_all_confirm_message": "Mark every unread message in your personal account as read?",
"delete_confirm_title": "Delete folder",
"delete_confirm_message": "Permanently delete the folder \"{name}\"? This action cannot be undone.",
"prompt_new_subfolder": "Enter a name for the new subfolder.",
"prompt_new_folder": "Enter a name for the new folder.",
"prompt_rename": "Enter a new name for this folder.",
"toast_marked_read": "Folder marked as read",
"toast_marked_read_count": "Marked {count, plural, one {1 message} other {# messages}} as read",
"toast_already_read": "No unread messages",
"toast_marked_all_read": "All folders marked as read",
"toast_emptied": "Folder emptied",
"toast_folder_created": "Folder created",
"toast_folder_renamed": "Folder renamed",
"toast_folder_deleted": "Folder deleted",
"toast_error_mark_read": "Failed to mark as read",
"toast_error_empty": "Failed to empty folder",
"toast_error_create": "Failed to create folder",
"toast_error_rename": "Failed to rename folder",
"toast_error_delete": "Failed to delete folder",
"toast_error_delete_has_children": "Folder has subfolders. Remove them first.",
"toast_error_delete_has_email": "Folder is not empty. Empty it first.",
"placeholder_folder_name": "Folder name",
"create": "Create",
"rename_confirm": "Rename"
"mark_folder_read": "Отметить папку как прочитанную",
"mark_folder_tree_read": "Отметить папку и вложенные папки как прочитанные",
"mark_all_folders_read": "Отметить все папки как прочитанные",
"new_subfolder": "Новая вложенная папка...",
"new_folder": "Новая папка...",
"rename": "Переименовать...",
"empty_folder": "Очистить папку",
"empty_folder_generic": "Очистить папку",
"delete_folder": "Удалить папку",
"refresh": "Обновить",
"mark_all_confirm_title": "Отметить все папки как прочитанные",
"mark_all_confirm_message": "Отметить все непрочитанные сообщения в вашем личном аккаунте как прочитанные?",
"delete_confirm_title": "Удалить папку",
"delete_confirm_message": "Безвозвратно удалить папку \"{name}\"? Это действие нельзя отменить.",
"prompt_new_subfolder": "Введите имя для новой вложенной папки.",
"prompt_new_folder": "Введите имя для новой папки.",
"prompt_rename": "Введите новое имя для этой папки.",
"toast_marked_read": "Папка отмечена как прочитанная",
"toast_marked_read_count": "Отмечено {count, plural, one {1 сообщение} few {# сообщения} other {# сообщений}} как прочитанные",
"toast_already_read": "Нет непрочитанных сообщений",
"toast_marked_all_read": "Все папки отмечены как прочитанные",
"toast_emptied": "Папка очищена",
"toast_folder_created": "Папка создана",
"toast_folder_renamed": "Папка переименована",
"toast_folder_deleted": "Папка удалена",
"toast_error_mark_read": "Не удалось отметить как прочитанное",
"toast_error_empty": "Не удалось очистить папку",
"toast_error_create": "Не удалось создать папку",
"toast_error_rename": "Не удалось переименовать папку",
"toast_error_delete": "Не удалось удалить папку",
"toast_error_delete_has_children": "Папка содержит вложенные папки. Сначала удалите их.",
"toast_error_delete_has_email": "Папка не пуста. Сначала очистите её.",
"placeholder_folder_name": "Имя папки",
"create": "Создать",
"rename_confirm": "Переименовать"
},
"shortcuts": {
"title": "Сочетания клавиш",
+50 -2
View File
@@ -184,6 +184,7 @@
"batch_actions": {
"select": "E-postaları seç",
"select_all": "Tümünü seç",
"selected_messages": "{count, plural, one {1 e-posta} other {# e-posta}} seçildi",
"mark_read": "Okundu olarak işaretle",
"mark_unread": "Okunmadı olarak işaretle",
"delete": "Sil",
@@ -223,6 +224,11 @@
"unstar": "Yıldızı Kaldır",
"mark_unread": "Okunmadı olarak işaretle",
"mark_read": "Okundu olarak işaretle",
"unread": "Okunmadı",
"read": "Okundu",
"spam_short": "Spam",
"not_spam_short": "Spam değil",
"move": "Taşı",
"print": "Yazdır",
"view_source": "Kaynağı görüntüle",
"export_email": ".eml olarak dışa aktar",
@@ -306,7 +312,20 @@
"fail": "DMARC Başarısız",
"none": "DMARC Yok"
},
"spam_score": "Spam Puanı"
"spam_score": "Spam Puanı",
"tooltip_spf": "Sender Policy Framework: gönderen sunucunun, etki alanı adına e-posta gönderme yetkisine sahip olduğunu doğrular",
"tooltip_dkim": "DomainKeys Identified Mail: kriptografik bir imza kullanarak e-postanın iletim sırasında değiştirilmediğini doğrular",
"tooltip_dmarc": "Domain-based Message Authentication, Reporting & Conformance: SPF ve DKIM'in göndericinin etki alanıyla uyumlu olmasını sağlar ve hatalar için bir ilke belirler",
"policy": "İlke",
"result": {
"pass": "Başarılı",
"fail": "Başarısız",
"softfail": "Hafif başarısızlık",
"neutral": "Nötr",
"permerror": "Kalıcı hata",
"temperror": "Geçici hata",
"none": "Yok"
}
},
"headers": {
"routing": "Yönlendirme",
@@ -431,7 +450,32 @@
"collapse": "Ayrıntıları gizle"
},
"send": "Gönder",
"more": "daha fazla"
"more": "daha fazla",
"details": {
"recipients_routing": "Alıcılar ve yönlendirme",
"authentication_security": "Kimlik doğrulama ve güvenlik",
"identifiers_threading": "Tanımlayıcılar ve konu zinciri",
"mailing_list": "Posta listesi",
"message_properties": "İleti özellikleri",
"sent": "Gönderildi",
"received": "Alındı",
"delivery_time": "Teslim süresi",
"in_reply_to": "Yanıtlanan",
"references": "Referanslar",
"thread_id": "Konu zinciri kimliği",
"size": "Boyut",
"mime_type": "MIME türü",
"attachments_summary": "{count} dosya · {size}",
"list_id": "Liste kimliği",
"list_help": "Liste yardımı",
"list_post": "Liste gönderimi",
"list_unsubscribe": "Abonelikten çık",
"iprev": "Ters DNS",
"spam_status": "Spam durumu",
"ai_verdict": "Yapay zeka kararı",
"account": "Hesap",
"no_subject": "(konu yok)"
}
},
"email_composer": {
"new_message": "Yeni İleti",
@@ -1009,6 +1053,10 @@
"hide_inline_image_attachments": {
"label": "Satır içi görüntüleri eklerden gizle",
"description": "İleti gövdesine gömülü görseller ayrı ek olarak listelenmez"
},
"attachment_image_previews": {
"label": "Eklerde resim önizlemelerini göster",
"description": "Resim eklerini, genel dosya simgeleri yerine küçük resim kartları olarak göster"
}
},
"composer": {
+85 -37
View File
@@ -184,6 +184,7 @@
"batch_actions": {
"select": "Виберіть електронні листи",
"select_all": "Вибрати все",
"selected_messages": "Вибрано {count, plural, one {1 електронний лист} other {# електронні листи}}",
"mark_read": "Позначити як прочитане",
"mark_unread": "Позначити як непрочитане",
"delete": "Видалити",
@@ -223,6 +224,11 @@
"unstar": "Зняти зірочку",
"mark_unread": "Позначити як непрочитане",
"mark_read": "Позначити як прочитане",
"unread": "Непрочитане",
"read": "Прочитане",
"spam_short": "Спам",
"not_spam_short": "Не спам",
"move": "Перенести",
"print": "Роздрукувати",
"view_source": "Переглянути джерело",
"export_email": "Експортувати як .eml",
@@ -306,7 +312,20 @@
"fail": "Помилка DMARC",
"none": "Без DMARC"
},
"spam_score": "Оцінка спаму"
"spam_score": "Оцінка спаму",
"tooltip_spf": "Sender Policy Framework: перевіряє, чи має сервер-відправник право надсилати пошту від імені домену",
"tooltip_dkim": "DomainKeys Identified Mail: підтверджує криптографічним підписом, що лист не було змінено під час пересилання",
"tooltip_dmarc": "Domain-based Message Authentication, Reporting & Conformance: забезпечує узгодженість SPF і DKIM із доменом відправника та встановлює політику для збоїв",
"policy": "Політика",
"result": {
"pass": "Пройдено",
"fail": "Не пройдено",
"softfail": "М'який збій",
"neutral": "Нейтрально",
"permerror": "Постійна помилка",
"temperror": "Тимчасова помилка",
"none": "Немає"
}
},
"headers": {
"routing": "Маршрутизація",
@@ -431,7 +450,32 @@
"expand": "Показати деталі"
},
"send": "Надіслати",
"more": "більше"
"more": "більше",
"details": {
"recipients_routing": "Одержувачі та маршрутизація",
"authentication_security": "Аутентифікація та безпека",
"identifiers_threading": "Ідентифікатори та обговорення",
"mailing_list": "Список розсилки",
"message_properties": "Властивості повідомлення",
"sent": "Надіслано",
"received": "Отримано",
"delivery_time": "Час доставки",
"in_reply_to": "У відповідь на",
"references": "Посилання",
"thread_id": "ID обговорення",
"size": "Розмір",
"mime_type": "Тип MIME",
"attachments_summary": "{count} файлів · {size}",
"list_id": "ID списку",
"list_help": "Довідка списку",
"list_post": "Публікація у список",
"list_unsubscribe": "Відписатися",
"iprev": "Зворотний DNS",
"spam_status": "Статус спаму",
"ai_verdict": "Висновок ШІ",
"account": "Обліковий запис",
"no_subject": "(без теми)"
}
},
"email_composer": {
"new_message": "Нове повідомлення",
@@ -1009,6 +1053,10 @@
"hide_inline_image_attachments": {
"label": "Приховувати вбудовані зображення з вкладень",
"description": "Зображення, вбудовані в тіло повідомлення, не відображаються як окремі вкладення"
},
"attachment_image_previews": {
"label": "Показувати попередній перегляд зображень у вкладеннях",
"description": "Відображати вкладені зображення як ескізи замість загальних піктограм файлів"
}
},
"composer": {
@@ -1622,41 +1670,41 @@
"edit_draft": "Редагувати чернетку"
},
"mailbox_context_menu": {
"mark_folder_read": "Mark folder as read",
"mark_folder_tree_read": "Mark folder & subfolders as read",
"mark_all_folders_read": "Mark all folders as read",
"new_subfolder": "New subfolder...",
"new_folder": "New folder...",
"rename": "Rename...",
"empty_folder": "Empty folder",
"empty_folder_generic": "Empty folder",
"delete_folder": "Delete folder",
"refresh": "Refresh",
"mark_all_confirm_title": "Mark all folders as read",
"mark_all_confirm_message": "Mark every unread message in your personal account as read?",
"delete_confirm_title": "Delete folder",
"delete_confirm_message": "Permanently delete the folder \"{name}\"? This action cannot be undone.",
"prompt_new_subfolder": "Enter a name for the new subfolder.",
"prompt_new_folder": "Enter a name for the new folder.",
"prompt_rename": "Enter a new name for this folder.",
"toast_marked_read": "Folder marked as read",
"toast_marked_read_count": "Marked {count, plural, one {1 message} other {# messages}} as read",
"toast_already_read": "No unread messages",
"toast_marked_all_read": "All folders marked as read",
"toast_emptied": "Folder emptied",
"toast_folder_created": "Folder created",
"toast_folder_renamed": "Folder renamed",
"toast_folder_deleted": "Folder deleted",
"toast_error_mark_read": "Failed to mark as read",
"toast_error_empty": "Failed to empty folder",
"toast_error_create": "Failed to create folder",
"toast_error_rename": "Failed to rename folder",
"toast_error_delete": "Failed to delete folder",
"toast_error_delete_has_children": "Folder has subfolders. Remove them first.",
"toast_error_delete_has_email": "Folder is not empty. Empty it first.",
"placeholder_folder_name": "Folder name",
"create": "Create",
"rename_confirm": "Rename"
"mark_folder_read": "Позначити папку як прочитану",
"mark_folder_tree_read": "Позначити папку та вкладені папки як прочитані",
"mark_all_folders_read": "Позначити всі папки як прочитані",
"new_subfolder": "Нова вкладена папка...",
"new_folder": "Нова папка...",
"rename": "Перейменувати...",
"empty_folder": "Очистити папку",
"empty_folder_generic": "Очистити папку",
"delete_folder": "Видалити папку",
"refresh": "Оновити",
"mark_all_confirm_title": "Позначити всі папки як прочитані",
"mark_all_confirm_message": "Позначити всі непрочитані повідомлення у вашому особистому акаунті як прочитані?",
"delete_confirm_title": "Видалити папку",
"delete_confirm_message": "Остаточно видалити папку \"{name}\"? Цю дію неможливо скасувати.",
"prompt_new_subfolder": "Введіть ім'я для нової вкладеної папки.",
"prompt_new_folder": "Введіть ім'я для нової папки.",
"prompt_rename": "Введіть нове ім'я для цієї папки.",
"toast_marked_read": "Папку позначено як прочитану",
"toast_marked_read_count": "Позначено {count, plural, one {1 повідомлення} few {# повідомлення} other {# повідомлень}} як прочитані",
"toast_already_read": "Немає непрочитаних повідомлень",
"toast_marked_all_read": "Всі папки позначені як прочитані",
"toast_emptied": "Папку очищено",
"toast_folder_created": "Папку створено",
"toast_folder_renamed": "Папку перейменовано",
"toast_folder_deleted": "Папку видалено",
"toast_error_mark_read": "Не вдалося позначити як прочитане",
"toast_error_empty": "Не вдалося очистити папку",
"toast_error_create": "Не вдалося створити папку",
"toast_error_rename": "Не вдалося перейменувати папку",
"toast_error_delete": "Не вдалося видалити папку",
"toast_error_delete_has_children": "Папка містить вкладені папки. Спочатку видаліть їх.",
"toast_error_delete_has_email": "Папка не порожня. Спочатку очистіть її.",
"placeholder_folder_name": "Ім'я папки",
"create": "Створити",
"rename_confirm": "Перейменувати"
},
"shortcuts": {
"title": "Комбінації клавіш",
+85 -37
View File
@@ -184,6 +184,7 @@
"batch_actions": {
"select": "选择邮件",
"select_all": "全选",
"selected_messages": "已選取 {count, plural, one {1} other {#}} 封電子郵件",
"mark_read": "标记为已读",
"mark_unread": "标记为未读",
"delete": "删除",
@@ -223,6 +224,11 @@
"unstar": "取消星标",
"mark_unread": "标记为未读",
"mark_read": "标记为已读",
"unread": "未读",
"read": "已读",
"spam_short": "垃圾邮件",
"not_spam_short": "非垃圾邮件",
"move": "移动",
"print": "打印",
"view_source": "查看源码",
"export_email": "导出为 .eml",
@@ -306,7 +312,20 @@
"fail": "DMARC 失败",
"none": "无 DMARC"
},
"spam_score": "垃圾邮件评分"
"spam_score": "垃圾邮件评分",
"tooltip_spf": "Sender Policy Framework:验证发送服务器是否有权代表该域名发送邮件",
"tooltip_dkim": "DomainKeys Identified Mail:通过加密签名确认邮件在传输过程中未被篡改",
"tooltip_dmarc": "Domain-based Message Authentication, Reporting & Conformance:确保 SPF 与 DKIM 与发件人域名一致,并为失败设置处理策略",
"policy": "策略",
"result": {
"pass": "通过",
"fail": "失败",
"softfail": "软失败",
"neutral": "中立",
"permerror": "永久错误",
"temperror": "临时错误",
"none": "无"
}
},
"headers": {
"routing": "路由",
@@ -431,7 +450,32 @@
"expand": "显示详情"
},
"send": "发送",
"more": "更多"
"more": "更多",
"details": {
"recipients_routing": "收件人与路由",
"authentication_security": "身份验证与安全",
"identifiers_threading": "标识符与会话",
"mailing_list": "邮件列表",
"message_properties": "邮件属性",
"sent": "发送时间",
"received": "接收时间",
"delivery_time": "送达耗时",
"in_reply_to": "回复对象",
"references": "引用",
"thread_id": "会话 ID",
"size": "大小",
"mime_type": "MIME 类型",
"attachments_summary": "{count} 个文件 · {size}",
"list_id": "列表 ID",
"list_help": "列表帮助",
"list_post": "列表投递",
"list_unsubscribe": "取消订阅",
"iprev": "反向 DNS",
"spam_status": "垃圾邮件状态",
"ai_verdict": "AI 判断",
"account": "账户",
"no_subject": "(无主题)"
}
},
"email_composer": {
"new_message": "新邮件",
@@ -1009,6 +1053,10 @@
"hide_inline_image_attachments": {
"label": "在附件列表中隐藏内嵌图片",
"description": "嵌入到邮件正文中的图片不会作为单独的附件显示"
},
"attachment_image_previews": {
"label": "在附件中显示图片预览",
"description": "将图片附件显示为缩略图卡片,而不是通用文件图标"
}
},
"composer": {
@@ -1622,41 +1670,41 @@
"edit_draft": "编辑草稿"
},
"mailbox_context_menu": {
"mark_folder_read": "Mark folder as read",
"mark_folder_tree_read": "Mark folder & subfolders as read",
"mark_all_folders_read": "Mark all folders as read",
"new_subfolder": "New subfolder...",
"new_folder": "New folder...",
"rename": "Rename...",
"empty_folder": "Empty folder",
"empty_folder_generic": "Empty folder",
"delete_folder": "Delete folder",
"refresh": "Refresh",
"mark_all_confirm_title": "Mark all folders as read",
"mark_all_confirm_message": "Mark every unread message in your personal account as read?",
"delete_confirm_title": "Delete folder",
"delete_confirm_message": "Permanently delete the folder \"{name}\"? This action cannot be undone.",
"prompt_new_subfolder": "Enter a name for the new subfolder.",
"prompt_new_folder": "Enter a name for the new folder.",
"prompt_rename": "Enter a new name for this folder.",
"toast_marked_read": "Folder marked as read",
"toast_marked_read_count": "Marked {count, plural, one {1 message} other {# messages}} as read",
"toast_already_read": "No unread messages",
"toast_marked_all_read": "All folders marked as read",
"toast_emptied": "Folder emptied",
"toast_folder_created": "Folder created",
"toast_folder_renamed": "Folder renamed",
"toast_folder_deleted": "Folder deleted",
"toast_error_mark_read": "Failed to mark as read",
"toast_error_empty": "Failed to empty folder",
"toast_error_create": "Failed to create folder",
"toast_error_rename": "Failed to rename folder",
"toast_error_delete": "Failed to delete folder",
"toast_error_delete_has_children": "Folder has subfolders. Remove them first.",
"toast_error_delete_has_email": "Folder is not empty. Empty it first.",
"placeholder_folder_name": "Folder name",
"create": "Create",
"rename_confirm": "Rename"
"mark_folder_read": "将文件夹标记为已读",
"mark_folder_tree_read": "将文件夹及子文件夹标记为已读",
"mark_all_folders_read": "将所有文件夹标记为已读",
"new_subfolder": "新建子文件夹...",
"new_folder": "新建文件夹...",
"rename": "重命名...",
"empty_folder": "清空文件夹",
"empty_folder_generic": "清空文件夹",
"delete_folder": "删除文件夹",
"refresh": "刷新",
"mark_all_confirm_title": "将所有文件夹标记为已读",
"mark_all_confirm_message": "将您个人账户中的所有未读邮件标记为已读?",
"delete_confirm_title": "删除文件夹",
"delete_confirm_message": "永久删除文件夹 \"{name}\"?此操作无法撤销。",
"prompt_new_subfolder": "请输入新子文件夹的名称。",
"prompt_new_folder": "请输入新文件夹的名称。",
"prompt_rename": "请输入此文件夹的新名称。",
"toast_marked_read": "文件夹已标记为已读",
"toast_marked_read_count": "已将 {count, plural, one {1 封邮件} other {# 封邮件}} 标记为已读",
"toast_already_read": "没有未读邮件",
"toast_marked_all_read": "所有文件夹已标记为已读",
"toast_emptied": "文件夹已清空",
"toast_folder_created": "文件夹已创建",
"toast_folder_renamed": "文件夹已重命名",
"toast_folder_deleted": "文件夹已删除",
"toast_error_mark_read": "标记为已读失败",
"toast_error_empty": "清空文件夹失败",
"toast_error_create": "创建文件夹失败",
"toast_error_rename": "重命名文件夹失败",
"toast_error_delete": "删除文件夹失败",
"toast_error_delete_has_children": "文件夹包含子文件夹,请先将其删除。",
"toast_error_delete_has_email": "文件夹不为空,请先清空它。",
"placeholder_folder_name": "文件夹名称",
"create": "创建",
"rename_confirm": "重命名"
},
"shortcuts": {
"title": "键盘快捷键",
+27 -5
View File
@@ -4,11 +4,20 @@ import { execSync } from "child_process";
import { readFileSync } from "fs";
import { join } from "path";
let gitCommitHash = "unknown";
try {
gitCommitHash = execSync("git rev-parse --short HEAD").toString().trim();
} catch {
// git not available
// Prefer an explicit build arg (passed in by CI / Docker, where .git is
// excluded from the build context) and fall back to `git rev-parse` for
// local builds.
let gitCommitHash = process.env.GIT_COMMIT?.trim() || "";
if (!gitCommitHash) {
try {
gitCommitHash = execSync("git rev-parse --short HEAD").toString().trim();
} catch {
gitCommitHash = "unknown";
}
}
// Normalise full 40-char SHAs (e.g. ${{ github.sha }}) to the short form.
if (/^[0-9a-f]{40}$/i.test(gitCommitHash)) {
gitCommitHash = gitCommitHash.slice(0, 7);
}
let appVersion = "0.0.0";
@@ -18,15 +27,28 @@ try {
// VERSION file not found
}
// Subpath deployment, e.g. NEXT_PUBLIC_BASE_PATH=/webmail. Read at build time
// because Next.js bakes basePath into emitted asset URLs and route metadata.
// Trailing slash is stripped; an empty/missing value disables the feature.
const rawBasePath = process.env.NEXT_PUBLIC_BASE_PATH?.trim() ?? "";
const basePath = rawBasePath.replace(/\/+$/, "");
if (basePath && !basePath.startsWith("/")) {
throw new Error(
`NEXT_PUBLIC_BASE_PATH must start with "/" (got: ${JSON.stringify(rawBasePath)})`
);
}
const nextConfig: NextConfig = {
output: "standalone",
allowedDevOrigins: ["192.168.1.51"],
basePath: basePath || undefined,
turbopack: {
root: import.meta.dirname,
},
env: {
NEXT_PUBLIC_GIT_COMMIT: gitCommitHash,
NEXT_PUBLIC_APP_VERSION: appVersion,
NEXT_PUBLIC_BASE_PATH: basePath,
},
};
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "bulwark-webmail",
"version": "1.5.4",
"version": "1.6.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bulwark-webmail",
"version": "1.5.4",
"version": "1.6.1",
"license": "AGPL-3.0-only",
"dependencies": {
"@tanstack/react-virtual": "^3.13.24",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "bulwark-webmail",
"version": "1.5.4",
"version": "1.6.1",
"description": "Bulwark Webmail - a modern webmail client built for Stalwart Mail Server",
"author": "Bulwark Webmail <bulwark@rbm.systems>",
"license": "AGPL-3.0-only",
+31 -7
View File
@@ -10,6 +10,20 @@
// task: relay sends only a state-change ping, the client fetches the
// newest unread email itself so the relay never sees mail content.
// When the app is mounted at a subpath (Next.js basePath, e.g. /webmail), the
// SW is served at /webmail/sw.js and registered with scope /webmail/. Derive
// the prefix from the SW's own URL so push fetches and notification clicks
// land on the right path - service workers can't read process.env.
function getBasePath() {
const path = new URL(self.location.href).pathname;
// self.location is .../sw.js; strip the trailing filename to get the dir,
// then strip the trailing slash so it concatenates cleanly with `/foo`.
const dir = path.replace(/[^/]*$/, "");
return dir.replace(/\/+$/, "");
}
const BASE_PATH = getBasePath();
self.addEventListener("install", () => {
self.skipWaiting();
});
@@ -48,7 +62,7 @@ async function handlePush(event) {
let preview = null;
let previewOk = false;
try {
const res = await fetch("/api/push/preview", {
const res = await fetch(`${BASE_PATH}/api/push/preview`, {
credentials: "include",
cache: "no-store",
});
@@ -99,8 +113,8 @@ async function handlePush(event) {
await self.registration.showNotification(title, {
body,
tag,
icon: "/icon-192x192.png",
badge: "/icon-192x192.png",
icon: `${BASE_PATH}/icon-192x192.png`,
badge: `${BASE_PATH}/icon-192x192.png`,
data,
renotify: true,
});
@@ -108,6 +122,7 @@ async function handlePush(event) {
async function handleNotificationClick(event) {
const data = event.notification.data || {};
const tag = event.notification.tag || "";
const targetUrl = buildClickUrl(data);
const allClients = await self.clients.matchAll({
@@ -115,6 +130,15 @@ async function handleNotificationClick(event) {
includeUncontrolled: true,
});
// Notify any in-app clients so plugins listening on toastHooks.onNotificationClick fire.
for (const client of allClients) {
try {
client.postMessage({ kind: "notificationclick", tag, data });
} catch (_) {
// Closed or detached client - ignore.
}
}
for (const client of allClients) {
// Reuse an existing tab whenever possible - users on desktop browsers
// get annoyed when each notification opens a fresh window.
@@ -132,17 +156,17 @@ async function handleNotificationClick(event) {
}
if (self.clients.openWindow) {
return self.clients.openWindow(targetUrl || "/");
return self.clients.openWindow(targetUrl || `${BASE_PATH}/`);
}
}
function buildClickUrl(data) {
if (!data) return "/";
if (!data) return `${BASE_PATH}/`;
if (data.kind === "email" && data.emailId) {
return `/?email=${encodeURIComponent(data.emailId)}`;
return `${BASE_PATH}/?email=${encodeURIComponent(data.emailId)}`;
}
// Generic "New mail" toast (preview API failed or returned no email): land
// the user on the latest unread message in their Inbox rather than just the
// app shell, so the click still feels purposeful.
return "/?openLatestUnread=1";
return `${BASE_PATH}/?openLatestUnread=1`;
}
+34 -11
View File
@@ -601,12 +601,21 @@ export const useAuthStore = create<AuthState>()(
set({ isLoading: true, error: null, isRateLimited: false, rateLimitUntil: null });
try {
// Determine slot for this account (use slot from sessionStorage if re-adding)
// Determine slot for this account (use slot from sessionStorage if re-adding).
// Note: `parseInt(getItem(...) || '0')` collapses "no value set" and
// "value is 0" into the same case, so the fallback to getNextCookieSlot()
// never fired for the common "+ Add Account" path — every OAuth account
// ended up on slot 0 and overwrote earlier accounts' refresh-token cookies.
// Distinguishing rawSlot === null from a parsed 0 fixes that. The page
// also writes oauth_cookie_slot before redirecting to the IdP.
const accountStore = useAccountStore.getState();
const pendingSlot = typeof window !== 'undefined'
? parseInt(sessionStorage.getItem('oauth_cookie_slot') || '0', 10)
: 0;
const slot = pendingSlot >= 0 && pendingSlot <= 4 ? pendingSlot : accountStore.getNextCookieSlot();
const rawSlot = typeof window !== 'undefined'
? sessionStorage.getItem('oauth_cookie_slot')
: null;
const pendingSlot = rawSlot !== null ? parseInt(rawSlot, 10) : NaN;
const slot = !isNaN(pendingSlot) && pendingSlot >= 0 && pendingSlot <= 4
? pendingSlot
: accountStore.getNextCookieSlot();
const tokenRes = await apiFetch(`/api/auth/token?slot=${slot}`, {
method: 'POST',
@@ -659,6 +668,12 @@ export const useAuthStore = create<AuthState>()(
hasError: false,
isDefault: accountStore.accounts.length === 0,
});
// The refresh-token cookie was written to `slot`. Force the stored
// cookieSlot to match: addAccount preserves the prior slot when
// re-adding an existing account, and recomputes via getNextCookieSlot
// for new accounts (which may disagree if another tab claimed a slot
// mid-flow). Either way, the cookie's slot is the source of truth.
accountStore.updateAccount(accountId, { cookieSlot: slot });
accountStore.setActiveAccount(accountId);
await syncStalwartAuthContext(serverUrl, username, client.getAuthHeader(), slot);
@@ -718,12 +733,19 @@ export const useAuthStore = create<AuthState>()(
set({ isLoading: true, error: null, isRateLimited: false, rateLimitUntil: null });
try {
// Server-side SSO: the server holds the PKCE verifier in an encrypted cookie
// Server-side SSO: the server holds the PKCE verifier in an encrypted cookie.
// Pass the next-free cookie slot so /api/auth/sso/complete writes the refresh
// token to the correct per-account jmap_rt_<slot> cookie. Without this the
// route hardcoded slot 0, which broke "+ Add Account" by overwriting the
// first account's refresh-token cookie.
const accountStore = useAccountStore.getState();
const slot = accountStore.getNextCookieSlot();
const ssoRes = await apiFetch('/api/auth/sso/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ code, state }),
body: JSON.stringify({ code, state, slot }),
});
if (!ssoRes.ok) {
@@ -741,8 +763,6 @@ export const useAuthStore = create<AuthState>()(
throw new Error('Server URL not configured');
}
const accountStore = useAccountStore.getState();
const refreshFn = get().refreshAccessToken;
const client = JMAPClient.withBearer(ssoServerUrl, access_token, '', () => refreshFn());
await client.connect();
@@ -779,10 +799,13 @@ export const useAuthStore = create<AuthState>()(
hasError: false,
isDefault: accountStore.accounts.length === 0,
});
// The refresh-token cookie was written to `slot` by /api/auth/sso/complete.
// Force the stored cookieSlot to match — see loginWithOAuth above for the
// re-add and concurrent-tab cases this guards against.
accountStore.updateAccount(accountId, { cookieSlot: slot });
accountStore.setActiveAccount(accountId);
const cookieSlot = accountStore.getAccountById(accountId)?.cookieSlot ?? 0;
await syncStalwartAuthContext(ssoServerUrl, username, client.getAuthHeader(), cookieSlot);
await syncStalwartAuthContext(ssoServerUrl, username, client.getAuthHeader(), slot);
set({
isAuthenticated: true,
+99 -17
View File
@@ -6,6 +6,7 @@ import { useSettingsStore } from "@/stores/settings-store";
import { useCalendarStore } from "@/stores/calendar-store";
import { SearchFilters, DEFAULT_SEARCH_FILTERS, buildJMAPFilter, isFilterEmpty } from "@/lib/jmap/search-utils";
import { emailHooks } from "@/lib/plugin-hooks";
import type { ExternalSearchResult } from "@/lib/plugin-types";
import { fetchUnifiedEmails, fetchUnifiedMailboxCounts, type UnifiedAccountClient, type UnifiedMailboxCounts } from "@/lib/unified-mailbox";
import { useAuthStore } from "@/stores/auth-store";
import { useAccountStore } from "@/stores/account-store";
@@ -42,6 +43,8 @@ interface EmailStore {
searchFilters: SearchFilters;
isAdvancedSearchOpen: boolean;
searchAbortController: AbortController | null;
/** Plugin-contributed search results (CRM hits, Slack messages, etc.) populated by emailHooks.onProvideSearchResults. */
externalSearchResults: ExternalSearchResult[];
// Unified mailbox state
isUnifiedView: boolean;
@@ -85,6 +88,7 @@ interface EmailStore {
clearSearchFilters: () => void;
toggleAdvancedSearch: () => void;
toggleStar: (client: IJMAPClient, emailId: string) => Promise<void>;
setEmailKeywordsLocal: (emailId: string, keywords: Record<string, boolean>) => void;
// Batch operations
batchMarkAsRead: (client: IJMAPClient, read: boolean) => Promise<void>;
@@ -160,6 +164,28 @@ function getNextSelectedEmail(state: { emails: Email[]; selectedEmail: Email | n
return getNextSelectedEmailAfterRemoval(state, new Set([removedEmailId]));
}
// Find the trash mailbox for a given account scope. Prefers JMAP role, but
// falls back to name matching ("trash" / "deleted") so users with custom or
// pre-existing folders (e.g. "Deleted Items") aren't silently destroyed.
function findTrashMailbox(
mailboxes: Mailbox[],
scope: { accountId?: string; isShared?: boolean }
): Mailbox | undefined {
const matchesScope = (mb: Mailbox): boolean => {
if (scope.accountId) return mb.accountId === scope.accountId;
return !mb.isShared;
};
const byRole = mailboxes.find(mb => mb.role === 'trash' && matchesScope(mb));
if (byRole) return byRole;
return mailboxes.find(mb => {
if (!matchesScope(mb)) return false;
const lower = mb.name.toLowerCase();
return lower.includes('trash') || lower.includes('deleted');
});
}
export const useEmailStore = create<EmailStore>((set, get) => ({
emails: [],
mailboxes: [],
@@ -193,6 +219,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
searchFilters: { ...DEFAULT_SEARCH_FILTERS },
isAdvancedSearchOpen: false,
searchAbortController: null,
externalSearchResults: [],
// Unified mailbox state
isUnifiedView: false,
@@ -566,15 +593,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
// If deleteAction is 'trash' and not forced permanent delete, try to move to trash mailbox
if (deleteAction === 'trash' && !forceDelete) {
// Find trash mailbox for the correct account
const trashMailbox = mailboxes.find(mb => {
if (accountId) {
// For shared folders, match by accountId
return mb.role === 'trash' && mb.accountId === accountId;
}
// For primary account, find trash that's not from a shared folder
return mb.role === 'trash' && !mb.isShared;
});
const trashMailbox = findTrashMailbox(mailboxes, { accountId });
if (trashMailbox) {
// Use originalId for shared mailboxes if available
@@ -619,7 +638,10 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
});
return;
}
// If no trash mailbox found, fall through to permanent delete
// No trash folder found in this account. Surface the failure rather
// than silently destroying the email - the user asked to move it to
// trash, not to permanently delete it.
throw new Error('Trash mailbox not found - cannot move email to trash');
}
// Permanent delete
@@ -953,8 +975,10 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
// Get emails per page from settings
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
const result = await client.searchEmails(query, jmapMailboxId, accountId, emailsPerPage, 0);
const externals = await emailHooks.onProvideSearchResults.transform([] as ExternalSearchResult[], { query, filters: get().searchFilters });
set({
emails: result.emails,
externalSearchResults: externals,
hasMoreEmails: result.hasMore,
totalEmails: result.total,
isLoading: false
@@ -964,6 +988,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
error: error instanceof Error ? error.message : "Failed to search emails",
isLoading: false,
emails: [],
externalSearchResults: [],
hasMoreEmails: false,
totalEmails: 0
});
@@ -998,8 +1023,11 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
if (controller.signal.aborted) return;
const externals = await emailHooks.onProvideSearchResults.transform([] as ExternalSearchResult[], { query: searchQuery, filters: searchFilters });
set({
emails: result.emails,
externalSearchResults: externals,
hasMoreEmails: result.hasMore,
totalEmails: result.total,
isLoading: false,
@@ -1011,6 +1039,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
error: error instanceof Error ? error.message : "Failed to search emails",
isLoading: false,
emails: [],
externalSearchResults: [],
hasMoreEmails: false,
totalEmails: 0,
searchAbortController: null,
@@ -1057,6 +1086,17 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
}
},
setEmailKeywordsLocal: (emailId, keywords) => {
set((state) => ({
emails: state.emails.map(e =>
e.id === emailId ? { ...e, keywords: { ...keywords } } : e
),
selectedEmail: state.selectedEmail?.id === emailId
? { ...state.selectedEmail, keywords: { ...keywords } }
: state.selectedEmail,
}));
},
// Batch operations
batchMarkAsRead: async (client, read) => {
const { selectedEmailIds, emails, mailboxes } = get();
@@ -1163,23 +1203,65 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
await Promise.allSettled(promises);
} else {
// Move to trash per account.
const failedAccounts: string[] = [];
const movedEmailIds = new Set<string>();
const promises = Array.from(emailsByAccount.entries()).map(async ([acctId, ids]) => {
const acctClient = getClient(acctId);
if (!acctClient) return;
const trashMailbox = mailboxes.find(mb => {
if (mb.role !== 'trash') return false;
if (acctId === '__default__') return !mb.isShared;
return mb.accountId === acctId;
if (!acctClient) {
failedAccounts.push(acctId);
return;
}
const trashMailbox = findTrashMailbox(mailboxes, {
accountId: acctId === '__default__' ? undefined : acctId,
});
if (!trashMailbox) {
// No trash available for this account - fall back to destroy so the action isn't silently dropped.
await acctClient.batchDeleteEmails(ids);
// No trash for this account: skip rather than silently destroying.
// The user asked to move to trash, not permanently delete.
failedAccounts.push(acctId);
return;
}
const trashId = trashMailbox.originalId || trashMailbox.id;
await acctClient.batchMoveEmails(ids, trashId, trashMailbox.accountId);
ids.forEach(id => movedEmailIds.add(id));
});
await Promise.allSettled(promises);
if (failedAccounts.length > 0 && movedEmailIds.size === 0) {
// Nothing moved - bail out so the UI doesn't drop the emails from view.
throw new Error('Trash mailbox not found - cannot move emails to trash');
}
// Only remove successfully moved emails from local state.
if (movedEmailIds.size < emailIdsArray.length) {
const deletedEmails = emails.filter(e => movedEmailIds.has(e.id));
const remainingEmails = emails.filter(e => !movedEmailIds.has(e.id));
const updatedMailboxes = mailboxes.map(mailbox => {
let deltaTotalEmails = 0;
let deltaUnreadEmails = 0;
deletedEmails.forEach(email => {
if (email.mailboxIds?.[mailbox.id]) {
deltaTotalEmails--;
if (!email.keywords?.$seen) deltaUnreadEmails--;
}
});
return {
...mailbox,
totalEmails: Math.max(0, mailbox.totalEmails + deltaTotalEmails),
unreadEmails: Math.max(0, mailbox.unreadEmails + deltaUnreadEmails),
totalThreads: Math.max(0, mailbox.totalThreads + deltaTotalEmails),
unreadThreads: Math.max(0, mailbox.unreadThreads + deltaUnreadEmails),
};
});
set({
emails: remainingEmails,
mailboxes: updatedMailboxes,
selectedEmailIds: new Set(),
selectedEmail: null,
isLoading: false,
error: 'Some emails could not be moved: trash folder missing for one or more accounts',
});
return;
}
}
// Remove deleted emails from local state
+6
View File
@@ -211,6 +211,10 @@ interface SettingsState {
// attachment list shown above the message body.
hideInlineImageAttachments: boolean;
// Render image attachments as thumbnail cards (preview the actual image
// contents inside the chip) instead of generic file icons.
attachmentImagePreviewsEnabled: boolean;
// Sidebar Apps
sidebarApps: SidebarApp[];
keepAppsLoaded: boolean;
@@ -384,6 +388,7 @@ const DEFAULT_SETTINGS = {
] as string[],
hideInlineImageAttachments: true,
attachmentImagePreviewsEnabled: true,
// Sidebar Apps
sidebarApps: [] as SidebarApp[],
@@ -491,6 +496,7 @@ export const useSettingsStore = create<SettingsState>()(
attachmentReminderEnabled: state.attachmentReminderEnabled,
attachmentReminderKeywords: state.attachmentReminderKeywords,
hideInlineImageAttachments: state.hideInlineImageAttachments,
attachmentImagePreviewsEnabled: state.attachmentImagePreviewsEnabled,
sidebarApps: state.sidebarApps,
keepAppsLoaded: state.keepAppsLoaded,
debugMode: state.debugMode,
+118
View File
@@ -0,0 +1,118 @@
import { create } from 'zustand';
import { apiFetch } from '@/lib/browser-navigation';
import type { UpdateStatus, UpdateSeverity } from '@/lib/version-check/types';
const POLL_INTERVAL_MS = 15 * 60 * 1000;
interface UpdateState {
status: UpdateStatus | null;
loading: boolean;
lastFetchedAt: number | null;
fetchStatus: () => Promise<void>;
startPolling: () => void;
stopPolling: () => void;
}
let pollTimer: ReturnType<typeof setInterval> | null = null;
let inFlight: Promise<void> | null = null;
interface ApiResponse {
status: UpdateStatus | null;
lastCheckedAt: string | null;
lastSuccessAt: string | null;
}
export const useUpdateStore = create<UpdateState>()((set, get) => ({
status: null,
loading: false,
lastFetchedAt: null,
fetchStatus: async () => {
if (inFlight) return inFlight;
set({ loading: true });
inFlight = (async () => {
try {
const res = await apiFetch('/api/system/update-status');
if (!res.ok) return;
const body = (await res.json()) as ApiResponse;
set({
status: body.status,
lastFetchedAt: Date.now(),
});
} catch {
// Silent — banner just won't appear, no need to disrupt the UI.
} finally {
set({ loading: false });
inFlight = null;
}
})();
return inFlight;
},
startPolling: () => {
if (pollTimer) return;
void get().fetchStatus();
pollTimer = setInterval(() => {
void get().fetchStatus();
}, POLL_INTERVAL_MS);
},
stopPolling: () => {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
},
}));
// Selectors. Keep them outside the store creator so components subscribing
// to a single derived value don't re-render on unrelated state changes.
export type BannerVariant = 'amber' | 'red';
export interface BannerInfo {
variant: BannerVariant;
severity: UpdateSeverity;
latest: string | null;
url: string | null;
advisory: string | null;
}
export function selectBanner(s: UpdateState): BannerInfo | null {
const st = s.status;
if (!st || !st.updateAvailable) return null;
if (st.severity === 'none' || st.severity === 'unknown') return null;
if (st.severity === 'security') {
return {
variant: 'red',
severity: 'security',
latest: st.latest,
url: st.url,
advisory: st.advisory,
};
}
if (st.severity === 'deprecated') {
return {
variant: 'red',
severity: 'deprecated',
latest: st.latest,
url: st.url,
advisory: null,
};
}
return {
variant: 'amber',
severity: 'normal',
latest: st.latest,
url: st.url,
advisory: null,
};
}
// Used by the admin shield + admin sidebar to show a dot when an update is
// available. Mirrors selectBanner's "should we show something" logic.
export function selectHasUpdate(s: UpdateState): boolean {
return !!s.status?.updateAvailable && s.status.severity !== 'unknown';
}