Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,11 @@ JMAP_SERVER_URL=https://your-jmap-server.com
# /app/data/settings - mount a persistent volume there (see docker-compose.yml).
# SETTINGS_DATA_DIR=./data/settings

# Directory for encrypted signature image assets (default: ./data/signatures).
# Used when inserting images into HTML signatures. Requires SESSION_SECRET.
# Mount a persistent volume in Docker (see docker-compose.yml).
# SIGNATURE_DATA_DIR=./data/signatures

# =============================================================================
# Admin Dashboard Data
# =============================================================================
Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,9 +188,13 @@ SESSION_SECRET_FILE=/session-secret # path to a file containing the secret

SETTINGS_SYNC_ENABLED=true
SETTINGS_DATA_DIR=./data/settings # mount as a volume in Docker

# Persistent signature images (embedded as CID inline parts when sending).
# Requires SESSION_SECRET. Mount as a volume in Docker.
SIGNATURE_DATA_DIR=./data/signatures
```

Credentials are encrypted with AES-256-GCM and stored in an httpOnly cookie (30-day expiry). Settings sync stores per-account preferences encrypted at rest and requires `SESSION_SECRET`.
Credentials are encrypted with AES-256-GCM and stored in an httpOnly cookie (30-day expiry). Settings sync stores per-account preferences encrypted at rest and requires `SESSION_SECRET`. Signature images are stored separately from JMAP Identity signatures (which are size-limited on some servers) and are embedded into outgoing mail as inline MIME parts — they are not hosted as public URLs.

</details>

Expand Down
49 changes: 4 additions & 45 deletions app/api/settings/route.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,9 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { logger } from '@/lib/logger';
import { decryptSession } from '@/lib/auth/crypto';
import { sessionCookieName } from '@/lib/auth/session-cookie';
import { readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context';
import { saveUserSettings, loadUserSettings, deleteUserSettings } from '@/lib/settings-sync';
import { configManager } from '@/lib/admin/config-manager';
import { hasSessionSecret } from '@/lib/auth/session-secret';
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
import { verifyAccountIdentity } from '@/lib/auth/verify-account-identity';

function classifyError(error: unknown): { message: string; status: number } {
const code = (error as NodeJS.ErrnoException).code;
Expand Down Expand Up @@ -56,43 +52,6 @@ function isEnabled(): boolean {
return flagOn && hasSessionSecret();
}

/** Strip trailing slashes so differently-formatted URLs still match. */
function normalizeUrl(url: string): string {
return url.replace(/\/+$/, '');
}

/**
* Verify identity against session cookies across all account slots.
* With multi-account, the requesting account may be on any slot.
* Checks both basic-auth session cookies and stalwart auth context cookies
* (used by OAuth/SSO and TOTP-upgraded sessions).
* Returns true only if a matching cookie is found.
*/
async function verifyIdentity(username: string, serverUrl: string): Promise<boolean> {
const cookieStore = await cookies();
const normalizedServerUrl = normalizeUrl(serverUrl);

for (let slot = 0; slot < MAX_ACCOUNT_SLOTS; slot++) {
// Check basic-auth session cookie
const token = cookieStore.get(sessionCookieName(slot))?.value;
if (token) {
const session = decryptSession(token);
if (session && session.username === username && normalizeUrl(session.serverUrl) === normalizedServerUrl) {
return true;
}
}

// Check stalwart auth context cookie (set for all auth modes)
const ctx = readStalwartAuthContextFromStore(cookieStore, slot);
if (ctx && ctx.username === username && normalizeUrl(ctx.serverUrl) === normalizedServerUrl) {
return true;
}
}

// No matching session found (or no cookies at all) → reject
return false;
}

export async function GET(request: NextRequest) {
if (!isEnabled()) {
return NextResponse.json({ error: 'Settings sync is disabled' }, { status: 404 });
Expand All @@ -104,7 +63,7 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: 'Missing identity headers' }, { status: 400 });
}

if (!(await verifyIdentity(username, serverUrl))) {
if (!(await verifyAccountIdentity(username, serverUrl))) {
return NextResponse.json({ error: 'Identity mismatch' }, { status: 403 });
}

Expand Down Expand Up @@ -135,7 +94,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Settings must be an object' }, { status: 400 });
}

if (!(await verifyIdentity(username, serverUrl))) {
if (!(await verifyAccountIdentity(username, serverUrl))) {
return NextResponse.json({ error: 'Identity mismatch' }, { status: 403 });
}

Expand Down Expand Up @@ -188,7 +147,7 @@ export async function DELETE(request: NextRequest) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
}

if (!(await verifyIdentity(username, serverUrl))) {
if (!(await verifyAccountIdentity(username, serverUrl))) {
return NextResponse.json({ error: 'Identity mismatch' }, { status: 403 });
}

Expand Down
116 changes: 116 additions & 0 deletions app/api/signatures/assets/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { hasSessionSecret } from '@/lib/auth/session-secret';
import { verifyAccountIdentity } from '@/lib/auth/verify-account-identity';
import {
SignatureAssetError,
loadSignatureAsset,
deleteSignatureAsset,
} from '@/lib/signature-assets';

function classifyAssetError(error: unknown): { message: string; status: number } {
if (error instanceof SignatureAssetError) {
switch (error.code) {
case 'not_configured':
return { message: error.message, status: 503 };
case 'invalid_identity':
case 'invalid_asset_id':
case 'invalid_mime':
case 'too_large':
case 'too_many':
return { message: error.message, status: 400 };
case 'not_found':
return { message: error.message, status: 404 };
case 'forbidden':
return { message: error.message, status: 403 };
case 'path':
return { message: 'Invalid request', status: 400 };
}
}
const msg = error instanceof Error ? error.message : 'Unknown error';
return { message: `Internal server error: ${msg}`, status: 500 };
}

type RouteContext = { params: Promise<{ id: string }> };

/**
* GET /api/signatures/assets/:id
* Authenticated fetch of asset bytes for the composer / identity editor.
*/
export async function GET(request: NextRequest, context: RouteContext) {
if (!hasSessionSecret()) {
return NextResponse.json(
{ error: 'Signature image storage requires SESSION_SECRET' },
{ status: 503 },
);
}

const username = request.headers.get('x-settings-username');
const serverUrl = request.headers.get('x-settings-server');
if (!username || !serverUrl) {
return NextResponse.json({ error: 'Missing identity headers' }, { status: 400 });
}

if (!(await verifyAccountIdentity(username, serverUrl))) {
return NextResponse.json({ error: 'Identity mismatch' }, { status: 403 });
}

try {
const { id } = await context.params;
const { asset, bytes } = await loadSignatureAsset(username, serverUrl, id);
return new NextResponse(new Uint8Array(bytes), {
status: 200,
headers: {
'Content-Type': asset.mimeType,
'Content-Length': String(bytes.length),
'Content-Disposition': `inline; filename="${asset.filename.replace(/"/g, '')}"`,
'Cache-Control': 'private, no-store',
'X-Content-Type-Options': 'nosniff',
},
});
} catch (error) {
const classified = classifyAssetError(error);
if (classified.status >= 500) {
logger.error('Signature asset fetch error', {
error: error instanceof Error ? error.message : 'Unknown error',
});
}
return NextResponse.json({ error: classified.message }, { status: classified.status });
}
}

/**
* DELETE /api/signatures/assets/:id
*/
export async function DELETE(request: NextRequest, context: RouteContext) {
if (!hasSessionSecret()) {
return NextResponse.json(
{ error: 'Signature image storage requires SESSION_SECRET' },
{ status: 503 },
);
}

const username = request.headers.get('x-settings-username');
const serverUrl = request.headers.get('x-settings-server');
if (!username || !serverUrl) {
return NextResponse.json({ error: 'Missing identity headers' }, { status: 400 });
}

if (!(await verifyAccountIdentity(username, serverUrl))) {
return NextResponse.json({ error: 'Identity mismatch' }, { status: 403 });
}

try {
const { id } = await context.params;
await deleteSignatureAsset(username, serverUrl, id);
return NextResponse.json({ ok: true });
} catch (error) {
const classified = classifyAssetError(error);
if (classified.status >= 500) {
logger.error('Signature asset delete error', {
error: error instanceof Error ? error.message : 'Unknown error',
});
}
return NextResponse.json({ error: classified.message }, { status: classified.status });
}
}
136 changes: 136 additions & 0 deletions app/api/signatures/assets/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { hasSessionSecret } from '@/lib/auth/session-secret';
import { verifyAccountIdentity } from '@/lib/auth/verify-account-identity';
import {
SignatureAssetError,
listSignatureAssets,
saveSignatureAsset,
SIGNATURE_ASSET_MAX_BYTES,
} from '@/lib/signature-assets';

function classifyAssetError(error: unknown): { message: string; status: number } {
if (error instanceof SignatureAssetError) {
switch (error.code) {
case 'not_configured':
return { message: error.message, status: 503 };
case 'invalid_identity':
case 'invalid_asset_id':
case 'invalid_mime':
case 'too_large':
case 'too_many':
return { message: error.message, status: 400 };
case 'not_found':
return { message: error.message, status: 404 };
case 'forbidden':
return { message: error.message, status: 403 };
case 'path':
return { message: 'Invalid request', status: 400 };
}
}
const code = (error as NodeJS.ErrnoException).code;
if (code === 'EACCES' || code === 'EPERM') {
return {
message: 'Write permission denied on signature data directory.',
status: 500,
};
}
if (code === 'ENOSPC') {
return { message: 'No disk space available to save signature image.', status: 507 };
}
const msg = error instanceof Error ? error.message : 'Unknown error';
return { message: `Internal server error: ${msg}`, status: 500 };
}

function requireConfigured(): NextResponse | null {
if (!hasSessionSecret()) {
return NextResponse.json(
{ error: 'Signature image storage requires SESSION_SECRET' },
{ status: 503 },
);
}
return null;
}

/**
* GET /api/signatures/assets?identityId=...
* Headers: x-settings-username, x-settings-server (same as settings sync)
*/
export async function GET(request: NextRequest) {
const blocked = requireConfigured();
if (blocked) return blocked;

const username = request.headers.get('x-settings-username');
const serverUrl = request.headers.get('x-settings-server');
const identityId = request.nextUrl.searchParams.get('identityId');
if (!username || !serverUrl || !identityId) {
return NextResponse.json({ error: 'Missing identity headers or identityId' }, { status: 400 });
}

if (!(await verifyAccountIdentity(username, serverUrl))) {
return NextResponse.json({ error: 'Identity mismatch' }, { status: 403 });
}

try {
const assets = await listSignatureAssets(username, serverUrl, identityId);
return NextResponse.json({ assets });
} catch (error) {
const classified = classifyAssetError(error);
if (classified.status >= 500) {
logger.error('Signature asset list error', {
error: error instanceof Error ? error.message : 'Unknown error',
});
}
return NextResponse.json({ error: classified.message }, { status: classified.status });
}
}

/**
* POST /api/signatures/assets
* multipart/form-data: identityId, file
* Headers: x-settings-username, x-settings-server
*/
export async function POST(request: NextRequest) {
const blocked = requireConfigured();
if (blocked) return blocked;

const username = request.headers.get('x-settings-username');
const serverUrl = request.headers.get('x-settings-server');
if (!username || !serverUrl) {
return NextResponse.json({ error: 'Missing identity headers' }, { status: 400 });
}

if (!(await verifyAccountIdentity(username, serverUrl))) {
return NextResponse.json({ error: 'Identity mismatch' }, { status: 403 });
}

try {
const form = await request.formData();
const identityId = String(form.get('identityId') || '');
const file = form.get('file');
if (!identityId || !(file instanceof File)) {
return NextResponse.json({ error: 'identityId and file are required' }, { status: 400 });
}
if (file.size > SIGNATURE_ASSET_MAX_BYTES) {
return NextResponse.json(
{ error: `Image exceeds the ${SIGNATURE_ASSET_MAX_BYTES} byte limit` },
{ status: 400 },
);
}
const buffer = Buffer.from(await file.arrayBuffer());
const asset = await saveSignatureAsset(username, serverUrl, identityId, {
buffer,
filename: file.name || 'signature-image',
mimeType: file.type,
});
return NextResponse.json({ asset });
} catch (error) {
const classified = classifyAssetError(error);
if (classified.status >= 500) {
logger.error('Signature asset upload error', {
error: error instanceof Error ? error.message : 'Unknown error',
});
}
return NextResponse.json({ error: classified.message }, { status: classified.status });
}
}
Loading