feat: multi-server JMAP support
This commit is contained in:
@@ -13,6 +13,12 @@ function parseEnvValue(value: string, type: string): unknown {
|
||||
switch (type) {
|
||||
case 'boolean':
|
||||
return value === 'true';
|
||||
case 'json':
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
case 'string':
|
||||
case 'url':
|
||||
case 'enum':
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* Multi-server JMAP support: schema, parsing, lookup, and redaction helpers.
|
||||
*/
|
||||
|
||||
export interface JmapServerOAuthConfig {
|
||||
clientId?: string;
|
||||
issuerUrl?: string;
|
||||
clientSecret?: string;
|
||||
}
|
||||
|
||||
export interface JmapServerEntry {
|
||||
id: string;
|
||||
label: string;
|
||||
url: string;
|
||||
domains?: string[];
|
||||
oauth?: JmapServerOAuthConfig;
|
||||
}
|
||||
|
||||
export interface PublicJmapServerEntry {
|
||||
id: string;
|
||||
label: string;
|
||||
url: string;
|
||||
domains: string[];
|
||||
oauth?: {
|
||||
clientId?: string;
|
||||
issuerUrl?: string;
|
||||
};
|
||||
}
|
||||
|
||||
const ID_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/i;
|
||||
|
||||
function trimUrl(url: string): string {
|
||||
return url.trim().replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function isHttpUrl(url: string): boolean {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
return u.protocol === 'https:' || u.protocol === 'http:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse the raw config value (may be array, string JSON, or null). */
|
||||
export function parseJmapServers(raw: unknown): JmapServerEntry[] {
|
||||
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: JmapServerEntry[] = [];
|
||||
for (const item of value) {
|
||||
if (!item || typeof item !== 'object') continue;
|
||||
const e = item as Record<string, unknown>;
|
||||
const id = typeof e.id === 'string' ? e.id.trim() : '';
|
||||
const label = typeof e.label === 'string' ? e.label.trim() : '';
|
||||
const url = typeof e.url === 'string' ? trimUrl(e.url) : '';
|
||||
if (!id || !ID_RE.test(id) || seen.has(id)) continue;
|
||||
if (!url || !isHttpUrl(url)) continue;
|
||||
seen.add(id);
|
||||
const domains = Array.isArray(e.domains)
|
||||
? e.domains
|
||||
.filter((d): d is string => typeof d === 'string')
|
||||
.map((d) => d.trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
let oauth: JmapServerOAuthConfig | undefined;
|
||||
if (e.oauth && typeof e.oauth === 'object') {
|
||||
const o = e.oauth as Record<string, unknown>;
|
||||
const clientId = typeof o.clientId === 'string' ? o.clientId.trim() : '';
|
||||
const issuerUrl = typeof o.issuerUrl === 'string' ? trimUrl(o.issuerUrl) : '';
|
||||
const clientSecret = typeof o.clientSecret === 'string' ? o.clientSecret : '';
|
||||
if (clientId || issuerUrl || clientSecret) {
|
||||
oauth = {};
|
||||
if (clientId) oauth.clientId = clientId;
|
||||
if (issuerUrl && isHttpUrl(issuerUrl)) oauth.issuerUrl = issuerUrl;
|
||||
if (clientSecret) oauth.clientSecret = clientSecret;
|
||||
}
|
||||
}
|
||||
out.push({
|
||||
id,
|
||||
label: label || id,
|
||||
url,
|
||||
...(domains.length > 0 ? { domains } : {}),
|
||||
...(oauth ? { oauth } : {}),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Strip secrets for client-side exposure. */
|
||||
export function redactJmapServers(servers: JmapServerEntry[]): PublicJmapServerEntry[] {
|
||||
return servers.map((s) => ({
|
||||
id: s.id,
|
||||
label: s.label,
|
||||
url: s.url,
|
||||
domains: s.domains ?? [],
|
||||
...(s.oauth && (s.oauth.clientId || s.oauth.issuerUrl)
|
||||
? {
|
||||
oauth: {
|
||||
...(s.oauth.clientId ? { clientId: s.oauth.clientId } : {}),
|
||||
...(s.oauth.issuerUrl ? { issuerUrl: s.oauth.issuerUrl } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
}));
|
||||
}
|
||||
|
||||
export function findServerById(servers: JmapServerEntry[], id: string | null | undefined): JmapServerEntry | undefined {
|
||||
if (!id) return undefined;
|
||||
return servers.find((s) => s.id === id);
|
||||
}
|
||||
|
||||
function normalizeUrl(url: string): string {
|
||||
try {
|
||||
const u = new URL(trimUrl(url));
|
||||
return `${u.protocol}//${u.host.toLowerCase()}${u.pathname.replace(/\/+$/, '')}`;
|
||||
} catch {
|
||||
return trimUrl(url).toLowerCase();
|
||||
}
|
||||
}
|
||||
|
||||
export function findServerByUrl(servers: JmapServerEntry[], url: string | null | undefined): JmapServerEntry | undefined {
|
||||
if (!url) return undefined;
|
||||
const target = normalizeUrl(url);
|
||||
return servers.find((s) => normalizeUrl(s.url) === target);
|
||||
}
|
||||
|
||||
/** Find the server whose `domains` array matches the given email's domain (case-insensitive). */
|
||||
export function findServerByEmailDomain(servers: JmapServerEntry[], email: string | null | undefined): JmapServerEntry | undefined {
|
||||
if (!email || !email.includes('@')) return undefined;
|
||||
const domain = email.split('@')[1]?.trim().toLowerCase();
|
||||
if (!domain) return undefined;
|
||||
return servers.find((s) => (s.domains ?? []).some((d) => d.toLowerCase() === domain));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a client-supplied JMAP URL to a trusted upstream URL by checking it
|
||||
* against the configured server list and the global `jmapServerUrl`. Returns
|
||||
* null when no match is found. Used by API routes that need to forward auth
|
||||
* requests upstream without being tricked into hitting internal hosts.
|
||||
*/
|
||||
export function resolveTrustedJmapUrl(
|
||||
requestedUrl: string | null | undefined,
|
||||
globalServerUrl: string | null | undefined,
|
||||
servers: JmapServerEntry[],
|
||||
): string | null {
|
||||
if (!requestedUrl) {
|
||||
return globalServerUrl ? trimUrl(globalServerUrl) : null;
|
||||
}
|
||||
const target = normalizeUrl(requestedUrl);
|
||||
if (globalServerUrl && normalizeUrl(globalServerUrl) === target) {
|
||||
return trimUrl(globalServerUrl);
|
||||
}
|
||||
const matched = servers.find((s) => normalizeUrl(s.url) === target);
|
||||
if (matched) return matched.url;
|
||||
// No match — caller decides whether to honor the request anyway (e.g. when
|
||||
// allowCustomJmapEndpoint is enabled).
|
||||
return null;
|
||||
}
|
||||
+3
-1
@@ -108,7 +108,7 @@ export interface AuditEntry {
|
||||
}
|
||||
|
||||
/** Config keys that map to environment variables */
|
||||
export const CONFIG_ENV_MAP: Record<string, { envVar: string; fileEnvVar?: string; type: 'string' | 'boolean' | 'url' | 'enum'; defaultValue: unknown; enumValues?: string[] }> = {
|
||||
export const CONFIG_ENV_MAP: Record<string, { envVar: string; fileEnvVar?: string; type: 'string' | 'boolean' | 'url' | 'enum' | 'json'; defaultValue: unknown; enumValues?: string[] }> = {
|
||||
appName: { envVar: 'APP_NAME', type: 'string', defaultValue: 'Webmail' },
|
||||
jmapServerUrl: { envVar: 'JMAP_SERVER_URL', type: 'url', defaultValue: '' },
|
||||
stalwartFeaturesEnabled: { envVar: 'STALWART_FEATURES', type: 'boolean', defaultValue: true },
|
||||
@@ -129,6 +129,8 @@ export const CONFIG_ENV_MAP: Record<string, { envVar: string; fileEnvVar?: strin
|
||||
oauthClientSecret: { envVar: 'OAUTH_CLIENT_SECRET', fileEnvVar: 'OAUTH_CLIENT_SECRET_FILE', type: 'string', defaultValue: '' },
|
||||
oauthIssuerUrl: { envVar: 'OAUTH_ISSUER_URL', type: 'url', defaultValue: '' },
|
||||
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 },
|
||||
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: '' },
|
||||
|
||||
Reference in New Issue
Block a user