feat: Add conditional MAIL_HOST validation, lazy-load mailer, and update Gitea workflow to use vars for mail and Sentry environment variables.
Some checks failed
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 14s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Successful in 1m44s
Build & Deploy KLZ Cables / 🏗️ Build App (push) Failing after 1m54s
Build & Deploy KLZ Cables / 🏗️ Build Gatekeeper (push) Successful in 31s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Has been skipped
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Has been skipped
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 2s

This commit is contained in:
2026-02-05 12:39:51 +01:00
parent e8957e0672
commit db4cf354ff
5 changed files with 199 additions and 55 deletions

View File

@@ -296,13 +296,13 @@ jobs:
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_base_url }} NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_base_url }}
NEXT_PUBLIC_UMAMI_WEBSITE_ID: ${{ needs.prepare.outputs.target == 'production' && secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_NEXT_PUBLIC_UMAMI_WEBSITE_ID || secrets.TESTING_NEXT_PUBLIC_UMAMI_WEBSITE_ID || secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID) }} NEXT_PUBLIC_UMAMI_WEBSITE_ID: ${{ needs.prepare.outputs.target == 'production' && secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_NEXT_PUBLIC_UMAMI_WEBSITE_ID || secrets.TESTING_NEXT_PUBLIC_UMAMI_WEBSITE_ID || secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID) }}
NEXT_PUBLIC_UMAMI_SCRIPT_URL: ${{ needs.prepare.outputs.target == 'production' && secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_NEXT_PUBLIC_UMAMI_SCRIPT_URL || secrets.TESTING_NEXT_PUBLIC_UMAMI_SCRIPT_URL || secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL) }} NEXT_PUBLIC_UMAMI_SCRIPT_URL: ${{ needs.prepare.outputs.target == 'production' && secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_NEXT_PUBLIC_UMAMI_SCRIPT_URL || secrets.TESTING_NEXT_PUBLIC_UMAMI_SCRIPT_URL || secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL) }}
SENTRY_DSN: ${{ needs.prepare.outputs.target == 'production' && secrets.SENTRY_DSN || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_SENTRY_DSN || secrets.TESTING_SENTRY_DSN || secrets.SENTRY_DSN) }} SENTRY_DSN: ${{ secrets.SENTRY_DSN || vars.SENTRY_DSN || (needs.prepare.outputs.target == 'production' && secrets.SENTRY_DSN || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_SENTRY_DSN || secrets.TESTING_SENTRY_DSN || secrets.SENTRY_DSN)) }}
MAIL_HOST: ${{ secrets.MAIL_HOST }} MAIL_HOST: ${{ secrets.MAIL_HOST || vars.MAIL_HOST }}
MAIL_PORT: ${{ secrets.MAIL_PORT }} MAIL_PORT: ${{ secrets.MAIL_PORT || vars.MAIL_PORT }}
MAIL_USERNAME: ${{ secrets.MAIL_USERNAME }} MAIL_USERNAME: ${{ secrets.MAIL_USERNAME || vars.MAIL_USERNAME }}
MAIL_PASSWORD: ${{ secrets.MAIL_PASSWORD }} MAIL_PASSWORD: ${{ secrets.MAIL_PASSWORD }}
MAIL_FROM: ${{ secrets.MAIL_FROM }} MAIL_FROM: ${{ secrets.MAIL_FROM || vars.MAIL_FROM }}
MAIL_RECIPIENTS: ${{ secrets.MAIL_RECIPIENTS }} MAIL_RECIPIENTS: ${{ secrets.MAIL_RECIPIENTS || vars.MAIL_RECIPIENTS }}
DIRECTUS_URL: ${{ needs.prepare.outputs.directus_url }} DIRECTUS_URL: ${{ needs.prepare.outputs.directus_url }}
DIRECTUS_HOST: ${{ needs.prepare.outputs.directus_host }} DIRECTUS_HOST: ${{ needs.prepare.outputs.directus_host }}
PROJECT_NAME: ${{ needs.prepare.outputs.project_name }} PROJECT_NAME: ${{ needs.prepare.outputs.project_name }}

60
lib/env.test.ts Normal file
View File

@@ -0,0 +1,60 @@
import { describe, it, expect } from 'vitest';
import { envSchema } from './env';
describe('envSchema', () => {
it('should allow missing MAIL_HOST in development', () => {
const result = envSchema.safeParse({
NEXT_PUBLIC_BASE_URL: 'http://localhost:3000',
TARGET: 'development',
});
expect(result.success).toBe(true);
});
it('should require MAIL_HOST in production', () => {
const result = envSchema.safeParse({
NEXT_PUBLIC_BASE_URL: 'https://example.com',
TARGET: 'production',
});
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(
'MAIL_HOST is required in non-development environments',
);
}
});
it('should require MAIL_HOST in testing', () => {
const result = envSchema.safeParse({
NEXT_PUBLIC_BASE_URL: 'https://testing.example.com',
TARGET: 'testing',
});
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(
'MAIL_HOST is required in non-development environments',
);
}
});
it('should require MAIL_HOST in staging', () => {
const result = envSchema.safeParse({
NEXT_PUBLIC_BASE_URL: 'https://staging.example.com',
TARGET: 'staging',
});
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(
'MAIL_HOST is required in non-development environments',
);
}
});
it('should pass if MAIL_HOST is provided in production', () => {
const result = envSchema.safeParse({
NEXT_PUBLIC_BASE_URL: 'https://example.com',
TARGET: 'production',
MAIL_HOST: 'smtp.example.com',
});
expect(result.success).toBe(true);
});
});

View File

@@ -8,7 +8,8 @@ const preprocessEmptyString = (val: unknown) => (val === '' ? undefined : val);
/** /**
* Environment variable schema. * Environment variable schema.
*/ */
export const envSchema = z.object({ export const envSchema = z
.object({
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
NEXT_PUBLIC_BASE_URL: z.preprocess(preprocessEmptyString, z.string().url()), NEXT_PUBLIC_BASE_URL: z.preprocess(preprocessEmptyString, z.string().url()),
NEXT_PUBLIC_TARGET: z.enum(['development', 'testing', 'staging', 'production']).optional(), NEXT_PUBLIC_TARGET: z.enum(['development', 'testing', 'staging', 'production']).optional(),
@@ -52,6 +53,18 @@ export const envSchema = z.object({
// Gotify // Gotify
GOTIFY_URL: z.preprocess(preprocessEmptyString, z.string().url().optional()), GOTIFY_URL: z.preprocess(preprocessEmptyString, z.string().url().optional()),
GOTIFY_TOKEN: z.preprocess(preprocessEmptyString, z.string().optional()), GOTIFY_TOKEN: z.preprocess(preprocessEmptyString, z.string().optional()),
})
.superRefine((data, ctx) => {
const target = data.NEXT_PUBLIC_TARGET || data.TARGET;
const isDev = target === 'development' || !target;
if (!isDev && !data.MAIL_HOST) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'MAIL_HOST is required in non-development environments',
path: ['MAIL_HOST'],
});
}
}); });
export type Env = z.infer<typeof envSchema>; export type Env = z.infer<typeof envSchema>;

59
lib/mail/mailer.test.ts Normal file
View File

@@ -0,0 +1,59 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { sendEmail } from './mailer';
import { config } from '../config';
// Mock getServerAppServices to avoid full app initialization
vi.mock('@/lib/services/create-services.server', () => ({
getServerAppServices: () => ({
logger: {
child: () => ({
info: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
}),
},
}),
}));
// Mock config
vi.mock('../config', () => ({
config: {
mail: {
host: 'smtp.example.com',
port: 587,
user: 'user',
pass: 'pass',
from: 'from@example.com',
recipients: ['to@example.com'],
},
},
getConfig: vi.fn(),
}));
describe('mailer', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('sendEmail', () => {
it('should throw error if MAIL_HOST is missing', async () => {
// Temporarily nullify host
const originalHost = config.mail.host;
(config.mail as any).host = '';
const result = await sendEmail({
subject: 'Test',
html: '<p>Test</p>',
});
expect(result.success).toBe(false);
expect((result.error as Error).message).toContain('MAIL_HOST is not configured');
// Restore host
(config.mail as any).host = originalHost;
});
// In a real environment, we'd mock nodemailer, but for now we focus on the validation logic
// we added. Full SMTP integration tests are usually out of scope for unit tests.
});
});

View File

@@ -3,7 +3,16 @@ import { getServerAppServices } from '@/lib/services/create-services.server';
import { config } from '../config'; import { config } from '../config';
import { ReactElement } from 'react'; import { ReactElement } from 'react';
const transporter = nodemailer.createTransport({ let transporterInstance: nodemailer.Transporter | null = null;
function getTransporter() {
if (transporterInstance) return transporterInstance;
if (!config.mail.host) {
throw new Error('MAIL_HOST is not configured. Please check your environment variables.');
}
transporterInstance = nodemailer.createTransport({
host: config.mail.host, host: config.mail.host,
port: config.mail.port, port: config.mail.port,
secure: config.mail.port === 465, secure: config.mail.port === 465,
@@ -13,6 +22,9 @@ const transporter = nodemailer.createTransport({
}, },
}); });
return transporterInstance;
}
interface SendEmailOptions { interface SendEmailOptions {
to?: string | string[]; to?: string | string[];
subject: string; subject: string;
@@ -32,7 +44,7 @@ export async function sendEmail({ to, subject, html }: SendEmailOptions) {
const logger = getServerAppServices().logger.child({ component: 'mailer' }); const logger = getServerAppServices().logger.child({ component: 'mailer' });
try { try {
const info = await transporter.sendMail(mailOptions); const info = await getTransporter().sendMail(mailOptions);
logger.info('Email sent successfully', { messageId: info.messageId, subject, recipients }); logger.info('Email sent successfully', { messageId: info.messageId, subject, recipients });
return { success: true, messageId: info.messageId }; return { success: true, messageId: info.messageId };
} catch (error) { } catch (error) {