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.
Files changed (61) hide show
  1. package/.cursor/rules/db_schema.mdc +0 -0
  2. package/.cursor/rules/design.mdc +16 -0
  3. package/.cursor/rules/general.mdc +49 -0
  4. package/README.md +765 -0
  5. package/components/emailer-html-editor.tsx +94 -0
  6. package/components/ui/button.tsx +53 -0
  7. package/components/ui/card.tsx +78 -0
  8. package/components/ui/input.tsx +24 -0
  9. package/components/ui/label.tsx +21 -0
  10. package/components/ui/sidebar.tsx +121 -0
  11. package/components/ui/spinner.tsx +54 -0
  12. package/components/ui/textarea.tsx +23 -0
  13. package/components/ui/tooltip.tsx +30 -0
  14. package/components.json +20 -0
  15. package/hazo_notify_config.ini +153 -0
  16. package/jest.config.js +27 -0
  17. package/jest.setup.js +1 -0
  18. package/next.config.js +22 -0
  19. package/package.json +72 -0
  20. package/postcss.config.js +6 -0
  21. package/src/app/api/hazo_notify/emailer/send/__tests__/route.test.ts +227 -0
  22. package/src/app/api/hazo_notify/emailer/send/route.ts +537 -0
  23. package/src/app/editor-00/page.tsx +47 -0
  24. package/src/app/globals.css +69 -0
  25. package/src/app/hazo_notify/emailer_test/layout.tsx +53 -0
  26. package/src/app/hazo_notify/emailer_test/page.tsx +369 -0
  27. package/src/app/hazo_notify/layout.tsx +77 -0
  28. package/src/app/hazo_notify/page.tsx +12 -0
  29. package/src/app/layout.tsx +26 -0
  30. package/src/app/page.tsx +14 -0
  31. package/src/components/blocks/editor-00/editor.tsx +61 -0
  32. package/src/components/blocks/editor-00/nodes.ts +11 -0
  33. package/src/components/blocks/editor-00/plugins.tsx +36 -0
  34. package/src/components/editor/editor-ui/content-editable.tsx +34 -0
  35. package/src/components/editor/themes/editor-theme.css +91 -0
  36. package/src/components/editor/themes/editor-theme.ts +130 -0
  37. package/src/components/ui/button.tsx +53 -0
  38. package/src/components/ui/card.tsx +78 -0
  39. package/src/components/ui/input.tsx +24 -0
  40. package/src/components/ui/label.tsx +21 -0
  41. package/src/components/ui/sidebar.tsx +121 -0
  42. package/src/components/ui/spinner.tsx +54 -0
  43. package/src/components/ui/textarea.tsx +23 -0
  44. package/src/components/ui/tooltip.tsx +30 -0
  45. package/src/lib/emailer/__tests__/emailer.test.ts +200 -0
  46. package/src/lib/emailer/emailer.ts +263 -0
  47. package/src/lib/emailer/index.ts +11 -0
  48. package/src/lib/emailer/providers/__tests__/zeptomail_provider.test.ts +196 -0
  49. package/src/lib/emailer/providers/index.ts +33 -0
  50. package/src/lib/emailer/providers/pop3_provider.ts +30 -0
  51. package/src/lib/emailer/providers/smtp_provider.ts +30 -0
  52. package/src/lib/emailer/providers/zeptomail_provider.ts +299 -0
  53. package/src/lib/emailer/types.ts +119 -0
  54. package/src/lib/emailer/utils/constants.ts +24 -0
  55. package/src/lib/emailer/utils/index.ts +9 -0
  56. package/src/lib/emailer/utils/logger.ts +71 -0
  57. package/src/lib/emailer/utils/validation.ts +84 -0
  58. package/src/lib/index.ts +6 -0
  59. package/src/lib/utils.ts +6 -0
  60. package/tailwind.config.ts +65 -0
  61. 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';