diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 76d3bfb..d6e8a0b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -131,5 +131,5 @@ jobs: - name: Release verification run: | python scripts/verify_release.py \ - dist/mailstack-1.3.3-source.zip \ - --checksum dist/mailstack-1.3.3-source.zip.sha256 + dist/mailstack-1.3.4-rc.1-source.zip \ + --checksum dist/mailstack-1.3.4-rc.1-source.zip.sha256 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 62b6326..d4013ed 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -63,7 +63,7 @@ jobs: path: | dist/*.zip dist/*.sha256 - docs/RELEASE_NOTES_1.3.3.md + docs/RELEASE_NOTES_1.3.4.md docs/FORENSIC_AUDIT_REPORT.md if-no-files-found: error @@ -121,7 +121,7 @@ jobs: --verify-tag --target "$GITHUB_SHA" --title "MailStack ${RELEASE_VERSION}" - --notes-file docs/RELEASE_NOTES_1.3.3.md + --notes-file docs/RELEASE_NOTES_1.3.4.md ) if [[ "$RELEASE_PRERELEASE" == "true" ]]; then args+=(--prerelease --latest=false) diff --git a/CHANGELOG.md b/CHANGELOG.md index a9e4c91..bf7fe87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,26 @@ All notable repository-level changes are recorded here. Application history before the open-source conversion remains in `mailbox-app/CHANGELOG.md`. +## Unreleased — PHASE-006 reader integrity and repair + +### Corrected + +- Prevented stripped HTML ` + + + +

Welcome to Harpoon!

This content must stay readable.

+intercom + + diff --git a/mailbox-app/tests/functional/test_compact_mailbox_reader.py b/mailbox-app/tests/functional/test_compact_mailbox_reader.py index 1fba9c9..a25ab69 100644 --- a/mailbox-app/tests/functional/test_compact_mailbox_reader.py +++ b/mailbox-app/tests/functional/test_compact_mailbox_reader.py @@ -55,7 +55,7 @@ def test_unified_reader_prefers_sanitized_html_and_preserves_sandbox(client, adm assert 'class="email-frame unified-email-frame"' in body assert 'sandbox=""' in body assert 'referrerpolicy="no-referrer"' in body - assert "Protected rendering" in body + assert "Protected rendering" not in body assert "Plain body" not in body assert "Plain text" not in body assert "Safe HTML" not in body diff --git a/mailbox-app/tests/integration/test_message_body_repair.py b/mailbox-app/tests/integration/test_message_body_repair.py new file mode 100644 index 0000000..373a7de --- /dev/null +++ b/mailbox-app/tests/integration/test_message_body_repair.py @@ -0,0 +1,141 @@ +from __future__ import annotations + +from io import StringIO +from pathlib import Path + +import pytest +from django.core.management import call_command +from django.core.management.base import CommandError +from django.utils import timezone + +from apps.ingestion.service import ingest_file +from apps.mailboxes.services import mailbox_paths +from apps.messages.models import Message + + +def _ingest_fixture(mailbox, fixtures_dir: Path, name: str, source_name: str) -> Message: + _root, maildir, _relative = mailbox_paths(mailbox.local_part) + source = maildir / "new" / source_name + source.write_bytes((fixtures_dir / name).read_bytes()) + assert ingest_file(mailbox, source) == "created" + return Message.objects.get(mailbox=mailbox, source_file_key=f"new/{source_name}") + + +@pytest.mark.django_db +def test_repair_message_bodies_requires_explicit_confirmation(mailbox): + with pytest.raises(CommandError, match="--confirm-repair"): + call_command("repair_message_bodies", mailbox=mailbox.local_part) + + +@pytest.mark.django_db +def test_repair_message_bodies_dry_run_then_mutation_preserves_state(mailbox, fixtures_dir): + message = _ingest_fixture(mailbox, fixtures_dir, "html_style_heavy.eml", "repair-html") + message.sanitized_html_body = "#outlook a { padding: 0; } broken body" + message.is_read = True + message.deleted_at = timezone.now() + message.save(update_fields=["sanitized_html_body", "is_read", "deleted_at", "updated_at"]) + + original = { + "pk": message.pk, + "uuid": message.uuid, + "source_file_key": message.source_file_key, + "source_sha256": message.source_sha256, + "mailbox_id": message.mailbox_id, + "is_read": message.is_read, + "deleted_at": message.deleted_at, + } + + dry_output = StringIO() + call_command( + "repair_message_bodies", + mailbox=mailbox.local_part, + dry_run=True, + stdout=dry_output, + ) + assert "would_update=1" in dry_output.getvalue() + message.refresh_from_db() + assert message.sanitized_html_body.startswith("#outlook") + + output = StringIO() + call_command( + "repair_message_bodies", + mailbox=mailbox.local_part, + confirm_repair=True, + stdout=output, + ) + assert "updated=1" in output.getvalue() + + message.refresh_from_db() + assert "Welcome to Harpoon!" in message.sanitized_html_body + assert "#outlook" not in message.sanitized_html_body + assert message.pk == original["pk"] + assert message.uuid == original["uuid"] + assert message.source_file_key == original["source_file_key"] + assert message.source_sha256 == original["source_sha256"] + assert message.mailbox_id == original["mailbox_id"] + assert message.is_read == original["is_read"] + assert message.deleted_at == original["deleted_at"] + + second_output = StringIO() + call_command( + "repair_message_bodies", + mailbox=mailbox.local_part, + confirm_repair=True, + stdout=second_output, + ) + assert "updated=0" in second_output.getvalue() + assert "unchanged=1" in second_output.getvalue() + + +@pytest.mark.django_db +def test_repair_message_bodies_preserves_attachment_identity(mailbox, fixtures_dir): + message = _ingest_fixture(mailbox, fixtures_dir, "one_attachment.eml", "repair-attachment") + attachment = message.attachments.get() + attachment_identity = ( + attachment.pk, + attachment.uuid, + attachment.sha256, + attachment.storage_relative_path, + ) + message.text_body = "stale" + message.save(update_fields=["text_body", "updated_at"]) + + call_command( + "repair_message_bodies", + message=str(message.uuid), + confirm_repair=True, + stdout=StringIO(), + ) + + message.refresh_from_db() + attachment.refresh_from_db() + assert message.text_body.strip() == "One attachment." + assert message.attachments.count() == 1 + assert ( + attachment.pk, + attachment.uuid, + attachment.sha256, + attachment.storage_relative_path, + ) == attachment_identity + + +@pytest.mark.django_db +def test_repair_message_bodies_reports_missing_and_mismatched_sources(mailbox, fixtures_dir): + missing = _ingest_fixture(mailbox, fixtures_dir, "html.eml", "repair-missing") + mismatch = _ingest_fixture(mailbox, fixtures_dir, "plain_text.eml", "repair-mismatch") + + _root, maildir, _relative = mailbox_paths(mailbox.local_part) + (maildir / missing.source_file_key).unlink() + (maildir / mismatch.source_file_key).write_bytes(b"changed source") + + output = StringIO() + call_command( + "repair_message_bodies", + mailbox=mailbox.local_part, + dry_run=True, + stdout=output, + ) + summary = output.getvalue() + assert "missing=1" in summary + assert "mismatch=1" in summary + assert "updated=0" in summary diff --git a/mailbox-app/tests/security/test_deployment_assets.py b/mailbox-app/tests/security/test_deployment_assets.py index ff34256..c3c0202 100644 --- a/mailbox-app/tests/security/test_deployment_assets.py +++ b/mailbox-app/tests/security/test_deployment_assets.py @@ -27,6 +27,14 @@ def test_clean_install_and_deploy_create_mail_roots(): assert "UID/GID 5000" in deploy +def test_gunicorn_25_control_socket_is_disabled_for_hardened_runtime(): + config = read("gunicorn.conf.py") + gunicorn = read("deployment/systemd/vibmail-gunicorn.service") + assert "control_socket_disable = True" in config + assert "WorkingDirectory=/opt/vibmail/app" in gunicorn + assert "ProtectSystem=strict" in gunicorn + + def test_systemd_runtime_directories_are_isolated(): gunicorn = read("deployment/systemd/vibmail-gunicorn.service") ingestion = read("deployment/systemd/vibmail-ingestion.service") diff --git a/mailbox-app/tests/unit/test_parser_storage.py b/mailbox-app/tests/unit/test_parser_storage.py index ab433b1..13c6022 100644 --- a/mailbox-app/tests/unit/test_parser_storage.py +++ b/mailbox-app/tests/unit/test_parser_storage.py @@ -62,15 +62,33 @@ def test_sanitize_html_removes_active_and_remote_content(): assert "onclick" not in lowered assert "javascript:" not in lowered assert "tracker.test" not in lowered + assert "alert(1)" not in lowered + assert "" + "

Readable email content

" + "intercom" + ) + lowered = cleaned.lower() + assert "#outlook" not in lowered + assert ".readmsgbody" not in lowered + assert "tracker.test" not in lowered + assert "intercom" not in lowered + assert " None: "--latest", 'dist/*.zip', 'dist/*.sha256', - 'docs/RELEASE_NOTES_1.3.3.md', + 'docs/RELEASE_NOTES_1.3.4.md', ) for marker in required: assert marker in text, marker