hazo_notify 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.cursor/rules/db_schema.mdc +0 -0
- package/.cursor/rules/design.mdc +16 -0
- package/.cursor/rules/general.mdc +49 -0
- package/README.md +765 -0
- package/components/emailer-html-editor.tsx +94 -0
- package/components/ui/button.tsx +53 -0
- package/components/ui/card.tsx +78 -0
- package/components/ui/input.tsx +24 -0
- package/components/ui/label.tsx +21 -0
- package/components/ui/sidebar.tsx +121 -0
- package/components/ui/spinner.tsx +54 -0
- package/components/ui/textarea.tsx +23 -0
- package/components/ui/tooltip.tsx +30 -0
- package/components.json +20 -0
- package/hazo_notify_config.ini +153 -0
- package/jest.config.js +27 -0
- package/jest.setup.js +1 -0
- package/next.config.js +22 -0
- package/package.json +72 -0
- package/postcss.config.js +6 -0
- package/src/app/api/hazo_notify/emailer/send/__tests__/route.test.ts +227 -0
- package/src/app/api/hazo_notify/emailer/send/route.ts +537 -0
- package/src/app/editor-00/page.tsx +47 -0
- package/src/app/globals.css +69 -0
- package/src/app/hazo_notify/emailer_test/layout.tsx +53 -0
- package/src/app/hazo_notify/emailer_test/page.tsx +369 -0
- package/src/app/hazo_notify/layout.tsx +77 -0
- package/src/app/hazo_notify/page.tsx +12 -0
- package/src/app/layout.tsx +26 -0
- package/src/app/page.tsx +14 -0
- package/src/components/blocks/editor-00/editor.tsx +61 -0
- package/src/components/blocks/editor-00/nodes.ts +11 -0
- package/src/components/blocks/editor-00/plugins.tsx +36 -0
- package/src/components/editor/editor-ui/content-editable.tsx +34 -0
- package/src/components/editor/themes/editor-theme.css +91 -0
- package/src/components/editor/themes/editor-theme.ts +130 -0
- package/src/components/ui/button.tsx +53 -0
- package/src/components/ui/card.tsx +78 -0
- package/src/components/ui/input.tsx +24 -0
- package/src/components/ui/label.tsx +21 -0
- package/src/components/ui/sidebar.tsx +121 -0
- package/src/components/ui/spinner.tsx +54 -0
- package/src/components/ui/textarea.tsx +23 -0
- package/src/components/ui/tooltip.tsx +30 -0
- package/src/lib/emailer/__tests__/emailer.test.ts +200 -0
- package/src/lib/emailer/emailer.ts +263 -0
- package/src/lib/emailer/index.ts +11 -0
- package/src/lib/emailer/providers/__tests__/zeptomail_provider.test.ts +196 -0
- package/src/lib/emailer/providers/index.ts +33 -0
- package/src/lib/emailer/providers/pop3_provider.ts +30 -0
- package/src/lib/emailer/providers/smtp_provider.ts +30 -0
- package/src/lib/emailer/providers/zeptomail_provider.ts +299 -0
- package/src/lib/emailer/types.ts +119 -0
- package/src/lib/emailer/utils/constants.ts +24 -0
- package/src/lib/emailer/utils/index.ts +9 -0
- package/src/lib/emailer/utils/logger.ts +71 -0
- package/src/lib/emailer/utils/validation.ts +84 -0
- package/src/lib/index.ts +6 -0
- package/src/lib/utils.ts +6 -0
- package/tailwind.config.ts +65 -0
- package/tsconfig.json +27 -0
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for emailer service
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { send_email, load_emailer_config } from '../emailer';
|
|
6
|
+
import { SendEmailOptions, EmailerConfig } from '../types';
|
|
7
|
+
|
|
8
|
+
// Mock hazo_config
|
|
9
|
+
jest.mock('hazo_config', () => {
|
|
10
|
+
const mock_config_data = {
|
|
11
|
+
emailer: {
|
|
12
|
+
emailer_module: 'zeptoemail_api',
|
|
13
|
+
zeptomail_api_endpoint: 'https://api.zeptomail.com.au/v1.1/email',
|
|
14
|
+
zeptomail_api_key: 'test_api_key',
|
|
15
|
+
from_email: 'test@example.com',
|
|
16
|
+
from_name: 'Test Sender',
|
|
17
|
+
},
|
|
18
|
+
ui: {
|
|
19
|
+
enable_ui: 'false',
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
HazoConfig: jest.fn().mockImplementation(() => ({
|
|
25
|
+
getSection: jest.fn((section: string) => mock_config_data[section as keyof typeof mock_config_data]),
|
|
26
|
+
get: jest.fn((section: string, key: string) => {
|
|
27
|
+
const section_data = mock_config_data[section as keyof typeof mock_config_data];
|
|
28
|
+
return section_data?.[key];
|
|
29
|
+
}),
|
|
30
|
+
getAllSections: jest.fn(() => mock_config_data),
|
|
31
|
+
})),
|
|
32
|
+
};
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// Mock fetch
|
|
36
|
+
global.fetch = jest.fn();
|
|
37
|
+
|
|
38
|
+
describe('Emailer Service', () => {
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
jest.clearAllMocks();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe('load_emailer_config', () => {
|
|
44
|
+
it('should load configuration from hazo_notify_config.ini', () => {
|
|
45
|
+
const config = load_emailer_config();
|
|
46
|
+
expect(config.emailer_module).toBe('zeptoemail_api');
|
|
47
|
+
expect(config.from_email).toBe('test@example.com');
|
|
48
|
+
expect(config.from_name).toBe('Test Sender');
|
|
49
|
+
expect(config.zeptomail_api_endpoint).toBe('https://api.zeptomail.com.au/v1.1/email');
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe('send_email', () => {
|
|
54
|
+
it('should send email successfully', async () => {
|
|
55
|
+
const mock_response = {
|
|
56
|
+
ok: true,
|
|
57
|
+
status: 200,
|
|
58
|
+
statusText: 'OK',
|
|
59
|
+
headers: new Headers({ 'content-type': 'application/json' }),
|
|
60
|
+
json: async () => ({
|
|
61
|
+
data: {
|
|
62
|
+
message_id: 'test_message_id',
|
|
63
|
+
},
|
|
64
|
+
}),
|
|
65
|
+
text: async () => '',
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
(global.fetch as jest.Mock).mockResolvedValueOnce(mock_response);
|
|
69
|
+
|
|
70
|
+
const options: SendEmailOptions = {
|
|
71
|
+
to: 'recipient@example.com',
|
|
72
|
+
subject: 'Test Subject',
|
|
73
|
+
content: {
|
|
74
|
+
text: 'Test email content',
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const result = await send_email(options);
|
|
79
|
+
|
|
80
|
+
expect(result.success).toBe(true);
|
|
81
|
+
expect(result.message_id).toBe('test_message_id');
|
|
82
|
+
expect(global.fetch).toHaveBeenCalledTimes(1);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('should handle email sending failure', async () => {
|
|
86
|
+
const mock_response = {
|
|
87
|
+
ok: false,
|
|
88
|
+
status: 400,
|
|
89
|
+
statusText: 'Bad Request',
|
|
90
|
+
headers: new Headers({ 'content-type': 'application/json' }),
|
|
91
|
+
json: async () => ({
|
|
92
|
+
error: 'Invalid email address',
|
|
93
|
+
}),
|
|
94
|
+
text: async () => '',
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
(global.fetch as jest.Mock).mockResolvedValueOnce(mock_response);
|
|
98
|
+
|
|
99
|
+
const options: SendEmailOptions = {
|
|
100
|
+
to: 'invalid_email',
|
|
101
|
+
subject: 'Test Subject',
|
|
102
|
+
content: {
|
|
103
|
+
text: 'Test email content',
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const result = await send_email(options);
|
|
108
|
+
|
|
109
|
+
expect(result.success).toBe(false);
|
|
110
|
+
expect(result.error).toBe('Invalid email address');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('should validate required fields', async () => {
|
|
114
|
+
const options: SendEmailOptions = {
|
|
115
|
+
to: '',
|
|
116
|
+
subject: 'Test Subject',
|
|
117
|
+
content: {
|
|
118
|
+
text: 'Test email content',
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const result = await send_email(options);
|
|
123
|
+
|
|
124
|
+
expect(result.success).toBe(false);
|
|
125
|
+
expect(result.error).toContain('Recipient email address');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('should support HTML email content', async () => {
|
|
129
|
+
const mock_response = {
|
|
130
|
+
ok: true,
|
|
131
|
+
status: 200,
|
|
132
|
+
statusText: 'OK',
|
|
133
|
+
headers: new Headers({ 'content-type': 'application/json' }),
|
|
134
|
+
json: async () => ({
|
|
135
|
+
data: {
|
|
136
|
+
message_id: 'test_message_id',
|
|
137
|
+
},
|
|
138
|
+
}),
|
|
139
|
+
text: async () => '',
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
(global.fetch as jest.Mock).mockResolvedValueOnce(mock_response);
|
|
143
|
+
|
|
144
|
+
const options: SendEmailOptions = {
|
|
145
|
+
to: 'recipient@example.com',
|
|
146
|
+
subject: 'Test Subject',
|
|
147
|
+
content: {
|
|
148
|
+
html: '<h1>Test HTML Email</h1>',
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const result = await send_email(options);
|
|
153
|
+
|
|
154
|
+
expect(result.success).toBe(true);
|
|
155
|
+
expect(global.fetch).toHaveBeenCalledTimes(1);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('should support multiple attachments', async () => {
|
|
159
|
+
const mock_response = {
|
|
160
|
+
ok: true,
|
|
161
|
+
status: 200,
|
|
162
|
+
statusText: 'OK',
|
|
163
|
+
headers: new Headers({ 'content-type': 'application/json' }),
|
|
164
|
+
json: async () => ({
|
|
165
|
+
data: {
|
|
166
|
+
message_id: 'test_message_id',
|
|
167
|
+
},
|
|
168
|
+
}),
|
|
169
|
+
text: async () => '',
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
(global.fetch as jest.Mock).mockResolvedValueOnce(mock_response);
|
|
173
|
+
|
|
174
|
+
const options: SendEmailOptions = {
|
|
175
|
+
to: 'recipient@example.com',
|
|
176
|
+
subject: 'Test Subject',
|
|
177
|
+
content: {
|
|
178
|
+
text: 'Test email content',
|
|
179
|
+
},
|
|
180
|
+
attachments: [
|
|
181
|
+
{
|
|
182
|
+
filename: 'test1.txt',
|
|
183
|
+
content: 'dGVzdCBjb250ZW50',
|
|
184
|
+
mime_type: 'text/plain',
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
filename: 'test2.pdf',
|
|
188
|
+
content: 'dGVzdCBwZGYgY29udGVudA==',
|
|
189
|
+
mime_type: 'application/pdf',
|
|
190
|
+
},
|
|
191
|
+
],
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
const result = await send_email(options);
|
|
195
|
+
|
|
196
|
+
expect(result.success).toBe(true);
|
|
197
|
+
expect(global.fetch).toHaveBeenCalledTimes(1);
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
});
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Emailer service implementation
|
|
3
|
+
* Reads configuration from hazo_notify_config.ini via hazo_config package
|
|
4
|
+
* and sends emails using the configured provider
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { EmailerConfig, SendEmailOptions, EmailSendResponse, EmailerModule } from './types';
|
|
8
|
+
import { get_email_provider } from './providers';
|
|
9
|
+
import { HazoConfig } from 'hazo_config';
|
|
10
|
+
import { join } from 'path';
|
|
11
|
+
import { VALID_EMAILER_MODULES, DEFAULT_RATE_LIMIT_REQUESTS, DEFAULT_RATE_LIMIT_WINDOW, DEFAULT_MAX_ATTACHMENT_SIZE, DEFAULT_MAX_ATTACHMENTS, DEFAULT_REQUEST_TIMEOUT, MAX_SUBJECT_LENGTH, MAX_BODY_LENGTH } from './utils/constants';
|
|
12
|
+
import { log_error, create_log_entry } from './utils/logger';
|
|
13
|
+
import { validate_email_address, validate_subject_length, validate_body_length, validate_attachment_size } from './utils/validation';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Load emailer configuration from hazo_notify_config.ini
|
|
17
|
+
* @returns Emailer configuration
|
|
18
|
+
*/
|
|
19
|
+
export function load_emailer_config(): EmailerConfig {
|
|
20
|
+
const filename = 'emailer.ts';
|
|
21
|
+
const function_name = 'load_emailer_config';
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
// Read configuration from hazo_notify_config.ini using hazo_config
|
|
25
|
+
const config_file_path = join(process.cwd(), 'hazo_notify_config.ini');
|
|
26
|
+
const hazo_config = new HazoConfig({ filePath: config_file_path });
|
|
27
|
+
const emailer_section = hazo_config.getSection('emailer') || {};
|
|
28
|
+
const emailer_module_raw = emailer_section?.emailer_module || 'zeptoemail_api';
|
|
29
|
+
|
|
30
|
+
// Validate emailer_module
|
|
31
|
+
if (!VALID_EMAILER_MODULES.includes(emailer_module_raw as EmailerModule)) {
|
|
32
|
+
throw new Error(`Invalid emailer_module: ${emailer_module_raw}. Must be one of: ${VALID_EMAILER_MODULES.join(', ')}`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const emailer_module = emailer_module_raw as EmailerModule;
|
|
36
|
+
|
|
37
|
+
// Build configuration object
|
|
38
|
+
const config: EmailerConfig = {
|
|
39
|
+
emailer_module: emailer_module,
|
|
40
|
+
from_email: emailer_section?.from_email || '',
|
|
41
|
+
from_name: emailer_section?.from_name || 'Hazo Notify',
|
|
42
|
+
// Rate limiting
|
|
43
|
+
rate_limit_requests: parseInt(emailer_section?.rate_limit_requests || String(DEFAULT_RATE_LIMIT_REQUESTS), 10),
|
|
44
|
+
rate_limit_window: parseInt(emailer_section?.rate_limit_window || String(DEFAULT_RATE_LIMIT_WINDOW), 10),
|
|
45
|
+
// Attachment limits
|
|
46
|
+
max_attachment_size: parseInt(emailer_section?.max_attachment_size || String(DEFAULT_MAX_ATTACHMENT_SIZE), 10),
|
|
47
|
+
max_attachments: parseInt(emailer_section?.max_attachments || String(DEFAULT_MAX_ATTACHMENTS), 10),
|
|
48
|
+
// Request timeout
|
|
49
|
+
request_timeout: parseInt(emailer_section?.request_timeout || String(DEFAULT_REQUEST_TIMEOUT), 10),
|
|
50
|
+
// Input length limits
|
|
51
|
+
max_subject_length: parseInt(emailer_section?.max_subject_length || String(MAX_SUBJECT_LENGTH), 10),
|
|
52
|
+
max_body_length: parseInt(emailer_section?.max_body_length || String(MAX_BODY_LENGTH), 10),
|
|
53
|
+
// CORS
|
|
54
|
+
cors_allowed_origins: emailer_section?.cors_allowed_origins || '',
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
// Load Zeptomail API configuration if emailer_module is 'zeptoemail_api'
|
|
58
|
+
if (emailer_module === 'zeptoemail_api') {
|
|
59
|
+
config.zeptomail_api_endpoint = emailer_section?.zeptomail_api_endpoint || 'https://api.zeptomail.com.au/v1.1/email';
|
|
60
|
+
// Prioritize environment variables over config file for security
|
|
61
|
+
// Check .env.local first (Next.js convention), then .env, then config file
|
|
62
|
+
config.zeptomail_api_key = process.env.ZEPTOMAIL_API_KEY || emailer_section?.zeptomail_api_key || '';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Load optional configuration
|
|
66
|
+
if (emailer_section?.reply_to_email) {
|
|
67
|
+
config.reply_to_email = emailer_section.reply_to_email;
|
|
68
|
+
}
|
|
69
|
+
if (emailer_section?.bounce_email) {
|
|
70
|
+
config.bounce_email = emailer_section.bounce_email;
|
|
71
|
+
}
|
|
72
|
+
if (emailer_section?.return_path_email) {
|
|
73
|
+
config.return_path_email = emailer_section.return_path_email;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Load SMTP configuration if emailer_module is 'smtp' (placeholder)
|
|
77
|
+
if (emailer_module === 'smtp') {
|
|
78
|
+
config.smtp_host = emailer_section?.smtp_host || '';
|
|
79
|
+
config.smtp_port = parseInt(emailer_section?.smtp_port || '587', 10);
|
|
80
|
+
const smtp_secure_value: unknown = emailer_section?.smtp_secure;
|
|
81
|
+
config.smtp_secure = smtp_secure_value === 'true' || smtp_secure_value === true;
|
|
82
|
+
config.smtp_auth_user = emailer_section?.smtp_auth_user || '';
|
|
83
|
+
config.smtp_auth_pass = emailer_section?.smtp_auth_pass || '';
|
|
84
|
+
config.smtp_timeout = parseInt(emailer_section?.smtp_timeout || '5000', 10);
|
|
85
|
+
const smtp_pool_value: unknown = emailer_section?.smtp_pool;
|
|
86
|
+
config.smtp_pool = smtp_pool_value === 'true' || smtp_pool_value === true;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Load POP3 configuration if emailer_module is 'pop3' (placeholder)
|
|
90
|
+
if (emailer_module === 'pop3') {
|
|
91
|
+
config.pop3_host = emailer_section?.pop3_host || '';
|
|
92
|
+
config.pop3_port = parseInt(emailer_section?.pop3_port || '995', 10);
|
|
93
|
+
const pop3_secure_value: unknown = emailer_section?.pop3_secure;
|
|
94
|
+
config.pop3_secure = pop3_secure_value === 'true' || pop3_secure_value === true;
|
|
95
|
+
config.pop3_auth_user = emailer_section?.pop3_auth_user || '';
|
|
96
|
+
config.pop3_auth_pass = emailer_section?.pop3_auth_pass || '';
|
|
97
|
+
config.pop3_timeout = parseInt(emailer_section?.pop3_timeout || '5000', 10);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Validate required configuration
|
|
101
|
+
if (!config.from_email) {
|
|
102
|
+
throw new Error('from_email is required in hazo_notify_config.ini');
|
|
103
|
+
}
|
|
104
|
+
if (!config.from_name) {
|
|
105
|
+
throw new Error('from_name is required in hazo_notify_config.ini');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Validate emailer_module specific configuration
|
|
109
|
+
if (emailer_module === 'zeptoemail_api') {
|
|
110
|
+
if (!config.zeptomail_api_endpoint) {
|
|
111
|
+
throw new Error('zeptomail_api_endpoint is required when emailer_module=zeptoemail_api');
|
|
112
|
+
}
|
|
113
|
+
if (!config.zeptomail_api_key) {
|
|
114
|
+
throw new Error('Zeptomail API key is required. Set ZEPTOMAIL_API_KEY in .env.local or zeptomail_api_key in config file');
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return config;
|
|
119
|
+
} catch (error: unknown) {
|
|
120
|
+
const error_message = error instanceof Error ? error.message : 'Failed to load emailer configuration';
|
|
121
|
+
const error_string = error instanceof Error ? error.toString() : String(error);
|
|
122
|
+
const stack = error instanceof Error ? error.stack : undefined;
|
|
123
|
+
|
|
124
|
+
log_error(create_log_entry(
|
|
125
|
+
filename,
|
|
126
|
+
function_name,
|
|
127
|
+
error_message,
|
|
128
|
+
{
|
|
129
|
+
line_number: stack || 'unknown',
|
|
130
|
+
error: error_string,
|
|
131
|
+
}
|
|
132
|
+
));
|
|
133
|
+
|
|
134
|
+
throw new Error(`Failed to load emailer configuration: ${error_message}`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Send email using the configured provider
|
|
140
|
+
* @param options - Email send options
|
|
141
|
+
* @param config - Optional emailer configuration (if not provided, loads from config file)
|
|
142
|
+
* @returns Promise with email send response
|
|
143
|
+
*/
|
|
144
|
+
export async function send_email(
|
|
145
|
+
options: SendEmailOptions,
|
|
146
|
+
config?: EmailerConfig
|
|
147
|
+
): Promise<EmailSendResponse> {
|
|
148
|
+
const filename = 'emailer.ts';
|
|
149
|
+
const function_name = 'send_email';
|
|
150
|
+
|
|
151
|
+
try {
|
|
152
|
+
// Load configuration if not provided
|
|
153
|
+
const emailer_config = config || load_emailer_config();
|
|
154
|
+
|
|
155
|
+
// Validate email options
|
|
156
|
+
if (!options.to || (Array.isArray(options.to) && options.to.length === 0)) {
|
|
157
|
+
throw new Error('Recipient email address(es) are required');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Validate email addresses
|
|
161
|
+
const to_emails = Array.isArray(options.to) ? options.to : [options.to];
|
|
162
|
+
for (const email of to_emails) {
|
|
163
|
+
if (!validate_email_address(email)) {
|
|
164
|
+
throw new Error(`Invalid recipient email address: ${email}`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (!options.subject) {
|
|
169
|
+
throw new Error('Email subject is required');
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Validate subject length
|
|
173
|
+
if (!validate_subject_length(options.subject)) {
|
|
174
|
+
throw new Error(`Email subject exceeds maximum length of ${emailer_config.max_subject_length || MAX_SUBJECT_LENGTH} characters`);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (!options.content.text && !options.content.html) {
|
|
178
|
+
throw new Error('Email content (text or html) is required');
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Validate body length
|
|
182
|
+
if (options.content.text && !validate_body_length(options.content.text)) {
|
|
183
|
+
throw new Error(`Email text body exceeds maximum size of ${emailer_config.max_body_length || MAX_BODY_LENGTH} bytes`);
|
|
184
|
+
}
|
|
185
|
+
if (options.content.html && !validate_body_length(options.content.html)) {
|
|
186
|
+
throw new Error(`Email HTML body exceeds maximum size of ${emailer_config.max_body_length || MAX_BODY_LENGTH} bytes`);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Validate attachments
|
|
190
|
+
if (options.attachments) {
|
|
191
|
+
const max_attachments = emailer_config.max_attachments || DEFAULT_MAX_ATTACHMENTS;
|
|
192
|
+
if (options.attachments.length > max_attachments) {
|
|
193
|
+
throw new Error(`Maximum ${max_attachments} attachments allowed`);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const max_size = emailer_config.max_attachment_size || DEFAULT_MAX_ATTACHMENT_SIZE;
|
|
197
|
+
for (const attachment of options.attachments) {
|
|
198
|
+
if (!validate_attachment_size(attachment.content, max_size)) {
|
|
199
|
+
throw new Error(`Attachment "${attachment.filename}" exceeds maximum size of ${max_size} bytes`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Get appropriate provider
|
|
205
|
+
const provider = get_email_provider(emailer_config);
|
|
206
|
+
|
|
207
|
+
// Send email
|
|
208
|
+
const response = await provider.send_email(options, emailer_config);
|
|
209
|
+
|
|
210
|
+
// Log error if email sending failed
|
|
211
|
+
if (!response.success) {
|
|
212
|
+
log_error(create_log_entry(
|
|
213
|
+
filename,
|
|
214
|
+
function_name,
|
|
215
|
+
response.error || 'Failed to send email',
|
|
216
|
+
{
|
|
217
|
+
options: {
|
|
218
|
+
to: options.to,
|
|
219
|
+
subject: options.subject,
|
|
220
|
+
has_text: !!options.content.text,
|
|
221
|
+
has_html: !!options.content.html,
|
|
222
|
+
attachments_count: options.attachments?.length || 0,
|
|
223
|
+
},
|
|
224
|
+
response: response.raw_response,
|
|
225
|
+
}
|
|
226
|
+
));
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return response;
|
|
230
|
+
} catch (error: unknown) {
|
|
231
|
+
const error_message = error instanceof Error ? error.message : 'Failed to send email';
|
|
232
|
+
const error_string = error instanceof Error ? error.toString() : String(error);
|
|
233
|
+
const stack = error instanceof Error ? error.stack : undefined;
|
|
234
|
+
const is_production = process.env.NODE_ENV === 'production';
|
|
235
|
+
|
|
236
|
+
log_error(create_log_entry(
|
|
237
|
+
filename,
|
|
238
|
+
function_name,
|
|
239
|
+
error_message,
|
|
240
|
+
{
|
|
241
|
+
line_number: stack || 'unknown',
|
|
242
|
+
error: error_string,
|
|
243
|
+
options: {
|
|
244
|
+
to: options.to,
|
|
245
|
+
subject: options.subject,
|
|
246
|
+
has_text: !!options.content.text,
|
|
247
|
+
has_html: !!options.content.html,
|
|
248
|
+
attachments_count: options.attachments?.length || 0,
|
|
249
|
+
},
|
|
250
|
+
}
|
|
251
|
+
));
|
|
252
|
+
|
|
253
|
+
return {
|
|
254
|
+
success: false,
|
|
255
|
+
error: error_message,
|
|
256
|
+
message: error_message,
|
|
257
|
+
raw_response: is_production ? undefined : {
|
|
258
|
+
error: error_string,
|
|
259
|
+
stack: stack,
|
|
260
|
+
},
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Emailer service exports
|
|
3
|
+
* Main entry point for the emailer library
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export { send_email, load_emailer_config } from './emailer';
|
|
7
|
+
export * from './types';
|
|
8
|
+
export { get_email_provider } from './providers';
|
|
9
|
+
export { ZeptomailProvider } from './providers/zeptomail_provider';
|
|
10
|
+
export { SmtpProvider } from './providers/smtp_provider';
|
|
11
|
+
export { Pop3Provider } from './providers/pop3_provider';
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for Zeptomail provider
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { ZeptomailProvider } from '../zeptomail_provider';
|
|
6
|
+
import { SendEmailOptions, EmailerConfig } from '../../types';
|
|
7
|
+
|
|
8
|
+
// Mock fetch
|
|
9
|
+
global.fetch = jest.fn();
|
|
10
|
+
|
|
11
|
+
describe('ZeptomailProvider', () => {
|
|
12
|
+
let provider: ZeptomailProvider;
|
|
13
|
+
let config: EmailerConfig;
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
provider = new ZeptomailProvider();
|
|
17
|
+
config = {
|
|
18
|
+
emailer_module: 'zeptoemail_api',
|
|
19
|
+
zeptomail_api_endpoint: 'https://api.zeptomail.com.au/v1.1/email',
|
|
20
|
+
zeptomail_api_key: 'test_api_key',
|
|
21
|
+
from_email: 'test@example.com',
|
|
22
|
+
from_name: 'Test Sender',
|
|
23
|
+
};
|
|
24
|
+
jest.clearAllMocks();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe('send_email', () => {
|
|
28
|
+
it('should send email successfully', async () => {
|
|
29
|
+
const mock_response = {
|
|
30
|
+
ok: true,
|
|
31
|
+
status: 200,
|
|
32
|
+
statusText: 'OK',
|
|
33
|
+
headers: new Headers({ 'content-type': 'application/json' }),
|
|
34
|
+
json: async () => ({
|
|
35
|
+
data: {
|
|
36
|
+
message_id: 'test_message_id',
|
|
37
|
+
},
|
|
38
|
+
}),
|
|
39
|
+
text: async () => '',
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
(global.fetch as jest.Mock).mockResolvedValueOnce(mock_response);
|
|
43
|
+
|
|
44
|
+
const options: SendEmailOptions = {
|
|
45
|
+
to: 'recipient@example.com',
|
|
46
|
+
subject: 'Test Subject',
|
|
47
|
+
content: {
|
|
48
|
+
text: 'Test email content',
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const result = await provider.send_email(options, config);
|
|
53
|
+
|
|
54
|
+
expect(result.success).toBe(true);
|
|
55
|
+
expect(result.message_id).toBe('test_message_id');
|
|
56
|
+
expect(global.fetch).toHaveBeenCalledTimes(1);
|
|
57
|
+
expect(global.fetch).toHaveBeenCalledWith(
|
|
58
|
+
config.zeptomail_api_endpoint,
|
|
59
|
+
expect.objectContaining({
|
|
60
|
+
method: 'POST',
|
|
61
|
+
headers: expect.objectContaining({
|
|
62
|
+
'Content-Type': 'application/json',
|
|
63
|
+
'Authorization': expect.stringContaining('Zoho-enczapikey'),
|
|
64
|
+
}),
|
|
65
|
+
})
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('should handle API errors', async () => {
|
|
70
|
+
const mock_response = {
|
|
71
|
+
ok: false,
|
|
72
|
+
status: 400,
|
|
73
|
+
statusText: 'Bad Request',
|
|
74
|
+
headers: new Headers({ 'content-type': 'application/json' }),
|
|
75
|
+
json: async () => ({
|
|
76
|
+
error: 'Invalid email address',
|
|
77
|
+
}),
|
|
78
|
+
text: async () => '',
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
(global.fetch as jest.Mock).mockResolvedValueOnce(mock_response);
|
|
82
|
+
|
|
83
|
+
const options: SendEmailOptions = {
|
|
84
|
+
to: 'invalid_email',
|
|
85
|
+
subject: 'Test Subject',
|
|
86
|
+
content: {
|
|
87
|
+
text: 'Test email content',
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const result = await provider.send_email(options, config);
|
|
92
|
+
|
|
93
|
+
expect(result.success).toBe(false);
|
|
94
|
+
expect(result.error).toBe('Invalid email address');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('should validate required configuration', async () => {
|
|
98
|
+
const invalid_config: EmailerConfig = {
|
|
99
|
+
emailer_module: 'zeptoemail_api',
|
|
100
|
+
from_email: 'test@example.com',
|
|
101
|
+
from_name: 'Test Sender',
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const options: SendEmailOptions = {
|
|
105
|
+
to: 'recipient@example.com',
|
|
106
|
+
subject: 'Test Subject',
|
|
107
|
+
content: {
|
|
108
|
+
text: 'Test email content',
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const result = await provider.send_email(options, invalid_config);
|
|
113
|
+
|
|
114
|
+
expect(result.success).toBe(false);
|
|
115
|
+
expect(result.error).toContain('required');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('should support HTML email content', async () => {
|
|
119
|
+
const mock_response = {
|
|
120
|
+
ok: true,
|
|
121
|
+
status: 200,
|
|
122
|
+
statusText: 'OK',
|
|
123
|
+
headers: new Headers({ 'content-type': 'application/json' }),
|
|
124
|
+
json: async () => ({
|
|
125
|
+
data: {
|
|
126
|
+
message_id: 'test_message_id',
|
|
127
|
+
},
|
|
128
|
+
}),
|
|
129
|
+
text: async () => '',
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
(global.fetch as jest.Mock).mockResolvedValueOnce(mock_response);
|
|
133
|
+
|
|
134
|
+
const options: SendEmailOptions = {
|
|
135
|
+
to: 'recipient@example.com',
|
|
136
|
+
subject: 'Test Subject',
|
|
137
|
+
content: {
|
|
138
|
+
html: '<h1>Test HTML Email</h1>',
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const result = await provider.send_email(options, config);
|
|
143
|
+
|
|
144
|
+
expect(result.success).toBe(true);
|
|
145
|
+
const call_args = (global.fetch as jest.Mock).mock.calls[0];
|
|
146
|
+
const request_body = JSON.parse(call_args[1].body);
|
|
147
|
+
expect(request_body.htmlbody).toBe('<h1>Test HTML Email</h1>');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('should support multiple attachments', async () => {
|
|
151
|
+
const mock_response = {
|
|
152
|
+
ok: true,
|
|
153
|
+
status: 200,
|
|
154
|
+
statusText: 'OK',
|
|
155
|
+
headers: new Headers({ 'content-type': 'application/json' }),
|
|
156
|
+
json: async () => ({
|
|
157
|
+
data: {
|
|
158
|
+
message_id: 'test_message_id',
|
|
159
|
+
},
|
|
160
|
+
}),
|
|
161
|
+
text: async () => '',
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
(global.fetch as jest.Mock).mockResolvedValueOnce(mock_response);
|
|
165
|
+
|
|
166
|
+
const options: SendEmailOptions = {
|
|
167
|
+
to: 'recipient@example.com',
|
|
168
|
+
subject: 'Test Subject',
|
|
169
|
+
content: {
|
|
170
|
+
text: 'Test email content',
|
|
171
|
+
},
|
|
172
|
+
attachments: [
|
|
173
|
+
{
|
|
174
|
+
filename: 'test1.txt',
|
|
175
|
+
content: 'dGVzdCBjb250ZW50',
|
|
176
|
+
mime_type: 'text/plain',
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
filename: 'test2.pdf',
|
|
180
|
+
content: 'dGVzdCBwZGYgY29udGVudA==',
|
|
181
|
+
mime_type: 'application/pdf',
|
|
182
|
+
},
|
|
183
|
+
],
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
const result = await provider.send_email(options, config);
|
|
187
|
+
|
|
188
|
+
expect(result.success).toBe(true);
|
|
189
|
+
const call_args = (global.fetch as jest.Mock).mock.calls[0];
|
|
190
|
+
const request_body = JSON.parse(call_args[1].body);
|
|
191
|
+
expect(request_body.attachments).toHaveLength(2);
|
|
192
|
+
expect(request_body.attachments[0].name).toBe('test1.txt');
|
|
193
|
+
expect(request_body.attachments[1].name).toBe('test2.pdf');
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider factory for email providers
|
|
3
|
+
* Selects and returns the appropriate provider based on configuration
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { EmailProvider, EmailerConfig, EmailerModule } from '../types';
|
|
7
|
+
import { ZeptomailProvider } from './zeptomail_provider';
|
|
8
|
+
import { SmtpProvider } from './smtp_provider';
|
|
9
|
+
import { Pop3Provider } from './pop3_provider';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Get email provider based on configuration module
|
|
13
|
+
* @param config - Emailer configuration
|
|
14
|
+
* @returns Email provider instance
|
|
15
|
+
*/
|
|
16
|
+
export function get_email_provider(config: EmailerConfig): EmailProvider {
|
|
17
|
+
const emailer_module: EmailerModule = config.emailer_module || 'zeptoemail_api';
|
|
18
|
+
|
|
19
|
+
switch (emailer_module) {
|
|
20
|
+
case 'zeptoemail_api':
|
|
21
|
+
return new ZeptomailProvider();
|
|
22
|
+
case 'smtp':
|
|
23
|
+
return new SmtpProvider();
|
|
24
|
+
case 'pop3':
|
|
25
|
+
return new Pop3Provider();
|
|
26
|
+
default:
|
|
27
|
+
throw new Error(`Unsupported emailer module: ${emailer_module}`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export { ZeptomailProvider } from './zeptomail_provider';
|
|
32
|
+
export { SmtpProvider } from './smtp_provider';
|
|
33
|
+
export { Pop3Provider } from './pop3_provider';
|