Python i18n - Localizing Transactional Email Templates
Tags: i18n, python, email, jinja
Published: 2020-12-29 13:28:29

## Leverage Python, Jinja and i18n Ally to define, localize, and preview transactional emails

Applications which support frontend localization usually also require some localization strategies for backend services. A prominent example is the generation of localized transactional emails. This article discusses i18n strategies and workflows for transactional emails processed via Python backends. It relies mainly upon the following resources:

- [Jinja](https://jinja.palletsprojects.com/), a versatile templating language for Python
- Postmark's excellent resource for [transactional email templates](https://github.com/wildbit/postmark-templates)
- [i18n Ally](https://github.com/antfu/i18n-ally), a VS Code extension for localization

If you are interested in reviewing the complete implementation:

<https://github.com/visini/email-templates-i18n>

## Defining Email Messages

For each "type" or kind of email message, inherit from a generic parent class `EmailMessage`, which in turn implements the required attributes and methods (e.g., rendering templates). See the [companion repository](https://github.com/visini/email-templates-i18n) for a possible implementation.

```python
class EmailVerification(EmailMessage):
    def __init__(self, config, locale, variables):
        email_type = "email_verification"
        required = ["cta_url"]
        super().__init__(config, email_type, required, locale, variables)

class PasswordReset(EmailMessage):
    def __init__(self, config, locale, variables):
        email_type = "password_reset"
        required = ["cta_url", "operating_system", "browser_name"]
        super().__init__(config, email_type, required, locale, variables)
```

Instantiate a new message with locales and variables, and retrieve all required attributes for sending localized emails:

```python
message = EmailVerification(config, "en-US", {"foo": "bar"})
# print(message.subject)  # localized subject
# print(message.html)     # localized HTML with inlined CSS
# print(message.txt)      # localized plaintext email
# ...
send_email(recepient, message.subject, message.html, message.txt)
```

## Locales and Templates

All locale strings are stored in JSON format. This ensures flexibility with both localization workflow and should be relatively resilient against changing requirements. Global variables are defined across locales, since they usually do not depend on locale context (e.g., company or product names).

```json
{
  "company_name": "Company Name",
  "product_name": "Product Name",
  "product_website": "https://www.example.com/"
}
```

Locale strings files contain a `global` key and a key for each email message type, for instance `email_verification`. The former contain "localized globals", i.e., strings reusable across various email message types. The latter define all strings of a particular email message type.

```json
{
  "global": {
    "greetings": "Viele Grüsse,",
    "all_rights_reserved": "Alle Rechte vorbehalten."
  },
  "email_verification": {
    "subject": "E-Mail-Adresse bestätigen",
    "thank_you": "Vielen Dank für deine Registrierung!"
  }
}
```

Use blocks to inherit the layout from higher order, more generic templates. In the [companion repository](https://github.com/visini/email-templates-i18n), a three-layer hierarchical inheritance is proposed (barebone layout with basic styling &rightarrow; reusable email layout &rightarrow; specific email message template). Template files interpolate both locale strings and variables with double curly brace notation.

```xml
{% extends "layouts/call_to_action.html" %}
{% block body_top %}
{{localized.thank_you}}
{% endblock %}
{% block body_bottom %}
{{localized.contact_us_for_help}}
{% endblock %}
```

Define a separate template for plain text emails - add dividers to structure the template without any markup. See [Postmark's best practices](https://postmarkapp.com/guides/transactional-email-best-practices#include-well-formatted-plain-text-versions) for more details about how to format plain text emails.

```xml
{% extends "layouts/call_to_action.txt" %}
{% block body_top %}
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
{{localized.thank_you}}
{% endblock %}
{% block body_bottom %}
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
{{localized.contact_us_for_help}}
{% endblock %}
```

Besides in template files, variables can be used for interpolating values in localization files with the same notation. Interpolate...

- global strings &rightarrow; `{{product_name}}`
- locale-specific strings ("localized globals") &rightarrow; `{{localized.support_phone_us}}`
- variables &rightarrow; `{{variables.operating_system}}`

```json
{
  "global": {
    "greetings": "Regards,",
    "team_signature": "Your {{product_name}} Team",
    "support_phone_us": "1-800-000-0000"
  },
  "password_reset": {
    "subject": "Password Reset Request",
    "support_message": "Need help? Call {{localized.support_phone_us}}.",
    "security_information": "This request was received from a {{variables.operating_system}} device."
  }
}
```

It's even possible to use more complex Jinja functionality, such as conditional statements, directly within locale strings.

```json
{
  "discounted_items_cart_abandonment_notification": {
    "subject": "There are {{variables.no_items_in_cart}} items in your shopping cart!",
    "promo_message": "{{'One item has a discount!' if variables.no_items_in_cart_discounted < 2 else variables.no_items_in_cart_discounted + ' items have a discount!'}}"
  }
}
```

A more maintainable approach however is to offload all logic to the respective templates and only use simple variable interpolation in locale string files for convenience.

## Localization Workflow

[i18n Ally](https://github.com/antfu/i18n-ally) is a VS Code extension for localization. It integrates with a variety of frameworks (e.g., Vue.js), but can also be used to speed up localization workflows of a static directory of locale JSON files. It even includes features to collaboratively review and discuss localizations.

![Localize email message strings in context to achieve consistency across locales](https://dev-to-uploads.s3.amazonaws.com/i/c0ab0y10b62vm25sbkly.jpg) 

![Track progress of localization across locales and email messages with i18n Ally](https://dev-to-uploads.s3.amazonaws.com/i/dvbsvo5j7vioulbxvoai.jpg)
 
## Previewing Generated Emails

In the [companion repository](https://github.com/visini/email-templates-i18n), a sample implementation for a thin utility to generate and serve rendered templates is provided.

- Auto-reload upon detected file changes (templates and locale strings)
- Interactively switch between locale, email type, and format (HTML and plain text)
- Responsive view (e.g., Chrome Devtools) allows viewing rendered templates in various scenarios
- Based on Vue.js and FastAPI, extensible and with minimal overhead
- Work in progress

![Interactively preview rendered email templates in all implemented formats and locales](https://dev-to-uploads.s3.amazonaws.com/i/7vdgvq3gzbcl086kpibk.jpg)
 
## Sending Email (AWS SES)

Access the class attributes of the instantiated email message for implementing email sending functionality. For illustration purposes, an example for AWS SES is provided below:

```python
import boto3
from botocore.exceptions import ClientError

from src.messages import EmailVerification

config_path = "./src/data"
lang_path = "./src/data/lang"
templates_path = "./src/templates"

# Message config
locale = "en-US"
variables = {"cta_url": "https://www.example.com/"}
message = EmailVerification(config, locale, variables)

#  Application config
SENDER = "Sender Name <sender@example.com>"
RECIPIENT = "recipient@example.com"
CONFIGURATION_SET = "ConfigSet"
AWS_REGION = "us-west-2"

client = boto3.client("ses", region_name=AWS_REGION)

try:
    response = client.send_email(
        Destination={"ToAddresses": [RECIPIENT] },
        Message={
            "Body": {
                "Html": {"Charset": "UTF-8", "Data": message.html},
                "Text": {"Charset": "UTF-8", "Data": message.txt},
            },
            "Subject": {"Charset": "UTF-8", "Data": message.subject},
        },
        Source=SENDER,
        ConfigurationSetName=CONFIGURATION_SET,
    )

except ClientError as e:
    print(e.response["Error"]["Message"])
else:
    print("Email sent! Message ID:"),
    print(response["MessageId"])
```

## Conclusion

Supporting multiple locales for transactional emails requires some additional considerations for templates and locale strings definition. The proposed approach includes classes and additional tooling to implement i18n transactional emails in Python applications.

I hope you found this article informative for how to approach i18n in Python backends!