Tip
Found anything unclear or needy of further explanation? Do send us the feedback at docs@tattler.dev !
Quick start#
This guides you to:
install
tattler
into a virtual environmentrun
tattler_server
- trigger a notification through it, using any of your 3 available options:
via HTTP
or via command line
or via python.
Write your first notification template.
Get faithful live previews as you edit, with
tattler_livepreview
.Write an addressbook plug-in to load contact data of your users.
Write a context plug-in to load common variables for your templates.
Install#
# create and load a virtualenv to install into
mkdir ~/tattler_quickstart
python3 -m venv ~/tattler_quickstart/venv
. ~/tattler_quickstart/venv/bin/activate
# install tattler into it
pip install tattler
Run tattler server#
export TATTLER_MASTER_MODE=production
# if you need to customize your SMTP settings
export TATTLER_SMTP_ADDRESS="127.0.0.1:25"
export TATTLER_SMTP_AUTH="username:password" # you will learn secure configuration later
export TATTLER_SMTP_TLS=yes
# run tattler server on default 127.0.0.1:11503
tattler_server
This causes the following basic scenario:
tattler_server
listens for requests on http://127.0.0.1:11503.tattler_server
delivers notification to the actual recipient (no dry-run!) due to the TATTLER_MASTER_MODE environment variable.tattler_server
uses SMTP server127.0.0.1:25
to deliver email, using STARTTLS and the given authentication credentials.
Don’t worry, you’ll soon learn a simpler, secure way to manage this configuration.
Send a notification via HTTP#
You can do this via plain HTTP request, e.g. using curl
:
# in a new terminal:
# replace ``your@email.com`` with your actual email address
curl -X POST 'http://127.0.0.1:11503/notification/demoscope/demoevent/?mode=production&user=your@email.com'
Here’s what this does:
It contacts tattler_server at its REST endpoint
http://127.0.0.1:11503/notification/
.It asks to send the notification for
demoevent
, which is built into tattler’s distribution.It asks to send it to
your@email.com
.It asks to actually send it to the indicated recipient, with
mode=production
.
In the terminal running tattler_server
you’ll see the notification go out successfully:
INFO:tattler.server.tattler_utils:Sending demoevent:None (evname:language) to #your@email.com@email => [your@email.com], context={'user_id': 'your@email.com', 'user_email': 'your@email.com', 'user_sms': None, 'user_firstname': 'Your', 'user_account_type': None, 'user_language': None, 'correlation_id': 'tattler:b8357483-1115-4e8a-9b5e-4840cbd4a285', 'notification_id': '4840cbd4a285', 'notification_mode': 'production', 'notification_vector': 'email', 'notification_scope': 'demoscope', 'event_name': 'demoevent'} (cid=tattler:b8357483-1115-4e8a-9b5e-4840cbd4a285)
INFO:tattler.server.sendable.vector_sendable:neb59232b-8819-4d2c-93f4-2791cda09f50: Sending 'demoevent'@'email' to: {'your@email.com'}
INFO:tattler.server.tattlersrv_http:Notification sent. [{'id': 'email:fda614e8-61ef-4a00-98b6-6c993e90f1f0', 'vector': 'email', 'resultCode': 0, 'result': 'success', 'detail': 'OK'}]
127.0.0.1 - - [11/Feb/2024 21:08:33] "POST /notification/demoscope/demoevent/?mode=production&user=your@email.com HTTP/1.1" 200 -
… and your mailbox will show the demo notification:
|
Why demo? Why email?#
Now here’s a couple of things which might turn your nose:
- “Why
demoevent
? I thought tattler allowed me to send my own notifications!” That’s right.
demoevent
is a template built into tattler to allow demos. We’ll look into writing your own notifications soon.- “Why
your@email.com
? I thought tattler would look up user information for me!” That’s right. Tattler really shines when it loads your data for you. We’ll look into that in the plug-ins section.
Send a notification via command-line#
An alternative is for you to trigger the notification with a command line tool.
Tattler includes a little utility to easily trigger notifications from the command line:
# load the same virtual environment where you installed tattler server
. ~/tattler_quickstart/venv/bin/activate
# replace ``your@email.com`` with your actual email address
tattler_notify -s '127.0.0.1:11503' -m production your@email.com demoscope demoevent
Done!
This does exactly the same as Send a notification via HTTP, using the same REST API, and actually relying on tattler’s python client SDK which we’ll look into next.
Argument
-s
provides the SMTP address to use to deliver email.Argument
-m
controls what notification mode the client should request, asTATTLER_MASTER_MODE
did for the server.
Send a notification via python#
A third option is for you to trigger the notification from python code.
Tattler includes a little python client library:
from tattler.client.tattler_py import send_notification
# replace ``your@email.com`` with your actual email address
send_notification('demoscope', 'demoevent', 'your@email.com', mode='production', srv_addr='127.0.0.1', srv_port=11503)
Again, this code does the same as shown in Send a notification via HTTP: it contacts
tattler_server
on the same REST API endpoint.
Write your own notification templates#
What actual content should tattler send, for the event we requested? Event templates tell tattler that.
# create a directory to host notification templates and change into it
mkdir -p ~/tattler_quickstart/templates/mywebapp
cd ~/tattler_quickstart/templates/mywebapp
# create a template for an event titled "password changed"
mkdir password_changed
# we want event 'password_changed' to send an email notification
mkdir password_changed/email
cd password_changed/email
# so we need a subject and body
echo 'You successfully changed your password!' > subject.txt
echo 'Hey!\n\nAccount password changed!' > body.txt
Done. Our notification templates directory now looks like this:
tattler_quickstart/
└── templates/
└── mywebapp/ # scope = mywebapp
└── password_changed/ # event = password_changed
└── email/ # vector = email
├── body.txt
└── subject.txt
Find more information about designing templates in the documentation for template designers.
Get live previews while editing#
When editing email templates – especially with HTML branding – you usually want to iterate editing and previews.
Tattler gives you live, hi-fi email previews as you edit with its embedded tattler_livepreview
tool:
tattler_livepreview ./tattler_quickstart/templates/
Simply give it your template base directory as first argument. tattler_livepreview
guides you through some basic
configuration, and then sends you a notification as soon as it detected you modified a template file.
tattler_livepreview
is great because:
It gives you reliable and faithful rendering through email, instead of deceiving browsers.
It delivers you previews through the exact same tattler logic which you’ll use in production, giving you free early testing e.g. for the consistency of your context data sets.
It delivers via real SMTP, giving you a early headstart if your content looks spammy to your email provider – a common case e.g. with Gmail.
Find more information in Testing and live previews.
Tell tattler server where to find event templates#
tattler_server
takes the path holding notification event templates from the TATTLER_TEMPLATE_BASE environment variable.
So let’s restart it with it:
# in the terminal which was running script 'tattler_server':
# Stop the running instance with Ctrl-c
# Re-start the instance with the new path
TATTLER_TEMPLATE_BASE=~/tattler_quickstart/templates TATTLER_MASTER_MODE=production tattler_server
… and tell tattler to send a notification for your new event (replace your email address, as usual):
curl -X POST 'http://127.0.0.1:11503/notification/mywebapp/password_changed/?user=your@email.com&mode=production'
Upon which tattler_server
will confirm:
INFO:tattler.server.tattler_utils:Sending password_changed:None (evname:language) to #your@email.com@email => [your@email.com], context={'user_id': 'your@email.com', 'user_email': 'your@email.com', 'user_sms': None, 'user_firstname': 'user', 'user_account_type': None, 'user_language': None, 'correlation_id': 'tattler:a6c81356-662a-4b1a-a6ca-a0b9dbcbe34e', 'notification_id': 'a0b9dbcbe34e', 'notification_mode': 'production', 'notification_vector': 'email', 'notification_scope': 'mywebapp', 'event_name': 'password_changed'} (cid=tattler:a6c81356-662a-4b1a-a6ca-a0b9dbcbe34e)
INFO:tattler.server.sendable.vector_sendable:n83311a12-f922-400b-8bef-362a84289f9e: Sending 'password_changed'@'email' to: {'your@email.com'}
INFO:tattler.server.tattlersrv_http:Notification sent. [{'id': 'email:56985c3c-b5c9-4c87-8329-75c2335d28de', 'vector': 'email', 'resultCode': 0, 'result': 'success', 'detail': 'OK'}]
127.0.0.1 - - [11/Feb/2024 21:33:55] "POST /notification/mywebapp/password_changed/?user=your@email.com&mode=production HTTP/1.1" 200 -
Then go check your mailbox to confirm that you received the notification with the content you put into your template.
Hint
Exploit the demo template!
The demo template you used earlier is your friend. It includes tips on writing effective, portable HTML emails – and most of all it includes some code snippets which you can simply copy and paste to write your own templates.
Find the source code of the demo templates in tattler’s repository.
Organize your configuration#
Tattler uses environment variables for configuration as they are highly flexible and avoid overbloating code with parsers.
envdir comes to your rescue to organize configuration cleanly, maintainably and securely. Envdir loads environment variables from a directory that holds one file per configuration key, whose content is the configuration value.
Here’s how you setup your configuration using envdir
:
# create the directory holding your configuration, and change into it
mkdir -p ~/tattler_quickstart/etc
cd ~/tattler_quickstart/etc
# create file "TATTLER_MASTER_MODE" to hold content "staging",
# to have tattler copy notifications to the actual recipient and to NOTIF_DEBUG_RECIPIENT_EMAIL
echo "staging" > TATTLER_MASTER_MODE
# add more non-sensitive configuration values
echo support@myorganization.org > TATTLER_EMAIL_SENDER
echo "127.0.0.1:11503" > TATTLER_LISTEN_ADDRESS
echo ~/tattler_quickstart/templates > TATTLER_TEMPLATE_BASE
echo "your_own_email@company.com" > NOTIF_DEBUG_RECIPIENT_EMAIL
echo "127.0.0.1:25" > TATTLER_SMTP_ADDRESS
echo yes > TATTLER_SMTP_TLS
# And here is how to add sensitive configuration values:
# 1. create a file and restrict access to it
touch TATTLER_SMTP_AUTH
chown tattler TATTLER_SMTP_AUTH
chmod 0400 TATTLER_SMTP_AUTH
# 2. add content using an editor, so it does not get stored into your shell history
vim TATTLER_SMTP_AUTH
And finally start tattler having envdir
loads the configuration above:
# load the virtualenv where you installed tattler
. ~/tattler_quickstart/venv/bin/activate
# have envdir load the configuration into an environment where tattler is started
envdir ~/tattler_quickstart/etc tattler_server &
Refer to available configuration options and documentation for sysadmins for further information.
Write an addressbook plug-in#
An addressbook plug-in enables tattler to automatically retrieve contact data about your users.
Once you have this, your applications no longer need to provide contact data to notify users.
They simply tell tattler “notify user #123 about event X”, and tattler figures out who is 123
,
what vectors it provided (email address, phone number), what are the concrete addresses, etc.
Start by creating a directory to hold your plugin(s):
# create directory to hold tattler plug-ins
mkdir -p ~/tattler_quickstart/plugins
cd ~/tattler_quickstart/plugins
# start editing your addressbook plug-in
vim myaddressbook_tattler_plugin.py
You may fill your plugin file myaddressbook_tattler_plugin.py
starting from the
sample addressbook plugin
in tattler’s repository. Just replace the names of your tables and fields to your own schema:
"""Example Addressbook plug-in for tattler accessing SQL database"""
import os
import logging
from typing import Mapping, Optional, Tuple
from sqlalchemy import create_engine, MetaData, Table, select
from sqlalchemy.engine import Engine
from tattler.server.pluginloader import AddressbookPlugin
logging.basicConfig(level=os.getenv('LOG_LEVEL', 'debug').lower())
log = logging.getLogger(__name__)
class SQLAddressbookPlugin(AddressbookPlugin):
"""Sample plug-in to extract recipient information from a SQL database.
This plug-in does the following:
1. it connects to a SQL database using a connection URI passed via environment variable ``DATABASE``.
2. it loads 2 tables from it: ``auth_user`` and ``userprofile``.
3. during lookups, it extracts columns "email", "first_name", "mobile_number" and "telegram_id" after a natural join of the tables.
Set an environment variable 'DATABASE' to point this code to connect to your database,
for example:
- sqlite: ``DATABASE=sqlite://///Users/foo/tattler/sqlplugins.db``
- postgresql: ``DATABASE=postgresql://username:password@192.168.12.15:6432/dbname``
- mysql: ``DATABASE=mysql+pymysql://user:pass@some_mariadb/dbname?charset=utf8mb4``
- mariadb: ``DATABASE=mariadb+pymysql://user:pass@some_mariadb/dbname?charset=utf8mb4``
also make sure you install a driver for SQLAlchemy to access your database:
- sqlite: driver is embedded in python
- postgresql: ``pip install psycopg``
- mysql: ``pip install PyMySQL``
- mariadb: ``pip install PyMySQL``
See `SQLAlchemy's documentation <https://docs.sqlalchemy.org/en/20/dialects/mysql.html#dialect-mysql>`_
for more information on database drivers and connection URIs:
"""
def _connect_db(self) -> Tuple[Engine, Mapping[str, Table]]:
"""Connect to the database and load the required tables.
Relies on environment variable ``DATABASE`` as a database connection URI, e.g. 'postgresql://user:pass@host:port/dbname'.
:return: tuple of SQLAlchemy engine and tables map.
"""
db_path = os.getenv('DATABASE')
if not db_path:
log.error("Unable to look up contacts: DATABASE envvar is not set or empty. Set to a database connection URI, e.g. 'postgresql://user:pass@host:port/dbname'.")
raise RuntimeError("Unable to look up contacts: DATABASE envvar is not set or empty. Set to a database connection URI, e.g. 'postgresql://user:pass@host:port/dbname'.")
dbengine: Engine = create_engine(db_path, echo=False, future=True)
metadata_obj = MetaData()
tables = {}
# load SQL tables 'auth_user' and 'userprofile'
tables['user'] = Table("auth_user", metadata_obj, autoload_with=dbengine)
tables['profile'] = Table("userprofile", metadata_obj, autoload_with=dbengine)
return dbengine, tables
def _get_recipient_data(self, recipient_id: str, role: Optional[str]=None) -> Mapping[str, str]:
"""Query database to return all known data about the recipient.
Put the actual database lookup here. Do not stress about performance:
premature optimization is the root of all evil!
"""
res = super().attributes(recipient_id, role)
recipient_id = '1'
dbconn, tabs = self._connect_db()
# select data by joining 'user' and 'profile' tables on id = user_id, then extracting columns "email", "first_name", "mobile_number", "telegram_id"
qr = select(tabs['user'].c.email, tabs['profile'].c["first_name", "mobile_number", "telegram_id"]).join_from(tabs['user'], tabs['profile'], tabs['user'].c.id == tabs['profile'].c.user_id).where(tabs['user'].c.id==int(recipient_id))
with dbconn.connect() as conn:
res = conn.execute(qr).one_or_none()
if res is None:
return {}
return {
'email': res.email,
'first_name': res.first_name or None,
'mobile': res.mobile_number or None,
'telegram': res.telegram_id or None,
}
def attributes(self, recipient_id: str, role: str | None = None) -> Mapping[str, str | None]:
"""Return all known properties for user in one go"""
data = self._get_recipient_data(recipient_id, role)
if 'mobile' in data:
data['sms'] = data['mobile']
data['whatsapp'] = data['mobile']
return data
Now enable tattler to load the plugin:
# load the virtualenv where you installed tattler
cd ~/tattler_quickstart
. venv/bin/activate
# install the dependencies required by your plug-in. In this case, let's assume
# sqlalchemy
pip install sqlalchemy
# use the sample database from our codebase to test this
curl -O https://raw.githubusercontent.com/tattler-community/tattler-community/main/plugins/sqlplugins.sql
sqlite3 sqlplugins.db < sqlplugins.sql
# check out the content of the sample DB. Feel free to replace e.g. your email address
sqlite3 sqlplugins.db 'select * from auth_user join userprofile on auth_user.id = user_id'
# configure tattler to load your new plug-in
echo ~/tattler_quickstart/plugins/ > ~/tattler_quickstart/etc/TATTLER_PLUGIN_PATH
echo sqlite:////$HOME/tattler_quickstart/sqlplugins.db > ~/tattler_quickstart/etc/DATABASE
# you may want to enable debug-level logging when testing new logic
echo debug > ~/tattler_quickstart/etc/LOG_LEVEL
# set email address to have tattler use as sender
echo support@myorganization.org > TATTLER_EMAIL_SENDER
# set tattler in 'staging' mode, i.e. copy every notification to a 'supervisor' address
echo staging > TATTLER_MASTER_MODE
# set email address to have tattler copy every notification to ()
echo notification@myorganization.org > TATTLER_SUPERVISOR_RECIPIENT_EMAIL
# restart tattler_server with the new configuration
envdir ~/tattler_quickstart/etc tattler_server
Have a look at the log output here. You’ll see some message like the following:
...
INFO:tattler.server.pluginloader:Loading plugin SQLAddressbookPlugin (<class 'myaddressbook_tattler_plugin.SQLAddressbookPlugin'>) from module myaddressbook_tattler_plugin
...
and now trigger a notification using a user ID:
curl -X POST 'http://127.0.0.1:11503/notification/demoscope/demoevent/?user=2&mode=production'
… and there you go: tattler delivers the notification to email address your@email.net
as stored in the database:
INFO:tattler.server.pluginloader:Looking up recipient 2 with addressbook plugin #1 'SQLAddressbookPlugin'
# ..
DEBUG:tattler.server.tattler_utils:Contacts for recipient 2 are: {'email': 'your@email.net', 'first_name': 'Michelle', 'mobile': '+1789456321', 'telegram': '5689234578', 'sms': '+1789456321', 'whatsapp': '+1789456321'}
INFO:tattler.server.tattler_utils:Recipient 2 is reachable over 1 vectors of the 1 requested: {'email'}
More details on this in addressbook plug-in documentation.
Write a context plug-in#
A context plug-in enables tattler to automatically retrieve data to make available to all your templates.
Once you have this, your applications no longer need to collect and supply data to expand templates. Instead, tattler autonomously pre-loads the necessary data through your context plug-in.
They are called “context” because they provide additional context to expand templates with, i.e. a collection of additional variables that templates may access.
Create your context plug-in in the same plug-in folder you created previously:
# as created in the example before
cd plugins
# start editing your context plug-in
vim mycontext_tattler_plugin.py
Feel free to fill your plugin file mycontext_tattler_plugin.py
starting from the
sample context plugin
in tattler’s repository. Just replace the names of your tables and fields to your own schema:
"""Example context plug-in for tattler loading data from a SQL database"""
import os
import logging
from typing import Mapping, Tuple, Any
from sqlalchemy import create_engine, MetaData, Table, select
from sqlalchemy.engine import Engine
from tattler.server.pluginloader import ContextPlugin, ContextType
logging.basicConfig(level=os.getenv('LOG_LEVEL', 'debug').lower())
log = logging.getLogger(__name__)
class SQLContextTattlerPlugin(ContextPlugin):
"""Sample plug-in to extract context information from a SQL database to expand templates.
This plug-in does the following:
1. it omits overriding :meth:`processing_required()`, thereby loading at every notification.
2. it loads some columns from 2 tables.
Set an environment variable 'DATABASE' to point this code to connect to your database,
for example:
- sqlite: ``DATABASE=sqlite://///Users/foo/tattler/sqlplugins.db``
- postgresql: ``DATABASE=postgresql://username:password@192.168.12.15:6432/dbname``
- mysql: ``DATABASE=mysql+pymysql://user:pass@some_mariadb/dbname?charset=utf8mb4``
- mariadb: ``DATABASE=mariadb+pymysql://user:pass@some_mariadb/dbname?charset=utf8mb4``
also make sure you install a driver for SQLAlchemy to access your database:
- sqlite: driver is embedded in python
- postgresql: ``pip install psycopg``
- mysql: ``pip install PyMySQL``
- mariadb: ``pip install PyMySQL``
See `SQLAlchemy's documentation <https://docs.sqlalchemy.org/en/20/dialects/mysql.html#dialect-mysql>`_
for more information on database drivers and connection URIs:
"""
def _connect_db(self) -> Tuple[Engine, Mapping[str, Table]]:
"""Connect to the database and load the required tables.
Relies on environment variable ``DATABASE`` as a database connection URI, e.g. 'postgresql://user:pass@host:port/dbname'.
:return: tuple of SQLAlchemy engine and tables map.
"""
db_path = os.getenv('DATABASE')
if not db_path:
log.error("Unable to look up contacts: DATABASE envvar is not set or empty. Set to a database connection URI, e.g. 'postgresql://user:pass@host:port/dbname'.")
raise RuntimeError("Unable to look up contacts: DATABASE envvar is not set or empty. Set to a database connection URI, e.g. 'postgresql://user:pass@host:port/dbname'.")
dbengine: Engine = create_engine(db_path, echo=False, future=True)
metadata_obj = MetaData()
tables = {}
# load SQL tables 'resource' and 'billing'
tables['resource'] = Table("resource", metadata_obj, autoload_with=dbengine)
tables['billing'] = Table("billing", metadata_obj, autoload_with=dbengine)
return dbengine, tables
def _get_resource_consumption(self, user_id: str, dbconn: Engine, tabs: Mapping[str, Table]) -> Mapping[str, Any]:
qr = select(tabs['resource'].c['traffic']).where(tabs['resource'].c.user_id==int(user_id))
with dbconn.connect() as conn:
res = conn.execute(qr).one_or_none()
if res is None:
return {}
return {
'traffic': res.traffic,
}
def _get_invoices(self, user_id: str, dbconn: Engine, tabs: Mapping[str, Table]) -> Mapping[str, Any]:
qr = select(tabs['billing'].c['number', 'paid']).where(tabs['billing'].c.user_id==int(user_id))
with dbconn.connect() as conn:
res = conn.execute(qr)
if res is None:
return {}
return {
'invoice': [[inv.number, bool(inv.paid)] for inv in res],
}
def process(self, context: ContextType) -> ContextType:
"""Run the plug-in to generate new context data.
The latest (previous) context is passed as input to this method, allowing
the method to add, change or remove variables from it.
:param context: The latest context resulting from the previous plug-in, or tattler's native context for the first-running context plug-in.
:return: The generated context to either feed to the template, or to the next context plug-in in the chain.
"""
dbconn, tables = self._connect_db()
user_id = context['user_id']
new_context = {}
# add resource variables to context
new_context |= self._get_resource_consumption(user_id, dbconn, tables)
# add invoice variables to context
new_context |= self._get_invoices(user_id, dbconn, tables)
return context | new_context
How to enable the new plug-in? If you already followed Write an addressbook plug-in , you have already setup what’s needed. If not, go do so 🙂
Then simply reload tattler and you’ll see the new plug-in loaded:
INFO:tattler.server.pluginloader:Loading plugin SQLContextTattlerPlugin (<class 'sqlcontext_tattler_plugin.SQLContextTattlerPlugin'>) from module sqlcontext_tattler_plugin
and now trigger a notification again:
curl -X POST 'http://127.0.0.1:11503/notification/demoscope/demoevent/?user=2&mode=production'
… and observe that tattler loads new context variables:
INFO:tattler.server.pluginloader:Context after plugin SQLContextTattlerPlugin (in 0:00:00.002836): {'user_id': '2', 'user_email': 'your@email.net', 'user_sms': '+1789456321', 'user_firstname': 'Your', 'user_account_type': 'unknown', 'user_language': None, 'correlation_id': 'tattler:af4054ab-fac7-4b3a-ad80-0a9fa4326d36', 'notification_id': '0a9fa4326d36', 'notification_mode': 'debug', 'notification_vector': 'email', 'notification_scope': 'mywebapp', 'event_name': 'password_changed', 'traffic': 1224994602, 'invoice': [['2023123001', True]]}
More details on this in context plug-in documentation.
Done!#
You have gotten to know most of Tattler’s capabilities already 👏🏻
If this journey scratched your itch, consider giving a star to our repo. And if you run a tech blog – would Tattler possibly be a suitable next topic?
If you found friction at any step of the way, do let us know. We take documentation seriously and look forward for feedback!