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.
142 lines
5 KiB
Bash
Executable file
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
|