Skip to content

Notifications

Notify the author after the same PostPublished event:

final author = await post.user().get();
if (author != null) {
await author.notify(PostPublishedNotification(post));
}

Sistrum (sistrum) delivers one notification through mail, database, or application-defined channels. A notification owns its content and channel choice; a notifiable object owns its delivery routes and database identity.

New Maat applications register NotificationServiceProvider and include sistrumMigrations automatically. In an existing application, add both:

.withProviders([
MailServiceProvider.new,
NotificationServiceProvider.new,
])
final migrations = <Migration>[
...sistrumMigrations,
];

Run maat migrate before using the database channel.

Defining a Notification

Generate the class:

Terminal window
maat make:notification PostPublishedNotification

Choose channels per recipient and provide each channel’s payload:

class PostPublishedNotification extends Notification {
PostPublishedNotification(this.post);
final Post post;
@override
List<String> via(Object notifiable) => const ['mail', 'database'];
@override
MailMessage toMail(Object notifiable) => MailMessage()
.subject('Your post is live')
.greeting('Published')
.line('"${post.title}" is now available to readers.')
.action('View post', 'https://example.com/posts/${post.id}')
.line('Thank you for writing.')
.success();
@override
Map<String, Object?> toArray(Object notifiable) => {
'postId': post.id,
'title': post.title,
};
}

toDatabase() wraps toArray() by default. Override it with a DatabaseMessage when the two payloads differ. Override databaseType() when stored type names must remain stable across Dart class renames.

MailMessage supports subject, greeting, line, lineIf, lines, action, salutation, success, error, from, cc, bcc, replyTo, attach, view, and mailer. Without a subject, Sistrum converts the notification class name to a headline. Without a view, it renders safe built-in HTML plus a plain-text alternative; a custom Khnum view replaces only HTML.

Notifiable Models

Mix Notifiable into a Seshat model. Its model key and runtime type become the database notification identity:

class User extends Model<User> with Notifiable {
@override
Object? routeNotificationFor(String channel) => switch (channel) {
'mail' => email,
_ => null,
};
}

The mail channel uses routeNotificationFor('mail') first, then falls back to a String email property. A route may be a String, Address, or iterable of either.

Sending

await author.notify(PostPublishedNotification(post));
await Notification.send([firstUser, secondUser], WeeklyDigest());
await user.notifyNow(
SecurityAlert(),
channels: const ['database'],
);
await Notification.route(
'mail',
'ops@example.com',
).notify(ServiceRecovered());

An anonymous/on-demand recipient cannot use the database channel because it has no stable type and key.

Use shouldSend for a final per-recipient, per-channel decision:

@override
bool shouldSend(Object notifiable, String channel) =>
channel != 'mail' || (notifiable as User).acceptsEmail;

Database Notifications

final all = await user.notifications().get();
final unread = await user.unreadNotifications().get();
final read = await user.readNotifications().get();
final opened = await unread.first.markAsRead();
await opened.markAsUnread();

Queries are newest first. State changes return a new DatabaseNotification, following Seshat’s immutable model convention.

Custom Channels

class SmsChannel implements NotificationChannel {
@override
Future<Object?> send(Object notifiable, Notification notification) async {
final number = (notifiable as User).phone;
return sms.send(number, notification.toArray(notifiable));
}
}
Notification.extend('sms', SmsChannel());

Register a custom channel before sending a notification that lists its name.

Delivery Events

NotificationSending fires before each channel. Return false to skip it:

Event.listen<NotificationSending>((event) {
if (event.channel == 'mail' && maintenanceMode) return false;
});
Event.listen<NotificationSent>((event) {
Log.info('Sent ${event.notification.runtimeType} on ${event.channel}');
});
Event.listen<NotificationSkipped>((event) {
Log.info('Skipped ${event.channel}');
});
Event.listen<NotificationFailed>((event) {
Log.error('Failed ${event.channel}: ${event.error}');
});

After NotificationFailed, the original channel error is rethrown.

Testing

final notifications = Notification.fake();
await author.notify(PostPublishedNotification(post));
notifications.assertSentTo<PostPublishedNotification>(author);
notifications.assertSentTo<PostPublishedNotification>(
author,
(message, channels) =>
message.post.id == post.id && channels.contains('database'),
);
notifications.assertSentToTimes<PostPublishedNotification>(author, 1);
notifications.assertNotSentTo<PasswordResetNotification>(author);
notifications.assertCount(1);

For on-demand recipients, use assertSentOnDemand<T>(). Other helpers are sent<T>(), assertNothingSent(), assertNothingSentTo(), and assertSentTimes<T>().

Sends are inline: the returned future completes after all selected channels. Queued notifications arrive with the queue package, not as a hidden behavior of send().