Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1c5dba7d7 | ||
|
|
8c50abe221 | ||
|
|
07367a8a5d | ||
|
|
1a50788c91 | ||
|
|
0e06bfe273 | ||
|
|
f68e41d81a | ||
|
|
8b164c556e | ||
|
|
2e1f53c899 | ||
|
|
a6d2efaf74 | ||
|
|
01cd9644ed | ||
|
|
1521826d37 | ||
|
|
0d218d0d2a | ||
|
|
9777dd655c | ||
|
|
f970fd1822 | ||
|
|
5e096240b3 | ||
|
|
bc97a1ac10 | ||
|
|
4594fb2572 | ||
|
|
5319562c94 | ||
|
|
599fa66822 | ||
|
|
8041700668 | ||
|
|
bade68a8b8 | ||
|
|
4be7176802 | ||
|
|
8813533958 | ||
|
|
affa239d75 | ||
|
|
5d292fa43f | ||
|
|
878df6bb49 | ||
|
|
607a9584fd | ||
|
|
b8e2bfd793 | ||
|
|
4ad6d37877 | ||
|
|
d7c29b7bec | ||
|
|
b86bc541ab | ||
|
|
8c74e01a40 | ||
|
|
231a9017d2 | ||
|
|
9a5bb78b18 | ||
|
|
eecf16daa2 | ||
|
|
210150a02e | ||
|
|
e50691d6c4 | ||
|
|
089963b1b3 | ||
|
|
4af952613a | ||
|
|
0d9fa0285f | ||
|
|
683fe75864 | ||
|
|
e9c9be84ad | ||
|
|
7822a363dd | ||
|
|
1e535e96a2 | ||
|
|
32135ddb95 | ||
|
|
841513e510 | ||
|
|
5964b2e456 | ||
|
|
6a8ad525f1 | ||
|
|
31d17098d6 | ||
|
|
3f97e6ed8d | ||
|
|
2dea33e698 | ||
|
|
123764f8b8 | ||
|
|
ec0f355c13 | ||
|
|
c555973b6b | ||
|
|
a8db02e881 | ||
|
|
7dc5984359 | ||
|
|
1c3003421e | ||
|
|
f3d9115ecd | ||
|
|
4400a7abba | ||
|
|
45a4db1c22 | ||
|
|
65eef4b2b8 | ||
|
|
25de7d996c | ||
|
|
c406fbb73e | ||
|
|
31024396e3 | ||
|
|
f0967f90eb | ||
|
|
a4bb8e0c28 | ||
|
|
7188abc9bc | ||
|
|
4a91cd0c44 | ||
|
|
6abf8a5dd8 | ||
|
|
3667c842c6 | ||
|
|
b64721b43c | ||
|
|
6b5ca2cb89 | ||
|
|
0f6e4f995f | ||
|
|
0b6fdcabfb |
@@ -50,6 +50,8 @@ jobs:
|
||||
context: .
|
||||
platforms: ${{ matrix.platform }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
GIT_COMMIT=${{ github.sha }}
|
||||
outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
|
||||
cache-from: type=gha,scope=${{ matrix.platform }}
|
||||
cache-to: type=gha,mode=max,scope=${{ matrix.platform }}
|
||||
|
||||
@@ -78,6 +78,8 @@ jobs:
|
||||
context: .
|
||||
platforms: ${{ matrix.platform }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
GIT_COMMIT=${{ github.sha }}
|
||||
outputs: type=image,name=${{ needs.prepare.outputs.image_name }},push-by-digest=true,name-canonical=true,push=true
|
||||
cache-from: type=gha,scope=${{ matrix.platform }}
|
||||
cache-to: type=gha,mode=max,scope=${{ matrix.platform }}
|
||||
|
||||
@@ -1,5 +1,87 @@
|
||||
# Changelog
|
||||
|
||||
## 1.6.1 (2026-05-04)
|
||||
|
||||
### Features
|
||||
|
||||
- **Updates**: Update-available detection with non-dismissible notice and dev-reload refresh
|
||||
- **Plugins**: New plugin hooks for compose, attachments, search, lifecycle, and routing
|
||||
- **Sharing**: Share indicators for calendars and contacts, updated JMAP capabilities (#244)
|
||||
- **Mail**: Auto-add recipients to trusted senders when replying
|
||||
- **Identity**: Sanitize identity display name to prevent invalid `From` headers
|
||||
|
||||
### Fixes
|
||||
|
||||
- **Mobile**: Synchronize mobile submenu view with browser history for better navigation
|
||||
- **Viewer**: Update email viewer styles to improve overflow handling
|
||||
- **Auth**: Ensure `cookieSlot` consistency during account updates in auth store
|
||||
- **Auth**: Thread per-account cookie slot through OAuth flows
|
||||
- **Calendar**: Square the colored left marker on calendar events
|
||||
- **About**: Show git commit in About instead of "unknown"
|
||||
|
||||
### i18n
|
||||
|
||||
- Update mailbox context menu translations across 12 locales
|
||||
|
||||
## 1.6.0 (2026-05-01)
|
||||
|
||||
### Features
|
||||
|
||||
- **Deployment**: Subpath deployment support via `NEXT_PUBLIC_BASE_PATH` environment variable
|
||||
- **Mail**: Image attachment thumbnails and preview chips
|
||||
- **Mobile**: Reworked mobile mail viewer toolbar
|
||||
- **Mobile**: Mobile-friendly settings panel
|
||||
- **Mobile**: Mobile-friendly admin panel
|
||||
- **Mail**: Redesigned expanded details panel
|
||||
- **Mailbox**: Show full path in mailbox context menu header with intelligent path shortening
|
||||
|
||||
### Fixes
|
||||
|
||||
- **Viewer**: Respect per-email dark mode toggle when "always show in light mode" is on
|
||||
- **Navigation**: Scroll apps list in navigation rail to prevent overflow
|
||||
- **Context menu**: Clamp submenu inside viewport
|
||||
- **Context menu**: Prevent context menu from clipping below viewport
|
||||
- **Context menu**: Prevent jump and animation on open
|
||||
- **Mail**: Stop silently destroying emails when trash mailbox isn't found (#195)
|
||||
- **Mail**: Preserve list scroll position when tagging an email
|
||||
- **Mail**: Render below-header overflow popup outside clipped row
|
||||
- **Mail**: Collapse below-header attachments to single row with overflow pill
|
||||
- **Push**: Fix push preview JMAP query
|
||||
- **Tour**: Navigate tour to mailbox when starting from another page
|
||||
- **i18n**: Add `useTranslations` for "selected emails" and "cancel" on email list batch operations
|
||||
|
||||
### i18n
|
||||
|
||||
- Translate SPF/DKIM/DMARC tooltips
|
||||
- Add missing keys across 14 locales
|
||||
|
||||
## 1.5.4 (2026-05-01)
|
||||
|
||||
### Features
|
||||
|
||||
- **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,6 +4,14 @@ COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
# Optional: serve under a subpath like /webmail. Baked into emitted asset URLs
|
||||
# at build time, so it cannot be changed without rebuilding.
|
||||
ARG NEXT_PUBLIC_BASE_PATH=
|
||||
ENV NEXT_PUBLIC_BASE_PATH=$NEXT_PUBLIC_BASE_PATH
|
||||
# Commit SHA shown in the About screen. .dockerignore excludes .git, so
|
||||
# `git rev-parse` inside the build can't find it — CI must pass it in.
|
||||
ARG GIT_COMMIT=unknown
|
||||
ENV GIT_COMMIT=$GIT_COMMIT
|
||||
RUN npx next build --webpack
|
||||
|
||||
FROM node:24-alpine AS runner
|
||||
|
||||
+1
-1
@@ -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`.
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.ar
|
||||
|
||||
[](LICENSE)
|
||||
[](https://discord.gg/tYCujymGrT)
|
||||
[](CHANGELOG.md)
|
||||
[](CHANGELOG.md)
|
||||
[](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 |
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useAccountStore } from "@/stores/account-store";
|
||||
import { useThemeStore } from "@/stores/theme-store";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useConfig } from "@/hooks/use-config";
|
||||
@@ -16,6 +17,7 @@ import { AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Monitor,
|
||||
import { discoverOAuth, type OAuthMetadata } from "@/lib/oauth/discovery";
|
||||
import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce";
|
||||
import { OAUTH_SCOPES } from "@/lib/oauth/tokens";
|
||||
import { useUpdateStore, selectBanner } from "@/stores/update-store";
|
||||
|
||||
const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "0.0.0";
|
||||
const GIT_COMMIT = process.env.NEXT_PUBLIC_GIT_COMMIT || "unknown";
|
||||
@@ -28,7 +30,12 @@ const THEME_OPTIONS = [
|
||||
|
||||
function VersionBadge() {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const versionInfo = `Version: ${APP_VERSION}\nBuild: ${GIT_COMMIT}`;
|
||||
const banner = useUpdateStore(useShallow(selectBanner));
|
||||
const startPolling = useUpdateStore((s) => s.startPolling);
|
||||
|
||||
useEffect(() => { startPolling(); }, [startPolling]);
|
||||
|
||||
const versionInfo = `Version: ${APP_VERSION}\nBuild: ${GIT_COMMIT}${banner?.latest ? `\nLatest: ${banner.latest}` : ""}`;
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(versionInfo).then(() => {
|
||||
@@ -37,16 +44,49 @@ function VersionBadge() {
|
||||
});
|
||||
};
|
||||
|
||||
const isRed = banner?.variant === "red";
|
||||
const triggerText = !banner
|
||||
? `v${APP_VERSION}`
|
||||
: banner.severity === "security"
|
||||
? "Security update available"
|
||||
: banner.severity === "deprecated"
|
||||
? "Version no longer supported"
|
||||
: "New version available";
|
||||
|
||||
const triggerColor = !banner
|
||||
? "text-muted-foreground/40"
|
||||
: isRed
|
||||
? "text-red-600/80 dark:text-red-400/80 hover:text-red-600 dark:hover:text-red-400"
|
||||
: "text-amber-600/80 dark:text-amber-400/80 hover:text-amber-600 dark:hover:text-amber-400";
|
||||
|
||||
const triggerClass = cn(
|
||||
"peer text-center text-xs transition-colors",
|
||||
triggerColor,
|
||||
banner?.url ? "cursor-pointer underline-offset-2 hover:underline" : "cursor-default",
|
||||
);
|
||||
|
||||
const trigger = banner?.url ? (
|
||||
<a href={banner.url} target="_blank" rel="noopener noreferrer" className={triggerClass}>
|
||||
{triggerText}
|
||||
</a>
|
||||
) : (
|
||||
<p className={triggerClass}>{triggerText}</p>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative inline-flex justify-center">
|
||||
<p className="peer text-center text-xs text-muted-foreground/40 cursor-default">
|
||||
v{APP_VERSION}
|
||||
</p>
|
||||
{trigger}
|
||||
<div className="absolute top-full left-1/2 -translate-x-1/2 mt-1.5 px-3 py-2 rounded-md bg-popover text-popover-foreground text-xs shadow-md border border-border opacity-0 peer-hover:opacity-100 hover:opacity-100 transition-opacity whitespace-nowrap z-10">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="space-y-0.5">
|
||||
<p>Version: <span className="font-medium">{APP_VERSION}</span></p>
|
||||
<p>Build: <span className="font-medium">{GIT_COMMIT}</span></p>
|
||||
{banner?.latest && (
|
||||
<p>Latest: <span className="font-medium">{banner.latest}</span></p>
|
||||
)}
|
||||
{banner?.advisory && (
|
||||
<p className="text-red-500 dark:text-red-400">{banner.advisory}</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
@@ -411,7 +451,8 @@ export default function LoginPage() {
|
||||
const verifier = generateCodeVerifier();
|
||||
const challenge = await generateCodeChallenge(verifier);
|
||||
const state = generateState();
|
||||
const redirectUri = `${window.location.origin}/${params.locale}/auth/callback`;
|
||||
const prefix = getPathPrefix(params.locale as string);
|
||||
const redirectUri = `${window.location.origin}${prefix}/${params.locale}/auth/callback`;
|
||||
|
||||
sessionStorage.setItem("oauth_code_verifier", verifier);
|
||||
sessionStorage.setItem("oauth_state", state);
|
||||
@@ -420,6 +461,16 @@ export default function LoginPage() {
|
||||
sessionStorage.setItem("oauth_add_account_mode", "true");
|
||||
}
|
||||
|
||||
// Persist the next-free cookie slot so loginWithOAuth (in stores/auth-store.ts)
|
||||
// writes the refresh token to the correct per-account jmap_rt_<slot> cookie.
|
||||
// loginWithOAuth reads this key but it was previously never written, so every
|
||||
// OAuth account collapsed onto slot 0 and clobbered earlier accounts' refresh
|
||||
// tokens. getNextCookieSlot() returns 0 when no accounts exist (correct for
|
||||
// first sign-in) and the lowest unused slot otherwise (correct for "+ Add
|
||||
// Account").
|
||||
const nextSlot = useAccountStore.getState().getNextCookieSlot();
|
||||
sessionStorage.setItem("oauth_cookie_slot", nextSlot.toString());
|
||||
|
||||
const authUrl = new URL(oauthMetadata.authorization_endpoint);
|
||||
authUrl.searchParams.set("response_type", "code");
|
||||
authUrl.searchParams.set("client_id", oauthClientId);
|
||||
|
||||
+212
-15
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useRef, useMemo, useCallback } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Sidebar } from "@/components/layout/sidebar";
|
||||
import { EmailList } from "@/components/email/email-list";
|
||||
@@ -51,12 +52,33 @@ import { Input } from "@/components/ui/input";
|
||||
import { FilePreviewModal } from "@/components/files/file-preview-modal";
|
||||
import { isFilePreviewable } from "@/lib/file-preview";
|
||||
import { appendPlainTextSignature } from "@/lib/signature-utils";
|
||||
import { computeReplyThreadingHeaders } from "@/lib/email-threading";
|
||||
import { Search, Filter, ChevronDown, X, Paperclip, Star, Mail, MailOpen, RotateCcw, PenSquare, PenLine, CheckSquare, Square, AlertTriangle } from "lucide-react";
|
||||
import { ResizeHandle } from "@/components/layout/resize-handle";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useConfig } from "@/hooks/use-config";
|
||||
import { usePluginStore } from "@/stores/plugin-store";
|
||||
import { useThemeStore } from "@/stores/theme-store";
|
||||
import { appLifecycleHooks, uiHooks, routerHooks, toastHooks, emailHooks } from "@/lib/plugin-hooks";
|
||||
import type { EmailReadView } from "@/lib/plugin-types";
|
||||
|
||||
function emailToReadView(email: Email): EmailReadView {
|
||||
return {
|
||||
id: email.id,
|
||||
threadId: email.threadId,
|
||||
mailboxIds: Object.keys(email.mailboxIds || {}).filter(k => email.mailboxIds[k]),
|
||||
from: (email.from || []).map(a => ({ name: a.name || '', email: a.email })),
|
||||
to: (email.to || []).map(a => ({ name: a.name || '', email: a.email })),
|
||||
cc: (email.cc || []).map(a => ({ name: a.name || '', email: a.email })),
|
||||
subject: email.subject || '',
|
||||
receivedAt: email.receivedAt,
|
||||
isRead: !!email.keywords?.['$seen'],
|
||||
isFlagged: !!email.keywords?.['$flagged'],
|
||||
hasAttachment: email.hasAttachment,
|
||||
preview: email.preview || '',
|
||||
keywords: Object.keys(email.keywords || {}).filter(k => email.keywords[k]),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
export default function Home() {
|
||||
@@ -114,6 +136,88 @@ export default function Home() {
|
||||
return () => clearInterval(timer);
|
||||
}, [isRateLimited, rateLimitUntil]);
|
||||
|
||||
// Plugin hooks: window-level lifecycle + selection + service-worker messages.
|
||||
// One effect because the listeners share a registration / cleanup window.
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
const onFocus = () => { appLifecycleHooks.onWindowFocus.emit(); };
|
||||
const onBlur = () => { appLifecycleHooks.onWindowBlur.emit(); };
|
||||
const onOnline = () => { appLifecycleHooks.onOnline.emit(); };
|
||||
const onOffline = () => { appLifecycleHooks.onOffline.emit(); };
|
||||
|
||||
let selectionTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const onSelectionChange = () => {
|
||||
if (selectionTimer) clearTimeout(selectionTimer);
|
||||
selectionTimer = setTimeout(() => {
|
||||
const sel = document.getSelection();
|
||||
const text = sel?.toString() ?? '';
|
||||
if (!text) return;
|
||||
const anchorNode = sel?.anchorNode as Node | null;
|
||||
const anchorEl = (anchorNode?.nodeType === Node.ELEMENT_NODE
|
||||
? anchorNode as Element
|
||||
: anchorNode?.parentElement) ?? null;
|
||||
let source: 'email-body' | 'composer' | 'task-detail' | 'event-detail' | 'other' = 'other';
|
||||
let emailId: string | undefined;
|
||||
if (anchorEl) {
|
||||
if (anchorEl.closest('[data-plugin-source="email-body"], iframe.email-body, .email-viewer-body')) {
|
||||
source = 'email-body';
|
||||
const idEl = anchorEl.closest('[data-email-id]') as HTMLElement | null;
|
||||
emailId = idEl?.dataset.emailId;
|
||||
} else if (anchorEl.closest('[data-plugin-source="composer"], .email-composer')) {
|
||||
source = 'composer';
|
||||
} else if (anchorEl.closest('[data-plugin-source="task-detail"]')) {
|
||||
source = 'task-detail';
|
||||
} else if (anchorEl.closest('[data-plugin-source="event-detail"]')) {
|
||||
source = 'event-detail';
|
||||
}
|
||||
}
|
||||
uiHooks.onTextSelectionChange.emit({ text, source, emailId });
|
||||
}, 150);
|
||||
};
|
||||
|
||||
const onSwMessage = (e: MessageEvent) => {
|
||||
const msg = e.data as { kind?: string; tag?: string; data?: unknown } | null;
|
||||
if (msg && msg.kind === 'notificationclick' && typeof msg.tag === 'string') {
|
||||
toastHooks.onNotificationClick.emit({ tag: msg.tag, data: msg.data });
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('focus', onFocus);
|
||||
window.addEventListener('blur', onBlur);
|
||||
window.addEventListener('online', onOnline);
|
||||
window.addEventListener('offline', onOffline);
|
||||
document.addEventListener('selectionchange', onSelectionChange);
|
||||
if (typeof navigator !== 'undefined' && navigator.serviceWorker) {
|
||||
navigator.serviceWorker.addEventListener('message', onSwMessage);
|
||||
}
|
||||
return () => {
|
||||
window.removeEventListener('focus', onFocus);
|
||||
window.removeEventListener('blur', onBlur);
|
||||
window.removeEventListener('online', onOnline);
|
||||
window.removeEventListener('offline', onOffline);
|
||||
document.removeEventListener('selectionchange', onSelectionChange);
|
||||
if (selectionTimer) clearTimeout(selectionTimer);
|
||||
if (typeof navigator !== 'undefined' && navigator.serviceWorker) {
|
||||
navigator.serviceWorker.removeEventListener('message', onSwMessage);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Plugin hooks: route navigation. Tracks Next.js pathname transitions.
|
||||
const pathname = usePathname();
|
||||
const prevPathnameRef = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (!pathname) return;
|
||||
const from = prevPathnameRef.current;
|
||||
if (from === pathname) return;
|
||||
if (from !== null) {
|
||||
routerHooks.onRouteLeave.emit({ path: from });
|
||||
routerHooks.onNavigate.emit({ path: pathname, from });
|
||||
}
|
||||
routerHooks.onRouteEnter.emit({ path: pathname });
|
||||
prevPathnameRef.current = pathname;
|
||||
}, [pathname]);
|
||||
|
||||
// Mobile/tablet responsive hooks
|
||||
const { isMobile, isTablet } = useDeviceDetection();
|
||||
const { activeView, sidebarOpen, setSidebarOpen, setActiveView, tabletListVisible, setTabletListVisible, sidebarWidth, emailListWidth, setSidebarWidth, setEmailListWidth, persistColumnWidths, sidebarCollapsed, resetSidebarWidth, resetEmailListWidth } = useUIStore();
|
||||
@@ -138,6 +242,7 @@ export default function Home() {
|
||||
deleteEmail,
|
||||
markAsRead,
|
||||
toggleStar,
|
||||
setEmailKeywordsLocal,
|
||||
moveToMailbox,
|
||||
moveThreadToMailbox,
|
||||
searchEmails,
|
||||
@@ -651,11 +756,50 @@ export default function Home() {
|
||||
});
|
||||
}, [enableUnifiedMailbox, isAuthenticated, client, mailboxes, connectedAccountsSignature, buildUnifiedAccounts, populateUnifiedAccountMailboxes, refreshUnifiedCounts]);
|
||||
|
||||
// System-notification click handler. The push SW navigates the user back
|
||||
// here with `?email=<id>` (specific email it built the toast from) or
|
||||
// `?openLatestUnread=1` (generic "New mail" toast — happens when the
|
||||
// preview API failed). We resolve those params once after the inbox has
|
||||
// finished loading and open the right message, then strip the params so a
|
||||
// refresh doesn't re-open it.
|
||||
const notificationParamHandledRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (notificationParamHandledRef.current) return;
|
||||
if (!isAuthenticated || !client) return;
|
||||
if (mailboxes.length === 0) return;
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const emailIdParam = params.get('email');
|
||||
const openLatestUnread = params.get('openLatestUnread') === '1';
|
||||
if (!emailIdParam && !openLatestUnread) return;
|
||||
|
||||
// For the latest-unread case we need the inbox emails loaded; bail and
|
||||
// let the effect re-run once `emails` is populated.
|
||||
if (openLatestUnread && emails.length === 0) return;
|
||||
|
||||
notificationParamHandledRef.current = true;
|
||||
window.history.replaceState({}, '', window.location.pathname);
|
||||
|
||||
if (emailIdParam) {
|
||||
setLoadingEmail(true);
|
||||
fetchEmailContent(client, emailIdParam).finally(() => setLoadingEmail(false));
|
||||
return;
|
||||
}
|
||||
|
||||
// emails are sorted receivedAt-desc, so the first unread is the newest.
|
||||
const newestUnread = emails.find(e => !e.keywords?.$seen);
|
||||
if (newestUnread) {
|
||||
selectEmail(newestUnread);
|
||||
}
|
||||
}, [isAuthenticated, client, mailboxes.length, emails, fetchEmailContent, selectEmail, setLoadingEmail]);
|
||||
|
||||
// Auto-fetch full email content when an email is auto-selected (e.g. after delete/archive)
|
||||
useEffect(() => {
|
||||
if (!selectedEmail || !client) return;
|
||||
// If the email lacks bodyValues, it was auto-selected from the list and needs full content
|
||||
if (!selectedEmail.bodyValues) {
|
||||
// If the email lacks bodyValues, it was auto-selected from the list and needs full content.
|
||||
// Skip when handleEmailSelect already started a fetch (it sets isLoadingEmail before
|
||||
// calling selectEmail on the stub), to avoid a duplicate request.
|
||||
if (!selectedEmail.bodyValues && !isLoadingEmail) {
|
||||
const perAccountClient = isUnifiedView && selectedEmail.accountId
|
||||
? useAuthStore.getState().getClientForAccount(selectedEmail.accountId)
|
||||
: undefined;
|
||||
@@ -750,6 +894,8 @@ export default function Home() {
|
||||
fromName?: string;
|
||||
identityId?: string;
|
||||
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>;
|
||||
inReplyTo?: string[];
|
||||
references?: string[];
|
||||
}) => {
|
||||
if (!client) return;
|
||||
|
||||
@@ -757,7 +903,7 @@ export default function Home() {
|
||||
const effectiveMode = pendingDraft?.mode ?? composerMode;
|
||||
const originalEmailId = selectedEmail?.id;
|
||||
|
||||
await sendEmail(client, data.to, data.subject, data.body, data.cc, data.bcc, data.identityId, data.fromEmail, data.draftId, data.fromName, data.htmlBody, data.attachments);
|
||||
await sendEmail(client, data.to, data.subject, data.body, data.cc, data.bcc, data.identityId, data.fromEmail, data.draftId, data.fromName, data.htmlBody, data.attachments, data.inReplyTo, data.references);
|
||||
setShowComposer(false);
|
||||
|
||||
// Mark the original email with $answered or $forwarded keyword
|
||||
@@ -792,7 +938,15 @@ export default function Home() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleReply = (draftText?: string) => {
|
||||
const handleReply = async (draftText?: string) => {
|
||||
if (selectedEmail) {
|
||||
const ok = await emailHooks.onBeforeReply.intercept({
|
||||
originalEmailId: selectedEmail.id,
|
||||
originalEmail: emailToReadView(selectedEmail),
|
||||
mode: 'reply' as const,
|
||||
});
|
||||
if (!ok) return;
|
||||
}
|
||||
setComposerDraftText(draftText || "");
|
||||
setComposerMode('reply');
|
||||
setShowComposer(true);
|
||||
@@ -851,13 +1005,29 @@ export default function Home() {
|
||||
if (isMobile) setActiveView('viewer');
|
||||
};
|
||||
|
||||
const handleReplyAll = () => {
|
||||
const handleReplyAll = async () => {
|
||||
if (selectedEmail) {
|
||||
const ok = await emailHooks.onBeforeReplyAll.intercept({
|
||||
originalEmailId: selectedEmail.id,
|
||||
originalEmail: emailToReadView(selectedEmail),
|
||||
mode: 'reply-all' as const,
|
||||
});
|
||||
if (!ok) return;
|
||||
}
|
||||
setComposerMode('replyAll');
|
||||
setShowComposer(true);
|
||||
if (isMobile) setActiveView('viewer');
|
||||
};
|
||||
|
||||
const handleForward = () => {
|
||||
const handleForward = async () => {
|
||||
if (selectedEmail) {
|
||||
const ok = await emailHooks.onBeforeForward.intercept({
|
||||
originalEmailId: selectedEmail.id,
|
||||
originalEmail: emailToReadView(selectedEmail),
|
||||
mode: 'forward' as const,
|
||||
});
|
||||
if (!ok) return;
|
||||
}
|
||||
setComposerMode('forward');
|
||||
setShowComposer(true);
|
||||
if (isMobile) setActiveView('viewer');
|
||||
@@ -889,13 +1059,24 @@ export default function Home() {
|
||||
}
|
||||
} else {
|
||||
// Not in trash: always move to trash
|
||||
const trashMailbox = mailboxes.find(m => m.role === 'trash' && !m.isShared);
|
||||
const trashMailbox =
|
||||
mailboxes.find(m => m.role === 'trash' && !m.isShared) ??
|
||||
mailboxes.find(m => {
|
||||
if (m.isShared) return false;
|
||||
const lower = m.name.toLowerCase();
|
||||
return lower.includes('trash') || lower.includes('deleted');
|
||||
});
|
||||
if (trashMailbox) {
|
||||
try {
|
||||
await moveToMailbox(client, emailToDelete.id, trashMailbox.id);
|
||||
} catch (error) {
|
||||
console.error("Failed to move email to trash:", error);
|
||||
const { toast } = await import('sonner');
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to move email to trash');
|
||||
}
|
||||
} else {
|
||||
const { toast } = await import('sonner');
|
||||
toast.error('Trash mailbox not found - cannot move email to trash');
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1043,11 +1224,9 @@ export default function Home() {
|
||||
// Update email keywords via JMAP
|
||||
await client.updateEmailKeywords(emailId, keywords);
|
||||
|
||||
// Update local state
|
||||
selectEmail(email.id === selectedEmail?.id ? { ...email, keywords } : selectedEmail);
|
||||
|
||||
// Refresh emails list to show color in list
|
||||
await fetchEmails(client, selectedMailbox);
|
||||
// Patch the email in place so the list keeps its scroll/pagination state
|
||||
// instead of being reset to the first page by a full refetch.
|
||||
setEmailKeywordsLocal(emailId, keywords);
|
||||
|
||||
// Refresh tag counts
|
||||
fetchTagCounts(client);
|
||||
@@ -1449,6 +1628,12 @@ export default function Home() {
|
||||
|
||||
const originalEmailId = selectedEmail.id;
|
||||
|
||||
// RFC 5322 §3.6.4 threading — keep the conversation stitched together (#234).
|
||||
const threading = computeReplyThreadingHeaders({
|
||||
messageId: selectedEmail.messageId,
|
||||
references: selectedEmail.references,
|
||||
});
|
||||
|
||||
// Send reply with just the body text
|
||||
await sendEmail(
|
||||
client,
|
||||
@@ -1460,7 +1645,11 @@ export default function Home() {
|
||||
primaryIdentity?.id,
|
||||
primaryIdentity?.email,
|
||||
undefined,
|
||||
primaryIdentity?.name || undefined
|
||||
primaryIdentity?.name || undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
threading?.inReplyTo,
|
||||
threading?.references,
|
||||
);
|
||||
|
||||
// Mark the original email as answered
|
||||
@@ -1502,7 +1691,13 @@ export default function Home() {
|
||||
setShowComposer(false);
|
||||
}
|
||||
|
||||
// Set loading state immediately (keep current email visible)
|
||||
// Show the list stub immediately so subject/sender render without
|
||||
// waiting for the body fetch — avoids the loading flicker.
|
||||
const listEmail = emails.find(e => e.id === email.id);
|
||||
if (listEmail) {
|
||||
selectEmail(listEmail);
|
||||
}
|
||||
|
||||
setLoadingEmail(true);
|
||||
|
||||
// On mobile, switch to viewer
|
||||
@@ -1519,7 +1714,6 @@ export default function Home() {
|
||||
try {
|
||||
// In unified view each email carries its own accountId. Use that
|
||||
// account's client so we fetch from the server that actually owns it.
|
||||
const listEmail = emails.find(e => e.id === email.id);
|
||||
const emailAccountId = isUnifiedView ? listEmail?.accountId : undefined;
|
||||
const perAccountClient = emailAccountId
|
||||
? useAuthStore.getState().getClientForAccount(emailAccountId)
|
||||
@@ -2111,6 +2305,9 @@ export default function Home() {
|
||||
htmlBody: selectedEmail.bodyValues?.[selectedEmail.htmlBody?.[0]?.partId || '']?.value || undefined,
|
||||
receivedAt: selectedEmail.receivedAt,
|
||||
attachments: selectedEmail.attachments,
|
||||
messageId: selectedEmail.messageId,
|
||||
inReplyTo: selectedEmail.inReplyTo,
|
||||
references: selectedEmail.references,
|
||||
} : undefined)}
|
||||
initialDraftText={composerDraftText}
|
||||
initialData={pendingDraft}
|
||||
|
||||
@@ -213,6 +213,22 @@ export default function SettingsPage() {
|
||||
}
|
||||
}, [initialCheckDone, isAuthenticated, authLoading]);
|
||||
|
||||
// Sync the mobile submenu view with browser history so the system back
|
||||
// button (or gesture) returns to the settings list before exiting /settings.
|
||||
useEffect(() => {
|
||||
if (isDesktop) return;
|
||||
if (typeof window === 'undefined') return;
|
||||
if (!mobileShowContent) return;
|
||||
|
||||
window.history.pushState({ __settingsSubmenu: true }, '');
|
||||
|
||||
const handlePop = () => {
|
||||
setMobileShowContent(false);
|
||||
};
|
||||
window.addEventListener('popstate', handlePop);
|
||||
return () => window.removeEventListener('popstate', handlePop);
|
||||
}, [isDesktop, mobileShowContent]);
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return null;
|
||||
}
|
||||
@@ -321,7 +337,7 @@ export default function SettingsPage() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setMobileShowContent(false)}
|
||||
onClick={() => window.history.back()}
|
||||
className="h-10 w-10"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
@@ -330,9 +346,7 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<div className="bg-card border border-border rounded-lg p-4">
|
||||
{renderTabContent()}
|
||||
</div>
|
||||
{renderTabContent()}
|
||||
</div>
|
||||
|
||||
<NavigationRail
|
||||
@@ -515,17 +529,8 @@ export default function SettingsPage() {
|
||||
/>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="max-w-3xl mx-auto p-8">
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center gap-2.5 mb-2">
|
||||
<SettingsIcon className="w-6 h-6 text-muted-foreground" />
|
||||
<h1 className="text-2xl font-semibold text-foreground">{t('title')}</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-card border border-border rounded-lg p-6">
|
||||
{renderTabContent()}
|
||||
</div>
|
||||
<div className="max-w-3xl mx-auto px-6 py-6">
|
||||
{renderTabContent()}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
+12
-12
@@ -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
@@ -162,8 +162,8 @@ export default function AdminBrandingPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-2xl font-semibold text-foreground">Branding</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">Customize logos, favicon, and company information</p>
|
||||
</div>
|
||||
@@ -193,22 +193,22 @@ export default function AdminBrandingPage() {
|
||||
<div className="divide-y divide-border">
|
||||
{IMAGE_FIELDS.map(field => (
|
||||
<div key={field.key} className="px-4 py-3">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<label className="text-sm text-foreground whitespace-nowrap">{field.label}</label>
|
||||
<label className="text-sm text-foreground">{field.label}</label>
|
||||
{config[field.key]?.source === 'admin' && (
|
||||
<span className="text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary">
|
||||
{isUploadedFile(field.key) ? 'uploaded' : 'admin'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 w-full sm:w-auto">
|
||||
<input
|
||||
type="text"
|
||||
value={currentValue(field.key)}
|
||||
onChange={(e) => handleChange(field.key, e.target.value)}
|
||||
placeholder="Enter URL or upload a file"
|
||||
className="h-8 w-64 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
className="h-8 w-full sm:w-64 min-w-0 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
<input
|
||||
ref={el => { fileInputRefs.current[field.key] = el; }}
|
||||
@@ -270,20 +270,20 @@ export default function AdminBrandingPage() {
|
||||
</div>
|
||||
<div className="divide-y divide-border">
|
||||
{TEXT_FIELDS.map(field => (
|
||||
<div key={field.key} className="px-4 py-3 flex items-center justify-between gap-4">
|
||||
<div key={field.key} className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<label className="text-sm text-foreground whitespace-nowrap">{field.label}</label>
|
||||
<label className="text-sm text-foreground">{field.label}</label>
|
||||
{config[field.key]?.source === 'admin' && (
|
||||
<span className="text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary">admin</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 w-full sm:w-auto">
|
||||
<input
|
||||
type="text"
|
||||
value={currentValue(field.key)}
|
||||
onChange={(e) => handleChange(field.key, e.target.value)}
|
||||
placeholder={field.key.includes('Url') ? 'https://...' : 'Enter value'}
|
||||
className="h-8 w-72 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
className="h-8 w-full sm:w-72 min-w-0 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
{config[field.key]?.source === 'admin' && (
|
||||
<button onClick={() => handleRevert(field.key)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
|
||||
|
||||
+224
-63
@@ -15,12 +15,15 @@ import {
|
||||
Puzzle,
|
||||
SwatchBook,
|
||||
Activity,
|
||||
Package,
|
||||
Mail,
|
||||
Calendar,
|
||||
BookUser,
|
||||
HardDrive,
|
||||
ArrowLeft,
|
||||
Store,
|
||||
Menu,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useConfig } from '@/hooks/use-config';
|
||||
@@ -28,6 +31,7 @@ import { useThemeStore } from '@/stores/theme-store';
|
||||
import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
|
||||
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useUpdateStore, selectHasUpdate } from '@/stores/update-store';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
|
||||
const NAV_GROUPS = [
|
||||
@@ -57,6 +61,7 @@ const NAV_GROUPS = [
|
||||
{
|
||||
label: 'System',
|
||||
items: [
|
||||
{ href: '/admin/version', label: 'Version', icon: Package },
|
||||
{ href: '/admin/telemetry', label: 'Telemetry', icon: Activity },
|
||||
{ href: '/admin/logs', label: 'Audit Log', icon: ScrollText },
|
||||
],
|
||||
@@ -69,12 +74,33 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
const [authenticated, setAuthenticated] = useState<boolean | null>(null);
|
||||
const [authError, setAuthError] = useState<string | null>(null);
|
||||
const [isStalwartAdmin, setIsStalwartAdmin] = useState(false);
|
||||
const [mobileNavOpen, setMobileNavOpen] = useState(false);
|
||||
const { appLogoLightUrl, appLogoDarkUrl, loginLogoLightUrl, loginLogoDarkUrl } = useConfig();
|
||||
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
||||
const logoUrl = resolvedTheme === 'dark'
|
||||
? (appLogoDarkUrl || appLogoLightUrl || loginLogoDarkUrl)
|
||||
: (appLogoLightUrl || appLogoDarkUrl || loginLogoLightUrl);
|
||||
|
||||
// Match the navigation rail: red for security/deprecated, amber for normal.
|
||||
const hasUpdate = useUpdateStore(selectHasUpdate);
|
||||
const updateSeverity = useUpdateStore((s) => s.status?.severity);
|
||||
const startUpdatePolling = useUpdateStore((s) => s.startPolling);
|
||||
useEffect(() => { startUpdatePolling(); }, [startUpdatePolling]);
|
||||
const updateImportant = updateSeverity === 'security' || updateSeverity === 'deprecated';
|
||||
|
||||
useEffect(() => {
|
||||
setMobileNavOpen(false);
|
||||
}, [pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mobileNavOpen) return;
|
||||
const previous = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
document.body.style.overflow = previous;
|
||||
};
|
||||
}, [mobileNavOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (pathname === '/admin/login') return;
|
||||
let cancelled = false;
|
||||
@@ -140,10 +166,90 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
const navContent = (
|
||||
<>
|
||||
<div className="flex-1 overflow-y-auto py-2">
|
||||
<div className="px-2 space-y-0.5">
|
||||
{NAV_GROUPS.map((group, groupIndex) => (
|
||||
<div key={group.label}>
|
||||
{groupIndex > 0 && <div className="mx-1 my-2 border-t border-border" />}
|
||||
<div className="px-3 pt-2.5 pb-1">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{group.label}
|
||||
</span>
|
||||
</div>
|
||||
{group.items.map(({ href, label, icon: Icon }) => {
|
||||
const active = href === '/admin' ? pathname === '/admin' : pathname.startsWith(href);
|
||||
const showDot = href === '/admin/version' && hasUpdate;
|
||||
return (
|
||||
<Link
|
||||
key={href}
|
||||
href={href}
|
||||
className={cn(
|
||||
'w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
|
||||
active
|
||||
? 'bg-accent text-accent-foreground font-medium'
|
||||
: 'hover:bg-muted text-foreground'
|
||||
)}
|
||||
>
|
||||
<span className="relative shrink-0">
|
||||
<Icon className={cn(
|
||||
'w-4 h-4',
|
||||
active ? 'text-accent-foreground' : 'text-muted-foreground'
|
||||
)} />
|
||||
{showDot && (
|
||||
<span
|
||||
className={cn(
|
||||
'absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full ring-2',
|
||||
active ? 'ring-accent' : 'ring-background',
|
||||
updateImportant ? 'bg-red-500' : 'bg-amber-500',
|
||||
)}
|
||||
aria-label={updateImportant ? 'Important update available' : 'Update available'}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
{label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-2 py-2 border-t border-border space-y-0.5 shrink-0">
|
||||
{!isStalwartAdmin && (
|
||||
<Link
|
||||
href="/admin/change-password"
|
||||
className={cn(
|
||||
'w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
|
||||
pathname === '/admin/change-password'
|
||||
? 'bg-accent text-accent-foreground font-medium'
|
||||
: 'hover:bg-muted text-foreground'
|
||||
)}
|
||||
>
|
||||
<KeyRound className={cn(
|
||||
'w-4 h-4 shrink-0',
|
||||
pathname === '/admin/change-password' ? 'text-accent-foreground' : 'text-muted-foreground'
|
||||
)} />
|
||||
Change Password
|
||||
</Link>
|
||||
)}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5 hover:bg-muted text-foreground"
|
||||
>
|
||||
<LogOut className="w-4 h-4 shrink-0 text-muted-foreground" />
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex bg-background">
|
||||
{/* Slim webmail nav rail */}
|
||||
<nav className="w-14 bg-secondary flex flex-col items-center py-3 gap-2 border-r border-border sticky top-0 h-screen shrink-0">
|
||||
{/* Slim webmail nav rail (desktop only) */}
|
||||
<nav className="hidden md:flex w-14 bg-secondary flex-col items-center py-3 gap-2 border-r border-border sticky top-0 h-screen shrink-0">
|
||||
{logoUrl ? (
|
||||
<img src={logoUrl} alt="" className="w-7 h-7 object-contain mb-2" />
|
||||
) : (
|
||||
@@ -191,8 +297,8 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Admin Sidebar */}
|
||||
<aside className="w-60 border-r border-border bg-secondary flex flex-col sticky top-0 h-screen">
|
||||
{/* Admin Sidebar (desktop only) */}
|
||||
<aside className="hidden md:flex w-60 border-r border-border bg-secondary flex-col sticky top-0 h-screen">
|
||||
<div className="h-14 flex items-center px-4 border-b border-border shrink-0">
|
||||
{logoUrl ? (
|
||||
<img src={logoUrl} alt="" className="w-5 h-5 object-contain mr-2" />
|
||||
@@ -201,74 +307,71 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
)}
|
||||
<span className="font-semibold text-sm text-foreground">Admin Panel</span>
|
||||
</div>
|
||||
{navContent}
|
||||
</aside>
|
||||
|
||||
<div className="flex-1 overflow-y-auto py-2">
|
||||
<div className="px-2 space-y-0.5">
|
||||
{NAV_GROUPS.map((group, groupIndex) => (
|
||||
<div key={group.label}>
|
||||
{groupIndex > 0 && <div className="mx-1 my-2 border-t border-border" />}
|
||||
<div className="px-3 pt-2.5 pb-1">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{group.label}
|
||||
</span>
|
||||
</div>
|
||||
{group.items.map(({ href, label, icon: Icon }) => {
|
||||
const active = href === '/admin' ? pathname === '/admin' : pathname.startsWith(href);
|
||||
return (
|
||||
<Link
|
||||
key={href}
|
||||
href={href}
|
||||
className={cn(
|
||||
'w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
|
||||
active
|
||||
? 'bg-accent text-accent-foreground font-medium'
|
||||
: 'hover:bg-muted text-foreground'
|
||||
)}
|
||||
>
|
||||
<Icon className={cn(
|
||||
'w-4 h-4 shrink-0',
|
||||
active ? 'text-accent-foreground' : 'text-muted-foreground'
|
||||
)} />
|
||||
{label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
{/* Mobile drawer overlay */}
|
||||
{mobileNavOpen && (
|
||||
<div
|
||||
className="md:hidden fixed inset-0 z-40 bg-black/50 backdrop-blur-sm"
|
||||
onClick={() => setMobileNavOpen(false)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Mobile drawer */}
|
||||
<aside
|
||||
className={cn(
|
||||
'md:hidden fixed inset-y-0 left-0 z-50 w-72 max-w-[85vw] border-r border-border bg-secondary flex flex-col transition-transform duration-200 ease-out',
|
||||
mobileNavOpen ? 'translate-x-0' : '-translate-x-full'
|
||||
)}
|
||||
aria-label="Admin navigation"
|
||||
aria-hidden={!mobileNavOpen}
|
||||
>
|
||||
<div className="h-14 flex items-center justify-between px-3 border-b border-border shrink-0">
|
||||
<div className="flex items-center min-w-0">
|
||||
{logoUrl ? (
|
||||
<img src={logoUrl} alt="" className="w-5 h-5 object-contain mr-2" />
|
||||
) : (
|
||||
<Shield className="w-5 h-5 text-primary mr-2" />
|
||||
)}
|
||||
<span className="font-semibold text-sm text-foreground truncate">Admin Panel</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-2 py-2 border-t border-border space-y-0.5 shrink-0">
|
||||
{!isStalwartAdmin && (
|
||||
<Link
|
||||
href="/admin/change-password"
|
||||
className={cn(
|
||||
'w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
|
||||
pathname === '/admin/change-password'
|
||||
? 'bg-accent text-accent-foreground font-medium'
|
||||
: 'hover:bg-muted text-foreground'
|
||||
)}
|
||||
>
|
||||
<KeyRound className={cn(
|
||||
'w-4 h-4 shrink-0',
|
||||
pathname === '/admin/change-password' ? 'text-accent-foreground' : 'text-muted-foreground'
|
||||
)} />
|
||||
Change Password
|
||||
</Link>
|
||||
)}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5 hover:bg-muted text-foreground"
|
||||
type="button"
|
||||
onClick={() => setMobileNavOpen(false)}
|
||||
className="flex items-center justify-center w-9 h-9 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||
aria-label="Close navigation"
|
||||
>
|
||||
<LogOut className="w-4 h-4 shrink-0 text-muted-foreground" />
|
||||
Sign out
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
{navContent}
|
||||
</aside>
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 overflow-auto">
|
||||
<div className="max-w-4xl mx-auto p-6">
|
||||
<main className="flex-1 min-w-0 overflow-x-hidden">
|
||||
{/* Mobile header */}
|
||||
<div className="md:hidden sticky top-0 z-30 h-14 flex items-center gap-2 px-3 border-b border-border bg-background">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMobileNavOpen(true)}
|
||||
className="flex items-center justify-center w-9 h-9 rounded-md text-foreground hover:bg-muted transition-colors"
|
||||
aria-label="Open navigation"
|
||||
>
|
||||
<Menu className="w-5 h-5" />
|
||||
</button>
|
||||
<div className="flex items-center min-w-0">
|
||||
{logoUrl ? (
|
||||
<img src={logoUrl} alt="" className="w-5 h-5 object-contain mr-2" />
|
||||
) : (
|
||||
<Shield className="w-5 h-5 text-primary mr-2" />
|
||||
)}
|
||||
<span className="font-semibold text-sm text-foreground truncate">Admin Panel</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-4xl mx-auto p-4 md:p-6 pb-[calc(4rem+env(safe-area-inset-bottom))] md:pb-6">
|
||||
{authError ? (
|
||||
<div className="rounded-lg border border-destructive/40 bg-destructive/10 p-4 text-sm text-destructive">
|
||||
<p className="font-medium">Admin authentication failed</p>
|
||||
@@ -283,6 +386,64 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
) : null}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Mobile bottom nav (main webmail nav) */}
|
||||
<nav
|
||||
className="md:hidden fixed inset-x-0 bottom-0 z-30 flex items-center bg-background border-t border-border pb-[env(safe-area-inset-bottom)]"
|
||||
aria-label="Main navigation"
|
||||
>
|
||||
<a
|
||||
href="/"
|
||||
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] transition-colors duration-150 text-muted-foreground hover:text-foreground"
|
||||
title="Mail"
|
||||
>
|
||||
<Mail className="w-5 h-5" />
|
||||
<span className="text-[10px] font-medium leading-tight truncate max-w-full">Mail</span>
|
||||
</a>
|
||||
<a
|
||||
href="/calendar"
|
||||
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] transition-colors duration-150 text-muted-foreground hover:text-foreground"
|
||||
title="Calendar"
|
||||
>
|
||||
<Calendar className="w-5 h-5" />
|
||||
<span className="text-[10px] font-medium leading-tight truncate max-w-full">Calendar</span>
|
||||
</a>
|
||||
<a
|
||||
href="/contacts"
|
||||
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] transition-colors duration-150 text-muted-foreground hover:text-foreground"
|
||||
title="Contacts"
|
||||
>
|
||||
<BookUser className="w-5 h-5" />
|
||||
<span className="text-[10px] font-medium leading-tight truncate max-w-full">Contacts</span>
|
||||
</a>
|
||||
<a
|
||||
href="/files"
|
||||
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] transition-colors duration-150 text-muted-foreground hover:text-foreground"
|
||||
title="Files"
|
||||
>
|
||||
<HardDrive className="w-5 h-5" />
|
||||
<span className="text-[10px] font-medium leading-tight truncate max-w-full">Files</span>
|
||||
</a>
|
||||
<div
|
||||
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] text-primary"
|
||||
title="Admin"
|
||||
aria-current="page"
|
||||
>
|
||||
<div className="relative">
|
||||
<Shield className="w-5 h-5" />
|
||||
<span className="absolute -bottom-1 left-1/2 -translate-x-1/2 w-4 h-0.5 rounded-full bg-primary" />
|
||||
</div>
|
||||
<span className="text-[10px] font-medium leading-tight truncate max-w-full">Admin</span>
|
||||
</div>
|
||||
<a
|
||||
href="/settings"
|
||||
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] transition-colors duration-150 text-muted-foreground hover:text-foreground"
|
||||
title="Settings"
|
||||
>
|
||||
<Settings className="w-5 h-5" />
|
||||
<span className="text-[10px] font-medium leading-tight truncate max-w-full">Settings</span>
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+36
-8
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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'}`}>
|
||||
|
||||
@@ -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
@@ -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)}
|
||||
|
||||
@@ -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
@@ -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'}`}>
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
CheckCircle2,
|
||||
AlertTriangle,
|
||||
ShieldAlert,
|
||||
ExternalLink,
|
||||
} from 'lucide-react';
|
||||
import { SettingsSection, SettingItem } from '@/components/settings/settings-section';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
import type { UpdateStatus, UpdateSeverity } from '@/lib/version-check/types';
|
||||
|
||||
interface VersionAdminStatus {
|
||||
current: string;
|
||||
build: string;
|
||||
endpoint: string;
|
||||
defaultEndpoint: string;
|
||||
disabledByEnv: boolean;
|
||||
lastCheckedAt: string | null;
|
||||
lastSuccessAt: string | null;
|
||||
nextScheduledAt: string | null;
|
||||
status: UpdateStatus | null;
|
||||
}
|
||||
|
||||
function timeAgo(iso: string | null): string {
|
||||
if (!iso) return 'never';
|
||||
const d = Date.now() - new Date(iso).getTime();
|
||||
if (d < 0) return new Date(iso).toLocaleString();
|
||||
const m = Math.floor(d / 60000);
|
||||
if (m < 1) return 'just now';
|
||||
if (m < 60) return `${m} min ago`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 48) return `${h} hours ago`;
|
||||
return `${Math.floor(h / 24)} days ago`;
|
||||
}
|
||||
|
||||
function severityChip(severity: UpdateSeverity) {
|
||||
switch (severity) {
|
||||
case 'security':
|
||||
return {
|
||||
label: 'Security update',
|
||||
className: 'bg-red-500/10 text-red-700 dark:text-red-300 border-red-500/30',
|
||||
Icon: ShieldAlert,
|
||||
};
|
||||
case 'deprecated':
|
||||
return {
|
||||
label: 'Deprecated',
|
||||
className: 'bg-red-500/10 text-red-700 dark:text-red-300 border-red-500/30',
|
||||
Icon: ShieldAlert,
|
||||
};
|
||||
case 'normal':
|
||||
return {
|
||||
label: 'Update available',
|
||||
className: 'bg-amber-500/10 text-amber-700 dark:text-amber-300 border-amber-500/30',
|
||||
Icon: AlertTriangle,
|
||||
};
|
||||
case 'unknown':
|
||||
return {
|
||||
label: 'Unknown',
|
||||
className: 'bg-muted text-muted-foreground border-border',
|
||||
Icon: AlertTriangle,
|
||||
};
|
||||
case 'none':
|
||||
default:
|
||||
return {
|
||||
label: 'Up to date',
|
||||
className: 'bg-emerald-500/10 text-emerald-700 dark:text-emerald-300 border-emerald-500/30',
|
||||
Icon: CheckCircle2,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default function AdminVersionPage() {
|
||||
const [data, setData] = useState<VersionAdminStatus | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [checkResult, setCheckResult] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||
|
||||
async function refresh(): Promise<void> {
|
||||
setLoading(true);
|
||||
try {
|
||||
const r = await apiFetch('/api/admin/version');
|
||||
if (!r.ok) throw new Error('failed to load');
|
||||
setData((await r.json()) as VersionAdminStatus);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
useEffect(() => { void refresh(); }, []);
|
||||
|
||||
async function checkNow(): Promise<void> {
|
||||
setChecking(true);
|
||||
setCheckResult(null);
|
||||
try {
|
||||
const r = await apiFetch('/api/admin/version', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'check-now' }),
|
||||
});
|
||||
const j = (await r.json().catch(() => ({}))) as { ok?: boolean; error?: string };
|
||||
setCheckResult({
|
||||
ok: !!j.ok,
|
||||
msg: j.ok ? 'Update check completed.' : `Failed: ${j.error ?? 'unknown'}`,
|
||||
});
|
||||
await refresh();
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading || !data) {
|
||||
return (
|
||||
<div className="p-8 flex items-center gap-2 text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> loading…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const status = data.status;
|
||||
const chip = severityChip(status?.severity ?? 'none');
|
||||
const ChipIcon = chip.Icon;
|
||||
const releaseUrl = status?.url ?? null;
|
||||
const newer = status?.latest && status.latest !== data.current ? status.latest : null;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-2xl font-semibold text-foreground">Version</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Hourly check against the Bulwark version server. Severity is decided server-side and
|
||||
disable with <code>BULWARK_UPDATE_CHECK=off</code>.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={checking}
|
||||
onClick={() => void checkNow()}
|
||||
className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm"
|
||||
>
|
||||
{checking ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCw className="w-4 h-4" />}
|
||||
Check now
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{checkResult && (
|
||||
<div
|
||||
className={`text-sm rounded-md px-3 py-2 ${
|
||||
checkResult.ok
|
||||
? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300'
|
||||
: 'bg-destructive/10 text-destructive'
|
||||
}`}
|
||||
>
|
||||
{checkResult.msg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SettingsSection title="Status">
|
||||
<SettingItem label="Severity">
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-xs font-medium ${chip.className}`}
|
||||
>
|
||||
<ChipIcon className="h-3 w-3" />
|
||||
{chip.label}
|
||||
</span>
|
||||
</SettingItem>
|
||||
<SettingItem label="Running" description={data.build !== 'unknown' ? `Build ${data.build}` : undefined}>
|
||||
<span className="text-sm font-mono text-foreground">{data.current}</span>
|
||||
</SettingItem>
|
||||
{newer && (
|
||||
<SettingItem label="Latest release">
|
||||
{releaseUrl ? (
|
||||
<a
|
||||
href={releaseUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 text-sm font-mono text-foreground hover:underline"
|
||||
>
|
||||
{newer} <ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-sm font-mono text-foreground">{newer}</span>
|
||||
)}
|
||||
</SettingItem>
|
||||
)}
|
||||
{status?.advisory && (
|
||||
<SettingItem label="Advisory">
|
||||
<span className="text-sm font-mono text-red-600 dark:text-red-400">{status.advisory}</span>
|
||||
</SettingItem>
|
||||
)}
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Schedule" description="Hourly polling with ±5 minute jitter.">
|
||||
<SettingItem label="Last checked">
|
||||
<span className="text-sm text-foreground">{timeAgo(data.lastCheckedAt)}</span>
|
||||
</SettingItem>
|
||||
<SettingItem label="Last success">
|
||||
<span className="text-sm text-foreground">{timeAgo(data.lastSuccessAt)}</span>
|
||||
</SettingItem>
|
||||
<SettingItem label="Next scheduled">
|
||||
<span className="text-sm text-foreground">{timeAgo(data.nextScheduledAt)}</span>
|
||||
</SettingItem>
|
||||
{status?.checkedAt && (
|
||||
<SettingItem label="Server timestamp" description="When the server last refreshed its release list.">
|
||||
<span className="text-sm text-foreground">{new Date(status.checkedAt).toLocaleString()}</span>
|
||||
</SettingItem>
|
||||
)}
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Source">
|
||||
<SettingItem
|
||||
label="Endpoint"
|
||||
description={data.endpoint === data.defaultEndpoint ? 'Default endpoint.' : `Default: ${data.defaultEndpoint}`}
|
||||
>
|
||||
<a
|
||||
href={data.endpoint}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 text-sm text-foreground hover:underline break-all"
|
||||
>
|
||||
{data.endpoint} <ExternalLink className="w-3 h-3 shrink-0" />
|
||||
</a>
|
||||
</SettingItem>
|
||||
<SettingItem label="Disabled by env" description="Set BULWARK_UPDATE_CHECK=off to disable.">
|
||||
<span className={`text-sm font-medium ${data.disabledByEnv ? 'text-amber-600 dark:text-amber-400' : 'text-muted-foreground'}`}>
|
||||
{data.disabledByEnv ? 'Yes' : 'No'}
|
||||
</span>
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAdminAuth } from '@/lib/admin/session';
|
||||
import { logger } from '@/lib/logger';
|
||||
import {
|
||||
loadState,
|
||||
checkOnce,
|
||||
effectiveEndpoint,
|
||||
disabledByEnv,
|
||||
DEFAULT_VERSION_ENDPOINT,
|
||||
} from '@/lib/version-check';
|
||||
|
||||
/**
|
||||
* GET /api/admin/version
|
||||
* Returns the cached update status, last check times, and effective config.
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
const auth = await requireAdminAuth();
|
||||
if ('error' in auth) return auth.error;
|
||||
|
||||
const state = await loadState();
|
||||
return NextResponse.json(
|
||||
{
|
||||
current: process.env.NEXT_PUBLIC_APP_VERSION || '0.0.0',
|
||||
build: process.env.NEXT_PUBLIC_GIT_COMMIT || 'unknown',
|
||||
endpoint: effectiveEndpoint(state),
|
||||
defaultEndpoint: DEFAULT_VERSION_ENDPOINT,
|
||||
disabledByEnv: disabledByEnv(),
|
||||
lastCheckedAt: state.lastCheckedAt,
|
||||
lastSuccessAt: state.lastSuccessAt,
|
||||
nextScheduledAt: state.nextScheduledAt,
|
||||
status: state.status,
|
||||
},
|
||||
{ headers: { 'Cache-Control': 'no-store' } },
|
||||
);
|
||||
} catch (err) {
|
||||
logger.error('version admin GET error', {
|
||||
error: err instanceof Error ? err.message : 'unknown',
|
||||
});
|
||||
return NextResponse.json({ error: 'failed' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/admin/version
|
||||
* { action: 'check-now' } — force a fresh upstream fetch.
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const auth = await requireAdminAuth();
|
||||
if ('error' in auth) return auth.error;
|
||||
|
||||
const body = (await req.json().catch(() => null)) as { action?: string } | null;
|
||||
if (!body || body.action !== 'check-now') {
|
||||
return NextResponse.json({ error: 'unknown action' }, { status: 400 });
|
||||
}
|
||||
|
||||
const result = await checkOnce({ reason: 'admin-trigger' });
|
||||
return NextResponse.json(result);
|
||||
} catch (err) {
|
||||
logger.error('version admin POST error', {
|
||||
error: err instanceof Error ? err.message : 'unknown',
|
||||
});
|
||||
return NextResponse.json({ error: 'failed' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -13,12 +13,18 @@ export async function POST(request: NextRequest) {
|
||||
const cookieStore = await cookies();
|
||||
|
||||
try {
|
||||
const { code, state } = await request.json();
|
||||
const { code, state, slot: bodySlot } = await request.json();
|
||||
|
||||
if (!code || !state) {
|
||||
return NextResponse.json({ error: 'Missing code or state' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Per-account refresh-token cookie slot. Without this the route hardcoded
|
||||
// slot 0, so the "+ Add Account" flow overwrote the first account's
|
||||
// refresh-token cookie. Default to 0 for back-compat with any caller that
|
||||
// omits slot. Mirrors the validation in /api/auth/token POST.
|
||||
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : 0;
|
||||
|
||||
// Read and decrypt the pending SSO cookie
|
||||
const pendingCookie = cookieStore.get(SSO_PENDING_COOKIE)?.value;
|
||||
if (!pendingCookie) {
|
||||
@@ -58,9 +64,9 @@ export async function POST(request: NextRequest) {
|
||||
// Exchange code for tokens
|
||||
const tokens = await exchangeCodeForTokens(code, codeVerifier, redirectUri);
|
||||
|
||||
// Store refresh token
|
||||
// Store refresh token in the per-account cookie slot.
|
||||
if (tokens.refresh_token) {
|
||||
const cookieName = refreshTokenCookieName(0);
|
||||
const cookieName = refreshTokenCookieName(slot);
|
||||
cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions());
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { checkOnce, loadState } from '@/lib/version-check';
|
||||
|
||||
// Public endpoint that returns the latest cached update status. Fed by the
|
||||
// background scheduler started in instrumentation.node.ts; in production we
|
||||
// never trigger a fresh upstream fetch from this route so an unauthenticated
|
||||
// client can't use it to amplify traffic to the version server.
|
||||
//
|
||||
// In development we force a fresh fetch on every hit so changes to the
|
||||
// version server's overrides take effect on the next page reload instead of
|
||||
// requiring a dev-server restart. The 5s upstream timeout in fetchStatus
|
||||
// caps the worst-case latency added to a dev reload.
|
||||
export async function GET() {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
await checkOnce({ reason: 'dev-reload' });
|
||||
}
|
||||
|
||||
const state = await loadState();
|
||||
return NextResponse.json(
|
||||
{
|
||||
status: state.status,
|
||||
lastCheckedAt: state.lastCheckedAt,
|
||||
lastSuccessAt: state.lastSuccessAt,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -2,34 +2,43 @@
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { getPathPrefix } from "@/lib/browser-navigation";
|
||||
|
||||
export default function NotFound() {
|
||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) {
|
||||
// Don't redirect admin routes to the webmail login page
|
||||
const isAdminRoute = window.location.pathname === '/admin' || window.location.pathname.startsWith('/admin/');
|
||||
const prefix = getPathPrefix();
|
||||
// Don't redirect admin routes to the webmail login page. Admin paths
|
||||
// are mounted relative to the deployment prefix, so account for it.
|
||||
const adminBase = `${prefix}/admin`;
|
||||
const isAdminRoute = window.location.pathname === adminBase || window.location.pathname.startsWith(`${adminBase}/`);
|
||||
if (!isAdminRoute) {
|
||||
window.location.href = "/login";
|
||||
window.location.href = `${prefix}/login`;
|
||||
}
|
||||
}
|
||||
}, [isAuthenticated]);
|
||||
|
||||
if (!isAuthenticated) {
|
||||
// Allow admin routes to render the 404 without redirecting
|
||||
const isAdmin = typeof window !== 'undefined' &&
|
||||
(window.location.pathname === '/admin' || window.location.pathname.startsWith('/admin/'));
|
||||
let isAdmin = false;
|
||||
if (typeof window !== 'undefined') {
|
||||
const prefix = getPathPrefix();
|
||||
const adminBase = `${prefix}/admin`;
|
||||
isAdmin = window.location.pathname === adminBase || window.location.pathname.startsWith(`${adminBase}/`);
|
||||
}
|
||||
if (!isAdmin) return null;
|
||||
}
|
||||
|
||||
const prefix = typeof window !== 'undefined' ? getPathPrefix() : '';
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||
<div className="text-center max-w-md px-4">
|
||||
<h1 className="text-4xl font-bold text-foreground mb-2">404</h1>
|
||||
<p className="text-muted-foreground mb-6">This page could not be found.</p>
|
||||
<a
|
||||
href="/"
|
||||
href={`${prefix}/`}
|
||||
className="inline-flex items-center px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:opacity-90 transition-opacity"
|
||||
>
|
||||
Go home
|
||||
|
||||
@@ -144,6 +144,12 @@ export function CalendarSidebarPanel({
|
||||
{cal.id === BIRTHDAY_CALENDAR_ID && (
|
||||
<Cake className="w-3 h-3 text-muted-foreground flex-shrink-0" />
|
||||
)}
|
||||
{!cal.isShared && Object.keys(cal.shareWith || {}).length > 0 && (
|
||||
<Users
|
||||
className="w-3 h-3 text-muted-foreground flex-shrink-0"
|
||||
aria-label={tMgmt('share')}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -150,9 +150,8 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
|
||||
aria-label={ariaLabel}
|
||||
{...dragProps}
|
||||
className={cn(
|
||||
"w-full h-full text-left rounded px-1.5 py-0.5 text-xs overflow-hidden",
|
||||
"w-full h-full text-left rounded-r px-1.5 py-0.5 text-xs overflow-hidden",
|
||||
"hover:opacity-90 transition-opacity cursor-pointer",
|
||||
continuesBefore && "rounded-l-sm",
|
||||
continuesAfter && "rounded-r-sm",
|
||||
continuesBefore && "-ml-0.5",
|
||||
continuesAfter && "pr-2",
|
||||
@@ -182,7 +181,7 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
|
||||
{...dragProps}
|
||||
data-calendar-event
|
||||
className={cn(
|
||||
"w-full h-full text-left rounded px-1.5 py-0.5 text-xs overflow-hidden",
|
||||
"w-full h-full text-left rounded-r px-1.5 py-0.5 text-xs overflow-hidden",
|
||||
"hover:opacity-90 transition-opacity cursor-pointer",
|
||||
isSelected && "ring-2 ring-primary",
|
||||
isBeingDragged && "opacity-50",
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
getUserStatus,
|
||||
getParticipantList,
|
||||
} from "@/lib/calendar-participants";
|
||||
import { useFormatEventDate } from "@/hooks/use-format-event-date";
|
||||
|
||||
interface EventDetailPopoverProps {
|
||||
event: CalendarEvent;
|
||||
@@ -253,6 +254,8 @@ export function EventDetailPopover({
|
||||
|
||||
const hasParticipants = participants.length > 0;
|
||||
|
||||
const formatEventDate = useFormatEventDate();
|
||||
|
||||
const popover = (
|
||||
<div
|
||||
ref={popoverRef}
|
||||
@@ -329,7 +332,7 @@ export function EventDetailPopover({
|
||||
<Clock className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||
<div className="text-sm">
|
||||
<span className="font-medium text-foreground">
|
||||
{format(startDate, "EEE, MMM d, yyyy")}
|
||||
{formatEventDate(startDate)}
|
||||
</span>
|
||||
{event.showWithoutTime ? (
|
||||
<span className="text-muted-foreground ml-1.5">{t("events.all_day")}</span>
|
||||
|
||||
+155
-106
@@ -21,6 +21,9 @@ import {
|
||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { generateUUID } from "@/lib/utils";
|
||||
import { useFormatEventDate } from "@/hooks/use-format-event-date";
|
||||
import { calendarHooks } from "@/lib/plugin-hooks";
|
||||
import type { ConflictWarning } from "@/lib/plugin-types";
|
||||
|
||||
export interface PendingEventPreview {
|
||||
start: Date;
|
||||
@@ -130,6 +133,7 @@ export function EventModal({
|
||||
const timeFormat = useSettingsStore((s) => s.timeFormat);
|
||||
const timeDisplayFmt = timeFormat === "12h" ? "h:mm a" : "HH:mm";
|
||||
const isEdit = !!event;
|
||||
const formatEventDate = useFormatEventDate();
|
||||
const [mode, setMode] = useState<"view" | "edit">(isEdit ? "view" : "edit");
|
||||
|
||||
const userIsOrganizer = useMemo(() => {
|
||||
@@ -240,6 +244,31 @@ export function EventModal({
|
||||
const [sendInvitations, setSendInvitations] = useState(true);
|
||||
const participantInputRef = useRef<ParticipantInputHandle>(null);
|
||||
|
||||
// Plugin transform: collect conflict warnings for the current event form.
|
||||
// Re-runs (debounced) whenever fields that affect scheduling change.
|
||||
const [pluginConflictWarnings, setPluginConflictWarnings] = useState<ConflictWarning[]>([]);
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const t = setTimeout(async () => {
|
||||
const startStr = allDay ? `${startDate}T00:00:00` : `${startDate}T${startTime}:00`;
|
||||
const endStr = allDay ? `${endDate}T23:59:59` : `${endDate}T${endTime}:00`;
|
||||
const warnings = await calendarHooks.onCheckEventConflicts.transform([] as ConflictWarning[], {
|
||||
event: {
|
||||
title,
|
||||
description,
|
||||
start: startStr,
|
||||
end: endStr,
|
||||
isAllDay: allDay,
|
||||
location,
|
||||
virtualLocation,
|
||||
calendarId,
|
||||
},
|
||||
});
|
||||
if (!cancelled) setPluginConflictWarnings(warnings);
|
||||
}, 250);
|
||||
return () => { cancelled = true; clearTimeout(t); };
|
||||
}, [title, description, startDate, startTime, endDate, endTime, allDay, location, virtualLocation, calendarId]);
|
||||
|
||||
// Report live preview to parent for grid outline
|
||||
useEffect(() => {
|
||||
if (!onPreviewChange || isEdit) return;
|
||||
@@ -495,14 +524,14 @@ export function EventModal({
|
||||
|
||||
return (
|
||||
<div ref={modalRef} role="dialog" aria-modal={isMobile || undefined} aria-label={event.title || t("events.no_title")} className={isMobile ? "fixed inset-0 z-50 flex flex-col bg-background" : "flex flex-col h-full bg-background"}>
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0">
|
||||
<h2 className="text-lg font-semibold truncate">{event.title || t("events.no_title")}</h2>
|
||||
<button onClick={onClose} className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 text-muted-foreground hover:text-foreground" aria-label={t("form.cancel")}>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0">
|
||||
<h2 className="text-lg font-semibold truncate">{event.title || t("events.no_title")}</h2>
|
||||
<button onClick={onClose} className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 text-muted-foreground hover:text-foreground" aria-label={t("form.cancel")}>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="px-6 py-4 space-y-3">
|
||||
<div className="flex items-start gap-3 rounded-lg border border-blue-200 dark:border-blue-800 bg-blue-50 dark:bg-blue-950/50 px-4 py-3">
|
||||
<CalendarDays className="w-5 h-5 text-blue-600 dark:text-blue-400 mt-0.5 flex-shrink-0" />
|
||||
@@ -517,7 +546,7 @@ export function EventModal({
|
||||
</div>
|
||||
|
||||
<div className="text-sm">
|
||||
<span className="font-medium">{format(startD, "EEE, MMM d, yyyy")}</span>
|
||||
<span className="font-medium">{formatEventDate(startD)}</span>
|
||||
{!event.showWithoutTime && (
|
||||
<span className="text-muted-foreground ml-2">
|
||||
{format(startD, timeDisplayFmt)} – {format(endD, timeDisplayFmt)}
|
||||
@@ -550,48 +579,48 @@ export function EventModal({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4 border-t border-border flex-shrink-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">{t("participants.rsvp_label")}</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={userCurrentStatus === "accepted" ? "default" : "outline"}
|
||||
onClick={() => handleRsvp("accepted")}
|
||||
className={userCurrentStatus === "accepted"
|
||||
? "bg-success hover:bg-success/80 text-success-foreground"
|
||||
: "text-success border-success/30 hover:bg-success/10"}
|
||||
>
|
||||
{userCurrentStatus === "accepted" && <Check className="w-4 h-4 mr-1" />}
|
||||
{t("participants.accepted")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={userCurrentStatus === "tentative" ? "default" : "outline"}
|
||||
onClick={() => handleRsvp("tentative")}
|
||||
className={userCurrentStatus === "tentative"
|
||||
? "bg-warning hover:bg-warning/80 text-warning-foreground"
|
||||
: "border border-warning/30 text-warning hover:bg-warning/10"}
|
||||
>
|
||||
{userCurrentStatus === "tentative" && <Check className="w-4 h-4 mr-1" />}
|
||||
{t("participants.tentative")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={userCurrentStatus === "declined" ? "default" : "ghost"}
|
||||
onClick={() => handleRsvp("declined")}
|
||||
className={userCurrentStatus === "declined"
|
||||
? "bg-destructive hover:bg-destructive/80 text-destructive-foreground"
|
||||
: "text-destructive hover:bg-destructive/10"}
|
||||
>
|
||||
{userCurrentStatus === "declined" && <Check className="w-4 h-4 mr-1" />}
|
||||
{t("participants.declined")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="px-6 py-4 border-t border-border flex-shrink-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">{t("participants.rsvp_label")}</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={userCurrentStatus === "accepted" ? "default" : "outline"}
|
||||
onClick={() => handleRsvp("accepted")}
|
||||
className={userCurrentStatus === "accepted"
|
||||
? "bg-success hover:bg-success/80 text-success-foreground"
|
||||
: "text-success border-success/30 hover:bg-success/10"}
|
||||
>
|
||||
{userCurrentStatus === "accepted" && <Check className="w-4 h-4 mr-1" />}
|
||||
{t("participants.accepted")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={userCurrentStatus === "tentative" ? "default" : "outline"}
|
||||
onClick={() => handleRsvp("tentative")}
|
||||
className={userCurrentStatus === "tentative"
|
||||
? "bg-warning hover:bg-warning/80 text-warning-foreground"
|
||||
: "border border-warning/30 text-warning hover:bg-warning/10"}
|
||||
>
|
||||
{userCurrentStatus === "tentative" && <Check className="w-4 h-4 mr-1" />}
|
||||
{t("participants.tentative")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={userCurrentStatus === "declined" ? "default" : "ghost"}
|
||||
onClick={() => handleRsvp("declined")}
|
||||
className={userCurrentStatus === "declined"
|
||||
? "bg-destructive hover:bg-destructive/80 text-destructive-foreground"
|
||||
: "text-destructive hover:bg-destructive/10"}
|
||||
>
|
||||
{userCurrentStatus === "declined" && <Check className="w-4 h-4 mr-1" />}
|
||||
{t("participants.declined")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -638,7 +667,7 @@ export function EventModal({
|
||||
<Clock className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||
<div className="text-sm">
|
||||
<span className="font-medium text-foreground">
|
||||
{format(startD, "EEE, MMM d, yyyy")}
|
||||
{formatEventDate(startD)}
|
||||
</span>
|
||||
{event.showWithoutTime ? (
|
||||
<span className="text-muted-foreground ml-1.5">{t("events.all_day")}</span>
|
||||
@@ -767,16 +796,16 @@ export function EventModal({
|
||||
|
||||
return (
|
||||
<div ref={modalRef} role="dialog" aria-modal={isMobile || undefined} aria-label={isEdit ? t("events.edit") : t("events.create")} data-tour="event-modal" className={isMobile ? "fixed inset-0 z-50 flex flex-col bg-background" : "flex flex-col h-full bg-background"}>
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0">
|
||||
<h2 className="text-lg font-semibold">
|
||||
{isEdit ? t("events.edit") : t("events.create")}
|
||||
</h2>
|
||||
<button onClick={onClose} className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 text-muted-foreground hover:text-foreground" aria-label={t("form.cancel")}>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0">
|
||||
<h2 className="text-lg font-semibold">
|
||||
{isEdit ? t("events.edit") : t("events.create")}
|
||||
</h2>
|
||||
<button onClick={onClose} className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 text-muted-foreground hover:text-foreground" aria-label={t("form.cancel")}>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="px-6 py-4 space-y-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1 block">{t("form.title")}</label>
|
||||
@@ -921,6 +950,26 @@ export function EventModal({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{pluginConflictWarnings.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
{pluginConflictWarnings.map(w => (
|
||||
<div
|
||||
key={w.key}
|
||||
className={
|
||||
w.severity === 'error'
|
||||
? 'text-sm rounded-md border border-destructive/50 bg-destructive/10 text-destructive px-3 py-2'
|
||||
: w.severity === 'info'
|
||||
? 'text-sm rounded-md border border-border bg-muted/40 text-muted-foreground px-3 py-2'
|
||||
: 'text-sm rounded-md border border-yellow-500/50 bg-yellow-500/10 text-yellow-700 dark:text-yellow-300 px-3 py-2'
|
||||
}
|
||||
title={w.message}
|
||||
>
|
||||
{w.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{calendars.length > 1 && (
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1 block">{t("form.calendar_select")}</label>
|
||||
@@ -986,69 +1035,69 @@ export function EventModal({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between px-6 py-4 border-t border-border flex-shrink-0">
|
||||
<div className="flex items-center gap-1">
|
||||
{isEdit && onDelete && (
|
||||
showDeleteConfirm ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div>
|
||||
<span className="text-sm text-red-600 dark:text-red-400">
|
||||
{t("form.delete_confirm")}
|
||||
</span>
|
||||
{hasParticipants && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{t("participants.cancel_notification")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => { onDelete(event!.id, hasParticipants || undefined); onClose(); }}
|
||||
className="text-red-600 dark:text-red-400 border-red-300 dark:border-red-700"
|
||||
>
|
||||
{t("events.delete")}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => setShowDeleteConfirm(false)}>
|
||||
{t("form.cancel")}
|
||||
</Button>
|
||||
<div className="flex items-center justify-between px-6 py-4 border-t border-border flex-shrink-0">
|
||||
<div className="flex items-center gap-1">
|
||||
{isEdit && onDelete && (
|
||||
showDeleteConfirm ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div>
|
||||
<span className="text-sm text-red-600 dark:text-red-400">
|
||||
{t("form.delete_confirm")}
|
||||
</span>
|
||||
{hasParticipants && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{t("participants.cancel_notification")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
className="text-red-600 dark:text-red-400"
|
||||
onClick={() => { onDelete(event!.id, hasParticipants || undefined); onClose(); }}
|
||||
className="text-red-600 dark:text-red-400 border-red-300 dark:border-red-700"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-1" />
|
||||
{t("events.delete")}
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
{isEdit && onDuplicate && !showDeleteConfirm && (
|
||||
<Button variant="ghost" size="sm" onClick={() => setShowDeleteConfirm(false)}>
|
||||
{t("form.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleDuplicate}
|
||||
aria-label={t("events.duplicate")}
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
className="text-red-600 dark:text-red-400"
|
||||
>
|
||||
<Copy className="w-4 h-4 mr-1" />
|
||||
{t("events.duplicate")}
|
||||
<Trash2 className="w-4 h-4 mr-1" />
|
||||
{t("events.delete")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={isEdit ? () => setMode("view") : onClose}>
|
||||
{t("form.cancel")}
|
||||
)
|
||||
)}
|
||||
{isEdit && onDuplicate && !showDeleteConfirm && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleDuplicate}
|
||||
aria-label={t("events.duplicate")}
|
||||
>
|
||||
<Copy className="w-4 h-4 mr-1" />
|
||||
{t("events.duplicate")}
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={!title.trim() || isSaving}>
|
||||
{t("form.save")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={isEdit ? () => setMode("view") : onClose}>
|
||||
{t("form.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={!title.trim() || isSaving}>
|
||||
{t("form.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -685,7 +685,13 @@ function AddressBookItem({
|
||||
>
|
||||
<Book className="w-4 h-4 flex-shrink-0" />
|
||||
<span className="truncate">{book.name}</span>
|
||||
<span className="ml-auto text-xs text-muted-foreground tabular-nums">
|
||||
{!book.isShared && Object.keys(book.shareWith || {}).length > 0 && (
|
||||
<Users className="w-3 h-3 text-muted-foreground flex-shrink-0 ml-auto" />
|
||||
)}
|
||||
<span className={cn(
|
||||
"text-xs text-muted-foreground tabular-nums",
|
||||
!(!book.isShared && Object.keys(book.shareWith || {}).length > 0) && "ml-auto"
|
||||
)}>
|
||||
{contactCount}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -10,6 +10,8 @@ import { cn, formatFileSize, formatDateTime, generateUUID } from "@/lib/utils";
|
||||
import { debug } from "@/lib/debug";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { sanitizeEmailHtml } from "@/lib/email-sanitization";
|
||||
import { emailHooks, contactHooks } from "@/lib/plugin-hooks";
|
||||
import type { OutgoingEmail, RecipientSuggestion } from "@/lib/plugin-types";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useIdentityStore } from "@/stores/identity-store";
|
||||
import { useAccountStore } from "@/stores/account-store";
|
||||
@@ -31,6 +33,7 @@ import { TemplateForm } from "@/components/templates/template-form";
|
||||
import type { EmailTemplate } from "@/lib/template-types";
|
||||
import { appendPlainTextSignature, getPlainTextSignature } from "@/lib/signature-utils";
|
||||
import { findReplyIdentityId } from "@/lib/reply-identity";
|
||||
import { computeReplyThreadingHeaders } from "@/lib/email-threading";
|
||||
import { RichTextEditor } from "@/components/email/rich-text-editor";
|
||||
|
||||
/** Strip HTML tags and decode entities to get a plain-text version */
|
||||
@@ -67,6 +70,8 @@ interface EmailComposerProps {
|
||||
fromName?: string;
|
||||
identityId?: string;
|
||||
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>;
|
||||
inReplyTo?: string[];
|
||||
references?: string[];
|
||||
}) => void | Promise<void>;
|
||||
onClose?: () => void;
|
||||
onDiscardDraft?: (draftId: string) => void;
|
||||
@@ -87,6 +92,11 @@ interface EmailComposerProps {
|
||||
receivedAt?: string;
|
||||
accountId?: string;
|
||||
attachments?: Array<{ blobId: string; name?: string; type: string; size: number; cid?: string; disposition?: string }>;
|
||||
// Threading: parent's Message-ID and References, used to set RFC 5322
|
||||
// In-Reply-To and References on outgoing replies. See #234.
|
||||
messageId?: string;
|
||||
inReplyTo?: string[];
|
||||
references?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
@@ -116,6 +126,7 @@ export function EmailComposer({
|
||||
const tCommon = useTranslations('common');
|
||||
const timeFormat = useSettingsStore((state) => state.timeFormat);
|
||||
const plainTextMode = useSettingsStore((state) => state.plainTextMode);
|
||||
const subAddressDelimiter = useSettingsStore((state) => state.subAddressDelimiter);
|
||||
const autoSelectReplyIdentity = useSettingsStore((state) => state.autoSelectReplyIdentity);
|
||||
const attachmentReminderEnabled = useSettingsStore((state) => state.attachmentReminderEnabled);
|
||||
const attachmentReminderKeywords = useSettingsStore((state) => state.attachmentReminderKeywords);
|
||||
@@ -317,6 +328,9 @@ export function EmailComposer({
|
||||
? `<div>${getPlainTextSignature(currentIdentity).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>')}</div>`
|
||||
: '';
|
||||
const getAutocomplete = useContactStore((s) => s.getAutocomplete);
|
||||
const addToTrustedSendersBook = useContactStore((s) => s.addToTrustedSendersBook);
|
||||
const addTrustedSender = useSettingsStore((s) => s.addTrustedSender);
|
||||
const trustedSendersAddressBook = useSettingsStore((s) => s.trustedSendersAddressBook);
|
||||
const addTemplate = useTemplateStore((s) => s.addTemplate);
|
||||
const sendRawEmail = useEmailStore((s) => s.sendRawEmail);
|
||||
const smimeStore = useSmimeStore();
|
||||
@@ -437,10 +451,13 @@ export function EmailComposer({
|
||||
return;
|
||||
}
|
||||
|
||||
autocompleteTimeoutRef.current = setTimeout(() => {
|
||||
const results = getAutocomplete(lastPart);
|
||||
setAutocompleteResults(results);
|
||||
setActiveAutoField(results.length > 0 ? field : null);
|
||||
autocompleteTimeoutRef.current = setTimeout(async () => {
|
||||
const localResults = getAutocomplete(lastPart);
|
||||
// Let plugins contribute extra suggestions (Slack handles, GitHub, CRM, …).
|
||||
const initial: RecipientSuggestion[] = localResults.map(r => ({ name: r.name, email: r.email }));
|
||||
const merged = await contactHooks.onProvideRecipientSuggestions.transform(initial, { query: lastPart });
|
||||
setAutocompleteResults(merged.map(s => ({ name: s.name, email: s.email })));
|
||||
setActiveAutoField(merged.length > 0 ? field : null);
|
||||
setAutoSelectedIndex(-1);
|
||||
}, 200);
|
||||
}, [getAutocomplete]);
|
||||
@@ -550,6 +567,19 @@ export function EmailComposer({
|
||||
const addFiles = useCallback(async (files: File[]) => {
|
||||
if (!client || files.length === 0) return;
|
||||
|
||||
// Let plugins veto each upload before it's queued.
|
||||
const allowedFiles: File[] = [];
|
||||
for (const file of files) {
|
||||
const ok = await emailHooks.onBeforeAttachmentUpload.intercept({
|
||||
name: file.name,
|
||||
type: file.type || 'application/octet-stream',
|
||||
size: file.size,
|
||||
});
|
||||
if (ok) allowedFiles.push(file);
|
||||
}
|
||||
if (allowedFiles.length === 0) return;
|
||||
files = allowedFiles;
|
||||
|
||||
const newAttachments: ComposerAttachment[] = files.map(file => {
|
||||
const controller = new AbortController();
|
||||
return {
|
||||
@@ -578,6 +608,12 @@ export function EmailComposer({
|
||||
: att
|
||||
)
|
||||
);
|
||||
emailHooks.onAfterAttachmentUpload.emit({
|
||||
name: file.name,
|
||||
type: file.type || 'application/octet-stream',
|
||||
size: file.size,
|
||||
blobId,
|
||||
});
|
||||
} catch (error) {
|
||||
if (controller?.signal.aborted) continue;
|
||||
debug.error(`Failed to upload ${file.name}:`, error);
|
||||
@@ -721,7 +757,7 @@ export function EmailComposer({
|
||||
// Generate sub-addressed email if tag is set
|
||||
const fromEmail = currentIdentity?.email
|
||||
? subAddressTag
|
||||
? generateSubAddress(currentIdentity.email, subAddressTag)
|
||||
? generateSubAddress(currentIdentity.email, subAddressTag, subAddressDelimiter)
|
||||
: currentIdentity.email
|
||||
: undefined;
|
||||
|
||||
@@ -736,7 +772,8 @@ export function EmailComposer({
|
||||
fromEmail,
|
||||
draftId || undefined,
|
||||
uploadedAttachments,
|
||||
currentIdentity?.name || undefined
|
||||
currentIdentity?.name || undefined,
|
||||
plainTextMode ? undefined : body
|
||||
);
|
||||
|
||||
setDraftId(savedDraftId);
|
||||
@@ -772,6 +809,19 @@ export function EmailComposer({
|
||||
|
||||
// Set new timeout for auto-save (2 seconds after last change)
|
||||
saveTimeoutRef.current = setTimeout(() => {
|
||||
// Plugin observers (AI assist, grammar, …) get a debounced snapshot here.
|
||||
emailHooks.onDraftChange.emit({
|
||||
to: to.split(',').map(s => s.trim()).filter(Boolean),
|
||||
cc: cc.split(',').map(s => s.trim()).filter(Boolean),
|
||||
bcc: bcc.split(',').map(s => s.trim()).filter(Boolean),
|
||||
subject,
|
||||
htmlBody: plainTextMode ? '' : body,
|
||||
textBody: plainTextMode ? body : htmlToPlainText(body),
|
||||
identityId: selectedIdentityId || '',
|
||||
attachments: attachments
|
||||
.filter(a => a.blobId && !a.uploading && !a.error)
|
||||
.map(a => ({ name: a.name, type: a.type || 'application/octet-stream', size: a.size })),
|
||||
});
|
||||
saveDraft();
|
||||
}, 2000);
|
||||
|
||||
@@ -812,19 +862,27 @@ export function EmailComposer({
|
||||
attachments: Array<{ blobId: string; name: string; type: string; size: number; disposition: 'inline'; cid: string }>;
|
||||
} => {
|
||||
const known = inlineImagesRef.current;
|
||||
if (known.length === 0) return { html, attachments: [] };
|
||||
|
||||
const doc = new DOMParser().parseFromString(`<body>${html}</body>`, 'text/html');
|
||||
const used = new Map<string, typeof known[number]>();
|
||||
|
||||
doc.querySelectorAll('img[data-cid]').forEach((img) => {
|
||||
const cid = img.getAttribute('data-cid');
|
||||
if (!cid) return;
|
||||
const entry = known.find((e) => e.cid === cid);
|
||||
if (!entry) return;
|
||||
img.setAttribute('src', `cid:${cid}`);
|
||||
img.removeAttribute('data-cid');
|
||||
used.set(cid, entry);
|
||||
if (known.length > 0) {
|
||||
doc.querySelectorAll('img[data-cid]').forEach((img) => {
|
||||
const cid = img.getAttribute('data-cid');
|
||||
if (!cid) return;
|
||||
const entry = known.find((e) => e.cid === cid);
|
||||
if (!entry) return;
|
||||
img.setAttribute('src', `cid:${cid}`);
|
||||
img.removeAttribute('data-cid');
|
||||
used.set(cid, entry);
|
||||
});
|
||||
}
|
||||
|
||||
// Recipient mail clients apply default <p> margins inside table cells,
|
||||
// inflating row height. Tiptap wraps cell text in <p>, so force margin:0
|
||||
// to match the composer's tight rows.
|
||||
doc.querySelectorAll('td > p, th > p').forEach((p) => {
|
||||
const existing = p.getAttribute('style') || '';
|
||||
p.setAttribute('style', `margin:0;${existing}`);
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -889,7 +947,7 @@ export function EmailComposer({
|
||||
|
||||
const fromEmail = currentIdentity?.email
|
||||
? subAddressTag
|
||||
? generateSubAddress(currentIdentity.email, subAddressTag)
|
||||
? generateSubAddress(currentIdentity.email, subAddressTag, subAddressDelimiter)
|
||||
: currentIdentity.email
|
||||
: undefined;
|
||||
|
||||
@@ -905,6 +963,11 @@ export function EmailComposer({
|
||||
return '';
|
||||
};
|
||||
|
||||
// RFC 5322 §3.6.4 threading — only continues the chain on a reply, not a forward.
|
||||
const threadingHeaders = (mode === 'reply' || mode === 'replyAll')
|
||||
? computeReplyThreadingHeaders(replyTo)
|
||||
: null;
|
||||
|
||||
// In plain text mode, send text/plain only (no HTML body)
|
||||
const finalBody = plainTextMode
|
||||
? appendPlainTextSignature(body, currentIdentity)
|
||||
@@ -968,12 +1031,22 @@ export function EmailComposer({
|
||||
}
|
||||
|
||||
// 4. Build canonical MIME
|
||||
// mime-builder takes inReplyTo as a single ref-form msg-id (with brackets);
|
||||
// references stays an array. threadingHeaders contains bare msg-ids.
|
||||
const mimeInReplyTo = threadingHeaders?.inReplyTo[0]
|
||||
? `<${threadingHeaders.inReplyTo[0]}>`
|
||||
: undefined;
|
||||
const mimeReferences = threadingHeaders?.references.length
|
||||
? threadingHeaders.references.map(id => `<${id}>`)
|
||||
: undefined;
|
||||
const mimeBytes = buildMimeMessage({
|
||||
from: { name: currentIdentity.name || undefined, email: fromEmail || currentIdentity.email },
|
||||
to: toAddresses.map(e => ({ email: e })),
|
||||
cc: ccAddresses.length > 0 ? ccAddresses.map(e => ({ email: e })) : undefined,
|
||||
bcc: bccAddresses.length > 0 ? bccAddresses.map(e => ({ email: e })) : undefined,
|
||||
subject,
|
||||
inReplyTo: mimeInReplyTo,
|
||||
references: mimeReferences,
|
||||
textBody: finalBody,
|
||||
htmlBody: finalHtmlBody,
|
||||
attachments: mimeAttachments.length > 0 ? mimeAttachments : undefined,
|
||||
@@ -986,6 +1059,8 @@ export function EmailComposer({
|
||||
to: toAddresses.map(e => ({ email: e })),
|
||||
cc: ccAddresses.length > 0 ? ccAddresses.map(e => ({ email: e })) : undefined,
|
||||
subject,
|
||||
inReplyTo: mimeInReplyTo,
|
||||
references: mimeReferences,
|
||||
};
|
||||
|
||||
// 5. Sign if enabled
|
||||
@@ -1030,19 +1105,48 @@ export function EmailComposer({
|
||||
.map(att => ({ blobId: att.blobId!, name: att.name, type: att.type || 'application/octet-stream', size: att.size }));
|
||||
uploadedAttachments.push(...inlineAttachments);
|
||||
|
||||
await onSend?.({
|
||||
// Let plugins (signatures, link-rewriting, encryption, AI rewrite, …)
|
||||
// transform the outgoing message immediately before submission.
|
||||
const transformInput: OutgoingEmail = {
|
||||
to: toAddresses,
|
||||
cc: ccAddresses,
|
||||
bcc: bccAddresses,
|
||||
subject,
|
||||
body: finalBody,
|
||||
htmlBody: finalHtmlBody,
|
||||
htmlBody: finalHtmlBody || '',
|
||||
textBody: finalBody,
|
||||
identityId: currentIdentity?.id || '',
|
||||
attachments: uploadedAttachments.map(a => ({ name: a.name, type: a.type, size: a.size })),
|
||||
inReplyTo: threadingHeaders?.inReplyTo?.[0],
|
||||
};
|
||||
const outgoing = await emailHooks.onTransformOutgoingEmail.transform(transformInput);
|
||||
|
||||
await onSend?.({
|
||||
to: outgoing.to,
|
||||
cc: outgoing.cc,
|
||||
bcc: outgoing.bcc,
|
||||
subject: outgoing.subject,
|
||||
body: outgoing.textBody,
|
||||
htmlBody: outgoing.htmlBody || undefined,
|
||||
draftId: finalDraftId || undefined,
|
||||
fromEmail,
|
||||
fromName: currentIdentity?.name || undefined,
|
||||
identityId: currentIdentity?.id,
|
||||
identityId: outgoing.identityId || currentIdentity?.id,
|
||||
attachments: uploadedAttachments.length > 0 ? uploadedAttachments : undefined,
|
||||
inReplyTo: threadingHeaders?.inReplyTo,
|
||||
references: threadingHeaders?.references,
|
||||
});
|
||||
|
||||
if (mode === 'reply' || mode === 'replyAll') {
|
||||
for (const recipient of [...outgoing.to, ...outgoing.cc].filter(Boolean)) {
|
||||
if (trustedSendersAddressBook && client) {
|
||||
addToTrustedSendersBook(client, recipient).catch(err => {
|
||||
debug.error('Failed to add trusted sender to address book:', err);
|
||||
});
|
||||
} else {
|
||||
addTrustedSender(recipient);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setTo("");
|
||||
@@ -1179,7 +1283,7 @@ export function EmailComposer({
|
||||
>
|
||||
{identities.map((identity) => {
|
||||
const displayEmail = subAddressTag
|
||||
? generateSubAddress(identity.email, subAddressTag)
|
||||
? generateSubAddress(identity.email, subAddressTag, subAddressDelimiter)
|
||||
: identity.email;
|
||||
return (
|
||||
<option key={identity.id} value={identity.id}>
|
||||
@@ -1192,7 +1296,7 @@ export function EmailComposer({
|
||||
<span className="text-sm text-foreground flex-1 truncate">
|
||||
{subAddressTag ? (
|
||||
<span className="font-mono">
|
||||
{generateSubAddress(primaryIdentity?.email || '', subAddressTag)}
|
||||
{generateSubAddress(primaryIdentity?.email || '', subAddressTag, subAddressDelimiter)}
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
+816
-546
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { usePolicyStore } from "@/stores/policy-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useAccountStore } from "@/stores/account-store";
|
||||
import { useUpdateStore, selectHasUpdate } from "@/stores/update-store";
|
||||
import { getActiveAccountSlotHeaders } from "@/lib/auth/active-account-slot";
|
||||
import { getInitials, MAX_ACCOUNTS } from "@/lib/account-utils";
|
||||
import { cn, formatFileSize } from "@/lib/utils";
|
||||
@@ -175,6 +176,11 @@ export function NavigationRail({
|
||||
const visibleSidebarApps = sidebarAppsEnabled ? sidebarApps : [];
|
||||
const inboxUnread = mailboxes.find(m => m.role === "inbox")?.unreadEmails || 0;
|
||||
const [isStalwartAdmin, setIsStalwartAdmin] = useState(false);
|
||||
const hasUpdate = useUpdateStore(selectHasUpdate);
|
||||
const updateSeverity = useUpdateStore((s) => s.status?.severity);
|
||||
const startUpdatePolling = useUpdateStore((s) => s.startPolling);
|
||||
useEffect(() => { startUpdatePolling(); }, [startUpdatePolling]);
|
||||
const updateImportant = updateSeverity === 'security' || updateSeverity === 'deprecated';
|
||||
|
||||
// Account list for rail
|
||||
const accounts = useAccountStore((s) => s.accounts);
|
||||
@@ -345,7 +351,18 @@ export function NavigationRail({
|
||||
"text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<Shield className="w-5 h-5" />
|
||||
<span className="relative">
|
||||
<Shield className="w-5 h-5" />
|
||||
{hasUpdate && (
|
||||
<span
|
||||
className={cn(
|
||||
"absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full ring-2 ring-background",
|
||||
updateImportant ? "bg-red-500" : "bg-amber-500",
|
||||
)}
|
||||
aria-label={updateImportant ? "Important update available" : "Update available"}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-[10px] font-medium leading-tight truncate max-w-full">{t("admin") || "Admin"}</span>
|
||||
</a>
|
||||
)}
|
||||
@@ -400,7 +417,7 @@ export function NavigationRail({
|
||||
|
||||
<nav
|
||||
className={cn(
|
||||
"flex flex-col",
|
||||
"flex flex-col flex-1 min-h-0 overflow-y-auto scroll-hidden",
|
||||
collapsed ? "items-center gap-1 py-3 px-1" : "gap-0.5 py-2 px-2",
|
||||
)}
|
||||
role="navigation"
|
||||
@@ -515,10 +532,19 @@ export function NavigationRail({
|
||||
{isStalwartAdmin && (
|
||||
<a
|
||||
href="/admin"
|
||||
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted relative"
|
||||
title={t("admin") || "Admin"}
|
||||
>
|
||||
<Shield className="w-[18px] h-[18px]" />
|
||||
{hasUpdate && (
|
||||
<span
|
||||
className={cn(
|
||||
"absolute top-2 right-2 w-2 h-2 rounded-full ring-2 ring-background",
|
||||
updateImportant ? "bg-red-500" : "bg-amber-500",
|
||||
)}
|
||||
aria-label={updateImportant ? "Important update available" : "Update available"}
|
||||
/>
|
||||
)}
|
||||
</a>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1028,6 +1028,7 @@ export function Sidebar({
|
||||
isOpen={mailboxContextMenu.isOpen}
|
||||
onClose={closeMailboxContextMenu}
|
||||
menuRef={mailboxMenuRef}
|
||||
mailboxes={mailboxes}
|
||||
onMarkFolderRead={onMarkFolderRead}
|
||||
onMarkFolderTreeRead={onMarkFolderTreeRead}
|
||||
onMarkAllFoldersRead={onMarkAllFoldersRead}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
})
|
||||
|
||||
@@ -1,18 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef } from 'react';
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { useConfig } from '@/hooks/use-config';
|
||||
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { usePolicyStore } from '@/stores/policy-store';
|
||||
import { useUpdateStore } from '@/stores/update-store';
|
||||
import { ExternalLink } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { SpamSiegeGame } from './spam-siege-game';
|
||||
|
||||
const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "0.0.0";
|
||||
const GIT_COMMIT = process.env.NEXT_PUBLIC_GIT_COMMIT || "unknown";
|
||||
|
||||
function VersionUpdateTag() {
|
||||
const status = useUpdateStore((s) => s.status);
|
||||
const startPolling = useUpdateStore((s) => s.startPolling);
|
||||
|
||||
useEffect(() => {
|
||||
startPolling();
|
||||
}, [startPolling]);
|
||||
|
||||
if (!status?.updateAvailable) return null;
|
||||
if (status.severity === 'unknown' || status.severity === 'none') return null;
|
||||
|
||||
const important = status.severity === 'security' || status.severity === 'deprecated';
|
||||
const label =
|
||||
status.severity === 'security' ? 'security'
|
||||
: status.severity === 'deprecated' ? 'deprecated'
|
||||
: status.latest ?? 'update';
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"ml-2 inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium align-middle",
|
||||
important
|
||||
? "bg-red-500/15 text-red-700 dark:text-red-300"
|
||||
: "bg-amber-500/15 text-amber-700 dark:text-amber-300",
|
||||
)}
|
||||
>
|
||||
{important ? label : `update: ${label}`}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function AboutDataSettings() {
|
||||
const t = useTranslations('settings.advanced');
|
||||
const tCommon = useTranslations('common');
|
||||
@@ -106,6 +139,7 @@ export function AboutDataSettings() {
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground group-hover/about:translate-x-0.5 group-active/about:translate-y-px transition-transform">
|
||||
v{APP_VERSION} <span className="text-muted-foreground/60">({GIT_COMMIT})</span>
|
||||
<VersionUpdateTag />
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@@ -139,6 +139,19 @@ export function AddressBookManagementSettings() {
|
||||
{t("default")}
|
||||
</span>
|
||||
)}
|
||||
{(() => {
|
||||
const shareCount = Object.keys(book.shareWith || {}).length;
|
||||
if (shareCount === 0 || book.isShared) return null;
|
||||
return (
|
||||
<span
|
||||
className="flex items-center gap-1 text-xs text-muted-foreground bg-muted px-2 py-0.5 rounded-full"
|
||||
title={t("share")}
|
||||
>
|
||||
<Users className="w-3 h-3" />
|
||||
{shareCount}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{canRename && (
|
||||
<button
|
||||
|
||||
@@ -516,6 +516,20 @@ export function CalendarManagementSettings() {
|
||||
</span>
|
||||
)}
|
||||
|
||||
{(() => {
|
||||
const shareCount = Object.keys(cal.shareWith || {}).length;
|
||||
if (shareCount === 0 || cal.isShared) return null;
|
||||
return (
|
||||
<span
|
||||
className="flex items-center gap-1 text-xs text-muted-foreground bg-muted px-2 py-0.5 rounded-full"
|
||||
title={t('share')}
|
||||
>
|
||||
<Users className="w-3 h-3" />
|
||||
{shareCount}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />}
|
||||
|
||||
@@ -79,7 +79,7 @@ export function ShareCollectionDialog({
|
||||
const t = useTranslations("sharing");
|
||||
const tCommon = useTranslations("common");
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
const [principals, setPrincipals] = useState<Principal[]>([]);
|
||||
const [allPrincipals, setAllPrincipals] = useState<Principal[]>([]);
|
||||
const [loadingPrincipals, setLoadingPrincipals] = useState(true);
|
||||
const [search, setSearch] = useState("");
|
||||
const [savingId, setSavingId] = useState<string | null>(null);
|
||||
@@ -91,23 +91,28 @@ export function ShareCollectionDialog({
|
||||
setLoadingPrincipals(true);
|
||||
client.getPrincipals().then((list) => {
|
||||
if (cancelled) return;
|
||||
// Exclude the user themselves and any principal that already has a share
|
||||
const existing = new Set(Object.keys(shareWith || {}));
|
||||
const filtered = list.filter((p) => p.id !== ownAccountId && !existing.has(p.id));
|
||||
setPrincipals(filtered);
|
||||
setAllPrincipals(list);
|
||||
setLoadingPrincipals(false);
|
||||
}).catch(() => {
|
||||
if (!cancelled) setLoadingPrincipals(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [client, ownAccountId, shareWith]);
|
||||
}, [client]);
|
||||
|
||||
// Map principal id -> Principal for displayed shares
|
||||
// Map of every fetched principal by id, used for name/description lookups in
|
||||
// the shared list. Must include principals that already have a share so the
|
||||
// list shows their name rather than the raw id.
|
||||
const allPrincipalsById = useMemo(() => {
|
||||
const map = new Map<string, Principal>();
|
||||
for (const p of principals) map.set(p.id, p);
|
||||
for (const p of allPrincipals) map.set(p.id, p);
|
||||
return map;
|
||||
}, [principals]);
|
||||
}, [allPrincipals]);
|
||||
|
||||
// Principals available to add: exclude self and anyone already shared with.
|
||||
const principals = useMemo(() => {
|
||||
const existing = new Set(Object.keys(shareWith || {}));
|
||||
return allPrincipals.filter((p) => p.id !== ownAccountId && !existing.has(p.id));
|
||||
}, [allPrincipals, ownAccountId, shareWith]);
|
||||
|
||||
// Close on Escape, focus trap, click outside
|
||||
useEffect(() => {
|
||||
@@ -155,8 +160,6 @@ export function ShareCollectionDialog({
|
||||
setSavingId(principal.id);
|
||||
try {
|
||||
await onShare(principal.id, rights);
|
||||
// Move principal out of the "to add" list
|
||||
setPrincipals((prev) => prev.filter((p) => p.id !== principal.id));
|
||||
setShowAdd(false);
|
||||
setSearch("");
|
||||
toast.success(t("share_added"));
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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: '简体中文' },
|
||||
];
|
||||
|
||||
@@ -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`;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}, []);
|
||||
|
||||
@@ -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]
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -13,7 +13,7 @@ const localePrefix = (process.env.NEXT_PUBLIC_LOCALE_PREFIX ?? 'never') as
|
||||
| 'as-needed';
|
||||
|
||||
export const routing = defineRouting({
|
||||
locales: ['cs', 'en', 'fr', 'de', 'es', 'it', 'ja', 'ko', 'lv', 'nl', 'pl', 'pt', 'ru', 'uk', 'zh'],
|
||||
locales: ['cs', 'en', 'fr', 'de', 'es', 'it', 'ja', 'ko', 'lv', 'nl', 'pl', 'pt', 'ru', 'tr', 'uk', 'zh'],
|
||||
defaultLocale: 'en',
|
||||
localePrefix
|
||||
});
|
||||
|
||||
+7
-37
@@ -2,49 +2,12 @@ import { readFileSync } from "fs";
|
||||
import { configManager } from "./lib/admin/config-manager";
|
||||
import { initAdminPassword } from "./lib/admin/password";
|
||||
|
||||
const VERSION_CHECK_URL =
|
||||
"https://raw.githubusercontent.com/bulwarkmail/webmail/main/VERSION";
|
||||
|
||||
const SEMVER_RE = /^\d+\.\d+\.\d+$/;
|
||||
|
||||
function compareVersions(current: string, remote: string): number {
|
||||
const a = current.split(".").map(Number);
|
||||
const b = remote.split(".").map(Number);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if ((b[i] ?? 0) > (a[i] ?? 0)) return 1;
|
||||
if ((b[i] ?? 0) < (a[i] ?? 0)) return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(`${process.cwd()}/package.json`, "utf-8")
|
||||
);
|
||||
const current: string = pkg.version ?? "0.0.0";
|
||||
console.info(`Bulwark Webmail v${current}`);
|
||||
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
fetch(VERSION_CHECK_URL, {
|
||||
cache: "no-store",
|
||||
signal: AbortSignal.timeout(5000),
|
||||
})
|
||||
.then((res) => {
|
||||
if (!res.ok) return;
|
||||
return res.text();
|
||||
})
|
||||
.then((text) => {
|
||||
if (!text) return;
|
||||
const remote = text.trim();
|
||||
if (!SEMVER_RE.test(remote)) return;
|
||||
if (compareVersions(current, remote) > 0) {
|
||||
console.info(
|
||||
`Update available: v${remote} - https://github.com/bulwarkmail/webmail`
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
// Initialize admin config and password bootstrap
|
||||
configManager.load()
|
||||
.then(() => initAdminPassword())
|
||||
@@ -59,6 +22,13 @@ configManager.load()
|
||||
markProcessStart();
|
||||
await startScheduler();
|
||||
})
|
||||
.then(async () => {
|
||||
// Hourly check against version.telemetry.bulwarkmail.org. Disable with
|
||||
// BULWARK_UPDATE_CHECK=off or override the endpoint with
|
||||
// BULWARK_UPDATE_CHECK_URL.
|
||||
const { startScheduler } = await import("./lib/version-check");
|
||||
await startScheduler();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn("Admin dashboard init skipped:", err instanceof Error ? err.message : err);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
+136
-11
@@ -1,4 +1,4 @@
|
||||
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, FileNodeFilter, Principal } from "./types";
|
||||
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, FileNodeFilter, Principal, PushSubscription } from "./types";
|
||||
import type { SieveScript, SieveCapabilities } from "./sieve-types";
|
||||
import type { IJMAPClient } from "./client-interface";
|
||||
import { toWildcardQuery } from "./search-utils";
|
||||
@@ -294,6 +294,22 @@ function foldIcsLine(line: string): string {
|
||||
return chunks.join('\r\n');
|
||||
}
|
||||
|
||||
// JMAP RFC 8621 stores Message-IDs without angle brackets. Strip any that
|
||||
// snuck in (e.g. when echoing values that originated from RFC 5322 headers).
|
||||
function stripMessageIdBrackets(id: string): string {
|
||||
return id.trim().replace(/^<+/, '').replace(/>+$/, '').trim();
|
||||
}
|
||||
|
||||
// Some servers (notably Stalwart) return Identity.name in RFC 5322 mailbox
|
||||
// form: `Display Name <addr@example.com>`. Re-emitting that as the JMAP
|
||||
// from.name field produces a doubled From header (`"Name <addr>" <addr>`)
|
||||
// whose display-name is invalid per RFC 5322 §3.4 and gets rejected by the
|
||||
// submission validator — the email then sits forever in Drafts.
|
||||
function sanitizeIdentityDisplayName(name: string | undefined | null): string {
|
||||
if (!name) return '';
|
||||
return name.replace(/\s*<[^>]*>\s*$/, '').trim();
|
||||
}
|
||||
|
||||
export class JMAPClient implements IJMAPClient {
|
||||
private static readonly RATE_LIMIT_TOAST_THROTTLE_MS = 10_000;
|
||||
|
||||
@@ -1748,7 +1764,8 @@ export class JMAPClient implements IJMAPClient {
|
||||
]);
|
||||
|
||||
if (response.methodResponses?.[0]?.[0] === "Identity/get") {
|
||||
return (response.methodResponses[0][1].list || []) as Identity[];
|
||||
const list = (response.methodResponses[0][1].list || []) as Identity[];
|
||||
return list.map((id) => ({ ...id, name: sanitizeIdentityDisplayName(id.name) }));
|
||||
}
|
||||
|
||||
return [];
|
||||
@@ -1933,7 +1950,8 @@ export class JMAPClient implements IJMAPClient {
|
||||
fromEmail?: string,
|
||||
draftId?: string,
|
||||
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>,
|
||||
fromName?: string
|
||||
fromName?: string,
|
||||
htmlBody?: string
|
||||
): Promise<string> {
|
||||
const mailboxes = await this.getMailboxes();
|
||||
const draftsMailbox = mailboxes.find(mb => mb.role === 'drafts');
|
||||
@@ -1952,20 +1970,27 @@ export class JMAPClient implements IJMAPClient {
|
||||
keywords: Record<string, boolean>;
|
||||
mailboxIds: Record<string, boolean>;
|
||||
bodyValues: Record<string, { value: string }>;
|
||||
textBody: { partId: string }[];
|
||||
textBody: { partId: string; type?: string }[];
|
||||
htmlBody?: { partId: string; type: string }[];
|
||||
attachments?: { blobId: string; type: string; name: string; disposition: string; cid?: string }[];
|
||||
}
|
||||
|
||||
const sanitizedFromName = sanitizeIdentityDisplayName(fromName);
|
||||
const emailData: EmailDraft = {
|
||||
from: [{ ...(fromName ? { name: fromName } : {}), email: fromEmail || this.username }],
|
||||
from: [{ ...(sanitizedFromName ? { name: sanitizedFromName } : {}), email: fromEmail || this.username }],
|
||||
to: to.map(email => ({ email })),
|
||||
cc: cc?.map(email => ({ email })),
|
||||
bcc: bcc?.map(email => ({ email })),
|
||||
subject,
|
||||
keywords: { "$draft": true },
|
||||
mailboxIds: { [draftsMailbox.id]: true },
|
||||
bodyValues: { "1": { value: body } },
|
||||
textBody: [{ partId: "1" }],
|
||||
bodyValues: htmlBody
|
||||
? { "text": { value: body }, "html": { value: htmlBody } }
|
||||
: { "1": { value: body } },
|
||||
textBody: htmlBody
|
||||
? [{ partId: "text", type: "text/plain" }]
|
||||
: [{ partId: "1" }],
|
||||
...(htmlBody ? { htmlBody: [{ partId: "html", type: "text/html" }] } : {}),
|
||||
};
|
||||
|
||||
if (attachments?.length) {
|
||||
@@ -2027,7 +2052,9 @@ export class JMAPClient implements IJMAPClient {
|
||||
draftId?: string,
|
||||
fromName?: string,
|
||||
htmlBody?: string,
|
||||
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>
|
||||
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>,
|
||||
inReplyTo?: string[],
|
||||
references?: string[]
|
||||
): Promise<void> {
|
||||
const emailId = `send-${Date.now()}`;
|
||||
const mailboxes = await this.getMailboxes();
|
||||
@@ -2067,14 +2094,22 @@ export class JMAPClient implements IJMAPClient {
|
||||
}
|
||||
}
|
||||
|
||||
// Per RFC 8621 §4.1.2.3 inReplyTo/references are arrays of bare msg-ids
|
||||
// (no angle brackets). Stalwart may return them either way, so normalize.
|
||||
const normalizedInReplyTo = inReplyTo?.map(stripMessageIdBrackets).filter(Boolean);
|
||||
const normalizedReferences = references?.map(stripMessageIdBrackets).filter(Boolean);
|
||||
|
||||
const sanitizedFromName = sanitizeIdentityDisplayName(fromName);
|
||||
// Always create a new email with the final body content
|
||||
const emailCreate: Record<string, unknown> = {
|
||||
from: [{ ...(fromName ? { name: fromName } : {}), email: fromEmail || this.username }],
|
||||
from: [{ ...(sanitizedFromName ? { name: sanitizedFromName } : {}), email: fromEmail || this.username }],
|
||||
replyTo: identityReplyTo?.length ? identityReplyTo : undefined,
|
||||
to: to.map(email => ({ email })),
|
||||
cc: cc?.map(email => ({ email })),
|
||||
bcc: bcc?.map(email => ({ email })),
|
||||
subject,
|
||||
inReplyTo: normalizedInReplyTo?.length ? normalizedInReplyTo : undefined,
|
||||
references: normalizedReferences?.length ? normalizedReferences : undefined,
|
||||
keywords: { "$seen": true, "$draft": true },
|
||||
mailboxIds: { [draftsMailbox.id]: true },
|
||||
};
|
||||
@@ -3057,11 +3092,19 @@ export class JMAPClient implements IJMAPClient {
|
||||
}
|
||||
|
||||
private contactUsing(): string[] {
|
||||
return ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:contacts"];
|
||||
const using = ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:contacts"];
|
||||
if (this.hasCapability("urn:ietf:params:jmap:principals")) {
|
||||
using.push("urn:ietf:params:jmap:principals:owner");
|
||||
}
|
||||
return using;
|
||||
}
|
||||
|
||||
private calendarUsing(): string[] {
|
||||
return ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:calendars"];
|
||||
const using = ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:calendars"];
|
||||
if (this.hasCapability("urn:ietf:params:jmap:principals")) {
|
||||
using.push("urn:ietf:params:jmap:principals:owner");
|
||||
}
|
||||
return using;
|
||||
}
|
||||
|
||||
private getCalendarCapableAccountIds(): string[] {
|
||||
@@ -3271,6 +3314,9 @@ export class JMAPClient implements IJMAPClient {
|
||||
const err = result.notUpdated[calendarId];
|
||||
throw new Error(err.description || "Failed to update calendar share");
|
||||
}
|
||||
if (!result?.updated || !(calendarId in result.updated)) {
|
||||
throw new Error("Server did not confirm the share update");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -3296,6 +3342,9 @@ export class JMAPClient implements IJMAPClient {
|
||||
const err = result.notUpdated[addressBookId];
|
||||
throw new Error(err.description || "Failed to update address book share");
|
||||
}
|
||||
if (!result?.updated || !(addressBookId in result.updated)) {
|
||||
throw new Error("Server did not confirm the share update");
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchPaginatedContacts(
|
||||
@@ -5291,4 +5340,80 @@ export class JMAPClient implements IJMAPClient {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── PushSubscription (RFC 8620 §7.2) ──────────────────────────────
|
||||
// Used by the PWA Web Push integration. The mobile app does the same dance
|
||||
// through its own JMAP client - keep these in sync.
|
||||
|
||||
async listPushSubscriptions(): Promise<PushSubscription[]> {
|
||||
const response = await this.request(
|
||||
[['PushSubscription/get', { ids: null }, '0']],
|
||||
['urn:ietf:params:jmap:core'],
|
||||
);
|
||||
const [, body] = response.methodResponses[0] ?? [];
|
||||
return ((body as { list?: PushSubscription[] } | undefined)?.list) ?? [];
|
||||
}
|
||||
|
||||
async createPushSubscription(params: {
|
||||
deviceClientId: string;
|
||||
url: string;
|
||||
types: string[];
|
||||
expires?: string;
|
||||
}): Promise<string> {
|
||||
const created: Record<string, unknown> = {
|
||||
deviceClientId: params.deviceClientId,
|
||||
url: params.url,
|
||||
types: params.types,
|
||||
};
|
||||
if (params.expires) created.expires = params.expires;
|
||||
|
||||
const response = await this.request(
|
||||
[['PushSubscription/set', { create: { new: created } }, '0']],
|
||||
['urn:ietf:params:jmap:core'],
|
||||
);
|
||||
const [, body] = response.methodResponses[0] ?? [];
|
||||
const result = (body as { created?: { new?: { id?: string } }; notCreated?: { new?: unknown } } | undefined);
|
||||
const id = result?.created?.new?.id;
|
||||
if (!id) {
|
||||
throw new Error(
|
||||
`PushSubscription/set create failed: ${JSON.stringify(result?.notCreated?.new ?? body)}`,
|
||||
);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
async verifyPushSubscription(id: string, verificationCode: string): Promise<void> {
|
||||
const response = await this.request(
|
||||
[['PushSubscription/set', { update: { [id]: { verificationCode } } }, '0']],
|
||||
['urn:ietf:params:jmap:core'],
|
||||
);
|
||||
const [, body] = response.methodResponses[0] ?? [];
|
||||
const notUpdated = (body as { notUpdated?: Record<string, unknown> } | undefined)?.notUpdated?.[id];
|
||||
if (notUpdated) {
|
||||
throw new Error(`PushSubscription verification failed: ${JSON.stringify(notUpdated)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Returns false when the server rejects the update (e.g. the subscription
|
||||
// was already destroyed) - the caller treats that as a signal to recreate.
|
||||
async updatePushSubscription(
|
||||
id: string,
|
||||
patch: { expires?: string; types?: string[] },
|
||||
): Promise<boolean> {
|
||||
const response = await this.request(
|
||||
[['PushSubscription/set', { update: { [id]: patch } }, '0']],
|
||||
['urn:ietf:params:jmap:core'],
|
||||
);
|
||||
const [, body] = response.methodResponses[0] ?? [];
|
||||
const r = body as { updated?: Record<string, unknown>; notUpdated?: Record<string, unknown> } | undefined;
|
||||
if (r?.notUpdated?.[id]) return false;
|
||||
return r?.updated?.[id] !== undefined;
|
||||
}
|
||||
|
||||
async destroyPushSubscription(id: string): Promise<void> {
|
||||
await this.request(
|
||||
[['PushSubscription/set', { destroy: [id] }, '0']],
|
||||
['urn:ietf:params:jmap:core'],
|
||||
);
|
||||
}
|
||||
}
|
||||
+54
-2
@@ -23,7 +23,7 @@ import {
|
||||
taskHooks, templateHooks, smimeHooks, vacationHooks,
|
||||
uiHooks, themeHooks, toastHooks, dragDropHooks,
|
||||
keyboardHooks, appLifecycleHooks, accountSecurityHooks,
|
||||
sidebarAppHooks, avatarHooks, renderHooks,
|
||||
sidebarAppHooks, avatarHooks, renderHooks, routerHooks,
|
||||
} from './plugin-hooks';
|
||||
import { createPluginI18n } from './plugin-i18n';
|
||||
import { toast as appToast } from '@/stores/toast-store';
|
||||
@@ -187,6 +187,22 @@ export interface PluginHooksAPI {
|
||||
onQuotaChange: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
/** Intercept - receives MailtoContext, return false to prevent the system mail client */
|
||||
onMailtoIntercept: (handler: (ctx: import('./plugin-types').MailtoContext) => boolean | void | Promise<boolean | void>) => Disposable;
|
||||
/** Transform - receives the OutgoingEmail and returns a (possibly modified) copy */
|
||||
onTransformOutgoingEmail: (handler: (email: import('./plugin-types').OutgoingEmail) => import('./plugin-types').OutgoingEmail | void | Promise<import('./plugin-types').OutgoingEmail | void>) => Disposable;
|
||||
/** Intercept - receives ReplyContext, return false to cancel */
|
||||
onBeforeReply: (handler: (ctx: import('./plugin-types').ReplyContext) => boolean | void | Promise<boolean | void>) => Disposable;
|
||||
onBeforeReplyAll: (handler: (ctx: import('./plugin-types').ReplyContext) => boolean | void | Promise<boolean | void>) => Disposable;
|
||||
onBeforeForward: (handler: (ctx: import('./plugin-types').ReplyContext) => boolean | void | Promise<boolean | void>) => Disposable;
|
||||
/** Intercept - receives AttachmentInfo, return false to refuse the upload */
|
||||
onBeforeAttachmentUpload: (handler: (info: import('./plugin-types').AttachmentInfo) => boolean | void | Promise<boolean | void>) => Disposable;
|
||||
onAfterAttachmentUpload: (handler: (info: import('./plugin-types').AttachmentInfo) => void) => Disposable;
|
||||
onAttachmentDownload: (handler: (info: import('./plugin-types').AttachmentInfo) => void) => Disposable;
|
||||
/** Transform - receives AttachmentPreview, may return a modified preview */
|
||||
onAttachmentPreview: (handler: (preview: import('./plugin-types').AttachmentPreview, info: import('./plugin-types').AttachmentInfo) => import('./plugin-types').AttachmentPreview | void | Promise<import('./plugin-types').AttachmentPreview | void>) => Disposable;
|
||||
/** Transform - receives ExternalSearchResult[] and returns an extended array */
|
||||
onProvideSearchResults: (handler: (results: import('./plugin-types').ExternalSearchResult[], ctx: { query: string; filters: import('./plugin-types').SearchFilters }) => import('./plugin-types').ExternalSearchResult[] | void | Promise<import('./plugin-types').ExternalSearchResult[] | void>) => Disposable;
|
||||
/** Observer - debounced snapshot of the composer draft */
|
||||
onDraftChange: (handler: (draft: import('./plugin-types').DraftView) => void) => Disposable;
|
||||
// Calendar
|
||||
onCalendarEventOpen: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onBeforeEventCreate: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
@@ -204,6 +220,8 @@ export interface PluginHooksAPI {
|
||||
onICalSubscriptionChange: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onCalendarAlert: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onCalendarAlertAcknowledge: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
/** Transform - receives ConflictWarning[] and returns an extended array */
|
||||
onCheckEventConflicts: (handler: (warnings: import('./plugin-types').ConflictWarning[], ctx: { event: import('./plugin-types').CalendarEventFormView }) => import('./plugin-types').ConflictWarning[] | void | Promise<import('./plugin-types').ConflictWarning[] | void>) => Disposable;
|
||||
// Calendar Form
|
||||
onCalendarEventFormOpen: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onCalendarEventFormSave: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
@@ -220,6 +238,8 @@ export interface PluginHooksAPI {
|
||||
onContactGroupChange: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onContactGroupMemberChange: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onContactMove: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
/** Transform - receives RecipientSuggestion[] and returns an extended array */
|
||||
onProvideRecipientSuggestions: (handler: (suggestions: import('./plugin-types').RecipientSuggestion[], ctx: { query: string }) => import('./plugin-types').RecipientSuggestion[] | void | Promise<import('./plugin-types').RecipientSuggestion[] | void>) => Disposable;
|
||||
// Files
|
||||
onFileNavigate: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onBeforeFileUpload: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
@@ -297,6 +317,10 @@ export interface PluginHooksAPI {
|
||||
onColumnResize: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onMobileBack: (handler: () => void) => Disposable;
|
||||
onMobileViewSwitch: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
/** Intercept - receives ExternalLinkContext, return false to cancel navigation */
|
||||
onBeforeExternalLink: (handler: (ctx: import('./plugin-types').ExternalLinkContext) => boolean | void | Promise<boolean | void>) => Disposable;
|
||||
/** Observer - debounced text-selection change */
|
||||
onTextSelectionChange: (handler: (ctx: import('./plugin-types').SelectionContext) => void) => Disposable;
|
||||
// Theme
|
||||
onThemeChange: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onCustomThemeChange: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
@@ -305,6 +329,8 @@ export interface PluginHooksAPI {
|
||||
onToastShow: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onToastDismiss: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onBrowserNotification: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
/** Observer fired when an OS-level notification is clicked */
|
||||
onNotificationClick: (handler: (ctx: { tag: string; data?: unknown }) => void) => Disposable;
|
||||
// Drag & Drop
|
||||
onDragStart: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onDragEnd: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
@@ -320,6 +346,12 @@ export interface PluginHooksAPI {
|
||||
onBeforeUnload: (handler: () => void) => Disposable;
|
||||
onAppError: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onInterval: (handler: () => void, intervalMs: number) => Disposable;
|
||||
/** Observer - browser window focus / blur */
|
||||
onWindowFocus: (handler: () => void) => Disposable;
|
||||
onWindowBlur: (handler: () => void) => Disposable;
|
||||
/** Observer - network connectivity transitions */
|
||||
onOnline: (handler: () => void) => Disposable;
|
||||
onOffline: (handler: () => void) => Disposable;
|
||||
// Account Security
|
||||
onPasswordChange: (handler: () => void) => Disposable;
|
||||
onTotpChange: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
@@ -335,6 +367,11 @@ export interface PluginHooksAPI {
|
||||
// Render - transform hook for email list row badges
|
||||
// Handler: (badges: EmailListBadge[], ctx: { emailId: string; email: EmailReadView }) => EmailListBadge[]
|
||||
onEmailListItemRender: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
// Router
|
||||
/** Observer - fired on every in-app navigation. RouteContext.from holds the previous path. */
|
||||
onNavigate: (handler: (ctx: import('./plugin-types').RouteContext) => void) => Disposable;
|
||||
onRouteEnter: (handler: (ctx: import('./plugin-types').RouteContext) => void) => Disposable;
|
||||
onRouteLeave: (handler: (ctx: import('./plugin-types').RouteContext) => void) => Disposable;
|
||||
}
|
||||
|
||||
// --- Permission mapping for hooks ----------------------------
|
||||
@@ -350,7 +387,13 @@ const HOOK_PERMISSIONS: Record<string, Permission> = {
|
||||
onEmailSelectionChange: 'email:read', onNewEmailReceived: 'email:read',
|
||||
onPushConnectionChange: 'email:read', onQuotaChange: 'email:read',
|
||||
onMailtoIntercept: 'email:read', onEmailListItemRender: 'email:read',
|
||||
onBeforeReply: 'email:read', onBeforeReplyAll: 'email:read',
|
||||
onBeforeForward: 'email:read', onAttachmentDownload: 'email:read',
|
||||
onAttachmentPreview: 'email:read', onProvideSearchResults: 'email:read',
|
||||
onDraftChange: 'email:read',
|
||||
onBeforeAttachmentUpload: 'email:write', onAfterAttachmentUpload: 'email:write',
|
||||
onBeforeEmailSend: 'email:send', onAfterEmailSend: 'email:send',
|
||||
onTransformOutgoingEmail: 'email:send',
|
||||
onBeforeEmailDelete: 'email:write', onAfterEmailDelete: 'email:write',
|
||||
onBeforeEmailMove: 'email:write', onAfterEmailMove: 'email:write',
|
||||
onEmailArchive: 'email:write', onEmailUnarchive: 'email:write',
|
||||
@@ -362,6 +405,7 @@ const HOOK_PERMISSIONS: Record<string, Permission> = {
|
||||
onCalendarEventOpen: 'calendar:read', onCalendarDateChange: 'calendar:read',
|
||||
onCalendarViewChange: 'calendar:read', onCalendarVisibilityToggle: 'calendar:read',
|
||||
onCalendarAlert: 'calendar:read', onCalendarAlertAcknowledge: 'calendar:read',
|
||||
onCheckEventConflicts: 'calendar:read',
|
||||
onCalendarEventFormOpen: 'calendar:read', onCalendarEventFormSave: 'calendar:write',
|
||||
onBeforeEventCreate: 'calendar:write', onAfterEventCreate: 'calendar:write',
|
||||
onBeforeEventUpdate: 'calendar:write', onAfterEventUpdate: 'calendar:write',
|
||||
@@ -370,6 +414,7 @@ const HOOK_PERMISSIONS: Record<string, Permission> = {
|
||||
onCalendarChange: 'calendar:write', onICalSubscriptionChange: 'calendar:write',
|
||||
// Contacts
|
||||
onContactOpen: 'contacts:read', onContactSelectionChange: 'contacts:read',
|
||||
onProvideRecipientSuggestions: 'contacts:read',
|
||||
onBeforeContactCreate: 'contacts:write', onAfterContactCreate: 'contacts:write',
|
||||
onBeforeContactUpdate: 'contacts:write', onAfterContactUpdate: 'contacts:write',
|
||||
onBeforeContactDelete: 'contacts:write', onAfterContactDelete: 'contacts:write',
|
||||
@@ -419,12 +464,13 @@ const HOOK_PERMISSIONS: Record<string, Permission> = {
|
||||
onSidebarCollapse: 'ui:observe', onDeviceTypeChange: 'ui:observe',
|
||||
onColumnResize: 'ui:observe', onMobileBack: 'ui:observe',
|
||||
onMobileViewSwitch: 'ui:observe',
|
||||
onBeforeExternalLink: 'ui:observe', onTextSelectionChange: 'ui:observe',
|
||||
// Theme
|
||||
onThemeChange: 'ui:observe', onCustomThemeChange: 'ui:observe',
|
||||
onLocaleChange: 'ui:observe',
|
||||
// Toast
|
||||
onToastShow: 'ui:observe', onToastDismiss: 'ui:observe',
|
||||
onBrowserNotification: 'ui:observe',
|
||||
onBrowserNotification: 'ui:observe', onNotificationClick: 'ui:observe',
|
||||
// Drag & Drop
|
||||
onDragStart: 'ui:observe', onDragEnd: 'ui:observe',
|
||||
onEmailDrop: 'ui:observe', onTagDrop: 'ui:observe',
|
||||
@@ -435,6 +481,8 @@ const HOOK_PERMISSIONS: Record<string, Permission> = {
|
||||
onAppReady: 'app:lifecycle', onVisibilityChange: 'app:lifecycle',
|
||||
onBeforeUnload: 'app:lifecycle', onAppError: 'app:lifecycle',
|
||||
onInterval: 'app:lifecycle',
|
||||
onWindowFocus: 'app:lifecycle', onWindowBlur: 'app:lifecycle',
|
||||
onOnline: 'app:lifecycle', onOffline: 'app:lifecycle',
|
||||
// Account Security
|
||||
onPasswordChange: 'security:read', onTotpChange: 'security:read',
|
||||
onAppPasswordChange: 'security:read', onEncryptionChange: 'security:read',
|
||||
@@ -444,6 +492,8 @@ const HOOK_PERMISSIONS: Record<string, Permission> = {
|
||||
onSidebarAppChange: 'ui:observe',
|
||||
// Avatar
|
||||
onAvatarResolve: 'email:read',
|
||||
// Router
|
||||
onNavigate: 'ui:observe', onRouteEnter: 'ui:observe', onRouteLeave: 'ui:observe',
|
||||
};
|
||||
|
||||
// Map hook names → actual HookBus instances
|
||||
@@ -494,6 +544,8 @@ const HOOK_BUSES: Record<string, { register: (pluginId: string, handler: (...arg
|
||||
...Object.fromEntries(Object.entries(avatarHooks)),
|
||||
// Render
|
||||
...Object.fromEntries(Object.entries(renderHooks)),
|
||||
// Router
|
||||
...Object.fromEntries(Object.entries(routerHooks)),
|
||||
};
|
||||
|
||||
// --- Slot registration bridge --------------------------------
|
||||
|
||||
+70
-1
@@ -207,6 +207,38 @@ export const emailHooks = {
|
||||
// Intercept hook - fired when a mailto: link is clicked.
|
||||
// Return false to prevent the browser from opening the system mail client.
|
||||
onMailtoIntercept: new HookBus(),
|
||||
// Transform hook - fires after onBeforeEmailSend has not cancelled,
|
||||
// immediately before the message is handed to the JMAP submission. Handlers
|
||||
// receive an OutgoingEmail and return a modified copy (or undefined to pass
|
||||
// through). Use to inject signatures, scrub tracking pixels from forwarded
|
||||
// bodies, encrypt content, or rewrite links.
|
||||
onTransformOutgoingEmail: new HookBus(),
|
||||
// Intercept hooks fired when the user clicks Reply / Reply-All / Forward.
|
||||
// Handler receives a ReplyContext; return false to cancel.
|
||||
onBeforeReply: new HookBus(),
|
||||
onBeforeReplyAll: new HookBus(),
|
||||
onBeforeForward: new HookBus(),
|
||||
// Intercept hook fired before a file is added to the composer as an
|
||||
// attachment. Handler receives AttachmentInfo (size/type/name only - the
|
||||
// raw file is not exposed). Return false to refuse the upload.
|
||||
onBeforeAttachmentUpload: new HookBus(),
|
||||
// Observer fired after an attachment has been uploaded and its blobId is
|
||||
// available. Handler receives AttachmentInfo with `blobId` populated.
|
||||
onAfterAttachmentUpload: new HookBus(),
|
||||
// Observer fired when the user downloads an attachment from a message.
|
||||
onAttachmentDownload: new HookBus(),
|
||||
// Transform hook - lets plugins replace the preview URL or supply a custom
|
||||
// renderer for an attachment. Initial value: AttachmentPreview, second
|
||||
// argument: AttachmentInfo.
|
||||
onAttachmentPreview: new HookBus(),
|
||||
// Transform hook - lets plugins contribute additional results to the global
|
||||
// search panel. Initial value: ExternalSearchResult[]. Second argument:
|
||||
// { query: string, filters: SearchFilters }.
|
||||
onProvideSearchResults: new HookBus(),
|
||||
// Observer fired (debounced) when the composer draft body, subject, or
|
||||
// recipients change. Handler receives a DraftView snapshot. Use for AI
|
||||
// assistants, grammar checkers, etc.
|
||||
onDraftChange: new HookBus(),
|
||||
};
|
||||
|
||||
// §7.2 Calendar Hooks
|
||||
@@ -227,6 +259,10 @@ export const calendarHooks = {
|
||||
onICalSubscriptionChange: new HookBus(),
|
||||
onCalendarAlert: new HookBus(),
|
||||
onCalendarAlertAcknowledge: new HookBus(),
|
||||
// Transform hook - fires when the event form is open and start/end change.
|
||||
// Initial value: ConflictWarning[], second argument: { event: CalendarEventFormView }.
|
||||
// Plugins return an extended array; the form renders each warning inline.
|
||||
onCheckEventConflicts: new HookBus(),
|
||||
};
|
||||
|
||||
// §7.2b Calendar Form Hooks (UI integration)
|
||||
@@ -249,6 +285,10 @@ export const contactHooks = {
|
||||
onContactGroupChange: new HookBus(),
|
||||
onContactGroupMemberChange: new HookBus(),
|
||||
onContactMove: new HookBus(),
|
||||
// Transform hook - lets plugins contribute extra recipient suggestions to
|
||||
// the composer's autocomplete. Initial value: RecipientSuggestion[],
|
||||
// second argument: { query: string }.
|
||||
onProvideRecipientSuggestions: new HookBus(),
|
||||
};
|
||||
|
||||
// §7.4 File Hooks
|
||||
@@ -358,6 +398,14 @@ export const uiHooks = {
|
||||
onColumnResize: new HookBus(),
|
||||
onMobileBack: new HookBus(),
|
||||
onMobileViewSwitch: new HookBus(),
|
||||
// Intercept hook - fires when the user clicks an external link inside the
|
||||
// app (typically inside an email body iframe). Handler receives
|
||||
// ExternalLinkContext; return false to cancel the navigation. Mutate
|
||||
// `href` in place to rewrite (e.g. strip UTM params, route via a proxy).
|
||||
onBeforeExternalLink: new HookBus(),
|
||||
// Observer (debounced) fired when the user changes the active text
|
||||
// selection inside an app surface. Receives SelectionContext.
|
||||
onTextSelectionChange: new HookBus(),
|
||||
};
|
||||
|
||||
// §7.14 Theme Hooks
|
||||
@@ -384,6 +432,10 @@ export const toastHooks = {
|
||||
onToastShow: new HookBus(),
|
||||
onToastDismiss: new HookBus(),
|
||||
onBrowserNotification: new HookBus(),
|
||||
// Observer fired when the user clicks an OS-level browser notification
|
||||
// dispatched by the host. Handler receives { tag: string, data?: unknown }
|
||||
// matching the original notification options.
|
||||
onNotificationClick: new HookBus(),
|
||||
};
|
||||
|
||||
// §7.16 Drag & Drop Hooks
|
||||
@@ -408,6 +460,14 @@ export const appLifecycleHooks = {
|
||||
onBeforeUnload: new HookBus(),
|
||||
onAppError: new HookBus(),
|
||||
onInterval: new HookBus(),
|
||||
// Observer fired when the browser window receives focus / blur. Useful for
|
||||
// refresh-on-focus behaviour (re-poll, recheck staleness, pause timers).
|
||||
onWindowFocus: new HookBus(),
|
||||
onWindowBlur: new HookBus(),
|
||||
// Observer fired when network connectivity transitions. Mirrors the
|
||||
// navigator online / offline events.
|
||||
onOnline: new HookBus(),
|
||||
onOffline: new HookBus(),
|
||||
};
|
||||
|
||||
// §7.19 Account Security Hooks
|
||||
@@ -433,6 +493,15 @@ export const avatarHooks = {
|
||||
onAvatarResolve: new HookBus(),
|
||||
};
|
||||
|
||||
// §7.23 Router Hooks
|
||||
// Observers fired by the app router. Handlers receive a RouteContext; on
|
||||
// onNavigate the previous path is exposed via `from`.
|
||||
export const routerHooks = {
|
||||
onNavigate: new HookBus(),
|
||||
onRouteEnter: new HookBus(),
|
||||
onRouteLeave: new HookBus(),
|
||||
};
|
||||
|
||||
// §7.22 Render Hooks
|
||||
export const renderHooks = {
|
||||
// Transform hook - runs for each visible email list row.
|
||||
@@ -451,7 +520,7 @@ const allHookGroups = [
|
||||
taskHooks, templateHooks, smimeHooks, vacationHooks,
|
||||
uiHooks, themeHooks, toastHooks, dragDropHooks,
|
||||
keyboardHooks, appLifecycleHooks, accountSecurityHooks, sidebarAppHooks,
|
||||
avatarHooks, renderHooks,
|
||||
avatarHooks, renderHooks, routerHooks,
|
||||
];
|
||||
|
||||
export function removeAllPluginHooks(pluginId: string): void {
|
||||
|
||||
@@ -522,6 +522,137 @@ export interface MailtoContext {
|
||||
body?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Passed to onTransformOutgoingEmail handlers as a transform value.
|
||||
* Handlers receive the email about to be sent and return a (possibly mutated)
|
||||
* copy. Use to inject signatures, rewrite links, strip tracking pixels from
|
||||
* forwards, encrypt the body, etc. Return undefined to pass through unchanged.
|
||||
*/
|
||||
export interface OutgoingEmail {
|
||||
to: string[];
|
||||
cc: string[];
|
||||
bcc: string[];
|
||||
subject: string;
|
||||
htmlBody: string;
|
||||
textBody: string;
|
||||
identityId: string;
|
||||
attachments: { name: string; type: string; size: number }[];
|
||||
/** Original message id when this is a reply or forward */
|
||||
inReplyTo?: string;
|
||||
/** Free-form custom headers added by the composer or earlier handlers */
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Passed to onBeforeReply / onBeforeReplyAll / onBeforeForward intercept hooks.
|
||||
* Return false to cancel the operation before the composer opens.
|
||||
*/
|
||||
export interface ReplyContext {
|
||||
originalEmailId: string;
|
||||
originalEmail: EmailReadView;
|
||||
mode: 'reply' | 'reply-all' | 'forward';
|
||||
}
|
||||
|
||||
/**
|
||||
* Describes an attachment crossing an attachment hook (upload, download, preview).
|
||||
*/
|
||||
export interface AttachmentInfo {
|
||||
name: string;
|
||||
type: string;
|
||||
size: number;
|
||||
/** JMAP blob id, when known (download / preview / after-upload) */
|
||||
blobId?: string;
|
||||
/** The email this attachment belongs to (download / preview) */
|
||||
emailId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initial value passed to the onAttachmentPreview transform hook. A handler
|
||||
* may return a different `previewUrl` (e.g. a proxied/sanitised URL) or a
|
||||
* React component descriptor identified by `customRenderer`. Return undefined
|
||||
* to pass through.
|
||||
*/
|
||||
export interface AttachmentPreview {
|
||||
previewUrl?: string;
|
||||
/** Optional plugin-supplied renderer key. The host resolves the renderer. */
|
||||
customRenderer?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Passed to onBeforeExternalLink intercept handlers when the user clicks a
|
||||
* link that would navigate away from the app (typically inside an email body).
|
||||
* Return false to cancel the navigation. Mutate `href` to rewrite it.
|
||||
*/
|
||||
export interface ExternalLinkContext {
|
||||
href: string;
|
||||
/** Anchor target ('_blank', '_self', etc.) when set */
|
||||
target?: string;
|
||||
/** Email currently in view, when the click came from an email body */
|
||||
emailId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Passed to onTextSelectionChange observer when the user selects text inside
|
||||
* the app. Source identifies which surface produced the selection so plugins
|
||||
* can scope themselves (e.g. translate-on-select only inside emails).
|
||||
*/
|
||||
export interface SelectionContext {
|
||||
text: string;
|
||||
source: 'email-body' | 'composer' | 'task-detail' | 'event-detail' | 'other';
|
||||
emailId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returned by onCheckEventConflicts transform handlers. The form UI renders
|
||||
* each warning as an inline notice next to the event time fields.
|
||||
*/
|
||||
export interface ConflictWarning {
|
||||
/** Stable unique key per warning, used as React key */
|
||||
key: string;
|
||||
/** Short message — e.g. "Conflicts with: Team Standup" */
|
||||
message: string;
|
||||
severity?: 'info' | 'warning' | 'error';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returned by onProvideSearchResults transform handlers. Plugins extend the
|
||||
* initial array with their own results (CRM hits, Slack messages, etc.).
|
||||
* The host renders these in a grouped section below native email results.
|
||||
*/
|
||||
export interface ExternalSearchResult {
|
||||
/** Stable unique key */
|
||||
key: string;
|
||||
title: string;
|
||||
snippet: string;
|
||||
/** Plugin-handled action when the result row is clicked */
|
||||
onClick: () => void;
|
||||
/** Optional source label, e.g. "Slack", "Notion" */
|
||||
source?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returned by onProvideRecipientSuggestions transform handlers. Lets plugins
|
||||
* contribute non-contact suggestions (Slack handles, GitHub usernames, etc.)
|
||||
* to the recipient autocomplete in the composer.
|
||||
*/
|
||||
export interface RecipientSuggestion {
|
||||
name: string;
|
||||
email: string;
|
||||
/** Optional source label rendered as a small tag */
|
||||
source?: string;
|
||||
avatarUrl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Passed to router hooks (onNavigate, onRouteEnter, onRouteLeave).
|
||||
* Paths are app-internal, e.g. "/mail/inbox", "/calendar".
|
||||
*/
|
||||
export interface RouteContext {
|
||||
path: string;
|
||||
/** Previous path (only on onNavigate) */
|
||||
from?: string;
|
||||
}
|
||||
|
||||
// ─── Plugin i18n API ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
+45
-14
@@ -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}`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { logger } from '@/lib/logger';
|
||||
import type { UpdateStatus, UpdateSeverity } from './types';
|
||||
|
||||
const SEVERITIES: ReadonlySet<UpdateSeverity> = new Set([
|
||||
'normal', 'security', 'deprecated', 'none', 'unknown',
|
||||
]);
|
||||
|
||||
function isString(v: unknown): v is string {
|
||||
return typeof v === 'string';
|
||||
}
|
||||
|
||||
function isNullableString(v: unknown): v is string | null {
|
||||
return v === null || typeof v === 'string';
|
||||
}
|
||||
|
||||
// Validate the response from the version server before we trust it. Returns
|
||||
// null on any malformed field so a hostile or buggy upstream can't poison the
|
||||
// UI with arbitrary strings (the URL, in particular, is rendered in <a href>).
|
||||
export function parseStatus(raw: unknown): UpdateStatus | null {
|
||||
if (!raw || typeof raw !== 'object') return null;
|
||||
const r = raw as Record<string, unknown>;
|
||||
if (r.schema !== 1) return null;
|
||||
if (!isString(r.current)) return null;
|
||||
if (!isNullableString(r.latest)) return null;
|
||||
if (typeof r.updateAvailable !== 'boolean') return null;
|
||||
if (!isString(r.severity) || !SEVERITIES.has(r.severity as UpdateSeverity)) return null;
|
||||
if (!isNullableString(r.url)) return null;
|
||||
if (!isNullableString(r.advisory)) return null;
|
||||
if (!isString(r.checkedAt)) return null;
|
||||
|
||||
// Only http(s) URLs are renderable; reject anything else so we don't end
|
||||
// up with a javascript: link in the banner.
|
||||
if (r.url && !/^https?:\/\//i.test(r.url)) return null;
|
||||
|
||||
return {
|
||||
schema: 1,
|
||||
current: r.current,
|
||||
latest: r.latest,
|
||||
updateAvailable: r.updateAvailable,
|
||||
severity: r.severity as UpdateSeverity,
|
||||
url: r.url,
|
||||
advisory: r.advisory,
|
||||
checkedAt: r.checkedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchStatus(
|
||||
endpoint: string,
|
||||
currentVersion: string,
|
||||
): Promise<{ ok: true; status: UpdateStatus } | { ok: false; error: string }> {
|
||||
if (!endpoint) return { ok: false, error: 'endpoint blank' };
|
||||
if (!currentVersion) return { ok: false, error: 'current version blank' };
|
||||
|
||||
// Build the URL safely — never inject the version as a raw path component.
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(endpoint);
|
||||
} catch {
|
||||
return { ok: false, error: 'endpoint not a URL' };
|
||||
}
|
||||
url.searchParams.set('v', currentVersion);
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: { accept: 'application/json' },
|
||||
cache: 'no-store',
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (!res.ok) {
|
||||
return { ok: false, error: `HTTP ${res.status}` };
|
||||
}
|
||||
const body: unknown = await res.json();
|
||||
const parsed = parseStatus(body);
|
||||
if (!parsed) return { ok: false, error: 'malformed response' };
|
||||
return { ok: true, status: parsed };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
logger.warn('version-check: fetch failed', { error: msg });
|
||||
return { ok: false, error: msg };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export { startScheduler, stopScheduler, checkOnce } from './sender';
|
||||
export { loadState, saveState, effectiveEndpoint, disabledByEnv } from './state';
|
||||
export { fetchStatus, parseStatus } from './fetcher';
|
||||
export type {
|
||||
UpdateStatus, UpdateSeverity, VersionCheckStateFile,
|
||||
} from './types';
|
||||
export { DEFAULT_VERSION_ENDPOINT } from './types';
|
||||
@@ -0,0 +1,100 @@
|
||||
import { logger } from '@/lib/logger';
|
||||
import { disabledByEnv, effectiveEndpoint, loadState, saveState } from './state';
|
||||
import { fetchStatus } from './fetcher';
|
||||
import type { UpdateStatus } from './types';
|
||||
|
||||
const HOUR_MS = 60 * 60 * 1000;
|
||||
const JITTER_MS = 5 * 60 * 1000; // ± 5 min, keeps containers from syncing
|
||||
const FIRST_DELAY_MS = 30 * 1000; // first check ~30s after boot
|
||||
const FAILURE_BACKOFF_MS = 15 * 60 * 1000; // after a failed fetch, retry in 15 min
|
||||
|
||||
let currentTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
function jitteredDelay(base: number): number {
|
||||
const j = (Math.random() * 2 - 1) * JITTER_MS;
|
||||
return Math.max(60_000, base + j);
|
||||
}
|
||||
|
||||
function getCurrentVersion(): string {
|
||||
return (process.env.NEXT_PUBLIC_APP_VERSION || '').trim();
|
||||
}
|
||||
|
||||
export async function checkOnce(opts?: { reason?: string }): Promise<{
|
||||
ok: boolean;
|
||||
status?: UpdateStatus;
|
||||
error?: string;
|
||||
}> {
|
||||
if (disabledByEnv()) return { ok: false, error: 'disabled by env' };
|
||||
|
||||
const state = await loadState();
|
||||
const endpoint = effectiveEndpoint(state);
|
||||
if (!endpoint) return { ok: false, error: 'endpoint blank' };
|
||||
|
||||
const current = getCurrentVersion();
|
||||
if (!current) return { ok: false, error: 'current version unset' };
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const result = await fetchStatus(endpoint, current);
|
||||
|
||||
const next = await loadState();
|
||||
next.lastCheckedAt = now;
|
||||
if (result.ok) {
|
||||
next.lastSuccessAt = now;
|
||||
next.status = result.status;
|
||||
}
|
||||
await saveState(next);
|
||||
|
||||
logger.info('version-check: ran', {
|
||||
ok: result.ok,
|
||||
severity: result.ok ? result.status.severity : null,
|
||||
reason: opts?.reason ?? 'scheduled',
|
||||
});
|
||||
|
||||
if (result.ok) return { ok: true, status: result.status };
|
||||
return { ok: false, error: result.error };
|
||||
}
|
||||
|
||||
async function scheduleNext(delayMs: number): Promise<void> {
|
||||
if (currentTimer) clearTimeout(currentTimer);
|
||||
const at = new Date(Date.now() + delayMs).toISOString();
|
||||
const state = await loadState();
|
||||
state.nextScheduledAt = at;
|
||||
await saveState(state);
|
||||
currentTimer = setTimeout(() => { void tick(); }, delayMs);
|
||||
// Don't keep the process alive just for this.
|
||||
currentTimer.unref?.();
|
||||
}
|
||||
|
||||
async function tick(): Promise<void> {
|
||||
const result = await checkOnce({ reason: 'scheduled' });
|
||||
const delay = result.ok ? jitteredDelay(HOUR_MS) : FAILURE_BACKOFF_MS;
|
||||
await scheduleNext(delay);
|
||||
}
|
||||
|
||||
// Idempotent — safe to call from instrumentation hot-reload in dev.
|
||||
export async function startScheduler(): Promise<void> {
|
||||
if (disabledByEnv()) {
|
||||
logger.info('version-check: scheduler not started (disabled by env)');
|
||||
return;
|
||||
}
|
||||
const state = await loadState();
|
||||
if (!effectiveEndpoint(state)) {
|
||||
logger.info('version-check: scheduler not started (no endpoint)');
|
||||
return;
|
||||
}
|
||||
// If a previous schedule was still in the future, honor it (don't blast on
|
||||
// every restart). Cap at one hour so a wildly-in-the-future timestamp can't
|
||||
// permanently silence the check.
|
||||
let delay = FIRST_DELAY_MS;
|
||||
if (state.nextScheduledAt) {
|
||||
const remaining = new Date(state.nextScheduledAt).getTime() - Date.now();
|
||||
if (remaining > 0) delay = Math.min(remaining, HOUR_MS + JITTER_MS);
|
||||
}
|
||||
await scheduleNext(delay);
|
||||
logger.info('version-check: scheduler started', { nextInMs: delay });
|
||||
}
|
||||
|
||||
export async function stopScheduler(): Promise<void> {
|
||||
if (currentTimer) clearTimeout(currentTimer);
|
||||
currentTimer = null;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { readFile, writeFile, mkdir, rename } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { logger } from '@/lib/logger';
|
||||
import type { VersionCheckStateFile } from './types';
|
||||
import { DEFAULT_VERSION_ENDPOINT } from './types';
|
||||
|
||||
function getDir(): string {
|
||||
return process.env.VERSION_CHECK_DATA_DIR ||
|
||||
path.join(process.cwd(), 'data', 'version-check');
|
||||
}
|
||||
|
||||
function statePath(): string { return path.join(getDir(), 'state.json'); }
|
||||
|
||||
const DEFAULTS: VersionCheckStateFile = {
|
||||
endpoint: DEFAULT_VERSION_ENDPOINT,
|
||||
lastCheckedAt: null,
|
||||
lastSuccessAt: null,
|
||||
nextScheduledAt: null,
|
||||
status: null,
|
||||
};
|
||||
|
||||
export async function ensureDir(): Promise<void> {
|
||||
if (!existsSync(getDir())) await mkdir(getDir(), { recursive: true });
|
||||
}
|
||||
|
||||
export async function loadState(): Promise<VersionCheckStateFile> {
|
||||
await ensureDir();
|
||||
try {
|
||||
const raw = await readFile(statePath(), 'utf8');
|
||||
const parsed = JSON.parse(raw) as Partial<VersionCheckStateFile>;
|
||||
return { ...DEFAULTS, ...parsed };
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
logger.warn('version-check: state read failed', {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
return { ...DEFAULTS };
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveState(state: VersionCheckStateFile): Promise<void> {
|
||||
await ensureDir();
|
||||
const tmp = statePath() + '.tmp';
|
||||
await writeFile(tmp, JSON.stringify(state, null, 2), 'utf8');
|
||||
await rename(tmp, statePath());
|
||||
}
|
||||
|
||||
export function disabledByEnv(): boolean {
|
||||
const v = (process.env.BULWARK_UPDATE_CHECK ?? '').toLowerCase();
|
||||
if (v === 'off' || v === 'false' || v === '0' || v === 'no') return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function effectiveEndpoint(state: VersionCheckStateFile): string {
|
||||
// Env var wins over state file so an operator can override at runtime
|
||||
// without editing on-disk state. An explicit empty value disables the check.
|
||||
const envUrl = process.env.BULWARK_UPDATE_CHECK_URL;
|
||||
if (envUrl !== undefined) return envUrl.trim();
|
||||
return state.endpoint || DEFAULT_VERSION_ENDPOINT;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// Update-status payload returned by the version server. Mirrors
|
||||
// repos/dashboard/version-server/src/registry.ts LookupResult.
|
||||
|
||||
export type UpdateSeverity = 'normal' | 'security' | 'deprecated' | 'none' | 'unknown';
|
||||
|
||||
export interface UpdateStatus {
|
||||
schema: 1;
|
||||
current: string;
|
||||
latest: string | null;
|
||||
updateAvailable: boolean;
|
||||
severity: UpdateSeverity;
|
||||
url: string | null;
|
||||
advisory: string | null;
|
||||
checkedAt: string;
|
||||
}
|
||||
|
||||
export interface VersionCheckStateFile {
|
||||
endpoint: string;
|
||||
lastCheckedAt: string | null;
|
||||
lastSuccessAt: string | null;
|
||||
nextScheduledAt: string | null;
|
||||
status: UpdateStatus | null;
|
||||
}
|
||||
|
||||
export const DEFAULT_VERSION_ENDPOINT = 'https://version.telemetry.bulwarkmail.org/';
|
||||
+356
@@ -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
@@ -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
@@ -184,6 +184,7 @@
|
||||
"batch_actions": {
|
||||
"select": "E-Mails auswählen",
|
||||
"select_all": "Alle auswählen",
|
||||
"selected_messages": "{count, plural, one {1 E-Mail} other {# E-Mails}} ausgewählt",
|
||||
"mark_read": "Als gelesen markieren",
|
||||
"mark_unread": "Als ungelesen markieren",
|
||||
"delete": "Löschen",
|
||||
@@ -223,6 +224,11 @@
|
||||
"unstar": "Stern entfernen",
|
||||
"mark_unread": "Als ungelesen markieren",
|
||||
"mark_read": "Als gelesen markieren",
|
||||
"unread": "Ungelesen",
|
||||
"read": "Gelesen",
|
||||
"spam_short": "Spam",
|
||||
"not_spam_short": "Kein Spam",
|
||||
"move": "Verschieben",
|
||||
"print": "Drucken",
|
||||
"view_source": "Quelltext anzeigen",
|
||||
"export_email": "Als .eml exportieren",
|
||||
@@ -304,7 +310,20 @@
|
||||
"fail": "DMARC Fehlgeschlagen",
|
||||
"none": "Kein DMARC"
|
||||
},
|
||||
"spam_score": "Spam-Bewertung"
|
||||
"spam_score": "Spam-Bewertung",
|
||||
"tooltip_spf": "Sender Policy Framework: Überprüft, ob der sendende Server berechtigt ist, E-Mails im Namen der Domain zu senden",
|
||||
"tooltip_dkim": "DomainKeys Identified Mail: Bestätigt mittels kryptografischer Signatur, dass die E-Mail während der Übertragung nicht verändert wurde",
|
||||
"tooltip_dmarc": "Domain-based Message Authentication, Reporting & Conformance: Stellt sicher, dass SPF und DKIM mit der Domain des Absenders übereinstimmen, und legt eine Richtlinie für Fehler fest",
|
||||
"policy": "Richtlinie",
|
||||
"result": {
|
||||
"pass": "Bestanden",
|
||||
"fail": "Fehlgeschlagen",
|
||||
"softfail": "Weicher Fehlschlag",
|
||||
"neutral": "Neutral",
|
||||
"permerror": "Permanenter Fehler",
|
||||
"temperror": "Vorübergehender Fehler",
|
||||
"none": "Keine"
|
||||
}
|
||||
},
|
||||
"headers": {
|
||||
"routing": "Routing",
|
||||
@@ -424,12 +443,39 @@
|
||||
"cancel_info": "Der Organisator hat diesen Termin abgesagt.",
|
||||
"event_updated": "Aktualisierung #{sequence}",
|
||||
"event_status_tentative": "Vorläufig",
|
||||
"event_status_cancelled": "Abgesagt"
|
||||
"event_status_cancelled": "Abgesagt",
|
||||
"collapse": "Details ausblenden",
|
||||
"expand": "Details anzeigen"
|
||||
},
|
||||
"previous": "Zurück",
|
||||
"next": "Weiter",
|
||||
"send": "Senden",
|
||||
"more": "mehr"
|
||||
"more": "mehr",
|
||||
"details": {
|
||||
"recipients_routing": "Empfänger & Weiterleitung",
|
||||
"authentication_security": "Authentifizierung & Sicherheit",
|
||||
"identifiers_threading": "Kennungen & Threading",
|
||||
"mailing_list": "Mailingliste",
|
||||
"message_properties": "Nachrichteneigenschaften",
|
||||
"sent": "Gesendet",
|
||||
"received": "Empfangen",
|
||||
"delivery_time": "Zustellzeit",
|
||||
"in_reply_to": "In-Reply-To",
|
||||
"references": "Verweise",
|
||||
"thread_id": "Thread-ID",
|
||||
"size": "Größe",
|
||||
"mime_type": "MIME-Typ",
|
||||
"attachments_summary": "{count} Dateien · {size}",
|
||||
"list_id": "Listen-ID",
|
||||
"list_help": "Listenhilfe",
|
||||
"list_post": "Listenbeitrag",
|
||||
"list_unsubscribe": "Abmelden",
|
||||
"iprev": "Reverse DNS",
|
||||
"spam_status": "Spam-Status",
|
||||
"ai_verdict": "KI-Urteil",
|
||||
"account": "Konto",
|
||||
"no_subject": "(kein Betreff)"
|
||||
}
|
||||
},
|
||||
"email_composer": {
|
||||
"new_message": "Neue Nachricht",
|
||||
@@ -508,7 +554,10 @@
|
||||
"message": "Ihre Nachricht enthält \"{keyword}\", aber es ist keine Datei angehängt. Trotzdem senden?",
|
||||
"send_anyway": "Trotzdem senden",
|
||||
"back": "Zurück zur Bearbeitung"
|
||||
}
|
||||
},
|
||||
"add_link": "Link hinzufügen",
|
||||
"link_url_prompt": "URL eingeben",
|
||||
"sending": "Wird gesendet..."
|
||||
},
|
||||
"confirm_dialog": {
|
||||
"confirm": "Bestätigen",
|
||||
@@ -801,6 +850,23 @@
|
||||
"sound_desc": "Einen Ton für Kalendererinnerungen abspielen",
|
||||
"invitation_parsing": "E-Mail-Einladungen erkennen",
|
||||
"invitation_parsing_desc": "Kalendereinladungen in E-Mail-Anhängen erkennen und Kalenderaktionen anzeigen"
|
||||
},
|
||||
"push": {
|
||||
"confirm_disable_message": "Dieses Gerät erhält keine Benachrichtigungen mehr, wenn die Seite geschlossen ist.",
|
||||
"confirm_disable_title": "Hintergrundbenachrichtigungen deaktivieren?",
|
||||
"description": "Systembenachrichtigungen für neue E-Mails empfangen, wenn diese Seite geschlossen ist. Zustellung über das Bulwark Push-Relay; das Relay sieht niemals E-Mail-Inhalte.",
|
||||
"disable": "Deaktivieren",
|
||||
"enable": "Aktivieren",
|
||||
"ios_hint": "Installieren Sie die Seite unter iOS zuerst auf dem Startbildschirm – Safari liefert Web Push nur an installierte PWAs.",
|
||||
"reenable": "Neu registrieren",
|
||||
"relay_desc": "Standardmäßig wird das gehostete Bulwark-Relay verwendet. Nur ändern, wenn Sie selbst hosten.",
|
||||
"relay_label": "Push-Relay",
|
||||
"relay_placeholder": "https://notifications.relay.example.com",
|
||||
"status_active": "Auf diesem Gerät aktiv",
|
||||
"status_busy": "Wird verarbeitet…",
|
||||
"status_inactive": "Auf diesem Gerät nicht aktiviert",
|
||||
"status_unsupported": "Dieser Browser unterstützt Web Push nicht",
|
||||
"title": "Hintergrundbenachrichtigungen"
|
||||
}
|
||||
},
|
||||
"language_region": {
|
||||
@@ -889,6 +955,13 @@
|
||||
"label": "Antwortadresse automatisch wählen",
|
||||
"description": "Beim Antworten die Absenderadresse automatisch auf die Identität umstellen, die die ursprüngliche Nachricht erhalten hat"
|
||||
},
|
||||
"sub_address_delimiter": {
|
||||
"label": "Sub-Adress-Trennzeichen",
|
||||
"description": "Zeichen, das Ihren Benutzernamen vom Sub-Adress-Tag trennt. Verwenden Sie das von Ihrem Mailserver verwendete Trennzeichen (z. B. benutzer{delimiter}tag@domain.de).",
|
||||
"option": "{delimiter} (benutzer{delimiter}tag@domain.de)",
|
||||
"custom": "Benutzerdefiniert…",
|
||||
"custom_input_label": "Benutzerdefiniertes Trennzeichen"
|
||||
},
|
||||
"attachment_click_action": {
|
||||
"label": "Aktion beim Klick auf Anhänge",
|
||||
"description": "Festlegen, ob ein Dateianhang beim Anklicken in der Vorschau geöffnet oder sofort heruntergeladen wird",
|
||||
@@ -980,6 +1053,10 @@
|
||||
"hide_inline_image_attachments": {
|
||||
"label": "Eingebettete Bilder in Anhangsliste ausblenden",
|
||||
"description": "Im Nachrichtentext eingebettete Bilder werden nicht als separate Anhänge aufgeführt"
|
||||
},
|
||||
"attachment_image_previews": {
|
||||
"label": "Bildvorschau in Anhängen anzeigen",
|
||||
"description": "Bildanhänge als Miniaturansichten statt als generische Dateisymbole anzeigen"
|
||||
}
|
||||
},
|
||||
"composer": {
|
||||
@@ -1333,7 +1410,9 @@
|
||||
"no_address_books": "Keine Adressbücher gefunden",
|
||||
"categories_title": "Kategorien",
|
||||
"categories_description": "Kontaktkategorien umbenennen",
|
||||
"no_categories": "Keine Kategorien gefunden"
|
||||
"no_categories": "Keine Kategorien gefunden",
|
||||
"group_by_letter_description": "Alphabetische Abschnittsüberschriften in der Kontaktliste anzeigen",
|
||||
"group_by_letter_label": "Nach Anfangsbuchstaben gruppieren"
|
||||
},
|
||||
"filters": {
|
||||
"title": "E-Mail-Filter",
|
||||
@@ -1732,7 +1811,7 @@
|
||||
"use_address": "Diese Adresse verwenden",
|
||||
"invalid_tag": "Tag darf nur alphanumerisch und Bindestriche enthalten",
|
||||
"tag_too_long": "Tag darf maximal 30 Zeichen lang sein",
|
||||
"help_text": "E-Mails an benutzer+tag@domain.de werden in Ihrem Posteingang ankommen",
|
||||
"help_text": "E-Mails an benutzer{delimiter}tag@domain.de werden in Ihrem Posteingang ankommen",
|
||||
"validation": {
|
||||
"empty": "Tag darf nicht leer sein",
|
||||
"too_long": "Tag darf maximal {max} Zeichen lang sein",
|
||||
@@ -2129,7 +2208,11 @@
|
||||
"tomorrow_header": "Morgen",
|
||||
"export_ics": "Als .ics exportieren",
|
||||
"copy_title": "Titel kopieren",
|
||||
"copy_link": "Meeting-Link kopieren"
|
||||
"copy_link": "Meeting-Link kopieren",
|
||||
"go_to_today": "Zu heute",
|
||||
"new_all_day_event": "Neuer ganztägiger Termin",
|
||||
"new_event": "Neuer Termin",
|
||||
"new_task": "Neue Aufgabe"
|
||||
},
|
||||
"detail": {
|
||||
"add_note": "Notiz hinzufügen...",
|
||||
@@ -2428,6 +2511,20 @@
|
||||
"due_today": "Heute fällig",
|
||||
"due_tomorrow": "Morgen fällig",
|
||||
"overdue": "Überfällig"
|
||||
},
|
||||
"months": {
|
||||
"jan": "Jan",
|
||||
"feb": "Feb",
|
||||
"mar": "Mär",
|
||||
"apr": "Apr",
|
||||
"may": "Mai",
|
||||
"jun": "Jun",
|
||||
"jul": "Jul",
|
||||
"aug": "Aug",
|
||||
"sep": "Sep",
|
||||
"oct": "Okt",
|
||||
"nov": "Nov",
|
||||
"dec": "Dez"
|
||||
}
|
||||
},
|
||||
"advanced_search": {
|
||||
|
||||
+90
-2
@@ -184,6 +184,7 @@
|
||||
"batch_actions": {
|
||||
"select": "Select emails",
|
||||
"select_all": "Select all",
|
||||
"selected_messages": "{count, plural, one {1 email} other {# emails}} selected",
|
||||
"mark_read": "Mark as read",
|
||||
"mark_unread": "Mark as unread",
|
||||
"delete": "Delete",
|
||||
@@ -223,6 +224,11 @@
|
||||
"unstar": "Unstar",
|
||||
"mark_unread": "Mark as unread",
|
||||
"mark_read": "Mark as read",
|
||||
"unread": "Unread",
|
||||
"read": "Read",
|
||||
"spam_short": "Spam",
|
||||
"not_spam_short": "Not spam",
|
||||
"move": "Move",
|
||||
"print": "Print",
|
||||
"view_source": "View source",
|
||||
"export_email": "Export as .eml",
|
||||
@@ -306,7 +312,45 @@
|
||||
"fail": "DMARC Fail",
|
||||
"none": "No DMARC"
|
||||
},
|
||||
"spam_score": "Spam Score"
|
||||
"spam_score": "Spam Score",
|
||||
"tooltip_spf": "Sender Policy Framework: Verifies that the sending server is authorized to send email on behalf of the domain",
|
||||
"tooltip_dkim": "DomainKeys Identified Mail: Confirms the email was not altered in transit using a cryptographic signature",
|
||||
"tooltip_dmarc": "Domain-based Message Authentication, Reporting & Conformance: Ensures SPF and DKIM align with the sender's domain and sets a policy for failures",
|
||||
"policy": "Policy",
|
||||
"result": {
|
||||
"pass": "Pass",
|
||||
"fail": "Fail",
|
||||
"softfail": "Soft fail",
|
||||
"neutral": "Neutral",
|
||||
"permerror": "Permanent error",
|
||||
"temperror": "Temporary error",
|
||||
"none": "None"
|
||||
}
|
||||
},
|
||||
"details": {
|
||||
"recipients_routing": "Recipients & routing",
|
||||
"authentication_security": "Authentication & security",
|
||||
"identifiers_threading": "Identifiers & threading",
|
||||
"mailing_list": "Mailing list",
|
||||
"message_properties": "Message properties",
|
||||
"sent": "Sent",
|
||||
"received": "Received",
|
||||
"delivery_time": "Delivery time",
|
||||
"in_reply_to": "In-Reply-To",
|
||||
"references": "References",
|
||||
"thread_id": "Thread ID",
|
||||
"size": "Size",
|
||||
"mime_type": "MIME type",
|
||||
"attachments_summary": "{count} files · {size}",
|
||||
"list_id": "List ID",
|
||||
"list_help": "List help",
|
||||
"list_post": "List post",
|
||||
"list_unsubscribe": "Unsubscribe",
|
||||
"iprev": "Reverse DNS",
|
||||
"spam_status": "Spam status",
|
||||
"ai_verdict": "AI verdict",
|
||||
"account": "Account",
|
||||
"no_subject": "(no subject)"
|
||||
},
|
||||
"headers": {
|
||||
"routing": "Routing",
|
||||
@@ -450,6 +494,8 @@
|
||||
"send": "Send",
|
||||
"cancel": "Cancel",
|
||||
"attach": "Attach",
|
||||
"attach_photos": "Photos & Videos",
|
||||
"attach_files": "Files",
|
||||
"discard": "Discard",
|
||||
"discard_draft_title": "Discard draft?",
|
||||
"discard_draft_confirm": "You have unsaved changes. Do you want to discard this draft?",
|
||||
@@ -783,6 +829,23 @@
|
||||
"swift": "Swift Gesture",
|
||||
"relax": "Relax"
|
||||
},
|
||||
"push": {
|
||||
"title": "Background Notifications",
|
||||
"description": "Receive system notifications for new mail when this site is closed. Delivered via the Bulwark push relay; the relay never sees mail content.",
|
||||
"relay_label": "Push relay",
|
||||
"relay_desc": "Defaults to the hosted Bulwark relay. Change only if you self-host.",
|
||||
"relay_placeholder": "https://notifications.relay.example.com",
|
||||
"status_active": "Active on this device",
|
||||
"status_inactive": "Not enabled on this device",
|
||||
"status_unsupported": "This browser does not support Web Push",
|
||||
"status_busy": "Working…",
|
||||
"enable": "Enable",
|
||||
"reenable": "Re-register",
|
||||
"disable": "Disable",
|
||||
"confirm_disable_title": "Disable background notifications?",
|
||||
"confirm_disable_message": "This device will stop receiving alerts when the site is closed.",
|
||||
"ios_hint": "On iOS, install the site to your home screen first - Safari only delivers Web Push to installed PWAs."
|
||||
},
|
||||
"sound_selection": {
|
||||
"title": "Notification Sound",
|
||||
"description": "Choose which sound to play for notifications",
|
||||
@@ -894,6 +957,13 @@
|
||||
"label": "Auto-select Reply Address",
|
||||
"description": "When replying, automatically switch the From address to the identity that originally received the message"
|
||||
},
|
||||
"sub_address_delimiter": {
|
||||
"label": "Sub-Address Delimiter",
|
||||
"description": "Character separating your username from a sub-address tag. Match the delimiter your mail server uses (e.g. user{delimiter}tag@domain.com).",
|
||||
"option": "{delimiter} (user{delimiter}tag@domain.com)",
|
||||
"custom": "Custom…",
|
||||
"custom_input_label": "Custom delimiter character"
|
||||
},
|
||||
"attachment_click_action": {
|
||||
"label": "Attachment Click Action",
|
||||
"description": "Choose whether clicking a file attachment previews it or downloads it immediately",
|
||||
@@ -985,6 +1055,10 @@
|
||||
"hide_inline_image_attachments": {
|
||||
"label": "Hide inline images from attachments",
|
||||
"description": "Images embedded in the message body are not listed as separate attachments"
|
||||
},
|
||||
"attachment_image_previews": {
|
||||
"label": "Show image previews in attachments",
|
||||
"description": "Render image attachments as thumbnail cards instead of generic file icons"
|
||||
}
|
||||
},
|
||||
"composer": {
|
||||
@@ -1739,7 +1813,7 @@
|
||||
"use_address": "Use This Address",
|
||||
"invalid_tag": "Tag must be alphanumeric and dashes only",
|
||||
"tag_too_long": "Tag must be 30 characters or less",
|
||||
"help_text": "Emails sent to user+tag@domain.com will arrive in your inbox",
|
||||
"help_text": "Emails sent to user{delimiter}tag@domain.com will arrive in your inbox",
|
||||
"validation": {
|
||||
"empty": "Tag cannot be empty",
|
||||
"too_long": "Tag must be {max} characters or less",
|
||||
@@ -2272,6 +2346,20 @@
|
||||
"sat": "Sat",
|
||||
"sun": "Sun"
|
||||
},
|
||||
"months": {
|
||||
"jan": "Jan",
|
||||
"feb": "Feb",
|
||||
"mar": "Mar",
|
||||
"apr": "Apr",
|
||||
"may": "May",
|
||||
"jun": "Jun",
|
||||
"jul": "Jul",
|
||||
"aug": "Aug",
|
||||
"sep": "Sep",
|
||||
"oct": "Oct",
|
||||
"nov": "Nov",
|
||||
"dec": "Dec"
|
||||
},
|
||||
"notifications": {
|
||||
"event_created": "Event created",
|
||||
"event_updated": "Event updated",
|
||||
|
||||
+139
-42
@@ -184,6 +184,7 @@
|
||||
"batch_actions": {
|
||||
"select": "Seleccionar correos",
|
||||
"select_all": "Seleccionar todo",
|
||||
"selected_messages": "{count, plural, one {1 correo electrónico seleccionado} other {# correos electrónicos seleccionados}}",
|
||||
"mark_read": "Marcar como leído",
|
||||
"mark_unread": "Marcar como no leído",
|
||||
"delete": "Eliminar",
|
||||
@@ -223,6 +224,11 @@
|
||||
"unstar": "Quitar destacado",
|
||||
"mark_unread": "Marcar como no leído",
|
||||
"mark_read": "Marcar como leído",
|
||||
"unread": "No leído",
|
||||
"read": "Leído",
|
||||
"spam_short": "Spam",
|
||||
"not_spam_short": "No spam",
|
||||
"move": "Mover",
|
||||
"print": "Imprimir",
|
||||
"view_source": "Ver código fuente",
|
||||
"export_email": "Exportar como .eml",
|
||||
@@ -304,7 +310,20 @@
|
||||
"fail": "DMARC Fallido",
|
||||
"none": "Sin DMARC"
|
||||
},
|
||||
"spam_score": "Puntuación de Spam"
|
||||
"spam_score": "Puntuación de Spam",
|
||||
"tooltip_spf": "Sender Policy Framework: verifica que el servidor de envío esté autorizado a enviar correo en nombre del dominio",
|
||||
"tooltip_dkim": "DomainKeys Identified Mail: confirma mediante una firma criptográfica que el correo no se alteró durante el transporte",
|
||||
"tooltip_dmarc": "Domain-based Message Authentication, Reporting & Conformance: garantiza que SPF y DKIM se alineen con el dominio del remitente y establece una política para los fallos",
|
||||
"policy": "Política",
|
||||
"result": {
|
||||
"pass": "Aprobado",
|
||||
"fail": "Fallido",
|
||||
"softfail": "Fallo leve",
|
||||
"neutral": "Neutro",
|
||||
"permerror": "Error permanente",
|
||||
"temperror": "Error temporal",
|
||||
"none": "Ninguno"
|
||||
}
|
||||
},
|
||||
"headers": {
|
||||
"routing": "Enrutamiento",
|
||||
@@ -424,12 +443,39 @@
|
||||
"cancel_info": "El organizador ha cancelado este evento.",
|
||||
"event_updated": "Actualización #{sequence}",
|
||||
"event_status_tentative": "Provisional",
|
||||
"event_status_cancelled": "Cancelado"
|
||||
"event_status_cancelled": "Cancelado",
|
||||
"collapse": "Ocultar detalles",
|
||||
"expand": "Mostrar detalles"
|
||||
},
|
||||
"previous": "Anterior",
|
||||
"next": "Siguiente",
|
||||
"send": "Enviar",
|
||||
"more": "más"
|
||||
"more": "más",
|
||||
"details": {
|
||||
"recipients_routing": "Destinatarios y enrutamiento",
|
||||
"authentication_security": "Autenticación y seguridad",
|
||||
"identifiers_threading": "Identificadores y conversación",
|
||||
"mailing_list": "Lista de correo",
|
||||
"message_properties": "Propiedades del mensaje",
|
||||
"sent": "Enviado",
|
||||
"received": "Recibido",
|
||||
"delivery_time": "Tiempo de entrega",
|
||||
"in_reply_to": "En respuesta a",
|
||||
"references": "Referencias",
|
||||
"thread_id": "ID de conversación",
|
||||
"size": "Tamaño",
|
||||
"mime_type": "Tipo MIME",
|
||||
"attachments_summary": "{count} archivos · {size}",
|
||||
"list_id": "ID de lista",
|
||||
"list_help": "Ayuda de lista",
|
||||
"list_post": "Publicación en lista",
|
||||
"list_unsubscribe": "Darse de baja",
|
||||
"iprev": "DNS inverso",
|
||||
"spam_status": "Estado de spam",
|
||||
"ai_verdict": "Veredicto de IA",
|
||||
"account": "Cuenta",
|
||||
"no_subject": "(sin asunto)"
|
||||
}
|
||||
},
|
||||
"email_composer": {
|
||||
"new_message": "Nuevo Mensaje",
|
||||
@@ -508,7 +554,10 @@
|
||||
"message": "Tu mensaje menciona \"{keyword}\" pero no hay ningún archivo adjunto. ¿Enviar de todos modos?",
|
||||
"send_anyway": "Enviar de todos modos",
|
||||
"back": "Volver a editar"
|
||||
}
|
||||
},
|
||||
"add_link": "Añadir enlace",
|
||||
"link_url_prompt": "Introduce la URL",
|
||||
"sending": "Enviando..."
|
||||
},
|
||||
"confirm_dialog": {
|
||||
"confirm": "Confirmar",
|
||||
@@ -801,6 +850,23 @@
|
||||
"sound_desc": "Reproducir un sonido para recordatorios del calendario",
|
||||
"invitation_parsing": "Analizar invitaciones por correo",
|
||||
"invitation_parsing_desc": "Detectar invitaciones de calendario en archivos adjuntos y mostrar acciones de calendario"
|
||||
},
|
||||
"push": {
|
||||
"confirm_disable_message": "Este dispositivo dejará de recibir alertas cuando el sitio esté cerrado.",
|
||||
"confirm_disable_title": "¿Desactivar las notificaciones en segundo plano?",
|
||||
"description": "Recibe notificaciones del sistema para correo nuevo cuando este sitio está cerrado. Se entrega a través del relay push de Bulwark; el relay nunca ve el contenido del correo.",
|
||||
"disable": "Desactivar",
|
||||
"enable": "Activar",
|
||||
"ios_hint": "En iOS, instala primero el sitio en la pantalla de inicio: Safari solo entrega Web Push a PWAs instaladas.",
|
||||
"reenable": "Volver a registrar",
|
||||
"relay_desc": "Usa el relay alojado de Bulwark de forma predeterminada. Cámbialo solo si te alojas tú mismo.",
|
||||
"relay_label": "Relay push",
|
||||
"relay_placeholder": "https://notifications.relay.example.com",
|
||||
"status_active": "Activo en este dispositivo",
|
||||
"status_busy": "Trabajando…",
|
||||
"status_inactive": "No habilitado en este dispositivo",
|
||||
"status_unsupported": "Este navegador no admite Web Push",
|
||||
"title": "Notificaciones en segundo plano"
|
||||
}
|
||||
},
|
||||
"language_region": {
|
||||
@@ -884,6 +950,13 @@
|
||||
"label": "Seleccionar dirección de respuesta automáticamente",
|
||||
"description": "Al responder, cambia automáticamente la dirección del remitente a la identidad que recibió el mensaje original"
|
||||
},
|
||||
"sub_address_delimiter": {
|
||||
"label": "Delimitador de sub-dirección",
|
||||
"description": "Carácter que separa tu nombre de usuario de la etiqueta de sub-dirección. Usa el delimitador que utilice tu servidor de correo (por ejemplo, usuario{delimiter}etiqueta@dominio.com).",
|
||||
"option": "{delimiter} (usuario{delimiter}etiqueta@dominio.com)",
|
||||
"custom": "Personalizado…",
|
||||
"custom_input_label": "Carácter delimitador personalizado"
|
||||
},
|
||||
"show_preview": {
|
||||
"label": "Mostrar Vista Previa",
|
||||
"description": "Mostrar vista previa del correo en la lista",
|
||||
@@ -980,6 +1053,10 @@
|
||||
"hide_inline_image_attachments": {
|
||||
"label": "Ocultar imágenes incrustadas de los adjuntos",
|
||||
"description": "Las imágenes incrustadas en el cuerpo del mensaje no se listan como adjuntos separados"
|
||||
},
|
||||
"attachment_image_previews": {
|
||||
"label": "Mostrar vistas previas de imágenes en adjuntos",
|
||||
"description": "Mostrar las imágenes adjuntas como tarjetas de miniatura en lugar de iconos de archivo genéricos"
|
||||
}
|
||||
},
|
||||
"composer": {
|
||||
@@ -1333,7 +1410,9 @@
|
||||
"no_address_books": "No se encontraron libretas de direcciones",
|
||||
"categories_title": "Categorías",
|
||||
"categories_description": "Renombrar categorías de contactos",
|
||||
"no_categories": "No se encontraron categorías"
|
||||
"no_categories": "No se encontraron categorías",
|
||||
"group_by_letter_description": "Mostrar encabezados alfabéticos en la lista de contactos",
|
||||
"group_by_letter_label": "Agrupar por primera letra"
|
||||
},
|
||||
"filters": {
|
||||
"title": "Filtros de correo",
|
||||
@@ -1591,41 +1670,41 @@
|
||||
"edit_draft": "Editar borrador"
|
||||
},
|
||||
"mailbox_context_menu": {
|
||||
"mark_folder_read": "Mark folder as read",
|
||||
"mark_folder_tree_read": "Mark folder & subfolders as read",
|
||||
"mark_all_folders_read": "Mark all folders as read",
|
||||
"new_subfolder": "New subfolder...",
|
||||
"new_folder": "New folder...",
|
||||
"rename": "Rename...",
|
||||
"empty_folder": "Empty folder",
|
||||
"empty_folder_generic": "Empty folder",
|
||||
"delete_folder": "Delete folder",
|
||||
"refresh": "Refresh",
|
||||
"mark_all_confirm_title": "Mark all folders as read",
|
||||
"mark_all_confirm_message": "Mark every unread message in your personal account as read?",
|
||||
"delete_confirm_title": "Delete folder",
|
||||
"delete_confirm_message": "Permanently delete the folder \"{name}\"? This action cannot be undone.",
|
||||
"prompt_new_subfolder": "Enter a name for the new subfolder.",
|
||||
"prompt_new_folder": "Enter a name for the new folder.",
|
||||
"prompt_rename": "Enter a new name for this folder.",
|
||||
"toast_marked_read": "Folder marked as read",
|
||||
"toast_marked_read_count": "Marked {count, plural, one {1 message} other {# messages}} as read",
|
||||
"toast_already_read": "No unread messages",
|
||||
"toast_marked_all_read": "All folders marked as read",
|
||||
"toast_emptied": "Folder emptied",
|
||||
"toast_folder_created": "Folder created",
|
||||
"toast_folder_renamed": "Folder renamed",
|
||||
"toast_folder_deleted": "Folder deleted",
|
||||
"toast_error_mark_read": "Failed to mark as read",
|
||||
"toast_error_empty": "Failed to empty folder",
|
||||
"toast_error_create": "Failed to create folder",
|
||||
"toast_error_rename": "Failed to rename folder",
|
||||
"toast_error_delete": "Failed to delete folder",
|
||||
"toast_error_delete_has_children": "Folder has subfolders. Remove them first.",
|
||||
"toast_error_delete_has_email": "Folder is not empty. Empty it first.",
|
||||
"placeholder_folder_name": "Folder name",
|
||||
"create": "Create",
|
||||
"rename_confirm": "Rename"
|
||||
"mark_folder_read": "Marcar carpeta como leída",
|
||||
"mark_folder_tree_read": "Marcar carpeta y subcarpetas como leídas",
|
||||
"mark_all_folders_read": "Marcar todas las carpetas como leídas",
|
||||
"new_subfolder": "Nueva subcarpeta...",
|
||||
"new_folder": "Nueva carpeta...",
|
||||
"rename": "Renombrar...",
|
||||
"empty_folder": "Vaciar carpeta",
|
||||
"empty_folder_generic": "Vaciar carpeta",
|
||||
"delete_folder": "Eliminar carpeta",
|
||||
"refresh": "Actualizar",
|
||||
"mark_all_confirm_title": "Marcar todas las carpetas como leídas",
|
||||
"mark_all_confirm_message": "¿Marcar todos los mensajes no leídos de su cuenta personal como leídos?",
|
||||
"delete_confirm_title": "Eliminar carpeta",
|
||||
"delete_confirm_message": "¿Eliminar permanentemente la carpeta \"{name}\"? Esta acción no se puede deshacer.",
|
||||
"prompt_new_subfolder": "Ingrese un nombre para la nueva subcarpeta.",
|
||||
"prompt_new_folder": "Ingrese un nombre para la nueva carpeta.",
|
||||
"prompt_rename": "Ingrese un nuevo nombre para esta carpeta.",
|
||||
"toast_marked_read": "Carpeta marcada como leída",
|
||||
"toast_marked_read_count": "Se marcaron {count, plural, one {1 mensaje} other {# mensajes}} como leídos",
|
||||
"toast_already_read": "No hay mensajes no leídos",
|
||||
"toast_marked_all_read": "Todas las carpetas marcadas como leídas",
|
||||
"toast_emptied": "Carpeta vaciada",
|
||||
"toast_folder_created": "Carpeta creada",
|
||||
"toast_folder_renamed": "Carpeta renombrada",
|
||||
"toast_folder_deleted": "Carpeta eliminada",
|
||||
"toast_error_mark_read": "Error al marcar como leído",
|
||||
"toast_error_empty": "Error al vaciar la carpeta",
|
||||
"toast_error_create": "Error al crear la carpeta",
|
||||
"toast_error_rename": "Error al renombrar la carpeta",
|
||||
"toast_error_delete": "Error al eliminar la carpeta",
|
||||
"toast_error_delete_has_children": "La carpeta tiene subcarpetas. Elimínelas primero.",
|
||||
"toast_error_delete_has_email": "La carpeta no está vacía. Vacíela primero.",
|
||||
"placeholder_folder_name": "Nombre de carpeta",
|
||||
"create": "Crear",
|
||||
"rename_confirm": "Renombrar"
|
||||
},
|
||||
"shortcuts": {
|
||||
"title": "Atajos de Teclado",
|
||||
@@ -1732,7 +1811,7 @@
|
||||
"use_address": "Usar Esta Dirección",
|
||||
"invalid_tag": "La etiqueta debe ser solo alfanumérica y guiones",
|
||||
"tag_too_long": "La etiqueta debe tener 30 caracteres o menos",
|
||||
"help_text": "Los correos enviados a usuario+etiqueta@dominio.com llegarán a su bandeja de entrada",
|
||||
"help_text": "Los correos enviados a usuario{delimiter}etiqueta@dominio.com llegarán a su bandeja de entrada",
|
||||
"validation": {
|
||||
"empty": "La etiqueta no puede estar vacía",
|
||||
"too_long": "La etiqueta debe tener {max} caracteres o menos",
|
||||
@@ -2129,7 +2208,11 @@
|
||||
"tomorrow_header": "Mañana",
|
||||
"export_ics": "Exportar como .ics",
|
||||
"copy_title": "Copiar título",
|
||||
"copy_link": "Copiar enlace de reunión"
|
||||
"copy_link": "Copiar enlace de reunión",
|
||||
"go_to_today": "Ir a hoy",
|
||||
"new_all_day_event": "Nuevo evento de todo el día",
|
||||
"new_event": "Nuevo evento",
|
||||
"new_task": "Nueva tarea"
|
||||
},
|
||||
"detail": {
|
||||
"add_note": "Añadir una nota...",
|
||||
@@ -2428,6 +2511,20 @@
|
||||
"due_today": "Vence hoy",
|
||||
"due_tomorrow": "Vence mañana",
|
||||
"overdue": "Vencida"
|
||||
},
|
||||
"months": {
|
||||
"jan": "Ene",
|
||||
"feb": "Feb",
|
||||
"mar": "Mar",
|
||||
"apr": "Abr",
|
||||
"may": "May",
|
||||
"jun": "Jun",
|
||||
"jul": "Jul",
|
||||
"aug": "Ago",
|
||||
"sep": "Sep",
|
||||
"oct": "Oct",
|
||||
"nov": "Nov",
|
||||
"dec": "Dic"
|
||||
}
|
||||
},
|
||||
"advanced_search": {
|
||||
|
||||
+139
-42
@@ -184,6 +184,7 @@
|
||||
"batch_actions": {
|
||||
"select": "Sélectionner les e-mails",
|
||||
"select_all": "Tout sélectionner",
|
||||
"selected_messages": "{count, plural, one {1 e-mail sélectionné} other {# e-mails sélectionnés}}",
|
||||
"mark_read": "Marquer comme lu",
|
||||
"mark_unread": "Marquer comme non lu",
|
||||
"delete": "Supprimer",
|
||||
@@ -223,6 +224,11 @@
|
||||
"unstar": "Retirer des favoris",
|
||||
"mark_unread": "Marquer comme non lu",
|
||||
"mark_read": "Marquer comme lu",
|
||||
"unread": "Non lu",
|
||||
"read": "Lu",
|
||||
"spam_short": "Spam",
|
||||
"not_spam_short": "Non spam",
|
||||
"move": "Déplacer",
|
||||
"print": "Imprimer",
|
||||
"view_source": "Voir la source",
|
||||
"export_email": "Exporter en .eml",
|
||||
@@ -304,7 +310,20 @@
|
||||
"fail": "DMARC Échoué",
|
||||
"none": "Pas de DMARC"
|
||||
},
|
||||
"spam_score": "Score de spam"
|
||||
"spam_score": "Score de spam",
|
||||
"tooltip_spf": "Sender Policy Framework : vérifie que le serveur d'envoi est autorisé à envoyer des e-mails au nom du domaine",
|
||||
"tooltip_dkim": "DomainKeys Identified Mail : confirme que l'e-mail n'a pas été modifié en transit grâce à une signature cryptographique",
|
||||
"tooltip_dmarc": "Domain-based Message Authentication, Reporting & Conformance : garantit l'alignement de SPF et DKIM avec le domaine de l'expéditeur et définit une politique en cas d'échec",
|
||||
"policy": "Politique",
|
||||
"result": {
|
||||
"pass": "Validé",
|
||||
"fail": "Échoué",
|
||||
"softfail": "Échec partiel",
|
||||
"neutral": "Neutre",
|
||||
"permerror": "Erreur permanente",
|
||||
"temperror": "Erreur temporaire",
|
||||
"none": "Aucun"
|
||||
}
|
||||
},
|
||||
"headers": {
|
||||
"routing": "Routage",
|
||||
@@ -424,12 +443,39 @@
|
||||
"cancel_info": "L'organisateur a annulé cet événement.",
|
||||
"event_updated": "Mise à jour #{sequence}",
|
||||
"event_status_tentative": "Provisoire",
|
||||
"event_status_cancelled": "Annulé"
|
||||
"event_status_cancelled": "Annulé",
|
||||
"collapse": "Masquer les détails",
|
||||
"expand": "Afficher les détails"
|
||||
},
|
||||
"previous": "Précédent",
|
||||
"next": "Suivant",
|
||||
"send": "Envoyer",
|
||||
"more": "plus"
|
||||
"more": "plus",
|
||||
"details": {
|
||||
"recipients_routing": "Destinataires & routage",
|
||||
"authentication_security": "Authentification & sécurité",
|
||||
"identifiers_threading": "Identifiants & fil de discussion",
|
||||
"mailing_list": "Liste de diffusion",
|
||||
"message_properties": "Propriétés du message",
|
||||
"sent": "Envoyé",
|
||||
"received": "Reçu",
|
||||
"delivery_time": "Délai de livraison",
|
||||
"in_reply_to": "En-réponse-à",
|
||||
"references": "Références",
|
||||
"thread_id": "ID de fil",
|
||||
"size": "Taille",
|
||||
"mime_type": "Type MIME",
|
||||
"attachments_summary": "{count} fichiers · {size}",
|
||||
"list_id": "ID de liste",
|
||||
"list_help": "Aide de liste",
|
||||
"list_post": "Publication de liste",
|
||||
"list_unsubscribe": "Se désinscrire",
|
||||
"iprev": "DNS inversé",
|
||||
"spam_status": "Statut spam",
|
||||
"ai_verdict": "Verdict IA",
|
||||
"account": "Compte",
|
||||
"no_subject": "(sans objet)"
|
||||
}
|
||||
},
|
||||
"email_composer": {
|
||||
"new_message": "Nouveau message",
|
||||
@@ -508,7 +554,10 @@
|
||||
"message": "Votre message mentionne \"{keyword}\" mais aucun fichier n'est joint. Envoyer quand même ?",
|
||||
"send_anyway": "Envoyer quand même",
|
||||
"back": "Retour à l'édition"
|
||||
}
|
||||
},
|
||||
"add_link": "Ajouter un lien",
|
||||
"link_url_prompt": "Saisissez l'URL",
|
||||
"sending": "Envoi..."
|
||||
},
|
||||
"confirm_dialog": {
|
||||
"confirm": "Confirmer",
|
||||
@@ -801,6 +850,23 @@
|
||||
"sound_desc": "Jouer un son pour les rappels de calendrier",
|
||||
"invitation_parsing": "Analyser les invitations par e-mail",
|
||||
"invitation_parsing_desc": "Détecter les invitations de calendrier dans les pièces jointes et afficher les actions de calendrier"
|
||||
},
|
||||
"push": {
|
||||
"confirm_disable_message": "Cet appareil cessera de recevoir des alertes lorsque le site est fermé.",
|
||||
"confirm_disable_title": "Désactiver les notifications en arrière-plan ?",
|
||||
"description": "Recevez des notifications système pour les nouveaux courriers quand ce site est fermé. Livré via le relais push Bulwark ; le relais ne voit jamais le contenu des courriers.",
|
||||
"disable": "Désactiver",
|
||||
"enable": "Activer",
|
||||
"ios_hint": "Sur iOS, installez d'abord le site sur l'écran d'accueil – Safari ne livre Web Push qu'aux PWA installées.",
|
||||
"reenable": "Réenregistrer",
|
||||
"relay_desc": "Utilise par défaut le relais Bulwark hébergé. Ne le changez que si vous l'hébergez vous-même.",
|
||||
"relay_label": "Relais push",
|
||||
"relay_placeholder": "https://notifications.relay.example.com",
|
||||
"status_active": "Actif sur cet appareil",
|
||||
"status_busy": "Traitement en cours…",
|
||||
"status_inactive": "Non activé sur cet appareil",
|
||||
"status_unsupported": "Ce navigateur ne prend pas en charge Web Push",
|
||||
"title": "Notifications en arrière-plan"
|
||||
}
|
||||
},
|
||||
"language_region": {
|
||||
@@ -884,6 +950,13 @@
|
||||
"label": "Sélection automatique de l'adresse de réponse",
|
||||
"description": "Lors d'une réponse, bascule automatiquement l'adresse d'expédition vers l'identité qui a reçu le message d'origine"
|
||||
},
|
||||
"sub_address_delimiter": {
|
||||
"label": "Délimiteur de sous-adresse",
|
||||
"description": "Caractère séparant votre nom d'utilisateur de l'étiquette de sous-adresse. Utilisez le délimiteur configuré sur votre serveur de messagerie (par ex. utilisateur{delimiter}tag@domaine.com).",
|
||||
"option": "{delimiter} (utilisateur{delimiter}tag@domaine.com)",
|
||||
"custom": "Personnalisé…",
|
||||
"custom_input_label": "Caractère de délimiteur personnalisé"
|
||||
},
|
||||
"show_preview": {
|
||||
"label": "Afficher l'aperçu",
|
||||
"description": "Afficher l'aperçu de l'email dans la liste",
|
||||
@@ -980,6 +1053,10 @@
|
||||
"hide_inline_image_attachments": {
|
||||
"label": "Masquer les images intégrées des pièces jointes",
|
||||
"description": "Les images intégrées au corps du message ne sont pas listées comme pièces jointes séparées"
|
||||
},
|
||||
"attachment_image_previews": {
|
||||
"label": "Afficher les aperçus d'images dans les pièces jointes",
|
||||
"description": "Afficher les images jointes sous forme de vignettes plutôt qu'avec des icônes de fichier génériques"
|
||||
}
|
||||
},
|
||||
"composer": {
|
||||
@@ -1333,7 +1410,9 @@
|
||||
"no_address_books": "Aucun carnet d'adresses trouvé",
|
||||
"categories_title": "Catégories",
|
||||
"categories_description": "Renommer les catégories de contacts",
|
||||
"no_categories": "Aucune catégorie trouvée"
|
||||
"no_categories": "Aucune catégorie trouvée",
|
||||
"group_by_letter_description": "Afficher des en-têtes alphabétiques dans la liste de contacts",
|
||||
"group_by_letter_label": "Grouper par première lettre"
|
||||
},
|
||||
"filters": {
|
||||
"title": "Filtres de courrier",
|
||||
@@ -1591,41 +1670,41 @@
|
||||
"edit_draft": "Modifier le brouillon"
|
||||
},
|
||||
"mailbox_context_menu": {
|
||||
"mark_folder_read": "Mark folder as read",
|
||||
"mark_folder_tree_read": "Mark folder & subfolders as read",
|
||||
"mark_all_folders_read": "Mark all folders as read",
|
||||
"new_subfolder": "New subfolder...",
|
||||
"new_folder": "New folder...",
|
||||
"rename": "Rename...",
|
||||
"empty_folder": "Empty folder",
|
||||
"empty_folder_generic": "Empty folder",
|
||||
"delete_folder": "Delete folder",
|
||||
"refresh": "Refresh",
|
||||
"mark_all_confirm_title": "Mark all folders as read",
|
||||
"mark_all_confirm_message": "Mark every unread message in your personal account as read?",
|
||||
"delete_confirm_title": "Delete folder",
|
||||
"delete_confirm_message": "Permanently delete the folder \"{name}\"? This action cannot be undone.",
|
||||
"prompt_new_subfolder": "Enter a name for the new subfolder.",
|
||||
"prompt_new_folder": "Enter a name for the new folder.",
|
||||
"prompt_rename": "Enter a new name for this folder.",
|
||||
"toast_marked_read": "Folder marked as read",
|
||||
"toast_marked_read_count": "Marked {count, plural, one {1 message} other {# messages}} as read",
|
||||
"toast_already_read": "No unread messages",
|
||||
"toast_marked_all_read": "All folders marked as read",
|
||||
"toast_emptied": "Folder emptied",
|
||||
"toast_folder_created": "Folder created",
|
||||
"toast_folder_renamed": "Folder renamed",
|
||||
"toast_folder_deleted": "Folder deleted",
|
||||
"toast_error_mark_read": "Failed to mark as read",
|
||||
"toast_error_empty": "Failed to empty folder",
|
||||
"toast_error_create": "Failed to create folder",
|
||||
"toast_error_rename": "Failed to rename folder",
|
||||
"toast_error_delete": "Failed to delete folder",
|
||||
"toast_error_delete_has_children": "Folder has subfolders. Remove them first.",
|
||||
"toast_error_delete_has_email": "Folder is not empty. Empty it first.",
|
||||
"placeholder_folder_name": "Folder name",
|
||||
"create": "Create",
|
||||
"rename_confirm": "Rename"
|
||||
"mark_folder_read": "Marquer le dossier comme lu",
|
||||
"mark_folder_tree_read": "Marquer le dossier et les sous-dossiers comme lus",
|
||||
"mark_all_folders_read": "Marquer tous les dossiers comme lus",
|
||||
"new_subfolder": "Nouveau sous-dossier...",
|
||||
"new_folder": "Nouveau dossier...",
|
||||
"rename": "Renommer...",
|
||||
"empty_folder": "Vider le dossier",
|
||||
"empty_folder_generic": "Vider le dossier",
|
||||
"delete_folder": "Supprimer le dossier",
|
||||
"refresh": "Actualiser",
|
||||
"mark_all_confirm_title": "Marquer tous les dossiers comme lus",
|
||||
"mark_all_confirm_message": "Marquer tous les messages non lus de votre compte personnel comme lus ?",
|
||||
"delete_confirm_title": "Supprimer le dossier",
|
||||
"delete_confirm_message": "Supprimer définitivement le dossier \"{name}\" ? Cette action est irréversible.",
|
||||
"prompt_new_subfolder": "Entrez un nom pour le nouveau sous-dossier.",
|
||||
"prompt_new_folder": "Entrez un nom pour le nouveau dossier.",
|
||||
"prompt_rename": "Entrez un nouveau nom pour ce dossier.",
|
||||
"toast_marked_read": "Dossier marqué comme lu",
|
||||
"toast_marked_read_count": "{count, plural, one {1 message marqué} other {# messages marqués}} comme lu(s)",
|
||||
"toast_already_read": "Aucun message non lu",
|
||||
"toast_marked_all_read": "Tous les dossiers marqués comme lus",
|
||||
"toast_emptied": "Dossier vidé",
|
||||
"toast_folder_created": "Dossier créé",
|
||||
"toast_folder_renamed": "Dossier renommé",
|
||||
"toast_folder_deleted": "Dossier supprimé",
|
||||
"toast_error_mark_read": "Échec du marquage comme lu",
|
||||
"toast_error_empty": "Échec du vidage du dossier",
|
||||
"toast_error_create": "Échec de la création du dossier",
|
||||
"toast_error_rename": "Échec du renommage du dossier",
|
||||
"toast_error_delete": "Échec de la suppression du dossier",
|
||||
"toast_error_delete_has_children": "Le dossier contient des sous-dossiers. Supprimez-les d'abord.",
|
||||
"toast_error_delete_has_email": "Le dossier n'est pas vide. Videz-le d'abord.",
|
||||
"placeholder_folder_name": "Nom du dossier",
|
||||
"create": "Créer",
|
||||
"rename_confirm": "Renommer"
|
||||
},
|
||||
"shortcuts": {
|
||||
"title": "Raccourcis clavier",
|
||||
@@ -1732,7 +1811,7 @@
|
||||
"use_address": "Utiliser cette adresse",
|
||||
"invalid_tag": "Le tag doit contenir uniquement des lettres, chiffres et tirets",
|
||||
"tag_too_long": "Le tag doit faire 30 caractères ou moins",
|
||||
"help_text": "Les emails envoyés à utilisateur+tag@domaine.com arriveront dans votre boîte de réception",
|
||||
"help_text": "Les emails envoyés à utilisateur{delimiter}tag@domaine.com arriveront dans votre boîte de réception",
|
||||
"validation": {
|
||||
"empty": "Le tag ne peut pas être vide",
|
||||
"too_long": "Le tag doit faire {max} caractères ou moins",
|
||||
@@ -2129,7 +2208,11 @@
|
||||
"tomorrow_header": "Demain",
|
||||
"export_ics": "Exporter en .ics",
|
||||
"copy_title": "Copier le titre",
|
||||
"copy_link": "Copier le lien de réunion"
|
||||
"copy_link": "Copier le lien de réunion",
|
||||
"go_to_today": "Aller à aujourd'hui",
|
||||
"new_all_day_event": "Nouvel événement sur la journée",
|
||||
"new_event": "Nouvel événement",
|
||||
"new_task": "Nouvelle tâche"
|
||||
},
|
||||
"detail": {
|
||||
"add_note": "Ajouter une note...",
|
||||
@@ -2261,6 +2344,20 @@
|
||||
"sat": "Sam",
|
||||
"sun": "Dim"
|
||||
},
|
||||
"months": {
|
||||
"jan": "janv.",
|
||||
"feb": "févr.",
|
||||
"mar": "mars",
|
||||
"apr": "avr.",
|
||||
"may": "mai",
|
||||
"jun": "juin",
|
||||
"jul": "juil.",
|
||||
"aug": "août",
|
||||
"sep": "sept.",
|
||||
"oct": "oct.",
|
||||
"nov": "nov.",
|
||||
"dec": "déc."
|
||||
},
|
||||
"notifications": {
|
||||
"event_created": "Événement créé",
|
||||
"event_updated": "Événement mis à jour",
|
||||
|
||||
+139
-42
@@ -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",
|
||||
@@ -1591,41 +1670,41 @@
|
||||
"edit_draft": "Modifica bozza"
|
||||
},
|
||||
"mailbox_context_menu": {
|
||||
"mark_folder_read": "Mark folder as read",
|
||||
"mark_folder_tree_read": "Mark folder & subfolders as read",
|
||||
"mark_all_folders_read": "Mark all folders as read",
|
||||
"new_subfolder": "New subfolder...",
|
||||
"new_folder": "New folder...",
|
||||
"rename": "Rename...",
|
||||
"empty_folder": "Empty folder",
|
||||
"empty_folder_generic": "Empty folder",
|
||||
"delete_folder": "Delete folder",
|
||||
"refresh": "Refresh",
|
||||
"mark_all_confirm_title": "Mark all folders as read",
|
||||
"mark_all_confirm_message": "Mark every unread message in your personal account as read?",
|
||||
"delete_confirm_title": "Delete folder",
|
||||
"delete_confirm_message": "Permanently delete the folder \"{name}\"? This action cannot be undone.",
|
||||
"prompt_new_subfolder": "Enter a name for the new subfolder.",
|
||||
"prompt_new_folder": "Enter a name for the new folder.",
|
||||
"prompt_rename": "Enter a new name for this folder.",
|
||||
"toast_marked_read": "Folder marked as read",
|
||||
"toast_marked_read_count": "Marked {count, plural, one {1 message} other {# messages}} as read",
|
||||
"toast_already_read": "No unread messages",
|
||||
"toast_marked_all_read": "All folders marked as read",
|
||||
"toast_emptied": "Folder emptied",
|
||||
"toast_folder_created": "Folder created",
|
||||
"toast_folder_renamed": "Folder renamed",
|
||||
"toast_folder_deleted": "Folder deleted",
|
||||
"toast_error_mark_read": "Failed to mark as read",
|
||||
"toast_error_empty": "Failed to empty folder",
|
||||
"toast_error_create": "Failed to create folder",
|
||||
"toast_error_rename": "Failed to rename folder",
|
||||
"toast_error_delete": "Failed to delete folder",
|
||||
"toast_error_delete_has_children": "Folder has subfolders. Remove them first.",
|
||||
"toast_error_delete_has_email": "Folder is not empty. Empty it first.",
|
||||
"placeholder_folder_name": "Folder name",
|
||||
"create": "Create",
|
||||
"rename_confirm": "Rename"
|
||||
"mark_folder_read": "Segna cartella come letta",
|
||||
"mark_folder_tree_read": "Segna cartella e sottocartelle come lette",
|
||||
"mark_all_folders_read": "Segna tutte le cartelle come lette",
|
||||
"new_subfolder": "Nuova sottocartella...",
|
||||
"new_folder": "Nuova cartella...",
|
||||
"rename": "Rinomina...",
|
||||
"empty_folder": "Svuota cartella",
|
||||
"empty_folder_generic": "Svuota cartella",
|
||||
"delete_folder": "Elimina cartella",
|
||||
"refresh": "Aggiorna",
|
||||
"mark_all_confirm_title": "Segna tutte le cartelle come lette",
|
||||
"mark_all_confirm_message": "Segnare tutti i messaggi non letti del tuo account personale come letti?",
|
||||
"delete_confirm_title": "Elimina cartella",
|
||||
"delete_confirm_message": "Eliminare definitivamente la cartella \"{name}\"? Questa azione non può essere annullata.",
|
||||
"prompt_new_subfolder": "Inserisci un nome per la nuova sottocartella.",
|
||||
"prompt_new_folder": "Inserisci un nome per la nuova cartella.",
|
||||
"prompt_rename": "Inserisci un nuovo nome per questa cartella.",
|
||||
"toast_marked_read": "Cartella segnata come letta",
|
||||
"toast_marked_read_count": "Segnati {count, plural, one {1 messaggio} other {# messaggi}} come letti",
|
||||
"toast_already_read": "Nessun messaggio non letto",
|
||||
"toast_marked_all_read": "Tutte le cartelle segnate come lette",
|
||||
"toast_emptied": "Cartella svuotata",
|
||||
"toast_folder_created": "Cartella creata",
|
||||
"toast_folder_renamed": "Cartella rinominata",
|
||||
"toast_folder_deleted": "Cartella eliminata",
|
||||
"toast_error_mark_read": "Impossibile segnare come letto",
|
||||
"toast_error_empty": "Impossibile svuotare la cartella",
|
||||
"toast_error_create": "Impossibile creare la cartella",
|
||||
"toast_error_rename": "Impossibile rinominare la cartella",
|
||||
"toast_error_delete": "Impossibile eliminare la cartella",
|
||||
"toast_error_delete_has_children": "La cartella contiene sottocartelle. Rimuoverle prima.",
|
||||
"toast_error_delete_has_email": "La cartella non è vuota. Svuotarla prima.",
|
||||
"placeholder_folder_name": "Nome cartella",
|
||||
"create": "Crea",
|
||||
"rename_confirm": "Rinomina"
|
||||
},
|
||||
"shortcuts": {
|
||||
"title": "Scorciatoie da tastiera",
|
||||
@@ -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": {
|
||||
|
||||
+139
-42
@@ -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": "メールフィルター",
|
||||
@@ -1591,41 +1670,41 @@
|
||||
"edit_draft": "下書きを編集"
|
||||
},
|
||||
"mailbox_context_menu": {
|
||||
"mark_folder_read": "Mark folder as read",
|
||||
"mark_folder_tree_read": "Mark folder & subfolders as read",
|
||||
"mark_all_folders_read": "Mark all folders as read",
|
||||
"new_subfolder": "New subfolder...",
|
||||
"new_folder": "New folder...",
|
||||
"rename": "Rename...",
|
||||
"empty_folder": "Empty folder",
|
||||
"empty_folder_generic": "Empty folder",
|
||||
"delete_folder": "Delete folder",
|
||||
"refresh": "Refresh",
|
||||
"mark_all_confirm_title": "Mark all folders as read",
|
||||
"mark_all_confirm_message": "Mark every unread message in your personal account as read?",
|
||||
"delete_confirm_title": "Delete folder",
|
||||
"delete_confirm_message": "Permanently delete the folder \"{name}\"? This action cannot be undone.",
|
||||
"prompt_new_subfolder": "Enter a name for the new subfolder.",
|
||||
"prompt_new_folder": "Enter a name for the new folder.",
|
||||
"prompt_rename": "Enter a new name for this folder.",
|
||||
"toast_marked_read": "Folder marked as read",
|
||||
"toast_marked_read_count": "Marked {count, plural, one {1 message} other {# messages}} as read",
|
||||
"toast_already_read": "No unread messages",
|
||||
"toast_marked_all_read": "All folders marked as read",
|
||||
"toast_emptied": "Folder emptied",
|
||||
"toast_folder_created": "Folder created",
|
||||
"toast_folder_renamed": "Folder renamed",
|
||||
"toast_folder_deleted": "Folder deleted",
|
||||
"toast_error_mark_read": "Failed to mark as read",
|
||||
"toast_error_empty": "Failed to empty folder",
|
||||
"toast_error_create": "Failed to create folder",
|
||||
"toast_error_rename": "Failed to rename folder",
|
||||
"toast_error_delete": "Failed to delete folder",
|
||||
"toast_error_delete_has_children": "Folder has subfolders. Remove them first.",
|
||||
"toast_error_delete_has_email": "Folder is not empty. Empty it first.",
|
||||
"placeholder_folder_name": "Folder name",
|
||||
"create": "Create",
|
||||
"rename_confirm": "Rename"
|
||||
"mark_folder_read": "フォルダーを既読にする",
|
||||
"mark_folder_tree_read": "フォルダーとサブフォルダーを既読にする",
|
||||
"mark_all_folders_read": "すべてのフォルダーを既読にする",
|
||||
"new_subfolder": "新しいサブフォルダー...",
|
||||
"new_folder": "新しいフォルダー...",
|
||||
"rename": "名前を変更...",
|
||||
"empty_folder": "フォルダーを空にする",
|
||||
"empty_folder_generic": "フォルダーを空にする",
|
||||
"delete_folder": "フォルダーを削除",
|
||||
"refresh": "更新",
|
||||
"mark_all_confirm_title": "すべてのフォルダーを既読にする",
|
||||
"mark_all_confirm_message": "個人アカウントのすべての未読メッセージを既読にしますか?",
|
||||
"delete_confirm_title": "フォルダーを削除",
|
||||
"delete_confirm_message": "フォルダー \"{name}\" を完全に削除しますか?この操作は元に戻せません。",
|
||||
"prompt_new_subfolder": "新しいサブフォルダーの名前を入力してください。",
|
||||
"prompt_new_folder": "新しいフォルダーの名前を入力してください。",
|
||||
"prompt_rename": "このフォルダーの新しい名前を入力してください。",
|
||||
"toast_marked_read": "フォルダーを既読にしました",
|
||||
"toast_marked_read_count": "{count, plural, one {1件のメッセージ} other {#件のメッセージ}}を既読にしました",
|
||||
"toast_already_read": "未読メッセージはありません",
|
||||
"toast_marked_all_read": "すべてのフォルダーを既読にしました",
|
||||
"toast_emptied": "フォルダーを空にしました",
|
||||
"toast_folder_created": "フォルダーを作成しました",
|
||||
"toast_folder_renamed": "フォルダー名を変更しました",
|
||||
"toast_folder_deleted": "フォルダーを削除しました",
|
||||
"toast_error_mark_read": "既読にできませんでした",
|
||||
"toast_error_empty": "フォルダーを空にできませんでした",
|
||||
"toast_error_create": "フォルダーを作成できませんでした",
|
||||
"toast_error_rename": "フォルダー名を変更できませんでした",
|
||||
"toast_error_delete": "フォルダーを削除できませんでした",
|
||||
"toast_error_delete_has_children": "フォルダーにサブフォルダーがあります。先に削除してください。",
|
||||
"toast_error_delete_has_email": "フォルダーが空ではありません。先に空にしてください。",
|
||||
"placeholder_folder_name": "フォルダー名",
|
||||
"create": "作成",
|
||||
"rename_confirm": "名前を変更"
|
||||
},
|
||||
"shortcuts": {
|
||||
"title": "キーボードショートカット",
|
||||
@@ -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": {
|
||||
|
||||
+139
-42
@@ -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": "이메일 필터",
|
||||
@@ -1591,41 +1670,41 @@
|
||||
"edit_draft": "임시보관 메일 수정"
|
||||
},
|
||||
"mailbox_context_menu": {
|
||||
"mark_folder_read": "Mark folder as read",
|
||||
"mark_folder_tree_read": "Mark folder & subfolders as read",
|
||||
"mark_all_folders_read": "Mark all folders as read",
|
||||
"new_subfolder": "New subfolder...",
|
||||
"new_folder": "New folder...",
|
||||
"rename": "Rename...",
|
||||
"empty_folder": "Empty folder",
|
||||
"empty_folder_generic": "Empty folder",
|
||||
"delete_folder": "Delete folder",
|
||||
"refresh": "Refresh",
|
||||
"mark_all_confirm_title": "Mark all folders as read",
|
||||
"mark_all_confirm_message": "Mark every unread message in your personal account as read?",
|
||||
"delete_confirm_title": "Delete folder",
|
||||
"delete_confirm_message": "Permanently delete the folder \"{name}\"? This action cannot be undone.",
|
||||
"prompt_new_subfolder": "Enter a name for the new subfolder.",
|
||||
"prompt_new_folder": "Enter a name for the new folder.",
|
||||
"prompt_rename": "Enter a new name for this folder.",
|
||||
"toast_marked_read": "Folder marked as read",
|
||||
"toast_marked_read_count": "Marked {count, plural, one {1 message} other {# messages}} as read",
|
||||
"toast_already_read": "No unread messages",
|
||||
"toast_marked_all_read": "All folders marked as read",
|
||||
"toast_emptied": "Folder emptied",
|
||||
"toast_folder_created": "Folder created",
|
||||
"toast_folder_renamed": "Folder renamed",
|
||||
"toast_folder_deleted": "Folder deleted",
|
||||
"toast_error_mark_read": "Failed to mark as read",
|
||||
"toast_error_empty": "Failed to empty folder",
|
||||
"toast_error_create": "Failed to create folder",
|
||||
"toast_error_rename": "Failed to rename folder",
|
||||
"toast_error_delete": "Failed to delete folder",
|
||||
"toast_error_delete_has_children": "Folder has subfolders. Remove them first.",
|
||||
"toast_error_delete_has_email": "Folder is not empty. Empty it first.",
|
||||
"placeholder_folder_name": "Folder name",
|
||||
"create": "Create",
|
||||
"rename_confirm": "Rename"
|
||||
"mark_folder_read": "폴더를 읽음으로 표시",
|
||||
"mark_folder_tree_read": "폴더 및 하위 폴더를 읽음으로 표시",
|
||||
"mark_all_folders_read": "모든 폴더를 읽음으로 표시",
|
||||
"new_subfolder": "새 하위 폴더...",
|
||||
"new_folder": "새 폴더...",
|
||||
"rename": "이름 바꾸기...",
|
||||
"empty_folder": "폴더 비우기",
|
||||
"empty_folder_generic": "폴더 비우기",
|
||||
"delete_folder": "폴더 삭제",
|
||||
"refresh": "새로 고침",
|
||||
"mark_all_confirm_title": "모든 폴더를 읽음으로 표시",
|
||||
"mark_all_confirm_message": "개인 계정의 모든 읽지 않은 메시지를 읽음으로 표시하시겠습니까?",
|
||||
"delete_confirm_title": "폴더 삭제",
|
||||
"delete_confirm_message": "폴더 \"{name}\"을(를) 영구적으로 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.",
|
||||
"prompt_new_subfolder": "새 하위 폴더의 이름을 입력하세요.",
|
||||
"prompt_new_folder": "새 폴더의 이름을 입력하세요.",
|
||||
"prompt_rename": "이 폴더의 새 이름을 입력하세요.",
|
||||
"toast_marked_read": "폴더를 읽음으로 표시했습니다",
|
||||
"toast_marked_read_count": "{count, plural, one {메시지 1개} other {메시지 #개}}을(를) 읽음으로 표시했습니다",
|
||||
"toast_already_read": "읽지 않은 메시지가 없습니다",
|
||||
"toast_marked_all_read": "모든 폴더를 읽음으로 표시했습니다",
|
||||
"toast_emptied": "폴더를 비웠습니다",
|
||||
"toast_folder_created": "폴더가 생성되었습니다",
|
||||
"toast_folder_renamed": "폴더 이름이 변경되었습니다",
|
||||
"toast_folder_deleted": "폴더가 삭제되었습니다",
|
||||
"toast_error_mark_read": "읽음으로 표시하지 못했습니다",
|
||||
"toast_error_empty": "폴더를 비우지 못했습니다",
|
||||
"toast_error_create": "폴더를 생성하지 못했습니다",
|
||||
"toast_error_rename": "폴더 이름을 변경하지 못했습니다",
|
||||
"toast_error_delete": "폴더를 삭제하지 못했습니다",
|
||||
"toast_error_delete_has_children": "폴더에 하위 폴더가 있습니다. 먼저 제거하세요.",
|
||||
"toast_error_delete_has_email": "폴더가 비어 있지 않습니다. 먼저 비우세요.",
|
||||
"placeholder_folder_name": "폴더 이름",
|
||||
"create": "만들기",
|
||||
"rename_confirm": "이름 바꾸기"
|
||||
},
|
||||
"shortcuts": {
|
||||
"title": "단축키",
|
||||
@@ -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": {
|
||||
|
||||
+140
-43
@@ -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",
|
||||
@@ -1591,41 +1670,41 @@
|
||||
"edit_draft": "Rediģēt melnrakstu"
|
||||
},
|
||||
"mailbox_context_menu": {
|
||||
"mark_folder_read": "Mark folder as read",
|
||||
"mark_folder_tree_read": "Mark folder & subfolders as read",
|
||||
"mark_all_folders_read": "Mark all folders as read",
|
||||
"new_subfolder": "New subfolder...",
|
||||
"new_folder": "New folder...",
|
||||
"rename": "Rename...",
|
||||
"empty_folder": "Empty folder",
|
||||
"empty_folder_generic": "Empty folder",
|
||||
"delete_folder": "Delete folder",
|
||||
"refresh": "Refresh",
|
||||
"mark_all_confirm_title": "Mark all folders as read",
|
||||
"mark_all_confirm_message": "Mark every unread message in your personal account as read?",
|
||||
"delete_confirm_title": "Delete folder",
|
||||
"delete_confirm_message": "Permanently delete the folder \"{name}\"? This action cannot be undone.",
|
||||
"prompt_new_subfolder": "Enter a name for the new subfolder.",
|
||||
"prompt_new_folder": "Enter a name for the new folder.",
|
||||
"prompt_rename": "Enter a new name for this folder.",
|
||||
"toast_marked_read": "Folder marked as read",
|
||||
"toast_marked_read_count": "Marked {count, plural, one {1 message} other {# messages}} as read",
|
||||
"toast_already_read": "No unread messages",
|
||||
"toast_marked_all_read": "All folders marked as read",
|
||||
"toast_emptied": "Folder emptied",
|
||||
"toast_folder_created": "Folder created",
|
||||
"toast_folder_renamed": "Folder renamed",
|
||||
"toast_folder_deleted": "Folder deleted",
|
||||
"toast_error_mark_read": "Failed to mark as read",
|
||||
"toast_error_empty": "Failed to empty folder",
|
||||
"toast_error_create": "Failed to create folder",
|
||||
"toast_error_rename": "Failed to rename folder",
|
||||
"toast_error_delete": "Failed to delete folder",
|
||||
"toast_error_delete_has_children": "Folder has subfolders. Remove them first.",
|
||||
"toast_error_delete_has_email": "Folder is not empty. Empty it first.",
|
||||
"placeholder_folder_name": "Folder name",
|
||||
"create": "Create",
|
||||
"rename_confirm": "Rename"
|
||||
"mark_folder_read": "Atzīmēt mapi kā lasītu",
|
||||
"mark_folder_tree_read": "Atzīmēt mapi un apakšmapes kā lasītas",
|
||||
"mark_all_folders_read": "Atzīmēt visas mapes kā lasītas",
|
||||
"new_subfolder": "Jauna apakšmape...",
|
||||
"new_folder": "Jauna mape...",
|
||||
"rename": "Pārsaukt...",
|
||||
"empty_folder": "Iztukšot mapi",
|
||||
"empty_folder_generic": "Iztukšot mapi",
|
||||
"delete_folder": "Dzēst mapi",
|
||||
"refresh": "Atjaunināt",
|
||||
"mark_all_confirm_title": "Atzīmēt visas mapes kā lasītas",
|
||||
"mark_all_confirm_message": "Atzīmēt visas nelasītās ziņas jūsu personīgajā kontā kā lasītas?",
|
||||
"delete_confirm_title": "Dzēst mapi",
|
||||
"delete_confirm_message": "Neatgriezeniski dzēst mapi \"{name}\"? Šo darbību nevar atsaukt.",
|
||||
"prompt_new_subfolder": "Ievadiet nosaukumu jaunajai apakšmapei.",
|
||||
"prompt_new_folder": "Ievadiet nosaukumu jaunajai mapei.",
|
||||
"prompt_rename": "Ievadiet jaunu nosaukumu šai mapei.",
|
||||
"toast_marked_read": "Mape atzīmēta kā lasīta",
|
||||
"toast_marked_read_count": "{count, plural, one {1 ziņa atzīmēta} other {# ziņas atzīmētas}} kā lasītas",
|
||||
"toast_already_read": "Nav nelasītu ziņu",
|
||||
"toast_marked_all_read": "Visas mapes atzīmētas kā lasītas",
|
||||
"toast_emptied": "Mape iztukšota",
|
||||
"toast_folder_created": "Mape izveidota",
|
||||
"toast_folder_renamed": "Mape pārsaukta",
|
||||
"toast_folder_deleted": "Mape dzēsta",
|
||||
"toast_error_mark_read": "Neizdevās atzīmēt kā lasītu",
|
||||
"toast_error_empty": "Neizdevās iztukšot mapi",
|
||||
"toast_error_create": "Neizdevās izveidot mapi",
|
||||
"toast_error_rename": "Neizdevās pārsaukt mapi",
|
||||
"toast_error_delete": "Neizdevās dzēst mapi",
|
||||
"toast_error_delete_has_children": "Mapei ir apakšmapes. Vispirms noņemiet tās.",
|
||||
"toast_error_delete_has_email": "Mape nav tukša. Vispirms iztukšojiet to.",
|
||||
"placeholder_folder_name": "Mapes nosaukums",
|
||||
"create": "Izveidot",
|
||||
"rename_confirm": "Pārsaukt"
|
||||
},
|
||||
"shortcuts": {
|
||||
"title": "Īsinājumtaustiņi",
|
||||
@@ -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",
|
||||
|
||||
+139
-42
@@ -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",
|
||||
@@ -1591,41 +1670,41 @@
|
||||
"edit_draft": "Concept bewerken"
|
||||
},
|
||||
"mailbox_context_menu": {
|
||||
"mark_folder_read": "Mark folder as read",
|
||||
"mark_folder_tree_read": "Mark folder & subfolders as read",
|
||||
"mark_all_folders_read": "Mark all folders as read",
|
||||
"new_subfolder": "New subfolder...",
|
||||
"new_folder": "New folder...",
|
||||
"rename": "Rename...",
|
||||
"empty_folder": "Empty folder",
|
||||
"empty_folder_generic": "Empty folder",
|
||||
"delete_folder": "Delete folder",
|
||||
"refresh": "Refresh",
|
||||
"mark_all_confirm_title": "Mark all folders as read",
|
||||
"mark_all_confirm_message": "Mark every unread message in your personal account as read?",
|
||||
"delete_confirm_title": "Delete folder",
|
||||
"delete_confirm_message": "Permanently delete the folder \"{name}\"? This action cannot be undone.",
|
||||
"prompt_new_subfolder": "Enter a name for the new subfolder.",
|
||||
"prompt_new_folder": "Enter a name for the new folder.",
|
||||
"prompt_rename": "Enter a new name for this folder.",
|
||||
"toast_marked_read": "Folder marked as read",
|
||||
"toast_marked_read_count": "Marked {count, plural, one {1 message} other {# messages}} as read",
|
||||
"toast_already_read": "No unread messages",
|
||||
"toast_marked_all_read": "All folders marked as read",
|
||||
"toast_emptied": "Folder emptied",
|
||||
"toast_folder_created": "Folder created",
|
||||
"toast_folder_renamed": "Folder renamed",
|
||||
"toast_folder_deleted": "Folder deleted",
|
||||
"toast_error_mark_read": "Failed to mark as read",
|
||||
"toast_error_empty": "Failed to empty folder",
|
||||
"toast_error_create": "Failed to create folder",
|
||||
"toast_error_rename": "Failed to rename folder",
|
||||
"toast_error_delete": "Failed to delete folder",
|
||||
"toast_error_delete_has_children": "Folder has subfolders. Remove them first.",
|
||||
"toast_error_delete_has_email": "Folder is not empty. Empty it first.",
|
||||
"placeholder_folder_name": "Folder name",
|
||||
"create": "Create",
|
||||
"rename_confirm": "Rename"
|
||||
"mark_folder_read": "Map markeren als gelezen",
|
||||
"mark_folder_tree_read": "Map en submappen markeren als gelezen",
|
||||
"mark_all_folders_read": "Alle mappen markeren als gelezen",
|
||||
"new_subfolder": "Nieuwe submap...",
|
||||
"new_folder": "Nieuwe map...",
|
||||
"rename": "Hernoemen...",
|
||||
"empty_folder": "Map leegmaken",
|
||||
"empty_folder_generic": "Map leegmaken",
|
||||
"delete_folder": "Map verwijderen",
|
||||
"refresh": "Vernieuwen",
|
||||
"mark_all_confirm_title": "Alle mappen markeren als gelezen",
|
||||
"mark_all_confirm_message": "Alle ongelezen berichten in uw persoonlijke account als gelezen markeren?",
|
||||
"delete_confirm_title": "Map verwijderen",
|
||||
"delete_confirm_message": "Map \"{name}\" definitief verwijderen? Deze actie kan niet ongedaan worden gemaakt.",
|
||||
"prompt_new_subfolder": "Voer een naam in voor de nieuwe submap.",
|
||||
"prompt_new_folder": "Voer een naam in voor de nieuwe map.",
|
||||
"prompt_rename": "Voer een nieuwe naam in voor deze map.",
|
||||
"toast_marked_read": "Map gemarkeerd als gelezen",
|
||||
"toast_marked_read_count": "{count, plural, one {1 bericht} other {# berichten}} gemarkeerd als gelezen",
|
||||
"toast_already_read": "Geen ongelezen berichten",
|
||||
"toast_marked_all_read": "Alle mappen gemarkeerd als gelezen",
|
||||
"toast_emptied": "Map leeggemaakt",
|
||||
"toast_folder_created": "Map aangemaakt",
|
||||
"toast_folder_renamed": "Map hernoemd",
|
||||
"toast_folder_deleted": "Map verwijderd",
|
||||
"toast_error_mark_read": "Markeren als gelezen mislukt",
|
||||
"toast_error_empty": "Map leegmaken mislukt",
|
||||
"toast_error_create": "Map aanmaken mislukt",
|
||||
"toast_error_rename": "Map hernoemen mislukt",
|
||||
"toast_error_delete": "Map verwijderen mislukt",
|
||||
"toast_error_delete_has_children": "Map bevat submappen. Verwijder deze eerst.",
|
||||
"toast_error_delete_has_email": "Map is niet leeg. Maak deze eerst leeg.",
|
||||
"placeholder_folder_name": "Mapnaam",
|
||||
"create": "Aanmaken",
|
||||
"rename_confirm": "Hernoemen"
|
||||
},
|
||||
"shortcuts": {
|
||||
"title": "Sneltoetsen",
|
||||
@@ -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": {
|
||||
|
||||
+139
-42
@@ -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",
|
||||
@@ -1591,41 +1670,41 @@
|
||||
"edit_draft": "Edytuj szkic"
|
||||
},
|
||||
"mailbox_context_menu": {
|
||||
"mark_folder_read": "Mark folder as read",
|
||||
"mark_folder_tree_read": "Mark folder & subfolders as read",
|
||||
"mark_all_folders_read": "Mark all folders as read",
|
||||
"new_subfolder": "New subfolder...",
|
||||
"new_folder": "New folder...",
|
||||
"rename": "Rename...",
|
||||
"empty_folder": "Empty folder",
|
||||
"empty_folder_generic": "Empty folder",
|
||||
"delete_folder": "Delete folder",
|
||||
"refresh": "Refresh",
|
||||
"mark_all_confirm_title": "Mark all folders as read",
|
||||
"mark_all_confirm_message": "Mark every unread message in your personal account as read?",
|
||||
"delete_confirm_title": "Delete folder",
|
||||
"delete_confirm_message": "Permanently delete the folder \"{name}\"? This action cannot be undone.",
|
||||
"prompt_new_subfolder": "Enter a name for the new subfolder.",
|
||||
"prompt_new_folder": "Enter a name for the new folder.",
|
||||
"prompt_rename": "Enter a new name for this folder.",
|
||||
"toast_marked_read": "Folder marked as read",
|
||||
"toast_marked_read_count": "Marked {count, plural, one {1 message} other {# messages}} as read",
|
||||
"toast_already_read": "No unread messages",
|
||||
"toast_marked_all_read": "All folders marked as read",
|
||||
"toast_emptied": "Folder emptied",
|
||||
"toast_folder_created": "Folder created",
|
||||
"toast_folder_renamed": "Folder renamed",
|
||||
"toast_folder_deleted": "Folder deleted",
|
||||
"toast_error_mark_read": "Failed to mark as read",
|
||||
"toast_error_empty": "Failed to empty folder",
|
||||
"toast_error_create": "Failed to create folder",
|
||||
"toast_error_rename": "Failed to rename folder",
|
||||
"toast_error_delete": "Failed to delete folder",
|
||||
"toast_error_delete_has_children": "Folder has subfolders. Remove them first.",
|
||||
"toast_error_delete_has_email": "Folder is not empty. Empty it first.",
|
||||
"placeholder_folder_name": "Folder name",
|
||||
"create": "Create",
|
||||
"rename_confirm": "Rename"
|
||||
"mark_folder_read": "Oznacz folder jako przeczytany",
|
||||
"mark_folder_tree_read": "Oznacz folder i podfoldery jako przeczytane",
|
||||
"mark_all_folders_read": "Oznacz wszystkie foldery jako przeczytane",
|
||||
"new_subfolder": "Nowy podfolder...",
|
||||
"new_folder": "Nowy folder...",
|
||||
"rename": "Zmień nazwę...",
|
||||
"empty_folder": "Opróżnij folder",
|
||||
"empty_folder_generic": "Opróżnij folder",
|
||||
"delete_folder": "Usuń folder",
|
||||
"refresh": "Odśwież",
|
||||
"mark_all_confirm_title": "Oznacz wszystkie foldery jako przeczytane",
|
||||
"mark_all_confirm_message": "Oznaczyć wszystkie nieprzeczytane wiadomości na koncie osobistym jako przeczytane?",
|
||||
"delete_confirm_title": "Usuń folder",
|
||||
"delete_confirm_message": "Trwale usunąć folder \"{name}\"? Tej operacji nie można cofnąć.",
|
||||
"prompt_new_subfolder": "Podaj nazwę nowego podfolderu.",
|
||||
"prompt_new_folder": "Podaj nazwę nowego folderu.",
|
||||
"prompt_rename": "Podaj nową nazwę tego folderu.",
|
||||
"toast_marked_read": "Folder oznaczony jako przeczytany",
|
||||
"toast_marked_read_count": "Oznaczono {count, plural, one {1 wiadomość} other {# wiadomości}} jako przeczytane",
|
||||
"toast_already_read": "Brak nieprzeczytanych wiadomości",
|
||||
"toast_marked_all_read": "Wszystkie foldery oznaczone jako przeczytane",
|
||||
"toast_emptied": "Folder opróżniony",
|
||||
"toast_folder_created": "Folder utworzony",
|
||||
"toast_folder_renamed": "Nazwa folderu zmieniona",
|
||||
"toast_folder_deleted": "Folder usunięty",
|
||||
"toast_error_mark_read": "Nie udało się oznaczyć jako przeczytane",
|
||||
"toast_error_empty": "Nie udało się opróżnić folderu",
|
||||
"toast_error_create": "Nie udało się utworzyć folderu",
|
||||
"toast_error_rename": "Nie udało się zmienić nazwy folderu",
|
||||
"toast_error_delete": "Nie udało się usunąć folderu",
|
||||
"toast_error_delete_has_children": "Folder zawiera podfoldery. Najpierw je usuń.",
|
||||
"toast_error_delete_has_email": "Folder nie jest pusty. Najpierw go opróżnij.",
|
||||
"placeholder_folder_name": "Nazwa folderu",
|
||||
"create": "Utwórz",
|
||||
"rename_confirm": "Zmień nazwę"
|
||||
},
|
||||
"shortcuts": {
|
||||
"title": "Skróty klawiszowe",
|
||||
@@ -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": {
|
||||
|
||||
+140
-43
@@ -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",
|
||||
@@ -1591,41 +1670,41 @@
|
||||
"edit_draft": "Editar rascunho"
|
||||
},
|
||||
"mailbox_context_menu": {
|
||||
"mark_folder_read": "Mark folder as read",
|
||||
"mark_folder_tree_read": "Mark folder & subfolders as read",
|
||||
"mark_all_folders_read": "Mark all folders as read",
|
||||
"new_subfolder": "New subfolder...",
|
||||
"new_folder": "New folder...",
|
||||
"rename": "Rename...",
|
||||
"empty_folder": "Empty folder",
|
||||
"empty_folder_generic": "Empty folder",
|
||||
"delete_folder": "Delete folder",
|
||||
"refresh": "Refresh",
|
||||
"mark_all_confirm_title": "Mark all folders as read",
|
||||
"mark_all_confirm_message": "Mark every unread message in your personal account as read?",
|
||||
"delete_confirm_title": "Delete folder",
|
||||
"delete_confirm_message": "Permanently delete the folder \"{name}\"? This action cannot be undone.",
|
||||
"prompt_new_subfolder": "Enter a name for the new subfolder.",
|
||||
"prompt_new_folder": "Enter a name for the new folder.",
|
||||
"prompt_rename": "Enter a new name for this folder.",
|
||||
"toast_marked_read": "Folder marked as read",
|
||||
"toast_marked_read_count": "Marked {count, plural, one {1 message} other {# messages}} as read",
|
||||
"toast_already_read": "No unread messages",
|
||||
"toast_marked_all_read": "All folders marked as read",
|
||||
"toast_emptied": "Folder emptied",
|
||||
"toast_folder_created": "Folder created",
|
||||
"toast_folder_renamed": "Folder renamed",
|
||||
"toast_folder_deleted": "Folder deleted",
|
||||
"toast_error_mark_read": "Failed to mark as read",
|
||||
"toast_error_empty": "Failed to empty folder",
|
||||
"toast_error_create": "Failed to create folder",
|
||||
"toast_error_rename": "Failed to rename folder",
|
||||
"toast_error_delete": "Failed to delete folder",
|
||||
"toast_error_delete_has_children": "Folder has subfolders. Remove them first.",
|
||||
"toast_error_delete_has_email": "Folder is not empty. Empty it first.",
|
||||
"placeholder_folder_name": "Folder name",
|
||||
"create": "Create",
|
||||
"rename_confirm": "Rename"
|
||||
"mark_folder_read": "Marcar pasta como lida",
|
||||
"mark_folder_tree_read": "Marcar pasta e subpastas como lidas",
|
||||
"mark_all_folders_read": "Marcar todas as pastas como lidas",
|
||||
"new_subfolder": "Nova subpasta...",
|
||||
"new_folder": "Nova pasta...",
|
||||
"rename": "Renomear...",
|
||||
"empty_folder": "Esvaziar pasta",
|
||||
"empty_folder_generic": "Esvaziar pasta",
|
||||
"delete_folder": "Excluir pasta",
|
||||
"refresh": "Atualizar",
|
||||
"mark_all_confirm_title": "Marcar todas as pastas como lidas",
|
||||
"mark_all_confirm_message": "Marcar todas as mensagens não lidas da sua conta pessoal como lidas?",
|
||||
"delete_confirm_title": "Excluir pasta",
|
||||
"delete_confirm_message": "Excluir permanentemente a pasta \"{name}\"? Esta ação não pode ser desfeita.",
|
||||
"prompt_new_subfolder": "Digite um nome para a nova subpasta.",
|
||||
"prompt_new_folder": "Digite um nome para a nova pasta.",
|
||||
"prompt_rename": "Digite um novo nome para esta pasta.",
|
||||
"toast_marked_read": "Pasta marcada como lida",
|
||||
"toast_marked_read_count": "{count, plural, one {1 mensagem marcada como lida} other {# mensagens marcadas como lidas}}",
|
||||
"toast_already_read": "Nenhuma mensagem não lida",
|
||||
"toast_marked_all_read": "Todas as pastas marcadas como lidas",
|
||||
"toast_emptied": "Pasta esvaziada",
|
||||
"toast_folder_created": "Pasta criada",
|
||||
"toast_folder_renamed": "Pasta renomeada",
|
||||
"toast_folder_deleted": "Pasta excluída",
|
||||
"toast_error_mark_read": "Falha ao marcar como lida",
|
||||
"toast_error_empty": "Falha ao esvaziar a pasta",
|
||||
"toast_error_create": "Falha ao criar a pasta",
|
||||
"toast_error_rename": "Falha ao renomear a pasta",
|
||||
"toast_error_delete": "Falha ao excluir a pasta",
|
||||
"toast_error_delete_has_children": "A pasta contém subpastas. Remova-as primeiro.",
|
||||
"toast_error_delete_has_email": "A pasta não está vazia. Esvazie-a primeiro.",
|
||||
"placeholder_folder_name": "Nome da pasta",
|
||||
"create": "Criar",
|
||||
"rename_confirm": "Renomear"
|
||||
},
|
||||
"shortcuts": {
|
||||
"title": "Atalhos de Teclado",
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+139
-42
@@ -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": "Фильтры почты",
|
||||
@@ -1591,41 +1670,41 @@
|
||||
"edit_draft": "Редактировать черновик"
|
||||
},
|
||||
"mailbox_context_menu": {
|
||||
"mark_folder_read": "Mark folder as read",
|
||||
"mark_folder_tree_read": "Mark folder & subfolders as read",
|
||||
"mark_all_folders_read": "Mark all folders as read",
|
||||
"new_subfolder": "New subfolder...",
|
||||
"new_folder": "New folder...",
|
||||
"rename": "Rename...",
|
||||
"empty_folder": "Empty folder",
|
||||
"empty_folder_generic": "Empty folder",
|
||||
"delete_folder": "Delete folder",
|
||||
"refresh": "Refresh",
|
||||
"mark_all_confirm_title": "Mark all folders as read",
|
||||
"mark_all_confirm_message": "Mark every unread message in your personal account as read?",
|
||||
"delete_confirm_title": "Delete folder",
|
||||
"delete_confirm_message": "Permanently delete the folder \"{name}\"? This action cannot be undone.",
|
||||
"prompt_new_subfolder": "Enter a name for the new subfolder.",
|
||||
"prompt_new_folder": "Enter a name for the new folder.",
|
||||
"prompt_rename": "Enter a new name for this folder.",
|
||||
"toast_marked_read": "Folder marked as read",
|
||||
"toast_marked_read_count": "Marked {count, plural, one {1 message} other {# messages}} as read",
|
||||
"toast_already_read": "No unread messages",
|
||||
"toast_marked_all_read": "All folders marked as read",
|
||||
"toast_emptied": "Folder emptied",
|
||||
"toast_folder_created": "Folder created",
|
||||
"toast_folder_renamed": "Folder renamed",
|
||||
"toast_folder_deleted": "Folder deleted",
|
||||
"toast_error_mark_read": "Failed to mark as read",
|
||||
"toast_error_empty": "Failed to empty folder",
|
||||
"toast_error_create": "Failed to create folder",
|
||||
"toast_error_rename": "Failed to rename folder",
|
||||
"toast_error_delete": "Failed to delete folder",
|
||||
"toast_error_delete_has_children": "Folder has subfolders. Remove them first.",
|
||||
"toast_error_delete_has_email": "Folder is not empty. Empty it first.",
|
||||
"placeholder_folder_name": "Folder name",
|
||||
"create": "Create",
|
||||
"rename_confirm": "Rename"
|
||||
"mark_folder_read": "Отметить папку как прочитанную",
|
||||
"mark_folder_tree_read": "Отметить папку и вложенные папки как прочитанные",
|
||||
"mark_all_folders_read": "Отметить все папки как прочитанные",
|
||||
"new_subfolder": "Новая вложенная папка...",
|
||||
"new_folder": "Новая папка...",
|
||||
"rename": "Переименовать...",
|
||||
"empty_folder": "Очистить папку",
|
||||
"empty_folder_generic": "Очистить папку",
|
||||
"delete_folder": "Удалить папку",
|
||||
"refresh": "Обновить",
|
||||
"mark_all_confirm_title": "Отметить все папки как прочитанные",
|
||||
"mark_all_confirm_message": "Отметить все непрочитанные сообщения в вашем личном аккаунте как прочитанные?",
|
||||
"delete_confirm_title": "Удалить папку",
|
||||
"delete_confirm_message": "Безвозвратно удалить папку \"{name}\"? Это действие нельзя отменить.",
|
||||
"prompt_new_subfolder": "Введите имя для новой вложенной папки.",
|
||||
"prompt_new_folder": "Введите имя для новой папки.",
|
||||
"prompt_rename": "Введите новое имя для этой папки.",
|
||||
"toast_marked_read": "Папка отмечена как прочитанная",
|
||||
"toast_marked_read_count": "Отмечено {count, plural, one {1 сообщение} few {# сообщения} other {# сообщений}} как прочитанные",
|
||||
"toast_already_read": "Нет непрочитанных сообщений",
|
||||
"toast_marked_all_read": "Все папки отмечены как прочитанные",
|
||||
"toast_emptied": "Папка очищена",
|
||||
"toast_folder_created": "Папка создана",
|
||||
"toast_folder_renamed": "Папка переименована",
|
||||
"toast_folder_deleted": "Папка удалена",
|
||||
"toast_error_mark_read": "Не удалось отметить как прочитанное",
|
||||
"toast_error_empty": "Не удалось очистить папку",
|
||||
"toast_error_create": "Не удалось создать папку",
|
||||
"toast_error_rename": "Не удалось переименовать папку",
|
||||
"toast_error_delete": "Не удалось удалить папку",
|
||||
"toast_error_delete_has_children": "Папка содержит вложенные папки. Сначала удалите их.",
|
||||
"toast_error_delete_has_email": "Папка не пуста. Сначала очистите её.",
|
||||
"placeholder_folder_name": "Имя папки",
|
||||
"create": "Создать",
|
||||
"rename_confirm": "Переименовать"
|
||||
},
|
||||
"shortcuts": {
|
||||
"title": "Сочетания клавиш",
|
||||
@@ -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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user