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!)

Screenshot of my n8n workflow that extracts login codes from emails
Screenshot of my n8n workflow that extracts login codes from emails

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:

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:

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:

Screenshot of the lower-right corner of my screen, showing general info (time, battery state) along with my screenshotting tool (flameshot) and a login code in a rounded rectangle.
Screenshot of the lower-right corner of my screen, showing general info (time, battery state) along with my screenshotting tool (flameshot) and a login code in a rounded rectangle.

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!).