Une tâche cron qui échoue en silence est pire qu'une tâche cron qui n'existe pas.
Si votre sauvegarde de base de données nocturne a cessé de s'exécuter il y a trois jours et que personne ne l'a remarqué, vous n'avez pas un système de sauvegarde. Vous avez une responsabilité. La proposition de valeur entière d'un service cron managé s'effondre si les échecs passent inaperçus, ce qui signifie que les notifications ne sont pas une fonctionnalité -- elles sont le produit.
Quand nous avons conçu 0cron.dev, nous avons posé une question simple : où les développeurs reçoivent-ils réellement les alertes ? La réponse, en 2026, est partout. Certaines équipes vivent dans Slack. D'autres utilisent Discord. Les développeurs solo consultent Telegram sur leur téléphone. Les équipes entreprise ont PagerDuty ou Opsgenie branchés sur des endpoints webhook. Et l'e-mail, malgré des années de prédictions sur sa mort, reste le repli universel.
Nous avons donc construit cinq canaux de notification. Pas parce que cinq est un joli chiffre rond, mais parce que ce sont les cinq endroits où les développeurs prêtent déjà attention. Et nous avons rendu chaque canal indépendamment configurable avec des filtres par événement, parce que recevoir un message Slack pour chaque health check réussi est le moyen le plus rapide de faire désactiver les notifications par quelqu'un.
Cet article couvre le service de notification de 296 lignes, le système de dispatch par canal, et les décisions de conception derrière chaque intégration.
Le NotificationService : un struct, cinq canaux
Le système de notification est centré sur un seul struct qui contient la configuration SMTP et expose des méthodes pour chaque canal :
rustpub struct NotificationService {
smtp_host: String,
smtp_port: u16,
smtp_username: String,
smtp_password: String,
smtp_from: String,
}
impl NotificationService {
pub fn from_env() -> Self {
Self {
smtp_host: std::env::var("SMTP_HOST").unwrap_or_else(|_| "smtp.gmail.com".to_string()),
smtp_port: std::env::var("SMTP_PORT")
.unwrap_or_else(|_| "587".to_string())
.parse()
.unwrap_or(587),
smtp_username: std::env::var("SMTP_USERNAME").unwrap_or_default(),
smtp_password: std::env::var("SMTP_PASSWORD").unwrap_or_default(),
smtp_from: std::env::var("SMTP_FROM")
.unwrap_or_else(|_| "[email protected]".to_string()),
}
}
}La conception est volontairement simple. La configuration SMTP provient de variables d'environnement avec des valeurs par défaut sensées. Nous n'utilisons pas de fichier de configuration ou de table de base de données pour les paramètres SMTP parce que ce sont des préoccupations au niveau du déploiement, pas au niveau de l'utilisateur. Chaque instance 0cron utilise un seul fournisseur SMTP, et ce fournisseur est configuré une fois au déploiement.
Le constructeur from_env() utilise unwrap_or_default() pour les identifiants, ce qui signifie que le service s'initialise même si SMTP n'est pas configuré. C'est intentionnel : en développement et en test, nous voulons que le service de notification existe (pour que le reste du système compile et s'exécute) mais se dégrade gracieusement en journalisation quand les identifiants SMTP sont absents.
La boucle de dispatch : comment les notifications sont envoyées
Quand une exécution de tâche se termine -- qu'elle réussisse ou échoue -- l'exécuteur appelle send_job_notifications(). Cette fonction est le pont entre le moteur d'exécution et le service de notification. Elle charge la configuration de notification de l'utilisateur depuis la base de données et itère sur les cinq canaux :
rustpub async fn send_job_notifications(
pool: &PgPool,
notification_service: &NotificationService,
user_id: i64,
job_name: &str,
execution: &Execution,
) -> Result<()> {
let config = sqlx::query_as::<_, NotificationConfig>(
"SELECT notification_config FROM users WHERE id = $1"
)
.fetch_optional(pool)
.await?;
let config = match config {
Some(c) => c,
None => return Ok(()), // no config = no notifications
};
let is_success = execution.status == "success";
let channels = [
("email", &config.email),
("slack", &config.slack),
("discord", &config.discord),
("telegram", &config.telegram),
("webhook", &config.webhook),
];
for (channel_name, channel_config) in &channels {
if !channel_config.enabled {
continue;
}
if is_success && !channel_config.on_success {
continue;
}
if !is_success && !channel_config.on_failure {
continue;
}
let body = format_notification_body(job_name, execution);
match *channel_name {
"email" => notification_service.send_email(&channel_config.target, &body).await,
"slack" => notification_service.send_slack(&channel_config.target, job_name, execution).await,
"discord" => notification_service.send_discord(&channel_config.target, job_name, execution).await,
"telegram" => notification_service.send_telegram(&channel_config.target, &body).await,
"webhook" => notification_service.send_webhook(&channel_config.target, job_name, execution).await,
_ => Ok(()),
};
}
Ok(())
}Trois choses se distinguent dans cette boucle de dispatch.
Filtrage par canal. Chaque canal a ses propres drapeaux enabled, on_success, et on_failure. Un utilisateur peut vouloir des notifications par e-mail uniquement en cas d'échec (le pattern « réveille-moi si quelque chose casse »), des notifications Slack sur les succès et échecs (le pattern « visibilité de l'équipe »), et un webhook sur tout (le pattern « nourrir mon tableau de bord de monitoring »). La vérification de filtre en deux lignes -- if is_success && !channel_config.on_success -- rend cela trivialement configurable sans logique conditionnelle complexe.
Échec gracieux. La boucle de dispatch ne court-circuite pas sur les erreurs. Si le webhook Slack est mal configuré mais que l'e-mail SMTP fonctionne bien, l'utilisateur reçoit quand même sa notification par e-mail. Chaque appel send_* retourne un Result, mais la boucle continue silencieusement en cas d'erreur. Nous journalisons les échecs, mais nous ne laissons jamais une URL Slack cassée empêcher l'envoi d'un e-mail.
Pas de fan-out asynchrone. Nous envoyons les notifications séquentiellement, pas en parallèle. C'est un choix délibéré. Envoyer cinq requêtes HTTP en parallèle économiserait quelques centaines de millisecondes, mais cela signifierait aussi cinq connexions simultanées par exécution de tâche, ce qui sous charge pourrait submerger les API externes. Le dispatch séquentiel avec des timeouts courts (5 secondes par canal) est plus simple, plus prévisible, et se termine quand même en moins d'une seconde pour les cinq canaux dans le cas normal.
Slack : Block Kit pour des messages riches
Slack est le canal de notification le plus populaire parmi nos utilisateurs, et il méritait un format plus riche que du texte brut. Nous utilisons le format Block Kit de Slack, qui structure les messages en blocs visuels avec des en-têtes, des sections, et des éléments de contexte :
rustasync fn send_slack(&self, webhook_url: &str, job_name: &str, execution: &Execution) -> Result<()> {
let status_icon = if execution.status == "success" { "[OK]" } else { "[FAILED]" };
let color = if execution.status == "success" { "#36a64f" } else { "#dc3545" };
let payload = serde_json::json!({
"attachments": [{
"color": color,
"blocks": [
{
"type": "header",
"text": {
"type": "plain_text",
"text": format!("{status_icon} Job: {job_name}")
}
},
{
"type": "section",
"fields": [
{
"type": "mrkdwn",
"text": format!("*Status:*\n{}", execution.status)
},
{
"type": "mrkdwn",
"text": format!("*Duration:*\n{}ms", execution.duration_ms.unwrap_or(0))
},
{
"type": "mrkdwn",
"text": format!("*HTTP Status:*\n{}", execution.http_status.unwrap_or(0))
},
{
"type": "mrkdwn",
"text": format!("*Executed At:*\n{}", execution.started_at)
}
]
}
]
}]
});
reqwest::Client::new()
.post(webhook_url)
.json(&payload)
.timeout(std::time::Duration::from_secs(5))
.send()
.await?;
Ok(())
}Nous avons choisi Block Kit plutôt que du texte brut pour une raison précise : la capacité de scan. Un canal Slack qui reçoit 50 notifications cron par jour est inutilisable si chaque notification est un mur de texte non formaté. Block Kit nous donne des statuts colorés (barre verte pour succès, rouge pour échec), un en-tête en gras avec le nom de la tâche, et des champs structurés qu'un développeur peut scanner en moins de deux secondes.
Le timeout de 5 secondes est important. L'API webhook de Slack prend occasionnellement 2-3 secondes pour répondre sous charge. Sans timeout, une réponse Slack lente bloquerait la boucle de notification et retarderait les livraisons aux canaux suivants. Avec le timeout, nous acceptons la notification Slack occasionnellement perdue en échange d'une livraison garantie en temps voulu sur les autres canaux.
Telegram : le canal mobile-first
Telegram est particulièrement populaire parmi les développeurs solo et les petites équipes, spécialement dans notre marché cible de développeurs en Afrique et en Asie du Sud-Est, où Telegram sert souvent de plateforme de messagerie principale. L'intégration utilise l'API Bot :
rustasync fn send_telegram(&self, config: &str, body: &str) -> Result<()> {
// config format: "bot_token:chat_id"
let parts: Vec<&str> = config.splitn(2, ':').collect();
if parts.len() != 2 {
tracing::warn!("Invalid Telegram config format, expected 'bot_token:chat_id'");
return Ok(());
}
let (bot_token, chat_id) = (parts[0], parts[1]);
let url = format!("https://api.telegram.org/bot{bot_token}/sendMessage");
reqwest::Client::new()
.post(&url)
.json(&serde_json::json!({
"chat_id": chat_id,
"text": body,
"parse_mode": "HTML"
}))
.timeout(std::time::Duration::from_secs(5))
.send()
.await?;
Ok(())
}Le format de configuration -- bot_token:chat_id empaqué dans une seule chaîne -- est un compromis pragmatique. La configuration de notification de l'utilisateur en base de données stocke une chaîne target par canal. Pour l'e-mail, cette cible est une adresse e-mail. Pour Slack, c'est une URL de webhook. Pour Telegram, nous avons besoin de deux informations (le token du bot et l'ID du chat), donc nous les empaquons dans une seule chaîne avec un délimiteur deux-points.
Le paramètre parse_mode: "HTML" nous permet d'envoyer des messages formatés avec des balises <b>gras</b> et <code>monospace</code>, ce qui rend le corps de la notification plus lisible sur les écrans mobiles. Nous utilisons HTML plutôt que Markdown parce que le parsing Markdown de Telegram est notoirement incohérent entre les versions, alors que le rendu HTML est fiable.
Webhooks : la trappe de secours
Les webhooks sont le canal de notification le plus puissant et le moins opinioné. Alors que les quatre autres canaux envoient des messages lisibles par des humains, le canal webhook envoie du JSON structuré avec le payload d'exécution complet :
rustasync fn send_webhook(&self, webhook_url: &str, job_name: &str, execution: &Execution) -> Result<()> {
let payload = serde_json::json!({
"event": "job.execution.completed",
"job": {
"name": job_name,
},
"execution": {
"id": execution.id,
"status": execution.status,
"http_status": execution.http_status,
"duration_ms": execution.duration_ms,
"response_body": execution.response_body,
"error_message": execution.error_message,
"started_at": execution.started_at,
"completed_at": execution.completed_at,
"retry_count": execution.retry_count,
},
"timestamp": chrono::Utc::now().to_rfc3339(),
});
reqwest::Client::new()
.post(webhook_url)
.header("Content-Type", "application/json")
.header("User-Agent", "0cron-webhook/1.0")
.json(&payload)
.timeout(std::time::Duration::from_secs(5))
.send()
.await?;
Ok(())
}Le payload du webhook inclut tout : ID d'exécution, statut, code de statut HTTP, durée, corps de la réponse, message d'erreur, horodatages, et compteur de retries. C'est intentionnellement verbeux. Le canal webhook existe pour les utilisateurs qui veulent construire des intégrations personnalisées -- alimenter Datadog avec les données d'exécution, déclencher un incident PagerDuty, mettre à jour une page de statut, nourrir un tableau de bord personnalisé. Ils ont besoin des données brutes, pas d'un résumé pré-formaté.
Le champ event: "job.execution.completed" est tourné vers l'avenir. Aujourd'hui, nous n'envoyons qu'un seul type d'événement. Mais le schéma est conçu pour que les futurs événements (tâche créée, tâche en pause, essai expirant, paiement échoué) puissent utiliser la même infrastructure webhook avec des types d'événements différents. Le récepteur peut filtrer sur le champ event sans changer son URL d'endpoint.
Le repli e-mail : SMTP avec dégradation gracieuse
L'e-mail est simultanément le canal de notification le plus fiable et le plus frustrant à implémenter. SMTP est un protocole de 1982, et ça se voit. Mais c'est universel -- chaque développeur a une adresse e-mail, et les e-mails arrivent même quand Slack est en panne et Discord a un incident.
Notre implémentation e-mail utilise le crate lettre avec STARTTLS, qui est le standard pour le SMTP moderne :
rustasync fn send_email(&self, to_address: &str, body: &str) -> Result<()> {
if self.smtp_username.is_empty() {
tracing::info!("SMTP not configured, logging notification: {body}");
return Ok(());
}
let email = Message::builder()
.from(self.smtp_from.parse()?)
.to(to_address.parse()?)
.subject("0cron Job Notification")
.body(body.to_string())?;
let creds = Credentials::new(
self.smtp_username.clone(),
self.smtp_password.clone(),
);
let mailer = SmtpTransport::starttls_relay(&self.smtp_host)?
.port(self.smtp_port)
.credentials(creds)
.build();
match mailer.send(&email) {
Ok(_) => tracing::info!("Email notification sent to {to_address}"),
Err(e) => tracing::error!("Failed to send email to {to_address}: {e}"),
}
Ok(())
}La ligne la plus importante dans cette fonction est la première : if self.smtp_username.is_empty(). C'est la dégradation gracieuse. En développement, en test, et sur tout déploiement où SMTP n'est pas configuré, la fonction journalise le corps de la notification et retourne succès. Elle ne panique pas. Elle ne retourne pas une erreur qui se propagerait vers le haut et ferait échouer l'exécution de la tâche. Elle note simplement qu'elle aurait envoyé un e-mail et passe à la suite.
Ce pattern -- « journaliser au lieu d'envoyer quand non configuré » -- est critique pour les workflows de développement. Quand vous testez l'exécution de tâches localement, vous ne voulez pas configurer un serveur SMTP juste pour vérifier que l'exécuteur fonctionne correctement. Le service de notification s'adapte à son environnement.
Pourquoi cinq canaux (et pas trois, ou dix)
Nous n'avons pas choisi cinq canaux arbitrairement. Nous avons étudié le paysage des outils de tâches cron et de monitoring et identifié les plateformes où les développeurs répondent réellement aux alertes :
E-mail est universel. Tout le monde en a un. C'est le plus petit dénominateur commun et le seul canal qui fonctionne même quand l'application réceptrice est en panne (parce que l'e-mail est store-and-forward).
Slack domine la communication d'équipe dans les startups et entreprises de taille moyenne. Un canal #cron-alerts est un pattern standard. L'API de Slack est bien documentée, et les webhooks entrants sont triviaux à configurer.
Discord remplit le même rôle pour les équipes open-source, les entreprises de jeux vidéo, et les communautés de développeurs. Son API webhook est quasi identique à celle de Slack en concept (bien que le format JSON diffère), donc supporter les deux a été un effort supplémentaire marginal.
Telegram est populaire mondialement, particulièrement en Afrique, en Europe de l'Est, et en Asie du Sud-Est -- des marchés où 0cron a un positionnement fort grâce à son prix bas. Les bots Telegram sont gratuits, rapides, et fonctionnent sur des connexions lentes.
Webhooks sont la trappe de secours. Toute intégration que nous n'avons pas construite -- PagerDuty, Opsgenie, Microsoft Teams, tableaux de bord personnalisés, workflows Zapier -- peut être connectée via un webhook. Au lieu de construire dix intégrations, nous en construisons quatre plus un webhook générique, et l'utilisateur connecte le reste.
Nous avons explicitement choisi de ne pas supporter les SMS. La livraison des SMS est peu fiable, coûte de l'argent par message (ce qui entre en conflit avec notre tarification notifications illimitées), et est de plus en plus bloquée par les filtres anti-spam. Les notifications push via une app mobile seraient idéales, mais nous n'avons pas encore d'app mobile. Quand nous en aurons une, elle deviendra le sixième canal.
Ce qui vient ensuite
Le système de notification a un chemin de mise à niveau clair. Les prochaines fonctionnalités sur la feuille de route sont :
Templates de notification. Permettre aux utilisateurs de personnaliser le format des messages par canal. Une notification Slack pourrait vouloir des champs différents d'une notification par e-mail.
Politiques d'escalade. Si une tâche échoue trois fois de suite, escalader de Slack à l'e-mail puis à l'appel téléphonique. Cela nécessite de suivre l'historique des notifications, ce que nous ne faisons pas actuellement.
Mode résumé. Au lieu d'une notification par exécution, envoyer un résumé quotidien : « 47 tâches ont tourné, 2 ont échoué, voici les détails. » C'est particulièrement utile pour les utilisateurs avec des centaines de tâches.
Mais le système actuel -- cinq canaux, filtrage par événement, dégradation gracieuse, payloads webhook structurés -- couvre les besoins de 95 % des utilisateurs au lancement. Et il le fait en 296 lignes de Rust.
Ceci est la partie 5 d'une série de 10 articles sur la construction de 0cron.dev.
| # | Article | Focus |
|---|---|---|
| 1 | Pourquoi le monde a besoin d'un service cron à 2 $ | Analyse de marché et philosophie tarifaire |
| 2 | 4 agents, 1 produit : construire 0cron en une seule session | Build parallèle avec 4 agents Claude |
| 3 | Construire un moteur de planification cron en Rust | Axum, sorted sets Redis, exécuteur de tâches |
| 4 | "Tous les jours à 9 h" : parsing de planification en langage naturel | Parseur NLP à base de regex en 152 lignes |
| 5 | Notifications multi-canaux : e-mail, Slack, Discord, Telegram, webhooks | Cet article |
| 6 | Intégration Stripe pour un SaaS à 1,99 $/mois | Facturation, essais, et gestion de webhooks |
| 7 | Du HTML statique au tableau de bord SvelteKit en une nuit | Architecture frontend et runes Svelte 5 |
| 8 | Monitoring heartbeat : quand votre tâche devrait vous pinguer | Modèle moniteur, pings, et périodes de grâce |
| 9 | Secrets chiffrés, clés API, et sécurité | AES-256-GCM, authentification par clé API, signature HMAC |
| 10 | D'Abidjan à la production : lancement de 0cron.dev | L'histoire complète et la suite |