Skip to content

Communication Platform Proposal

  • Author: Rodda John
  • Status: Draft

Overview

Moab will need to send transaction emails and text messages. This MEP outlines the architecture of a communication platform to handle these communications.

Message Templates

A template represents a type of transactional message that is sent by the application.

A template is defined in code (as opposed to e.g. yaml or pkl) as it references models and schemas used elsewhere, as well as has callbacks.

A template exists to centralize and group logic that is related for messages of the same type. Such a centralization also has monitoring and preferences benefits (e.g. all message sending errors of the same type, allowing a user to disable messages of a certain type).

A template governs the two step process necessary to enqueue a message to the message service (described below). The first step is translating the product-aware inputs into JSON for the template engine; the second step is to hit the templating engine to return back the raw data to then enqueue.

The interface between Moab and the template engine should be JSON compatible to allow for an external renderer if necessary. Such a renderer is out of scope here.

For email, the template engine should return HTML, whereas for SMS, raw text is acceptable.

This process (sans message enqueing) is identical to how a PDF renderer would work, as it also takes product aware variables and outputs some result from a templating service.

Template Configuration

A template must allow for specifying the various context pieces that are needed to send a message of this type.

Context is either a raw schema or is an object, which is accompanied by a serialization function.

class Template:
    ...

    context = [
        RawContext(
            name='raw',
            schema=SomeSchema,
        ),
        ObjectContext(
            name='user',
            model=User,
            schema=UserSchema,
            transformer=transform_user,
        ),
    ]

Raw context is used to refer to arbitrary data the sender should pass in.

Object context is used to refer to an object that the sender should pass in, that is then transformed -- using the supplied transformation function -- to the context.

The above example would result in JSON that would look like:

{
    'raw': {
        # Contents of the raw schema
    },
    'user': {
        # Contents of the user schema
    }
}

The template engine can then choose to use this JSON however they want.

The send message callsite for this template may look like

Template.send_message(
    raw=SomeSchema(...),
    user=User.objects.get(id=1),
    ...
)

A template should also have a number of other fields:

Field Description Example
slug A unique identifier of the template signed-up
type What sort of communication method this template supports EMAIL or SMS

Templates should ideally be registered using a registry pattern so as to allow static validation. Alternatively, if mypy supports static evaluation of protocols, that is preferable given there is less code required than in the case of registries.

Idempotency

A template shall require an idempotency_function be defined.

This function shall accept the same inputs that the send message call accepts recipients, all objects and context, as well as the timestamp, and must return a single string representing the idempotency key for this specific send_message invocation.

These idempotency keys will be hashed and stored on the MessageQueue objects; but if a second message is enqueud with the same idempotency keys, it will be dropped.

This could be used to do naive rate limiting (e.g. bucket by hour).

Template Engine

Every template will be required to have an engine parameter, which should point to a class that implements a single method designed to render a template.

A template engine can optionally declare a metadata parameter, which is a pydantic schema against which the template_engine_kwargs parameter will be validated against. This is envisioned to allow passing credentials, a path, or a template.

class TemplateEngine:
    def render(self, data: dict) -> str:
        ...

The first template engine will be jinja, and a a sample implementation could look like the following:

env = Environment(
    loader=PackageLoader("app"),
    autoescape=select_autoescape(["html", "xml"]),
)

class JinjaTemplateEngine:
    def render(self, data: dict) -> str:
        env.get_template("template.html").render(**data)

In the case of multiple recipients of a message, the template engine shall be called per recipient. This is to allow template variables like {{ recipient_name }} to function.

By default, recipient_name shall be provided as a template variable. This list may expand in the future.

Recipients

Obviously, a send_message call requires recipient information.

By default, this is passed in as a list of Recipient objects to send_message.

Each Recipient object either has a user or a manual_recipient sub dictionary, which in turn defines an email and or phone number.

A sample implementation of a Recipient base class:

class ManualRecipient(TypedDict):
    email: str
    phone: str
    name: str

class Recipient(TypedDict):
    user: User | None = None
    manual_recipient: ManualRecipient | None = None

Send Message Call

A send message call should create a Message object that stores relevant metadata about the sending of the object.

A template engine should not be hit synchronously in the send_message call. That should instead be queued and polled for. This is to allow for easy scaling if the template service is external.

Message Enqueing

Once a message has been rendered into html or txt, it now needs to be sent.

This will be accomplished by writing a MessageQueue model in PG that indicates a message is queued to be sent.

Field Description Examples
id PK 1
message_id FK to Message
type Email or SMS EMAIL or SMS
business_id
recipient An email address or phone number
body HTML / TXT stored in a PG TEXT field rodda@trymoab.com
subject TXT stored in a PG TEXT field Hello!
attachments List of CustomerUpload IDs [1, 2, 3]
status pending, sent, failed
created_at
sent_at
idempotency_key Hashed idempotency key
remote_id The ID of the message in Sendgrid or Twilio

These message queues shall be polled for frequently (every second) and then sent. When handling a message queue, there should be a WITH FOR UPDATE lock acquired on the relevant row in PG.

Thought: why couldn't this simply be all stored on the Message?

Logging

Create a separate MessageLog table that is related to both messages and optionally message queues. This should record all incoming webhooks regarding a message but also store failure information as well as store state transition information as relevant on a message.

Appendices

On Transaction Guarantees

Notice the only thing that happens when sending a message is writing models to a database. This means we get transactional guarantees by default -- namely that if the surrounding transaction is rolled back, the message would not get sent as the INSERT would never be commited.

This ought be preserved.

Scheduled Messages

A message queue could very easily have a scheduled_at field which is then honored in the sending.

Digests

In theory, it should be possible to specify a frequency by which a message type would be digested. This could be intercepted at the message queue level. This is a little complex because the templates may have to be regenerated. Think about this more before implementation.

Message Responses

It will likely be important to handle message responses as well as CTAs within such a platform. This will allow Moab to handle more CRM-lite applications, like text conversations.

These should be handled by specifying a callback function on a message template that is called upon any incoming webhook regarding that message. These webhooks should be sanitized into a single MessageResponse datatype.

This response should include the recipient (match it to a user if possible).

Reliable Asynchronous Tasks

How much do we trust Dramatiq?

There are a two instances of custom polling logic here (Message and MessageQueue); perhaps create a generic action queue concept that is then polled for.

This is functionally creating a PG backed message queue -- which has its downsides, but as opt-in could be good.