The right way to email your users

mmic

Michele Mazzucchi

Posted on July 2, 2024

The right way to email your users

When you build a web application you need to email your users for password resets, orders placed and the like.

A trivial task, it seems. Here's a one-liner in django:

from django.core.mail import send_mail

send_mail("Password changed!",
          f"Hey {request.user.first_name}!\n\nYour password changed...",
          "support@yourservice.com",
          [request.user.email],
          fail_silently=False)
Enter fullscreen mode Exit fullscreen mode

Reality kicks in

A one-liner. Problem solved, now moving on.

But soon after, issues arise:

  1. You want to include some special characters like 'πŸ™‚' or 'schΓΆn'. The one-liner no longer works.
  2. You need some conditional text for paying users. Which requires some templating logic.
  3. Some users fail to receive your notifications because their spam filter distrusts your bare-bones emails. Figure out what they need and build it.
  4. You want branded emails with your logo and stationery. You need to build complex MIME/multipart assemblies.
  5. You need to prevent delivery to actual users during development from your dev environment. You need environment-specific logic.

You can surely address each hurdle. Your one-liner grows into 10, 100, 1000 lines of code.

It may grow organically, adapted at every notification-sending point. Or structured - and get you to build your own DIY notification framework.

Notification system

You may build your DIY notification system, experiencing and overcoming a problem after the other.

But why go through the pain if the work is already done?

Tattler solves all the problems above and more. It's a lightweight service you deploy within minutes, and your notification one-liners stay a one-liner:

from tattler.client.tattler_py import send_notification

# trigger notification in Python code
send_notification('website', 'password_changed', request.user.email)
Enter fullscreen mode Exit fullscreen mode

You can call tattler from any language and tech stack -- with one call to its REST API:

# trigger notification via REST API in any language
curl -XPOST http://localhost:11503/notification/website/password_changed?user=foo@bar.com
Enter fullscreen mode Exit fullscreen mode

Notification templates look like this:

Hey {{ user_firstname }}! πŸ‘‹πŸ»

A quick heads up that your password was changed.

If it was you, all good. If it wasn't, reply to this email without delay.

{% if user_account_type == 'premium' %}
P.S.: Thank you for supporting us with your premium account! 🀩
{% endif %}
Enter fullscreen mode Exit fullscreen mode

Deploying tattler

Install Tattler into an own folder ~/tattler_quickstart, with this structure:

~/tattler_quickstart/
β”œβ”€β”€ conf/         # configuration files
β”œβ”€β”€ templates/    # templates for notifications
└── venv/         # python virtual environment holding the code
Enter fullscreen mode Exit fullscreen mode

Here's a terminal session performing the steps in this guide:

Installation

Create this directory structure:

# create the directory structure above
mkdir -p ~/tattler_quickstart
cd ~/tattler_quickstart
mkdir conf templates

# create and load a virtualenv to install into
python3 -m venv ~/tattler_quickstart/venv
. ~/tattler_quickstart/venv/bin/activate

# install tattler into it
pip install tattler
Enter fullscreen mode Exit fullscreen mode

Configuration

Tattler's configuration is organized in a bunch of files (envdir), whose filename is the configuration key and content its value. We'll configure the following:

~/tattler_quickstart/
└── conf/
    β”œβ”€β”€ TATTLER_MASTER_MODE    # actually deliver notifications
    β”œβ”€β”€ TATTLER_SMTP_ADDRESS   # IP:port of the SMTP server
    └── TATTLER_SMTP_AUTH      # username:password SMTP credentials
Enter fullscreen mode Exit fullscreen mode

Let's do it:

cd ~/tattler_quickstart/conf

echo 'production'   > TATTLER_MASTER_MODE

# replace with your SMTP server
echo '127.0.0.1:25' > TATTLER_SMTP_ADDRESS

echo 'username:password'  > TATTLER_SMTP_AUTH
chmod 400 TATTLER_SMTP_AUTH
Enter fullscreen mode Exit fullscreen mode

Running tattler

At this point tattler is ready to run, so let's!

# run this from the terminal where you loaded your virtual environment

envdir ~/tattler_quickstart/conf tattler_server
Enter fullscreen mode Exit fullscreen mode

And tattler will confirm:

INFO:tattler.server.pluginloader:Loading plugin PassThroughAddressbookPlugin (<class 'passthrough_addressbook_tattler_plugin.PassThroughAddressbookPlugin'>) from module passthrough_addressbook_tattler_plugin
INFO:tattler.server.tattlersrv_http:Using templates from /../tattler_quickstart/templates
INFO:tattler.server.tattlersrv_http:==> Meet tattler @ https://tattler.dev . If you like tattler, consider posting about it! ;-)
WARNING:tattler.server.tattlersrv_http:Tattler enterprise now serving at 127.0.0.1:11503
Enter fullscreen mode Exit fullscreen mode

Done! βœ…

Sending a test notification

Ask tattler to send a test notification with the tattler_notify command:

#              [ recipient ]  [ scope ]  [ event ]  [ do send! ]

tattler_notify my@email.com   demoscope  demoevent  -m production
Enter fullscreen mode Exit fullscreen mode

This sends a demo notification embedded in tattler, so you don't need to write your own content. -m production tells tattler to deliver to the actual recipient my@email.com. Without it, tattler will safely operate in development mode and divert any delivery to a development address (configured in TATTLER_DEBUG_RECIPIENT_EMAIL) so real users aren’t accidentally sent emails during development.

Proceed to your mailbox to find the result. If nothing is in, check the logs of tattler_server. They should look like this:

[...]
INFO:tattler.server.sendable.vector_email:SMTP delivery to 127.0.0.1:25 completed successfully.
INFO:tattler.server.tattlersrv_http:Notification sent. [{'id': 'email:b386e396-7ad4-4d50-bc2c-1406bf6a8814', 'vector': 'email', 'resultCode': 0, 'result': 'success', 'detail': 'OK'}]
Enter fullscreen mode Exit fullscreen mode

If they don't, you'll see an error description. Most likely your SMTP server is misconfigured, so check again Configuration and restart tattler_server when fixed.

Next, you'll want to write your own content to notify users.

Templates

Write your own template to define what content to send upon an event like "password_changed".

Templates are folders organized in this directory structure:

~/tattler_quickstart/
└── templates/                  # base directory holding all notification templates
    └── website/                # an arbitrary 'scope' (ignore for now)
        β”œβ”€β”€ password_changed/   # template to send when user changes password
        β”œβ”€β”€ order_placed/       # template to send when user places an order
        └── ...
Enter fullscreen mode Exit fullscreen mode

What does the content of the event template itself look like?

password_changed/         # arbitrary event name for the template
└── email/
    β”œβ”€β”€ body.html         # template to expand for the HTML body
    β”œβ”€β”€ body.txt          # template to expand for the plain text body
    └── subject.txt       # template to expand for the subject
Enter fullscreen mode Exit fullscreen mode

Let's proceed to create the directories for one:

cd ~/tattler_quickstart

mkdir -p templates/password_changed/email
Enter fullscreen mode Exit fullscreen mode

Plain text template

Now, edit file email/body.txt with the content of the plain text email. This is seen by users of webmails or email applications that lack support for HTML emails.

{# file email/body.txt (this is a comment) #}
Hey {{ user_firstname }}! πŸ‘‹πŸ»

A quick heads up that your password was changed.

If it was you, all good. If it wasn't, reply to this email without delay.

{% if user_account_type == 'premium' %}
P.S.: Thank you for supporting us with your premium account! 🀩
{% endif %}
Enter fullscreen mode Exit fullscreen mode

Subject

Next, edit file email/subject.txt with the subject of the email:

Warning: password changed for account {{ user_email }}! ⚠️
Enter fullscreen mode Exit fullscreen mode

Notice that the subject supports both non-ASCII characters and templating too! 😎

HTML template

Then, edit file email/body.html with HTML content. Here's where you implement your company's stationery -- colors, logo, fonts, layout and all.

Make sure to stick to HTML constructs supported by email clients! That's MUCH less than your browser does. See caniemail.com for help.

{# file email/body.html (this is a comment) #}
<html>
  <head><title>Your password was changed!</title></head>
  <body>
    <h1>Dear {{ user_firstname }}! πŸ‘‹πŸ»</h1>

    <p>A quick heads up that <strong>your password was changed</strong>!</p>

    <p>If it was you, all good. If it wasn't, reply to this email without delay.</p>

    {% if user_account_type == 'premium' %}
    <p style="color: darkgray">P.S.: Thank you for supporting us with your premium account! 🀩</p>
    {% endif %}
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

Send your template

Now that you've got your own template, tell tattler to deliver it:

#              [ recipient ]  [ scope ]  [ event ]       [ do send! ]

tattler_notify my@email.com   website   password_changed  -m production
Enter fullscreen mode Exit fullscreen mode

Check your inbox and that's it!

Integration

Now that tattler runs and you provisioned your template, how to have it sent from your code?

Whenever your application wants to notify an event, like "password changed", you can fire it either:

  • From python via tattler's native client API.
  • From any other language via tattler's REST API.

Let's look at some concrete examples.

Python

Tattler is written in python, so python developers can use its native python API:

from tattler.client.tattler_py import send_notification

send_notification('website', 'password_changed', request.user.email)

Enter fullscreen mode Exit fullscreen mode

REST API

This works with any language and tech stack. Simply make a POST request to 127.0.0.1:80

http://localhost:11503/notification/website/password_changed?user=foo@bar.com

[           API base URL          ] [scope] [event name]    [ recipient ]
Enter fullscreen mode Exit fullscreen mode

Notice the following:

  • Host localhost and TCP port 11503
  • Base URL of the API = http://localhost:11503/notification/
  • POST request (not GET)!
  • website is the scope name (see Templates)
  • password_changed is the template name (see Templates)
  • user=foo@bar.com tells tattler whom to send to

Here’s how to leverage this from a number of programming languages.

Go

package main

import (
    "bytes"
    "net/http"
)

func main() {
    _, err := http.NewRequest("POST", "http://localhost:11503/notification/website/password_changed?user=my@email.com", bytes.NewBufferString(""))
    if err != nil {
      panic(err)
    }
}
Enter fullscreen mode Exit fullscreen mode

C

using System.Net.Http;

public class Program
{
    public static void Main(string[] args)
    {
        using var client = new HttpClient();
        var response = await client.PostAsync("http://localhost:11503/notification/website/password_changed?user=my@email.com", null);
    }
}
Enter fullscreen mode Exit fullscreen mode

Java

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;

public class App {
  public static void main(String[] args) {
    HttpClient client = HttpClient.newHttpClient();
    HttpRequest request = HttpRequest.newBuilder()
      .uri(new URI("http://localhost:11503/notification/website/password_changed?user=my@email.com"))
      .header("Content-Type", "application/json")
      .POST(HttpRequest.BodyPublishers.noBody())
      .build();

    HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
  }
}
Enter fullscreen mode Exit fullscreen mode

Swift

import Foundation

let url = URL(string: "http://localhost:11503/notification/website/password_changed?user=my@email.com")!
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = Data()

let task = URLSession.shared.dataTask(with: request) { data, response, error in
    if let httpResponse = response as? HTTPURLResponse {
        print("Response Code: \(httpResponse.statusCode)")
    }
}
task.resume()
Enter fullscreen mode Exit fullscreen mode

Conclusion

That's it. You installed and tested tattler within minutes, and integrated it into your code within a few more minutes.

As your needs grow, tattler smoothly accommodates them without bloating your codebase.

Advanced topics include passing variables to templates, sending SMS, having tattler plug-ins automatically retrieve user addresses or template variables. Find it all in tattler's thorough documentation.

Tattler is proudly open-source, with a vastly liberal license (BSD) allowing commercial use. And if your company's policies require support and maintenance for every dependency, tattler offers them in an enterprise subscription, plus more features like S/MIME, multilingualism and delivery with Telegram and WhatsApp.

πŸ’– πŸ’ͺ πŸ™… 🚩
mmic
Michele Mazzucchi

Posted on July 2, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related

The right way to email your users
notifications The right way to email your users

July 2, 2024