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_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
|
||||||
|
|||||||
@@ -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',
|
||||||
|
],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user