feat(mail): clarify mailgun configuration feedback

This commit is contained in:
William Valentin
2025-09-23 10:30:12 -07:00
parent 71c37f4b7b
commit e7dbe30763
3 changed files with 246 additions and 42 deletions

View File

@@ -193,6 +193,18 @@ DEV_API_URL=http://localhost:5984
| `COUCHDB_USERNAME` | `admin` | Database username | | `COUCHDB_USERNAME` | `admin` | Database username |
| `COUCHDB_PASSWORD` | - | Database password (required) | | `COUCHDB_PASSWORD` | - | Database password (required) |
### Email (Mailgun) Variables
| Variable | Default | Description |
| ------------------------- | ---------------------------- | ------------------------------------------------------------------------------ |
| `VITE_MAILGUN_API_KEY` | _required_ | Mailgun API key used for authenticated requests |
| `VITE_MAILGUN_DOMAIN` | _required_ | Mailgun sending domain (e.g. `mg.yourdomain.com`) |
| `VITE_MAILGUN_BASE_URL` | `https://api.mailgun.net/v3` | Mailgun REST API base URL |
| `VITE_MAILGUN_FROM_NAME` | `Medication Reminder` | Friendly name used in the `from` header |
| `VITE_MAILGUN_FROM_EMAIL` | _required_ | Email address used in the `from` header (must belong to the configured domain) |
> **Tip:** When any required Mailgun variables are missing, the application falls back to a development mode that logs email previews instead of sending real messages. Configure the variables above in `.env.local` (git ignored) before testing real email flows.
### Network & Ingress Variables ### Network & Ingress Variables
| Variable | Default | Description | | Variable | Default | Description |
@@ -243,6 +255,20 @@ DEV_API_URL=http://localhost:5984
| `API_SECRET_KEY` | - | API secret key | | `API_SECRET_KEY` | - | API secret key |
| `JWT_SECRET` | - | JWT signing secret | | `JWT_SECRET` | - | JWT signing secret |
### Bootstrap Admin Variables
These variables control the default admin account created/updated at app startup by the frontend seeder. They are read at build-time (Vite), so changing them requires rebuilding the frontend image.
| Variable | Default | Description |
| --------------------- | ----------------- | ----------------------------------- |
| `VITE_ADMIN_EMAIL` | `admin@localhost` | Email of the default admin user |
| `VITE_ADMIN_PASSWORD` | `admin123!` | Password for the default admin user |
Notes:
- To change these in Docker, set build args in `docker-compose.yaml` or define them in `.env` and rebuild: `docker compose build frontend && docker compose up -d`.
- The seeder is idempotent: if a user with this email exists, it updates role/status and keeps the latest password you set.
## Usage Examples ## Usage Examples
### Basic Development Setup ### Basic Development Setup

View File

@@ -1,15 +1,17 @@
// Mock the mailgun config before any imports // Mock the mailgun config before any imports
const mockGetMailgunConfig = jest.fn().mockReturnValue({ jest.mock('../mailgun.config', () => {
const defaultConfig = {
apiKey: 'test-api-key', apiKey: 'test-api-key',
domain: 'test.mailgun.org', domain: 'test.mailgun.org',
baseUrl: 'https://api.mailgun.net/v3', baseUrl: 'https://api.mailgun.net/v3',
fromName: 'Test App', fromName: 'Test App',
fromEmail: 'test@example.com', fromEmail: 'test@example.com',
}); };
jest.mock('../mailgun.config', () => ({ return {
getMailgunConfig: mockGetMailgunConfig, getMailgunConfig: jest.fn(() => defaultConfig),
})); };
});
// Mock the app config // Mock the app config
jest.mock('../../config/unified.config', () => ({ jest.mock('../../config/unified.config', () => ({
@@ -18,6 +20,7 @@ jest.mock('../../config/unified.config', () => ({
baseUrl: 'http://localhost:3000', baseUrl: 'http://localhost:3000',
}, },
}, },
getAppConfig: jest.fn(() => ({ baseUrl: 'http://localhost:3000' })),
})); }));
// Mock global fetch and related APIs // Mock global fetch and related APIs
@@ -32,10 +35,28 @@ global.btoa = jest
// Import the service after mocks are set up // Import the service after mocks are set up
import { MailgunService } from '../mailgun.service'; import { MailgunService } from '../mailgun.service';
import { getMailgunConfig } from '../mailgun.config';
import { logger } from '../logging';
const mockGetMailgunConfig = getMailgunConfig as jest.MockedFunction<
typeof getMailgunConfig
>;
mockGetMailgunConfig.mockReturnValue({
apiKey: 'test-api-key',
domain: 'test.mailgun.org',
baseUrl: 'https://api.mailgun.net/v3',
fromName: 'Test App',
fromEmail: 'test@example.com',
});
describe('MailgunService', () => { describe('MailgunService', () => {
let mockFetch: jest.MockedFunction<typeof fetch>; let mockFetch: jest.MockedFunction<typeof fetch>;
let mockFormData: jest.MockedFunction<any>; let mockFormData: jest.MockedFunction<any>;
let warnSpy: jest.SpyInstance;
let infoSpy: jest.SpyInstance;
let errorSpy: jest.SpyInstance;
let debugSpy: jest.SpyInstance;
const mockConfig = { const mockConfig = {
apiKey: 'test-api-key', apiKey: 'test-api-key',
@@ -47,10 +68,13 @@ describe('MailgunService', () => {
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
console.warn = jest.fn();
console.error = jest.fn();
mockFetch = fetch as jest.MockedFunction<typeof fetch>; mockFetch = fetch as jest.MockedFunction<typeof fetch>;
mockFormData = MockFormData; mockFormData = MockFormData;
warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => undefined);
infoSpy = jest.spyOn(logger, 'info').mockImplementation(() => undefined);
errorSpy = jest.spyOn(logger, 'error').mockImplementation(() => undefined);
debugSpy = jest.spyOn(logger, 'debug').mockImplementation(() => undefined);
}); });
describe('constructor', () => { describe('constructor', () => {
@@ -67,11 +91,28 @@ describe('MailgunService', () => {
new MailgunService(); new MailgunService();
expect(console.warn).toHaveBeenCalledWith( expect(warnSpy).toHaveBeenCalledWith(
'📧 Mailgun Service: Running in development mode (emails will be logged only)' 'Mailgun running in development mode; emails will not be delivered',
'MAILGUN',
{
missingFields: [
'VITE_MAILGUN_API_KEY',
'VITE_MAILGUN_DOMAIN',
'VITE_MAILGUN_FROM_EMAIL',
],
domain: undefined,
}
); );
expect(console.warn).toHaveBeenCalledWith( expect(infoSpy).toHaveBeenCalledWith(
'💡 To enable real emails, configure Mailgun credentials in .env.local' 'To enable email delivery, configure Mailgun environment variables',
'MAILGUN',
{
requiredVariables: [
'VITE_MAILGUN_API_KEY',
'VITE_MAILGUN_DOMAIN',
'VITE_MAILGUN_FROM_EMAIL',
],
}
); );
}); });
@@ -80,10 +121,15 @@ describe('MailgunService', () => {
new MailgunService(); new MailgunService();
expect(console.warn).toHaveBeenCalledWith( expect(infoSpy).toHaveBeenCalledWith(
'📧 Mailgun Service: Configured for production with domain:', 'Mailgun configured for delivery',
'test.mailgun.org' 'MAILGUN',
{
domain: 'test.mailgun.org',
fromEmail: 'test@example.com',
}
); );
expect(warnSpy).not.toHaveBeenCalled();
}); });
}); });
@@ -122,8 +168,9 @@ describe('MailgunService', () => {
}), }),
}) })
); );
expect(console.warn).toHaveBeenCalledWith( expect(infoSpy).toHaveBeenCalledWith(
'📧 Email sent successfully via Mailgun:', 'Email sent via Mailgun',
'MAILGUN',
{ {
to: 'test@example.com', to: 'test@example.com',
subject: 'Test Subject', subject: 'Test Subject',
@@ -148,8 +195,10 @@ describe('MailgunService', () => {
const result = await service.sendEmail('test@example.com', template); const result = await service.sendEmail('test@example.com', template);
expect(result).toBe(false); expect(result).toBe(false);
expect(console.error).toHaveBeenCalledWith( expect(errorSpy).toHaveBeenCalledWith(
'Email sending failed:', 'Mailgun email send failed',
'MAILGUN',
{ domain: 'test.mailgun.org' },
expect.any(Error) expect.any(Error)
); );
}); });
@@ -165,8 +214,10 @@ describe('MailgunService', () => {
const result = await service.sendEmail('test@example.com', template); const result = await service.sendEmail('test@example.com', template);
expect(result).toBe(false); expect(result).toBe(false);
expect(console.error).toHaveBeenCalledWith( expect(errorSpy).toHaveBeenCalledWith(
'Email sending failed:', 'Mailgun email send failed',
'MAILGUN',
{ domain: 'test.mailgun.org' },
expect.any(Error) expect.any(Error)
); );
}); });
@@ -237,6 +288,56 @@ describe('MailgunService', () => {
expect.anything() expect.anything()
); );
}); });
test('logs preview and skips send when configuration is missing', async () => {
const unconfiguredConfig = {
apiKey: undefined,
domain: undefined,
baseUrl: 'https://api.mailgun.net/v3',
fromName: 'Test App',
fromEmail: undefined,
};
mockGetMailgunConfig.mockReturnValue(unconfiguredConfig);
const unconfiguredService = new MailgunService();
const template = {
subject: 'Test Subject',
html: '<p>Test HTML</p>',
};
const result = await unconfiguredService.sendEmail(
'test@example.com',
template
);
expect(result).toBe(false);
expect(mockFetch).not.toHaveBeenCalled();
expect(warnSpy).toHaveBeenCalledWith(
'Skipping email send; Mailgun is not configured',
'MAILGUN',
expect.objectContaining({
to: 'test@example.com',
missingFields: [
'VITE_MAILGUN_API_KEY',
'VITE_MAILGUN_DOMAIN',
'VITE_MAILGUN_FROM_EMAIL',
],
preview: true,
})
);
expect(debugSpy).toHaveBeenCalledWith(
'Mailgun email preview',
'MAILGUN',
expect.objectContaining({
to: 'test@example.com',
subject: 'Test Subject',
html: '<p>Test HTML</p>',
})
);
mockGetMailgunConfig.mockReturnValue(mockConfig);
});
}); });
describe('sendVerificationEmail', () => { describe('sendVerificationEmail', () => {
@@ -360,6 +461,7 @@ describe('MailgunService', () => {
mode: 'production', mode: 'production',
domain: 'test.mailgun.org', domain: 'test.mailgun.org',
fromEmail: 'test@example.com', fromEmail: 'test@example.com',
missingFields: [],
}); });
}); });
@@ -382,6 +484,11 @@ describe('MailgunService', () => {
mode: 'development', mode: 'development',
domain: undefined, domain: undefined,
fromEmail: undefined, fromEmail: undefined,
missingFields: [
'VITE_MAILGUN_API_KEY',
'VITE_MAILGUN_DOMAIN',
'VITE_MAILGUN_FROM_EMAIL',
],
}); });
}); });
@@ -404,6 +511,13 @@ describe('MailgunService', () => {
mode: 'development', mode: 'development',
domain: '', domain: '',
fromEmail: '', fromEmail: '',
missingFields: [
'VITE_MAILGUN_API_KEY',
'VITE_MAILGUN_DOMAIN',
'VITE_MAILGUN_FROM_EMAIL',
'VITE_MAILGUN_BASE_URL',
'VITE_MAILGUN_FROM_NAME',
],
}); });
}); });
}); });

View File

@@ -5,6 +5,8 @@
import { getMailgunConfig, type MailgunConfig } from './mailgun.config'; import { getMailgunConfig, type MailgunConfig } from './mailgun.config';
import { getAppConfig } from '../config/unified.config'; import { getAppConfig } from '../config/unified.config';
import { logger } from './logging';
import { normalizeError } from '../utils/error';
interface EmailTemplate { interface EmailTemplate {
subject: string; subject: string;
@@ -14,24 +16,34 @@ interface EmailTemplate {
export class MailgunService { export class MailgunService {
private config: MailgunConfig; private config: MailgunConfig;
private readonly context = 'MAILGUN';
constructor() { constructor() {
this.config = getMailgunConfig(); this.config = getMailgunConfig();
// Log configuration status on startup // Log configuration status on startup
const status = this.getConfigurationStatus(); const status = this.getConfigurationStatus();
if (status.mode === 'development') { if (!status.configured) {
console.warn( logger.warn(
'📧 Mailgun Service: Running in development mode (emails will be logged only)' 'Mailgun running in development mode; emails will not be delivered',
this.context,
{
missingFields: status.missingFields,
domain: status.domain,
}
); );
console.warn( logger.info(
'💡 To enable real emails, configure Mailgun credentials in .env.local' 'To enable email delivery, configure Mailgun environment variables',
this.context,
{
requiredVariables: status.missingFields,
}
); );
} else { } else {
console.warn( logger.info('Mailgun configured for delivery', this.context, {
'📧 Mailgun Service: Configured for production with domain:', domain: status.domain,
status.domain fromEmail: status.fromEmail,
); });
} }
} }
@@ -95,6 +107,27 @@ export class MailgunService {
async sendEmail(to: string, template: EmailTemplate): Promise<boolean> { async sendEmail(to: string, template: EmailTemplate): Promise<boolean> {
try { try {
const status = this.getConfigurationStatus();
if (!status.configured) {
logger.warn(
'Skipping email send; Mailgun is not configured',
this.context,
{
to,
subject: template.subject,
missingFields: status.missingFields,
preview: status.mode === 'development',
}
);
logger.debug('Mailgun email preview', this.context, {
to,
subject: template.subject,
html: template.html,
text: template.text,
});
return false;
}
// Production Mailgun API call // Production Mailgun API call
const formData = new FormData(); const formData = new FormData();
formData.append( formData.append(
@@ -125,7 +158,7 @@ export class MailgunService {
} }
const result = await response.json(); const result = await response.json();
console.warn('📧 Email sent successfully via Mailgun:', { logger.info('Email sent via Mailgun', this.context, {
to, to,
subject: template.subject, subject: template.subject,
messageId: result.id, messageId: result.id,
@@ -133,7 +166,14 @@ export class MailgunService {
return true; return true;
} catch (error: unknown) { } catch (error: unknown) {
console.error('Email sending failed:', error); logger.error(
'Mailgun email send failed',
this.context,
{
domain: this.config.domain,
},
normalizeError(error)
);
return false; return false;
} }
} }
@@ -156,13 +196,10 @@ export class MailgunService {
mode: 'development' | 'production'; mode: 'development' | 'production';
domain: string; domain: string;
fromEmail: string; fromEmail: string;
missingFields: string[];
} { } {
const configured = const missingFields = this.getMissingFields();
!!this.config.apiKey && const configured = missingFields.length === 0;
!!this.config.domain &&
!!this.config.baseUrl &&
!!this.config.fromEmail &&
!!this.config.fromName;
const mode: 'development' | 'production' = configured const mode: 'development' | 'production' = configured
? 'production' ? 'production'
: 'development'; : 'development';
@@ -171,8 +208,35 @@ export class MailgunService {
mode, mode,
domain: this.config.domain, domain: this.config.domain,
fromEmail: this.config.fromEmail, fromEmail: this.config.fromEmail,
missingFields,
}; };
} }
private getMissingFields(): string[] {
const missing: string[] = [];
if (!this.config.apiKey) {
missing.push('VITE_MAILGUN_API_KEY');
}
if (!this.config.domain) {
missing.push('VITE_MAILGUN_DOMAIN');
}
if (!this.config.fromEmail) {
missing.push('VITE_MAILGUN_FROM_EMAIL');
}
if (!this.config.baseUrl) {
missing.push('VITE_MAILGUN_BASE_URL');
}
if (!this.config.fromName) {
missing.push('VITE_MAILGUN_FROM_NAME');
}
return missing;
}
} }
export const mailgunService = new MailgunService(); export const mailgunService = new MailgunService();