Skip to main content

mailmail

check PyPI Python License

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

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 — 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"}
    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 --version in a terminal (on Windows it may be py --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. provider is naver or gmail.
  • [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.

  1. Turn on Naver ID → Security → Two-step verification. Without it, the next menu does not appear at all.
  2. 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 mailmail in 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.
  3. 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

  1. Turn on two-step verification first. Without it, the app-password menu does not appear.
  2. Generate one at myaccount.google.com/apppasswords.
  3. 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 cc and there is no cc. What you don't name doesn't ride along.
  • bcc is invisible to the recipients. Blind recipients don't see each other either.
  • html and body should 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 sendto/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 empty accepted and 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 .zip or .tar.gz doesn'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

mailmail-0.1.0.tar.gz (99.6 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

mailmail-0.1.0-py3-none-any.whl (50.7 kB view details)

Uploaded Python 3

File details

Details for the file mailmail-0.1.0.tar.gz.

File metadata

  • Download URL: mailmail-0.1.0.tar.gz
  • Upload date:
  • Size: 99.6 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.14

File hashes

Hashes for mailmail-0.1.0.tar.gz
Algorithm Hash digest
SHA256 e2d4e137a67f41fbc420d1e0f2387771ffb693d6ffd85a8d1982fcbdac7bb667
MD5 a701479d500d013686a7c959668e4607
BLAKE2b-256 82faa54ce4a53e30a87e3bc6fb971092d94460731019e6670d51edad68d12cd7

See more details on using hashes here.

Provenance

The following attestation bundles were made for mailmail-0.1.0.tar.gz:

Publisher: publish.yml on seokhoonj/mailmail

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file mailmail-0.1.0-py3-none-any.whl.

File metadata

  • Download URL: mailmail-0.1.0-py3-none-any.whl
  • Upload date:
  • Size: 50.7 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.14

File hashes

Hashes for mailmail-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 8e8e5d2cde976b02ad63b7de60fb1e69b60a92d0c6fac21fb3766139d01a4d02
MD5 4a840b2d2cbc88c89644b7fe5bb6708c
BLAKE2b-256 ba7a12aae66f00bb0b22b15b2b953ed12b78b19d4f17f287be4e19090a9d85fd

See more details on using hashes here.

Provenance

The following attestation bundles were made for mailmail-0.1.0-py3-none-any.whl:

Publisher: publish.yml on seokhoonj/mailmail

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

0.2.0

2 files

This release

0.1.0 This release

2 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