How to generate and email PDF reports automatically on a schedule

Published: (February 26, 2026 at 03:18 AM EST)
5 min read
Source: Dev.to

Source: Dev.to

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

No headless browser dependency. PageBolt handles the PDF rendering.

npm install node-cron nodemailer

1. Scheduler (node‑cron)

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

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

2. Main workflow

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

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

  // 3️⃣ Capture as PDF (PageBolt)
  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);
}

3. Data fetching

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,
    })),
  };
}

4. HTML rendering

function renderReportHtml(data) {
  const topPagesRows = data.topPages
    .map(
      (p, i) => `
        ${i + 1}
        ${p.path}
        ${p.views.toLocaleString()}
      `
    )
    .join("");

  return `
    <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>

    <h2>Weekly Report</h2>
    <p>${data.weekLabel}</p>

    <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>Views</th></tr>
      </thead>
      <tbody>
        ${topPagesRows}
      </tbody>
    </table>
  `;
}

5. Emailing 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(", ")}`);
}

6. Personalized reports on each user’s 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");
    })
  );
});

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

7. Running the scheduler in production

# Using PM2 (or any process manager)
pm2 start report-scheduler.js --name weekly-reports

That’s it! You now have a fully automated, headless‑browser‑free pipeline that:

  1. Fetches live data on a schedule.
  2. Renders a polished HTML report.
  3. Converts it to PDF via PageBolt’s API.
  4. Emails the PDF to the right recipients.

Feel free to adapt the HTML, data sources, or email layout to match your brand. Happy reporting!

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

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

0 views
Back to Blog

Related posts

Read more »