Two further hardening findings:
- Destroy-on-success was unsafe: the script exited 0 for empty/no-archive
lists too, so the README's &&-gated loop could remove a list that
produced no backup artifact. Give the no-file outcomes a distinct exit
code 3 ("nothing to archive" — not an error). The destroy loop now
removes only on exit 0 (archive written) + a successful member dump,
and leaves exit-3 lists intact unless ALLOW_NO_ARCHIVE=1. Safe by
default: an unexpected "no archive" can no longer authorise a delete.
- Publish wasn't crash-durable: the handoff fsynced file contents but not
the parent directory, so the rename's directory entry could be lost to
a crash after success was reported and the list removed. fsync the
destination directory after os.replace(), before returning success.
Verified with stubbed mailman-web: NONE/EMPTY -> exit 3 (no file),
FILE -> exit 0 (0600, content intact, source temp unlinked), bad
address -> exit 2. sh -n + shellcheck clean, embedded python compiles.
5 KiB
mm3-hk-export
Export a private HyperKitty (Mailman 3) list archive to an mbox file from
the command line: the scriptable equivalent of clicking the web UI's
Download button, so it fits a backup-and-destroy loop without manual
browser steps.
Why this exists
HyperKitty has no hyperkitty_export management command; the only built-in
export is the web "Download" view (gated by the HYPERKITTY_MBOX_EXPORT
setting). This script reproduces that view's exact output by calling the same
Email.as_bytes() code path on each archived message, driven through Debian's
mailman-web wrapper: no web login, no direct database reconstruction.
The archives live in the mailman-web / HyperKitty database, which is
separate from Mailman core. That is why the export must go through
mailman-web (not mailman / mailman-wrapper, whose shell only sees core).
Requirements
- Debian-style Mailman 3 with the
mailman-webwrapper onPATH(Django site at/usr/share/mailman3-web/, HyperKitty installed). - Run as root:
mailman-webdrops privileges to the web user to read the archive, and the script (as root) moves the finished file into place. - Optional, only to enrich the "not archived" notice: the
mailman-wrappercore CLI (for a best-effortarchive_policylookup viamailman shell, reading core's DB directly, no REST, no credentials). Missing it just falls back to a generic message.
Usage
mm3-hk-export [-z|--gzip] <list@domain> [output]
outputdefaults to./<localpart>.mbox(./<localpart>.mbox.gzwith-z).-z/--gzipgzip-compresses the stream, matching the web download's.mbox.gzbyte-for-byte (deterministic header,mtime=0);.gzis appended to the output name if absent.
Examples:
mm3-hk-export announce@lists.example.com /root/mm3/announce.mbox
mm3-hk-export --gzip announce@lists.example.com /root/mm3/announce.mbox.gz
Outcomes and exit codes
The script never writes a partial or empty file. The exit code tells a caller exactly what happened, most importantly whether a backup was produced:
| Situation | HyperKitty state | Result | Exit |
|---|---|---|---|
| Archive has messages | row with N emails | writes the mbox, prints wrote N messages to … |
0 |
| Archived but empty | row, 0 emails | no file, prints an "empty archive" notice | 3 |
| No archive at all | no MailingList row |
no file; consults core archive_policy to say whether the list is never-archived or simply has nothing archived yet |
3 |
| Usage / bad address | n/a | usage message | 2 |
Genuine error (e.g. mailman-web failing) |
n/a | underlying output on stderr | 1 |
Exit 3 ("nothing to archive") is deliberately distinct from 0 ("archive
written"). A genuinely empty or never-archived list is a normal, expected
outcome, but it is not a successful backup, so a backup-and-destroy caller
can refuse to delete a list whose archive was never captured (see below).
In a backup-and-destroy loop
mkdir -p /root/mm3
for list in list1 list2; do
addr="$list@domain.tld"
mm3-hk-export "$addr" "/root/mm3/$list.mbox"
case $? in
0) ;; # archive written; safe to proceed
3) # nothing to archive (empty / no archive):
# no backup file exists, so by default do
# not destroy. Override only if you mean it.
if [ "${ALLOW_NO_ARCHIVE:-0}" != 1 ]; then
echo "skip: $addr has no archive to back up; not removing" >&2
continue
fi ;;
*) echo "skip: $addr export failed; not removing" >&2; continue ;;
esac
mailman-wrapper members "$addr" > "/root/mm3/$list.members" \
|| { echo "skip: $addr member dump failed; not removing" >&2; continue; }
mailman-wrapper remove "$addr"
done
The loop destroys a list only when its archive was actually written
(exit 0) and its member roster was dumped. A list with nothing to archive
(exit 3) is left intact unless you opt in with ALLOW_NO_ARCHIVE=1, so a
transient or unexpected "no archive" can never silently authorise a delete.
Export before remove, and never remove on a failed backup. (HyperKitty's
archive lives in a separate database and is not deleted by core's remove, but
a failed export must still halt the destroy step.)
Caveats
- Reconstruction, not the raw original. HyperKitty rebuilds each message
from parsed fields:
From,To,Subject,Date,Message-ID,In-Reply-To, body, and attachments. Headers likeCc,References,List-*, and customX-*are not preserved. This is inherent to HyperKitty; the manual web download has the identical limitation. - Memory. Messages are streamed with a DB iterator and written incrementally, avoiding the web export's known blow-up on large lists.
- Re-import. The output is valid mbox (proper
Fromseparators,From-mangled bodies) and can be re-imported withhyperkitty_import.