Merge branch 'bulwarkmail:main' into feature/scheduled-send

This commit is contained in:
Lucas Gaitzsch
2026-05-23 06:39:35 +02:00
committed by GitHub
54 changed files with 1891 additions and 152 deletions
+17
View File
@@ -146,6 +146,23 @@ describe('oauth/discovery', () => {
expect(consoleSpy).toHaveBeenCalled();
});
it('accepts private/loopback endpoints when validateEndpoint is omitted (admin opted in)', async () => {
// Split-DNS deployments: mail.example.com resolves to an RFC-1918 address
// locally. With the SSRF validator off, discovery must succeed.
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({
issuer: 'https://mail.example.com',
authorization_endpoint: 'http://10.0.0.5/authorize',
token_endpoint: 'http://10.0.0.5/token',
}),
}));
const result = await discoverOAuth('https://mail.example.com');
expect(result?.token_endpoint).toBe('http://10.0.0.5/token');
});
it('caches results - second call for same server URL does not re-fetch', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce({
ok: true,
+1
View File
@@ -153,6 +153,7 @@ export const CONFIG_ENV_MAP: Record<string, { envVar: string; fileEnvVar?: strin
oauthIssuerUrl: { envVar: 'OAUTH_ISSUER_URL', type: 'url', defaultValue: '' },
oauthScopes: { envVar: 'OAUTH_SCOPES', type: 'string', defaultValue: '' },
oauthExtraScopes: { envVar: 'OAUTH_EXTRA_SCOPES', type: 'string', defaultValue: '' },
oauthAllowPrivateEndpoints: { envVar: 'OAUTH_ALLOW_PRIVATE_ENDPOINTS', type: 'boolean', defaultValue: false },
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 },
+10 -3
View File
@@ -24,8 +24,14 @@ export interface TimedEventLayout {
export function getEventStartDate(
event: Pick<CalendarEvent, 'start' | 'utcStart' | 'showWithoutTime'>,
): Date {
const source = !event.showWithoutTime && event.utcStart ? event.utcStart : event.start;
return parseISO(source);
// Prefer utcStart for timed events but fall back to start if utcStart is
// missing or unparseable - a malformed utcStart used to surface as an
// Invalid Date that crashed downstream format() calls (#316).
if (!event.showWithoutTime && event.utcStart) {
const utc = parseISO(event.utcStart);
if (!isNaN(utc.getTime())) return utc;
}
return parseISO(event.start);
}
export function packWeekSegments(rawSegments: CalendarWeekSegment[]): CalendarWeekSegment[] {
@@ -56,7 +62,8 @@ export function packWeekSegments(rawSegments: CalendarWeekSegment[]): CalendarWe
export function getEventEndDate(event: CalendarEvent): Date {
if (!event.showWithoutTime && event.utcEnd) {
return parseISO(event.utcEnd);
const utc = parseISO(event.utcEnd);
if (!isNaN(utc.getTime())) return utc;
}
const start = getEventStartDate(event);
+12 -5
View File
@@ -267,10 +267,11 @@ export class DemoJMAPClient implements IJMAPClient {
this.recalcMailboxCounts();
}
async moveToTrash(emailId: string, trashMailboxId: string): Promise<void> {
async moveToTrash(emailId: string, trashMailboxId: string, _accountId?: string, markAsRead?: boolean): Promise<void> {
const email = this.data.emails.find(e => e.id === emailId);
if (!email) return;
email.mailboxIds = { [trashMailboxId]: true };
if (markAsRead) email.keywords.$seen = true;
this.recalcMailboxCounts();
}
@@ -280,10 +281,13 @@ export class DemoJMAPClient implements IJMAPClient {
this.recalcMailboxCounts();
}
async batchMoveEmails(emailIds: string[], toMailboxId: string): Promise<void> {
async batchMoveEmails(emailIds: string[], toMailboxId: string, _accountId?: string, markAsRead?: boolean): Promise<void> {
for (const id of emailIds) {
const email = this.data.emails.find(e => e.id === id);
if (email) email.mailboxIds = { [toMailboxId]: true };
if (email) {
email.mailboxIds = { [toMailboxId]: true };
if (markAsRead) email.keywords.$seen = true;
}
}
this.recalcMailboxCounts();
}
@@ -360,10 +364,13 @@ export class DemoJMAPClient implements IJMAPClient {
return count;
}
async markAsSpam(emailId: string): Promise<void> {
async markAsSpam(emailId: string, _accountId?: string, markAsRead?: boolean): Promise<void> {
const email = this.data.emails.find(e => e.id === emailId);
const junkMb = this.data.mailboxes.find(m => m.role === 'junk');
if (email && junkMb) email.mailboxIds = { [junkMb.id]: true };
if (email && junkMb) {
email.mailboxIds = { [junkMb.id]: true };
if (markAsRead) email.keywords.$seen = true;
}
this.recalcMailboxCounts();
}
+264
View File
@@ -0,0 +1,264 @@
import type { Email } from "@/lib/jmap/types";
// Allow any Unicode letter or digit (so umlauts, accents, CJK survive) plus a
// small set of safe punctuation. Everything else - emojis, RTL/zero-width
// marks, control chars, and the filesystem-reserved `<>:"/\|?*` - collapses
// to `_`. Keeps filenames usable across Windows/macOS/Linux without flattening
// non-ASCII scripts.
const SAFE_CHARS = /[^\p{L}\p{N} _\-().,!@#&+=[\]{}']/gu;
export type SpaceReplacement = "keep" | "underscore" | "dash";
export interface FilenameTransformOptions {
spaceReplacement?: SpaceReplacement;
lowercase?: boolean;
stripDiacritics?: boolean;
collapseSeparators?: boolean;
}
export interface EmailFilenameOptions extends FilenameTransformOptions {
template?: string;
}
export const DEFAULT_TRANSFORM: Required<FilenameTransformOptions> = {
spaceReplacement: "keep",
lowercase: false,
stripDiacritics: false,
collapseSeparators: true,
};
export const DEFAULT_EMAIL_TEMPLATE = "{date} ({from}-{to}) {subject}";
export const DEFAULT_ATTACHMENT_TEMPLATE = "{filename}";
export const DEFAULT_BUNDLE_TEMPLATE = "emails-{count}";
export const EMAIL_TOKENS: { token: string; description: string }[] = [
{ token: "date", description: "Full date and time, e.g. 2026-05-22 14.05.33" },
{ token: "date_short", description: "Date only, e.g. 2026-05-22" },
{ token: "time", description: "Time only, e.g. 14.05.33" },
{ token: "year", description: "4-digit year" },
{ token: "month", description: "2-digit month" },
{ token: "day", description: "2-digit day" },
{ token: "from", description: "Sender display name (falls back to email user part)" },
{ token: "from_email", description: "Sender full email address" },
{ token: "from_name", description: "Sender name only" },
{ token: "to", description: "First recipient display name" },
{ token: "to_email", description: "First recipient full email address" },
{ token: "to_name", description: "First recipient name only" },
{ token: "subject", description: "Email subject" },
];
export const ATTACHMENT_TOKENS: { token: string; description: string }[] = [
...EMAIL_TOKENS,
{ token: "filename", description: "Original attachment filename including extension" },
{ token: "name", description: "Attachment filename without extension" },
{ token: "ext", description: "Attachment file extension without leading dot" },
];
export const BUNDLE_TOKENS: { token: string; description: string }[] = [
{ token: "count", description: "Number of emails in the bundle" },
{ token: "date", description: "Current date and time, e.g. 2026-05-22 14.05.33" },
{ token: "date_short", description: "Current date, e.g. 2026-05-22" },
{ token: "time", description: "Current time, e.g. 14.05.33" },
{ token: "year", description: "4-digit year" },
{ token: "month", description: "2-digit month" },
{ token: "day", description: "2-digit day" },
];
function sanitizePart(input: string, maxLen = 80): string {
const cleaned = input
.replace(SAFE_CHARS, "_")
.replace(/_+/g, "_")
.replace(/\s+/g, " ")
.trim()
.replace(/^[._-]+|[._-]+$/g, "");
return cleaned.slice(0, maxLen);
}
function applyTransforms(input: string, opts: FilenameTransformOptions): string {
let s = input;
if (opts.stripDiacritics) {
// NFD splits "ä" into "a" + U+0308 (combining diaeresis); stripping all
// combining marks then leaves plain ASCII letters. `ß` has no
// decomposition so it survives as-is.
s = s.normalize("NFD").replace(/\p{M}+/gu, "");
}
const repl = opts.spaceReplacement ?? "keep";
if (repl === "underscore") s = s.replace(/ +/g, "_");
else if (repl === "dash") s = s.replace(/ +/g, "-");
if (opts.collapseSeparators ?? true) {
s = s.replace(/_+/g, "_").replace(/-+/g, "-").replace(/ +/g, " ");
}
if (opts.lowercase) s = s.toLocaleLowerCase();
return s.replace(/^[._\- ]+|[._\- ]+$/g, "");
}
function pad2(n: number): string {
return String(n).padStart(2, "0");
}
function dateParts(iso: string | null | undefined) {
const d = iso ? new Date(iso) : new Date();
if (Number.isNaN(d.getTime())) {
return {
date: "0000-00-00 00.00.00",
date_short: "0000-00-00",
time: "00.00.00",
year: "0000",
month: "00",
day: "00",
};
}
const year = String(d.getFullYear());
const month = pad2(d.getMonth() + 1);
const day = pad2(d.getDate());
const time = `${pad2(d.getHours())}.${pad2(d.getMinutes())}.${pad2(d.getSeconds())}`;
return {
date: `${year}-${month}-${day} ${time}`,
date_short: `${year}-${month}-${day}`,
time,
year,
month,
day,
};
}
function addrLabel(addr: { name?: string | null; email: string } | undefined): {
name: string;
email: string;
label: string;
} {
if (!addr) return { name: "", email: "", label: "unknown" };
const name = (addr.name && addr.name.trim()) || "";
const email = addr.email || "";
const label = name || email.split("@")[0] || email || "unknown";
return { name, email, label };
}
export function emailVars(email: Email): Record<string, string> {
const dp = dateParts(email.receivedAt || email.sentAt);
const from = addrLabel(email.from?.[0]);
const to = addrLabel(email.to?.[0]);
return {
...dp,
from: from.label,
from_email: from.email,
from_name: from.name,
to: to.label,
to_email: to.email,
to_name: to.name,
subject: email.subject || "no subject",
};
}
export interface AttachmentLike {
name?: string | null;
type?: string | null;
}
export function attachmentVars(email: Email, attachment: AttachmentLike): Record<string, string> {
const filename = (attachment.name || "attachment").trim();
const dot = filename.lastIndexOf(".");
const hasExt = dot > 0 && dot < filename.length - 1;
const name = hasExt ? filename.slice(0, dot) : filename;
const ext = hasExt ? filename.slice(dot + 1) : "";
return {
...emailVars(email),
filename,
name,
ext,
};
}
function renderRaw(template: string, vars: Record<string, string>): string {
return template.replace(/\{(\w+)\}/g, (_, key: string) => {
const value = vars[key];
if (value === undefined) return "";
return sanitizePart(value);
});
}
export function emailExportFilename(
email: Email,
options: EmailFilenameOptions | string = {},
): string {
const opts = typeof options === "string" ? { template: options } : options;
const template = opts.template ?? DEFAULT_EMAIL_TEMPLATE;
const rendered = renderRaw(template, emailVars(email));
const cleaned = sanitizePart(rendered, 200);
const transformed = applyTransforms(cleaned, opts);
const stem = transformed.slice(0, 200) || "email";
return `${stem}.eml`;
}
export function attachmentDownloadFilename(
email: Email | null | undefined,
attachment: AttachmentLike,
options: EmailFilenameOptions | string = {},
): string {
const opts = typeof options === "string" ? { template: options } : options;
const template = opts.template ?? DEFAULT_ATTACHMENT_TEMPLATE;
if (!email) {
const filename = (attachment.name || "attachment").trim();
const cleaned = sanitizePart(filename, 200) || "attachment";
return applyTransforms(cleaned, opts) || cleaned;
}
const vars = attachmentVars(email, attachment);
const rendered = template.replace(/\{(\w+)\}/g, (_, key: string) => {
const value = vars[key];
if (value === undefined) return "";
// Preserve dots in {filename} so the original extension survives the
// sanitiser (it strips trailing dots otherwise).
return key === "filename" ? value.replace(SAFE_CHARS, "_") : sanitizePart(value);
});
const templateMentionsExt = /\{(ext|filename)\}/.test(template);
const cleaned = sanitizePart(rendered, 200) || "attachment";
if (templateMentionsExt) {
return applyTransforms(cleaned, opts) || cleaned;
}
const transformedStem = applyTransforms(cleaned, opts) || cleaned;
const ext = vars.ext;
if (!ext) return transformedStem;
const transformedExt = opts.lowercase ? ext.toLocaleLowerCase() : ext;
return `${transformedStem}.${transformedExt}`;
}
export function bundleVars(count: number, iso?: string): Record<string, string> {
const dp = dateParts(iso ?? new Date().toISOString());
return { ...dp, count: String(count) };
}
export function bundleExportFilename(
count: number,
options: EmailFilenameOptions | string = {},
iso?: string,
): string {
const opts = typeof options === "string" ? { template: options } : options;
const template = opts.template ?? DEFAULT_BUNDLE_TEMPLATE;
const rendered = renderRaw(template, bundleVars(count, iso));
const cleaned = sanitizePart(rendered, 200);
const transformed = applyTransforms(cleaned, opts);
const stem = transformed.slice(0, 200) || "emails";
return `${stem}.zip`;
}
// Build a synthetic email for previewing templates in the settings UI.
export function buildSampleEmail(): Email {
// Use a fixed date so the preview doesn't churn as the user types.
const iso = "2026-05-22T14:05:33Z";
return {
id: "sample-1",
threadId: "sample-thread-1",
mailboxIds: { inbox: true },
keywords: { $seen: true },
size: 12345,
receivedAt: iso,
sentAt: iso,
from: [{ name: "Alice Sender", email: "alice@example.com" }],
to: [{ name: "Bob Recipient", email: "bob@example.com" }],
cc: [],
subject: "Benachrichtigung von Ihrem Gerät",
preview: "",
hasAttachment: true,
blobId: "sample-blob",
};
}
+48
View File
@@ -0,0 +1,48 @@
export interface ImportableEmail {
name: string;
blob: Blob;
}
const EMAIL_MIME = "message/rfc822";
function isEmlName(name: string): boolean {
return /\.eml$/i.test(name);
}
function isZipName(name: string): boolean {
return /\.zip$/i.test(name);
}
async function extractEmlsFromZip(file: File): Promise<ImportableEmail[]> {
const { default: JSZip } = await import("jszip");
const zip = await JSZip.loadAsync(await file.arrayBuffer());
const out: ImportableEmail[] = [];
const entries = Object.values(zip.files);
for (const entry of entries) {
if (entry.dir) continue;
if (!isEmlName(entry.name)) continue;
const data = await entry.async("arraybuffer");
out.push({
name: entry.name.split(/[\\/]/).pop() || entry.name,
blob: new Blob([data], { type: EMAIL_MIME }),
});
}
return out;
}
export async function expandImportableEmails(
files: File[],
): Promise<ImportableEmail[]> {
const out: ImportableEmail[] = [];
for (const file of files) {
if (isZipName(file.name) || file.type === "application/zip") {
out.push(...(await extractEmlsFromZip(file)));
continue;
}
const blob = new Blob([await file.arrayBuffer()], { type: EMAIL_MIME });
out.push({ name: file.name, blob });
}
return out;
}
export const EML_IMPORT_ACCEPT = ".eml,.zip,message/rfc822,application/zip";
+3 -3
View File
@@ -98,9 +98,9 @@ export interface IJMAPClient {
setKeyword(emailId: string, keyword: string): Promise<void>;
migrateKeyword(oldKeyword: string, newKeyword: string): Promise<number>;
deleteEmail(emailId: string): Promise<void>;
moveToTrash(emailId: string, trashMailboxId: string, accountId?: string): Promise<void>;
moveToTrash(emailId: string, trashMailboxId: string, accountId?: string, markAsRead?: boolean): Promise<void>;
batchDeleteEmails(emailIds: string[]): Promise<void>;
batchMoveEmails(emailIds: string[], toMailboxId: string, accountId?: string): Promise<void>;
batchMoveEmails(emailIds: string[], toMailboxId: string, accountId?: string, markAsRead?: boolean): Promise<void>;
batchArchiveEmails(
emails: Array<{ id: string; receivedAt: string }>,
archiveMailboxId: string,
@@ -112,7 +112,7 @@ export interface IJMAPClient {
emptyMailbox(mailboxId: string): Promise<number>;
markMailboxAsRead(mailboxId: string, accountId?: string): Promise<number>;
markAllAsRead(excludeMailboxIds?: string[], accountId?: string): Promise<number>;
markAsSpam(emailId: string, accountId?: string): Promise<void>;
markAsSpam(emailId: string, accountId?: string, markAsRead?: boolean): Promise<void>;
undoSpam(emailId: string, originalMailboxId: string, accountId?: string): Promise<void>;
// ── Threads ───────────────────────────────────────────────────
+19 -14
View File
@@ -99,6 +99,8 @@ const EMAIL_LIST_PROPERTIES = [
"subject",
"preview",
"hasAttachment",
// Needed so list rows can serve drag-out to the file system as .eml.
"blobId",
] as const;
// Stalwart's default property list for Calendar/get omits shareWith, isVisible,
@@ -1278,16 +1280,14 @@ export class JMAPClient implements IJMAPClient {
]);
}
async moveToTrash(emailId: string, trashMailboxId: string, accountId?: string): Promise<void> {
async moveToTrash(emailId: string, trashMailboxId: string, accountId?: string, markAsRead?: boolean): Promise<void> {
const targetAccountId = accountId || this.accountId;
const patch: Record<string, unknown> = { mailboxIds: { [trashMailboxId]: true } };
if (markAsRead) patch["keywords/$seen"] = true;
await this.request([
["Email/set", {
accountId: targetAccountId,
update: {
[emailId]: {
mailboxIds: { [trashMailboxId]: true },
},
},
update: { [emailId]: patch },
}, "0"],
]);
}
@@ -1303,10 +1303,15 @@ export class JMAPClient implements IJMAPClient {
]);
}
async batchMoveEmails(emailIds: string[], toMailboxId: string, accountId?: string): Promise<void> {
async batchMoveEmails(emailIds: string[], toMailboxId: string, accountId?: string, markAsRead?: boolean): Promise<void> {
if (emailIds.length === 0) return;
const updates = Object.fromEntries(emailIds.map(id => [id, { mailboxIds: { [toMailboxId]: true } }]));
const buildPatch = () => {
const patch: Record<string, unknown> = { mailboxIds: { [toMailboxId]: true } };
if (markAsRead) patch["keywords/$seen"] = true;
return patch;
};
const updates = Object.fromEntries(emailIds.map(id => [id, buildPatch()]));
await this.request([
["Email/set", { accountId: accountId || this.accountId, update: updates }, "0"],
]);
@@ -1559,7 +1564,7 @@ export class JMAPClient implements IJMAPClient {
return totalMarked;
}
async markAsSpam(emailId: string, accountId?: string): Promise<void> {
async markAsSpam(emailId: string, accountId?: string, markAsRead?: boolean): Promise<void> {
const targetAccountId = accountId || this.accountId;
const mailboxes = await this.getMailboxes();
@@ -1578,14 +1583,13 @@ export class JMAPClient implements IJMAPClient {
? junkMailbox.originalId
: junkMailbox.id;
const patch: Record<string, unknown> = { mailboxIds: { [mailboxId]: true } };
if (markAsRead) patch["keywords/$seen"] = true;
await this.request([
["Email/set", {
accountId: targetAccountId,
update: {
[emailId]: {
mailboxIds: { [mailboxId]: true },
},
},
update: { [emailId]: patch },
}, "0"],
]);
}
@@ -4279,6 +4283,7 @@ export class JMAPClient implements IJMAPClient {
const createMap: Record<string, Partial<CalendarEvent>> = {};
for (let i = 0; i < events.length; i++) {
const { originalId: _oi, originalCalendarIds: _oc, accountId: _ai, accountName: _an, isShared: _is, ...clean } = events[i] as CalendarEvent;
cleanRecurrenceRules(clean as unknown as Record<string, unknown>);
createMap[`new-${i}`] = clean;
}
+13 -3
View File
@@ -1,11 +1,21 @@
import { logger } from '@/lib/logger';
import { discoverOAuth } from '@/lib/oauth/discovery';
import type { OAuthMetadata } from '@/lib/oauth/discovery';
import type { EndpointValidator, OAuthMetadata } from '@/lib/oauth/discovery';
import { isPublicHttpUrl } from '@/lib/security/url-guard';
import { readFileEnv } from '@/lib/read-file-env';
import { configManager } from '@/lib/admin/config-manager';
import { parseJmapServers, findServerById } from '@/lib/admin/jmap-servers';
// SSRF guard for OAuth discovery. When `oauthAllowPrivateEndpoints` is set,
// the admin opts in to discovery resolving to RFC-1918 / loopback hosts —
// required for split-DNS deployments where the JMAP server's public hostname
// resolves to an internal IP locally. The guard remains in force for any
// caller that passes a user-supplied serverUrl (see totp-token-exchange).
export function getDiscoveryValidator(): EndpointValidator | undefined {
const allowPrivate = configManager.get<boolean>('oauthAllowPrivateEndpoints', false);
return allowPrivate ? undefined : isPublicHttpUrl;
}
function getGlobalClientSecret(): string {
const adminSecret = configManager.get<string>('oauthClientSecret', '');
if (adminSecret) return adminSecret;
@@ -47,7 +57,7 @@ function getClientSecret(serverId?: string | null): string {
export async function getTokenEndpoint(serverId?: string | null): Promise<string> {
const { discoveryUrl } = getRequiredConfig(serverId);
const metadata = await discoverOAuth(discoveryUrl, { validateEndpoint: isPublicHttpUrl });
const metadata = await discoverOAuth(discoveryUrl, { validateEndpoint: getDiscoveryValidator() });
if (!metadata?.token_endpoint) {
throw new Error('OAuth token endpoint not found');
}
@@ -56,7 +66,7 @@ export async function getTokenEndpoint(serverId?: string | null): Promise<string
export async function getMetadata(serverId?: string | null): Promise<OAuthMetadata | null> {
const { discoveryUrl } = getRequiredConfig(serverId);
return discoverOAuth(discoveryUrl, { validateEndpoint: isPublicHttpUrl });
return discoverOAuth(discoveryUrl, { validateEndpoint: getDiscoveryValidator() });
}
export function buildOAuthParams(base: Record<string, string>, serverId?: string | null): URLSearchParams {
+3 -1
View File
@@ -10,6 +10,8 @@
// installing their own code. Verification kicks in for server-managed
// bundles only (the `managed: true` flag on `InstalledPlugin`).
import { apiFetch } from '@/lib/browser-navigation';
let cachedPubKey: CryptoKey | null = null;
let pubKeyPromise: Promise<CryptoKey | null> | null = null;
@@ -26,7 +28,7 @@ async function importEd25519PublicKey(raw: Uint8Array): Promise<CryptoKey | null
async function fetchPublicKey(): Promise<CryptoKey | null> {
try {
const res = await fetch('/api/plugin-signing-pubkey', { credentials: 'same-origin' });
const res = await apiFetch('/api/plugin-signing-pubkey', { credentials: 'same-origin' });
if (!res.ok) return null;
const data = await res.json() as { algorithm?: string; publicKey?: string };
if (data.algorithm !== 'ed25519' || typeof data.publicKey !== 'string') return null;