Skip to content

feat: email notification system for CL comments#2040

Merged
genedna merged 1 commit intoweb3infra-foundation:mainfrom
abby-ql:email-notification-system
Mar 31, 2026
Merged

feat: email notification system for CL comments#2040
genedna merged 1 commit intoweb3infra-foundation:mainfrom
abby-ql:email-notification-system

Conversation

@abby-ql
Copy link
Copy Markdown
Contributor

@abby-ql abby-ql commented Mar 30, 2026

fix #1939
This PR implements an email notification system for Change List (CL) comments. It enables users to receive notifications when new comments are added to CLs they are involved in.

  • Send email notifications for cl.comment.created events
  • Notify CL author and reviewers
  • Exclude the actor from notifications
  • Respect user notification settings
  • Asynchronous processing via an email job queue
  • Retry on SMTP failure
  • Support both plain text and HTML email formats

Overview

  • Trigger layer (triggers.rs)
    Determines recipients and enqueues email jobs

  • Storage layer (NotificationStorage)
    Persists jobs in email_jobs with status tracking

  • Dispatcher (dispatcher.rs)
    Processes pending jobs and sends emails via SMTP

Testing

  • End-to-end testing was completed before merging upstream changes using a local SMTP debug server

  • Verified:

    • correct recipient resolution (author + reviewers)
    • exclusion of actor
    • respect for user preferences
    • retry behavior under SMTP failure
  • After merging upstream (OAuth / schema changes), full end-to-end testing is currently blocked by authentication requirements

Notes

  • end to end testing was all done before merging with upstream. Integration with the new authentication flow requires a valid session cookie (external OAuth), which currently blocks local end-to-end testing after the merge

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3e9726cbae

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +173 to +175
let settings = match self.get_user_settings(username).await? {
Some(s) => s,
None => return Ok(false),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Treat missing user settings as default-enabled

Returning false when get_user_settings is None means recipients are skipped unless they already created a settings row, but this commit only creates rows in the notification-config user endpoints. In practice, CL authors/reviewers who never opened that page will never receive comment notifications, even though event types are default-enabled, so the new feature silently misses most intended recipients.

Useful? React with 👍 / 👎.

Comment on lines +129 to +131
Err(e) => {
tracing::warn!("Failed to initialize SMTP mailer, falling back to noop: {e:?}");
Arc::new(NoopMailer)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Avoid noop fallback when SMTP initialization fails

Falling back to NoopMailer on SMTP init errors causes silent data loss: NoopMailer returns Ok(()), and the dispatcher marks jobs as sent on success, so queued notifications are permanently drained without delivery or retry. This is especially harmful during transient/configuration startup issues because operators lose all pending emails instead of preserving them until mail is healthy.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Implements an email notification system for Change List (CL) comment events across the stack (schema + storage + trigger + background dispatcher), and exposes user-facing APIs to manage notification preferences.

Changes:

  • Add notification center schema (event types, user settings/preferences, email job outbox) plus SeaORM entities and a NotificationStorage implementation.
  • Trigger cl.comment.created to enqueue email jobs for CL author/reviewers (excluding actor), and add a background dispatcher to send/retry via SMTP.
  • Add user APIs/models to list notification event types and get/update the current user’s notification config; add mail configuration + SMTP mailer module.

Reviewed changes

Copilot reviewed 30 out of 32 changed files in this pull request and generated 9 comments.

Show a summary per file
File Description
orion-server/src/scheduler.rs Renames/adds queue test (currently doesn’t validate FIFO order).
mono/start-mono.sh Minor script formatting change.
mono/src/server/http_server.rs Spawns an email dispatcher background task; adds SMTP/Noop mailer wiring and shutdown handling.
mono/src/notification/triggers.rs Implements CL comment trigger: recipient resolution + enqueue email jobs; includes tests.
mono/src/notification/mod.rs Adds notification module exports.
mono/src/notification/dispatcher.rs Implements background dispatcher to claim/send/retry email jobs; includes tests.
mono/src/main.rs Updates module declarations/imports for notification/server wiring (currently problematic).
mono/src/lib.rs Exposes notification module from the library crate.
mono/src/api/router/user_router.rs Adds endpoints to list notification types and get/update notification config.
mono/src/api/router/cl_router.rs Calls the notification trigger after saving a CL comment.
mono/src/api/mod.rs Plumbs NotificationStorage access into API state.
mono/Cargo.toml Adds tempfile dev-dependency for new tests.
jupiter/src/tests.rs Wires NotificationStorage into test Storage.
jupiter/src/storage/notification_storage.rs Adds storage layer for event types, user settings/preferences, and email job outbox + retry/claim logic.
jupiter/src/storage/mod.rs Exposes NotificationStorage and includes it in Storage/AppService construction.
jupiter/src/migration/mod.rs Registers notification-center migration and adds schema/constraint tests.
jupiter/src/migration/m20260224_230000_create_notification_center.rs Adds DB migration creating notification-related tables and indices.
jupiter/callisto/src/user_notification_settings.rs Adds SeaORM entity for user notification settings table.
jupiter/callisto/src/user_notification_preferences.rs Adds SeaORM entity for per-event user preference overrides.
jupiter/callisto/src/prelude.rs Exposes new notification entities in Callisto prelude.
jupiter/callisto/src/notification_event_types.rs Adds SeaORM entity for notification event types table.
jupiter/callisto/src/mod.rs Registers new Callisto modules/entities.
jupiter/callisto/src/email_jobs.rs Adds SeaORM entity for email job outbox table.
config/config.toml Adds [mail] configuration block.
common/src/lib.rs Exports new common::email module.
common/src/email/mod.rs Adds Mailer trait plus SMTP + noop implementations using lettre.
common/src/config/mod.rs Adds optional [mail] config struct and deserialization test.
common/Cargo.toml Adds async-trait and lettre dependencies.
ceres/src/model/notification.rs Adds API models/schemas for notification types and user config payloads.
ceres/src/model/mod.rs Exposes notification models module.
Cargo.toml Adds workspace dependency configuration for lettre.
Cargo.lock Locks new deps pulled in by lettre/tempfile additions.

.await
.unwrap();

// SUppose the actor is carol, should notify alice and bob but not carol
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Typo in test comment: SUpposeSuppose.

Suggested change
// SUppose the actor is carol, should notify alice and bob but not carol
// Suppose the actor is carol, should notify alice and bob but not carol

Copilot uses AI. Check for mistakes.
Comment on lines +25 to +29
// Evnt types
pub async fn list_event_types(
&self,
) -> Result<Vec<notification_event_types::Model>, sea_orm::DbErr> {
notification_event_types::Entity::find()
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Typo in comment: Evnt typesEvent types.

Copilot uses AI. Check for mistakes.
Comment on lines +268 to +270
starttls = false
smtp_tls = false
tls = false No newline at end of file
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The [mail] section includes keys (smtp_tls, tls) that are not defined in common::config::MailConfig. Serde-based config deserialization will error on unknown fields, which can prevent the service from starting with this config. Either remove these keys from config.toml or add matching (defaulted) fields to MailConfig.

Suggested change
starttls = false
smtp_tls = false
tls = false
starttls = false

Copilot uses AI. Check for mistakes.
Comment on lines +124 to +137
// Build a mailer (Default to NoopMailer if config missing or invalid)
let cfg = ctx.storage.config();
let mailer: Arc<dyn Mailer> = if let Some(mail_cfg) = &cfg.mail {
match SmtpMailer::new(mail_cfg) {
Ok(m) => Arc::new(m),
Err(e) => {
tracing::warn!("Failed to initialize SMTP mailer, falling back to noop: {e:?}");
Arc::new(NoopMailer)
}
}
} else {
Arc::new(NoopMailer)
};

Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Falling back to NoopMailer means the dispatcher will report success and mark email jobs as sent even though no email was actually delivered (also when mail is disabled/misconfigured). This can silently drop notifications and lose the ability to retry once SMTP is fixed. Consider not starting the dispatcher when no real mailer is configured, or have the fallback path keep jobs pending/failed with a clear error instead of treating them as sent.

Suggested change
// Build a mailer (Default to NoopMailer if config missing or invalid)
let cfg = ctx.storage.config();
let mailer: Arc<dyn Mailer> = if let Some(mail_cfg) = &cfg.mail {
match SmtpMailer::new(mail_cfg) {
Ok(m) => Arc::new(m),
Err(e) => {
tracing::warn!("Failed to initialize SMTP mailer, falling back to noop: {e:?}");
Arc::new(NoopMailer)
}
}
} else {
Arc::new(NoopMailer)
};
let cfg = ctx.storage.config();
// If mail configuration is missing, do not start the dispatcher.
// This avoids silently "sending" emails via a noop implementation.
let mail_cfg = if let Some(mail_cfg) = &cfg.mail {
mail_cfg
} else {
tracing::info!(
"Email dispatcher not started: mail configuration is missing or disabled"
);
return tokio::spawn(async move {
tracing::info!(
"Email dispatcher idle task waiting for shutdown (mail disabled)"
);
token.cancelled().await;
tracing::info!(
"Email dispatcher idle task exiting (mail disabled)"
);
});
};
// Attempt to build a real SMTP mailer; if this fails, keep jobs pending
// by not starting the dispatcher.
let mailer: Arc<dyn Mailer> = match SmtpMailer::new(mail_cfg) {
Ok(m) => Arc::new(m),
Err(e) => {
tracing::error!(
"Failed to initialize SMTP mailer, email dispatcher will not start: {e:?}"
);
return tokio::spawn(async move {
tracing::warn!(
"Email dispatcher idle task waiting for shutdown (mailer init failed)"
);
token.cancelled().await;
tracing::warn!(
"Email dispatcher idle task exiting (mailer init failed)"
);
});
}
};

Copilot uses AI. Check for mistakes.
pub mod git_protocol;
pub mod server;
pub mod notification;
use mono::server;
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

main.rs no longer declares the server module (it was replaced with use mono::server;). However, command handlers under mono/src/commands/service/* still import crate::server::..., which will fail to compile because crate::server no longer exists in the binary crate. Either restore pub mod server; here, or switch the binary to use the library crate modules consistently (e.g., call mono::cli::parse and update command modules to reference mono::server).

Suggested change
use mono::server;
pub mod server;

Copilot uses AI. Check for mistakes.
Comment on lines +501 to +510
// Fire notification trigger
if let Err(e) = triggers::on_cl_comment_created(
&state.notification_stg(),
&state.cl_stg(),
&state.storage.reviewer_storage(),
&user.username,
&link,
&payload.content,
)
.await
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

&state.notification_stg() takes a reference to a temporary NotificationStorage (the method returns an owned value). Because the call is awaited, this borrow will not live long enough and should not compile. Bind the storage to a local variable first (or change notification_stg() to return a reference/Arc) and pass a reference to that binding.

Copilot uses AI. Check for mistakes.
Comment on lines +16 to +36
async fn ensure_event_type_exists(stg: &NotificationStorage) -> Result<(), MegaError> {
if stg
.get_event_type(EVENT_CL_COMMENT_CREATED)
.await?
.is_some()
{
return Ok(());
}

let now = chrono::Utc::now().naive_utc();
notification_event_types::ActiveModel {
code: Set(EVENT_CL_COMMENT_CREATED.to_owned()),
category: Set("cl".to_owned()),
description: Set("New comment on a Change List".to_owned()),
system_required: Set(false),
default_enabled: Set(true),
created_at: Set(now),
updated_at: Set(now),
}
.insert(stg.db())
.await?;
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ensure_event_type_exists does a check-then-insert for notification_event_types. Under concurrent first-use (multiple comment events in parallel), this can race and the insert can fail with a unique/PK violation, causing the trigger to error. Consider using an UPSERT/ON CONFLICT DO NOTHING (or catch unique-violation and treat it as success) to make the seeding idempotent.

Copilot uses AI. Check for mistakes.
Comment on lines +250 to +262
/// Try to claim a job for sending by atomically changing its status from "pending" to "sending".
/// Returns true if the claim was successful, false if the job was already claimed by another
pub async fn try_claim_job(&self, job_id: i64) -> Result<bool, sea_orm::DbErr> {
let now = chrono::Utc::now().naive_utc();
let res = email_jobs::Entity::update_many()
.col_expr(email_jobs::Column::Status, Expr::value("sending"))
.col_expr(email_jobs::Column::UpdatedAt, Expr::value(now))
.filter(email_jobs::Column::Id.eq(job_id))
.filter(email_jobs::Column::Status.eq("pending"))
.exec(self.db())
.await?;
Ok(res.rows_affected == 1)
}
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Jobs are transitioned to status = "sending" via try_claim_job, but there is no mechanism to recover jobs that get stuck in "sending" (e.g., process crash after claiming but before mark_job_sent/mark_job_failed_with_retry). Those jobs will never be fetched again by fetch_pending_jobs. Consider adding a lease/timeout (e.g., reclaim sending jobs older than N minutes) or avoiding the intermediate "sending" state.

Copilot uses AI. Check for mistakes.
Comment on lines 600 to +641
#[test]
fn test_queue_capacity_v2() {
fn test_task_queue_fifo() {
let config = TaskQueueConfig::default();
let mut queue = TaskQueue::new(config);

let build_event1 = BuildEventPayload::new(
Uuid::now_v7(),
Uuid::now_v7(),
"test_cl_link".to_string(),
"/test/repo".to_string(),
0,
);

let task1 = PendingBuildEventV2 {
event_payload: build_event1.clone(),
targets: vec![],
changes: vec![],
created_at: Instant::now(),
};

let build_event2 = BuildEventPayload::new(
Uuid::now_v7(),
Uuid::now_v7(),
"test_cl_link_2".to_string(),
"/test2/repo".to_string(),
0,
);

let task2 = PendingBuildEventV2 {
event_payload: build_event2.clone(),
targets: vec![],
changes: vec![],
created_at: Instant::now(),
};

assert!(queue.enqueue_v2(task1).is_ok());
assert!(queue.enqueue_v2(task2).is_ok());

// check stats
let stats = queue.get_stats();
assert_eq!(stats.total_queued, 2);
}
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

test_task_queue_fifo currently only asserts total_queued == 2 after enqueue, but it never verifies FIFO ordering. Either rename the test to reflect what it checks (queue length/stats) or extend it to dequeue/pop and assert the insertion order.

Copilot uses AI. Check for mistakes.
@benjamin-747 benjamin-747 enabled auto-merge March 31, 2026 01:15
@genedna genedna disabled auto-merge March 31, 2026 01:31
@genedna genedna merged commit c56f243 into web3infra-foundation:main Mar 31, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[r2cn] 实现系统邮件通知中心,并支持用户自定义通知类型配置

4 participants