I Built A Serverless Calendar SMS Service On AWS Because I Refuse To Carry A Smartphone

Last Updated 2025-09-21

Yes, it is as ridiculous as it sounds. If you haven't read my article Dumbphones are getting dumber, that's the less technical primer to this post.

If you don't want to read that, the TL;DR is that there are specific pain points that I've encountered since switching to a dumbphone. One I still haven't sufficiently alleviated yet is: integrating my calendar system into my daily workflow without a smartphone.

The Terraform files, Lambda functions and bash script used to run this project are available on a Github repo. It's still very experimental, so use at your own risk.

Anyways, this blog post outlines one approach I took to solve this issue, and although I'm not using it currently, basically due to the price per SMS charged by AWS, I still think it's worth sharing.

Constraints

Teasing out the problem a little more from just 'smartphone bad, but i miss calendar', I figured the solution to integrating my dumbphone with my calendar system would need the following:

Designing The Initial Architecture

The AWS ecosystem is large, complex, and at times downright confusing. Figuring out what would go at the end of the pipeline was straightforward though, an SMS endpoint screams an SNS topic. Obviously, the main difference to most projects is that my topic would have one subscriber; me!

The design of the pipeline feeding the SNS topic could be more flexible, however. The underlying calendar software would end up guiding this decision.

For a while I've been using my own fork of Lazyorg to manage my calendar from my laptop. It's a cool little TUI that is built with gocui. The events are backed by an SQLite database. To get these events pushed to my dumbphone, I'd need to build a pipeline that uploads the database, convert the events to a message, and place the event in the SNS topic.

Working with a small SQLite database lends itself well to using AWS Lambda. Therefore, I settled on a solution that would: upload the SQLite database file to an S3 bucket, and using S3 event notifications, create EventBridge schedules for each of these upcoming events contained in the database. The schedule would call a second Lambda function 15 minutes before a given event that formats the event as a message, and publishes it to an SNS topic, which my mobile phone was subscribed to.

I thought about potentially offloading some of the more complex transformations and parsing to my local machine before uploading, which would potentially remove the need for an S3 bucket, or a secondary Lambda function. However, I thought the following architecture provided the greatest flexibility to alter the design later, as well as provide advanced control over the message content that would be sent over text.

Overview Of Pipeline

Local cronjob -> S3 -> Processing Lambda -> EventBridge -> Notification Lambda -> SNS Topic -> Dumbphone

Implementing Prototype

With the architecture decided, I moved onto implementation. I setup a cronjob to upload my SQLite database once a week to an S3 bucket. This provided a beneficial secondary purpose, it would serve as a weekly backup of my calendar!

I used Claude Code to whip up a quick prototype Lambda function that would trigger on any upload of a file to complete the following:

  1. Download the .sqlite3 database to the Lambda's /tmp directory
  2. Perform an SQL query to grab upcoming events (within the next 7 days)
  3. Clear any existing EventBridge schedules from previous uploads
  4. Create new EventBridge schedules that triggers a second Lambda function 15 minutes before an event in my calendar

Fighting Dependencies

Something I wasn't quite happy about, however, was how the underlying dependencies of the project led to suboptimal code being produced for the Lambda sections of the pipeline.

One great example of this is with date parsing. Python has a wonderful datetime library (especially compared to Go, which I'm using for other projects), but it felt a little bit awkward integrating the SQL database which stores the date and times as strings with the inbuilt datetime implementation.

My calendar stored everything as UTC strings, so we had to do the following:

# Calculate time window: now to 7 days in the future
now_utc = datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S')
end_date_utc = (datetime.utcnow() + timedelta(days=7)).strftime('%Y-%m-%d %H:%M:%S')

# Query events within the 7-day window
cursor.execute("""
    SELECT id, name, description, location, time, duration
    FROM events
    WHERE time > ? AND time <= ?
    ORDER BY time
""", (now_utc, end_date_utc))

And then for each event, parsing the strings back into Python datetime objects:

if '+' in time_str or 'T' in time_str:
    event_time_utc = datetime.fromisoformat(time_str.replace('+00:00', '')).replace(tzinfo=None)
else:
    event_time_utc = datetime.strptime(time_str, '%Y-%m-%d %H:%M:%S')

It certainly felt like I was fighting the data format rather than working with it. Regardless, we pushed on.

Testing The Prototype

Despite some questionable data conversion, the prototype was largely working outside of the inevitable timezone issues you run into when developing anything calendar adjacent. Regardless, I was receiving my calendar notifications 15 minutes before the events occurred, as expected.

This was great, until I stopped receiving notifications for my events about a day or so later...

To debug, I first went through the logs of the first Lambda function's executions. This wasn't it, they were triggering fine. Next, I checked the EventBridge schedules, these were created and were set to trigger at the right time too! I narrowed down the problem to the final Lambda then, or so I thought...

This was working fine too! I was befuddled; the entire pipeline seems to be working, but I'm not getting any messages?!?!

Then I realised something... I wasn't receiving SMS messages from AWS AT ALL! I set up a separate SNS to SMS topic for the purposes of cost monitoring, and these weren't being pushed to my phone either! This was weird, surely my pipeline wasn't affecting other SNS topics I'd set up, right?

Well, turns out, I'd fallen victim to AWS quotas. AWS has a default spending threshold of $1 per account for SMS. I'm sure this helps prevent a significant amount of spam, but I felt a little frustrated that I wasn't even informed upon reaching the quota that the quota even existed!

The funny thing about this prototype, was that the most difficult thing to do was to request an increase on my quota (taking a number of days to be approved). I felt a little silly filling out the form clearly meant for much larger organisations running sophisticated campaigns and programs, requesting a $15 a month spend, and an intended audience of... myself.

Nevertheless, when the quota increase was approved, we were back in business, and receiving SMS messages again.

Reflections On Prototype

Living with the prototype for a while made me reflect on the architecture and decisions I had made, and how it could be improved.

The primary drawback of the prototype was that the calendar was backed by an SQLite database rather than a more standard iCal based system. This means that the Lambda functions I developed felt a little janky, and the entire system to me felt brittle because of it. Also, being a prototype, I hadn't written any tests (iT wOrKs On mY MaChInE).

Really, these problems could have been avoided if I gave less control of the design of the Lambda functions to Claude Code, and took more time teasing out the detailed instructions that it works best with. Instead, I ended up with giant cyclomatic issues, and a spaghetti code that, while working, wasn't worth going back and fixing (looking at you magic numbers and giga-methods).

Another problem with the prototype was that the build wasn't reproducible, I used the AWS console to set everything up, when I should've used CloudFormation or Terraform.

Finally, the S3 cronjob upload method I relied on to trigger the serverless pipeline wasn't particularly reliable; my machine obviously needs to be on when the cronjob is run, which means that if my laptop lid is closed when the cronjob is set to go off, the pipeline wouldn't be set off at all (until I noticed I'm not receiving SMS messages and manually uploaded them to S3).

Moreover, this architecture, specifically this use of S3, meant that it would only ever support one calendar, as each upload overwrites the previous calendar's events. Why? S3 event notifications trigger on every uploaded file, not on every upload batch, preventing uploading multiple calendars simultaneously.

Building V2

Despite the issues in the prototype, I was happy enough with how it was running.

The trigger to create a V2 of this project was actually building Chronos, my own calendar TUI. I'll be releasing it in the next couple of months. It's backed by a more standard iCal compliant system. Making the switch to this really forced my hand, and allowed me to make some major changes to this notification project.

Architectural Changes

The major design difference compared to the prototype would be with regards to how we used the S3 bucket. In the prototype, an upload would trigger the Lambda using S3 event notifications, but since we wanted to support multiple calendars (with Chronos: multiple .ics files), we would need to automatically trigger the Lambda function in a different way. Since calendar files are pretty small (usually measured in kb), we should be able to trigger a Lambda function with the calendar files as a part of the payload.

I still kept the S3 bucket as a part of the pipeline, however, as I still found it handy to have a second place my calendar is saved to, but, it really isn't needed to process the events, as the Lambda does all of the lifting now.

The final architecture diagram

Another massive change is on the reproducibility of the project in V2. Now, we're using Terraform. This simplified the orchestration of the AWS services, and I really LOVE the declarative approach it uses to spinning up and managing running services. Now I've used Terraform for this project, I don't think I'll be using anything else.

One challenge moving to Terraform did present, ended up being very beneficial. It forced me to use best practices, and to avoid shortcuts. One example of this was the packaging of the libraries used in the Lambda functions. The recurring date time functions that expand iCal events into individual objects isn't a part of the standard datetime library in Python, instead, you would use a separate library or roll your own (RIP prototype recurrence generator). Terraform encouraged me to set up a Lambda Layer rather than just manually package libraries as a part of the Lambda function itself. It did this by making it dead simple to setup and manage a Lambda Layer.

Another key difference is the robustness and stability of the software. While I again used Claude Code to develop the Python Lambda functions, I took the time to control the design and implementation, using an iterative process to break down the complex functions into smaller, composable, and most importantly, testable functions. I used pytest and moto to mock and test the Lambda functions (I'll be honest, I just vibed the tests, outside of making sure it focused on timezone handling). Despite this, we still encountered a couple of bugs... timezones and missing events, a tale as old as time.

Implementation Challenges

One timezone bug (now patched) would occur every time the first Lambda function was triggered. Events that existed in the past (up to 10 hours ago) would immediately be texted to me. Since I'm in UTC+10, it was pretty clear what was going on. Digging a little deeper, it occurred when events had no timezone attached. So, adding a default timezone (Melbourne) seemed to work fine. This is implemented with a conditional check for timezone existence.

Another bug was with regards to EventBridge schedule groups. Rather than iterate through all existing EventBridge events to delete each one individually, I decided it would be more elegant to delete the entire EventBridge group. Only problem was that I wanted to immediately write to this EventBridge group again. The boto function to delete EventBridge groups operates asynchronously, so I had to introduce a manual sleep() that gives enough time for AWS to delete the group before attempting to write to it again, else the Lambda would error out. If anyone wants to measure the length of time this takes to save on Lambda compute, be my guest.

A Little Vent On IAM & LLMs

Why do LLMs seem to have a love for poor security practices, particularly when it comes to creating IAM policies? When building V2, it would create policies with permissions that were widely out of scope to the project, and even those that were necessary, would make the permissions far too broad. Several times I found myself reviewing generated policies that would be applied to all of my buckets, or to all of my EventBridge schedules, despite the fact that Claude Code had basically the entire project and previous secure policies from the prototype in it's existing context window.

Caution Before Running

So... You read the title, you got a little excited, you cloned the repo, modified the Terraform state location variable, added your phone number, and you're ready to run your own personal SMS calendar solution. Well... at least if you're in Australia, maybe think twice before jumping in with terraform apply.

The cost per SMS varies widely across different countries, and over here, it ends up being about $0.04 per text. I use a timeblocking methodology to organise my schedule, so I end up with about 10-15 events per day. Over the course of the month, this adds up to about $30AUD, which is more than I spend on my entire mobile plan.

The other major downside/quirk of this system is the deliverer ID. When you get a message, it's not from 'Sam's iCal', but rather 'UNVERIFIED'. Outside of testing prototype systems, it's expected that you end up registering a sender ID and setting up an origination identity, which can be quite a costly and time consuming process. Perhaps the market for rolling your own private SMS notification system might be a little smaller than I initially thought...

However, if you're a dumbphone user flush with cash, and can mentally map 'UNVERIFIED' to 'My Calendar', then check out the repo!

Conclusion & Future

I had a fair bit of fun getting this project up and running. However, it turned out to be an expensive education. Serverless is cheap, and Terraform is awesome, but AWS has more hidden quotas than you can poke a stick at. Most importantly, I proved that you can solve a $0 problem with a $30/month solution smh...

So, at least for now, I won't be using this system to push my calendar to my dumbphone. Having had some more time to reflect on the problem, I think the actual solution would be to use the inbuilt, barely working web browser on my dumbphone.

Perhaps I could setup a really simple webpage that my dumbphone could access, with simple password authentication provided by HTTP Basic Auth, and then secure it by enforcing a HTTPS connection. This would certainly be more flexible, however, the obvious downside is that there wouldn't be notifications. I might have to use my brain every now and again...