Skip to content
Back to skills

Heinzel Email

ASecurity

Send an email about a managed server — ad-hoc text

  • 96 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added October 10, 2026
ai-agentsgoshellgit

Works with

  • claude code

Security analysis

A100/100

Scanned October 10, 2026

npx -y skills add wintermeyer/heinzel --skill heinzel-email --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Heinzel Email?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Heinzel Email
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/wintermeyer-heinzel-email/badge)](https://www.skillsdirectory.com/skills/wintermeyer-heinzel-email)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: heinzel-email
argument-hint: "[hostname] [recipient]"
description: Send an email about a managed server — ad-hoc text
  and/or file attachments. Use when the user asks to "email me
  X", "send X by mail", "mail this to <address>", "attach
  /var/log/foo to an email", or "send a report by email". The
  first email per host asks where to send from (local
  workstation vs the server itself). On the remote path,
  prefers the existing MTA (postfix, sendmail, msmtp,
  mail/mailx) and asks before installing one. Sends as a
  non-root user via `runuser`/`su -`. Attachments check sender
  readability, size, and offer a content preview before
  sending. **Never run automatically** — only on explicit user
  request.
---

# heinzel-email

Send ad-hoc text and file attachments by email about a managed
server. The report content is *about* the server; whether the
mail leaves *from* the server or *from* your workstation is a
per-host preference that's asked once and remembered.

The full heinzel first-connection onboarding pipeline still
applies before any of this runs.

## Workflow

1. **Onboarding pipeline.** Run `rules/first-connection.md` in
   full. No "quick question" exception — even a one-line email
   still goes through blacklist/read-only check, DNS alias
   detection, SSH user lookup, OS detection, server memory
   load, and activity check.

2. **Load overrides.** Apply the heinzel rule-override chain
   (later wins):
   - `memory/custom-rules/heinzel-email.md` if present.
   - `memory/servers/<host>/memory.md` (recipient, source,
     transport, policies — see "Per-server memory" below).
   - `memory/servers/<host>/rules.md` if present.
   `memory/custom-rules/all.md` is already loaded by the
   session-start preflight — do not re-read it.

3. **Resolve recipient.** In order of precedence:
   1. **User said an explicit address** ("send to alice@…",
      "mail it to bob@example.com") → use that. Don't override
      it with stored values.
   2. **Per-server memory** — `memory/servers/<host>/memory.md`
      has an `Alert email:` line (the established pattern) →
      use that.
   3. **"Send me" shorthand** ("email me", "send me", "mail it
      to me") and the user's default email is recorded in
      Claude Code's auto-memory (the `MEMORY.md` index will
      show a "Default email" entry under User → load that file
      and use the address) → use it without prompting. Still
      write the address back to the per-server `memory.md` as
      `- Alert email: <addr>` on first use so the skill stays
      self-contained for future runs.
   4. Otherwise, ask once via the picker, then persist to
      `memory.md` as in (3).
   - Never guess or invent a recipient.

4. **Consent gate 0 — sender side (local vs remote).** Check
   `memory.md` for `Email source: local | remote`:
   - `local` → jump to step **5L**.
   - `remote` → continue with step **5R**.
   - Missing → ask the user with four options:
     > "First-time email for `<host>`. Send from where?"
     - **Remote — once**: send from the server this time, ask
       again next time.
     - **Remote — always for this host**: write
       `Email source: remote` into `memory.md`, then continue
       to 5R.
     - **Local — once**: send from this workstation this time,
       ask again next time.
     - **Local — always for this host**: write
       `Email source: local` into `memory.md`, then jump to
       5L.

   Why this gate exists: some hosts have great mail
   infrastructure (e.g. a working local Postfix relaying to
   an external smarthost); others have none and the user may
   prefer not to install anything on them. Either side is a
   perfectly valid choice the user should be able to lock in
   once.

### 5L. Local-side workflow

**5L.1** Probe the workstation for a local transport:
`command -v mail || command -v mailx || command -v sendmail
|| command -v msmtp`. On macOS, also confirm Postfix is
loaded: `launchctl print system/com.apple.postfix.master`
exits 0.

**5L.2** **No install fallback locally.** If nothing's there,
refuse cleanly: "no mail tooling on this workstation —
install msmtp locally and rerun, or pick remote next time by
deleting `Email source: local` from `memory.md`." Do not
auto-install on the workstation.

**5L.3** Skip Gate A (5R.2) and Gate B (5R.3) entirely —
those are remote-only.

**5L.4** Skip the sender-identity step (5R.4). Local sending
runs as the current shell user.

**5L.5** Continue at the shared step **6 (Compose)**. At step
**7 (Send)** the command runs locally. At step **8 (Verify)**
inspect local logs:
- macOS: `log show --style compact --last 1m --predicate
  'process == "smtpd" OR process == "smtp"'`
- Linux workstation: `journalctl --since "1 minute ago" -t
  postfix` or `tail -50 /var/log/mail.log`

### 5R. Remote-side workflow

**5R.1 Resolve transport** — probe in this order on the
remote host:
- `command -v mail || command -v mailx || command -v s-nail`
- `command -v sendmail`
- `command -v msmtp`
- `systemctl is-active postfix opensmtpd exim4` (any active)

**5R.2 Consent gate A — existing MTA.** If 5R.1 found a
working MTA, check `memory.md` for `Email send policy:
<always|never>`:
- `always` → proceed silently.
- `never` → refuse with the reason; do not send.
- missing → ask:
  > "Use the MTA already on `<host>` (`<detected tool>`)?"
  - **Once** — send this time, ask again next time.
  - **Always** (recommended) — write `Email send policy:
    always` into `memory.md`, send.
  - **Never** — write `Email send policy: never` into
    `memory.md`, abort.

If 5R.1 found a working MTA, skip 5R.3 entirely.

**5R.3 Consent gate B — install a new MTA.** Only reached
when 5R.1 found nothing. Check `memory.md` for `MTA install
policy: <always|never>`:
- `always` → install silently using the OS-family default
  below.
- `never` → refuse; do not install, do not send.
- missing → ask:
  > "No MTA found on `<host>`. Install one?"
  - **Once** — install this time, ask again next time.
  - **Always** — write `MTA install policy: always` into
    `memory.md`, install.
  - **Never** — write `MTA install policy: never` into
    `memory.md`, abort.

Before any install — whichever answer allowed it — run the
mandatory service class conflict check from
`rules/service-class-check.md`. An MTA is a service class:
the check can find a member the 5R.1 transport probe missed
(e.g. an installed-but-stopped postfix), and a second MTA
must never be added without explicit user approval.

Install targets (OS-family defaults):
- Debian/Ubuntu: `apt-get install -y msmtp-mta bsd-mailx`
- RHEL/Fedora: `dnf install -y msmtp s-nail`
- SUSE: `zypper install -y msmtp s-nail`
- FreeBSD: `pkg install -y msmtp` (`mail(1)` is in base)
- macOS as a managed target: do **not** install. Use
  `/usr/bin/mail` if a working Postfix is already
  configured; otherwise refuse cleanly and explain
  (residential macOS rarely sends).

Before installing, surface the deliverability caveat: the
server's IP probably has no PTR/SPF/DKIM, so mail to
gmail-style providers will likely be filtered. Recommend a
smarthost relay (msmtp config) if the user has one. If a
smarthost is configured during install, follow
`rules/backups.md` (back up `/etc/msmtprc` before edits) and
store credentials with `0600 root:root`.

**5R.4 Pick the sender identity — least privilege.** Sending
mail almost never needs root. Choose the UID for the send,
in this order:

1. Current SSH user is non-root → use that user.
2. Current SSH user is root (common on hosts that allow only
   root SSH with no usable sudo, or after privileged earlier
   work in the session):
   - First check `memory.md` for an `Email sender:` line —
     if present, use it without re-probing.
   - Otherwise read `memory/user.md` for the preferred
     username.
   - `id <user>` to verify the account exists on the host.
   - `su - <user> -c 'command -v <transport>'` to verify the
     account can invoke the chosen transport. If group perms
     on `/etc/msmtprc` block it, fall to case 3.
   - Drop privileges for the send only:
     `runuser -u <user> -- sh -c '…'` (Linux util-linux) or
     `su - <user> -c '…'` (portable, FreeBSD).
3. If no non-root sender is viable, send as root and tag the
   report **WARN** with the reason. Never invent a user.

The install step (5R.3) still requires root/sudo — that is
the only root-privileged operation in the workflow.

## Shared steps (both 5L and 5R converge here)

6. **Compose.**
   - Default subject: `[heinzel/<short-hostname>] <topic>` —
     even on the local-side path, the subject names the
     server the report is *about*.
   - Body: plain text, in this order and nothing else — one
     line naming what ran and on which host, the report or the
     user's content verbatim, then the Heinzel closing (see
     **Greeting** and **Signature** below). If the user asks to
     send command output, run the command and embed
     stdout/stderr inline in a fenced block in place of the
     report.
   - **Ceiling: 5 lines of your own text** around that block,
     per "Talking to Humans" in `CLAUDE.md`. No cover sentence,
     no restatement of the subject, no "as requested", no recap
     under the report. A report goes in exactly as its skill
     produced it — nothing added before or after it.
     Attachments get one line each: name and size, not a
     description of the contents.

   **Attachments** (e.g. "email me /var/log/auth.log"):

   a. Per file, `stat` the path on the side that holds it
      (remote when the path lives on the server; local
      otherwise). Refuse if it doesn't exist; never invent
      paths.
   b. **Readability check.** Confirm the chosen sender UID
      (5R.4 result on the remote path, current shell user on
      the local path) can read it: `[ -r path ]` under that
      UID. If not, do not silently escalate. Show the perms
      (`ls -l`) and ask:
      - skip the file (default offered);
      - copy via root to a temp file `0600 <sender>:<sender>`
        the sender can read, then clean up after send;
      - send as root with a **WARN**.
   c. **Size check.** If the file is over 10 MB, show the
      size and Gmail's 25 MB cap, then ask: send as-is, gzip
      first (recommended for text logs), or skip.
   d. **Sensitive-content nudge.** Log files often contain
      secrets, IPs, hostnames, internal email addresses.
      Show the file's `head -5` and ask "ok to attach?"
      before proceeding. The user can override globally for
      the session by saying "skip the log preview" — do not
      persist that override to memory.

      **Likely-secret files are default-refuse.** For
      `.env`, `id_*`, `*_key`, `*.pem` with private key
      material, `shadow`, `msmtprc`, `.netrc`, cloud
      credential files, or anything under
      `/etc/ssl/private/`: warn explicitly and attach only
      on an explicit per-file override. Never show their
      content as a preview — the preview itself would leak.
      Preview with `ls -l` + `file` instead. See
      `rules/secrets.md`. The session-wide "skip the log
      preview" override does NOT apply to these files.
   e. Multiple attachments: repeat a–d per file. Hard cap of
      5 attachments per message in v1; refuse the 6th and
      suggest splitting the mail.

   **Voice.** Heinzel, not the operator, is the apparent
   author of every outgoing message. Body text — including
   any casual sign-off the user asks for above the fixed
   greeting — must speak in Heinzel's voice on the
   operator's behalf. If a closing line precedes the
   greeting (e.g. "Have a good week,"), attribute it to
   Heinzel acting for the operator, like:

   ```
   Have a good week,
   Heinzel (for <Operator name>)
   ```

   Never write `<Operator> (via heinzel)`, `<Operator> via
   heinzel`, or any phrasing that frames the operator as
   the author with Heinzel as a delivery channel. The
   persona is **"Heinzel for `<Operator>`"**, not
   "`<Operator>` via Heinzel". Same applies to the subject
   and any inline narration.

   **Greeting.** Before the signature, every outgoing
   message carries a fixed two-line human close, separated
   from the body above by one blank line and from the
   signature below by another blank line:

   ```
   Viele Grüße
   Heinzel
   ```

   Heinzel is the author of the closing — not the operator.
   The operator attribution lives in the signature block
   below. Keep the greeting fixed across languages; the
   subject and body may be English, the "Viele Grüße /
   Heinzel" close stays the tool's voice. Users who want a
   different wording can set a `Greeting:` line in
   `memory/user.md` (global) or
   `memory/servers/<host>/memory.md` (per-host); if present,
   it replaces both lines verbatim (multi-line allowed).
   Per-send instructions ("use 'Mit freundlichen Grüßen'
   this time") always win over memory.

   **Signature.** Every outgoing message ends with a fixed
   three-line signature block, separated from the greeting
   above by one blank line and opened by the RFC 3676
   delimiter `"-- "` (two hyphens, one space, then newline
   — most MUAs collapse the sig visually only when the
   delimiter is exact):

   ```
   -- 
   Sent by Heinzel on behalf of <Operator name>
   https://github.com/wintermeyer/heinzel
   ```

   Keep it to these three lines. No timestamp, no hostname,
   no extra attribution — the subject already names the
   host. Plain text only; no HTML.

   **Resolve `<Operator name>`** in this order, stop at the
   first hit. Never fabricate a name from a short handle
   like `root` or `admin`:

   1. `Operator name:` line in
      `memory/servers/<host>/memory.md` (per-host override,
      rare).
   2. `Operator name:` line in `memory/user.md` (global,
      canonical).
   3. Claude Code auto-memory — the `user_profile.md` file
      referenced from `MEMORY.md`. Take the human name from
      its front-matter `name:` field (strip any suffix like
      ` — user profile`). This is the same auto-memory
      channel step 3 uses for the default email.
   4. `git config --global user.name` on the workstation.
   5. GECOS full name:
      `getent passwd "$USER" | cut -d: -f5 | cut -d, -f1`
      on Linux, `id -F` on macOS/BSD.
   6. `$USER` as a last resort.
   7. If even `$USER` is empty, ask once via the picker and
      persist the answer.

   **Persist on first resolution via 3/4/5/6** — write
   `Operator name: <name>` into `memory/user.md` under the
   existing `# Preferences` section so the next run skips
   the probes and the user can edit the canonical value.
   Do not overwrite an `Operator name:` line that already
   exists; user edits win.

   **From header.** Heinzel mail is machine-generated. Set
   `From: noreply@<sending-host-fqdn>` so recipients see at
   a glance that the mailbox is not monitored:

   - Remote path: `<sending-host-fqdn>` is the per-server
     hostname (the directory name under
     `memory/servers/<host>/`).
   - Local path: `<sending-host-fqdn>` is the workstation's
     FQDN (`hostname -f`, fall back to `hostname`).

   The `noreply@…` mailbox does **not** need to exist on the
   host. Real bounces follow the *envelope* sender (the
   submitter UID picked in 5R.4, or the current shell user
   on the local path) — that is always a real account that
   can receive MAILER-DAEMON notices. The From header is
   purely visual, for the recipient's MUA.

   A host can pin a different From mailbox by adding a
   `From:` line to its `memory.md` (rare — only useful when
   a host needs a non-`noreply@` identity such as
   `alerts@<host>`).

   **Reply-To header.** Because the From mailbox is unread,
   every Heinzel message MUST carry a `Reply-To:` pointing
   at the human operator, so recipients hitting "Reply"
   land in a real inbox.

   The operator email is the address of the human
   *using* Heinzel — the same person logged into Claude
   Code right now. It is **never** an account on the
   managed server: no `root@<host>`, no
   `<ssh-user>@<host>`, no alias derived from `/etc/aliases`
   or `~/.forward` on the target. Do not probe
   `getent passwd`, `id`, or any mail metadata on the
   managed host to resolve it. Replies must land in the
   operator's real inbox, not on the server they were
   asking Heinzel to work on.

   **Resolve `<operator email>`** in this order, stop at
   the first hit. Never fabricate an email from a short
   handle like `root` or `admin`, and never derive it
   from a managed host:

   1. `Reply-To:` line in
      `memory/servers/<host>/memory.md` (per-host
      override, rare — e.g. a different operator fields
      replies for one specific host; still must be a
      real off-server inbox).
   2. `Reply-To:` line in `memory/user.md` (global,
      canonical).
   3. Claude Code auto-memory — the "Default email"
      entry under User in `MEMORY.md`. Load the linked
      file and use the address. Same channel step 3 of
      "Resolve recipient" uses.
   4. `git config --global user.email` on the
      workstation.
   5. If still nothing, omit the Reply-To header, tag
      the report **WARN** with the reason, and tell the
      user before sending — don't ship a Heinzel mail
      with no working reply path silently.

   **Persist on first resolution via 3/4** — write
   `Reply-To: <addr>` into `memory/user.md` under the
   `# Preferences` section so the next run skips the
   probes and the user can edit the canonical value.
   Do not overwrite an existing `Reply-To:` line.

   **Anti-auto-reply headers.** Every Heinzel email is an
   automated status message about a managed server. It
   should never fan out out-of-office or vacation replies
   back at the operator. To that end, every outgoing
   message carries this fixed header triple, regardless of
   path or attachments:

   ```
   Auto-Submitted: auto-generated
   Precedence: bulk
   X-Auto-Response-Suppress: OOF, AutoReply
   ```

   - `Auto-Submitted: auto-generated` is the RFC 3834
     signal. Standards-compliant auto-responders
     (vacation(1), Sieve `vacation`, recent postfix,
     well-behaved providers) MUST NOT reply to a message
     that carries it.
   - `Precedence: bulk` is the older sendmail convention,
     still honoured by many legacy responders.
   - `X-Auto-Response-Suppress: OOF, AutoReply` is the
     Microsoft Exchange / Outlook-specific knob that
     suppresses OOF replies and "I'm out of the office"
     auto-responses when the recipient uses Exchange.

   Together the three cover RFC-compliant systems, legacy
   Unix responders, and the Exchange-flavoured world.
   Do not make them per-host configurable; there is no
   realistic Heinzel message that should be treated as
   a normal human email by an auto-responder.

7. **Send.** Because Heinzel always injects custom headers
   (the anti-auto-reply triple above, plus MIME headers
   when attaching), the canonical send path builds the
   full RFC 822 message and pipes it to a sendmail-style
   agent that reads headers from stdin (`-t` mode). This
   is uniform across Postfix, msmtp-mta, exim, opensmtpd,
   and macOS Postfix — they all expose `/usr/sbin/sendmail`
   with compatible `-t` semantics.

   The composed message always has this shape (headers,
   blank line, body + greeting + signature):

   ```
   From: noreply@<sending-host-fqdn>
   Reply-To: <operator email>
   To: <recipient>
   Subject: <subject>
   Auto-Submitted: auto-generated
   Precedence: bulk
   X-Auto-Response-Suppress: OOF, AutoReply
   MIME-Version: 1.0
   Content-Type: text/plain; charset=utf-8

   <body>

   <greeting>

   -- 
   <signature>
   ```

   `From:` and `Reply-To:` are resolved per the rules
   above. If `Reply-To:` could not be resolved (case 5),
   omit the line entirely after warning the user.

   **No attachments.** Pipe the message to
   `sendmail -t -oi` (`-oi` prevents a lone `.` on a line
   from ending input). Prefer the MTA-provided
   `/usr/sbin/sendmail`; fall back to `msmtp -t` when only
   msmtp is present.

   **With attachments**, build a MIME multipart message by
   hand — `text/plain` body plus parts that are
   `text/plain` for `text/*` MIME types (detected via
   `file --mime-type`) and base64-encoded
   `application/octet-stream` otherwise. Heinzel
   constructs the headers (including the anti-auto-reply
   triple) and the boundary itself, then pipes into
   `sendmail -t -oi` (or `msmtp -t` when only msmtp is
   present). This replaces the earlier tool-specific
   shell-outs to `mutt` and `mail -a`: a single code path
   means headers are guaranteed to survive every send.

   **macOS local path.** `/usr/bin/sendmail` on macOS is a
   Postfix compatibility shim and accepts the same `-t`
   invocation, so the same composed message pipes through
   without change. `/usr/bin/mail` is not used for the
   send itself anymore; we only consulted it during the
   5L.1 probe to confirm a working local MTA exists.

   Remote path: run under the user chosen in 5R.4, via
   `runuser -u <user> -- sh -c '…'` or
   `su - <user> -c '…'`. Local path: under the current
   shell user.

8. **Verify delivery.** Check the send command's exit code,
   then probe the appropriate mail log from the last minute:
   - Linux remote: `journalctl -u postfix --since "1 minute
     ago" | tail -20` (substitute the active MTA unit), or
     `tail -50 /var/log/mail.log` (Debian) /
     `/var/log/maillog` (RHEL/FreeBSD).
   - macOS local: `log show --style compact --last 1m
     --predicate 'process == "smtpd" OR process == "smtp"'`.
   - Linux workstation local: `journalctl --since "1 minute
     ago" -t postfix` or `/var/log/mail.log`.

   Look for `status=sent` (or msmtp's `delivery
   successful`). Flag `deferred` / `bounced` as **CRITICAL**
   and report verbatim instead of claiming success. Never
   call a send successful on the basis of the command's exit
   code alone.

   On the remote path, confirm the log line's `from=<…>`
   matches the chosen sender user (not root, unless 5R.4
   fell to case 3). This is the *envelope* sender (the
   Return-Path), which always reflects the submitter UID;
   the visible `From:` header is `noreply@…` and is
   independent — do not flag the mismatch as a problem.

9. **Update `memory.md`** if anything new was learned (source
   chosen, transport discovered or installed, recipient
   added, policy set, sender identity confirmed, per-host
   operator override requested). Use the shape under
   "Per-server memory" below. Only write `…always` or
   `…never` lines when the user explicitly picked them;
   absence means "ask next time". The global `Operator name`
   is persisted to `memory/user.md`, not to per-server
   memory — write a per-host `Operator name:` line only when
   the user asks for a different signer on that specific
   host.

10. **Log to changelog** per `rules/changelog.md`:

    ```
    logger -t heinzel "Email to <recipient> from \
        <local|remote/<user>>: <subject>"
    ```

## Per-server memory

No new file. The skill reads and updates lines in the
existing `memory/servers/<host>/memory.md`, extending the
existing per-server memory format. Policy lines are only
written once the user picks **Always** or **Never**;
absence means "ask next time".

```
- Mail: <transport summary>          # remote path only
                                     # e.g. "postfix + bsd-mailx
                                     #       (outbound via Google MX)"
                                     # or  "msmtp via smtp.fastmail.com:587"
- Alert email: <recipient address>
- Email source: local | remote       # gate 0 — sender side
- Email sender: <username>           # remote path only — non-root user
- Email send policy: always | never  # gate A — use existing remote MTA
- MTA install policy: always | never # gate B — install a new remote MTA
- Operator name: <full name>         # per-host override for signature
                                     # (global default in memory/user.md)
- Greeting: <closing text>           # per-host override for the greeting
                                     # (global default in memory/user.md;
                                     # absent = "Viele Grüße / Heinzel")
- From: <mailbox>                    # per-host From override
                                     # (default: noreply@<host>)
- Reply-To: <addr>                   # per-host Reply-To override
                                     # (global default in memory/user.md)
```

The two policy lines are deliberately separate: a user may
be happy to use a mature postfix that's already there
("send: always") but want to be asked every time before
heinzel installs new packages on a different server
("install: ask" = line absent). Mirrors the shape of
`memory/service-policy.md`'s split between `restart-auto`
and `restart-never`.

## References (read on demand)

- `rules/first-connection.md` — the mandatory onboarding
  pipeline.
- `rules/server-memory.md` — server memory file format.
- `rules/changelog.md` — session logging procedure.
- `rules/best-practices.md` — anti-pattern review before
  installing software.
- `rules/service-class-check.md` — mandatory conflict check
  before installing an MTA (gate B).
- `rules/backups.md` — config backup before any edit (e.g.
  `/etc/msmtprc`).
- `rules/secrets.md` — secrets hygiene; default-refuse
  attachment gate for likely-secret files.

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…