Automated emails v2 (2023)¶
Github project: https://github.com/orgs/carpentries/projects/10
The aim of this project is to replace the current system of automated emails, which is based on Redis and Python RQ, with a new system based on decoupled systems. The new system should be more robust, easier to maintain, and allow for more complex scheduling of emails, at the same time allowing to add the new types of emails more easily.
Apart from being decoupled, another important feature of this system is that it fetches the newest representation of objects used in the scheduled email content using AMY API, and creating new actions is simpler and more elegant thanks to the use of Django signals.
Infrastructure¶
At the core of automated emails there are:
- AMY API exposing endpoints for managing scheduled emails,
- AMY API exposing endpoints for details of objects used in emails' context,
- A separate application for accessing the AMY API and sending emails.
At the core of the new system in AMY there are used:
- Django signals for triggering actions,
- a controller for managing scheduled emails (scheduling, rescheduling, updating, cancelling, etc.),
- types defined for improved type-safety,
- management panel for viewing and managing scheduled emails.
How it works¶
Emails can be scheduled by sending a signal with a specific payload. There always is one signal for scheduling the email, but some emails allow for updating or cancelling them, which is done through two other signals.
To help deciding if an email should be sent, updated or cancelled, these emails provide strategies, which implement checks for conditions that should be met for the email to be sent (or updated, or cancelled).
Once the email has been scheduled, it is stored in the database as
ScheduledEmail
record. This record contains all the information
needed to send the email, including the email's content, the time
when it should be sent, and the email's context objects.
The context contains information about the objects that should be used when generating MD and HTML content of the email. It consists of model name and model's primary key, which allows to fetch the object from the API when rendering the email.
Once emails have been scheduled, they can be retrieved by the email worker. This is a Python lambda application that runs every 5 minutes, fetches the emails and sends them. The accurate algorithm is described in another section.
Email worker algorithm¶
After the worker sets up, it fetches all emails that should be sent by now. Then it processes them individually asynchronously:
- Lock the email record to prevent UI work on it.
- Create context objects for email recipient list and email body content.
- Create the email context with actual data from the API.
- Create the email recipient list with actual data from the API.
- Render the email body and subject with Jinja2 and the context.
- Render the Markdown version of the email body (this generates the HTML).
- Send the email.
- Update the email record with the status.
At any step this process can fail and the email will be marked as failed. The worker will pick it up again in the next run.
Implementation of new actions¶
All actions are defined in emails.actions
module. Each action is a class
inheriting from BaseAction
class (for scheduling emails). If a specific action
could allow for updating or cancelling, it should consist of 2 additional classes
inheriting from BaseActionUpdate
and BaseActionCancel
respectively.
Each action must implement the following required methods and fields:
-
inheriting from
BaseAction
:signal
- parameter that contains a value uniquely identifying the action signal, and therefore also the email templateget_scheduled_at()
- method that calculates when the action should be runget_context()
- method that returns the context for the emailget_context_json()
- method that returns the context for the email in JSON format (this is used by the email worker to fetch the context from the API)get_generic_relation_object()
- method that returns the main object for the email (e.g. an event or a person)get_recipients()
- method that returns the list of recipients of the emailget_recipients_context_json()
- method that returns the recipients of the email in JSON format (this is used by the email worker to fetch the recipients from the API)
-
inheriting from
BaseActionUpdate
:- the same fields and methods as in
BaseAction
class
- the same fields and methods as in
-
inheriting from
BaseActionCancel
:- the same fields and methods as in
BaseAction
class, except forget_recipients()
andget_scheduled_at()
methods, which are not needed for the cancelling action.
- the same fields and methods as in
Each base class implements a __call__()
method that in turn uses appropriate
EmailController
method to schedule, update, or cancel the email.
Implementing a new action - checklist¶
- Add new action signal name to
emails.signals.SignalNameEnum
enum. - Define the context TypedDict in
emails.types
module. This should be a dictionary with keys and types of values that will be passed to the email template as context. - Define the kwargs TypedDict in
emails.types
module. This should be a dictionary with keys and types of values that will be passed to the action's constructor (when the signal for email is being sent). - Define the action class in a new module in
emails.actions
package. This class should inherit fromBaseAction
class and implement all required methods. - If the action should allow for updating or cancelling, define additional classes
inheriting from
BaseActionUpdate
andBaseActionCancel
respectively. -
Create receivers as instances of the action classes. Link the receivers to the appropriate signals in
emails.signals
module:receiver = MyAction() signal.connect(receiver)
-
If the action consists of scheduling, updating, and cancelling, create
action_strategy
andrun_action_strategy
functions. Follow examples from other actions.
Using a new action¶
If the action contains a strategy, then using it is quite simple:
run_action_strategy(
action_strategy(object),
request,
object,
)
Strategies may accept other parameters, but the selected strategy and request (as in Django view request) are required.