Building a Tinder-Style Email Triage UI with Hammerspoon

Copied to clipboard! Copies a prompt to paste into ChatGPT, Claude, etc.

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 - e for archive, x for 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:

  1. Hammerspoon UI (~/.hammerspoon/email-triage.lua) - Handles rendering, keyboard input, and state management using hs.canvas for the interface and hs.webview for HTML email bodies.

  2. 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 archive
  • s - 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 body
  • Esc - Close the triage UI

Optimistic UI Updates

The key to feeling fast is not waiting for network calls. When you press a key:

  1. Immediately remove the email from the list
  2. Show the next email
  3. Fire the API call in the background
  4. 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

  1. Optimistic UI is essential - Waiting 200-500ms for each API call makes the experience feel sluggish. Fire-and-forget with background sync feels instant.

  2. Webview + Canvas layering works - You can layer hs.webview on top of hs.canvas elements by using different window levels.

  3. UV scripts are perfect for helpers - No virtualenv management, dependencies declared in the script itself, runs immediately.

  4. 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

PRs welcome! The combination of Hammerspoon’s UI capabilities and Python’s API libraries makes for a powerful personal automation platform.

Copied to clipboard!