kickload-watcher-mcp 0.1.0 → 0.1.2

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/config.js CHANGED
@@ -1,103 +1,113 @@
1
- import dotenv from "dotenv";
2
- dotenv.config();
3
-
4
- export const config = {
5
- anthropic: {
6
- apiKey: process.env.ANTHROPIC_API_KEY || "",
7
- complianceApiKey: process.env.ANTHROPIC_COMPLIANCE_API_KEY || "",
8
- complianceBaseUrl:"https://api.anthropic.com/v1",
9
- pollIntervalMs: parseInt(process.env.POLL_INTERVAL_MS || "60000"),
10
- model: "claude-sonnet-4-20250514",
11
- },
12
-
13
- kickload: {
14
- apiToken: process.env.KICKLOAD_API_TOKEN || "",
15
- baseUrl: process.env.KICKLOAD_BASE_URL || "https://kickload.neeyatai.com/api",
16
- defaultThreads: parseInt(process.env.DEFAULT_THREADS || "5"),
17
- defaultLoopCount: parseInt(process.env.DEFAULT_LOOPS || "2"),
18
- defaultRampTime: parseInt(process.env.DEFAULT_RAMP || "2"),
19
- },
20
-
21
- targetApiBaseUrl: process.env.TARGET_API_BASE_URL || null,
22
-
23
- // ngrok.enabled is now advisory only — pipeline auto-enables for localhost
24
- ngrok: {
25
- enabled: process.env.NGROK_ENABLED !== "false", // default true
26
- authToken: process.env.NGROK_AUTHTOKEN || "",
27
- binDir: ".bin",
28
- },
29
-
30
- email: {
31
- provider: process.env.EMAIL_PROVIDER || "smtp",
32
- smtp: {
33
- host: process.env.SMTP_HOST || "smtp.gmail.com",
34
- port: parseInt(process.env.SMTP_PORT || "465"),
35
- user: process.env.SMTP_USER || "",
36
- pass: process.env.SMTP_PASS || "",
37
- },
38
- sendgrid: { apiKey: process.env.SENDGRID_API_KEY || "" },
39
- ses: {
40
- accessKey: process.env.AWS_ACCESS_KEY_ID || "",
41
- secretKey: process.env.AWS_SECRET_ACCESS_KEY || "",
42
- region: process.env.AWS_REGION || "us-east-1",
43
- },
44
- fromName: process.env.EMAIL_FROM_NAME || "KickLoad Watcher",
45
- fromAddress: process.env.EMAIL_FROM_ADDRESS || "",
46
- },
47
-
48
- watcher: {
49
- watchPaths: (process.env.WATCH_PATHS || ".").split(",").map(p => p.trim()),
50
- extensions: [".js", ".ts", ".py", ".go", ".java", ".rb", ".php", ".cs", ".rs"],
51
- ignore: ["**/node_modules/**", "**/.git/**", "**/dist/**", "**/build/**"],
52
- },
53
-
54
- identity: {
55
- mapFile: process.env.IDENTITY_MAP || "./users.json",
56
- defaultUserId: process.env.DEFAULT_USER_ID || null,
57
- defaultDevEmail: process.env.DEFAULT_DEVELOPER_EMAIL || null,
58
- },
59
-
60
- triggerMode: process.env.TRIGGER_MODE || "claudecode",
61
-
62
- github: {
63
- token: process.env.GITHUB_TOKEN || "",
64
- webhookPort: parseInt(process.env.GITHUB_WEBHOOK_PORT || "3456"),
65
- webhookSecret: process.env.GITHUB_WEBHOOK_SECRET || "",
66
- },
67
-
68
- logLevel: process.env.LOG_LEVEL || "info",
69
- };
70
-
71
- export function validateConfig() {
72
- const errors = [];
73
- const warnings = [];
74
-
75
- if (!config.anthropic.apiKey) errors.push("ANTHROPIC_API_KEY is required");
76
- if (!config.kickload.apiToken) errors.push("KICKLOAD_API_TOKEN is required");
77
- if (!config.email.fromAddress) errors.push("EMAIL_FROM_ADDRESS is required");
78
-
79
- if (config.email.provider === "smtp") {
80
- if (!config.email.smtp.user) errors.push("SMTP_USER is required for smtp provider");
81
- if (!config.email.smtp.pass) errors.push("SMTP_PASS is required for smtp provider");
82
- }
83
-
84
- if (config.email.provider === "sendgrid" && !config.email.sendgrid.apiKey) {
85
- errors.push("SENDGRID_API_KEY is required when EMAIL_PROVIDER=sendgrid");
86
- }
87
-
88
- // ngrok token is a warning, not a hard error — auto-mode handles it gracefully
89
- if (!config.ngrok.authToken) {
90
- warnings.push("NGROK_AUTHTOKEN not set ngrok tunnel will not authenticate (localhost backends cannot be tested)");
91
- }
92
-
93
- if (warnings.length) {
94
- warnings.forEach(w => console.warn(` ⚠️ ${w}`));
95
- }
96
-
97
- if (errors.length) {
98
- console.error("\n❌ Missing required configuration:");
99
- errors.forEach(e => console.error(` • ${e}`));
100
- console.error("\n Fix: delete .env and re-run to go through setup again, or edit .env manually.\n");
101
- process.exit(1);
102
- }
103
- }
1
+ import dotenv from "dotenv";
2
+ dotenv.config();
3
+
4
+ // Called by index.js after setup writes .env so all fields reflect the real values.
5
+ export function reloadConfig() {
6
+ dotenv.config({ override: true });
7
+ Object.assign(config, buildConfig());
8
+ }
9
+
10
+ function buildConfig() {
11
+ return {
12
+ anthropic: {
13
+ apiKey: process.env.ANTHROPIC_API_KEY || "",
14
+ complianceApiKey: process.env.ANTHROPIC_COMPLIANCE_API_KEY || "",
15
+ complianceBaseUrl:"https://api.anthropic.com/v1",
16
+ pollIntervalMs: parseInt(process.env.POLL_INTERVAL_MS || "60000"),
17
+ model: "claude-sonnet-4-20250514",
18
+ },
19
+
20
+ kickload: {
21
+ apiToken: process.env.KICKLOAD_API_TOKEN || "",
22
+ baseUrl: process.env.KICKLOAD_BASE_URL || "https://kickload.neeyatai.com/api",
23
+ defaultThreads: parseInt(process.env.DEFAULT_THREADS || "5"),
24
+ defaultLoopCount: parseInt(process.env.DEFAULT_LOOPS || "2"),
25
+ defaultRampTime: parseInt(process.env.DEFAULT_RAMP || "2"),
26
+ },
27
+
28
+ targetApiBaseUrl: process.env.TARGET_API_BASE_URL || null,
29
+
30
+ // ngrok.enabled is now advisory only — pipeline auto-enables for localhost
31
+ ngrok: {
32
+ enabled: process.env.NGROK_ENABLED !== "false", // default true
33
+ authToken: process.env.NGROK_AUTHTOKEN || "",
34
+ binDir: ".bin",
35
+ },
36
+
37
+ email: {
38
+ provider: process.env.EMAIL_PROVIDER || "smtp",
39
+ smtp: {
40
+ host: process.env.SMTP_HOST || "smtp.gmail.com",
41
+ port: parseInt(process.env.SMTP_PORT || "465"),
42
+ user: process.env.SMTP_USER || "",
43
+ pass: process.env.SMTP_PASS || "",
44
+ },
45
+ sendgrid: { apiKey: process.env.SENDGRID_API_KEY || "" },
46
+ ses: {
47
+ accessKey: process.env.AWS_ACCESS_KEY_ID || "",
48
+ secretKey: process.env.AWS_SECRET_ACCESS_KEY || "",
49
+ region: process.env.AWS_REGION || "us-east-1",
50
+ },
51
+ fromName: process.env.EMAIL_FROM_NAME || "KickLoad Watcher",
52
+ fromAddress: process.env.EMAIL_FROM_ADDRESS || "",
53
+ },
54
+
55
+ watcher: {
56
+ watchPaths: (process.env.WATCH_PATHS || ".").split(",").map(p => p.trim()),
57
+ extensions: [".js", ".ts", ".py", ".go", ".java", ".rb", ".php", ".cs", ".rs"],
58
+ ignore: ["**/node_modules/**", "**/.git/**", "**/dist/**", "**/build/**"],
59
+ },
60
+
61
+ identity: {
62
+ mapFile: process.env.IDENTITY_MAP || "./users.json",
63
+ defaultUserId: process.env.DEFAULT_USER_ID || null,
64
+ defaultDevEmail: process.env.DEFAULT_DEVELOPER_EMAIL || null,
65
+ },
66
+
67
+ triggerMode: process.env.TRIGGER_MODE || "claudecode",
68
+
69
+ github: {
70
+ token: process.env.GITHUB_TOKEN || "",
71
+ webhookPort: parseInt(process.env.GITHUB_WEBHOOK_PORT || "3456"),
72
+ webhookSecret: process.env.GITHUB_WEBHOOK_SECRET || "",
73
+ },
74
+
75
+ logLevel: process.env.LOG_LEVEL || "info",
76
+ }; // end return
77
+ } // end buildConfig
78
+
79
+ export const config = buildConfig();
80
+
81
+ export function validateConfig() {
82
+ const errors = [];
83
+ const warnings = [];
84
+
85
+ if (!config.anthropic.apiKey) errors.push("ANTHROPIC_API_KEY is required");
86
+ if (!config.kickload.apiToken) errors.push("KICKLOAD_API_TOKEN is required");
87
+ if (!config.email.fromAddress) errors.push("EMAIL_FROM_ADDRESS is required");
88
+
89
+ if (config.email.provider === "smtp") {
90
+ if (!config.email.smtp.user) errors.push("SMTP_USER is required for smtp provider");
91
+ if (!config.email.smtp.pass) errors.push("SMTP_PASS is required for smtp provider");
92
+ }
93
+
94
+ if (config.email.provider === "sendgrid" && !config.email.sendgrid.apiKey) {
95
+ errors.push("SENDGRID_API_KEY is required when EMAIL_PROVIDER=sendgrid");
96
+ }
97
+
98
+ // ngrok token is a warning, not a hard error auto-mode handles it gracefully
99
+ if (!config.ngrok.authToken) {
100
+ warnings.push("NGROK_AUTHTOKEN not set ngrok tunnel will not authenticate (localhost backends cannot be tested)");
101
+ }
102
+
103
+ if (warnings.length) {
104
+ warnings.forEach(w => console.warn(` ⚠️ ${w}`));
105
+ }
106
+
107
+ if (errors.length) {
108
+ console.error("\n❌ Missing required configuration:");
109
+ errors.forEach(e => console.error(` • ${e}`));
110
+ console.error("\n Fix: delete .env and re-run to go through setup again, or edit .env manually.\n");
111
+ process.exit(1);
112
+ }
113
+ }
package/email-sender.js CHANGED
@@ -1,137 +1,137 @@
1
- import nodemailer from "nodemailer";
2
- import { config } from "./config.js";
3
-
4
- let transporter = null;
5
-
6
- export function _getTransporter() { return transporter; }
7
-
8
- /**
9
- * Initialize email transporter.
10
- * Does NOT verify — call verifyEmailService() for startup check.
11
- */
12
- export function initEmailSender() {
13
- const p = config.email.provider;
14
-
15
- if (p === "smtp") {
16
- transporter = nodemailer.createTransport({
17
- host: config.email.smtp.host,
18
- port: config.email.smtp.port,
19
- secure: config.email.smtp.port === 465,
20
- auth: { user: config.email.smtp.user, pass: config.email.smtp.pass },
21
- });
22
- } else if (p === "sendgrid") {
23
- transporter = nodemailer.createTransport({
24
- host: "smtp.sendgrid.net", port: 587,
25
- auth: { user: "apikey", pass: config.email.sendgrid.apiKey },
26
- });
27
- } else if (p === "ses") {
28
- transporter = nodemailer.createTransport({
29
- host: `email-smtp.${config.email.ses.region}.amazonaws.com`,
30
- port: 587, secure: false,
31
- auth: { user: config.email.ses.accessKey, pass: config.email.ses.secretKey },
32
- });
33
- } else {
34
- throw new Error(`Unknown email provider: ${p}`);
35
- }
36
- }
37
-
38
- /**
39
- * PROBLEM 2 — Email strict mode.
40
- * Verifies the transporter can authenticate.
41
- * Exits process if verification fails.
42
- */
43
- export async function verifyEmailService() {
44
- if (!transporter) {
45
- console.error("\n❌ Email transporter not initialized before verify()");
46
- process.exit(1);
47
- }
48
-
49
- process.stdout.write(" Verifying email service... ");
50
- try {
51
- await transporter.verify();
52
- console.log("✅ Email service verified");
53
- } catch (err) {
54
- console.log(""); // newline after the "..."
55
- console.error("\n❌ Email verification failed — cannot start.");
56
- console.error(` Provider : ${config.email.provider}`);
57
- console.error(` User : ${config.email.smtp.user || "(none)"}`);
58
- console.error(` Error : ${err.message}`);
59
- console.error("\n Fix: check SMTP_USER / SMTP_PASS in .env");
60
- console.error(" For Gmail, use an App Password: myaccount.google.com/apppasswords\n");
61
- process.exit(1);
62
- }
63
- }
64
-
65
- export async function sendResultEmail({ toEmail, endpoint, passed, summary, downloadUrl, pdfFilename, durationMs, testPrompt }) {
66
- if (!transporter) throw new Error("Email sender not initialized");
67
-
68
- const status = passed ? "✅ PASS" : "❌ FAIL";
69
- const statusColor = passed ? "#16a34a" : "#dc2626";
70
- const statusBg = passed ? "#f0fdf4" : "#fef2f2";
71
- const statusBorder = passed ? "#86efac" : "#fca5a5";
72
- const durationSec = durationMs ? Math.round(durationMs / 1000) : null;
73
- const testedAt = new Date().toLocaleString("en-IN", { timeZone: "Asia/Kolkata", dateStyle: "medium", timeStyle: "short" });
74
- const subject = `[KickLoad Watcher] ${status} — ${endpoint}`;
75
-
76
- const rows = [
77
- ["Endpoint", `<span style="font-family:monospace">${endpoint}</span>`],
78
- ["Tested At", `${testedAt} IST`],
79
- summary?.averageLatency != null ? ["Latency", `${summary.averageLatency}ms`] : null,
80
- summary?.errorPercentage != null ? ["Error Rate", `${summary.errorPercentage}%`] : null,
81
- summary?.throughput != null ? ["Throughput", `${summary.throughput?.toLocaleString()} req/s`] : null,
82
- durationSec != null ? ["Duration", `${durationSec}s`] : null,
83
- ].filter(Boolean).map(([label, value]) =>
84
- `<tr style="border-bottom:1px solid #f3f4f6;">
85
- <td style="padding:8px 16px;color:#6b7280;font-size:14px;">${label}</td>
86
- <td style="padding:8px 16px;color:#111827;font-size:14px;font-weight:600;text-align:right;">${value}</td>
87
- </tr>`
88
- ).join("");
89
-
90
- const downloadBtn = downloadUrl
91
- ? `<a href="${downloadUrl}" style="display:inline-block;background:#f47c20;color:#fff;text-decoration:none;padding:12px 28px;border-radius:8px;font-weight:700;font-size:14px;">Download Report</a>
92
- <p style="color:#9ca3af;font-size:12px;margin-top:8px;">${pdfFilename || ""} · expires in 24hrs</p>`
93
- : `<p style="color:#9ca3af;font-size:13px;">Report available in KickLoad dashboard.</p>`;
94
-
95
- const html = `<!DOCTYPE html><html><body style="margin:0;padding:0;background:#f9fafb;font-family:Inter,Arial,sans-serif;">
96
- <table width="100%" cellpadding="0" cellspacing="0" style="padding:40px 20px;"><tr><td align="center">
97
- <table width="560" cellpadding="0" cellspacing="0" style="background:#fff;border-radius:12px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,0.1);">
98
- <tr><td style="background:linear-gradient(135deg,#1e202a,#303952);padding:28px 32px;">
99
- <h1 style="margin:0;color:#fff;font-size:22px;font-weight:800;">KickLoad Watcher</h1>
100
- <p style="margin:4px 0 0;color:#9ca3af;font-size:13px;">OneQA — Automated API Testing</p>
101
- </td></tr>
102
- <tr><td style="padding:28px 32px 0;">
103
- <div style="background:${statusBg};border:1px solid ${statusBorder};border-radius:10px;padding:16px 20px;">
104
- <div style="font-size:20px;font-weight:800;color:${statusColor};">${status}</div>
105
- <div style="font-size:13px;color:#6b7280;margin-top:2px;font-family:monospace;">${endpoint}</div>
106
- </div>
107
- </td></tr>
108
- <tr><td style="padding:20px 32px 0;">
109
- <table width="100%" cellpadding="0" cellspacing="0" style="border:1px solid #e5e7eb;border-radius:8px;overflow:hidden;">
110
- <tr style="background:#f9fafb;"><td colspan="2" style="padding:10px 16px;font-size:12px;font-weight:700;color:#9ca3af;text-transform:uppercase;border-bottom:1px solid #e5e7eb;">Test Details</td></tr>
111
- ${rows}
112
- </table>
113
- </td></tr>
114
- <tr><td style="padding:24px 32px;text-align:center;">${downloadBtn}</td></tr>
115
- <tr><td style="padding:20px 32px;border-top:1px solid #f3f4f6;">
116
- <p style="margin:0;color:#9ca3af;font-size:12px;text-align:center;">
117
- KickLoad Watcher · <a href="https://kickload.neeyatai.com" style="color:#f47c20;text-decoration:none;">NeeyatAI</a>
118
- </p>
119
- </td></tr>
120
- </table></td></tr></table></body></html>`;
121
-
122
- const text =
123
- `KickLoad Watcher — ${status}\nEndpoint: ${endpoint}\nTested: ${testedAt} IST\n` +
124
- (summary?.averageLatency != null ? `Latency: ${summary.averageLatency}ms\n` : "") +
125
- (summary?.errorPercentage != null ? `Errors: ${summary.errorPercentage}%\n` : "") +
126
- (summary?.throughput != null ? `Throughput: ${summary.throughput} req/s\n` : "") +
127
- (durationSec != null ? `Duration: ${durationSec}s\n` : "") +
128
- (downloadUrl ? `\nReport: ${downloadUrl}\n` : "") +
129
- `\n— KickLoad Watcher`;
130
-
131
- const info = await transporter.sendMail({
132
- from: `"${config.email.fromName}" <${config.email.fromAddress}>`,
133
- to: toEmail, subject, html, text,
134
- });
135
-
136
- console.log(`📧 Email sent → ${toEmail} (${info.messageId})`);
137
- }
1
+ import nodemailer from "nodemailer";
2
+ import { config } from "./config.js";
3
+
4
+ let transporter = null;
5
+
6
+ export function _getTransporter() { return transporter; }
7
+
8
+ /**
9
+ * Initialize email transporter.
10
+ * Does NOT verify — call verifyEmailService() for startup check.
11
+ */
12
+ export function initEmailSender() {
13
+ const p = config.email.provider;
14
+
15
+ if (p === "smtp") {
16
+ transporter = nodemailer.createTransport({
17
+ host: config.email.smtp.host,
18
+ port: config.email.smtp.port,
19
+ secure: config.email.smtp.port === 465,
20
+ auth: { user: config.email.smtp.user, pass: config.email.smtp.pass },
21
+ });
22
+ } else if (p === "sendgrid") {
23
+ transporter = nodemailer.createTransport({
24
+ host: "smtp.sendgrid.net", port: 587,
25
+ auth: { user: "apikey", pass: config.email.sendgrid.apiKey },
26
+ });
27
+ } else if (p === "ses") {
28
+ transporter = nodemailer.createTransport({
29
+ host: `email-smtp.${config.email.ses.region}.amazonaws.com`,
30
+ port: 587, secure: false,
31
+ auth: { user: config.email.ses.accessKey, pass: config.email.ses.secretKey },
32
+ });
33
+ } else {
34
+ throw new Error(`Unknown email provider: ${p}`);
35
+ }
36
+ }
37
+
38
+ /**
39
+ * PROBLEM 2 — Email strict mode.
40
+ * Verifies the transporter can authenticate.
41
+ * Exits process if verification fails.
42
+ */
43
+ export async function verifyEmailService() {
44
+ if (!transporter) {
45
+ console.error("\n❌ Email transporter not initialized before verify()");
46
+ process.exit(1);
47
+ }
48
+
49
+ process.stdout.write(" Verifying email service... ");
50
+ try {
51
+ await transporter.verify();
52
+ console.log("✅ Email service verified");
53
+ } catch (err) {
54
+ console.log(""); // newline after the "..."
55
+ console.error("\n❌ Email verification failed — cannot start.");
56
+ console.error(` Provider : ${config.email.provider}`);
57
+ console.error(` User : ${config.email.smtp.user || "(none)"}`);
58
+ console.error(` Error : ${err.message}`);
59
+ console.error("\n Fix: check SMTP_USER / SMTP_PASS in .env");
60
+ console.error(" For Gmail, use an App Password: myaccount.google.com/apppasswords\n");
61
+ process.exit(1);
62
+ }
63
+ }
64
+
65
+ export async function sendResultEmail({ toEmail, endpoint, passed, summary, downloadUrl, pdfFilename, durationMs, testPrompt }) {
66
+ if (!transporter) throw new Error("Email sender not initialized");
67
+
68
+ const status = passed ? "✅ PASS" : "❌ FAIL";
69
+ const statusColor = passed ? "#16a34a" : "#dc2626";
70
+ const statusBg = passed ? "#f0fdf4" : "#fef2f2";
71
+ const statusBorder = passed ? "#86efac" : "#fca5a5";
72
+ const durationSec = durationMs ? Math.round(durationMs / 1000) : null;
73
+ const testedAt = new Date().toLocaleString("en-IN", { timeZone: "Asia/Kolkata", dateStyle: "medium", timeStyle: "short" });
74
+ const subject = `[KickLoad Watcher] ${status} — ${endpoint}`;
75
+
76
+ const rows = [
77
+ ["Endpoint", `<span style="font-family:monospace">${endpoint}</span>`],
78
+ ["Tested At", `${testedAt} IST`],
79
+ summary?.averageLatency != null ? ["Latency", `${summary.averageLatency}ms`] : null,
80
+ summary?.errorPercentage != null ? ["Error Rate", `${summary.errorPercentage}%`] : null,
81
+ summary?.throughput != null ? ["Throughput", `${summary.throughput?.toLocaleString()} req/s`] : null,
82
+ durationSec != null ? ["Duration", `${durationSec}s`] : null,
83
+ ].filter(Boolean).map(([label, value]) =>
84
+ `<tr style="border-bottom:1px solid #f3f4f6;">
85
+ <td style="padding:8px 16px;color:#6b7280;font-size:14px;">${label}</td>
86
+ <td style="padding:8px 16px;color:#111827;font-size:14px;font-weight:600;text-align:right;">${value}</td>
87
+ </tr>`
88
+ ).join("");
89
+
90
+ const downloadBtn = downloadUrl
91
+ ? `<a href="${downloadUrl}" style="display:inline-block;background:#f47c20;color:#fff;text-decoration:none;padding:12px 28px;border-radius:8px;font-weight:700;font-size:14px;">Download Report</a>
92
+ <p style="color:#9ca3af;font-size:12px;margin-top:8px;">${pdfFilename || ""} · expires in 24hrs</p>`
93
+ : `<p style="color:#9ca3af;font-size:13px;">Report available in KickLoad dashboard.</p>`;
94
+
95
+ const html = `<!DOCTYPE html><html><body style="margin:0;padding:0;background:#f9fafb;font-family:Inter,Arial,sans-serif;">
96
+ <table width="100%" cellpadding="0" cellspacing="0" style="padding:40px 20px;"><tr><td align="center">
97
+ <table width="560" cellpadding="0" cellspacing="0" style="background:#fff;border-radius:12px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,0.1);">
98
+ <tr><td style="background:linear-gradient(135deg,#1e202a,#303952);padding:28px 32px;">
99
+ <h1 style="margin:0;color:#fff;font-size:22px;font-weight:800;">KickLoad Watcher</h1>
100
+ <p style="margin:4px 0 0;color:#9ca3af;font-size:13px;">OneQA — Automated API Testing</p>
101
+ </td></tr>
102
+ <tr><td style="padding:28px 32px 0;">
103
+ <div style="background:${statusBg};border:1px solid ${statusBorder};border-radius:10px;padding:16px 20px;">
104
+ <div style="font-size:20px;font-weight:800;color:${statusColor};">${status}</div>
105
+ <div style="font-size:13px;color:#6b7280;margin-top:2px;font-family:monospace;">${endpoint}</div>
106
+ </div>
107
+ </td></tr>
108
+ <tr><td style="padding:20px 32px 0;">
109
+ <table width="100%" cellpadding="0" cellspacing="0" style="border:1px solid #e5e7eb;border-radius:8px;overflow:hidden;">
110
+ <tr style="background:#f9fafb;"><td colspan="2" style="padding:10px 16px;font-size:12px;font-weight:700;color:#9ca3af;text-transform:uppercase;border-bottom:1px solid #e5e7eb;">Test Details</td></tr>
111
+ ${rows}
112
+ </table>
113
+ </td></tr>
114
+ <tr><td style="padding:24px 32px;text-align:center;">${downloadBtn}</td></tr>
115
+ <tr><td style="padding:20px 32px;border-top:1px solid #f3f4f6;">
116
+ <p style="margin:0;color:#9ca3af;font-size:12px;text-align:center;">
117
+ KickLoad Watcher · <a href="https://kickload.neeyatai.com" style="color:#f47c20;text-decoration:none;">NeeyatAI</a>
118
+ </p>
119
+ </td></tr>
120
+ </table></td></tr></table></body></html>`;
121
+
122
+ const text =
123
+ `KickLoad Watcher — ${status}\nEndpoint: ${endpoint}\nTested: ${testedAt} IST\n` +
124
+ (summary?.averageLatency != null ? `Latency: ${summary.averageLatency}ms\n` : "") +
125
+ (summary?.errorPercentage != null ? `Errors: ${summary.errorPercentage}%\n` : "") +
126
+ (summary?.throughput != null ? `Throughput: ${summary.throughput} req/s\n` : "") +
127
+ (durationSec != null ? `Duration: ${durationSec}s\n` : "") +
128
+ (downloadUrl ? `\nReport: ${downloadUrl}\n` : "") +
129
+ `\n— KickLoad Watcher`;
130
+
131
+ const info = await transporter.sendMail({
132
+ from: `"${config.email.fromName}" <${config.email.fromAddress}>`,
133
+ to: toEmail, subject, html, text,
134
+ });
135
+
136
+ console.log(`📧 Email sent → ${toEmail} (${info.messageId})`);
137
+ }