.NET

Building a Reliable Email Scheduler in ASP.NET Core MVC with TickerQ

5 min read

At a glance

Schedule emails for now or later in ASP.NET Core MVC with TickerQ β€” self-rescheduling retries, per-row retry config, and full status visibility in your UI and dashboard.

Building a Reliable Email Scheduler in ASP.NET Core MVC with TickerQ
Banner illustration for scheduling reliable email delivery with TickerQ in ASP.NET Core MVC.

Advertisement

Why TickerQ?

.NET has had job schedulers for a long time β€” Hangfire and Quartz.NET are the household names. TickerQ is a newer entrant that's worth a look for this kind of use case because it:

  • Uses compile-time source generators instead of reflection to register job functions, so there's no runtime scanning overhead and it's fully AOT/trim-friendly.
  • Persists jobs directly into your own EF Core DbContext (SQL Server, PostgreSQL, MySQL, or SQLite) β€” no separate job store to stand up.
  • Ships with a built-in real-time dashboard (via SignalR) so you can watch jobs move through Idle β†’ Queued β†’ InProgress β†’ Done/Failed without building your own monitoring UI.
  • Has first-class retry support β€” Retries and RetryIntervals on the scheduled job itself β€” plus distributed locking so multiple app instances don't double-send the same email.
  • Supports both one-off, time-based jobs (TimeTicker) and recurring cron jobs (CronTicker) β€” a scheduled email is naturally a TimeTicker.

The Data Model

The starting point is a single EF Core entity that represents a scheduled email and everything needed to track its delivery lifecycle:

C#
public class EmailScheduler
{
    public long Id { get; set; }
    public string? FromName { get; set; }
    public string FromEmail { get; set; } = null!;
    public string? ToName { get; set; }
    public string ToEmail { get; set; } = null!;
    public string Subject { get; set; } = null!;
    public string Body { get; set; } = null!;
    public bool IsHtml { get; set; }
    public string? BccEmail { get; set; }
    public string? Attachments { get; set; }
    public DateTimeOffset ScheduledAt { get; set; }
    public bool SendImmediately { get; set; }
    public string Status { get; set; } = "Pending"; // Pending, Sent, Failed
    public int AttemptCount { get; set; }
    public int MaxRetryCount { get; set; } = 3;
    public int RetryIntervalMin { get; set; } = 15;
    public DateTimeOffset? LastAttemptAt { get; set; }
    public DateTimeOffset NextAttemptAt { get; set; }
    public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
}

A useful thing to notice: this entity already carries its own retry bookkeeping β€” AttemptCount, MaxRetryCount, RetryIntervalMin, NextAttemptAt. That's deliberate. Rather than leaning purely on TickerQ's built-in Retries/RetryIntervals (which live on the ticker, not on your business row), we keep retry state on the domain entity itself. That gives us:

  • A row the MVC UI can query and display directly (status, attempt count, next attempt time) without reaching into TickerQ's internal tables.
  • Retry behavior that's configurable per email, from user input, rather than fixed at job-registration time.
  • A clean separation: TickerQ is the engine that wakes a job up at the right time; our entity is the record of truth for what happened.

TickerQ still does real work for us β€” it persists the β€œwhen should this run” instruction, survives app restarts, gives us the dashboard, and guarantees a job only fires once even across multiple app instances.

Architecture at a Glance

Color key: blue = MVC/app layer, purple = TickerQ engine, amber = job execution & retry, green = success path, red = permanent failure. Dashed arrows show the re-queue / status-update loop.

TickerQ Email Scheduler Architecture
Email scheduler architecture showing MVC app layer, TickerQ engine, retry loop, and success/failure paths.
  • The controller creates an EmailScheduler row and asks EmailSchedulingService to enqueue it.
  • EmailSchedulingService calls ITimeTickerManager<TimeTickerEntity>.AddAsync(...), passing only the row's Id as the job payload β€” never the whole email body, to keep the ticker payload small and the database as the single source of truth.
  • When the scheduled time arrives, TickerQ invokes EmailDispatchJob.SendScheduledEmail, which reloads the entity, attempts delivery, and updates Status, AttemptCount, LastAttemptAt.
  • If delivery fails and AttemptCount < MaxRetryCount, the job computes NextAttemptAt = now + RetryIntervalMin and enqueues anotherTimeTicker for that time β€” a self-rescheduling retry loop driven entirely by the entity's own configuration.
  • If AttemptCount >= MaxRetryCount, the row is marked Failed and no further ticker is scheduled.

Registering TickerQ

C#
builder.Services.AddDbContext(options =>
    options.UseSqlServer(builder.Configuration.GetConnectionString("Default")));
builder.Services.AddTickerQ(opt =>
{
    opt.AddOperationalStore(efOpt =>
    {
        efOpt.UseModelCustomizerForMigrations();
        efOpt.CancelMissedTickersOnAppStart(); // don't fire a storm of overdue emails on restart
    });
    opt.AddDashboard(dash => dash.BasePath = "/ticker-dashboard");
});
var app = builder.Build();
app.UseTickerQ();
app.MapControllerRoute(name: "default", pattern: "{controller=EmailScheduler}/{action=Index}/{id?}");
app.Run();

AddOperationalStore<AppDbContext> tells TickerQ to store its own TimeTicker/CronTicker tables inside the sameDbContext as our domain entities β€” one database, one connection string, one set of migrations.

The Job Function

C#
public class EmailDispatchJob
{
    private readonly AppDbContext _db;
    private readonly ISmtpEmailSender _sender;
    private readonly ITimeTickerManager _tickerManager;
    private readonly ILogger _logger;
    public EmailDispatchJob(AppDbContext db, ISmtpEmailSender sender,
        ITimeTickerManager tickerManager, ILogger logger)
    {
        _db = db; _sender = sender; _tickerManager = tickerManager; _logger = logger;
    }
    [TickerFunction(functionName: "SendScheduledEmail")]
    public async Task SendScheduledEmail(TickerFunctionContext context, CancellationToken cancellationToken)
    {
        var emailId = context.Request;
        var email = await _db.EmailSchedulers.FindAsync(new object[] { emailId }, cancellationToken);
        if (email is null || email.Status == "Sent") return; // deleted or already sent
        email.AttemptCount++;
        email.LastAttemptAt = DateTimeOffset.UtcNow;
        try
        {
            await _sender.SendAsync(email, cancellationToken);
            email.Status = "Sent";
        }
        catch (Exception ex)
        {
            _logger.LogWarning(ex, "Email {Id} attempt {Attempt} failed", email.Id, email.AttemptCount);
            if (email.AttemptCount < email.MaxRetryCount)
            {
                email.Status = "Pending";
                email.NextAttemptAt = DateTimeOffset.UtcNow.AddMinutes(email.RetryIntervalMin);
                await _tickerManager.AddAsync(new TimeTickerEntity
                {
                    Function = "SendScheduledEmail",
                    ExecutionTime = email.NextAttemptAt.UtcDateTime,
                    Request = TickerHelper.CreateTickerRequest(email.Id)
                }, cancellationToken);
            }
            else
            {
                email.Status = "Failed";
            }
        }
        await _db.SaveChangesAsync(cancellationToken);
    }
}

This is the heart of the fail/retry story: every failure re-reads the entity's own MaxRetryCount and RetryIntervalMin, so retry behavior is data-driven, not hardcoded, and each retry attempt is its own visible TimeTicker row you can inspect in the TickerQ dashboard.

Scheduling from the Controller

C#
[HttpPost]
public async Task Create(EmailScheduler model)
{
    if (!ModelState.IsValid) return View(model);
    model.CreatedAt = DateTimeOffset.UtcNow;
    model.Status = "Pending";
    model.NextAttemptAt = model.SendImmediately ? DateTimeOffset.UtcNow : model.ScheduledAt;
    _db.EmailSchedulers.Add(model);
    await _db.SaveChangesAsync();
    await _timeTickerManager.AddAsync(new TimeTickerEntity
    {
        Function = "SendScheduledEmail",
        ExecutionTime = model.NextAttemptAt.UtcDateTime,
        Request = TickerHelper.CreateTickerRequest(model.Id)
    });
    TempData["Message"] = $"Email #{model.Id} scheduled for {model.NextAttemptAt:g}.";
    return RedirectToAction(nameof(Index));
}

SendImmediately and ScheduledAt collapse into one thing TickerQ understands: an ExecutionTime. β€œSend now” is just β€œsend at DateTimeOffset.UtcNow” β€” there's no separate code path.

Retry & Fail, End to End

  • Automatic retry: handled entirely inside EmailDispatchJob, driven by AttemptCount/MaxRetryCount/RetryIntervalMin on the row.
  • Manual retry: a controller action resets AttemptCount = 0, Status = "Pending", NextAttemptAt = now, and enqueues a fresh TimeTicker β€” useful once you've fixed a bad recipient address or an SMTP credential.
  • Permanent failure: once AttemptCount >= MaxRetryCount, the row is marked Failed and stays that way until a human retries it. No silent infinite loop, no runaway job storm.
  • Visibility: the MVC Index view lists every row with a colored status badge (Pending = amber, Sent = green, Failed = red) plus attempt count and next attempt time, and TickerQ's own dashboard gives you a second, lower-level view of the underlying job executions if you need to debug timing or locking issues.

Wrapping Up

The pattern here generalizes well beyond email: any "do this thing later, and retry it a few times if it doesn’t work" feature β€” SMS notifications, webhook delivery, report generation β€” fits the same shape. TickerQ handles the when reliably (persisted, distributed-safe, restart-proof), while your own entity stays the readable, queryable source of truth for what happened. That split keeps your MVC screens simple and your retry logic fully under your control.

Note on API surface. TickerQ is under active development and small API details (e.g., exact TickerFunctionContext<T> member names, attribute constructor overloads) can shift slightly between versions. Always check the version you install against docs or IntelliSense before deploying to production.

ai-is-not-replacing-dotnet-full-stack-developers.png

AI Is Not Replacing .NET or Full Stack Developers

Every few months, a new wave of headlines declares that AI is about to make software developers obsolete. For .NET and full stack developers in particular, the message can feel personal — after all, tools like GitHub Copilot, Cursor, and Claude can now write controllers, scaffold APIs, and generate…

Continue reading