Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
00b40fc48a | ||
|
|
856496715b | ||
|
|
eb7eeae1ac | ||
|
|
6b1a99a70b | ||
|
|
1da04c254b | ||
|
|
8ae5ecba41 | ||
|
|
4ff05bd4ec | ||
|
|
31e96d6a46 |
@@ -47,3 +47,8 @@ LOG_LEVEL=debug
|
||||
# LOGIN_IMPRINT_URL=https://example.com/imprint
|
||||
# LOGIN_PRIVACY_POLICY_URL=https://example.com/privacy
|
||||
# LOGIN_WEBSITE_URL=https://example.com
|
||||
|
||||
# Per-domain branding overrides. Each entry must have "host" (exact or
|
||||
# "*.subdomain" wildcard) plus any subset of branding fields to override.
|
||||
# Unset fields fall through to the global values above.
|
||||
# DOMAIN_BRANDING=[{"host":"localhost","loginCompanyName":"Local Dev"}]
|
||||
|
||||
@@ -225,6 +225,29 @@ LOGIN_COMPANY_NAME=Bulwark Webmail
|
||||
# URL for the company website link on the login page.
|
||||
LOGIN_WEBSITE_URL=https://bulwarkmail.org
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Per-domain branding overrides (optional)
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# When you serve the webmail on multiple hostnames, each hostname can override
|
||||
# a subset of branding fields. Unset fields fall back to the global values
|
||||
# above. Match is on the request's Host (or X-Forwarded-Host) header.
|
||||
#
|
||||
# Use the leftmost label "*." to match any subdomain (e.g. "*.example.com"
|
||||
# matches mail.example.com and any deeper subdomain, but NOT example.com).
|
||||
# Exact matches always win over wildcards; the longest wildcard suffix wins
|
||||
# among multiple wildcard matches.
|
||||
#
|
||||
# Overridable keys: appName, appShortName, appDescription, faviconUrl,
|
||||
# pwaIconUrl, pwaThemeColor, pwaBackgroundColor, appLogoLightUrl,
|
||||
# appLogoDarkUrl, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName,
|
||||
# loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl.
|
||||
#
|
||||
# Prefer setting this from the admin dashboard (PATCH /api/admin/config).
|
||||
# The env-var form is provided for stateless deployments.
|
||||
#
|
||||
# DOMAIN_BRANDING=[{"host":"maildomain1.com","loginCompanyName":"Company One","loginLogoLightUrl":"/branding/one-color.svg","loginLogoDarkUrl":"/branding/one-white.svg","loginWebsiteUrl":"https://one.example"},{"host":"maildomain2.com","loginCompanyName":"Company Two","faviconUrl":"/branding/two-favicon.svg"},{"host":"*.intranet.example.com","loginCompanyName":"Internal"}]
|
||||
|
||||
# =============================================================================
|
||||
# Extension Directory / Marketplace
|
||||
# =============================================================================
|
||||
|
||||
@@ -1,5 +1,51 @@
|
||||
# Changelog
|
||||
|
||||
## 1.7.2 (2026-05-28)
|
||||
|
||||
### Features
|
||||
|
||||
- **Mail**: Scheduled send and send delay (#322)
|
||||
- **Mail**: Drag emails out to the file explorer as `.eml`
|
||||
- **Mail**: Import emails from `.zip` archives
|
||||
- **Mail**: "Move to Trash and mark as read" delete action (#323)
|
||||
- **Mail**: Include group inboxes in the unified mailbox view (#328)
|
||||
- **Mail**: Locale-aware date format in the email list with a preset picker (#331)
|
||||
- **Mail**: Allow drag-and-drop into shared mailboxes
|
||||
- **Composer**: Ctrl/Cmd+Enter sends the open draft
|
||||
- **Settings**: New Downloads tab with template editor for `.eml` and attachment filenames
|
||||
- **Settings**: Filename transform settings and an ASCII-only "date (from-to) subject" template
|
||||
- **Settings**: Post-export action (keep / archive / trash)
|
||||
- **Settings**: Template for multi-email `.zip` filenames
|
||||
- **Admin**: Per-domain branding editor with overrides on `/api/config`, manifest, and PWA icon (#332)
|
||||
- **Admin**: Policy-controlled push relay URL with optional user lock
|
||||
- **i18n**: `NEXT_PUBLIC_DEFAULT_LOCALE` for fallback UI locale (#243)
|
||||
|
||||
### Fixes
|
||||
|
||||
- **Mail**: Editable HTML signature in new mail; clean state on every compose entry (#329)
|
||||
- **Mail**: Report real upload progress with XHR progress events (#333)
|
||||
- **Mail**: Restore `blob:` in `object-src` and `frame-src` CSP for PDF/HTML previews
|
||||
- **Mail**: Match user-avatar treatment on quick reply
|
||||
- **Email viewer**: Stop shattering table cells with `word-break: break-word`
|
||||
- **Composer**: Scope Ctrl/Cmd+Enter send to the focused composer
|
||||
- **Composer**: Stop closing the form when editing any field
|
||||
- **Pro**: Keep the empty viewer pane visible in the split layout
|
||||
- **Pro**: Prevent an empty main pane when reordering tabs across panes
|
||||
- **Mobile**: Collapse focus mail layout to multi-line
|
||||
- **Mobile**: Keep a gutter on bare-HTML and plain-text emails
|
||||
- **Calendar**: Align continued multi-week events with the week's left edge
|
||||
- **Calendar**: Show the end date in the event popover for multi-day events (#318)
|
||||
- **Calendar**: Convert `recurrenceRules` to singular in batch create
|
||||
- **Calendar**: Handle malformed event dates (#316)
|
||||
- **Files**: Stop URL-encoding drag-out filenames and preserve Unicode letters
|
||||
- **Routing**: Prefix remaining `<img>`, favicon, and WebDAV URLs with `basePath` (#319)
|
||||
- **Routing**: Prefix hand-written URLs with `basePath` for subpath deployments
|
||||
- **Auth**: `OAUTH_ALLOW_PRIVATE_ENDPOINTS` for split-DNS setups
|
||||
|
||||
### i18n
|
||||
|
||||
- Add missing translation keys across 16 locales
|
||||
|
||||
## 1.7.1 (2026-05-22)
|
||||
|
||||
### Features
|
||||
|
||||
@@ -12,7 +12,7 @@ A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.ar
|
||||
|
||||
[](LICENSE)
|
||||
[](https://discord.gg/tYCujymGrT)
|
||||
[](CHANGELOG.md)
|
||||
[](CHANGELOG.md)
|
||||
[](https://ghcr.io/bulwarkmail/webmail)
|
||||
[](https://grafana.external.bulwarkmail.org/)
|
||||
|
||||
@@ -211,6 +211,12 @@ LOGIN_COMPANY_NAME=My Company
|
||||
LOGIN_WEBSITE_URL=https://example.com
|
||||
LOGIN_IMPRINT_URL=https://example.com/imprint
|
||||
LOGIN_PRIVACY_POLICY_URL=https://example.com/privacy
|
||||
|
||||
# Per-domain overrides (optional). When the webmail is served on multiple
|
||||
# hostnames, each host can override any subset of the branding fields above.
|
||||
# Match is on the request Host (or X-Forwarded-Host). Use "*.example.com" to
|
||||
# match any subdomain. Unset fields fall back to the global values.
|
||||
DOMAIN_BRANDING=[{"host":"maildomain1.com","loginCompanyName":"Company One","loginLogoLightUrl":"/branding/one.svg"},{"host":"maildomain2.com","loginCompanyName":"Company Two"}]
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
@@ -608,6 +608,7 @@ export default function Home() {
|
||||
}
|
||||
},
|
||||
onCompose: () => {
|
||||
startFreshComposerSession();
|
||||
setComposerMode('compose');
|
||||
setShowComposer(true);
|
||||
if (isMobile) setActiveView('viewer');
|
||||
@@ -1219,6 +1220,15 @@ export default function Home() {
|
||||
}
|
||||
}, [tCommon]);
|
||||
|
||||
// Force a clean composer remount on every fresh entry point so prior
|
||||
// compose state can't bleed into the new session (#329 C). The composer is
|
||||
// keyed on composerSessionId, and pendingDraft would otherwise pin the
|
||||
// composer to a stale draft from a discarded reply.
|
||||
const startFreshComposerSession = useCallback(() => {
|
||||
setComposerSessionId(id => id + 1);
|
||||
setPendingDraft(null);
|
||||
}, []);
|
||||
|
||||
const handleReply = async (draftText?: string) => {
|
||||
if (selectedEmail) {
|
||||
const ok = await emailHooks.onBeforeReply.intercept({
|
||||
@@ -1231,6 +1241,7 @@ export default function Home() {
|
||||
} else {
|
||||
setComposerQuoteHeader(null);
|
||||
}
|
||||
startFreshComposerSession();
|
||||
setComposerDraftText(draftText || "");
|
||||
setComposerMode('reply');
|
||||
setShowComposer(true);
|
||||
@@ -1340,6 +1351,7 @@ export default function Home() {
|
||||
} else {
|
||||
setComposerQuoteHeader(null);
|
||||
}
|
||||
startFreshComposerSession();
|
||||
setComposerMode('replyAll');
|
||||
setShowComposer(true);
|
||||
if (isMobile) setActiveView('viewer');
|
||||
@@ -1357,6 +1369,7 @@ export default function Home() {
|
||||
} else {
|
||||
setComposerQuoteHeader(null);
|
||||
}
|
||||
startFreshComposerSession();
|
||||
setComposerMode('forward');
|
||||
setShowComposer(true);
|
||||
if (isMobile) setActiveView('viewer');
|
||||
@@ -2278,6 +2291,7 @@ export default function Home() {
|
||||
const handleConversationReply = async (email: Email) => {
|
||||
selectEmail(email);
|
||||
await prepareComposerQuoteHeader(email, 'reply');
|
||||
startFreshComposerSession();
|
||||
setComposerMode('reply');
|
||||
setShowComposer(true);
|
||||
if (isMobile) setActiveView('viewer');
|
||||
@@ -2286,6 +2300,7 @@ export default function Home() {
|
||||
const handleConversationReplyAll = async (email: Email) => {
|
||||
selectEmail(email);
|
||||
await prepareComposerQuoteHeader(email, 'replyAll');
|
||||
startFreshComposerSession();
|
||||
setComposerMode('replyAll');
|
||||
setShowComposer(true);
|
||||
if (isMobile) setActiveView('viewer');
|
||||
@@ -2294,6 +2309,7 @@ export default function Home() {
|
||||
const handleConversationForward = async (email: Email) => {
|
||||
selectEmail(email);
|
||||
await prepareComposerQuoteHeader(email, 'forward');
|
||||
startFreshComposerSession();
|
||||
setComposerMode('forward');
|
||||
setShowComposer(true);
|
||||
if (isMobile) setActiveView('viewer');
|
||||
@@ -2421,6 +2437,7 @@ export default function Home() {
|
||||
onImportEmail={handleImportEmailFromContextMenu}
|
||||
onRefreshMailboxes={handleRefreshMailboxes}
|
||||
onCompose={() => {
|
||||
startFreshComposerSession();
|
||||
setComposerMode('compose');
|
||||
setShowComposer(true);
|
||||
if (isMobile) {
|
||||
@@ -2805,6 +2822,7 @@ export default function Home() {
|
||||
{/* Floating Compose Button */}
|
||||
<Button
|
||||
onClick={() => {
|
||||
startFreshComposerSession();
|
||||
setComposerMode('compose');
|
||||
setShowComposer(true);
|
||||
if (isMobile) setActiveView('viewer');
|
||||
@@ -3023,6 +3041,7 @@ export default function Home() {
|
||||
}
|
||||
}}
|
||||
onCompose={() => {
|
||||
startFreshComposerSession();
|
||||
setComposerMode('compose');
|
||||
setShowComposer(true);
|
||||
}}
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Save, Loader2, RotateCcw, ImageIcon, Upload, Trash2 } from 'lucide-react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Save, Loader2, RotateCcw, ImageIcon, Upload, Trash2, Globe, Plus, X } from 'lucide-react';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
import {
|
||||
BRANDING_OVERRIDE_KEYS,
|
||||
parseDomainBranding,
|
||||
type BrandingOverrideKey,
|
||||
type DomainBrandingEntry,
|
||||
} from '@/lib/admin/domain-branding';
|
||||
|
||||
interface ConfigEntry {
|
||||
value?: unknown;
|
||||
@@ -16,42 +22,65 @@ const IMAGE_FIELDS = [
|
||||
{ key: 'appLogoDarkUrl', label: 'App Logo (Dark Mode)', accept: '.svg,.png,.jpg,.webp' },
|
||||
{ key: 'loginLogoLightUrl', label: 'Login Logo (Light Mode)', accept: '.svg,.png,.jpg,.webp' },
|
||||
{ key: 'loginLogoDarkUrl', label: 'Login Logo (Dark Mode)', accept: '.svg,.png,.jpg,.webp' },
|
||||
];
|
||||
] as const;
|
||||
|
||||
const TEXT_FIELDS = [
|
||||
{ key: 'loginCompanyName', label: 'Company Name' },
|
||||
{ key: 'loginImprintUrl', label: 'Imprint URL' },
|
||||
{ key: 'loginPrivacyPolicyUrl', label: 'Privacy Policy URL' },
|
||||
{ key: 'loginWebsiteUrl', label: 'Company Website URL' },
|
||||
];
|
||||
] as const;
|
||||
|
||||
const PWA_IMAGE_FIELDS = [
|
||||
{ key: 'pwaIconUrl', label: 'PWA Icon', accept: '.svg,.png,.jpg,.webp' },
|
||||
];
|
||||
] as const;
|
||||
|
||||
const PWA_TEXT_FIELDS = [
|
||||
{ key: 'appShortName', label: 'Short Name', placeholder: 'Shown on home screen (max ~12 chars)' },
|
||||
{ key: 'appDescription', label: 'Description', placeholder: 'App description for install prompts' },
|
||||
];
|
||||
] as const;
|
||||
|
||||
const PWA_COLOR_FIELDS = [
|
||||
{ key: 'pwaThemeColor', label: 'Theme Color', defaultValue: '#ffffff' },
|
||||
{ key: 'pwaBackgroundColor', label: 'Background Color', defaultValue: '#ffffff' },
|
||||
];
|
||||
] as const;
|
||||
|
||||
// Accepts exact hosts and one-level wildcards (e.g. *.example.com).
|
||||
const HOST_RE = /^(\*\.)?[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/;
|
||||
// Tighter rule for uploads: wildcards can only point to externally-hosted
|
||||
// URLs, since we'd have no concrete subdomain to serve a file from.
|
||||
const EXACT_HOST_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/;
|
||||
|
||||
export function BrandingTab() {
|
||||
const [config, setConfig] = useState<Record<string, ConfigEntry>>({});
|
||||
const [edits, setEdits] = useState<Record<string, unknown>>({});
|
||||
const [edits, setEdits] = useState<Record<string, string>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [uploading, setUploading] = useState<string | null>(null);
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
const [selectedHost, setSelectedHost] = useState<string | null>(null);
|
||||
const [addingHost, setAddingHost] = useState(false);
|
||||
const [newHostInput, setNewHostInput] = useState('');
|
||||
const [newHostError, setNewHostError] = useState<string | null>(null);
|
||||
const fileInputRefs = useRef<Record<string, HTMLInputElement | null>>({});
|
||||
|
||||
useEffect(() => {
|
||||
fetchConfig();
|
||||
}, []);
|
||||
|
||||
const domainEntries = useMemo<DomainBrandingEntry[]>(
|
||||
() => parseDomainBranding(config['domainBranding']?.value),
|
||||
[config],
|
||||
);
|
||||
|
||||
// Drop selection if the host disappeared from the config (e.g. concurrent edit).
|
||||
useEffect(() => {
|
||||
if (selectedHost && !domainEntries.some(e => e.host === selectedHost)) {
|
||||
setSelectedHost(null);
|
||||
setEdits({});
|
||||
}
|
||||
}, [domainEntries, selectedHost]);
|
||||
|
||||
async function fetchConfig() {
|
||||
setLoading(true);
|
||||
const res = await apiFetch('/api/admin/config');
|
||||
@@ -59,29 +88,81 @@ export function BrandingTab() {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
function selectedEntry(): DomainBrandingEntry | null {
|
||||
if (!selectedHost) return null;
|
||||
return domainEntries.find(e => e.host === selectedHost) ?? null;
|
||||
}
|
||||
|
||||
function handleChange(key: string, value: string) {
|
||||
setEdits(prev => ({ ...prev, [key]: value }));
|
||||
setMessage(null);
|
||||
}
|
||||
|
||||
function currentValue(key: string): string {
|
||||
if (key in edits) return edits[key] as string;
|
||||
if (key in edits) return edits[key];
|
||||
if (selectedHost) {
|
||||
const entry = selectedEntry();
|
||||
return (entry?.[key as BrandingOverrideKey] as string | undefined) ?? '';
|
||||
}
|
||||
return (config[key]?.value as string) ?? '';
|
||||
}
|
||||
|
||||
function isOverriddenInScope(key: string): boolean {
|
||||
if (selectedHost) {
|
||||
const entry = selectedEntry();
|
||||
const v = entry?.[key as BrandingOverrideKey];
|
||||
return typeof v === 'string' && v.length > 0;
|
||||
}
|
||||
return config[key]?.source === 'admin';
|
||||
}
|
||||
|
||||
const isUploadedFile = (key: string): boolean => {
|
||||
const val = currentValue(key);
|
||||
return val.startsWith('/api/admin/branding/');
|
||||
};
|
||||
|
||||
function buildUpdatedDomainBranding(merge: Record<string, string>): DomainBrandingEntry[] {
|
||||
if (!selectedHost) return domainEntries;
|
||||
const next = domainEntries.slice();
|
||||
const idx = next.findIndex(e => e.host === selectedHost);
|
||||
const base: DomainBrandingEntry =
|
||||
idx === -1 ? { host: selectedHost } : { ...next[idx] };
|
||||
const writable = base as unknown as Record<string, string | undefined>;
|
||||
for (const [key, value] of Object.entries(merge)) {
|
||||
if (!(BRANDING_OVERRIDE_KEYS as readonly string[]).includes(key)) continue;
|
||||
if (typeof value === 'string' && value.length > 0) {
|
||||
writable[key] = value;
|
||||
} else {
|
||||
delete writable[key];
|
||||
}
|
||||
}
|
||||
if (idx === -1) next.push(base);
|
||||
else next[idx] = base;
|
||||
return next;
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (Object.keys(edits).length === 0) return;
|
||||
setSaving(true);
|
||||
setMessage(null);
|
||||
|
||||
const payload = selectedHost
|
||||
? { domainBranding: buildUpdatedDomainBranding(edits) }
|
||||
: edits;
|
||||
|
||||
const res = await apiFetch('/api/admin/config', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(edits),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setMessage({ type: 'success', text: 'Branding updated. Changes visible on next page load.' });
|
||||
setMessage({
|
||||
type: 'success',
|
||||
text: selectedHost
|
||||
? `Branding for ${selectedHost} updated. Changes visible on next page load.`
|
||||
: 'Branding updated. Changes visible on next page load.',
|
||||
});
|
||||
setEdits({});
|
||||
await fetchConfig();
|
||||
} else {
|
||||
@@ -92,12 +173,20 @@ export function BrandingTab() {
|
||||
}
|
||||
|
||||
async function handleUpload(slot: string, file: File) {
|
||||
if (selectedHost && !EXACT_HOST_RE.test(selectedHost)) {
|
||||
setMessage({
|
||||
type: 'error',
|
||||
text: 'Wildcard hosts cannot upload files. Enter a URL instead.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
setUploading(slot);
|
||||
setMessage(null);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('slot', slot);
|
||||
if (selectedHost) formData.append('host', selectedHost);
|
||||
|
||||
const res = await apiFetch('/api/admin/branding', {
|
||||
method: 'POST',
|
||||
@@ -112,10 +201,9 @@ export function BrandingTab() {
|
||||
delete next[slot];
|
||||
return next;
|
||||
});
|
||||
setConfig(prev => ({
|
||||
...prev,
|
||||
[slot]: { value: data.url, source: 'admin' },
|
||||
}));
|
||||
// Refresh from server so domainBranding entries reflect the upload.
|
||||
await fetchConfig();
|
||||
void data;
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setMessage({ type: 'error', text: data.error || 'Upload failed' });
|
||||
@@ -126,10 +214,13 @@ export function BrandingTab() {
|
||||
async function handleDeleteUpload(slot: string) {
|
||||
setMessage(null);
|
||||
|
||||
const body: { slot: string; host?: string } = { slot };
|
||||
if (selectedHost) body.host = selectedHost;
|
||||
|
||||
const res = await apiFetch('/api/admin/branding', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ slot }),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
@@ -147,6 +238,25 @@ export function BrandingTab() {
|
||||
}
|
||||
|
||||
async function handleRevert(key: string) {
|
||||
if (selectedHost) {
|
||||
// Domain scope: drop the field from the entry and PATCH the array.
|
||||
const updated = buildUpdatedDomainBranding({ [key]: '' });
|
||||
const res = await apiFetch('/api/admin/config', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ domainBranding: updated }),
|
||||
});
|
||||
if (res.ok) {
|
||||
setEdits(prev => {
|
||||
const next = { ...prev };
|
||||
delete next[key];
|
||||
return next;
|
||||
});
|
||||
await fetchConfig();
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Default scope: revert via DELETE /api/admin/config
|
||||
const res = await apiFetch('/api/admin/config', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -162,12 +272,71 @@ export function BrandingTab() {
|
||||
}
|
||||
}
|
||||
|
||||
const isUploadedFile = (key: string): boolean => {
|
||||
const val = currentValue(key);
|
||||
return val.startsWith('/api/admin/branding/');
|
||||
};
|
||||
async function handleAddDomain() {
|
||||
const host = newHostInput.trim().toLowerCase().replace(/\.+$/, '');
|
||||
if (!host) {
|
||||
setNewHostError('Enter a hostname');
|
||||
return;
|
||||
}
|
||||
if (!HOST_RE.test(host)) {
|
||||
setNewHostError('Invalid hostname. Use foo.example.com or *.example.com');
|
||||
return;
|
||||
}
|
||||
if (domainEntries.some(e => e.host === host)) {
|
||||
setNewHostError('A branding entry for this host already exists');
|
||||
return;
|
||||
}
|
||||
setNewHostError(null);
|
||||
|
||||
const next: DomainBrandingEntry[] = [...domainEntries, { host }];
|
||||
const res = await apiFetch('/api/admin/config', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ domainBranding: next }),
|
||||
});
|
||||
if (res.ok) {
|
||||
setNewHostInput('');
|
||||
setAddingHost(false);
|
||||
setSelectedHost(host);
|
||||
setEdits({});
|
||||
await fetchConfig();
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setNewHostError(data.error || 'Failed to add domain');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteDomain() {
|
||||
if (!selectedHost) return;
|
||||
if (!confirm(`Remove branding entry for ${selectedHost}? Uploaded files for this domain will be left behind on disk.`)) {
|
||||
return;
|
||||
}
|
||||
const next = domainEntries.filter(e => e.host !== selectedHost);
|
||||
const res = await apiFetch('/api/admin/config', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ domainBranding: next }),
|
||||
});
|
||||
if (res.ok) {
|
||||
setSelectedHost(null);
|
||||
setEdits({});
|
||||
await fetchConfig();
|
||||
setMessage({ type: 'success', text: `Removed branding entry for ${selectedHost}.` });
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setMessage({ type: 'error', text: data.error || 'Failed to remove domain' });
|
||||
}
|
||||
}
|
||||
|
||||
function handleScopeChange(host: string | null) {
|
||||
if (Object.keys(edits).length > 0 && !confirm('Discard unsaved changes?')) return;
|
||||
setSelectedHost(host);
|
||||
setEdits({});
|
||||
setMessage(null);
|
||||
}
|
||||
|
||||
const hasEdits = Object.keys(edits).length > 0;
|
||||
const wildcardScope = !!selectedHost && !EXACT_HOST_RE.test(selectedHost);
|
||||
|
||||
if (loading) {
|
||||
return <div className="flex items-center justify-center py-12 text-muted-foreground text-sm">Loading...</div>;
|
||||
@@ -192,6 +361,102 @@ export function BrandingTab() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Scope picker */}
|
||||
<div className="border border-border rounded-lg">
|
||||
<div className="px-4 py-3 border-b border-border bg-muted/30 flex items-center gap-2">
|
||||
<Globe className="w-4 h-4 text-muted-foreground" />
|
||||
<h2 className="text-sm font-medium text-foreground">Scope</h2>
|
||||
</div>
|
||||
<div className="px-4 py-3 space-y-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleScopeChange(null)}
|
||||
className={`h-8 px-3 rounded-md text-sm font-medium transition-colors ${
|
||||
selectedHost === null
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted text-foreground hover:bg-muted/70'
|
||||
}`}
|
||||
>
|
||||
Default
|
||||
</button>
|
||||
{domainEntries.map(entry => (
|
||||
<button
|
||||
key={entry.host}
|
||||
type="button"
|
||||
onClick={() => handleScopeChange(entry.host)}
|
||||
className={`h-8 px-3 rounded-md text-sm font-medium transition-colors ${
|
||||
selectedHost === entry.host
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted text-foreground hover:bg-muted/70'
|
||||
}`}
|
||||
>
|
||||
{entry.host}
|
||||
</button>
|
||||
))}
|
||||
{!addingHost && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setAddingHost(true); setNewHostError(null); }}
|
||||
className="inline-flex items-center gap-1 h-8 px-3 rounded-md border border-dashed border-input text-sm text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
Add domain
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{addingHost && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
autoFocus
|
||||
value={newHostInput}
|
||||
onChange={(e) => { setNewHostInput(e.target.value); setNewHostError(null); }}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') void handleAddDomain(); }}
|
||||
placeholder="mail.example.com or *.example.com"
|
||||
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"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddDomain}
|
||||
className="h-8 px-3 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setAddingHost(false); setNewHostInput(''); setNewHostError(null); }}
|
||||
className="h-8 px-2.5 rounded-md text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
{newHostError && <span className="text-xs text-destructive">{newHostError}</span>}
|
||||
</div>
|
||||
)}
|
||||
{selectedHost ? (
|
||||
<div className="flex items-center justify-between gap-3 text-xs">
|
||||
<p className="text-muted-foreground">
|
||||
Editing overrides for <span className="font-mono text-foreground">{selectedHost}</span>.
|
||||
Unset fields fall back to the Default values.
|
||||
{wildcardScope && ' Uploads are disabled for wildcard hosts; enter a URL instead.'}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDeleteDomain}
|
||||
className="inline-flex items-center gap-1 text-destructive hover:underline whitespace-nowrap"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
Remove domain
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Editing the Default branding. Add a domain to override branding when the webmail is served on a specific hostname.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div className={`text-sm rounded-md px-3 py-2 ${message.type === 'success' ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300' : 'bg-destructive/10 text-destructive'}`}>
|
||||
{message.text}
|
||||
@@ -209,9 +474,9 @@ export function BrandingTab() {
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<label className="text-sm text-foreground">{field.label}</label>
|
||||
{config[field.key]?.source === 'admin' && (
|
||||
{isOverriddenInScope(field.key) && (
|
||||
<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' : selectedHost ? 'domain' : 'admin'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -220,7 +485,7 @@ export function BrandingTab() {
|
||||
type="text"
|
||||
value={currentValue(field.key)}
|
||||
onChange={(e) => handleChange(field.key, e.target.value)}
|
||||
placeholder="Enter URL or upload a file"
|
||||
placeholder={selectedHost ? 'Enter URL (uploads only for default scope)' : 'Enter URL or upload a file'}
|
||||
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
|
||||
@@ -236,9 +501,9 @@ export function BrandingTab() {
|
||||
/>
|
||||
<button
|
||||
onClick={() => fileInputRefs.current[field.key]?.click()}
|
||||
disabled={uploading === field.key}
|
||||
disabled={uploading === field.key || wildcardScope}
|
||||
className="inline-flex items-center gap-1.5 h-8 px-2.5 rounded-md border border-input bg-background text-sm text-foreground hover:bg-muted disabled:opacity-50 transition-colors"
|
||||
title="Upload file"
|
||||
title={wildcardScope ? 'Uploads disabled for wildcard hosts' : 'Upload file'}
|
||||
>
|
||||
{uploading === field.key ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Upload className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
@@ -251,7 +516,7 @@ export function BrandingTab() {
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{config[field.key]?.source === 'admin' && !isUploadedFile(field.key) && (
|
||||
{isOverriddenInScope(field.key) && !isUploadedFile(field.key) && (
|
||||
<button onClick={() => handleRevert(field.key)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
@@ -287,9 +552,9 @@ export function BrandingTab() {
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<label className="text-sm text-foreground">{field.label}</label>
|
||||
{config[field.key]?.source === 'admin' && (
|
||||
{isOverriddenInScope(field.key) && (
|
||||
<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' : selectedHost ? 'domain' : 'admin'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -298,7 +563,7 @@ export function BrandingTab() {
|
||||
type="text"
|
||||
value={currentValue(field.key)}
|
||||
onChange={(e) => handleChange(field.key, e.target.value)}
|
||||
placeholder="Enter URL or upload a file"
|
||||
placeholder={selectedHost ? 'Enter URL (uploads only for default scope)' : 'Enter URL or upload a file'}
|
||||
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
|
||||
@@ -314,9 +579,9 @@ export function BrandingTab() {
|
||||
/>
|
||||
<button
|
||||
onClick={() => fileInputRefs.current[field.key]?.click()}
|
||||
disabled={uploading === field.key}
|
||||
disabled={uploading === field.key || wildcardScope}
|
||||
className="inline-flex items-center gap-1.5 h-8 px-2.5 rounded-md border border-input bg-background text-sm text-foreground hover:bg-muted disabled:opacity-50 transition-colors"
|
||||
title="Upload file"
|
||||
title={wildcardScope ? 'Uploads disabled for wildcard hosts' : 'Upload file'}
|
||||
>
|
||||
{uploading === field.key ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Upload className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
@@ -329,7 +594,7 @@ export function BrandingTab() {
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{config[field.key]?.source === 'admin' && !isUploadedFile(field.key) && (
|
||||
{isOverriddenInScope(field.key) && !isUploadedFile(field.key) && (
|
||||
<button onClick={() => handleRevert(field.key)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
@@ -355,8 +620,10 @@ export function BrandingTab() {
|
||||
<div key={field.key} className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<label className="text-sm text-foreground">{field.label}</label>
|
||||
{config[field.key]?.source === 'admin' && (
|
||||
<span className="text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary">admin</span>
|
||||
{isOverriddenInScope(field.key) && (
|
||||
<span className="text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary">
|
||||
{selectedHost ? 'domain' : 'admin'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 w-full sm:w-auto">
|
||||
@@ -367,7 +634,7 @@ export function BrandingTab() {
|
||||
placeholder={field.placeholder}
|
||||
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' && (
|
||||
{isOverriddenInScope(field.key) && (
|
||||
<button onClick={() => handleRevert(field.key)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
@@ -381,8 +648,10 @@ export function BrandingTab() {
|
||||
<div key={field.key} className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<label className="text-sm text-foreground">{field.label}</label>
|
||||
{config[field.key]?.source === 'admin' && (
|
||||
<span className="text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary">admin</span>
|
||||
{isOverriddenInScope(field.key) && (
|
||||
<span className="text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary">
|
||||
{selectedHost ? 'domain' : 'admin'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 w-full sm:w-auto">
|
||||
@@ -400,7 +669,7 @@ export function BrandingTab() {
|
||||
placeholder={field.defaultValue}
|
||||
className="h-8 w-full sm:w-32 min-w-0 rounded-md border border-input bg-background px-2.5 text-sm font-mono text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
{config[field.key]?.source === 'admin' && (
|
||||
{isOverriddenInScope(field.key) && (
|
||||
<button onClick={() => handleRevert(field.key)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
@@ -421,8 +690,10 @@ export function BrandingTab() {
|
||||
<div key={field.key} className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<label className="text-sm text-foreground">{field.label}</label>
|
||||
{config[field.key]?.source === 'admin' && (
|
||||
<span className="text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary">admin</span>
|
||||
{isOverriddenInScope(field.key) && (
|
||||
<span className="text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary">
|
||||
{selectedHost ? 'domain' : 'admin'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 w-full sm:w-auto">
|
||||
@@ -433,7 +704,7 @@ export function BrandingTab() {
|
||||
placeholder={field.key.includes('Url') ? 'https://...' : 'Enter value'}
|
||||
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' && (
|
||||
{isOverriddenInScope(field.key) && (
|
||||
<button onClick={() => handleRevert(field.key)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
|
||||
@@ -74,6 +74,18 @@ export function PolicyTab() {
|
||||
setMessage(null);
|
||||
}
|
||||
|
||||
function setPushRelayUrl(value: string) {
|
||||
setPolicy(prev => ({ ...prev, pushRelayUrl: value }));
|
||||
setDirty(true);
|
||||
setMessage(null);
|
||||
}
|
||||
|
||||
function togglePushRelayLocked() {
|
||||
setPolicy(prev => ({ ...prev, pushRelayUrlLocked: !prev.pushRelayUrlLocked }));
|
||||
setDirty(true);
|
||||
setMessage(null);
|
||||
}
|
||||
|
||||
function toggleLocked(settingKey: string) {
|
||||
setPolicy(prev => {
|
||||
const existing = prev.restrictions[settingKey] || {};
|
||||
@@ -183,6 +195,34 @@ export function PolicyTab() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border border-border rounded-lg">
|
||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||
<h2 className="text-sm font-medium text-foreground">Push Relay</h2>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Override the Web Push relay URL shown in user notification settings. Leave empty to use the built-in default.</p>
|
||||
</div>
|
||||
<div className="px-4 py-3 space-y-3">
|
||||
<input
|
||||
type="url"
|
||||
inputMode="url"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
value={policy.pushRelayUrl ?? ''}
|
||||
onChange={(e) => setPushRelayUrl(e.target.value)}
|
||||
placeholder="https://notifications.relay.example.com"
|
||||
className="w-full rounded border border-input bg-background px-3 py-2 text-sm"
|
||||
/>
|
||||
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!policy.pushRelayUrlLocked}
|
||||
onChange={togglePushRelayLocked}
|
||||
className="rounded border-input"
|
||||
/>
|
||||
<Lock className="w-3 h-3" /> Lock - users cannot change this URL
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{categories.map(category => (
|
||||
<div key={category} className="border border-border rounded-lg">
|
||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||
|
||||
+155
-32
@@ -3,8 +3,13 @@ import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
|
||||
import { auditLog } from '@/lib/admin/audit';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { getConfigDir } from '@/lib/admin/paths';
|
||||
import {
|
||||
parseDomainBranding,
|
||||
type DomainBrandingEntry,
|
||||
type BrandingOverrideKey,
|
||||
} from '@/lib/admin/domain-branding';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { writeFile, unlink, mkdir } from 'node:fs/promises';
|
||||
import { writeFile, unlink, mkdir, readdir } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
@@ -22,7 +27,7 @@ const ALLOWED_MIME_TYPES = new Set([
|
||||
]);
|
||||
|
||||
/** Slots that correspond to branding config keys */
|
||||
const VALID_SLOTS = new Set([
|
||||
const VALID_SLOTS = new Set<BrandingOverrideKey>([
|
||||
'faviconUrl',
|
||||
'pwaIconUrl',
|
||||
'appLogoLightUrl',
|
||||
@@ -31,17 +36,85 @@ const VALID_SLOTS = new Set([
|
||||
'loginLogoDarkUrl',
|
||||
]);
|
||||
|
||||
const EXT_BY_MIME: Record<string, string> = {
|
||||
'image/svg+xml': '.svg',
|
||||
'image/png': '.png',
|
||||
'image/jpeg': '.jpg',
|
||||
'image/webp': '.webp',
|
||||
'image/x-icon': '.ico',
|
||||
'image/vnd.microsoft.icon': '.ico',
|
||||
};
|
||||
|
||||
const POSSIBLE_EXTS = ['.svg', '.png', '.jpg', '.jpeg', '.webp', '.ico'];
|
||||
|
||||
// Exact hostnames only (no wildcards): wildcards can't be uploaded against
|
||||
// because we'd need a real subdomain to serve the file from.
|
||||
const EXACT_HOST_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/;
|
||||
|
||||
function sanitizeFilename(name: string): string {
|
||||
// Strip directory traversal, keep only safe chars
|
||||
return path.basename(name).replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||
}
|
||||
|
||||
function normalizeHost(raw: string): string {
|
||||
return raw.trim().toLowerCase().replace(/\.+$/, '');
|
||||
}
|
||||
|
||||
/** Filename used to store a per-host uploaded asset. */
|
||||
function domainAssetName(host: string, slot: BrandingOverrideKey, ext: string): string {
|
||||
return sanitizeFilename(`domain__${host}__${slot}${ext}`);
|
||||
}
|
||||
|
||||
/** True if the file belongs to the given host+slot (any extension). */
|
||||
function isDomainAssetFor(filename: string, host: string, slot: BrandingOverrideKey): boolean {
|
||||
const prefix = sanitizeFilename(`domain__${host}__${slot}.`);
|
||||
return filename.startsWith(prefix);
|
||||
}
|
||||
|
||||
/** Merge a per-host update into the existing domainBranding array. */
|
||||
function mergeDomainEntry(
|
||||
current: DomainBrandingEntry[],
|
||||
host: string,
|
||||
patch: Partial<DomainBrandingEntry>,
|
||||
): DomainBrandingEntry[] {
|
||||
const next = current.slice();
|
||||
const idx = next.findIndex(e => e.host === host);
|
||||
if (idx === -1) {
|
||||
next.push({ host, ...patch });
|
||||
} else {
|
||||
next[idx] = { ...next[idx], ...patch };
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Remove keys from a host's entry. If the entry has nothing left besides
|
||||
* `host`, drop it entirely. */
|
||||
function clearDomainKeys(
|
||||
current: DomainBrandingEntry[],
|
||||
host: string,
|
||||
keys: BrandingOverrideKey[],
|
||||
): DomainBrandingEntry[] {
|
||||
const idx = current.findIndex(e => e.host === host);
|
||||
if (idx === -1) return current;
|
||||
const entry = { ...current[idx] };
|
||||
for (const key of keys) delete (entry as Record<string, unknown>)[key];
|
||||
const next = current.slice();
|
||||
if (Object.keys(entry).filter(k => k !== 'host').length === 0) {
|
||||
next.splice(idx, 1);
|
||||
} else {
|
||||
next[idx] = entry;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/admin/branding - Upload a branding image file
|
||||
*
|
||||
* Expects multipart/form-data with:
|
||||
* - file: the image file
|
||||
* - slot: which branding field this is for (e.g. "faviconUrl")
|
||||
* - host (optional): when set, the upload is stored against the
|
||||
* per-domain entry for that hostname instead of the global default.
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
@@ -52,15 +125,24 @@ export async function POST(request: NextRequest) {
|
||||
const formData = await request.formData();
|
||||
const file = formData.get('file') as File | null;
|
||||
const slot = formData.get('slot') as string | null;
|
||||
const rawHost = (formData.get('host') as string | null) ?? '';
|
||||
|
||||
if (!file || !slot) {
|
||||
return NextResponse.json({ error: 'Missing file or slot' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!VALID_SLOTS.has(slot)) {
|
||||
if (!VALID_SLOTS.has(slot as BrandingOverrideKey)) {
|
||||
return NextResponse.json({ error: `Invalid slot: ${slot}` }, { status: 400 });
|
||||
}
|
||||
|
||||
const host = rawHost ? normalizeHost(rawHost) : '';
|
||||
if (host && !EXACT_HOST_RE.test(host)) {
|
||||
return NextResponse.json(
|
||||
{ error: `Invalid host: ${rawHost} (wildcards must be configured by URL, not upload)` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
return NextResponse.json({ error: 'File too large (max 2 MB)' }, { status: 400 });
|
||||
}
|
||||
@@ -72,34 +154,51 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
// Determine extension from mime type
|
||||
const extMap: Record<string, string> = {
|
||||
'image/svg+xml': '.svg',
|
||||
'image/png': '.png',
|
||||
'image/jpeg': '.jpg',
|
||||
'image/webp': '.webp',
|
||||
'image/x-icon': '.ico',
|
||||
'image/vnd.microsoft.icon': '.ico',
|
||||
};
|
||||
const ext = extMap[file.type] || '.png';
|
||||
const safeName = sanitizeFilename(`${slot}${ext}`);
|
||||
const ext = EXT_BY_MIME[file.type] ?? '.png';
|
||||
const safeName = host
|
||||
? domainAssetName(host, slot as BrandingOverrideKey, ext)
|
||||
: sanitizeFilename(`${slot}${ext}`);
|
||||
const filePath = path.join(getBrandingDir(), safeName);
|
||||
|
||||
// Ensure branding directory exists
|
||||
if (!existsSync(getBrandingDir())) {
|
||||
await mkdir(getBrandingDir(), { recursive: true });
|
||||
}
|
||||
|
||||
// Write file to disk
|
||||
// Strip any prior asset for the same slot but a different extension so
|
||||
// the directory doesn't accumulate orphan files on re-upload.
|
||||
const dir = getBrandingDir();
|
||||
const allFiles = await readdir(dir).catch(() => [] as string[]);
|
||||
for (const f of allFiles) {
|
||||
if (f === safeName) continue;
|
||||
const isSame = host
|
||||
? isDomainAssetFor(f, host, slot as BrandingOverrideKey)
|
||||
: POSSIBLE_EXTS.some(e => f === `${slot}${e}`);
|
||||
if (isSame) {
|
||||
try { await unlink(path.join(dir, f)); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
await writeFile(filePath, buffer);
|
||||
|
||||
// Update config to point to the served URL
|
||||
const servedUrl = `/api/admin/branding/${safeName}`;
|
||||
await configManager.ensureLoaded();
|
||||
await configManager.setAdminConfig({ [slot]: servedUrl });
|
||||
|
||||
await auditLog('branding_upload', { slot, filename: safeName, size: file.size, mimeType: file.type }, ip);
|
||||
if (host) {
|
||||
const current = parseDomainBranding(configManager.get<unknown>('domainBranding', []));
|
||||
const next = mergeDomainEntry(current, host, { [slot]: servedUrl });
|
||||
await configManager.setAdminConfig({ domainBranding: next });
|
||||
} else {
|
||||
await configManager.setAdminConfig({ [slot]: servedUrl });
|
||||
}
|
||||
|
||||
await auditLog('branding_upload', {
|
||||
slot,
|
||||
host: host || undefined,
|
||||
filename: safeName,
|
||||
size: file.size,
|
||||
mimeType: file.type,
|
||||
}, ip);
|
||||
|
||||
return NextResponse.json({ url: servedUrl, filename: safeName });
|
||||
} catch (error) {
|
||||
@@ -111,7 +210,11 @@ export async function POST(request: NextRequest) {
|
||||
/**
|
||||
* DELETE /api/admin/branding - Remove an uploaded branding file
|
||||
*
|
||||
* Expects JSON body: { slot: string }
|
||||
* Expects JSON body: { slot: string, host?: string }
|
||||
*
|
||||
* When `host` is provided, only the per-domain asset for that host+slot is
|
||||
* removed (and the override in `domainBranding[host][slot]` is cleared).
|
||||
* Otherwise the global asset and config override are removed.
|
||||
*/
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
@@ -119,28 +222,48 @@ export async function DELETE(request: NextRequest) {
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const ip = getClientIP(request);
|
||||
const { slot } = await request.json();
|
||||
const body = await request.json().catch(() => ({})) as { slot?: string; host?: string };
|
||||
const slot = body.slot;
|
||||
const rawHost = body.host ?? '';
|
||||
|
||||
if (!slot || !VALID_SLOTS.has(slot)) {
|
||||
if (!slot || !VALID_SLOTS.has(slot as BrandingOverrideKey)) {
|
||||
return NextResponse.json({ error: 'Invalid or missing slot' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Find and remove matching files for this slot
|
||||
const possibleExts = ['.svg', '.png', '.jpg', '.webp', '.ico'];
|
||||
const host = rawHost ? normalizeHost(rawHost) : '';
|
||||
if (host && !EXACT_HOST_RE.test(host)) {
|
||||
return NextResponse.json({ error: `Invalid host: ${rawHost}` }, { status: 400 });
|
||||
}
|
||||
|
||||
const dir = getBrandingDir();
|
||||
let removed = false;
|
||||
for (const ext of possibleExts) {
|
||||
const filePath = path.join(getBrandingDir(), `${slot}${ext}`);
|
||||
if (existsSync(filePath)) {
|
||||
await unlink(filePath);
|
||||
removed = true;
|
||||
if (host) {
|
||||
const allFiles = await readdir(dir).catch(() => [] as string[]);
|
||||
for (const f of allFiles) {
|
||||
if (isDomainAssetFor(f, host, slot as BrandingOverrideKey)) {
|
||||
try { await unlink(path.join(dir, f)); removed = true; } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const ext of POSSIBLE_EXTS) {
|
||||
const filePath = path.join(dir, `${slot}${ext}`);
|
||||
if (existsSync(filePath)) {
|
||||
await unlink(filePath);
|
||||
removed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clear the config override so it falls back to default/env
|
||||
await configManager.ensureLoaded();
|
||||
await configManager.removeAdminOverride(slot);
|
||||
if (host) {
|
||||
const current = parseDomainBranding(configManager.get<unknown>('domainBranding', []));
|
||||
const next = clearDomainKeys(current, host, [slot as BrandingOverrideKey]);
|
||||
await configManager.setAdminConfig({ domainBranding: next });
|
||||
} else {
|
||||
await configManager.removeAdminOverride(slot);
|
||||
}
|
||||
|
||||
await auditLog('branding_delete', { slot, fileRemoved: removed }, ip);
|
||||
await auditLog('branding_delete', { slot, host: host || undefined, fileRemoved: removed }, ip);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
|
||||
import { auditLog } from '@/lib/admin/audit';
|
||||
import { CONFIG_ENV_MAP, SENSITIVE_CONFIG_KEYS } from '@/lib/admin/types';
|
||||
import { parseJmapServers } from '@/lib/admin/jmap-servers';
|
||||
import { parseDomainBranding } from '@/lib/admin/domain-branding';
|
||||
import { logger } from '@/lib/logger';
|
||||
|
||||
// Strings that count as "no real secret configured" - used so the dashboard
|
||||
@@ -88,6 +89,25 @@ export async function PATCH(request: NextRequest) {
|
||||
updates.jmapServers = sanitized;
|
||||
}
|
||||
|
||||
// Normalize domainBranding: drop entries with an invalid/missing host or
|
||||
// duplicate hosts before persisting. Each entry's branding field strings
|
||||
// are passed through unchanged (URL/string content is the operator's
|
||||
// responsibility, same as the flat branding fields).
|
||||
if ('domainBranding' in updates) {
|
||||
const incoming = updates.domainBranding;
|
||||
if (incoming != null && !Array.isArray(incoming)) {
|
||||
return NextResponse.json({ error: 'domainBranding must be an array' }, { status: 400 });
|
||||
}
|
||||
const sanitized = parseDomainBranding(incoming);
|
||||
const incomingCount = Array.isArray(incoming) ? incoming.length : 0;
|
||||
if (sanitized.length !== incomingCount) {
|
||||
return NextResponse.json({
|
||||
error: 'One or more domainBranding entries are invalid (each needs a unique, valid host).',
|
||||
}, { status: 400 });
|
||||
}
|
||||
updates.domainBranding = sanitized;
|
||||
}
|
||||
|
||||
// Get old values for audit
|
||||
const oldValues: Record<string, unknown> = {};
|
||||
for (const key of Object.keys(updates)) {
|
||||
|
||||
+66
-36
@@ -1,9 +1,15 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { parseJmapServers, redactJmapServers } from '@/lib/admin/jmap-servers';
|
||||
import { hasSessionSecret } from '@/lib/auth/session-secret';
|
||||
import { getOauthScopes } from '@/lib/oauth/tokens';
|
||||
import {
|
||||
matchDomainBranding,
|
||||
parseDomainBranding,
|
||||
pickRequestHost,
|
||||
type BrandingOverrideKey,
|
||||
} from '@/lib/admin/domain-branding';
|
||||
|
||||
/**
|
||||
* Runtime configuration endpoint
|
||||
@@ -13,49 +19,73 @@ import { getOauthScopes } from '@/lib/oauth/tokens';
|
||||
* post-build configuration for Docker deployments.
|
||||
*
|
||||
* Priority order:
|
||||
* 1. Admin dashboard overrides (data/admin/config.json)
|
||||
* 2. Runtime env vars (APP_NAME, JMAP_SERVER_URL)
|
||||
* 3. Build-time env vars (NEXT_PUBLIC_APP_NAME, NEXT_PUBLIC_JMAP_SERVER_URL)
|
||||
* 4. Default values
|
||||
* 1. Per-domain branding override (admin-configured, matched on request host)
|
||||
* 2. Admin dashboard overrides (data/admin/config.json)
|
||||
* 3. Runtime env vars (APP_NAME, JMAP_SERVER_URL)
|
||||
* 4. Build-time env vars (NEXT_PUBLIC_APP_NAME, NEXT_PUBLIC_JMAP_SERVER_URL)
|
||||
* 5. Default values
|
||||
*/
|
||||
export async function GET() {
|
||||
export async function GET(request: NextRequest) {
|
||||
logger.debug('Config requested');
|
||||
await configManager.ensureLoaded();
|
||||
|
||||
const appName = configManager.get<string>('appName') || process.env.NEXT_PUBLIC_APP_NAME || 'Webmail';
|
||||
const host = pickRequestHost(request);
|
||||
const domainOverrides = matchDomainBranding(
|
||||
host,
|
||||
parseDomainBranding(configManager.get<unknown>('domainBranding', [])),
|
||||
);
|
||||
|
||||
// Per-domain override wins over the global value, but only when the
|
||||
// entry explicitly sets that key. Otherwise we fall through to the
|
||||
// global admin/env/default chain.
|
||||
const branded = <T,>(key: BrandingOverrideKey, fallback: T): T => {
|
||||
const override = domainOverrides[key];
|
||||
if (typeof override === 'string' && override.length > 0) return override as T;
|
||||
return configManager.get<T>(key, fallback);
|
||||
};
|
||||
|
||||
const appName =
|
||||
branded<string>('appName', '') || process.env.NEXT_PUBLIC_APP_NAME || 'Webmail';
|
||||
const jmapServerUrl = configManager.get<string>('jmapServerUrl') || process.env.NEXT_PUBLIC_JMAP_SERVER_URL || '';
|
||||
const oauthEnabled = configManager.get<boolean>('oauthEnabled', false);
|
||||
const oauthOnly = oauthEnabled && configManager.get<boolean>('oauthOnly', false);
|
||||
const stalwartFeaturesEnabled = configManager.get<boolean>('stalwartFeaturesEnabled', true);
|
||||
const allowedFrameAncestors = configManager.get<string>('allowedFrameAncestors', '');
|
||||
|
||||
return NextResponse.json({
|
||||
appName,
|
||||
jmapServerUrl,
|
||||
oauthEnabled,
|
||||
oauthOnly,
|
||||
oauthClientId: configManager.get<string>('oauthClientId', ''),
|
||||
oauthIssuerUrl: configManager.get<string>('oauthIssuerUrl', ''),
|
||||
oauthScopes: getOauthScopes(),
|
||||
rememberMeEnabled: hasSessionSecret(),
|
||||
settingsSyncEnabled: configManager.get<boolean>('settingsSyncEnabled', false) && hasSessionSecret(),
|
||||
stalwartFeaturesEnabled,
|
||||
devMode: configManager.get<boolean>('devMode', false),
|
||||
faviconUrl: configManager.get<string>('faviconUrl', '/branding/Bulwark_Favicon.svg'),
|
||||
appLogoLightUrl: configManager.get<string>('appLogoLightUrl', ''),
|
||||
appLogoDarkUrl: configManager.get<string>('appLogoDarkUrl', ''),
|
||||
loginLogoLightUrl: configManager.get<string>('loginLogoLightUrl', '/branding/Bulwark_Logo_Color.svg'),
|
||||
loginLogoDarkUrl: configManager.get<string>('loginLogoDarkUrl', '/branding/Bulwark_Logo_White.svg'),
|
||||
loginCompanyName: configManager.get<string>('loginCompanyName', ''),
|
||||
loginImprintUrl: configManager.get<string>('loginImprintUrl', ''),
|
||||
loginPrivacyPolicyUrl: configManager.get<string>('loginPrivacyPolicyUrl', ''),
|
||||
loginWebsiteUrl: configManager.get<string>('loginWebsiteUrl', ''),
|
||||
demoMode: configManager.get<boolean>('demoMode', false),
|
||||
allowCustomJmapEndpoint: configManager.get<boolean>('allowCustomJmapEndpoint', false),
|
||||
jmapServers: redactJmapServers(parseJmapServers(configManager.get<unknown>('jmapServers', []))),
|
||||
jmapServerAutoPickByDomain: configManager.get<boolean>('jmapServerAutoPickByDomain', false),
|
||||
autoSsoEnabled: configManager.get<boolean>('autoSsoEnabled', false),
|
||||
embeddedMode: !!allowedFrameAncestors && allowedFrameAncestors !== "'none'",
|
||||
parentOrigin: configManager.get<string>('parentOrigin', ''),
|
||||
});
|
||||
return NextResponse.json(
|
||||
{
|
||||
appName,
|
||||
jmapServerUrl,
|
||||
oauthEnabled,
|
||||
oauthOnly,
|
||||
oauthClientId: configManager.get<string>('oauthClientId', ''),
|
||||
oauthIssuerUrl: configManager.get<string>('oauthIssuerUrl', ''),
|
||||
oauthScopes: getOauthScopes(),
|
||||
rememberMeEnabled: hasSessionSecret(),
|
||||
settingsSyncEnabled: configManager.get<boolean>('settingsSyncEnabled', false) && hasSessionSecret(),
|
||||
stalwartFeaturesEnabled,
|
||||
devMode: configManager.get<boolean>('devMode', false),
|
||||
faviconUrl: branded<string>('faviconUrl', '/branding/Bulwark_Favicon.svg'),
|
||||
appLogoLightUrl: branded<string>('appLogoLightUrl', ''),
|
||||
appLogoDarkUrl: branded<string>('appLogoDarkUrl', ''),
|
||||
loginLogoLightUrl: branded<string>('loginLogoLightUrl', '/branding/Bulwark_Logo_Color.svg'),
|
||||
loginLogoDarkUrl: branded<string>('loginLogoDarkUrl', '/branding/Bulwark_Logo_White.svg'),
|
||||
loginCompanyName: branded<string>('loginCompanyName', ''),
|
||||
loginImprintUrl: branded<string>('loginImprintUrl', ''),
|
||||
loginPrivacyPolicyUrl: branded<string>('loginPrivacyPolicyUrl', ''),
|
||||
loginWebsiteUrl: branded<string>('loginWebsiteUrl', ''),
|
||||
demoMode: configManager.get<boolean>('demoMode', false),
|
||||
allowCustomJmapEndpoint: configManager.get<boolean>('allowCustomJmapEndpoint', false),
|
||||
jmapServers: redactJmapServers(parseJmapServers(configManager.get<unknown>('jmapServers', []))),
|
||||
jmapServerAutoPickByDomain: configManager.get<boolean>('jmapServerAutoPickByDomain', false),
|
||||
autoSsoEnabled: configManager.get<boolean>('autoSsoEnabled', false),
|
||||
embeddedMode: !!allowedFrameAncestors && allowedFrameAncestors !== "'none'",
|
||||
parentOrigin: configManager.get<string>('parentOrigin', ''),
|
||||
},
|
||||
{
|
||||
// Branding varies by host, so any cache between us and the browser
|
||||
// must key its entry by the host headers we consulted.
|
||||
headers: { Vary: 'Host, X-Forwarded-Host' },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,11 @@ import path from 'node:path';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { getConfigDir } from '@/lib/admin/paths';
|
||||
import {
|
||||
matchDomainBranding,
|
||||
parseDomainBranding,
|
||||
pickRequestHost,
|
||||
} from '@/lib/admin/domain-branding';
|
||||
|
||||
const VALID_SIZES = new Set([192, 512]);
|
||||
|
||||
@@ -33,7 +38,7 @@ async function fetchSourceImage(iconUrl: string): Promise<Buffer> {
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
_req: NextRequest,
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ size: string }> }
|
||||
) {
|
||||
const { size: sizeParam } = await params;
|
||||
@@ -44,8 +49,15 @@ export async function GET(
|
||||
}
|
||||
|
||||
await configManager.ensureLoaded();
|
||||
const host = pickRequestHost(req);
|
||||
const domainOverrides = matchDomainBranding(
|
||||
host,
|
||||
parseDomainBranding(configManager.get<unknown>('domainBranding', [])),
|
||||
);
|
||||
const sources = configManager.getAllWithSources();
|
||||
const iconUrl =
|
||||
domainOverrides.pwaIconUrl ||
|
||||
domainOverrides.faviconUrl ||
|
||||
(sources.pwaIconUrl?.source !== 'default' ? (sources.pwaIconUrl?.value as string) : '') ||
|
||||
(sources.faviconUrl?.source !== 'default' ? (sources.faviconUrl?.value as string) : '');
|
||||
if (!iconUrl) {
|
||||
@@ -55,6 +67,7 @@ export async function GET(
|
||||
const pngHeaders = {
|
||||
'Content-Type': 'image/png',
|
||||
'Cache-Control': 'public, max-age=86400',
|
||||
Vary: 'Host, X-Forwarded-Host',
|
||||
};
|
||||
|
||||
const cacheKey = `${size}|${iconUrl}`;
|
||||
|
||||
+32
-10
@@ -1,5 +1,12 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
import { headers } from "next/headers";
|
||||
import { configManager } from "@/lib/admin/config-manager";
|
||||
import {
|
||||
matchDomainBranding,
|
||||
parseDomainBranding,
|
||||
pickRequestHost,
|
||||
type BrandingOverrideKey,
|
||||
} from "@/lib/admin/domain-branding";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -25,25 +32,40 @@ const withBase = (p: string) => `${BASE_PATH}${p}`;
|
||||
export default async function manifest(): Promise<ExtendedManifest> {
|
||||
await configManager.ensureLoaded();
|
||||
|
||||
const host = pickRequestHost(await headers());
|
||||
const domainOverrides = matchDomainBranding(
|
||||
host,
|
||||
parseDomainBranding(configManager.get<unknown>("domainBranding", [])),
|
||||
);
|
||||
const branded = <T,>(key: BrandingOverrideKey, fallback: T): T => {
|
||||
const override = domainOverrides[key];
|
||||
if (typeof override === "string" && override.length > 0) return override as T;
|
||||
return configManager.get<T>(key, fallback);
|
||||
};
|
||||
|
||||
const appName =
|
||||
configManager.get<string>("appName") ||
|
||||
branded<string>("appName", "") ||
|
||||
process.env.NEXT_PUBLIC_APP_NAME ||
|
||||
"Bulwark Webmail";
|
||||
|
||||
const shortName = configManager.get<string>("appShortName") || appName;
|
||||
const shortName = branded<string>("appShortName", "") || appName;
|
||||
const description =
|
||||
configManager.get<string>("appDescription") ||
|
||||
branded<string>("appDescription", "") ||
|
||||
"A modern webmail client built for Stalwart Mail Server";
|
||||
const themeColor = configManager.get<string>("pwaThemeColor") || "#ffffff";
|
||||
const backgroundColor = configManager.get<string>("pwaBackgroundColor") || "#ffffff";
|
||||
const themeColor = branded<string>("pwaThemeColor", "") || "#ffffff";
|
||||
const backgroundColor = branded<string>("pwaBackgroundColor", "") || "#ffffff";
|
||||
|
||||
// If pwaIconUrl or faviconUrl was explicitly configured (admin override or
|
||||
// env var), serve dynamically resized PNGs via /api/pwa-icon/[size].
|
||||
// Otherwise fall back to the static Bulwark PNGs - sources marked "default"
|
||||
// are the built-in placeholder paths and not real custom icons.
|
||||
// If pwaIconUrl or faviconUrl was explicitly configured (admin override,
|
||||
// env var, or per-domain override), serve dynamically resized PNGs via
|
||||
// /api/pwa-icon/[size]. Otherwise fall back to the static Bulwark PNGs -
|
||||
// sources marked "default" are the built-in placeholder paths and not
|
||||
// real custom icons.
|
||||
const sources = configManager.getAllWithSources();
|
||||
const hasCustomIcon =
|
||||
sources.pwaIconUrl?.source !== "default" || sources.faviconUrl?.source !== "default";
|
||||
!!domainOverrides.pwaIconUrl ||
|
||||
!!domainOverrides.faviconUrl ||
|
||||
sources.pwaIconUrl?.source !== "default" ||
|
||||
sources.faviconUrl?.source !== "default";
|
||||
|
||||
const icons: MetadataRoute.Manifest["icons"] = hasCustomIcon
|
||||
? [
|
||||
|
||||
@@ -228,10 +228,16 @@ export function EmailComposer({
|
||||
const initialSignatureIdentity = (initialCurrentIdentityForSig?.htmlSignature || initialCurrentIdentityForSig?.textSignature)
|
||||
? initialCurrentIdentityForSig
|
||||
: primaryIdentity;
|
||||
const hasInitialSignature = !!(initialSignatureIdentity?.htmlSignature || initialSignatureIdentity?.textSignature);
|
||||
const shouldEmbedSignatureAboveQuote =
|
||||
(mode === 'reply' || mode === 'replyAll' || mode === 'forward') &&
|
||||
signaturePosition === 'above_quote' &&
|
||||
!!(initialSignatureIdentity?.htmlSignature || initialSignatureIdentity?.textSignature);
|
||||
hasInitialSignature;
|
||||
// New-mail composes always embed the signature into the editor body so it's
|
||||
// editable/removable (the previous read-only preview below the editor was
|
||||
// never spec-correct - see #329). Compose mode also ignores any leftover
|
||||
// `replyTo` from a still-selected email; getInitialBody short-circuits below.
|
||||
const shouldEmbedSignatureInNewMail = mode === 'compose' && hasInitialSignature;
|
||||
|
||||
// Initialize with reply/forward data if provided
|
||||
const getInitialTo = () => {
|
||||
@@ -272,6 +278,14 @@ export function EmailComposer({
|
||||
if (plainTextMode) {
|
||||
// Plain text mode: produce plain text body with no HTML
|
||||
const prefix = initialDraftText || "";
|
||||
// Compose mode: ignore any leftover replyTo (e.g. a selected mail in the
|
||||
// viewer) and embed the signature directly into the body so it's
|
||||
// editable. Fixes #329 (A,B).
|
||||
if (mode === 'compose') {
|
||||
if (!shouldEmbedSignatureInNewMail) return prefix;
|
||||
const sep = signatureSeparatorEnabled ? '\n\n-- \n' : '\n\n';
|
||||
return `${prefix}${sep}${getPlainTextSignature(initialSignatureIdentity)}`;
|
||||
}
|
||||
if (!replyTo?.body && !replyTo?.htmlBody) return prefix;
|
||||
|
||||
const date = replyTo.receivedAt ? formatDateTime(replyTo.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' }) : "";
|
||||
@@ -305,6 +319,18 @@ export function EmailComposer({
|
||||
}
|
||||
|
||||
const prefix = initialDraftText ? `<p>${initialDraftText.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>')}</p>` : "";
|
||||
// Compose mode: ignore any leftover replyTo and embed the signature
|
||||
// directly into the body so the user can edit/delete it. The leading
|
||||
// empty paragraph gives the cursor a place to land above the signature.
|
||||
if (mode === 'compose') {
|
||||
if (!shouldEmbedSignatureInNewMail) return prefix;
|
||||
const composePrefix = prefix || '<p></p>';
|
||||
const embedded = buildEmbeddedSignatureHtml(initialSignatureIdentity, {
|
||||
embed: true,
|
||||
separator: signatureSeparatorEnabled,
|
||||
});
|
||||
return `${composePrefix}${embedded}`;
|
||||
}
|
||||
if (!replyTo?.body && !replyTo?.htmlBody) return prefix;
|
||||
|
||||
const date = replyTo.receivedAt ? formatDateTime(replyTo.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' }) : "";
|
||||
@@ -471,8 +497,11 @@ export function EmailComposer({
|
||||
if (!editor) return;
|
||||
if (!identityChanged && !separatorChanged) return;
|
||||
if (plainTextMode) return;
|
||||
if (mode !== 'reply' && mode !== 'replyAll' && mode !== 'forward') return;
|
||||
if (signaturePosition !== 'above_quote') return;
|
||||
// Replies/forwards only embed when configured for "above quote". Compose
|
||||
// always embeds (see getInitialBody), so swap on identity change there too.
|
||||
const isReplyLike = mode === 'reply' || mode === 'replyAll' || mode === 'forward';
|
||||
if (!isReplyLike && mode !== 'compose') return;
|
||||
if (isReplyLike && signaturePosition !== 'above_quote') return;
|
||||
|
||||
const currentHtml = editor.getHTML();
|
||||
const doc = new DOMParser().parseFromString(currentHtml, 'text/html');
|
||||
@@ -703,9 +732,16 @@ export function EmailComposer({
|
||||
// Ref to latest saveDraft for use in event handlers with stale closures
|
||||
const saveDraftRef = useRef<() => Promise<string | null>>(() => Promise.resolve(null));
|
||||
|
||||
// Set by the explicit close paths (clean close, save-and-close, discard) so
|
||||
// the unmount auto-save below doesn't fire and stash a stale pendingDraft
|
||||
// on the parent (#329 D). Without this, "Reply → Discard → New mail" would
|
||||
// open the next composer with the discarded reply's mode/replyTo.
|
||||
const explicitCloseRef = useRef(false);
|
||||
|
||||
// Auto-save state on unmount (when user navigates away without explicitly closing)
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (explicitCloseRef.current) return;
|
||||
if (onSaveState && isDirtyRef.current) {
|
||||
const s = stateRef.current;
|
||||
onSaveState({
|
||||
@@ -1372,12 +1408,14 @@ export function EmailComposer({
|
||||
const envelopeMailFrom = overrideActive ? identityFromEmail : undefined;
|
||||
|
||||
// Body is already HTML from the rich text editor (or plain text in plain text mode).
|
||||
// When "above quote" mode is configured for replies/forwards, the signature
|
||||
// was embedded into the body during init (see getInitialBody) so the
|
||||
// trailing append must be skipped to avoid duplicating it.
|
||||
// The signature is embedded into the body during init for compose mode
|
||||
// (when the initial identity had a signature) and for above-quote
|
||||
// replies/forwards - skip the trailing append in those cases so we don't
|
||||
// duplicate it.
|
||||
const signatureAlreadyInBody =
|
||||
(mode === 'reply' || mode === 'replyAll' || mode === 'forward') &&
|
||||
signaturePosition === 'above_quote';
|
||||
shouldEmbedSignatureInNewMail ||
|
||||
((mode === 'reply' || mode === 'replyAll' || mode === 'forward') &&
|
||||
signaturePosition === 'above_quote');
|
||||
|
||||
// Build HTML signature block (used only in rich text mode)
|
||||
const buildSignatureHtml = (): string => {
|
||||
@@ -1693,6 +1731,7 @@ export function EmailComposer({
|
||||
}, []);
|
||||
|
||||
const cleanClose = () => {
|
||||
explicitCloseRef.current = true;
|
||||
if (saveTimeoutRef.current) {
|
||||
clearTimeout(saveTimeoutRef.current);
|
||||
}
|
||||
@@ -1701,6 +1740,7 @@ export function EmailComposer({
|
||||
};
|
||||
|
||||
const handleSaveDraftAndClose = async () => {
|
||||
explicitCloseRef.current = true;
|
||||
setShowCloseDialog(false);
|
||||
if (saveTimeoutRef.current) {
|
||||
clearTimeout(saveTimeoutRef.current);
|
||||
@@ -1711,6 +1751,7 @@ export function EmailComposer({
|
||||
};
|
||||
|
||||
const handleDiscardAndClose = () => {
|
||||
explicitCloseRef.current = true;
|
||||
setShowCloseDialog(false);
|
||||
if (saveTimeoutRef.current) {
|
||||
clearTimeout(saveTimeoutRef.current);
|
||||
@@ -2107,8 +2148,9 @@ export function EmailComposer({
|
||||
)}
|
||||
|
||||
{/* Hide the visual signature preview when the signature has already been
|
||||
embedded into the body above the quote (otherwise it would appear twice). */}
|
||||
{((mode === 'reply' || mode === 'replyAll' || mode === 'forward') && signaturePosition === 'above_quote') ? null
|
||||
embedded into the body (compose, or above-quote replies). */}
|
||||
{(shouldEmbedSignatureInNewMail
|
||||
|| ((mode === 'reply' || mode === 'replyAll' || mode === 'forward') && signaturePosition === 'above_quote')) ? null
|
||||
: plainTextMode ? (
|
||||
getPlainTextSignature(signatureIdentity) ? (
|
||||
<div className="px-4 pb-3 text-sm leading-6 text-muted-foreground break-words whitespace-pre-wrap font-mono">
|
||||
|
||||
@@ -7,7 +7,7 @@ import { SettingsSection, SettingItem, ToggleSwitch, Select } from './settings-s
|
||||
import { playNotificationSound, NOTIFICATION_SOUNDS } from '@/lib/notification-sound';
|
||||
import type { NotificationSoundChoice } from '@/lib/notification-sound';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { CheckCircle2, Volume2, XCircle } from 'lucide-react';
|
||||
import { CheckCircle2, Lock, Volume2, XCircle } from 'lucide-react';
|
||||
import { usePolicyStore } from '@/stores/policy-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
@@ -40,16 +40,30 @@ export function NotificationSettings() {
|
||||
updateSetting,
|
||||
} = useSettingsStore();
|
||||
const { isSettingLocked, isSettingHidden } = usePolicyStore();
|
||||
const adminPushRelayUrl = usePolicyStore((s) => s.policy.pushRelayUrl);
|
||||
const pushRelayLocked = usePolicyStore((s) => s.policy.pushRelayUrlLocked) === true;
|
||||
const client = useAuthStore((s) => s.client);
|
||||
const username = useAuthStore((s) => s.username);
|
||||
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
|
||||
|
||||
const supported = typeof window !== 'undefined' && isWebPushSupported();
|
||||
const [relayUrl, setRelayUrl] = useState(DEFAULT_RELAY_BASE_URL);
|
||||
const adminUrl = (adminPushRelayUrl ?? '').trim();
|
||||
const [relayUrl, setRelayUrl] = useState(adminUrl || DEFAULT_RELAY_BASE_URL);
|
||||
const [pushStatus, setPushStatus] = useState<PushStatus>(
|
||||
supported ? { kind: 'idle' } : { kind: 'unsupported' },
|
||||
);
|
||||
|
||||
// Pull the admin-configured URL into local state when policy loads/changes.
|
||||
// When locked, the admin value always wins; when only set (not locked), use
|
||||
// it as the initial default but let the user override.
|
||||
useEffect(() => {
|
||||
if (pushRelayLocked && adminUrl) {
|
||||
setRelayUrl(adminUrl);
|
||||
} else if (adminUrl) {
|
||||
setRelayUrl((current) => (current === DEFAULT_RELAY_BASE_URL ? adminUrl : current));
|
||||
}
|
||||
}, [adminUrl, pushRelayLocked]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!supported) return;
|
||||
if (!client) return;
|
||||
@@ -125,12 +139,17 @@ export function NotificationSettings() {
|
||||
<SettingsSection title={t('push.title')} description={t('push.description')}>
|
||||
<div className="rounded-md border p-4 space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<label className="text-sm font-medium" htmlFor="push-relay-url">
|
||||
<label className="text-sm font-medium inline-flex items-center gap-1.5" htmlFor="push-relay-url">
|
||||
{t('push.relay_label')}
|
||||
{pushRelayLocked && (
|
||||
<Lock className="w-3 h-3 text-muted-foreground" aria-label={t('push.relay_locked')} />
|
||||
)}
|
||||
</label>
|
||||
<PushStatusBadge status={pushStatus} t={t} />
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t('push.relay_desc')}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{pushRelayLocked ? t('push.relay_locked_desc') : t('push.relay_desc')}
|
||||
</p>
|
||||
<input
|
||||
id="push-relay-url"
|
||||
type="url"
|
||||
@@ -140,7 +159,8 @@ export function NotificationSettings() {
|
||||
value={relayUrl}
|
||||
onChange={(e) => setRelayUrl(e.target.value)}
|
||||
placeholder={t('push.relay_placeholder')}
|
||||
disabled={busy || pushStatus.kind === 'unsupported'}
|
||||
disabled={busy || pushStatus.kind === 'unsupported' || pushRelayLocked}
|
||||
readOnly={pushRelayLocked}
|
||||
className="w-full rounded border bg-background px-3 py-2 text-sm disabled:opacity-50"
|
||||
/>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
|
||||
@@ -36,16 +36,29 @@ describe('config API route', () => {
|
||||
delete process.env.LOGIN_IMPRINT_URL;
|
||||
delete process.env.LOGIN_PRIVACY_POLICY_URL;
|
||||
delete process.env.LOGIN_WEBSITE_URL;
|
||||
delete process.env.DOMAIN_BRANDING;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
async function getConfig() {
|
||||
function mockRequest(headers: Record<string, string> = {}): unknown {
|
||||
const lc: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(headers)) lc[k.toLowerCase()] = v;
|
||||
return {
|
||||
headers: {
|
||||
get(name: string) {
|
||||
return lc[name.toLowerCase()] ?? null;
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function getConfig(headers?: Record<string, string>) {
|
||||
// Re-import to pick up env changes
|
||||
const { GET } = await import('@/app/api/config/route');
|
||||
const response = await GET();
|
||||
const response = await GET(mockRequest(headers) as Parameters<typeof GET>[0]);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
@@ -192,4 +205,70 @@ describe('config API route', () => {
|
||||
expect(config.appLogoLightUrl).toBe('/branding/my-logo.svg');
|
||||
expect(config.appLogoDarkUrl).toBe('/branding/my-logo-white.svg');
|
||||
});
|
||||
|
||||
describe('per-domain branding overrides', () => {
|
||||
it('applies overrides for the matching host', async () => {
|
||||
process.env.LOGIN_COMPANY_NAME = 'Default Co';
|
||||
process.env.LOGIN_WEBSITE_URL = 'https://default.example';
|
||||
process.env.DOMAIN_BRANDING = JSON.stringify([
|
||||
{
|
||||
host: 'mail1.example.com',
|
||||
loginCompanyName: 'Brand One',
|
||||
loginWebsiteUrl: 'https://one.example',
|
||||
},
|
||||
]);
|
||||
|
||||
const config = await getConfig({ host: 'mail1.example.com' });
|
||||
|
||||
expect(config.loginCompanyName).toBe('Brand One');
|
||||
expect(config.loginWebsiteUrl).toBe('https://one.example');
|
||||
});
|
||||
|
||||
it('falls through to the global value when the host has no entry', async () => {
|
||||
process.env.LOGIN_COMPANY_NAME = 'Default Co';
|
||||
process.env.DOMAIN_BRANDING = JSON.stringify([
|
||||
{ host: 'mail1.example.com', loginCompanyName: 'Brand One' },
|
||||
]);
|
||||
|
||||
const config = await getConfig({ host: 'unmapped.example.com' });
|
||||
|
||||
expect(config.loginCompanyName).toBe('Default Co');
|
||||
});
|
||||
|
||||
it('falls through field-by-field when the matching entry omits a field', async () => {
|
||||
process.env.LOGIN_COMPANY_NAME = 'Default Co';
|
||||
process.env.LOGIN_WEBSITE_URL = 'https://default.example';
|
||||
process.env.DOMAIN_BRANDING = JSON.stringify([
|
||||
{ host: 'mail1.example.com', loginCompanyName: 'Brand One' },
|
||||
]);
|
||||
|
||||
const config = await getConfig({ host: 'mail1.example.com' });
|
||||
|
||||
expect(config.loginCompanyName).toBe('Brand One');
|
||||
expect(config.loginWebsiteUrl).toBe('https://default.example');
|
||||
});
|
||||
|
||||
it('prefers X-Forwarded-Host over Host', async () => {
|
||||
process.env.DOMAIN_BRANDING = JSON.stringify([
|
||||
{ host: 'public.example.com', loginCompanyName: 'Public' },
|
||||
]);
|
||||
|
||||
const config = await getConfig({
|
||||
host: 'internal.example.com',
|
||||
'x-forwarded-host': 'public.example.com',
|
||||
});
|
||||
|
||||
expect(config.loginCompanyName).toBe('Public');
|
||||
});
|
||||
|
||||
it('strips the port from the host header before matching', async () => {
|
||||
process.env.DOMAIN_BRANDING = JSON.stringify([
|
||||
{ host: 'mail1.example.com', loginCompanyName: 'Brand One' },
|
||||
]);
|
||||
|
||||
const config = await getConfig({ host: 'mail1.example.com:8443' });
|
||||
|
||||
expect(config.loginCompanyName).toBe('Brand One');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
matchDomainBranding,
|
||||
parseDomainBranding,
|
||||
pickRequestHost,
|
||||
type DomainBrandingEntry,
|
||||
} from '@/lib/admin/domain-branding';
|
||||
|
||||
function mockHeaders(map: Record<string, string>): Headers {
|
||||
const lc: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(map)) lc[k.toLowerCase()] = v;
|
||||
return {
|
||||
get(name: string) {
|
||||
return lc[name.toLowerCase()] ?? null;
|
||||
},
|
||||
} as unknown as Headers;
|
||||
}
|
||||
|
||||
describe('parseDomainBranding', () => {
|
||||
it('returns [] for null/undefined/empty', () => {
|
||||
expect(parseDomainBranding(null)).toEqual([]);
|
||||
expect(parseDomainBranding(undefined)).toEqual([]);
|
||||
expect(parseDomainBranding('')).toEqual([]);
|
||||
expect(parseDomainBranding([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('parses a stringified JSON array', () => {
|
||||
const raw = JSON.stringify([{ host: 'mail.example.com', loginCompanyName: 'Acme' }]);
|
||||
expect(parseDomainBranding(raw)).toEqual([
|
||||
{ host: 'mail.example.com', loginCompanyName: 'Acme' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('lower-cases hosts and strips trailing dots', () => {
|
||||
expect(parseDomainBranding([{ host: 'Mail.Example.COM.' }])).toEqual([
|
||||
{ host: 'mail.example.com' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('accepts wildcard hosts', () => {
|
||||
expect(parseDomainBranding([{ host: '*.example.com', loginCompanyName: 'Wild' }])).toEqual([
|
||||
{ host: '*.example.com', loginCompanyName: 'Wild' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('drops entries with invalid hosts', () => {
|
||||
const out = parseDomainBranding([
|
||||
{ host: '' },
|
||||
{ host: 'has space.com' },
|
||||
{ host: 'foo..bar' },
|
||||
{ host: '*.*.example.com' }, // embedded wildcard not allowed
|
||||
{ host: 'good.example.com' },
|
||||
]);
|
||||
expect(out.map(e => e.host)).toEqual(['good.example.com']);
|
||||
});
|
||||
|
||||
it('drops duplicate hosts, keeping the first', () => {
|
||||
const out = parseDomainBranding([
|
||||
{ host: 'foo.com', loginCompanyName: 'First' },
|
||||
{ host: 'FOO.com', loginCompanyName: 'Second' },
|
||||
]);
|
||||
expect(out).toEqual([{ host: 'foo.com', loginCompanyName: 'First' }]);
|
||||
});
|
||||
|
||||
it('ignores non-string and empty-string override fields', () => {
|
||||
const out = parseDomainBranding([
|
||||
{
|
||||
host: 'foo.com',
|
||||
loginCompanyName: '',
|
||||
loginImprintUrl: 42,
|
||||
loginWebsiteUrl: 'https://foo.com',
|
||||
},
|
||||
]);
|
||||
expect(out).toEqual([{ host: 'foo.com', loginWebsiteUrl: 'https://foo.com' }]);
|
||||
});
|
||||
|
||||
it('ignores unknown fields', () => {
|
||||
const out = parseDomainBranding([
|
||||
{ host: 'foo.com', notARealField: 'x', loginCompanyName: 'OK' },
|
||||
]);
|
||||
expect(out).toEqual([{ host: 'foo.com', loginCompanyName: 'OK' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pickRequestHost', () => {
|
||||
it('returns null when no host headers are set', () => {
|
||||
expect(pickRequestHost(mockHeaders({}))).toBeNull();
|
||||
});
|
||||
|
||||
it('prefers X-Forwarded-Host over Host', () => {
|
||||
expect(pickRequestHost(mockHeaders({
|
||||
'x-forwarded-host': 'forwarded.example.com',
|
||||
host: 'origin.example.com',
|
||||
}))).toBe('forwarded.example.com');
|
||||
});
|
||||
|
||||
it('falls back to Host when X-Forwarded-Host is absent', () => {
|
||||
expect(pickRequestHost(mockHeaders({ host: 'origin.example.com' }))).toBe('origin.example.com');
|
||||
});
|
||||
|
||||
it('strips the port', () => {
|
||||
expect(pickRequestHost(mockHeaders({ host: 'example.com:8080' }))).toBe('example.com');
|
||||
});
|
||||
|
||||
it('takes the first entry of a comma-separated X-Forwarded-Host', () => {
|
||||
expect(pickRequestHost(mockHeaders({
|
||||
'x-forwarded-host': 'first.example.com, second.example.com',
|
||||
}))).toBe('first.example.com');
|
||||
});
|
||||
|
||||
it('lower-cases the result', () => {
|
||||
expect(pickRequestHost(mockHeaders({ host: 'EXAMPLE.com' }))).toBe('example.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('matchDomainBranding', () => {
|
||||
const entries: DomainBrandingEntry[] = [
|
||||
{ host: 'mail.example.com', loginCompanyName: 'Exact' },
|
||||
{ host: '*.example.com', loginCompanyName: 'Wildcard' },
|
||||
{ host: '*.dev.example.com', loginCompanyName: 'Specific Wildcard' },
|
||||
{ host: 'other.com', loginCompanyName: 'Other' },
|
||||
];
|
||||
|
||||
it('returns {} when host is null', () => {
|
||||
expect(matchDomainBranding(null, entries)).toEqual({});
|
||||
});
|
||||
|
||||
it('returns {} when no entries match', () => {
|
||||
expect(matchDomainBranding('unknown.org', entries)).toEqual({});
|
||||
});
|
||||
|
||||
it('prefers exact match over wildcard', () => {
|
||||
expect(matchDomainBranding('mail.example.com', entries).loginCompanyName).toBe('Exact');
|
||||
});
|
||||
|
||||
it('matches wildcards on subdomains', () => {
|
||||
expect(matchDomainBranding('foo.example.com', entries).loginCompanyName).toBe('Wildcard');
|
||||
});
|
||||
|
||||
it('prefers the longest wildcard suffix', () => {
|
||||
expect(matchDomainBranding('app.dev.example.com', entries).loginCompanyName).toBe('Specific Wildcard');
|
||||
});
|
||||
|
||||
it('does not match the wildcard host against the apex domain', () => {
|
||||
expect(matchDomainBranding('example.com', entries)).toEqual({});
|
||||
});
|
||||
|
||||
it('is case-insensitive on the request host', () => {
|
||||
expect(matchDomainBranding('Mail.Example.COM', entries).loginCompanyName).toBe('Exact');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Per-domain branding overrides: schema, parsing, host extraction, and match.
|
||||
*
|
||||
* The webmail can be served on multiple hostnames (e.g. mail1.example.com,
|
||||
* mail2.other.com). Each hostname can override a subset of branding fields;
|
||||
* unset fields fall back to the global admin/env/default value.
|
||||
*/
|
||||
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
/** Config keys that can be overridden per domain. */
|
||||
export const BRANDING_OVERRIDE_KEYS = [
|
||||
'appName',
|
||||
'appShortName',
|
||||
'appDescription',
|
||||
'faviconUrl',
|
||||
'pwaIconUrl',
|
||||
'pwaThemeColor',
|
||||
'pwaBackgroundColor',
|
||||
'appLogoLightUrl',
|
||||
'appLogoDarkUrl',
|
||||
'loginLogoLightUrl',
|
||||
'loginLogoDarkUrl',
|
||||
'loginCompanyName',
|
||||
'loginImprintUrl',
|
||||
'loginPrivacyPolicyUrl',
|
||||
'loginWebsiteUrl',
|
||||
] as const;
|
||||
|
||||
export type BrandingOverrideKey = (typeof BRANDING_OVERRIDE_KEYS)[number];
|
||||
|
||||
export interface DomainBrandingEntry {
|
||||
/**
|
||||
* Hostname this entry applies to. Either an exact host like
|
||||
* "mail.example.com" or a wildcard like "*.example.com" (matches any
|
||||
* direct or deeper subdomain). Case-insensitive; trailing dots are
|
||||
* stripped on parse.
|
||||
*/
|
||||
host: string;
|
||||
appName?: string;
|
||||
appShortName?: string;
|
||||
appDescription?: string;
|
||||
faviconUrl?: string;
|
||||
pwaIconUrl?: string;
|
||||
pwaThemeColor?: string;
|
||||
pwaBackgroundColor?: string;
|
||||
appLogoLightUrl?: string;
|
||||
appLogoDarkUrl?: string;
|
||||
loginLogoLightUrl?: string;
|
||||
loginLogoDarkUrl?: string;
|
||||
loginCompanyName?: string;
|
||||
loginImprintUrl?: string;
|
||||
loginPrivacyPolicyUrl?: string;
|
||||
loginWebsiteUrl?: string;
|
||||
}
|
||||
|
||||
// Accepts plain hostnames (foo, foo.bar, foo.bar.baz) and one-level wildcards
|
||||
// at the leftmost label (*.example.com). Rejects IPs, scheme/path/userinfo,
|
||||
// and embedded wildcards.
|
||||
const HOST_RE = /^(\*\.)?[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/;
|
||||
|
||||
function normalizeHost(host: string): string {
|
||||
return host.trim().toLowerCase().replace(/\.+$/, '');
|
||||
}
|
||||
|
||||
/** Parse the raw config value (array of entries, or string-JSON). Invalid entries are dropped. */
|
||||
export function parseDomainBranding(raw: unknown): DomainBrandingEntry[] {
|
||||
if (!raw) return [];
|
||||
let value = raw;
|
||||
if (typeof value === 'string') {
|
||||
if (!value.trim()) return [];
|
||||
try {
|
||||
value = JSON.parse(value);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
if (!Array.isArray(value)) return [];
|
||||
|
||||
const seen = new Set<string>();
|
||||
const out: DomainBrandingEntry[] = [];
|
||||
for (const item of value) {
|
||||
if (!item || typeof item !== 'object') continue;
|
||||
const rec = item as Record<string, unknown>;
|
||||
const rawHost = typeof rec.host === 'string' ? rec.host : '';
|
||||
const host = normalizeHost(rawHost);
|
||||
if (!host || !HOST_RE.test(host)) continue;
|
||||
if (seen.has(host)) continue;
|
||||
seen.add(host);
|
||||
|
||||
const entry: DomainBrandingEntry = { host };
|
||||
const writable = entry as unknown as Record<string, string>;
|
||||
for (const key of BRANDING_OVERRIDE_KEYS) {
|
||||
const v = rec[key];
|
||||
if (typeof v === 'string' && v.length > 0) {
|
||||
writable[key] = v;
|
||||
}
|
||||
}
|
||||
out.push(entry);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
type HeadersLike = Headers | { get(name: string): string | null };
|
||||
|
||||
/**
|
||||
* Pick the request's host, preferring X-Forwarded-Host (first entry if
|
||||
* comma-separated) over Host. Strips the port. Returns null when no usable
|
||||
* host header is set.
|
||||
*/
|
||||
export function pickRequestHost(headersOrReq: NextRequest | HeadersLike): string | null {
|
||||
const headers: HeadersLike = 'headers' in headersOrReq ? (headersOrReq as NextRequest).headers : headersOrReq;
|
||||
const raw = headers.get('x-forwarded-host') || headers.get('host');
|
||||
if (!raw) return null;
|
||||
const first = raw.split(',')[0]?.trim();
|
||||
if (!first) return null;
|
||||
return normalizeHost(first.split(':')[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the entry whose host matches `host`. Exact match always wins; among
|
||||
* wildcards the longest (most-specific) suffix wins. Returns {} when no
|
||||
* entry matches.
|
||||
*/
|
||||
export function matchDomainBranding(
|
||||
host: string | null,
|
||||
entries: DomainBrandingEntry[],
|
||||
): Partial<DomainBrandingEntry> {
|
||||
if (!host || entries.length === 0) return {};
|
||||
const lower = normalizeHost(host);
|
||||
let wildcardMatch: DomainBrandingEntry | undefined;
|
||||
for (const entry of entries) {
|
||||
if (entry.host === lower) return entry;
|
||||
if (entry.host.startsWith('*.')) {
|
||||
const suffix = entry.host.slice(1); // ".example.com"
|
||||
if (lower.endsWith(suffix) && lower.length > suffix.length) {
|
||||
if (!wildcardMatch || entry.host.length > wildcardMatch.host.length) {
|
||||
wildcardMatch = entry;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return wildcardMatch ?? {};
|
||||
}
|
||||
@@ -106,6 +106,10 @@ export interface SettingsPolicy {
|
||||
approvedPlugins: string[];
|
||||
/** Theme IDs that are force-enabled (users cannot deactivate) */
|
||||
forceEnabledThemes: string[];
|
||||
/** Web Push relay base URL shown to users. Empty means the built-in default. */
|
||||
pushRelayUrl?: string;
|
||||
/** When true, users cannot change pushRelayUrl in notification settings. */
|
||||
pushRelayUrlLocked?: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_POLICY: SettingsPolicy = {
|
||||
@@ -116,6 +120,8 @@ export const DEFAULT_POLICY: SettingsPolicy = {
|
||||
forceEnabledPlugins: [],
|
||||
approvedPlugins: [],
|
||||
forceEnabledThemes: [],
|
||||
pushRelayUrl: '',
|
||||
pushRelayUrlLocked: false,
|
||||
};
|
||||
|
||||
export interface AuditEntry {
|
||||
@@ -157,6 +163,7 @@ export const CONFIG_ENV_MAP: Record<string, { envVar: string; fileEnvVar?: strin
|
||||
allowCustomJmapEndpoint: { envVar: 'ALLOW_CUSTOM_JMAP_ENDPOINT', type: 'boolean', defaultValue: false },
|
||||
jmapServers: { envVar: 'JMAP_SERVERS', type: 'json', defaultValue: [] },
|
||||
jmapServerAutoPickByDomain: { envVar: 'JMAP_SERVER_AUTO_PICK_BY_DOMAIN', type: 'boolean', defaultValue: false },
|
||||
domainBranding: { envVar: 'DOMAIN_BRANDING', type: 'json', defaultValue: [] },
|
||||
autoSsoEnabled: { envVar: 'AUTO_SSO_ENABLED', type: 'boolean', defaultValue: false },
|
||||
cookieSameSite: { envVar: 'COOKIE_SAME_SITE', type: 'enum', defaultValue: 'lax', enumValues: ['lax', 'strict', 'none'] },
|
||||
allowedFrameAncestors: { envVar: 'ALLOWED_FRAME_ANCESTORS', type: 'string', defaultValue: '' },
|
||||
|
||||
@@ -955,6 +955,8 @@
|
||||
"reenable": "Znovu zaregistrovat",
|
||||
"relay_desc": "Výchozí je hostovaný relay Bulwark. Změňte pouze pokud používáte vlastní hosting.",
|
||||
"relay_label": "Push relay",
|
||||
"relay_locked": "Nastaveno administrátorem",
|
||||
"relay_locked_desc": "URL push relay byla nastavena administrátorem a nelze ji změnit.",
|
||||
"relay_placeholder": "https://notifications.relay.example.com",
|
||||
"status_active": "Aktivní na tomto zařízení",
|
||||
"status_busy": "Pracuji…",
|
||||
@@ -1010,6 +1012,7 @@
|
||||
"label": "Akce odstranění",
|
||||
"description": "Co se má stát po odstranění e-mailu",
|
||||
"trash": "Přesunout do Koše",
|
||||
"trash_and_read": "Přesunout do Koše a označit jako přečtené",
|
||||
"permanent": "Trvale odstranit",
|
||||
"warning": "E-maily budou trvale odstraněny a nebude možné je obnovit. Tato akce je nevratná."
|
||||
},
|
||||
|
||||
@@ -927,6 +927,8 @@
|
||||
"description": "Modtag systemnotifikationer for ny mail, når dette site er lukket. Leveres via Bulwark push-relæet; relæet ser aldrig mail-indhold.",
|
||||
"relay_label": "Push-relæ",
|
||||
"relay_desc": "Som standard det hosted Bulwark-relæ. Ændr kun, hvis du selv-host'er.",
|
||||
"relay_locked": "Indstillet af administrator",
|
||||
"relay_locked_desc": "Push-relæ-URL'en er indstillet af din administrator og kan ikke ændres.",
|
||||
"relay_placeholder": "https://notifikationer.relay.eksempel.dk",
|
||||
"status_active": "Aktiv på denne enhed",
|
||||
"status_inactive": "Ikke aktiveret på denne enhed",
|
||||
@@ -1011,6 +1013,7 @@
|
||||
"label": "Slet-handling",
|
||||
"description": "Hvad der sker, når du sletter en e-mail",
|
||||
"trash": "Flyt til papirkurv",
|
||||
"trash_and_read": "Flyt til papirkurv og marker som læst",
|
||||
"permanent": "Slet permanent",
|
||||
"warning": "E-mails vil blive slettet permanent og kan ikke gendannes. Denne handling er irreversibel."
|
||||
},
|
||||
|
||||
@@ -955,6 +955,8 @@
|
||||
"reenable": "Neu registrieren",
|
||||
"relay_desc": "Standardmäßig wird das gehostete Bulwark-Relay verwendet. Nur ändern, wenn Sie selbst hosten.",
|
||||
"relay_label": "Push-Relay",
|
||||
"relay_locked": "Vom Administrator festgelegt",
|
||||
"relay_locked_desc": "Die Push-Relay-URL wurde von Ihrem Administrator festgelegt und kann nicht geändert werden.",
|
||||
"relay_placeholder": "https://notifications.relay.example.com",
|
||||
"status_active": "Auf diesem Gerät aktiv",
|
||||
"status_busy": "Wird verarbeitet…",
|
||||
@@ -1010,6 +1012,7 @@
|
||||
"label": "Löschaktion",
|
||||
"description": "Was passiert, wenn Sie eine E-Mail löschen",
|
||||
"trash": "In Papierkorb verschieben",
|
||||
"trash_and_read": "In Papierkorb verschieben und als gelesen markieren",
|
||||
"permanent": "Dauerhaft löschen",
|
||||
"warning": "E-Mails werden dauerhaft gelöscht und können nicht wiederhergestellt werden. Diese Aktion ist unwiderruflich."
|
||||
},
|
||||
|
||||
@@ -927,6 +927,8 @@
|
||||
"description": "Receive system notifications for new mail when this site is closed. Delivered via the Bulwark push relay; the relay never sees mail content.",
|
||||
"relay_label": "Push relay",
|
||||
"relay_desc": "Defaults to the hosted Bulwark relay. Change only if you self-host.",
|
||||
"relay_locked": "Set by administrator",
|
||||
"relay_locked_desc": "The push relay URL has been set by your administrator and cannot be changed.",
|
||||
"relay_placeholder": "https://notifications.relay.example.com",
|
||||
"status_active": "Active on this device",
|
||||
"status_inactive": "Not enabled on this device",
|
||||
|
||||
@@ -955,6 +955,8 @@
|
||||
"reenable": "Volver a registrar",
|
||||
"relay_desc": "Usa el relay alojado de Bulwark de forma predeterminada. Cámbialo solo si te alojas tú mismo.",
|
||||
"relay_label": "Relay push",
|
||||
"relay_locked": "Establecido por el administrador",
|
||||
"relay_locked_desc": "La URL del relay push ha sido establecida por tu administrador y no se puede cambiar.",
|
||||
"relay_placeholder": "https://notifications.relay.example.com",
|
||||
"status_active": "Activo en este dispositivo",
|
||||
"status_busy": "Trabajando…",
|
||||
@@ -1010,6 +1012,7 @@
|
||||
"label": "Acción al Eliminar",
|
||||
"description": "Qué sucede cuando elimina un correo",
|
||||
"trash": "Mover a Papelera",
|
||||
"trash_and_read": "Mover a Papelera y marcar como leído",
|
||||
"permanent": "Eliminar Permanentemente",
|
||||
"warning": "Los correos se eliminarán permanentemente y no se podrán recuperar. Esta acción es irreversible."
|
||||
},
|
||||
|
||||
@@ -955,6 +955,8 @@
|
||||
"reenable": "Réenregistrer",
|
||||
"relay_desc": "Utilise par défaut le relais Bulwark hébergé. Ne le changez que si vous l'hébergez vous-même.",
|
||||
"relay_label": "Relais push",
|
||||
"relay_locked": "Défini par l'administrateur",
|
||||
"relay_locked_desc": "L'URL du relais push a été définie par votre administrateur et ne peut pas être modifiée.",
|
||||
"relay_placeholder": "https://notifications.relay.example.com",
|
||||
"status_active": "Actif sur cet appareil",
|
||||
"status_busy": "Traitement en cours…",
|
||||
@@ -1010,6 +1012,7 @@
|
||||
"label": "Action de suppression",
|
||||
"description": "Que se passe-t-il quand vous supprimez un email",
|
||||
"trash": "Déplacer vers la corbeille",
|
||||
"trash_and_read": "Déplacer vers la corbeille et marquer comme lu",
|
||||
"permanent": "Supprimer définitivement",
|
||||
"warning": "Les emails seront supprimés définitivement et ne pourront pas être récupérés. Cette action est irréversible."
|
||||
},
|
||||
|
||||
@@ -955,6 +955,8 @@
|
||||
"reenable": "Registra di nuovo",
|
||||
"relay_desc": "Per impostazione predefinita usa il relay Bulwark ospitato. Cambialo solo se ospiti in autonomia.",
|
||||
"relay_label": "Relay push",
|
||||
"relay_locked": "Impostato dall'amministratore",
|
||||
"relay_locked_desc": "L'URL del relay push è stato impostato dall'amministratore e non può essere modificato.",
|
||||
"relay_placeholder": "https://notifications.relay.example.com",
|
||||
"status_active": "Attivo su questo dispositivo",
|
||||
"status_busy": "In elaborazione…",
|
||||
@@ -1010,6 +1012,7 @@
|
||||
"label": "Azione di eliminazione",
|
||||
"description": "Cosa accade quando elimini un messaggio",
|
||||
"trash": "Sposta nel cestino",
|
||||
"trash_and_read": "Sposta nel cestino e segna come letto",
|
||||
"permanent": "Elimina definitivamente",
|
||||
"warning": "I messaggi verranno eliminati definitivamente e non potranno essere recuperati. Questa azione è irreversibile."
|
||||
},
|
||||
|
||||
@@ -955,6 +955,8 @@
|
||||
"reenable": "再登録",
|
||||
"relay_desc": "デフォルトはホストされた Bulwark リレーです。セルフホストする場合のみ変更してください。",
|
||||
"relay_label": "プッシュリレー",
|
||||
"relay_locked": "管理者が設定",
|
||||
"relay_locked_desc": "プッシュリレーの URL は管理者によって設定されており、変更できません。",
|
||||
"relay_placeholder": "https://notifications.relay.example.com",
|
||||
"status_active": "このデバイスで有効",
|
||||
"status_busy": "処理中…",
|
||||
@@ -1010,6 +1012,7 @@
|
||||
"label": "削除動作",
|
||||
"description": "メール削除時の動作",
|
||||
"trash": "ゴミ箱に移動",
|
||||
"trash_and_read": "ゴミ箱に移動して既読にする",
|
||||
"permanent": "完全に削除",
|
||||
"warning": "メールは完全に削除され、復元できません。この操作は元に戻せません。"
|
||||
},
|
||||
|
||||
@@ -955,6 +955,8 @@
|
||||
"reenable": "다시 등록",
|
||||
"relay_desc": "기본값은 호스팅된 Bulwark 릴레이입니다. 셀프 호스팅 시에만 변경하세요.",
|
||||
"relay_label": "푸시 릴레이",
|
||||
"relay_locked": "관리자에 의해 설정됨",
|
||||
"relay_locked_desc": "푸시 릴레이 URL은 관리자가 설정했으며 변경할 수 없어요.",
|
||||
"relay_placeholder": "https://notifications.relay.example.com",
|
||||
"status_active": "이 기기에서 활성",
|
||||
"status_busy": "처리 중…",
|
||||
@@ -1010,6 +1012,7 @@
|
||||
"label": "삭제 동작",
|
||||
"description": "메일을 삭제할 때의 동작을 선택해 주세요",
|
||||
"trash": "휴지통으로 이동",
|
||||
"trash_and_read": "휴지통으로 이동하고 읽음으로 표시",
|
||||
"permanent": "영구 삭제",
|
||||
"warning": "메일이 영구적으로 삭제되며 복구할 수 없어요. 주의해서 사용해 주세요."
|
||||
},
|
||||
|
||||
@@ -955,6 +955,8 @@
|
||||
"reenable": "Reģistrēt vēlreiz",
|
||||
"relay_desc": "Pēc noklusējuma izmanto izmitināto Bulwark releju. Mainiet tikai tad, ja izmitināt pats.",
|
||||
"relay_label": "Push relejs",
|
||||
"relay_locked": "Iestatījis administrators",
|
||||
"relay_locked_desc": "Push releja URL ir iestatījis administrators, un to nevar mainīt.",
|
||||
"relay_placeholder": "https://notifications.relay.example.com",
|
||||
"status_active": "Aktīvs šajā ierīcē",
|
||||
"status_busy": "Notiek darbs…",
|
||||
@@ -1010,6 +1012,7 @@
|
||||
"label": "Darbība dzēšot",
|
||||
"description": "Kas notiek, izdzēšot vēstuli",
|
||||
"trash": "Pārvietot uz atkritni",
|
||||
"trash_and_read": "Pārvietot uz atkritni un atzīmēt kā lasītu",
|
||||
"permanent": "Dzēst neatgriezeniski",
|
||||
"warning": "Vēstules tiks dzēstas neatgriezeniski. Šī darbība nav atceļama."
|
||||
},
|
||||
|
||||
@@ -955,6 +955,8 @@
|
||||
"reenable": "Opnieuw registreren",
|
||||
"relay_desc": "Gebruikt standaard de gehoste Bulwark-relay. Wijzig alleen als je zelf hostt.",
|
||||
"relay_label": "Push-relay",
|
||||
"relay_locked": "Ingesteld door beheerder",
|
||||
"relay_locked_desc": "De push-relay-URL is ingesteld door je beheerder en kan niet worden gewijzigd.",
|
||||
"relay_placeholder": "https://notifications.relay.example.com",
|
||||
"status_active": "Actief op dit apparaat",
|
||||
"status_busy": "Bezig…",
|
||||
@@ -1010,6 +1012,7 @@
|
||||
"label": "Verwijderactie",
|
||||
"description": "Wat er gebeurt wanneer je een e-mail verwijdert",
|
||||
"trash": "Verplaatsen naar prullenbak",
|
||||
"trash_and_read": "Verplaatsen naar prullenbak en als gelezen markeren",
|
||||
"permanent": "Permanent verwijderen",
|
||||
"warning": "E-mails worden permanent verwijderd en kunnen niet worden hersteld. Deze actie is onomkeerbaar."
|
||||
},
|
||||
|
||||
@@ -955,6 +955,8 @@
|
||||
"reenable": "Zarejestruj ponownie",
|
||||
"relay_desc": "Domyślnie używa hostowanego przekaźnika Bulwark. Zmień tylko jeśli hostujesz samodzielnie.",
|
||||
"relay_label": "Przekaźnik push",
|
||||
"relay_locked": "Ustawione przez administratora",
|
||||
"relay_locked_desc": "Adres URL przekaźnika push został ustawiony przez administratora i nie można go zmienić.",
|
||||
"relay_placeholder": "https://notifications.relay.example.com",
|
||||
"status_active": "Aktywne na tym urządzeniu",
|
||||
"status_busy": "Przetwarzanie…",
|
||||
@@ -1010,6 +1012,7 @@
|
||||
"label": "Akcja usuwania",
|
||||
"description": "Co ma się stać po usunięciu wiadomości e-mail",
|
||||
"trash": "Przenieś do kosza",
|
||||
"trash_and_read": "Przenieś do kosza i oznacz jako przeczytane",
|
||||
"permanent": "Usuń trwale",
|
||||
"warning": "Wiadomości e-mail zostaną trwale usunięte i nie będzie można ich odzyskać. Ta akcja jest nieodwracalna."
|
||||
},
|
||||
|
||||
@@ -955,6 +955,8 @@
|
||||
"reenable": "Registrar novamente",
|
||||
"relay_desc": "Usa o relay Bulwark hospedado por padrão. Altere apenas se você hospedar.",
|
||||
"relay_label": "Relay push",
|
||||
"relay_locked": "Definido pelo administrador",
|
||||
"relay_locked_desc": "A URL do relay push foi definida pelo seu administrador e não pode ser alterada.",
|
||||
"relay_placeholder": "https://notifications.relay.example.com",
|
||||
"status_active": "Ativo neste dispositivo",
|
||||
"status_busy": "Processando…",
|
||||
@@ -1010,6 +1012,7 @@
|
||||
"label": "Ação de Exclusão",
|
||||
"description": "O que acontece quando você exclui um e-mail",
|
||||
"trash": "Mover para Lixeira",
|
||||
"trash_and_read": "Mover para Lixeira e marcar como lida",
|
||||
"permanent": "Excluir Permanentemente",
|
||||
"warning": "Os e-mails serão excluídos permanentemente e não poderão ser recuperados. Esta ação é irreversível."
|
||||
},
|
||||
|
||||
@@ -955,6 +955,8 @@
|
||||
"reenable": "Перерегистрировать",
|
||||
"relay_desc": "По умолчанию используется размещённый релей Bulwark. Меняйте только если хостите самостоятельно.",
|
||||
"relay_label": "Push-релей",
|
||||
"relay_locked": "Установлено администратором",
|
||||
"relay_locked_desc": "URL push-релея установлен вашим администратором и не может быть изменён.",
|
||||
"relay_placeholder": "https://notifications.relay.example.com",
|
||||
"status_active": "Активно на этом устройстве",
|
||||
"status_busy": "Выполняется…",
|
||||
@@ -1010,6 +1012,7 @@
|
||||
"label": "Действие при удалении",
|
||||
"description": "Что происходит при удалении письма",
|
||||
"trash": "Переместить в корзину",
|
||||
"trash_and_read": "Переместить в корзину и пометить как прочитанное",
|
||||
"permanent": "Удалить навсегда",
|
||||
"warning": "Письма будут удалены навсегда и не могут быть восстановлены. Это действие необратимо."
|
||||
},
|
||||
|
||||
@@ -955,6 +955,8 @@
|
||||
"reenable": "Yeniden kaydet",
|
||||
"relay_desc": "Varsayılan olarak barındırılan Bulwark rölesini kullanır. Yalnızca kendiniz barındırıyorsanız değiştirin.",
|
||||
"relay_label": "Push röle",
|
||||
"relay_locked": "Yönetici tarafından ayarlandı",
|
||||
"relay_locked_desc": "Push röle URL'si yöneticiniz tarafından ayarlandı ve değiştirilemez.",
|
||||
"relay_placeholder": "https://notifications.relay.example.com",
|
||||
"status_active": "Bu cihazda etkin",
|
||||
"status_busy": "Çalışıyor…",
|
||||
@@ -1010,6 +1012,7 @@
|
||||
"label": "Silme İşlemi",
|
||||
"description": "Bir e-postayı sildiğinizde ne olacağı",
|
||||
"trash": "Çöp Kutusuna Taşı",
|
||||
"trash_and_read": "Çöp Kutusuna Taşı ve okundu olarak işaretle",
|
||||
"permanent": "Kalıcı Olarak Sil",
|
||||
"warning": "E-postalar kalıcı olarak silinecek ve kurtarılamayacak. Bu işlem geri alınamaz."
|
||||
},
|
||||
|
||||
@@ -955,6 +955,8 @@
|
||||
"reenable": "Перереєструвати",
|
||||
"relay_desc": "Типово використовується розміщене реле Bulwark. Змінюйте лише, якщо хостите самостійно.",
|
||||
"relay_label": "Push-реле",
|
||||
"relay_locked": "Встановлено адміністратором",
|
||||
"relay_locked_desc": "URL push-реле встановлено вашим адміністратором і його не можна змінити.",
|
||||
"relay_placeholder": "https://notifications.relay.example.com",
|
||||
"status_active": "Активно на цьому пристрої",
|
||||
"status_busy": "Виконується…",
|
||||
@@ -1010,6 +1012,7 @@
|
||||
"label": "Видалити дію",
|
||||
"description": "Що відбувається, коли ви видаляєте електронний лист",
|
||||
"trash": "Перемістити в кошик",
|
||||
"trash_and_read": "Перемістити в кошик і позначити як прочитане",
|
||||
"permanent": "Видалити назавжди",
|
||||
"warning": "Електронні листи буде видалено назавжди, і їх неможливо буде відновити. Ця дія незворотна."
|
||||
},
|
||||
|
||||
@@ -955,6 +955,8 @@
|
||||
"reenable": "重新注册",
|
||||
"relay_desc": "默认使用托管的 Bulwark 中继。仅当您自托管时才更改。",
|
||||
"relay_label": "推送中继",
|
||||
"relay_locked": "由管理员设置",
|
||||
"relay_locked_desc": "推送中继 URL 已由您的管理员设置,无法更改。",
|
||||
"relay_placeholder": "https://notifications.relay.example.com",
|
||||
"status_active": "在此设备上活动",
|
||||
"status_busy": "工作中…",
|
||||
@@ -1010,6 +1012,7 @@
|
||||
"label": "删除操作",
|
||||
"description": "删除邮件后的行为",
|
||||
"trash": "移至已删除",
|
||||
"trash_and_read": "移至已删除并标记为已读",
|
||||
"permanent": "永久删除",
|
||||
"warning": "邮件将被永久删除且无法恢复。此操作是不可逆转的。"
|
||||
},
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "bulwark-webmail",
|
||||
"version": "1.7.1",
|
||||
"version": "1.7.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "bulwark-webmail",
|
||||
"version": "1.7.1",
|
||||
"version": "1.7.2",
|
||||
"license": "AGPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@tanstack/react-virtual": "^3.13.24",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bulwark-webmail",
|
||||
"version": "1.7.1",
|
||||
"version": "1.7.2",
|
||||
"description": "Bulwark Webmail - a modern webmail client built for Stalwart Mail Server",
|
||||
"author": "Bulwark Webmail <bulwark@rbm.systems>",
|
||||
"license": "AGPL-3.0-only",
|
||||
|
||||
Reference in New Issue
Block a user