Quartz.Jobs provides some useful ready-made jobs for your convenience.
Quartz provides a number of utility jobs that you can use in your application for doing things like sending e-mails and invoking native processes. These out-of-the-box jobs live in the Quartz.Jobs namespace, which is also the assembly and NuGet package name. In 3.x the namespace was the singular Quartz.Job; a configuration string or a stored JOB_CLASS_NAME naming the old spelling still resolves, with a warning.
Installation
You need to add NuGet package reference to your project which uses Quartz.
dotnet add package Quartz.Jobs
How these jobs are configured
Each of these jobs reads its settings from its JobDataMap, under the keys listed with it below. Those keys are the persisted form: they are what a job store writes, what a cluster shares, and what an XML or JSON scheduling file names.
Each job also has an options type that maps onto exactly those keys, and an extension that writes it. It is the same stored job either way — but the key cannot be misspelled, the value cannot be of the wrong type, and every setting the job honours is a named property you can find by typing a dot.
| Job | Options | Extension |
|---|---|---|
DirectoryScanJob | DirectoryScanOptions | UsingDirectoryScanOptions(…) |
FileScanJob | FileScanOptions | UsingFileScanOptions(…) |
NativeJob | NativeJobOptions | UsingNativeJobOptions(…) |
SendMailJob | SendMailOptions | UsingSendMailOptions(…) |
The extensions work on both configuration surfaces — JobBuilder.Create<TJob>() and the configurator AddJob<TJob>(…) hands you — and each leaves you with what you started with, so the chain continues as usual. Options.FromJobData(map) reads the same settings back out of a job's data.
Features
DirectoryScanJob
Inspects a directory and compares whether any files' "last modified dates" have changed since the last time it was inspected. If one or more files have been updated, created or deleted, the job invokes a call-back method on an IDirectoryScanListener.
IJobDetail job = JobBuilder.Create<DirectoryScanJob>()
.WithIdentity("inboxScan")
.UsingDirectoryScanOptions(new DirectoryScanOptions
{
Directories = ["/var/spool/inbox"],
ScanListenerName = nameof(InboxListener),
SearchPattern = "*.csv",
IncludeSubDirectories = true,
MinimumUpdateAge = TimeSpan.FromSeconds(30),
})
.Build();
| Setting | Job data key | Default |
|---|---|---|
Directories | DIRECTORY_NAMES (semicolon-separated), or DIRECTORY_NAME for one | — |
DirectoryProviderName | DIRECTORY_PROVIDER_NAME | none; the paths above are used |
ScanListenerName | DIRECTORY_SCAN_LISTENER_NAME | required |
SearchPattern | SEARCH_PATTERN | * |
IncludeSubDirectories | INCLUDE_SUB_DIRECTORIES | false |
MinimumUpdateAge | MINIMUM_UPDATE_AGE, in milliseconds | 5 seconds |
MinimumUpdateAge is how long a file must have been left alone before the job reports it. Without it a file another process is still writing would be handed to the listener half-finished.
The listener is found in one of three ways, in this order:
- A keyed registration:
AddKeyedSingleton<IDirectoryScanListener>("inbox", …), andScanListenerName = "inbox". - Dependency injection by type name: register your implementation as
IDirectoryScanListener—AddSingleton<IDirectoryScanListener, InboxListener>()— and name its type,ScanListenerName = nameof(InboxListener). SchedulerContext: store the instance under a key, and name that key.
Registering the concrete type alone is no longer enough
Until 4.0 rc.1 the name was resolved by sweeping every loaded assembly with GetTypes(), so AddSingleton<InboxListener>() was found. It is not any more, and neither is a same-named type from an assembly you did not mean. Register the listener under IDirectoryScanListener, or key it.
scheduler.Context["inboxListener"] = new InboxListener();
The scheduler context is not a secret store
GET {ApiPath}/schedulers/{name}/context returns every entry, rendered with Convert.ToString as the fallback — which for a record or a struct with a compiler-generated ToString is every field it has. So the context is exactly as secret as a job's data map, which is to say not at all: an authorized caller reads both. Put a shared instance there, or a name; keep the connection string and the API key in IConfiguration, a key vault or the container.
Where the directories come from can be decided at run time instead of being listed: implement IDirectoryProvider, put the instance in the SchedulerContext, and name that key as DirectoryProviderName. It is handed the merged job data and returns the paths to scan.
The job keeps its own bookkeeping — the last modification time it saw and the file list it saw it in — in the job detail's data map, which is why it is [PersistJobDataAfterExecution]. The file list is stored as a Dictionary<string, string> of full path to last-write ticks under CURRENT_FILE_LIST, which is a shape both shipped serializers accept; before 4.0 rc.1 it was a List<FileInfo>, which neither can read back, so the first firing against a persistent store failed to persist and reading the job's data map over the HTTP API refused it.
FileScanJob
Inspects a single file and compares whether its "last modified date" has changed since the last time it was inspected. If it has, the job invokes a call-back method on an IFileScanListener found in the SchedulerContext.
IJobDetail job = JobBuilder.Create<FileScanJob>()
.WithIdentity("configWatch")
.UsingFileScanOptions(new FileScanOptions
{
FileName = "/etc/app/settings.json",
ScanListenerName = "settingsListener",
MinimumUpdateAge = TimeSpan.FromSeconds(5),
})
.Build();
| Setting | Job data key | Default |
|---|---|---|
FileName | FILE_NAME | required |
ScanListenerName | FILE_SCAN_LISTENER_NAME | required |
MinimumUpdateAge | MINIMUM_UPDATE_AGE, in milliseconds | 5 seconds |
NativeJob
Runs a native executable in a separate process.
IJobDetail job = JobBuilder.Create<NativeJob>()
.WithIdentity("dumbJob")
.UsingNativeJobOptions(new NativeJobOptions
{
Command = "echo",
Parameters = "\"hi\" >> foobar.txt",
})
.Build();
ITrigger trigger = TriggerBuilder.Create()
.WithIdentity("dumbTrigger")
.WithSimpleSchedule(x => x.WithInterval(TimeSpan.FromSeconds(5)).RepeatForever())
.Build();
await scheduler.ScheduleJob(job, trigger);
| Setting | Job data key | Default |
|---|---|---|
Command | command | required |
Parameters | parameters | none |
WaitForProcess | waitForProcess | true |
ConsumeStreams | consumeStreams | false |
WorkingDirectory | workingDirectory | the scheduler's |
When WaitForProcess is on, the integer exit code of the process is saved as the job execution result in the IJobExecutionContext. Turn ConsumeStreams on for a chatty process: one that writes more output than its pipe holds blocks until someone reads it.
Referencing this package changes what an open scheduling endpoint means
Both HTTP surfaces — the HTTP API and the dashboard — schedule a job whose type is a string the request supplies. The name is stored unresolved and resolved later with Type.GetType against whatever is on the host's probing path; there is no allow-list, and the only validation is on the shape of the name. NativeJob is on that path as soon as Quartz.Jobs is referenced, and it starts the executable its job data names with the arguments its job data names. So an unauthenticated Quartz endpoint in a process that references this package is remote code execution rather than an information leak.
You may have this package without a line for it in your project. Quartz.Plugins takes a plain package dependency on Quartz.Jobs, so an application that installed the plugins — for XML scheduling, say — has NativeJob on its probing path with nothing in its own csproj that names Quartz.Jobs. Check your restored graph, not your project file.
Neither surface will start when its mapping says nothing about authorization, which is what closes the common way into this. DirectoryScanJob and FileScanJob read the paths they scan from job data the same way, and SendMailJob reads an SMTP credential from job data unless one is registered — see Keep the SMTP credential out of job data.
SendMailJob
Sends an e-mail with the configured content to the configured recipient.
IJobDetail job = JobBuilder.Create<SendMailJob>()
.WithIdentity("nightlyDigest")
.UsingSendMailOptions(new SendMailOptions
{
SmtpHost = "smtp.example.com",
SmtpPort = 587,
Sender = "[email protected]",
Recipient = "[email protected]",
Subject = "Nightly digest",
Message = "Everything ran.",
})
.Build();
| Setting | Job data key | Default |
|---|---|---|
SmtpHost | smtp_host | required |
SmtpPort | smtp_port | the client's default |
Sender | sender | required |
Recipient | recipient | required |
CcRecipient | cc_recipient | none |
ReplyTo | reply_to | the sender |
Subject | subject | required |
Message | message | required |
Encoding | encoding | the default |
EnableSsl | smtp_enable_ssl | false |
Override Send(MailInfo, CancellationToken) to route the mail through something other than SmtpClient, or BuildMessage(SendMailOptions) to add to the message — an attachment, a header — before it goes.
This job is an authenticated relay for whoever can schedule it
Sender, Recipient, Subject and Message are all caller data, and so is SmtpHost. Anyone who can schedule a job can therefore send mail claiming to be from any address, to any address, through your server. That is the same trust boundary the rest of this page describes — an authorized caller is trusted — but it is worth naming, because "send mail" reads as harmless in a way that "start a process" does not.
EnableSsl is off by default, which is SmtpClient's own default: turning it on fails outright against a server that does not offer TLS, and a relay on the same host that has been taking this job's mail for years would stop. Turn it on for anything that crosses a network you do not own, and for anything that authenticates — SMTP AUTH LOGIN is base64, not encryption.
Keep the SMTP credential out of job data
SendMailOptions has no user name or password on purpose. Job data is durable: a persistent job store writes it to QRTZ_JOB_DETAILS, every node in the cluster reads it, the dashboard shows it, and any export of that table carries it. A password put there is a password in all of those places.
Register the credential with the container instead, bound to the server it belongs to, and the job authenticates with it:
// Bound to the server it belongs to. The host to send through is job data, so a credential that
// answers for every host would go to whatever that data names.
CredentialCache credentials = new();
credentials.Add("smtp.example.com", 587, "Basic", new NetworkCredential("mailer", smtpPassword));
services.AddSingleton<ICredentialsByHost>(credentials);
CredentialCache is the security choice here, not merely the multi-server convenience. smtp_host is job data, so the host to authenticate to is chosen by whoever scheduled the job; a bare NetworkCredential answers ICredentialsByHost.GetCredential with itself for every host, so pairing the two would hand the registered login to whatever host that job data names — as base64 AUTH LOGIN, to a listener the caller controls. So:
- a
CredentialCachewith an entry for the host in job data → that entry is used; - a
CredentialCachewith no entry for it → the mail goes out unauthenticated, rather than authenticating to a stranger; - a bare
NetworkCredential→ the job refuses to send, with a message naming the host and how to bind the credential to it.
Any other ICredentialsByHost of your own is asked GetCredential(host, port, "Basic") and then "login", and its answer is taken as its author's decision.
The password itself belongs wherever the rest of your secrets live — user secrets in development, a key vault or an environment variable in production — and reaches this registration through IConfiguration.
The smtp_username and smtp_password job data keys are still read when nothing is registered, so a job scheduled by an earlier version keeps sending; that path is unaffected by the rule above, because whoever wrote the user name wrote the host beside it. The job logs a warning when it uses them, and a credential from the container wins.
NoOpJob
A job that does nothing. Useful as a placeholder, and for triggering listeners on a schedule without any work attached.
Registering these jobs with the container
The jobs take their dependencies — a TimeProvider, an IServiceProvider, an ICredentialsByHost — from the container, so register them the same way you register your own:
builder.Services.AddQuartz(q =>
{
q.AddJob<NativeJob>(j => j
.WithIdentity("nightlyReport")
.StoreDurably()
.UsingNativeJobOptions(new NativeJobOptions
{
Command = "report.exe",
Parameters = "--nightly",
ConsumeStreams = true,
}));
q.AddTrigger<NativeJob>(t => t
.ForJob("nightlyReport")
.WithCronSchedule("0 0 2 * * ?"));
});
