mm3-hk-export/mm3-hk-export
veg f7a9714e93 Add mm3-hk-export: HyperKitty archive -> mbox exporter
Scriptable equivalent of HyperKitty's web Download button for
backup-and-destroy loops. Runs the export through Debian's mailman-web
wrapper (HyperKitty lives in the web DB, separate from core), reproducing
the web view's Email.as_bytes() output.

- Handles the three archive states without writing partial/empty files:
  populated (writes mbox), row-but-empty, and no row at all.
- For a missing row, best-effort core REST lookup distinguishes
  archive_policy=never from 'nothing archived yet'.
- -z/--gzip matches the web .mbox.gz (deterministic header, mtime=0).
- Privilege-safe: web user writes a 0600 mkstemp file, root moves it.
- Address validated before interpolation into the embedded Python.
2026-05-31 12:24:58 +00:00

142 lines
5 KiB
Bash
Executable file

#!/bin/sh
# mm3-hk-export: export a private HyperKitty (Mailman 3) list archive to an mbox.
#
# Usage: mm3-hk-export [-z|--gzip] <list@domain> [output]
#
# Output is byte-for-byte what the web "Download" button produces (same
# Email.as_bytes() code path). With -z/--gzip the stream is gzip-compressed,
# matching the web download's .mbox.gz exactly (and ".gz" is appended to the
# output name if absent). If the list has no archived messages — including the
# case where HyperKitty never created a MailingList row for it — nothing is
# written and the script exits 0 with a notice.
#
# Runs the export through Debian's `mailman-web` wrapper, which executes the
# Django site as the correct web user. The mbox is created by that (privilege-
# dropped) user via tempfile.mkstemp (0600, race-free) and then moved into
# place by this script, which is expected to run as root.
set -eu
usage() {
echo "usage: mm3-hk-export [-z|--gzip] <list@domain> [output]" >&2
}
GZIP=0
while [ $# -gt 0 ]; do
case "$1" in
-z|--gzip) GZIP=1; shift ;;
-h|--help) usage; exit 0 ;;
--) shift; break ;;
-*) echo "mm3-hk-export: unknown option: $1" >&2; usage; exit 2 ;;
*) break ;;
esac
done
if [ $# -lt 1 ]; then usage; exit 2; fi
addr="$1"
out="${2:-./${addr%@*}.mbox}"
if [ "$GZIP" -eq 1 ]; then
gzip_py=True
case "$out" in *.gz) ;; *) out="$out.gz" ;; esac
else
gzip_py=False
fi
# Validate the address: this value is interpolated into a Python string
# literal below, so reject anything that isn't a plain localpart@domain.
if ! printf '%s' "$addr" | grep -Eq '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+$'; then
echo "mm3-hk-export: invalid list address: $addr" >&2
exit 2
fi
# Best-effort: ask Mailman *core* (via its REST API) for a list's archive_policy.
# Used only to explain why a list has no HyperKitty archive. Echoes the policy
# string (never|private|public) on success, nothing on any failure — it must
# never abort the export. REST root + credentials are read live from
# `mailman info` so no secrets are baked into this script.
core_archive_policy() {
command -v curl >/dev/null 2>&1 || return 0
command -v mailman-wrapper >/dev/null 2>&1 || return 0
info=$(mailman-wrapper info 2>/dev/null) || return 0
root=$(printf '%s\n' "$info" | sed -n 's/^REST root url: *//p')
creds=$(printf '%s\n' "$info" | sed -n 's/^REST credentials: *//p')
[ -n "$root" ] && [ -n "$creds" ] || return 0
lid=$(printf '%s' "$1" | sed 's/@/./')
curl -sf -u "$creds" "${root}lists/${lid}/config/archive_policy" 2>/dev/null \
| sed -n 's/.*"archive_policy": *"\([^"]*\)".*/\1/p'
}
result=$(mailman-web shell <<EOF
import sys, os, tempfile, gzip
from hyperkitty.models import MailingList
addr = "$addr"
GZIP = $gzip_py
try:
ml = MailingList.objects.get(name=addr)
except MailingList.DoesNotExist:
# No HyperKitty row at all: list is not archived (archive_policy=never)
# or no message has ever been archived for it.
print("MM3HK|NONE|0")
sys.exit(0)
n = ml.emails.count()
if n == 0:
# Row exists but holds no messages.
print("MM3HK|EMPTY|0")
sys.exit(0)
fd, path = tempfile.mkstemp(prefix="mm3hk-", suffix=".mbox", dir="/var/tmp")
raw = os.fdopen(fd, "wb")
# mtime=0 keeps the gzip header deterministic (no timestamp), matching the
# web export, which uses a bare zlib gzip stream.
fh = gzip.GzipFile(fileobj=raw, mode="wb", mtime=0) if GZIP else raw
try:
for e in ml.emails.order_by("archived_date").iterator():
fh.write(e.as_bytes())
fh.write(b"\n\n")
finally:
if GZIP:
fh.close()
raw.close()
print("MM3HK|FILE|%d|%s" % (n, path))
EOF
)
line=$(printf '%s\n' "$result" | grep '^MM3HK|' | tail -n1 || true)
if [ -z "$line" ]; then
echo "mm3-hk-export: unexpected output from mailman-web shell:" >&2
printf '%s\n' "$result" >&2
exit 1
fi
status=$(printf '%s' "$line" | cut -d'|' -f2)
count=$(printf '%s' "$line" | cut -d'|' -f3)
case "$status" in
NONE)
policy=$(core_archive_policy "$addr")
case "$policy" in
never)
echo "mm3-hk-export: $addr is set not to archive (archive_policy=never); nothing to export." >&2
;;
private|public)
echo "mm3-hk-export: $addr archives (archive_policy=$policy) but nothing has been archived yet; nothing written." >&2
;;
*)
echo "mm3-hk-export: $addr has no HyperKitty archive (archiving disabled, or nothing ever archived); nothing written." >&2
;;
esac
;;
EMPTY)
echo "mm3-hk-export: $addr has a HyperKitty archive but 0 messages — nothing written." >&2
;;
FILE)
path=$(printf '%s' "$line" | cut -d'|' -f4)
mkdir -p "$(dirname "$out")"
mv "$path" "$out"
echo "mm3-hk-export: wrote $count messages to $out" >&2
;;
*)
echo "mm3-hk-export: unexpected status '$status'" >&2
exit 1
;;
esac