Projects
-
Building a Cookbook With Python, for Reasons (part 1)
📖 + 🐍 = ?
*Note: This is a longer writeup of the project I presented at PyLadies 2025. If you want the bite-size version, watch it here.
I’m part of a monthly potluck that organizes meetups over email, then meets in person to eat delicious vegan food. (I’m not vegan, but I love any excuse to try new recipes and eat more plants.) Occasionally, potluck members will email around a link to the recipe they used or are planning to use.

Pretty common thing, but I wanted to capture these recipes in a more permanent way than a link to a random blog in a mailing list archive. Link rot is a thing, plus it’s just not very fun to have to search old messages and try to remember when that delicious soup recipe was sent around – was it this year or last?
I had an idea pop into my head at over the summer (I love when these things happen) that I could automatically post recipes to a new blog, when they were posted to our listserv. So then I spent the next two weekends making it happen.
I’ll discuss how I built it in a series of posts (the writeup is far too long for a single post). Today’s post is about the overall stack, as well as the FastAPI backend.
The stack
We need an email address that will serve as the “listener” to notice when new recipes are posted and do something with them. This could be anything but I may as well buy a domain and then I can host the blog there as well. So I went to Cloudflare and bought
brooklandrecipe.party.We also need a backend that can do the “something” when a new email comes in. Based solely on the fact that the first recipe-parsing library I found was written in Python, I chose to use FastAPI, which is a lightweight Python backend. This turned out to be an excellent choice.
We need a way for the email listener to talk to the backend. I found ProxiedMail, which has incoming email webhooks.1 And it’s got a free plan. Fantastic. Now when anyone sends an email to
[email protected]2, we can make a POST request to our new API.And we need a frontend, preferably one that updates with (minimal) intervention from a human. Jekyll with Github Pages is great for this. Posts are built in markdown and upon a successful merge to
mainthe site automatically will build and deploy.Basically, we need this:

Those are all the parts! Let’s see how they fit together.
The email and the backend
As previously stated, I set up
[email protected]3 to post back on receipt of an email. We can inspect the shape of the payload before doing anything, by instead setting the postback destination to a free4 URL on webhook.site. This shows us the shape of the payload, which I have shortened by removing the boring stuff:{ "id": "A48CF945-BD00-0000-00003CC8", "payload": { "Content-Type": "multipart/alternative;boundary=\"000000000000a4a98d063b045239\"", "Date": "Mon, 28 Jul 2025 17:53:20 -0400", "Mime-Version": "1.0", "Subject": "test", "To": "[email protected]", "body-html": "<div dir=\"ltr\">hello</div>\r\n", "body-plain": "hello\r\n", "from": "Rachel Kaufman <my-email>", "recipient": "[email protected]", "stripped-html": "<div dir=\"ltr\">hello</div>\n", "stripped-text": "hello", "subject": "test" }, "attachments": [] }This is going to post to our backend REST API built with FastAPI. FastAPI uses Pydantic to define types under the hood, so we can design our endpoint’s desired input like:
class EmailPayload(BaseModel): Date: str from_email: str = Field(..., alias="from") stripped_text: str = Field(..., alias="stripped-text") recipient: str = Field(..., pattern=pattern)Notice those “alias” fields; this is a cool FastAPI/Pydantic trick to change any string input to valid python. (
stripped-textisn’t a valid name for a variable in Python, even if it’s valid as a JSON key. And I aliasedfromtofrom_emailjust so I had a clearer picture of what that variable represented. Although now I think I should have called itsender….Oh well.)The actual logic is pretty simple. We need to get the text of the email, check if it contains a URL. If it does, we need to check if that URL is for a recipe (and isn’t just a link found in someone’s signature for example). If it is a recipe, we need to scrape the recipe data, create a Markdown file with the recipe data in it, then send that Markdown file to Github in the frontend repo.
Putting it all together it looks like:
@app.post("/my-route") async def parse_message(message: IncomingEmail): message_body = message.payload.stripped_text message_sender = message.payload.from_email.split(" ")[0] recipe_url = contains_url(message_body) #a pretty simple regex that returns the first match if found or None if not if not recipe_url: return {"message": "no url found"} recipe = parse_recipe(recipe_url) #uses the recipe_scrapers library and returns a dict if not recipe: return {"message": f"no recipe found at URL {recipe_url}"} template, filename = generate_template(recipe, message_sender) #creates a blob from the template and dict make_github_call(template, filename) #actually makes quite a few github calls return {"message": "ok"}Let’s look at a few of these methods in more detail. I’ll skip over
contains_urlas it’s pretty boring.parse_recipeis also pretty simple - it just grabs the URL, resolves it, and uses therecipe_scraperslibrary to get the recipe data, such as its title, cook time, ingredients and instructions. Most recipe websites use standardized formats, codified by Schema.org so this library supports a good number of sites (but not all).Once we have our dict of parsed values, we can inject them into a Markdown template for use by Jekyll. Which I’ll discuss at a later date.
-
SO MANY services that claim to have “email webhooks” only have webhooks for message delivery events, which honestly makes sense as it is probably the much more frequent use case. But I just want to make a POST request when a new email comes in. ↩
-
Not the actual email address. ↩
-
Still not the actual email address. ↩
-
Each unique webhook.site URL can respond to 100 post requests. More than that and you’ll need to pay up. ↩
-
-
Blogs I Follow By Women In Tech
This is how I assume all women look when they write their dev blogs. I certainly do.Recently, a reader wrote in1 and said that she enjoyed my blog because it’s hard to find technical blogs not written by men. I counted up all the blogs I subscribe to in my RSS reader and … yeah, there aren’t a ton.
But I do follow a few women in tech whose writing inspires me. Not all of these blogs are purely tech-focused, and not all of them update frequently, but it costs me nothing to keep their subscriptions in my feed reader, and they all are great reads when they do post.
Check them out, and if you know of great blogs by tech women that are not on this list, get in touch.
- Rach Smith’s Digital Garden
- Lea Verou
- Chelsea Troy
- Cassey Lottman
- Syscily - The Culture Coded Dev
- Julia Evans
- Cassidy Williams
- Maggie Appleton
- Daniela Baron
Again, and I cannot stress this enough – if you know of a blog by a woman or nonbinary engineer, product manager, devrel, etc., I would very much like to know about it!
-
A reader wrote in – I cannot describe how unlikely a sentence I thought this would be to write. And yet, here we are. ↩
-
A Handy Shell Script to Publish Jekyll Drafts
The quest to remove friction from posting to this blog continues. In an earlier post, I shared how I used rake to automatically generate a blog template for me and place it in Jekyll’s drafts folder. Now, I realized I’d also like to handle publishing that post with approximately 10% fewer keystrokes.
I’ll share the script first, then explain my motivations and how it works.
#!/bin/bash PS3="Choose a draft to publish: " select FILENAME in _drafts/*; do today=$(date -I) shortfile=$(basename $FILENAME) mv $FILENAME _posts/$today-$shortfile && echo "Successfully moved" || echo "Had a problem" break done exitThat’s it, that’s literally it, but I’m so excited about it.
Jekyll considers a post to be a draft if it is a markdown file in its
_draftsfolder. It considers it to be published if it is in its_postsfolder. I believe the filename in_postsalso needs to contain the date (e.g.2025-11-19-this-is-a-post.md) but I’m not sure if that’s a hard requirement or just a requirement for my setup.So what I have to do when I publish a new post is
mv _drafts/mydraft.md _posts/yyyy-mm-dd-mycoolpost.mdand that is clearly too many keystrokes, right? Now I just have to writerake publish(I created a rake task that just runs this script), choose my file from a list of files, and I’m done.To write this I had to learn about two new-to-me bash concepts, the
selectconstruct andbasename.The
selectconstructselectcan be used to create (super basic) menus. The man page forselect…..is for the wrong thing! But a good writeup on the select construct can be found here. In short,select thing in listwill pop up a menu that you can interact with by choosing the item number, and assign the value to the variable$thing.You can do:
select option in "BLT" "cheesesteak" "pb&j"and you’ll get the following output:1) BLT 2) cheesesteak 3) pb&j $>In the case of my script, the “in” is the contents of the directory
_drafts/*.(Notice the syntax is not
in ls _drafts/*, implying that we’re not just executing a command and passing the results to the select construct. Which is a mystery for another time.)The upshot, however, is that I get a list like:
1) _drafts/ai-is-hard.md 3) _drafts/microblogging.md 5) _drafts/recipe-buddy-part1.md 2) _drafts/efficient-linux-ch-4.md 4) _drafts/rake-publish.md(oooh, a peek behind the curtain!)
selectwill then allow you to choose one of the numbers, and store the value in the variable we defined (here,$FILENAME.)It will continue to loop until it reaches a
breakcommand, so the break is important in this script. But then all we need to do is get today’s date and rename/move the file to its new home.basenameThis is a lil one, but a handy one. Again, let’s go to the man page for this util:
BASENAME - strip directory and suffix from filenamesDoes what it says on the tin. If you have
/home/user/long/path/to/file.mdand you wantfile.md,basename /home/user/long/path/to/file.mdwill get that for you. Note that it removes the extension if and only if the extension is provided as a second argument, and if the provided extension matches that of the file, which is a little quirky. In my case I want to keep the extension, so this works well for my use case.And there you have it, 10 lines of code that took longer to write about than to write, and which will surely save me
hoursminutesseconds of precious time. Huzzah! -
Are There Any Good AI Documentation Apps Out There?

One of the places that I had hoped “AI” could be truly a time-saver is in the realm of creating documentation. There are a number of services out there, including Guidde, Scribe, and Tango, that claim that using their browser extension, you can simply perform a task while the extension records you, and then their software will describe what you did, magically creating a how-to document or video to share with your team.
The promise is amazing, but the reality is .. not so much.
The first limitation of many of these services is that you are limited to actions taken in the browser. If your task requires doing anything outside the browser, you may be out of luck. (I believe Scribe now offers a desktop app, this may be true of others as well.)
Unfortunately, the second limitation of these services is that they are all stupid.


