Browse documentation

Specifications

RankFlow AI — Email Reports & PDF Generation Documentation

│ Email & Reporting System │

docs/specs/email-reports-pdf.md
On this page

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