---
name: mail
description: Mail / Почта — fetch and send emails via Yandex Mail with OAuth2 authentication. Fetches via IMAP XOAUTH2 into structured directories, sends via SMTP XOAUTH2.
license: MIT
compatibility: Requires Python 3.10+, network access to imap.yandex.ru and smtp.yandex.com
metadata:
  author: bizyumov
  version: "2026.05.26"
---

# Yandex Mail / Почта

Generic email fetcher and sender for Yandex Mail via IMAP/SMTP XOAUTH2. Fetches incoming emails matching configured filters into a structured directory for downstream processing by other skills. Sends emails via SMTP through managed OAuth and the configured SMTP send app.

## Quick Start

Ask the user to verify that IMAP + OAuth is enabled for the target account first:

- EN: Open Yandex Mail in a browser, go to Settings → Mail clients (direct URL: `https://mail.yandex.ru/#setup/client`), enable IMAP access and OAuth-token access for mail clients, then save.
- RU: Откройте Яндекс Почту в браузере, перейдите в Настройки → Почтовые программы (прямая ссылка: `https://mail.yandex.ru/#setup/client`), включите доступ по IMAP и доступ OAuth-токенов для почтовых клиентов, затем сохраните изменения.

```bash
# Print an OAuth approval URL; add --account only if alias alex is already known:
python3 <full-path-to-yandex-office>/scripts/oauth_setup.py --app mail-readonly

# For SMTP sending, authorize the send app:
python3 <full-path-to-yandex-office>/scripts/oauth_setup.py --app mail-smtp

# Discover available account aliases before using Mail
python3 <full-path-to-yandex-office>/scripts/oauth_setup.py --accounts list

# Fetch new emails with all enabled configured filters
python3 <full-path-to-yandex-office>/mail/scripts/fetch_emails.py

# Fetch at most N new messages from enabled configured filters in this run
python3 <full-path-to-yandex-office>/mail/scripts/fetch_emails.py --num 20

# Run one named filter only
python3 <full-path-to-yandex-office>/mail/scripts/fetch_emails.py --filter forms

# Run an ad-hoc one-off search without touching persistent cursor state
python3 <full-path-to-yandex-office>/mail/scripts/fetch_emails.py --account alex --subject "code" --since-date 2026-05-26 --dry-run --preview-body --num 5

# Fetch exactly one message without advancing state
python3 <full-path-to-yandex-office>/mail/scripts/fetch_emails.py --account alex --uid <uid>
```

> Recommended: use `--app mail-readonly` for fetching and `--app mail-smtp`
> for SMTP sending. If Disk access is also needed, the agent runs setup again
> with `--app disk-read` under user authorization.

## What It Does

- Loads shared skill config plus `{data_dir}/config.agent.json`.
- Connects to Yandex Mail via IMAP XOAUTH2 using managed auth.
- Runs configured filters or one-off ad-hoc searches.
- Writes message body, attachments, and `meta.json` under `{data_dir}/incoming/<filter>/`.
- Persists cursors only for persistent named-filter runs; `--uid`, raw filters,
  and dry runs are non-persistent.

## Typical Agent Workflow

1. Run `python3 <full-path-to-yandex-office>/scripts/oauth_setup.py --accounts list`.
2. Pick only a listed account alias requested by the user.
3. Run `python3 <full-path-to-yandex-office>/mail/scripts/fetch_emails.py --account <alias> --subject "<pattern>" --since-date <YYYY-MM-DD> --dry-run --preview-body --num 5`.
4. Inspect all returned matches and use the newest relevant message.
5. If a full saved copy is needed, run `python3 <full-path-to-yandex-office>/mail/scripts/fetch_emails.py --account <alias> --uid <uid>`.
6. Read `email_body.html` or `email_body.txt` from the saved incoming directory.

If the requested account alias is missing, stop. The agent imports that account
through `yandex-office` under user authorization. Do not use another account as
a fallback.

## Flood Control (`--num`)

Use `--num` to cap the total number of newly fetched messages per run:

```bash
python3 <full-path-to-yandex-office>/mail/scripts/fetch_emails.py --num 25
```

Behavior:

- Cap is global across all selected accounts.
- Messages are fetched in ascending UID order (oldest unseen first).
- Once the cap is reached, remaining accounts are skipped for that run.
- UID state is persisted after each successfully processed message.
- `--num` must be a positive integer.

## Filters And Ad-Hoc Overrides

`mail.filters` is a flat filter map:

```json
{
  "mail": {
    "filters": {
      "telemost": {
        "sender": "keeper@telemost.yandex.ru"
      },
      "forms": {
        "sender": "forms@yandex.ru",
        "subject": "New response",
        "enabled": false
      }
    }
  }
}
```

For one logical stream with multiple alternative query shapes, use `any`:

```json
{
  "mail": {
    "filters": {
      "payment_receipts": {
        "enabled": true,
        "any": [
          { "sender": "receipts-a.example" },
          { "sender": "receipts-b.example", "since_date": "2026-05-01" }
        ]
      }
    }
  }
}
```

`any` semantics:

- branches are OR'ed under the same logical filter name
- fields inside one branch keep current AND semantics
- results are written under the same `incoming/<filter>/` directory
- each branch gets an independent cursor keyed by `sha256` of canonical compact branch JSON
- branch cursor state is stored in the normal mail state file (`mail.state_file`, default `state.json`) directly under `filters.<filter>.accounts.<account>` as `sha256:...` keys

Filter key rules:

- filter keys are schema keys, not user-facing labels
- use lowercase English keys only: letters, digits, underscores
- keys must start with a letter
- keys are used for persistent state and incoming subdirectory names
- `default` is reserved for ad-hoc one-off runs and must not be used as a configured filter key

Run model:

- bare run with no filter CLI arguments executes all enabled configured filters across all selected accounts
- configured entries under `mail.filters` are peer filters such as `telemost` or `forms`
- the legacy top-level keys (`mail.filters.sender`, etc.) are still upgraded in-memory into `mail.filters.telemost`
- `enabled: false` excludes that named filter from bare runs
- `--filter NAME` runs exactly that named filter, even if `enabled: false`

CLI options:

- `--filter NAME` selects one named filter and keeps persistent state isolated to that filter.
- `--sender`, `--subject`, `--since-date`, `--before-date` run as one raw ad-hoc filter when used without `--filter`.
- raw ad-hoc criteria without `--filter` search account history by default instead of inheriting a stored filter cursor.
- when `--filter NAME` is present, those same criteria flags are ignored; the configured named filter identity, criteria, output path, and state namespace are used as-is.
- use `--filter NAME` whenever you need one specific configured filter only; bare run means “all enabled filters”, not “one selected filter”
- `--account NAME` restricts the run to one token-backed account alias.
- `--from-uid UID` starts from a specific UID floor for a one-off backfill.
- `--uid UID` fetches exactly one message, skips filter search logic, and implies non-persistence.
- `--preview-body` with `--dry-run` includes a `body` object for matching messages by fetching message bodies into memory without writing incoming files.
- `--preview-body` is valid only with `--dry-run`; plain dry-run stays header-only.
- `--no-persist` disables state writes for the run.

Persistence rules:

- `--dry-run` never writes state.
- `--from-uid` is always treated as non-persistent.
- `--uid` is always treated as non-persistent.
- Raw CLI filter overrides used without `--filter` are treated as ad-hoc runs and do not advance persistent cursors.
- Raw CLI filter overrides used without `--filter` also ignore stored filter cursors by default, so one-off lookups do not need `--from-uid 1` just to search account history.
- Selecting a named filter with `--filter` keeps normal persistent behavior; criteria flags supplied alongside `--filter` are ignored and do not make the run ad-hoc.

## Heavy Output Handling

Broad account lookups can return too much data for efficient inline assistant use. In dry-run mode:

- pending results stay inline only while the rendered payload is within the configured symbol threshold
- when the threshold is exceeded, the full result set is saved to `{data_dir}/latest-query/` instead
- stdout returns a compact summary plus `output_file`
- each spilled run clears the previous spill artifact from that directory
- if you want to keep a spilled file, copy it elsewhere before the next spilled run

Config:

```json
{
  "mail": {
    "output": {
      "max_inline_symbols": 2000,
      "spill_dir": "latest-query"
    }
  }
}
```

Notes:

- threshold is measured in symbols / characters, not bytes
- default `max_inline_symbols` is `2000`
- default `spill_dir` is `latest-query`

## Optional `SINCE` Mode

For large accounts, you can globally limit IMAP search to messages sent since a date.

Root config (`config.skill.json`):

```json
{
  "mail": {
    "since": "on",
    "filters": {
      "sender": "keeper@telemost.yandex.ru"
    }
  }
}
```

Behavior:

- When `mail.since` is `"on"`, fetcher reads per-account `last_received_date` from the active filter state in `state.json` and applies IMAP `SINCE <date>`.
- Search criteria is an intersection (AND): `SINCE` + sender criteria.
- Sender criteria remains based on configured full address and is queried as `FROM "<left-of-@>" AND FROM "<right-of-@>"`.
- State now persists both:
  - `last_uid`
  - `last_received_date` (UTC date extracted from fetched email timestamp)
- Named filters keep separate state buckets so their cursors do not interfere with each other.

Reference:

- RFC 3501 `SEARCH` keys and AND intersection semantics: https://www.ietf.org/rfc/rfc3501.html

## Global Fetch Throttle

You can configure a global pause between `_process_email` iterations:

```json
{
  "mail": {
    "fetch": {
      "sleep_seconds": 0.5
    }
  }
}
```

Behavior:

- Applies between processed messages within an account loop.
- Unit: seconds.
- Default: `0.5`.
- `0` disables the delay.
- Negative/invalid values fall back to default or clamp to `0`.

## CLI Output

Default stdout is intentionally brief JSON:

```json
{
  "filter": "telemost",
  "persist_state": true,
  "fetched_total": 12,
  "accounts": {
    "alex": 4,
    "mary": 8
  }
}
```

Large dry-run output example:

```json
{
  "dry_run": true,
  "filter": "telemost",
  "persist_state": false,
  "pending_total": 178,
  "pending": [],
  "accounts": {
    "beta": 0
  },
  "output_file": "/path/to/yandex-data/latest-query/mail_dry_run_20260409T183247123456Z.json",
  "output_spilled": true,
  "inline_threshold_symbols": 2000,
  "output_notice": "Copy this file if you need to keep it. The next spilled run replaces the previous spill artifact."
}
```

Verbose mode (`-v`) keeps detailed logs in stderr/logger output.

## Output Structure

```
{data_dir}/incoming/{filter}/{YYYY-MM-DD}_{account}_uid{N}/
    {original_filename}.txt    # Attachments (preserved names)
    email_body.txt             # Email body (text)
    email_body.html            # Email body (raw HTML, for downstream parsing)
    meta.json                  # Generic metadata
```

### meta.json Fields

```json
{
  "imap_uid": 2550,
  "account": "alex",
  "filter": "telemost",
  "subject": "Конспект встречи от 08.02.2026",
  "sender": "Хранитель встреч Телемоста <keeper@telemost.yandex.ru>",
  "timestamp": "2026-02-08T09:27:00Z",
  "body": {
    "text": "email_body.txt",
    "html": "email_body.html"
  },
  "attachments": [
    {
      "original-filename": "2026-02-08 19:07 (MSK) 5981404294.txt",
      "saved-filename": "2026-02-08 19:07 (MSK) 5981404294.txt",
      "content-type": "text/plain",
      "size": 12345,
      "disposition": "attachment",
      "content-id": null,
      "part-index": 3
    }
  ],
  "dir_name": "2026-02-08_alex_uid2550",
  "dir_relpath": "telemost/2026-02-08_alex_uid2550"
}
```

`body.text` and `body.html` appear only when those MIME body parts are saved.
New writes store `attachments` as metadata objects for saved non-body MIME file
parts, including inline file assets. Existing legacy metadata may still contain
`attachments` as a list of saved filename strings; readers and downstream code
must continue accepting that legacy shape.

No business logic fields — downstream skills (telemost, etc.) enrich meta.json as needed.

## Configuration

Uses shared root `config.skill.json`, local `yandex-data/config.agent.json`,
and managed auth accounts. Key fields:

- `imap.server` / `imap.port` — IMAP connection settings
- managed auth accounts are selected by account alias
- `mail.filters.telemost` — configured Telemost filter definition
- `mail.filters.<name>.sender` — FROM filter criterion
- `mail.filters.<name>.subject` — SUBJECT filter criterion
- `mail.filters.<name>.since_date` / `before_date` — optional date bounds for that filter
- `mail.filters.<name>.any` — OR-style atomic query branches for one logical filter; each branch supports `sender`, `subject`, `since_date`, and `before_date`, and stores cursor state in `mail.state_file` directly under `filters.<filter>.accounts.<account>` as `sha256:...` keys
- legacy `mail.filters.sender` — still supported and upgraded in-memory into `mail.filters.telemost.sender`
- `mail.since` — `"on"`/`"off"` toggle for state-driven IMAP `SINCE` filtering
- `mail.fetch.sleep_seconds` — Global sleep between `_process_email` iterations (seconds, default `0.5`)
- `mail.fetch.sleep_seconds` does not affect `--dry-run` search-only queries; it only throttles real `_process_email` iterations
- `mail.output.max_inline_symbols` — spill dry-run result sets to a file when inline output would exceed this symbol threshold (default `2000`)
- `mail.output.spill_dir` — relative output directory inside `{data_dir}` for spilled dry-run result files (default `latest-query`)
- sender and subject filters are literal IMAP substring matches; no additional query language is implemented
- `mail.state_file` — shared state file with per-filter account cursors
- runtime data dir defaults to `./yandex-data`, or `--data-dir` when explicitly passed

## Email Headers — Observed Behavior

Headers tested with Yandex Mail (smtp.yandex.com) as sender and various clients as recipient:

- **X-Priority: 1** — Works. Red exclamation mark shown in Outlook and other clients.
- **Importance: high** — Works. Clients recognise high importance.
- **Disposition-Notification-To** — Set correctly, header is preserved through Yandex SMTP relay, but **read receipt notifications are NOT triggered** in receiving clients (tested with Outlook). The header is present in the delivered message but clients do not act on it. Do NOT rely on this for read confirmation.
- **X-Yandex-Spam** — Yandex adds this header to incoming messages. Empirically: `1` = not spam, `4` = spam. No official documentation from Yandex on the scale.

## Sending Emails

Use `send_email.py` to send emails via Yandex SMTP with managed OAuth and the configured SMTP send app:

```bash
# Send a simple email
python3 <full-path-to-yandex-office>/mail/scripts/send_email.py \
    --account <alias> \
    --to recipient@example.com \
    --subject "Hello" \
    --body "Hi there"

# Send with CC and Reply-To
python3 <full-path-to-yandex-office>/mail/scripts/send_email.py \
    --account <alias> \
    --to recipient@example.com \
    --cc other@example.com \
    --reply-to sender@example.com \
    --subject "Re: Topic" \
    --body "Reply body"

# Read body from file (multi-line content)
python3 <full-path-to-yandex-office>/mail/scripts/send_email.py \
    --account <alias> \
    --to recipient@example.com \
    --subject "Report" \
    --body-file /path/to/report.txt

# HTML body
python3 <full-path-to-yandex-office>/mail/scripts/send_email.py \
    --account <alias> \
    --to recipient@example.com \
    --subject "HTML Report" \
    --body "<h1>Hello</h1><p>Content</p>" \
    --content-type html

# JSON output
python3 <full-path-to-yandex-office>/mail/scripts/send_email.py \
    --account <alias> \
    --to recipient@example.com \
    --subject "Test" \
    --body "OK" \
    --format json
```

### Python API

```python
from pathlib import Path
sys.path.insert(0, str(Path("<full-path-to-yandex-office>")))
from mail.scripts.send_email import EmailSender

sender = EmailSender()
result = sender.send(
    to="recipient@example.com",
    subject="Hello",
    body="Hi there",
    account="alex",
)
# result = {"status": "sent", "from": "...", "to": [...], "subject": "...", "message_id": "..."}
```

### Send CLI Options

- `--account <alias>` — Account alias (required when multiple token files exist)
- `--to <addr> [<addr> ...]` — Recipient(s), **required**
- `--subject <text>` — Subject line, **required**
- `--body <text>` — Email body text (mutually exclusive with `--body-file`)
- `--body-file <path>` — Read body from file
- `--cc <addr> [<addr> ...]` — CC recipient(s)
- `--bcc <addr> [<addr> ...]` — BCC recipient(s) (not included in message headers)
- `--reply-to <addr>` — Reply-To header
- `--content-type plain|html` — Body content type (default: `plain`)
- `--format json|text` — Output format (default: `text`)
- `--data-dir <path>` — Override data directory
- `-v / --verbose` — Debug logging

### Auth for Sending

`send_email.py` uses `@yandex_api_method("mail.smtp.send")` and managed OAuth
token dispatch. Authorize the configured `mail-smtp` app for SMTP sending. IMAP
read/readwrite apps are not SMTP-send authority. The SMTP
XOAUTH2 auth string format is:

```
user={email}\x01auth=Bearer {token}\x01\x01
```

Configuration:
- `smtp.server` — SMTP server (default: `smtp.yandex.com`)
- `smtp.port` — SMTP port (default: `465`, SSL)

## Managed Auth

Use `python3 <full-path-to-yandex-office>/scripts/oauth_setup.py` for OAuth
intake and refresh, normally with `--app mail-readonly` for fetching and
`--app mail-smtp` for sending. Runtime selects eligible credentials through the
decorated auth metadata and config-backed OAuth app catalog.

## Files

- `mail/scripts/fetch_emails.py` — Main fetcher (CLI + Python API)
- `mail/scripts/send_email.py` — Email sender (CLI + Python API)
- `mail/scripts/test_send_email.py` — Sender regression tests
- `<full-path-to-yandex-office>/scripts/oauth_setup.py` — Shared bootstrap/account alias/managed auth setup tool for all Yandex sub-skills
- `mail/scripts/fetch.sh` — Cron-safe shell wrapper with PID lock (passes `--num` and other args through)
