This commit is contained in:
Linus Rath
2026-04-13 00:51:21 +02:00
76 changed files with 1422 additions and 317 deletions
+32
View File
@@ -1,3 +1,4 @@
import { unlink, writeFileSync } from "fs";
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
// Mock NextResponse before importing the route
@@ -24,6 +25,7 @@ describe('config API route', () => {
delete process.env.OAUTH_CLIENT_ID;
delete process.env.OAUTH_ISSUER_URL;
delete process.env.SESSION_SECRET;
delete process.env.SESSION_SECRET_FILE;
delete process.env.SETTINGS_SYNC_ENABLED;
delete process.env.STALWART_FEATURES;
delete process.env.DEV_MOCK_JMAP;
@@ -128,6 +130,19 @@ describe('config API route', () => {
const config = await getConfig();
expect(config.rememberMeEnabled).toBe(true);
});
it('should enable rememberMe when SESSION_SECRET_FILE is set', async () => {
writeFileSync('./session-secret', 'test-secret');
process.env.SESSION_SECRET_FILE = './session-secret';
const config = await getConfig();
unlink('./session-secret', (err) => {
if (err) throw err;
});
expect(config.rememberMeEnabled).toBe(true);
});
@@ -138,6 +153,23 @@ describe('config API route', () => {
process.env.SESSION_SECRET = 'test-secret';
const config2 = await getConfig();
expect(config2.settingsSyncEnabled).toBe(true);
});
it('should enable settingsSync only when both SESSION_SECRET_FILE and SETTINGS_SYNC_ENABLED are set', async () => {
process.env.SETTINGS_SYNC_ENABLED = 'true';
const config1 = await getConfig();
expect(config1.settingsSyncEnabled).toBe(false);
writeFileSync('./session-secret', 'test-secret');
process.env.SESSION_SECRET_FILE = './session-secret';
const config2 = await getConfig();
unlink('./session-secret', (err) => {
if (err) throw err;
});
expect(config2.settingsSyncEnabled).toBe(true);
});
+2 -1
View File
@@ -1,6 +1,7 @@
import { cookies } from 'next/headers';
import { NextResponse } from 'next/server';
import { createCipheriv, createDecipheriv, randomBytes, createHash } from 'node:crypto';
import { readFileEnv } from '@/lib/read-file-env';
import { ADMIN_SESSION_COOKIE, DEFAULT_ADMIN_SESSION_TTL } from './types';
import type { AdminSessionPayload } from './types';
@@ -11,7 +12,7 @@ const TAG_LENGTH = 16;
const MIN_SECRET_LENGTH = 32;
function getKey(): Buffer {
const secret = process.env.SESSION_SECRET;
const secret = process.env.SESSION_SECRET || readFileEnv(process.env.SESSION_SECRET_FILE);
if (!secret) throw new Error('SESSION_SECRET not configured');
if (secret.length < MIN_SECRET_LENGTH) {
throw new Error(
+2 -1
View File
@@ -1,5 +1,6 @@
import { createCipheriv, createDecipheriv, randomBytes, createHash } from 'node:crypto';
import { logger } from '@/lib/logger';
import { readFileEnv } from '@/lib/read-file-env';
const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 12;
@@ -8,7 +9,7 @@ const TAG_LENGTH = 16;
const MIN_SECRET_LENGTH = 32;
function getKey(): Buffer {
const secret = process.env.SESSION_SECRET;
const secret = process.env.SESSION_SECRET || readFileEnv(process.env.SESSION_SECRET_FILE);
if (!secret) throw new Error('SESSION_SECRET not configured');
if (secret.length < MIN_SECRET_LENGTH) {
throw new Error(
+1 -1
View File
@@ -104,7 +104,7 @@ export const debug = {
}
};
const CATEGORY_KEYS = new Set<string>(['jmap', 'calendar', 'tasks', 'auth', 'filters', 'email', 'push']);
const CATEGORY_KEYS = new Set<string>(['jmap', 'calendar', 'tasks', 'auth', 'filters', 'email', 'push', 'contacts']);
function isCategoryKey(value: string): value is DebugCategory {
return CATEGORY_KEYS.has(value);
}
+6
View File
@@ -481,6 +481,12 @@ export class DemoJMAPClient implements IJMAPClient {
async getAddressBooks(): Promise<AddressBook[]> { return [...this.data.addressBooks]; }
async getAllAddressBooks(): Promise<AddressBook[]> { return [...this.data.addressBooks]; }
async createAddressBook(name: string): Promise<AddressBook> {
const book: AddressBook = { id: `demo-book-${Date.now()}`, name };
this.data.addressBooks.push(book);
return book;
}
async updateAddressBook(addressBookId: string, updates: Partial<AddressBook>): Promise<void> {
const book = this.data.addressBooks.find(b => b.id === addressBookId);
if (book) Object.assign(book, updates);
+1
View File
@@ -178,6 +178,7 @@ export interface IJMAPClient {
getContactsAccountId(): string;
getAddressBooks(): Promise<AddressBook[]>;
getAllAddressBooks(): Promise<AddressBook[]>;
createAddressBook(name: string): Promise<AddressBook>;
updateAddressBook(addressBookId: string, updates: Partial<AddressBook>, targetAccountId?: string): Promise<void>;
getContacts(addressBookId?: string): Promise<ContactCard[]>;
getAllContacts(): Promise<ContactCard[]>;
+21
View File
@@ -2868,6 +2868,27 @@ export class JMAPClient implements IJMAPClient {
}
}
async createAddressBook(name: string): Promise<AddressBook> {
const accountId = this.getContactsAccountId();
const response = await this.request([
["AddressBook/set", {
accountId,
create: { "new-book": { name } },
}, "0"]
], this.contactUsing());
if (response.methodResponses?.[0]?.[0] === "AddressBook/set") {
const result = response.methodResponses[0][1];
const created = result.created?.["new-book"];
if (created) {
return { id: created.id, name, ...created } as AddressBook;
}
const err = result.notCreated?.["new-book"];
throw new Error(err?.description || "Failed to create address book");
}
throw new Error("Failed to create address book");
}
async updateAddressBook(addressBookId: string, updates: Partial<AddressBook>, targetAccountId?: string): Promise<void> {
const accountId = targetAccountId || this.getContactsAccountId();
// Only forward server-settable properties
+4 -2
View File
@@ -203,12 +203,14 @@ export interface ContactCard {
}
export interface ContactName {
components: NameComponent[];
components?: NameComponent[];
isOrdered?: boolean;
full?: string;
defaultSeparator?: string;
}
export interface NameComponent {
kind: 'given' | 'surname' | 'prefix' | 'suffix' | 'additional' | 'separator' | 'credential';
kind: 'given' | 'surname' | 'prefix' | 'suffix' | 'additional' | 'separator' | 'credential' | 'title' | 'middle' | 'given2' | 'surname2' | 'generation';
value: string;
}
+2 -1
View File
@@ -1,8 +1,9 @@
import { logger } from '@/lib/logger';
import { discoverOAuth } from '@/lib/oauth/discovery';
import type { OAuthMetadata } from '@/lib/oauth/discovery';
import { readFileEnv } from '@/lib/read-file-env';
const CLIENT_SECRET = process.env.OAUTH_CLIENT_SECRET || '';
const CLIENT_SECRET = process.env.OAUTH_CLIENT_SECRET || readFileEnv(process.env.OAUTH_CLIENT_SECRET_FILE) || '';
export function getRequiredConfig() {
const clientId = process.env.OAUTH_CLIENT_ID;
+7 -1
View File
@@ -22,7 +22,7 @@ import {
taskHooks, templateHooks, smimeHooks, vacationHooks,
uiHooks, themeHooks, toastHooks, dragDropHooks,
keyboardHooks, appLifecycleHooks, accountSecurityHooks,
sidebarAppHooks,
sidebarAppHooks, avatarHooks,
} from './plugin-hooks';
import { toast as appToast } from '@/stores/toast-store';
import { useAuthStore } from '@/stores/auth-store';
@@ -314,6 +314,8 @@ export interface PluginHooksAPI {
onSidebarAppOpen: (handler: (...args: unknown[]) => unknown) => Disposable;
onSidebarAppClose: (handler: (...args: unknown[]) => unknown) => Disposable;
onSidebarAppChange: (handler: (...args: unknown[]) => unknown) => Disposable;
// Avatar
onAvatarResolve: (handler: (...args: unknown[]) => unknown) => Disposable;
}
// --- Permission mapping for hooks ----------------------------
@@ -417,6 +419,8 @@ const HOOK_PERMISSIONS: Record<string, Permission> = {
// Sidebar Apps
onSidebarAppOpen: 'ui:observe', onSidebarAppClose: 'ui:observe',
onSidebarAppChange: 'ui:observe',
// Avatar
onAvatarResolve: 'email:read',
};
// Map hook names → actual HookBus instances
@@ -463,6 +467,8 @@ const HOOK_BUSES: Record<string, { register: (pluginId: string, handler: (...arg
...Object.fromEntries(Object.entries(accountSecurityHooks)),
// Sidebar Apps
...Object.fromEntries(Object.entries(sidebarAppHooks)),
// Avatar
...Object.fromEntries(Object.entries(avatarHooks)),
};
// --- Slot registration bridge --------------------------------
+8
View File
@@ -399,6 +399,13 @@ export const sidebarAppHooks = {
onSidebarAppChange: new HookBus(),
};
// §7.21 Avatar Hooks
// Transform hook: handlers receive (currentUrl: string | null, context: { email: string; name?: string })
// and return a URL string to use as the avatar, or undefined/null to pass through to the next handler.
export const avatarHooks = {
onAvatarResolve: new HookBus(),
};
// ─── Aggregate: remove all handlers for a plugin across all buses ───
const allHookGroups = [
@@ -407,6 +414,7 @@ const allHookGroups = [
taskHooks, templateHooks, smimeHooks, vacationHooks,
uiHooks, themeHooks, toastHooks, dragDropHooks,
keyboardHooks, appLifecycleHooks, accountSecurityHooks, sidebarAppHooks,
avatarHooks,
];
export function removeAllPluginHooks(pluginId: string): void {
+13
View File
@@ -0,0 +1,13 @@
import { readFileSync } from "fs";
export function readFileEnv(path: string | undefined): string | null {
if (!path) {
return null;
}
try {
return readFileSync(path, "utf-8").trim();
} catch {
return null;
}
}
+2 -1
View File
@@ -3,13 +3,14 @@ import { readFile, writeFile, unlink, mkdir, rename } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import path from 'node:path';
import { logger } from '@/lib/logger';
import { readFileEnv } from '@/lib/read-file-env';
const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 12;
const TAG_LENGTH = 16;
function getKey(): Buffer {
const secret = process.env.SESSION_SECRET;
const secret = process.env.SESSION_SECRET || readFileEnv(process.env.SESSION_SECRET_FILE);
if (!secret) throw new Error('SESSION_SECRET not configured');
return createHash('sha256').update(secret).digest();
}
+19 -8
View File
@@ -152,21 +152,32 @@ export const KEYWORD_PREFIX = "$label:";
export const KEYWORD_PREFIX_LEGACY = "$color:";
/**
* Gets label/color tag from email keywords (if any).
* Gets all active label/color tag IDs from email keywords.
* Reads both the current $label: prefix and the legacy $color: prefix.
*/
export function getEmailColorTag(keywords: Record<string, boolean> | undefined): string | null {
if (!keywords) return null;
export function getEmailColorTags(keywords: Record<string, boolean> | undefined): string[] {
if (!keywords) return [];
const tags: string[] = [];
for (const key of Object.keys(keywords)) {
if ((key.startsWith(KEYWORD_PREFIX) || key.startsWith(KEYWORD_PREFIX_LEGACY)) && keywords[key] === true) {
return key.startsWith(KEYWORD_PREFIX)
? key.slice(KEYWORD_PREFIX.length)
: key.slice(KEYWORD_PREFIX_LEGACY.length);
tags.push(
key.startsWith(KEYWORD_PREFIX)
? key.slice(KEYWORD_PREFIX.length)
: key.slice(KEYWORD_PREFIX_LEGACY.length)
);
}
}
return tags;
}
return null;
/**
* Gets label/color tag from email keywords (if any).
* Reads both the current $label: prefix and the legacy $color: prefix.
* @deprecated Use getEmailColorTags for multi-tag support.
*/
export function getEmailColorTag(keywords: Record<string, boolean> | undefined): string | null {
const tags = getEmailColorTags(keywords);
return tags.length > 0 ? tags[0] : null;
}
/**
+2 -2
View File
@@ -527,7 +527,7 @@ function buildContact(raw: Record<string, string[]>): ContactCard | null {
}
}
const hasName = card.name && card.name.components.length > 0;
const hasName = card.name && (card.name.components?.length ?? 0) > 0 || !!card.name?.full;
const hasEmail = card.emails && Object.keys(card.emails).length > 0;
if (!hasName && !hasEmail && card.kind !== "group") return null;
@@ -564,7 +564,7 @@ function generateSingleVCard(contact: ContactCard): string {
const suffix = components.find(c => c.kind === "suffix")?.value || "";
const additional = components.find(c => c.kind === "additional")?.value || "";
const fn = [prefix, given, additional, surname, suffix].filter(Boolean).join(" ");
const fn = [prefix, given, additional, surname, suffix].filter(Boolean).join(" ") || contact.name?.full || "";
if (fn) {
lines.push(`FN:${encodeValue(fn)}`);
lines.push(`N:${encodeValue(surname)};${encodeValue(given)};${encodeValue(additional)};${encodeValue(prefix)};${encodeValue(suffix)}`);