Create an email newsletter from an RSS feed with Modal


This is a how-to post showing how to run an email subscribers list for your personal website using Modal webhooks and Gmail API. The application functionality allows a blog maintainer to accept and manage email suscribers who will automatically receive notifications when the maintainer publishes a new post to their RSS feed. This Jekyll static site you’re reading has RSS support built-in, and most other static site generating software does as well.

screenshot of the new email subscriber call-to-action component

Besides the RSS feed, this newsletter solution requires only two other things: a Gmail account and a Modal account. Both are free to setup, so do that before proceeding 🙂.

Once you have those three things, we’re ready to start!

1. Gmail API setup

To set up the application to send emails on my behalf, I followed this Python quickstart guide from the official Google docs. The only non-trivial part was setting up the OAuth app, so I’ll walk you through that in a bit of detail.

Adding a constent screen and Oauth scopes:

After enabling the Gmail API in your Google Cloud project (mine is called thundergolfer-dotcom) the quickstart guide linked above tells you to create an OAuth client ID. This is necessary, but before I could do that I need to create an OAuth app registration, specify the requisite Oauth scopes, and fill-in a form to create an OAuth consent screen. The form is pictured below.

setting up gmail oauth app with appropriate oauth scopes

This is a Yak-shave, as this application won’t ever use the consent form, but Google made me do it so I did it. I don’t pay for Google Workspaces with my personal Google Account so I needed to create an ‘External OAuth’ app. This is fine because Google will put the app in ‘testing mode’ and still allow me to authenticate my own email account. Deploying with an OAuth app in ‘testing mode’ works for stuff like this; I had to do the similar to how it works for my Spotify OAuth app integration.

Some of the form fields are irrelevant but required, such as adding links to your app website and adding a logo. I just linked to my blog, and added a picture of myself 🤷.

Create OAuth Client ID

creating an oauth client ID

After finishing the consent page go back to Menu menu > APIs & Services > Credentials and create an OAuth client ID (pictured above). This will allow you to download a JSON file with personal credentials, including a client_id and client_secret. This file will be used to go through the OAuth flow yourself and acquire a crucial refresh_token granting indefinite access to Gmail sending for your account.

To go through the OAuth flow, take my code (linked above) and run the create_refresh_token_and_test_creds() function locally. The end result of the process is a .json file written to local disk.

python3 -m email_subs.main create-refresh-token

Once you have the .json file containing the refresh_token, copy from it the following fields into a Modal Secret called gmail.

JSON file field 'gmail' Modal Secret key
client_id GMAIL_AUTH_CLIENT_ID
client_secret GMAIL_AUTH_CLIENT_SECRET
refresh_token GMAIL_AUTH_REFRESH_TOKEN

Done! The Modal application will use this populated gmail secret to authenticate with the GMail API.

Put your Google Cloud app ‘in production’

ensure the Google Cloud app is 'in production'

This last step is quick and very important. If you don’t do it, your unexpiring refresh_token will actually expire in 7 days. Go to the OAuth Consent Screen tab and under “Publishing Status” change your app from being in “testing” mode to being “in production”.

Now, Google will then say your app needs verification, but you can ignore this. What matters is that a production app’s refresh tokens will not expire and so the Modal email subscribers app won’t break after seven days.

For background on this crucial step, check out this StackOverflow answer.

2. Modal app setup

While I would prefer to inline all the code for this solution into the blog post, it would be too much, so I will just provide a summary of its structure. The code is heavily documented so as to be beginner friendly, and shouldn’t require modification besides changing some configuration values according to the commented instructions within.

App structure

The subscribers application is comprised of three web endpoint handlers (subscribe, confirm, unsubscribe), a simple SQlite database to store subscribers and notification history, and a cronjob function that checks the RSS feed and sends emails with GMail’s API. The three endpoints initiate state-transitions for the email passed as query parameter:

eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nO1cXFlT28hcdTAwMTZ+z6+gSNV9XG6d3pepmppLXHUwMDAwXHUwMDA3Jlx1MDAwNEhcYlx1MDAxMLgzRVx0S8ZcdTAwMWFcdTAwMGLJkWWWTOW/39NcdTAwMDKs3diOTcjiqlCgpd3qPt9yuo/y77OlpeXkpu8t/7a07F23ncB3Y+dq+YU9funFXHUwMDAzP1xu4Vx1MDAxNE3/XHUwMDFlRMO4nV7ZTZL+4LeXL7M7UDu6uL3LXHUwMDBivFx1MDAwYi9MXHUwMDA2cN3/4O+lpX/Tn3DGd+29XHUwMDFir/45ver4J1x1MDAxN/T4gnW24q34dStJb00vXHUwMDFhdSZcYvz+wMtOXFzDUUK4QFxcKMxcYlx1MDAxNaMzN3CGMYKM1Co7euW7SdfewzQyRmLDcie7nn/eTdKzXGZRUjzphOeB7Vx1MDAwM1x1MDAxZVx1MDAxZFx1MDAxOSRx1PPWoiCKbd+e4/ST9e3MaffO42hcdTAwMTi6o2uS2Fx0XHUwMDA3fSeGociu6/hBsJ/cXHUwMDA0t4PotLvDOPeEt99ydNdvWjo+um9cdTAwMTDBkGd3wdeed0NvYFx1MDAwN5yMjkZ9p+0ndmRcYs6ew/axv+Wmc/N3voXQvWvhfrKymaB3R75k/fE8O5NUa0xcdONZ81nASM7LR3eiMFxyXHUwMDFlXCIlIVxuk9x9/mBcdTAwMWSiJklb7TjBwMtcdTAwMDbWdm2jXHUwMDFjUfmoKlx1MDAwNE3iXWejnYu5la1VJ17bJFvb65/Od/i61ENcdTAwMTYuj6778qK+2dubj/Hxq9X400ky/Hh+wlx1MDAwZVvdzd7qSvFb7r/fiePoatJ2dy+3lYu3P3AuyZH50OHHXHUwMDFir8PJ2r37LZvCYd91bsePSEWoXHUwMDExXGY+ho3OXHUwMDA3ftiDk+EwXGKyY1G7l1xy+bNcXIdLiK1cdTAwMWa9XG5iXHUwMDBig39cdTAwMDdXZeEqsNJcdTAwMTW8aomw4pTUQVaZKlDpU8Mn+Sb4LMzgXHUwMDFkXHUwMDEwXHUwMDE1Z0LCgOpcdTAwMWEgXG6qXHUwMDFhgchcdTAwMDXRRtDcfZNcdTAwMDOx0I+vj74smGxcdTAwMTDBILZjL20ym5ooTPb9zykj4cLRlnPhXHUwMDA3N4XRta2sXHUwMDA2/nmYNlx1MDAwNV324uX8MCQ+6Nboglx1MDAwYt91XHUwMDAzL1x1MDAxZi1cdTAwMDNcdTAwMGa6nI5RNjZt+CpcdTAwMDeOxluTyFhcdTAwMTT7537oXHUwMDA0XHUwMDFmSs9TXHUwMDBis0bNy+aRkeZ5xIxRrKichFBvXHUwMDExve52X1x1MDAwZuX+ZuvkI9vdWvkwlFx1MDAxZLz5dGBQXHUwMDA1eMomXFxhJDWRXHUwMDFjyzKbcI4g9IpcdTAwMDZgXqQwu5G4w6dkWHBpcFx1MDAxNkvz0eE5ieOadtd7bzaP38ur1tp29y3tnG5cdTAwMWZ/a3FU5NBcdTAwMWPwg/WNdmDWjvhqK2yxgzm0u1x1MDAxNb3vXHIv42FXtXZayZ9esPb21d6cRJdrXGbMl/m2XHUwMDE5RbdeT3NmnFSI/p5cbpRWXHUwMDAwXHUwMDBlhidh9HGT/+SZwFxiREpwT3nAaERcdTAwMTXjbIE0YHBcdTAwMTX8tIJ5zTlcdTAwMTbUSD5cdTAwMDXmXHUwMDBigTKV4E5cdTAwMWZ5s+tqqmZR2PHji7w+14vm11x0cUlyXHUwMDFmUK2K5I46OavoXHUwMDFh3JzFKIOJXHUwMDAwSZhcdTAwMWNq17RzeXoqRUBaQpz1er13fP1cdOWGXHJQ01x1MDAxOCNcIjAzVdFcdTAwMTVcdTAwMDRcdTAwMDHVXGKzSLTNLLpAhVpKo+hcdTAwMTRcdTAwMDB8RNHt9C9X6ZXaaFx1MDAwNauuXHUwMDFjXvrDoOe8/pHFXHUwMDExXHUwMDFjkFA6XHUwMDBirlx1MDAwNYmjpqZcdTAwMTGyRkBcZlx1MDAxM0P5xJCtn6Wnro5cdTAwMWFcdTAwMWJEuC6ooL1cdTAwMTVcdTAwMTRcdHFSdM9zxyslVZhW5Vx1MDAxMYBcdTAwMGL5KjV65tx3uoR06uD7On1cdTAwMWOGg+HZoFx1MDAxZPtnjyyRXHUwMDBmaExZXCJcdTAwMGL9rMXbJC6/RFxuxWCkWiFcdLPNIO7Kds1cdTAwMDYkXGJcYpPEMMrhR83qrVx1MDAxNODoXHUwMDE0JXCzhuDNLWeMwlx1MDAwYiMhidBKXHUwMDExuMAuhqgxXGL5eZaKpljKtYaaSKpcdTAwMDXNR95cdTAwMWSlXHUwMDEyhln5cGaDqKJWgbNcdTAwMWK//Vx1MDAxYVI/8svanP22lMVC+sfo979f1F7dXHUwMDFjgOntldDL2qtIYuBcZpK16OLCT+BB92wny1x1MDAwZjRInDh55YeuXHUwMDFmnlx1MDAxN2fwbldlkrWnlJ7aw0GKXGbwRlx1MDAxYXOF4TEwNiSbSVx1MDAxYkJO39o4xFxyKFx1MDAwMsZSXHUwMDE5bsDVykqUeKH7cKd6YlVfnK5cXFx1MDAxZP/Z8b29zaP2/lx1MDAwN9dp6JTgXHUwMDFjLKWQWlx1MDAxMyY1U5VOXHRksFwi8KFgLzEholx1MDAxYbl2qFYt4XQ9xy1cdTAwMGYk9Dh/Lm9X6lx1MDAxOW68pVx1MDAxYctwknJcdTAwMDShXG4jXGKPw3WGhJTiXGJBVMK4aka0kErIXHUwMDFhiuNcYiRbSqyUYIrkcsZcdTAwMTHFScSVMoBcdTAwMGKlIL2H0fvFcFMyXHUwMDFjXHUwMDAwlDDB61x1MDAxOY5KUT48YjhcbiFqt7nmv0o+uymZM8M1xZ/9VFwi7zFcYm58pp/jklx1MDAxNSBcdTAwMTNOqZK220AnNuurkFx04UiCXHUwMDA1wVx1MDAxMlx1MDAwYsmBXHUwMDEw6YxcZjfeXFyVe8W10dbzXHUwMDEwLriinFe7RVx1MDAxMMYgKFxcXHUwMDE4zojBeOEkNz6/XHUwMDFiS3KaXHQ7iFx1MDAwNlx1MDAxYlx1MDAxOETIqzPwpiynMdKaXHUwMDAxZTPQQZCcKstRbTfopaSQkkNAcVxcx3JcdTAwMThcdTAwMTFDrJCCK8SSZJf8Yrn8XGY1b8nD4GHFZd2WPHxpM8lcdMC/XHUwMDEw4Fx1MDAwMX9UklsxQHKAM1x1MDAwNZ1J45flb69cdTAwMDTeg801h7P9XHUwMDAwVsBqSbtcdTAwMDIl4UtcdTAwMTnN2ltcdTAwMWNpTkNPgmhcdTAwMGVsSTVcdTAwMTaUcSVzV92SXHUwMDEzWFx1MDAxYSqpVFxmRlxmmIyRhZtCQ1x1MDAwNPRcdTAwMDdSQYhEa6QqXaJ2tZFcdTAwMGJIXHUwMDEzrU1cdTAwMDU+Z4vmy/FcdTAwMTVcdTAwMWHj+NJIgyikXHUwMDBiMH5aU5FbJb7dplx1MDAwMNNcctmEgvPwj+ZWpPKm0ChONMyBwJTrutVPXHUwMDA0XHUwMDE3MCoopsaA8uFfee+0fIlcdTAwMDVcdTAwMDST5KTWXHUwMDE1atG4llxiQlx1MDAwN3dNttE2bdqr2Fx1MDAxM+DL5vhLz1ZcIu8xXGJuS1x1MDAxZlx1MDAxZlx1MDAwZlx1MDAxY4ZPN1x1MDAwNvxwfXNn7bJ3876B4FxiY1xmjJXkQlx0gjmrsV/AcFx1MDAwNvjPJqNAcVx1MDAxMFxis1HcNMk4U8BxWIIhtCtitNZcdTAwMTNSxSTjVMD4UshcdTAwMWNcdTAwMTfuXHTHXHUwMDBl6ojjXFzfuYhCt8hyWihcdTAwMTA6Q+pcbjKA0EH6qF3lXHUwMDE1XHUwMDEwPjVcZkdcdTAwMTTCorR9lHFcdTAwMWIxSKtFXHUwMDE0Zj5vey7PidBcdTAwMGbGaIRyu9ajKzuX1l/zMVx1MDAwZVx1MDAxMGZcdTAwMGKGeyZcdTAwMDc441x1MDAxZdjNXHUwMDFl31x1MDAwZdTZzkrX9Y/ab1x1MDAwZvtcdTAwMWY6zlOpnpyShMdcdTAwMDCs/ikrXHUwMDAwq1ZPam2QrNnGXHUwMDAxR4HAb1x1MDAxYVx1MDAwNYZd1WNLsCqiflx1MDAxNVA2XHUwMDE2UFx1MDAxMqVA6KyLr1x1MDAwMVxypO9NoKFcdTAwMDI8vlAzVTIvylx1MDAwNdxvQe1cdTAwMWNsbz/d6slcdTAwMDdUp7xPlT5MLcYmqOJcdTAwMTCqMq1Z6aTGXHUwMDE48p8pqjjGZzRPtYqDXHUwMDBijWStUDOOXHUwMDE0tWbJsJJQz31rWIK5XHUwMDExxVLwTO9cdTAwMDVGXHUwMDEy0ES0guxcdTAwMGU+WS5zX9BBuaE4b9VcdTAwMWa1oGOx5Y5fUXkxtl2ne9x9c7hzdXhzvcXfnbjbR0a6c1JJXHUwMDBluYFWc3rHILg+61x1MDAwZbx/4nPv/PrEbJycnu5fXU+ikpCkIFx1MDAwMSiuvmJcdTAwMDBcdTAwMTlcbqJcZvw9a/agqqZagZkxaPp53lx1MDAwNGpcdTAwMTBKmzLnN0Ny+TJcdTAwMDC8iWi1sovh+SmYn1JcbpFbP5lBKV+OaiD+XG7tiT+8XHUwMDBix1x1MDAwZn6//u/NZ9SOajU055JcdTAwMGJcdTAwMWHKXG6NjyQy8DrJXHUwMDE4XHUwMDA1TaJ+rXzmyiGK8ll4urJWPvA4Y1W0qapcbjKKxtdcdTAwMGY04cRgISZJXHUwMDFjbrF+1npcdTAwMTdtXHUwMDFmtm5OW9tvL1x1MDAwZte33uxFb86eXHUwMDBlNEagR4BsgpVcdTAwMDQjXHUwMDBlaajUQlKS3XxX3Vx1MDAwMlx1MDAxZV3CdcZgyXHuJY1bXHUwMDA20lxiXHUwMDE0l9lcblx0m/MvUltrqpJzy1f3+MXEgJ81fJpXXHUwMDExvqLsijFbdDdcdTAwMGI4p1x1MDAwNltcdTAwMWH6dyW/f9xGfiHwb1x1MDAwZv2nXHUwMDFkud7vhDK/XHUwMDAxc0JcdTAwMTRanVx0wtOAdbpcdTAwMWXPXHUwMDA2Xp1cdTAwMWLByu625EKYKV5cdTAwMThcdTAwMTj01902PVxc+SxON5TukPf7vYOV71x1MDAxM7ySYkSN1rT6dlwiZ0gpblx1MDAxNa1sXHUwMDFkvlx1MDAxNXKZ3fkg+Vx1MDAxZLrFXCKXU5HbT1g4cnOViFx1MDAwZmHhyou1wU9cdTAwMDK90/d6Nlx1MDAwNFx1MDAxYt1cXIFHXHUwMDA0pYbkq4ZcdTAwMWWCsFx1MDAxN1x1MDAxY1x1MDAxZr0/Odhcci6Zq9+92j37uNtrf69cdTAwMTBcdTAwMTaIXHUwMDEwwqpcdTAwMTDmXFwg8KmPk9hOXHUwMDA2YSExeFx1MDAwMKlcdTAwMWbnnSDBJWdGPlx1MDAxZYR/SvGdJM1cdTAwMWVbWFx1MDAwM1x0KtJY2VxuXGZuVzhMIYqpMojZXXhcdTAwMDNGiovaXG5pplx1MDAxMFdcdTAwMTJcdTAwMGJqtcq2VFxyRkmRolxuazB+ym4/6Wkqa+iZdpg3XHUwMDE2XHUwMDFlz4lwnLPObFx1MDAxY1JcdTAwMDbmJFx1MDAxY5L19lx1MDAxMfZU7Dq7xrj2/XquKlvH98xstFx1MDAxMEDObFx1MDAwMTU1ym4+z4Lsue5cdTAwMTErjpjhmNqSukLgpSeRXHJoJc19xD3UWnNcdTAwMWPbTzWCs/aeldqd347z5blcdTAwMWaRg+iT2qWbXHUwMDA34T/DZE/uNpXUUGzfLbIvtcDcXGKW/Vx1MDAxZlx1MDAxNkv3m7tcdTAwMWFpo1x1MDAwMeN2jVMoXCJ1JeDmXVNcdTAwMDOJJ7c1eyCgtr5LV+t8XHUwMDE00lYoQMKYrWEnXHUwMDBi3G9+2OWY5tctITJcYqa5MspcdTAwMDddzvi5+65cXE76XHUwMDEyJDYgXHUwMDA2RkhT9DnU6lx1MDAwN2T0hGhty3NzdFMmcuCx2U1cdTAwMGXFNXuKudw+t8KgpeLzMzlj1+NcdTAwMTe5vm1JRtOZaHaml8b2XHUwMDEz+PqlpOskS7HX9vxLb/BXmJqSpTBK/Fx1MDAwZXieXHUwMDA0IDGotU58XHUwMDBlS49TWKeJ+/rsfqzTcV52+v301lx1MDAxMb0tX/re1atcdTAwMWFH0Uk/9v6URyxevVS3vzz78n826r7xIn0= createdconfirmedunsubscribedNULL/subscribe [email protected]/confirm? [email protected] &code=123iop/unsubscribe? [email protected] &code=wer890/confirm? [email protected] &code=123iopState that receivesemail notifications

These endpoints are implemented by a FastAPI app and live in the main.py module.

web_app = FastAPI()

...

@web_app.get("/subscribe")
def subscribe(email: str): ...

@web_app.get("/confirm")
def confirm(email: str, code: str): ...

@web_app.get("/unsubscribe")
def unsubscribe(email: str, code: str): ...

Datastore

As said before, these endpoints store and manipulate state using an SQLite database file that lives on a Modal persistent shared volume (“persistent” means the data remains even if the application stops, and “shared” means it can be access by multiple Modal Functions).

The code for the database and its simple ORM is in datastore.py. The web handler functions use it like so:

conn = datastore.get_db(DB_PATH)
store = datastore.Datastore(
    conn=conn,
    codegen_fn=lambda: str(uuid.uuid4()),
    clock_fn=lambda: datetime.now(timezone.utc),
)

notifications = store.list_notifications()  # Show sent newsletter email notifications
confirmed = store.confirm_sub(email=email, code=code)  # Confirm a subscriber
unsubbed = store.unsub(email=email, code=code)  # Unsubscribe a subscriber

Emailer

Our application needs to be able to send emails to subscribers, and this is implemented in the emailer.py module.

It merely defines an ‘emailer’ interface and a single implementation, GmailSender(EmailSender):

class EmailSender(Protocol):
    def send(self, message: EmailMessage) -> None:
        ...

A FakeEmailer(EmailSender) implementation exists in the tests.

Cronjob function

Finally, we have the cron-scheduled Modal Function which uses the feedparser Python package to download and parse the configured RSS feed.

It then checks the fetched RSS entries for blog posts that haven’t been previously sent to subscribers, creates basic HTML emails using the email_copy.py module, and sends them to subscribers.

3. Signup web component setup

Your readers will not want to hit the Modal web endpoint with curl to do signup. There should be a familiar, friendly web component interface with a simple text box input. That’s what you see at the top of this post. All it does is accept a (valid) email address and pass that to the web endpoint’s /subscribe handler, which will process the subscription and send back a confirmation.

If you’re a frontend afficianado you might be want write this functionality yourself into your website, maybe in React, Vue.js, or Svelte.

As this website is boring old Jekyll, the web component I wrote and provide in the source code is a rudimentary HTML and vanilla Javascript component, with CSS style. The file is called subscribe.html. You should be able to just copy paste this into your site; just update the endpoint URL and maybe tweak the CSS a bit 👍.

Testing, and launch

dramatization of launching email subscribers by showing Immortan Joe releasing water from his desert tower

I released the functionality on my site recently and now have four, count em, four (4) subscribers! Gmail’s SREs feel trouble in the air whenever I push a new blog post.

Here’s what the last sent email looked like. I published a new post on my website around 5PM on the day, and an hour or so later the Modal cronjob ran and delivered this email to each subscriber.

example sent email

If you set this up and need a first subscriber to test things, hit me up on Twitter or Reddit and I’ll be happy to be a testcase!

Want to get blog posts over email?

Enter your email address and get notified when there's a new post!