sch-mail
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@sch-mail list my recent emails in the inbox"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
sch-mail
MCP server for the Greek School Network (sch.gr) mailbox: IMAP read + SMTP send, exposed as ten typed tools over the Model Context Protocol.
What it is
A single-file Python MCP server that talks to the sch.gr mail servers over IMAP4 (SSL, port 993) and SMTP (SSL, port 465), using the MCPServer class from the mcp[cli] package (MCP Python SDK 2.x). From inside an MCP client like Claude Code it lets you list folders, read and search mail, download attachments, send new messages, forward existing ones, move messages between folders, and create folders.
Built for the specific quirks of the Greek School Network mailbox: Greek Unicode in subject and sender lines falls back to client-side filtering (IMAP SEARCH on mail.sch.gr does not handle non-ASCII reliably), and Drafts / Sent folder discovery walks a candidate list to cope with IMAP namespace variations.
send_mail and forward_mail default to draft-first: the message is IMAP-APPENDed into the Drafts folder with the \Draft flag rather than dispatched. Pass send_now=True to actually put it on the wire.
Deployment policy stays local. If a particular mailbox should be used more narrowly than the tools allow, write that guidance in
~/.sch-mail/instructions.mdrather than in the code. The server appends the file's text to its MCP instructions at startup (see Local instructions), so every client sees it. The SMTP path works whenever a tool is called withsend_now=True; keep that in mind before wiring it into any automation.
Related MCP server: thunderbird-mcp-server
Features
Ten MCP tools (six read, four write), all defined in server.py and registered via the @mcp.tool() decorator on an MCPServer("sch-mail") instance. Every tool accepts an optional account: str matching a key in the multi-account credentials file (default: the default key).
Read tools
Tool | Purpose |
| List all mailbox folders on the account. |
| List recent messages in a folder with id (the message's IMAP UID, which every |
| Fetch a specific message by id and return headers, attachment metadata, and the body as plain text or HTML (with |
| Search a folder by keyword across |
| Save attachments from a message to disk (default |
| Quick counts for a folder: total, unread, received today, received in the last 7 days. |
Write tools
Tool | Purpose |
| Compose and either save as draft (default) or send via SMTP with |
| Forward an existing message to new recipients, preserving all original attachments verbatim. Same draft-first default as |
| Move one message, by UID, between folders: UID MOVE where the server supports it, else UID COPY + |
| Create a new mailbox folder and optionally subscribe to it. Idempotent: returns |
Architecture
flowchart TD
Client["MCP client<br/>(Claude Code)"] -- stdio JSON-RPC --> Server["sch-mail<br/>MCPServer<br/>(server.py)"]
Server -- IMAP4 SSL:993 --> IMAP["mail.sch.gr<br/>IMAP"]
Server -- SMTP SSL:465 --> SMTP["mail.sch.gr<br/>SMTP"]
Server -. reads .-> Creds["~/.sch-mail/<br/>credentials.json"]
IMAP --> Mailbox["sch.gr mailbox"]
SMTP --> MailboxThe server opens a fresh IMAP or SMTP connection per tool call and closes it in a finally block. There is no connection pooling and no long-lived session.
Installation
Requires Python 3.12+. The repo ships a uv.lock for reproducible installs via uv, but plain pip works too.
# HTTPS (no SSH key required):
git clone https://github.com/weirdapps/sch-mail.git
# or, with SSH:
git clone git@github.com:weirdapps/sch-mail.git
cd sch-mail
# Option A: uv (recommended, matches the committed lockfile)
uv sync
# Option B: pip
python -m venv .venv
.venv/bin/pip install -e .The only runtime dependency is mcp[cli] (constraint declared in pyproject.toml, currently >=2.0.0, pinned by uv.lock). The optional dev extra adds ruff and pytest, which is what CI lints and tests with.
Configuration
Credentials live at ~/.sch-mail/credentials.json, outside the repo. credentials.json is also in .gitignore for safety.
Single-account format:
{
"email": "user@sch.gr",
"password": "..."
}Multi-account format (referenced from every tool via the account argument):
{
"accounts": {
"personal": { "email": "user@sch.gr", "password": "..." },
"work": { "email": "other.user@sch.gr", "password": "..." }
},
"default": "personal"
}The key names are yours to choose. A tool called without account uses the account default names, or the first one listed when there is no default.
Connection parameters are hard-coded in server.py:
Constant | Value |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Local instructions (optional)
When ~/.sch-mail/instructions.md exists, the server reads it once at startup and appends its text, after a single space, to the built-in MCP instructions that every client receives. Use it for anything specific to one deployment, such as house rules for how a mailbox may be used, so that none of it has to live in this repo. A missing or blank file leaves the built-in text alone; a file that cannot be read or decoded as UTF-8 stops the server from starting rather than letting it run without the guidance.
Allowed folders
sch_send_mail reads attachments, and sch_download_attachments saves files, only inside an allowlist of folders. Every tool argument is chosen by a model that is also reading mail anyone can send, so without the limit a hostile message could get a private key or document attached to a draft, or a file saved somewhere that runs it. Paths are resolved with symlinks followed before the check, so neither .. nor a link inside an allowed folder can point out of it. Hidden files and folders below an allowed folder are refused too, because other programs load files from them without asking (a .venv's site-packages, .git/hooks, editor and agent settings); to use a hidden folder anyway, name that folder itself in SCH_MAIL_ALLOWED_DIRS. The defaults are deliberately narrow:
~/Downloadsthe system temp dir, and
/tmp
~/Documents, ~/Desktop and cloud-synced folders are not among them. To attach a file kept elsewhere, copy it into ~/Downloads first, or name its folder in SCH_MAIL_ALLOWED_DIRS.
SCH_MAIL_ALLOWED_DIRS replaces the defaults outright: absolute paths separated by os.pathsep (: on macOS and Linux). ~ is expanded and relative entries are ignored. It is the only setting the server takes from the environment. A refused path is reported as an error that names the variable. An attachment that is refused, missing or not a regular file fails the whole call, so a message never goes out quietly missing a file. Saved file names are cleaned too: path separators, control characters and bidirectional-override characters become _, and a leading dot gets a _ prefix so nothing lands hidden.
Usage
Run the server
bash run_mcp.shrun_mcp.sh resolves its own directory and execs .venv/bin/python -m server, so it works from any working directory.
Register with Claude Code
Add an entry to your Claude Code MCP config pointing at the launcher script:
{
"mcpServers": {
"sch-mail": {
"command": "/absolute/path/to/sch-mail/run_mcp.sh"
}
}
}Claude Code starts the server on demand over stdio; you do not run it manually.
Example tool calls
Once the server is registered, tools are available as sch_list_mail, sch_send_mail, and so on. Concrete examples:
Triage the inbox:
sch_list_mail(folder="INBOX", top=20)returns the 20 most recent messages with read / attachment flags.Read a message:
sch_get_mail(msg_id="12345", body="text", max_body_chars=5000).Search for a topic in Greek:
sch_search_mail(query="Επιμόρφωση", field="subject", top=10)(auto-uses client-side filtering because of non-ASCII).Save a draft (default behaviour):
sch_send_mail(to="foo@sch.gr", subject="test", body="hello")returns{"status": "draft_saved", "folder": "Drafts", ...}. Review it insch.grwebmail and hit send, or rerun withsend_now=True.Dispatch immediately: same call with
send_now=Truesends via SMTP and archives to the Sent folder via IMAP APPEND. There is no undo.Forward preserving attachments:
sch_forward_mail(msg_id="12345", to="bar@sch.gr", additional_text="FYI").Move a message:
sch_move_mail(msg_id="12345", dest_folder="INBOX/Archive2026"). The destination must exist; usesch_create_folder("INBOX/Archive2026")first if it does not.
Development
The project follows the house Python conventions used across weirdapps repos.
# Install the dev extra first: a plain `uv sync` does not pull ruff in,
# because ruff lives in the optional `dev` extra, not the default deps.
uv sync --extra dev --frozen
# Lint + format (config in pyproject.toml)
uv run ruff check .
uv run ruff format --check .
# Import sanity check, then the test suite (offline: tests/conftest.py
# blocks real sockets and the real credentials file)
uv run python -c "import server; print('Import OK')"
uv run pytest -q.pre-commit-config.yaml wires up ruff, ruff-format, gitleaks secret scanning, and the standard pre-commit-hooks hygiene set. Install once with pip install pre-commit && pre-commit install.
CI (.github/workflows/ci.yml) runs two jobs on push and PR to master: lint (ruff check plus ruff format --check) and test (the import server smoke check, then pytest -q). Both install with uv sync --frozen, so uv.lock has to stay in step with pyproject.toml or CI fails before it runs anything. SonarCloud analysis is wired via sonar-project.properties (project key weirdapps_sch-mail).
Dependabot runs weekly against two ecosystems, uv and github-actions, with minor and patch updates grouped (see .github/dependabot.yml). .github/workflows/dependabot-auto-merge.yml is a thin caller for the shared reusable workflow weirdapps/shared-workflows/.github/workflows/dependabot-auto-merge.yml, pinned to a commit SHA; it merges a Dependabot PR once that PR's own checks are green and leaves majors open for review.
Security
Report vulnerabilities privately through GitHub (the Security tab, then Report a vulnerability); see SECURITY.md. Credentials never enter the repo: credentials.json and *.env are in .gitignore, and gitleaks runs pre-commit.
Related projects
weirdapps/yahoo-access is a downstream adaptation of this server (a copy rather than a GitHub fork, so there is no upstream PR relationship), retargeted at Yahoo Mail's IMAP / SMTP endpoints and its own multi-account setup.
License
MIT, see LICENSE. Copyright (c) 2026 Dimitrios Plessas.
Available Tools
10 toolssch_create_folderA
Create a new mailbox folder.
Args: name: Folder name. For nested folders use the server's separator (commonly "/" or ".", e.g. "INBOX/Archive2026" or "Archive.2026"). subscribe: Subscribe to the folder so it appears in mail clients (default: True). account: Account key in credentials.json (e.g. "personal" or "work"). Default: the account named by "default" in that file.
Inputs: name (required), account, subscribe. Returns: object
sch_download_attachmentsA
Download attachments from a message to disk.
Args: msg_id: Message UID from sch_list_mail / sch_search_mail folder: Mailbox folder (default: INBOX) out_dir: Directory to save files (default: ~/Downloads). Must lie inside the allowed folders (by default ~/Downloads and the temp dirs; SCH_MAIL_ALLOWED_DIRS replaces them). filename_filter: Only download files matching this substring (case-insensitive) account: Account key in credentials.json (e.g. "personal" or "work"). Default: the account named by "default" in that file.
Inputs: msg_id (required), folder, account, out_dir, +1 more. Returns: result
sch_forward_mailA
Forward an existing message to new recipient(s), preserving attachments.
Args: msg_id: Source message UID (from sch_list_mail / sch_search_mail) to: Recipient(s), comma-separated cc: CC recipient(s) bcc: BCC recipient(s) additional_text: Optional text prepended above the forwarded content (plain text) folder: Source folder (default: INBOX) send_now: True to dispatch immediately. Default False = save to Drafts. account: Account key in credentials.json (e.g. "personal" or "work"). Default: the account named by "default" in that file.
Returns status, subject, attachment count, and message id.
Inputs: to (required), msg_id (required), cc, bcc, +4 more. Returns: object
sch_get_mailA
Read a specific message by UID (the "id" from sch_list_mail or sch_search_mail).
Args: msg_id: Message UID from sch_list_mail / sch_search_mail folder: Mailbox folder (default: INBOX) body: Body format: "text" (plain text, default), "html", or "none" max_body_chars: Truncate body to this many chars (default: 5000) account: Account key in credentials.json (e.g. "personal" or "work"). Default: the account named by "default" in that file.
Inputs: msg_id (required), body, folder, account, +1 more. Returns: object
sch_list_foldersA
List all mailbox folders.
Returns folder names available in the sch.gr mailbox. Pass account (a key in credentials.json, e.g. "work") to pick a mailbox, or omit it for the account named by "default" in that file.
Inputs: account. Returns: result
sch_list_mailA
List recent messages with subject, sender, date, and attachment indicators.
Each result's "id" is the message's IMAP UID: stable across calls, unlike a sequence number, and the value every msg_id parameter expects.
Args: folder: Mailbox folder (default: INBOX) top: Max messages to return (default: 20, max: 100) since: Only messages after this date (YYYY-MM-DD). Default: last 30 days. account: Account key in credentials.json (e.g. "personal" or "work"). Default: the account named by "default" in that file.
Inputs: top, since, folder, account. Returns: result
sch_mail_statsB
Quick mailbox statistics: total messages, recent/unseen counts, date range.
Args: folder: Mailbox folder (default: INBOX) account: Account key in credentials.json (e.g. "personal" or "work"). Default: the account named by "default" in that file.
Inputs: folder, account. Returns: object
sch_move_mailA
Move one message, addressed by UID, from source_folder to dest_folder.
Uses UID MOVE when the server supports it, else UID COPY + \Deleted + UID EXPUNGE, and a bare EXPUNGE only on a server with neither MOVE nor UIDPLUS. Destination folder must already exist (use sch_create_folder first if needed).
Args: msg_id: Source message UID from sch_list_mail / sch_search_mail (one UID; ranges and lists are refused) dest_folder: Target folder name source_folder: Source folder (default: INBOX) account: Account key in credentials.json (e.g. "personal" or "work"). Default: the account named by "default" in that file.
Inputs: msg_id (required), dest_folder (required), account, source_folder. Returns: object
sch_search_mailA
Search messages by keyword in subject, sender, or body.
Result ids are IMAP UIDs, the same ids sch_list_mail returns.
Args: query: Search text (case-insensitive) folder: Mailbox folder (default: INBOX) field: Where to search: "subject", "from", "body", or "all" (default: subject) since: Only search after this date (YYYY-MM-DD) top: Max results (default: 20) account: Account key in credentials.json (e.g. "personal" or "work"). Default: the account named by "default" in that file.
Inputs: query (required), top, field, since, +2 more. Returns: result
sch_send_mailA
Send a new email via SMTP, or save as draft (default).
Args: to: Recipient(s), comma-separated (e.g. "a@x.gr,b@y.gr") subject: Subject line body: Email body (plain text by default; set html=True for HTML) cc: CC recipient(s), comma-separated bcc: BCC recipient(s), comma-separated html: True if body is HTML; False (default) for plain text attachments: List of absolute file paths to attach. Each must be an existing file inside the allowed folders (by default ~/Downloads and the temp dirs; SCH_MAIL_ALLOWED_DIRS replaces them). One that is missing or outside them fails the whole call, so nothing is saved or sent without it. send_now: True to dispatch via SMTP immediately. Default False = save to Drafts folder. account: Account key in credentials.json (e.g. "personal" or "work"). Default: the account named by "default" in that file.
Returns dict with status ("draft_saved" or "sent"), folder/recipients, and message id.
Inputs: to (required), body (required), subject (required), cc, +5 more. Returns: object
Tool Schema Changelog
Recent tool additions, removals, and schema changes observed during successful MCP inspections.
Loading tool schema changelog…
TDQS
Scored across 10 tools
Each tool targets a distinct action on the mail domain: listing, searching, reading, sending, forwarding, moving, downloading attachments, folder listing/creation, and stats. The closest pair (sch_list_mail vs sch_search_mail) is clearly separated as browse-vs-query, and both share the same UID semantics, so there is no realistic misselection.
The set follows a consistent sch_<verb>_<noun> pattern (sch_list_mail, sch_get_mail, sch_send_mail, sch_move_mail, sch_create_folder). The only deviation is sch_mail_stats, which puts the noun before the description rather than using a verb, but it remains readable.
Ten tools is a well-scoped size for an email client surface, covering reading, sending, organizing, and attachments without bloat. Every tool earns its place and none appears redundant.
Core lifecycle is covered (list, search, read, send, forward, move, create folder, attachments, stats), but notable operations are missing: no delete_mail or delete/rename folder, and no reply or mark read/unread. These are common email workflows whose absence forces agents to work around dead ends.
Maintenance
Related MCP Connectors
Your IMAP mailbox as an MCP server: read, search and (if you allow it) organize mail. Open source.
Hosted email MCP for your own Gmail, Outlook.com, Microsoft 365, iCloud or IMAP inbox: read, search, draft, reply in thread, forward and file mail. It moves or flags up to 500 messages in one call, and a send leaves exactly one copy in Sent. A calendar is a separate connection, and connecting one adds diary and scheduling tools.
Email infrastructure for AI agents — send, receive, search, and reply to email over MCP.
Your mailbox for MCP clients: search, read, draft, send, rules and notes. Sending is off by default.
Related MCP Servers
- AlicenseNot gradedqualityDmaintenanceEnables interaction with Gmail through the MCP protocol, supporting sending, reading, searching, replying, forwarding, managing drafts and labels, and saving attachments.7 npm3MIT
- FlicenseNot gradedqualityCmaintenanceCentralizes Thunderbird email operations as MCP tools: read, send, draft, search, attachments.-
- AlicenseNot gradedqualityCmaintenanceExposes any IMAP mailbox and SMTP relay as MCP tools, enabling email management (read, search, send, delete) through MCP-compatible agents.1MIT
- AlicenseCqualityDmaintenanceA local-first Python MCP server that turns Gmail, Outlook/Microsoft 365, iCloud Mail, and generic IMAP/SMTP mailboxes into a synchronized, searchable OKF knowledge layer, exposing 38 tools and four resources for mailbox actions, synchronization, retrieval, attachments, and optional semantic search while keeping the provider mailbox authoritative.381MIT