Compare commits

...
55 Commits
Author SHA1 Message Date
Linus Rath 8041700668 chore: update version to 1.6.0 2026-05-01 22:02:57 +02:00
Linus Rath bade68a8b8 i18n: add missing email viewer detail and authentication translations 2026-05-01 22:02:14 +02:00
Linus Rath 4be7176802 chore: update version to 1.6.0 2026-05-01 21:56:53 +02:00
Linus Rath 8813533958 fix: respect per-email dark mode toggle when always-light setting is on 2026-05-01 21:42:02 +02:00
Linus Rath affa239d75 Merge branch 'main' of https://github.com/bulwarkmail/webmail 2026-05-01 21:35:53 +02:00
Linus Rath 5d292fa43f fix: scroll apps list in navigation rail to prevent overflow 2026-05-01 21:35:29 +02:00
Linus Rath 878df6bb49 refactor: enhance path rendering in mailbox context menu 2026-05-01 21:26:03 +02:00
Linus Rath 607a9584fd refactor: implement path shortening for mailbox context menu 2026-05-01 21:25:31 +02:00
Linus Rath b8e2bfd793 fix: show full path in mailbox context menu header 2026-05-01 21:15:47 +02:00
Linus Rath 4ad6d37877 fix: clamp context submenu inside viewport 2026-05-01 21:03:54 +02:00
Linus Rath d7c29b7bec refactor: rework mobile mail viewer toolbar 2026-05-01 21:00:34 +02:00
Luis Felipe MarzagaoandLinus Rath b86bc541ab i18n: add missing keys accross 14 locales 2026-05-01 20:37:51 +02:00
Luis Felipe MarzagaoandLinus Rath 8c74e01a40 fix: add useTranslations for "selected emails" and "cancel" on email list batch operations 2026-05-01 20:37:51 +02:00
Linus Rath 231a9017d2 refactor: make settings panel mobile friendly 2026-05-01 20:30:15 +02:00
Linus Rath 9a5bb78b18 refactor: make admin panel mobile friendly 2026-05-01 20:22:29 +02:00
Linus Rath eecf16daa2 fix: stop silently destroying emails when trash mailbox isnt found #195 2026-05-01 20:01:13 +02:00
Vadim BelovandLinus Rath 210150a02e Fix push preview JMAP query
Resolve the Inbox mailbox id before running Email/query.

The previous query passed a JMAP result reference object directly into the inMailbox filter, which can make the preview endpoint return 502 and cause push notifications to fall back to the generic “New mail” text.
2026-05-01 19:53:25 +02:00
Linus Rath e50691d6c4 fix: navigate tour to mailbox when starting from another page 2026-05-01 18:30:51 +02:00
Linus Rath 089963b1b3 refactor: redesign expanded details panel 2026-05-01 18:22:06 +02:00
Linus Rath 4af952613a fix: prevent context menu jump and animation on open 2026-05-01 17:43:07 +02:00
Linus Rath 0d9fa0285f fix: prevent context menu from clipping below viewport 2026-05-01 17:28:40 +02:00
Linus Rath 683fe75864 i18n: translate SPF/DKIM/DMARC tooltips 2026-05-01 17:25:52 +02:00
Linus Rath e9c9be84ad fix: preserve list scroll position when tagging an email 2026-05-01 17:18:11 +02:00
Linus Rath 7822a363dd fix: render below-header overflow popup outside clipped row 2026-05-01 17:10:17 +02:00
Linus Rath 1e535e96a2 feat: image attachment thumbnails and preview chips 2026-05-01 17:03:45 +02:00
Linus Rath 32135ddb95 fix: collapse below-header attachments to single row with overflow pill 2026-05-01 16:29:38 +02:00
Linus Rath 841513e510 feat: support subpath deployment with NEXT_PUBLIC_BASE_PATH environment variable 2026-05-01 14:57:49 +02:00
Linus RathandGitHub 5964b2e456 Update README 2026-05-01 10:06:49 +02:00
Linus Rath 6a8ad525f1 chore: update version to 1.5.4 2026-05-01 02:17:33 +02:00
Linus Rath 31d17098d6 feat: open mail from push notification clicks 2026-05-01 02:13:25 +02:00
Linus Rath 3f97e6ed8d fix: scope email notifications to genuine inbox deliveries 2026-05-01 02:08:13 +02:00
Linus Rath 2dea33e698 feat: improve new email push notification logic for inbox 2026-05-01 01:57:57 +02:00
Linus Rath 123764f8b8 feat: improve new email notification logic for inbox 2026-05-01 01:55:24 +02:00
Linus Rath ec0f355c13 feat: allow custom sub-addressing delimiter character #239 2026-05-01 01:48:39 +02:00
Linus Rath c555973b6b feat: configurable sub-addressing delimiter #239 2026-05-01 01:42:06 +02:00
Linus Rath a8db02e881 i18n: add missing keys across 15 locales 2026-05-01 01:26:22 +02:00
AbdullahandLinus Rath 7dc5984359 feat(i18n): add Turkish localization 2026-05-01 01:16:30 +02:00
Linus Rath 1c3003421e fix: extend timeout for PushVerification and clean up leftover subscriptions 2026-05-01 00:54:40 +02:00
Linus Rath f3d9115ecd feat: web push notifications for PWA #233 2026-05-01 00:26:48 +02:00
Linus Rath 4400a7abba fix: evict unrecoverable basic-auth accounts on reload 2026-04-30 15:39:11 +02:00
Linus Rath 45a4db1c22 fix: pin JMAP auth verification to configured server URL #237 2026-04-30 15:34:14 +02:00
Linus Rath 65eef4b2b8 fix: persist htmlBody in drafts to preserve rich formatting #236 2026-04-30 15:24:09 +02:00
Linus Rath 25de7d996c feat: add tables to composer #236 2026-04-30 15:16:51 +02:00
Luis Felipe MarzagaoandLinus Rath c406fbb73e fixup! fix: implement useTranslations for start date on event detail popover 2026-04-30 11:08:16 +02:00
Luis Felipe MarzagaoandLinus Rath 31024396e3 fixup! fix: implement useTranslations for start date on event modal 2026-04-30 11:08:16 +02:00
Luis Felipe MarzagaoandLinus Rath f0967f90eb fix: implement useTranslations for start date on event modal 2026-04-30 11:08:16 +02:00
Luis Felipe MarzagaoandLinus Rath a4bb8e0c28 fix: implement useTranslations for start date on event detail popover 2026-04-30 11:08:16 +02:00
Linus Rath 7188abc9bc fix: set In-Reply-To and References on replies #234 2026-04-30 01:25:04 +02:00
Linus Rath 4a91cd0c44 style: drop iframe border-radius 2026-04-29 20:26:40 +02:00
Linus Rath 6abf8a5dd8 fix: detect <style> tag for padding 2026-04-29 20:25:24 +02:00
Linus Rath 3667c842c6 fix: light-mode override to body content only 2026-04-29 20:17:59 +02:00
Linus Rath b64721b43c fix: pad bare HTML emails like plain-text mails 2026-04-29 20:09:00 +02:00
Linus Rath 6b5ca2cb89 fix: smooth out body load, prevent flicker 2026-04-29 20:03:58 +02:00
Linus Rath 0f6e4f995f fix: prevent iframe flash on Load images or Trust sender 2026-04-29 18:29:48 +02:00
Linus Rath 0b6fdcabfb fix: update connect-src to include http protocol in development mode 2026-04-29 13:55:38 +02:00
88 changed files with 8202 additions and 1132 deletions
+59
View File
@@ -1,5 +1,64 @@
# 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).
+4
View File
@@ -4,6 +4,10 @@ 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
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 -4
View File
@@ -12,7 +12,7 @@ A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.ar
[![License: AGPL v3](https://img.shields.io/badge/license-AGPL%20v3-blue.svg?logo=gnu&logoColor=white)](LICENSE)
[![Discord](https://img.shields.io/discord/1482128142939455674?color=7289da&label=discord&logo=discord&logoColor=white)](https://discord.gg/tYCujymGrT)
[![Version](https://img.shields.io/badge/version-1.5.3-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>
@@ -53,11 +53,9 @@ A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.ar
</tr>
</table>
> **Anonymous telemetry is on by default** since 1.5.3. Each instance sends a daily heartbeat (version, platform, bucketed account counts, feature toggles - no message data, no PII). Disable from **Admin → Telemetry**, by setting `BULWARK_TELEMETRY=off`, or by clearing the endpoint. Full schema: [privacy notice](https://bulwarkmail.org/docs/legal/privacy/telemetry).
## Overview
Bulwark is a full webmail suite not just an inbox. It bundles the four apps most self-hosters end up wanting on the same login:
Bulwark is a full webmail suite, not just an inbox. It bundles the four apps most self-hosters end up wanting on the same login:
- **Mail** threading, unified inbox, full-text search, Sieve filters, S/MIME, templates
- **Calendar** month/week/day/agenda, recurring events, iMIP invitations, CalDAV subscriptions
@@ -219,6 +217,26 @@ LOG_LEVEL=info # error | warn | info | debug
</details>
<details>
<summary>Subpath / reverse proxy mount</summary>
To serve the webmail at a subpath (e.g. `https://example.com/webmail`):
```env
NEXT_PUBLIC_BASE_PATH=/webmail
NEXT_PUBLIC_LOCALE_PREFIX=always # avoids next-intl rewrite loops
```
Unlike most other variables, `NEXT_PUBLIC_BASE_PATH` is read at **build time** because Next.js bakes it into emitted asset URLs. To use it with the published Docker image, build your own image with the variable set:
```bash
docker build --build-arg NEXT_PUBLIC_BASE_PATH=/webmail -t bulwark-webmail .
```
Then point your reverse proxy at the container without stripping the prefix - the app expects to receive requests under `/webmail/...` and serves all routes (`/webmail/api/...`, `/webmail/_next/static/...`, `/webmail/sw.js`, etc.) accordingly.
</details>
## Keyboard Shortcuts
| Key | Action |
+1 -1
View File
@@ -1 +1 @@
1.5.3
1.6.0
+2 -1
View File
@@ -411,7 +411,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);
+82 -12
View File
@@ -51,6 +51,7 @@ 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";
@@ -138,6 +139,7 @@ export default function Home() {
deleteEmail,
markAsRead,
toggleStar,
setEmailKeywordsLocal,
moveToMailbox,
moveThreadToMailbox,
searchEmails,
@@ -651,11 +653,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 +791,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 +800,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
@@ -889,13 +932,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 +1097,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 +1501,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 +1518,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 +1564,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 +1587,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 +2178,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">
+201 -63
View File
@@ -21,6 +21,8 @@ import {
HardDrive,
ArrowLeft,
Store,
Menu,
X,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { useConfig } from '@/hooks/use-config';
@@ -69,12 +71,26 @@ 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);
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 +156,77 @@ 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);
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>
))}
</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 +274,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 +284,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 +363,64 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
) : null}
</div>
</main>
{/* Mobile bottom nav (main webmail nav) */}
<nav
className="md:hidden fixed inset-x-0 bottom-0 z-30 flex items-center bg-background border-t border-border pb-[env(safe-area-inset-bottom)]"
aria-label="Main navigation"
>
<a
href="/"
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] transition-colors duration-150 text-muted-foreground hover:text-foreground"
title="Mail"
>
<Mail className="w-5 h-5" />
<span className="text-[10px] font-medium leading-tight truncate max-w-full">Mail</span>
</a>
<a
href="/calendar"
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] transition-colors duration-150 text-muted-foreground hover:text-foreground"
title="Calendar"
>
<Calendar className="w-5 h-5" />
<span className="text-[10px] font-medium leading-tight truncate max-w-full">Calendar</span>
</a>
<a
href="/contacts"
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] transition-colors duration-150 text-muted-foreground hover:text-foreground"
title="Contacts"
>
<BookUser className="w-5 h-5" />
<span className="text-[10px] font-medium leading-tight truncate max-w-full">Contacts</span>
</a>
<a
href="/files"
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] transition-colors duration-150 text-muted-foreground hover:text-foreground"
title="Files"
>
<HardDrive className="w-5 h-5" />
<span className="text-[10px] font-medium leading-tight truncate max-w-full">Files</span>
</a>
<div
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] text-primary"
title="Admin"
aria-current="page"
>
<div className="relative">
<Shield className="w-5 h-5" />
<span className="absolute -bottom-1 left-1/2 -translate-x-1/2 w-4 h-0.5 rounded-full bg-primary" />
</div>
<span className="text-[10px] font-medium leading-tight truncate max-w-full">Admin</span>
</div>
<a
href="/settings"
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] transition-colors duration-150 text-muted-foreground hover:text-foreground"
title="Settings"
>
<Settings className="w-5 h-5" />
<span className="text-[10px] font-medium leading-tight truncate max-w-full">Settings</span>
</a>
</nav>
</div>
);
}
+36 -8
View File
@@ -33,8 +33,8 @@ export default function AdminLogsPage() {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<h1 className="text-2xl font-semibold text-foreground">Audit Log</h1>
<p className="text-sm text-muted-foreground mt-1">{total} total entries</p>
</div>
@@ -52,7 +52,7 @@ export default function AdminLogsPage() {
<select
value={actionFilter}
onChange={(e) => { setActionFilter(e.target.value); setPage(1); }}
className="h-8 rounded-md border border-input bg-background px-2.5 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
className="h-8 w-full sm:w-auto rounded-md border border-input bg-background px-2.5 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<option value="">All actions</option>
<option value="admin.login">Login</option>
@@ -66,15 +66,43 @@ export default function AdminLogsPage() {
</select>
</div>
{/* Table */}
<div className="border border-border rounded-lg overflow-hidden">
{/* Mobile cards */}
<div className="sm:hidden space-y-2">
{loading && entries.length === 0 ? (
<div className="rounded-lg border border-border px-4 py-8 text-center text-sm text-muted-foreground">Loading...</div>
) : entries.length === 0 ? (
<div className="rounded-lg border border-border px-4 py-8 text-center text-sm text-muted-foreground">No entries found</div>
) : (
entries.map((entry, i) => (
<div key={i} className="rounded-lg border border-border p-3 space-y-1.5">
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-mono px-2 py-0.5 rounded bg-muted text-muted-foreground truncate">
{entry.action}
</span>
<span className="text-[11px] text-muted-foreground whitespace-nowrap">
{new Date(entry.ts).toLocaleString()}
</span>
</div>
<div className="text-xs text-foreground break-words">
{formatDetail(entry.detail)}
</div>
<div className="text-[11px] text-muted-foreground font-mono">
{entry.ip}
</div>
</div>
))
)}
</div>
{/* Desktop table */}
<div className="hidden sm:block border border-border rounded-lg overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-muted/30">
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Time</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Action</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground whitespace-nowrap">Time</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground whitespace-nowrap">Action</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Details</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">IP</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground whitespace-nowrap">IP</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
+36 -34
View File
@@ -210,46 +210,48 @@ export default function MarketplacePreviewPage() {
</Link>
{/* Header */}
<div className="flex items-start gap-4">
<div className="w-14 h-14 rounded-lg bg-muted flex items-center justify-center shrink-0">
{isPlugin ? (
<Puzzle className="w-7 h-7 text-muted-foreground" />
) : (
<SwatchBook className="w-7 h-7 text-muted-foreground" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h1 className="text-2xl font-semibold text-foreground truncate">{ext.name}</h1>
{ext.featured && <Star className="w-4 h-4 text-warning fill-warning shrink-0" />}
{data.installed && (
<span className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-md bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400 font-medium">
<Check className="w-3 h-3" /> Installed
</span>
<div className="flex flex-col gap-4 sm:flex-row sm:items-start">
<div className="flex items-start gap-4 flex-1 min-w-0">
<div className="w-14 h-14 rounded-lg bg-muted flex items-center justify-center shrink-0">
{isPlugin ? (
<Puzzle className="w-7 h-7 text-muted-foreground" />
) : (
<SwatchBook className="w-7 h-7 text-muted-foreground" />
)}
</div>
<div className="flex items-center gap-2 mt-1 text-sm text-muted-foreground flex-wrap">
<span className={`text-[10px] px-1.5 py-0.5 rounded font-medium ${
isPlugin
? 'bg-blue-100 text-blue-700 dark:bg-blue-950/30 dark:text-blue-400'
: 'bg-purple-100 text-purple-700 dark:bg-purple-950/30 dark:text-purple-400'
}`}>
{isPlugin ? (ext.pluginType || 'plugin') : 'theme'}
</span>
{ext.author && (
<span>by {ext.author.displayName}</span>
)}
{ext.latestVersion && <span>v{ext.latestVersion}</span>}
{ext.license && <span>{ext.license}</span>}
<span className="inline-flex items-center gap-1">
<Download className="w-3 h-3" />
{ext.totalDownloads.toLocaleString()}
</span>
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<h1 className="text-2xl font-semibold text-foreground break-words min-w-0">{ext.name}</h1>
{ext.featured && <Star className="w-4 h-4 text-warning fill-warning shrink-0" />}
{data.installed && (
<span className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-md bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400 font-medium">
<Check className="w-3 h-3" /> Installed
</span>
)}
</div>
<div className="flex items-center gap-2 mt-1 text-sm text-muted-foreground flex-wrap">
<span className={`text-[10px] px-1.5 py-0.5 rounded font-medium ${
isPlugin
? 'bg-blue-100 text-blue-700 dark:bg-blue-950/30 dark:text-blue-400'
: 'bg-purple-100 text-purple-700 dark:bg-purple-950/30 dark:text-purple-400'
}`}>
{isPlugin ? (ext.pluginType || 'plugin') : 'theme'}
</span>
{ext.author && (
<span>by {ext.author.displayName}</span>
)}
{ext.latestVersion && <span>v{ext.latestVersion}</span>}
{ext.license && <span>{ext.license}</span>}
<span className="inline-flex items-center gap-1">
<Download className="w-3 h-3" />
{ext.totalDownloads.toLocaleString()}
</span>
</div>
</div>
</div>
{/* Action buttons */}
<div className="flex items-center gap-2 shrink-0">
<div className="flex flex-wrap items-center gap-2 shrink-0">
{data.installed ? (
<>
<Link
+3 -3
View File
@@ -142,8 +142,8 @@ export default function AdminMarketplacePage() {
)}
{/* Search & Filters */}
<div className="flex items-center gap-3">
<div className="relative flex-1">
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
<div className="relative flex-1 min-w-0">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<input
type="text"
@@ -153,7 +153,7 @@ export default function AdminMarketplacePage() {
className="w-full h-9 pl-9 pr-3 rounded-md border border-input bg-background text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring/20 focus:border-ring"
/>
</div>
<div className="flex items-center gap-1 rounded-md border border-input bg-background p-0.5">
<div className="flex items-center gap-1 rounded-md border border-input bg-background p-0.5 self-start sm:self-auto">
{(['all', 'plugin', 'theme'] as const).map((t) => (
<button
key={t}
+9 -9
View File
@@ -262,12 +262,12 @@ export default function AdminPluginsPage() {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<h1 className="text-2xl font-semibold text-foreground">Plugins</h1>
<p className="text-sm text-muted-foreground mt-1">Manage plugins and plugin policy for all users</p>
</div>
<div className="flex items-center gap-2">
<div className="flex flex-wrap items-center gap-2">
{policyDirty && (
<button
onClick={handleSavePolicy}
@@ -309,7 +309,7 @@ export default function AdminPluginsPage() {
<p className="text-xs text-muted-foreground mt-0.5">Control plugin availability for users</p>
</div>
<div className="divide-y divide-border">
<div className="px-4 py-3 flex items-center justify-between gap-4">
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div>
<span className="text-sm text-foreground">Plugins Enabled</span>
<p className="text-xs text-muted-foreground mt-0.5">Allow the plugin system to load and run plugins for users</p>
@@ -320,7 +320,7 @@ export default function AdminPluginsPage() {
</button>
</div>
<div className="px-4 py-3 flex items-center justify-between gap-4">
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div>
<span className="text-sm text-foreground">User Plugin Uploads</span>
<p className="text-xs text-muted-foreground mt-0.5">Allow users to upload plugin ZIP files in Settings</p>
@@ -331,7 +331,7 @@ export default function AdminPluginsPage() {
</button>
</div>
<div className="px-4 py-3 flex items-center justify-between gap-4">
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div>
<span className="text-sm text-foreground">Require Admin Approval</span>
<p className="text-xs text-muted-foreground mt-0.5">User-uploaded plugins must be approved by an admin before they can be enabled</p>
@@ -344,7 +344,7 @@ export default function AdminPluginsPage() {
{/* Force enable / disable all */}
{plugins.length > 0 && (
<div className="px-4 py-3 flex items-center justify-between gap-4">
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div>
<span className="text-sm text-foreground">Force Enable / Disable All</span>
<p className="text-xs text-muted-foreground mt-0.5">Bulk toggle all deployed plugins at once</p>
@@ -388,9 +388,9 @@ export default function AdminPluginsPage() {
) : (
<div className="divide-y divide-border">
{plugins.map(plugin => (
<div key={plugin.id} className="px-4 py-4 flex items-center justify-between gap-4">
<div key={plugin.id} className="px-4 py-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="text-sm font-medium text-foreground">{plugin.name}</span>
<span className="text-xs text-muted-foreground">v{plugin.version}</span>
<span className={`text-xs px-1.5 py-0.5 rounded ${plugin.enabled ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400' : 'bg-muted text-muted-foreground'}`}>
+7 -7
View File
@@ -132,8 +132,8 @@ export default function AdminPolicyPage() {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<h1 className="text-2xl font-semibold text-foreground">User Policy</h1>
<p className="text-sm text-muted-foreground mt-1">Control which features and settings users can access</p>
</div>
@@ -170,13 +170,13 @@ export default function AdminPolicyPage() {
const { label, description } = meta;
const enabled = policy.features[key];
return (
<div key={key} className="px-4 py-3 flex items-center justify-between gap-4">
<div>
<div key={key} className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="min-w-0">
<span className="text-sm text-foreground">{label}</span>
<p className="text-xs text-muted-foreground mt-0.5">{description}</p>
</div>
<button onClick={() => toggleFeature(key)}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${enabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
className={`shrink-0 relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${enabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${enabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
</button>
</div>
@@ -195,9 +195,9 @@ export default function AdminPolicyPage() {
{RESTRICTABLE_SETTINGS.filter(s => s.category === category).map(setting => {
const restriction = policy.restrictions[setting.key] || {};
return (
<div key={setting.key} className="px-4 py-3 flex items-center justify-between gap-4">
<div key={setting.key} className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<span className="text-sm text-foreground">{setting.label}</span>
<div className="flex items-center gap-3">
<div className="flex items-center gap-3 shrink-0">
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer">
<input type="checkbox" checked={!!restriction.locked} onChange={() => toggleLocked(setting.key)}
className="rounded border-input" />
+12 -12
View File
@@ -86,8 +86,8 @@ export default function AdminSettingsPage() {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<h1 className="text-2xl font-semibold text-foreground">Server Settings</h1>
<p className="text-sm text-muted-foreground mt-1">General server configuration</p>
</div>
@@ -166,21 +166,21 @@ function TextSetting({ label, configKey, value, source, onChange, onRevert, plac
onChange: (key: string, value: unknown) => void; onRevert: (key: string) => void; placeholder?: string;
}) {
return (
<div className="px-4 py-3 flex items-center justify-between gap-4">
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="flex items-center gap-2 min-w-0">
<label className="text-sm text-foreground whitespace-nowrap">{label}</label>
<label className="text-sm text-foreground">{label}</label>
<SourceBadge source={source} />
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 w-full sm:w-auto">
<input
type="text"
value={value ?? ''}
onChange={(e) => onChange(configKey, e.target.value)}
placeholder={placeholder}
className="h-8 w-64 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
className="h-8 w-full sm:w-64 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
{source === 'admin' && (
<button onClick={() => onRevert(configKey)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
<button onClick={() => onRevert(configKey)} className="shrink-0 text-muted-foreground hover:text-foreground" title="Revert to default">
<RotateCcw className="w-3.5 h-3.5" />
</button>
)}
@@ -194,7 +194,7 @@ function ToggleSetting({ label, description, configKey, value, source, onChange,
onChange: (key: string, value: unknown) => void; onRevert: (key: string) => void;
}) {
return (
<div className="px-4 py-3 flex items-center justify-between gap-4">
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm text-foreground">{label}</span>
@@ -202,7 +202,7 @@ function ToggleSetting({ label, description, configKey, value, source, onChange,
</div>
{description && <p className="text-xs text-muted-foreground mt-0.5">{description}</p>}
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 shrink-0">
<button
onClick={() => onChange(configKey, !value)}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${value ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}
@@ -224,12 +224,12 @@ function SelectSetting({ label, configKey, value, source, options, onChange, onR
onChange: (key: string, value: unknown) => void; onRevert: (key: string) => void;
}) {
return (
<div className="px-4 py-3 flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="flex items-center gap-2 min-w-0">
<span className="text-sm text-foreground">{label}</span>
<SourceBadge source={source} />
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 shrink-0">
<select
value={value ?? ''}
onChange={(e) => onChange(configKey, e.target.value)}
+9 -9
View File
@@ -114,7 +114,7 @@ export default function AdminTelemetryPage() {
const isOn = status.consent === 'on';
return (
<div className="max-w-3xl mx-auto p-6 space-y-6">
<div className="space-y-6">
<header className="space-y-2">
<h1 className="text-2xl font-semibold">Anonymous Usage Stats</h1>
<p className="text-sm text-muted-foreground">
@@ -133,8 +133,8 @@ export default function AdminTelemetryPage() {
</header>
<section className="rounded-lg border p-4 space-y-3">
<div className="flex items-center justify-between">
<div>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div className="min-w-0">
<div className="font-medium">Status</div>
<div className="text-sm text-muted-foreground">
{status.consent === 'pending' && 'Initialising - no heartbeats sent yet.'}
@@ -195,19 +195,19 @@ export default function AdminTelemetryPage() {
Where heartbeats are sent. Defaults to the project's collector. Point at your own collector
(open source at <code>bulwarkmail/dashboard</code>) or clear this field to disable sending.
</p>
<div className="flex gap-2">
<div className="flex flex-col sm:flex-row gap-2">
<input
type="url"
value={endpointDraft}
onChange={(e) => setEndpointDraft(e.target.value)}
placeholder={status.defaultEndpoint}
className="flex-1 px-3 py-1.5 rounded-md border bg-background"
className="flex-1 min-w-0 px-3 py-1.5 rounded-md border bg-background"
/>
<button
type="button"
disabled={busy === 'endpoint' || endpointDraft === status.endpoint}
onClick={() => void saveEndpoint()}
className="px-3 py-1.5 rounded-md border bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 inline-flex items-center gap-1"
className="shrink-0 px-3 py-1.5 rounded-md border bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 inline-flex items-center justify-center gap-1"
>
<Save className="h-4 w-4" /> Save
</button>
@@ -215,8 +215,8 @@ export default function AdminTelemetryPage() {
</section>
<section className="rounded-lg border p-4 space-y-3">
<div className="flex items-center justify-between">
<div>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div className="min-w-0">
<div className="font-medium">Payload preview</div>
<div className="text-sm text-muted-foreground">
Exactly what the next heartbeat would send from this install, right now.
@@ -226,7 +226,7 @@ export default function AdminTelemetryPage() {
type="button"
disabled={busy === 'send' || !isOn}
onClick={() => void sendNow()}
className="px-3 py-1.5 rounded-md border bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 inline-flex items-center gap-1"
className="shrink-0 px-3 py-1.5 rounded-md border bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 inline-flex items-center gap-1"
>
<Send className="h-4 w-4" /> Send now
</button>
+17 -17
View File
@@ -307,12 +307,12 @@ export default function AdminThemesPage() {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<h1 className="text-2xl font-semibold text-foreground">Themes</h1>
<p className="text-sm text-muted-foreground mt-1">Manage themes and theme policy for all users</p>
</div>
<div className="flex items-center gap-2">
<div className="flex flex-wrap items-center gap-2">
{policyDirty && (
<button
onClick={handleSavePolicy}
@@ -356,37 +356,37 @@ export default function AdminThemesPage() {
<div className="divide-y divide-border">
{/* Master toggle */}
<div className="px-4 py-3 flex items-center justify-between gap-4">
<div>
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="min-w-0">
<span className="text-sm text-foreground">Themes Enabled</span>
<p className="text-xs text-muted-foreground mt-0.5">Allow users to select and apply themes</p>
</div>
<button onClick={toggleThemesEnabled}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${themesEnabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
className={`shrink-0 relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${themesEnabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${themesEnabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
</button>
</div>
{/* User uploads toggle */}
<div className="px-4 py-3 flex items-center justify-between gap-4">
<div>
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="min-w-0">
<span className="text-sm text-foreground">User Theme Uploads</span>
<p className="text-xs text-muted-foreground mt-0.5">Allow users to upload their own theme files</p>
</div>
<button onClick={toggleUserThemeUploads}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${userThemesEnabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
className={`shrink-0 relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${userThemesEnabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${userThemesEnabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
</button>
</div>
{/* Force enable / disable all */}
{themes.length > 0 && (
<div className="px-4 py-3 flex items-center justify-between gap-4">
<div>
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="min-w-0">
<span className="text-sm text-foreground">Force Enable / Disable All</span>
<p className="text-xs text-muted-foreground mt-0.5">Bulk toggle all deployed themes at once</p>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 shrink-0">
<button
onClick={forceEnableAll}
className="inline-flex items-center gap-1.5 h-7 px-3 rounded-md bg-emerald-600 text-white text-xs font-medium hover:bg-emerald-700 transition-colors"
@@ -407,15 +407,15 @@ export default function AdminThemesPage() {
{/* Default Theme */}
<div className="px-4 py-3">
<div className="flex items-center justify-between gap-4">
<div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="min-w-0">
<span className="text-sm text-foreground">Default Theme</span>
<p className="text-xs text-muted-foreground mt-0.5">Theme applied when users have not chosen one</p>
</div>
<select
value={policy.themePolicy?.defaultThemeId || ''}
onChange={(e) => setDefaultTheme(e.target.value || null)}
className="h-8 px-2 rounded-md border border-input bg-background text-sm text-foreground"
className="h-8 px-2 w-full sm:w-auto shrink-0 rounded-md border border-input bg-background text-sm text-foreground"
>
<option value="">System Default</option>
<optgroup label="Built-in">
@@ -498,9 +498,9 @@ export default function AdminThemesPage() {
) : (
<div className="divide-y divide-border">
{themes.map(theme => (
<div key={theme.id} className="px-4 py-4 flex items-center justify-between gap-4">
<div key={theme.id} className="px-4 py-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="text-sm font-medium text-foreground">{theme.name}</span>
<span className="text-xs text-muted-foreground">v{theme.version}</span>
<span className={`text-xs px-1.5 py-0.5 rounded ${theme.enabled ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400' : 'bg-muted text-muted-foreground'}`}>
+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,
+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 });
}
}
+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
+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>
+108 -106
View File
@@ -21,6 +21,7 @@ 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";
export interface PendingEventPreview {
start: Date;
@@ -130,6 +131,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(() => {
@@ -495,14 +497,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 +519,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 +552,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 +640,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 +769,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>
@@ -986,69 +988,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>
);
}
+52 -15
View File
@@ -31,6 +31,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 +68,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 +90,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 +124,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);
@@ -721,7 +730,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 +745,8 @@ export function EmailComposer({
fromEmail,
draftId || undefined,
uploadedAttachments,
currentIdentity?.name || undefined
currentIdentity?.name || undefined,
plainTextMode ? undefined : body
);
setDraftId(savedDraftId);
@@ -812,19 +822,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 +907,7 @@ export function EmailComposer({
const fromEmail = currentIdentity?.email
? subAddressTag
? generateSubAddress(currentIdentity.email, subAddressTag)
? generateSubAddress(currentIdentity.email, subAddressTag, subAddressDelimiter)
: currentIdentity.email
: undefined;
@@ -905,6 +923,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 +991,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 +1019,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
@@ -1042,6 +1077,8 @@ export function EmailComposer({
fromName: currentIdentity?.name || undefined,
identityId: currentIdentity?.id,
attachments: uploadedAttachments.length > 0 ? uploadedAttachments : undefined,
inReplyTo: threadingHeaders?.inReplyTo,
references: threadingHeaders?.references,
});
}
@@ -1179,7 +1216,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 +1229,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}
+1 -1
View File
@@ -400,7 +400,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"
+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);
})
+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
});
+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();
});
});
+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: {
+104 -6
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,12 @@ 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();
}
export class JMAPClient implements IJMAPClient {
private static readonly RATE_LIMIT_TOAST_THROTTLE_MS = 10_000;
@@ -1933,7 +1939,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,7 +1959,8 @@ 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 }[];
}
@@ -1964,8 +1972,13 @@ export class JMAPClient implements IJMAPClient {
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 +2040,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,6 +2082,11 @@ 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);
// Always create a new email with the final body content
const emailCreate: Record<string, unknown> = {
from: [{ ...(fromName ? { name: fromName } : {}), email: fromEmail || this.username }],
@@ -2075,6 +2095,8 @@ export class JMAPClient implements IJMAPClient {
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 },
};
@@ -5291,4 +5313,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'],
);
}
}
+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}`;
}
/**
+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": {
+88 -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",
@@ -783,6 +827,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 +955,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 +1053,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 +1811,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 +2344,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",
+104 -7
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",
@@ -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": {
+104 -7
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",
@@ -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",
+104 -7
View File
@@ -184,6 +184,7 @@
"batch_actions": {
"select": "Seleziona e-mail",
"select_all": "Seleziona tutto",
"selected_messages": "{count} email {count, plural, one {selezionata} other {selezionate}}",
"mark_read": "Segna come letto",
"mark_unread": "Segna come non letto",
"delete": "Elimina",
@@ -223,6 +224,11 @@
"unstar": "Rimuovi stella",
"mark_unread": "Segna come non letto",
"mark_read": "Segna come letto",
"unread": "Non letto",
"read": "Letto",
"spam_short": "Spam",
"not_spam_short": "Non spam",
"move": "Sposta",
"print": "Stampa",
"view_source": "Visualizza sorgente",
"export_email": "Esporta come .eml",
@@ -304,7 +310,20 @@
"fail": "DMARC fallito",
"none": "Nessun DMARC"
},
"spam_score": "Punteggio spam"
"spam_score": "Punteggio spam",
"tooltip_spf": "Sender Policy Framework: verifica che il server mittente sia autorizzato a inviare e-mail per conto del dominio",
"tooltip_dkim": "DomainKeys Identified Mail: conferma tramite firma crittografica che l'e-mail non è stata alterata durante il transito",
"tooltip_dmarc": "Domain-based Message Authentication, Reporting & Conformance: assicura che SPF e DKIM siano allineati al dominio del mittente e definisce una politica in caso di errore",
"policy": "Criterio",
"result": {
"pass": "Superato",
"fail": "Fallito",
"softfail": "Fallimento lieve",
"neutral": "Neutro",
"permerror": "Errore permanente",
"temperror": "Errore temporaneo",
"none": "Nessuno"
}
},
"headers": {
"routing": "Instradamento",
@@ -424,12 +443,39 @@
"cancel_info": "L'organizzatore ha annullato questo evento.",
"event_updated": "Aggiornamento #{sequence}",
"event_status_tentative": "Provvisorio",
"event_status_cancelled": "Annullato"
"event_status_cancelled": "Annullato",
"collapse": "Nascondi dettagli",
"expand": "Mostra dettagli"
},
"previous": "Precedente",
"next": "Successivo",
"send": "Invia",
"more": "altri"
"more": "altri",
"details": {
"recipients_routing": "Destinatari e instradamento",
"authentication_security": "Autenticazione e sicurezza",
"identifiers_threading": "Identificatori e conversazione",
"mailing_list": "Mailing list",
"message_properties": "Proprietà del messaggio",
"sent": "Inviato",
"received": "Ricevuto",
"delivery_time": "Tempo di consegna",
"in_reply_to": "In-Reply-To",
"references": "Riferimenti",
"thread_id": "ID conversazione",
"size": "Dimensione",
"mime_type": "Tipo MIME",
"attachments_summary": "{count} file · {size}",
"list_id": "ID lista",
"list_help": "Guida lista",
"list_post": "Invio alla lista",
"list_unsubscribe": "Annulla iscrizione",
"iprev": "DNS inverso",
"spam_status": "Stato spam",
"ai_verdict": "Verdetto IA",
"account": "Account",
"no_subject": "(nessun oggetto)"
}
},
"email_composer": {
"new_message": "Nuovo messaggio",
@@ -508,7 +554,10 @@
"message": "Il tuo messaggio menziona \"{keyword}\" ma nessun file è allegato. Inviare comunque?",
"send_anyway": "Invia comunque",
"back": "Torna alla modifica"
}
},
"add_link": "Aggiungi link",
"link_url_prompt": "Inserisci l'URL",
"sending": "Invio in corso..."
},
"confirm_dialog": {
"confirm": "Conferma",
@@ -801,6 +850,23 @@
"sound_desc": "Riproduci un suono per i promemoria del calendario",
"invitation_parsing": "Analizza inviti via e-mail",
"invitation_parsing_desc": "Rileva inviti calendario negli allegati e mostra azioni calendario"
},
"push": {
"confirm_disable_message": "Questo dispositivo non riceverà più avvisi quando il sito è chiuso.",
"confirm_disable_title": "Disabilitare le notifiche in background?",
"description": "Ricevi notifiche di sistema per la nuova posta quando questo sito è chiuso. Consegnato tramite il relay push Bulwark; il relay non vede mai il contenuto della posta.",
"disable": "Disabilita",
"enable": "Abilita",
"ios_hint": "Su iOS, installa prima il sito sulla schermata Home: Safari consegna Web Push solo alle PWA installate.",
"reenable": "Registra di nuovo",
"relay_desc": "Per impostazione predefinita usa il relay Bulwark ospitato. Cambialo solo se ospiti in autonomia.",
"relay_label": "Relay push",
"relay_placeholder": "https://notifications.relay.example.com",
"status_active": "Attivo su questo dispositivo",
"status_busy": "In elaborazione…",
"status_inactive": "Non abilitato su questo dispositivo",
"status_unsupported": "Questo browser non supporta Web Push",
"title": "Notifiche in background"
}
},
"language_region": {
@@ -884,6 +950,13 @@
"label": "Seleziona automaticamente l'indirizzo di risposta",
"description": "Quando rispondi, passa automaticamente l'indirizzo mittente all'identità che ha ricevuto il messaggio originale"
},
"sub_address_delimiter": {
"label": "Delimitatore sub-indirizzo",
"description": "Carattere che separa il tuo nome utente dall'etichetta del sub-indirizzo. Usa il delimitatore configurato sul tuo server di posta (es. utente{delimiter}tag@dominio.com).",
"option": "{delimiter} (utente{delimiter}tag@dominio.com)",
"custom": "Personalizzato…",
"custom_input_label": "Carattere delimitatore personalizzato"
},
"show_preview": {
"label": "Mostra anteprima testo",
"description": "Visualizza l'anteprima del messaggio nell'elenco",
@@ -980,6 +1053,10 @@
"hide_inline_image_attachments": {
"label": "Nascondi le immagini inline dagli allegati",
"description": "Le immagini incorporate nel corpo del messaggio non vengono elencate come allegati separati"
},
"attachment_image_previews": {
"label": "Mostra anteprime delle immagini negli allegati",
"description": "Mostra gli allegati immagine come miniature anziché con icone di file generiche"
}
},
"composer": {
@@ -1333,7 +1410,9 @@
"no_address_books": "Nessuna rubrica trovata",
"categories_title": "Categorie",
"categories_description": "Rinomina le categorie dei contatti",
"no_categories": "Nessuna categoria trovata"
"no_categories": "Nessuna categoria trovata",
"group_by_letter_description": "Mostra intestazioni alfabetiche nell'elenco dei contatti",
"group_by_letter_label": "Raggruppa per prima lettera"
},
"filters": {
"title": "Filtri email",
@@ -1732,7 +1811,7 @@
"use_address": "Usa questo indirizzo",
"invalid_tag": "Il tag deve contenere solo caratteri alfanumerici e trattini",
"tag_too_long": "Il tag deve essere di massimo 30 caratteri",
"help_text": "I messaggi inviati a utente+tag@dominio.com arriveranno nella tua casella di posta",
"help_text": "I messaggi inviati a utente{delimiter}tag@dominio.com arriveranno nella tua casella di posta",
"validation": {
"empty": "Il tag non può essere vuoto",
"too_long": "Il tag deve essere di massimo {max} caratteri",
@@ -2129,7 +2208,11 @@
"tomorrow_header": "Domani",
"export_ics": "Esporta come .ics",
"copy_title": "Copia titolo",
"copy_link": "Copia link riunione"
"copy_link": "Copia link riunione",
"go_to_today": "Vai a oggi",
"new_all_day_event": "Nuovo evento giornata intera",
"new_event": "Nuovo evento",
"new_task": "Nuova attività"
},
"detail": {
"add_note": "Aggiungi una nota...",
@@ -2428,6 +2511,20 @@
"due_today": "Scade oggi",
"due_tomorrow": "Scade domani",
"overdue": "Scaduta"
},
"months": {
"jan": "gen",
"feb": "feb",
"mar": "mar",
"apr": "apr",
"may": "mag",
"jun": "giu",
"jul": "lug",
"aug": "ago",
"sep": "set",
"oct": "ott",
"nov": "nov",
"dec": "dic"
}
},
"advanced_search": {
+104 -7
View File
@@ -184,6 +184,7 @@
"batch_actions": {
"select": "メールを選択",
"select_all": "すべて選択",
"selected_messages": "{count, plural, one {1} other {#}}通のメールが選択されました",
"mark_read": "既読にする",
"mark_unread": "未読にする",
"delete": "削除",
@@ -223,6 +224,11 @@
"unstar": "スターを外す",
"mark_unread": "未読にする",
"mark_read": "既読にする",
"unread": "未読",
"read": "既読",
"spam_short": "迷惑メール",
"not_spam_short": "迷惑メールでない",
"move": "移動",
"print": "印刷",
"view_source": "ソースを表示",
"export_email": ".emlとしてエクスポート",
@@ -304,7 +310,20 @@
"fail": "DMARC不合格",
"none": "DMARCなし"
},
"spam_score": "スパムスコア"
"spam_score": "スパムスコア",
"tooltip_spf": "Sender Policy Framework: 送信サーバーがそのドメインを代表してメールを送信する権限を持つことを確認します",
"tooltip_dkim": "DomainKeys Identified Mail: 暗号署名を用いて、メールが送信中に改ざんされていないことを確認します",
"tooltip_dmarc": "Domain-based Message Authentication, Reporting & Conformance: SPF と DKIM が送信者のドメインと一致することを確認し、失敗時のポリシーを定めます",
"policy": "ポリシー",
"result": {
"pass": "合格",
"fail": "不合格",
"softfail": "ソフト失敗",
"neutral": "中立",
"permerror": "永続的エラー",
"temperror": "一時的エラー",
"none": "なし"
}
},
"headers": {
"routing": "ルーティング",
@@ -424,12 +443,39 @@
"cancel_info": "主催者がこのイベントをキャンセルしました。",
"event_updated": "更新 #{sequence}",
"event_status_tentative": "仮",
"event_status_cancelled": "キャンセル済み"
"event_status_cancelled": "キャンセル済み",
"collapse": "詳細を非表示",
"expand": "詳細を表示"
},
"previous": "前へ",
"next": "次へ",
"send": "送信",
"more": "他"
"more": "他",
"details": {
"recipients_routing": "宛先とルーティング",
"authentication_security": "認証とセキュリティ",
"identifiers_threading": "識別子とスレッド",
"mailing_list": "メーリングリスト",
"message_properties": "メッセージのプロパティ",
"sent": "送信日時",
"received": "受信日時",
"delivery_time": "配信時間",
"in_reply_to": "返信先",
"references": "参照",
"thread_id": "スレッドID",
"size": "サイズ",
"mime_type": "MIMEタイプ",
"attachments_summary": "{count} ファイル · {size}",
"list_id": "リストID",
"list_help": "リストヘルプ",
"list_post": "リスト投稿",
"list_unsubscribe": "購読解除",
"iprev": "逆引きDNS",
"spam_status": "スパム状態",
"ai_verdict": "AIの判定",
"account": "アカウント",
"no_subject": "(件名なし)"
}
},
"email_composer": {
"new_message": "新規メッセージ",
@@ -508,7 +554,10 @@
"message": "メッセージに「{keyword}」が含まれていますが、ファイルが添付されていません。このまま送信しますか?",
"send_anyway": "そのまま送信",
"back": "編集に戻る"
}
},
"add_link": "リンクを追加",
"link_url_prompt": "URLを入力してください",
"sending": "送信中..."
},
"confirm_dialog": {
"confirm": "確認",
@@ -801,6 +850,23 @@
"sound_desc": "カレンダーリマインダーの音を鳴らす",
"invitation_parsing": "メール招待を解析",
"invitation_parsing_desc": "メール添付ファイルのカレンダー招待を検出し、カレンダーアクションを表示"
},
"push": {
"confirm_disable_message": "このデバイスは、サイトが閉じているときに通知を受信しなくなります。",
"confirm_disable_title": "バックグラウンド通知を無効にしますか?",
"description": "このサイトが閉じているときに新着メールのシステム通知を受信します。Bulwark プッシュリレー経由で配信され、リレーがメール内容を見ることはありません。",
"disable": "無効化",
"enable": "有効化",
"ios_hint": "iOS では、最初にサイトをホーム画面にインストールしてください。Safari はインストールされた PWA にのみ Web Push を配信します。",
"reenable": "再登録",
"relay_desc": "デフォルトはホストされた Bulwark リレーです。セルフホストする場合のみ変更してください。",
"relay_label": "プッシュリレー",
"relay_placeholder": "https://notifications.relay.example.com",
"status_active": "このデバイスで有効",
"status_busy": "処理中…",
"status_inactive": "このデバイスでは有効になっていません",
"status_unsupported": "このブラウザは Web Push をサポートしていません",
"title": "バックグラウンド通知"
}
},
"language_region": {
@@ -884,6 +950,13 @@
"label": "返信元アドレスを自動選択",
"description": "返信時に、元のメッセージを受信したIDへ差出人アドレスを自動的に切り替えます"
},
"sub_address_delimiter": {
"label": "サブアドレス区切り文字",
"description": "ユーザー名とサブアドレスタグを区切る文字です。お使いのメールサーバーが使用する区切り文字に合わせてください(例: user{delimiter}tag@domain.com)。",
"option": "{delimiter} (user{delimiter}tag@domain.com)",
"custom": "カスタム…",
"custom_input_label": "カスタム区切り文字"
},
"show_preview": {
"label": "プレビューテキストを表示",
"description": "リストにメールのプレビューを表示",
@@ -980,6 +1053,10 @@
"hide_inline_image_attachments": {
"label": "添付ファイル一覧からインライン画像を隠す",
"description": "本文に埋め込まれた画像を個別の添付ファイルとして表示しません"
},
"attachment_image_previews": {
"label": "添付ファイル内の画像プレビューを表示",
"description": "画像の添付ファイルを汎用ファイルアイコンではなくサムネイルカードとして表示します"
}
},
"composer": {
@@ -1333,7 +1410,9 @@
"no_address_books": "アドレス帳が見つかりません",
"categories_title": "カテゴリ",
"categories_description": "連絡先カテゴリの名前を変更",
"no_categories": "カテゴリが見つかりません"
"no_categories": "カテゴリが見つかりません",
"group_by_letter_description": "連絡先リストにアルファベット順のセクション見出しを表示",
"group_by_letter_label": "頭文字でグループ化"
},
"filters": {
"title": "メールフィルター",
@@ -1732,7 +1811,7 @@
"use_address": "このアドレスを使用",
"invalid_tag": "タグは英数字とハイフンのみ使用できます",
"tag_too_long": "タグは30文字以内にしてください",
"help_text": "user+tag@domain.comに送信されたメールは受信トレイに届きます",
"help_text": "user{delimiter}tag@domain.comに送信されたメールは受信トレイに届きます",
"validation": {
"empty": "タグは空にできません",
"too_long": "タグは{max}文字以内にしてください",
@@ -2129,7 +2208,11 @@
"tomorrow_header": "明日",
"export_ics": ".icsとしてエクスポート",
"copy_title": "タイトルをコピー",
"copy_link": "会議リンクをコピー"
"copy_link": "会議リンクをコピー",
"go_to_today": "今日に移動",
"new_all_day_event": "新しい終日イベント",
"new_event": "新しいイベント",
"new_task": "新しいタスク"
},
"detail": {
"add_note": "メモを追加...",
@@ -2428,6 +2511,20 @@
"due_today": "今日が期限",
"due_tomorrow": "明日が期限",
"overdue": "期限切れ"
},
"months": {
"jan": "1月",
"feb": "2月",
"mar": "3月",
"apr": "4月",
"may": "5月",
"jun": "6月",
"jul": "7月",
"aug": "8月",
"sep": "9月",
"oct": "10月",
"nov": "11月",
"dec": "12月"
}
},
"advanced_search": {
+104 -7
View File
@@ -184,6 +184,7 @@
"batch_actions": {
"select": "메일 선택",
"select_all": "모두 선택",
"selected_messages": "이메일 {count, plural, one {1개} other {#건}} 선택됨",
"mark_read": "읽은 상태로 표시",
"mark_unread": "읽지 않은 상태로 표시",
"delete": "삭제",
@@ -223,6 +224,11 @@
"unstar": "별표 해제",
"mark_unread": "읽지 않은 상태로 표시",
"mark_read": "읽은 상태로 표시",
"unread": "안 읽음",
"read": "읽음",
"spam_short": "스팸",
"not_spam_short": "스팸 아님",
"move": "이동",
"print": "인쇄",
"view_source": "원본 보기",
"export_email": ".eml 파일로 내보내기",
@@ -306,7 +312,20 @@
"fail": "DMARC 실패",
"none": "DMARC 없음"
},
"spam_score": "스팸 점수"
"spam_score": "스팸 점수",
"tooltip_spf": "Sender Policy Framework: 발신 서버가 해당 도메인을 대신해 메일을 보낼 권한이 있는지 확인합니다",
"tooltip_dkim": "DomainKeys Identified Mail: 암호화 서명을 통해 메일이 전송 중에 변경되지 않았음을 확인합니다",
"tooltip_dmarc": "Domain-based Message Authentication, Reporting & Conformance: SPF와 DKIM이 발신자 도메인과 일치하는지 확인하고 실패에 대한 정책을 설정합니다",
"policy": "정책",
"result": {
"pass": "통과",
"fail": "실패",
"softfail": "경미한 실패",
"neutral": "중립",
"permerror": "영구 오류",
"temperror": "일시적 오류",
"none": "없음"
}
},
"headers": {
"routing": "라우팅",
@@ -426,10 +445,37 @@
"cancel_info": "주최자가 이 일정을 취소했어요.",
"event_updated": "업데이트 #{sequence}",
"event_status_tentative": "미정",
"event_status_cancelled": "취소됨"
"event_status_cancelled": "취소됨",
"collapse": "세부정보 숨기기",
"expand": "세부정보 표시"
},
"send": "보내기",
"more": "더보기"
"more": "더보기",
"details": {
"recipients_routing": "수신자 및 라우팅",
"authentication_security": "인증 및 보안",
"identifiers_threading": "식별자 및 스레드",
"mailing_list": "메일링 리스트",
"message_properties": "메시지 속성",
"sent": "보낸 시각",
"received": "받은 시각",
"delivery_time": "전달 시간",
"in_reply_to": "회신 대상",
"references": "참조",
"thread_id": "스레드 ID",
"size": "크기",
"mime_type": "MIME 타입",
"attachments_summary": "{count}개 파일 · {size}",
"list_id": "리스트 ID",
"list_help": "리스트 도움말",
"list_post": "리스트 게시",
"list_unsubscribe": "구독 해지",
"iprev": "역방향 DNS",
"spam_status": "스팸 상태",
"ai_verdict": "AI 판정",
"account": "계정",
"no_subject": "(제목 없음)"
}
},
"email_composer": {
"new_message": "새 메시지",
@@ -508,7 +554,10 @@
"message": "메시지에 \"{keyword}\"이(가) 언급되어 있지만 파일이 첨부되지 않았습니다. 그래도 보내시겠습니까?",
"send_anyway": "그래도 보내기",
"back": "편집으로 돌아가기"
}
},
"add_link": "링크 추가",
"link_url_prompt": "URL을 입력하세요",
"sending": "전송 중..."
},
"confirm_dialog": {
"confirm": "확인",
@@ -801,6 +850,23 @@
"sound_desc": "일정 알림이 올 때 소리로 알려줘요",
"invitation_parsing": "이메일 초대장 분석",
"invitation_parsing_desc": "이메일 첨부파일에서 캘린더 초대장을 감지하고 캘린더에 표시해요"
},
"push": {
"confirm_disable_message": "이 사이트가 닫혀 있을 때 이 기기는 알림을 더 이상 받지 않습니다.",
"confirm_disable_title": "백그라운드 알림을 비활성화하시겠습니까?",
"description": "이 사이트가 닫혀 있을 때 새 메일에 대한 시스템 알림을 받습니다. Bulwark 푸시 릴레이를 통해 전달되며, 릴레이는 메일 내용을 절대 보지 않습니다.",
"disable": "비활성화",
"enable": "활성화",
"ios_hint": "iOS에서는 먼저 사이트를 홈 화면에 설치하세요. Safari는 설치된 PWA에만 Web Push를 전달합니다.",
"reenable": "다시 등록",
"relay_desc": "기본값은 호스팅된 Bulwark 릴레이입니다. 셀프 호스팅 시에만 변경하세요.",
"relay_label": "푸시 릴레이",
"relay_placeholder": "https://notifications.relay.example.com",
"status_active": "이 기기에서 활성",
"status_busy": "처리 중…",
"status_inactive": "이 기기에서 활성화되지 않음",
"status_unsupported": "이 브라우저는 Web Push를 지원하지 않습니다",
"title": "백그라운드 알림"
}
},
"language_region": {
@@ -889,6 +955,13 @@
"label": "답장 시 보내는 사람 자동 선택",
"description": "답장할 때 메일을 받았던 주소로 보내는 사람을 자동으로 변경해요"
},
"sub_address_delimiter": {
"label": "서브 주소 구분자",
"description": "사용자 이름과 서브 주소 태그를 나누는 문자예요. 메일 서버가 사용하는 구분자에 맞춰 주세요 (예: user{delimiter}tag@domain.com).",
"option": "{delimiter} (user{delimiter}tag@domain.com)",
"custom": "사용자 지정…",
"custom_input_label": "사용자 지정 구분 문자"
},
"attachment_click_action": {
"label": "첨부파일 클릭 동작",
"description": "파일을 클릭했을 때 미리보기를 할지, 바로 다운로드할지 선택해 주세요",
@@ -980,6 +1053,10 @@
"hide_inline_image_attachments": {
"label": "첨부 파일 목록에서 인라인 이미지 숨기기",
"description": "메시지 본문에 포함된 이미지는 별도의 첨부 파일로 표시되지 않습니다"
},
"attachment_image_previews": {
"label": "첨부 파일에 이미지 미리보기 표시",
"description": "이미지 첨부 파일을 일반 파일 아이콘 대신 썸네일 카드로 표시합니다"
}
},
"composer": {
@@ -1333,7 +1410,9 @@
"no_address_books": "주소록을 찾을 수 없음",
"categories_title": "카테고리",
"categories_description": "연락처 카테고리 이름 변경",
"no_categories": "카테고리를 찾을 수 없음"
"no_categories": "카테고리를 찾을 수 없음",
"group_by_letter_description": "연락처 목록에 알파벳순 섹션 헤더 표시",
"group_by_letter_label": "첫 글자로 그룹화"
},
"filters": {
"title": "이메일 필터",
@@ -1732,7 +1811,7 @@
"use_address": "이 주소 사용하기",
"invalid_tag": "태그는 알파벳, 숫자, 대시(-)만 쓸 수 있어요",
"tag_too_long": "태그는 30자 이하여야 해요",
"help_text": "user+tag@domain.com 으로 보낸 메일은 내 받은편지함으로 들어와요",
"help_text": "user{delimiter}tag@domain.com 으로 보낸 메일은 내 받은편지함으로 들어와요",
"validation": {
"empty": "태그를 비워둘 수 없어요",
"too_long": "태그는 {max}자 이하여야 해요",
@@ -2129,7 +2208,11 @@
"tomorrow_header": "내일",
"export_ics": ".ics로 내보내기",
"copy_title": "제목 복사",
"copy_link": "회의 링크 복사"
"copy_link": "회의 링크 복사",
"go_to_today": "오늘로 이동",
"new_all_day_event": "새 종일 이벤트",
"new_event": "새 이벤트",
"new_task": "새 작업"
},
"detail": {
"add_note": "메모 추가...",
@@ -2428,6 +2511,20 @@
"due_today": "오늘 마감",
"due_tomorrow": "내일 마감",
"overdue": "기한 지남"
},
"months": {
"jan": "1월",
"feb": "2월",
"mar": "3월",
"apr": "4월",
"may": "5월",
"jun": "6월",
"jul": "7월",
"aug": "8월",
"sep": "9월",
"oct": "10월",
"nov": "11월",
"dec": "12월"
}
},
"advanced_search": {
+105 -8
View File
@@ -184,6 +184,7 @@
"batch_actions": {
"select": "Atlasīt vēstules",
"select_all": "Atlasīt visas",
"selected_messages": "{count, plural, one {Izvēlēta 1 e-pasta vēstule} other {Izvēlētas # e-pasta vēstules}}",
"mark_read": "Atzīmēt kā izlasītu",
"mark_unread": "Atzīmēt kā nelasītu",
"delete": "Dzēst",
@@ -223,6 +224,11 @@
"unstar": "Noņemt zvaigznīti",
"mark_unread": "Atzīmēt kā nelasītu",
"mark_read": "Atzīmēt kā izlasītu",
"unread": "Nelasīts",
"read": "Lasīts",
"spam_short": "Spams",
"not_spam_short": "Nav spams",
"move": "Pārvietot",
"print": "Drukāt",
"view_source": "Skatīt avota kodu",
"export_email": "Eksportēt kā .eml",
@@ -306,7 +312,20 @@
"fail": "DMARC nav izturēts",
"none": "DMARC nav atrasts"
},
"spam_score": "Mēstuļu vērtējums"
"spam_score": "Mēstuļu vērtējums",
"tooltip_spf": "Sender Policy Framework: pārbauda, vai sūtošajam serverim ir tiesības sūtīt e-pastu domēna vārdā",
"tooltip_dkim": "DomainKeys Identified Mail: ar kriptogrāfisku parakstu apstiprina, ka vēstule pārsūtīšanas laikā nav grozīta",
"tooltip_dmarc": "Domain-based Message Authentication, Reporting & Conformance: nodrošina, ka SPF un DKIM atbilst sūtītāja domēnam, un nosaka politiku kļūmju gadījumiem",
"policy": "Politika",
"result": {
"pass": "Izturēts",
"fail": "Nav izturēts",
"softfail": "Daļēja kļūme",
"neutral": "Neitrāli",
"permerror": "Pastāvīga kļūda",
"temperror": "Pagaidu kļūda",
"none": "Nav"
}
},
"headers": {
"routing": "Maršrutēšana",
@@ -426,10 +445,37 @@
"cancel_info": "Organizators atcēla šo pasākumu.",
"event_updated": "Atjauninājums Nr. {sequence}",
"event_status_tentative": "Pagaidām",
"event_status_cancelled": "Atcelts"
"event_status_cancelled": "Atcelts",
"collapse": "Paslēpt detaļas",
"expand": "Rādīt detaļas"
},
"send": "Sūtīt",
"more": "vairāk"
"more": "vairāk",
"details": {
"recipients_routing": "Adresāti un maršrutēšana",
"authentication_security": "Autentifikācija un drošība",
"identifiers_threading": "Identifikatori un sarakste",
"mailing_list": "Adresātu saraksts",
"message_properties": "Vēstules īpašības",
"sent": "Nosūtīts",
"received": "Saņemts",
"delivery_time": "Piegādes laiks",
"in_reply_to": "Atbildot uz",
"references": "Atsauces",
"thread_id": "Sarakstes ID",
"size": "Lielums",
"mime_type": "MIME tips",
"attachments_summary": "{count} faili · {size}",
"list_id": "Saraksta ID",
"list_help": "Saraksta palīdzība",
"list_post": "Sūtīšana sarakstam",
"list_unsubscribe": "Atteikties",
"iprev": "Apgrieztais DNS",
"spam_status": "Mēstuļu statuss",
"ai_verdict": "AI vērtējums",
"account": "Konts",
"no_subject": "(bez temata)"
}
},
"email_composer": {
"new_message": "Jauns ziņojums",
@@ -508,7 +554,10 @@
"message": "Jūsu ziņojumā minēts \"{keyword}\", bet nav pievienots neviens fails. Vai tomēr sūtīt?",
"send_anyway": "Sūtīt tik un tā",
"back": "Atpakaļ pie rediģēšanas"
}
},
"add_link": "Pievienot saiti",
"link_url_prompt": "Ievadiet URL",
"sending": "Sūta..."
},
"confirm_dialog": {
"confirm": "Apstiprināt",
@@ -801,6 +850,23 @@
"sound_desc": "Atskaņot skaņas signālu kalendāra atgādinājumiem",
"invitation_parsing": "Atpazīt uzaicinājumus e-pastā",
"invitation_parsing_desc": "Noteikt kalendāra uzaicinājumus pielikumos un rādīt kalendāra darbības"
},
"push": {
"confirm_disable_message": "Šī ierīce vairs nesaņems brīdinājumus, kad vietne būs aizvērta.",
"confirm_disable_title": "Atspējot fona paziņojumus?",
"description": "Saņem sistēmas paziņojumus par jaunu pastu, kad šī vietne ir aizvērta. Piegādāts caur Bulwark push releju; relejs nekad neredz pasta saturu.",
"disable": "Atspējot",
"enable": "Iespējot",
"ios_hint": "Operētājsistēmā iOS vispirms instalējiet vietni sākuma ekrānā Safari piegādā Web Push tikai instalētajām PWA.",
"reenable": "Reģistrēt vēlreiz",
"relay_desc": "Pēc noklusējuma izmanto izmitināto Bulwark releju. Mainiet tikai tad, ja izmitināt pats.",
"relay_label": "Push relejs",
"relay_placeholder": "https://notifications.relay.example.com",
"status_active": "Aktīvs šajā ierīcē",
"status_busy": "Notiek darbs…",
"status_inactive": "Nav iespējots šajā ierīcē",
"status_unsupported": "Šī pārlūkprogramma neatbalsta Web Push",
"title": "Fona paziņojumi"
}
},
"language_region": {
@@ -884,6 +950,13 @@
"label": "Automātiski izvēlēties atbildes adresi",
"description": "Atbildot automātiski izmantot to kontu, uz kuru vēstule tika saņemta"
},
"sub_address_delimiter": {
"label": "Apakšadreses atdalītājs",
"description": "Zīme, kas atdala lietotājvārdu no apakšadreses tagu. Izvēlieties atdalītāju, ko lieto jūsu pasta serveris (piem. lietotajs{delimiter}tags@domens.lv).",
"option": "{delimiter} (lietotajs{delimiter}tags@domens.lv)",
"custom": "Pielāgots…",
"custom_input_label": "Pielāgota atdalītāja zīme"
},
"show_preview": {
"label": "Rādīt priekšskatījuma tekstu",
"description": "Rādīt vēstules fragmentu sarakstā",
@@ -980,6 +1053,10 @@
"hide_inline_image_attachments": {
"label": "Slēpt iegultos attēlus no pielikumu saraksta",
"description": "Ziņojuma pamattekstā iegultie attēli netiek rādīti kā atsevišķi pielikumi"
},
"attachment_image_previews": {
"label": "Rādīt attēlu priekšskatījumus pielikumos",
"description": "Rādīt attēlu pielikumus kā sīktēlu kartītes, nevis vispārīgas failu ikonas"
}
},
"composer": {
@@ -1333,7 +1410,9 @@
"no_address_books": "Adrešu grāmatas nav atrastas",
"categories_title": "Kategorijas",
"categories_description": "Pārdēvēt kontaktu kategorijas",
"no_categories": "Kategorijas nav atrastas"
"no_categories": "Kategorijas nav atrastas",
"group_by_letter_description": "Rādīt alfabētiskos sadaļu virsrakstus kontaktu sarakstā",
"group_by_letter_label": "Grupēt pēc pirmā burta"
},
"filters": {
"title": "Pasta filtri",
@@ -1732,7 +1811,7 @@
"use_address": "Izmantot šo adresi",
"invalid_tag": "Tags var saturēt tikai burtus, ciparus un domuzīmes",
"tag_too_long": "Tags nedrīkst pārsniegt 30 rakstzīmes",
"help_text": "Vēstules uz lietotajs+tags@domens.lv nonāks jūsu pastkastē",
"help_text": "Vēstules uz lietotajs{delimiter}tags@domens.lv nonāks jūsu pastkastē",
"validation": {
"empty": "Tags nevar būt tukšs",
"too_long": "Tags nedrīkst pārsniegt {max} rakstzīmes",
@@ -2128,7 +2207,11 @@
"tomorrow_header": "Rīt",
"export_ics": "Eksportēt kā .ics",
"copy_title": "Kopēt nosaukumu",
"copy_link": "Kopēt sapulces saiti"
"copy_link": "Kopēt sapulces saiti",
"go_to_today": "Pāriet uz šodienu",
"new_all_day_event": "Jauns visas dienas notikums",
"new_event": "Jauns notikums",
"new_task": "Jauns uzdevums"
},
"detail": {
"add_note": "Pievienot piezīmi...",
@@ -2428,7 +2511,21 @@
"due_tomorrow": "Rīt",
"overdue": "Kavēts"
},
"birthday_calendar": "Dzimšanas dienas"
"birthday_calendar": "Dzimšanas dienas",
"months": {
"jan": "janv.",
"feb": "febr.",
"mar": "marts",
"apr": "apr.",
"may": "maijs",
"jun": "jūn.",
"jul": "jūl.",
"aug": "aug.",
"sep": "sept.",
"oct": "okt.",
"nov": "nov.",
"dec": "dec."
}
},
"advanced_search": {
"title": "Izvērstā meklēšana",
+104 -7
View File
@@ -185,6 +185,7 @@
"select": "E-mails selecteren",
"select_all": "Alles selecteren",
"mark_read": "Markeren als gelezen",
"selected_messages": "{count, plural, one {1 e-mail} other {# e-mails}} geselecteerd",
"mark_unread": "Markeren als ongelezen",
"delete": "Verwijderen",
"delete_confirm_title": "E-mails verwijderen",
@@ -223,6 +224,11 @@
"unstar": "Ster verwijderen",
"mark_unread": "Markeren als ongelezen",
"mark_read": "Markeren als gelezen",
"unread": "Ongelezen",
"read": "Gelezen",
"spam_short": "Spam",
"not_spam_short": "Geen spam",
"move": "Verplaatsen",
"print": "Afdrukken",
"view_source": "Bron bekijken",
"export_email": "Exporteren als .eml",
@@ -304,7 +310,20 @@
"fail": "DMARC Mislukt",
"none": "Geen DMARC"
},
"spam_score": "Spamscore"
"spam_score": "Spamscore",
"tooltip_spf": "Sender Policy Framework: controleert of de verzendende server gemachtigd is om e-mail namens het domein te verzenden",
"tooltip_dkim": "DomainKeys Identified Mail: bevestigt met een cryptografische handtekening dat de e-mail tijdens het transport niet is gewijzigd",
"tooltip_dmarc": "Domain-based Message Authentication, Reporting & Conformance: zorgt ervoor dat SPF en DKIM overeenkomen met het domein van de afzender en stelt een beleid in voor fouten",
"policy": "Beleid",
"result": {
"pass": "Geslaagd",
"fail": "Mislukt",
"softfail": "Zachte mislukking",
"neutral": "Neutraal",
"permerror": "Permanente fout",
"temperror": "Tijdelijke fout",
"none": "Geen"
}
},
"headers": {
"routing": "Routering",
@@ -424,12 +443,39 @@
"cancel_info": "De organisator heeft dit evenement geannuleerd.",
"event_updated": "Update #{sequence}",
"event_status_tentative": "Voorlopig",
"event_status_cancelled": "Geannuleerd"
"event_status_cancelled": "Geannuleerd",
"collapse": "Details verbergen",
"expand": "Details tonen"
},
"previous": "Vorige",
"next": "Volgende",
"send": "Verzenden",
"more": "meer"
"more": "meer",
"details": {
"recipients_routing": "Ontvangers & routing",
"authentication_security": "Authenticatie & beveiliging",
"identifiers_threading": "Identificatoren & threading",
"mailing_list": "Mailinglijst",
"message_properties": "Berichteigenschappen",
"sent": "Verzonden",
"received": "Ontvangen",
"delivery_time": "Bezorgtijd",
"in_reply_to": "In-Reply-To",
"references": "Verwijzingen",
"thread_id": "Thread-ID",
"size": "Grootte",
"mime_type": "MIME-type",
"attachments_summary": "{count} bestanden · {size}",
"list_id": "Lijst-ID",
"list_help": "Lijsthulp",
"list_post": "Lijstbericht",
"list_unsubscribe": "Afmelden",
"iprev": "Reverse DNS",
"spam_status": "Spamstatus",
"ai_verdict": "AI-oordeel",
"account": "Account",
"no_subject": "(geen onderwerp)"
}
},
"email_composer": {
"new_message": "Nieuw bericht",
@@ -508,7 +554,10 @@
"message": "Uw bericht vermeldt \"{keyword}\" maar er is geen bestand bijgevoegd. Toch verzenden?",
"send_anyway": "Toch verzenden",
"back": "Terug naar bewerken"
}
},
"add_link": "Link toevoegen",
"link_url_prompt": "Voer de URL in",
"sending": "Bezig met verzenden..."
},
"confirm_dialog": {
"confirm": "Bevestigen",
@@ -801,6 +850,23 @@
"sound_desc": "Een geluid afspelen voor agendaherinneringen",
"invitation_parsing": "E-mailuitnodigingen herkennen",
"invitation_parsing_desc": "Agenda-uitnodigingen in e-mailbijlagen detecteren en agendaacties tonen"
},
"push": {
"confirm_disable_message": "Dit apparaat ontvangt geen meldingen meer als de site is gesloten.",
"confirm_disable_title": "Achtergrondmeldingen uitschakelen?",
"description": "Ontvang systeemmeldingen voor nieuwe e-mail wanneer deze site gesloten is. Geleverd via de Bulwark-pushrelay; de relay ziet nooit e-mailinhoud.",
"disable": "Uitschakelen",
"enable": "Inschakelen",
"ios_hint": "Installeer de site op iOS eerst op het beginscherm Safari levert Web Push alleen aan geïnstalleerde PWA's.",
"reenable": "Opnieuw registreren",
"relay_desc": "Gebruikt standaard de gehoste Bulwark-relay. Wijzig alleen als je zelf hostt.",
"relay_label": "Push-relay",
"relay_placeholder": "https://notifications.relay.example.com",
"status_active": "Actief op dit apparaat",
"status_busy": "Bezig…",
"status_inactive": "Niet ingeschakeld op dit apparaat",
"status_unsupported": "Deze browser ondersteunt geen Web Push",
"title": "Achtergrondmeldingen"
}
},
"language_region": {
@@ -884,6 +950,13 @@
"label": "Antwoordadres automatisch selecteren",
"description": "Schakel bij het beantwoorden automatisch het Van-adres om naar de identiteit die het oorspronkelijke bericht ontving"
},
"sub_address_delimiter": {
"label": "Sub-adres scheidingsteken",
"description": "Teken dat je gebruikersnaam scheidt van het sub-adres-label. Gebruik het scheidingsteken dat je mailserver gebruikt (bv. gebruiker{delimiter}tag@domein.nl).",
"option": "{delimiter} (gebruiker{delimiter}tag@domein.nl)",
"custom": "Aangepast…",
"custom_input_label": "Aangepast scheidingsteken"
},
"show_preview": {
"label": "Voorbeeldtekst tonen",
"description": "E-mailvoorbeeld weergeven in de lijst",
@@ -980,6 +1053,10 @@
"hide_inline_image_attachments": {
"label": "Inline-afbeeldingen verbergen uit bijlagenlijst",
"description": "Afbeeldingen die in de berichttekst zijn ingesloten worden niet als aparte bijlagen weergegeven"
},
"attachment_image_previews": {
"label": "Voorbeelden van afbeeldingen tonen in bijlagen",
"description": "Toon afbeeldingsbijlagen als miniatuurkaarten in plaats van generieke bestandspictogrammen"
}
},
"composer": {
@@ -1333,7 +1410,9 @@
"no_address_books": "Geen adresboeken gevonden",
"categories_title": "Categorieën",
"categories_description": "Contactcategorieën hernoemen",
"no_categories": "Geen categorieën gevonden"
"no_categories": "Geen categorieën gevonden",
"group_by_letter_description": "Toon alfabetische sectiekoppen in de contactenlijst",
"group_by_letter_label": "Groeperen op eerste letter"
},
"filters": {
"title": "E-mailfilters",
@@ -1732,7 +1811,7 @@
"use_address": "Dit adres gebruiken",
"invalid_tag": "Tag mag alleen letters, cijfers en streepjes bevatten",
"tag_too_long": "Tag mag maximaal 30 tekens bevatten",
"help_text": "E-mails verzonden naar gebruiker+tag@domein.nl komen in je postvak IN aan",
"help_text": "E-mails verzonden naar gebruiker{delimiter}tag@domein.nl komen in je postvak IN aan",
"validation": {
"empty": "Tag mag niet leeg zijn",
"too_long": "Tag mag maximaal {max} tekens bevatten",
@@ -2129,7 +2208,11 @@
"tomorrow_header": "Morgen",
"export_ics": "Exporteren als .ics",
"copy_title": "Titel kopiëren",
"copy_link": "Vergaderlink kopiëren"
"copy_link": "Vergaderlink kopiëren",
"go_to_today": "Ga naar vandaag",
"new_all_day_event": "Nieuwe dagvullende afspraak",
"new_event": "Nieuwe afspraak",
"new_task": "Nieuwe taak"
},
"detail": {
"add_note": "Notitie toevoegen...",
@@ -2428,6 +2511,20 @@
"due_today": "Vervalt vandaag",
"due_tomorrow": "Vervalt morgen",
"overdue": "Achterstallig"
},
"months": {
"jan": "jan",
"feb": "feb",
"mar": "mrt",
"apr": "apr",
"may": "mei",
"jun": "jun",
"jul": "jul",
"aug": "aug",
"sep": "sep",
"oct": "okt",
"nov": "nov",
"dec": "dec"
}
},
"advanced_search": {
+104 -7
View File
@@ -184,6 +184,7 @@
"batch_actions": {
"select": "Zaznacz wiadomości",
"select_all": "Zaznacz wszystkie",
"selected_messages": "Wybrano {count, plural, one {1 wiadomość} other {# wiadomości}} e-mail",
"mark_read": "Oznacz jako przeczytane",
"mark_unread": "Oznacz jako nieprzeczytane",
"delete": "Usuń",
@@ -223,6 +224,11 @@
"unstar": "Usuń gwiazdkę",
"mark_unread": "Oznacz jako nieprzeczytane",
"mark_read": "Oznacz jako przeczytane",
"unread": "Nieprzeczytane",
"read": "Przeczytane",
"spam_short": "Spam",
"not_spam_short": "Nie spam",
"move": "Przenieś",
"print": "Drukuj",
"view_source": "Pokaż źródło",
"export_email": "Eksportuj jako .eml",
@@ -306,7 +312,20 @@
"fail": "DMARC niezaliczony",
"none": "Brak DMARC"
},
"spam_score": "Ocena spamu"
"spam_score": "Ocena spamu",
"tooltip_spf": "Sender Policy Framework: sprawdza, czy serwer wysyłający jest uprawniony do wysyłania wiadomości w imieniu domeny",
"tooltip_dkim": "DomainKeys Identified Mail: potwierdza za pomocą podpisu kryptograficznego, że wiadomość nie została zmieniona w trakcie przesyłania",
"tooltip_dmarc": "Domain-based Message Authentication, Reporting & Conformance: zapewnia zgodność SPF i DKIM z domeną nadawcy i określa politykę dla niepowodzeń",
"policy": "Polityka",
"result": {
"pass": "Zaliczony",
"fail": "Niezaliczony",
"softfail": "Łagodny błąd",
"neutral": "Neutralny",
"permerror": "Błąd trwały",
"temperror": "Błąd tymczasowy",
"none": "Brak"
}
},
"headers": {
"routing": "Routing",
@@ -426,10 +445,37 @@
"cancel_info": "Organizator anulował to wydarzenie.",
"event_updated": "Aktualizacja #{sequence}",
"event_status_tentative": "Wstępne",
"event_status_cancelled": "Anulowane"
"event_status_cancelled": "Anulowane",
"collapse": "Ukryj szczegóły",
"expand": "Pokaż szczegóły"
},
"send": "Wyślij",
"more": "więcej"
"more": "więcej",
"details": {
"recipients_routing": "Odbiorcy i trasowanie",
"authentication_security": "Uwierzytelnianie i bezpieczeństwo",
"identifiers_threading": "Identyfikatory i wątkowanie",
"mailing_list": "Lista mailingowa",
"message_properties": "Właściwości wiadomości",
"sent": "Wysłano",
"received": "Odebrano",
"delivery_time": "Czas dostarczenia",
"in_reply_to": "W odpowiedzi na",
"references": "Odniesienia",
"thread_id": "ID wątku",
"size": "Rozmiar",
"mime_type": "Typ MIME",
"attachments_summary": "{count} plików · {size}",
"list_id": "ID listy",
"list_help": "Pomoc listy",
"list_post": "Wysyłanie do listy",
"list_unsubscribe": "Wypisz się",
"iprev": "Odwrotny DNS",
"spam_status": "Status spamu",
"ai_verdict": "Werdykt AI",
"account": "Konto",
"no_subject": "(brak tematu)"
}
},
"email_composer": {
"new_message": "Nowa wiadomość",
@@ -508,7 +554,10 @@
"message": "Twoja wiadomość wspomina o \"{keyword}\", ale nie załączono żadnego pliku. Wysłać mimo to?",
"send_anyway": "Wyślij mimo to",
"back": "Wróć do edycji"
}
},
"add_link": "Dodaj link",
"link_url_prompt": "Wprowadź adres URL",
"sending": "Wysyłanie..."
},
"confirm_dialog": {
"confirm": "Potwierdź",
@@ -801,6 +850,23 @@
"sound_desc": "Odtwarzaj sygnał dźwiękowy dla przypomnień kalendarza",
"invitation_parsing": "Rozpoznawaj zaproszenia e-mail",
"invitation_parsing_desc": "Wykrywaj zaproszenia kalendarzowe w załącznikach wiadomości e-mail i pokazuj akcje kalendarza"
},
"push": {
"confirm_disable_message": "To urządzenie przestanie otrzymywać powiadomienia, gdy strona jest zamknięta.",
"confirm_disable_title": "Wyłączyć powiadomienia w tle?",
"description": "Otrzymuj powiadomienia systemowe o nowych wiadomościach, gdy ta strona jest zamknięta. Dostarczane przez przekaźnik push Bulwark; przekaźnik nigdy nie widzi treści wiadomości.",
"disable": "Wyłącz",
"enable": "Włącz",
"ios_hint": "W systemie iOS najpierw zainstaluj stronę na ekranie głównym Safari dostarcza Web Push tylko zainstalowanym PWA.",
"reenable": "Zarejestruj ponownie",
"relay_desc": "Domyślnie używa hostowanego przekaźnika Bulwark. Zmień tylko jeśli hostujesz samodzielnie.",
"relay_label": "Przekaźnik push",
"relay_placeholder": "https://notifications.relay.example.com",
"status_active": "Aktywne na tym urządzeniu",
"status_busy": "Przetwarzanie…",
"status_inactive": "Niewłączone na tym urządzeniu",
"status_unsupported": "Ta przeglądarka nie obsługuje Web Push",
"title": "Powiadomienia w tle"
}
},
"language_region": {
@@ -889,6 +955,13 @@
"label": "Automatycznie wybieraj adres odpowiedzi",
"description": "Podczas odpowiadania automatycznie przełączaj adres nadawcy na tożsamość, która pierwotnie otrzymała wiadomość"
},
"sub_address_delimiter": {
"label": "Separator sub-adresu",
"description": "Znak oddzielający Twoją nazwę użytkownika od tagu sub-adresu. Użyj separatora zgodnego z Twoim serwerem pocztowym (np. user{delimiter}tag@domain.com).",
"option": "{delimiter} (user{delimiter}tag@domain.com)",
"custom": "Niestandardowy…",
"custom_input_label": "Niestandardowy znak separatora"
},
"attachment_click_action": {
"label": "Akcja po kliknięciu załącznika",
"description": "Wybierz, czy kliknięcie załącznika pliku ma pokazać podgląd, czy od razu go pobrać",
@@ -980,6 +1053,10 @@
"hide_inline_image_attachments": {
"label": "Ukryj obrazy osadzone z listy załączników",
"description": "Obrazy osadzone w treści wiadomości nie są wyświetlane jako osobne załączniki"
},
"attachment_image_previews": {
"label": "Pokaż podgląd obrazów w załącznikach",
"description": "Wyświetlaj załączone obrazy jako miniatury zamiast ogólnych ikon plików"
}
},
"composer": {
@@ -1333,7 +1410,9 @@
"no_address_books": "Nie znaleziono książek adresowych",
"categories_title": "Kategorie",
"categories_description": "Zmień nazwy kategorii kontaktów",
"no_categories": "Nie znaleziono kategorii"
"no_categories": "Nie znaleziono kategorii",
"group_by_letter_description": "Pokaż alfabetyczne nagłówki sekcji na liście kontaktów",
"group_by_letter_label": "Grupuj według pierwszej litery"
},
"filters": {
"title": "Filtry wiadomości e-mail",
@@ -1732,7 +1811,7 @@
"use_address": "Użyj tego adresu",
"invalid_tag": "Tag może zawierać tylko litery, cyfry i myślniki",
"tag_too_long": "Tag może mieć maksymalnie 30 znaków",
"help_text": "Wiadomości wysłane na adres user+tag@domain.com trafią do Twojej skrzynki odbiorczej",
"help_text": "Wiadomości wysłane na adres user{delimiter}tag@domain.com trafią do Twojej skrzynki odbiorczej",
"validation": {
"empty": "Tag nie może być pusty",
"too_long": "Tag może mieć maksymalnie {max} znaków",
@@ -2129,7 +2208,11 @@
"tomorrow_header": "Jutro",
"export_ics": "Eksportuj jako .ics",
"copy_title": "Kopiuj tytuł",
"copy_link": "Kopiuj link do spotkania"
"copy_link": "Kopiuj link do spotkania",
"go_to_today": "Przejdź do dzisiaj",
"new_all_day_event": "Nowe wydarzenie całodniowe",
"new_event": "Nowe wydarzenie",
"new_task": "Nowe zadanie"
},
"detail": {
"add_note": "Dodaj notatkę...",
@@ -2428,6 +2511,20 @@
"due_today": "Dzisiaj",
"due_tomorrow": "Jutro",
"overdue": "Zaległe"
},
"months": {
"jan": "sty",
"feb": "lut",
"mar": "mar",
"apr": "kwi",
"may": "maj",
"jun": "cze",
"jul": "lip",
"aug": "sie",
"sep": "wrz",
"oct": "paź",
"nov": "lis",
"dec": "gru"
}
},
"advanced_search": {
+105 -8
View File
@@ -184,6 +184,7 @@
"batch_actions": {
"select": "Selecionar e-mails",
"select_all": "Selecionar tudo",
"selected_messages": "{count, plural, one {1 e-mail selecionado} other {# e-mails selecionados}}",
"mark_read": "Marcar como lido",
"mark_unread": "Marcar como não lido",
"delete": "Excluir",
@@ -223,6 +224,11 @@
"unstar": "Remover Estrela",
"mark_unread": "Marcar como não lido",
"mark_read": "Marcar como lido",
"unread": "Não lido",
"read": "Lido",
"spam_short": "Spam",
"not_spam_short": "Não spam",
"move": "Mover",
"print": "Imprimir",
"view_source": "Ver código-fonte",
"export_email": "Exportar como .eml",
@@ -304,7 +310,20 @@
"fail": "DMARC Falhou",
"none": "Sem DMARC"
},
"spam_score": "Pontuação de Spam"
"spam_score": "Pontuação de Spam",
"tooltip_spf": "Sender Policy Framework: verifica se o servidor de envio está autorizado a enviar e-mails em nome do domínio",
"tooltip_dkim": "DomainKeys Identified Mail: confirma, por meio de assinatura criptográfica, que o e-mail não foi alterado durante o transporte",
"tooltip_dmarc": "Domain-based Message Authentication, Reporting & Conformance: garante que SPF e DKIM estejam alinhados com o domínio do remetente e define uma política para falhas",
"policy": "Política",
"result": {
"pass": "Aprovado",
"fail": "Falhou",
"softfail": "Falha parcial",
"neutral": "Neutro",
"permerror": "Erro permanente",
"temperror": "Erro temporário",
"none": "Nenhum"
}
},
"headers": {
"routing": "Roteamento",
@@ -424,12 +443,39 @@
"cancel_info": "O organizador cancelou este evento.",
"event_updated": "Atualização #{sequence}",
"event_status_tentative": "Provisório",
"event_status_cancelled": "Cancelado"
"event_status_cancelled": "Cancelado",
"collapse": "Ocultar detalhes",
"expand": "Mostrar detalhes"
},
"previous": "Anterior",
"next": "Próximo",
"send": "Enviar",
"more": "mais"
"more": "mais",
"details": {
"recipients_routing": "Destinatários e roteamento",
"authentication_security": "Autenticação e segurança",
"identifiers_threading": "Identificadores e conversação",
"mailing_list": "Lista de discussão",
"message_properties": "Propriedades da mensagem",
"sent": "Enviado",
"received": "Recebido",
"delivery_time": "Tempo de entrega",
"in_reply_to": "Em resposta a",
"references": "Referências",
"thread_id": "ID da conversa",
"size": "Tamanho",
"mime_type": "Tipo MIME",
"attachments_summary": "{count} arquivos · {size}",
"list_id": "ID da lista",
"list_help": "Ajuda da lista",
"list_post": "Postagem na lista",
"list_unsubscribe": "Cancelar inscrição",
"iprev": "DNS reverso",
"spam_status": "Status de spam",
"ai_verdict": "Veredito da IA",
"account": "Conta",
"no_subject": "(sem assunto)"
}
},
"email_composer": {
"new_message": "Nova Mensagem",
@@ -508,7 +554,10 @@
"message": "A sua mensagem menciona \"{keyword}\" mas nenhum arquivo está anexado. Enviar mesmo assim?",
"send_anyway": "Enviar mesmo assim",
"back": "Voltar à edição"
}
},
"add_link": "Adicionar link",
"link_url_prompt": "Insira a URL",
"sending": "Enviando..."
},
"confirm_dialog": {
"confirm": "Confirmar",
@@ -801,6 +850,23 @@
"sound_desc": "Reproduzir um som para lembretes do calendário",
"invitation_parsing": "Analisar convites por e-mail",
"invitation_parsing_desc": "Detectar convites de calendário em anexos de e-mail e mostrar ações de calendário"
},
"push": {
"confirm_disable_message": "Este dispositivo deixará de receber alertas quando o site estiver fechado.",
"confirm_disable_title": "Desativar notificações em segundo plano?",
"description": "Receba notificações do sistema para novas mensagens quando este site estiver fechado. Entregue através do relay push do Bulwark; o relay nunca vê o conteúdo da mensagem.",
"disable": "Desativar",
"enable": "Ativar",
"ios_hint": "No iOS, instale o site primeiro na tela inicial o Safari só entrega Web Push para PWAs instalados.",
"reenable": "Registrar novamente",
"relay_desc": "Usa o relay Bulwark hospedado por padrão. Altere apenas se você hospedar.",
"relay_label": "Relay push",
"relay_placeholder": "https://notifications.relay.example.com",
"status_active": "Ativo neste dispositivo",
"status_busy": "Processando…",
"status_inactive": "Não ativado neste dispositivo",
"status_unsupported": "Este navegador não oferece suporte a Web Push",
"title": "Notificações em segundo plano"
}
},
"language_region": {
@@ -884,6 +950,13 @@
"label": "Selecionar automaticamente o endereço de resposta",
"description": "Ao responder, muda automaticamente o endereço do remetente para a identidade que recebeu a mensagem original"
},
"sub_address_delimiter": {
"label": "Delimitador de sub-endereço",
"description": "Caractere que separa seu nome de usuário da tag de sub-endereço. Use o delimitador configurado no seu servidor de e-mail (ex.: usuario{delimiter}tag@dominio.com).",
"option": "{delimiter} (usuario{delimiter}tag@dominio.com)",
"custom": "Personalizado…",
"custom_input_label": "Caractere delimitador personalizado"
},
"show_preview": {
"label": "Mostrar Texto de Visualização",
"description": "Exibir visualização do e-mail na lista",
@@ -980,6 +1053,10 @@
"hide_inline_image_attachments": {
"label": "Ocultar imagens incorporadas dos anexos",
"description": "As imagens incorporadas no corpo da mensagem não são listadas como anexos separados"
},
"attachment_image_previews": {
"label": "Mostrar prévias de imagens em anexos",
"description": "Renderizar anexos de imagem como miniaturas em vez de ícones genéricos de arquivo"
}
},
"composer": {
@@ -1333,7 +1410,9 @@
"no_address_books": "Nenhum catálogo de endereços encontrado",
"categories_title": "Categorias",
"categories_description": "Renomear categorias de contatos",
"no_categories": "Nenhuma categoria encontrada"
"no_categories": "Nenhuma categoria encontrada",
"group_by_letter_description": "Mostrar cabeçalhos de seção alfabéticos na lista de contatos",
"group_by_letter_label": "Agrupar pela primeira letra"
},
"filters": {
"title": "Filtros de e-mail",
@@ -1732,7 +1811,7 @@
"use_address": "Usar Este Endereço",
"invalid_tag": "A tag deve conter apenas caracteres alfanuméricos e hífens",
"tag_too_long": "A tag deve ter no máximo 30 caracteres",
"help_text": "E-mails enviados para usuario+tag@dominio.com chegarão na sua caixa de entrada",
"help_text": "E-mails enviados para usuario{delimiter}tag@dominio.com chegarão na sua caixa de entrada",
"validation": {
"empty": "A tag não pode estar vazia",
"too_long": "A tag deve ter no máximo {max} caracteres",
@@ -2129,7 +2208,11 @@
"tomorrow_header": "Amanhã",
"export_ics": "Exportar como .ics",
"copy_title": "Copiar título",
"copy_link": "Copiar link da reunião"
"copy_link": "Copiar link da reunião",
"go_to_today": "Ir para hoje",
"new_all_day_event": "Novo evento de dia inteiro",
"new_event": "Novo evento",
"new_task": "Nova tarefa"
},
"detail": {
"add_note": "Adicionar uma nota...",
@@ -2261,6 +2344,20 @@
"sat": "Sáb",
"sun": "Dom"
},
"months": {
"jan": "jan",
"feb": "fev",
"mar": "mar",
"apr": "abr",
"may": "mai",
"jun": "jun",
"jul": "jul",
"aug": "ago",
"sep": "set",
"oct": "out",
"nov": "nov",
"dec": "dez"
},
"notifications": {
"event_created": "Evento criado",
"event_updated": "Evento atualizado",
@@ -2738,4 +2835,4 @@
"custom": "Personalizado"
}
}
}
}
+104 -7
View File
@@ -184,6 +184,7 @@
"batch_actions": {
"select": "Выбрать письма",
"select_all": "Выбрать все",
"selected_messages": "Выбрано {count, plural, one {1 письмо} other {# письма}}",
"mark_read": "Отметить прочитанными",
"mark_unread": "Отметить непрочитанными",
"delete": "Удалить",
@@ -223,6 +224,11 @@
"unstar": "Снять пометку",
"mark_unread": "Отметить непрочитанным",
"mark_read": "Отметить прочитанным",
"unread": "Не прочитано",
"read": "Прочитано",
"spam_short": "Спам",
"not_spam_short": "Не спам",
"move": "Переместить",
"print": "Распечатать",
"view_source": "Просмотреть исходный код",
"export_email": "Экспортировать как .eml",
@@ -306,7 +312,20 @@
"fail": "DMARC не прошёл",
"none": "DMARC отсутствует"
},
"spam_score": "Оценка спама"
"spam_score": "Оценка спама",
"tooltip_spf": "Sender Policy Framework: проверяет, имеет ли отправляющий сервер право отправлять почту от имени домена",
"tooltip_dkim": "DomainKeys Identified Mail: подтверждает с помощью криптографической подписи, что письмо не было изменено при передаче",
"tooltip_dmarc": "Domain-based Message Authentication, Reporting & Conformance: обеспечивает согласованность SPF и DKIM с доменом отправителя и устанавливает политику для сбоев",
"policy": "Политика",
"result": {
"pass": "Пройдено",
"fail": "Не пройдено",
"softfail": "Частичный сбой",
"neutral": "Нейтрально",
"permerror": "Постоянная ошибка",
"temperror": "Временная ошибка",
"none": "Нет"
}
},
"headers": {
"routing": "Маршрутизация",
@@ -426,10 +445,37 @@
"cancel_info": "Организатор отменил это событие.",
"event_updated": "Обновление №{sequence}",
"event_status_tentative": "Под вопросом",
"event_status_cancelled": "Отменено"
"event_status_cancelled": "Отменено",
"collapse": "Скрыть детали",
"expand": "Показать детали"
},
"send": "Отправить",
"more": "ещё"
"more": "ещё",
"details": {
"recipients_routing": "Получатели и маршрутизация",
"authentication_security": "Аутентификация и безопасность",
"identifiers_threading": "Идентификаторы и обсуждения",
"mailing_list": "Список рассылки",
"message_properties": "Свойства сообщения",
"sent": "Отправлено",
"received": "Получено",
"delivery_time": "Время доставки",
"in_reply_to": "В ответ на",
"references": "Ссылки",
"thread_id": "ID обсуждения",
"size": "Размер",
"mime_type": "MIME-тип",
"attachments_summary": "{count} файлов · {size}",
"list_id": "ID списка",
"list_help": "Справка списка",
"list_post": "Публикация в список",
"list_unsubscribe": "Отписаться",
"iprev": "Обратный DNS",
"spam_status": "Статус спама",
"ai_verdict": "Вердикт ИИ",
"account": "Аккаунт",
"no_subject": "(без темы)"
}
},
"email_composer": {
"new_message": "Новое письмо",
@@ -508,7 +554,10 @@
"message": "В вашем сообщении упоминается \"{keyword}\", но файл не прикреплён. Отправить всё равно?",
"send_anyway": "Отправить всё равно",
"back": "Вернуться к редактированию"
}
},
"add_link": "Добавить ссылку",
"link_url_prompt": "Введите URL",
"sending": "Отправка..."
},
"confirm_dialog": {
"confirm": "Подтвердить",
@@ -801,6 +850,23 @@
"sound_desc": "Воспроизводить звуковой сигнал для напоминаний календаря",
"invitation_parsing": "Распознавать приглашения по почте",
"invitation_parsing_desc": "Обнаруживать приглашения календаря во вложениях писем и показывать действия календаря"
},
"push": {
"confirm_disable_message": "Это устройство перестанет получать оповещения, когда сайт закрыт.",
"confirm_disable_title": "Отключить фоновые уведомления?",
"description": "Получайте системные уведомления о новых письмах, когда этот сайт закрыт. Доставляется через push-релей Bulwark; релей никогда не видит содержимое писем.",
"disable": "Отключить",
"enable": "Включить",
"ios_hint": "В iOS сначала установите сайт на главный экран – Safari доставляет Web Push только установленным PWA.",
"reenable": "Перерегистрировать",
"relay_desc": "По умолчанию используется размещённый релей Bulwark. Меняйте только если хостите самостоятельно.",
"relay_label": "Push-релей",
"relay_placeholder": "https://notifications.relay.example.com",
"status_active": "Активно на этом устройстве",
"status_busy": "Выполняется…",
"status_inactive": "Не включено на этом устройстве",
"status_unsupported": "Этот браузер не поддерживает Web Push",
"title": "Фоновые уведомления"
}
},
"language_region": {
@@ -884,6 +950,13 @@
"label": "Автоматически выбирать адрес для ответа",
"description": "При ответе автоматически переключать адрес отправителя на ту учетную запись, которая получила исходное сообщение"
},
"sub_address_delimiter": {
"label": "Разделитель суб-адресов",
"description": "Символ, отделяющий имя пользователя от тега суб-адреса. Используйте разделитель, настроенный на вашем почтовом сервере (например, user{delimiter}tag@domain.com).",
"option": "{delimiter} (user{delimiter}tag@domain.com)",
"custom": "Свой…",
"custom_input_label": "Свой символ-разделитель"
},
"show_preview": {
"label": "Показывать текст предпросмотра",
"description": "Отображать предпросмотр письма в списке",
@@ -980,6 +1053,10 @@
"hide_inline_image_attachments": {
"label": "Скрывать встроенные изображения из вложений",
"description": "Изображения, встроенные в тело сообщения, не отображаются как отдельные вложения"
},
"attachment_image_previews": {
"label": "Показывать миниатюры изображений во вложениях",
"description": "Отображать вложенные изображения в виде миниатюр вместо обычных значков файлов"
}
},
"composer": {
@@ -1333,7 +1410,9 @@
"no_address_books": "Адресные книги не найдены",
"categories_title": "Категории",
"categories_description": "Переименование категорий контактов",
"no_categories": "Категории не найдены"
"no_categories": "Категории не найдены",
"group_by_letter_description": "Показывать алфавитные заголовки разделов в списке контактов",
"group_by_letter_label": "Группировать по первой букве"
},
"filters": {
"title": "Фильтры почты",
@@ -1732,7 +1811,7 @@
"use_address": "Использовать этот адрес",
"invalid_tag": "Тег должен содержать только буквы, цифры и дефисы",
"tag_too_long": "Тег не должен превышать 30 символов",
"help_text": "Письма на адрес user+tag@domain.com будут приходить в ваш ящик",
"help_text": "Письма на адрес user{delimiter}tag@domain.com будут приходить в ваш ящик",
"validation": {
"empty": "Тег не может быть пустым",
"too_long": "Тег не должен превышать {max} символов",
@@ -2129,7 +2208,11 @@
"tomorrow_header": "Завтра",
"export_ics": "Экспорт в .ics",
"copy_title": "Скопировать название",
"copy_link": "Скопировать ссылку встречи"
"copy_link": "Скопировать ссылку встречи",
"go_to_today": "Перейти к сегодня",
"new_all_day_event": "Новое событие на весь день",
"new_event": "Новое событие",
"new_task": "Новая задача"
},
"detail": {
"add_note": "Добавить заметку...",
@@ -2428,6 +2511,20 @@
"due_today": "Сегодня",
"due_tomorrow": "Завтра",
"overdue": "Просрочено"
},
"months": {
"jan": "янв.",
"feb": "февр.",
"mar": "март",
"apr": "апр.",
"may": "май",
"jun": "июнь",
"jul": "июль",
"aug": "авг.",
"sep": "сент.",
"oct": "окт.",
"nov": "нояб.",
"dec": "дек."
}
},
"advanced_search": {
File diff suppressed because it is too large Load Diff
+104 -7
View File
@@ -184,6 +184,7 @@
"batch_actions": {
"select": "Виберіть електронні листи",
"select_all": "Вибрати все",
"selected_messages": "Вибрано {count, plural, one {1 електронний лист} other {# електронні листи}}",
"mark_read": "Позначити як прочитане",
"mark_unread": "Позначити як непрочитане",
"delete": "Видалити",
@@ -223,6 +224,11 @@
"unstar": "Зняти зірочку",
"mark_unread": "Позначити як непрочитане",
"mark_read": "Позначити як прочитане",
"unread": "Непрочитане",
"read": "Прочитане",
"spam_short": "Спам",
"not_spam_short": "Не спам",
"move": "Перенести",
"print": "Роздрукувати",
"view_source": "Переглянути джерело",
"export_email": "Експортувати як .eml",
@@ -306,7 +312,20 @@
"fail": "Помилка DMARC",
"none": "Без DMARC"
},
"spam_score": "Оцінка спаму"
"spam_score": "Оцінка спаму",
"tooltip_spf": "Sender Policy Framework: перевіряє, чи має сервер-відправник право надсилати пошту від імені домену",
"tooltip_dkim": "DomainKeys Identified Mail: підтверджує криптографічним підписом, що лист не було змінено під час пересилання",
"tooltip_dmarc": "Domain-based Message Authentication, Reporting & Conformance: забезпечує узгодженість SPF і DKIM із доменом відправника та встановлює політику для збоїв",
"policy": "Політика",
"result": {
"pass": "Пройдено",
"fail": "Не пройдено",
"softfail": "М'який збій",
"neutral": "Нейтрально",
"permerror": "Постійна помилка",
"temperror": "Тимчасова помилка",
"none": "Немає"
}
},
"headers": {
"routing": "Маршрутизація",
@@ -426,10 +445,37 @@
"cancel_info": "Організатор скасував цю подію.",
"event_updated": "Оновлення №{sequence}",
"event_status_tentative": "Орієнтовний",
"event_status_cancelled": "Скасовано"
"event_status_cancelled": "Скасовано",
"collapse": "Сховати деталі",
"expand": "Показати деталі"
},
"send": "Надіслати",
"more": "більше"
"more": "більше",
"details": {
"recipients_routing": "Одержувачі та маршрутизація",
"authentication_security": "Аутентифікація та безпека",
"identifiers_threading": "Ідентифікатори та обговорення",
"mailing_list": "Список розсилки",
"message_properties": "Властивості повідомлення",
"sent": "Надіслано",
"received": "Отримано",
"delivery_time": "Час доставки",
"in_reply_to": "У відповідь на",
"references": "Посилання",
"thread_id": "ID обговорення",
"size": "Розмір",
"mime_type": "Тип MIME",
"attachments_summary": "{count} файлів · {size}",
"list_id": "ID списку",
"list_help": "Довідка списку",
"list_post": "Публікація у список",
"list_unsubscribe": "Відписатися",
"iprev": "Зворотний DNS",
"spam_status": "Статус спаму",
"ai_verdict": "Висновок ШІ",
"account": "Обліковий запис",
"no_subject": "(без теми)"
}
},
"email_composer": {
"new_message": "Нове повідомлення",
@@ -508,7 +554,10 @@
"message": "У вашому повідомленні згадується \"{keyword}\", але файл не вкладено. Все одно надіслати?",
"send_anyway": "Все одно надішліть",
"back": "Назад до редагування"
}
},
"add_link": "Додати посилання",
"link_url_prompt": "Введіть URL",
"sending": "Надсилання..."
},
"confirm_dialog": {
"confirm": "Підтвердити",
@@ -801,6 +850,23 @@
"sound_desc": "Відтворення звукового сповіщення для нагадувань календаря",
"invitation_parsing": "Проаналізуйте запрошення електронною поштою",
"invitation_parsing_desc": "Виявляти запрошення календаря у вкладеннях електронної пошти та показувати дії календаря"
},
"push": {
"confirm_disable_message": "Цей пристрій перестане отримувати сповіщення, коли сайт закритий.",
"confirm_disable_title": "Вимкнути фонові сповіщення?",
"description": "Отримуйте системні сповіщення про нові листи, коли цей сайт закритий. Доставляється через push-реле Bulwark; реле ніколи не бачить вміст листів.",
"disable": "Вимкнути",
"enable": "Увімкнути",
"ios_hint": "На iOS спочатку встановіть сайт на головний екран – Safari доставляє Web Push лише встановленим PWA.",
"reenable": "Перереєструвати",
"relay_desc": "Типово використовується розміщене реле Bulwark. Змінюйте лише, якщо хостите самостійно.",
"relay_label": "Push-реле",
"relay_placeholder": "https://notifications.relay.example.com",
"status_active": "Активно на цьому пристрої",
"status_busy": "Виконується…",
"status_inactive": "Не ввімкнено на цьому пристрої",
"status_unsupported": "Цей браузер не підтримує Web Push",
"title": "Фонові сповіщення"
}
},
"language_region": {
@@ -889,6 +955,13 @@
"label": "Автоматичний вибір адреси для відповіді",
"description": "Під час відповіді автоматично змінюйте адресу відправника на особу, яка спочатку отримала повідомлення"
},
"sub_address_delimiter": {
"label": "Розділювач під-адреси",
"description": "Символ, який відокремлює ім'я користувача від мітки під-адреси. Використовуйте розділювач, налаштований на вашому поштовому сервері (напр. user{delimiter}tag@domain.com).",
"option": "{delimiter} (user{delimiter}tag@domain.com)",
"custom": "Власний…",
"custom_input_label": "Власний символ-розділювач"
},
"attachment_click_action": {
"label": "Вкладення Натисніть Дія",
"description": "Виберіть, чи клацання вкладеного файлу попередньо переглядає його чи негайно завантажує",
@@ -980,6 +1053,10 @@
"hide_inline_image_attachments": {
"label": "Приховувати вбудовані зображення з вкладень",
"description": "Зображення, вбудовані в тіло повідомлення, не відображаються як окремі вкладення"
},
"attachment_image_previews": {
"label": "Показувати попередній перегляд зображень у вкладеннях",
"description": "Відображати вкладені зображення як ескізи замість загальних піктограм файлів"
}
},
"composer": {
@@ -1333,7 +1410,9 @@
"no_address_books": "Адресних книг не знайдено",
"categories_title": "Категорії",
"categories_description": "Перейменувати категорії контактів",
"no_categories": "Категорії не знайдено"
"no_categories": "Категорії не знайдено",
"group_by_letter_description": "Показувати алфавітні заголовки розділів у списку контактів",
"group_by_letter_label": "Групувати за першою літерою"
},
"filters": {
"title": "Фільтри електронної пошти",
@@ -1732,7 +1811,7 @@
"use_address": "Використовуйте цю адресу",
"invalid_tag": "Тег має бути лише буквено-цифровим і тире",
"tag_too_long": "Тег має містити 30 символів або менше",
"help_text": "Електронні листи, надіслані на user+tag@domain.com, надходитимуть до вашої скриньки",
"help_text": "Електронні листи, надіслані на user{delimiter}tag@domain.com, надходитимуть до вашої скриньки",
"validation": {
"empty": "Тег не може бути порожнім",
"too_long": "Тег має містити не більше {max} символів",
@@ -2129,7 +2208,11 @@
"tomorrow_header": "завтра",
"export_ics": "Експортувати як .ics",
"copy_title": "Скопіювати назву",
"copy_link": "Скопіювати посилання зустрічі"
"copy_link": "Скопіювати посилання зустрічі",
"go_to_today": "Перейти до сьогодні",
"new_all_day_event": "Нова подія на весь день",
"new_event": "Нова подія",
"new_task": "Нове завдання"
},
"detail": {
"add_note": "Додати примітку...",
@@ -2428,6 +2511,20 @@
"due_today": "Сьогодні",
"due_tomorrow": "завтра",
"overdue": "Прострочена"
},
"months": {
"jan": "січ.",
"feb": "лют.",
"mar": "бер.",
"apr": "квіт.",
"may": "трав.",
"jun": "черв.",
"jul": "лип.",
"aug": "серп.",
"sep": "верес.",
"oct": "жовт.",
"nov": "лист.",
"dec": "груд."
}
},
"advanced_search": {
+104 -7
View File
@@ -184,6 +184,7 @@
"batch_actions": {
"select": "选择邮件",
"select_all": "全选",
"selected_messages": "已選取 {count, plural, one {1} other {#}} 封電子郵件",
"mark_read": "标记为已读",
"mark_unread": "标记为未读",
"delete": "删除",
@@ -223,6 +224,11 @@
"unstar": "取消星标",
"mark_unread": "标记为未读",
"mark_read": "标记为已读",
"unread": "未读",
"read": "已读",
"spam_short": "垃圾邮件",
"not_spam_short": "非垃圾邮件",
"move": "移动",
"print": "打印",
"view_source": "查看源码",
"export_email": "导出为 .eml",
@@ -306,7 +312,20 @@
"fail": "DMARC 失败",
"none": "无 DMARC"
},
"spam_score": "垃圾邮件评分"
"spam_score": "垃圾邮件评分",
"tooltip_spf": "Sender Policy Framework:验证发送服务器是否有权代表该域名发送邮件",
"tooltip_dkim": "DomainKeys Identified Mail:通过加密签名确认邮件在传输过程中未被篡改",
"tooltip_dmarc": "Domain-based Message Authentication, Reporting & Conformance:确保 SPF 与 DKIM 与发件人域名一致,并为失败设置处理策略",
"policy": "策略",
"result": {
"pass": "通过",
"fail": "失败",
"softfail": "软失败",
"neutral": "中立",
"permerror": "永久错误",
"temperror": "临时错误",
"none": "无"
}
},
"headers": {
"routing": "路由",
@@ -426,10 +445,37 @@
"cancel_info": "组织者已取消此活动。",
"event_updated": "更新 #{sequence}",
"event_status_tentative": "暂定",
"event_status_cancelled": "已取消"
"event_status_cancelled": "已取消",
"collapse": "隐藏详情",
"expand": "显示详情"
},
"send": "发送",
"more": "更多"
"more": "更多",
"details": {
"recipients_routing": "收件人与路由",
"authentication_security": "身份验证与安全",
"identifiers_threading": "标识符与会话",
"mailing_list": "邮件列表",
"message_properties": "邮件属性",
"sent": "发送时间",
"received": "接收时间",
"delivery_time": "送达耗时",
"in_reply_to": "回复对象",
"references": "引用",
"thread_id": "会话 ID",
"size": "大小",
"mime_type": "MIME 类型",
"attachments_summary": "{count} 个文件 · {size}",
"list_id": "列表 ID",
"list_help": "列表帮助",
"list_post": "列表投递",
"list_unsubscribe": "取消订阅",
"iprev": "反向 DNS",
"spam_status": "垃圾邮件状态",
"ai_verdict": "AI 判断",
"account": "账户",
"no_subject": "(无主题)"
}
},
"email_composer": {
"new_message": "新邮件",
@@ -508,7 +554,10 @@
"message": "您的消息中提到了“{keyword}”,但未附加任何文件。仍然发送吗?",
"send_anyway": "仍然发送",
"back": "返回编辑"
}
},
"add_link": "添加链接",
"link_url_prompt": "输入 URL",
"sending": "发送中..."
},
"confirm_dialog": {
"confirm": "确认",
@@ -801,6 +850,23 @@
"sound_desc": "播放日历提醒的音频提醒",
"invitation_parsing": "解析邮件邀请",
"invitation_parsing_desc": "检测邮件附件中的日历邀请并显示日历操作"
},
"push": {
"confirm_disable_message": "当网站关闭时,此设备将停止接收提醒。",
"confirm_disable_title": "禁用后台通知?",
"description": "在此网站关闭时接收新邮件的系统通知。通过 Bulwark 推送中继传送;中继永远不会看到邮件内容。",
"disable": "禁用",
"enable": "启用",
"ios_hint": "在 iOS 上,请先将网站安装到主屏幕 – Safari 仅向已安装的 PWA 发送 Web Push。",
"reenable": "重新注册",
"relay_desc": "默认使用托管的 Bulwark 中继。仅当您自托管时才更改。",
"relay_label": "推送中继",
"relay_placeholder": "https://notifications.relay.example.com",
"status_active": "在此设备上活动",
"status_busy": "工作中…",
"status_inactive": "在此设备上未启用",
"status_unsupported": "此浏览器不支持 Web Push",
"title": "后台通知"
}
},
"language_region": {
@@ -889,6 +955,13 @@
"label": "自动选择回复地址",
"description": "回复时自动将发件人地址切换为最初收到该邮件的身份"
},
"sub_address_delimiter": {
"label": "子地址分隔符",
"description": "用于分隔用户名和子地址标签的字符。请选择与您的邮件服务器一致的分隔符(例如 user{delimiter}tag@domain.com)。",
"option": "{delimiter} (user{delimiter}tag@domain.com)",
"custom": "自定义…",
"custom_input_label": "自定义分隔字符"
},
"attachment_click_action": {
"label": "附件单击操作",
"description": "选择点击附件时是预览还是直接下载",
@@ -980,6 +1053,10 @@
"hide_inline_image_attachments": {
"label": "在附件列表中隐藏内嵌图片",
"description": "嵌入到邮件正文中的图片不会作为单独的附件显示"
},
"attachment_image_previews": {
"label": "在附件中显示图片预览",
"description": "将图片附件显示为缩略图卡片,而不是通用文件图标"
}
},
"composer": {
@@ -1333,7 +1410,9 @@
"no_address_books": "未找到地址簿",
"categories_title": "类别",
"categories_description": "重命名联系人类别",
"no_categories": "未找到类别"
"no_categories": "未找到类别",
"group_by_letter_description": "在联系人列表中显示按字母顺序排列的分节标题",
"group_by_letter_label": "按首字母分组"
},
"filters": {
"title": "邮件过滤器",
@@ -1732,7 +1811,7 @@
"use_address": "使用此地址",
"invalid_tag": "标签只能是字母数字和破折号",
"tag_too_long": "标签不得超过 30 个字符",
"help_text": "发送到 user+tag@domain.com 的邮件将送达您的收件箱",
"help_text": "发送到 user{delimiter}tag@domain.com 的邮件将送达您的收件箱",
"validation": {
"empty": "标签不能为空",
"too_long": "标签不得超过 {max} 个字符",
@@ -2129,7 +2208,11 @@
"tomorrow_header": "明天",
"export_ics": "导出为 .ics",
"copy_title": "复制标题",
"copy_link": "复制会议链接"
"copy_link": "复制会议链接",
"go_to_today": "前往今天",
"new_all_day_event": "新建全天事件",
"new_event": "新建事件",
"new_task": "新建任务"
},
"detail": {
"add_note": "添加注释...",
@@ -2428,6 +2511,20 @@
"due_today": "今天",
"due_tomorrow": "明天",
"overdue": "逾期"
},
"months": {
"jan": "1月",
"feb": "2月",
"mar": "3月",
"apr": "4月",
"may": "5月",
"jun": "6月",
"jul": "7月",
"aug": "8月",
"sep": "9月",
"oct": "10月",
"nov": "11月",
"dec": "12月"
}
},
"advanced_search": {
+13
View File
@@ -18,15 +18,28 @@ try {
// VERSION file not found
}
// Subpath deployment, e.g. NEXT_PUBLIC_BASE_PATH=/webmail. Read at build time
// because Next.js bakes basePath into emitted asset URLs and route metadata.
// Trailing slash is stripped; an empty/missing value disables the feature.
const rawBasePath = process.env.NEXT_PUBLIC_BASE_PATH?.trim() ?? "";
const basePath = rawBasePath.replace(/\/+$/, "");
if (basePath && !basePath.startsWith("/")) {
throw new Error(
`NEXT_PUBLIC_BASE_PATH must start with "/" (got: ${JSON.stringify(rawBasePath)})`
);
}
const nextConfig: NextConfig = {
output: "standalone",
allowedDevOrigins: ["192.168.1.51"],
basePath: basePath || undefined,
turbopack: {
root: import.meta.dirname,
},
env: {
NEXT_PUBLIC_GIT_COMMIT: gitCommitHash,
NEXT_PUBLIC_APP_VERSION: appVersion,
NEXT_PUBLIC_BASE_PATH: basePath,
},
};
+59 -2
View File
@@ -1,12 +1,12 @@
{
"name": "bulwark-webmail",
"version": "1.5.3",
"version": "1.6.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bulwark-webmail",
"version": "1.5.3",
"version": "1.6.0",
"license": "AGPL-3.0-only",
"dependencies": {
"@tanstack/react-virtual": "^3.13.24",
@@ -14,6 +14,10 @@
"@tiptap/extension-image": "^3.22.4",
"@tiptap/extension-link": "^3.22.4",
"@tiptap/extension-placeholder": "^3.22.4",
"@tiptap/extension-table": "^3.22.4",
"@tiptap/extension-table-cell": "^3.22.4",
"@tiptap/extension-table-header": "^3.22.4",
"@tiptap/extension-table-row": "^3.22.4",
"@tiptap/extension-text-align": "^3.22.4",
"@tiptap/extension-text-style": "^3.22.4",
"@tiptap/extension-underline": "^3.22.4",
@@ -3639,6 +3643,59 @@
"@tiptap/core": "3.22.4"
}
},
"node_modules/@tiptap/extension-table": {
"version": "3.22.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-table/-/extension-table-3.22.4.tgz",
"integrity": "sha512-kjvLv3Z4JI+1tLDqZKa+bKU8VcxY+ZOyMCKWQA7wYmy8nKWkLJ60W+xy8AcXXpHB2goCIgSFLhsTyswx0GXH4w==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.22.4",
"@tiptap/pm": "3.22.4"
}
},
"node_modules/@tiptap/extension-table-cell": {
"version": "3.22.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-table-cell/-/extension-table-cell-3.22.4.tgz",
"integrity": "sha512-uvFegCc1UQYK2nfIV2sIHg+hzLIMroJJm00XomzBgC1w/eSO7Ui8APiDh/baBcTPpCSU3SLiQLTgx7AU7oE3pg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-table": "3.22.4"
}
},
"node_modules/@tiptap/extension-table-header": {
"version": "3.22.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-table-header/-/extension-table-header-3.22.4.tgz",
"integrity": "sha512-V4kLLWeRdc/I+IXiXZZhLAjsaHHiJWuLXTuOtZRDrCxQUiFLi4AgNg1DPQ09JAANkEWDhXq3x6BoUXaFwumbEw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-table": "3.22.4"
}
},
"node_modules/@tiptap/extension-table-row": {
"version": "3.22.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-table-row/-/extension-table-row-3.22.4.tgz",
"integrity": "sha512-9tdS6jgS6DqUu5TpEmNrRoo/DL5Xam0PyrQaUEXUC+ssci+bMRCJ8PAWMcunNsI9NKf/Tb3wYrv6hGFChaT9uA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-table": "3.22.4"
}
},
"node_modules/@tiptap/extension-text": {
"version": "3.22.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.22.4.tgz",
+5 -1
View File
@@ -1,6 +1,6 @@
{
"name": "bulwark-webmail",
"version": "1.5.3",
"version": "1.6.0",
"description": "Bulwark Webmail - a modern webmail client built for Stalwart Mail Server",
"author": "Bulwark Webmail <bulwark@rbm.systems>",
"license": "AGPL-3.0-only",
@@ -37,6 +37,10 @@
"@tiptap/extension-image": "^3.22.4",
"@tiptap/extension-link": "^3.22.4",
"@tiptap/extension-placeholder": "^3.22.4",
"@tiptap/extension-table": "^3.22.4",
"@tiptap/extension-table-cell": "^3.22.4",
"@tiptap/extension-table-header": "^3.22.4",
"@tiptap/extension-table-row": "^3.22.4",
"@tiptap/extension-text-align": "^3.22.4",
"@tiptap/extension-text-style": "^3.22.4",
"@tiptap/extension-underline": "^3.22.4",
+1 -1
View File
@@ -23,7 +23,7 @@ export async function proxy(request: NextRequest) {
? `'self' 'nonce-${nonce}' 'unsafe-eval' blob:`
: `'self' 'nonce-${nonce}' blob:`;
const connectSrc = isDev ? `'self' https: ws: wss:` : `'self' https:`;
const connectSrc = isDev ? `'self' http: https: ws: wss:` : `'self' https:`;
const frameAncestors = process.env.ALLOWED_FRAME_ANCESTORS?.trim() || "'none'";
+150 -4
View File
@@ -1,8 +1,28 @@
/* eslint-disable no-undef */
// Minimal service worker satisfies the PWA installability requirement
// without caching any assets. All requests fall through to the network,
// so there is no risk of serving stale chunks after a deployment.
// Bulwark service worker.
//
// This SW does two jobs:
// 1. Satisfy the PWA installability requirement (network-only fetch handler,
// no caching - so we never serve stale chunks after a deployment).
// 2. Receive Web Push wake-up pings from the relay and turn them into
// enriched system notifications. Mirrors the React Native FCM headless
// task: relay sends only a state-change ping, the client fetches the
// newest unread email itself so the relay never sees mail content.
// When the app is mounted at a subpath (Next.js basePath, e.g. /webmail), the
// SW is served at /webmail/sw.js and registered with scope /webmail/. Derive
// the prefix from the SW's own URL so push fetches and notification clicks
// land on the right path - service workers can't read process.env.
function getBasePath() {
const path = new URL(self.location.href).pathname;
// self.location is .../sw.js; strip the trailing filename to get the dir,
// then strip the trailing slash so it concatenates cleanly with `/foo`.
const dir = path.replace(/[^/]*$/, "");
return dir.replace(/\/+$/, "");
}
const BASE_PATH = getBasePath();
self.addEventListener("install", () => {
self.skipWaiting();
@@ -12,5 +32,131 @@ self.addEventListener("activate", (event) => {
event.waitUntil(self.clients.claim());
});
// Network-only fetch handler no caching.
self.addEventListener("fetch", () => {});
self.addEventListener("push", (event) => {
event.waitUntil(handlePush(event));
});
self.addEventListener("notificationclick", (event) => {
event.notification.close();
event.waitUntil(handleNotificationClick(event));
});
async function handlePush(event) {
let payload = null;
try {
payload = event.data ? event.data.json() : null;
} catch (_) {
payload = null;
}
const accountLabel = (payload && typeof payload.accountLabel === "string")
? payload.accountLabel
: "";
// Best effort: ask the webmail to look up the latest unread email so we can
// build a useful notification. If the request fails (offline, session
// expired, server down) we fall back to a generic "New mail" so the user
// still sees something.
let preview = null;
let previewOk = false;
try {
const res = await fetch(`${BASE_PATH}/api/push/preview`, {
credentials: "include",
cache: "no-store",
});
if (res.ok) {
preview = await res.json();
previewOk = true;
}
} catch (_) {
preview = null;
}
const email = preview && preview.email ? preview.email : null;
const unreadTotal = preview && typeof preview.unreadTotal === "number"
? preview.unreadTotal
: 0;
// Push subscription is scoped to EmailDelivery, but stragglers from the
// older broader-types subscription, marking-as-read races and verification
// pings can still wake us with no actual unread mail. When the preview API
// succeeded and reports zero unread, stay silent. When the preview API
// failed (network/auth/server down) we cannot tell, so fall through to the
// generic "New mail" toast rather than miss a real delivery.
if (previewOk && !email && unreadTotal === 0) {
return;
}
let title;
let body;
let tag = "bulwark-mail";
let data = { kind: "mail-list" };
if (email) {
const sender = email.from && email.from[0];
const senderName = (sender && sender.name) || (sender && sender.email) || "New mail";
title = senderName + (accountLabel ? ` (${accountLabel})` : "");
body = email.subject || email.preview || "(no subject)";
tag = "bulwark-mail:" + email.id;
data = {
kind: "email",
emailId: email.id,
threadId: email.threadId,
};
} else {
title = accountLabel ? `New mail (${accountLabel})` : "New mail";
body = unreadTotal > 1 ? `${unreadTotal} unread messages` : "You have new mail";
}
await self.registration.showNotification(title, {
body,
tag,
icon: `${BASE_PATH}/icon-192x192.png`,
badge: `${BASE_PATH}/icon-192x192.png`,
data,
renotify: true,
});
}
async function handleNotificationClick(event) {
const data = event.notification.data || {};
const targetUrl = buildClickUrl(data);
const allClients = await self.clients.matchAll({
type: "window",
includeUncontrolled: true,
});
for (const client of allClients) {
// Reuse an existing tab whenever possible - users on desktop browsers
// get annoyed when each notification opens a fresh window.
if ("focus" in client) {
try {
if ("navigate" in client && targetUrl) {
await client.navigate(targetUrl);
}
return client.focus();
} catch (_) {
// navigate() can reject for cross-origin or detached clients - fall
// through and open a new window below.
}
}
}
if (self.clients.openWindow) {
return self.clients.openWindow(targetUrl || `${BASE_PATH}/`);
}
}
function buildClickUrl(data) {
if (!data) return `${BASE_PATH}/`;
if (data.kind === "email" && data.emailId) {
return `${BASE_PATH}/?email=${encodeURIComponent(data.emailId)}`;
}
// Generic "New mail" toast (preview API failed or returned no email): land
// the user on the latest unread message in their Inbox rather than just the
// app shell, so the click still feels purposeful.
return `${BASE_PATH}/?openLatestUnread=1`;
}
+10 -4
View File
@@ -1209,6 +1209,15 @@ export const useAuthStore = create<AuthState>()(
for (const account of accounts) {
if (clients.has(account.id)) continue; // Already connected
// Basic auth without rememberMe leaves nothing to restore — the
// user logged in without persisting credentials. Evict silently
// so the login screen is shown without flagging a fake error.
if (account.authMode === 'basic' && !account.rememberMe) {
evictAccount(account.id);
accountStore.removeAccount(account.id);
continue;
}
try {
if (account.authMode === 'oauth') {
const res = await apiFetch(`/api/auth/token?slot=${account.cookieSlot}`, { method: 'PUT' });
@@ -1225,7 +1234,7 @@ export const useAuthStore = create<AuthState>()(
} else {
throw new Error(`Token refresh failed: ${res.status}`);
}
} else if (account.authMode === 'basic' && account.rememberMe) {
} else {
const res = await apiFetch(`/api/auth/session?slot=${account.cookieSlot}`, { method: 'PUT' });
if (res.ok) {
const { serverUrl, username, password } = await res.json();
@@ -1238,9 +1247,6 @@ export const useAuthStore = create<AuthState>()(
} else {
throw new Error(`Session cookie missing: ${res.status}`);
}
} else {
// Basic auth without rememberMe - can't restore
throw new Error('No saved session');
}
} catch (err) {
debug.error(`Failed to restore account ${account.id}:`, err);
+102 -27
View File
@@ -72,7 +72,7 @@ interface EmailStore {
loadMoreEmails: (client: IJMAPClient) => Promise<void>;
fetchEmailContent: (client: IJMAPClient, emailId: string) => Promise<Email | null>;
fetchQuota: (client: IJMAPClient) => Promise<void>;
sendEmail: (client: IJMAPClient, to: string[], subject: string, body: string, cc?: string[], bcc?: string[], identityId?: string, fromEmail?: string, draftId?: string, fromName?: string, htmlBody?: string, attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>) => Promise<void>;
sendEmail: (client: IJMAPClient, to: string[], subject: string, body: string, cc?: string[], bcc?: string[], identityId?: string, fromEmail?: string, draftId?: string, 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>;
sendRawEmail: (client: IJMAPClient, rawMimeBlob: Blob, identityId: string) => Promise<void>;
deleteEmail: (client: IJMAPClient, emailId: string, forceDelete?: boolean) => Promise<void>;
markAsRead: (client: IJMAPClient, emailId: string, read: boolean) => Promise<void>;
@@ -85,6 +85,7 @@ interface EmailStore {
clearSearchFilters: () => void;
toggleAdvancedSearch: () => void;
toggleStar: (client: IJMAPClient, emailId: string) => Promise<void>;
setEmailKeywordsLocal: (emailId: string, keywords: Record<string, boolean>) => void;
// Batch operations
batchMarkAsRead: (client: IJMAPClient, read: boolean) => Promise<void>;
@@ -160,6 +161,28 @@ function getNextSelectedEmail(state: { emails: Email[]; selectedEmail: Email | n
return getNextSelectedEmailAfterRemoval(state, new Set([removedEmailId]));
}
// Find the trash mailbox for a given account scope. Prefers JMAP role, but
// falls back to name matching ("trash" / "deleted") so users with custom or
// pre-existing folders (e.g. "Deleted Items") aren't silently destroyed.
function findTrashMailbox(
mailboxes: Mailbox[],
scope: { accountId?: string; isShared?: boolean }
): Mailbox | undefined {
const matchesScope = (mb: Mailbox): boolean => {
if (scope.accountId) return mb.accountId === scope.accountId;
return !mb.isShared;
};
const byRole = mailboxes.find(mb => mb.role === 'trash' && matchesScope(mb));
if (byRole) return byRole;
return mailboxes.find(mb => {
if (!matchesScope(mb)) return false;
const lower = mb.name.toLowerCase();
return lower.includes('trash') || lower.includes('deleted');
});
}
export const useEmailStore = create<EmailStore>((set, get) => ({
emails: [],
mailboxes: [],
@@ -507,10 +530,10 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
}
},
sendEmail: async (client, to, subject, body, cc, bcc, identityId, fromEmail, draftId, fromName, htmlBody, attachments) => {
sendEmail: async (client, to, subject, body, cc, bcc, identityId, fromEmail, draftId, fromName, htmlBody, attachments, inReplyTo, references) => {
set({ isLoading: true, error: null });
try {
await client.sendEmail(to, subject, body, cc, bcc, identityId, fromEmail, draftId, fromName, htmlBody, attachments);
await client.sendEmail(to, subject, body, cc, bcc, identityId, fromEmail, draftId, fromName, htmlBody, attachments, inReplyTo, references);
// Refresh handled by UI layer for immediate feedback
set({ isLoading: false });
} catch (error) {
@@ -566,15 +589,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
// If deleteAction is 'trash' and not forced permanent delete, try to move to trash mailbox
if (deleteAction === 'trash' && !forceDelete) {
// Find trash mailbox for the correct account
const trashMailbox = mailboxes.find(mb => {
if (accountId) {
// For shared folders, match by accountId
return mb.role === 'trash' && mb.accountId === accountId;
}
// For primary account, find trash that's not from a shared folder
return mb.role === 'trash' && !mb.isShared;
});
const trashMailbox = findTrashMailbox(mailboxes, { accountId });
if (trashMailbox) {
// Use originalId for shared mailboxes if available
@@ -619,7 +634,10 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
});
return;
}
// If no trash mailbox found, fall through to permanent delete
// No trash folder found in this account. Surface the failure rather
// than silently destroying the email - the user asked to move it to
// trash, not to permanently delete it.
throw new Error('Trash mailbox not found - cannot move email to trash');
}
// Permanent delete
@@ -1057,6 +1075,17 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
}
},
setEmailKeywordsLocal: (emailId, keywords) => {
set((state) => ({
emails: state.emails.map(e =>
e.id === emailId ? { ...e, keywords: { ...keywords } } : e
),
selectedEmail: state.selectedEmail?.id === emailId
? { ...state.selectedEmail, keywords: { ...keywords } }
: state.selectedEmail,
}));
},
// Batch operations
batchMarkAsRead: async (client, read) => {
const { selectedEmailIds, emails, mailboxes } = get();
@@ -1163,23 +1192,65 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
await Promise.allSettled(promises);
} else {
// Move to trash per account.
const failedAccounts: string[] = [];
const movedEmailIds = new Set<string>();
const promises = Array.from(emailsByAccount.entries()).map(async ([acctId, ids]) => {
const acctClient = getClient(acctId);
if (!acctClient) return;
const trashMailbox = mailboxes.find(mb => {
if (mb.role !== 'trash') return false;
if (acctId === '__default__') return !mb.isShared;
return mb.accountId === acctId;
if (!acctClient) {
failedAccounts.push(acctId);
return;
}
const trashMailbox = findTrashMailbox(mailboxes, {
accountId: acctId === '__default__' ? undefined : acctId,
});
if (!trashMailbox) {
// No trash available for this account - fall back to destroy so the action isn't silently dropped.
await acctClient.batchDeleteEmails(ids);
// No trash for this account: skip rather than silently destroying.
// The user asked to move to trash, not permanently delete.
failedAccounts.push(acctId);
return;
}
const trashId = trashMailbox.originalId || trashMailbox.id;
await acctClient.batchMoveEmails(ids, trashId, trashMailbox.accountId);
ids.forEach(id => movedEmailIds.add(id));
});
await Promise.allSettled(promises);
if (failedAccounts.length > 0 && movedEmailIds.size === 0) {
// Nothing moved - bail out so the UI doesn't drop the emails from view.
throw new Error('Trash mailbox not found - cannot move emails to trash');
}
// Only remove successfully moved emails from local state.
if (movedEmailIds.size < emailIdsArray.length) {
const deletedEmails = emails.filter(e => movedEmailIds.has(e.id));
const remainingEmails = emails.filter(e => !movedEmailIds.has(e.id));
const updatedMailboxes = mailboxes.map(mailbox => {
let deltaTotalEmails = 0;
let deltaUnreadEmails = 0;
deletedEmails.forEach(email => {
if (email.mailboxIds?.[mailbox.id]) {
deltaTotalEmails--;
if (!email.keywords?.$seen) deltaUnreadEmails--;
}
});
return {
...mailbox,
totalEmails: Math.max(0, mailbox.totalEmails + deltaTotalEmails),
unreadEmails: Math.max(0, mailbox.unreadEmails + deltaUnreadEmails),
totalThreads: Math.max(0, mailbox.totalThreads + deltaTotalEmails),
unreadThreads: Math.max(0, mailbox.unreadThreads + deltaUnreadEmails),
};
});
set({
emails: remainingEmails,
mailboxes: updatedMailboxes,
selectedEmailIds: new Set(),
selectedEmail: null,
isLoading: false,
error: 'Some emails could not be moved: trash folder missing for one or more accounts',
});
return;
}
}
// Remove deleted emails from local state
@@ -1530,13 +1601,17 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
const currentEmails = get().emails;
// Check if there are new emails by comparing the first email ID
const currentFirstEmailId = currentEmails[0]?.id;
const newFirstEmailId = result.emails[0]?.id;
// If the first email changed, we have a new email - trigger notification
if (currentFirstEmailId !== newFirstEmailId && result.emails[0]) {
get().handleNewEmailNotification(result.emails[0]);
// Only notify for genuinely new incoming mail in the Inbox.
// Without these guards the toast/sound also fires when sending,
// saving drafts, or moving/deleting the top message in any mailbox,
// because all of those change the first-email id of the current view.
const newFirst = result.emails[0];
if (
newFirst &&
mailbox?.role === 'inbox' &&
!currentEmails.some(e => e.id === newFirst.id)
) {
get().handleNewEmailNotification(newFirst);
}
// Merge the refreshed first page with the existing loaded emails.
+16
View File
@@ -4,6 +4,10 @@ import { useThemeStore } from './theme-store';
import { useLocaleStore } from './locale-store';
import type { NotificationSoundChoice } from '@/lib/notification-sound';
import { apiFetch } from '@/lib/browser-navigation';
import {
DEFAULT_SUB_ADDRESS_DELIMITER,
isValidSubAddressDelimiter,
} from '@/lib/sub-addressing';
// Use console directly to avoid circular dependency with lib/debug.ts
// (debug.ts imports useSettingsStore for debugMode check)
@@ -138,6 +142,7 @@ interface SettingsState {
defaultReplyMode: ReplyMode;
autoSelectReplyIdentity: boolean;
plainTextMode: boolean; // Send plain text only (no rich text editor)
subAddressDelimiter: string; // Character separating user from tag (e.g. "user+tag@")
// Privacy & Security
sessionTimeout: number; // minutes (0 = never)
@@ -206,6 +211,10 @@ interface SettingsState {
// attachment list shown above the message body.
hideInlineImageAttachments: boolean;
// Render image attachments as thumbnail cards (preview the actual image
// contents inside the chip) instead of generic file icons.
attachmentImagePreviewsEnabled: boolean;
// Sidebar Apps
sidebarApps: SidebarApp[];
keepAppsLoaded: boolean;
@@ -286,6 +295,7 @@ const DEFAULT_SETTINGS = {
defaultReplyMode: 'reply' as ReplyMode,
autoSelectReplyIdentity: false,
plainTextMode: false,
subAddressDelimiter: DEFAULT_SUB_ADDRESS_DELIMITER,
// Privacy & Security
sessionTimeout: 0, // Never
@@ -378,6 +388,7 @@ const DEFAULT_SETTINGS = {
] as string[],
hideInlineImageAttachments: true,
attachmentImagePreviewsEnabled: true,
// Sidebar Apps
sidebarApps: [] as SidebarApp[],
@@ -456,6 +467,7 @@ export const useSettingsStore = create<SettingsState>()(
defaultReplyMode: state.defaultReplyMode,
autoSelectReplyIdentity: state.autoSelectReplyIdentity,
plainTextMode: state.plainTextMode,
subAddressDelimiter: state.subAddressDelimiter,
sessionTimeout: state.sessionTimeout,
emailNotificationsEnabled: state.emailNotificationsEnabled,
emailNotificationSound: state.emailNotificationSound,
@@ -484,6 +496,7 @@ export const useSettingsStore = create<SettingsState>()(
attachmentReminderEnabled: state.attachmentReminderEnabled,
attachmentReminderKeywords: state.attachmentReminderKeywords,
hideInlineImageAttachments: state.hideInlineImageAttachments,
attachmentImagePreviewsEnabled: state.attachmentImagePreviewsEnabled,
sidebarApps: state.sidebarApps,
keepAppsLoaded: state.keepAppsLoaded,
debugMode: state.debugMode,
@@ -508,6 +521,9 @@ export const useSettingsStore = create<SettingsState>()(
// Apply settings
Object.keys(settings).forEach((key) => {
if (key in DEFAULT_SETTINGS) {
if (key === 'subAddressDelimiter' && !isValidSubAddressDelimiter(settings[key])) {
return;
}
set({ [key]: settings[key] });
}
});