# Claude Code: Email Results When Tasks Finish

> Make Claude Code email you result files when a long task finishes: Gmail app passwords, msmtp with Keychain, attachments, hooks, and scheduled digests.

**Published:** 2026-08-16 | **Updated:** 2026-08-16

---


Ever sent off a long task, gone to do something else, and come back to the terminal an hour later only to find it finished thirty minutes earlier? Or the opposite — checking the window every five minutes and getting nothing else done?

For me there was one more wrinkle on top of that: **when the task produces a file.** A markdown writeup of some research, an extracted CSV, a report I asked for. If it finished while I was away, there was no good way to check it from my phone. SSH in and `cat` the file, and every line break is mangled, you're scrolling with your thumb, and if there's a table in there, forget reading it at all.

**PPT and Word files were the deal-breaker.** These days I'll ask for a slide deck or a draft report, and `.pptx`, `.docx`, `.xlsx` are **not text files.** Open one up and it's a zip archive full of XML. I actually unzipped an Excel file I had on hand and found ten-odd entries like `xl/workbook.xml` and `xl/worksheets/sheet1.xml`. Running `cat` on that just spits out garbage. Remote session or not, **the file needs an app that can open it** — and my phone has every one of those apps. It just needs the file to arrive.

So I ended up making it **email the file itself the moment a task finishes.** You get a notification, the attachment shows up, and one tap on your phone and Keynote or Word opens it right up. Building it turned up a few gotchas along the way, most of which don't show up easily in search, so here's the full writeup.

This post covers four things: getting Gmail to send mail (app passwords and a sending tool), attaching result files, wiring it into Claude Code (hooks), and getting a daily summary automatically (scheduled runs). Every command is written out so you can follow along directly. I've also compared it against other methods later in the post, in case email isn't the right fit for you.

Here's the overall picture. Two branches share the same sending path.

{{< img src="images/contents/mail-notify-flow.png" alt="Task notification email architecture diagram - the hook branch has UserPromptSubmit record a start time and Stop measure elapsed time, passing through only turns over 10 minutes, while the scheduled branch has a LaunchAgent run claude-digest daily. Both branches go through claude-mail and msmtp out to Gmail SMTP, and the app password lives in Keychain rather than a config file" >}}

> This post assumes macOS. The flow is the same on Linux, but you'll swap Keychain for something like `pass` or `gpg`. I've covered that part at the end.

## Why email — how I picked a notification method

Terminal bells, desktop notifications, Slack, email. There's no shortage of options. I went with email, for four reasons.

**It survives you stepping away.** Miss a desktop notification and it's gone; lock your screen and you'll never see it. Email follows you to your phone.

**It can carry content.** Instead of a bare "task done," you can put what the task was and how it wrapped up right in the body. You can get the full picture just by reading the email on your phone.

**The file comes along with it.** This was the deciding factor for me. Open the attachment and your phone's viewer just handles it, whether it's markdown or CSV. It's a completely different experience from reading over a terminal connection. And since it sits in your inbox, you can search for it and pull it back up later — something a notification that flashes and disappears can't do.

**No extra account needed.** Slack webhooks are nice too, but they require a workspace, which is overkill for a personal project.

The downsides are real too — it's less immediate, and setup is the most involved of the bunch. Gmail in particular **blocks plain-password logins**, so this is where most people hit a wall on the first try. Let's start there.

## Step 1: Get a Gmail app password

To send mail from a program to Gmail, you log in to Google's servers using SMTP. **SMTP** (Simple Mail Transfer Protocol) is just the protocol mail gets sent over — that's all you need to know for now.

The catch is that if you put your regular Google password in, it gets **rejected.** Google has blocked "less secure app" access since 2022. Instead, you're expected to use an **app password**, issued separately per app.

### Turn on 2-Step Verification first

App passwords only show up in the menu once 2-Step Verification is on. If it's off, you can search all you want and never find the option.

1. Go to your [Google Account security settings](https://myaccount.google.com/security)
2. Turn on **2-Step Verification** (via phone number or an authenticator app)

### Create the app password

1. Go to the [App Passwords page](https://myaccount.google.com/apppasswords)
2. Give it a name — something like `msmtp` or `claude-notify` so you'll recognize it later
3. Click **Create**, and you'll get a 16-character password

One thing matters here: **once you close this screen, you can't see it again.** If you lose it, you'll have to generate a new one. So keep the window open until you're through the next step.

> An app password is a **separate password, unrelated to your account password.** You can't log into Gmail with these 16 characters — it's scoped narrowly for connecting to a specific program like a mail sender. If it leaks, it's only good for that one purpose, and deleting that entry from your account revokes it instantly. Still, you'd rather it never leaked in the first place, which is why the next step matters.

## Hold on — you can have Claude do the rest

If you've made it this far, the part that **has to be done by a human is actually finished.** Getting the app password means logging into your Google account and clicking a button, so that can't be delegated. But everything after this is just creating files, setting permissions, and writing config — you can hand that straight to Claude Code.

Fire up `claude` in your terminal and paste the whole thing below.

````text
Set up an environment where I get Claude Code task notifications emailed to me on macOS. Go in this order:

1. Install msmtp (`brew install msmtp`). Skip if it's already there.
2. Write `~/.msmtprc`. Account is Gmail (smtp.gmail.com, port 587, tls on,
   auth on), from/user is my Gmail address. **Never write the password into
   the file** — instead have it read from Keychain with
   `passwordeval "security find-generic-password -s msmtp-gmail -a <my-address> -w"`.
   Set logfile to `~/.msmtp.log`. Run `chmod 600 ~/.msmtprc` afterward.
3. Write `~/.claude/bin/claude-mail` — subject as an argument, body from
   stdin, sent through msmtp. Make sure to include a
   `Content-Type: text/plain; charset=UTF-8` header (without it, non-ASCII
   text breaks). If there's no matching Keychain entry, exit quietly with
   `exit 0` instead of erroring.
4. Write `~/.claude/bin/claude-mail-attach` — same as #3, but attaches the
   files passed as arguments. msmtp can't assemble MIME, so build the
   message with Python's `email.message.EmailMessage` and hand it to msmtp.
   Skip missing files with just a warning. `chmod +x` both scripts.
5. Write two hooks — `UserPromptSubmit` records the per-session start time
   to a temp folder, and `Stop` measures the elapsed time and only sends
   through `claude-mail` once it crosses a threshold (env var
   `CLAUDE_NOTIFY_AFTER_SEC`, default 600 seconds). **Make sure to include
   the time gate — without it every single turn sends an email and it
   becomes spam.** Then register both hooks in `~/.claude/settings.json`.
   If settings already exist, merge in rather than overwrite.
6. Create and load a LaunchAgent under `~/Library/LaunchAgents/` for a daily
   summary email. **Don't use cron** — macOS's cron can't read the login
   Keychain and fails silently. Pick a time slightly off the hour.

Important:
- **Don't ask me for the app password, and never write it to any file.**
  I'll run the Keychain registration command
  (`security add-generic-password ...`) myself — just tell me the command
  to run.
- Explain briefly what each step does and why.
- At the end, tell me how to test that sending works.
````

Once Claude has all the files in place, the only thing you'll need to run yourself is the one line that puts the password into Keychain. **Touching the password is the only part that stays human.** I'll explain why in the next section.

> **I'd still recommend reading on.** This prompt gets things built, but it doesn't explain them. Once emails stop arriving, you'll need to know how the pieces fit together to fix it. The rest of this post is basically **the traps I hit while building this** — a button in the Keychain prompt, the time gate on the `Stop` hook, why cron fails silently. Even if Claude routes around these for you, it's worth knowing why they mattered.

## Step 2: Install a sending tool and put the password in Keychain

To send mail, you need something to do the actual sending. I'm using **msmtp**. It's lightweight, needs just one config file, and — most importantly — has a way to **avoid putting the password in a file at all.**

```bash
brew install msmtp
```

### Why not just write the password into a file

You *can* put `password your16characters` in the msmtp config and it'll work — plenty of tutorials do it that way. But that runs into three problems.

- If you back up or sync your home directory, the password goes along for the ride
- If you track your dotfiles in a repo, it ends up on GitHub without you noticing
- One screen share or screenshot exposes it

So instead, put it in macOS **Keychain**. Keychain is the password vault the OS itself manages. Run the following command and it'll prompt for the password — **it won't show on screen as you type.** You might wonder if it's actually registering your keystrokes; it is, that's normal.

```bash
security add-generic-password -s msmtp-gmail -a you@gmail.com -w
```

`-s` is the entry name (service), `-a` is the account, and `-w` means it'll prompt for a password. Paste in the 16 characters you got earlier and hit enter. Leave out any spaces. (Swap `you@gmail.com` for your own address before running this.)

To check it's actually there:

```bash
security find-generic-password -s msmtp-gmail -a you@gmail.com -w
```

This prints the password right on screen, so **don't run it where someone else can see.**

### Write the msmtp config file

Now create `~/.msmtprc`. Paste the following and just swap in your address.

```bash
# msmtp — for sending task notification emails
#
# The app password does not live in this file. It's read from macOS Keychain.

defaults
auth            on
tls             on
tls_trust_file  system
logfile         ~/.msmtp.log

account         gmail
host            smtp.gmail.com
port            587
from            you@gmail.com
user            you@gmail.com
passwordeval    "security find-generic-password -s msmtp-gmail -a you@gmail.com -w"

account default : gmail
```

The important line is the second-to-last one, **`passwordeval`.** Instead of writing the password directly, you write **a command that fetches the password.** Every time msmtp sends, it runs this command and pulls the password from Keychain. The file itself never has one.

Lock down the file permissions too.

```bash
chmod 600 ~/.msmtprc
```

> You'll often see it claimed that "msmtp refuses to run if permissions are too loose." When I actually checked, that's only true if you write a plaintext `password` line. A file that only uses `passwordeval`, like ours, runs fine even at 644 (confirmed on msmtp 1.8.34). So this `chmod` isn't something msmtp forces on you — it's **just good hygiene on our part.** Even with no password in the file, there's no reason to leave your account address and server info visible to anyone who happens to look.

### Send a first test message

```bash
printf 'Subject: Test\n\nHello\n' | msmtp you@gmail.com
```

This should pop up a prompt asking **whether macOS should allow Keychain access.** Be sure to click **"Always Allow."** If you click just "Allow," the prompt reappears every time you send, and later, in an automated run, it'll just hang there waiting for a click that never comes. That one button is enough to make you spend a while wondering "why doesn't this work automatically?"

If the mail shows up in your inbox, you're halfway there. If not, check the log.

```bash
tail -20 ~/.msmtp.log
```

If you see `authentication failed`, either the app password is wrong or a stray space snuck in. To re-register it in Keychain, delete the existing entry and add it again.

```bash
security delete-generic-password -s msmtp-gmail -a you@gmail.com
security add-generic-password -s msmtp-gmail -a you@gmail.com -w
```

## Step 3: Wrap it in a convenient command

Assembling headers with `printf` every time is tedious, so wrap it in a short script. Save it as `~/.claude/bin/claude-mail`.

```bash
#!/bin/bash
# Send a task notification email. Subject as an argument, body from stdin.
#   echo "body" | claude-mail "subject"
#
# If there's no app password in Keychain, exit quietly. A failed
# notification should never block the actual task.

set -u
TO="${CLAUDE_NOTIFY_TO:-you@gmail.com}"
SUBJECT="${1:-Claude Code Notification}"
MSMTP="$(command -v msmtp || echo /opt/homebrew/bin/msmtp)"

[ -x "$MSMTP" ] || { echo "msmtp not found" >&2; exit 0; }
security find-generic-password -s msmtp-gmail -a "$TO" -w >/dev/null 2>&1 || {
  echo "No msmtp-gmail entry in Keychain — skipping send" >&2; exit 0; }

BODY="$(cat)"
{
  printf 'To: %s\n' "$TO"
  printf 'Subject: %s\n' "$SUBJECT"
  printf 'Content-Type: text/plain; charset=UTF-8\n'
  printf '\n%s\n' "$BODY"
} | "$MSMTP" -t 2>>"$HOME/.msmtp.log"
```

Once it's saved, make it executable.

```bash
mkdir -p ~/.claude/bin
chmod +x ~/.claude/bin/claude-mail
```

Two things here are deliberate.

**The `Content-Type` line** is required, or non-ASCII body text arrives garbled. UTF-8 has to be declared explicitly.

The subject line is a slightly different story. Strictly speaking, putting non-ASCII text directly in a mail header isn't spec-compliant — the correct approach is to encode it as something like `=?utf-8?B?...?=`. In practice, though, most modern mail servers accept UTF-8 headers as-is, so this script works fine for real-world use. I've been running it like this for months myself. That said, if you're sending to something older, like a legacy corporate mail server, use the attachment version further down — **it handles subject encoding properly on its own.**

**If there's no Keychain entry, it exits quietly with `exit 0`.** The notification is a side feature — if it fails and that blocks the actual task, that's a real problem. This judgment call matters even more once it's wired into a hook.

Now you can use it like this.

```bash
echo "this is the body" | ~/.claude/bin/claude-mail "this is the subject"
```

### Sending result files as attachments

Here's where you hit a limitation. **msmtp doesn't attach files.** Its job is strictly "hand a finished message to the server." A message with an attachment has to be built as **MIME**, bundling multiple pieces together — and that assembly isn't msmtp's job. Check `msmtp --help` and there's no attachment option in sight.

So we leave the assembly to Python and hand the sending back to msmtp. The standard library's `email` module does all the MIME assembly for you, so there's nothing extra to install.

> You'll often see it said that Python "comes bundled with macOS." More precisely, it **comes with the Xcode Command Line Tools.** If you installed msmtp via Homebrew earlier, Homebrew requires those tools, so it's already there. If `python3 --version` prints a version, you're set.

Save this as `~/.claude/bin/claude-mail-attach`.

```bash
#!/bin/bash
# Sends with file attachments. Body from stdin, attachments as arguments.
#   echo "body" | claude-mail-attach "subject" report.md summary.csv

set -u
TO="${CLAUDE_NOTIFY_TO:-you@gmail.com}"
SUBJECT="${1:-Claude Code Notification}"; shift || true
MSMTP="$(command -v msmtp || echo /opt/homebrew/bin/msmtp)"

[ -x "$MSMTP" ] || { echo "msmtp not found" >&2; exit 0; }
security find-generic-password -s msmtp-gmail -a "$TO" -w >/dev/null 2>&1 || {
  echo "No msmtp-gmail entry in Keychain — skipping send" >&2; exit 0; }

BODY="$(cat)"
python3 - "$TO" "$SUBJECT" "$BODY" "$@" <<'PY' | "$MSMTP" -t 2>>"$HOME/.msmtp.log"
import sys, mimetypes, pathlib
from email.message import EmailMessage

to, subject, body, *files = sys.argv[1:]
msg = EmailMessage()
msg["To"] = to
msg["Subject"] = subject
msg.set_content(body)                    # non-ASCII body encoding is handled automatically here

for f in files:
    p = pathlib.Path(f)
    if not p.is_file():
        print(f"Skipping attachment (file not found): {f}", file=sys.stderr)
        continue
    ctype, _ = mimetypes.guess_type(p.name)
    maintype, _, subtype = (ctype or "application/octet-stream").partition("/")
    msg.add_attachment(p.read_bytes(), maintype=maintype,
                       subtype=subtype, filename=p.name)

sys.stdout.write(msg.as_string())
PY
```

Give it execute permission with `chmod +x ~/.claude/bin/claude-mail-attach`, then use it like this.

```bash
echo "The research is done. Please check the attachment." \
  | ~/.claude/bin/claude-mail-attach "[Claude] Research results" report.md summary.csv
```

Compared to `claude-mail`, here's what changed.

**`set_content` takes care of UTF-8 for you.** In the earlier script we wrote the `Content-Type` header by hand. Here, `EmailMessage` looks at the body and adds whatever headers and encoding it needs — you don't need to, and shouldn't, write it yourself.

**MIME type is guessed from the file extension.** `mimetypes.guess_type` sees `.md` and returns `text/markdown`, `.csv` and returns `text/csv`. Unknown extensions fall back to `application/octet-stream`, which just means "plain binary" — the attachment still goes through fine.

**Missing files are skipped.** It logs a warning and keeps going. If one missing file kills the whole email, that's a failed notification system.

When I actually sent a markdown file and a CSV with non-ASCII filenames as attachments, they arrived in my inbox with the filenames intact, and each was correctly typed as `text/markdown` and `text/csv` respectively. Passing a nonexistent file as one of the arguments didn't stop the other two from going out fine.

> **Watch the size.** Gmail's attachment limit is **25 MB.** And since attachments travel over email as Base64, the actual payload runs **about 33% larger** than the original file. For big output, either compress it (`zip`) or send just a summary and a path instead of the file itself.

## Step 4: Wire it into Claude Code — hooks

This is where it gets real. A **hook** is a command Claude Code runs automatically at a given point — things like "run this script when the turn ends."

We'll use two.

| Hook | When it fires | What it does here |
|---|---|---|
| `UserPromptSubmit` | When you send a message | Records the start time |
| `Stop` | When Claude finishes responding | Measures elapsed time and sends the email |

### Why track the timestamp separately — this is the key part

The `Stop` hook fires at the **end of every turn.** Even asking "hi" triggers it. Send unconditionally and you'll get dozens of emails a day.

So instead, **record the start time and only send once the elapsed time crosses a threshold.** Without this gate, it's not a notification system — it's a spam generator.

### The start hook

`~/.claude/hooks/notify-start.sh`:

```bash
#!/bin/bash
# UserPromptSubmit — records this turn's start time.
set -u
D="${TMPDIR:-/tmp}/claude-notify"; mkdir -p "$D"
SID="$(python3 -c 'import json,sys;print(json.load(sys.stdin).get("session_id","unknown"))' 2>/dev/null || echo unknown)"
date +%s > "$D/$SID.start"
exit 0
```

Hooks receive JSON on stdin. This pulls the session ID out of it and writes the start time to a file named after that session. Since each session gets its own file, multiple windows won't interfere with each other.

### The stop hook

`~/.claude/hooks/notify-stop.sh`:

```bash
#!/bin/bash
# Stop — only sends an email if this turn ran longer than the threshold. Default 10 minutes.
set -u
THRESHOLD="${CLAUDE_NOTIFY_AFTER_SEC:-600}"
D="${TMPDIR:-/tmp}/claude-notify"

IN="$(cat)"
read -r SID CWD <<EOF
$(printf '%s' "$IN" | python3 -c '
import json,sys
d=json.load(sys.stdin)
print(d.get("session_id","unknown"), d.get("cwd","?"))
' 2>/dev/null || echo "unknown ?")
EOF

F="$D/$SID.start"
[ -f "$F" ] || exit 0
START="$(cat "$F")"; rm -f "$F"
ELAPSED=$(( $(date +%s) - START ))
[ "$ELAPSED" -ge "$THRESHOLD" ] || exit 0
MIN=$(( ELAPSED / 60 ))

printf 'Working directory: %s\nElapsed time: %d min\nSession: %s\n' "$CWD" "$MIN" "$SID" \
  | "$HOME/.claude/bin/claude-mail" "[Claude] Task complete — $(basename "$CWD") (${MIN} min)"
exit 0
```

Here's the order it reads in: if there's no start file, it just exits (the session started before the hook was in place). If there is one, it measures elapsed time — under the threshold, it exits quietly. Only past the threshold does it send the email.

Both files need execute permission.

```bash
mkdir -p ~/.claude/hooks
chmod +x ~/.claude/hooks/notify-start.sh ~/.claude/hooks/notify-stop.sh
```

### Register the hooks

Add the following to `~/.claude/settings.json`. If the file already exists, just add the `hooks` key.

```json
{
  "hooks": {
    "UserPromptSubmit": [
      {
        "hooks": [
          { "type": "command", "command": "\"$HOME/.claude/hooks/notify-start.sh\"", "timeout": 5 }
        ]
      }
    ],
    "Stop": [
      {
        "hooks": [
          { "type": "command", "command": "\"$HOME/.claude/hooks/notify-stop.sh\"", "timeout": 30, "async": true }
        ]
      }
    ]
  }
}
```

Notice **`"async": true`** on the `Stop` side. It means the process doesn't wait for the email to finish sending before moving on. Without it, a slow network would leave your response hanging for a few extra seconds after it's actually done.

Hooks apply **starting with the next session.** They won't take effect in your current window — open a new one to test.

### Try it out

It's easier to test with the threshold turned down. It's controlled by an environment variable, so drop it to 10 seconds.

```bash
CLAUDE_NOTIFY_AFTER_SEC=10 claude
```

Ask anything that takes over 10 seconds and the email should arrive. Once you've confirmed it works, just run `claude` normally and it goes back to the 10-minute default.

## Step 5: Get a daily summary — scheduled runs

Instead of a notification every time a task finishes, you might want **a rolled-up daily digest.** I have mine collect the day's commits across the repos under `~/Projects` and send them as a single email at night.

```bash
#!/bin/bash
# Collects recent commits across repos under ~/Projects and emails them.
#   claude-digest          → last 1 day
#   claude-digest 7 weekly → last 7 days, labeled "weekly" in the subject
set -u
DAYS="${1:-1}"
LABEL="${2:-daily}"
ROOT="$HOME/Projects"

BODY="$(
  cd "$ROOT" || exit
  found=0
  while IFS= read -r g; do
    r="$(dirname "$g")"
    log="$(git -C "$r" log --since="${DAYS} days ago" --format='  %ad  %s' --date=format:'%m/%d %H:%M' 2>/dev/null)"
    [ -n "$log" ] || continue
    found=1
    printf '## %s\n%s\n\n' "${r#./}" "$log"
  done < <(find . -maxdepth 3 -name .git -type d 2>/dev/null | sort)
  [ "$found" = 1 ] || printf 'No commits in the last %s days.\n' "$DAYS"
)"

printf '%s\n' "$BODY" | "$HOME/.claude/bin/claude-mail" "[Claude] ${LABEL} task summary — $(date '+%Y-%m-%d')"
```

Save it as `~/.claude/bin/claude-digest`, make it executable, and run it by hand first.

```bash
chmod +x ~/.claude/bin/claude-digest
~/.claude/bin/claude-digest
```

### cron fails silently

Now for scheduling this to run daily on its own — and **this is the biggest trap in the whole post.**

"Scheduled" usually makes people reach for `cron`. But run this through cron on macOS and **the email never arrives.** No error. No log entry. It just quietly does nothing.

The reason is **Keychain.** cron runs outside your GUI user session, so the login Keychain stays locked, and `security find-generic-password` fails. The `claude-mail` we built earlier is designed to exit quietly when it can't read Keychain (which was the right call inside a hook), so the whole thing vanishes without a trace.

The fix is to use macOS's own scheduling mechanism, **LaunchAgent.** Since it runs inside your login session, Keychain stays unlocked.

`~/Library/LaunchAgents/com.example.claude-digest.daily.plist`:

```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>Label</key>
  <string>com.example.claude-digest.daily</string>
  <key>ProgramArguments</key>
  <array>
    <string>/Users/youraccount/.claude/bin/claude-digest</string>
    <string>1</string>
    <string>daily</string>
  </array>
  <key>StartCalendarInterval</key>
  <dict>
    <key>Hour</key><integer>23</integer>
    <key>Minute</key><integer>47</integer>
  </dict>
  <key>StandardErrorPath</key>
  <string>/Users/youraccount/.claude/digest.log</string>
  <key>StandardOutPath</key>
  <string>/Users/youraccount/.claude/digest.log</string>
</dict>
</plist>
```

You can't use `~` in the path, so spell out the full path. Check it with `echo $HOME` and use that value.

Validate the syntax and register it.

```bash
plutil -lint ~/Library/LaunchAgents/com.example.claude-digest.daily.plist
launchctl load ~/Library/LaunchAgents/com.example.claude-digest.daily.plist
launchctl list | grep claude-digest
```

If the name shows up in that last command, it's registered. To change the time, edit the plist, then `launchctl unload` and `load` it again.

> `load`/`unload` are commands Apple marked deprecated a long time ago. They still work fine, and most search results show them this way, but the modern equivalents are `launchctl bootstrap gui/$(id -u) <plist>` and `launchctl bootout gui/$(id -u)/<label>`. If you're learning this fresh, I'd go with those instead.

Picking an off-hour time like 23:47 instead of a round number is intentional. A lot of scheduled jobs pile up right at the top of the hour, and shifting a few minutes off avoids that crowd.

## Where the Gmail connector fits — sending and reading are different jobs

By now you might be thinking, "Claude has a Gmail connector — can't I just use that?"

Good question, and the answer is: **they do different jobs.**

| Aspect | Gmail Connector | msmtp |
|---|---|---|
| Primary use | **Reading**, searching, drafting mail | **Sending** mail |
| Auth | Google account linking (OAuth) | App password |
| When it works | Mid-conversation, while Claude is running | Whenever the command runs |
| Scheduled runs | Not possible | Possible |
| Usable in hooks | No | Yes |

The deciding difference is the **last two rows.** The connector works as a tool Claude calls mid-conversation, so it's unavailable when a LaunchAgent is running on its own at 3 a.m. Hooks are the same story — a hook runs a shell script, and there's no way for a shell script to reach the connector.

Does that make the connector useless? Not at all. **It was genuinely useful for verifying this whole setup.**

After sending an email, checking "did it actually arrive" usually means opening Gmail yourself. With the connector attached, you can check right there in the conversation. SMTP returning `250 OK` doesn't necessarily mean it landed in the inbox — it could get flagged as spam, or the encoding could be broken. **You need to check both the sending log and the actual inbox** to call the verification done.

So it comes down to this: **msmtp sends, the connector checks.** They're not competing — they're a pair.

> To connect the Gmail connector, add Gmail under the connectors section of your Claude settings and grant your Google account permission. It's unrelated to the app password — it's a read permission, so check what scope you're granting before connecting.

## Are there other ways to do this? Four alternatives

Email isn't the only answer. If the goal is just "get the results of a long task onto my phone," there are several ways to do it, and I weighed these before settling on email. If your situation is different, one of these might suit you better.

### 1. Remote Control — bring the session itself to your phone

This is the most direct approach. Claude Code has a feature that lets you **pick up a local session from your phone or browser.** Type `/remote-control` (or `/rc` for short) in the terminal, or launch it that way from the start with `claude --remote-control`.

```bash
claude --remote-control "my project"
```

That session then opens in the Claude app or on claude.ai. **Execution and file access still happen on your Mac** — your phone is just the window. So asking "show me the report you just made" from your phone has Claude read the local file and display it in the chat. Type `@` and your project's file paths autocomplete. Push notifications work too — enable them in `/config` and you'll get pinged when Claude decides something's worth flagging, or when it needs permission approval.

You might wonder why I built email if this exists — but they serve different purposes.

- **The session has to stay alive.** The local process *is* the session, so closing the terminal or shutting down your Mac takes it offline. Email, on the other hand, has already left — it stays put even if the sender goes down.
- **It's reading, not receiving.** You're viewing file contents in a chat, and nothing actually lands on your phone. To find it again later, you have to reopen the session.
- **PPT, Word, and Excel don't work this way.** Same problem as before. A chat window is ultimately just a place to display text, so a non-text document, at best, gets reduced to a few lines pulled out of it — no slides, no formatting, no tables. **Seeing a document as a document means the file itself has to reach your phone.** This was the main reason I built the email version.
- There are conditions too — it's in research preview, available on Pro/Max/Team/Enterprise plans, and doesn't work with API key auth.

**If you want to step in mid-task, this is overwhelmingly the better option** — you can redirect from your phone or respond to a permission request. But for "quietly check the finished result later," email fit better for me. The two aren't mutually exclusive; you can run both.

### 2. ntfy — five seconds to set up, three hours to keep an attachment

[ntfy](https://ntfy.sh/) is a service that pushes a notification to your phone with a single HTTP request. No account, no config file. Pick a topic name, subscribe to it in the app, and send to that address — done.

```bash
# Send with a file attached
curl -T report.md -H "Filename: report.md" \
     -H "Title: Task complete" https://ntfy.sh/your-chosen-topic-name
```

Here's what the attachment info looks like in the response when I actually tried it (only the attachment portion is shown, out of the full response).

```json
{
  "attachment": {
    "name": "report.md",
    "size": 40,
    "url": "https://ntfy.sh/file/Ktw6QNT7b42j.txt"
  }
}
```

On the public server, **attachments are capped at 15 MB and deleted after 3 hours.** The setup effort is dramatically lower than anything else here, and it's more real-time than email too.

The problem is **security.** On the public server, **anyone who knows the topic name can subscribe** — there's no other authentication. Same goes for attachment URLs. Even a long, hard-to-guess name isn't a lock, just an unpublished state. **Don't put work output or personal data through it.** Use it as a signal for things like "build finished," or self-host your own server with access control if you need more.

### 3. Sync folders — the simplest option

Just copy the output into an iCloud Drive or Dropbox folder.

```bash
cp report.md ~/Library/Mobile\ Documents/com~apple~CloudDocs/claude-out/
```

That one line and it opens in your phone's Files app. There's basically nothing to configure, and the size limit is whatever your cloud storage allows. For large files, this is clearly the better option.

The tradeoff is **no notification.** If you don't know it's done, you won't think to check the folder. So this works better **paired with a notification** rather than used alone — send "it's done, here's the summary" over email, and leave the file in the sync folder.

If setting this up feels like a hassle, you can also just tell Claude something like "after the task finishes, copy file A to folder B and let me know."

### 4. git push — when the output belongs in a repo

If the result is going to end up in a repo anyway, committing and pushing it is the most natural move. It reads straight from GitHub's phone app or the web, and markdown even renders. Having a history is a plus nothing else here offers.

This obviously only applies to **things worth committing.** Push intermediate outputs or temp files into it and the repo just gets messy.

### Summing it up

| Method | File delivery | Notification | Retrieval later | Requirements |
|---|---|---|---|---|
| Email (this post) | Attachments up to 25 MB | Yes | Searchable in your inbox | App password issuance, ~30 min setup |
| Remote Control | Read in chat | Push | Have to reopen the session | Session must stay live, paid plan |
| ntfy | 15 MB, 3 hours | Push | Gone after 3 hours | Public server is essentially unprotected |
| Sync folder | Effectively unlimited | None | Right there in the folder | Cloud account |
| git push | Whatever the repo can hold | None | Permanent in commit history | Only for things worth committing |

I picked email because it was **the only option that gets you the file, the notification, and long-term storage all at once.** The file arrives, the notification comes, and I can still search it up six months later. The tradeoff is that it takes the most setup — which is also why this post ran so long.

One more thing worth adding: once it's set up, you don't have to touch it again. I never reopened a single config file while writing this post.

## When it's not working — troubleshoot by symptom

These are the things I actually hit while building this.

**No email arrives at all** — Check `tail -20 ~/.msmtp.log` first. If the log is empty, it never even attempted to send. Usually `claude-mail` couldn't find the Keychain entry and quietly bailed out. Confirm `security find-generic-password -s msmtp-gmail -a you@gmail.com -w` actually works.

**`authentication failed` shows up** — The app password is wrong. Check that you entered all 16 characters with no spaces, and if that doesn't fix it, generate a new one.

**It works by hand but not through the scheduled run** — Nine times out of ten, that's cron. Switch to the LaunchAgent approach above.

**The Keychain access prompt shows up every time** — You clicked "Allow" on the first run instead of "Always Allow." Fix the entry's access control in Keychain Access, or delete and re-register it.

**Body text arrives garbled** — The `Content-Type: text/plain; charset=UTF-8` header is missing.

**Only the subject arrives garbled** — The receiving server doesn't accept raw UTF-8 in headers. The attachment version (`claude-mail-attach`) encodes the subject to spec automatically.

**Too many emails** — There's no time gate, or the threshold is too low. Raise `CLAUDE_NOTIFY_AFTER_SEC`.

## A note on accessibility — more channels is better

Since this blog covers accessibility, one thing is worth adding.

It's common to signal task completion with **sound alone** — a terminal bell or system sound. But sound is a single channel that depends on hearing, and if you can't hear it, you simply miss it. That's true for anyone who's deaf or hard of hearing, but also for someone with their earbuds out or sitting in a loud office.

What makes email notifications work well is that **the reader gets to choose how to consume it.** Read it visually, have a screen reader read it aloud, or just notice the phone buzz. Making the same information available through multiple senses — that's exactly what WCAG means by **perceivability.**

So it's worth putting some care into how you write the email subject too. **Write it so the subject alone tells you what happened** — something like `[Claude] Task complete — codeslog (23 min)`. Screen reader users skim an inbox by subject line, and if every subject just says "Notification," the list becomes a wall with no information in it. It's the same principle behind [why link text should describe its destination]({{< relref "/posts/markdown-syntax-guide" >}}).

## On Linux

Only the Keychain part changes — everything else is the same. Swap the command in `passwordeval` for whatever password manager your environment uses.

```
# If you use pass
passwordeval "pass show gmail/app-password"

# If you use a gpg-encrypted file
passwordeval "gpg --quiet --for-your-eyes-only --decrypt ~/.msmtp-password.gpg"
```

For scheduled runs, you can just use cron or a systemd timer as usual on Linux — macOS's Keychain problem doesn't exist there. If you're using gpg, though, make sure to check whether the agent is locked.

## TL;DR

- Gmail won't accept a plain password over SMTP. **Turn on 2-Step Verification → get a 16-character app password.**
- **Only the app password issuance needs a human — everything else can go to Claude.** The prompt is right there in the post.
- PPT, Word, and Excel aren't text — they're zip archives full of XML. **A terminal can't open them, and neither can a chat window.** The file has to reach your phone.
- App passwords belong in **Keychain**, not a config file — read via msmtp's `passwordeval`.
- On the first send, click **"Always Allow"** in the Keychain prompt. Just "Allow" blocks automated runs.
- Garbled body text usually means the `Content-Type: ... charset=UTF-8` header is missing. The attachment version handles subject encoding automatically.
- **msmtp can't build attachments.** Leave MIME assembly to Python's `email` module and hand off just the sending. Gmail's cap is 25 MB.
- The `Stop` hook fires on every turn. **No time gate means spam.**
- Scheduled runs need **LaunchAgent, not cron.** cron can't read Keychain and fails silently.
- **The Gmail connector is for reading and checking, msmtp is for sending.** Hooks and scheduled runs can't use the connector.
- Want to step in mid-task? Use **Remote Control.** Want to check a finished result later? Use email. You can run both.
- Don't rely on sound alone for notifications — write the subject so it tells the story by itself.

{{< faq >}}

## References

- [Google App Passwords guide](https://support.google.com/accounts/answer/185833)
- [msmtp official documentation](https://marlam.de/msmtp/msmtp.html)
- [Claude Code hooks documentation](https://code.claude.com/docs/en/hooks-guide)
- [Claude Code Remote Control documentation](https://code.claude.com/docs/en/remote-control)
- [Apple launchd documentation](https://developer.apple.com/library/archive/documentation/MacOSX/Conceptual/BPSystemStartup/Chapters/CreatingLaunchdJobs.html)
- [Python `email.message` documentation](https://docs.python.org/3/library/email.message.html)
- [ntfy publish documentation — attachments](https://docs.ntfy.sh/publish/#attachments)

