Merge branch 'bulwarkmail:main' into feature/scheduled-send
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user