Determine correct url for confirmation pages in mail templates.

This commit is contained in:
Hendrik Belitz
2025-08-22 13:49:22 +02:00
parent 151cbd5438
commit 91fb1fa115
13 changed files with 531 additions and 162 deletions
@@ -0,0 +1,313 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { generateBaseUrl } from "../email-service";
import type { SelectTenant } from "$lib/server/db/central-schema";
// Mock NODE_ENV
const mockEnv = vi.hoisted(() => ({ NODE_ENV: "development" }));
vi.mock("$env/dynamic/private", () => ({
env: mockEnv
}));
describe("generateBaseUrl", () => {
beforeEach(() => {
// Reset NODE_ENV to development for each test
mockEnv.NODE_ENV = "development";
});
afterEach(() => {
vi.clearAllMocks();
});
describe("Development/Local Environment", () => {
it("should return localhost URL regardless of tenant", () => {
const requestUrl = new URL("http://localhost:5173");
const tenant: SelectTenant = {
id: "tenant-1",
shortName: "acme",
longName: "ACME Corp",
primaryColor: "#000000",
backgroundColor: "#ffffff",
logo: null,
createdAt: new Date(),
updatedAt: new Date()
};
const result = generateBaseUrl(requestUrl, tenant);
expect(result).toBe("http://localhost:5173");
});
it("should return localhost URL for null tenant", () => {
const requestUrl = new URL("http://localhost:3000");
const result = generateBaseUrl(requestUrl, null);
expect(result).toBe("http://localhost:3000");
});
it("should preserve port for localhost", () => {
const requestUrl = new URL("http://localhost:8080");
const tenant: SelectTenant = {
id: "tenant-1",
shortName: "test",
longName: "Test Corp",
primaryColor: "#000000",
backgroundColor: "#ffffff",
logo: null,
createdAt: new Date(),
updatedAt: new Date()
};
const result = generateBaseUrl(requestUrl, tenant);
expect(result).toBe("http://localhost:8080");
});
it("should handle 127.x.x.x addresses", () => {
const requestUrl = new URL("http://127.0.0.1:3000");
const tenant: SelectTenant = {
id: "tenant-1",
shortName: "acme",
longName: "ACME Corp",
primaryColor: "#000000",
backgroundColor: "#ffffff",
logo: null,
createdAt: new Date(),
updatedAt: new Date()
};
const result = generateBaseUrl(requestUrl, tenant);
expect(result).toBe("http://127.0.0.1:3000");
});
it("should handle 192.168.x.x addresses", () => {
const requestUrl = new URL("http://192.168.1.100:8080");
const tenant: SelectTenant = {
id: "tenant-1",
shortName: "acme",
longName: "ACME Corp",
primaryColor: "#000000",
backgroundColor: "#ffffff",
logo: null,
createdAt: new Date(),
updatedAt: new Date()
};
const result = generateBaseUrl(requestUrl, tenant);
expect(result).toBe("http://192.168.1.100:8080");
});
});
describe("Production Environment", () => {
beforeEach(() => {
mockEnv.NODE_ENV = "production";
});
it("should return main domain for null tenant", () => {
const requestUrl = new URL("https://example.com");
const result = generateBaseUrl(requestUrl, null);
expect(result).toBe("https://example.com");
});
it("should return main domain with port for null tenant", () => {
const requestUrl = new URL("https://example.com:8443");
const result = generateBaseUrl(requestUrl, null);
expect(result).toBe("https://example.com:8443");
});
it("should create subdomain URL for tenant on main domain", () => {
const requestUrl = new URL("https://example.com");
const tenant: SelectTenant = {
id: "tenant-1",
shortName: "acme",
longName: "ACME Corp",
primaryColor: "#000000",
backgroundColor: "#ffffff",
logo: null,
createdAt: new Date(),
updatedAt: new Date()
};
const result = generateBaseUrl(requestUrl, tenant);
expect(result).toBe("https://acme.example.com");
});
it("should create subdomain URL with port", () => {
const requestUrl = new URL("https://example.com:8443");
const tenant: SelectTenant = {
id: "tenant-1",
shortName: "acme",
longName: "ACME Corp",
primaryColor: "#000000",
backgroundColor: "#ffffff",
logo: null,
createdAt: new Date(),
updatedAt: new Date()
};
const result = generateBaseUrl(requestUrl, tenant);
expect(result).toBe("https://acme.example.com:8443");
});
it("should replace existing subdomain with tenant shortName", () => {
const requestUrl = new URL("https://old-tenant.example.com");
const tenant: SelectTenant = {
id: "tenant-2",
shortName: "new-tenant",
longName: "New Tenant Corp",
primaryColor: "#000000",
backgroundColor: "#ffffff",
logo: null,
createdAt: new Date(),
updatedAt: new Date()
};
const result = generateBaseUrl(requestUrl, tenant);
expect(result).toBe("https://new-tenant.example.com");
});
it("should replace existing subdomain with port", () => {
const requestUrl = new URL("https://old-tenant.example.com:8443");
const tenant: SelectTenant = {
id: "tenant-2",
shortName: "new-tenant",
longName: "New Tenant Corp",
primaryColor: "#000000",
backgroundColor: "#ffffff",
logo: null,
createdAt: new Date(),
updatedAt: new Date()
};
const result = generateBaseUrl(requestUrl, tenant);
expect(result).toBe("https://new-tenant.example.com:8443");
});
it("should handle complex subdomains (keep last two parts)", () => {
const requestUrl = new URL("https://admin.api.example.com");
const tenant: SelectTenant = {
id: "tenant-1",
shortName: "tenant",
longName: "Tenant Corp",
primaryColor: "#000000",
backgroundColor: "#ffffff",
logo: null,
createdAt: new Date(),
updatedAt: new Date()
};
const result = generateBaseUrl(requestUrl, tenant);
expect(result).toBe("https://tenant.example.com");
});
it("should handle http protocol", () => {
const requestUrl = new URL("http://example.com");
const tenant: SelectTenant = {
id: "tenant-1",
shortName: "acme",
longName: "ACME Corp",
primaryColor: "#000000",
backgroundColor: "#ffffff",
logo: null,
createdAt: new Date(),
updatedAt: new Date()
};
const result = generateBaseUrl(requestUrl, tenant);
expect(result).toBe("http://acme.example.com");
});
it("should return main domain when tenant has no shortName", () => {
const requestUrl = new URL("https://example.com");
const tenant: SelectTenant = {
id: "tenant-1",
shortName: "", // Empty shortName
longName: "ACME Corp",
primaryColor: "#000000",
backgroundColor: "#ffffff",
logo: null,
createdAt: new Date(),
updatedAt: new Date()
};
const result = generateBaseUrl(requestUrl, tenant);
expect(result).toBe("https://example.com");
});
});
describe("Edge Cases", () => {
it("should handle single domain names in production", () => {
mockEnv.NODE_ENV = "production";
const requestUrl = new URL("https://app");
const tenant: SelectTenant = {
id: "tenant-1",
shortName: "acme",
longName: "ACME Corp",
primaryColor: "#000000",
backgroundColor: "#ffffff",
logo: null,
createdAt: new Date(),
updatedAt: new Date()
};
const result = generateBaseUrl(requestUrl, tenant);
expect(result).toBe("https://acme.app");
});
it("should handle localhost in production (still treated as development)", () => {
mockEnv.NODE_ENV = "production";
const requestUrl = new URL("https://localhost:8443");
const tenant: SelectTenant = {
id: "tenant-1",
shortName: "acme",
longName: "ACME Corp",
primaryColor: "#000000",
backgroundColor: "#ffffff",
logo: null,
createdAt: new Date(),
updatedAt: new Date()
};
const result = generateBaseUrl(requestUrl, tenant);
expect(result).toBe("https://localhost:8443");
});
it("should handle IP addresses as development", () => {
mockEnv.NODE_ENV = "production";
const requestUrl = new URL("https://192.168.1.100:8443");
const tenant: SelectTenant = {
id: "tenant-1",
shortName: "acme",
longName: "ACME Corp",
primaryColor: "#000000",
backgroundColor: "#ffffff",
logo: null,
createdAt: new Date(),
updatedAt: new Date()
};
const result = generateBaseUrl(requestUrl, tenant);
expect(result).toBe("https://192.168.1.100:8443");
});
it("should handle default HTTP port (80)", () => {
mockEnv.NODE_ENV = "production";
const requestUrl = new URL("http://example.com:80");
// URL constructor should handle port 80 correctly
const result = generateBaseUrl(requestUrl, null);
// Default HTTP port shouldn't be included in URL
expect(result).toBe("http://example.com");
});
it("should handle default HTTPS port (443)", () => {
mockEnv.NODE_ENV = "production";
const requestUrl = new URL("https://example.com:443");
// URL constructor should handle port 443 correctly
const result = generateBaseUrl(requestUrl, null);
// Default HTTPS port shouldn't be included in URL
expect(result).toBe("https://example.com");
});
});
});
+47 -2
View File
@@ -187,12 +187,47 @@ export async function sendAppointmentUpdatedEmail(
});
}
/**
* Generate base URL for email templates based on request URL and tenant
* @param {URL} requestUrl - The request URL
* @param {SelectTenant | null} tenant - Tenant information (null for global admin)
* @returns {string} The appropriate base URL
*/
export function generateBaseUrl(requestUrl: URL, tenant: SelectTenant | null): string {
const protocol = requestUrl.protocol;
const port = requestUrl.port ? `:${requestUrl.port}` : '';
const hostname = requestUrl.hostname;
// In development, always use the original hostname regardless of tenant
if (hostname === 'localhost' || hostname.startsWith('127.') || hostname.startsWith('192.168.')) {
return `${protocol}//${hostname}${port}`;
}
// In production, handle tenant subdomains
if (tenant?.shortName) {
const parts = hostname.split('.');
if (parts.length > 2) {
// Complex subdomain - use only the last two parts (domain.tld) and add tenant
const domain = parts.slice(-2).join('.');
return `${protocol}//${tenant.shortName}.${domain}${port}`;
} else {
// Main domain, prepend tenant subdomain
return `${protocol}//${tenant.shortName}.${hostname}${port}`;
}
}
// For global admin or no tenant, use main domain
return `${protocol}//${hostname}${port}`;
}
/**
* Send registration confirmation email with one-time code
* @param {SelectClient | SelectStaff} user - Database user object
* @param {SelectTenant} tenant - Tenant information for branding
* @param {string} confirmationCode - One-time confirmation code
* @param {number} [expirationMinutes=15] - Code expiration time in minutes
* @param {URL} [requestUrl] - Request URL for generating baseUrl
* @throws {Error} When email sending fails
* @returns {Promise<void>}
*/
@@ -200,13 +235,23 @@ export async function sendConfirmationEmail(
user: { id: string; email: string | null; name: string | null; language?: string | null },
tenant: SelectTenant,
confirmationCode: string,
expirationMinutes: number = 15
expirationMinutes: number = 15,
requestUrl?: URL
): Promise<void> {
const recipient = createEmailRecipient(user);
const language = (recipient.language as Language) || "en";
const subject = language === "en" ? "Confirm Your Registration" : "Registrierung bestätigen";
await sendTemplatedEmail("confirmation", recipient, subject, language, tenant, {
// Generate appropriate base URL if request URL is provided
const baseUrl = requestUrl ? generateBaseUrl(requestUrl, tenant) : 'http://localhost:5173';
// Create enhanced tenant object with baseUrl
const tenantWithBaseUrl = {
...tenant,
baseUrl
};
await sendTemplatedEmail("confirmation", recipient, subject, language, tenantWithBaseUrl, {
confirmationCode,
expirationMinutes
});
@@ -1,173 +1,174 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Registrierung bestätigen</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
line-height: 1.6;
margin: 0;
padding: 20px;
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Registrierung bestätigen</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
line-height: 1.6;
margin: 0;
padding: 20px;
background-color: {
{
tenant.backgroundColor
background-color: {
{
tenant.backgroundColor
}
}
;
}
;
}
.container {
max-width: 600px;
margin: 0 auto;
background: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.header {
background-color: {
{
tenant.primaryColor
}
.container {
max-width: 600px;
margin: 0 auto;
background: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
;
color: white;
padding: 30px;
text-align: center;
}
.logo {
color: white;
font-size: 24px;
font-weight: bold;
margin-bottom: 10px;
}
.content {
padding: 30px;
}
.confirmation-code {
background-color: #f8f9fa;
border: 2px solid {
{
tenant.primaryColor
.header {
background-color: {
{
tenant.primaryColor
}
}
;
color: white;
padding: 30px;
text-align: center;
}
;
border-radius: 8px;
padding: 20px;
text-align: center;
margin: 20px 0;
}
.code {
font-family: 'Courier New', monospace;
font-size: 32px;
font-weight: bold;
color: {
{
tenant.primaryColor
}
.logo {
color: white;
font-size: 24px;
font-weight: bold;
margin-bottom: 10px;
}
;
letter-spacing: 4px;
margin: 10px 0;
}
.footer {
background-color: #f8f9fa;
padding: 20px;
text-align: center;
font-size: 14px;
color: #666;
}
.footer a {
color: {
{
tenant.primaryColor
}
.content {
padding: 30px;
}
;
text-decoration: none;
}
.confirmation-code {
background-color: #f8f9fa;
.warning {
background-color: #fff3cd;
border: 1px solid #ffeaa7;
border-radius: 4px;
padding: 15px;
margin: 20px 0;
color: #856404;
}
</style>
</head>
border: 2px solid {
{
tenant.primaryColor
}
}
<body>
<div class="container">
<div class="header">
{{#if tenant.logo}}
<img src="data:image/png;base64,{{tenant.logo}}" alt="{{tenant.longName}}"
style="max-height: 60px; margin-bottom: 10px" />
{{/if}}
<div class="logo">{{tenant.longName}}</div>
<h1 style="margin: 0; font-size: 28px">Registrierung bestätigen</h1>
</div>
;
border-radius: 8px;
padding: 20px;
text-align: center;
margin: 20px 0;
}
<div class="content">
<p>Hallo,</p>
.code {
font-family: 'Courier New', monospace;
font-size: 32px;
font-weight: bold;
<p>
vielen Dank für Ihre Registrierung bei <strong>{{tenant.longName}}</strong>. Um Ihre
Registrierung abzuschließen, verwenden Sie bitte den folgenden Bestätigungscode:
</p>
color: {
{
tenant.primaryColor
}
}
<div class="confirmation-code">
<div>Ihr Bestätigungscode:</div>
<div class="code">{{confirmationCode}}</div>
<div style="font-size: 14px; color: #666; margin-top: 10px">
Geben Sie diesen Code in das Bestätigungsfeld ein
;
letter-spacing: 4px;
margin: 10px 0;
}
.footer {
background-color: #f8f9fa;
padding: 20px;
text-align: center;
font-size: 14px;
color: #666;
}
.footer a {
color: {
{
tenant.primaryColor
}
}
;
text-decoration: none;
}
.warning {
background-color: #fff3cd;
border: 1px solid #ffeaa7;
border-radius: 4px;
padding: 15px;
margin: 20px 0;
color: #856404;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
{{#if tenant.logo}}
<img
src="data:image/png;base64,{{tenant.logo}}"
alt="{{tenant.longName}}"
style="max-height: 60px; margin-bottom: 10px"
/>
{{/if}}
<div class="logo">{{tenant.longName}}</div>
<h1 style="margin: 0; font-size: 28px">Registrierung bestätigen</h1>
</div>
<div class="content">
<p>Hallo,</p>
<p>
vielen Dank für Ihre Registrierung bei <strong>{{tenant.longName}}</strong>. Um Ihre
Registrierung abzuschließen, verwenden Sie bitte den folgenden Bestätigungscode:
</p>
<div class="confirmation-code">
<div>Ihr Bestätigungscode:</div>
<div class="code">{{confirmationCode}}</div>
<div style="font-size: 14px; color: #666; margin-top: 10px">
Geben Sie diesen Code in das Bestätigungsfeld ein
</div>
<a href="{{tenant.baseUrl}}/confirm/{{confirmationCode}}">Oder hier klicken.</a>
</div>
{{tenant.baseUrl}}/confirm/{{confirmationCode}}
<div class="warning">
<strong>Wichtiger Hinweis:</strong> Dieser Code ist nur für
<strong>{{expirationMinutes}} Minuten</strong> gültig und kann nur einmal verwendet
werden.
</div>
<p>
Falls Sie sich nicht bei {{tenant.longName}} registriert haben, können Sie diese E-Mail
ignorieren.
</p>
<p>Bei Fragen wenden Sie sich gerne an unser Support-Team.</p>
<p>
Mit freundlichen Grüßen<br />
Das {{tenant.longName}} Team
</p>
</div>
<div class="warning">
<strong>Wichtiger Hinweis:</strong> Dieser Code ist nur für
<strong>{{expirationMinutes}} Minuten</strong> gültig und kann nur einmal verwendet
werden.
<div class="footer">
<p>Diese E-Mail wurde automatisch generiert. Bitte antworten Sie nicht auf diese E-Mail.</p>
<p>&copy; {{tenant.longName}} - Sicher, verschlüsselt, vertrauenswürdig</p>
</div>
<p>
Falls Sie sich nicht bei {{tenant.longName}} registriert haben, können Sie diese E-Mail
ignorieren.
</p>
<p>Bei Fragen wenden Sie sich gerne an unser Support-Team.</p>
<p>
Mit freundlichen Grüßen<br />
Das {{tenant.longName}} Team
</p>
</div>
<div class="footer">
<p>Diese E-Mail wurde automatisch generiert. Bitte antworten Sie nicht auf diese E-Mail.</p>
<p>&copy; {{tenant.longName}} - Sicher, verschlüsselt, vertrauenswürdig</p>
</div>
</div>
</body>
</html>
</body>
</html>
@@ -10,6 +10,8 @@ vielen Dank für Ihre Registrierung bei {{tenant.longName}}. Um Ihre Registrieru
Geben Sie diesen Code in das Bestätigungsfeld ein, um Ihre Registrierung zu vervollständigen.
Alternativ können Sie direkt diese Seite besuchen: {{tenant.baseUrl}}/confirm/{{confirmationCode}}
WICHTIGER HINWEIS: Dieser Code ist nur für {{expirationMinutes}} Minuten gültig und kann nur einmal verwendet werden.
Falls Sie sich nicht bei {{tenant.longName}} registriert haben, können Sie diese E-Mail ignorieren.
@@ -100,6 +100,7 @@
<div style="font-size: 14px; color: #666; margin-top: 10px">
Enter this code in the confirmation field
</div>
<a href="{{tenant.baseUrl}}/confirm/{{confirmationCode}}">Or simply click here.</a>
</div>
<div class="warning">
@@ -10,6 +10,8 @@ Thank you for registering with {{tenant.longName}}. To complete your registratio
Enter this code in the confirmation field to complete your registration.
Or simply visit this site: {{tenant.baseUrl}}/confirm/{{confirmationCode}}
IMPORTANT NOTICE: This code is valid for {{expirationMinutes}} minutes only and can be used only once.
If you did not register with {{tenant.longName}}, you can safely ignore this email.
+7 -4
View File
@@ -71,7 +71,7 @@ export class UserService {
/**
* Create a new user
*/
static async createUser(userData: UserCreation) {
static async createUser(userData: UserCreation, requestUrl?: URL) {
const log = logger.setContext("UserService");
log.debug("Creating new user account", {
email: userData.email,
@@ -138,7 +138,8 @@ export class UserService {
result[0],
tenant,
result[0].token,
10 // 10 minutes expiration to match tokenValidUntil
10, // 10 minutes expiration to match tokenValidUntil
requestUrl
);
log.debug("Confirmation email sent successfully", {
userId: result[0].id,
@@ -165,8 +166,9 @@ export class UserService {
/**
* Resend the confirmation email for a user
* @param email - Email of the user to confirm
* @param requestUrl - Optional request URL for generating correct baseUrl
*/
static async resendConfirmationEmail(email: string): Promise<void> {
static async resendConfirmationEmail(email: string, requestUrl?: URL): Promise<void> {
const log = logger.setContext("UserService");
log.debug("Resending confirmation email", { email });
@@ -195,7 +197,8 @@ export class UserService {
user,
tenant,
token,
10 // 10 minutes expiration to match tokenValidUntil
10, // 10 minutes expiration to match tokenValidUntil
requestUrl
);
log.debug("Confirmation email sent successfully", {
userId: user.id,
@@ -24,7 +24,7 @@ export const actions: Actions = {
});
}
await UserService.resendConfirmationEmail(form.data.email);
await UserService.resendConfirmationEmail(form.data.email, event.url);
log.debug("Resent confirmation e-mail", {
email: form.data.email
@@ -30,7 +30,7 @@ export const actions: Actions = {
email: form.data.email,
passphrase: form.data.passphrase,
language: form.data.language
});
}, event.url);
const hasPasskey = false;
log.debug("Admin account created successfully", {
+2 -2
View File
@@ -105,7 +105,7 @@ registerOpenAPIRoute("/admin/init", "POST", {
}
});
export const POST: RequestHandler = async ({ request, cookies }) => {
export const POST: RequestHandler = async ({ request, cookies, url }) => {
const log = logger.setContext("API");
try {
@@ -140,7 +140,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
email: body.email,
passphrase: body.passphrase, // Will be undefined if passkey is used
language: body.language || "de"
});
}, url);
// Add the passkey to the admin account if provided
if (hasPasskey) {
+2 -2
View File
@@ -123,7 +123,7 @@ registerOpenAPIRoute("/auth/register", "POST", {
}
});
export const POST: RequestHandler = async ({ request, cookies }) => {
export const POST: RequestHandler = async ({ request, cookies, url }) => {
const log = logger.setContext("API");
try {
@@ -187,7 +187,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
tenantId: finalTenantId,
passphrase: body.passphrase,
language: inviteUsed?.language || body.language || "de"
});
}, url);
// Add the passkey to the user account if provided
if (body.passkey) {
@@ -68,12 +68,12 @@ registerOpenAPIRoute("/auth/resend-confirmation", "POST", {
}
});
export const POST: RequestHandler = async ({ request }) => {
export const POST: RequestHandler = async ({ request, url }) => {
try {
const body = await request.json();
// Resend confirmation email
await UserService.resendConfirmationEmail(body.email);
await UserService.resendConfirmationEmail(body.email, url);
return json(
{
+2
View File
@@ -104,6 +104,8 @@ export const authHandle: Handle = async ({ event, resolve }) => {
sessionId: sessionData.sessionId
};
logger.debug(`Added user information for ${sessionData.user.id}`);
if (isGlobalAdminPath && !AuthorizationService.hasRole(sessionData.user, "GLOBAL_ADMIN")) {
return new Response(JSON.stringify({ error: "Authentication failed" }), {
status: 403,