In-App Notifications

In-app notifications is a system to schedule notifications for subsets of our user base with server-side definitions. As such, we don’t have to ship all possible messaging in advance when building the application. However, we still include some high value notifications in the built application to ensure they are shown to as many users as possible.

Location

The in-app notification system is fairly self-contained in mail/components/inappnotifications. The only exception are strings for localization, styles, as well as branding specific data, prefs and the starting entrypoints in MailGlue and messenger.js.

The cache is stored in the scheduled-notifications folder within the profile.

Architecture

The client code is generally split into two concerns: a back-end that manages the data and picking out the notification that should currently be shown, as well as handling the special notification types that open a browser or in-application tab directly.

The UI code is almost entirely implemented in custom elements, which are injected from messenger.js.

The code for the server service used to serve the notification data is available at https://github.com/thunderbird/thunderbird-notifications/. It also contains a schema describing the shape of the notification data.

The back-end is initialized from the InAppNotifications module, which glues all the features of the back-end together.

The custom elements start with an in-app-notification-manager element that connects to the events of the NotificationManager exposed through the InAppNotifications module from the back-end and then creates and removes notifications as needed. It does not have any interaction logic itself, instead it tells the NotificationManager about the user interaction, which might then in turn tell the UI to hide a notification.

Selecting which notification to display

The NotificationManager is what ultimately selects the notification to display, after the raw data was filtered by NotificationFilter. It chooses the notification with the highest severity (so lowest number). It tries to avoid switching notification if there is one already visible, only displacing the current notification if there is a notification with a higher severity to display instead. Lastly the notification manager handles the case where the notification expires (end_at transitioning to the past), requesting new notifications when that happens. It also requests new notifications to display when a notification is dismissed. It generally tries to wait a bit before showing the next notification.

InAppNotifications asks NotificationManager to recalculate the current notification whenever the raw data is updated, or when any new notification becomes available (its start_attransitioning to the past). It also handles the requests for current notifications from the NotificationManager by giving it the currently cached list of available notifications.

Updating

The local cache of notifications is regularly refreshed against a server (specified by an URL that is formatted using the URL formatter). If at startup of the system the server can’t be reached and there’s either no cache or the cache is older than the application build, it falls back to a set of notifications that were included at build time.

There is a cache timestamp, with the lifetime controlled by a pref. If the cache is recent enough, the network is not contacted.

Cache

The notification data provided by the server is cached locally (notifications), in addition the cache also contains seeds for the set of notifications currently returned by the server (seeds), a list of notification IDs that should no longer be shown because the user interacted with them (interactedWith) - also limited to notifications that the server currently returns - and lastly the timestamp of when the cached data from the server was last updated (lastUpdate).

See also the displayed_notifications section for some more usage info related to the list of notifications that were shown.

The seeds are stored in an object, keyed by notification ID with the value being the seed this profile rolled for that notification.

Data format/contents

The schema for the notifiation data is maintained at https://github.com/thunderbird/thunderbird-notifications/.

Text fields

The user-facing text fields are generally expected to only contain plain text. The UI might limit how much of the text is visible by default.

URL

Only URL values that use the https protocol are allowed, otherwise the notification is never shown. The URL is formatted using the URL formatter.

Types

There are two kinds of notification types: ones that show an actual notification within the application and ones that trigger an action directly when “shown”.

Type Behavior Used fields Telemetry events
donation_browser Opens tab in the default system browser URL interaction
donation_tab Opens tab within the application URL interaction
donation Shows a dismissable notification with illustrations related to our typical fundraising look. title, description, CTA/URL shown, interaction, closed, dismissed
blog Shows a dismissable notification with a simple style and a "circle-question" icon. title, description, CTA/URL shown, interaction, closed, dismissed
message Shows a dismissable notification with a simple style and a "circle-error" icon. title, description, CTA/URL shown, interaction, closed, dismissed
security Shows a dismissable notification with a simple style and a "warning" icon. title, description, CTA/URL shown, interaction, closed, dismissed

Notably the shown telemetry event is triggered every time a notification is shown, which can be multiple times per profile, since it will be shown every time the application is launched until any of the other three events occurs.

Targeting/filtering

Date range

Notifications will be only shown in the timespan between start_at and end_at. This means a notifications will be shown at start_at at the earliest, and hidden by end_at, even if the user never interacted with it - or never got to see it. The date-time string is parsed using Date.parse, so the format should be one supported by it (like ISO 8601).

percent_chance

The value determines how many percent of the user population should see the notification. This is implemented by rolling a seed between 0 and 100 (inclusive) per notification stored in the profile. That way, we always make the same decision for the same notification, but we don’t end up showing all notifications to some users and much fewer notification to another set of users.

Removes that amount of people from the remaining pool if used in combination with displayed_notifications. So 33%/33%/33% is actually declared as 33%/50%/100% with decreasing severity.

exclude/include

The exclude and include keys allow us to target specific configurations of Thunderbird. They are both arrays of configurations. To put it differently, the objects in the array are ORed against each other, while the keys in the objects are ANDed - so like a DNF.

When the exclude or include key are null or omitted the notification is displayed without any checks in relation to the conditions those keys could check. An empty array for exclude will also behave like that, however an empty array for include will lead to the notification never being shown.

Profile properties

There are two kinds of keys that we target in the profile, single values and lists of values. Both of them have arrays in the targeting profile, but the arrays behave differently. Single values are locales, versions, channels and operating systems - there is only one possible active value for all of them. So the values in the array of the targeting profile are ORed against each other, if any of them is the current value the profile matches. displayed_notifications, pref_true and pref_false compare against lists of values. So all of the items listed in the targeting profile have to be true for the profile to match.

If any key is missing or null it will not affect the filtering result. Meanwhile an empty array will behave differently for the single values, leading to the profile always matching, while it behaves like null for the properties for lists of values.

displayed_notifications

Assert that the IDs listed have been displayed in this profile. The IDs have to still be present as notifications in the full list returned by the server since they were shown. Else the application forgets that it has shown the notification. Notably, those “past” notifications no longer need to have any useful information returned by the server, apart from their ID and they would still need to be valid according to the schema, so they should probably retain their end_at date. But things like targeting and texts can be shortened to the minimum allowed value.

pref_true/pref_false

The pref_true key allows targeting preferences that are set to true. If there is no default value shipped with the application, unset values are treated as false. pref_false is almost the opposite, except that it treats unset prefs as being true.

This means an unset pref can be targeted with the following:

{
  "id": "test-notification",
  [...],
  "targeting": {
    "exclude": [
      {
        "pref_true": ["example.unset.pref"],
      },
      {
        "pref_false": ["example.unset.pref"],
      },
    ]
  }
}

Preferences

mail.inappnotifications.bypass-filtering

This preference disables all filtering logic (including if a notification has been shown before and was interacted with), leading to the most severe notification provided by the server being shown. This applies the next time the active notification is updated, so either when the currently shown notification is closed, a new notification could become active or when the application is restarted.

mail.inappnotifications.url

The URL is specified in the branding specific preferences, so its value varies depending on the version of Thunderbird. The value is the URL used to update notifications from the server.

mail.inappnotifications.refreshInterval

The refresh interval controls how often we try to get new notifications from the server. It is a value in seconds. The fetch does not bypass HTTP caching, so this interval might not be well aligned with HTTP caching.