Specifications
RankFlow AI — Email Reports & PDF Generation Documentation
│ Email & Reporting System │
docs/specs/email-reports-pdf.mdOn this page
- 1. Email System Architecture
- Email Types
- 2. Monthly Report Email
- Report Sections
- Email Template
- 3. Weekly Digest
- Content
- 4. Alert Emails
- Alert Types
- Alert Template
- 5. PDF Generation
- Playwright Implementation
- PDF HTML Template
- 6. Report Templates
- Score Calculation
- Report Data Structure
- 7. Scheduling & Delivery
- Monthly Report Cron
- 8. Email Tracking
- Delivery Status
- Resend Webhook Handler
Version: 1.0.0
Email Service: Resend
PDF Engine: Playwright (Chromium)
Storage: AWS S3
1. Email System Architecture#
┌─────────────────────────────────────────────────────────────┐
│ Email & Reporting System │
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌───────────┐ │
│ │ Report │ │ HTML │ │ PDF │ │
│ │ Data │ ───→ │ Template │ ───→ │ Gen │ │
│ │ (DB) │ │ (React) │ │(Playwright)│ │
│ └─────────────┘ └─────────────┘ └─────┬─────┘ │
│ │ │
│ ┌─────────────┐ ┌─────────────┐ │ │
│ │ Resend │ ←─── │ Email │ ←─────────┘ │
│ │ API │ │ Service │ │
│ └─────────────┘ └─────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
Email Types#
| Type | Frequency | Audience | Content |
|---|---|---|---|
| Monthly Report | 1st of month | Client | Full SEO report with PDF |
| Weekly Digest | Every Monday | Client | Top 3 stats summary |
| Alert | Event-driven | Client + Admin | GBP drop, citation removed, negative review |
| Onboarding | One-time | Client | Welcome, GBP auth link, setup guide |
| Invoice | Monthly | Client | Payment receipt |
| Admin Summary | Daily | Admin | Failed jobs, API costs, new signups |
2. Monthly Report Email#
Report Sections#
| Section | Metrics | Source |
|---|---|---|
| Executive Summary | Overall score (0-100), period | Calculated |
| GBP Performance | Views, clicks, calls, directions, posts | GBP API |
| Citation Health | Live count, NAP consistency % | Citation DB |
| Review Summary | New reviews, avg rating, reply rate | Review DB |
| Rank Tracking | Keyword positions, top 10 count | DataForSEO |
| Social Activity | Posts published, engagement | Social DB |
| Site Traffic | Visitors, bounce rate | Analytics |
| AI Engine Mentions | ChatGPT/Perplexity visibility | Manual/Kimi |
| Action Items | 3 recommended actions | AI-generated |
Email Template#
// src/server/services/email/templates.ts
export function buildMonthlyReportEmail(data: ReportData): string {
return `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Monthly SEO Report — ${data.practiceName}</title>
<style>
@media only screen and (max-width: 600px) {
.container { width: 100% !important; }
.metric-card { width: 100% !important; display: block !important; }
}
</style>
</head>
<body style="margin:0;padding:0;background-color:#f3f4f6;font-family:Inter,system-ui,sans-serif;">
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="background-color:#f3f4f6;">
<tr>
<td align="center" style="padding:20px 0;">
<table role="presentation" class="container" cellpadding="0" cellspacing="0" width="640" style="background-color:#ffffff;border-radius:8px;overflow:hidden;">
<!-- Header -->
<tr>
<td style="background:linear-gradient(135deg,#2563eb 0%,#1d4ed8 100%);padding:30px 40px;text-align:center;">
<h1 style="color:#ffffff;margin:0 0 8px;font-size:24px;font-weight:700;">${data.practiceName}</h1>
<p style="color:#bfdbfe;margin:0;font-size:14px;">Monthly SEO Report — ${data.period}</p>
</td>
</tr>
<!-- Overall Score -->
<tr>
<td style="padding:30px 40px;text-align:center;border-bottom:1px solid #e5e7eb;">
<div style="display:inline-block;background:#f0fdf4;border:2px solid #22c55e;border-radius:50%;width:100px;height:100px;line-height:96px;margin-bottom:15px;">
<span style="font-size:36px;font-weight:800;color:#15803d;">${data.scoreOverall}</span>
</div>
<p style="margin:0;font-size:14px;color:#6b7280;">Overall SEO Score</p>
</td>
</tr>
<!-- Score Breakdown -->
<tr>
<td style="padding:20px 40px;border-bottom:1px solid #e5e7eb;">
<table role="presentation" cellpadding="0" cellspacing="0" width="100%">
<tr>
<td class="metric-card" width="50%" style="padding:10px;">
<div style="background:#f9fafb;border-radius:8px;padding:15px;text-align:center;border-left:4px solid #2563eb;">
<p style="margin:0 0 5px;font-size:12px;color:#6b7280;font-weight:500;">GBP</p>
<p style="margin:0 0 3px;font-size:28px;font-weight:800;color:#2563eb;">${data.scoreGbp}</p>
<p style="margin:0;font-size:11px;color:#6b7280;">${data.gbpViews} Views</p>
</div>
</td>
<td class="metric-card" width="50%" style="padding:10px;">
<div style="background:#f9fafb;border-radius:8px;padding:15px;text-align:center;border-left:4px solid #10b981;">
<p style="margin:0 0 5px;font-size:12px;color:#6b7280;font-weight:500;">Citations</p>
<p style="margin:0 0 3px;font-size:28px;font-weight:800;color:#10b981;">${data.scoreCitations}</p>
<p style="margin:0;font-size:11px;color:#6b7280;">${data.citationsVerified} Verified</p>
</div>
</td>
</tr>
<tr>
<td class="metric-card" width="50%" style="padding:10px;">
<div style="background:#f9fafb;border-radius:8px;padding:15px;text-align:center;border-left:4px solid #f59e0b;">
<p style="margin:0 0 5px;font-size:12px;color:#6b7280;font-weight:500;">Reviews</p>
<p style="margin:0 0 3px;font-size:28px;font-weight:800;color:#f59e0b;">${data.scoreReviews}</p>
<p style="margin:0;font-size:11px;color:#6b7280;">${data.reviewAvg}/5.0</p>
</div>
</td>
<td class="metric-card" width="50%" style="padding:10px;">
<div style="background:#f9fafb;border-radius:8px;padding:15px;text-align:center;border-left:4px solid #8b5cf6;">
<p style="margin:0 0 5px;font-size:12px;color:#6b7280;font-weight:500;">Rankings</p>
<p style="margin:0 0 3px;font-size:28px;font-weight:800;color:#8b5cf6;">${data.scoreRankings}</p>
<p style="margin:0;font-size:11px;color:#6b7280;">${data.keywordsTop10} in Top 10</p>
</div>
</td>
</tr>
</table>
</td>
</tr>
<!-- Action Items -->
<tr>
<td style="padding:20px 40px;border-bottom:1px solid #e5e7eb;">
<h3 style="margin:0 0 15px;font-size:16px;color:#0d1b2a;">Recommended Actions</h3>
${data.actionItems.map(item => `
<div style="background:#f9fafb;border-radius:6px;padding:12px;margin-bottom:8px;">
<p style="margin:0;font-size:13px;color:#374151;">${item}</p>
</div>
`).join('')}
</td>
</tr>
<!-- CTA -->
<tr>
<td style="padding:30px 40px;text-align:center;background:#f9fafb;">
<a href="${data.reportUrl}" style="display:inline-block;background:#2563eb;color:#ffffff;padding:12px 30px;border-radius:6px;text-decoration:none;font-weight:600;font-size:14px;">
View Full Report in Dashboard
</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`;
}
3. Weekly Digest#
Content#
┌────────────────────────────────────────┐
│ RankFlow Weekly Digest │
│ Dr. Smith Dental Clinic │
│ │
│ 📍 Maps Position: #3 (+1 from last wk)│
│ ⭐ New Reviews: 4 (avg 4.8★) │
│ 📝 Posts Published: 2 │
│ │
│ [View Dashboard] │
└────────────────────────────────────────┘
4. Alert Emails#
Alert Types#
| Alert | Trigger | Recipient | Priority |
|---|---|---|---|
| GBP position drop | Rank falls > 5 positions | Client + Admin | High |
| Citation removed | NAP check returns NOT_FOUND | Client + Admin | High |
| Negative review | 1-2 star review received | Client | High |
| Token expiry warning | Token expires in < 7 days | Admin | Medium |
| Job failure | Job fails after max retries | Admin | Medium |
| Subscription past due | Payment failed | Client | High |
Alert Template#
export function buildAlertEmail(alert: AlertData): string {
const colors = {
high: "#dc2626",
medium: "#f59e0b",
low: "#2563eb",
};
return `<!DOCTYPE html>
<html>
<body style="margin:0;padding:20px;background:#f3f4f6;font-family:Inter,sans-serif;">
<table width="100%" max-width="600" style="background:#ffffff;border-radius:8px;padding:30px;">
<tr>
<td>
<div style="background:${colors[alert.priority]};color:#fff;padding:10px 15px;border-radius:6px;margin-bottom:20px;">
<strong>${alert.type}</strong> — ${alert.priority.toUpperCase()}
</div>
<h2 style="margin:0 0 15px;color:#0d1b2a;">${alert.title}</h2>
<p style="color:#4b5563;line-height:1.6;">${alert.message}</p>
${alert.actionUrl ? `
<a href="${alert.actionUrl}" style="display:inline-block;background:#2563eb;color:#fff;padding:10px 20px;border-radius:6px;text-decoration:none;margin-top:15px;">
Take Action
</a>
` : ''}
</td>
</tr>
</table>
</body>
</html>`;
}
5. PDF Generation#
Playwright Implementation#
// src/server/services/pdf/playwright.ts
import { chromium } from "playwright";
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
const s3 = new S3Client({
region: process.env.AWS_REGION,
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
},
});
export async function generatePdf(html: string, fileName: string): Promise<string> {
const browser = await chromium.launch({ headless: true });
try {
const page = await browser.newPage();
await page.setContent(html, { waitUntil: "networkidle" });
await page.waitForTimeout(2000); // Wait for fonts
const pdfBuffer = await page.pdf({
format: "A4",
printBackground: true,
margin: { top: "20px", right: "20px", bottom: "20px", left: "20px" },
displayHeaderFooter: true,
headerTemplate: `<div style="font-size:9px;color:#6b7280;width:100%;text-align:center;padding:10px 40px;">
RankFlow AI — Monthly SEO Report
</div>`,
footerTemplate: `<div style="font-size:9px;color:#6b7280;width:100%;text-align:center;padding:10px 40px;">
<span class="pageNumber"></span> of <span class="totalPages"></span>
</div>`,
});
// Upload to S3
await s3.send(new PutObjectCommand({
Bucket: process.env.AWS_S3_BUCKET,
Key: `reports/${fileName}`,
Body: pdfBuffer,
ContentType: "application/pdf",
}));
return `https://${process.env.AWS_S3_BUCKET}.s3.${process.env.AWS_REGION}.amazonaws.com/reports/${fileName}`;
} finally {
await browser.close();
}
}
PDF HTML Template#
export function buildReportPdfHtml(data: ReportData): string {
return `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800&display=swap" rel="stylesheet">
<style>
body { font-family: 'Inter', sans-serif; color: #1f2937; line-height: 1.6; }
.header { background: linear-gradient(135deg, #2563eb, #1d4ed8); color: white; padding: 40px; text-align: center; }
.score-circle { width: 120px; height: 120px; border-radius: 50%; border: 4px solid #22c55e; display: flex; align-items: center; justify-content: center; margin: 20px auto; }
.score-number { font-size: 48px; font-weight: 800; color: #15803d; }
.metric-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin: 30px 0; }
.metric-card { background: #f9fafb; border-radius: 8px; padding: 20px; border-left: 4px solid; }
.section { margin: 30px 0; page-break-inside: avoid; }
table { width: 100%; border-collapse: collapse; margin: 15px 0; }
th, td { padding: 10px; text-align: left; border-bottom: 1px solid #e5e7eb; }
th { background: #f3f4f6; font-weight: 600; }
</style>
</head>
<body>
<div class="header">
<h1>${data.practiceName}</h1>
<p>Monthly SEO Report — ${data.period}</p>
</div>
<div style="padding: 40px;">
<div class="score-circle">
<span class="score-number">${data.scoreOverall}</span>
</div>
<p style="text-align:center;color:#6b7280;">Overall SEO Score</p>
<div class="metric-grid">
<div class="metric-card" style="border-color:#2563eb;">
<h3 style="color:#2563eb;margin:0;">GBP</h3>
<p style="font-size:32px;font-weight:800;margin:10px 0;">${data.scoreGbp}</p>
<p style="color:#6b7280;font-size:12px;">${data.gbpViews} views • ${data.gbpClicks} clicks</p>
</div>
<div class="metric-card" style="border-color:#10b981;">
<h3 style="color:#10b981;margin:0;">Citations</h3>
<p style="font-size:32px;font-weight:800;margin:10px 0;">${data.scoreCitations}</p>
<p style="color:#6b7280;font-size:12px;">${data.citationsVerified} verified • ${data.citationsLive} live</p>
</div>
<div class="metric-card" style="border-color:#f59e0b;">
<h3 style="color:#f59e0b;margin:0;">Reviews</h3>
<p style="font-size:32px;font-weight:800;margin:10px 0;">${data.scoreReviews}</p>
<p style="color:#6b7280;font-size:12px;">${data.reviewAvg}/5.0 • ${data.reviewsNew} new this month</p>
</div>
<div class="metric-card" style="border-color:#8b5cf6;">
<h3 style="color:#8b5cf6;margin:0;">Rankings</h3>
<p style="font-size:32px;font-weight:800;margin:10px 0;">${data.scoreRankings}</p>
<p style="color:#6b7280;font-size:12px;">${data.keywordsTop10} in top 10 • ${data.keywordsTop3} in top 3</p>
</div>
</div>
<div class="section">
<h2>Keyword Rankings</h2>
<table>
<thead>
<tr><th>Keyword</th><th>Current</th><th>Previous</th><th>Change</th></tr>
</thead>
<tbody>
${data.rankings.map(r => `
<tr>
<td>${r.keyword}</td>
<td>${r.currentRank || ">100"}</td>
<td>${r.previousRank || ">100"}</td>
<td style="color:${r.change > 0 ? '#dc2626' : r.change < 0 ? '#22c55e' : '#6b7280'}">
${r.change > 0 ? "↓" : r.change < 0 ? "↑" : "—"} ${Math.abs(r.change)}
</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
<div class="section">
<h2>Recommended Actions</h2>
<ol>
${data.actionItems.map(item => `<li>${item}</li>`).join('')}
</ol>
</div>
</div>
</body>
</html>`;
}
6. Report Templates#
Score Calculation#
| Category | Weight | Calculation |
|---|---|---|
| GBP | 25% | (views + clicks + calls + directions) / baseline |
| Citations | 25% | (live count / 30 * 100) * (NAP consistency %) |
| Reviews | 20% | (avg rating / 5 * 50) + (reply rate * 50) |
| Rankings | 20% | (keywords in top 10 / total keywords * 100) |
| SEO | 10% | Site health + schema validity + speed score |
Report Data Structure#
interface ReportData {
practiceName: string;
period: string; // "January 2025"
scoreOverall: number;
scoreGbp: number;
scoreCitations: number;
scoreReviews: number;
scoreRankings: number;
scoreSeo: number;
// GBP
gbpViews: number;
gbpClicks: number;
gbpCalls: number;
gbpDirections: number;
gbpPostsPublished: number;
// Citations
citationsLive: number;
citationsVerified: number;
napConsistency: number;
// Reviews
reviewAvg: number;
reviewsNew: number;
reviewsReplied: number;
replyRate: number;
// Rankings
keywordsTop10: number;
keywordsTop3: number;
rankings: Array<{
keyword: string;
currentRank: number | null;
previousRank: number | null;
change: number;
}>;
// Actions
actionItems: string[];
// URLs
reportUrl: string;
pdfUrl: string;
}
7. Scheduling & Delivery#
Monthly Report Cron#
// src/server/inngest/functions/report-generate.ts
export const monthlyReport = inngest.createFunction(
{ id: "monthly-report", retries: 3 },
{ cron: "0 4 1 * *" }, // 1st of month at 4 AM
async ({ step }) => {
const practices = await step.run("get-practices", async () => {
return await db.practice.findMany({
where: { status: "ACTIVE" },
include: { locations: true, owner: true },
});
});
for (const practice of practices) {
await step.run(`report-${practice.id}`, async () => {
// 1. Gather data
const reportData = await gatherReportData(practice);
// 2. Generate PDF
const pdfHtml = buildReportPdfHtml(reportData);
const pdfUrl = await generatePdf(
pdfHtml,
`report-${practice.id}-${reportData.period}.pdf`
);
// 3. Save report record
await db.report.create({
data: {
practiceId: practice.id,
name: `Monthly Report — ${reportData.period}`,
periodStart: new Date(new Date().getFullYear(), new Date().getMonth(), 1),
periodEnd: new Date(),
status: "READY",
sections: reportData as any,
scoreOverall: reportData.scoreOverall,
scoreGbp: reportData.scoreGbp,
scoreCitations: reportData.scoreCitations,
scoreReviews: reportData.scoreReviews,
scoreRankings: reportData.scoreRankings,
scoreSeo: reportData.scoreSeo,
pdfUrl,
},
});
// 4. Send email
const emailHtml = buildMonthlyReportEmail({ ...reportData, pdfUrl });
await email.send({
to: practice.owner.email,
subject: `Your Monthly SEO Report — ${reportData.period}`,
html: emailHtml,
attachments: [pdfUrl],
});
// 5. Update sent timestamp
await db.report.updateMany({
where: { practiceId: practice.id, pdfUrl },
data: { emailSentAt: new Date() },
});
});
}
return { reportsGenerated: practices.length };
}
);
8. Email Tracking#
Delivery Status#
model EmailLog {
id String @id @default(cuid())
practiceId String?
userId String?
type String // MONTHLY_REPORT, WEEKLY_DIGEST, ALERT, etc.
to String
subject String
status String @default("SENT") // SENT, DELIVERED, OPENED, BOUNCED, FAILED
sentAt DateTime @default(now())
deliveredAt DateTime?
openedAt DateTime?
bouncedAt DateTime?
bounceReason String?
metadata Json @default("{}")
@@map("email_logs")
}
Resend Webhook Handler#
// src/app/api/webhooks/resend/route.ts
export async function POST(request: Request) {
const signature = request.headers.get("x-resend-signature");
const body = await request.text();
// Verify webhook signature
if (!verifyWebhookSignature(body, signature)) {
return new Response("Invalid signature", { status: 401 });
}
const event = JSON.parse(body);
switch (event.type) {
case "email.delivered":
await db.emailLog.update({
where: { id: event.data.email_id },
data: { status: "DELIVERED", deliveredAt: new Date() },
});
break;
case "email.opened":
await db.emailLog.update({
where: { id: event.data.email_id },
data: { status: "OPENED", openedAt: new Date() },
});
break;
case "email.bounced":
await db.emailLog.update({
where: { id: event.data.email_id },
data: {
status: "BOUNCED",
bouncedAt: new Date(),
bounceReason: event.data.reason,
},
});
break;
}
return new Response("OK");
}
End of Email Reports & PDF Generation Documentation