Email login codes extraction with n8n
Posted June 12, 2025;
I don’t like opening my emails just to copy a 6-digits login code to access some online account.
So, yesterday, I set up n8n, automated the retrieval of login codes, and now I don’t have to check my emails to get the codes.
Here’s how I did it.
The setup
This part was really straightforward ; the docs are self-sufficient, and I already have a server with debian and an nginx reverse proxy set up.
Just for reference, my docker-compose.yml
:
services:
n8n:
image: docker.n8n.io/n8nio/n8n
container_name: n8n
restart: unless-stopped
environment:
- N8N_HOST=<the URL you'll type to get to n8n>
- N8N_PROTOCOL=https
- NODE_ENV=production
# disable telemetry
- N8N_DIAGNOSTICS_ENABLED=false
- N8N_DIAGNOSTICS_CONFIG_FRONTEND=
- N8N_DIAGNOSTICS_CONFIG_BACKEND=
ports:
- 37021:5678
volumes:
- ./local_files:/home/node/.n8n
Really basic stuff, all explained in the docs. Don’t forget to set up the email variables if you want to e.g. reset your password.
Nodemating (Automating, with nodes)
n8n actually has a community tab full of premade workflows (2400-ish at time of writing), but I wanted to try my hand and make it myself, since it’s not even a really complicated task, and it looked fun. (spoiler: it is!)

The workflow starts as follows:
The trigger, to the left, is an IMAP connection to my inbox, which triggers on new unread email. Processing an email marks it as read, avoiding duplicate execution.
The next two nodes, sort and limit, are not important here ; they sort the emails by date, newest first and take the first 15. Only useful for debugging, since in my experience it loads around 280 emails at once. In production, emails are processed as they come, so these are useless nodes.
As you may or may not know, emails may contain an html and a plain text version, or just one or the other. The HTML one generally has more content, but is much bigger, containing a lot of useless crap like styling.
The if block tests whether there is an HTML version, and:
- if there is, converts it to markdown
- if there isn’t, take the plaintext version
Output: always just the pure text version of the email (markdown formatting is negligible).
The loop part is to execute each item sequentially instead of in parallel, as again, running 280 parallel API calls on each test is kind of, uh, bad. The done end of the loop goes nowhere because I don’t need to do anything after everything has been processed.
The core of the workflow is with the AI blocks:
- The classifier takes in the email body and some headers (subject and from) and automatically classifies the email as either a “login code email” or “something else”.
- If it is a “login code email”, the extracter takes the email and extracts both a “platform name” (Generally the website’s name, or the organisation’s) and the actual login code. There is no restriction on what the shape of a login code is (6 digits, letters, dashes or not, etc.), the LLM will figure it out.
Both of these nodes connect to the same AI model node, which links to a specific model provided by openrouter.
The extracted login code gets sent via ntfy - a simple, open-source pubsub (publication / subscription) service that works like a messaging system. You can publish messages to a named channel from anywhere (like n8n), and subscribe to that channel from multiple devices (phone, PC, etc.).
The final node sends a POST request with the login code to my ntfy channel, which both my phone and PC are listening to.
Receiving the data
As I said, both my phone and my pc are listening for the code being sent.
The actual data being sent is intended to be a notification, and thus has a message and a title.
The title is Login code for <platform>
, and the body is Your login code is <code>
.
Phone
On my phone, the ntfy app handles this automatically, and just shows me a regular notification. Since ntfy apparently doesn’t support copy-to-clipboard actions in the notification, I just copy it by hand, which is fine since notifications are accessible over any apps.
Pc
How I set up login code handling on my pc is the more interesting part of this setup.
First, some context: I use linux (arch (btw) specifically) and awesomewm, a window manager that’s very cool and fully programmable in lua.
This lets me define any script I want and, most importantly, render any sort of UI directly to my existing desktop UI.
This is the code that listens to the ntfy endpoint using the ntfy cli:
local awful = require("awful")
local gears = require("gears")
local naughty = require("naughty")
local M = {}
local NTFY_ENDPOINT = "<censored>"
local process = nil
-- assumes code is last word of message
local function extract_code(message)
return message and (message:match(".*%s(%w+)%s*$") or message:match("^(%w+)%s*$"))
end
local function unescape_json(str)
return str:gsub('\\"', '"'):gsub('\\n', '\n'):gsub('\\t', '\t')
end
local function handle_message(line)
if not line or line == "" then return end
local raw_message = line:match('"message"%s*:%s*"([^"]*)"')
if not raw_message then return end
local message = unescape_json(raw_message)
local code = extract_code(message)
if code then
awesome.emit_signal("security::authentication_code", {
code = code,
message = message,
timestamp = os.time(),
source = "email"
})
end
end
local function handle_error(line)
naughty.notify({
title = "NTFY Error",
text = line,
preset = naughty.config.presets.critical
})
end
local function handle_exit(reason, code)
if code ~= 0 then
naughty.notify({
title = "NTFY Stopped",
text = string.format("Exit: %d (%s)", code, reason),
preset = naughty.config.presets.critical
})
end
process = nil
end
function M.start()
if process then M.stop() end
process = awful.spawn.with_line_callback(
"ntfy subscribe " .. NTFY_ENDPOINT,
{
stdout = handle_message,
stderr = handle_error,
exit = handle_exit
}
)
end
function M.stop()
if process then
awesome.kill(process, 15)
process = nil
end
end
function M.restart()
M.stop()
gears.timer.start_new(1, function()
M.start()
return false
end)
end
M.start()
awesome.connect_signal("exit", M.stop)
return M
Nothing too complicated. It emits an event called security::authentication_code
(if you know of any resource on good awesomewm event naming, do let me know), that the following widget listens to:
local awful = require("awful")
local wibox = require("wibox")
local beautiful = require("beautiful")
local gears = require("gears")
local naughty = require("naughty")
local M = {}
local current_code = nil
local textbox = wibox.widget {
text = "",
align = "center",
valign = "center",
font = beautiful.font or "sans 9",
widget = wibox.widget.textbox,
}
local container = wibox.widget {
{
textbox,
margins = 4,
widget = wibox.container.margin,
},
bg = beautiful.bg_normal or "#222222",
fg = beautiful.fg_normal or "#ffffff",
shape = gears.shape.rounded_rect,
shape_border_width = 1,
shape_border_color = beautiful.border_color or "#444444",
widget = wibox.container.background,
visible = false,
}
local function flash_urgent()
container.bg = beautiful.bg_urgent or "#ff6b6b"
gears.timer.start_new(0.5, function()
container.bg = beautiful.bg_normal or "#222222"
return false
end)
end
local function show_code(code_data)
current_code = code_data
textbox.text = code_data.code
container.visible = true
flash_urgent()
end
local function hide_code()
current_code = nil
textbox.text = ""
container.visible = false
end
local function copy_to_clipboard()
if not current_code then return end
awful.spawn.easy_async_with_shell(
"echo '" .. current_code.code .. "' | xclip -selection clipboard",
function()
naughty.notify({
title = "Code Copied",
text = "Authentication code copied to clipboard",
timeout = 2,
urgency = "low"
})
hide_code()
end
)
end
container:connect_signal("button::press", function(_, _, _, button)
if button == 1 then copy_to_clipboard() end
end)
container:connect_signal("mouse::enter", function()
if current_code then
container.shape_border_color = beautiful.border_focus or "#888888"
end
end)
container:connect_signal("mouse::leave", function()
container.shape_border_color = beautiful.border_color or "#444444"
end)
awesome.connect_signal("security::authentication_code", show_code)
M.widget = container
M.hide = hide_code
M.show = show_code
return M
Which then renders like this when a login code is detected:

Clicking on that widget copies the code to my clipboard, letting me paste it to the website. All I have to do is just wait for it to arrive, which takes around the time for the email to arrive + ~3 seconds.
The UI kind of sucks right now ; the margins are (very) bad, the button flashes red at the start to alert me, which looks a bit harsh, and it could show the platform name to confirm it caught a real code. But those improvements depend on me improving/completing my desktop, which is currently a half-made mess (the trade-off of a fully programmable window manager!).