Files
tessera-ctl/apps/api/src/settings/settings.service.ts
T
schalli 01ff137f43
Tessera CI/CD / Lint & Type Check (push) Waiting to run
Tessera CI/CD / Tests (push) Blocked by required conditions
Tessera CI/CD / Build & Deploy (push) Blocked by required conditions
fix(07): UAT fixes — SMTP test email, DKV routing, Exchange domain field
- SMTP: add test-to field, send real email via sendMail() instead of verify()
- SMTP: fix no_auth warning shown as error for open-relay servers
- DKV: register dkv-fleet in MODULE_REGISTRY (fixes "Modul nicht gefunden")
- DKV: add dynamic [category]/[moduleSlug]/settings + vehicles sub-routes
- DKV: settings/vehicles links use useParams for consistent URLs
- DKV: add gear icon settings link to module main page
- DKV: rename module to "DKV-Rechnung" in seed + translations
- DKV: add optional domain field for Exchange (WebCredentials 3rd arg)
- DKV: hide IMAP-only fields (Port/Verschlüsselung/Ordner) when Exchange selected
- DKV: hide Exchange-only field (Domain) when IMAP selected
- Prisma: add domain column to DkvModuleConfig

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-28 21:58:26 +02:00

213 lines
6.3 KiB
TypeScript

import { Injectable, Logger } from '@nestjs/common';
import { CalendarCryptoService } from '../calendar/crypto.service';
import { PrismaService } from '../prisma/prisma.service';
import { SmtpConfigDto } from './dto/smtp-config.dto';
import * as nodemailer from 'nodemailer';
/**
* Safe select for SmtpConfig rows — never returns the encrypted password to API callers.
* T-07-07: encryptedPassword is excluded from all GET responses.
*/
const SMTP_SAFE_SELECT = {
id: true,
tenantId: true,
host: true,
port: true,
encryption: true,
username: true,
// encryptedPassword: NEVER included — T-07-07
fromAddress: true,
createdAt: true,
updatedAt: true,
} as const;
@Injectable()
export class SettingsService {
private readonly logger = new Logger(SettingsService.name);
constructor(
private readonly prisma: PrismaService,
private readonly crypto: CalendarCryptoService,
) {}
/**
* Get the SMTP config for a tenant — safe (no password field).
* Returns null when no config row exists for the tenant.
*/
async getSmtpConfig(tenantId: string) {
return this.prisma.smtpConfig.findUnique({
where: { tenantId },
select: {
...SMTP_SAFE_SELECT,
// Include encryptedPassword presence for hasPassword boolean only
encryptedPassword: true,
},
});
}
/**
* Upsert the SMTP config for a tenant.
* Encrypts the password with AES-256-GCM when a new password is provided.
* When `dto.password` is empty or absent, the existing encrypted password is preserved.
*
* T-07-08: Encryption via CalendarCryptoService. Never logs the plaintext password.
*/
async saveSmtpConfig(tenantId: string, dto: SmtpConfigDto) {
// Determine the encrypted password to store
let encryptedPassword: string | undefined;
if (dto.password && dto.password.length > 0) {
encryptedPassword = this.crypto.encrypt(dto.password);
// T-07-10: Never log the plaintext password
}
const data = {
host: dto.host,
port: dto.port,
encryption: dto.encryption,
username: dto.username ?? null,
fromAddress: dto.fromAddress,
...(encryptedPassword !== undefined ? { encryptedPassword } : {}),
};
const result = await this.prisma.smtpConfig.upsert({
where: { tenantId },
create: { tenantId, ...data },
update: data,
select: SMTP_SAFE_SELECT,
});
return result;
}
/**
* Internal: Get the decrypted SMTP config for a tenant.
* Used by DkvMailService to build a nodemailer transport at send time.
* NEVER log the decrypted password (T-07-10 / T-05-13).
*/
async getDecryptedSmtpConfig(tenantId: string): Promise<{
host: string;
port: number;
encryption: string;
username: string | null;
fromAddress: string;
decryptedPassword: string | null;
} | null> {
const config = await this.prisma.smtpConfig.findUnique({
where: { tenantId },
});
if (!config) return null;
let decryptedPassword: string | null = null;
if (config.encryptedPassword) {
// T-05-13: Never log this value
decryptedPassword = this.crypto.decrypt(config.encryptedPassword);
}
return {
host: config.host,
port: config.port,
encryption: config.encryption,
username: config.username,
fromAddress: config.fromAddress,
decryptedPassword,
};
}
/**
* Test an SMTP connection using the submitted DTO.
* When `dto.password` is empty, uses the stored decrypted password instead.
* Returns true on success, false on failure.
*
* T-07-16: Returns only a boolean — no credentials or transport details in the response.
*/
async testSmtpConfig(
tenantId: string,
dto: SmtpConfigDto,
): Promise<{ success: boolean; warning?: string }> {
let password: string | undefined = dto.password;
let username: string | undefined = dto.username;
// Fall back to stored credentials (form never pre-fills password — T-07-17)
if (!password || !username) {
const stored = await this.getDecryptedSmtpConfig(tenantId);
if (stored) {
if (!password) password = stored.decryptedPassword ?? undefined;
if (!username) username = stored.username ?? undefined;
}
}
try {
const transport = nodemailer.createTransport({
host: dto.host,
port: dto.port,
secure: dto.encryption === 'ssl-tls',
requireTLS: dto.encryption === 'starttls',
connectionTimeout: 10_000,
greetingTimeout: 10_000,
socketTimeout: 10_000,
auth: username
? { user: username, pass: password ?? '' }
: undefined,
});
if (dto.testTo) {
await transport.sendMail({
from: dto.fromAddress,
to: dto.testTo,
subject: 'Tessera SMTP-Test',
text: 'Diese E-Mail bestätigt, dass die SMTP-Konfiguration in Tessera funktioniert.',
});
} else {
await transport.verify();
}
return { success: true };
} catch (error) {
this.logger.warn(
`SMTP connection test failed for tenant ${tenantId}: ${(error as Error).message}`,
);
return { success: false };
}
}
/**
* Tenant-agnostic startup accessor for the MailModule factory.
* Returns the first SmtpConfig row in the DB (single-tenant deployments) with
* the password decrypted. Returns null when no row exists (env-var fallback path).
*
* D-06: MailModule reads this at startup (priority 1) and falls back to env vars (priority 2).
* T-07-11: Decrypted password is used only to build the transport — never logged.
*/
async getStartupSmtpConfig(): Promise<{
host: string;
port: number;
secure: boolean;
requireTLS: boolean;
username: string | null;
password: string | null;
fromAddress: string;
} | null> {
const config = await this.prisma.smtpConfig.findFirst();
if (!config) return null;
let password: string | null = null;
if (config.encryptedPassword) {
// T-07-11: Used only to build transport at startup; never logged
password = this.crypto.decrypt(config.encryptedPassword);
}
return {
host: config.host,
port: config.port,
secure: config.encryption === 'ssl-tls',
requireTLS: config.encryption === 'starttls',
username: config.username,
password,
fromAddress: config.fromAddress,
};
}
}