promptpulse
Version:
Track and analyze your Claude Code usage across multiple machines with team collaboration
322 lines (280 loc) • 10.2 kB
JavaScript
import { Resend } from "resend";
import dotenv from "dotenv";
import { log } from "./logger.js";
dotenv.config();
class EmailService {
constructor() {
this.resend = null;
this.initialized = false;
this.init();
}
init() {
const apiKey = process.env.RESEND_API_KEY;
if (!apiKey) {
log.warn("RESEND_API_KEY not found in environment variables. Email functionality will be disabled.");
return;
}
// Log API key format for debugging (mask most of it for security)
const maskedKey = apiKey.length > 8 ?
`${apiKey.substring(0, 4)}...${apiKey.substring(apiKey.length - 4)}` :
"invalid_length";
log.info(`Initializing email service with API key: ${maskedKey}`);
try {
this.resend = new Resend(apiKey);
this.initialized = true;
log.info("Email service initialized successfully with Resend", {
apiKeyPrefix: apiKey.substring(0, 3)
});
} catch (error) {
log.error("Failed to initialize email service:", error);
}
}
isEnabled() {
return this.initialized && this.resend !== null;
}
async sendEmail({ to, subject, html, from = null }) {
// Use configurable domain or fallback to Resend subdomain
if (!from) {
const emailDomain = process.env.EMAIL_FROM_DOMAIN || "mail.promptpulse.dev";
from = `PromptPulse <noreply@${emailDomain}>`;
}
if (!this.isEnabled()) {
const error = "Email service not initialized. Check RESEND_API_KEY environment variable.";
log.error(error, {
initialized: this.initialized,
resendNull: this.resend === null,
apiKeyExists: !!process.env.RESEND_API_KEY
});
throw new Error(error);
}
log.emailSending(to, subject, "resend");
try {
const result = await this.resend.emails.send({
from,
to,
subject,
html
});
log.info("Resend API response received", {
success: !!result.data,
emailId: result.data?.id,
error: result.error,
fullResult: result
});
if (result.error) {
log.emailError(result.error, {
service: "resend",
to,
subject,
errorType: "api_response_error"
});
return {
success: false,
emailId: null,
error: result.error.message || "Resend API error"
};
}
log.emailSuccess(to, subject, result.data?.id, "resend");
return {
success: true,
emailId: result.data?.id,
error: null
};
} catch (error) {
log.emailError(error, {
service: "resend",
to,
subject,
errorType: "exception_caught",
errorStack: error.stack,
errorName: error.name
});
return {
success: false,
emailId: null,
error: error.message
};
}
}
async sendUsageReport(userEmail, reportData, reportType = "weekly") {
const { subject, html } = this.generateReportEmail(reportData, reportType);
return await this.sendEmail({
to: userEmail,
subject,
html
});
}
async sendTestEmail(userEmail, username) {
const subject = "PromptPulse Email Test";
const html = this.generateTestEmail(username);
return await this.sendEmail({
to: userEmail,
subject,
html
});
}
generateReportEmail(reportData, reportType) {
const {
username,
displayName,
periodStart,
periodEnd,
totalTokens,
totalCost,
sessionCount,
topProjects,
costBreakdown,
tokenBreakdown
} = reportData;
const displayUserName = displayName || username;
const formattedPeriod = this.formatPeriod(periodStart, periodEnd, reportType);
const subject = `Your ${reportType} PromptPulse Usage Report - ${formattedPeriod}`;
const html = `
<html>
<head>
<meta charset="utf-8">
<title>${subject}</title>
<style>
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background: #f8f9fa; padding: 20px; border-radius: 8px; margin-bottom: 30px; text-align: center; }
.metric { background: #fff; border: 1px solid #e9ecef; padding: 15px; margin: 10px 0; border-radius: 5px; }
.metric-value { font-size: 24px; font-weight: bold; color: #0066cc; }
.metric-label { font-size: 14px; color: #666; }
.section { margin: 30px 0; }
.project-list { list-style: none; padding: 0; }
.project-item { padding: 8px 0; border-bottom: 1px solid #eee; }
.footer { margin-top: 40px; padding-top: 20px; border-top: 1px solid #eee; font-size: 12px; color: #666; text-align: center; }
.unsubscribe { margin-top: 20px; font-size: 11px; color: #999; }
</style>
</head>
<body>
<div class="header">
<h1>Your ${reportType.charAt(0).toUpperCase() + reportType.slice(1)} Usage Report</h1>
<p>Hi ${displayUserName}! Here's your Claude Code usage summary for ${formattedPeriod}</p>
</div>
<div class="section">
<h2>📊 Usage Overview</h2>
<div class="metric">
<div class="metric-value">${totalTokens.toLocaleString()}</div>
<div class="metric-label">Total Tokens Used</div>
</div>
<div class="metric">
<div class="metric-value">$${totalCost.toFixed(2)}</div>
<div class="metric-label">Total Cost</div>
</div>
<div class="metric">
<div class="metric-value">${sessionCount}</div>
<div class="metric-label">Coding Sessions</div>
</div>
</div>
${tokenBreakdown ? `
<div class="section">
<h2>🔤 Token Breakdown</h2>
<div class="metric">
<div class="metric-value">${tokenBreakdown.input.toLocaleString()}</div>
<div class="metric-label">Input Tokens</div>
</div>
<div class="metric">
<div class="metric-value">${tokenBreakdown.output.toLocaleString()}</div>
<div class="metric-label">Output Tokens</div>
</div>
${tokenBreakdown.cache_creation ? `
<div class="metric">
<div class="metric-value">${tokenBreakdown.cache_creation.toLocaleString()}</div>
<div class="metric-label">Cache Creation Tokens</div>
</div>
` : ""}
${tokenBreakdown.cache_read ? `
<div class="metric">
<div class="metric-value">${tokenBreakdown.cache_read.toLocaleString()}</div>
<div class="metric-label">Cache Read Tokens</div>
</div>
` : ""}
</div>
` : ""}
${topProjects && topProjects.length > 0 ? `
<div class="section">
<h2>🚀 Top Projects</h2>
<ul class="project-list">
${topProjects.map(project => `
<li class="project-item">
<strong>${project.name}</strong><br>
<small>${project.tokens.toLocaleString()} tokens • $${project.cost.toFixed(2)} • ${project.sessions} sessions</small>
</li>
`).join("")}
</ul>
</div>
` : ""}
<div class="footer">
<p>Keep coding! 🚀</p>
<p><strong>PromptPulse</strong> - Your Claude Code Usage Tracker</p>
<div class="unsubscribe">
<p>Don't want these emails? Update your preferences in the <a href="https://promptpulse.com/settings">PromptPulse dashboard</a></p>
</div>
</div>
</body>
</html>
`;
return { subject, html };
}
generateTestEmail(username) {
return `
<html>
<head>
<meta charset="utf-8">
<title>PromptPulse Email Test</title>
<style>
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background: #f8f9fa; padding: 20px; border-radius: 8px; margin-bottom: 30px; text-align: center; }
.content { background: #fff; padding: 20px; border-radius: 8px; margin: 20px 0; }
.footer { margin-top: 30px; padding-top: 20px; border-top: 1px solid #eee; font-size: 12px; color: #666; text-align: center; }
</style>
</head>
<body>
<div class="header">
<h1>Email Test Successful!</h1>
</div>
<div class="content">
<p>Hi ${username}!</p>
<p>Great news! Your email settings are working correctly. You'll now receive your usage reports at the frequency you've selected.</p>
<p>If you have any questions or need help, feel free to reach out through our support channels.</p>
<p>Happy coding! 🚀</p>
</div>
<div class="footer">
<p><strong>PromptPulse</strong> - Your Claude Code Usage Tracker</p>
</div>
</body>
</html>
`;
}
formatPeriod(startDate, endDate, reportType) {
const start = new Date(startDate);
const end = new Date(endDate);
const options = {
month: "short",
day: "numeric",
year: start.getFullYear() !== end.getFullYear() ? "numeric" : undefined
};
if (reportType === "daily") {
return start.toLocaleDateString("en-US", {
weekday: "long",
month: "long",
day: "numeric",
year: "numeric"
});
} else if (reportType === "weekly") {
return `${start.toLocaleDateString("en-US", options)} - ${end.toLocaleDateString("en-US", options)}`;
} else if (reportType === "monthly") {
return start.toLocaleDateString("en-US", {
month: "long",
year: "numeric"
});
}
return `${start.toLocaleDateString("en-US", options)} - ${end.toLocaleDateString("en-US", options)}`;
}
}
// Create singleton instance
const emailService = new EmailService();
export default emailService;