DEV Community

Custodia-Admin
Custodia-Admin

Posted on • Originally published at pagebolt.dev

How to generate and email PDF reports automatically on a schedule

How to Generate and Email PDF Reports Automatically on a Schedule

Generating a PDF report manually is easy. Doing it automatically — pulling live data, rendering it into a branded HTML template, capturing it as a pixel-perfect PDF, and emailing it to stakeholders — is where most setups fall apart.

The usual blocker: Puppeteer or Playwright in a cron job. Headless browsers in scheduled tasks fail silently, consume memory, and don't survive container restarts.

Here's a robust pipeline: cron → fetch data → render HTML → PageBolt PDF → email.

The stack

npm install node-cron nodemailer
Enter fullscreen mode Exit fullscreen mode

No headless browser dependency. PageBolt handles the PDF rendering.

Basic scheduled report

import cron from "node-cron";
import nodemailer from "nodemailer";

// Runs every Monday at 8am
cron.schedule("0 8 * * 1", async () => {
  console.log("Generating weekly report...");
  await generateAndSendReport();
});

async function generateAndSendReport() {
  // 1. Fetch your data
  const data = await fetchReportData();

  // 2. Render HTML
  const html = renderReportHtml(data);

  // 3. Capture as PDF
  const pdfRes = await fetch("https://pagebolt.dev/api/v1/pdf", {
    method: "POST",
    headers: {
      "x-api-key": process.env.PAGEBOLT_API_KEY,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ html }),
  });

  const pdfBuffer = Buffer.from(await pdfRes.arrayBuffer());

  // 4. Email it
  await emailReport(pdfBuffer, data.weekLabel);
}
Enter fullscreen mode Exit fullscreen mode

Fetch data from your database

import { db } from "./db.js"; // your DB client

async function fetchReportData() {
  const weekStart = new Date();
  weekStart.setDate(weekStart.getDate() - 7);

  const [revenue, signups, churn] = await Promise.all([
    db.payments.sum({ where: { createdAt: { gte: weekStart } } }),
    db.users.count({ where: { createdAt: { gte: weekStart } } }),
    db.subscriptions.count({
      where: { canceledAt: { gte: weekStart }, status: "canceled" },
    }),
  ]);

  const topPages = await db.pageViews.groupBy({
    by: ["path"],
    _sum: { views: true },
    orderBy: { _sum: { views: "desc" } },
    take: 5,
    where: { createdAt: { gte: weekStart } },
  });

  return {
    weekLabel: `Week of ${weekStart.toLocaleDateString()}`,
    revenue: (revenue._sum.amount / 100).toFixed(2),
    signups,
    churn,
    topPages: topPages.map((p) => ({ path: p.path, views: p._sum.views })),
  };
}
Enter fullscreen mode Exit fullscreen mode

HTML report template

function renderReportHtml(data) {
  const topPagesRows = data.topPages
    .map(
      (p, i) => `
      <tr>
        <td>${i + 1}</td>
        <td>${p.path}</td>
        <td style="text-align:right">${p.views.toLocaleString()}</td>
      </tr>`
    )
    .join("");

  return `<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <style>
    body { font-family: -apple-system, sans-serif; color: #111; max-width: 720px; margin: 40px auto; padding: 0 24px; }
    h1 { font-size: 22px; margin-bottom: 4px; }
    .subtitle { color: #666; margin-bottom: 32px; }
    .metrics { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin-bottom: 40px; }
    .metric { background: #f5f5f5; border-radius: 8px; padding: 20px; }
    .metric-value { font-size: 28px; font-weight: 700; }
    .metric-label { color: #666; font-size: 13px; margin-top: 4px; }
    table { width: 100%; border-collapse: collapse; }
    th { text-align: left; border-bottom: 2px solid #111; padding: 8px 0; font-size: 13px; }
    td { padding: 10px 0; border-bottom: 1px solid #eee; font-size: 14px; }
    h2 { font-size: 16px; margin: 32px 0 12px; }
  </style>
</head>
<body>
  <h1>Weekly Report</h1>
  <div class="subtitle">${data.weekLabel}</div>

  <div class="metrics">
    <div class="metric">
      <div class="metric-value">$${data.revenue}</div>
      <div class="metric-label">Revenue</div>
    </div>
    <div class="metric">
      <div class="metric-value">${data.signups}</div>
      <div class="metric-label">New signups</div>
    </div>
    <div class="metric">
      <div class="metric-value">${data.churn}</div>
      <div class="metric-label">Cancellations</div>
    </div>
  </div>

  <h2>Top pages</h2>
  <table>
    <thead>
      <tr><th>#</th><th>Page</th><th style="text-align:right">Views</th></tr>
    </thead>
    <tbody>${topPagesRows}</tbody>
  </table>
</body>
</html>`;
}
Enter fullscreen mode Exit fullscreen mode

Email the PDF

const transporter = nodemailer.createTransport({
  host: process.env.SMTP_HOST,
  port: 587,
  auth: {
    user: process.env.SMTP_USER,
    pass: process.env.SMTP_PASS,
  },
});

async function emailReport(pdfBuffer, weekLabel) {
  const recipients = process.env.REPORT_RECIPIENTS.split(",");

  await transporter.sendMail({
    from: "reports@yourapp.com",
    to: recipients,
    subject: `Weekly Report — ${weekLabel}`,
    text: "Your weekly report is attached.",
    attachments: [
      {
        filename: `weekly-report-${Date.now()}.pdf`,
        content: pdfBuffer,
        contentType: "application/pdf",
      },
    ],
  });

  console.log(`Report sent to ${recipients.join(", ")}`);
}
Enter fullscreen mode Exit fullscreen mode

Per-customer scheduled reports

Send a personalized report to each user on their billing cycle:

// Called by a daily cron at midnight
cron.schedule("0 0 * * *", async () => {
  const today = new Date().getDate();

  // Find users whose billing day matches today
  const users = await db.users.findMany({
    where: { billingDay: today, reportEnabled: true },
  });

  await Promise.allSettled(
    users.map(async (user) => {
      const data = await fetchUserReportData(user.id);
      const html = renderUserReportHtml(user, data);

      const pdfRes = await fetch("https://pagebolt.dev/api/v1/pdf", {
        method: "POST",
        headers: {
          "x-api-key": process.env.PAGEBOLT_API_KEY,
          "Content-Type": "application/json",
        },
        body: JSON.stringify({ html }),
      });

      const pdf = Buffer.from(await pdfRes.arrayBuffer());
      await emailReport(pdf, user.email, `Your monthly summary`);
    })
  );
});
Enter fullscreen mode Exit fullscreen mode

Promise.allSettled ensures one failure doesn't abort the batch — each user's report is independent.

Deploy on a VPS or Lambda

VPS / PM2:

pm2 start report-scheduler.js --name weekly-reports
Enter fullscreen mode Exit fullscreen mode

Lambda (EventBridge schedule):

// handler.js — triggered by EventBridge rule: cron(0 8 ? * MON *)
export const handler = async () => {
  await generateAndSendReport();
  return { statusCode: 200 };
};
Enter fullscreen mode Exit fullscreen mode

No browser binary in the Lambda package. The PDF generation is a single outbound HTTP request — stays well within Lambda's memory and timeout limits.


Try it free — 100 requests/month, no credit card. → Get started in 2 minutes

Top comments (0)