Merge branch 'main' into feature/scheduled-send

This commit is contained in:
Lucas Gaitzsch
2026-05-04 15:45:15 +02:00
121 changed files with 10270 additions and 1669 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 }}
+78
View File
@@ -1,5 +1,83 @@
# Changelog
## 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
- **PWA**: Web push notifications for new inbox mail (#233), with click-through to open the message
- **Composer**: Insert and edit tables in rich-text emails (#236)
- **Mail**: Configurable sub-addressing delimiter character (#239)
- **i18n**: Turkish localization
- **i18n**: Missing keys filled in across 15 locales
### Fixes
- **Mail**: Set In-Reply-To and References headers on replies (#234)
- **Mail**: Persist htmlBody in drafts to preserve rich formatting (#236)
- **Auth**: Pin JMAP auth verification to the configured server URL (#237)
- **Auth**: Evict unrecoverable basic-auth accounts on reload
- **Notifications**: Scope new-mail notifications to genuine inbox deliveries
- **Notifications**: Extend PushVerification timeout and clean up leftover subscriptions
- **Viewer**: Smooth out body load to prevent flicker on first render
- **Viewer**: Prevent iframe flash when loading images or trusting the sender
- **Viewer**: Pad bare HTML emails like plain-text mails for consistent layout
- **Viewer**: Light-mode override now only affects body content
- **Viewer**: Detect `<style>` tag when applying padding
- **Viewer**: Drop iframe border-radius
- **Calendar**: Localize event start date in detail popover and event modal
- **Dev**: Include http protocol in connect-src for development mode CSP
## 1.5.3 (2026-04-28)
> **New:** Help shape Bulwark Webmail. Each instance now sends a lightweight daily heartbeat (version, platform, bucketed account counts, feature toggles - never message data or PII) so we can see which platforms and features actually get used and prioritize fixes where they matter most. You're in control: opt out any time from **Admin → Telemetry** or by setting `BULWARK_TELEMETRY=off`. Full schema in the [privacy notice](https://bulwarkmail.org/docs/legal/privacy/telemetry).
### Features
- **Telemetry**: Anonymous instance telemetry, on by default. Reports schema version, platform, bucketed account counts, and feature toggles only - disable from the admin UI, with `BULWARK_TELEMETRY=off`, or by clearing the endpoint
- **Telemetry**: Track unique logins (HMAC'd per instance, 90-day retention) so the heartbeat can report bucketed account totals without storing usernames
- **Plugins**: Theme API v2 with token compiler and skin slot
- **Plugins**: Extension preview page and detailed extension info API
- **Calendar**: Right-click context menu on empty calendar space
- **Docker**: Persistent named volume for telemetry data so the instance id and admin's consent choice survive container upgrades
### Fixes
- **Security**: Block telemetry endpoint from pointing at internal/loopback hosts (validation + DNS-rebind re-check at fetch time)
- **Security**: Harden plugin config, TOTP token exchange, and branding file serving
- **Mail**: Batch shortcuts now act on the multi-selection when one is present (#228)
## 1.5.2 (2026-04-27)
### 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
+1 -1
View File
@@ -94,7 +94,7 @@
## Internationalization
14 languages: English · Français · 日本語 · Español · Italiano · Deutsch · Nederlands · Português · Русский · 한국어 · Polski · Latviešu · 简体中文 · Українська
15 languages: English · Français · 日本語 · Español · Italiano · Deutsch · Nederlands · Português · Русский · Türkçe · 한국어 · Polski · Latviešu · 简体中文 · Українська
Automatic browser detection with persistent preference. Configurable locale URL prefix via `NEXT_PUBLIC_LOCALE_PREFIX`.
+22 -2
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.2-green.svg?logo=git&logoColor=white)](CHANGELOG.md)
[![Version](https://img.shields.io/badge/version-1.6.0-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>
@@ -55,7 +55,7 @@ A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.ar
## 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
@@ -217,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.2
1.6.0
+45 -5
View File
@@ -16,6 +16,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 +29,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 +43,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 +450,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);
+212 -15
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";
@@ -51,12 +52,33 @@ import { Input } from "@/components/ui/input";
import { FilePreviewModal } from "@/components/files/file-preview-modal";
import { isFilePreviewable } from "@/lib/file-preview";
import { appendPlainTextSignature } from "@/lib/signature-utils";
import { computeReplyThreadingHeaders } from "@/lib/email-threading";
import { Search, Filter, ChevronDown, X, Paperclip, Star, Mail, MailOpen, RotateCcw, PenSquare, PenLine, CheckSquare, Square, AlertTriangle } from "lucide-react";
import { ResizeHandle } from "@/components/layout/resize-handle";
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() {
@@ -114,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();
@@ -138,6 +242,7 @@ export default function Home() {
deleteEmail,
markAsRead,
toggleStar,
setEmailKeywordsLocal,
moveToMailbox,
moveThreadToMailbox,
searchEmails,
@@ -651,11 +756,50 @@ export default function Home() {
});
}, [enableUnifiedMailbox, isAuthenticated, client, mailboxes, connectedAccountsSignature, buildUnifiedAccounts, populateUnifiedAccountMailboxes, refreshUnifiedCounts]);
// System-notification click handler. The push SW navigates the user back
// here with `?email=<id>` (specific email it built the toast from) or
// `?openLatestUnread=1` (generic "New mail" toast — happens when the
// preview API failed). We resolve those params once after the inbox has
// finished loading and open the right message, then strip the params so a
// refresh doesn't re-open it.
const notificationParamHandledRef = useRef(false);
useEffect(() => {
if (notificationParamHandledRef.current) return;
if (!isAuthenticated || !client) return;
if (mailboxes.length === 0) return;
const params = new URLSearchParams(window.location.search);
const emailIdParam = params.get('email');
const openLatestUnread = params.get('openLatestUnread') === '1';
if (!emailIdParam && !openLatestUnread) return;
// For the latest-unread case we need the inbox emails loaded; bail and
// let the effect re-run once `emails` is populated.
if (openLatestUnread && emails.length === 0) return;
notificationParamHandledRef.current = true;
window.history.replaceState({}, '', window.location.pathname);
if (emailIdParam) {
setLoadingEmail(true);
fetchEmailContent(client, emailIdParam).finally(() => setLoadingEmail(false));
return;
}
// emails are sorted receivedAt-desc, so the first unread is the newest.
const newestUnread = emails.find(e => !e.keywords?.$seen);
if (newestUnread) {
selectEmail(newestUnread);
}
}, [isAuthenticated, client, mailboxes.length, emails, fetchEmailContent, selectEmail, setLoadingEmail]);
// Auto-fetch full email content when an email is auto-selected (e.g. after delete/archive)
useEffect(() => {
if (!selectedEmail || !client) return;
// If the email lacks bodyValues, it was auto-selected from the list and needs full content
if (!selectedEmail.bodyValues) {
// If the email lacks bodyValues, it was auto-selected from the list and needs full content.
// Skip when handleEmailSelect already started a fetch (it sets isLoadingEmail before
// calling selectEmail on the stub), to avoid a duplicate request.
if (!selectedEmail.bodyValues && !isLoadingEmail) {
const perAccountClient = isUnifiedView && selectedEmail.accountId
? useAuthStore.getState().getClientForAccount(selectedEmail.accountId)
: undefined;
@@ -750,6 +894,8 @@ export default function Home() {
fromName?: string;
identityId?: string;
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>;
inReplyTo?: string[];
references?: string[];
}) => {
if (!client) return;
@@ -757,7 +903,7 @@ export default function Home() {
const effectiveMode = pendingDraft?.mode ?? composerMode;
const originalEmailId = selectedEmail?.id;
await sendEmail(client, data.to, data.subject, data.body, data.cc, data.bcc, data.identityId, data.fromEmail, data.draftId, data.fromName, data.htmlBody, data.attachments);
await sendEmail(client, data.to, data.subject, data.body, data.cc, data.bcc, data.identityId, data.fromEmail, data.draftId, data.fromName, data.htmlBody, data.attachments, data.inReplyTo, data.references);
setShowComposer(false);
// Mark the original email with $answered or $forwarded keyword
@@ -792,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);
@@ -851,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');
@@ -889,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');
}
}
};
@@ -1043,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);
@@ -1449,6 +1628,12 @@ export default function Home() {
const originalEmailId = selectedEmail.id;
// RFC 5322 §3.6.4 threading — keep the conversation stitched together (#234).
const threading = computeReplyThreadingHeaders({
messageId: selectedEmail.messageId,
references: selectedEmail.references,
});
// Send reply with just the body text
await sendEmail(
client,
@@ -1460,7 +1645,11 @@ export default function Home() {
primaryIdentity?.id,
primaryIdentity?.email,
undefined,
primaryIdentity?.name || undefined
primaryIdentity?.name || undefined,
undefined,
undefined,
threading?.inReplyTo,
threading?.references,
);
// Mark the original email as answered
@@ -1502,7 +1691,13 @@ export default function Home() {
setShowComposer(false);
}
// Set loading state immediately (keep current email visible)
// Show the list stub immediately so subject/sender render without
// waiting for the body fetch — avoids the loading flicker.
const listEmail = emails.find(e => e.id === email.id);
if (listEmail) {
selectEmail(listEmail);
}
setLoadingEmail(true);
// On mobile, switch to viewer
@@ -1519,7 +1714,6 @@ export default function Home() {
try {
// In unified view each email carries its own accountId. Use that
// account's client so we fetch from the server that actually owns it.
const listEmail = emails.find(e => e.id === email.id);
const emailAccountId = isUnifiedView ? listEmail?.accountId : undefined;
const perAccountClient = emailAccountId
? useAuthStore.getState().getClientForAccount(emailAccountId)
@@ -2111,6 +2305,9 @@ export default function Home() {
htmlBody: selectedEmail.bodyValues?.[selectedEmail.htmlBody?.[0]?.partId || '']?.value || undefined,
receivedAt: selectedEmail.receivedAt,
attachments: selectedEmail.attachments,
messageId: selectedEmail.messageId,
inReplyTo: selectedEmail.inReplyTo,
references: selectedEmail.references,
} : undefined)}
initialDraftText={composerDraftText}
initialData={pendingDraft}
+3 -14
View File
@@ -330,9 +330,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 +513,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'}`}>
+8 -7
View File
@@ -15,6 +15,7 @@ const FEATURE_GATE_LABELS: Partial<Record<keyof FeatureGates, { label: string; d
customKeywordsEnabled: { label: 'Custom Keywords', description: 'Allow user-created labels and tags' },
templatesEnabled: { label: 'Email Templates', description: 'Allow email template creation and library' },
calendarTasksEnabled: { label: 'Calendar Tasks', description: 'Show task panel in calendar view' },
contactsEnabled: { label: 'Contacts', description: 'Enable contacts/address book features' },
smimeEnabled: { label: 'S/MIME', description: 'Enable certificate management and email signing' },
externalContentEnabled: { label: 'External Content', description: 'Allow users to choose external content loading policy' },
debugModeEnabled: { label: 'Debug Mode', description: 'Allow users to enable debug/diagnostic mode' },
@@ -131,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>
@@ -169,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>
@@ -194,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)}
+10 -10
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,11 +133,11 @@ 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.'}
{status.consent === 'pending' && 'Initialising - no heartbeats sent yet.'}
{status.consent === 'on' && 'Heartbeats are enabled (default).'}
{status.consent === 'off' && 'Heartbeats are off.'}
{envOverridden && (
@@ -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 });
}
}
+29 -1
View File
@@ -10,6 +10,7 @@ import {
setStalwartAuthContextInStore,
} from '@/lib/stalwart/auth-context';
import { configManager } from '@/lib/admin/config-manager';
import { isPublicHttpUrl } from '@/lib/security/url-guard';
import { recordLogin } from '@/lib/telemetry/login-tracker';
const COOKIE_OPTIONS = {
@@ -38,10 +39,37 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
}
// Pin the upstream URL to the configured JMAP server so an unauthenticated
// caller cannot point this route at internal hosts. Only when no server URL
// is configured AND the deployment explicitly allows custom JMAP endpoints
// do we honor the body URL — and even then it must be a public URL.
await configManager.ensureLoaded();
const configuredServerUrl =
configManager.get<string>('jmapServerUrl', '') ||
process.env.JMAP_SERVER_URL ||
process.env.NEXT_PUBLIC_JMAP_SERVER_URL ||
'';
const allowCustomEndpoint = configManager.get<boolean>('allowCustomJmapEndpoint', false);
let upstreamUrl: string;
let upstreamTrusted: boolean;
if (configuredServerUrl) {
upstreamUrl = configuredServerUrl;
upstreamTrusted = true;
} else if (allowCustomEndpoint) {
if (!(await isPublicHttpUrl(serverUrl))) {
return NextResponse.json({ error: 'Server URL is not allowed' }, { status: 400 });
}
upstreamUrl = serverUrl;
upstreamTrusted = false;
} else {
return NextResponse.json({ error: 'JMAP server not configured' }, { status: 500 });
}
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : getSlot(request);
const cookieName = sessionCookieName(slot);
const authHeader = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
const normalizedServerUrl = await verifyJmapAuth(serverUrl, authHeader);
const normalizedServerUrl = await verifyJmapAuth(upstreamUrl, authHeader, { trusted: upstreamTrusted });
const token = encryptSession(normalizedServerUrl, username, password);
const cookieStore = await cookies();
cookieStore.set(cookieName, token, COOKIE_OPTIONS);
+30 -1
View File
@@ -2,6 +2,8 @@ import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { JmapAuthVerificationError, verifyJmapAuth } from '@/lib/auth/verify-jmap-auth';
import { setStalwartAuthContext } from '@/lib/stalwart/auth-context';
import { configManager } from '@/lib/admin/config-manager';
import { isPublicHttpUrl } from '@/lib/security/url-guard';
import { recordLogin } from '@/lib/telemetry/login-tracker';
function getSlot(request: NextRequest, bodySlot: unknown): number {
@@ -24,8 +26,35 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
}
// Pin the upstream URL to the configured JMAP server so an unauthenticated
// caller cannot point this route at internal hosts. Only when no server URL
// is configured AND the deployment explicitly allows custom JMAP endpoints
// do we honor the body URL — and even then it must be a public URL.
await configManager.ensureLoaded();
const configuredServerUrl =
configManager.get<string>('jmapServerUrl', '') ||
process.env.JMAP_SERVER_URL ||
process.env.NEXT_PUBLIC_JMAP_SERVER_URL ||
'';
const allowCustomEndpoint = configManager.get<boolean>('allowCustomJmapEndpoint', false);
let upstreamUrl: string;
let upstreamTrusted: boolean;
if (configuredServerUrl) {
upstreamUrl = configuredServerUrl;
upstreamTrusted = true;
} else if (allowCustomEndpoint) {
if (!(await isPublicHttpUrl(serverUrl))) {
return NextResponse.json({ error: 'Server URL is not allowed' }, { status: 400 });
}
upstreamUrl = serverUrl;
upstreamTrusted = false;
} else {
return NextResponse.json({ error: 'JMAP server not configured' }, { status: 500 });
}
const slot = getSlot(request, bodySlot);
const normalizedServerUrl = await verifyJmapAuth(serverUrl, authHeader);
const normalizedServerUrl = await verifyJmapAuth(upstreamUrl, authHeader, { trusted: upstreamTrusted });
await setStalwartAuthContext(slot, {
serverUrl: normalizedServerUrl,
+1 -1
View File
@@ -89,7 +89,7 @@ export async function POST(request: NextRequest) {
// Pin the upstream URL to the configured JMAP server so an unauthenticated
// caller cannot point this route at internal hosts. Only when no server
// URL is configured (and the deployment explicitly allows custom JMAP
// endpoints) do we fall back to the user-supplied URL and even then
// endpoints) do we fall back to the user-supplied URL - and even then
// it must resolve to a public address.
await configManager.ensureLoaded();
const configuredServerUrl =
+176
View File
@@ -0,0 +1,176 @@
import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
/**
* GET /api/push/preview
*
* Called from the service worker when a Web Push wake-up arrives. Fetches the
* latest unread email so the SW can build an enriched system notification
* (sender, subject, avatar) without ever exposing JMAP credentials to the
* SW context.
*
* The relay's push payload is intentionally minimal (just a state-change
* ping), so this is what makes "From: Alice / Subject: …" appear instead of
* a generic "New mail" string.
*/
export async function GET(request: NextRequest) {
try {
const creds = await getStalwartCredentials(request);
if (!creds) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const sessionRes = await fetch(`${creds.serverUrl}/.well-known/jmap`, {
headers: { Authorization: creds.authHeader },
});
if (!sessionRes.ok) {
return NextResponse.json({ error: 'JMAP session failed' }, { status: 502 });
}
const session = (await sessionRes.json()) as {
apiUrl?: string;
primaryAccounts?: Record<string, string>;
};
const apiUrl = session.apiUrl;
const accountId = session.primaryAccounts?.['urn:ietf:params:jmap:mail'];
if (!apiUrl || !accountId) {
return NextResponse.json({ error: 'Incomplete JMAP session' }, { status: 502 });
}
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: [
[
'Email/query',
{
accountId,
filter: {
operator: 'AND',
conditions: [
{ inMailbox: inboxId },
{ notKeyword: '$seen' },
],
},
sort: [{ property: 'receivedAt', isAscending: false }],
limit: 1,
calculateTotal: true,
},
'eq',
],
[
'Email/get',
{
accountId,
'#ids': { resultOf: 'eq', name: 'Email/query', path: '/ids' },
properties: ['id', 'threadId', 'from', 'subject', 'preview', 'receivedAt'],
},
'eg',
],
],
};
const jmapRes = await fetch(apiUrl, {
method: 'POST',
headers: {
Authorization: creds.authHeader,
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
});
if (!jmapRes.ok) {
return NextResponse.json({ error: 'JMAP request failed' }, { status: 502 });
}
const data = (await jmapRes.json()) as {
methodResponses: [string, Record<string, unknown>, string][];
};
type EmailLite = {
id: string;
threadId: string;
from?: { name?: string | null; email?: string }[] | null;
subject?: string | null;
preview?: string | null;
receivedAt?: string | null;
};
let email: EmailLite | null = null;
let unreadTotal = 0;
for (const [method, body] of data.methodResponses) {
if (method === 'Email/query') {
unreadTotal = ((body as { total?: number }).total) ?? 0;
}
if (method === 'Email/get') {
const list = (body as { list?: EmailLite[] }).list ?? [];
email = list[0] ?? null;
}
}
return NextResponse.json({
email,
unreadTotal,
}, {
headers: {
// SW already gates on its own logic - don't let push events get
// cached and served stale.
'Cache-Control': 'no-store',
},
});
} catch (error) {
// `fetch failed` from undici is too generic to debug — the real reason
// (ENOTFOUND, ECONNREFUSED, TLS error, …) is on `error.cause`.
const err = error as Error & { cause?: { code?: string; message?: string } };
logger.error('push preview failed', {
error: err?.message ?? 'Unknown error',
causeCode: err?.cause?.code,
causeMessage: err?.cause?.message,
});
return NextResponse.json({ error: 'Internal error' }, { status: 500 });
}
}
+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',
},
},
);
}
+85
View File
@@ -204,6 +204,31 @@ body {
min-width: 100%;
}
/* Forces light-theme CSS variables inside the email content area, so when
"Always Show Emails in Light Mode" is enabled in dark theme the surrounding
sender info / attachments / plain-text body don't end up with light text
on a white background. */
.email-content-light {
--color-background: #ffffff;
--color-foreground: #0f172a;
--color-muted: #f1f5f9;
--color-muted-foreground: #64748b;
--color-border: #e2e8f0;
--color-card: #ffffff;
--color-card-foreground: #0f172a;
--color-popover: #ffffff;
--color-popover-foreground: #0f172a;
--color-secondary: #f8fafc;
--color-secondary-foreground: #0f172a;
--color-accent: #dbeafe;
--color-accent-foreground: #1e40af;
--color-input: #e2e8f0;
}
.email-content-light .email-content-text a {
color: #2563eb;
}
.email-content-text {
padding: 1rem 1.25rem;
}
@@ -497,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 {
@@ -680,3 +714,54 @@ body {
.tiptap .ProseMirror-selectednode img {
outline: none;
}
.tiptap table {
border-collapse: collapse;
margin: 0.5rem 0;
table-layout: fixed;
width: 100%;
overflow: hidden;
}
.tiptap table td,
.tiptap table th {
border: 1px solid var(--color-border);
padding: 0.375rem 0.5rem;
vertical-align: top;
position: relative;
min-width: 1em;
}
.tiptap table th {
background-color: var(--color-muted) !important;
color: var(--color-foreground) !important;
font-weight: 600;
text-align: left;
}
.tiptap table p {
margin: 0;
}
.tiptap table .selectedCell::after {
background: rgba(99, 102, 241, 0.15);
content: "";
inset: 0;
pointer-events: none;
position: absolute;
z-index: 2;
}
.tiptap table .column-resize-handle {
background-color: var(--color-primary);
bottom: -2px;
pointer-events: none;
position: absolute;
right: -2px;
top: 0;
width: 4px;
}
.tiptap.resize-cursor {
cursor: col-resize;
}
+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",
+4 -1
View File
@@ -19,6 +19,7 @@ import {
getUserStatus,
getParticipantList,
} from "@/lib/calendar-participants";
import { useFormatEventDate } from "@/hooks/use-format-event-date";
interface EventDetailPopoverProps {
event: CalendarEvent;
@@ -253,6 +254,8 @@ export function EventDetailPopover({
const hasParticipants = participants.length > 0;
const formatEventDate = useFormatEventDate();
const popover = (
<div
ref={popoverRef}
@@ -329,7 +332,7 @@ export function EventDetailPopover({
<Clock className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
<div className="text-sm">
<span className="font-medium text-foreground">
{format(startDate, "EEE, MMM d, yyyy")}
{formatEventDate(startDate)}
</span>
{event.showWithoutTime ? (
<span className="text-muted-foreground ml-1.5">{t("events.all_day")}</span>
+155 -106
View File
@@ -21,6 +21,9 @@ import {
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;
@@ -130,6 +133,7 @@ export function EventModal({
const timeFormat = useSettingsStore((s) => s.timeFormat);
const timeDisplayFmt = timeFormat === "12h" ? "h:mm a" : "HH:mm";
const isEdit = !!event;
const formatEventDate = useFormatEventDate();
const [mode, setMode] = useState<"view" | "edit">(isEdit ? "view" : "edit");
const userIsOrganizer = useMemo(() => {
@@ -240,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;
@@ -495,14 +524,14 @@ export function EventModal({
return (
<div ref={modalRef} role="dialog" aria-modal={isMobile || undefined} aria-label={event.title || t("events.no_title")} className={isMobile ? "fixed inset-0 z-50 flex flex-col bg-background" : "flex flex-col h-full bg-background"}>
<div className="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0">
<h2 className="text-lg font-semibold truncate">{event.title || t("events.no_title")}</h2>
<button onClick={onClose} className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 text-muted-foreground hover:text-foreground" aria-label={t("form.cancel")}>
<X className="w-5 h-5" />
</button>
</div>
<div className="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0">
<h2 className="text-lg font-semibold truncate">{event.title || t("events.no_title")}</h2>
<button onClick={onClose} className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 text-muted-foreground hover:text-foreground" aria-label={t("form.cancel")}>
<X className="w-5 h-5" />
</button>
</div>
<div className="flex-1 overflow-y-auto">
<div className="flex-1 overflow-y-auto">
<div className="px-6 py-4 space-y-3">
<div className="flex items-start gap-3 rounded-lg border border-blue-200 dark:border-blue-800 bg-blue-50 dark:bg-blue-950/50 px-4 py-3">
<CalendarDays className="w-5 h-5 text-blue-600 dark:text-blue-400 mt-0.5 flex-shrink-0" />
@@ -517,7 +546,7 @@ export function EventModal({
</div>
<div className="text-sm">
<span className="font-medium">{format(startD, "EEE, MMM d, yyyy")}</span>
<span className="font-medium">{formatEventDate(startD)}</span>
{!event.showWithoutTime && (
<span className="text-muted-foreground ml-2">
{format(startD, timeDisplayFmt)} {format(endD, timeDisplayFmt)}
@@ -550,48 +579,48 @@ export function EventModal({
</div>
)}
</div>
</div>
</div>
<div className="px-6 py-4 border-t border-border flex-shrink-0">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">{t("participants.rsvp_label")}</span>
<div className="flex gap-2">
<Button
size="sm"
variant={userCurrentStatus === "accepted" ? "default" : "outline"}
onClick={() => handleRsvp("accepted")}
className={userCurrentStatus === "accepted"
? "bg-success hover:bg-success/80 text-success-foreground"
: "text-success border-success/30 hover:bg-success/10"}
>
{userCurrentStatus === "accepted" && <Check className="w-4 h-4 mr-1" />}
{t("participants.accepted")}
</Button>
<Button
size="sm"
variant={userCurrentStatus === "tentative" ? "default" : "outline"}
onClick={() => handleRsvp("tentative")}
className={userCurrentStatus === "tentative"
? "bg-warning hover:bg-warning/80 text-warning-foreground"
: "border border-warning/30 text-warning hover:bg-warning/10"}
>
{userCurrentStatus === "tentative" && <Check className="w-4 h-4 mr-1" />}
{t("participants.tentative")}
</Button>
<Button
size="sm"
variant={userCurrentStatus === "declined" ? "default" : "ghost"}
onClick={() => handleRsvp("declined")}
className={userCurrentStatus === "declined"
? "bg-destructive hover:bg-destructive/80 text-destructive-foreground"
: "text-destructive hover:bg-destructive/10"}
>
{userCurrentStatus === "declined" && <Check className="w-4 h-4 mr-1" />}
{t("participants.declined")}
</Button>
</div>
<div className="px-6 py-4 border-t border-border flex-shrink-0">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">{t("participants.rsvp_label")}</span>
<div className="flex gap-2">
<Button
size="sm"
variant={userCurrentStatus === "accepted" ? "default" : "outline"}
onClick={() => handleRsvp("accepted")}
className={userCurrentStatus === "accepted"
? "bg-success hover:bg-success/80 text-success-foreground"
: "text-success border-success/30 hover:bg-success/10"}
>
{userCurrentStatus === "accepted" && <Check className="w-4 h-4 mr-1" />}
{t("participants.accepted")}
</Button>
<Button
size="sm"
variant={userCurrentStatus === "tentative" ? "default" : "outline"}
onClick={() => handleRsvp("tentative")}
className={userCurrentStatus === "tentative"
? "bg-warning hover:bg-warning/80 text-warning-foreground"
: "border border-warning/30 text-warning hover:bg-warning/10"}
>
{userCurrentStatus === "tentative" && <Check className="w-4 h-4 mr-1" />}
{t("participants.tentative")}
</Button>
<Button
size="sm"
variant={userCurrentStatus === "declined" ? "default" : "ghost"}
onClick={() => handleRsvp("declined")}
className={userCurrentStatus === "declined"
? "bg-destructive hover:bg-destructive/80 text-destructive-foreground"
: "text-destructive hover:bg-destructive/10"}
>
{userCurrentStatus === "declined" && <Check className="w-4 h-4 mr-1" />}
{t("participants.declined")}
</Button>
</div>
</div>
</div>
</div>
);
}
@@ -638,7 +667,7 @@ export function EventModal({
<Clock className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
<div className="text-sm">
<span className="font-medium text-foreground">
{format(startD, "EEE, MMM d, yyyy")}
{formatEventDate(startD)}
</span>
{event.showWithoutTime ? (
<span className="text-muted-foreground ml-1.5">{t("events.all_day")}</span>
@@ -767,16 +796,16 @@ export function EventModal({
return (
<div ref={modalRef} role="dialog" aria-modal={isMobile || undefined} aria-label={isEdit ? t("events.edit") : t("events.create")} data-tour="event-modal" className={isMobile ? "fixed inset-0 z-50 flex flex-col bg-background" : "flex flex-col h-full bg-background"}>
<div className="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0">
<h2 className="text-lg font-semibold">
{isEdit ? t("events.edit") : t("events.create")}
</h2>
<button onClick={onClose} className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 text-muted-foreground hover:text-foreground" aria-label={t("form.cancel")}>
<X className="w-5 h-5" />
</button>
</div>
<div className="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0">
<h2 className="text-lg font-semibold">
{isEdit ? t("events.edit") : t("events.create")}
</h2>
<button onClick={onClose} className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 text-muted-foreground hover:text-foreground" aria-label={t("form.cancel")}>
<X className="w-5 h-5" />
</button>
</div>
<div className="flex-1 overflow-y-auto">
<div className="flex-1 overflow-y-auto">
<div className="px-6 py-4 space-y-4">
<div>
<label className="text-sm font-medium mb-1 block">{t("form.title")}</label>
@@ -921,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>
@@ -986,69 +1035,69 @@ export function EventModal({
</div>
)}
</div>
</div>
</div>
<div className="flex items-center justify-between px-6 py-4 border-t border-border flex-shrink-0">
<div className="flex items-center gap-1">
{isEdit && onDelete && (
showDeleteConfirm ? (
<div className="flex items-center gap-2">
<div>
<span className="text-sm text-red-600 dark:text-red-400">
{t("form.delete_confirm")}
</span>
{hasParticipants && (
<p className="text-xs text-muted-foreground mt-0.5">
{t("participants.cancel_notification")}
</p>
)}
</div>
<Button
variant="outline"
size="sm"
onClick={() => { onDelete(event!.id, hasParticipants || undefined); onClose(); }}
className="text-red-600 dark:text-red-400 border-red-300 dark:border-red-700"
>
{t("events.delete")}
</Button>
<Button variant="ghost" size="sm" onClick={() => setShowDeleteConfirm(false)}>
{t("form.cancel")}
</Button>
<div className="flex items-center justify-between px-6 py-4 border-t border-border flex-shrink-0">
<div className="flex items-center gap-1">
{isEdit && onDelete && (
showDeleteConfirm ? (
<div className="flex items-center gap-2">
<div>
<span className="text-sm text-red-600 dark:text-red-400">
{t("form.delete_confirm")}
</span>
{hasParticipants && (
<p className="text-xs text-muted-foreground mt-0.5">
{t("participants.cancel_notification")}
</p>
)}
</div>
) : (
<Button
variant="ghost"
variant="outline"
size="sm"
onClick={() => setShowDeleteConfirm(true)}
className="text-red-600 dark:text-red-400"
onClick={() => { onDelete(event!.id, hasParticipants || undefined); onClose(); }}
className="text-red-600 dark:text-red-400 border-red-300 dark:border-red-700"
>
<Trash2 className="w-4 h-4 mr-1" />
{t("events.delete")}
</Button>
)
)}
{isEdit && onDuplicate && !showDeleteConfirm && (
<Button variant="ghost" size="sm" onClick={() => setShowDeleteConfirm(false)}>
{t("form.cancel")}
</Button>
</div>
) : (
<Button
variant="ghost"
size="sm"
onClick={handleDuplicate}
aria-label={t("events.duplicate")}
onClick={() => setShowDeleteConfirm(true)}
className="text-red-600 dark:text-red-400"
>
<Copy className="w-4 h-4 mr-1" />
{t("events.duplicate")}
<Trash2 className="w-4 h-4 mr-1" />
{t("events.delete")}
</Button>
)}
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={isEdit ? () => setMode("view") : onClose}>
{t("form.cancel")}
)
)}
{isEdit && onDuplicate && !showDeleteConfirm && (
<Button
variant="ghost"
size="sm"
onClick={handleDuplicate}
aria-label={t("events.duplicate")}
>
<Copy className="w-4 h-4 mr-1" />
{t("events.duplicate")}
</Button>
<Button onClick={handleSave} disabled={!title.trim() || isSaving}>
{t("form.save")}
</Button>
</div>
)}
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={isEdit ? () => setMode("view") : onClose}>
{t("form.cancel")}
</Button>
<Button onClick={handleSave} disabled={!title.trim() || isSaving}>
{t("form.save")}
</Button>
</div>
</div>
</div>
);
}
+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>
+127 -23
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";
@@ -31,6 +33,7 @@ import { TemplateForm } from "@/components/templates/template-form";
import type { EmailTemplate } from "@/lib/template-types";
import { appendPlainTextSignature, getPlainTextSignature } from "@/lib/signature-utils";
import { findReplyIdentityId } from "@/lib/reply-identity";
import { computeReplyThreadingHeaders } from "@/lib/email-threading";
import { RichTextEditor } from "@/components/email/rich-text-editor";
/** Strip HTML tags and decode entities to get a plain-text version */
@@ -67,6 +70,8 @@ interface EmailComposerProps {
fromName?: string;
identityId?: string;
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>;
inReplyTo?: string[];
references?: string[];
}) => void | Promise<void>;
onClose?: () => void;
onDiscardDraft?: (draftId: string) => void;
@@ -87,6 +92,11 @@ interface EmailComposerProps {
receivedAt?: string;
accountId?: string;
attachments?: Array<{ blobId: string; name?: string; type: string; size: number; cid?: string; disposition?: string }>;
// Threading: parent's Message-ID and References, used to set RFC 5322
// In-Reply-To and References on outgoing replies. See #234.
messageId?: string;
inReplyTo?: string[];
references?: string[];
};
}
@@ -116,6 +126,7 @@ export function EmailComposer({
const tCommon = useTranslations('common');
const timeFormat = useSettingsStore((state) => state.timeFormat);
const plainTextMode = useSettingsStore((state) => state.plainTextMode);
const subAddressDelimiter = useSettingsStore((state) => state.subAddressDelimiter);
const autoSelectReplyIdentity = useSettingsStore((state) => state.autoSelectReplyIdentity);
const attachmentReminderEnabled = useSettingsStore((state) => state.attachmentReminderEnabled);
const attachmentReminderKeywords = useSettingsStore((state) => state.attachmentReminderKeywords);
@@ -317,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();
@@ -437,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]);
@@ -550,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 {
@@ -578,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);
@@ -721,7 +757,7 @@ export function EmailComposer({
// Generate sub-addressed email if tag is set
const fromEmail = currentIdentity?.email
? subAddressTag
? generateSubAddress(currentIdentity.email, subAddressTag)
? generateSubAddress(currentIdentity.email, subAddressTag, subAddressDelimiter)
: currentIdentity.email
: undefined;
@@ -736,7 +772,8 @@ export function EmailComposer({
fromEmail,
draftId || undefined,
uploadedAttachments,
currentIdentity?.name || undefined
currentIdentity?.name || undefined,
plainTextMode ? undefined : body
);
setDraftId(savedDraftId);
@@ -772,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);
@@ -812,19 +862,27 @@ export function EmailComposer({
attachments: Array<{ blobId: string; name: string; type: string; size: number; disposition: 'inline'; cid: string }>;
} => {
const known = inlineImagesRef.current;
if (known.length === 0) return { html, attachments: [] };
const doc = new DOMParser().parseFromString(`<body>${html}</body>`, 'text/html');
const used = new Map<string, typeof known[number]>();
doc.querySelectorAll('img[data-cid]').forEach((img) => {
const cid = img.getAttribute('data-cid');
if (!cid) return;
const entry = known.find((e) => e.cid === cid);
if (!entry) return;
img.setAttribute('src', `cid:${cid}`);
img.removeAttribute('data-cid');
used.set(cid, entry);
if (known.length > 0) {
doc.querySelectorAll('img[data-cid]').forEach((img) => {
const cid = img.getAttribute('data-cid');
if (!cid) return;
const entry = known.find((e) => e.cid === cid);
if (!entry) return;
img.setAttribute('src', `cid:${cid}`);
img.removeAttribute('data-cid');
used.set(cid, entry);
});
}
// Recipient mail clients apply default <p> margins inside table cells,
// inflating row height. Tiptap wraps cell text in <p>, so force margin:0
// to match the composer's tight rows.
doc.querySelectorAll('td > p, th > p').forEach((p) => {
const existing = p.getAttribute('style') || '';
p.setAttribute('style', `margin:0;${existing}`);
});
return {
@@ -889,7 +947,7 @@ export function EmailComposer({
const fromEmail = currentIdentity?.email
? subAddressTag
? generateSubAddress(currentIdentity.email, subAddressTag)
? generateSubAddress(currentIdentity.email, subAddressTag, subAddressDelimiter)
: currentIdentity.email
: undefined;
@@ -905,6 +963,11 @@ export function EmailComposer({
return '';
};
// RFC 5322 §3.6.4 threading — only continues the chain on a reply, not a forward.
const threadingHeaders = (mode === 'reply' || mode === 'replyAll')
? computeReplyThreadingHeaders(replyTo)
: null;
// In plain text mode, send text/plain only (no HTML body)
const finalBody = plainTextMode
? appendPlainTextSignature(body, currentIdentity)
@@ -968,12 +1031,22 @@ export function EmailComposer({
}
// 4. Build canonical MIME
// mime-builder takes inReplyTo as a single ref-form msg-id (with brackets);
// references stays an array. threadingHeaders contains bare msg-ids.
const mimeInReplyTo = threadingHeaders?.inReplyTo[0]
? `<${threadingHeaders.inReplyTo[0]}>`
: undefined;
const mimeReferences = threadingHeaders?.references.length
? threadingHeaders.references.map(id => `<${id}>`)
: undefined;
const mimeBytes = buildMimeMessage({
from: { name: currentIdentity.name || undefined, email: fromEmail || currentIdentity.email },
to: toAddresses.map(e => ({ email: e })),
cc: ccAddresses.length > 0 ? ccAddresses.map(e => ({ email: e })) : undefined,
bcc: bccAddresses.length > 0 ? bccAddresses.map(e => ({ email: e })) : undefined,
subject,
inReplyTo: mimeInReplyTo,
references: mimeReferences,
textBody: finalBody,
htmlBody: finalHtmlBody,
attachments: mimeAttachments.length > 0 ? mimeAttachments : undefined,
@@ -986,6 +1059,8 @@ export function EmailComposer({
to: toAddresses.map(e => ({ email: e })),
cc: ccAddresses.length > 0 ? ccAddresses.map(e => ({ email: e })) : undefined,
subject,
inReplyTo: mimeInReplyTo,
references: mimeReferences,
};
// 5. Sign if enabled
@@ -1030,19 +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("");
@@ -1179,7 +1283,7 @@ export function EmailComposer({
>
{identities.map((identity) => {
const displayEmail = subAddressTag
? generateSubAddress(identity.email, subAddressTag)
? generateSubAddress(identity.email, subAddressTag, subAddressDelimiter)
: identity.email;
return (
<option key={identity.id} value={identity.id}>
@@ -1192,7 +1296,7 @@ export function EmailComposer({
<span className="text-sm text-foreground flex-1 truncate">
{subAddressTag ? (
<span className="font-mono">
{generateSubAddress(primaryIdentity?.email || '', subAddressTag)}
{generateSubAddress(primaryIdentity?.email || '', subAddressTag, subAddressDelimiter)}
</span>
) : (
<>
+6 -4
View File
@@ -5,6 +5,7 @@ import { Mail, Tag } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { Email, Identity } from '@/lib/jmap/types';
import { parseSubAddress } from '@/lib/sub-addressing';
import { useSettingsStore } from '@/stores/settings-store';
interface EmailIdentityBadgeProps {
email: Email;
@@ -20,12 +21,13 @@ export function EmailIdentityBadge({
className,
}: EmailIdentityBadgeProps) {
const t = useTranslations('identities.badge');
const subAddressDelimiter = useSettingsStore((state) => state.subAddressDelimiter);
const fromAddress = email.from?.[0]?.email;
if (!fromAddress) return null;
// Parse the from address to check for sub-addressing
const parsedFrom = parseSubAddress(fromAddress);
const parsedFrom = parseSubAddress(fromAddress, subAddressDelimiter);
// Find matching identity (email sent BY the user)
const matchingIdentity = identities.find(
@@ -37,7 +39,7 @@ export function EmailIdentityBadge({
if (!matchingIdentity) {
// Check all TO addresses for sub-address tags matching user's identities
for (const recipient of email.to || []) {
const parsedTo = parseSubAddress(recipient.email);
const parsedTo = parseSubAddress(recipient.email, subAddressDelimiter);
if (parsedTo.tag) {
// Check if this base email matches any of the user's identities
const matchingToIdentity = identities.find(
@@ -70,7 +72,7 @@ export function EmailIdentityBadge({
title={t('sub_address_tag', { tag: displayTag })}
>
<Tag className="w-3 h-3" />
<span className="font-mono">+{displayTag}</span>
<span className="font-mono">{subAddressDelimiter}{displayTag}</span>
</div>
);
}
@@ -114,7 +116,7 @@ export function EmailIdentityBadge({
aria-label={t('sub_address_tag', { tag: displayTag })}
>
<Tag className="w-3 h-3" />
<span className="font-mono">{t('subaddress_tag', { tag: displayTag })}</span>
<span className="font-mono">{subAddressDelimiter}{displayTag}</span>
</div>
)}
+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
+164 -1
View File
@@ -1,6 +1,6 @@
"use client";
import React, { useEffect, useCallback } from "react";
import React, { useEffect, useCallback, useState, useRef } from "react";
import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Underline from "@tiptap/extension-underline";
@@ -10,6 +10,10 @@ import { TextStyle } from "@tiptap/extension-text-style";
import Color from "@tiptap/extension-color";
import { ResizableImage } from "@/components/email/resizable-image";
import Placeholder from "@tiptap/extension-placeholder";
import { Table } from "@tiptap/extension-table";
import { TableRow } from "@tiptap/extension-table-row";
import { TableHeader } from "@tiptap/extension-table-header";
import { TableCell } from "@tiptap/extension-table-cell";
import { cn } from "@/lib/utils";
import {
Bold,
@@ -29,6 +33,10 @@ import {
RemoveFormatting,
Heading1,
Heading2,
Table as TableIcon,
Trash2,
Rows3,
Columns3,
} from "lucide-react";
export interface InlineImageUpload {
@@ -79,6 +87,43 @@ function ToolbarSeparator() {
return <div className="w-px h-5 bg-border mx-0.5" />;
}
const TABLE_PICKER_ROWS = 6;
const TABLE_PICKER_COLS = 8;
function TableSizePicker({ onPick }: { onPick: (rows: number, cols: number) => void }) {
const [hover, setHover] = useState<{ r: number; c: number } | null>(null);
return (
<div>
<div
className="grid gap-0.5"
style={{ gridTemplateColumns: `repeat(${TABLE_PICKER_COLS}, 1fr)` }}
onMouseLeave={() => setHover(null)}
>
{Array.from({ length: TABLE_PICKER_ROWS * TABLE_PICKER_COLS }).map((_, i) => {
const r = Math.floor(i / TABLE_PICKER_COLS);
const c = i % TABLE_PICKER_COLS;
const active = hover && r <= hover.r && c <= hover.c;
return (
<button
key={i}
type="button"
onMouseEnter={() => setHover({ r, c })}
onClick={() => onPick(r + 1, c + 1)}
className={cn(
"w-4 h-4 border border-border/60 rounded-[2px] transition-colors",
active ? "bg-primary border-primary" : "bg-background hover:bg-accent"
)}
/>
);
})}
</div>
<div className="text-xs text-muted-foreground mt-1.5 text-center">
{hover ? `${hover.r + 1} × ${hover.c + 1}` : "Pick size"}
</div>
</div>
);
}
export function RichTextEditor({
content,
onChange,
@@ -109,6 +154,27 @@ export function RichTextEditor({
Placeholder.configure({
placeholder,
}),
Table.configure({
resizable: true,
HTMLAttributes: {
border: "1",
cellpadding: "6",
cellspacing: "0",
width: "100%",
style: "width:100%;border-collapse:collapse;",
},
}),
TableRow,
TableHeader.configure({
HTMLAttributes: {
style: "padding:6px 8px;border:1px solid #ccc;background-color:#f5f5f5;color:#1f2937;text-align:left;",
},
}),
TableCell.configure({
HTMLAttributes: {
style: "padding:6px 8px;border:1px solid #ccc;vertical-align:top;",
},
}),
],
content,
editorProps: {
@@ -188,6 +254,20 @@ export function RichTextEditor({
.run();
}, [editor]);
const [tableMenuOpen, setTableMenuOpen] = useState(false);
const tableWrapperRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!tableMenuOpen) return;
const handler = (e: MouseEvent) => {
if (tableWrapperRef.current && !tableWrapperRef.current.contains(e.target as Node)) {
setTableMenuOpen(false);
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [tableMenuOpen]);
if (!editor) {
return (
<div className={cn("min-h-[100px]", className)} />
@@ -309,6 +389,89 @@ export function RichTextEditor({
<LinkIcon className="w-4 h-4" />
</ToolbarButton>
<div ref={tableWrapperRef} className="relative">
<ToolbarButton
active={editor.isActive("table")}
onClick={() => setTableMenuOpen((v) => !v)}
title="Table"
>
<TableIcon className="w-4 h-4" />
</ToolbarButton>
{tableMenuOpen && (
<div className="absolute z-50 top-full left-0 mt-1 bg-popover border border-border rounded-md shadow-md p-2 min-w-[200px]">
{editor.isActive("table") ? (
<div className="flex flex-col gap-0.5">
<button
type="button"
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-left"
onClick={() => { editor.chain().focus().addRowBefore().run(); setTableMenuOpen(false); }}
>
<Rows3 className="w-4 h-4" /> Add row above
</button>
<button
type="button"
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-left"
onClick={() => { editor.chain().focus().addRowAfter().run(); setTableMenuOpen(false); }}
>
<Rows3 className="w-4 h-4" /> Add row below
</button>
<button
type="button"
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-left"
onClick={() => { editor.chain().focus().addColumnBefore().run(); setTableMenuOpen(false); }}
>
<Columns3 className="w-4 h-4" /> Add column before
</button>
<button
type="button"
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-left"
onClick={() => { editor.chain().focus().addColumnAfter().run(); setTableMenuOpen(false); }}
>
<Columns3 className="w-4 h-4" /> Add column after
</button>
<div className="h-px bg-border my-1" />
<button
type="button"
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-left"
onClick={() => { editor.chain().focus().deleteRow().run(); setTableMenuOpen(false); }}
>
<Trash2 className="w-4 h-4" /> Delete row
</button>
<button
type="button"
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-left"
onClick={() => { editor.chain().focus().deleteColumn().run(); setTableMenuOpen(false); }}
>
<Trash2 className="w-4 h-4" /> Delete column
</button>
<button
type="button"
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-left"
onClick={() => { editor.chain().focus().toggleHeaderRow().run(); setTableMenuOpen(false); }}
>
<Rows3 className="w-4 h-4" /> Toggle header row
</button>
<div className="h-px bg-border my-1" />
<button
type="button"
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-left text-red-600 dark:text-red-400"
onClick={() => { editor.chain().focus().deleteTable().run(); setTableMenuOpen(false); }}
>
<Trash2 className="w-4 h-4" /> Delete table
</button>
</div>
) : (
<TableSizePicker
onPick={(rows, cols) => {
editor.chain().focus().insertTable({ rows, cols, withHeaderRow: true }).run();
setTableMenuOpen(false);
}}
/>
)}
</div>
)}
</div>
<ToolbarSeparator />
<ToolbarButton
+4 -2
View File
@@ -7,6 +7,7 @@ import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { useIdentityStore } from '@/stores/identity-store';
import { useSettingsStore } from '@/stores/settings-store';
import {
generateSubAddress,
extractDomain,
@@ -35,6 +36,7 @@ export function SubAddressHelper({
const popoverRef = useRef<HTMLDivElement>(null);
const { subAddress, addRecentTag, addTagSuggestion } = useIdentityStore();
const subAddressDelimiter = useSettingsStore((state) => state.subAddressDelimiter);
// Get suggestions based on recipient (memoized for performance)
const suggestions = useMemo(() => {
@@ -47,7 +49,7 @@ export function SubAddressHelper({
}, [recipientEmails]);
// Generate preview
const preview = tag ? generateSubAddress(baseEmail, tag) : baseEmail;
const preview = tag ? generateSubAddress(baseEmail, tag, subAddressDelimiter) : baseEmail;
// Close popover when clicking outside
useEffect(() => {
@@ -226,7 +228,7 @@ export function SubAddressHelper({
{/* Help Text */}
<div className="mb-3 text-xs text-muted-foreground">
{t('help_text')}
{t('help_text', { delimiter: subAddressDelimiter })}
</div>
{/* Use Address Button */}
+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
@@ -16,12 +16,13 @@ import nlMessages from '@/locales/nl/common.json';
import plMessages from '@/locales/pl/common.json';
import ptMessages from '@/locales/pt/common.json';
import ruMessages from '@/locales/ru/common.json';
import trMessages from '@/locales/tr/common.json';
import ukMessages from '@/locales/uk/common.json';
import zhMessages from '@/locales/zh/common.json';
// Pre-loaded translations (loaded at build time, not runtime)
const ALL_MESSAGES = {
cs: csMessages,
cs: csMessages,
en: enMessages,
fr: frMessages,
ja: jaMessages,
@@ -34,6 +35,7 @@ const ALL_MESSAGES = {
pl: plMessages,
pt: ptMessages,
ru: ruMessages,
tr: trMessages,
uk: ukMessages,
zh: zhMessages,
};
+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"
+55 -2
View File
@@ -4,8 +4,17 @@ import { useState, useCallback } from 'react';
import { useTranslations } from 'next-intl';
import { useConfig } from '@/hooks/use-config';
import { useSettingsStore } from '@/stores/settings-store';
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
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,
isValidSubAddressDelimiter,
} from '@/lib/sub-addressing';
const CUSTOM_DELIMITER_SENTINEL = '__custom__';
const DEFAULT_CUSTOM_DELIMITER = '~';
export function ComposingSettings() {
const t = useTranslations('settings.email_behavior');
@@ -17,13 +26,14 @@ export function ComposingSettings() {
autoSelectReplyIdentity,
attachmentReminderEnabled,
attachmentReminderKeywords,
subAddressDelimiter,
updateSetting,
} = useSettingsStore();
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 {
@@ -40,6 +50,49 @@ export function ComposingSettings() {
/>
</SettingItem>
<SettingItem
label={t('sub_address_delimiter.label')}
description={t('sub_address_delimiter.description', { delimiter: subAddressDelimiter })}
>
<div className="flex flex-col items-end gap-2">
<Select
value={isSupportedSubAddressDelimiter(subAddressDelimiter) ? subAddressDelimiter : CUSTOM_DELIMITER_SENTINEL}
onChange={(value) => {
if (value === CUSTOM_DELIMITER_SENTINEL) {
if (isSupportedSubAddressDelimiter(subAddressDelimiter)) {
updateSetting('subAddressDelimiter', DEFAULT_CUSTOM_DELIMITER);
}
} else {
updateSetting('subAddressDelimiter', value);
}
}}
options={[
...SUPPORTED_SUB_ADDRESS_DELIMITERS.map((delim) => ({
value: delim,
label: t('sub_address_delimiter.option', { delimiter: delim }),
})),
{ value: CUSTOM_DELIMITER_SENTINEL, label: t('sub_address_delimiter.custom') },
]}
/>
{!isSupportedSubAddressDelimiter(subAddressDelimiter) && (
<input
type="text"
maxLength={1}
value={subAddressDelimiter}
onChange={(e) => {
const next = e.target.value.slice(0, 1);
if (next && isValidSubAddressDelimiter(next)) {
updateSetting('subAddressDelimiter', next);
}
}}
aria-label={t('sub_address_delimiter.custom_input_label')}
placeholder={DEFAULT_CUSTOM_DELIMITER}
className="w-16 px-2 py-1 text-sm font-mono text-center bg-background border border-border rounded-md focus:outline-none focus:ring-1 focus:ring-ring"
/>
)}
</div>
</SettingItem>
<SettingItem label={t('attachment_reminder.label')} description={t('attachment_reminder.description')}>
<ToggleSwitch
checked={attachmentReminderEnabled}
+170 -1
View File
@@ -1,13 +1,32 @@
"use client";
import { useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { useSettingsStore } from '@/stores/settings-store';
import { SettingsSection, SettingItem, ToggleSwitch, Select } from './settings-section';
import { playNotificationSound, NOTIFICATION_SOUNDS } from '@/lib/notification-sound';
import type { NotificationSoundChoice } from '@/lib/notification-sound';
import { Button } from '@/components/ui/button';
import { Volume2 } from 'lucide-react';
import { CheckCircle2, Volume2, XCircle } from 'lucide-react';
import { usePolicyStore } from '@/stores/policy-store';
import { useAuthStore } from '@/stores/auth-store';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { useConfirmDialog } from '@/hooks/use-confirm-dialog';
import {
DEFAULT_RELAY_BASE_URL,
WebPushUnsupportedError,
disableWebPush,
enableWebPush,
isWebPushEnabled,
isWebPushSupported,
} from '@/lib/web-push';
type PushStatus =
| { kind: 'idle' }
| { kind: 'busy' }
| { kind: 'enabled' }
| { kind: 'unsupported' }
| { kind: 'error'; message: string };
export function NotificationSettings() {
const t = useTranslations('settings.notifications');
@@ -21,6 +40,77 @@ export function NotificationSettings() {
updateSetting,
} = useSettingsStore();
const { isSettingLocked, isSettingHidden } = usePolicyStore();
const client = useAuthStore((s) => s.client);
const username = useAuthStore((s) => s.username);
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
const supported = typeof window !== 'undefined' && isWebPushSupported();
const [relayUrl, setRelayUrl] = useState(DEFAULT_RELAY_BASE_URL);
const [pushStatus, setPushStatus] = useState<PushStatus>(
supported ? { kind: 'idle' } : { kind: 'unsupported' },
);
useEffect(() => {
if (!supported) return;
void (async () => {
const enabled = await isWebPushEnabled();
if (enabled) setPushStatus({ kind: 'enabled' });
})();
}, [supported]);
const trimmedRelay = relayUrl.trim().replace(/\/+$/, '');
const isValidRelay = /^https?:\/\/.+/i.test(trimmedRelay);
const busy = pushStatus.kind === 'busy';
const handleEnablePush = async () => {
if (!client) {
setPushStatus({ kind: 'error', message: 'Sign in first' });
return;
}
if (!isValidRelay) {
setPushStatus({ kind: 'error', message: 'Enter a valid https:// URL' });
return;
}
setPushStatus({ kind: 'busy' });
try {
await enableWebPush({
client,
relayBaseUrl: trimmedRelay,
accountLabel: username ?? undefined,
});
setPushStatus({ kind: 'enabled' });
} catch (err) {
if (err instanceof WebPushUnsupportedError) {
setPushStatus({ kind: 'unsupported' });
return;
}
setPushStatus({
kind: 'error',
message: err instanceof Error ? err.message : 'Failed to enable push',
});
}
};
const handleDisablePush = async () => {
if (!client) return;
const confirmed = await confirmDialog({
title: t('push.confirm_disable_title'),
message: t('push.confirm_disable_message'),
confirmText: t('push.disable'),
variant: 'destructive',
});
if (!confirmed) return;
setPushStatus({ kind: 'busy' });
try {
await disableWebPush({ client, relayBaseUrl: trimmedRelay });
setPushStatus({ kind: 'idle' });
} catch (err) {
setPushStatus({
kind: 'error',
message: err instanceof Error ? err.message : 'Failed to disable push',
});
}
};
const soundOptions = NOTIFICATION_SOUNDS.map((s) => ({
value: s.id,
@@ -29,6 +119,46 @@ export function NotificationSettings() {
return (
<div className="space-y-8">
<SettingsSection title={t('push.title')} description={t('push.description')}>
<div className="rounded-md border p-4 space-y-3">
<div className="flex items-center justify-between gap-3">
<label className="text-sm font-medium" htmlFor="push-relay-url">
{t('push.relay_label')}
</label>
<PushStatusBadge status={pushStatus} t={t} />
</div>
<p className="text-xs text-muted-foreground">{t('push.relay_desc')}</p>
<input
id="push-relay-url"
type="url"
inputMode="url"
autoComplete="off"
spellCheck={false}
value={relayUrl}
onChange={(e) => setRelayUrl(e.target.value)}
placeholder={t('push.relay_placeholder')}
disabled={busy || pushStatus.kind === 'unsupported'}
className="w-full rounded border bg-background px-3 py-2 text-sm disabled:opacity-50"
/>
<div className="flex flex-wrap gap-2">
<Button
onClick={handleEnablePush}
disabled={busy || pushStatus.kind === 'unsupported' || !isValidRelay || !client}
>
{pushStatus.kind === 'enabled' ? t('push.reenable') : t('push.enable')}
</Button>
{pushStatus.kind === 'enabled' && (
<Button variant="outline" onClick={handleDisablePush} disabled={busy}>
{t('push.disable')}
</Button>
)}
</div>
{pushStatus.kind === 'unsupported' && (
<p className="text-xs text-muted-foreground">{t('push.ios_hint')}</p>
)}
</div>
</SettingsSection>
<SettingsSection title={t('sound_selection.title')} description={t('sound_selection.description')}>
<SettingItem
label={t('sound_selection.choose')}
@@ -118,6 +248,45 @@ export function NotificationSettings() {
/>
</SettingItem>
</SettingsSection>
<ConfirmDialog {...confirmDialogProps} />
</div>
);
}
function PushStatusBadge({
status,
t,
}: {
status: PushStatus;
t: ReturnType<typeof useTranslations>;
}) {
if (status.kind === 'enabled') {
return (
<span className="inline-flex items-center gap-1 text-xs text-emerald-600 dark:text-emerald-400">
<CheckCircle2 className="w-3.5 h-3.5" />
{t('push.status_active')}
</span>
);
}
if (status.kind === 'busy') {
return <span className="text-xs text-muted-foreground">{t('push.status_busy')}</span>;
}
if (status.kind === 'unsupported') {
return (
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
<XCircle className="w-3.5 h-3.5" />
{t('push.status_unsupported')}
</span>
);
}
if (status.kind === 'error') {
return (
<span className="inline-flex items-center gap-1 text-xs text-destructive" title={status.message}>
<XCircle className="w-3.5 h-3.5" />
{status.message}
</span>
);
}
return <span className="text-xs text-muted-foreground">{t('push.status_inactive')}</span>;
}
+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" />}
+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>
+13
View File
@@ -152,6 +152,18 @@ export function FlagRU(props: FlagProps) {
);
}
/** Turkey - Red with white crescent and star */
export function FlagTR(props: FlagProps) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 30 20" width={W} height={H} className={flagClass} {...props}>
<rect width="30" height="20" fill="#E30A17" />
<circle cx="10" cy="10" r="6" fill="#fff" />
<circle cx="11.5" cy="10" r="5" fill="#E30A17" />
<polygon points="19.5,7.8 19.994,9.32 21.592,9.32 20.299,10.26 20.793,11.78 19.5,10.84 18.207,11.78 18.701,10.26 17.408,9.32 19.006,9.32" fill="#fff" />
</svg>
);
}
/** Ukraine Blue, Yellow horizontal */
export function FlagUA(props: FlagProps) {
return (
@@ -192,6 +204,7 @@ export const flagComponents: Record<string, (props: FlagProps) => ReactElement>
pl: FlagPL,
pt: FlagBR,
ru: FlagRU,
tr: FlagTR,
uk: FlagUA,
zh: FlagCN,
};
+2 -1
View File
@@ -8,7 +8,7 @@ import { cn } from '@/lib/utils';
import { flagComponents } from './flag-icons';
const languages = [
{ value: 'cs', label: 'Česky' },
{ value: 'cs', label: 'Česky' },
{ value: 'en', label: 'English' },
{ value: 'fr', label: 'Français' },
{ value: 'ja', label: '日本語' },
@@ -21,6 +21,7 @@ const languages = [
{ value: 'pl', label: 'Polski' },
{ value: 'pt', label: 'Português' },
{ value: 'ru', label: 'Русский' },
{ value: 'tr', label: 'Türkçe' },
{ value: 'uk', label: 'Українська' },
{ value: 'zh', label: '简体中文' },
];
+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 };
}, []);
+27
View File
@@ -0,0 +1,27 @@
import { useCallback } from "react";
import { useTranslations } from "next-intl";
import { format } from "date-fns";
/**
* Returns a memoized function that formats a calendar event date
* using the current locale for day and month names.
*
* The string will be in the format: "EEE, MMM d, yyyy"
*
* For example: "Wed, Apr 29, 2026" (en)
* "Qua, Abr 29, 2026" (pt)
*/
export function useFormatEventDate(): (date: Date) => string {
const t = useTranslations("calendar");
return useCallback(
(date: Date): string => {
const dayOfWeek = format(date, "EEE").toLowerCase();
const month = format(date, "MMM").toLowerCase();
const day = format(date, "d");
const year = format(date, "yyyy");
return `${t(`days.${dayOfWeek}`)}, ${t(`months.${month}`)} ${day}, ${year}`;
},
[t]
);
}
+3
View File
@@ -47,6 +47,9 @@ export default getRequestConfig(async ({ requestLocale }) => {
case 'ru':
messages = (await import('../locales/ru/common.json')).default;
break;
case 'tr':
messages = (await import('../locales/tr/common.json')).default;
break;
case 'uk':
messages = (await import('../locales/uk/common.json')).default;
break;
+1 -1
View File
@@ -13,7 +13,7 @@ const localePrefix = (process.env.NEXT_PUBLIC_LOCALE_PREFIX ?? 'never') as
| 'as-needed';
export const routing = defineRouting({
locales: ['cs', 'en', 'fr', 'de', 'es', 'it', 'ja', 'ko', 'lv', 'nl', 'pl', 'pt', 'ru', 'uk', 'zh'],
locales: ['cs', 'en', 'fr', 'de', 'es', 'it', 'ja', 'ko', 'lv', 'nl', 'pl', 'pt', 'ru', 'tr', 'uk', 'zh'],
defaultLocale: 'en',
localePrefix
});
+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);
});
+86
View File
@@ -0,0 +1,86 @@
import { describe, it, expect } from 'vitest';
import {
computeReplyThreadingHeaders,
stripMessageIdBrackets,
} from '../email-threading';
describe('stripMessageIdBrackets', () => {
it('strips surrounding angle brackets', () => {
expect(stripMessageIdBrackets('<abc@example.com>')).toBe('abc@example.com');
});
it('handles whitespace and missing brackets', () => {
expect(stripMessageIdBrackets(' abc@example.com ')).toBe('abc@example.com');
expect(stripMessageIdBrackets('<abc@example.com')).toBe('abc@example.com');
expect(stripMessageIdBrackets('abc@example.com>')).toBe('abc@example.com');
});
});
describe('computeReplyThreadingHeaders', () => {
it('returns null when the parent has no Message-ID', () => {
expect(computeReplyThreadingHeaders(undefined)).toBeNull();
expect(computeReplyThreadingHeaders({})).toBeNull();
expect(computeReplyThreadingHeaders({ messageId: '' })).toBeNull();
expect(computeReplyThreadingHeaders({ messageId: ' ' })).toBeNull();
});
it('sets In-Reply-To to the parent Message-ID and seeds References with it', () => {
const result = computeReplyThreadingHeaders({
messageId: '<root@example.com>',
});
expect(result).toEqual({
inReplyTo: ['root@example.com'],
references: ['root@example.com'],
});
});
it('appends the parent to existing References per RFC 5322', () => {
const result = computeReplyThreadingHeaders({
messageId: '<msg-2@example.com>',
references: ['<msg-0@example.com>', '<msg-1@example.com>'],
});
expect(result).toEqual({
inReplyTo: ['msg-2@example.com'],
references: ['msg-0@example.com', 'msg-1@example.com', 'msg-2@example.com'],
});
});
it('de-duplicates if the parent already appears in References', () => {
const result = computeReplyThreadingHeaders({
messageId: '<msg-1@example.com>',
references: ['<msg-0@example.com>', '<msg-1@example.com>'],
});
expect(result?.references).toEqual([
'msg-0@example.com',
'msg-1@example.com',
]);
});
it('accepts bare Message-IDs without angle brackets', () => {
const result = computeReplyThreadingHeaders({
messageId: 'msg-2@example.com',
references: ['msg-1@example.com'],
});
expect(result).toEqual({
inReplyTo: ['msg-2@example.com'],
references: ['msg-1@example.com', 'msg-2@example.com'],
});
});
// JMAP RFC 8621 §4.1.2.3 returns messageId as String[]|null. Verify we
// don't crash on that shape even though most call sites pass a string.
it('accepts an array-shaped messageId per JMAP spec', () => {
const result = computeReplyThreadingHeaders({
messageId: ['<msg-2@example.com>'],
references: ['<msg-1@example.com>'],
});
expect(result).toEqual({
inReplyTo: ['msg-2@example.com'],
references: ['msg-1@example.com', 'msg-2@example.com'],
});
});
it('returns null for an empty messageId array', () => {
expect(computeReplyThreadingHeaders({ messageId: [] })).toBeNull();
});
});
+151
View File
@@ -0,0 +1,151 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { JMAPClient } from '../jmap/client';
function createClient(): JMAPClient {
const client = new JMAPClient('https://jmap.example.com', 'user@example.com', 'pass');
Object.assign(client, {
apiUrl: 'https://jmap.example.com/api',
accountId: 'account-1',
username: 'user@example.com',
});
return client;
}
interface JMAPMethodCall {
0: string;
1: Record<string, unknown>;
2: string;
}
interface CapturedRequest {
using?: string[];
methodCalls: JMAPMethodCall[];
}
/**
* Mock fetch to script three sequential JMAP requests sendEmail makes:
* Mailbox/get Identity/get Email/set + EmailSubmission/set.
* Returns the captured request bodies for assertions.
*/
function mockSendEmailFlow() {
const captured: CapturedRequest[] = [];
const fetchSpy = vi.spyOn(globalThis, 'fetch');
fetchSpy.mockImplementation(async (_url, init) => {
const body = JSON.parse((init as { body: string }).body) as CapturedRequest;
captured.push(body);
const callIdx = captured.length - 1;
let payload: unknown;
if (callIdx === 0) {
payload = {
methodResponses: [[
'Mailbox/get',
{
list: [
{ id: 'mb-drafts', name: 'Drafts', role: 'drafts' },
{ id: 'mb-sent', name: 'Sent', role: 'sent' },
],
},
'0',
]],
};
} else if (callIdx === 1) {
payload = {
methodResponses: [[
'Identity/get',
{ list: [{ id: 'identity-1', email: 'user@example.com', mayDelete: false }] },
'0',
]],
};
} else {
payload = {
methodResponses: [
['Email/set', { created: { [Object.keys((captured[callIdx].methodCalls[0][1] as { create: Record<string, unknown> }).create)[0]]: { id: 'sent-id-1' } } }, '0'],
['EmailSubmission/set', { created: { '1': { id: 'sub-1' } } }, '1'],
],
};
}
return {
ok: true,
status: 200,
text: () => Promise.resolve(JSON.stringify(payload)),
json: () => Promise.resolve(payload),
} as Response;
});
return captured;
}
describe('JMAPClient.sendEmail threading headers', () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it('writes inReplyTo and references on the Email/set create when supplied', async () => {
const client = createClient();
const captured = mockSendEmailFlow();
await client.sendEmail(
['recipient@example.com'],
'Re: testmail',
'reply body',
undefined, undefined, 'identity-1', 'user@example.com',
undefined, undefined, undefined, undefined,
['<parent@example.com>'],
['<root@example.com>', '<parent@example.com>'],
);
// Third request is the Email/set + EmailSubmission/set batch.
const setCall = captured[2].methodCalls[0];
expect(setCall[0]).toBe('Email/set');
const create = setCall[1].create as Record<string, Record<string, unknown>>;
const draft = Object.values(create)[0];
// Bare msg-ids per RFC 8621 — angle brackets stripped.
expect(draft.inReplyTo).toEqual(['parent@example.com']);
expect(draft.references).toEqual(['root@example.com', 'parent@example.com']);
});
it('omits threading fields when no parent ids are supplied', async () => {
const client = createClient();
const captured = mockSendEmailFlow();
await client.sendEmail(
['recipient@example.com'],
'Fresh thread',
'body',
undefined, undefined, 'identity-1', 'user@example.com',
);
const setCall = captured[2].methodCalls[0];
const create = setCall[1].create as Record<string, Record<string, unknown>>;
const draft = Object.values(create)[0];
expect(draft.inReplyTo).toBeUndefined();
expect(draft.references).toBeUndefined();
});
it('drops empty / whitespace-only ids rather than sending blank entries', async () => {
const client = createClient();
const captured = mockSendEmailFlow();
await client.sendEmail(
['recipient@example.com'],
'Re: testmail',
'body',
undefined, undefined, 'identity-1', 'user@example.com',
undefined, undefined, undefined, undefined,
['<>', ' ', '<real@example.com>'],
[],
);
const setCall = captured[2].methodCalls[0];
const create = setCall[1].create as Record<string, Record<string, unknown>>;
const draft = Object.values(create)[0];
expect(draft.inReplyTo).toEqual(['real@example.com']);
expect(draft.references).toBeUndefined();
});
});
+123
View File
@@ -6,6 +6,10 @@ import {
suggestTagsForDomain,
isValidTag,
getTagValidationError,
isSupportedSubAddressDelimiter,
isValidSubAddressDelimiter,
SUPPORTED_SUB_ADDRESS_DELIMITERS,
DEFAULT_SUB_ADDRESS_DELIMITER,
MAX_TAG_LENGTH,
} from '../sub-addressing';
@@ -354,3 +358,122 @@ describe('getTagValidationError', () => {
expect(getTagValidationError('日本語')).toBe('INVALID_CHARS');
});
});
describe('custom delimiter', () => {
describe('parseSubAddress with non-default delimiter', () => {
it('should parse with "-" delimiter', () => {
const result = parseSubAddress('user-shopping@example.com', '-');
expect(result.baseUser).toBe('user');
expect(result.tag).toBe('shopping');
});
it('should parse with "." delimiter', () => {
const result = parseSubAddress('user.shopping@example.com', '.');
expect(result.baseUser).toBe('user');
expect(result.tag).toBe('shopping');
});
it('should parse with "=" delimiter', () => {
const result = parseSubAddress('user=shopping@example.com', '=');
expect(result.baseUser).toBe('user');
expect(result.tag).toBe('shopping');
});
it('should ignore "+" when "-" is configured as the delimiter', () => {
const result = parseSubAddress('user+shopping@example.com', '-');
expect(result.baseUser).toBe('user+shopping');
expect(result.tag).toBeNull();
});
it('should split on first occurrence when delimiter appears multiple times', () => {
const result = parseSubAddress('alice-shop-orders@example.com', '-');
expect(result.baseUser).toBe('alice');
expect(result.tag).toBe('shop-orders');
});
});
describe('generateSubAddress with non-default delimiter', () => {
it('should generate using "-" delimiter', () => {
expect(generateSubAddress('user@example.com', 'shopping', '-')).toBe('user-shopping@example.com');
});
it('should generate using "." delimiter', () => {
expect(generateSubAddress('user@example.com', 'shopping', '.')).toBe('user.shopping@example.com');
});
it('should replace existing tag using the configured delimiter', () => {
expect(generateSubAddress('user-old@example.com', 'new', '-')).toBe('user-new@example.com');
});
it('should not strip a "+" sign in the local part when delimiter is "-"', () => {
// "+" is not the delimiter so it should remain part of the base user
expect(generateSubAddress('user+plus@example.com', 'tag', '-')).toBe('user+plus-tag@example.com');
});
});
describe('isSupportedSubAddressDelimiter', () => {
it('accepts every supported delimiter', () => {
for (const delim of SUPPORTED_SUB_ADDRESS_DELIMITERS) {
expect(isSupportedSubAddressDelimiter(delim)).toBe(true);
}
});
it('rejects unsupported characters', () => {
expect(isSupportedSubAddressDelimiter('_')).toBe(false);
expect(isSupportedSubAddressDelimiter('++')).toBe(false);
expect(isSupportedSubAddressDelimiter('')).toBe(false);
});
it('default delimiter is supported', () => {
expect(isSupportedSubAddressDelimiter(DEFAULT_SUB_ADDRESS_DELIMITER)).toBe(true);
});
});
describe('isValidSubAddressDelimiter', () => {
it('accepts every preset delimiter', () => {
for (const delim of SUPPORTED_SUB_ADDRESS_DELIMITERS) {
expect(isValidSubAddressDelimiter(delim)).toBe(true);
}
});
it('accepts atext special characters as custom delimiters', () => {
const customs = ['~', '!', '#', '$', '%', '&', "'", '*', '/', '?', '^', '_', '`', '{', '|', '}'];
for (const c of customs) {
expect(isValidSubAddressDelimiter(c)).toBe(true);
}
});
it('rejects alphanumeric characters', () => {
expect(isValidSubAddressDelimiter('a')).toBe(false);
expect(isValidSubAddressDelimiter('Z')).toBe(false);
expect(isValidSubAddressDelimiter('0')).toBe(false);
});
it('rejects "@", whitespace, and quotes', () => {
expect(isValidSubAddressDelimiter('@')).toBe(false);
expect(isValidSubAddressDelimiter(' ')).toBe(false);
expect(isValidSubAddressDelimiter('\t')).toBe(false);
expect(isValidSubAddressDelimiter('"')).toBe(false);
});
it('rejects multi-character strings', () => {
expect(isValidSubAddressDelimiter('++')).toBe(false);
expect(isValidSubAddressDelimiter('abc')).toBe(false);
});
it('rejects empty / non-string inputs', () => {
expect(isValidSubAddressDelimiter('')).toBe(false);
expect(isValidSubAddressDelimiter(null)).toBe(false);
expect(isValidSubAddressDelimiter(undefined)).toBe(false);
expect(isValidSubAddressDelimiter(1)).toBe(false);
});
it('round-trips through parse/generate with a custom "~" delimiter', () => {
const generated = generateSubAddress('user@example.com', 'shopping', '~');
expect(generated).toBe('user~shopping@example.com');
const parsed = parseSubAddress(generated, '~');
expect(parsed.baseUser).toBe('user');
expect(parsed.tag).toBe('shopping');
});
});
});
+34
View File
@@ -138,4 +138,38 @@ describe('verifyJmapAuth SSRF protection', () => {
});
expect(fetchSpy).not.toHaveBeenCalled();
});
it('with trusted=true, accepts a hostname resolving to a private IP', async () => {
lookup.mockResolvedValue([{ address: '10.0.20.5', family: 4 }]);
fetchSpy.mockResolvedValueOnce(
new Response(JSON.stringify({ apiUrl: 'https://mail.internal/api', accounts: {} }), {
status: 200,
headers: { 'content-type': 'application/json' },
}),
);
const { verifyJmapAuth } = await load();
await expect(
verifyJmapAuth('https://mail.internal', 'Bearer x', { trusted: true }),
).resolves.toBe('https://mail.internal');
expect(fetchSpy).toHaveBeenCalledWith(
'https://mail.internal/.well-known/jmap',
expect.objectContaining({ redirect: 'manual' }),
);
});
it('with trusted=true, still rejects unsupported protocols', async () => {
const { verifyJmapAuth } = await load();
await expect(
verifyJmapAuth('file:///etc/passwd', 'Bearer x', { trusted: true }),
).rejects.toMatchObject({ status: 400 });
expect(fetchSpy).not.toHaveBeenCalled();
});
it('with trusted=true, still rejects an invalid Authorization header', async () => {
const { verifyJmapAuth } = await load();
await expect(
verifyJmapAuth('https://mail.internal', 'NotAuth', { trusted: true }),
).rejects.toMatchObject({ status: 400 });
expect(fetchSpy).not.toHaveBeenCalled();
});
});
+8 -2
View File
@@ -3,7 +3,7 @@ import { existsSync } from 'node:fs';
import path from 'node:path';
import { logger } from '@/lib/logger';
import { readFileEnv } from '@/lib/read-file-env';
import { CONFIG_ENV_MAP, DEFAULT_POLICY, DEFAULT_THEME_POLICY, type SettingsPolicy } from './types';
import { CONFIG_ENV_MAP, DEFAULT_FEATURE_GATES, DEFAULT_POLICY, DEFAULT_THEME_POLICY, type SettingsPolicy } from './types';
function getAdminDir(): string {
return process.env.ADMIN_DATA_DIR || path.join(process.cwd(), 'data', 'admin');
@@ -35,6 +35,7 @@ class ConfigManager {
this.policyCache = {
...DEFAULT_POLICY,
...policy,
features: { ...DEFAULT_FEATURE_GATES, ...(policy.features || {}) },
themePolicy: { ...DEFAULT_THEME_POLICY, ...(policy.themePolicy || {}) },
};
} else {
@@ -143,7 +144,12 @@ class ConfigManager {
* Update the settings policy. Writes to disk.
*/
async setPolicy(policy: SettingsPolicy): Promise<void> {
this.policyCache = { ...DEFAULT_POLICY, ...policy };
this.policyCache = {
...DEFAULT_POLICY,
...policy,
features: { ...DEFAULT_FEATURE_GATES, ...(policy.features || {}) },
themePolicy: { ...DEFAULT_THEME_POLICY, ...(policy.themePolicy || {}) },
};
await this.writeJsonFile('policy.json', this.policyCache as unknown as Record<string, unknown>);
}
+2
View File
@@ -39,6 +39,7 @@ export interface FeatureGates {
folderIconsEnabled: boolean;
hoverActionsConfigEnabled: boolean;
filesEnabled: boolean;
contactsEnabled: boolean;
}
export const DEFAULT_FEATURE_GATES: FeatureGates = {
@@ -58,6 +59,7 @@ export const DEFAULT_FEATURE_GATES: FeatureGates = {
folderIconsEnabled: true,
hoverActionsConfigEnabled: true,
filesEnabled: true,
contactsEnabled: true,
};
export interface ThemePolicy {
+7 -3
View File
@@ -40,11 +40,15 @@ export function validateProxyAuthHeader(authHeader: string): void {
}
}
export async function verifyJmapAuth(serverUrl: string, authHeader: string): Promise<string> {
export async function verifyJmapAuth(
serverUrl: string,
authHeader: string,
options: { trusted?: boolean } = {},
): Promise<string> {
const normalizedServerUrl = normalizeJmapServerUrl(serverUrl);
validateProxyAuthHeader(authHeader);
if (!(await isPublicHttpUrl(normalizedServerUrl))) {
if (!options.trusted && !(await isPublicHttpUrl(normalizedServerUrl))) {
throw new JmapAuthVerificationError('Server URL is not allowed', 400);
}
@@ -56,7 +60,7 @@ export async function verifyJmapAuth(serverUrl: string, authHeader: string): Pro
let response: Response | undefined;
for (let i = 0; i <= MAX_REDIRECTS; i++) {
if (!(await isPublicHttpUrl(currentUrl))) {
if (!options.trusted && !(await isPublicHttpUrl(currentUrl))) {
throw new JmapAuthVerificationError('Server URL is not allowed', 400);
}
+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);
+24 -3
View File
@@ -95,6 +95,18 @@ export class DemoJMAPClient implements IJMAPClient {
getLastStates(): AccountStates { return { ...this.lastStates }; }
setLastStates(states: AccountStates): void { this.lastStates = { ...states }; }
// PushSubscription endpoints have no meaning in demo mode - the demo client
// never makes real network calls so there's nothing for the relay to push to.
async listPushSubscriptions() { return []; }
async createPushSubscription(): Promise<string> {
throw new Error('Push subscriptions are not available in demo mode');
}
async verifyPushSubscription(): Promise<void> {
throw new Error('Push subscriptions are not available in demo mode');
}
async updatePushSubscription(): Promise<boolean> { return false; }
async destroyPushSubscription(): Promise<void> { /* no-op */ }
// ── Quota ─────────────────────────────────────────────────────
async getQuota(): Promise<{ used: number; total: number } | null> {
@@ -383,6 +395,7 @@ export class DemoJMAPClient implements IJMAPClient {
draftId?: string,
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>,
_fromName?: string,
htmlBody?: string,
): Promise<string> {
const draftsMb = this.data.mailboxes.find(m => m.role === 'drafts');
const id = draftId || generateDemoId('email');
@@ -402,9 +415,13 @@ export class DemoJMAPClient implements IJMAPClient {
sentAt: new Date().toISOString(),
preview: body.substring(0, 200),
hasAttachment: !!attachments?.length,
textBody: [{ partId: '1', blobId: generateDemoId('blob'), size: body.length, type: 'text/plain' }],
htmlBody: [],
bodyValues: { '1': { value: body } },
textBody: [{ partId: htmlBody ? 'text' : '1', blobId: generateDemoId('blob'), size: body.length, type: 'text/plain' }],
htmlBody: htmlBody
? [{ partId: 'html', blobId: generateDemoId('blob'), size: htmlBody.length, type: 'text/html' }]
: [],
bodyValues: htmlBody
? { text: { value: body }, html: { value: htmlBody } }
: { '1': { value: body } },
attachments: attachments?.map(a => ({ ...a, partId: generateDemoId('part') })),
messageId: `<${id}@demo.example.com>`,
};
@@ -430,6 +447,8 @@ export class DemoJMAPClient implements IJMAPClient {
_fromName?: string,
htmlBody?: string,
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>,
inReplyTo?: string[],
references?: string[],
): Promise<void> {
// Remove draft if updating
if (draftId) {
@@ -455,6 +474,8 @@ export class DemoJMAPClient implements IJMAPClient {
bodyValues: htmlBody ? { '1': { value: body }, '2': { value: htmlBody } } : { '1': { value: body } },
attachments: attachments?.map(a => ({ ...a, partId: generateDemoId('part') })),
messageId: `<${generateDemoId('msg')}@demo.example.com>`,
inReplyTo: inReplyTo?.length ? inReplyTo : undefined,
references: references?.length ? references : undefined,
};
this.data.emails.push(email);
this.recalcMailboxCounts();
+1
View File
@@ -151,6 +151,7 @@ export function collapseBlockedImageContainers(html: string): string {
const hasVisibleMedia = el.querySelector('img:not([data-blocked-src]), video, canvas');
const hasLinks = el.querySelector('a[href]');
if (!hasVisibleText && !hasVisibleMedia && !hasLinks) {
el.setAttribute('data-blocked-collapsed-style', el.style.cssText);
el.style.display = 'none';
el.style.height = '0';
el.style.padding = '0';
+51
View File
@@ -0,0 +1,51 @@
/**
* RFC 5322 §3.6.4 reply threading.
*
* Computes the In-Reply-To and References headers an outgoing reply must
* carry so MUAs can stitch the conversation back together.
*
* In-Reply-To = parent.Message-ID
* References = parent.References (if any) + parent.Message-ID
*
* Bare msg-ids only angle brackets are stripped because JMAP RFC 8621
* §4.1.2.3 stores Message-IDs without them.
*/
export interface ParentThreadingInfo {
// JMAP RFC 8621 §4.1.2.3 specifies messageId as String[]|null, but the
// codebase has historically typed it as string. Accept either shape.
messageId?: string | string[];
references?: string[];
}
export interface ReplyThreadingHeaders {
inReplyTo: string[];
references: string[];
}
export function stripMessageIdBrackets(id: string): string {
return id.trim().replace(/^<+/, '').replace(/>+$/, '').trim();
}
export function computeReplyThreadingHeaders(
parent: ParentThreadingInfo | undefined,
): ReplyThreadingHeaders | null {
const rawId = Array.isArray(parent?.messageId) ? parent.messageId[0] : parent?.messageId;
const parentId = rawId ? stripMessageIdBrackets(rawId) : '';
if (!parentId) return null;
const ancestors = (parent?.references ?? [])
.map(stripMessageIdBrackets)
.filter(Boolean);
// De-dupe while preserving order; the parent's id closes the chain.
const seen = new Set<string>();
const references: string[] = [];
for (const id of [...ancestors, parentId]) {
if (seen.has(id)) continue;
seen.add(id);
references.push(id);
}
return { inReplyTo: [parentId], references };
}
+18 -1
View File
@@ -1,4 +1,4 @@
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, Principal } from "./types";
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, Principal, PushSubscription } from "./types";
import type { SieveScript, SieveCapabilities } from "./sieve-types";
/**
@@ -51,6 +51,20 @@ export interface IJMAPClient {
getLastStates(): AccountStates;
setLastStates(states: AccountStates): void;
// ── PushSubscription (RFC 8620 §7.2) ───────────────────────────
// Browser-driven Web Push setup: register a relay URL the JMAP server can
// forward StateChange events to. Mobile uses the same primitives.
listPushSubscriptions(): Promise<PushSubscription[]>;
createPushSubscription(params: {
deviceClientId: string;
url: string;
types: string[];
expires?: string;
}): Promise<string>;
verifyPushSubscription(id: string, verificationCode: string): Promise<void>;
updatePushSubscription(id: string, patch: { expires?: string; types?: string[] }): Promise<boolean>;
destroyPushSubscription(id: string): Promise<void>;
// ── Quota ─────────────────────────────────────────────────────
getQuota(): Promise<{ used: number; total: number } | null>;
@@ -115,6 +129,7 @@ export interface IJMAPClient {
draftId?: string,
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>,
fromName?: string,
htmlBody?: string,
): Promise<string>;
sendEmail(
@@ -129,6 +144,8 @@ export interface IJMAPClient {
fromName?: string,
htmlBody?: string,
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>,
inReplyTo?: string[],
references?: string[],
): Promise<void>;
sendImipReply(opts: {
+130 -11
View File
@@ -1,4 +1,4 @@
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, FileNodeFilter, Principal } from "./types";
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, FileNodeFilter, Principal, PushSubscription } from "./types";
import type { SieveScript, SieveCapabilities } from "./sieve-types";
import type { IJMAPClient } from "./client-interface";
import { toWildcardQuery } from "./search-utils";
@@ -294,6 +294,22 @@ function foldIcsLine(line: string): string {
return chunks.join('\r\n');
}
// JMAP RFC 8621 stores Message-IDs without angle brackets. Strip any that
// snuck in (e.g. when echoing values that originated from RFC 5322 headers).
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;
@@ -1748,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 [];
@@ -1933,7 +1950,8 @@ export class JMAPClient implements IJMAPClient {
fromEmail?: string,
draftId?: string,
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>,
fromName?: string
fromName?: string,
htmlBody?: string
): Promise<string> {
const mailboxes = await this.getMailboxes();
const draftsMailbox = mailboxes.find(mb => mb.role === 'drafts');
@@ -1952,20 +1970,27 @@ export class JMAPClient implements IJMAPClient {
keywords: Record<string, boolean>;
mailboxIds: Record<string, boolean>;
bodyValues: Record<string, { value: string }>;
textBody: { partId: string }[];
textBody: { partId: string; type?: string }[];
htmlBody?: { partId: string; type: string }[];
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 })),
subject,
keywords: { "$draft": true },
mailboxIds: { [draftsMailbox.id]: true },
bodyValues: { "1": { value: body } },
textBody: [{ partId: "1" }],
bodyValues: htmlBody
? { "text": { value: body }, "html": { value: htmlBody } }
: { "1": { value: body } },
textBody: htmlBody
? [{ partId: "text", type: "text/plain" }]
: [{ partId: "1" }],
...(htmlBody ? { htmlBody: [{ partId: "html", type: "text/html" }] } : {}),
};
if (attachments?.length) {
@@ -2027,7 +2052,9 @@ export class JMAPClient implements IJMAPClient {
draftId?: string,
fromName?: string,
htmlBody?: string,
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>,
inReplyTo?: string[],
references?: string[]
): Promise<void> {
const emailId = `send-${Date.now()}`;
const mailboxes = await this.getMailboxes();
@@ -2067,14 +2094,22 @@ export class JMAPClient implements IJMAPClient {
}
}
// Per RFC 8621 §4.1.2.3 inReplyTo/references are arrays of bare msg-ids
// (no angle brackets). Stalwart may return them either way, so normalize.
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 })),
bcc: bcc?.map(email => ({ email })),
subject,
inReplyTo: normalizedInReplyTo?.length ? normalizedInReplyTo : undefined,
references: normalizedReferences?.length ? normalizedReferences : undefined,
keywords: { "$seen": true, "$draft": true },
mailboxIds: { [draftsMailbox.id]: true },
};
@@ -3057,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[] {
@@ -5291,4 +5334,80 @@ export class JMAPClient implements IJMAPClient {
}
}
}
// ── PushSubscription (RFC 8620 §7.2) ──────────────────────────────
// Used by the PWA Web Push integration. The mobile app does the same dance
// through its own JMAP client - keep these in sync.
async listPushSubscriptions(): Promise<PushSubscription[]> {
const response = await this.request(
[['PushSubscription/get', { ids: null }, '0']],
['urn:ietf:params:jmap:core'],
);
const [, body] = response.methodResponses[0] ?? [];
return ((body as { list?: PushSubscription[] } | undefined)?.list) ?? [];
}
async createPushSubscription(params: {
deviceClientId: string;
url: string;
types: string[];
expires?: string;
}): Promise<string> {
const created: Record<string, unknown> = {
deviceClientId: params.deviceClientId,
url: params.url,
types: params.types,
};
if (params.expires) created.expires = params.expires;
const response = await this.request(
[['PushSubscription/set', { create: { new: created } }, '0']],
['urn:ietf:params:jmap:core'],
);
const [, body] = response.methodResponses[0] ?? [];
const result = (body as { created?: { new?: { id?: string } }; notCreated?: { new?: unknown } } | undefined);
const id = result?.created?.new?.id;
if (!id) {
throw new Error(
`PushSubscription/set create failed: ${JSON.stringify(result?.notCreated?.new ?? body)}`,
);
}
return id;
}
async verifyPushSubscription(id: string, verificationCode: string): Promise<void> {
const response = await this.request(
[['PushSubscription/set', { update: { [id]: { verificationCode } } }, '0']],
['urn:ietf:params:jmap:core'],
);
const [, body] = response.methodResponses[0] ?? [];
const notUpdated = (body as { notUpdated?: Record<string, unknown> } | undefined)?.notUpdated?.[id];
if (notUpdated) {
throw new Error(`PushSubscription verification failed: ${JSON.stringify(notUpdated)}`);
}
}
// Returns false when the server rejects the update (e.g. the subscription
// was already destroyed) - the caller treats that as a signal to recreate.
async updatePushSubscription(
id: string,
patch: { expires?: string; types?: string[] },
): Promise<boolean> {
const response = await this.request(
[['PushSubscription/set', { update: { [id]: patch } }, '0']],
['urn:ietf:params:jmap:core'],
);
const [, body] = response.methodResponses[0] ?? [];
const r = body as { updated?: Record<string, unknown>; notUpdated?: Record<string, unknown> } | undefined;
if (r?.notUpdated?.[id]) return false;
return r?.updated?.[id] !== undefined;
}
async destroyPushSubscription(id: string): Promise<void> {
await this.request(
[['PushSubscription/set', { destroy: [id] }, '0']],
['urn:ietf:params:jmap:core'],
);
}
}
+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 {
+1 -1
View File
@@ -88,7 +88,7 @@ export const pluginStorage = {
await deleteItem(STORE_THEMES, themeId);
},
// Theme skin CSS separate store so it can be present/absent independently
// Theme skin CSS - separate store so it can be present/absent independently
// of the colour-token CSS (e.g. some v2 themes ship colours only).
async saveThemeSkin(themeId: string, skin: string): Promise<void> {
await putItem(STORE_THEME_SKINS, themeId, skin);
+136 -5
View File
@@ -12,7 +12,7 @@ export type ThemeVariant = 'light' | 'dark';
// ─── Manifests ───────────────────────────────────────────────
/**
* Advanced theme fields ("Theme API v2"). All optional and additive a
* Advanced theme fields ("Theme API v2"). All optional and additive - a
* legacy theme that ships only `:root`/`.dark` CSS continues to work.
*
* When `apiVersion >= 2` (or any of `tokens`/`extends`/`derive`/`density`/
@@ -62,7 +62,7 @@ export interface ThemeManifest {
apiVersion?: 1 | 2;
/** Inherit tokens/CSS from another installed (or built-in) theme by id. */
extends?: string;
/** Structured colour tokens compiled into CSS at install time. */
/** Structured colour tokens - compiled into CSS at install time. */
tokens?: ThemeTokenSet;
/** When true, missing standard tokens are derived (e.g. *-foreground from contrast). */
derive?: boolean;
@@ -121,10 +121,10 @@ export interface InstalledTheme {
author: string;
description: string;
preview?: string; // data: URI or blob URL
css: string; // compiled CSS text what gets injected
css: string; // compiled CSS text - what gets injected
/**
* Optional "skin" CSS shipped by Theme API v2 themes that need to restyle
* actual UI components (toolbars, lists, buttons, etc.) not just colour
* actual UI components (toolbars, lists, buttons, etc.) - not just colour
* tokens. Injected into a separate `<style>` tag so it can be stripped
* cleanly when the theme is deactivated. Stored in IndexedDB with the same
* lifecycle as `css` to keep localStorage small.
@@ -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 ─────────────────────────────────────────
/**
@@ -598,7 +729,7 @@ export const MAX_PLUGIN_SIZE = 5 * 1024 * 1024; // 5 MB
export const MAX_THEME_SIZE = 2 * 1024 * 1024; // 2 MB (was 1 MB; v2 themes may ship a skin.css)
/**
* Maximum size of an individual `skin.css` payload after extraction.
* Skins are component-level CSS, not images anything bigger than this is
* Skins are component-level CSS, not images - anything bigger than this is
* almost certainly bundling assets the validator will refuse anyway.
*/
export const MAX_THEME_SKIN_BYTES = 256 * 1024; // 256 KB
+4 -4
View File
@@ -24,7 +24,7 @@ export interface ThemeExtractionResult extends ValidationResult {
manifest: ThemeManifest | null;
css: string;
/**
* Optional skin CSS component-level overrides extracted from `skin.css`.
* Optional skin CSS - component-level overrides extracted from `skin.css`.
* Only populated for Theme API v2 manifests; v1 themes ignore the file.
*/
skin: string | null;
@@ -225,7 +225,7 @@ export async function extractTheme(file: File): Promise<ThemeExtractionResult> {
return { valid: false, errors, warnings, manifest: null, css: '', skin: null, preview: null };
}
// Read theme.css required for v1 themes, optional when the manifest
// Read theme.css - required for v1 themes, optional when the manifest
// declares Theme API v2 fields (tokens/extends/derive/density/radii/typography),
// since the compiler can produce CSS purely from the manifest.
const cssFile = zip.file(root + 'theme.css');
@@ -279,13 +279,13 @@ export async function extractTheme(file: File): Promise<ThemeExtractionResult> {
}
// Read skin.css if present (Theme API v2 only). Skins target real
// component selectors and bypass the strict :root/.dark selector check
// component selectors and bypass the strict :root/.dark selector check -
// they still go through the dangerous-pattern sanitizer.
let skin: string | null = null;
const skinFile = zip.file(root + 'skin.css');
if (skinFile) {
if (!isAdvanced) {
warnings.push('skin.css ignored only Theme API v2 manifests can ship a skin');
warnings.push('skin.css ignored - only Theme API v2 manifests can ship a skin');
} else {
const rawSkin = await skinFile.async('string');
if (rawSkin.length > MAX_THEME_SKIN_BYTES) {
+45 -14
View File
@@ -1,12 +1,32 @@
/**
* Sub-addressing utilities for user+tag@domain.com format
* Sub-addressing utilities for user{delimiter}tag@domain.com format
* Works server-side automatically - no JMAP API calls needed
*
* The delimiter character is configurable per server (RFC 5233). Common
* choices: "+" (Postfix, Stalwart default), "-" (qmail), ".", "=".
*/
// Constants for tag validation
const MAX_TAG_LENGTH = 30;
const TAG_REGEX = /^[a-zA-Z0-9-]{1,30}$/;
export const DEFAULT_SUB_ADDRESS_DELIMITER = '+';
export const SUPPORTED_SUB_ADDRESS_DELIMITERS = ['+', '-', '.', '='] as const;
export type SubAddressDelimiterPreset = (typeof SUPPORTED_SUB_ADDRESS_DELIMITERS)[number];
export function isSupportedSubAddressDelimiter(value: string): value is SubAddressDelimiterPreset {
return (SUPPORTED_SUB_ADDRESS_DELIMITERS as readonly string[]).includes(value);
}
// RFC 5321 atext "special" characters, minus alphanumerics and "@". A custom
// delimiter must be exactly one of these — they're safe to embed in a local
// part and unambiguously separate the user from the tag.
const VALID_DELIMITER_REGEX = /^[!#$%&'*+\-./=?^_`{|}~]$/;
export function isValidSubAddressDelimiter(value: unknown): value is string {
return typeof value === 'string' && VALID_DELIMITER_REGEX.test(value);
}
export type TagValidationErrorCode =
| 'EMPTY'
| 'TOO_LONG'
@@ -22,10 +42,14 @@ export interface ParsedAddress {
}
/**
* Parse an email address to extract sub-address tag
* Example: "user+shopping@example.com" -> { baseUser: "user", tag: "shopping" }
* Parse an email address to extract sub-address tag.
* The first occurrence of the delimiter in the local part separates the
* base user from the tag, matching the behavior of Postfix/qmail/Sieve.
*/
export function parseSubAddress(email: string): ParsedAddress {
export function parseSubAddress(
email: string,
delimiter: string = DEFAULT_SUB_ADDRESS_DELIMITER,
): ParsedAddress {
const [localPart, domain] = email.split('@');
if (!localPart || !domain) {
@@ -38,9 +62,9 @@ export function parseSubAddress(email: string): ParsedAddress {
};
}
const plusIndex = localPart.indexOf('+');
const delimiterIndex = localPart.indexOf(delimiter);
if (plusIndex === -1) {
if (delimiterIndex === -1) {
return {
localPart,
baseUser: localPart,
@@ -50,8 +74,8 @@ export function parseSubAddress(email: string): ParsedAddress {
};
}
const baseUser = localPart.substring(0, plusIndex);
const tag = localPart.substring(plusIndex + 1);
const baseUser = localPart.substring(0, delimiterIndex);
const tag = localPart.substring(delimiterIndex + delimiter.length);
return {
localPart,
@@ -63,18 +87,25 @@ export function parseSubAddress(email: string): ParsedAddress {
}
/**
* Generate a sub-addressed email
* Example: generateSubAddress("user@example.com", "shopping") -> "user+shopping@example.com"
* Generate a sub-addressed email.
* Example: generateSubAddress("user@example.com", "shopping", "+") -> "user+shopping@example.com"
*/
export function generateSubAddress(baseEmail: string, tag: string): string {
export function generateSubAddress(
baseEmail: string,
tag: string,
delimiter: string = DEFAULT_SUB_ADDRESS_DELIMITER,
): string {
const [localPart, domain] = baseEmail.split('@');
if (!localPart || !domain || !tag) {
return baseEmail;
}
// Remove existing tag if present
const cleanLocal = localPart.split('+')[0];
// Strip an existing tag if one is already present
const existingDelimiterIndex = localPart.indexOf(delimiter);
const cleanLocal = existingDelimiterIndex === -1
? localPart
: localPart.substring(0, existingDelimiterIndex);
// Sanitize tag (alphanumeric and dash only)
const cleanTag = tag.replace(/[^a-zA-Z0-9-]/g, '').toLowerCase();
@@ -83,7 +114,7 @@ export function generateSubAddress(baseEmail: string, tag: string): string {
return baseEmail;
}
return `${cleanLocal}+${cleanTag}@${domain}`;
return `${cleanLocal}${delimiter}${cleanTag}@${domain}`;
}
/**
+2 -2
View File
@@ -6,7 +6,7 @@ import { isIP } from 'node:net';
// URL; without this an attacker with a session (or a hostile admin in a
// multi-tenant deploy) could redirect heartbeats at internal hosts.
//
// Set BULWARK_TELEMETRY_ALLOW_PRIVATE=1 to bypass useful only for local
// Set BULWARK_TELEMETRY_ALLOW_PRIVATE=1 to bypass - useful only for local
// dev where the collector is on the loopback.
const PRIVATE_V4: RegExp[] = [
@@ -106,7 +106,7 @@ export async function resolveEndpointAllowed(raw: string): Promise<EndpointCheck
}
return { ok: true };
} catch {
// Don't block on transient DNS failures fetch will fail loudly anyway,
// Don't block on transient DNS failures - fetch will fail loudly anyway,
// and we don't want to lock admins out of their config when the resolver
// is flaky. The literal-IP check above already covers the direct-attack
// case.
+58 -11
View File
@@ -1,6 +1,8 @@
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { configManager } from '@/lib/admin/config-manager';
import { logger } from '@/lib/logger';
import { resolveEndpointAllowed } from './endpoint-guard';
import { getInstanceId } from './state';
import { getLoginCounts } from './login-tracker';
import type {
@@ -58,23 +60,67 @@ export function bucketCount(n: number): CountBucket {
async function readFeatures(): Promise<TelemetryFeatures> {
await configManager.ensureLoaded();
const policy = configManager.getPolicy();
const gates = policy.features ?? {};
const gates = configManager.getPolicy().features;
const cfg = configManager.getAll();
return {
// Booleans only. We read whether a feature is enabled - never any
// config value beyond a presence check.
calendar: gates.calendarTasksEnabled !== false,
contacts: true,
files: gates.filesEnabled === true,
extensions: gates.pluginsEnabled !== false,
push_relay: !!cfg['pushRelayUrl'],
oauth_enabled: !!cfg['oauthClientId'],
smime_enabled: gates.smimeEnabled === true,
webdav_enabled: gates.filesEnabled === true,
calendar: gates.calendarTasksEnabled === true,
contacts: gates.contactsEnabled === true,
files: gates.filesEnabled === true,
extensions: gates.pluginsEnabled === true,
oauth_enabled: cfg['oauthEnabled'] === true,
smime_enabled: gates.smimeEnabled === true,
};
}
const STALWART_VERSION_TTL_MS = 24 * 60 * 60 * 1000;
let stalwartVersionCache: { version: string | null; fetchedAt: number } | null = null;
// Stalwart returns the version in the Server response header
// (e.g. "Stalwart Mail Server v0.16.0"). The /.well-known/jmap endpoint
// requires auth, but the header is on the 401 response too, so an
// unauthenticated GET is enough. Cached for a day to avoid hammering
// the JMAP server on every payload preview.
async function detectStalwartVersion(): Promise<string | null> {
if (process.env.STALWART_VERSION) return process.env.STALWART_VERSION;
if (stalwartVersionCache &&
Date.now() - stalwartVersionCache.fetchedAt < STALWART_VERSION_TTL_MS) {
return stalwartVersionCache.version;
}
await configManager.ensureLoaded();
const serverUrl = configManager.get<string>('jmapServerUrl', '').trim();
if (!serverUrl) {
stalwartVersionCache = { version: null, fetchedAt: Date.now() };
return null;
}
const wellKnown = `${serverUrl.replace(/\/+$/, '')}/.well-known/jmap`;
// Reuse the SSRF guard so a misconfigured JMAP_SERVER_URL pointing at an
// internal host doesn't get probed from telemetry context either.
const guard = await resolveEndpointAllowed(wellKnown);
if (!guard.ok) {
stalwartVersionCache = { version: null, fetchedAt: Date.now() };
return null;
}
try {
const res = await fetch(wellKnown, {
method: 'GET',
signal: AbortSignal.timeout(3000),
});
const server = res.headers.get('server') ?? '';
const m = server.match(/(\d+\.\d+\.\d+(?:-[\w.]+)?)/);
const version = m?.[1] ?? null;
stalwartVersionCache = { version, fetchedAt: Date.now() };
return version;
} catch (err) {
logger.debug?.('telemetry: stalwart version probe failed', {
error: err instanceof Error ? err.message : String(err),
});
stalwartVersionCache = { version: null, fetchedAt: Date.now() };
return null;
}
}
// Account counts come from the local login tracker, which records a per-
// instance HMAC of every successful login plus the timestamp. Total = unique
// identities seen in the last 90 days; active7d = identities with a login in
@@ -99,6 +145,7 @@ export async function buildPayload(): Promise<TelemetryPayload> {
const features = await readFeatures();
const accounts = await getLoginCounts();
const exts = await countExtensions();
const stalwart_version = await detectStalwartVersion();
const uptime_days = Math.min(
365,
Math.floor((Date.now() - processStartedAt) / 86_400_000),
@@ -113,7 +160,7 @@ export async function buildPayload(): Promise<TelemetryPayload> {
platform: detectPlatform(),
node_version: process.versions.node,
os_family: detectOs(),
stalwart_version: process.env.STALWART_VERSION ?? null,
stalwart_version,
features,
counts: {
accounts: bucketCount(accounts.total),
+1 -1
View File
@@ -41,7 +41,7 @@ export async function getInstanceId(): Promise<string> {
return fresh;
}
// Default consent is 'on' telemetry is anonymous and enabled by default.
// Default consent is 'on' - telemetry is anonymous and enabled by default.
// Admins can disable via the UI, the BULWARK_TELEMETRY env var, or by clearing
// the endpoint. See https://bulwarkmail.org/docs/legal/privacy/telemetry.
const DEFAULTS: TelemetryStateFile = {
-2
View File
@@ -12,10 +12,8 @@ export interface TelemetryFeatures {
contacts: boolean;
files: boolean;
extensions: boolean;
push_relay: boolean;
oauth_enabled: boolean;
smime_enabled: boolean;
webdav_enabled: boolean;
}
export interface TelemetryPayload {
+8 -8
View File
@@ -1,4 +1,4 @@
// Advanced Theme API v2 compiles structured manifest fields (tokens,
// Advanced Theme API v2 - compiles structured manifest fields (tokens,
// radii, typography, density, extends) into a single CSS string that the
// existing `injectThemeCSS` pipeline can apply unchanged.
@@ -79,11 +79,11 @@ function emitTokens(
for (const [rawKey, value] of Object.entries(expanded)) {
if (typeof value !== 'string' || !value.trim()) continue;
if (!isSafeTokenKey(rawKey)) {
warnings.push(`Token "${rawKey}" dropped invalid key (only [a-z0-9-] allowed)`);
warnings.push(`Token "${rawKey}" dropped - invalid key (only [a-z0-9-] allowed)`);
continue;
}
if (!isSafeTokenValue(value)) {
warnings.push(`Token "${rawKey}" dropped value contains unsafe characters`);
warnings.push(`Token "${rawKey}" dropped - value contains unsafe characters`);
continue;
}
lines.push(` ${tokenName(rawKey)}: ${value.trim()};`);
@@ -170,7 +170,7 @@ export interface CompileOptions {
/**
* Resolves a `extends: <id>` chain to that base theme's compiled CSS.
* Implementations should return null for unknown ids; circular refs are
* the caller's problem (we don't recurse just one level of inheritance).
* the caller's problem (we don't recurse - just one level of inheritance).
*/
resolveExtends?: (id: string) => string | null;
/**
@@ -211,16 +211,16 @@ export function compileAdvancedTheme(
const sections: string[] = [];
// 1. extends prepend parent CSS verbatim
// 1. extends - prepend parent CSS verbatim
if (manifest.extends && opts.resolveExtends) {
const parentCSS = opts.resolveExtends(manifest.extends);
if (parentCSS == null) {
warnings.push(`extends: parent theme "${manifest.extends}" not found skipping`);
warnings.push(`extends: parent theme "${manifest.extends}" not found - skipping`);
} else {
sections.push(`/* inherited from ${manifest.extends} */\n${parentCSS}`);
}
} else if (manifest.extends) {
warnings.push(`extends: no resolver provided "${manifest.extends}" ignored`);
warnings.push(`extends: no resolver provided - "${manifest.extends}" ignored`);
}
// 2. :root block (light + common + structural)
@@ -268,7 +268,7 @@ export function compileAdvancedTheme(
}
if (sections.length === 0) {
errors.push('Compiled theme is empty no tokens, radii, typography, or density supplied');
errors.push('Compiled theme is empty - no tokens, radii, typography, or density supplied');
}
return {
+2 -2
View File
@@ -91,7 +91,7 @@ export function removeThemeCSS(): void {
}
/**
* Inject a theme's *skin* CSS component-level overrides shipped by Theme
* Inject a theme's *skin* CSS - component-level overrides shipped by Theme
* API v2 themes via `skin.css`. Lives in a separate `<style>` tag so it can
* be removed cleanly without touching the colour-token block, and is placed
* AFTER the colour block so component rules win specificity.
@@ -124,7 +124,7 @@ export function removeThemeSkinCSS(): void {
}
/**
* Sanitize a theme *skin* looser than `sanitizeThemeCSS` because skins
* Sanitize a theme *skin* - looser than `sanitizeThemeCSS` because skins
* intentionally target real component selectors (toolbars, lists, buttons),
* not just `:root`/`.dark`. The same script-injection / external-resource
* prohibitions still apply.
+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/';
+356
View File
@@ -0,0 +1,356 @@
// Browser-side Web Push setup. Mirrors the React Native flow in
// repos/react-native/src/lib/push-notifications.ts so the relay sees the same
// shape from both clients - the only differences are which native API
// produces the push token (PushManager.subscribe here, FCM there) and which
// register endpoint we hit on the relay.
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.
export const DEFAULT_RELAY_BASE_URL =
process.env.NEXT_PUBLIC_PUSH_RELAY_URL || 'https://notifications.relay.bulwarkmail.org';
// Match the mobile app's lifetime hint. The JMAP server may clamp this down.
const SUBSCRIPTION_EXPIRES_DAYS = 90;
const SUBSCRIPTION_REFRESH_THRESHOLD_DAYS = 7;
// Only `EmailDelivery` state-changes when new mail is actually delivered.
// `Email` fires for any mutation (sending, drafting, moving, marking read,
// deleting) and `Mailbox` fires for mailbox edits — both produced spurious
// system notifications, so we keep them out of the push subscription.
// In-app sync uses a separate StateChange channel and is unaffected.
const PUSH_TYPES = ['EmailDelivery'] as const;
function sameTypes(a: readonly string[] | null | undefined, b: readonly string[]): boolean {
if (!a || a.length !== b.length) return false;
const sortedA = [...a].sort();
const sortedB = [...b].sort();
return sortedA.every((t, i) => t === sortedB[i]);
}
export interface EnableWebPushParams {
client: IJMAPClient;
// Optional - falls back to DEFAULT_RELAY_BASE_URL.
relayBaseUrl?: string;
// Free-form label the relay shows in /metrics; never returned in pushes.
accountLabel?: string;
}
export interface EnableWebPushResult {
subscriptionId: string;
}
export class WebPushUnsupportedError extends Error {
constructor(message: string) {
super(message);
this.name = 'WebPushUnsupportedError';
}
}
export function isWebPushSupported(): boolean {
if (typeof window === 'undefined') return false;
return (
'serviceWorker' in navigator &&
'PushManager' in window &&
'Notification' in window
);
}
function buildRelayUrl(base: string, suffix: string): string {
return base.replace(/\/+$/, '') + suffix;
}
function expiresFromNow(days: number): string {
return new Date(Date.now() + days * 24 * 60 * 60 * 1000).toISOString();
}
function randomDeviceClientId(): string {
const bytes = new Uint8Array(16);
crypto.getRandomValues(bytes);
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
}
function getOrCreateDeviceClientId(): string {
const existing = localStorage.getItem(DEVICE_CLIENT_ID_KEY);
if (existing) return existing;
const next = randomDeviceClientId();
localStorage.setItem(DEVICE_CLIENT_ID_KEY, next);
return next;
}
// PushManager.subscribe wants the VAPID public key as a BufferSource.
// Returning a Uint8Array<ArrayBuffer> (not the wider ArrayBufferLike that
// includes SharedArrayBuffer) keeps strict TS happy on lib.dom 2024+.
function urlBase64ToUint8Array(base64Url: string): Uint8Array<ArrayBuffer> {
const padding = '='.repeat((4 - (base64Url.length % 4)) % 4);
const base64 = (base64Url + padding).replace(/-/g, '+').replace(/_/g, '/');
const raw = atob(base64);
const buffer = new ArrayBuffer(raw.length);
const out = new Uint8Array(buffer);
for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
return out;
}
function readPushKey(
sub: PushSubscription,
name: 'p256dh' | 'auth',
): string {
const raw = sub.getKey(name);
if (!raw) throw new Error(`PushSubscription is missing the ${name} key`);
// Browsers want application/json over the wire so encode as base64url.
let binary = '';
const bytes = new Uint8Array(raw);
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
async function fetchVapidPublicKey(relayBaseUrl: string): Promise<string> {
const res = await fetch(buildRelayUrl(relayBaseUrl, '/api/push/vapid-public-key'));
if (!res.ok) {
if (res.status === 503) {
throw new Error('The push relay does not have Web Push configured');
}
throw new Error(`Failed to fetch VAPID key: ${res.status}`);
}
const body = (await res.json()) as { publicKey?: string };
if (!body.publicKey) throw new Error('Relay returned an empty VAPID key');
return body.publicKey;
}
async function ensurePermission(): Promise<void> {
if (Notification.permission === 'granted') return;
if (Notification.permission === 'denied') {
throw new Error('Notifications are blocked - allow them in browser settings to continue');
}
const result = await Notification.requestPermission();
if (result !== 'granted') {
throw new Error('Notification permission was not granted');
}
}
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(SW_SCOPE);
if (!registration) {
registration = await navigator.serviceWorker.register(SW_URL, { scope: SW_SCOPE });
}
await navigator.serviceWorker.ready;
return registration;
}
async function registerWithRelay(params: {
relayBaseUrl: string;
subscriptionId: string;
// Subset of PushSubscriptionJSON we actually serialise. Inlined so eslint's
// no-undef rule (which doesn't know about DOM type-only globals) is happy.
subscription: {
endpoint: string;
keys: { p256dh: string; auth: string };
};
accountLabel?: string;
}): Promise<void> {
const { endpoint, keys } = params.subscription;
if (!endpoint || !keys?.p256dh || !keys?.auth) {
throw new Error('Browser returned an incomplete PushSubscription');
}
const res = await fetch(buildRelayUrl(params.relayBaseUrl, '/api/push/register/web'), {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
subscriptionId: params.subscriptionId,
subscription: { endpoint, keys: { p256dh: keys.p256dh, auth: keys.auth } },
accountLabel: params.accountLabel,
}),
});
if (!res.ok) {
throw new Error(`Relay register failed: ${res.status}`);
}
}
async function pollVerificationCode(
relayBaseUrl: string,
subscriptionId: string,
): Promise<string> {
// Stalwart per-account rate-limits PushVerification posts (default 60s).
// If there are leftover unverified subscriptions on the account, our new
// one queues up behind them - so we wait long enough to clear one verify
// window even in the unlucky case.
const timeoutAt = Date.now() + 75_000;
let delay = 400;
while (Date.now() < timeoutAt) {
const res = await fetch(
buildRelayUrl(relayBaseUrl, `/api/push/verify/${encodeURIComponent(subscriptionId)}`),
);
if (res.ok) {
const body = (await res.json()) as { verificationCode?: string | null };
if (body.verificationCode) return body.verificationCode;
}
await new Promise((r) => setTimeout(r, delay));
delay = Math.min(delay * 1.5, 2000);
}
throw new Error('Timed out waiting for PushVerification from the JMAP server');
}
async function refreshSubscriptionExpires(
client: IJMAPClient,
sub: { id: string; expires: string | null; types: string[] | null },
): Promise<boolean> {
const typesNeedUpdate = !sameTypes(sub.types, PUSH_TYPES);
if (!typesNeedUpdate && sub.expires) {
const remainingMs = new Date(sub.expires).getTime() - Date.now();
const thresholdMs = SUBSCRIPTION_REFRESH_THRESHOLD_DAYS * 24 * 60 * 60 * 1000;
if (Number.isFinite(remainingMs) && remainingMs > thresholdMs) return true;
}
try {
const patch: { expires?: string; types?: string[] } = {
expires: expiresFromNow(SUBSCRIPTION_EXPIRES_DAYS),
};
if (typesNeedUpdate) patch.types = [...PUSH_TYPES];
return await client.updatePushSubscription(sub.id, patch);
} catch {
return false;
}
}
export async function enableWebPush(
params: EnableWebPushParams,
): Promise<EnableWebPushResult> {
if (!isWebPushSupported()) {
throw new WebPushUnsupportedError(
'This browser does not support Web Push. On iOS the site needs to be installed to the home screen.',
);
}
const relayBaseUrl = (params.relayBaseUrl ?? DEFAULT_RELAY_BASE_URL).replace(/\/+$/, '');
if (!relayBaseUrl) throw new Error('relayBaseUrl is required');
await ensurePermission();
const registration = await ensureServiceWorker();
const vapidPublicKey = await fetchVapidPublicKey(relayBaseUrl);
// Reuse an existing browser PushSubscription when possible - resubscribing
// with the same VAPID key produces the same endpoint, but the call still
// costs a network round-trip the user can feel.
let pushSubscription = await registration.pushManager.getSubscription();
if (pushSubscription) {
const keyMatches = pushSubscription.options?.applicationServerKey;
if (!keyMatches) {
await pushSubscription.unsubscribe();
pushSubscription = null;
}
}
if (!pushSubscription) {
pushSubscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
});
}
const deviceClientId = getOrCreateDeviceClientId();
await registerWithRelay({
relayBaseUrl,
subscriptionId: deviceClientId,
subscription: {
endpoint: pushSubscription.endpoint,
keys: {
p256dh: readPushKey(pushSubscription, 'p256dh'),
auth: readPushKey(pushSubscription, 'auth'),
},
},
accountLabel: params.accountLabel,
});
// Reuse the JMAP-side PushSubscription if the server still has it, just
// refreshing the expiry so it doesn't time out between sessions.
const existingSubs = await params.client.listPushSubscriptions().catch(() => []);
const storedServerId = localStorage.getItem(SUBSCRIPTION_ID_KEY);
if (storedServerId) {
const match = existingSubs.find((s) => s.id === storedServerId);
if (match) {
const refreshed = await refreshSubscriptionExpires(params.client, match);
if (refreshed) return { subscriptionId: storedServerId };
await params.client.destroyPushSubscription(storedServerId).catch(() => undefined);
}
localStorage.removeItem(SUBSCRIPTION_ID_KEY);
}
// Reap any leftover subscriptions still bound to this device. These pile
// up when a previous enable attempt failed mid-flow (verification timed
// out, browser tab closed, etc). Stalwart per-account rate-limits
// verification posts, so leaving stragglers around blocks the new one.
const stragglers = existingSubs.filter(
(s) => s.deviceClientId === deviceClientId && s.id !== storedServerId,
);
for (const s of stragglers) {
await params.client.destroyPushSubscription(s.id).catch(() => undefined);
}
const serverAssignedId = await params.client.createPushSubscription({
deviceClientId,
url: buildRelayUrl(relayBaseUrl, `/api/push/jmap/${encodeURIComponent(deviceClientId)}`),
types: [...PUSH_TYPES],
expires: expiresFromNow(SUBSCRIPTION_EXPIRES_DAYS),
});
const verificationCode = await pollVerificationCode(relayBaseUrl, deviceClientId);
await params.client.verifyPushSubscription(serverAssignedId, verificationCode);
localStorage.setItem(SUBSCRIPTION_ID_KEY, serverAssignedId);
return { subscriptionId: serverAssignedId };
}
export interface DisableWebPushParams {
client: IJMAPClient;
relayBaseUrl?: string;
}
// Best-effort teardown: clear the JMAP subscription, the relay mapping, and
// the browser PushSubscription. Any single failure is swallowed so the user
// always ends up in a "disabled" state locally.
export async function disableWebPush(params: DisableWebPushParams): Promise<void> {
const relayBaseUrl = (params.relayBaseUrl ?? DEFAULT_RELAY_BASE_URL).replace(/\/+$/, '');
const storedServerId = localStorage.getItem(SUBSCRIPTION_ID_KEY);
if (storedServerId) {
await params.client.destroyPushSubscription(storedServerId).catch(() => undefined);
localStorage.removeItem(SUBSCRIPTION_ID_KEY);
}
const deviceClientId = localStorage.getItem(DEVICE_CLIENT_ID_KEY);
if (deviceClientId && relayBaseUrl) {
await fetch(
buildRelayUrl(relayBaseUrl, `/api/push/register/${encodeURIComponent(deviceClientId)}`),
{ method: 'DELETE' },
).catch(() => undefined);
}
if (typeof navigator !== 'undefined' && 'serviceWorker' in navigator) {
const registration = await navigator.serviceWorker.getRegistration(SW_SCOPE);
const sub = await registration?.pushManager.getSubscription();
if (sub) await sub.unsubscribe().catch(() => undefined);
}
}
export async function isWebPushEnabled(): Promise<boolean> {
if (!isWebPushSupported()) return false;
if (Notification.permission !== 'granted') return false;
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;
}
+103 -11
View File
@@ -127,6 +127,7 @@
"demo_tour": "Průvodce",
"tags": "Štítky",
"folders": "Složky",
"shared": "Sdílené",
"mail": "Pošta",
"nav_label": "Navigace",
"add_app": "Aplikace"
@@ -183,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",
@@ -222,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",
@@ -305,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í",
@@ -426,11 +446,36 @@
"event_updated": "Aktualizace #{sequence}",
"event_status_tentative": "Nezávazně",
"event_status_cancelled": "Zrušeno",
"expand": "Zobrazit detaily",
"collapse": "Skrýt detaily"
"expand": "Zobrazit podrobnosti",
"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",
@@ -509,7 +554,10 @@
"message": "Vaše zpráva obsahuje slovo \"{keyword}\", ale není k ní připojen žádný soubor. Přesto odeslat?",
"send_anyway": "Přesto odeslat",
"back": "Zpět k úpravám"
}
},
"add_link": "Přidat odkaz",
"link_url_prompt": "Zadejte URL",
"sending": "Odesílání..."
},
"confirm_dialog": {
"confirm": "Potvrdit",
@@ -602,7 +650,6 @@
},
"language": {
"title": "Jazyk",
"czech": "Česky",
"english": "English",
"french": "Français",
"japanese": "日本語",
@@ -614,7 +661,6 @@
"portuguese": "Português",
"russian": "Русский",
"select_language": "Vybrat jazyk",
"switch_to_czech": "Přepnout na češtinu",
"switch_to_english": "Přepnout na angličtinu",
"switch_to_french": "Přepnout na francouzštinu",
"switch_to_japanese": "Přepnout na japonštinu",
@@ -804,6 +850,23 @@
"sound_desc": "Přehrát zvukové upozornění pro připomenutí kalendáře",
"invitation_parsing": "Rozpoznávat e-mailové pozvánky",
"invitation_parsing_desc": "Rozpoznávat pozvánky kalendáře v přílohách e-mailů a zobrazovat akce kalendáře"
},
"push": {
"confirm_disable_message": "Toto zařízení přestane přijímat upozornění, když je web zavřený.",
"confirm_disable_title": "Zakázat oznámení na pozadí?",
"description": "Přijímat systémová oznámení o nové poště, když je tento web zavřený. Doručováno přes push relay Bulwark; relay nikdy nevidí obsah pošty.",
"disable": "Zakázat",
"enable": "Povolit",
"ios_hint": "Na iOS nejprve nainstalujte web na domovskou obrazovku Safari doručuje Web Push pouze nainstalovaným PWA.",
"reenable": "Znovu zaregistrovat",
"relay_desc": "Výchozí je hostovaný relay Bulwark. Změňte pouze pokud používáte vlastní hosting.",
"relay_label": "Push relay",
"relay_placeholder": "https://notifications.relay.example.com",
"status_active": "Aktivní na tomto zařízení",
"status_busy": "Pracuji…",
"status_inactive": "Není povoleno na tomto zařízení",
"status_unsupported": "Tento prohlížeč nepodporuje Web Push",
"title": "Oznámení na pozadí"
}
},
"language_region": {
@@ -892,6 +955,13 @@
"label": "Automaticky vybírat adresu pro odpověď",
"description": "Při odpovídání automaticky přepnout adresu odesílatele na identitu, která původně obdržela zprávu"
},
"sub_address_delimiter": {
"label": "Oddělovač sub-adresy",
"description": "Znak oddělující uživatelské jméno od sub-adresy. Zvolte oddělovač používaný vaším poštovním serverem (např. uzivatel{delimiter}stitek@domena.cz).",
"option": "{delimiter} (uzivatel{delimiter}stitek@domena.cz)",
"custom": "Vlastní…",
"custom_input_label": "Vlastní znak oddělovače"
},
"attachment_click_action": {
"label": "Akce po kliknutí na přílohu",
"description": "Vyberte, zda má kliknutí na přílohu zobrazit náhled, nebo ji ihned stáhnout",
@@ -983,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": {
@@ -1327,8 +1401,8 @@
"contacts": {
"title": "Kontakty",
"description": "Import a export kontaktů",
"group_by_letter_label": "Seskupit podle prvního písmene",
"group_by_letter_description": "Zobrazovat abecední nadpisy v seznamu kontaktů",
"group_by_letter_label": "Seskupit podle prvního písmena",
"group_by_letter_description": "Zobrazit záhlaví sekcí podle abecedy v seznamu kontaktů",
"import_label": "Importovat kontakty",
"import_description": "Importovat kontakty ze souboru vCard (.vcf)",
"export_label": "Exportovat kontakty",
@@ -1737,7 +1811,7 @@
"use_address": "Použít tuto adresu",
"invalid_tag": "Štítek může obsahovat pouze písmena, číslice a pomlčky",
"tag_too_long": "Štítek může mít maximálně 30 znaků",
"help_text": "Zprávy odeslané na adresu uzivatel+stitek@domena.cz budou doručeny do vaší doručené pošty",
"help_text": "Zprávy odeslané na adresu uzivatel{delimiter}stitek@domena.cz budou doručeny do vaší doručené pošty",
"validation": {
"empty": "Štítek nesmí být prázdný",
"too_long": "Štítek může mít maximálně {max} znaků",
@@ -2134,7 +2208,11 @@
"tomorrow_header": "Zítra",
"export_ics": "Exportovat jako .ics",
"copy_title": "Kopírovat název",
"copy_link": "Kopírovat odkaz na schůzku"
"copy_link": "Kopírovat odkaz na schůzku",
"go_to_today": "Přejít na dnešek",
"new_all_day_event": "Nová celodenní událost",
"new_event": "Nová událost",
"new_task": "Nový úkol"
},
"detail": {
"add_note": "Přidat poznámku...",
@@ -2433,6 +2511,20 @@
"due_today": "Dnes",
"due_tomorrow": "Zítra",
"overdue": "Po termínu"
},
"months": {
"jan": "led",
"feb": "úno",
"mar": "bře",
"apr": "dub",
"may": "kvě",
"jun": "čvn",
"jul": "čvc",
"aug": "srp",
"sep": "zář",
"oct": "říj",
"nov": "lis",
"dec": "pro"
}
},
"advanced_search": {
+104 -7
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",
@@ -424,12 +443,39 @@
"cancel_info": "Der Organisator hat diesen Termin abgesagt.",
"event_updated": "Aktualisierung #{sequence}",
"event_status_tentative": "Vorläufig",
"event_status_cancelled": "Abgesagt"
"event_status_cancelled": "Abgesagt",
"collapse": "Details ausblenden",
"expand": "Details anzeigen"
},
"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",
@@ -508,7 +554,10 @@
"message": "Ihre Nachricht enthält \"{keyword}\", aber es ist keine Datei angehängt. Trotzdem senden?",
"send_anyway": "Trotzdem senden",
"back": "Zurück zur Bearbeitung"
}
},
"add_link": "Link hinzufügen",
"link_url_prompt": "URL eingeben",
"sending": "Wird gesendet..."
},
"confirm_dialog": {
"confirm": "Bestätigen",
@@ -801,6 +850,23 @@
"sound_desc": "Einen Ton für Kalendererinnerungen abspielen",
"invitation_parsing": "E-Mail-Einladungen erkennen",
"invitation_parsing_desc": "Kalendereinladungen in E-Mail-Anhängen erkennen und Kalenderaktionen anzeigen"
},
"push": {
"confirm_disable_message": "Dieses Gerät erhält keine Benachrichtigungen mehr, wenn die Seite geschlossen ist.",
"confirm_disable_title": "Hintergrundbenachrichtigungen deaktivieren?",
"description": "Systembenachrichtigungen für neue E-Mails empfangen, wenn diese Seite geschlossen ist. Zustellung über das Bulwark Push-Relay; das Relay sieht niemals E-Mail-Inhalte.",
"disable": "Deaktivieren",
"enable": "Aktivieren",
"ios_hint": "Installieren Sie die Seite unter iOS zuerst auf dem Startbildschirm Safari liefert Web Push nur an installierte PWAs.",
"reenable": "Neu registrieren",
"relay_desc": "Standardmäßig wird das gehostete Bulwark-Relay verwendet. Nur ändern, wenn Sie selbst hosten.",
"relay_label": "Push-Relay",
"relay_placeholder": "https://notifications.relay.example.com",
"status_active": "Auf diesem Gerät aktiv",
"status_busy": "Wird verarbeitet…",
"status_inactive": "Auf diesem Gerät nicht aktiviert",
"status_unsupported": "Dieser Browser unterstützt Web Push nicht",
"title": "Hintergrundbenachrichtigungen"
}
},
"language_region": {
@@ -889,6 +955,13 @@
"label": "Antwortadresse automatisch wählen",
"description": "Beim Antworten die Absenderadresse automatisch auf die Identität umstellen, die die ursprüngliche Nachricht erhalten hat"
},
"sub_address_delimiter": {
"label": "Sub-Adress-Trennzeichen",
"description": "Zeichen, das Ihren Benutzernamen vom Sub-Adress-Tag trennt. Verwenden Sie das von Ihrem Mailserver verwendete Trennzeichen (z. B. benutzer{delimiter}tag@domain.de).",
"option": "{delimiter} (benutzer{delimiter}tag@domain.de)",
"custom": "Benutzerdefiniert…",
"custom_input_label": "Benutzerdefiniertes Trennzeichen"
},
"attachment_click_action": {
"label": "Aktion beim Klick auf Anhänge",
"description": "Festlegen, ob ein Dateianhang beim Anklicken in der Vorschau geöffnet oder sofort heruntergeladen wird",
@@ -980,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": {
@@ -1333,7 +1410,9 @@
"no_address_books": "Keine Adressbücher gefunden",
"categories_title": "Kategorien",
"categories_description": "Kontaktkategorien umbenennen",
"no_categories": "Keine Kategorien gefunden"
"no_categories": "Keine Kategorien gefunden",
"group_by_letter_description": "Alphabetische Abschnittsüberschriften in der Kontaktliste anzeigen",
"group_by_letter_label": "Nach Anfangsbuchstaben gruppieren"
},
"filters": {
"title": "E-Mail-Filter",
@@ -1732,7 +1811,7 @@
"use_address": "Diese Adresse verwenden",
"invalid_tag": "Tag darf nur alphanumerisch und Bindestriche enthalten",
"tag_too_long": "Tag darf maximal 30 Zeichen lang sein",
"help_text": "E-Mails an benutzer+tag@domain.de werden in Ihrem Posteingang ankommen",
"help_text": "E-Mails an benutzer{delimiter}tag@domain.de werden in Ihrem Posteingang ankommen",
"validation": {
"empty": "Tag darf nicht leer sein",
"too_long": "Tag darf maximal {max} Zeichen lang sein",
@@ -2129,7 +2208,11 @@
"tomorrow_header": "Morgen",
"export_ics": "Als .ics exportieren",
"copy_title": "Titel kopieren",
"copy_link": "Meeting-Link kopieren"
"copy_link": "Meeting-Link kopieren",
"go_to_today": "Zu heute",
"new_all_day_event": "Neuer ganztägiger Termin",
"new_event": "Neuer Termin",
"new_task": "Neue Aufgabe"
},
"detail": {
"add_note": "Notiz hinzufügen...",
@@ -2428,6 +2511,20 @@
"due_today": "Heute fällig",
"due_tomorrow": "Morgen fällig",
"overdue": "Überfällig"
},
"months": {
"jan": "Jan",
"feb": "Feb",
"mar": "Mär",
"apr": "Apr",
"may": "Mai",
"jun": "Jun",
"jul": "Jul",
"aug": "Aug",
"sep": "Sep",
"oct": "Okt",
"nov": "Nov",
"dec": "Dez"
}
},
"advanced_search": {
+90 -2
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?",
@@ -783,6 +829,23 @@
"swift": "Swift Gesture",
"relax": "Relax"
},
"push": {
"title": "Background Notifications",
"description": "Receive system notifications for new mail when this site is closed. Delivered via the Bulwark push relay; the relay never sees mail content.",
"relay_label": "Push relay",
"relay_desc": "Defaults to the hosted Bulwark relay. Change only if you self-host.",
"relay_placeholder": "https://notifications.relay.example.com",
"status_active": "Active on this device",
"status_inactive": "Not enabled on this device",
"status_unsupported": "This browser does not support Web Push",
"status_busy": "Working…",
"enable": "Enable",
"reenable": "Re-register",
"disable": "Disable",
"confirm_disable_title": "Disable background notifications?",
"confirm_disable_message": "This device will stop receiving alerts when the site is closed.",
"ios_hint": "On iOS, install the site to your home screen first - Safari only delivers Web Push to installed PWAs."
},
"sound_selection": {
"title": "Notification Sound",
"description": "Choose which sound to play for notifications",
@@ -894,6 +957,13 @@
"label": "Auto-select Reply Address",
"description": "When replying, automatically switch the From address to the identity that originally received the message"
},
"sub_address_delimiter": {
"label": "Sub-Address Delimiter",
"description": "Character separating your username from a sub-address tag. Match the delimiter your mail server uses (e.g. user{delimiter}tag@domain.com).",
"option": "{delimiter} (user{delimiter}tag@domain.com)",
"custom": "Custom…",
"custom_input_label": "Custom delimiter character"
},
"attachment_click_action": {
"label": "Attachment Click Action",
"description": "Choose whether clicking a file attachment previews it or downloads it immediately",
@@ -985,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": {
@@ -1739,7 +1813,7 @@
"use_address": "Use This Address",
"invalid_tag": "Tag must be alphanumeric and dashes only",
"tag_too_long": "Tag must be 30 characters or less",
"help_text": "Emails sent to user+tag@domain.com will arrive in your inbox",
"help_text": "Emails sent to user{delimiter}tag@domain.com will arrive in your inbox",
"validation": {
"empty": "Tag cannot be empty",
"too_long": "Tag must be {max} characters or less",
@@ -2272,6 +2346,20 @@
"sat": "Sat",
"sun": "Sun"
},
"months": {
"jan": "Jan",
"feb": "Feb",
"mar": "Mar",
"apr": "Apr",
"may": "May",
"jun": "Jun",
"jul": "Jul",
"aug": "Aug",
"sep": "Sep",
"oct": "Oct",
"nov": "Nov",
"dec": "Dec"
},
"notifications": {
"event_created": "Event created",
"event_updated": "Event updated",
+139 -42
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",
@@ -424,12 +443,39 @@
"cancel_info": "El organizador ha cancelado este evento.",
"event_updated": "Actualización #{sequence}",
"event_status_tentative": "Provisional",
"event_status_cancelled": "Cancelado"
"event_status_cancelled": "Cancelado",
"collapse": "Ocultar detalles",
"expand": "Mostrar detalles"
},
"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",
@@ -508,7 +554,10 @@
"message": "Tu mensaje menciona \"{keyword}\" pero no hay ningún archivo adjunto. ¿Enviar de todos modos?",
"send_anyway": "Enviar de todos modos",
"back": "Volver a editar"
}
},
"add_link": "Añadir enlace",
"link_url_prompt": "Introduce la URL",
"sending": "Enviando..."
},
"confirm_dialog": {
"confirm": "Confirmar",
@@ -801,6 +850,23 @@
"sound_desc": "Reproducir un sonido para recordatorios del calendario",
"invitation_parsing": "Analizar invitaciones por correo",
"invitation_parsing_desc": "Detectar invitaciones de calendario en archivos adjuntos y mostrar acciones de calendario"
},
"push": {
"confirm_disable_message": "Este dispositivo dejará de recibir alertas cuando el sitio esté cerrado.",
"confirm_disable_title": "¿Desactivar las notificaciones en segundo plano?",
"description": "Recibe notificaciones del sistema para correo nuevo cuando este sitio está cerrado. Se entrega a través del relay push de Bulwark; el relay nunca ve el contenido del correo.",
"disable": "Desactivar",
"enable": "Activar",
"ios_hint": "En iOS, instala primero el sitio en la pantalla de inicio: Safari solo entrega Web Push a PWAs instaladas.",
"reenable": "Volver a registrar",
"relay_desc": "Usa el relay alojado de Bulwark de forma predeterminada. Cámbialo solo si te alojas tú mismo.",
"relay_label": "Relay push",
"relay_placeholder": "https://notifications.relay.example.com",
"status_active": "Activo en este dispositivo",
"status_busy": "Trabajando…",
"status_inactive": "No habilitado en este dispositivo",
"status_unsupported": "Este navegador no admite Web Push",
"title": "Notificaciones en segundo plano"
}
},
"language_region": {
@@ -884,6 +950,13 @@
"label": "Seleccionar dirección de respuesta automáticamente",
"description": "Al responder, cambia automáticamente la dirección del remitente a la identidad que recibió el mensaje original"
},
"sub_address_delimiter": {
"label": "Delimitador de sub-dirección",
"description": "Carácter que separa tu nombre de usuario de la etiqueta de sub-dirección. Usa el delimitador que utilice tu servidor de correo (por ejemplo, usuario{delimiter}etiqueta@dominio.com).",
"option": "{delimiter} (usuario{delimiter}etiqueta@dominio.com)",
"custom": "Personalizado…",
"custom_input_label": "Carácter delimitador personalizado"
},
"show_preview": {
"label": "Mostrar Vista Previa",
"description": "Mostrar vista previa del correo en la lista",
@@ -980,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": {
@@ -1333,7 +1410,9 @@
"no_address_books": "No se encontraron libretas de direcciones",
"categories_title": "Categorías",
"categories_description": "Renombrar categorías de contactos",
"no_categories": "No se encontraron categorías"
"no_categories": "No se encontraron categorías",
"group_by_letter_description": "Mostrar encabezados alfabéticos en la lista de contactos",
"group_by_letter_label": "Agrupar por primera letra"
},
"filters": {
"title": "Filtros de correo",
@@ -1591,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",
@@ -1732,7 +1811,7 @@
"use_address": "Usar Esta Dirección",
"invalid_tag": "La etiqueta debe ser solo alfanumérica y guiones",
"tag_too_long": "La etiqueta debe tener 30 caracteres o menos",
"help_text": "Los correos enviados a usuario+etiqueta@dominio.com llegarán a su bandeja de entrada",
"help_text": "Los correos enviados a usuario{delimiter}etiqueta@dominio.com llegarán a su bandeja de entrada",
"validation": {
"empty": "La etiqueta no puede estar vacía",
"too_long": "La etiqueta debe tener {max} caracteres o menos",
@@ -2129,7 +2208,11 @@
"tomorrow_header": "Mañana",
"export_ics": "Exportar como .ics",
"copy_title": "Copiar título",
"copy_link": "Copiar enlace de reunión"
"copy_link": "Copiar enlace de reunión",
"go_to_today": "Ir a hoy",
"new_all_day_event": "Nuevo evento de todo el día",
"new_event": "Nuevo evento",
"new_task": "Nueva tarea"
},
"detail": {
"add_note": "Añadir una nota...",
@@ -2428,6 +2511,20 @@
"due_today": "Vence hoy",
"due_tomorrow": "Vence mañana",
"overdue": "Vencida"
},
"months": {
"jan": "Ene",
"feb": "Feb",
"mar": "Mar",
"apr": "Abr",
"may": "May",
"jun": "Jun",
"jul": "Jul",
"aug": "Ago",
"sep": "Sep",
"oct": "Oct",
"nov": "Nov",
"dec": "Dic"
}
},
"advanced_search": {
+139 -42
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",
@@ -424,12 +443,39 @@
"cancel_info": "L'organisateur a annulé cet événement.",
"event_updated": "Mise à jour #{sequence}",
"event_status_tentative": "Provisoire",
"event_status_cancelled": "Annulé"
"event_status_cancelled": "Annulé",
"collapse": "Masquer les détails",
"expand": "Afficher les détails"
},
"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",
@@ -508,7 +554,10 @@
"message": "Votre message mentionne \"{keyword}\" mais aucun fichier n'est joint. Envoyer quand même ?",
"send_anyway": "Envoyer quand même",
"back": "Retour à l'édition"
}
},
"add_link": "Ajouter un lien",
"link_url_prompt": "Saisissez l'URL",
"sending": "Envoi..."
},
"confirm_dialog": {
"confirm": "Confirmer",
@@ -801,6 +850,23 @@
"sound_desc": "Jouer un son pour les rappels de calendrier",
"invitation_parsing": "Analyser les invitations par e-mail",
"invitation_parsing_desc": "Détecter les invitations de calendrier dans les pièces jointes et afficher les actions de calendrier"
},
"push": {
"confirm_disable_message": "Cet appareil cessera de recevoir des alertes lorsque le site est fermé.",
"confirm_disable_title": "Désactiver les notifications en arrière-plan ?",
"description": "Recevez des notifications système pour les nouveaux courriers quand ce site est fermé. Livré via le relais push Bulwark ; le relais ne voit jamais le contenu des courriers.",
"disable": "Désactiver",
"enable": "Activer",
"ios_hint": "Sur iOS, installez d'abord le site sur l'écran d'accueil Safari ne livre Web Push qu'aux PWA installées.",
"reenable": "Réenregistrer",
"relay_desc": "Utilise par défaut le relais Bulwark hébergé. Ne le changez que si vous l'hébergez vous-même.",
"relay_label": "Relais push",
"relay_placeholder": "https://notifications.relay.example.com",
"status_active": "Actif sur cet appareil",
"status_busy": "Traitement en cours…",
"status_inactive": "Non activé sur cet appareil",
"status_unsupported": "Ce navigateur ne prend pas en charge Web Push",
"title": "Notifications en arrière-plan"
}
},
"language_region": {
@@ -884,6 +950,13 @@
"label": "Sélection automatique de l'adresse de réponse",
"description": "Lors d'une réponse, bascule automatiquement l'adresse d'expédition vers l'identité qui a reçu le message d'origine"
},
"sub_address_delimiter": {
"label": "Délimiteur de sous-adresse",
"description": "Caractère séparant votre nom d'utilisateur de l'étiquette de sous-adresse. Utilisez le délimiteur configuré sur votre serveur de messagerie (par ex. utilisateur{delimiter}tag@domaine.com).",
"option": "{delimiter} (utilisateur{delimiter}tag@domaine.com)",
"custom": "Personnalisé…",
"custom_input_label": "Caractère de délimiteur personnalisé"
},
"show_preview": {
"label": "Afficher l'aperçu",
"description": "Afficher l'aperçu de l'email dans la liste",
@@ -980,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": {
@@ -1333,7 +1410,9 @@
"no_address_books": "Aucun carnet d'adresses trouvé",
"categories_title": "Catégories",
"categories_description": "Renommer les catégories de contacts",
"no_categories": "Aucune catégorie trouvée"
"no_categories": "Aucune catégorie trouvée",
"group_by_letter_description": "Afficher des en-têtes alphabétiques dans la liste de contacts",
"group_by_letter_label": "Grouper par première lettre"
},
"filters": {
"title": "Filtres de courrier",
@@ -1591,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",
@@ -1732,7 +1811,7 @@
"use_address": "Utiliser cette adresse",
"invalid_tag": "Le tag doit contenir uniquement des lettres, chiffres et tirets",
"tag_too_long": "Le tag doit faire 30 caractères ou moins",
"help_text": "Les emails envoyés à utilisateur+tag@domaine.com arriveront dans votre boîte de réception",
"help_text": "Les emails envoyés à utilisateur{delimiter}tag@domaine.com arriveront dans votre boîte de réception",
"validation": {
"empty": "Le tag ne peut pas être vide",
"too_long": "Le tag doit faire {max} caractères ou moins",
@@ -2129,7 +2208,11 @@
"tomorrow_header": "Demain",
"export_ics": "Exporter en .ics",
"copy_title": "Copier le titre",
"copy_link": "Copier le lien de réunion"
"copy_link": "Copier le lien de réunion",
"go_to_today": "Aller à aujourd'hui",
"new_all_day_event": "Nouvel événement sur la journée",
"new_event": "Nouvel événement",
"new_task": "Nouvelle tâche"
},
"detail": {
"add_note": "Ajouter une note...",
@@ -2261,6 +2344,20 @@
"sat": "Sam",
"sun": "Dim"
},
"months": {
"jan": "janv.",
"feb": "févr.",
"mar": "mars",
"apr": "avr.",
"may": "mai",
"jun": "juin",
"jul": "juil.",
"aug": "août",
"sep": "sept.",
"oct": "oct.",
"nov": "nov.",
"dec": "déc."
},
"notifications": {
"event_created": "Événement créé",
"event_updated": "Événement mis à jour",

Some files were not shown because too many files have changed in this diff Show More