Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -1,5 +1,37 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 1.6.0 (2026-05-01)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Deployment**: Subpath deployment support via `NEXT_PUBLIC_BASE_PATH` environment variable
|
||||||
|
- **Mail**: Image attachment thumbnails and preview chips
|
||||||
|
- **Mobile**: Reworked mobile mail viewer toolbar
|
||||||
|
- **Mobile**: Mobile-friendly settings panel
|
||||||
|
- **Mobile**: Mobile-friendly admin panel
|
||||||
|
- **Mail**: Redesigned expanded details panel
|
||||||
|
- **Mailbox**: Show full path in mailbox context menu header with intelligent path shortening
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- **Viewer**: Respect per-email dark mode toggle when "always show in light mode" is on
|
||||||
|
- **Navigation**: Scroll apps list in navigation rail to prevent overflow
|
||||||
|
- **Context menu**: Clamp submenu inside viewport
|
||||||
|
- **Context menu**: Prevent context menu from clipping below viewport
|
||||||
|
- **Context menu**: Prevent jump and animation on open
|
||||||
|
- **Mail**: Stop silently destroying emails when trash mailbox isn't found (#195)
|
||||||
|
- **Mail**: Preserve list scroll position when tagging an email
|
||||||
|
- **Mail**: Render below-header overflow popup outside clipped row
|
||||||
|
- **Mail**: Collapse below-header attachments to single row with overflow pill
|
||||||
|
- **Push**: Fix push preview JMAP query
|
||||||
|
- **Tour**: Navigate tour to mailbox when starting from another page
|
||||||
|
- **i18n**: Add `useTranslations` for "selected emails" and "cancel" on email list batch operations
|
||||||
|
|
||||||
|
### i18n
|
||||||
|
|
||||||
|
- Translate SPF/DKIM/DMARC tooltips
|
||||||
|
- Add missing keys across 14 locales
|
||||||
|
|
||||||
## 1.5.4 (2026-05-01)
|
## 1.5.4 (2026-05-01)
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ COPY package.json package-lock.json ./
|
|||||||
RUN npm ci
|
RUN npm ci
|
||||||
COPY . .
|
COPY . .
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
# Optional: serve under a subpath like /webmail. Baked into emitted asset URLs
|
||||||
|
# at build time, so it cannot be changed without rebuilding.
|
||||||
|
ARG NEXT_PUBLIC_BASE_PATH=
|
||||||
|
ENV NEXT_PUBLIC_BASE_PATH=$NEXT_PUBLIC_BASE_PATH
|
||||||
RUN npx next build --webpack
|
RUN npx next build --webpack
|
||||||
|
|
||||||
FROM node:24-alpine AS runner
|
FROM node:24-alpine AS runner
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.ar
|
|||||||
|
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](https://discord.gg/tYCujymGrT)
|
[](https://discord.gg/tYCujymGrT)
|
||||||
[](CHANGELOG.md)
|
[](CHANGELOG.md)
|
||||||
[](https://ghcr.io/bulwarkmail/webmail)
|
[](https://ghcr.io/bulwarkmail/webmail)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -53,11 +53,9 @@ A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.ar
|
|||||||
</tr>
|
</tr>
|
||||||
</table>
|
</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
|
## 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
|
- **Mail** – threading, unified inbox, full-text search, Sieve filters, S/MIME, templates
|
||||||
- **Calendar** – month/week/day/agenda, recurring events, iMIP invitations, CalDAV subscriptions
|
- **Calendar** – month/week/day/agenda, recurring events, iMIP invitations, CalDAV subscriptions
|
||||||
@@ -219,6 +217,26 @@ LOG_LEVEL=info # error | warn | info | debug
|
|||||||
|
|
||||||
</details>
|
</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
|
## Keyboard Shortcuts
|
||||||
|
|
||||||
| Key | Action |
|
| Key | Action |
|
||||||
|
|||||||
@@ -411,7 +411,8 @@ export default function LoginPage() {
|
|||||||
const verifier = generateCodeVerifier();
|
const verifier = generateCodeVerifier();
|
||||||
const challenge = await generateCodeChallenge(verifier);
|
const challenge = await generateCodeChallenge(verifier);
|
||||||
const state = generateState();
|
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_code_verifier", verifier);
|
||||||
sessionStorage.setItem("oauth_state", state);
|
sessionStorage.setItem("oauth_state", state);
|
||||||
|
|||||||
+16
-6
@@ -139,6 +139,7 @@ export default function Home() {
|
|||||||
deleteEmail,
|
deleteEmail,
|
||||||
markAsRead,
|
markAsRead,
|
||||||
toggleStar,
|
toggleStar,
|
||||||
|
setEmailKeywordsLocal,
|
||||||
moveToMailbox,
|
moveToMailbox,
|
||||||
moveThreadToMailbox,
|
moveThreadToMailbox,
|
||||||
searchEmails,
|
searchEmails,
|
||||||
@@ -931,13 +932,24 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Not in trash: always move to trash
|
// 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) {
|
if (trashMailbox) {
|
||||||
try {
|
try {
|
||||||
await moveToMailbox(client, emailToDelete.id, trashMailbox.id);
|
await moveToMailbox(client, emailToDelete.id, trashMailbox.id);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to move email to trash:", error);
|
console.error("Failed to move email to trash:", error);
|
||||||
|
const { toast } = await import('sonner');
|
||||||
|
toast.error(error instanceof Error ? error.message : 'Failed to move email to trash');
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
const { toast } = await import('sonner');
|
||||||
|
toast.error('Trash mailbox not found - cannot move email to trash');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -1085,11 +1097,9 @@ export default function Home() {
|
|||||||
// Update email keywords via JMAP
|
// Update email keywords via JMAP
|
||||||
await client.updateEmailKeywords(emailId, keywords);
|
await client.updateEmailKeywords(emailId, keywords);
|
||||||
|
|
||||||
// Update local state
|
// Patch the email in place so the list keeps its scroll/pagination state
|
||||||
selectEmail(email.id === selectedEmail?.id ? { ...email, keywords } : selectedEmail);
|
// instead of being reset to the first page by a full refetch.
|
||||||
|
setEmailKeywordsLocal(emailId, keywords);
|
||||||
// Refresh emails list to show color in list
|
|
||||||
await fetchEmails(client, selectedMailbox);
|
|
||||||
|
|
||||||
// Refresh tag counts
|
// Refresh tag counts
|
||||||
fetchTagCounts(client);
|
fetchTagCounts(client);
|
||||||
|
|||||||
@@ -330,9 +330,7 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-4">
|
<div className="flex-1 overflow-y-auto p-4">
|
||||||
<div className="bg-card border border-border rounded-lg p-4">
|
{renderTabContent()}
|
||||||
{renderTabContent()}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<NavigationRail
|
<NavigationRail
|
||||||
@@ -515,17 +513,8 @@ export default function SettingsPage() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
<div className="max-w-3xl mx-auto p-8">
|
<div className="max-w-3xl mx-auto px-6 py-6">
|
||||||
<div className="mb-6">
|
{renderTabContent()}
|
||||||
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
+12
-12
@@ -129,8 +129,8 @@ export default function AdminAuthPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<h1 className="text-2xl font-semibold text-foreground">Authentication</h1>
|
<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>
|
<p className="text-sm text-muted-foreground mt-1">OAuth, SSO, and session configuration</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -154,7 +154,7 @@ export default function AdminAuthPage() {
|
|||||||
|
|
||||||
{/* Auto-setup */}
|
{/* Auto-setup */}
|
||||||
<div className="rounded-lg border border-primary/30 bg-primary/5 p-4">
|
<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="min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Sparkles className="w-4 h-4 text-primary shrink-0" />
|
<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;
|
onChange: (k: string, v: unknown) => void; onRevert: (k: string) => void; placeholder?: string; type?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
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="min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm text-foreground">{label}</span>
|
<span className="text-sm text-foreground">{label}</span>
|
||||||
@@ -322,11 +322,11 @@ function Text({ label, description, configKey, value, source, onChange, onRevert
|
|||||||
</div>
|
</div>
|
||||||
{description && <p className="text-xs text-muted-foreground mt-0.5">{description}</p>}
|
{description && <p className="text-xs text-muted-foreground mt-0.5">{description}</p>}
|
||||||
</div>
|
</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}
|
<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' && (
|
{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>
|
||||||
</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;
|
onChange: (k: string, v: unknown) => void; onRevert: (k: string) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
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="min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm text-foreground">{label}</span>
|
<span className="text-sm text-foreground">{label}</span>
|
||||||
@@ -346,7 +346,7 @@ function Toggle({ label, description, configKey, value, source, onChange, onReve
|
|||||||
</div>
|
</div>
|
||||||
{description && <p className="text-xs text-muted-foreground mt-0.5">{description}</p>}
|
{description && <p className="text-xs text-muted-foreground mt-0.5">{description}</p>}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
<button onClick={() => onChange(configKey, !value)}
|
<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'}`}>
|
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]'}`} />
|
<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;
|
onChange: (k: string, v: unknown) => void; onRevert: (k: string) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
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">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<span className="text-sm text-foreground">{label}</span>
|
<span className="text-sm text-foreground">{label}</span>
|
||||||
<SourceBadge source={source} />
|
<SourceBadge source={source} />
|
||||||
</div>
|
</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)}
|
<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">
|
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>)}
|
{options.map(o => <option key={o} value={o}>{o}</option>)}
|
||||||
|
|||||||
+10
-10
@@ -162,8 +162,8 @@ export default function AdminBrandingPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<h1 className="text-2xl font-semibold text-foreground">Branding</h1>
|
<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>
|
<p className="text-sm text-muted-foreground mt-1">Customize logos, favicon, and company information</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -193,22 +193,22 @@ export default function AdminBrandingPage() {
|
|||||||
<div className="divide-y divide-border">
|
<div className="divide-y divide-border">
|
||||||
{IMAGE_FIELDS.map(field => (
|
{IMAGE_FIELDS.map(field => (
|
||||||
<div key={field.key} className="px-4 py-3">
|
<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">
|
<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' && (
|
{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">
|
<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'}
|
{isUploadedFile(field.key) ? 'uploaded' : 'admin'}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 w-full sm:w-auto">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={currentValue(field.key)}
|
value={currentValue(field.key)}
|
||||||
onChange={(e) => handleChange(field.key, e.target.value)}
|
onChange={(e) => handleChange(field.key, e.target.value)}
|
||||||
placeholder="Enter URL or upload a file"
|
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
|
<input
|
||||||
ref={el => { fileInputRefs.current[field.key] = el; }}
|
ref={el => { fileInputRefs.current[field.key] = el; }}
|
||||||
@@ -270,20 +270,20 @@ export default function AdminBrandingPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="divide-y divide-border">
|
<div className="divide-y divide-border">
|
||||||
{TEXT_FIELDS.map(field => (
|
{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">
|
<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' && (
|
{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>
|
<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>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 w-full sm:w-auto">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={currentValue(field.key)}
|
value={currentValue(field.key)}
|
||||||
onChange={(e) => handleChange(field.key, e.target.value)}
|
onChange={(e) => handleChange(field.key, e.target.value)}
|
||||||
placeholder={field.key.includes('Url') ? 'https://...' : 'Enter 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' && (
|
{config[field.key]?.source === 'admin' && (
|
||||||
<button onClick={() => handleRevert(field.key)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
|
<button onClick={() => handleRevert(field.key)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
|
||||||
|
|||||||
+201
-63
@@ -21,6 +21,8 @@ import {
|
|||||||
HardDrive,
|
HardDrive,
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Store,
|
Store,
|
||||||
|
Menu,
|
||||||
|
X,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useConfig } from '@/hooks/use-config';
|
import { useConfig } from '@/hooks/use-config';
|
||||||
@@ -69,12 +71,26 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
const [authenticated, setAuthenticated] = useState<boolean | null>(null);
|
const [authenticated, setAuthenticated] = useState<boolean | null>(null);
|
||||||
const [authError, setAuthError] = useState<string | null>(null);
|
const [authError, setAuthError] = useState<string | null>(null);
|
||||||
const [isStalwartAdmin, setIsStalwartAdmin] = useState(false);
|
const [isStalwartAdmin, setIsStalwartAdmin] = useState(false);
|
||||||
|
const [mobileNavOpen, setMobileNavOpen] = useState(false);
|
||||||
const { appLogoLightUrl, appLogoDarkUrl, loginLogoLightUrl, loginLogoDarkUrl } = useConfig();
|
const { appLogoLightUrl, appLogoDarkUrl, loginLogoLightUrl, loginLogoDarkUrl } = useConfig();
|
||||||
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
||||||
const logoUrl = resolvedTheme === 'dark'
|
const logoUrl = resolvedTheme === 'dark'
|
||||||
? (appLogoDarkUrl || appLogoLightUrl || loginLogoDarkUrl)
|
? (appLogoDarkUrl || appLogoLightUrl || loginLogoDarkUrl)
|
||||||
: (appLogoLightUrl || appLogoDarkUrl || loginLogoLightUrl);
|
: (appLogoLightUrl || appLogoDarkUrl || loginLogoLightUrl);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMobileNavOpen(false);
|
||||||
|
}, [pathname]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mobileNavOpen) return;
|
||||||
|
const previous = document.body.style.overflow;
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = previous;
|
||||||
|
};
|
||||||
|
}, [mobileNavOpen]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (pathname === '/admin/login') return;
|
if (pathname === '/admin/login') return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -140,10 +156,77 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const navContent = (
|
||||||
|
<>
|
||||||
|
<div className="flex-1 overflow-y-auto py-2">
|
||||||
|
<div className="px-2 space-y-0.5">
|
||||||
|
{NAV_GROUPS.map((group, groupIndex) => (
|
||||||
|
<div key={group.label}>
|
||||||
|
{groupIndex > 0 && <div className="mx-1 my-2 border-t border-border" />}
|
||||||
|
<div className="px-3 pt-2.5 pb-1">
|
||||||
|
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
|
{group.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{group.items.map(({ href, label, icon: Icon }) => {
|
||||||
|
const active = href === '/admin' ? pathname === '/admin' : pathname.startsWith(href);
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={href}
|
||||||
|
href={href}
|
||||||
|
className={cn(
|
||||||
|
'w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
|
||||||
|
active
|
||||||
|
? 'bg-accent text-accent-foreground font-medium'
|
||||||
|
: 'hover:bg-muted text-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className={cn(
|
||||||
|
'w-4 h-4 shrink-0',
|
||||||
|
active ? 'text-accent-foreground' : 'text-muted-foreground'
|
||||||
|
)} />
|
||||||
|
{label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-2 py-2 border-t border-border space-y-0.5 shrink-0">
|
||||||
|
{!isStalwartAdmin && (
|
||||||
|
<Link
|
||||||
|
href="/admin/change-password"
|
||||||
|
className={cn(
|
||||||
|
'w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
|
||||||
|
pathname === '/admin/change-password'
|
||||||
|
? 'bg-accent text-accent-foreground font-medium'
|
||||||
|
: 'hover:bg-muted text-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<KeyRound className={cn(
|
||||||
|
'w-4 h-4 shrink-0',
|
||||||
|
pathname === '/admin/change-password' ? 'text-accent-foreground' : 'text-muted-foreground'
|
||||||
|
)} />
|
||||||
|
Change Password
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5 hover:bg-muted text-foreground"
|
||||||
|
>
|
||||||
|
<LogOut className="w-4 h-4 shrink-0 text-muted-foreground" />
|
||||||
|
Sign out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex bg-background">
|
<div className="min-h-screen flex bg-background">
|
||||||
{/* Slim webmail nav rail */}
|
{/* Slim webmail nav rail (desktop only) */}
|
||||||
<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">
|
<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 ? (
|
{logoUrl ? (
|
||||||
<img src={logoUrl} alt="" className="w-7 h-7 object-contain mb-2" />
|
<img src={logoUrl} alt="" className="w-7 h-7 object-contain mb-2" />
|
||||||
) : (
|
) : (
|
||||||
@@ -191,8 +274,8 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Admin Sidebar */}
|
{/* Admin Sidebar (desktop only) */}
|
||||||
<aside className="w-60 border-r border-border bg-secondary flex flex-col sticky top-0 h-screen">
|
<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">
|
<div className="h-14 flex items-center px-4 border-b border-border shrink-0">
|
||||||
{logoUrl ? (
|
{logoUrl ? (
|
||||||
<img src={logoUrl} alt="" className="w-5 h-5 object-contain mr-2" />
|
<img src={logoUrl} alt="" className="w-5 h-5 object-contain mr-2" />
|
||||||
@@ -201,74 +284,71 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
)}
|
)}
|
||||||
<span className="font-semibold text-sm text-foreground">Admin Panel</span>
|
<span className="font-semibold text-sm text-foreground">Admin Panel</span>
|
||||||
</div>
|
</div>
|
||||||
|
{navContent}
|
||||||
|
</aside>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto py-2">
|
{/* Mobile drawer overlay */}
|
||||||
<div className="px-2 space-y-0.5">
|
{mobileNavOpen && (
|
||||||
{NAV_GROUPS.map((group, groupIndex) => (
|
<div
|
||||||
<div key={group.label}>
|
className="md:hidden fixed inset-0 z-40 bg-black/50 backdrop-blur-sm"
|
||||||
{groupIndex > 0 && <div className="mx-1 my-2 border-t border-border" />}
|
onClick={() => setMobileNavOpen(false)}
|
||||||
<div className="px-3 pt-2.5 pb-1">
|
aria-hidden="true"
|
||||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
/>
|
||||||
{group.label}
|
)}
|
||||||
</span>
|
|
||||||
</div>
|
{/* Mobile drawer */}
|
||||||
{group.items.map(({ href, label, icon: Icon }) => {
|
<aside
|
||||||
const active = href === '/admin' ? pathname === '/admin' : pathname.startsWith(href);
|
className={cn(
|
||||||
return (
|
'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',
|
||||||
<Link
|
mobileNavOpen ? 'translate-x-0' : '-translate-x-full'
|
||||||
key={href}
|
)}
|
||||||
href={href}
|
aria-label="Admin navigation"
|
||||||
className={cn(
|
aria-hidden={!mobileNavOpen}
|
||||||
'w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
|
>
|
||||||
active
|
<div className="h-14 flex items-center justify-between px-3 border-b border-border shrink-0">
|
||||||
? 'bg-accent text-accent-foreground font-medium'
|
<div className="flex items-center min-w-0">
|
||||||
: 'hover:bg-muted text-foreground'
|
{logoUrl ? (
|
||||||
)}
|
<img src={logoUrl} alt="" className="w-5 h-5 object-contain mr-2" />
|
||||||
>
|
) : (
|
||||||
<Icon className={cn(
|
<Shield className="w-5 h-5 text-primary mr-2" />
|
||||||
'w-4 h-4 shrink-0',
|
)}
|
||||||
active ? 'text-accent-foreground' : 'text-muted-foreground'
|
<span className="font-semibold text-sm text-foreground truncate">Admin Panel</span>
|
||||||
)} />
|
|
||||||
{label}
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</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
|
<button
|
||||||
onClick={handleLogout}
|
type="button"
|
||||||
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"
|
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" />
|
<X className="w-5 h-5" />
|
||||||
Sign out
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{navContent}
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* Main content */}
|
{/* Main content */}
|
||||||
<main className="flex-1 overflow-auto">
|
<main className="flex-1 min-w-0 overflow-x-hidden">
|
||||||
<div className="max-w-4xl mx-auto p-6">
|
{/* 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 ? (
|
{authError ? (
|
||||||
<div className="rounded-lg border border-destructive/40 bg-destructive/10 p-4 text-sm text-destructive">
|
<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>
|
<p className="font-medium">Admin authentication failed</p>
|
||||||
@@ -283,6 +363,64 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+36
-8
@@ -33,8 +33,8 @@ export default function AdminLogsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<h1 className="text-2xl font-semibold text-foreground">Audit Log</h1>
|
<h1 className="text-2xl font-semibold text-foreground">Audit Log</h1>
|
||||||
<p className="text-sm text-muted-foreground mt-1">{total} total entries</p>
|
<p className="text-sm text-muted-foreground mt-1">{total} total entries</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -52,7 +52,7 @@ export default function AdminLogsPage() {
|
|||||||
<select
|
<select
|
||||||
value={actionFilter}
|
value={actionFilter}
|
||||||
onChange={(e) => { setActionFilter(e.target.value); setPage(1); }}
|
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="">All actions</option>
|
||||||
<option value="admin.login">Login</option>
|
<option value="admin.login">Login</option>
|
||||||
@@ -66,15 +66,43 @@ export default function AdminLogsPage() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Table */}
|
{/* Mobile cards */}
|
||||||
<div className="border border-border rounded-lg overflow-hidden">
|
<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">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-border bg-muted/30">
|
<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 whitespace-nowrap">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">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">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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-border">
|
<tbody className="divide-y divide-border">
|
||||||
|
|||||||
@@ -210,46 +210,48 @@ export default function MarketplacePreviewPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-start gap-4">
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-start">
|
||||||
<div className="w-14 h-14 rounded-lg bg-muted flex items-center justify-center shrink-0">
|
<div className="flex items-start gap-4 flex-1 min-w-0">
|
||||||
{isPlugin ? (
|
<div className="w-14 h-14 rounded-lg bg-muted flex items-center justify-center shrink-0">
|
||||||
<Puzzle className="w-7 h-7 text-muted-foreground" />
|
{isPlugin ? (
|
||||||
) : (
|
<Puzzle className="w-7 h-7 text-muted-foreground" />
|
||||||
<SwatchBook 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>
|
</div>
|
||||||
<div className="flex items-center gap-2 mt-1 text-sm text-muted-foreground flex-wrap">
|
<div className="flex-1 min-w-0">
|
||||||
<span className={`text-[10px] px-1.5 py-0.5 rounded font-medium ${
|
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||||
isPlugin
|
<h1 className="text-2xl font-semibold text-foreground break-words min-w-0">{ext.name}</h1>
|
||||||
? 'bg-blue-100 text-blue-700 dark:bg-blue-950/30 dark:text-blue-400'
|
{ext.featured && <Star className="w-4 h-4 text-warning fill-warning shrink-0" />}
|
||||||
: 'bg-purple-100 text-purple-700 dark:bg-purple-950/30 dark:text-purple-400'
|
{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">
|
||||||
{isPlugin ? (ext.pluginType || 'plugin') : 'theme'}
|
<Check className="w-3 h-3" /> Installed
|
||||||
</span>
|
</span>
|
||||||
{ext.author && (
|
)}
|
||||||
<span>by {ext.author.displayName}</span>
|
</div>
|
||||||
)}
|
<div className="flex items-center gap-2 mt-1 text-sm text-muted-foreground flex-wrap">
|
||||||
{ext.latestVersion && <span>v{ext.latestVersion}</span>}
|
<span className={`text-[10px] px-1.5 py-0.5 rounded font-medium ${
|
||||||
{ext.license && <span>{ext.license}</span>}
|
isPlugin
|
||||||
<span className="inline-flex items-center gap-1">
|
? 'bg-blue-100 text-blue-700 dark:bg-blue-950/30 dark:text-blue-400'
|
||||||
<Download className="w-3 h-3" />
|
: 'bg-purple-100 text-purple-700 dark:bg-purple-950/30 dark:text-purple-400'
|
||||||
{ext.totalDownloads.toLocaleString()}
|
}`}>
|
||||||
</span>
|
{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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Action buttons */}
|
{/* Action buttons */}
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
<div className="flex flex-wrap items-center gap-2 shrink-0">
|
||||||
{data.installed ? (
|
{data.installed ? (
|
||||||
<>
|
<>
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@@ -142,8 +142,8 @@ export default function AdminMarketplacePage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Search & Filters */}
|
{/* Search & Filters */}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
|
||||||
<div className="relative flex-1">
|
<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" />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
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"
|
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>
|
||||||
<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) => (
|
{(['all', 'plugin', 'theme'] as const).map((t) => (
|
||||||
<button
|
<button
|
||||||
key={t}
|
key={t}
|
||||||
|
|||||||
@@ -262,12 +262,12 @@ export default function AdminPluginsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<h1 className="text-2xl font-semibold text-foreground">Plugins</h1>
|
<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>
|
<p className="text-sm text-muted-foreground mt-1">Manage plugins and plugin policy for all users</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
{policyDirty && (
|
{policyDirty && (
|
||||||
<button
|
<button
|
||||||
onClick={handleSavePolicy}
|
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>
|
<p className="text-xs text-muted-foreground mt-0.5">Control plugin availability for users</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="divide-y divide-border">
|
<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>
|
<div>
|
||||||
<span className="text-sm text-foreground">Plugins Enabled</span>
|
<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>
|
<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>
|
</button>
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<span className="text-sm text-foreground">User Plugin Uploads</span>
|
<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>
|
<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>
|
</button>
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<span className="text-sm text-foreground">Require Admin Approval</span>
|
<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>
|
<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 */}
|
{/* Force enable / disable all */}
|
||||||
{plugins.length > 0 && (
|
{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>
|
<div>
|
||||||
<span className="text-sm text-foreground">Force Enable / Disable All</span>
|
<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>
|
<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">
|
<div className="divide-y divide-border">
|
||||||
{plugins.map(plugin => (
|
{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="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-sm font-medium text-foreground">{plugin.name}</span>
|
||||||
<span className="text-xs text-muted-foreground">v{plugin.version}</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'}`}>
|
<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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<h1 className="text-2xl font-semibold text-foreground">User Policy</h1>
|
<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>
|
<p className="text-sm text-muted-foreground mt-1">Control which features and settings users can access</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -170,13 +170,13 @@ export default function AdminPolicyPage() {
|
|||||||
const { label, description } = meta;
|
const { label, description } = meta;
|
||||||
const enabled = policy.features[key];
|
const enabled = policy.features[key];
|
||||||
return (
|
return (
|
||||||
<div key={key} className="px-4 py-3 flex items-center justify-between gap-4">
|
<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>
|
<div className="min-w-0">
|
||||||
<span className="text-sm text-foreground">{label}</span>
|
<span className="text-sm text-foreground">{label}</span>
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">{description}</p>
|
<p className="text-xs text-muted-foreground mt-0.5">{description}</p>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => toggleFeature(key)}
|
<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]'}`} />
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -195,9 +195,9 @@ export default function AdminPolicyPage() {
|
|||||||
{RESTRICTABLE_SETTINGS.filter(s => s.category === category).map(setting => {
|
{RESTRICTABLE_SETTINGS.filter(s => s.category === category).map(setting => {
|
||||||
const restriction = policy.restrictions[setting.key] || {};
|
const restriction = policy.restrictions[setting.key] || {};
|
||||||
return (
|
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>
|
<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">
|
<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)}
|
<input type="checkbox" checked={!!restriction.locked} onChange={() => toggleLocked(setting.key)}
|
||||||
className="rounded border-input" />
|
className="rounded border-input" />
|
||||||
|
|||||||
+12
-12
@@ -86,8 +86,8 @@ export default function AdminSettingsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<h1 className="text-2xl font-semibold text-foreground">Server Settings</h1>
|
<h1 className="text-2xl font-semibold text-foreground">Server Settings</h1>
|
||||||
<p className="text-sm text-muted-foreground mt-1">General server configuration</p>
|
<p className="text-sm text-muted-foreground mt-1">General server configuration</p>
|
||||||
</div>
|
</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;
|
onChange: (key: string, value: unknown) => void; onRevert: (key: string) => void; placeholder?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
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">
|
<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} />
|
<SourceBadge source={source} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 w-full sm:w-auto">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={value ?? ''}
|
value={value ?? ''}
|
||||||
onChange={(e) => onChange(configKey, e.target.value)}
|
onChange={(e) => onChange(configKey, e.target.value)}
|
||||||
placeholder={placeholder}
|
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' && (
|
{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" />
|
<RotateCcw className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -194,7 +194,7 @@ function ToggleSetting({ label, description, configKey, value, source, onChange,
|
|||||||
onChange: (key: string, value: unknown) => void; onRevert: (key: string) => void;
|
onChange: (key: string, value: unknown) => void; onRevert: (key: string) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
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="min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm text-foreground">{label}</span>
|
<span className="text-sm text-foreground">{label}</span>
|
||||||
@@ -202,7 +202,7 @@ function ToggleSetting({ label, description, configKey, value, source, onChange,
|
|||||||
</div>
|
</div>
|
||||||
{description && <p className="text-xs text-muted-foreground mt-0.5">{description}</p>}
|
{description && <p className="text-xs text-muted-foreground mt-0.5">{description}</p>}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
<button
|
<button
|
||||||
onClick={() => onChange(configKey, !value)}
|
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'}`}
|
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;
|
onChange: (key: string, value: unknown) => void; onRevert: (key: string) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
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">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<span className="text-sm text-foreground">{label}</span>
|
<span className="text-sm text-foreground">{label}</span>
|
||||||
<SourceBadge source={source} />
|
<SourceBadge source={source} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
<select
|
<select
|
||||||
value={value ?? ''}
|
value={value ?? ''}
|
||||||
onChange={(e) => onChange(configKey, e.target.value)}
|
onChange={(e) => onChange(configKey, e.target.value)}
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ export default function AdminTelemetryPage() {
|
|||||||
const isOn = status.consent === 'on';
|
const isOn = status.consent === 'on';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-3xl mx-auto p-6 space-y-6">
|
<div className="space-y-6">
|
||||||
<header className="space-y-2">
|
<header className="space-y-2">
|
||||||
<h1 className="text-2xl font-semibold">Anonymous Usage Stats</h1>
|
<h1 className="text-2xl font-semibold">Anonymous Usage Stats</h1>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
@@ -133,8 +133,8 @@ export default function AdminTelemetryPage() {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section className="rounded-lg border p-4 space-y-3">
|
<section className="rounded-lg border p-4 space-y-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<div className="font-medium">Status</div>
|
<div className="font-medium">Status</div>
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
{status.consent === 'pending' && 'Initialising - no heartbeats sent yet.'}
|
{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
|
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.
|
(open source at <code>bulwarkmail/dashboard</code>) or clear this field to disable sending.
|
||||||
</p>
|
</p>
|
||||||
<div className="flex gap-2">
|
<div className="flex flex-col sm:flex-row gap-2">
|
||||||
<input
|
<input
|
||||||
type="url"
|
type="url"
|
||||||
value={endpointDraft}
|
value={endpointDraft}
|
||||||
onChange={(e) => setEndpointDraft(e.target.value)}
|
onChange={(e) => setEndpointDraft(e.target.value)}
|
||||||
placeholder={status.defaultEndpoint}
|
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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={busy === 'endpoint' || endpointDraft === status.endpoint}
|
disabled={busy === 'endpoint' || endpointDraft === status.endpoint}
|
||||||
onClick={() => void saveEndpoint()}
|
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
|
<Save className="h-4 w-4" /> Save
|
||||||
</button>
|
</button>
|
||||||
@@ -215,8 +215,8 @@ export default function AdminTelemetryPage() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="rounded-lg border p-4 space-y-3">
|
<section className="rounded-lg border p-4 space-y-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<div className="font-medium">Payload preview</div>
|
<div className="font-medium">Payload preview</div>
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
Exactly what the next heartbeat would send from this install, right now.
|
Exactly what the next heartbeat would send from this install, right now.
|
||||||
@@ -226,7 +226,7 @@ export default function AdminTelemetryPage() {
|
|||||||
type="button"
|
type="button"
|
||||||
disabled={busy === 'send' || !isOn}
|
disabled={busy === 'send' || !isOn}
|
||||||
onClick={() => void sendNow()}
|
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
|
<Send className="h-4 w-4" /> Send now
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
+17
-17
@@ -307,12 +307,12 @@ export default function AdminThemesPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<h1 className="text-2xl font-semibold text-foreground">Themes</h1>
|
<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>
|
<p className="text-sm text-muted-foreground mt-1">Manage themes and theme policy for all users</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
{policyDirty && (
|
{policyDirty && (
|
||||||
<button
|
<button
|
||||||
onClick={handleSavePolicy}
|
onClick={handleSavePolicy}
|
||||||
@@ -356,37 +356,37 @@ export default function AdminThemesPage() {
|
|||||||
|
|
||||||
<div className="divide-y divide-border">
|
<div className="divide-y divide-border">
|
||||||
{/* Master toggle */}
|
{/* Master toggle */}
|
||||||
<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>
|
<div className="min-w-0">
|
||||||
<span className="text-sm text-foreground">Themes Enabled</span>
|
<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>
|
<p className="text-xs text-muted-foreground mt-0.5">Allow users to select and apply themes</p>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={toggleThemesEnabled}
|
<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]'}`} />
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* User uploads toggle */}
|
{/* User uploads toggle */}
|
||||||
<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>
|
<div className="min-w-0">
|
||||||
<span className="text-sm text-foreground">User Theme Uploads</span>
|
<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>
|
<p className="text-xs text-muted-foreground mt-0.5">Allow users to upload their own theme files</p>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={toggleUserThemeUploads}
|
<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]'}`} />
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Force enable / disable all */}
|
{/* Force enable / disable all */}
|
||||||
{themes.length > 0 && (
|
{themes.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>
|
<div className="min-w-0">
|
||||||
<span className="text-sm text-foreground">Force Enable / Disable All</span>
|
<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>
|
<p className="text-xs text-muted-foreground mt-0.5">Bulk toggle all deployed themes at once</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
<button
|
<button
|
||||||
onClick={forceEnableAll}
|
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"
|
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 */}
|
{/* Default Theme */}
|
||||||
<div className="px-4 py-3">
|
<div 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>
|
<div className="min-w-0">
|
||||||
<span className="text-sm text-foreground">Default Theme</span>
|
<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>
|
<p className="text-xs text-muted-foreground mt-0.5">Theme applied when users have not chosen one</p>
|
||||||
</div>
|
</div>
|
||||||
<select
|
<select
|
||||||
value={policy.themePolicy?.defaultThemeId || ''}
|
value={policy.themePolicy?.defaultThemeId || ''}
|
||||||
onChange={(e) => setDefaultTheme(e.target.value || null)}
|
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>
|
<option value="">System Default</option>
|
||||||
<optgroup label="Built-in">
|
<optgroup label="Built-in">
|
||||||
@@ -498,9 +498,9 @@ export default function AdminThemesPage() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="divide-y divide-border">
|
<div className="divide-y divide-border">
|
||||||
{themes.map(theme => (
|
{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="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-sm font-medium text-foreground">{theme.name}</span>
|
||||||
<span className="text-xs text-muted-foreground">v{theme.version}</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'}`}>
|
<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'}`}>
|
||||||
|
|||||||
@@ -40,17 +40,53 @@ export async function GET(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'Incomplete JMAP session' }, { status: 502 });
|
return NextResponse.json({ error: 'Incomplete JMAP session' }, { status: 502 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find the inbox, then pull the most recent unread message in it. We use
|
const inboxRes = await fetch(apiUrl, {
|
||||||
// a single batched JMAP request with back-references so this round-trip
|
method: 'POST',
|
||||||
// is one POST regardless of how many messages exist.
|
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 = {
|
const requestBody = {
|
||||||
using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail'],
|
using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail'],
|
||||||
methodCalls: [
|
methodCalls: [
|
||||||
[
|
|
||||||
'Mailbox/query',
|
|
||||||
{ accountId, filter: { role: 'inbox' }, limit: 1 },
|
|
||||||
'mb',
|
|
||||||
],
|
|
||||||
[
|
[
|
||||||
'Email/query',
|
'Email/query',
|
||||||
{
|
{
|
||||||
@@ -58,7 +94,7 @@ export async function GET(request: NextRequest) {
|
|||||||
filter: {
|
filter: {
|
||||||
operator: 'AND',
|
operator: 'AND',
|
||||||
conditions: [
|
conditions: [
|
||||||
{ inMailbox: { resultOf: 'mb', name: 'Mailbox/query', path: '/ids/0' } },
|
{ inMailbox: inboxId },
|
||||||
{ notKeyword: '$seen' },
|
{ notKeyword: '$seen' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -522,6 +522,15 @@ body {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.scroll-hidden::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroll-hidden {
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* Mobile backdrop blur support */
|
/* Mobile backdrop blur support */
|
||||||
@supports (backdrop-filter: blur(8px)) {
|
@supports (backdrop-filter: blur(8px)) {
|
||||||
.mobile-backdrop {
|
.mobile-backdrop {
|
||||||
|
|||||||
+20
-14
@@ -2,6 +2,12 @@ import type { MetadataRoute } from "next";
|
|||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
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 {
|
export default function manifest(): MetadataRoute.Manifest {
|
||||||
const appName =
|
const appName =
|
||||||
process.env.APP_NAME ||
|
process.env.APP_NAME ||
|
||||||
@@ -21,26 +27,26 @@ export default function manifest(): MetadataRoute.Manifest {
|
|||||||
|
|
||||||
const icons: MetadataRoute.Manifest["icons"] = hasCustomIcon
|
const icons: MetadataRoute.Manifest["icons"] = hasCustomIcon
|
||||||
? [
|
? [
|
||||||
{ src: "/api/pwa-icon/192", sizes: "192x192", type: "image/png", purpose: "any" },
|
{ src: withBase("/api/pwa-icon/192"), sizes: "192x192", type: "image/png", purpose: "any" },
|
||||||
{ src: "/api/pwa-icon/512", sizes: "512x512", type: "image/png", purpose: "any" },
|
{ src: withBase("/api/pwa-icon/512"), sizes: "512x512", type: "image/png", purpose: "any" },
|
||||||
{ src: "/api/pwa-icon/192", sizes: "192x192", type: "image/png", purpose: "maskable" },
|
{ src: withBase("/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/512"), sizes: "512x512", type: "image/png", purpose: "maskable" },
|
||||||
]
|
]
|
||||||
: [
|
: [
|
||||||
{ src: "/icon-192x192.png", sizes: "192x192", type: "image/png", purpose: "any" },
|
{ src: withBase("/icon-192x192.png"), sizes: "192x192", type: "image/png", purpose: "any" },
|
||||||
{ src: "/icon-512x512.png", sizes: "512x512", type: "image/png", purpose: "any" },
|
{ src: withBase("/icon-512x512.png"), sizes: "512x512", type: "image/png", purpose: "any" },
|
||||||
{ src: "/icon-maskable-light-192x192.png", sizes: "192x192", type: "image/png", purpose: "maskable" },
|
{ src: withBase("/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: withBase("/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: withBase("/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-maskable-dark-512x512.png"), sizes: "512x512", type: "image/png", purpose: "maskable" },
|
||||||
];
|
];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: appName,
|
name: appName,
|
||||||
short_name: shortName,
|
short_name: shortName,
|
||||||
description,
|
description,
|
||||||
start_url: "/",
|
start_url: withBase("/"),
|
||||||
scope: "/",
|
scope: withBase("/"),
|
||||||
display: "standalone",
|
display: "standalone",
|
||||||
orientation: "portrait-primary",
|
orientation: "portrait-primary",
|
||||||
theme_color: themeColor,
|
theme_color: themeColor,
|
||||||
@@ -48,8 +54,8 @@ export default function manifest(): MetadataRoute.Manifest {
|
|||||||
icons,
|
icons,
|
||||||
categories: ["productivity"],
|
categories: ["productivity"],
|
||||||
screenshots: [
|
screenshots: [
|
||||||
{ src: "/screenshot-540x720.png", sizes: "540x720", type: "image/png" },
|
{ src: withBase("/screenshot-540x720.png"), sizes: "540x720", type: "image/png" },
|
||||||
{ src: "/screenshot-1280x720.png", sizes: "1280x720", type: "image/png" },
|
{ src: withBase("/screenshot-1280x720.png"), sizes: "1280x720", type: "image/png" },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-7
@@ -2,34 +2,43 @@
|
|||||||
|
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
|
import { getPathPrefix } from "@/lib/browser-navigation";
|
||||||
|
|
||||||
export default function NotFound() {
|
export default function NotFound() {
|
||||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
// Don't redirect admin routes to the webmail login page
|
const prefix = getPathPrefix();
|
||||||
const isAdminRoute = window.location.pathname === '/admin' || window.location.pathname.startsWith('/admin/');
|
// 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) {
|
if (!isAdminRoute) {
|
||||||
window.location.href = "/login";
|
window.location.href = `${prefix}/login`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [isAuthenticated]);
|
}, [isAuthenticated]);
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
// Allow admin routes to render the 404 without redirecting
|
let isAdmin = false;
|
||||||
const isAdmin = typeof window !== 'undefined' &&
|
if (typeof window !== 'undefined') {
|
||||||
(window.location.pathname === '/admin' || window.location.pathname.startsWith('/admin/'));
|
const prefix = getPathPrefix();
|
||||||
|
const adminBase = `${prefix}/admin`;
|
||||||
|
isAdmin = window.location.pathname === adminBase || window.location.pathname.startsWith(`${adminBase}/`);
|
||||||
|
}
|
||||||
if (!isAdmin) return null;
|
if (!isAdmin) return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const prefix = typeof window !== 'undefined' ? getPathPrefix() : '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-background">
|
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||||
<div className="text-center max-w-md px-4">
|
<div className="text-center max-w-md px-4">
|
||||||
<h1 className="text-4xl font-bold text-foreground mb-2">404</h1>
|
<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>
|
<p className="text-muted-foreground mb-6">This page could not be found.</p>
|
||||||
<a
|
<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"
|
className="inline-flex items-center px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:opacity-90 transition-opacity"
|
||||||
>
|
>
|
||||||
Go home
|
Go home
|
||||||
|
|||||||
@@ -178,6 +178,14 @@ export function EmailList({
|
|||||||
setIsProcessing(true);
|
setIsProcessing(true);
|
||||||
try {
|
try {
|
||||||
await batchDelete(client, isInTrash);
|
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 {
|
} finally {
|
||||||
setTimeout(() => setIsProcessing(false), 500);
|
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="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">
|
<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">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1 animate-in fade-in slide-in-from-right-3 duration-300">
|
<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}
|
disabled={isProcessing}
|
||||||
className="text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50"
|
className="text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50"
|
||||||
>
|
>
|
||||||
Cancel
|
{t('batch_actions.clear_selection')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+635
-505
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { Mailbox } from "@/lib/jmap/types";
|
import { Mailbox } from "@/lib/jmap/types";
|
||||||
|
import { getMailboxPath } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
ContextMenu,
|
ContextMenu,
|
||||||
ContextMenuItem,
|
ContextMenuItem,
|
||||||
@@ -10,8 +11,10 @@ import {
|
|||||||
} from "@/components/ui/context-menu";
|
} from "@/components/ui/context-menu";
|
||||||
import {
|
import {
|
||||||
CheckCheck,
|
CheckCheck,
|
||||||
|
ChevronRight,
|
||||||
MailOpen,
|
MailOpen,
|
||||||
Mails,
|
Mails,
|
||||||
|
MoreHorizontal,
|
||||||
Trash2,
|
Trash2,
|
||||||
FolderPlus,
|
FolderPlus,
|
||||||
Pencil,
|
Pencil,
|
||||||
@@ -28,12 +31,51 @@ export type MailboxContextTarget =
|
|||||||
| { kind: "mailbox"; mailbox: Mailbox; hasChildren: boolean }
|
| { kind: "mailbox"; mailbox: Mailbox; hasChildren: boolean }
|
||||||
| { kind: "folders-section" };
|
| { 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 {
|
interface MailboxContextMenuProps {
|
||||||
target: MailboxContextTarget | null;
|
target: MailboxContextTarget | null;
|
||||||
position: Position;
|
position: Position;
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
menuRef: React.RefObject<HTMLDivElement | null>;
|
menuRef: React.RefObject<HTMLDivElement | null>;
|
||||||
|
mailboxes: Mailbox[];
|
||||||
onMarkFolderRead?: (mailboxId: string) => void;
|
onMarkFolderRead?: (mailboxId: string) => void;
|
||||||
onMarkFolderTreeRead?: (mailboxId: string) => void;
|
onMarkFolderTreeRead?: (mailboxId: string) => void;
|
||||||
onMarkAllFoldersRead?: () => void;
|
onMarkAllFoldersRead?: () => void;
|
||||||
@@ -51,6 +93,7 @@ export function MailboxContextMenu({
|
|||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
menuRef,
|
menuRef,
|
||||||
|
mailboxes,
|
||||||
onMarkFolderRead,
|
onMarkFolderRead,
|
||||||
onMarkFolderTreeRead,
|
onMarkFolderTreeRead,
|
||||||
onMarkAllFoldersRead,
|
onMarkAllFoldersRead,
|
||||||
@@ -107,9 +150,13 @@ export function MailboxContextMenu({
|
|||||||
const canSetSeen = mailbox.myRights?.maySetSeen !== false;
|
const canSetSeen = mailbox.myRights?.maySetSeen !== false;
|
||||||
const canRemoveItems = mailbox.myRights?.mayRemoveItems !== false;
|
const canRemoveItems = mailbox.myRights?.mayRemoveItems !== false;
|
||||||
|
|
||||||
|
const fullPath = getMailboxPath(mailbox, mailboxes);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ContextMenu ref={menuRef} isOpen={isOpen} position={position} onClose={onClose}>
|
<ContextMenu ref={menuRef} isOpen={isOpen} position={position} onClose={onClose}>
|
||||||
<ContextMenuHeader>{mailbox.name}</ContextMenuHeader>
|
<ContextMenuHeader>
|
||||||
|
<span title={fullPath}>{renderShortenedPath(fullPath)}</span>
|
||||||
|
</ContextMenuHeader>
|
||||||
|
|
||||||
<ContextMenuItem
|
<ContextMenuItem
|
||||||
icon={MailOpen}
|
icon={MailOpen}
|
||||||
|
|||||||
@@ -400,7 +400,7 @@ export function NavigationRail({
|
|||||||
|
|
||||||
<nav
|
<nav
|
||||||
className={cn(
|
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",
|
collapsed ? "items-center gap-1 py-3 px-1" : "gap-0.5 py-2 px-2",
|
||||||
)}
|
)}
|
||||||
role="navigation"
|
role="navigation"
|
||||||
|
|||||||
@@ -1028,6 +1028,7 @@ export function Sidebar({
|
|||||||
isOpen={mailboxContextMenu.isOpen}
|
isOpen={mailboxContextMenu.isOpen}
|
||||||
onClose={closeMailboxContextMenu}
|
onClose={closeMailboxContextMenu}
|
||||||
menuRef={mailboxMenuRef}
|
menuRef={mailboxMenuRef}
|
||||||
|
mailboxes={mailboxes}
|
||||||
onMarkFolderRead={onMarkFolderRead}
|
onMarkFolderRead={onMarkFolderRead}
|
||||||
onMarkFolderTreeRead={onMarkFolderTreeRead}
|
onMarkFolderTreeRead={onMarkFolderTreeRead}
|
||||||
onMarkAllFoldersRead={onMarkAllFoldersRead}
|
onMarkAllFoldersRead={onMarkAllFoldersRead}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
const BASE_PATH = (process.env.NEXT_PUBLIC_BASE_PATH ?? "").replace(/\/+$/, "");
|
||||||
|
|
||||||
export function ServiceWorkerRegistration() {
|
export function ServiceWorkerRegistration() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === "undefined" || !("serviceWorker" in navigator)) {
|
if (typeof window === "undefined" || !("serviceWorker" in navigator)) {
|
||||||
@@ -23,7 +25,7 @@ export function ServiceWorkerRegistration() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
navigator.serviceWorker
|
navigator.serviceWorker
|
||||||
.register("/sw.js")
|
.register(`${BASE_PATH}/sw.js`, { scope: `${BASE_PATH}/` })
|
||||||
.then((registration) => {
|
.then((registration) => {
|
||||||
console.log("Service Worker registered successfully:", registration);
|
console.log("Service Worker registered successfully:", registration);
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useConfig } from '@/hooks/use-config';
|
|||||||
import { useSettingsStore } from '@/stores/settings-store';
|
import { useSettingsStore } from '@/stores/settings-store';
|
||||||
import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
|
import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
|
||||||
import { Mail, X } from 'lucide-react';
|
import { Mail, X } from 'lucide-react';
|
||||||
|
import { getPathPrefix } from '@/lib/browser-navigation';
|
||||||
import {
|
import {
|
||||||
SUPPORTED_SUB_ADDRESS_DELIMITERS,
|
SUPPORTED_SUB_ADDRESS_DELIMITERS,
|
||||||
isSupportedSubAddressDelimiter,
|
isSupportedSubAddressDelimiter,
|
||||||
@@ -32,7 +33,7 @@ export function ComposingSettings() {
|
|||||||
const handleSetDefaultMailProgram = useCallback(() => {
|
const handleSetDefaultMailProgram = useCallback(() => {
|
||||||
try {
|
try {
|
||||||
if (typeof navigator !== 'undefined' && navigator.registerProtocolHandler) {
|
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');
|
setDefaultMailStatus('success');
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export function ReadingSettings() {
|
|||||||
hoverActionsMode,
|
hoverActionsMode,
|
||||||
hoverActionsCorner,
|
hoverActionsCorner,
|
||||||
hideInlineImageAttachments,
|
hideInlineImageAttachments,
|
||||||
|
attachmentImagePreviewsEnabled,
|
||||||
updateSetting,
|
updateSetting,
|
||||||
} = useSettingsStore();
|
} = useSettingsStore();
|
||||||
|
|
||||||
@@ -206,6 +207,13 @@ export function ReadingSettings() {
|
|||||||
/>
|
/>
|
||||||
</SettingItem>
|
</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') && (
|
{isFeatureEnabled('hoverActionsConfigEnabled') && (
|
||||||
<div className="py-3 border-b border-border space-y-3">
|
<div className="py-3 border-b border-border space-y-3">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -44,8 +44,8 @@ interface SettingItemProps {
|
|||||||
|
|
||||||
export function SettingItem({ label, description, children, locked }: SettingItemProps) {
|
export function SettingItem({ label, description, children, locked }: SettingItemProps) {
|
||||||
return (
|
return (
|
||||||
<div className={cn("flex items-start justify-between py-3 border-b border-border last:border-0", locked && "opacity-60")}>
|
<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 pr-4">
|
<div className="flex-1 min-w-0 sm:pr-4">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<label className="text-sm font-medium text-foreground">{label}</label>
|
<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" />}
|
{locked && <Lock className="w-3 h-3 text-muted-foreground" aria-label="Managed by administrator" />}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { createContext, useContext, useState, useCallback, useEffect, type ReactNode } from "react";
|
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 { useAuthStore } from "@/stores/auth-store";
|
||||||
import { useCalendarStore } from "@/stores/calendar-store";
|
import { useCalendarStore } from "@/stores/calendar-store";
|
||||||
import { useWebDAVStore } from "@/stores/webdav-store";
|
import { useWebDAVStore } from "@/stores/webdav-store";
|
||||||
@@ -34,6 +34,7 @@ export function useTour() {
|
|||||||
|
|
||||||
export function TourProvider({ children }: { children: ReactNode }) {
|
export function TourProvider({ children }: { children: ReactNode }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
const { isDemoMode } = useAuthStore();
|
const { isDemoMode } = useAuthStore();
|
||||||
const { supportsCalendar } = useCalendarStore();
|
const { supportsCalendar } = useCalendarStore();
|
||||||
const { supportsWebDAV } = useWebDAVStore();
|
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 the resume step is beyond the current steps, start from 0
|
||||||
if (resumeStep >= steps.length) resumeStep = 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);
|
setCurrentStep(resumeStep);
|
||||||
setIsActive(true);
|
setIsActive(true);
|
||||||
}, [steps.length]);
|
}, [steps, pathname, router]);
|
||||||
|
|
||||||
const stopTour = useCallback(() => {
|
const stopTour = useCallback(() => {
|
||||||
setIsActive(false);
|
setIsActive(false);
|
||||||
@@ -90,14 +98,17 @@ export function TourProvider({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
const next = currentStep + 1;
|
const next = currentStep + 1;
|
||||||
const nextStepDef = steps[next];
|
const nextStepDef = steps[next];
|
||||||
|
const currentStepDef = steps[currentStep];
|
||||||
setCurrentStep(next);
|
setCurrentStep(next);
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(TOUR_CURRENT_STEP_KEY, String(next));
|
localStorage.setItem(TOUR_CURRENT_STEP_KEY, String(next));
|
||||||
} catch { /* */ }
|
} catch { /* */ }
|
||||||
|
|
||||||
// Navigate if the next step requires a different page
|
// Navigate when the next step is on a different page (treat "no page" as the mailbox)
|
||||||
if (nextStepDef?.page) {
|
const nextPage = nextStepDef?.page ?? "/";
|
||||||
router.push(nextStepDef.page);
|
const currentPage = currentStepDef?.page ?? "/";
|
||||||
|
if (nextPage !== currentPage) {
|
||||||
|
router.push(nextPage);
|
||||||
}
|
}
|
||||||
}, [currentStep, steps, completeTour, router]);
|
}, [currentStep, steps, completeTour, router]);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { forwardRef, useState, useRef, useEffect } from "react";
|
import { forwardRef, useState, useRef, useEffect, useLayoutEffect } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { ChevronRight } from "lucide-react";
|
import { ChevronRight } from "lucide-react";
|
||||||
@@ -17,26 +17,72 @@ interface ContextMenuProps {
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const VIEWPORT_MARGIN = 10;
|
||||||
|
|
||||||
export const ContextMenu = forwardRef<HTMLDivElement, ContextMenuProps>(
|
export const ContextMenu = forwardRef<HTMLDivElement, ContextMenuProps>(
|
||||||
({ isOpen, position, onClose: _onClose, children }, ref) => {
|
({ isOpen, position, onClose: _onClose, children }, ref) => {
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
|
const [adjustedPosition, setAdjustedPosition] = useState<Position | null>(null);
|
||||||
|
const localRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMounted(true);
|
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;
|
if (!mounted || !isOpen) return null;
|
||||||
|
|
||||||
|
const renderPosition = adjustedPosition ?? position;
|
||||||
|
const isPositioned = adjustedPosition !== null;
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={setRefs}
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed z-50 min-w-[200px] bg-background rounded-md shadow-lg border border-border",
|
"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"
|
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
left: position.x,
|
left: renderPosition.x,
|
||||||
top: position.y,
|
top: renderPosition.y,
|
||||||
|
visibility: isPositioned ? "visible" : "hidden",
|
||||||
}}
|
}}
|
||||||
role="menu"
|
role="menu"
|
||||||
aria-orientation="vertical"
|
aria-orientation="vertical"
|
||||||
@@ -112,22 +158,41 @@ export function ContextMenuSubMenu({
|
|||||||
children,
|
children,
|
||||||
}: ContextMenuSubMenuProps) {
|
}: ContextMenuSubMenuProps) {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
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 itemRef = useRef<HTMLDivElement>(null);
|
||||||
const subMenuRef = useRef<HTMLDivElement>(null);
|
const subMenuRef = useRef<HTMLDivElement>(null);
|
||||||
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (isOpen && itemRef.current) {
|
if (!isOpen) {
|
||||||
const rect = itemRef.current.getBoundingClientRect();
|
setSubMenuPos(null);
|
||||||
const viewportWidth = window.innerWidth;
|
return;
|
||||||
|
|
||||||
if (rect.right + 200 > viewportWidth - 10) {
|
|
||||||
setSubMenuPosition("left");
|
|
||||||
} else {
|
|
||||||
setSubMenuPosition("right");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
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]);
|
}, [isOpen]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -174,13 +239,17 @@ export function ContextMenuSubMenu({
|
|||||||
<div
|
<div
|
||||||
ref={subMenuRef}
|
ref={subMenuRef}
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute top-0 min-w-[180px] bg-background rounded-md shadow-lg border border-border",
|
"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",
|
"animate-in fade-in-0 zoom-in-95 duration-100"
|
||||||
subMenuPosition === "right" ? "left-full" : "right-full"
|
|
||||||
)}
|
)}
|
||||||
|
style={{
|
||||||
|
left: subMenuPos?.x ?? 0,
|
||||||
|
top: subMenuPos?.y ?? 0,
|
||||||
|
visibility: subMenuPos ? "visible" : "hidden",
|
||||||
|
}}
|
||||||
role="menu"
|
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}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { useCalendarNotificationStore } from '@/stores/calendar-notification-sto
|
|||||||
import { useToastStore } from '@/stores/toast-store';
|
import { useToastStore } from '@/stores/toast-store';
|
||||||
import { getPendingAlerts, getPendingTaskAlerts, buildAlertKey } from '@/lib/calendar-alerts';
|
import { getPendingAlerts, getPendingTaskAlerts, buildAlertKey } from '@/lib/calendar-alerts';
|
||||||
import { playNotificationSound } from '@/lib/notification-sound';
|
import { playNotificationSound } from '@/lib/notification-sound';
|
||||||
|
import { getPathPrefix } from '@/lib/browser-navigation';
|
||||||
import type { CalendarEvent } from '@/lib/jmap/types';
|
import type { CalendarEvent } from '@/lib/jmap/types';
|
||||||
|
|
||||||
const CHECK_INTERVAL_MS = 60 * 1000;
|
const CHECK_INTERVAL_MS = 60 * 1000;
|
||||||
@@ -68,7 +69,7 @@ export function useCalendarAlerts() {
|
|||||||
message,
|
message,
|
||||||
duration: 15000,
|
duration: 15000,
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
window.location.href = `/${locale}/calendar`;
|
window.location.href = `${getPathPrefix(locale)}/${locale}/calendar`;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -97,7 +98,7 @@ export function useCalendarAlerts() {
|
|||||||
message: taskMsg,
|
message: taskMsg,
|
||||||
duration: 15000,
|
duration: 15000,
|
||||||
onClick: () => {
|
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_WIDTH = 200;
|
||||||
const MENU_HEIGHT = 320; // Approximate max height
|
const MENU_HEIGHT = 320; // Approximate max height
|
||||||
|
const VIEWPORT_MARGIN = 10;
|
||||||
|
|
||||||
export function useContextMenu<T>(): UseContextMenuReturn<T> {
|
export function useContextMenu<T>(): UseContextMenuReturn<T> {
|
||||||
const [contextMenu, setContextMenu] = useState<ContextMenuState<T>>({
|
const [contextMenu, setContextMenu] = useState<ContextMenuState<T>>({
|
||||||
@@ -40,18 +41,18 @@ export function useContextMenu<T>(): UseContextMenuReturn<T> {
|
|||||||
let y = clientY;
|
let y = clientY;
|
||||||
|
|
||||||
// Adjust for right edge
|
// Adjust for right edge
|
||||||
if (x + MENU_WIDTH > viewportWidth - 10) {
|
if (x + MENU_WIDTH > viewportWidth - VIEWPORT_MARGIN) {
|
||||||
x = viewportWidth - MENU_WIDTH - 10;
|
x = viewportWidth - MENU_WIDTH - VIEWPORT_MARGIN;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Adjust for bottom edge
|
// Adjust for bottom edge
|
||||||
if (y + MENU_HEIGHT > viewportHeight - 10) {
|
if (y + MENU_HEIGHT > viewportHeight - VIEWPORT_MARGIN) {
|
||||||
y = viewportHeight - MENU_HEIGHT - 10;
|
y = viewportHeight - MENU_HEIGHT - VIEWPORT_MARGIN;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure minimum position
|
// Ensure minimum position
|
||||||
x = Math.max(10, x);
|
x = Math.max(VIEWPORT_MARGIN, x);
|
||||||
y = Math.max(10, y);
|
y = Math.max(VIEWPORT_MARGIN, y);
|
||||||
|
|
||||||
return { x, y };
|
return { x, y };
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
@@ -8,17 +8,27 @@ export function replaceWindowLocation(url: string): void {
|
|||||||
window.location.replace(url);
|
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.
|
* Returns the mount prefix the app is served at.
|
||||||
* 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.
|
|
||||||
*
|
*
|
||||||
* If a locale is supplied (e.g. from route params) it is used directly;
|
* Resolution order:
|
||||||
* otherwise the first path segment that matches a known locale is used.
|
* 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.
|
* Returns '' when there is no prefix.
|
||||||
*/
|
*/
|
||||||
export function getPathPrefix(locale?: string): string {
|
export function getPathPrefix(locale?: string): string {
|
||||||
|
if (STATIC_BASE_PATH) return STATIC_BASE_PATH;
|
||||||
if (typeof window === 'undefined') return '';
|
if (typeof window === 'undefined') return '';
|
||||||
|
|
||||||
const segments = window.location.pathname.split('/').filter(Boolean);
|
const segments = window.location.pathname.split('/').filter(Boolean);
|
||||||
|
|||||||
+8
-4
@@ -9,6 +9,10 @@ import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
|||||||
const DEVICE_CLIENT_ID_KEY = 'bulwark.push.deviceClientId.v1';
|
const DEVICE_CLIENT_ID_KEY = 'bulwark.push.deviceClientId.v1';
|
||||||
const SUBSCRIPTION_ID_KEY = 'bulwark.push.subscriptionId.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.
|
// 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
|
// Override at build time via NEXT_PUBLIC_PUSH_RELAY_URL or at runtime by
|
||||||
// calling enableWebPush({ relayBaseUrl }) from the settings UI.
|
// calling enableWebPush({ relayBaseUrl }) from the settings UI.
|
||||||
@@ -139,9 +143,9 @@ async function ensureServiceWorker(): Promise<ServiceWorkerRegistration> {
|
|||||||
// The webmail's PWA already registers /sw.js for installability. If it
|
// 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
|
// hasn't been picked up yet (e.g. first load), kick it ourselves so the
|
||||||
// push handler is in place.
|
// push handler is in place.
|
||||||
let registration = await navigator.serviceWorker.getRegistration('/');
|
let registration = await navigator.serviceWorker.getRegistration(SW_SCOPE);
|
||||||
if (!registration) {
|
if (!registration) {
|
||||||
registration = await navigator.serviceWorker.register('/sw.js');
|
registration = await navigator.serviceWorker.register(SW_URL, { scope: SW_SCOPE });
|
||||||
}
|
}
|
||||||
await navigator.serviceWorker.ready;
|
await navigator.serviceWorker.ready;
|
||||||
return registration;
|
return registration;
|
||||||
@@ -336,7 +340,7 @@ export async function disableWebPush(params: DisableWebPushParams): Promise<void
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (typeof navigator !== 'undefined' && 'serviceWorker' in navigator) {
|
if (typeof navigator !== 'undefined' && 'serviceWorker' in navigator) {
|
||||||
const registration = await navigator.serviceWorker.getRegistration('/');
|
const registration = await navigator.serviceWorker.getRegistration(SW_SCOPE);
|
||||||
const sub = await registration?.pushManager.getSubscription();
|
const sub = await registration?.pushManager.getSubscription();
|
||||||
if (sub) await sub.unsubscribe().catch(() => undefined);
|
if (sub) await sub.unsubscribe().catch(() => undefined);
|
||||||
}
|
}
|
||||||
@@ -345,7 +349,7 @@ export async function disableWebPush(params: DisableWebPushParams): Promise<void
|
|||||||
export async function isWebPushEnabled(): Promise<boolean> {
|
export async function isWebPushEnabled(): Promise<boolean> {
|
||||||
if (!isWebPushSupported()) return false;
|
if (!isWebPushSupported()) return false;
|
||||||
if (Notification.permission !== 'granted') return false;
|
if (Notification.permission !== 'granted') return false;
|
||||||
const registration = await navigator.serviceWorker.getRegistration('/');
|
const registration = await navigator.serviceWorker.getRegistration(SW_SCOPE);
|
||||||
if (!registration) return false;
|
if (!registration) return false;
|
||||||
const sub = await registration.pushManager.getSubscription();
|
const sub = await registration.pushManager.getSubscription();
|
||||||
return sub !== null && localStorage.getItem(SUBSCRIPTION_ID_KEY) !== null;
|
return sub !== null && localStorage.getItem(SUBSCRIPTION_ID_KEY) !== null;
|
||||||
|
|||||||
+50
-2
@@ -184,6 +184,7 @@
|
|||||||
"batch_actions": {
|
"batch_actions": {
|
||||||
"select": "Vybrat zprávy",
|
"select": "Vybrat zprávy",
|
||||||
"select_all": "Vybrat vše",
|
"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_read": "Označit jako přečtené",
|
||||||
"mark_unread": "Označit jako nepřečtené",
|
"mark_unread": "Označit jako nepřečtené",
|
||||||
"delete": "Odstranit",
|
"delete": "Odstranit",
|
||||||
@@ -223,6 +224,11 @@
|
|||||||
"unstar": "Odebrat hvězdičku",
|
"unstar": "Odebrat hvězdičku",
|
||||||
"mark_unread": "Označit jako nepřečtené",
|
"mark_unread": "Označit jako nepřečtené",
|
||||||
"mark_read": "Označit jako př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",
|
"print": "Tisk",
|
||||||
"view_source": "Zobrazit zdrojový kód",
|
"view_source": "Zobrazit zdrojový kód",
|
||||||
"export_email": "Exportovat jako .eml",
|
"export_email": "Exportovat jako .eml",
|
||||||
@@ -306,7 +312,20 @@
|
|||||||
"fail": "DMARC selhalo",
|
"fail": "DMARC selhalo",
|
||||||
"none": "Bez DMARC"
|
"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": {
|
"headers": {
|
||||||
"routing": "Směrování",
|
"routing": "Směrování",
|
||||||
@@ -431,7 +450,32 @@
|
|||||||
"collapse": "Skrýt podrobnosti"
|
"collapse": "Skrýt podrobnosti"
|
||||||
},
|
},
|
||||||
"send": "Odeslat",
|
"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": {
|
"email_composer": {
|
||||||
"new_message": "Nová zpráva",
|
"new_message": "Nová zpráva",
|
||||||
@@ -1009,6 +1053,10 @@
|
|||||||
"hide_inline_image_attachments": {
|
"hide_inline_image_attachments": {
|
||||||
"label": "Skrýt vložené obrázky ze seznamu příloh",
|
"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"
|
"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": {
|
"composer": {
|
||||||
|
|||||||
+50
-2
@@ -184,6 +184,7 @@
|
|||||||
"batch_actions": {
|
"batch_actions": {
|
||||||
"select": "E-Mails auswählen",
|
"select": "E-Mails auswählen",
|
||||||
"select_all": "Alle 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_read": "Als gelesen markieren",
|
||||||
"mark_unread": "Als ungelesen markieren",
|
"mark_unread": "Als ungelesen markieren",
|
||||||
"delete": "Löschen",
|
"delete": "Löschen",
|
||||||
@@ -223,6 +224,11 @@
|
|||||||
"unstar": "Stern entfernen",
|
"unstar": "Stern entfernen",
|
||||||
"mark_unread": "Als ungelesen markieren",
|
"mark_unread": "Als ungelesen markieren",
|
||||||
"mark_read": "Als gelesen markieren",
|
"mark_read": "Als gelesen markieren",
|
||||||
|
"unread": "Ungelesen",
|
||||||
|
"read": "Gelesen",
|
||||||
|
"spam_short": "Spam",
|
||||||
|
"not_spam_short": "Kein Spam",
|
||||||
|
"move": "Verschieben",
|
||||||
"print": "Drucken",
|
"print": "Drucken",
|
||||||
"view_source": "Quelltext anzeigen",
|
"view_source": "Quelltext anzeigen",
|
||||||
"export_email": "Als .eml exportieren",
|
"export_email": "Als .eml exportieren",
|
||||||
@@ -304,7 +310,20 @@
|
|||||||
"fail": "DMARC Fehlgeschlagen",
|
"fail": "DMARC Fehlgeschlagen",
|
||||||
"none": "Kein DMARC"
|
"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": {
|
"headers": {
|
||||||
"routing": "Routing",
|
"routing": "Routing",
|
||||||
@@ -431,7 +450,32 @@
|
|||||||
"previous": "Zurück",
|
"previous": "Zurück",
|
||||||
"next": "Weiter",
|
"next": "Weiter",
|
||||||
"send": "Senden",
|
"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": {
|
"email_composer": {
|
||||||
"new_message": "Neue Nachricht",
|
"new_message": "Neue Nachricht",
|
||||||
@@ -1009,6 +1053,10 @@
|
|||||||
"hide_inline_image_attachments": {
|
"hide_inline_image_attachments": {
|
||||||
"label": "Eingebettete Bilder in Anhangsliste ausblenden",
|
"label": "Eingebettete Bilder in Anhangsliste ausblenden",
|
||||||
"description": "Im Nachrichtentext eingebettete Bilder werden nicht als separate Anhänge aufgeführt"
|
"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": {
|
"composer": {
|
||||||
|
|||||||
+49
-1
@@ -184,6 +184,7 @@
|
|||||||
"batch_actions": {
|
"batch_actions": {
|
||||||
"select": "Select emails",
|
"select": "Select emails",
|
||||||
"select_all": "Select all",
|
"select_all": "Select all",
|
||||||
|
"selected_messages": "{count, plural, one {1 email} other {# emails}} selected",
|
||||||
"mark_read": "Mark as read",
|
"mark_read": "Mark as read",
|
||||||
"mark_unread": "Mark as unread",
|
"mark_unread": "Mark as unread",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
@@ -223,6 +224,11 @@
|
|||||||
"unstar": "Unstar",
|
"unstar": "Unstar",
|
||||||
"mark_unread": "Mark as unread",
|
"mark_unread": "Mark as unread",
|
||||||
"mark_read": "Mark as read",
|
"mark_read": "Mark as read",
|
||||||
|
"unread": "Unread",
|
||||||
|
"read": "Read",
|
||||||
|
"spam_short": "Spam",
|
||||||
|
"not_spam_short": "Not spam",
|
||||||
|
"move": "Move",
|
||||||
"print": "Print",
|
"print": "Print",
|
||||||
"view_source": "View source",
|
"view_source": "View source",
|
||||||
"export_email": "Export as .eml",
|
"export_email": "Export as .eml",
|
||||||
@@ -306,7 +312,45 @@
|
|||||||
"fail": "DMARC Fail",
|
"fail": "DMARC Fail",
|
||||||
"none": "No DMARC"
|
"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": {
|
"headers": {
|
||||||
"routing": "Routing",
|
"routing": "Routing",
|
||||||
@@ -1009,6 +1053,10 @@
|
|||||||
"hide_inline_image_attachments": {
|
"hide_inline_image_attachments": {
|
||||||
"label": "Hide inline images from attachments",
|
"label": "Hide inline images from attachments",
|
||||||
"description": "Images embedded in the message body are not listed as separate 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": {
|
"composer": {
|
||||||
|
|||||||
+50
-2
@@ -184,6 +184,7 @@
|
|||||||
"batch_actions": {
|
"batch_actions": {
|
||||||
"select": "Seleccionar correos",
|
"select": "Seleccionar correos",
|
||||||
"select_all": "Seleccionar todo",
|
"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_read": "Marcar como leído",
|
||||||
"mark_unread": "Marcar como no leído",
|
"mark_unread": "Marcar como no leído",
|
||||||
"delete": "Eliminar",
|
"delete": "Eliminar",
|
||||||
@@ -223,6 +224,11 @@
|
|||||||
"unstar": "Quitar destacado",
|
"unstar": "Quitar destacado",
|
||||||
"mark_unread": "Marcar como no leído",
|
"mark_unread": "Marcar como no leído",
|
||||||
"mark_read": "Marcar como 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",
|
"print": "Imprimir",
|
||||||
"view_source": "Ver código fuente",
|
"view_source": "Ver código fuente",
|
||||||
"export_email": "Exportar como .eml",
|
"export_email": "Exportar como .eml",
|
||||||
@@ -304,7 +310,20 @@
|
|||||||
"fail": "DMARC Fallido",
|
"fail": "DMARC Fallido",
|
||||||
"none": "Sin DMARC"
|
"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": {
|
"headers": {
|
||||||
"routing": "Enrutamiento",
|
"routing": "Enrutamiento",
|
||||||
@@ -431,7 +450,32 @@
|
|||||||
"previous": "Anterior",
|
"previous": "Anterior",
|
||||||
"next": "Siguiente",
|
"next": "Siguiente",
|
||||||
"send": "Enviar",
|
"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": {
|
"email_composer": {
|
||||||
"new_message": "Nuevo Mensaje",
|
"new_message": "Nuevo Mensaje",
|
||||||
@@ -1009,6 +1053,10 @@
|
|||||||
"hide_inline_image_attachments": {
|
"hide_inline_image_attachments": {
|
||||||
"label": "Ocultar imágenes incrustadas de los adjuntos",
|
"label": "Ocultar imágenes incrustadas de los adjuntos",
|
||||||
"description": "Las imágenes incrustadas en el cuerpo del mensaje no se listan como adjuntos separados"
|
"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": {
|
"composer": {
|
||||||
|
|||||||
+50
-2
@@ -184,6 +184,7 @@
|
|||||||
"batch_actions": {
|
"batch_actions": {
|
||||||
"select": "Sélectionner les e-mails",
|
"select": "Sélectionner les e-mails",
|
||||||
"select_all": "Tout sélectionner",
|
"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_read": "Marquer comme lu",
|
||||||
"mark_unread": "Marquer comme non lu",
|
"mark_unread": "Marquer comme non lu",
|
||||||
"delete": "Supprimer",
|
"delete": "Supprimer",
|
||||||
@@ -223,6 +224,11 @@
|
|||||||
"unstar": "Retirer des favoris",
|
"unstar": "Retirer des favoris",
|
||||||
"mark_unread": "Marquer comme non lu",
|
"mark_unread": "Marquer comme non lu",
|
||||||
"mark_read": "Marquer comme lu",
|
"mark_read": "Marquer comme lu",
|
||||||
|
"unread": "Non lu",
|
||||||
|
"read": "Lu",
|
||||||
|
"spam_short": "Spam",
|
||||||
|
"not_spam_short": "Non spam",
|
||||||
|
"move": "Déplacer",
|
||||||
"print": "Imprimer",
|
"print": "Imprimer",
|
||||||
"view_source": "Voir la source",
|
"view_source": "Voir la source",
|
||||||
"export_email": "Exporter en .eml",
|
"export_email": "Exporter en .eml",
|
||||||
@@ -304,7 +310,20 @@
|
|||||||
"fail": "DMARC Échoué",
|
"fail": "DMARC Échoué",
|
||||||
"none": "Pas de DMARC"
|
"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": {
|
"headers": {
|
||||||
"routing": "Routage",
|
"routing": "Routage",
|
||||||
@@ -431,7 +450,32 @@
|
|||||||
"previous": "Précédent",
|
"previous": "Précédent",
|
||||||
"next": "Suivant",
|
"next": "Suivant",
|
||||||
"send": "Envoyer",
|
"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": {
|
"email_composer": {
|
||||||
"new_message": "Nouveau message",
|
"new_message": "Nouveau message",
|
||||||
@@ -1009,6 +1053,10 @@
|
|||||||
"hide_inline_image_attachments": {
|
"hide_inline_image_attachments": {
|
||||||
"label": "Masquer les images intégrées des pièces jointes",
|
"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"
|
"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": {
|
"composer": {
|
||||||
|
|||||||
+50
-2
@@ -184,6 +184,7 @@
|
|||||||
"batch_actions": {
|
"batch_actions": {
|
||||||
"select": "Seleziona e-mail",
|
"select": "Seleziona e-mail",
|
||||||
"select_all": "Seleziona tutto",
|
"select_all": "Seleziona tutto",
|
||||||
|
"selected_messages": "{count} email {count, plural, one {selezionata} other {selezionate}}",
|
||||||
"mark_read": "Segna come letto",
|
"mark_read": "Segna come letto",
|
||||||
"mark_unread": "Segna come non letto",
|
"mark_unread": "Segna come non letto",
|
||||||
"delete": "Elimina",
|
"delete": "Elimina",
|
||||||
@@ -223,6 +224,11 @@
|
|||||||
"unstar": "Rimuovi stella",
|
"unstar": "Rimuovi stella",
|
||||||
"mark_unread": "Segna come non letto",
|
"mark_unread": "Segna come non letto",
|
||||||
"mark_read": "Segna come letto",
|
"mark_read": "Segna come letto",
|
||||||
|
"unread": "Non letto",
|
||||||
|
"read": "Letto",
|
||||||
|
"spam_short": "Spam",
|
||||||
|
"not_spam_short": "Non spam",
|
||||||
|
"move": "Sposta",
|
||||||
"print": "Stampa",
|
"print": "Stampa",
|
||||||
"view_source": "Visualizza sorgente",
|
"view_source": "Visualizza sorgente",
|
||||||
"export_email": "Esporta come .eml",
|
"export_email": "Esporta come .eml",
|
||||||
@@ -304,7 +310,20 @@
|
|||||||
"fail": "DMARC fallito",
|
"fail": "DMARC fallito",
|
||||||
"none": "Nessun DMARC"
|
"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": {
|
"headers": {
|
||||||
"routing": "Instradamento",
|
"routing": "Instradamento",
|
||||||
@@ -431,7 +450,32 @@
|
|||||||
"previous": "Precedente",
|
"previous": "Precedente",
|
||||||
"next": "Successivo",
|
"next": "Successivo",
|
||||||
"send": "Invia",
|
"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": {
|
"email_composer": {
|
||||||
"new_message": "Nuovo messaggio",
|
"new_message": "Nuovo messaggio",
|
||||||
@@ -1009,6 +1053,10 @@
|
|||||||
"hide_inline_image_attachments": {
|
"hide_inline_image_attachments": {
|
||||||
"label": "Nascondi le immagini inline dagli allegati",
|
"label": "Nascondi le immagini inline dagli allegati",
|
||||||
"description": "Le immagini incorporate nel corpo del messaggio non vengono elencate come allegati separati"
|
"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": {
|
"composer": {
|
||||||
|
|||||||
+50
-2
@@ -184,6 +184,7 @@
|
|||||||
"batch_actions": {
|
"batch_actions": {
|
||||||
"select": "メールを選択",
|
"select": "メールを選択",
|
||||||
"select_all": "すべて選択",
|
"select_all": "すべて選択",
|
||||||
|
"selected_messages": "{count, plural, one {1} other {#}}通のメールが選択されました",
|
||||||
"mark_read": "既読にする",
|
"mark_read": "既読にする",
|
||||||
"mark_unread": "未読にする",
|
"mark_unread": "未読にする",
|
||||||
"delete": "削除",
|
"delete": "削除",
|
||||||
@@ -223,6 +224,11 @@
|
|||||||
"unstar": "スターを外す",
|
"unstar": "スターを外す",
|
||||||
"mark_unread": "未読にする",
|
"mark_unread": "未読にする",
|
||||||
"mark_read": "既読にする",
|
"mark_read": "既読にする",
|
||||||
|
"unread": "未読",
|
||||||
|
"read": "既読",
|
||||||
|
"spam_short": "迷惑メール",
|
||||||
|
"not_spam_short": "迷惑メールでない",
|
||||||
|
"move": "移動",
|
||||||
"print": "印刷",
|
"print": "印刷",
|
||||||
"view_source": "ソースを表示",
|
"view_source": "ソースを表示",
|
||||||
"export_email": ".emlとしてエクスポート",
|
"export_email": ".emlとしてエクスポート",
|
||||||
@@ -304,7 +310,20 @@
|
|||||||
"fail": "DMARC不合格",
|
"fail": "DMARC不合格",
|
||||||
"none": "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": {
|
"headers": {
|
||||||
"routing": "ルーティング",
|
"routing": "ルーティング",
|
||||||
@@ -431,7 +450,32 @@
|
|||||||
"previous": "前へ",
|
"previous": "前へ",
|
||||||
"next": "次へ",
|
"next": "次へ",
|
||||||
"send": "送信",
|
"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": {
|
"email_composer": {
|
||||||
"new_message": "新規メッセージ",
|
"new_message": "新規メッセージ",
|
||||||
@@ -1009,6 +1053,10 @@
|
|||||||
"hide_inline_image_attachments": {
|
"hide_inline_image_attachments": {
|
||||||
"label": "添付ファイル一覧からインライン画像を隠す",
|
"label": "添付ファイル一覧からインライン画像を隠す",
|
||||||
"description": "本文に埋め込まれた画像を個別の添付ファイルとして表示しません"
|
"description": "本文に埋め込まれた画像を個別の添付ファイルとして表示しません"
|
||||||
|
},
|
||||||
|
"attachment_image_previews": {
|
||||||
|
"label": "添付ファイル内の画像プレビューを表示",
|
||||||
|
"description": "画像の添付ファイルを汎用ファイルアイコンではなくサムネイルカードとして表示します"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"composer": {
|
"composer": {
|
||||||
|
|||||||
+50
-2
@@ -184,6 +184,7 @@
|
|||||||
"batch_actions": {
|
"batch_actions": {
|
||||||
"select": "메일 선택",
|
"select": "메일 선택",
|
||||||
"select_all": "모두 선택",
|
"select_all": "모두 선택",
|
||||||
|
"selected_messages": "이메일 {count, plural, one {1개} other {#건}} 선택됨",
|
||||||
"mark_read": "읽은 상태로 표시",
|
"mark_read": "읽은 상태로 표시",
|
||||||
"mark_unread": "읽지 않은 상태로 표시",
|
"mark_unread": "읽지 않은 상태로 표시",
|
||||||
"delete": "삭제",
|
"delete": "삭제",
|
||||||
@@ -223,6 +224,11 @@
|
|||||||
"unstar": "별표 해제",
|
"unstar": "별표 해제",
|
||||||
"mark_unread": "읽지 않은 상태로 표시",
|
"mark_unread": "읽지 않은 상태로 표시",
|
||||||
"mark_read": "읽은 상태로 표시",
|
"mark_read": "읽은 상태로 표시",
|
||||||
|
"unread": "안 읽음",
|
||||||
|
"read": "읽음",
|
||||||
|
"spam_short": "스팸",
|
||||||
|
"not_spam_short": "스팸 아님",
|
||||||
|
"move": "이동",
|
||||||
"print": "인쇄",
|
"print": "인쇄",
|
||||||
"view_source": "원본 보기",
|
"view_source": "원본 보기",
|
||||||
"export_email": ".eml 파일로 내보내기",
|
"export_email": ".eml 파일로 내보내기",
|
||||||
@@ -306,7 +312,20 @@
|
|||||||
"fail": "DMARC 실패",
|
"fail": "DMARC 실패",
|
||||||
"none": "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": {
|
"headers": {
|
||||||
"routing": "라우팅",
|
"routing": "라우팅",
|
||||||
@@ -431,7 +450,32 @@
|
|||||||
"expand": "세부정보 표시"
|
"expand": "세부정보 표시"
|
||||||
},
|
},
|
||||||
"send": "보내기",
|
"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": {
|
"email_composer": {
|
||||||
"new_message": "새 메시지",
|
"new_message": "새 메시지",
|
||||||
@@ -1009,6 +1053,10 @@
|
|||||||
"hide_inline_image_attachments": {
|
"hide_inline_image_attachments": {
|
||||||
"label": "첨부 파일 목록에서 인라인 이미지 숨기기",
|
"label": "첨부 파일 목록에서 인라인 이미지 숨기기",
|
||||||
"description": "메시지 본문에 포함된 이미지는 별도의 첨부 파일로 표시되지 않습니다"
|
"description": "메시지 본문에 포함된 이미지는 별도의 첨부 파일로 표시되지 않습니다"
|
||||||
|
},
|
||||||
|
"attachment_image_previews": {
|
||||||
|
"label": "첨부 파일에 이미지 미리보기 표시",
|
||||||
|
"description": "이미지 첨부 파일을 일반 파일 아이콘 대신 썸네일 카드로 표시합니다"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"composer": {
|
"composer": {
|
||||||
|
|||||||
+50
-2
@@ -184,6 +184,7 @@
|
|||||||
"batch_actions": {
|
"batch_actions": {
|
||||||
"select": "Atlasīt vēstules",
|
"select": "Atlasīt vēstules",
|
||||||
"select_all": "Atlasīt visas",
|
"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_read": "Atzīmēt kā izlasītu",
|
||||||
"mark_unread": "Atzīmēt kā nelasītu",
|
"mark_unread": "Atzīmēt kā nelasītu",
|
||||||
"delete": "Dzēst",
|
"delete": "Dzēst",
|
||||||
@@ -223,6 +224,11 @@
|
|||||||
"unstar": "Noņemt zvaigznīti",
|
"unstar": "Noņemt zvaigznīti",
|
||||||
"mark_unread": "Atzīmēt kā nelasītu",
|
"mark_unread": "Atzīmēt kā nelasītu",
|
||||||
"mark_read": "Atzīmēt kā izlasī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",
|
"print": "Drukāt",
|
||||||
"view_source": "Skatīt avota kodu",
|
"view_source": "Skatīt avota kodu",
|
||||||
"export_email": "Eksportēt kā .eml",
|
"export_email": "Eksportēt kā .eml",
|
||||||
@@ -306,7 +312,20 @@
|
|||||||
"fail": "DMARC nav izturēts",
|
"fail": "DMARC nav izturēts",
|
||||||
"none": "DMARC nav atrasts"
|
"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": {
|
"headers": {
|
||||||
"routing": "Maršrutēšana",
|
"routing": "Maršrutēšana",
|
||||||
@@ -431,7 +450,32 @@
|
|||||||
"expand": "Rādīt detaļas"
|
"expand": "Rādīt detaļas"
|
||||||
},
|
},
|
||||||
"send": "Sūtīt",
|
"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": {
|
"email_composer": {
|
||||||
"new_message": "Jauns ziņojums",
|
"new_message": "Jauns ziņojums",
|
||||||
@@ -1009,6 +1053,10 @@
|
|||||||
"hide_inline_image_attachments": {
|
"hide_inline_image_attachments": {
|
||||||
"label": "Slēpt iegultos attēlus no pielikumu saraksta",
|
"label": "Slēpt iegultos attēlus no pielikumu saraksta",
|
||||||
"description": "Ziņojuma pamattekstā iegultie attēli netiek rādīti kā atsevišķi pielikumi"
|
"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": {
|
"composer": {
|
||||||
|
|||||||
+50
-2
@@ -185,6 +185,7 @@
|
|||||||
"select": "E-mails selecteren",
|
"select": "E-mails selecteren",
|
||||||
"select_all": "Alles selecteren",
|
"select_all": "Alles selecteren",
|
||||||
"mark_read": "Markeren als gelezen",
|
"mark_read": "Markeren als gelezen",
|
||||||
|
"selected_messages": "{count, plural, one {1 e-mail} other {# e-mails}} geselecteerd",
|
||||||
"mark_unread": "Markeren als ongelezen",
|
"mark_unread": "Markeren als ongelezen",
|
||||||
"delete": "Verwijderen",
|
"delete": "Verwijderen",
|
||||||
"delete_confirm_title": "E-mails verwijderen",
|
"delete_confirm_title": "E-mails verwijderen",
|
||||||
@@ -223,6 +224,11 @@
|
|||||||
"unstar": "Ster verwijderen",
|
"unstar": "Ster verwijderen",
|
||||||
"mark_unread": "Markeren als ongelezen",
|
"mark_unread": "Markeren als ongelezen",
|
||||||
"mark_read": "Markeren als gelezen",
|
"mark_read": "Markeren als gelezen",
|
||||||
|
"unread": "Ongelezen",
|
||||||
|
"read": "Gelezen",
|
||||||
|
"spam_short": "Spam",
|
||||||
|
"not_spam_short": "Geen spam",
|
||||||
|
"move": "Verplaatsen",
|
||||||
"print": "Afdrukken",
|
"print": "Afdrukken",
|
||||||
"view_source": "Bron bekijken",
|
"view_source": "Bron bekijken",
|
||||||
"export_email": "Exporteren als .eml",
|
"export_email": "Exporteren als .eml",
|
||||||
@@ -304,7 +310,20 @@
|
|||||||
"fail": "DMARC Mislukt",
|
"fail": "DMARC Mislukt",
|
||||||
"none": "Geen DMARC"
|
"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": {
|
"headers": {
|
||||||
"routing": "Routering",
|
"routing": "Routering",
|
||||||
@@ -431,7 +450,32 @@
|
|||||||
"previous": "Vorige",
|
"previous": "Vorige",
|
||||||
"next": "Volgende",
|
"next": "Volgende",
|
||||||
"send": "Verzenden",
|
"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": {
|
"email_composer": {
|
||||||
"new_message": "Nieuw bericht",
|
"new_message": "Nieuw bericht",
|
||||||
@@ -1009,6 +1053,10 @@
|
|||||||
"hide_inline_image_attachments": {
|
"hide_inline_image_attachments": {
|
||||||
"label": "Inline-afbeeldingen verbergen uit bijlagenlijst",
|
"label": "Inline-afbeeldingen verbergen uit bijlagenlijst",
|
||||||
"description": "Afbeeldingen die in de berichttekst zijn ingesloten worden niet als aparte bijlagen weergegeven"
|
"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": {
|
"composer": {
|
||||||
|
|||||||
+50
-2
@@ -184,6 +184,7 @@
|
|||||||
"batch_actions": {
|
"batch_actions": {
|
||||||
"select": "Zaznacz wiadomości",
|
"select": "Zaznacz wiadomości",
|
||||||
"select_all": "Zaznacz wszystkie",
|
"select_all": "Zaznacz wszystkie",
|
||||||
|
"selected_messages": "Wybrano {count, plural, one {1 wiadomość} other {# wiadomości}} e-mail",
|
||||||
"mark_read": "Oznacz jako przeczytane",
|
"mark_read": "Oznacz jako przeczytane",
|
||||||
"mark_unread": "Oznacz jako nieprzeczytane",
|
"mark_unread": "Oznacz jako nieprzeczytane",
|
||||||
"delete": "Usuń",
|
"delete": "Usuń",
|
||||||
@@ -223,6 +224,11 @@
|
|||||||
"unstar": "Usuń gwiazdkę",
|
"unstar": "Usuń gwiazdkę",
|
||||||
"mark_unread": "Oznacz jako nieprzeczytane",
|
"mark_unread": "Oznacz jako nieprzeczytane",
|
||||||
"mark_read": "Oznacz jako przeczytane",
|
"mark_read": "Oznacz jako przeczytane",
|
||||||
|
"unread": "Nieprzeczytane",
|
||||||
|
"read": "Przeczytane",
|
||||||
|
"spam_short": "Spam",
|
||||||
|
"not_spam_short": "Nie spam",
|
||||||
|
"move": "Przenieś",
|
||||||
"print": "Drukuj",
|
"print": "Drukuj",
|
||||||
"view_source": "Pokaż źródło",
|
"view_source": "Pokaż źródło",
|
||||||
"export_email": "Eksportuj jako .eml",
|
"export_email": "Eksportuj jako .eml",
|
||||||
@@ -306,7 +312,20 @@
|
|||||||
"fail": "DMARC niezaliczony",
|
"fail": "DMARC niezaliczony",
|
||||||
"none": "Brak DMARC"
|
"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": {
|
"headers": {
|
||||||
"routing": "Routing",
|
"routing": "Routing",
|
||||||
@@ -431,7 +450,32 @@
|
|||||||
"expand": "Pokaż szczegóły"
|
"expand": "Pokaż szczegóły"
|
||||||
},
|
},
|
||||||
"send": "Wyślij",
|
"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": {
|
"email_composer": {
|
||||||
"new_message": "Nowa wiadomość",
|
"new_message": "Nowa wiadomość",
|
||||||
@@ -1009,6 +1053,10 @@
|
|||||||
"hide_inline_image_attachments": {
|
"hide_inline_image_attachments": {
|
||||||
"label": "Ukryj obrazy osadzone z listy załączników",
|
"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"
|
"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": {
|
"composer": {
|
||||||
|
|||||||
+50
-2
@@ -184,6 +184,7 @@
|
|||||||
"batch_actions": {
|
"batch_actions": {
|
||||||
"select": "Selecionar e-mails",
|
"select": "Selecionar e-mails",
|
||||||
"select_all": "Selecionar tudo",
|
"select_all": "Selecionar tudo",
|
||||||
|
"selected_messages": "{count, plural, one {1 e-mail selecionado} other {# e-mails selecionados}}",
|
||||||
"mark_read": "Marcar como lido",
|
"mark_read": "Marcar como lido",
|
||||||
"mark_unread": "Marcar como não lido",
|
"mark_unread": "Marcar como não lido",
|
||||||
"delete": "Excluir",
|
"delete": "Excluir",
|
||||||
@@ -223,6 +224,11 @@
|
|||||||
"unstar": "Remover Estrela",
|
"unstar": "Remover Estrela",
|
||||||
"mark_unread": "Marcar como não lido",
|
"mark_unread": "Marcar como não lido",
|
||||||
"mark_read": "Marcar como 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",
|
"print": "Imprimir",
|
||||||
"view_source": "Ver código-fonte",
|
"view_source": "Ver código-fonte",
|
||||||
"export_email": "Exportar como .eml",
|
"export_email": "Exportar como .eml",
|
||||||
@@ -304,7 +310,20 @@
|
|||||||
"fail": "DMARC Falhou",
|
"fail": "DMARC Falhou",
|
||||||
"none": "Sem DMARC"
|
"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": {
|
"headers": {
|
||||||
"routing": "Roteamento",
|
"routing": "Roteamento",
|
||||||
@@ -431,7 +450,32 @@
|
|||||||
"previous": "Anterior",
|
"previous": "Anterior",
|
||||||
"next": "Próximo",
|
"next": "Próximo",
|
||||||
"send": "Enviar",
|
"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": {
|
"email_composer": {
|
||||||
"new_message": "Nova Mensagem",
|
"new_message": "Nova Mensagem",
|
||||||
@@ -1009,6 +1053,10 @@
|
|||||||
"hide_inline_image_attachments": {
|
"hide_inline_image_attachments": {
|
||||||
"label": "Ocultar imagens incorporadas dos anexos",
|
"label": "Ocultar imagens incorporadas dos anexos",
|
||||||
"description": "As imagens incorporadas no corpo da mensagem não são listadas como anexos separados"
|
"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": {
|
"composer": {
|
||||||
|
|||||||
+50
-2
@@ -184,6 +184,7 @@
|
|||||||
"batch_actions": {
|
"batch_actions": {
|
||||||
"select": "Выбрать письма",
|
"select": "Выбрать письма",
|
||||||
"select_all": "Выбрать все",
|
"select_all": "Выбрать все",
|
||||||
|
"selected_messages": "Выбрано {count, plural, one {1 письмо} other {# письма}}",
|
||||||
"mark_read": "Отметить прочитанными",
|
"mark_read": "Отметить прочитанными",
|
||||||
"mark_unread": "Отметить непрочитанными",
|
"mark_unread": "Отметить непрочитанными",
|
||||||
"delete": "Удалить",
|
"delete": "Удалить",
|
||||||
@@ -223,6 +224,11 @@
|
|||||||
"unstar": "Снять пометку",
|
"unstar": "Снять пометку",
|
||||||
"mark_unread": "Отметить непрочитанным",
|
"mark_unread": "Отметить непрочитанным",
|
||||||
"mark_read": "Отметить прочитанным",
|
"mark_read": "Отметить прочитанным",
|
||||||
|
"unread": "Не прочитано",
|
||||||
|
"read": "Прочитано",
|
||||||
|
"spam_short": "Спам",
|
||||||
|
"not_spam_short": "Не спам",
|
||||||
|
"move": "Переместить",
|
||||||
"print": "Распечатать",
|
"print": "Распечатать",
|
||||||
"view_source": "Просмотреть исходный код",
|
"view_source": "Просмотреть исходный код",
|
||||||
"export_email": "Экспортировать как .eml",
|
"export_email": "Экспортировать как .eml",
|
||||||
@@ -306,7 +312,20 @@
|
|||||||
"fail": "DMARC не прошёл",
|
"fail": "DMARC не прошёл",
|
||||||
"none": "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": {
|
"headers": {
|
||||||
"routing": "Маршрутизация",
|
"routing": "Маршрутизация",
|
||||||
@@ -431,7 +450,32 @@
|
|||||||
"expand": "Показать детали"
|
"expand": "Показать детали"
|
||||||
},
|
},
|
||||||
"send": "Отправить",
|
"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": {
|
"email_composer": {
|
||||||
"new_message": "Новое письмо",
|
"new_message": "Новое письмо",
|
||||||
@@ -1009,6 +1053,10 @@
|
|||||||
"hide_inline_image_attachments": {
|
"hide_inline_image_attachments": {
|
||||||
"label": "Скрывать встроенные изображения из вложений",
|
"label": "Скрывать встроенные изображения из вложений",
|
||||||
"description": "Изображения, встроенные в тело сообщения, не отображаются как отдельные вложения"
|
"description": "Изображения, встроенные в тело сообщения, не отображаются как отдельные вложения"
|
||||||
|
},
|
||||||
|
"attachment_image_previews": {
|
||||||
|
"label": "Показывать миниатюры изображений во вложениях",
|
||||||
|
"description": "Отображать вложенные изображения в виде миниатюр вместо обычных значков файлов"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"composer": {
|
"composer": {
|
||||||
|
|||||||
+50
-2
@@ -184,6 +184,7 @@
|
|||||||
"batch_actions": {
|
"batch_actions": {
|
||||||
"select": "E-postaları seç",
|
"select": "E-postaları seç",
|
||||||
"select_all": "Tümünü seç",
|
"select_all": "Tümünü seç",
|
||||||
|
"selected_messages": "{count, plural, one {1 e-posta} other {# e-posta}} seçildi",
|
||||||
"mark_read": "Okundu olarak işaretle",
|
"mark_read": "Okundu olarak işaretle",
|
||||||
"mark_unread": "Okunmadı olarak işaretle",
|
"mark_unread": "Okunmadı olarak işaretle",
|
||||||
"delete": "Sil",
|
"delete": "Sil",
|
||||||
@@ -223,6 +224,11 @@
|
|||||||
"unstar": "Yıldızı Kaldır",
|
"unstar": "Yıldızı Kaldır",
|
||||||
"mark_unread": "Okunmadı olarak işaretle",
|
"mark_unread": "Okunmadı olarak işaretle",
|
||||||
"mark_read": "Okundu olarak işaretle",
|
"mark_read": "Okundu olarak işaretle",
|
||||||
|
"unread": "Okunmadı",
|
||||||
|
"read": "Okundu",
|
||||||
|
"spam_short": "Spam",
|
||||||
|
"not_spam_short": "Spam değil",
|
||||||
|
"move": "Taşı",
|
||||||
"print": "Yazdır",
|
"print": "Yazdır",
|
||||||
"view_source": "Kaynağı görüntüle",
|
"view_source": "Kaynağı görüntüle",
|
||||||
"export_email": ".eml olarak dışa aktar",
|
"export_email": ".eml olarak dışa aktar",
|
||||||
@@ -306,7 +312,20 @@
|
|||||||
"fail": "DMARC Başarısız",
|
"fail": "DMARC Başarısız",
|
||||||
"none": "DMARC Yok"
|
"none": "DMARC Yok"
|
||||||
},
|
},
|
||||||
"spam_score": "Spam Puanı"
|
"spam_score": "Spam Puanı",
|
||||||
|
"tooltip_spf": "Sender Policy Framework: gönderen sunucunun, etki alanı adına e-posta gönderme yetkisine sahip olduğunu doğrular",
|
||||||
|
"tooltip_dkim": "DomainKeys Identified Mail: kriptografik bir imza kullanarak e-postanın iletim sırasında değiştirilmediğini doğrular",
|
||||||
|
"tooltip_dmarc": "Domain-based Message Authentication, Reporting & Conformance: SPF ve DKIM'in göndericinin etki alanıyla uyumlu olmasını sağlar ve hatalar için bir ilke belirler",
|
||||||
|
"policy": "İlke",
|
||||||
|
"result": {
|
||||||
|
"pass": "Başarılı",
|
||||||
|
"fail": "Başarısız",
|
||||||
|
"softfail": "Hafif başarısızlık",
|
||||||
|
"neutral": "Nötr",
|
||||||
|
"permerror": "Kalıcı hata",
|
||||||
|
"temperror": "Geçici hata",
|
||||||
|
"none": "Yok"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"headers": {
|
"headers": {
|
||||||
"routing": "Yönlendirme",
|
"routing": "Yönlendirme",
|
||||||
@@ -431,7 +450,32 @@
|
|||||||
"collapse": "Ayrıntıları gizle"
|
"collapse": "Ayrıntıları gizle"
|
||||||
},
|
},
|
||||||
"send": "Gönder",
|
"send": "Gönder",
|
||||||
"more": "daha fazla"
|
"more": "daha fazla",
|
||||||
|
"details": {
|
||||||
|
"recipients_routing": "Alıcılar ve yönlendirme",
|
||||||
|
"authentication_security": "Kimlik doğrulama ve güvenlik",
|
||||||
|
"identifiers_threading": "Tanımlayıcılar ve konu zinciri",
|
||||||
|
"mailing_list": "Posta listesi",
|
||||||
|
"message_properties": "İleti özellikleri",
|
||||||
|
"sent": "Gönderildi",
|
||||||
|
"received": "Alındı",
|
||||||
|
"delivery_time": "Teslim süresi",
|
||||||
|
"in_reply_to": "Yanıtlanan",
|
||||||
|
"references": "Referanslar",
|
||||||
|
"thread_id": "Konu zinciri kimliği",
|
||||||
|
"size": "Boyut",
|
||||||
|
"mime_type": "MIME türü",
|
||||||
|
"attachments_summary": "{count} dosya · {size}",
|
||||||
|
"list_id": "Liste kimliği",
|
||||||
|
"list_help": "Liste yardımı",
|
||||||
|
"list_post": "Liste gönderimi",
|
||||||
|
"list_unsubscribe": "Abonelikten çık",
|
||||||
|
"iprev": "Ters DNS",
|
||||||
|
"spam_status": "Spam durumu",
|
||||||
|
"ai_verdict": "Yapay zeka kararı",
|
||||||
|
"account": "Hesap",
|
||||||
|
"no_subject": "(konu yok)"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"email_composer": {
|
"email_composer": {
|
||||||
"new_message": "Yeni İleti",
|
"new_message": "Yeni İleti",
|
||||||
@@ -1009,6 +1053,10 @@
|
|||||||
"hide_inline_image_attachments": {
|
"hide_inline_image_attachments": {
|
||||||
"label": "Satır içi görüntüleri eklerden gizle",
|
"label": "Satır içi görüntüleri eklerden gizle",
|
||||||
"description": "İleti gövdesine gömülü görseller ayrı ek olarak listelenmez"
|
"description": "İleti gövdesine gömülü görseller ayrı ek olarak listelenmez"
|
||||||
|
},
|
||||||
|
"attachment_image_previews": {
|
||||||
|
"label": "Eklerde resim önizlemelerini göster",
|
||||||
|
"description": "Resim eklerini, genel dosya simgeleri yerine küçük resim kartları olarak göster"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"composer": {
|
"composer": {
|
||||||
|
|||||||
+50
-2
@@ -184,6 +184,7 @@
|
|||||||
"batch_actions": {
|
"batch_actions": {
|
||||||
"select": "Виберіть електронні листи",
|
"select": "Виберіть електронні листи",
|
||||||
"select_all": "Вибрати все",
|
"select_all": "Вибрати все",
|
||||||
|
"selected_messages": "Вибрано {count, plural, one {1 електронний лист} other {# електронні листи}}",
|
||||||
"mark_read": "Позначити як прочитане",
|
"mark_read": "Позначити як прочитане",
|
||||||
"mark_unread": "Позначити як непрочитане",
|
"mark_unread": "Позначити як непрочитане",
|
||||||
"delete": "Видалити",
|
"delete": "Видалити",
|
||||||
@@ -223,6 +224,11 @@
|
|||||||
"unstar": "Зняти зірочку",
|
"unstar": "Зняти зірочку",
|
||||||
"mark_unread": "Позначити як непрочитане",
|
"mark_unread": "Позначити як непрочитане",
|
||||||
"mark_read": "Позначити як прочитане",
|
"mark_read": "Позначити як прочитане",
|
||||||
|
"unread": "Непрочитане",
|
||||||
|
"read": "Прочитане",
|
||||||
|
"spam_short": "Спам",
|
||||||
|
"not_spam_short": "Не спам",
|
||||||
|
"move": "Перенести",
|
||||||
"print": "Роздрукувати",
|
"print": "Роздрукувати",
|
||||||
"view_source": "Переглянути джерело",
|
"view_source": "Переглянути джерело",
|
||||||
"export_email": "Експортувати як .eml",
|
"export_email": "Експортувати як .eml",
|
||||||
@@ -306,7 +312,20 @@
|
|||||||
"fail": "Помилка DMARC",
|
"fail": "Помилка DMARC",
|
||||||
"none": "Без 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": {
|
"headers": {
|
||||||
"routing": "Маршрутизація",
|
"routing": "Маршрутизація",
|
||||||
@@ -431,7 +450,32 @@
|
|||||||
"expand": "Показати деталі"
|
"expand": "Показати деталі"
|
||||||
},
|
},
|
||||||
"send": "Надіслати",
|
"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": {
|
"email_composer": {
|
||||||
"new_message": "Нове повідомлення",
|
"new_message": "Нове повідомлення",
|
||||||
@@ -1009,6 +1053,10 @@
|
|||||||
"hide_inline_image_attachments": {
|
"hide_inline_image_attachments": {
|
||||||
"label": "Приховувати вбудовані зображення з вкладень",
|
"label": "Приховувати вбудовані зображення з вкладень",
|
||||||
"description": "Зображення, вбудовані в тіло повідомлення, не відображаються як окремі вкладення"
|
"description": "Зображення, вбудовані в тіло повідомлення, не відображаються як окремі вкладення"
|
||||||
|
},
|
||||||
|
"attachment_image_previews": {
|
||||||
|
"label": "Показувати попередній перегляд зображень у вкладеннях",
|
||||||
|
"description": "Відображати вкладені зображення як ескізи замість загальних піктограм файлів"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"composer": {
|
"composer": {
|
||||||
|
|||||||
+50
-2
@@ -184,6 +184,7 @@
|
|||||||
"batch_actions": {
|
"batch_actions": {
|
||||||
"select": "选择邮件",
|
"select": "选择邮件",
|
||||||
"select_all": "全选",
|
"select_all": "全选",
|
||||||
|
"selected_messages": "已選取 {count, plural, one {1} other {#}} 封電子郵件",
|
||||||
"mark_read": "标记为已读",
|
"mark_read": "标记为已读",
|
||||||
"mark_unread": "标记为未读",
|
"mark_unread": "标记为未读",
|
||||||
"delete": "删除",
|
"delete": "删除",
|
||||||
@@ -223,6 +224,11 @@
|
|||||||
"unstar": "取消星标",
|
"unstar": "取消星标",
|
||||||
"mark_unread": "标记为未读",
|
"mark_unread": "标记为未读",
|
||||||
"mark_read": "标记为已读",
|
"mark_read": "标记为已读",
|
||||||
|
"unread": "未读",
|
||||||
|
"read": "已读",
|
||||||
|
"spam_short": "垃圾邮件",
|
||||||
|
"not_spam_short": "非垃圾邮件",
|
||||||
|
"move": "移动",
|
||||||
"print": "打印",
|
"print": "打印",
|
||||||
"view_source": "查看源码",
|
"view_source": "查看源码",
|
||||||
"export_email": "导出为 .eml",
|
"export_email": "导出为 .eml",
|
||||||
@@ -306,7 +312,20 @@
|
|||||||
"fail": "DMARC 失败",
|
"fail": "DMARC 失败",
|
||||||
"none": "无 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": {
|
"headers": {
|
||||||
"routing": "路由",
|
"routing": "路由",
|
||||||
@@ -431,7 +450,32 @@
|
|||||||
"expand": "显示详情"
|
"expand": "显示详情"
|
||||||
},
|
},
|
||||||
"send": "发送",
|
"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": {
|
"email_composer": {
|
||||||
"new_message": "新邮件",
|
"new_message": "新邮件",
|
||||||
@@ -1009,6 +1053,10 @@
|
|||||||
"hide_inline_image_attachments": {
|
"hide_inline_image_attachments": {
|
||||||
"label": "在附件列表中隐藏内嵌图片",
|
"label": "在附件列表中隐藏内嵌图片",
|
||||||
"description": "嵌入到邮件正文中的图片不会作为单独的附件显示"
|
"description": "嵌入到邮件正文中的图片不会作为单独的附件显示"
|
||||||
|
},
|
||||||
|
"attachment_image_previews": {
|
||||||
|
"label": "在附件中显示图片预览",
|
||||||
|
"description": "将图片附件显示为缩略图卡片,而不是通用文件图标"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"composer": {
|
"composer": {
|
||||||
|
|||||||
@@ -18,15 +18,28 @@ try {
|
|||||||
// VERSION file not found
|
// VERSION file not found
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Subpath deployment, e.g. NEXT_PUBLIC_BASE_PATH=/webmail. Read at build time
|
||||||
|
// because Next.js bakes basePath into emitted asset URLs and route metadata.
|
||||||
|
// Trailing slash is stripped; an empty/missing value disables the feature.
|
||||||
|
const rawBasePath = process.env.NEXT_PUBLIC_BASE_PATH?.trim() ?? "";
|
||||||
|
const basePath = rawBasePath.replace(/\/+$/, "");
|
||||||
|
if (basePath && !basePath.startsWith("/")) {
|
||||||
|
throw new Error(
|
||||||
|
`NEXT_PUBLIC_BASE_PATH must start with "/" (got: ${JSON.stringify(rawBasePath)})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
output: "standalone",
|
output: "standalone",
|
||||||
allowedDevOrigins: ["192.168.1.51"],
|
allowedDevOrigins: ["192.168.1.51"],
|
||||||
|
basePath: basePath || undefined,
|
||||||
turbopack: {
|
turbopack: {
|
||||||
root: import.meta.dirname,
|
root: import.meta.dirname,
|
||||||
},
|
},
|
||||||
env: {
|
env: {
|
||||||
NEXT_PUBLIC_GIT_COMMIT: gitCommitHash,
|
NEXT_PUBLIC_GIT_COMMIT: gitCommitHash,
|
||||||
NEXT_PUBLIC_APP_VERSION: appVersion,
|
NEXT_PUBLIC_APP_VERSION: appVersion,
|
||||||
|
NEXT_PUBLIC_BASE_PATH: basePath,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "bulwark-webmail",
|
"name": "bulwark-webmail",
|
||||||
"version": "1.5.4",
|
"version": "1.6.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "bulwark-webmail",
|
"name": "bulwark-webmail",
|
||||||
"version": "1.5.4",
|
"version": "1.6.0",
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-virtual": "^3.13.24",
|
"@tanstack/react-virtual": "^3.13.24",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "bulwark-webmail",
|
"name": "bulwark-webmail",
|
||||||
"version": "1.5.4",
|
"version": "1.6.0",
|
||||||
"description": "Bulwark Webmail - a modern webmail client built for Stalwart Mail Server",
|
"description": "Bulwark Webmail - a modern webmail client built for Stalwart Mail Server",
|
||||||
"author": "Bulwark Webmail <bulwark@rbm.systems>",
|
"author": "Bulwark Webmail <bulwark@rbm.systems>",
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
|
|||||||
+21
-7
@@ -10,6 +10,20 @@
|
|||||||
// task: relay sends only a state-change ping, the client fetches the
|
// task: relay sends only a state-change ping, the client fetches the
|
||||||
// newest unread email itself so the relay never sees mail content.
|
// newest unread email itself so the relay never sees mail content.
|
||||||
|
|
||||||
|
// When the app is mounted at a subpath (Next.js basePath, e.g. /webmail), the
|
||||||
|
// SW is served at /webmail/sw.js and registered with scope /webmail/. Derive
|
||||||
|
// the prefix from the SW's own URL so push fetches and notification clicks
|
||||||
|
// land on the right path - service workers can't read process.env.
|
||||||
|
function getBasePath() {
|
||||||
|
const path = new URL(self.location.href).pathname;
|
||||||
|
// self.location is .../sw.js; strip the trailing filename to get the dir,
|
||||||
|
// then strip the trailing slash so it concatenates cleanly with `/foo`.
|
||||||
|
const dir = path.replace(/[^/]*$/, "");
|
||||||
|
return dir.replace(/\/+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
const BASE_PATH = getBasePath();
|
||||||
|
|
||||||
self.addEventListener("install", () => {
|
self.addEventListener("install", () => {
|
||||||
self.skipWaiting();
|
self.skipWaiting();
|
||||||
});
|
});
|
||||||
@@ -48,7 +62,7 @@ async function handlePush(event) {
|
|||||||
let preview = null;
|
let preview = null;
|
||||||
let previewOk = false;
|
let previewOk = false;
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/push/preview", {
|
const res = await fetch(`${BASE_PATH}/api/push/preview`, {
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
});
|
});
|
||||||
@@ -99,8 +113,8 @@ async function handlePush(event) {
|
|||||||
await self.registration.showNotification(title, {
|
await self.registration.showNotification(title, {
|
||||||
body,
|
body,
|
||||||
tag,
|
tag,
|
||||||
icon: "/icon-192x192.png",
|
icon: `${BASE_PATH}/icon-192x192.png`,
|
||||||
badge: "/icon-192x192.png",
|
badge: `${BASE_PATH}/icon-192x192.png`,
|
||||||
data,
|
data,
|
||||||
renotify: true,
|
renotify: true,
|
||||||
});
|
});
|
||||||
@@ -132,17 +146,17 @@ async function handleNotificationClick(event) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (self.clients.openWindow) {
|
if (self.clients.openWindow) {
|
||||||
return self.clients.openWindow(targetUrl || "/");
|
return self.clients.openWindow(targetUrl || `${BASE_PATH}/`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildClickUrl(data) {
|
function buildClickUrl(data) {
|
||||||
if (!data) return "/";
|
if (!data) return `${BASE_PATH}/`;
|
||||||
if (data.kind === "email" && data.emailId) {
|
if (data.kind === "email" && data.emailId) {
|
||||||
return `/?email=${encodeURIComponent(data.emailId)}`;
|
return `${BASE_PATH}/?email=${encodeURIComponent(data.emailId)}`;
|
||||||
}
|
}
|
||||||
// Generic "New mail" toast (preview API failed or returned no email): land
|
// Generic "New mail" toast (preview API failed or returned no email): land
|
||||||
// the user on the latest unread message in their Inbox rather than just the
|
// the user on the latest unread message in their Inbox rather than just the
|
||||||
// app shell, so the click still feels purposeful.
|
// app shell, so the click still feels purposeful.
|
||||||
return "/?openLatestUnread=1";
|
return `${BASE_PATH}/?openLatestUnread=1`;
|
||||||
}
|
}
|
||||||
|
|||||||
+88
-17
@@ -85,6 +85,7 @@ interface EmailStore {
|
|||||||
clearSearchFilters: () => void;
|
clearSearchFilters: () => void;
|
||||||
toggleAdvancedSearch: () => void;
|
toggleAdvancedSearch: () => void;
|
||||||
toggleStar: (client: IJMAPClient, emailId: string) => Promise<void>;
|
toggleStar: (client: IJMAPClient, emailId: string) => Promise<void>;
|
||||||
|
setEmailKeywordsLocal: (emailId: string, keywords: Record<string, boolean>) => void;
|
||||||
|
|
||||||
// Batch operations
|
// Batch operations
|
||||||
batchMarkAsRead: (client: IJMAPClient, read: boolean) => Promise<void>;
|
batchMarkAsRead: (client: IJMAPClient, read: boolean) => Promise<void>;
|
||||||
@@ -160,6 +161,28 @@ function getNextSelectedEmail(state: { emails: Email[]; selectedEmail: Email | n
|
|||||||
return getNextSelectedEmailAfterRemoval(state, new Set([removedEmailId]));
|
return getNextSelectedEmailAfterRemoval(state, new Set([removedEmailId]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Find the trash mailbox for a given account scope. Prefers JMAP role, but
|
||||||
|
// falls back to name matching ("trash" / "deleted") so users with custom or
|
||||||
|
// pre-existing folders (e.g. "Deleted Items") aren't silently destroyed.
|
||||||
|
function findTrashMailbox(
|
||||||
|
mailboxes: Mailbox[],
|
||||||
|
scope: { accountId?: string; isShared?: boolean }
|
||||||
|
): Mailbox | undefined {
|
||||||
|
const matchesScope = (mb: Mailbox): boolean => {
|
||||||
|
if (scope.accountId) return mb.accountId === scope.accountId;
|
||||||
|
return !mb.isShared;
|
||||||
|
};
|
||||||
|
|
||||||
|
const byRole = mailboxes.find(mb => mb.role === 'trash' && matchesScope(mb));
|
||||||
|
if (byRole) return byRole;
|
||||||
|
|
||||||
|
return mailboxes.find(mb => {
|
||||||
|
if (!matchesScope(mb)) return false;
|
||||||
|
const lower = mb.name.toLowerCase();
|
||||||
|
return lower.includes('trash') || lower.includes('deleted');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export const useEmailStore = create<EmailStore>((set, get) => ({
|
export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||||
emails: [],
|
emails: [],
|
||||||
mailboxes: [],
|
mailboxes: [],
|
||||||
@@ -566,15 +589,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
|||||||
|
|
||||||
// If deleteAction is 'trash' and not forced permanent delete, try to move to trash mailbox
|
// If deleteAction is 'trash' and not forced permanent delete, try to move to trash mailbox
|
||||||
if (deleteAction === 'trash' && !forceDelete) {
|
if (deleteAction === 'trash' && !forceDelete) {
|
||||||
// Find trash mailbox for the correct account
|
const trashMailbox = findTrashMailbox(mailboxes, { accountId });
|
||||||
const trashMailbox = mailboxes.find(mb => {
|
|
||||||
if (accountId) {
|
|
||||||
// For shared folders, match by accountId
|
|
||||||
return mb.role === 'trash' && mb.accountId === accountId;
|
|
||||||
}
|
|
||||||
// For primary account, find trash that's not from a shared folder
|
|
||||||
return mb.role === 'trash' && !mb.isShared;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (trashMailbox) {
|
if (trashMailbox) {
|
||||||
// Use originalId for shared mailboxes if available
|
// Use originalId for shared mailboxes if available
|
||||||
@@ -619,7 +634,10 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// If no trash mailbox found, fall through to permanent delete
|
// No trash folder found in this account. Surface the failure rather
|
||||||
|
// than silently destroying the email - the user asked to move it to
|
||||||
|
// trash, not to permanently delete it.
|
||||||
|
throw new Error('Trash mailbox not found - cannot move email to trash');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Permanent delete
|
// Permanent delete
|
||||||
@@ -1057,6 +1075,17 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setEmailKeywordsLocal: (emailId, keywords) => {
|
||||||
|
set((state) => ({
|
||||||
|
emails: state.emails.map(e =>
|
||||||
|
e.id === emailId ? { ...e, keywords: { ...keywords } } : e
|
||||||
|
),
|
||||||
|
selectedEmail: state.selectedEmail?.id === emailId
|
||||||
|
? { ...state.selectedEmail, keywords: { ...keywords } }
|
||||||
|
: state.selectedEmail,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
// Batch operations
|
// Batch operations
|
||||||
batchMarkAsRead: async (client, read) => {
|
batchMarkAsRead: async (client, read) => {
|
||||||
const { selectedEmailIds, emails, mailboxes } = get();
|
const { selectedEmailIds, emails, mailboxes } = get();
|
||||||
@@ -1163,23 +1192,65 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
|||||||
await Promise.allSettled(promises);
|
await Promise.allSettled(promises);
|
||||||
} else {
|
} else {
|
||||||
// Move to trash per account.
|
// Move to trash per account.
|
||||||
|
const failedAccounts: string[] = [];
|
||||||
|
const movedEmailIds = new Set<string>();
|
||||||
const promises = Array.from(emailsByAccount.entries()).map(async ([acctId, ids]) => {
|
const promises = Array.from(emailsByAccount.entries()).map(async ([acctId, ids]) => {
|
||||||
const acctClient = getClient(acctId);
|
const acctClient = getClient(acctId);
|
||||||
if (!acctClient) return;
|
if (!acctClient) {
|
||||||
const trashMailbox = mailboxes.find(mb => {
|
failedAccounts.push(acctId);
|
||||||
if (mb.role !== 'trash') return false;
|
return;
|
||||||
if (acctId === '__default__') return !mb.isShared;
|
}
|
||||||
return mb.accountId === acctId;
|
const trashMailbox = findTrashMailbox(mailboxes, {
|
||||||
|
accountId: acctId === '__default__' ? undefined : acctId,
|
||||||
});
|
});
|
||||||
if (!trashMailbox) {
|
if (!trashMailbox) {
|
||||||
// No trash available for this account - fall back to destroy so the action isn't silently dropped.
|
// No trash for this account: skip rather than silently destroying.
|
||||||
await acctClient.batchDeleteEmails(ids);
|
// The user asked to move to trash, not permanently delete.
|
||||||
|
failedAccounts.push(acctId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const trashId = trashMailbox.originalId || trashMailbox.id;
|
const trashId = trashMailbox.originalId || trashMailbox.id;
|
||||||
await acctClient.batchMoveEmails(ids, trashId, trashMailbox.accountId);
|
await acctClient.batchMoveEmails(ids, trashId, trashMailbox.accountId);
|
||||||
|
ids.forEach(id => movedEmailIds.add(id));
|
||||||
});
|
});
|
||||||
await Promise.allSettled(promises);
|
await Promise.allSettled(promises);
|
||||||
|
|
||||||
|
if (failedAccounts.length > 0 && movedEmailIds.size === 0) {
|
||||||
|
// Nothing moved - bail out so the UI doesn't drop the emails from view.
|
||||||
|
throw new Error('Trash mailbox not found - cannot move emails to trash');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only remove successfully moved emails from local state.
|
||||||
|
if (movedEmailIds.size < emailIdsArray.length) {
|
||||||
|
const deletedEmails = emails.filter(e => movedEmailIds.has(e.id));
|
||||||
|
const remainingEmails = emails.filter(e => !movedEmailIds.has(e.id));
|
||||||
|
const updatedMailboxes = mailboxes.map(mailbox => {
|
||||||
|
let deltaTotalEmails = 0;
|
||||||
|
let deltaUnreadEmails = 0;
|
||||||
|
deletedEmails.forEach(email => {
|
||||||
|
if (email.mailboxIds?.[mailbox.id]) {
|
||||||
|
deltaTotalEmails--;
|
||||||
|
if (!email.keywords?.$seen) deltaUnreadEmails--;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
...mailbox,
|
||||||
|
totalEmails: Math.max(0, mailbox.totalEmails + deltaTotalEmails),
|
||||||
|
unreadEmails: Math.max(0, mailbox.unreadEmails + deltaUnreadEmails),
|
||||||
|
totalThreads: Math.max(0, mailbox.totalThreads + deltaTotalEmails),
|
||||||
|
unreadThreads: Math.max(0, mailbox.unreadThreads + deltaUnreadEmails),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
set({
|
||||||
|
emails: remainingEmails,
|
||||||
|
mailboxes: updatedMailboxes,
|
||||||
|
selectedEmailIds: new Set(),
|
||||||
|
selectedEmail: null,
|
||||||
|
isLoading: false,
|
||||||
|
error: 'Some emails could not be moved: trash folder missing for one or more accounts',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove deleted emails from local state
|
// Remove deleted emails from local state
|
||||||
|
|||||||
@@ -211,6 +211,10 @@ interface SettingsState {
|
|||||||
// attachment list shown above the message body.
|
// attachment list shown above the message body.
|
||||||
hideInlineImageAttachments: boolean;
|
hideInlineImageAttachments: boolean;
|
||||||
|
|
||||||
|
// Render image attachments as thumbnail cards (preview the actual image
|
||||||
|
// contents inside the chip) instead of generic file icons.
|
||||||
|
attachmentImagePreviewsEnabled: boolean;
|
||||||
|
|
||||||
// Sidebar Apps
|
// Sidebar Apps
|
||||||
sidebarApps: SidebarApp[];
|
sidebarApps: SidebarApp[];
|
||||||
keepAppsLoaded: boolean;
|
keepAppsLoaded: boolean;
|
||||||
@@ -384,6 +388,7 @@ const DEFAULT_SETTINGS = {
|
|||||||
] as string[],
|
] as string[],
|
||||||
|
|
||||||
hideInlineImageAttachments: true,
|
hideInlineImageAttachments: true,
|
||||||
|
attachmentImagePreviewsEnabled: true,
|
||||||
|
|
||||||
// Sidebar Apps
|
// Sidebar Apps
|
||||||
sidebarApps: [] as SidebarApp[],
|
sidebarApps: [] as SidebarApp[],
|
||||||
@@ -491,6 +496,7 @@ export const useSettingsStore = create<SettingsState>()(
|
|||||||
attachmentReminderEnabled: state.attachmentReminderEnabled,
|
attachmentReminderEnabled: state.attachmentReminderEnabled,
|
||||||
attachmentReminderKeywords: state.attachmentReminderKeywords,
|
attachmentReminderKeywords: state.attachmentReminderKeywords,
|
||||||
hideInlineImageAttachments: state.hideInlineImageAttachments,
|
hideInlineImageAttachments: state.hideInlineImageAttachments,
|
||||||
|
attachmentImagePreviewsEnabled: state.attachmentImagePreviewsEnabled,
|
||||||
sidebarApps: state.sidebarApps,
|
sidebarApps: state.sidebarApps,
|
||||||
keepAppsLoaded: state.keepAppsLoaded,
|
keepAppsLoaded: state.keepAppsLoaded,
|
||||||
debugMode: state.debugMode,
|
debugMode: state.debugMode,
|
||||||
|
|||||||
Reference in New Issue
Block a user