Mail & Notifications

  1. Mail
    1. Queued mail
    2. Testing mail
  2. Notifications
    1. Multi-channel delivery: notification::to(...)
    2. notify_and_mail - the original two-channel convenience
  3. A real example, composed
  4. Next

Mail

A Mailable is a plain struct implementing two methods:

pub struct WelcomeMail<'a> {
    pub user: &'a User,
}

impl Mailable for WelcomeMail<'_> {
    fn subject(&self) -> String {
        format!("Welcome, {}!", self.user.name)
    }

    fn html_body(&self) -> String {
        format!("<h1>Welcome, {}</h1><p>Glad to have you.</p>", self.user.name)
    }
}
mail().to(&user.email).send(WelcomeMail { user: &user }).await?;

Mailable deliberately has no Serialize/'static bound - unlike a queued Job, a mailable is free to borrow (&'a User, as above) since .send() renders it immediately, synchronously, at the call site.

# .env
MAIL_DRIVER=log    # writes the rendered subject/body to your app's log -
                    # no SMTP server needed for local dev or `cargo test`
# MAIL_DRIVER=smtp # fill in MAIL_HOST/PORT/USERNAME/PASSWORD/ENCRYPTION to send for real

Queued mail

mail().to(&user.email).queue(WelcomeMail { user: &user }).await?;

Because a mailable can borrow and isn’t required to be Serialize, .queue() can’t hand the mailable itself off to a worker the way an app-defined Job does. Instead it renders subject()/html_body() immediately, synchronously, at the call site - the same rendering .send() does - and enqueues only the already-rendered {to, subject, html_body} for a background worker to actually deliver. This is a deliberate, narrower deferral than Laravel’s own .queue() (which re-resolves and re-renders your mailable fresh on the worker): it defers the SMTP/network I/O, not the rendering.

A freshly scaffolded app registers this framework’s own MailJob in its queue:work dispatch by default (see Events, Queues, Scheduling & Commands) so .queue() works with zero setup - an idle registration costs nothing if you never call it.

Testing mail

larust_testing::fake();

// ... exercise a route that sends mail ...

larust_testing::assert_sent::<WelcomeMail>(|sent| sent.to.contains(&user.email));
larust_testing::assert_not_sent::<PostPublishedMail>(|_| true);

fake() records every .send()/.queue() call instead of actually dispatching it (log or smtp alike) - reached only through larust-testing, never the production larust_support::mail facade, so there’s no risk of accidentally leaving a test-only mode reachable from real app code. A faked .queue() call is recorded identically to .send() - there’s no separate “assert queued vs. assert sent” distinction yet.

Notifications

Laravel’s database notification channel, plus a compile-time-checked multi-channel builder covering database and mail together - not a Laravel-shaped runtime via() dispatch table. A Notification is a plain, serializable struct with one constant:

#[derive(Serialize)]
pub struct PostPublished {
    pub post_id: i64,
    pub title: String,
}

impl Notification for PostPublished {
    const NOTIFICATION_TYPE: &'static str = "post_published";
}
notification::notify(&author, &PostPublished { post_id, title }).await?;

let recent = notification::notifications_for(&user, 20).await?;  // Vec<StoredNotification>, newest first
let unread = notification::unread_count(&user).await?;
notification::mark_as_read(&user, notification_id).await?;       // 403s if it isn't this user's own
notification::mark_all_as_read(&user).await?;

StoredNotification::data stays a raw serde_json::Value rather than a concrete type, since one query can read rows across many different Notification types at once - match on notification_type to interpret data if a generic display (title/timestamp/read state) isn’t enough.

Multi-channel delivery: notification::to(...)

Laravel’s Notification::send()/via(), but every channel is opted into explicitly at the call site rather than decided by the notification’s own via() method at runtime:

impl MailNotification for PostPublished {
    type Mail = PostPublishedMail;
    fn to_mail(&self) -> Self::Mail {
        PostPublishedMail { post_id: self.post_id, title: self.title.clone() }
    }
}

impl MailNotifiable for User {
    fn notification_email(&self) -> String { self.email.clone() }
}
notification::to(&author, &PostPublished { post_id, title })
    .database()   // needs `impl Notification for PostPublished` - already there
    .mail()        // needs `impl MailNotification for PostPublished` + `impl MailNotifiable for User`
    .send()
    .await?;

.database()/.mail() only compile if the notification/notifiable actually implement what that channel needs - asking for a channel there’s no real impl for is a compile error pointing at the missing impl, the same “a real gap is a compile error, not a silently-skipped optional method” property Mailable/Job/Authenticatable already have, not something this builder relaxes. Chaining neither method at all and calling .send() directly is legal (logged, not an error) - useful if which channels apply is itself conditional and ends up empty sometimes.

No broadcast channel on the builder - push::broadcast (see Realtime & Broadcasting) is already synchronous and infallible, so call it alongside rather than through the builder:

notification::to(&author, &PostPublished { post_id, title }).database().mail().send().await?;
push::broadcast(&format!("notifications.{}", author.auth_id()), &payload);

notify_and_mail - the original two-channel convenience

Still here, unchanged, for existing callers - [to]’s builder doesn’t replace it:

notification::notify_and_mail(
    &author,
    &PostPublished { post_id, title },
    &author.email,
    WelcomeMail { user: &author },
).await?;

Runs [notify] and mail().to(...).send(...) concurrently via tokio::try_join!, rather than [to]’s own sequential-per-channel send() - a fixed two channels, not an arbitrary list.

A real example, composed

This is the reference app’s actual PostCreated listener - one event, three independently-composed reactions, no framework dispatch table tying them together:

event::listeners().on::<PostCreated, _, _>(|event: PostCreated| async move {
    // 1. Queued - a background job, decoupled from the request.
    queue::dispatch(&NotifyPostCreatedJob { post_id: event.post_id }).await?;

    let author = User::find(event.user_id).await?.unwrap();

    // 2. Database notification - shows up in the author's own notification list.
    notification::notify(&author, &PostPublished {
        post_id: event.post_id, title: event.title.clone(),
    }).await?;

    // 3. Mail - a real email, sent (or logged) immediately.
    mail().to(&author.email).send(PostPublishedMail {
        author: &author, post_title: &event.title, post_id: event.post_id,
    }).await?;
});

See Events, Queues, Scheduling & Commands for event::dispatch/listeners() and Job/queue::dispatch themselves.

Next

Events, Queues, Scheduling & Commands.