Building a Tinder-Style Email Triage UI with Hammerspoon
I process hundreds of emails weekly. The constant context-switching between reading, deciding, and acting on each message was killing my productivity. So I built a full-screen email triage interface in Hammerspoon that lets me blaze through my inbox with keyboard shortcuts - like Tinder for email, but with Gmail-style actions.
The Problem
Email clients are designed for reading and composing, not for rapid triage. Every decision requires multiple clicks: read the email, decide what to do, click archive or delete, wait for the animation, move to the next one. For someone processing 50-100 emails in a sitting, this friction adds up.
What I wanted:
- Full-screen focus - no distractions, just the current email
- Single-key actions -
efor archive,xfor delete, no confirmation dialogs - Instant feedback - next email appears immediately, no waiting for API calls
- Smart integrations - create Todoist tasks directly, apply labels to file emails
The Architecture
The system has two components:
-
Hammerspoon UI (
~/.hammerspoon/email-triage.lua) - Handles rendering, keyboard input, and state management usinghs.canvasfor the interface andhs.webviewfor HTML email bodies. -
Python Helper (
~/bin/email-triage) - A UV script that handles Gmail API calls, Todoist integration, and decision logging to SQLite.
βββββββββββββββββββββββββββββββββββββββββββββββ
β Hammerspoon (Lua) β
β βββββββββββββ βββββββββββββ βββββββββββ β
β β Canvas β β Webview β β Keyboardβ β
β β (UI) β β (HTML) β β Events β β
β βββββββββββββ βββββββββββββ βββββββββββ β
ββββββββββββββββββββββ¬βββββββββββββββββββββββββ
β hs.task (subprocess)
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββ
β Python Helper (UV script) β
β βββββββββββββ βββββββββββββ βββββββββββ β
β β Gmail β β Todoist β β SQLite β β
β β API β β API β β Log β β
β βββββββββββββ βββββββββββββ βββββββββββ β
βββββββββββββββββββββββββββββββββββββββββββββββ
Key Features
Gmail-Style Keyboard Shortcuts
The UI responds to single keystrokes:
e- Archive (remove from inbox, mark as read)x- Delete (move to trash)t- Create Todoist task with email link, then archives- Suppress (create “unsubscribe from sender” task, then archive)l- Open label picker to file the email under a labelβ- Undo last action (restores email and reverses Gmail action)β/β- Scroll the email bodyEsc- Close the triage UI
Optimistic UI Updates
The key to feeling fast is not waiting for network calls. When you press a key:
- Immediately remove the email from the list
- Show the next email
- Fire the API call in the background
- If the API fails, queue the action for later sync
local function performAction(action)
-- Optimistically update UI immediately
state.triaged = state.triaged + 1
table.remove(state.emails, state.currentIndex)
render() -- Show next email NOW
-- Fire API call in background (don't wait)
runTask("actionTask", action, {emailId}, function(success)
if not success then
queueOfflineAction(email, action)
end
end)
end
HTML Email Rendering
Plain text rendering wasn’t enough - modern emails are HTML. I use hs.webview layered on top of the canvas to render email bodies with proper dark theme styling:
state.webview = hs.webview.new({...})
state.webview:windowStyle({"borderless", "utility"})
state.webview:level(hs.canvas.windowLevels.overlay + 1)
local htmlTemplate = [[
<!DOCTYPE html>
<html>
<head>
<style>
body {
background: #1a1a1a;
color: #e0e0e0;
font-family: -apple-system, sans-serif;
}
a { color: #5cb3ff; }
</style>
</head>
<body>%s</body>
</html>
]]
state.webview:html(string.format(htmlTemplate, emailBody))
Beautiful Label Picker
Pressing l opens a pastel-colored label picker with keyboard hints. Each label gets a letter (a-z, 0-9), and pressing that letter applies the label AND archives the email in one action:
local PASTEL_COLORS = {
pink = {red = 1.0, green = 0.8, blue = 0.85, alpha = 1},
peach = {red = 1.0, green = 0.85, blue = 0.75, alpha = 1},
mint = {red = 0.75, green = 0.95, blue = 0.8, alpha = 1},
-- ...
}
The label picker shows a multi-column grid with colored badges for quick visual scanning.
Spotify Integration for Motivation
Every 10 emails triaged, the system skips to the next track on Spotify (if it’s already playing) and shows a notification with the song info. It won’t launch Spotify if it’s not running - it only enhances an existing listening session:
local function playMusicForMilestone(triagedCount)
local script = [[
if application "Spotify" is not running then
return "not_running"
end if
tell application "Spotify"
if player state is playing then
next track
return name of current track & " | " & artist of current track
end if
end tell
]]
hs.osascript.applescript(script, function(success, result)
if success and result ~= "not_running" then
hs.notify.new({
title = "π₯ Level " .. math.floor(triagedCount / 10) .. "!",
informativeText = result,
}):send()
end
end)
end
Progress Tracking
A battery-style progress bar fills up as you process emails. Every 10 emails earns a bronze medal, 5 bronze = 1 silver, 2 silver = 1 gold. The medals persist across the session and give a sense of accomplishment.
The Python Helper
The Python helper is a UV script (no virtualenv management needed) that handles all the API work:
#!/Users/laurent/.local/bin/uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = [
# "google-auth>=2.0",
# "google-auth-oauthlib>=1.0",
# "google-api-python-client>=2.0",
# "requests>=2.31",
# ]
# ///
Key features:
- Batch requests for fetching emails (one API call for metadata of 30 emails)
- Decision logging to SQLite for future ML training on which emails I archive vs. create tasks for
- Offline queue - if the network fails, actions are queued and synced later
- Gmail OAuth with automatic token refresh
Lessons Learned
-
Optimistic UI is essential - Waiting 200-500ms for each API call makes the experience feel sluggish. Fire-and-forget with background sync feels instant.
-
Webview + Canvas layering works - You can layer
hs.webviewon top ofhs.canvaselements by using different window levels. -
UV scripts are perfect for helpers - No virtualenv management, dependencies declared in the script itself, runs immediately.
-
Small rewards matter - The Spotify track skip and medal system turn a chore into a game.
Usage
Press Cmd+Shift+E to open the triage UI:
-- In ~/.hammerspoon/init.lua
local emailTriage = require("email-triage")
hs.hotkey.bind({"cmd", "shift"}, "E", emailTriage.toggle)
The first time, you’ll need to set up Gmail OAuth:
~/bin/email-triage setup
Then just start triaging. With single-key actions and instant feedback, processing 50 emails takes about 5 minutes instead of 20.
Open Source
I’ve open-sourced this project at github.com/charignon/email-triage.
Quick Start
# Clone the repo
git clone https://github.com/charignon/email-triage.git
cd email-triage
# Copy files to their destinations
cp email-triage.lua ~/.hammerspoon/
cp email-triage ~/bin/
chmod +x ~/bin/email-triage
# Set up Gmail OAuth (one-time)
~/bin/email-triage setup
# Add Todoist token to keychain
security add-generic-password -s org-todoist -a todoist -w 'YOUR_TODOIST_TOKEN'
# Add to your Hammerspoon init.lua
echo 'local emailTriage = require("email-triage")' >> ~/.hammerspoon/init.lua
echo 'hs.hotkey.bind({"cmd", "shift"}, "E", emailTriage.toggle)' >> ~/.hammerspoon/init.lua
Requirements
- macOS with Hammerspoon
- Python 3.11+ with uv
- Gmail API credentials from Google Cloud Console
- Todoist account (for task creation)
PRs welcome! The combination of Hammerspoon’s UI capabilities and Python’s API libraries makes for a powerful personal automation platform.