Mail & Notifications

  1. Mail
    1. Queued mail
    2. Testing mail
  2. Notifications
    1. Why no via()/multi-channel dispatch
  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 specifically - not a multi-channel 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.

Why no via()/multi-channel dispatch

Laravel’s Notification::via() decides at runtime which channels (mail, database, broadcast, …) a notification goes out on. This framework’s equivalent traits (Mailable, Job, Authenticatable) are all zero-default-method traits by convention - building a via()-style dispatcher here would be the first thing in this codebase to break that “a real gap is a compile error, not a silently-skipped optional method” rule. Since Mail and the WebSocket push::broadcast (see Realtime & Broadcasting) already each fully solve their own job independently, reaching more than one channel is just composing ordinary, independently-typed calls at the same call site - exactly what the real listener example below does. A convenience for the common case of exactly two channels at once exists too:

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

Runs both concurrently via tokio::try_join! rather than one after the other.

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.