EmailMerge sends out tailored emails to a list of recipients using a template. It is similar in function to Microsoft Word's mail merge funcionality.
Requires Python 3. To install requirements, run
pip install -r requirements.txt
EmailMerge uses Python templating to format its emails. For more on that see the Templating section. It also supports a plugin system to perform extra processing on email bodies. For more on the plugins, see the Plugin section.
By default, EmailMerge runs in debug mode. See Debugging
To run EmailMerge, run driver.py
. The arguments driver.py
takes are as follows.
Tag | Description |
---|---|
--help |
Print the help message |
--html |
An HTML version of the email body to send. If no html is provided, the text will be compiled to html. |
--text |
A plaintext version of the email body to send. If no text is provided, the html will be compiled to text. |
--img |
Optional images to include in the body of the email. There can be mulitple images. |
--sent-from |
String to include as the sender of the email. NOT the sender's email address. |
--subject |
Subject of the email. |
--merge-data |
CSV file with the fields to be merged into the email. See Templating for details. |
--sender |
Email address to send from. |
--password |
Password for the email address to send from. |
--smtp-server |
SMTP server for the given email address. Defaults to Gmail. |
--no-debug |
Don't debug. By default, EmailMerge prints out all emails to stdout . To actually send emails, include this flag. |
--plugins |
Optional plugins to perform further modifications to the data. See Plugins for details |
Note that Gmail requires certain security features be disabled for us to connect to their SMTP server using Python. It is recommended either that you re-enable them after using EmailMerge, or that you create a new email account only for use with EmailMerge. You can disable the security features here.
By default, EmailMerge runs in debug mode. This will print all the emails to stdout
. Note that if you're including images in your email they will be base64 encoded in stdout
, so the output will likely be unreadable. To trunucate the output, consider piping EmailMerge through head
:
python driver.py [...] | head -n 50
To actually send emails, include the --no-debug
flag.
EmailMerge takes a CSV file with the merge data to send out emails. It expects one column called email
. Any other columns should match template fields in the HTML and text files. For example, this CSV file would match this HTML file and an equivalent text file.
email,name,favorite_food
yair@fax.com,Yair Fax,steak
ezra@fax.com,Ezra Fax,pizza
neima@fax.com,Neima Fax,hot dogs
<html>
<body>
<p>Hi ${name},</p>
<p>
I'm hosting a dinner party, and we'll be serving ${favorite_food}. Can you
make it?
</p>
</body>
</html>
Note that EmailMerge modifies the CSV headers to make them all lower case and replace spaces with underscores. So Data 1
becomes tag ${data_1}
.
EmailMerge supports including images in your email as attachments and embedded elements. Images are included from the command line using the --img
flag. Note that this flag is optional, images need not be included. It also accepts any number of arguments, so you can include more than one image in your email.
python driver.py [...] --img patio.jpg lawn.jpg
To embed these images in your email, use <img>
tags with src='cid:<img>'
in your HTML file, where <img>
is the filename of the image without the extension. So for the previous example, this is what your HTML would look like:
<html>
<body>
<p>Hi ${name},</p>
<p>
I'm hosting a dinner party, and we'll be serving ${favorite_food}. Can you
make it? <br />
Because of COVID we can't host everyone in the same place, can you pick
where you would rather sit? This is a picture of our patio.
<img src="cid:patio" />
This is a picture of our lawn.
<img src="cid:lawn" />
</p>
</body>
</html>
Note that embedded images should only be included in the HTML file, not the plaintext file. If the email client doesn't support HTML, the images will be included as attachments.
Either --html
or --text
, and optionally both, must be provided. If both are provided, both will be used. If --html
is not specified, the text file will be converted to HTML in the following format. All images will be appended to the end of the body of the email. Every newline character in the text file will be converted to a <br/>
tag in the HTML.
<html>
<body>
<p>
[text body]
<p>
<body>
[<img src="cid:<img>">]
</html>
If --text
is not specified, the HTML file will be compiled down to text. All <img>
tags are removed and <br />
characters are replaced with newlines, as are breaks between <p>
tags. Note that images will still be attached to the email, but won't be included inline.
EmailMerge supports Python plugins to perform further modifications on the merge data before it's formatted for the email. Plugins should go in the plugin folder. As an example, we will write a plugin that transforms the full name into only the first name before formatting the email. We start by creating name.py
in the plugins
folder.
In our plugin file, we define a class called Plugin
:
class Plugin:
EmailMerge expects Plugin
to have a process_row
method, which modifies the row with the text that it wants to be formatted into the template, and filters the image list for images that should be attached to the email. The method takes in a dict
called row
which has all the fields from the CSV file and a list
of dict
s called imgs
, which is the list of images to attach to the email. Each element of imgs
has a field img
, which is the literal bytes of the image, a field tag
which is the filename without the extension, and a field img_str
which is the full filename. If the plugin doesn't need to filter images it can simply return the parameter unchanged.
It also expects an __init__
function that takes argv
as a parameter, and a static get_args
method that also takes argv
as a parameter. For an example that uses this, see the next section. For our example, we can write it as such. Note that this example and the next one are in the plugins directory.
def __init__(self, argv):
pass
@staticmethod
def get_args(argv):
pass
def process_row(self, row, imgs):
row["name"] = row["name"].split(" ")[0]
return row, imgs
In our example, this will replace all full names with only first names, so that the email reads Hi Yair,
instead of Hi Yair Fax,
.
The plugin is invoked as such:
python driver.py --plugins name [...]
Note that plugins can be stacked on top of each other. Plugins run in the order listed, so changes from one plugin may override changes from a previous one. In general it is not recommended to stack plugins that modify the same fields, so that the listing of the plugins can be order agnostic. Note also that the dict
of fields is completely replaced by what the plugin returns, so the plugin is responsible for maintaining any fields it doesn't modify.
Let's do a bit of a more involved example to show why get_args
is necessary. In this example, we are telling the guests in our dinner party where they'll be sitting, but in our CSV file we only have their locations listed as numbers.
email,name,favorite_food,location
yair@fax.com,Yair Fax,steak,1
ezra@fax.com,Ezra Fax,pizza,2
neima@fax.com,Neima Fax,hot dogs,1
We have a separate file locations.csv
, that has the locations corresponding to the numbers, and the names of the images of the places they'll be sitting.
num,location,location_img
1,lawn,lawn.jpg
2,patio,patio.jpg
We also have to update our HTML and text files accordingly:
<html>
<body>
<p>Hi ${name},</p>
<p>
I'm hosting a dinner party, and we'll be serving ${favorite_food}. Can you
make it? <br />
Because of COVID we can't host everyone in the same place, so you're going
to be sitting on the ${location}. <br />
This is a picture of where you'll be sitting.
<img src="cid:${location_img}" />
</p>
</body>
</html>
We then need to write our plugin to replace location
in our main merge data with the actual location string from locations.csv
.
EmailMerge uses the argparse library to parse its arguments, as should the static method get_args
in the plugin. In fact, when the user calls python driver.py -h
, EmailMerge will call get_args
in each plugin and display its argparse
help.
For our example, get_args
needs to take in locations.csv
.
import argparse
@staticmethod
def get_args(argv):
parser = argparse.ArgumentParser(description="Parse the arguments for the picnic plugin.")
parser.add_argument("--locations-file", required=True, action="store", help="file with the location data for the picnic.")
args, _ = parser.parse_known_args(argv)
return args
In our __init__
function we need to parse the arguments and store anything that we'll need for formatting later. In our example that means reading in the CSV file with the location data and storing it.
import csv
def __init__(self, argv):
self.args = self.get_args(argv)
self.locations = {}
with open(self.locations_file, "r") as file:
reader = csv.DictReader(file)
for row in reader:
# Results in {num: {"location": location, "location_img": location_img}}
index = row["num"]
del row["num"]
row["location_img"] = row["location_img"].split(".")[0]
self.locations[index] = row
We then need to write our process_row
function which will actually modify the data. The value in the row as it is is the number of the location, and we need ot replace it with the location string. We also need to filter the list of images to only include the image of the location where this guest will be sitting.
def process_row(self, row, imgs):
index = row["location"]
row["location"] = self.locations[index]["location"]
row["location_img"] = self.locations[index]["location_img"]
return row, [img for img in imgs if img["tag"] == row["location_img"]]
Note that location_img
isn't part of the original data file. This is OK, we wrote our template expecting that our plugin would be called.
And we're done! We invoke this plugin thus:
python driver.py --plugins picnic --locations-file locations.csv [...]
As stated above, we can stack these plugins atop one another:
python driver.py --plugins picnic name --locations-file locations.csv [...]