How to generate and email PDF reports automatically on a schedule
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:
- Fetches live data on a schedule.
- Renders a polished HTML report.
- Converts it to PDF via PageBolt’s API.
- 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