How about some realtime content in your emails? It has been a trend for years now. Think of complex applications like personalized product recommendations. Or simpler: latest blog posts, the nearest store, animated countdowns, live tweets around an event. No matter what, the content will always auto-update itself when the recipients opens her newsletter.
This post demonstrates one way to code your own live tweets widget, ready to include them in your emails to promote events, or your channel.
But first things first: How do live tweets fit into the realtime personalization email trend, and why should you care about it anyway?
The case for realtime content in emails
From a technical perspective, the key characteristic of most realtime content technologies in email marketing is that parts of the message are being sent in dynamic image containers. In such an <img> placeholder, the content – i.e. the served image – remains adaptive even after it has been delivered into the inbox. Upon every email open, the graphics might be newly created and switched automatically by the server, depending on the recipients current context and her recent actions. Such subscriber context could be time of day, device, latest behaviour, or current geo location.
From a creative perspective, realtime content is able to prevent a drop in relevance due to outdated content: products that are out of stock or that have already been bought, nearest stores that are meanwhile miles away, supposedly or latest blog posts or tweets that have already been replaced by new ones. Using animated countdowns, counting down to the end of a flash sale or to the beginning of an event, the technology is equally capable of strengthening the call to action.
As a result, subscribers benefit from a better user experience, and marketers increase their subscriber lifetime values.
Live tweets are a classic, however…
Live tweets are one application of realtime email content. Relevant service providers like Kickdynamic, LiveClicker or Movable Ink, to name a few, offer it out of the box.
I love all these and what they brought to the email ecosysten. It’s all darn easy to use. However, one might find things to tweak. Like limited options to customize tweets. For instance, when I first experimented with live tweets in emails, I missed options to exclude specific tweets by, say, tweet ID, screenname and keywords.
My first thought wasn’t about free riders that misuse the feature just to see their – potentially offensive – message appear in a newsletter. It was more about making sure there is a diversity of voices instead of just one channel filling all tweet slots in the image. Which comes pretty natural, because the organizer posts about an event very often during the promotional phase.
Also, I wasn’t too sure whether the commercial offerings were able to break image caches from email providers.
Code your own live tweets
To have full control, and for the fun of it, I created my own experimental live tweets. The webserver, which I use, interprets Python scripts. So I gave it a try. Please find the resulting code below.
If you reuse it, you may have to replace a few things:
- your tweet query (e.g. “#MyEvent”),
- your Twitter API credentials (https://apps.twitter.com/),
- the server paths to the font files (regular & bold) , which are used to render the tweet information,
- the sever path to the file in which the script keeps a memory of past tweets (“database”),
- the server path where the script should place the rendered tweets images.
I used a cron job to execute the script every few minutes. It does not overwrite the existing latest tweets image, but places one aside with an added timestamp. This prevents the theoretical case of a subscriber seeing a halfway finalized tweets image, while it is being created by the Python script. The new filename also ensures that Google does not use the cached image.
A PHP script, which is also placed below, chooses the latest image and serves it to the client. You will have to replace the path there, too.
As it’s unusual to refer to an image with a .php extension, I put a a server redirect in place in my .htaccess file, which redirects from .gif to .php. The relevant line reads like
redirect 301 /grafiken/livetweets.gif http://www.emailmarketingtipps.de/php_script_name.php
. This way, I can refer to the image by src=”…/grafiken/livetweets.gif” instead of something like “src=”…/php/livetweets.php”.
### MODULES & CONSTANTS ###
###########################
import cgitb
cgitb.enable()
import twitter
import PIL
from PIL import ImageFont
from PIL import Image
from PIL import ImageDraw
import pickle
import datetime
import time
import urllib, cStringIO
import glob
import os
import re
import sys
from subprocess import Popen, PIPE
blacklist_ids = [123L, 345L] # Exclude these tweets by ID
blacklist_screen_names = [] # ["Emarsys", "Emarsys_DE"] # Excude tweets by screen name
blacklist_words_regex = "foobar" # excude tweets by text pattern
tweet_count_query = 100 # maximum number of tweets to query the Twitter api for
tweet_count_max = 5 # maximum number of tweets in a screen shot
tweets_filename = r'mypath/data/twitter_result.pkl' # archive of historic tweets
screen_names_unique = True # whether the tweets image should only include a screen name only once
tweet_query = "#emarsysrevolution OR revolution.emarsys.com -filter:retweets" # the Twitter query
twitter_consumer_key = '..................'
twitter_consumer_secret = '..................'
twitter_token_key = '...................'
twitter_access_token_secret = '...................'
img_width = 540 # the output width of the tweets image
img_mode = "RGB" # the mode of the tweets image (=> fileisze)
line_space = 2 # spacing between tweet text lines in tweets image
margin = (5,5,10,5) # t,r,b,l
color_bg = (255, 255, 255) # background color
color_fg = (0,0,0) # text color normal
color_fg_gray = (101,119,134) # text color date
font_size = 14
retinafy = True # whether to double the tweets image dimensions for retina displays
if retinafy:
font_size *= 2
img_width *= 2
font = ImageFont.truetype("mypath/fonts/arial.ttf", font_size) # regular font
font_bold = ImageFont.truetype("mypath/fonts/arialbd.ttf", font_size) # bold font for screen name
output_path = "mypath/gfx/" # where to store resulting tweet image
file_prefix = "twitter_event_" # filename of a tweets image (followed my timestamp and .png suffix)
### FUNCTIONS ###
#################
def get_tweets(tweet_query, tweet_count_query):
try:
api = twitter.Api(consumer_key = twitter_consumer_key,
consumer_secret = twitter_consumer_secret,
access_token_key = twitter_token_key,
access_token_secret = twitter_access_token_secret,
tweet_mode = "extended")
except:
return -1, []
try:
with open(tweets_filename, 'rb') as f:
result = pickle.load(f)
except IOError:
# Unable to open file? It's probably not there => first time the script is called
result = []
result_new = api.GetSearch(term = tweet_query,
count = tweet_count_query,
result_type="recent")
except:
return -1, []
else:
# File opened => only get new tweets
result_new = api.GetSearch(term = tweet_query,
count = tweet_count_query,
result_type="recent",
since_id = max([status.id for status in result]))
if (len(result_new) > 0):
# if new tweets => append
result = result_new + result
with open(tweets_filename, 'wb') as f:
pickle.dump(result, f)
return len(result_new), result
def check_exclude(
status,
blacklist_ids = [],
blacklist_screen_names = [],
blacklist_words_regex = [],
screen_names_unique = True):
if status.id in blacklist_ids or status.user.screen_name in blacklist_screen_names or re.search(blacklist_words_regex, status.full_text) is not None:
return True
if screen_names_unique:
if status.user.screen_name in screen_names:
return True
return False
def wrap_text(text, width, font):
# https://stackoverflow.com/questions/11159990/write-text-to-image-with-max-width-in-pixels-python-pil
text_lines = []
text_line = []
text = text.replace('\n', ' [br] ')
words = text.split()
for word in words:
if word == '[br]':
text_lines.append(' '.join(text_line))
text_line = []
continue
text_line.append(word)
w, h = font.getsize(' '.join(text_line))
if w > width:
text_line.pop()
text_lines.append(' '.join(text_line))
text_line = [word]
if len(text_line) > 0:
text_lines.append(' '.join(text_line))
return text_lines
def get_profileimg(status, retinafy = True, standard_size = 48):
f = cStringIO.StringIO(urllib.urlopen(status.user.profile_image_url).read())
try:
profileimg = Image.open(f)
except:
profileimg = False
profileimg_width = standard_size
if retinafy:
profileimg_width *= 2
else:
profileimg = profileimg.convert(img_mode)
if retinafy:
profileimg = profileimg.resize((standard_size*2, standard_size*2), Image.ANTIALIAS)
profileimg_width = profileimg.size[0]
f.close()
return profileimg_width, profileimg
def get_image(status):
# profile image
profileimg_width, profileimg = get_profileimg(status, retinafy, 48)
# tweet creation date formatted
created_at = datetime.datetime.strptime(status.created_at, '%a %b %d %H:%M:%S +0000 %Y').strftime("%d.%m.%Y, %H:%M Uhr")
# wrapped tweet text
text = status.full_text
text = wrap_text(text, img_width - margin[1] - margin[3] - profileimg_width, font)
text.insert(0, "@" + status.user.screen_name + ", " + created_at + ":")
# create tweet image
text_height = font.getsize(text[0])[1]*len(text) + len(text)*line_space
img = Image.new(img_mode, (img_width, text_height + margin[0] + margin[2]), color_bg)
draw = ImageDraw.Draw(img)
# draw text
x, y = margin[3] + profileimg_width, margin[0]
for line in text:
if y == margin[0]:
# First line with twitter handle (bold) and date (gray)
screen_name = "@" + status.user.screen_name + " "
draw.text( (x,y), screen_name, color_fg, font=font_bold)
w, h = font_bold.getsize(screen_name)
draw.text( (x + w, y), "- " + created_at, color_fg_gray, font=font)
y = y + font_size+line_space
else:
# Other lines: tweet text
draw.text( (x,y), line, color_fg, font=font)
y = y + font_size+line_space
# draw profile img if possible
if profileimg is not False:
img.paste(profileimg, (0,0+margin[0]))
return img
def combine_images(images):
widths, heights = zip(*(i.size for i in images))
total_height = sum(heights)
max_width = max(widths)
combined_image = Image.new(img_mode, (max_width, total_height))
y_offset = 0
for img in images:
combined_image.paste(img, (0,y_offset))
y_offset += img.size[1]
return(combined_image)
### MAIN ###
############
# ----------------------
# Check for (new) tweets
# ----------------------
new_tweets, result = get_tweets(tweet_query, tweet_count_query)
if new_tweets == 0:
print "No new tweets, nothing to do: exiting..."
raise SystemExit(0)
elif new_tweets < 0:
print "An error occured during tweets check: exiting..."
raise SystemExit(1)
else:
print "Added", new_tweets, "new tweet(s): updating tweets image..."
# -------------------
# Create Tweets image
# -------------------
images = [] # will contain tweet images
screen_names = [] # will contain screen names from included tweets
for status in result:
# skip this tweet?
if check_exclude(status,
blacklist_ids,
blacklist_screen_names,
blacklist_words_regex,
screen_names_unique) is True:
continue
# max number of tweets reached?
if len(images) >= tweet_count_max:
break
# turn tweet status object into tweet image
img = get_image(status)
images.append(img) # add tweet image to list of tweet images
screen_names.append(status.user.screen_name) # add screen name to check against duplicates when unique screen names is set
image = combine_images(images)
# ----------------------------
# Save Tweets image & clean-up
# ----------------------------
# delete old files
m = glob.glob(output_path + "/" + file_prefix + "*.png")
if (len(m) > 2):
m.sort()
for f in m[0:-1]:
os.remove(f)
# save new file: TMP_ + prefix + timestamp + .png
fn = file_prefix + str(int(time.time())) + ".png"
image = image.convert("P") # 8-bit => smaller fileisze
image.save(output_path + "TMP_" + fn, "PNG", optimize = True)
# after saving completed: remove TMP_ prefix and make it available
os.rename(output_path + "TMP_" + fn, output_path + fn)
try:
os.remove(output_path + "TMP_" + fn)
except:
None
print "Saving" + output_path + fn + "..."
// Server right image
$fns = glob("twitter_event_*.png");
sort($fns);
$fn_newest = $fns[sizeof($fns)-1];
$url = "http://emailmarketingtipps.de/images/" . $fn_newest;
if (headers_sent() === false) {
header("Cache-Control: no-store, no-cache, must-revalidate, max-age=0");
header("Cache-Control: post-check=0, pre-check=0", false);
header("Pragma: no-cache");
header("Location: ".$url, true, 302);
die();
}
?>
Here’s the result as of 4th Oct. 2018:
That all being said, the image with the specified width and fonts then looks like this:
And that’s the way it’s meant to be embedded into emails, in order to auto-update itself:
<img src="https://www.emailmarketingtipps.de/gfx/ema_revol_tweets.gif">
(To test it: i) Tweet about #EmarsysRevolution or post a link to revolution.emarsys.com, because that’s what the script catches, ii) wait up to 15 minutes (=current update-interval), iii) then check the image. 🙂 )
How would I be able to code both Python and PHP together and please let me know about the online environment where I can test and create app.
Hi Rhul, you probably need webspace. Ask your favorite webhoster. And make sure PHP and Python is supported. Then you can test.
Thank you so much!
Could you please suggest some web-host or environment service which support PHP and Python?