#!/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 3, a distinct "nothing to archive" status rather
# than an error. A backup-and-destroy caller can then tell "backed up" from
# "nothing was backed up" and refuse to delete an uncaptured list.
#
# 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); this script,
# running as root, then copies it into a root-owned temp file on the
# destination filesystem and atomically renames it into place, so the published
# backup is root-owned and $out is never left partial.

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 for a list's archive_policy, to explain why a
# list has no HyperKitty archive. Reads it straight from core's own DB through
# "mailman-wrapper shell", whose Python namespace pre-binds getUtility() and
# IListManager, so there is no REST call, no credential handling, and no curl
# dependency. Echoes never|private|public on success, nothing on any failure;
# it must never abort the export. The address is interpolated into the Python
# snippet below, gated by the same regex that guards the export heredoc.
core_archive_policy() {
    command -v mailman-wrapper >/dev/null 2>&1 || return 0
    mailman-wrapper shell 2>/dev/null <<EOF | sed -n 's/^MM3HKPOL|//p'
try:
    ml = getUtility(IListManager).get("$1")
    print("MM3HKPOL|%s" % (ml.archive_policy.name if ml else "NOLIST"))
except Exception:
    pass
EOF
}

result=$(mailman-web shell <<EOF
import sys, os, tempfile, gzip, time
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
# Live progress bar on stderr: stdout is captured by the shell (the MM3HK|
# line), so a shell-side spinner can't see per-message progress, but this loop
# knows n up front. TTY-only, so cron/piped runs stay silent. Braille frames
# and blue borrowed from warez's spinner.
tty = sys.stderr.isatty()
SPIN = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
def draw(done, spin):
    w = 24
    fill = done * w // n
    sys.stderr.write("\r\033[0;34m%s\033[0m [%s%s] %d/%d (%d%%)" % (
        SPIN[spin % len(SPIN)], "█" * fill, "░" * (w - fill),
        done, n, done * 100 // n))
    sys.stderr.flush()
spin = 0
last = 0.0
try:
    for i, e in enumerate(ml.emails.order_by("archived_date").iterator(), 1):
        fh.write(e.as_bytes())
        fh.write(b"\n\n")
        if tty:
            now = time.monotonic()
            if now - last >= 0.1 or i == n:  # ~10 fps, plus a final 100% frame
                draw(i, spin); spin += 1; last = now
finally:
    if GZIP:
        fh.close()  # flush the gzip trailer into raw (does not close raw)
    raw.close()
    if tty:
        sys.stderr.write("\r\033[K"); sys.stderr.flush()  # clear the bar
print("MM3HK|FILE|%d|%s" % (n, path))
EOF
) || { echo "mm3-hk-export: mailman-web shell failed; aborting, nothing exported" >&2; exit 1; }
# Any non-zero from mailman-web is normalized to exit 1 above so a wrapper that
# happens to exit 3 can never be misread as our reserved "nothing archived" code.

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
        # Nothing archived: exit 3 (distinct from 0=archive-written) so a
        # backup-and-destroy caller can refuse to delete a list whose archive
        # was never captured. Not an error, just a normal recoverable outcome.
        exit 3
        ;;
    EMPTY)
        echo "mm3-hk-export: $addr has a HyperKitty archive but 0 messages; nothing written." >&2
        exit 3
        ;;
    FILE)
        path=$(printf '%s' "$line" | cut -d'|' -f4)
        # The export was written by the privilege-dropped web user into
        # /var/tmp; copy it out as root (the web user can't write into a
        # root-owned destination). Stage it in the destination dir and rename
        # into place so $out is never left partial.
        dest_dir=$(dirname "$out")
        mkdir -p "$dest_dir" || { echo "mm3-hk-export: cannot create $dest_dir" >&2; exit 1; }
        tmp=$(mktemp "$dest_dir/.mm3hk.XXXXXX") || { echo "mm3-hk-export: cannot create temp in $dest_dir" >&2; exit 1; }
        if cp "$path" "$tmp" && mv -f "$tmp" "$out"; then
            rm -f "$path"
            echo "mm3-hk-export: wrote $count messages to $out" >&2
        else
            rm -f "$tmp" "$path"
            echo "mm3-hk-export: failed to publish export to $out" >&2
            exit 1
        fi
        ;;
    *)
        echo "mm3-hk-export: unexpected status '$status'" >&2
        exit 1
        ;;
esac
