feat(mail): clarify mailgun configuration feedback
This commit is contained in:
@@ -193,6 +193,18 @@ DEV_API_URL=http://localhost:5984
|
||||
| `COUCHDB_USERNAME` | `admin` | Database username |
|
||||
| `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
|
||||
|
||||
| Variable | Default | Description |
|
||||
@@ -243,6 +255,20 @@ DEV_API_URL=http://localhost:5984
|
||||
| `API_SECRET_KEY` | - | API secret key |
|
||||
| `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
|
||||
|
||||
### Basic Development Setup
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
// Mock the mailgun config before any imports
|
||||
const mockGetMailgunConfig = jest.fn().mockReturnValue({
|
||||
apiKey: 'test-api-key',
|
||||
domain: 'test.mailgun.org',
|
||||
baseUrl: 'https://api.mailgun.net/v3',
|
||||
fromName: 'Test App',
|
||||
fromEmail: 'test@example.com',
|
||||
});
|
||||
jest.mock('../mailgun.config', () => {
|
||||
const defaultConfig = {
|
||||
apiKey: 'test-api-key',
|
||||
domain: 'test.mailgun.org',
|
||||
baseUrl: 'https://api.mailgun.net/v3',
|
||||
fromName: 'Test App',
|
||||
fromEmail: 'test@example.com',
|
||||
};
|
||||
|
||||
jest.mock('../mailgun.config', () => ({
|
||||
getMailgunConfig: mockGetMailgunConfig,
|
||||
}));
|
||||
return {
|
||||
getMailgunConfig: jest.fn(() => defaultConfig),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock the app config
|
||||
jest.mock('../../config/unified.config', () => ({
|
||||
@@ -18,6 +20,7 @@ jest.mock('../../config/unified.config', () => ({
|
||||
baseUrl: 'http://localhost:3000',
|
||||
},
|
||||
},
|
||||
getAppConfig: jest.fn(() => ({ baseUrl: 'http://localhost:3000' })),
|
||||
}));
|
||||
|
||||
// Mock global fetch and related APIs
|
||||
@@ -32,10 +35,28 @@ global.btoa = jest
|
||||
|
||||
// Import the service after mocks are set up
|
||||
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', () => {
|
||||
let mockFetch: jest.MockedFunction<typeof fetch>;
|
||||
let mockFormData: jest.MockedFunction<any>;
|
||||
let warnSpy: jest.SpyInstance;
|
||||
let infoSpy: jest.SpyInstance;
|
||||
let errorSpy: jest.SpyInstance;
|
||||
let debugSpy: jest.SpyInstance;
|
||||
|
||||
const mockConfig = {
|
||||
apiKey: 'test-api-key',
|
||||
@@ -47,10 +68,13 @@ describe('MailgunService', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
console.warn = jest.fn();
|
||||
console.error = jest.fn();
|
||||
mockFetch = fetch as jest.MockedFunction<typeof fetch>;
|
||||
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', () => {
|
||||
@@ -67,11 +91,28 @@ describe('MailgunService', () => {
|
||||
|
||||
new MailgunService();
|
||||
|
||||
expect(console.warn).toHaveBeenCalledWith(
|
||||
'📧 Mailgun Service: Running in development mode (emails will be logged only)'
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
'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(
|
||||
'💡 To enable real emails, configure Mailgun credentials in .env.local'
|
||||
expect(infoSpy).toHaveBeenCalledWith(
|
||||
'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();
|
||||
|
||||
expect(console.warn).toHaveBeenCalledWith(
|
||||
'📧 Mailgun Service: Configured for production with domain:',
|
||||
'test.mailgun.org'
|
||||
expect(infoSpy).toHaveBeenCalledWith(
|
||||
'Mailgun configured for delivery',
|
||||
'MAILGUN',
|
||||
{
|
||||
domain: 'test.mailgun.org',
|
||||
fromEmail: 'test@example.com',
|
||||
}
|
||||
);
|
||||
expect(warnSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -122,8 +168,9 @@ describe('MailgunService', () => {
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(console.warn).toHaveBeenCalledWith(
|
||||
'📧 Email sent successfully via Mailgun:',
|
||||
expect(infoSpy).toHaveBeenCalledWith(
|
||||
'Email sent via Mailgun',
|
||||
'MAILGUN',
|
||||
{
|
||||
to: 'test@example.com',
|
||||
subject: 'Test Subject',
|
||||
@@ -148,8 +195,10 @@ describe('MailgunService', () => {
|
||||
const result = await service.sendEmail('test@example.com', template);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
'Email sending failed:',
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
'Mailgun email send failed',
|
||||
'MAILGUN',
|
||||
{ domain: 'test.mailgun.org' },
|
||||
expect.any(Error)
|
||||
);
|
||||
});
|
||||
@@ -165,8 +214,10 @@ describe('MailgunService', () => {
|
||||
const result = await service.sendEmail('test@example.com', template);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
'Email sending failed:',
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
'Mailgun email send failed',
|
||||
'MAILGUN',
|
||||
{ domain: 'test.mailgun.org' },
|
||||
expect.any(Error)
|
||||
);
|
||||
});
|
||||
@@ -237,6 +288,56 @@ describe('MailgunService', () => {
|
||||
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', () => {
|
||||
@@ -360,6 +461,7 @@ describe('MailgunService', () => {
|
||||
mode: 'production',
|
||||
domain: 'test.mailgun.org',
|
||||
fromEmail: 'test@example.com',
|
||||
missingFields: [],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -382,6 +484,11 @@ describe('MailgunService', () => {
|
||||
mode: 'development',
|
||||
domain: undefined,
|
||||
fromEmail: undefined,
|
||||
missingFields: [
|
||||
'VITE_MAILGUN_API_KEY',
|
||||
'VITE_MAILGUN_DOMAIN',
|
||||
'VITE_MAILGUN_FROM_EMAIL',
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -404,6 +511,13 @@ describe('MailgunService', () => {
|
||||
mode: 'development',
|
||||
domain: '',
|
||||
fromEmail: '',
|
||||
missingFields: [
|
||||
'VITE_MAILGUN_API_KEY',
|
||||
'VITE_MAILGUN_DOMAIN',
|
||||
'VITE_MAILGUN_FROM_EMAIL',
|
||||
'VITE_MAILGUN_BASE_URL',
|
||||
'VITE_MAILGUN_FROM_NAME',
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
|
||||
import { getMailgunConfig, type MailgunConfig } from './mailgun.config';
|
||||
import { getAppConfig } from '../config/unified.config';
|
||||
import { logger } from './logging';
|
||||
import { normalizeError } from '../utils/error';
|
||||
|
||||
interface EmailTemplate {
|
||||
subject: string;
|
||||
@@ -14,24 +16,34 @@ interface EmailTemplate {
|
||||
|
||||
export class MailgunService {
|
||||
private config: MailgunConfig;
|
||||
private readonly context = 'MAILGUN';
|
||||
|
||||
constructor() {
|
||||
this.config = getMailgunConfig();
|
||||
|
||||
// Log configuration status on startup
|
||||
const status = this.getConfigurationStatus();
|
||||
if (status.mode === 'development') {
|
||||
console.warn(
|
||||
'📧 Mailgun Service: Running in development mode (emails will be logged only)'
|
||||
if (!status.configured) {
|
||||
logger.warn(
|
||||
'Mailgun running in development mode; emails will not be delivered',
|
||||
this.context,
|
||||
{
|
||||
missingFields: status.missingFields,
|
||||
domain: status.domain,
|
||||
}
|
||||
);
|
||||
console.warn(
|
||||
'💡 To enable real emails, configure Mailgun credentials in .env.local'
|
||||
logger.info(
|
||||
'To enable email delivery, configure Mailgun environment variables',
|
||||
this.context,
|
||||
{
|
||||
requiredVariables: status.missingFields,
|
||||
}
|
||||
);
|
||||
} else {
|
||||
console.warn(
|
||||
'📧 Mailgun Service: Configured for production with domain:',
|
||||
status.domain
|
||||
);
|
||||
logger.info('Mailgun configured for delivery', this.context, {
|
||||
domain: status.domain,
|
||||
fromEmail: status.fromEmail,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,6 +107,27 @@ export class MailgunService {
|
||||
|
||||
async sendEmail(to: string, template: EmailTemplate): Promise<boolean> {
|
||||
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
|
||||
const formData = new FormData();
|
||||
formData.append(
|
||||
@@ -125,7 +158,7 @@ export class MailgunService {
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
console.warn('📧 Email sent successfully via Mailgun:', {
|
||||
logger.info('Email sent via Mailgun', this.context, {
|
||||
to,
|
||||
subject: template.subject,
|
||||
messageId: result.id,
|
||||
@@ -133,7 +166,14 @@ export class MailgunService {
|
||||
|
||||
return true;
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
@@ -156,13 +196,10 @@ export class MailgunService {
|
||||
mode: 'development' | 'production';
|
||||
domain: string;
|
||||
fromEmail: string;
|
||||
missingFields: string[];
|
||||
} {
|
||||
const configured =
|
||||
!!this.config.apiKey &&
|
||||
!!this.config.domain &&
|
||||
!!this.config.baseUrl &&
|
||||
!!this.config.fromEmail &&
|
||||
!!this.config.fromName;
|
||||
const missingFields = this.getMissingFields();
|
||||
const configured = missingFields.length === 0;
|
||||
const mode: 'development' | 'production' = configured
|
||||
? 'production'
|
||||
: 'development';
|
||||
@@ -171,8 +208,35 @@ export class MailgunService {
|
||||
mode,
|
||||
domain: this.config.domain,
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user