Merge pull request #66 from LINUXexpert-org/deploy-image-retention

Keep the last few images, and record delete-all-spam as verified live
This commit is contained in:
LINUXexpert.org
2026-08-26 12:17:48 -07:00
committed by GitHub
2 changed files with 38 additions and 0 deletions
+9
View File
@@ -239,6 +239,14 @@ Each build is tagged with its own version as well as `:current`, so rolling
back is running the previous tag rather than rebuilding it. The environment
file is never read by the script, only handed to `docker run --env-file`.
Those images add up — roughly 650 MB each, one per deploy, and `docker image
prune` will not touch them because they are tagged. After a deploy reports
healthy, the script removes all but the newest `IHASMAIL_KEEP_VERSIONS` of them
(three by default; `0` keeps every version). It never removes the image the
container is actually running, which matters after a rollback, when that is an
old one — and it prunes only *after* the health check, so a rollback target is
never dropped while the thing replacing it is still unproven.
## Configuration
All configuration is via environment variables (see `.env.example`):
@@ -280,6 +288,7 @@ works the same way — and dropped where 0.15 was the whole subject. Support for
0.15 was removed on 2026-08-26; the last release that runs on it is tagged
[`stalwart-0.15-support`](https://github.com/LINUXexpert-org/ihasmail/releases/tag/stalwart-0.15-support).
- **Delete all spam destroys, and does not pass through Deleted Items** — this is the point of the feature and the thing worth checking on a real server, since a folder that empties into another folder has solved nothing. `Email/set destroy`, walked a page at a time so it survives `maxObjectsInSet` the way emptying Deleted Items already had to. **Confirmed live on 0.16.19 (2026-08-26)**: Junk Mail emptied and Deleted Items stayed empty afterwards. There is no undo, which is why all three entry points share one dialog that says so. Only Deleted Items and Junk Mail can be emptied this way, enforced in the store rather than only hidden in the menus.
- **Read receipts are built here, not by the server** — JMAP has an extension for them, [RFC 9007](https://www.rfc-editor.org/rfc/rfc9007.html)'s `MDN/send`, and Stalwart does not implement it: `urn:ietf:params:jmap:mdn` is not among its capabilities. So ihasmail assembles the `multipart/report` itself and sends it the long way round — raw MIME uploaded as a blob, `Email/import`, then `EmailSubmission` — which is also why the receipt lands in Sent, where it honestly belongs. Non-ASCII parts are base64 rather than `8bit`, so nothing depends on 8BITMIME surviving every hop. There is deliberately no "always send" setting: a receipt confirms to whoever asked that the address is live and when it was read, to an address of the sender's choosing, so each one is a decision. Verified against the mock end to end (upload, import, submit, `$mdnsent`), and **confirmed live on 0.16.19 (2026-08-26)**: a receipt asked for by a real sender was assembled, uploaded, imported and submitted, landed in Sent, and set `$mdnsent` so a second look does not offer to send another.
- **Where 0.16 advertises `urn:stalwart:jmap`** — not where a JMAP client would look, and this now decides whether a sign-in is allowed at all. Stalwart builds the session-level `capabilities` from a fixed list (`Session::new`, plus WebSocket) that has never contained this capability, in any 0.16.x from 0.16.0 to 0.16.19. It hands it out per-account instead, so it appears in `primaryAccounts` and in each account's `accountCapabilities`. ihasmail tested for it in `capabilities` alone, which made every real 0.16 server read as older than 0.16 — and that one check drove three things: self-service credentials fell back to `POST /api/account/auth`, which 0.16 removed, so password changes, 2FA and app passwords all failed with "this mail server does not offer self-service credential management"; About reported the wrong generation; and Files took the older code path. It now looks in all three places, and is covered by tests on each. Worth restating plainly, because the stakes went up when 0.15 support was dropped: there is no longer a fallback path for this check to be wrong *into*. Getting it wrong now refuses every sign-in against a perfectly good server — a loud failure rather than a quiet misrouting, which is the trade the removal was making.
- **HTML signatures** — Stalwart caps a signature at 2047 **bytes** (`value.len() < 2048` on a Rust string, so UTF-8 bytes, not characters). ihasmail compacts pasted HTML, moves images to Files and, if still too large, keeps the full signature in Files behind a short marker; other clients see a text fallback. Confirmed live on 0.15.5 (2026-08-24): oversized, non-ASCII and inline-image signatures all save, and a test message arrived intact at Gmail with the logo inline.
+29
View File
@@ -48,6 +48,11 @@ VOLUME="${IHASMAIL_VOLUME:-ihasmail-data}"
IMAGE_REPO="${IHASMAIL_IMAGE:-ihasmail}"
# How long to wait for the new container to report healthy, in seconds.
HEALTH_TIMEOUT="${IHASMAIL_HEALTH_TIMEOUT:-30}"
# How many past versions to keep as images, for rolling back to. Each is around
# 650 MB, and a deploy adds one, so left alone they accumulate a gigabyte every
# couple of releases -- and `docker image prune` will not touch them, because
# they are tagged. 0 keeps every version.
KEEP_VERSIONS="${IHASMAIL_KEEP_VERSIONS:-3}"
# --- run from a copy, if this script lives in the checkout it resets ---------
# `git reset --hard` below rewrites the working tree, and this script may be
@@ -153,6 +158,29 @@ git reset --hard --quiet "$TARGET"
# cannot: .dockerignore keeps .git out of the build context. Without this the
# build falls back to the base version in package.json and every deployment
# reports the same number -- see "Version numbers" in the README.
# Drop the oldest versioned images, keeping the newest KEEP_VERSIONS of them.
#
# Only ever runs after the new container reports healthy, so a rollback target
# is never removed while the thing replacing it is still unproven. The image in
# use is excluded outright rather than relied on to sort newest -- docker
# refuses to remove an image a container is using, but being refused is not the
# same as not having tried.
prune_old_images() {
[ "$KEEP_VERSIONS" -gt 0 ] || return 0
local in_use stale
in_use="$(docker inspect "$NAME" --format '{{.Config.Image}}' 2>/dev/null || true)"
# Newest first, tags only, skipping the moving ":current" pointer.
stale="$(docker images "$IMAGE_REPO" --format '{{.Repository}}:{{.Tag}}\t{{.CreatedAt}}' \
| grep -v ":current" \
| sort -k2 -r \
| cut -f1 \
| grep -vxF "$in_use" \
| tail -n +"$((KEEP_VERSIONS + 1))")"
[ -n "$stale" ] || return 0
echo "==> removing $(printf '%s\n' "$stale" | wc -l) old image(s), keeping the newest $KEEP_VERSIONS"
printf '%s\n' "$stale" | xargs -r docker rmi >/dev/null 2>&1 || true
}
VERSION="$(node scripts/version.mjs)"
# A Docker tag may not contain "+", which a version for a commit that did not
# come through a pull request does: 2.16.57+g1fa6578. The image is tagged with
@@ -174,6 +202,7 @@ docker run -d --name "$NAME" --restart unless-stopped \
for _ in $(seq 1 "$HEALTH_TIMEOUT"); do
if health=$(curl -sf "http://$BIND/api/health"); then
echo "==> healthy: $health"
prune_old_images
exit 0
fi
sleep 1