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. Verbatim user content, followed by
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
above the closing.
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:
Operator name: line in
memory/servers/<host>/memory.md (per-host override,
rare).
Operator name: line in memory/user.md (global,
canonical).
- 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.
git config --global user.name on the workstation.
- GECOS full name:
getent passwd "$USER" | cut -d: -f5 | cut -d, -f1
on Linux, id -F on macOS/BSD.
$USER as a last resort.
- 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:
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).
Reply-To: line in memory/user.md (global,
canonical).
- 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.
git config --global user.email on the
workstation.
- 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.
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.