Merge branch 'main' of https://github.com/bulwarkmail/webmail
This commit is contained in:
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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[]>;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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 --------------------------------
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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)}`);
|
||||
|
||||
Reference in New Issue
Block a user