DEV Community

Cover image for Scheduled PDF and Excel report emails in ASP.NET Core with Quartz.NET
Razi Syed
Razi Syed

Posted on

Scheduled PDF and Excel report emails in ASP.NET Core with Quartz.NET

By Razi Syed. Sample code: github.com/dotnetreport/dotnetreport-scheduled-reports

The moment users can build their own reports, the very next request is "can it just email me this every Monday at 8?" It sounds like a checkbox. It is actually five separate problems, and teams that treat it as a checkbox tend to discover them one at a time in production:

  1. Scheduling — cron expressions, and evaluating them correctly against the last run so a missed tick doesn't fire twice or not at all.
  2. Time zones — "8 a.m." means 8 a.m. to the user, not to your server in UTC.
  3. Rendering — a report on screen is HTML; an email wants a PDF or an Excel file that looks right.
  4. Security with nobody logged in — the job runs at 6 a.m. with no HTTP request, no cookie, no claims. Whatever row-level security you applied interactively has to apply here too, or your nightly email leaks data.
  5. Hosting — background jobs inside a web process die when the process idles, and duplicate when you scale out.

This article walks through how the scheduler that ships with Dotnet Report (which I work on — disclosure) handles each, because the design decisions are the useful part even if you build your own. It's a Quartz.NET job, so if you know Quartz the shape will be familiar.

The design in one paragraph

A single Quartz job starts with the app and polls every 60 seconds. Each pass it loads the saved schedules, and for each one evaluates the schedule's cron expression, in the schedule's own time zone, against the schedule's recorded last run to decide if it's due. When it is, it renders the report — carrying the schedule's saved user, tenant and data filters so security holds — as PDF, Excel, or a link, emails it via SMTP, and records the run. That's it. Nothing clever; the value is in getting the five details right.

1. Starting the job

Enabling it is one line after the app is built, plus telling the job the app's own public URL:

var app = builder.Build();

JobScheduler.Start();
JobScheduler.WebAppRootUrl = builder.Configuration["App:PublicUrl"] ?? "https://localhost:5001";
Enter fullscreen mode Exit fullscreen mode

Under the hood Start() builds a durable Quartz job and a simple trigger that repeats every 60 seconds forever:

IJobDetail job = JobBuilder.Create<DotNetReportJob>()
    .WithIdentity("DotNetReportJob")
    .StoreDurably()
    .Build();

ITrigger trigger = TriggerBuilder.Create()
    .WithIdentity("DotNetReportJobTrigger")
    .StartNow()
    .WithSimpleSchedule(s => s.WithIntervalInSeconds(60).RepeatForever())
    .Build();

await scheduler.ScheduleJob(job, trigger);
Enter fullscreen mode Exit fullscreen mode

Why poll every minute instead of registering one Quartz trigger per user schedule? Because user schedules are data — created, edited and deleted from the UI all day — and a single polling job that reads them fresh each pass is far simpler to keep consistent than a fleet of triggers you'd have to mirror on every change. A one-minute resolution is plenty for report delivery.

2. Cron + time zones, evaluated against last run

Each schedule stores a cron expression and a time zone id. The job resolves the zone, parses the cron with Quartz's CronExpression, and asks for the next occurrence after the last run — converting the stored last-run into the user's zone first so "after 8 a.m. Monday" is evaluated where the user lives:

TimeZoneInfo targetTimeZone = !string.IsNullOrEmpty(timeZoneId)
    ? TimeZoneInfo.FindSystemTimeZoneById(timeZoneId)
    : TimeZoneInfo.Local;

var chron = new CronExpression(cron);
var lastRun = !string.IsNullOrEmpty(lastRunFromDb)
    ? Convert.ToDateTime(lastRunFromDb)
    : DateTimeOffset.UtcNow.AddMinutes(-10);

if (!string.IsNullOrEmpty(timeZoneId) && !string.IsNullOrEmpty(lastRunFromDb))
    lastRun = TimeZoneInfo.ConvertTime(lastRun, targetTimeZone);

var nextRun = chron.GetTimeAfter(lastRun);
Enter fullscreen mode Exit fullscreen mode

Two details worth copying. Evaluating against the last run (not "now") is what makes the job robust to restarts: if the process was down at 8:00 and comes back at 8:03, the next-run-after-last-run is still 8:00, so it fires once, late, instead of never. And a schedule can carry an optional start/end window so "every Monday, but only during the audit period" works without the user having to remember to delete it.

3. Rendering PDF and Excel

Users choose a format per schedule: PDF, Excel, Excel-Sub (with sub-totals expanded), or Link.

PDF is rendered by loading the report's print view — {WebAppRootUrl}/DotnetReport/ReportPrint — in a headless browser (PuppeteerSharp) and printing it, with the schedule's page size and orientation. That's why WebAppRootUrl must be a URL the job can actually reach; on a dev box https://localhost:5001 is fine, in production it's your public host. Rendering the real HTML view means the PDF matches what the user sees, charts included.

Excel is generated directly with EPPlus from the report definition — columns, grouping, sub-totals, pivots — and if the report has a chart, the chart is rendered to an image and embedded in the workbook. Dashboards with several reports are combined into one PDF or one multi-sheet workbook.

Link just emails a link to the live report, for people who'd rather open it in the app.

4. Security when nobody is logged in

This is the one that bites teams who bolt scheduling on afterwards. Interactive reports are filtered by the current user's context — in Dotnet Report that's GetSettings(), which passes the user id, tenant id and a DataFilters object the engine appends to every query for row-level security (I covered that in Multi-tenant reporting in ASP.NET Core: row-level security your users can't bypass).

At 6 a.m. there is no current user. So each schedule saves the user's id and DataFilters with it, and the job passes exactly those into rendering:

fileData = await DotNetReportHelper.GetPdfFile(
    JobScheduler.WebAppRootUrl + "/DotnetReport/ReportPrint",
    reportId, reportSql, connectKey, reportName,
    schedule.UserId,                                  // who scheduled it
    clientId,                                         // their tenant
    JsonConvert.SerializeObject(schedule.DataFilters), // their row-level filters
    pageSize: schedule.SelectedPageSize,
    pageOrientation: schedule.SelectedPageOrientation);
Enter fullscreen mode Exit fullscreen mode

The emailed PDF is therefore filtered identically to what that user would have seen in the browser. The principle generalizes: snapshot the security context at schedule time and replay it at run time. Never render a scheduled report as an anonymous or admin identity and hope the filters come along.

5. Email

Delivery is plain SmtpClient / MailMessage using an email section in configuration:

"email": {
  "fromemail": "reports@yourapp.com",
  "fromname": "Your App Reports",
  "server": "smtp.yourprovider.com",
  "username": "smtp-username",
  "password": "smtp-password"
}
Enter fullscreen mode Exit fullscreen mode

Keep the password in user secrets or environment variables. If you'd rather use a transactional email API than SMTP, this is the one seam to swap.

Hosting: the two rules

Keep the process alive. The job runs inside the web app. IIS idles app pools by default; set idle timeout to 0 or enable Application Initialization, and on Azure App Service turn on Always On. Otherwise the scheduler quietly stops when the site goes quiet — usually exactly when nightly reports should fire.

Run one scheduler. If you scale out to several instances, run the job on a single designated instance (a config flag or a leader-election check is enough). Otherwise every instance polls, every instance decides the 8 a.m. report is due, and your users get three copies. Recording the last run promptly narrows the window but does not eliminate it; one scheduler does.

Wrap-up

"Email me this every Monday" decomposes into scheduling against last-run, time-zone-aware cron, real rendering to PDF/Excel, replaying the user's security context with no user present, and keeping exactly one live scheduler. The sample host, Program.cs and config are in the repo: dotnetreport/dotnetreport-scheduled-reports. If you haven't set up the report builder yet, start with Add self-service ad hoc reporting to an ASP.NET Core app.


Razi Syed builds Dotnet Report, an embedded self-service reporting platform for .NET whose report-builder front-end is source-available on GitHub. He writes about adding reporting and analytics to SaaS products without rebuilding them from scratch.

Top comments (0)