mailmail
Send mail from Python through Gmail or Naver SMTP — one message, or a personalised batch to a whole list — with the provider's own rules checked before anything leaves your machine: blocked file types, the real size limit, the 100-recipient cap, the app-password login both services now demand. Attachments, an HTML body, cc/bcc, and an address book so you can call the people you write often by name.
English | 한국어
- Quick start
- At a glance
- What you need
- 1. Install
- 2. Configure your accounts (
~/.config/mailmail/config.toml) - 3. Get an app password — Naver, Gmail
- 4. Send mail
- Many at once (mail merge) (
send_bulk) - From the command line (
mailmail) - Files you can't send
- Use it from Claude Code
- When something goes wrong
- Development
Quick start
from mailmail import send
send(
to = "someone@example.com",
subject = "Weekly report",
body = "Please find the report attached.",
attachments = ["report.xlsx"],
)
Using Claude Code, you can send mail by asking, without writing any Python → Use it from Claude Code
Runs on Windows, macOS, and Linux. Installing it installs this package and nothing else — it pulls in no other libraries.
At a glance
One send() reads the config, picks the account, resolves aliases and builds the
message, refuses anything the provider would bounce — a blocked file type, a
message over the size limit, more than 100 recipients — before the connection is
ever opened, and returns
the outcome as a SendReceipt.
flowchart TB
caller["send(to='lead', subject=..., body=...)"] --> cfg["load_config()<br/>config.toml"]
cfg --> acct["resolve_account()<br/>SmtpAccount + provider"]
acct --> compose["compose_message()<br/>resolve aliases, attach files"]
compose --> screen{"can the provider carry it?<br/>blocked type, size limit, recipient cap"}
screen -->|"no"| refuse["raise<br/>(before the connection opens)"]
screen -->|"yes"| connect["Mailer<br/>STARTTLS + app-password login"]
connect --> server(("Gmail<br/>Naver"))
server --> receipt["SendReceipt<br/>accepted · refused · message-id"]
What you need
- Python 3.11 or newer. Check with
python --versionin a terminal (on Windows it may bepy --version). If it is missing or older, get it from python.org. - A Naver or Gmail account. Gmail is open to anyone, anywhere. A Naver account is easiest to create with a Korean mobile number — which foreign residents get with an Alien Registration Card (ARC). Without a Korean number, Naver also lets you verify with a passport or government-issued ID, which takes a day or two. These requirements change and vary by country, so check Naver's signup page; if you don't have a Naver account, Gmail works everywhere.
- That account's app password. You get it in step 3. Your normal login password will not work — both services reject it over SMTP.
1. Install
pip install mailmail
Check it landed:
python -c "import mailmail; print(mailmail.__version__)"
2. Configure your accounts
Create a config.toml under .config/mailmail/ in your home folder. The full path:
| Path | |
|---|---|
| macOS · Linux | ~/.config/mailmail/config.toml |
| Windows | C:\Users\<name>\.config\mailmail\config.toml |
Make the folder if it isn't there. Put your own addresses in:
default_account = "naver"
[accounts.naver]
provider = "naver"
username = "you@naver.com"
[accounts.gmail]
provider = "gmail"
username = "you@gmail.com"
[contacts]
me = "you@naver.com"
lead = "lead@example.com"
team = ["me", "lead"]
default_account— which account to send as when you don't pick one.[accounts.*]— the accounts you'll use. One is enough.providerisnaverorgmail.[contacts]— an address book, optional. A group (team) sends to everyone in it at once, and a group may contain other names.
The password does not go in this file. You store it separately in step 3.
3. Get an app password
This is where people get stuck most. Both services reject your normal login password and make you generate a separate app password. The two formats are near opposites, which is easy to confuse.
| Length | Form | Two-step verification | |
|---|---|---|---|
| Naver | 12 characters | UPPERCASE + digits | required |
| Gmail | 16 characters | lowercase | required |
Naver
Since 2025-06-24, connecting a mail program requires two-step verification and an app password. If a setup that used to work suddenly stopped, this is why.
- Turn on Naver ID → Security → Two-step verification. Without it, the next menu does not appear at all.
- On the same screen, Application password → Generate.
- The "type" you pick is just a label. Outlook, iPhone, Gmail — whichever you
choose, the password is the same. Typing
mailmailin the free-text field makes it easy to recognise later. - A 12-character uppercase-and-digit password appears. You cannot see it again once you leave that screen — copy it.
- The "type" you pick is just a label. Outlook, iPhone, Gmail — whichever you
choose, the password is the same. Typing
- Under Mail → Settings → POP3/IMAP, confirm SMTP is enabled. Even if it already is, toggle it once: Off → Save → On → Save. That is what applies the June 2025 policy change to an older account.
Gmail
- Turn on two-step verification first. Without it, the app-password menu does not appear.
- Generate one at myaccount.google.com/apppasswords.
- A 16-character lowercase password appears in four groups. The spaces don't matter — keep them or drop them.
Store the password you got
Store it once and you are not asked again. Running this prompts for it:
python -c "
from getpass import getpass
from mailmail import load_config, store_password
account = load_config().resolve_account('naver') # 'gmail' for Gmail
password = getpass(f'{account.username} app password: ').strip()
print(f' length: {len(password)}') # Naver 12, Gmail 16
store_password(account, password)
print(' saved')
"
Nothing showing on screen while you paste is normal. So you can't tell if you pasted twice — check the length it prints.
The password is stored, readable only by you, in credentials.json beside the
config — not in the config file. That file is not encrypted, so the only thing that
goes in it is an app password: it is used only to send mail, it leaves your
account password untouched, and you can revoke it any time. Do not put your account
password here.
Check it worked
You can test just the login, without sending anything:
python -c "
import smtplib
from mailmail import load_config, resolve_password
for name in ('naver',): # ('naver', 'gmail') if you stored Gmail too
account = load_config().resolve_account(name)
smtp = smtplib.SMTP(account.provider.smtp_host, account.provider.smtp_port, timeout=20)
smtp.ehlo(); smtp.starttls(); smtp.ehlo()
try:
smtp.login(account.username, resolve_password(account))
print(f' {name}: OK')
except smtplib.SMTPAuthenticationError as e:
print(f' {name}: {e.smtp_code} {e.smtp_error.decode()[:60]}')
smtp.quit()
"
OK means you're done. If not:
| What the server says | What it means |
|---|---|
535 5.7.1 Username and Password not accepted (Naver) |
Wrong password, or SMTP is off. Check both — this message does not tell them apart |
534 5.7.9 Application-specific password required (Gmail) |
You used the login password. Try the app password |
4. Send mail
from mailmail import send
send(
to = "someone@example.com",
subject = "Weekly report",
body = "Please find the report attached.",
)
If you registered a name in the address book, you can call it. You can mix names and addresses.
send(to="lead", subject="Weekly report", body="Please review.")
send(to=["lead", "someone@example.com"], subject="Weekly report", body="...")
send(to="team", subject="Weekly report", body="...") # the group expands
The body goes out exactly as written, so line breaks survive. A triple-quoted string is handy for a long one.
body = """\
Hello,
This week's report is attached. The loss ratio moved 1.2%p from last month;
the detail is on the second sheet.
Best regards,
"""
send(to="lead", subject="Weekly report", body=body, attachments=["report.xlsx"])
With an account, attachments, cc, and HTML:
receipt = send(
account = "gmail", # default_account if omitted
to = "lead",
cc = "team",
bcc = "audit@example.com",
subject = "Month-end close",
body = "Month-end figures attached.",
html = "<p>Month-end figures attached.</p>",
attachments = ["close.xlsx", "notes.pdf"],
)
if not receipt.is_complete: # when some addresses were refused
print(receipt.reason_by_refused_recipient)
A few things to know:
- Omit
ccand there is no cc. What you don't name doesn't ride along. bccis invisible to the recipients. Blind recipients don't see each other either.htmlandbodyshould say the same thing. A mail client shows one or the other, so if they differ, different people read different mail.- Korean (or any non-ASCII) just works. Nothing special to do in the subject or the body.
Many at once (mail merge)
To send the same note to many people with a different value or attachment for each,
use send_bulk. One Mail per person, and they all go out over one connection —
thirty people is one login, not thirty.
from mailmail import Mail, send_bulk
receipts = send_bulk([
Mail(to="alice@example.com", subject="June results",
body="Hi Alice, please find yours attached.", attachments=["alice.xlsx"]),
Mail(to="bob@example.com", subject="June results",
body="Hi Bob, please find yours attached.", attachments=["bob.xlsx"]),
])
for receipt in receipts:
if not receipt.is_complete: # an address this message refused
print(receipt.reason_by_refused_recipient)
Mail is the same vocabulary as send — to/cc/bcc take addresses or
address-book names, and attachments are paths. Personalise (name, figures,
attachment) by building each Mail differently.
A few things to know:
- You get a list of receipts in the same order.
zip(mails, receipts)pairs who got what. If one message is refused for every recipient, that message comes back as a receipt with emptyacceptedand the rest still go — 3 of 30 blocked means 27 sent. - Most bad rows are stopped before anything is sent. An unknown address-book name, a blank subject, a blocked attachment, or attachments already over the limit, in any row, raises before the connection opens and nothing goes out.
- Two failures can still land mid-batch: (1) a message that crosses the size limit only once assembled (the up-front screen weighs attachments, not the finished MIME), and (2) the connection dropping partway through. In both, earlier messages have already gone and cannot be unsent, and the receipts collected so far are lost. What the server refuses per recipient goes into the receipt, not an exception.
From the command line
Everything above works from a terminal too, no Python required — installing the package
puts a mailmail command on your PATH.
mailmail send --to lead --subject "Weekly report" --body "Please review."
--to (and --cc, --bcc) take an address or an address-book name, and repeat for
several; --attach repeats too. A body with line breaks reads better from a file or a
pipe than as one shell argument:
mailmail send --to team --subject "Month-end close" \
--body-file note.txt --attach close.xlsx --attach notes.pdf --account gmail
mailmail send --to lead --subject "Weekly report" < note.txt
Send many at once from a CSV — one row per message, one login for the batch. The header
names the fields: to, subject, and body are required; cc, bcc, html, and
attachments are optional. A cell holding several entries (recipients, attachments)
separates them with a semicolon:
mailmail send-bulk batch.csv --account gmail
to,subject,body,attachments
alice@example.com,June results,"Hi Alice, yours is attached.",alice.xlsx
team,June summary,"All figures attached.",june.xlsx;notes.pdf
The other three commands set things up and check them:
mailmail setup # the config and credentials paths, and a template
mailmail contacts # the accounts and address-book names you can use
mailmail set-password --account naver # store the app password, prompted
set-password asks for the password at a prompt instead of taking it as an argument, so
it never lands in your shell history. Anything a send would reject — a blocked
attachment, a message over the limit, a missing password — is reported the same way it
is from Python, before a connection opens. A partial refusal exits non-zero, so a script
can tell.
Files you can't send
Attachments a mail service would reject are reported before sending, as an exception. That beats learning it minutes later from a bounce (or from a message that silently lands in a folder nobody reads).
- Executables.
.exe,.dll,.jar,.js,.bat,.vbs,.ps1,.msi, and the like. Putting them in a.zipor.tar.gzdoesn't help — those are looked inside. (Gmail's published list. Naver does not publish one, so the same standard is applied.) - A password-protected zip. Rejected whatever is in it, because the service cannot open it.
- Archives too big or too deeply nested. Scanned up to 4 levels deep, and up to 64 MB per inner file.
Share those through a link instead.
Size limit — an attachment grows about 37% on the way out, so the ceiling on the raw files is about 25 MB for Gmail and about 27 MB for Naver. That can differ from the number the web UI shows; this is the one the server actually accepts.
.7z and .rar cannot be looked inside. The Python standard library cannot read
those formats. If one holds an executable it passes the check, reaches the server, and
is refused there. Better not to use those two.
Use it from Claude Code
Install the skill into Claude Code and you can send mail by asking, without writing Python. A skill is an instruction sheet that tells Claude "when a request like this comes in, do this."
The skill lives in this repository, so clone it first, then link its skill folder into the place Claude looks:
git clone https://github.com/seokhoonj/mailmail.git
ln -s "$PWD/mailmail/skills/send" ~/.claude/skills/send # macOS, Linux
git clone https://github.com/seokhoonj/mailmail.git
New-Item -ItemType SymbolicLink -Path "$HOME\.claude\skills\send" `
-Target "$PWD\mailmail\skills\send" # Windows (PowerShell)
After that, just say it in Claude Code:
Summarise June's P&L as a table, attach balance.xlsx, and send it to lead.
Point out what goes in the body, what is an attachment, and who it's for, and it is assembled as told. What you don't specify, it asks about — it does not guess.
Before sending, it shows the recipients, subject, and attachments for approval.
Address-book names are expanded to real addresses, so you can see exactly who team
reaches. Mail cannot be unsent.
When something goes wrong
The exception message states, in words, what is wrong and how to fix it.
| Meaning | |
|---|---|
ConfigError |
The config file is missing or malformed. Go to step 2 |
MissingPasswordError |
No password stored yet. Go to step 3 |
UnknownContactError |
A name that isn't in the address book. It lists the names it knows |
BlockedAttachmentError |
A file the mail service blocks. Share it as a link |
MessageTooLargeError |
The attachments are over the limit |
AuthenticationFailedError |
The login was refused. Check the app password is right, and for Naver that SMTP is on |
Development
git clone https://github.com/seokhoonj/mailmail.git
cd mailmail
python -m venv .venv
source .venv/bin/activate # .venv\Scripts\activate on Windows
pip install -e ".[dev]"
pytest # sends no real mail; a fake SMTP server stands in
ruff check src tests scripts
mypy
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file mailmail-0.2.0.tar.gz.
File metadata
- Download URL: mailmail-0.2.0.tar.gz
- Upload date:
- Size: 109.1 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/6.1.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
e118da9f432076d1cffc914071269524f37e5abe8cfcb70447d3aadff74cb544
|
|
| MD5 |
f8dd30ffc9101e98f69144aa1ef19ef0
|
|
| BLAKE2b-256 |
935faa11424cc6928e45790ad9f2a419239b78f1cc796b142e2edcdc0d8bdd2b
|
Provenance
The following attestation bundles were made for mailmail-0.2.0.tar.gz:
Publisher:
publish.yml on seokhoonj/mailmail
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
mailmail-0.2.0.tar.gz -
Subject digest:
e118da9f432076d1cffc914071269524f37e5abe8cfcb70447d3aadff74cb544 - Sigstore transparency entry: 2255442703
- Sigstore integration time:
-
Permalink:
seokhoonj/mailmail@f8bd08f61dbcbd88357c46c720f7d1a630325748 -
Branch / Tag:
refs/tags/v0.2.0 - Owner: https://github.com/seokhoonj
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@f8bd08f61dbcbd88357c46c720f7d1a630325748 -
Trigger Event:
release
-
Statement type:
File details
Details for the file mailmail-0.2.0-py3-none-any.whl.
File metadata
- Download URL: mailmail-0.2.0-py3-none-any.whl
- Upload date:
- Size: 55.4 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/6.1.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
e32857cdb6ef8c299b39bf354fb4102d6cdb827bfca999ab4d09c663c59f12a5
|
|
| MD5 |
a430b59433d32b0bc4e53d23691940ec
|
|
| BLAKE2b-256 |
0c78b12f8250c0dcfc14e6a3e2ccab850a7c79d3bcea93587edb50adcf389158
|
Provenance
The following attestation bundles were made for mailmail-0.2.0-py3-none-any.whl:
Publisher:
publish.yml on seokhoonj/mailmail
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
mailmail-0.2.0-py3-none-any.whl -
Subject digest:
e32857cdb6ef8c299b39bf354fb4102d6cdb827bfca999ab4d09c663c59f12a5 - Sigstore transparency entry: 2255442707
- Sigstore integration time:
-
Permalink:
seokhoonj/mailmail@f8bd08f61dbcbd88357c46c720f7d1a630325748 -
Branch / Tag:
refs/tags/v0.2.0 - Owner: https://github.com/seokhoonj
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@f8bd08f61dbcbd88357c46c720f7d1a630325748 -
Trigger Event:
release
-
Statement type: