Agendando e-mails no .NET com HangFire

O Hangfire é uma biblioteca open source para .NET que executa tarefas em segundo plano com persistência, e por isso resolve muito bem o agendamento de e-mails. Imagine que você precise enviar milhares de mensagens para seus usuários sem travar a aplicação web e sem depender de uma rotina manual.

A resposta para esse cenário são os background jobs. Neste artigo, vamos explorar os conceitos fundamentais da biblioteca usando um único caso prático: um agendador de e-mails construído com ASP.NET Core e Minimal APIs.

O problema que o Hangfire resolve

Suponha um sistema que permite agendar campanhas de e-mail. Cada mensagem precisa sair na data e na hora definidas, mesmo que o servidor esteja sob carga ou seja reiniciado no meio do caminho.

Além disso, o time quer acompanhar o status de cada envio. Afinal, saber que uma campanha falhou só quando o cliente reclama não é uma opção aceitável.

Uma saída ingênua seria disparar o envio com Task.Run dentro do próprio endpoint. No entanto, essa tarefa vive apenas na memória do processo, então um deploy ou uma queda do servidor apaga tudo o que estava pendente.

Também seria possível escrever um serviço hospedado com BackgroundService, como mostramos no artigo sobre Background Services em C# e .NET. Contudo, nesse caso você mesmo precisaria implementar fila, persistência, novas tentativas e monitoramento.

O Hangfire entrega exatamente essas peças prontas. Por isso, ele dispensa serviços adicionais como Windows Services, o Agendador de Tarefas do Windows ou cron jobs externos.

O que é o Hangfire

O Hangfire é uma biblioteca que permite executar métodos .NET em background com confiabilidade. Segundo o repositório oficial do projeto, ela é distribuída sob a licença LGPLv3 e funciona em aplicações web, aplicações de console e serviços do Windows.

Na prática, a biblioteca oferece suporte a quatro modelos principais de execução:

  • Jobs imediatos, também chamados de fire-and-forget.
  • Agendamentos para um momento futuro.
  • Tarefas recorrentes, baseadas em expressões CRON.
  • Continuações, que encadeiam um job ao término de outro.

O ponto central da arquitetura é o armazenamento persistente. O Hangfire grava cada job em um storage, como SQL Server ou Redis, e por isso as tarefas não se perdem quando o processo cai.

Existem ainda pacotes mantidos pela comunidade para PostgreSQL e outros bancos. Dessa forma, você escolhe o storage que já faz parte da sua infraestrutura.

Como o Hangfire processa os jobs

O fluxo tem três participantes. Primeiro, o cliente cria o job e o serializa no storage; em seguida, o servidor do Hangfire busca os jobs pendentes e os executa em threads próprias.

Por fim, o resultado de cada execução volta para o storage como um novo estado. Assim, um job passa por estados como Enqueued, Scheduled, Processing, Succeeded e Failed, e você consegue consultar esse histórico a qualquer momento.

Essa separação também permite escalar. Você pode ter várias instâncias da API criando jobs e um ou mais servidores processando a fila, todos compartilhando o mesmo storage.

Configurando o Hangfire no projeto

Vamos criar uma aplicação ASP.NET Core com um endpoint que agenda e-mails. O primeiro passo é instalar os pacotes NuGet necessários.

dotnet add package Hangfire
dotnet add package Hangfire.AspNetCore
dotnet add package Hangfire.MemoryStorage

O pacote Hangfire traz o núcleo da biblioteca e Hangfire.AspNetCore faz a integração com o host e com a injeção de dependência. Já Hangfire.MemoryStorage guarda os jobs em memória, o que simplifica o exemplo.

Para produção, prefira um banco como SQL Server ou PostgreSQL. Em memória, os jobs desaparecem quando a aplicação reinicia, ou seja, você perde justamente a garantia que motivou o uso do Hangfire.

Vale citar também o pacote Hangfire.InMemory, publicado pela própria equipe do Hangfire. Ele serve ao mesmo propósito de desenvolvimento e testes, então você pode adotá-lo no lugar do Hangfire.MemoryStorage se preferir um pacote oficial.

Registrando os serviços do Hangfire

Toda a configuração acontece no Program.cs. Vamos montar o arquivo em partes, começando pelos using e pela criação do builder.

using Hangfire;
using Hangfire.MemoryStorage;

var builder = WebApplication.CreateBuilder(args);

Os using importam as dependências do Hangfire. Enquanto isso, o builder prepara o container de serviços e a configuração da aplicação.

builder.Services.AddHangfire(config =>
    config.SetDataCompatibilityLevel(CompatibilityLevel.Version_170)
          .UseSimpleAssemblyNameTypeSerializer()
          .UseRecommendedSerializerSettings()
          .UseMemoryStorage());

Aqui adicionamos o Hangfire aos serviços da aplicação. Cada chamada da cadeia tem um papel específico:

  • SetDataCompatibilityLevel define o formato dos dados gravados no storage.
  • UseSimpleAssemblyNameTypeSerializer grava nomes de tipos mais curtos, sem versão do assembly.
  • UseRecommendedSerializerSettings aplica as configurações de serialização JSON recomendadas.
  • UseMemoryStorage escolhe o armazenamento em memória para testes e desenvolvimento.

O nível de compatibilidade merece atenção, porque ele dita como os dados ficam no banco. Por isso, ao atualizar a biblioteca, revise as notas da versão antes de subir esse valor.

builder.Services.AddHangfireServer();
builder.Services.AddSingleton<IEmailService, EmailService>();

O método AddHangfireServer registra o servidor que processa os jobs em background como um serviço hospedado. Logo em seguida, registramos nosso serviço de e-mail na injeção de dependência.

Ativando o dashboard e os endpoints

Com os serviços registrados, construímos a aplicação e ativamos o painel de monitoramento.

var app = builder.Build();

app.UseHangfireDashboard("/hangfire");

app.MapGet("/", () => "Email scheduling API with Hangfire");

O dashboard fica acessível em /hangfire. Além dele, criamos uma rota de teste simples que apenas retorna uma mensagem padrão.

app.MapPost("/schedule-email", (EmailRequest request, IBackgroundJobClient client) =>
{
    // Agenda o envio para o horário informado pelo cliente
    client.Schedule<IEmailService>(
        x => x.SendEmailAsync(request.To, request.Subject, request.Body),
        request.ScheduledAt - DateTime.UtcNow);

    return Results.Ok("Email scheduled successfully!");
});

app.Run();

Esse endpoint recebe a requisição e usa client.Schedule para registrar a execução futura de SendEmailAsync. Então o Hangfire grava o job no estado Scheduled e só o enfileira quando o horário chega.

Repare que o segundo argumento é um TimeSpan calculado a partir de ScheduledAt. A biblioteca também aceita um DateTimeOffset direto, o que evita a conta e deixa a intenção mais clara.

Exemplo completo da API com Hangfire

Juntando todas as partes, o Program.cs fica assim:

using Hangfire;
using Hangfire.MemoryStorage;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddHangfire(config =>
    config.SetDataCompatibilityLevel(CompatibilityLevel.Version_170)
          .UseSimpleAssemblyNameTypeSerializer()
          .UseRecommendedSerializerSettings()
          .UseMemoryStorage());

builder.Services.AddHangfireServer();

builder.Services.AddSingleton<IEmailService, EmailService>();

var app = builder.Build();

app.UseHangfireDashboard("/hangfire");

app.MapGet("/", () => "Email scheduling API with Hangfire");

app.MapPost("/schedule-email", (EmailRequest request, IBackgroundJobClient client) =>
{
    client.Schedule<IEmailService>(
        x => x.SendEmailAsync(request.To, request.Subject, request.Body),
        request.ScheduledAt - DateTime.UtcNow);

    return Results.Ok("Email scheduled successfully!");
});

app.Run();

Criando o serviço de envio de e-mail

Agora definimos uma interface e uma implementação concreta para o serviço que envia os e-mails. Essa separação entre contrato e implementação facilita a injeção de dependência e os testes unitários.

Além disso, ela permite trocar o mecanismo de envio no futuro. Por exemplo, você pode substituir o Console.WriteLine por um provedor como SendGrid ou por um cliente SMTP sem mexer no agendamento.

Interface do serviço

public interface IEmailService
{
    Task SendEmailAsync(string to, string subject, string body);
}

A interface IEmailService define um contrato com um único método, SendEmailAsync. Ele recebe o destinatário, o assunto e o corpo do e-mail.

Assim, qualquer classe que implemente essa interface segue o mesmo formato. Isso também ajuda o Hangfire, porque o job aponta para o contrato e não para uma classe específica.

Implementação concreta

public class EmailService : IEmailService
{
    public async Task SendEmailAsync(string to, string subject, string body)
    {
        Console.WriteLine($"[Email sent] To: {to} | Subject: {subject}");
        await Task.Delay(1000); // simula o tempo de envio
    }
}

A classe EmailService implementa IEmailService e simula o envio escrevendo no console. Em seguida, ela aguarda um segundo com Task.Delay(1000) para imitar a latência de um provedor real.

Em uma aplicação de verdade, esse método chamaria uma API de envio de e-mails. Mesmo assim, o design orientado a interfaces continua valendo, pois facilita a manutenção, permite usar mocks em testes e desacopla o código das implementações específicas.

O DTO EmailRequest

A classe EmailRequest é um DTO, ou seja, um objeto que transporta dados entre a camada da API e a lógica de negócio. Neste contexto, ela representa o JSON que o cliente envia para agendar um e-mail.

public class EmailRequest
{
    public string To { get; set; } = string.Empty;
    public string Subject { get; set; } = string.Empty;
    public string Body { get; set; } = string.Empty;
    public DateTime ScheduledAt { get; set; }
}

O endpoint /schedule-email espera esses quatro campos. Cada um tem uma função clara:

  • To guarda o endereço de e-mail do destinatário.
  • Subject guarda o título do e-mail.
  • Body guarda o conteúdo da mensagem.
  • ScheduledAt indica a data e a hora, em UTC, do envio.

Com esses dados, o serviço sabe quem recebe, o que recebe e quando recebe. Portanto, o DTO concentra toda a informação que o Hangfire precisa serializar junto com o job.

Um cuidado importante é manter os argumentos pequenos. O Hangfire grava os parâmetros do método no storage, então prefira passar identificadores e buscar os dados completos dentro do job.

Monitorando jobs no dashboard do Hangfire

O Hangfire inclui um painel visual acessível em /hangfire. Com ele, você consegue acompanhar o ciclo de vida de cada envio sem escrever uma linha de código extra.

Entre as funções mais úteis do painel, destacam-se estas:

  • Ver jobs agendados, em execução, concluídos ou com erro.
  • Reprocessar falhas manualmente com um clique.
  • Acompanhar o histórico de execução e os detalhes de cada exceção.
  • Disparar jobs recorrentes sob demanda.

Por padrão, o dashboard só aceita requisições locais. Por isso, ele funciona no seu localhost durante o desenvolvimento, mas bloqueia o acesso quando a aplicação vai para um servidor.

Para liberar o acesso em produção, você precisa configurar uma regra de autorização. Uma abordagem comum é mapear o painel com MapHangfireDashboard e aplicar RequireAuthorization com uma política que exija um usuário autenticado e com o papel adequado.

Nunca exponha o painel sem proteção. Afinal, ele permite reexecutar e excluir jobs, e também revela os argumentos de cada chamada.

Tipos de jobs do Hangfire

Além do Schedule, o Hangfire suporta outros formatos de execução. Cada um atende a uma necessidade diferente do agendador de e-mails.

  • Enqueue executa o job assim que um worker estiver livre.
  • Schedule executa o job em um momento futuro.
  • RecurringJob.AddOrUpdate cria jobs recorrentes, como um relatório todo dia às 8h.
  • ContinueJobWith encadeia um job ao término de outro.

Um e-mail de boas-vindas, por exemplo, pode usar Enqueue. Assim, o cadastro responde na hora e o envio acontece logo depois, fora da requisição.

Jobs recorrentes

O exemplo abaixo cria um relatório diário enviado ao administrador:

RecurringJob.AddOrUpdate<IEmailService>(
    "daily-report",
    x => x.SendEmailAsync("admin@site.com", "Daily report", "Content..."),
    Cron.Daily);

O primeiro argumento é o identificador do job recorrente. Com ele, você atualiza ou remove a agenda depois, e o painel exibe esse nome na aba de recorrentes.

A classe Cron oferece atalhos como Daily, Hourly e Weekly. Também é possível passar uma expressão CRON completa quando a agenda é mais específica.

Continuações

As continuações são úteis quando uma etapa depende da anterior. Por exemplo, você pode gerar um relatório e, só depois que ele terminar, enviar o e-mail com o link.

var reportJobId = BackgroundJob.Enqueue<IReportService>(x => x.GenerateAsync());

BackgroundJob.ContinueJobWith<IEmailService>(
    reportJobId,
    x => x.SendEmailAsync("admin@site.com", "Report ready", "Your report is ready."));

Nesse caso, o segundo job só entra na fila quando o primeiro termina com sucesso. Dessa forma, o fluxo fica explícito e rastreável no dashboard.

Falhas e novas tentativas automáticas

Provedores de e-mail falham com frequência por limites de taxa, instabilidade de rede ou indisponibilidade momentânea. Felizmente, a biblioteca trata esse cenário de forma nativa.

Segundo a documentação sobre exceções, o filtro AutomaticRetry vem aplicado globalmente com 10 tentativas. Além disso, o intervalo entre as tentativas cresce a cada falha.

Quando as tentativas se esgotam, o job vai para o estado Failed e aparece no painel. Lá ele permanece até alguém excluí-lo, então você pode corrigir a causa e reprocessar manualmente.

Você também pode ajustar esse comportamento por método. Para desativar as novas tentativas em um job, basta decorar o método com [AutomaticRetry(Attempts = 0)].

Como um job pode rodar mais de uma vez, escreva o envio de forma idempotente. Na prática, registre que o e-mail saiu e verifique esse registro antes de enviar de novo.

Boas práticas para usar o Hangfire em produção

O exemplo deste artigo usa memória para facilitar o aprendizado. Em produção, porém, alguns cuidados fazem diferença:

  • Use um storage persistente, como SQL Server, PostgreSQL ou Redis.
  • Proteja o dashboard com autenticação e autorização.
  • Passe identificadores como argumentos, e não objetos grandes.
  • Trabalhe sempre com datas em UTC ao agendar.
  • Escreva jobs idempotentes, porque novas tentativas acontecem.

Também vale lembrar que o Hangfire resolve dependências pelo container do ASP.NET Core. Portanto, serviços com tempo de vida scoped funcionam dentro dos jobs, já que cada execução recebe seu próprio escopo.

Se o volume crescer, você pode separar o processamento em outro processo. Nesse caso, a API apenas cria os jobs, enquanto um worker dedicado chama AddHangfireServer e consome a fila.

Hangfire ou outras opções de agendamento

O Hangfire não é a única forma de agendar tarefas no .NET. O próprio framework oferece os serviços hospedados, descritos na documentação de hosted services do ASP.NET Core, que atendem bem tarefas simples e contínuas.

Outra alternativa popular é o Quartz.NET, focado em agendas complexas e calendários. Mostramos essa biblioteca em detalhes no artigo Agendando tarefas com Quartz no .NET.

Em resumo, o Hangfire se destaca quando você quer disparar jobs a partir do código da aplicação, com persistência e painel prontos. Já o Quartz brilha em cenários dominados por agendas e gatilhos.

Considerações finais

Com o Hangfire, você desacopla tarefas demoradas do fluxo principal da aplicação. Como resultado, o sistema fica mais rápido para o usuário, mais confiável diante de falhas e mais fácil de monitorar.

Usando um agendador de e-mails, mostramos como configurar a biblioteca, agendar envios e acompanhar tudo pelo dashboard. Agora, o próximo passo é trocar o storage em memória por um banco real e proteger o painel antes de ir para produção.

FAQ

O Hangfire é gratuito?

Sim, o núcleo do Hangfire é open source e distribuído sob a licença LGPLv3. Existem extensões comerciais, mas o agendamento básico não exige licença paga.

Posso usar o Hangfire com PostgreSQL?

Sim, existem pacotes de storage mantidos pela comunidade para PostgreSQL. Basta trocar UseMemoryStorage pelo método de configuração do pacote escolhido.

O que acontece com os jobs se a aplicação reiniciar?

Com um storage persistente, os jobs continuam gravados e voltam a ser processados quando o servidor sobe. Com armazenamento em memória, eles se perdem.

Quantas vezes o Hangfire tenta executar um job que falhou?

Por padrão, são 10 tentativas com intervalos crescentes. Você pode alterar esse número com o atributo AutomaticRetry.

Como proteger o dashboard em produção?

Configure uma política de autorização e aplique ao endpoint do painel. Sem isso, o dashboard aceita apenas requisições locais.