agentdev-webui 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 (39) hide show
  1. package/lib/agent-api.js +530 -0
  2. package/lib/auth.js +127 -0
  3. package/lib/config.js +53 -0
  4. package/lib/database.js +762 -0
  5. package/lib/device-flow.js +257 -0
  6. package/lib/email.js +420 -0
  7. package/lib/encryption.js +112 -0
  8. package/lib/github.js +339 -0
  9. package/lib/history.js +143 -0
  10. package/lib/pwa.js +107 -0
  11. package/lib/redis-logs.js +226 -0
  12. package/lib/routes.js +680 -0
  13. package/migrations/000_create_database.sql +33 -0
  14. package/migrations/001_create_agentdev_schema.sql +135 -0
  15. package/migrations/001_create_agentdev_schema.sql.old +100 -0
  16. package/migrations/001_create_agentdev_schema_fixed.sql +135 -0
  17. package/migrations/002_add_github_token.sql +17 -0
  18. package/migrations/003_add_agent_logs_table.sql +23 -0
  19. package/migrations/004_remove_oauth_columns.sql +11 -0
  20. package/migrations/005_add_projects.sql +44 -0
  21. package/migrations/006_project_github_token.sql +7 -0
  22. package/migrations/007_project_repositories.sql +12 -0
  23. package/migrations/008_add_notifications.sql +20 -0
  24. package/migrations/009_unified_oauth.sql +153 -0
  25. package/migrations/README.md +97 -0
  26. package/package.json +37 -0
  27. package/public/css/styles.css +1140 -0
  28. package/public/device.html +384 -0
  29. package/public/docs.html +862 -0
  30. package/public/docs.md +697 -0
  31. package/public/favicon.svg +5 -0
  32. package/public/index.html +271 -0
  33. package/public/js/app.js +2379 -0
  34. package/public/login.html +224 -0
  35. package/public/profile.html +394 -0
  36. package/public/register.html +392 -0
  37. package/public/reset-password.html +349 -0
  38. package/public/verify-email.html +177 -0
  39. package/server.js +1450 -0
@@ -0,0 +1,257 @@
1
+ const crypto = require('crypto');
2
+ const jwt = require('jsonwebtoken');
3
+ const config = require('./config');
4
+ const db = require('./database');
5
+ const redisLogs = require('./redis-logs');
6
+
7
+ /**
8
+ * Generate a random user code (e.g., "ABCD-EFGH")
9
+ */
10
+ function generateUserCode() {
11
+ const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // Remove confusing chars
12
+ let code = '';
13
+ for (let i = 0; i < 8; i++) {
14
+ if (i === 4) code += '-';
15
+ code += chars[Math.floor(Math.random() * chars.length)];
16
+ }
17
+ return code;
18
+ }
19
+
20
+ /**
21
+ * Generate a device code (64 hex chars)
22
+ */
23
+ function generateDeviceCode() {
24
+ return crypto.randomBytes(32).toString('hex');
25
+ }
26
+
27
+ /**
28
+ * Generate JWT token for agent
29
+ */
30
+ function generateAgentToken(agentId, userId, projectId = null) {
31
+ const payload = { agent_id: agentId, user_id: userId, type: 'agent' };
32
+ if (projectId) payload.project_id = projectId;
33
+ return jwt.sign(payload, config.JWT_SECRET, { expiresIn: config.JWT_EXPIRES_IN });
34
+ }
35
+
36
+ /**
37
+ * Verify JWT token
38
+ */
39
+ function verifyAgentToken(token) {
40
+ try {
41
+ const decoded = jwt.verify(token, config.JWT_SECRET);
42
+ if (decoded.type !== 'agent') {
43
+ throw new Error('Invalid token type');
44
+ }
45
+ return decoded;
46
+ } catch (error) {
47
+ return null;
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Hash token for storage
53
+ */
54
+ function hashToken(token) {
55
+ return crypto.createHash('sha256').update(token).digest('hex');
56
+ }
57
+
58
+ /**
59
+ * Request device code
60
+ * Step 1 of device flow
61
+ */
62
+ async function requestDeviceCode(agentName, capabilities) {
63
+ const deviceCode = generateDeviceCode();
64
+ const userCode = generateUserCode();
65
+
66
+ // Store in database
67
+ await db.createDeviceCode(deviceCode, userCode, agentName, capabilities);
68
+
69
+ // Also store in Redis for quick lookup with TTL
70
+ await redisLogs.set(
71
+ `device:${deviceCode}`,
72
+ JSON.stringify({
73
+ user_code: userCode,
74
+ agent_name: agentName,
75
+ capabilities,
76
+ status: 'pending'
77
+ }),
78
+ config.DEVICE_CODE_TTL
79
+ );
80
+
81
+ await redisLogs.set(
82
+ `usercode:${userCode}`,
83
+ deviceCode,
84
+ config.DEVICE_CODE_TTL
85
+ );
86
+
87
+ return {
88
+ device_code: deviceCode,
89
+ user_code: userCode,
90
+ verification_uri: `${config.BASE_URL}/device`,
91
+ expires_in: config.DEVICE_CODE_TTL,
92
+ interval: config.DEVICE_CODE_POLL_INTERVAL
93
+ };
94
+ }
95
+
96
+ /**
97
+ * Get device code details by user code
98
+ */
99
+ async function getDeviceCodeByUserCode(userCode) {
100
+ // Try Redis first (faster)
101
+ const deviceCode = await redisLogs.get(`usercode:${userCode}`);
102
+ if (deviceCode) {
103
+ const data = await redisLogs.get(`device:${deviceCode}`);
104
+ if (data) {
105
+ return { device_code: deviceCode, ...data };
106
+ }
107
+ }
108
+
109
+ // Fallback to database
110
+ const dbRecord = await db.getDeviceCodeByUserCode(userCode);
111
+ if (dbRecord) {
112
+ return {
113
+ device_code: dbRecord.device_code,
114
+ user_code: dbRecord.user_code,
115
+ agent_name: dbRecord.agent_name,
116
+ capabilities: dbRecord.agent_capabilities,
117
+ status: dbRecord.status,
118
+ user_id: dbRecord.user_id
119
+ };
120
+ }
121
+
122
+ return null;
123
+ }
124
+
125
+ /**
126
+ * Approve device code
127
+ * User approves agent registration in Web UI
128
+ */
129
+ async function approveDeviceCode(userCode, userId, projectId = null) {
130
+ const deviceData = await getDeviceCodeByUserCode(userCode);
131
+ if (!deviceData) {
132
+ throw new Error('Device code not found or expired');
133
+ }
134
+
135
+ if (deviceData.status !== 'pending') {
136
+ throw new Error('Device code already processed');
137
+ }
138
+
139
+ // Update database
140
+ await db.approveDeviceCode(deviceData.device_code, userId);
141
+
142
+ // Update Redis (include project_id)
143
+ await redisLogs.set(
144
+ `device:${deviceData.device_code}`,
145
+ JSON.stringify({
146
+ ...deviceData,
147
+ status: 'approved',
148
+ user_id: userId,
149
+ project_id: projectId
150
+ }),
151
+ 300 // Keep for 5 more minutes for polling
152
+ );
153
+
154
+ return true;
155
+ }
156
+
157
+ /**
158
+ * Deny device code
159
+ */
160
+ async function denyDeviceCode(userCode) {
161
+ const deviceData = await getDeviceCodeByUserCode(userCode);
162
+ if (!deviceData) {
163
+ throw new Error('Device code not found or expired');
164
+ }
165
+
166
+ // Update database
167
+ await db.denyDeviceCode(deviceData.device_code);
168
+
169
+ // Update Redis
170
+ await redisLogs.set(
171
+ `device:${deviceData.device_code}`,
172
+ JSON.stringify({
173
+ ...deviceData,
174
+ status: 'denied'
175
+ }),
176
+ 60 // Keep for 1 minute
177
+ );
178
+
179
+ return true;
180
+ }
181
+
182
+ /**
183
+ * Poll for device token
184
+ * Step 2 of device flow - agent polls this endpoint
185
+ */
186
+ async function pollDeviceToken(deviceCode) {
187
+ // Check Redis first
188
+ let deviceData = await redisLogs.get(`device:${deviceCode}`);
189
+
190
+ // Fallback to database
191
+ if (!deviceData) {
192
+ const dbRecord = await db.getDeviceCode(deviceCode);
193
+ if (!dbRecord) {
194
+ return { error: 'invalid_grant', error_description: 'Device code not found or expired' };
195
+ }
196
+ deviceData = {
197
+ status: dbRecord.status,
198
+ user_id: dbRecord.user_id,
199
+ agent_name: dbRecord.agent_name,
200
+ capabilities: dbRecord.agent_capabilities
201
+ };
202
+ }
203
+
204
+ if (deviceData.status === 'pending') {
205
+ return { error: 'authorization_pending', error_description: 'User has not approved yet' };
206
+ }
207
+
208
+ if (deviceData.status === 'denied') {
209
+ return { error: 'access_denied', error_description: 'User denied the request' };
210
+ }
211
+
212
+ if (deviceData.status === 'approved') {
213
+ // Generate agent ID and token
214
+ const agentId = `agent-${Date.now()}-${crypto.randomBytes(4).toString('hex')}`;
215
+ const projectId = deviceData.project_id || null;
216
+ const accessToken = generateAgentToken(agentId, deviceData.user_id, projectId);
217
+ const tokenHash = hashToken(accessToken);
218
+
219
+ // Create agent in database
220
+ await db.createAgent({
221
+ id: agentId,
222
+ userId: deviceData.user_id,
223
+ name: deviceData.agent_name,
224
+ hostname: null,
225
+ capabilities: deviceData.capabilities,
226
+ accessTokenHash: tokenHash,
227
+ projectId: projectId
228
+ });
229
+
230
+ // Clean up device code
231
+ await redisLogs.del(`device:${deviceCode}`);
232
+ await redisLogs.del(`usercode:${deviceData.user_code}`);
233
+
234
+ return {
235
+ access_token: accessToken,
236
+ token_type: 'Bearer',
237
+ expires_in: 90 * 24 * 60 * 60, // 90 days in seconds
238
+ agent_id: agentId,
239
+ project_id: projectId
240
+ };
241
+ }
242
+
243
+ return { error: 'invalid_grant', error_description: 'Invalid device code state' };
244
+ }
245
+
246
+ module.exports = {
247
+ generateUserCode,
248
+ generateDeviceCode,
249
+ generateAgentToken,
250
+ verifyAgentToken,
251
+ hashToken,
252
+ requestDeviceCode,
253
+ getDeviceCodeByUserCode,
254
+ approveDeviceCode,
255
+ denyDeviceCode,
256
+ pollDeviceToken
257
+ };
package/lib/email.js ADDED
@@ -0,0 +1,420 @@
1
+ const nodemailer = require('nodemailer');
2
+
3
+ // SMTP configuration from environment or config
4
+ const SMTP_CONFIG = {
5
+ host: process.env.SMTP_HOST || 'smtp.gmail.com',
6
+ port: parseInt(process.env.SMTP_PORT || '587'),
7
+ secure: process.env.SMTP_SECURE === 'true', // use TLS for port 587
8
+ auth: {
9
+ user: process.env.SMTP_USER || 'riccardo.ravaro@datatamer.ai',
10
+ pass: process.env.SMTP_PASSWORD || ''
11
+ }
12
+ };
13
+
14
+ // Create reusable transporter
15
+ let transporter = null;
16
+
17
+ function getTransporter() {
18
+ if (!transporter) {
19
+ transporter = nodemailer.createTransport(SMTP_CONFIG);
20
+ }
21
+ return transporter;
22
+ }
23
+
24
+ async function sendEmail(to, subject, html) {
25
+ try {
26
+ const transport = getTransporter();
27
+
28
+ const info = await transport.sendMail({
29
+ from: `"AgentDev" <${SMTP_CONFIG.auth.user}>`,
30
+ to,
31
+ subject,
32
+ html
33
+ });
34
+
35
+ console.log('Email sent:', info.messageId);
36
+ return { success: true, messageId: info.messageId };
37
+ } catch (error) {
38
+ console.error('Error sending email:', error);
39
+ return { success: false, error: error.message };
40
+ }
41
+ }
42
+
43
+ async function sendPasswordResetEmail(email, resetToken, baseUrl) {
44
+ const resetLink = `${baseUrl}/reset-password?token=${resetToken}`;
45
+
46
+ const html = `
47
+ <!DOCTYPE html>
48
+ <html>
49
+ <head>
50
+ <meta charset="UTF-8">
51
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
52
+ <style>
53
+ body {
54
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
55
+ line-height: 1.6;
56
+ color: #333;
57
+ margin: 0;
58
+ padding: 0;
59
+ background-color: #f5f5f5;
60
+ }
61
+ .container {
62
+ max-width: 600px;
63
+ margin: 40px auto;
64
+ background: white;
65
+ border-radius: 8px;
66
+ overflow: hidden;
67
+ box-shadow: 0 2px 8px rgba(0,0,0,0.1);
68
+ }
69
+ .header {
70
+ background: linear-gradient(135deg, #ff6b6b 0%, #ee5a6f 100%);
71
+ padding: 40px 30px;
72
+ text-align: center;
73
+ }
74
+ .header h1 {
75
+ margin: 0;
76
+ color: white;
77
+ font-size: 28px;
78
+ font-weight: 600;
79
+ }
80
+ .content {
81
+ padding: 40px 30px;
82
+ }
83
+ .content p {
84
+ margin: 0 0 20px;
85
+ font-size: 16px;
86
+ }
87
+ .button {
88
+ display: inline-block;
89
+ padding: 14px 32px;
90
+ background: linear-gradient(135deg, #ff6b6b 0%, #ee5a6f 100%);
91
+ color: white !important;
92
+ text-decoration: none;
93
+ border-radius: 6px;
94
+ font-weight: 600;
95
+ font-size: 16px;
96
+ margin: 20px 0;
97
+ }
98
+ .button:hover {
99
+ background: linear-gradient(135deg, #ee5a6f 0%, #ff6b6b 100%);
100
+ }
101
+ .code-box {
102
+ background: #f8f9fa;
103
+ border: 1px solid #e9ecef;
104
+ border-radius: 6px;
105
+ padding: 16px;
106
+ margin: 20px 0;
107
+ text-align: center;
108
+ }
109
+ .code {
110
+ font-family: 'Courier New', monospace;
111
+ font-size: 18px;
112
+ font-weight: bold;
113
+ color: #333;
114
+ letter-spacing: 2px;
115
+ }
116
+ .footer {
117
+ background: #f8f9fa;
118
+ padding: 30px;
119
+ text-align: center;
120
+ font-size: 14px;
121
+ color: #6c757d;
122
+ }
123
+ .warning {
124
+ background: #fff3cd;
125
+ border: 1px solid #ffeeba;
126
+ border-radius: 6px;
127
+ padding: 16px;
128
+ margin: 20px 0;
129
+ font-size: 14px;
130
+ color: #856404;
131
+ }
132
+ </style>
133
+ </head>
134
+ <body>
135
+ <div class="container">
136
+ <div class="header">
137
+ <h1>🔒 Password Reset</h1>
138
+ </div>
139
+
140
+ <div class="content">
141
+ <p>Hello,</p>
142
+
143
+ <p>We received a request to reset your password for your AgentDev account. Click the button below to create a new password:</p>
144
+
145
+ <div style="text-align: center;">
146
+ <a href="${resetLink}" class="button">Reset Password</a>
147
+ </div>
148
+
149
+ <p style="font-size: 14px; color: #6c757d;">Or copy and paste this link into your browser:</p>
150
+ <div class="code-box">
151
+ <div class="code">${resetLink}</div>
152
+ </div>
153
+
154
+ <div class="warning">
155
+ <strong>⚠️ Security Notice:</strong>
156
+ <ul style="margin: 8px 0; padding-left: 20px;">
157
+ <li>This link expires in 1 hour</li>
158
+ <li>If you didn't request this, you can safely ignore this email</li>
159
+ <li>Your password won't change until you click the link and create a new one</li>
160
+ </ul>
161
+ </div>
162
+
163
+ <p>If you have any questions or concerns, please contact support.</p>
164
+
165
+ <p>Best regards,<br>
166
+ <strong>The AgentDev Team</strong></p>
167
+ </div>
168
+
169
+ <div class="footer">
170
+ <p>This is an automated email from AgentDev. Please do not reply to this email.</p>
171
+ <p style="margin-top: 8px;">© ${new Date().getFullYear()} AgentDev. All rights reserved.</p>
172
+ </div>
173
+ </div>
174
+ </body>
175
+ </html>
176
+ `;
177
+
178
+ return await sendEmail(
179
+ email,
180
+ '🔒 Reset your AgentDev password',
181
+ html
182
+ );
183
+ }
184
+
185
+ async function sendWelcomeEmail(email, baseUrl) {
186
+ const html = `
187
+ <!DOCTYPE html>
188
+ <html>
189
+ <head>
190
+ <meta charset="UTF-8">
191
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
192
+ <style>
193
+ body {
194
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
195
+ line-height: 1.6;
196
+ color: #333;
197
+ margin: 0;
198
+ padding: 0;
199
+ background-color: #f5f5f5;
200
+ }
201
+ .container {
202
+ max-width: 600px;
203
+ margin: 40px auto;
204
+ background: white;
205
+ border-radius: 8px;
206
+ overflow: hidden;
207
+ box-shadow: 0 2px 8px rgba(0,0,0,0.1);
208
+ }
209
+ .header {
210
+ background: linear-gradient(135deg, #ff6b6b 0%, #ee5a6f 100%);
211
+ padding: 40px 30px;
212
+ text-align: center;
213
+ }
214
+ .header h1 {
215
+ margin: 0;
216
+ color: white;
217
+ font-size: 28px;
218
+ font-weight: 600;
219
+ }
220
+ .content {
221
+ padding: 40px 30px;
222
+ }
223
+ .button {
224
+ display: inline-block;
225
+ padding: 14px 32px;
226
+ background: linear-gradient(135deg, #ff6b6b 0%, #ee5a6f 100%);
227
+ color: white !important;
228
+ text-decoration: none;
229
+ border-radius: 6px;
230
+ font-weight: 600;
231
+ font-size: 16px;
232
+ margin: 20px 0;
233
+ }
234
+ .footer {
235
+ background: #f8f9fa;
236
+ padding: 30px;
237
+ text-align: center;
238
+ font-size: 14px;
239
+ color: #6c757d;
240
+ }
241
+ </style>
242
+ </head>
243
+ <body>
244
+ <div class="container">
245
+ <div class="header">
246
+ <h1>🎉 Welcome to AgentDev!</h1>
247
+ </div>
248
+
249
+ <div class="content">
250
+ <p>Hello,</p>
251
+
252
+ <p>Your AgentDev account has been created successfully! You can now start managing distributed agents and automating your workflows.</p>
253
+
254
+ <div style="text-align: center;">
255
+ <a href="${baseUrl}/login" class="button">Go to Dashboard</a>
256
+ </div>
257
+
258
+ <p><strong>Getting Started:</strong></p>
259
+ <ol>
260
+ <li>Configure your GitHub OAuth App in the Profile settings</li>
261
+ <li>Install the agent client on your machines</li>
262
+ <li>Register agents using the device flow</li>
263
+ <li>Start automating your GitHub tickets!</li>
264
+ </ol>
265
+
266
+ <p>If you have any questions, feel free to reach out to our support team.</p>
267
+
268
+ <p>Happy automating!<br>
269
+ <strong>The AgentDev Team</strong></p>
270
+ </div>
271
+
272
+ <div class="footer">
273
+ <p>© ${new Date().getFullYear()} AgentDev. All rights reserved.</p>
274
+ </div>
275
+ </div>
276
+ </body>
277
+ </html>
278
+ `;
279
+
280
+ return await sendEmail(
281
+ email,
282
+ '🎉 Welcome to AgentDev!',
283
+ html
284
+ );
285
+ }
286
+
287
+ async function sendVerificationEmail(email, verificationToken, baseUrl) {
288
+ const verifyLink = `${baseUrl}/verify-email?token=${verificationToken}`;
289
+
290
+ const html = `
291
+ <!DOCTYPE html>
292
+ <html>
293
+ <head>
294
+ <meta charset="UTF-8">
295
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
296
+ <style>
297
+ body {
298
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
299
+ line-height: 1.6;
300
+ color: #333;
301
+ margin: 0;
302
+ padding: 0;
303
+ background-color: #f5f5f5;
304
+ }
305
+ .container {
306
+ max-width: 600px;
307
+ margin: 40px auto;
308
+ background: white;
309
+ border-radius: 8px;
310
+ overflow: hidden;
311
+ box-shadow: 0 2px 8px rgba(0,0,0,0.1);
312
+ }
313
+ .header {
314
+ background: linear-gradient(135deg, #ff6b6b 0%, #ee5a6f 100%);
315
+ padding: 40px 30px;
316
+ text-align: center;
317
+ }
318
+ .header h1 {
319
+ margin: 0;
320
+ color: white;
321
+ font-size: 28px;
322
+ font-weight: 600;
323
+ }
324
+ .content {
325
+ padding: 40px 30px;
326
+ }
327
+ .button {
328
+ display: inline-block;
329
+ padding: 14px 32px;
330
+ background: linear-gradient(135deg, #ff6b6b 0%, #ee5a6f 100%);
331
+ color: white !important;
332
+ text-decoration: none;
333
+ border-radius: 6px;
334
+ font-weight: 600;
335
+ font-size: 16px;
336
+ margin: 20px 0;
337
+ }
338
+ .code-box {
339
+ background: #f8f9fa;
340
+ border: 1px solid #e9ecef;
341
+ border-radius: 6px;
342
+ padding: 16px;
343
+ margin: 20px 0;
344
+ text-align: center;
345
+ }
346
+ .footer {
347
+ background: #f8f9fa;
348
+ padding: 30px;
349
+ text-align: center;
350
+ font-size: 14px;
351
+ color: #6c757d;
352
+ }
353
+ .warning {
354
+ background: #fff3cd;
355
+ border: 1px solid #ffeeba;
356
+ border-radius: 6px;
357
+ padding: 16px;
358
+ margin: 20px 0;
359
+ font-size: 14px;
360
+ color: #856404;
361
+ }
362
+ </style>
363
+ </head>
364
+ <body>
365
+ <div class="container">
366
+ <div class="header">
367
+ <h1>📧 Verify Your Email</h1>
368
+ </div>
369
+
370
+ <div class="content">
371
+ <p>Hello,</p>
372
+
373
+ <p>Thank you for registering with AgentDev! Please verify your email address to activate your account.</p>
374
+
375
+ <div style="text-align: center;">
376
+ <a href="${verifyLink}" class="button">Verify Email Address</a>
377
+ </div>
378
+
379
+ <p style="font-size: 14px; color: #6c757d;">Or copy and paste this link into your browser:</p>
380
+ <div class="code-box">
381
+ <div style="font-family: 'Courier New', monospace; font-size: 12px; word-break: break-all;">${verifyLink}</div>
382
+ </div>
383
+
384
+ <div class="warning">
385
+ <strong>⏰ Important:</strong>
386
+ <ul style="margin: 8px 0; padding-left: 20px;">
387
+ <li>This link expires in 24 hours</li>
388
+ <li>You must verify your email before you can log in</li>
389
+ <li>If you didn't create this account, you can safely ignore this email</li>
390
+ </ul>
391
+ </div>
392
+
393
+ <p>Once verified, you'll be able to sign in and start managing your distributed agents!</p>
394
+
395
+ <p>Best regards,<br>
396
+ <strong>The AgentDev Team</strong></p>
397
+ </div>
398
+
399
+ <div class="footer">
400
+ <p>This is an automated email from AgentDev. Please do not reply to this email.</p>
401
+ <p style="margin-top: 8px;">© ${new Date().getFullYear()} AgentDev. All rights reserved.</p>
402
+ </div>
403
+ </div>
404
+ </body>
405
+ </html>
406
+ `;
407
+
408
+ return await sendEmail(
409
+ email,
410
+ '📧 Verify your AgentDev email address',
411
+ html
412
+ );
413
+ }
414
+
415
+ module.exports = {
416
+ sendEmail,
417
+ sendPasswordResetEmail,
418
+ sendWelcomeEmail,
419
+ sendVerificationEmail
420
+ };