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
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:
@@ -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
60
lib/env.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
91
lib/env.ts
91
lib/env.ts
@@ -8,51 +8,64 @@ const preprocessEmptyString = (val: unknown) => (val === '' ? undefined : val);
|
|||||||
/**
|
/**
|
||||||
* Environment variable schema.
|
* Environment variable schema.
|
||||||
*/
|
*/
|
||||||
export const envSchema = z.object({
|
export const envSchema = z
|
||||||
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
|
.object({
|
||||||
NEXT_PUBLIC_BASE_URL: z.preprocess(preprocessEmptyString, z.string().url()),
|
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
|
||||||
NEXT_PUBLIC_TARGET: z.enum(['development', 'testing', 'staging', 'production']).optional(),
|
NEXT_PUBLIC_BASE_URL: z.preprocess(preprocessEmptyString, z.string().url()),
|
||||||
|
NEXT_PUBLIC_TARGET: z.enum(['development', 'testing', 'staging', 'production']).optional(),
|
||||||
|
|
||||||
// Analytics
|
// Analytics
|
||||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID: z.preprocess(preprocessEmptyString, z.string().optional()),
|
NEXT_PUBLIC_UMAMI_WEBSITE_ID: z.preprocess(preprocessEmptyString, z.string().optional()),
|
||||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL: z.preprocess(
|
NEXT_PUBLIC_UMAMI_SCRIPT_URL: z.preprocess(
|
||||||
preprocessEmptyString,
|
preprocessEmptyString,
|
||||||
z.string().url().default('https://analytics.infra.mintel.me/script.js'),
|
z.string().url().default('https://analytics.infra.mintel.me/script.js'),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Error Tracking
|
// Error Tracking
|
||||||
SENTRY_DSN: z.preprocess(preprocessEmptyString, z.string().optional()),
|
SENTRY_DSN: z.preprocess(preprocessEmptyString, z.string().optional()),
|
||||||
|
|
||||||
// Logging
|
// Logging
|
||||||
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
|
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
|
||||||
|
|
||||||
// Mail
|
// Mail
|
||||||
MAIL_HOST: z.preprocess(preprocessEmptyString, z.string().optional()),
|
MAIL_HOST: z.preprocess(preprocessEmptyString, z.string().optional()),
|
||||||
MAIL_PORT: z.preprocess(preprocessEmptyString, z.coerce.number().default(587)),
|
MAIL_PORT: z.preprocess(preprocessEmptyString, z.coerce.number().default(587)),
|
||||||
MAIL_USERNAME: z.preprocess(preprocessEmptyString, z.string().optional()),
|
MAIL_USERNAME: z.preprocess(preprocessEmptyString, z.string().optional()),
|
||||||
MAIL_PASSWORD: z.preprocess(preprocessEmptyString, z.string().optional()),
|
MAIL_PASSWORD: z.preprocess(preprocessEmptyString, z.string().optional()),
|
||||||
MAIL_FROM: z.preprocess(preprocessEmptyString, z.string().optional()),
|
MAIL_FROM: z.preprocess(preprocessEmptyString, z.string().optional()),
|
||||||
MAIL_RECIPIENTS: z.preprocess(
|
MAIL_RECIPIENTS: z.preprocess(
|
||||||
(val) => (typeof val === 'string' ? val.split(',').filter(Boolean) : val),
|
(val) => (typeof val === 'string' ? val.split(',').filter(Boolean) : val),
|
||||||
z.array(z.string()).default([]),
|
z.array(z.string()).default([]),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Directus
|
// Directus
|
||||||
DIRECTUS_URL: z.preprocess(
|
DIRECTUS_URL: z.preprocess(
|
||||||
preprocessEmptyString,
|
preprocessEmptyString,
|
||||||
z.string().url().default('http://localhost:8055'),
|
z.string().url().default('http://localhost:8055'),
|
||||||
),
|
),
|
||||||
DIRECTUS_ADMIN_EMAIL: z.preprocess(preprocessEmptyString, z.string().optional()),
|
DIRECTUS_ADMIN_EMAIL: z.preprocess(preprocessEmptyString, z.string().optional()),
|
||||||
DIRECTUS_ADMIN_PASSWORD: z.preprocess(preprocessEmptyString, z.string().optional()),
|
DIRECTUS_ADMIN_PASSWORD: z.preprocess(preprocessEmptyString, z.string().optional()),
|
||||||
DIRECTUS_API_TOKEN: z.preprocess(preprocessEmptyString, z.string().optional()),
|
DIRECTUS_API_TOKEN: z.preprocess(preprocessEmptyString, z.string().optional()),
|
||||||
INTERNAL_DIRECTUS_URL: z.preprocess(preprocessEmptyString, z.string().url().optional()),
|
INTERNAL_DIRECTUS_URL: z.preprocess(preprocessEmptyString, z.string().url().optional()),
|
||||||
|
|
||||||
// Deploy Target
|
// Deploy Target
|
||||||
TARGET: z.enum(['development', 'testing', 'staging', 'production']).optional(),
|
TARGET: z.enum(['development', 'testing', 'staging', 'production']).optional(),
|
||||||
// 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
59
lib/mail/mailer.test.ts
Normal 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.
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,15 +3,27 @@ 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;
|
||||||
host: config.mail.host,
|
|
||||||
port: config.mail.port,
|
function getTransporter() {
|
||||||
secure: config.mail.port === 465,
|
if (transporterInstance) return transporterInstance;
|
||||||
auth: {
|
|
||||||
user: config.mail.user,
|
if (!config.mail.host) {
|
||||||
pass: config.mail.pass,
|
throw new Error('MAIL_HOST is not configured. Please check your environment variables.');
|
||||||
},
|
}
|
||||||
});
|
|
||||||
|
transporterInstance = nodemailer.createTransport({
|
||||||
|
host: config.mail.host,
|
||||||
|
port: config.mail.port,
|
||||||
|
secure: config.mail.port === 465,
|
||||||
|
auth: {
|
||||||
|
user: config.mail.user,
|
||||||
|
pass: config.mail.pass,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return transporterInstance;
|
||||||
|
}
|
||||||
|
|
||||||
interface SendEmailOptions {
|
interface SendEmailOptions {
|
||||||
to?: string | string[];
|
to?: string | 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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user