feat: multi-server JMAP support

This commit is contained in:
Linus Rath
2026-05-06 17:33:55 +02:00
parent 43475945bf
commit d3d79be64c
20 changed files with 818 additions and 108 deletions
+272
View File
@@ -0,0 +1,272 @@
'use client';
import { useEffect, useState } from 'react';
import { Plus, Trash2, RotateCcw, ChevronDown, ChevronRight } from 'lucide-react';
import type { JmapServerEntry } from '@/lib/admin/jmap-servers';
interface Props {
value: JmapServerEntry[];
source?: string;
onChange: (next: JmapServerEntry[]) => void;
onRevert: () => void;
}
interface RowDraft {
id: string;
label: string;
url: string;
domains: string;
oauthClientId: string;
oauthIssuerUrl: string;
oauthClientSecret: string;
oauthExpanded: boolean;
}
function entryToDraft(e: JmapServerEntry): RowDraft {
return {
id: e.id,
label: e.label,
url: e.url,
domains: (e.domains ?? []).join(', '),
oauthClientId: e.oauth?.clientId ?? '',
oauthIssuerUrl: e.oauth?.issuerUrl ?? '',
oauthClientSecret: e.oauth?.clientSecret ?? '',
oauthExpanded: !!(e.oauth && (e.oauth.clientId || e.oauth.issuerUrl || e.oauth.clientSecret)),
};
}
function draftToEntry(d: RowDraft): JmapServerEntry | null {
const id = d.id.trim();
const url = d.url.trim().replace(/\/+$/, '');
if (!id || !url) return null;
const domains = d.domains
.split(/[,\s]+/)
.map((s) => s.trim().toLowerCase())
.filter(Boolean);
const clientId = d.oauthClientId.trim();
const issuerUrl = d.oauthIssuerUrl.trim().replace(/\/+$/, '');
const clientSecret = d.oauthClientSecret;
const oauth = clientId || issuerUrl || clientSecret
? {
...(clientId ? { clientId } : {}),
...(issuerUrl ? { issuerUrl } : {}),
...(clientSecret ? { clientSecret } : {}),
}
: undefined;
return {
id,
label: d.label.trim() || id,
url,
...(domains.length > 0 ? { domains } : {}),
...(oauth ? { oauth } : {}),
};
}
function emptyDraft(): RowDraft {
return {
id: '',
label: '',
url: '',
domains: '',
oauthClientId: '',
oauthIssuerUrl: '',
oauthClientSecret: '',
oauthExpanded: false,
};
}
export function JmapServersSection({ value, source, onChange, onRevert }: Props) {
const [drafts, setDrafts] = useState<RowDraft[]>(() => value.map(entryToDraft));
useEffect(() => {
// Re-sync from props when the underlying config value changes (e.g. revert,
// initial load). Skip when drafts already represent the same array to avoid
// clobbering in-progress edits.
setDrafts((prev) => {
if (prev.length === value.length) {
const same = prev.every((d, i) => {
const e = value[i];
return d.id === e.id && d.url === e.url && d.label === e.label;
});
if (same) return prev;
}
return value.map(entryToDraft);
});
}, [value]);
function commit(next: RowDraft[]) {
setDrafts(next);
const entries: JmapServerEntry[] = [];
for (const d of next) {
const e = draftToEntry(d);
if (e) entries.push(e);
}
onChange(entries);
}
function update(idx: number, patch: Partial<RowDraft>) {
commit(drafts.map((d, i) => (i === idx ? { ...d, ...patch } : d)));
}
function remove(idx: number) {
commit(drafts.filter((_, i) => i !== idx));
}
function add() {
setDrafts((prev) => [...prev, emptyDraft()]);
// Don't commit yet - new row needs id+url before it counts.
}
const ids = new Set<string>();
const duplicateIdx = new Set<number>();
drafts.forEach((d, i) => {
const id = d.id.trim();
if (!id) return;
if (ids.has(id)) duplicateIdx.add(i);
ids.add(id);
});
return (
<div className="px-4 py-3 space-y-3">
<div className="flex items-center justify-between gap-2">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-foreground">Servers</span>
{source && source !== 'default' && (
<span className={`text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded ${source === 'admin' ? 'bg-primary/10 text-primary' : 'bg-muted text-muted-foreground'}`}>
{source}
</span>
)}
</div>
<p className="text-xs text-muted-foreground mt-0.5">
Each entry appears as an option on the login dropdown. Leave the list empty to fall back to the single <code className="text-[11px]">JMAP Server URL</code> above.
</p>
</div>
<div className="flex items-center gap-2 shrink-0">
{source === 'admin' && (
<button
onClick={onRevert}
className="text-muted-foreground hover:text-foreground"
title="Revert to default"
>
<RotateCcw className="w-3.5 h-3.5" />
</button>
)}
<button
onClick={add}
className="inline-flex items-center gap-1.5 h-8 px-2.5 rounded-md border border-input bg-background text-xs text-foreground hover:bg-muted transition-colors"
>
<Plus className="w-3.5 h-3.5" />
Add server
</button>
</div>
</div>
{drafts.length === 0 && (
<div className="text-xs text-muted-foreground italic">No servers configured.</div>
)}
{drafts.map((d, i) => {
const isDuplicate = duplicateIdx.has(i);
return (
<div key={i} className="rounded-md border border-border bg-muted/20 p-3 space-y-2">
<div className="grid grid-cols-1 sm:grid-cols-12 gap-2 items-start">
<div className="sm:col-span-3">
<label className="block text-[11px] font-medium text-muted-foreground mb-1">ID</label>
<input
type="text"
value={d.id}
onChange={(e) => update(i, { id: e.target.value })}
placeholder="main"
className={`h-8 w-full rounded-md border bg-background px-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${isDuplicate ? 'border-destructive' : 'border-input'}`}
/>
{isDuplicate && <p className="text-[10px] text-destructive mt-0.5">Duplicate id</p>}
</div>
<div className="sm:col-span-4">
<label className="block text-[11px] font-medium text-muted-foreground mb-1">Label</label>
<input
type="text"
value={d.label}
onChange={(e) => update(i, { label: e.target.value })}
placeholder="Main server"
className="h-8 w-full rounded-md border border-input bg-background px-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
</div>
<div className="sm:col-span-5">
<label className="block text-[11px] font-medium text-muted-foreground mb-1">JMAP URL</label>
<div className="flex items-center gap-2">
<input
type="url"
value={d.url}
onChange={(e) => update(i, { url: e.target.value })}
placeholder="https://mail.example.com"
className="h-8 w-full rounded-md border border-input bg-background px-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
<button
onClick={() => remove(i)}
className="shrink-0 text-muted-foreground hover:text-destructive"
title="Remove server"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
</div>
<div>
<label className="block text-[11px] font-medium text-muted-foreground mb-1">
Email domains (comma-separated, used for auto-pick)
</label>
<input
type="text"
value={d.domains}
onChange={(e) => update(i, { domains: e.target.value })}
placeholder="example.com, example.org"
className="h-8 w-full rounded-md border border-input bg-background px-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
</div>
<button
onClick={() => update(i, { oauthExpanded: !d.oauthExpanded })}
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
type="button"
>
{d.oauthExpanded ? <ChevronDown className="w-3.5 h-3.5" /> : <ChevronRight className="w-3.5 h-3.5" />}
Per-server OAuth (optional, overrides global)
</button>
{d.oauthExpanded && (
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2 pl-4 border-l border-border">
<div>
<label className="block text-[11px] font-medium text-muted-foreground mb-1">OAuth Client ID</label>
<input
type="text"
value={d.oauthClientId}
onChange={(e) => update(i, { oauthClientId: e.target.value })}
className="h-8 w-full rounded-md border border-input bg-background px-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
</div>
<div>
<label className="block text-[11px] font-medium text-muted-foreground mb-1">OAuth Issuer URL</label>
<input
type="url"
value={d.oauthIssuerUrl}
onChange={(e) => update(i, { oauthIssuerUrl: e.target.value })}
placeholder="https://auth.example.com"
className="h-8 w-full rounded-md border border-input bg-background px-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
</div>
<div>
<label className="block text-[11px] font-medium text-muted-foreground mb-1">OAuth Client Secret</label>
<input
type="password"
value={d.oauthClientSecret}
onChange={(e) => update(i, { oauthClientSecret: e.target.value })}
className="h-8 w-full rounded-md border border-input bg-background px-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
</div>
</div>
)}
</div>
);
})}
</div>
);
}
+27
View File
@@ -3,6 +3,8 @@
import { useEffect, useState } from 'react';
import { Save, RotateCcw, Loader2 } from 'lucide-react';
import { apiFetch } from '@/lib/browser-navigation';
import { JmapServersSection } from './_jmap-servers-section';
import type { JmapServerEntry } from '@/lib/admin/jmap-servers';
interface ConfigEntry {
value: unknown;
@@ -124,6 +126,31 @@ export function SettingsTab() {
<ToggleSetting label="Demo Mode" description="Enable demo mode with sample data" configKey="demoMode" value={currentValue('demoMode') as boolean} source={config.demoMode?.source} onChange={handleChange} onRevert={handleRevert} />
</SettingsSection>
<SettingsSection title="JMAP Servers (multi-server)">
<ToggleSetting
label="Auto-pick server by email domain"
description="When users type their email, automatically select the matching server from the list below."
configKey="jmapServerAutoPickByDomain"
value={currentValue('jmapServerAutoPickByDomain') as boolean}
source={config.jmapServerAutoPickByDomain?.source}
onChange={handleChange}
onRevert={handleRevert}
/>
<JmapServersSection
value={(currentValue('jmapServers') as JmapServerEntry[]) ?? []}
source={config.jmapServers?.source}
onChange={(next) => handleChange('jmapServers', next)}
onRevert={() => handleRevert('jmapServers')}
/>
{Array.isArray(currentValue('jmapServers')) && (currentValue('jmapServers') as JmapServerEntry[]).length > 0 && (
<div className="px-4 py-2.5 bg-amber-50 dark:bg-amber-950/30 border-l-2 border-amber-400 dark:border-amber-600">
<p className="text-xs text-amber-800 dark:text-amber-300 leading-relaxed">
<strong>CORS warning:</strong> Each JMAP server must allow this webmail's origin in its <code className="text-[11px] bg-amber-100 dark:bg-amber-900/50 px-1 py-0.5 rounded">Access-Control-Allow-Origin</code> header, or browser requests will be blocked.
</p>
</div>
)}
</SettingsSection>
<SettingsSection title="Logging">
<SelectSetting label="Log Format" configKey="logFormat" value={currentValue('logFormat') as string} source={config.logFormat?.source} options={['text', 'json']} onChange={handleChange} onRevert={handleRevert} />
<SelectSetting label="Log Level" configKey="logLevel" value={currentValue('logLevel') as string} source={config.logLevel?.source} options={['error', 'warn', 'info', 'debug']} onChange={handleChange} onRevert={handleRevert} />