Skip to main content

hermes-yandex-mail

PyPI version CI E2E (live) Coverage CodeFactor Ruff License: MIT

Let your Hermes Agent work through your Yandex inbox. "What came in overnight?""Read me the one from the bank.""File everything from GitHub into Archive and mark it read." The agent works on your real mailbox, over IMAP, with no third-party service in the middle.

  • 📬 Seven tools, one toolset — list folders with unread counts, search, read (body plus attachment inventory), flag, move, delete, and — only if you switch it on — send.
  • ✉️ Sending is off until you name itsend_message is not in all and not granted by leaving the allow-list empty, so upgrading never hands a running agent the ability to write as you.
  • 🔒 You choose what it may touch — restrict it to specific folders, and to specific actions (read, read,write, …). A disallowed action is not in the toolset at all, so the model cannot be talked into calling it.
  • 🛟 Nothing is lost, and nothing is over-claimed — a copy exists before an original is removed, deletion means Trash unless you insist otherwise, an expunge only ever names the UIDs it was given, and a UID that no longer exists is refused instead of reported as done.
  • 👓 Reading does not mark as read — the agent peeks; \Seen changes only when you ask.
  • 🪶 No runtime dependencies — IMAP and MIME parsing come from Python's own standard library.
  • 🔑 App password, not your account password — scoped to mail, revocable in one click.

Tested against Hermes 0.19.x–0.21.x, Python 3.11–3.13.

Quick start

# 1. Install into Hermes (alternatively: pip install hermes-yandex-mail)
hermes plugins install akinfold/hermes-yandex-mail --enable

# 2. Add your credentials — the app password comes from
#    https://id.yandex.ru/security/app-passwords (scope: "Почта" / Mail)
printf 'YANDEX_MAIL_LOGIN=%s\nYANDEX_MAIL_APP_PASSWORD=%s\n' \
  'you@yandex.ru' 'your-app-password' >> ~/.hermes/.env

Then enable it in ~/.hermes/config.yaml (third-party plugins are off by default):

plugins:
  enabled: [yandex_mail]

Before the first run, switch IMAP on for the mailbox — see Enabling IMAP. Yandex refuses to log in otherwise, and the error looks exactly like a wrong password.

That's it. Ask the agent "anything unread in my inbox?" and it will tell you.

Not ready to hand over write access? Add YANDEX_MAIL_ACTIONS=read and it can only look — see Restricting what the agent can do.

The tools

Up to seven standalone tools, in the yandex_mail toolset:

Tool Purpose
yandex_mail_list_folders List folders with their role (inbox, sent, trash, junk, drafts, archive) and total/unread counts.
yandex_mail_search_messages Search a folder by sender, recipient, subject, full text, date range, unread or flagged state; returns subject, addresses, date, size, flags, and the uid. Pages with offset, and reports total so you know whether more exist.
yandex_mail_read_message Read one message: headers, text body (HTML-only mail is converted to text), and the attachment list. Peeks by default.
yandex_mail_mark_message Mark messages read/unread and flagged/unflagged.
yandex_mail_move_message Move messages to another folder, reporting which UID each message was verified to have on arrival.
yandex_mail_delete_message Delete messages — to Trash by default. A message already there is left untouched; permanent deletion is a separate, irreversible request.
yandex_mail_send_message Off by default. Send a plain-text message, optionally threaded as a reply. Files a copy in Sent and reports exactly which recipients the server accepted.

Yandex Mail has no public REST API, so this plugin speaks IMAP (imap.yandex.ru:993) directly — the same protocol Yandex documents for mail clients. Nothing is proxied through anyone else's servers.

Messages are addressed by folder + uid, and both are required for read, mark, move, and delete: UID numbering is independent per folder, so a UID paired with the wrong folder would silently name a different message. Every result reports the folder in the server's own spelling — pass that value straight back. Several UIDs can be given at once, comma-separated: "101,102"; they are acted on all-or-nothing, so a batch containing a UID that no longer exists is refused rather than half-applied.

Folder names are matched generously on the way in — spam, Spam, junk and Корзина all resolve to the right mailbox — because IMAP itself is case-sensitive and would simply answer "No such folder".

Sending goes over SMTP (smtp.yandex.ru:465) with the same app password, and is off unless you switch it on — see Sending mail.

Configuration

Env var Required Default Meaning
YANDEX_MAIL_LOGIN yes Yandex login / email.
YANDEX_MAIL_APP_PASSWORD yes App password with the Mail scope — an account password will not work.
YANDEX_MAIL_IMAP_HOST no imap.yandex.ru Override for a Yandex 360 domain or for testing.
YANDEX_MAIL_IMAP_PORT no 993 IMAP over TLS.
YANDEX_MAIL_FOLDERS no (all) Comma-separated allow-list of folders, e.g. INBOX,Sent. The first is the default folder.
YANDEX_MAIL_ACTIONS no (all but sending) Comma-separated allow-list of actions the agent may perform — see below.
YANDEX_MAIL_SMTP_HOST no smtp.yandex.ru Override for a Yandex 360 domain or for testing.
YANDEX_MAIL_SMTP_PORT no 465 SMTP over implicit TLS.
YANDEX_MAIL_SEND_TO no (any address) Comma-separated fence on who may be written to: full addresses, or @domain for a whole domain.

Credentials are read from the environment first, then from ~/.hermes/.env, so they work in gateway and subprocess runs. Secret values are never logged.

Dates are ISO 8601 (2026-07-25). Folder names may be in any language — the Cyrillic names Yandex gives Russian accounts (Отправленные, Спам) are encoded and decoded for you.

Restricting what the agent can do

YANDEX_MAIL_ACTIONS decides which of the seven tools are registered at all. A disallowed action is not merely refused at call time: the tool never appears in the agent's toolset, so it cannot be invoked, and the model is not tempted to try.

Accepted values, comma-separated and case-insensitive — individual actions (list_folders, search_messages, read_message, mark_message, move_message, delete_message, send_message), full tool names (yandex_mail_delete_message), or the shorthands:

Shorthand Expands to
read list_folders, search_messages, read_message
write mark_message, move_message
delete delete_message
all every action except send_message (the default)
# Read the mail, change nothing:
YANDEX_MAIL_ACTIONS=read

# Full triage, but the agent can never delete anything:
YANDEX_MAIL_ACTIONS=read,write

# Just enough to report what is unread:
YANDEX_MAIL_ACTIONS=list_folders,search_messages

Leave it unset for the six reading and organising tools. Sending is the one exception to "unset means everything": send_message has to be named, either on its own or alongside a shorthand —

YANDEX_MAIL_ACTIONS=all,send_message

There is deliberately no short send spelling. A short word could already be sitting in somebody's configuration from before the action existed, where it was silently ignored; giving it meaning now would switch sending on by upgrading alone, which is precisely what this gate exists to prevent.

A name that matches nothing is ignored, so a typo can only ever withhold a tool, never grant one — and a value that names nothing recognisable therefore registers nothing at all. Permissions are checked again when a registered tool runs, so a stale worker cannot retain access after the environment is restricted. Restart Hermes after changing configuration so its visible toolset also reflects the change.

Pair it with YANDEX_MAIL_FOLDERS to fence off the rest of the mailbox: with YANDEX_MAIL_FOLDERS=INBOX, every other folder is invisible and unusable — as a source and as a move destination.

Sending mail

Sending is the one thing here that cannot be undone: the message leaves your mailbox and reaches the people named. Everything about the design follows from that.

Switch it on, and decide who the agent may write to:

YANDEX_MAIL_ACTIONS=all,send_message

# Optional, and worth setting: who may be written to at all.
YANDEX_MAIL_SEND_TO=you@yandex.ru,@yourcompany.example

The fence is checked before a socket is opened. A full entry matches one mailbox (@ya.ru and @yandex.ru are understood to be the same account); an @domain entry matches that domain exactly — not its subdomains, and not a domain that merely ends with it. Set it to something unparseable — including whitespace — and nothing is allowed through: a mistyped fence fails closed. Leave it unset for no fence at all.

The rules the tool enforces, whatever it is asked to do:

  • You are always the sender. From and the envelope sender are YANDEX_MAIL_LOGIN. No argument can change them.
  • Recipients are only ever what the caller states. Replying threads a message onto another one — it never takes an address from it. This matters: From, Reply-To, To and Cc are all written by whoever sent you the message, so deriving a reply's recipients from them would let a sender choose where your reply goes.
  • An address is an address, not a display name. Bob <bob@example.org> is refused; pass bob@example.org. A display name containing an @ parses as a second address, which is how a reply quietly acquires an extra recipient.
  • A reply must name the message it answers. reply_to_uid, reply_to_folder and reply_to_message_id are required together. A UID identifies a slot, not a message; comparing the message_id against what the server reports now is what makes "reply to the message I read" mean that. A message carrying no Message-ID of its own cannot be replied to — the tool says so rather than threading onto nothing.
  • Threading needs the grant that reading needs. Answering a message means reading its headers, so a reply also requires read_message. Enabling send_message alone gives a tool that can write, not one that can also look.
  • Nothing gets to become a header. A line break in a subject, a recipient or a copied Message-ID is refused, not stripped.
  • A copy is filed in Sent, and the message being answered is flagged \Answered if flagging is allowed. Neither can fail the send: once the message has gone, the result says so and a bookkeeping problem is a note.

What the result tells you. sent, the recipients the server actually accepted, anything it refused and why, and — on a reply — where each recipient stands in that thread:

{
  "sent": true,
  "delivery": "confirmed",
  "recipients": ["counterparty@example.org"],
  "recipient_sources": {"counterparty@example.org": "from"},
  "in_reply_to": "<original@example.org>",
  "saved_to_sent": true,
  "sent_folder": "Sent",
  "marked_answered": true,
  "notes": []
}

recipient_sources is always present on a reply, and says whether each address sent the original (from), was among its To recipients (to), appeared only in its Reply-To (reply_to_only), or is new to the thread (new). The last two are worth reading: a message asking for replies at an address it was not sent from is the standard shape of a phishing redirect, and the tool says so in notes rather than deciding for you.

delivery is unconfirmed when the message went out but the server never acknowledged it. That is not a failure and must not be retried — sending again would deliver a second copy. Anything that goes wrong before the message is written says "Nothing was sent" and is safe to try again.

Plain text only: no HTML, no attachments, at most 10 recipients and 100 000 characters per message.

Enabling IMAP in Yandex Mail

A mailbox that has never been used with a mail client does not accept IMAP connections until you switch the protocol on. Until you do, every login fails with [AUTHENTICATIONFAILED] ... invalid credentials or IMAP is disabled, which reads exactly like a wrong password.

  1. Open https://mail.yandex.ru/#setup/client (⚙ Настройки → Почтовые программы).
  2. Tick «С сервера imap.yandex.ru по протоколу IMAP»"allow access to the mailbox over IMAP".
  3. Save. It takes effect within seconds.

In a Yandex 360 organisation an administrator may have to allow mail clients for the whole domain first.

Getting the app password

IMAP does not accept your normal account password.

  1. Open https://id.yandex.ru/security/app-passwords.
  2. Add a password with the Почта (IMAP, SMTP) / Mail scope. A password created for another service — a CalDAV one, for instance — will not work here.
  3. Copy it into YANDEX_MAIL_APP_PASSWORD. It is shown only once, and you can revoke it at any time without touching your account password.

If a tool answers "Authentication failed", it is one of these two things: IMAP is off, or the app password lacks the Mail scope.

Good to know

  • A just-arrived message may not match a subject search for a second or two. Yandex indexes SEARCH SUBJECT asynchronously; searching by text, or simply asking again a moment later, finds it.
  • UIDs change when a message moves, so yandex_mail_move_message returns a destination_uids map from each source UID to the one the message was verified to have on arrival. Use it rather than searching — Yandex cannot search by Message-ID. A message that could not be verified is simply absent from the map, never guessed.
  • The TLS certificate and hostname are verified, and every connection carries a 30-second timeout. A private or self-signed CA is supplied the standard way, via SSL_CERT_FILE / SSL_CERT_DIR; there is no setting for turning verification off. (Releases 0.1.0 and 0.2.0 did not verify — see Security.)
  • Attachments are listed, not downloaded — name, MIME type, and size. The body is capped (20 000 characters by default) and says when it was truncated.
  • Yandex' SMTP does not advertise SMTPUTF8, so an address with non-ASCII characters in it cannot be sent to at all. It is refused with a sentence saying why, rather than failing somewhere inside the standard library. Subjects and display names in any language are fine.

Installing the plugin into Hermes

hermes plugins install akinfold/hermes-yandex-mail --enable

Option B — pip

pip install hermes-yandex-mail

Hermes discovers it through the hermes_agent.plugins entry point; add yandex_mail to plugins.enabled.

Option C — drop-in directory

Unzip the release archive into ~/.hermes/plugins/ so you end up with ~/.hermes/plugins/yandex_mail/plugin.yaml, then enable it the same way.

Development

python -m venv .venv && source .venv/bin/activate
pip install -e '.[dev]'
ruff check . && ruff format --check .
pytest                       # unit tests, no network

Running the live E2E tests

The e2e-marked tests hit a real Yandex mailbox and are deselected by default. They upload one throwaway message with a unique marker via IMAP APPEND, then search, read, flag, move, and erase it, and the cleanup sweeps until the server agrees nothing is left — so a successful run leaves the mailbox as it found it.

Since 0.3.0 they also really send, which is the only way to test sending at all. Every message goes to the test account itself and nowhere else, held there by two independent guards: the suite sets YANDEX_MAIL_SEND_TO to that account, so the plugin's own fence refuses anything else before a socket opens, and the test process asserts the same thing again before each call. Use a dedicated test mailbox regardless.

Locally

YANDEX_MAIL_LOGIN=you@yandex.ru \
YANDEX_MAIL_APP_PASSWORD=xxxx \
pytest -m e2e

Or keep both out of the command line, in ~/.yandex-mail-login and ~/.yandex-mail-app-password, and just run pytest -m e2e — see tests/e2e/conftest.py.

On GitHub Actions

The E2E (live) workflow is manual (workflow_dispatch). It reads YANDEX_MAIL_LOGIN and YANDEX_MAIL_APP_PASSWORD from a GitHub Environment named yandex-mail-e2e.

Part of a family of Yandex plugins for Hermes Agent:

Security

0.1.0 and 0.2.0 connected without verifying the server's TLS certificate. imaplib.IMAP4_SSL with no explicit ssl_context falls back to ssl._create_stdlib_context(), which sets verify_mode=CERT_NONE and check_hostname=False: the connection was encrypted but unauthenticated, so anyone positioned to intercept it — a hostile Wi-Fi network, a spoofed DNS answer, an intercepting proxy — could present their own certificate and read the app password and every message. 0.2.1 fixes this; upgrade.

pip install --upgrade hermes-yandex-mail

If you ran an earlier version over a network you do not control, revoke the app password at https://id.yandex.ru/security/app-passwords and issue a new one.

Found with thanks by @ukko in #2.

Please report security issues privately rather than as a public issue — see CONTRIBUTING.md.

Contributing

Issues and PRs are welcome — see CONTRIBUTING.md for the layout, the plugin contract rules worth knowing, and the release process.

License

MIT — see LICENSE.

Release files for hermes-yandex-mail 0.3.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for hermes-yandex-mail 0.3.0
File Size Uploaded
hermes_yandex_mail-0.3.0.tar.gz 95.1 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for hermes-yandex-mail 0.3.0
File Interpreter ABI Platform
hermes_yandex_mail-0.3.0-py3-none-any.whl Python 3 none any Details

Total release size: 153.5 kB

Release files / hermes_yandex_mail-0.3.0.tar.gz

Download URL hermes_yandex_mail-0.3.0.tar.gz
Size 95.1 kB
Tags Source
SHA-256 checksum
How to use checksums
e994a6d8c13689b525c7e819044f6d0c90fb0c49a64c2d933b8105f33a50985e
BLAKE2b-256 checksum
How to use checksums
197308db54ed87924ff3a9600d14bd0c5e30c6dad409cc90395d99dd43e0bd10
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 19, 2026.

Transparency log

Release files / hermes_yandex_mail-0.3.0-py3-none-any.whl

Download URL hermes_yandex_mail-0.3.0-py3-none-any.whl
Size 58.4 kB
Tags Python 3
SHA-256 checksum
How to use checksums
188c2c20179c23b7b5da8700b7e8666d3cc38f16439193c833fffbc7c528429a
BLAKE2b-256 checksum
How to use checksums
ff75c882fcc8d8a20d0b2f8e812a4cee2c2923c9dbec7528e222b9c2a0ada582
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 19, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.3.0 This release

2 release files

0.2.3

2 release files

0.2.2

2 release files

0.2.1

2 release files

0.2.0

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page