Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ Read these before changing behaviour in those areas, and update them if you do.
## Pitfalls

- **Background IMAP thread:** `initialize_imap_polling` is skipped when `app.config['TESTING']` is set. In tests always call `create_app(..., one_off_call=True)` — see `tests/conftest.py`.
- **SCSS at startup:** The app shells out to `sass` on startup. `sass` must be on PATH for the production server but is not needed for tests (`one_off_call=True` skips it).
- **SCSS at startup:** The app compiles SCSS on startup, preferring `sass` on PATH but falling back to the bundled `sass-embedded` package if it's not found. Not needed for tests (`one_off_call=True` skips it).
- **EmailIn composite PK:** `email_in.(message_id, list_id)` is a composite PK. `email_out` holds a compound FK to both columns — handle carefully in queries and migrations.
- **Soft-delete:** `MailingList` is never hard-deleted. Use `.deactivate()` / `.reactivate()`.
- **IMAP in tests:** Use the `fixture_mailbox_stub` fixture (`MailboxStub`) — never open real IMAP connections in tests.
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ scripts/ Utility scripts
- Tests run with an in-memory SQLite database
- The app is created with `one_off_call=True` to avoid starting background IMAP threads
- IMAP interactions are mocked using the `MailboxStub` fixture in `tests/conftest.py`
- SCSS compilation requires `sass` on PATH; tests skip this via `one_off_call=True`
- SCSS compilation prefers `sass` on PATH but falls back to the bundled `sass-embedded` package if unavailable; tests skip both compiler paths entirely via `one_off_call=True`

## Submitting changes

Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,11 @@ CastMail2List is not a replacement for Mailman in large-scale or enterprise setu
- **Internationalization** — UI available in English and German; extensible via standard gettext.
- **Database migrations** — schema changes handled automatically via Alembic/Flask-Migrate.

## Requirements

- Python 3.10+
- Optional: `sass`. CastMail2List compiles its bundled SCSS to CSS on startup. If no system-wide `sass` binary is found on `PATH`, it automatically falls back to the bundled [`sass-embedded`](https://pypi.org/project/sass-embedded/) Python package, which downloads a pinned Dart Sass binary into the virtual environment on first run (cached afterward, no repeated downloads). Installing system `sass` is optional but avoids that one-time download, keeps the virtual environment smaller, and allows the user to define which version is being used.

## Installation

### From PyPI
Expand Down
2 changes: 1 addition & 1 deletion castmail2list/static/scss/main.scss
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// SPDX-FileCopyrightText: 2025 Max Mehl <https://mehl.mx>
// SPDX-License-Identifier: Apache-2.0

// Import Pico CSS framework with custom theme color
// Import Pico CSS framework (version 2.1.1 with minimal fixes) with custom theme color
@use "pico" with ($theme-color: "amber");
@use 'sass:list';

Expand Down
5 changes: 2 additions & 3 deletions castmail2list/static/scss/pico/components/_modal.scss
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,8 @@
// Content
> article {
$close-selector: if(
$enable-classes,
".close, :is(a, button)[rel=prev]",
":is(a, button)[rel=prev]"
sass($enable-classes): ".close, :is(a, button)[rel=prev]";
else: ":is(a, button)[rel=prev]",
);
width: 100%;
max-height: calc(100vh - var(#{$css-var-prefix}spacing) * 2);
Expand Down
57 changes: 53 additions & 4 deletions castmail2list/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import logging
import os
import re
import shutil
import subprocess
import sys
import uuid
Expand All @@ -28,10 +29,16 @@
from .models import EmailIn, EmailOut, Logs, MailingList, Subscriber, db


def compile_scss(compiler: str, scss_input: str, css_output: str) -> None:
"""Compile SCSS files to CSS using an external compiler."""
def _compile_scss_system(compiler: str, scss_input: str, css_output: str) -> None:
"""Compile SCSS files to CSS using the system-installed Sass compiler.

Args:
compiler (str): Path/name of the system Sass executable (e.g. "sass").
scss_input (str): Absolute path to the SCSS input file.
css_output (str): Absolute path to the CSS output file.
"""
try:
logging.info("Compiling %s to %s", scss_input, css_output)
logging.info("Compiling %s to %s (system sass)", scss_input, css_output)
subprocess.run([compiler, scss_input, css_output], check=True) # noqa: S603
except subprocess.CalledProcessError as e:
logging.critical("Error compiling %s: %s", scss_input, e)
Expand All @@ -43,6 +50,48 @@ def compile_scss(compiler: str, scss_input: str, css_output: str) -> None:
sys.exit(1)


def _compile_scss_embedded(scss_input: str, css_output: str) -> None:
"""Compile SCSS files to CSS using the bundled sass-embedded package.

Used as a fallback when no system Sass compiler is available on PATH. On first use, this
downloads a pinned Dart Sass binary into the virtual environment; subsequent calls reuse the
cached binary.

Args:
scss_input (str): Absolute path to the SCSS input file.
css_output (str): Absolute path to the CSS output file.
"""
from sass_embedded import compile_file # noqa: PLC0415
from sass_embedded.dart_sass.installer import install as install_dart_sass # noqa: PLC0415

try:
logging.info("Compiling %s to %s (bundled sass-embedded)", scss_input, css_output)
install_dart_sass() # idempotent; no-op if already cached in the venv
compile_file(Path(scss_input), Path(css_output))
except Exception as e: # noqa: BLE001
logging.critical("Error compiling %s with sass-embedded: %s", scss_input, e)
sys.exit(1)


def _compile_scss(scss_input: str, css_output: str) -> None:
"""Compile SCSS to CSS, preferring a system Sass compiler if available.

If a `sass` executable is found on PATH, it is used. Otherwise, this falls back to the
bundled `sass-embedded` package, which downloads a pinned Dart Sass binary into the virtual
environment on first use.

Args:
scss_input (str): Absolute path to the SCSS input file.
css_output (str): Absolute path to the CSS output file.
"""
if system_sass := shutil.which("sass"):
logging.info("Using system sass binary at %s", system_sass)
_compile_scss_system(system_sass, scss_input=scss_input, css_output=css_output)
else:
logging.info("System sass not found on PATH; falling back to bundled sass-embedded")
_compile_scss_embedded(scss_input=scss_input, css_output=css_output)


def compile_scss_on_startup(scss_files: list[tuple[str, str]]) -> list[tuple[str, str]]:
"""Compile SCSS to CSS on application startup.

Expand All @@ -56,7 +105,7 @@ def compile_scss_on_startup(scss_files: list[tuple[str, str]]) -> list[tuple[str
for scss_input, css_output in scss_files:
scss_input_abs = str(curpath / Path(scss_input))
css_output_abs = str(curpath / Path(css_output))
compile_scss("sass", scss_input=scss_input_abs, css_output=css_output_abs)
_compile_scss(scss_input=scss_input_abs, css_output=css_output_abs)
compiled_files.append((scss_input_abs, css_output_abs))
return compiled_files

Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ dependencies = [
"gunicorn>=26,<27",
"platformdirs>=4.5.0,<5",
"ago>=0.1.1",
"sass-embedded>=0.1.5",
]

[project.urls]
Expand Down
132 changes: 132 additions & 0 deletions tests/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -729,3 +729,135 @@ def test_create_app_raises_on_missing_secret_key() -> None:
},
one_off_call=True,
)


# ---------------------- SCSS Compilation Tests ----------------------


def test_compile_scss_uses_system_sass_when_available(monkeypatch: MonkeyPatch) -> None:
"""If a system sass binary is found on PATH, it should be used via subprocess."""
monkeypatch.setattr(utils.shutil, "which", lambda _name: "/usr/bin/sass")

calls: list[list[str]] = []

def fake_run(cmd: list[str], check: bool) -> None:
calls.append(cmd)

monkeypatch.setattr(utils.subprocess, "run", fake_run)

utils._compile_scss(scss_input="/tmp/in.scss", css_output="/tmp/out.css")

assert calls == [["/usr/bin/sass", "/tmp/in.scss", "/tmp/out.css"]]


def test_compile_scss_falls_back_to_embedded_when_no_system_sass(
monkeypatch: MonkeyPatch,
) -> None:
"""If no system sass binary is found on PATH, fall back to sass-embedded."""
monkeypatch.setattr(utils.shutil, "which", lambda _name: None)

embedded_calls: list[tuple[str, str]] = []

def fake_compile_scss_embedded(scss_input: str, css_output: str) -> None:
embedded_calls.append((scss_input, css_output))

monkeypatch.setattr(utils, "_compile_scss_embedded", fake_compile_scss_embedded)

utils._compile_scss(scss_input="/tmp/in.scss", css_output="/tmp/out.css")

assert embedded_calls == [("/tmp/in.scss", "/tmp/out.css")]


def test_compile_scss_embedded_installs_and_compiles(monkeypatch: MonkeyPatch) -> None:
"""compile_scss_embedded should install Dart Sass (idempotent), then compile via
sass_embedded.
"""
install_calls = []
compile_calls = []

fake_sass_embedded = type(
"FakeModule",
(),
{"compile_file": staticmethod(lambda source, dest: compile_calls.append((source, dest)))},
)()
fake_installer = type(
"FakeInstaller", (), {"install": staticmethod(lambda: install_calls.append(True))}
)()

monkeypatch.setitem(__import__("sys").modules, "sass_embedded", fake_sass_embedded)
monkeypatch.setitem(
__import__("sys").modules, "sass_embedded.dart_sass.installer", fake_installer
)

utils._compile_scss_embedded(scss_input="/tmp/in.scss", css_output="/tmp/out.css")

assert install_calls == [True]
assert compile_calls == [(Path("/tmp/in.scss"), Path("/tmp/out.css"))]


def test_compile_scss_system_exits_on_missing_compiler(monkeypatch: MonkeyPatch) -> None:
"""compile_scss_system should log critical and exit if the compiler binary is not found."""

def fake_run(cmd: list[str], check: bool) -> NoReturn:
raise FileNotFoundError

monkeypatch.setattr(utils.subprocess, "run", fake_run)

with pytest.raises(SystemExit) as exc_info:
utils._compile_scss_system("sass", scss_input="/tmp/in.scss", css_output="/tmp/out.css")

assert exc_info.value.code == 1


def test_compile_scss_system_exits_on_compile_error(monkeypatch: MonkeyPatch) -> None:
"""compile_scss_system should log critical and exit if compilation fails."""

def fake_run(cmd: list[str], check: bool) -> NoReturn:
raise subprocess.CalledProcessError(returncode=1, cmd=cmd)

monkeypatch.setattr(utils.subprocess, "run", fake_run)

with pytest.raises(SystemExit) as exc_info:
utils._compile_scss_system("sass", scss_input="/tmp/in.scss", css_output="/tmp/out.css")

assert exc_info.value.code == 1


def test_compile_scss_embedded_exits_on_compile_error(monkeypatch: MonkeyPatch) -> None:
"""compile_scss_embedded should log critical and exit if sass-embedded compilation fails."""

def fake_compile_file(source: Path, dest: Path) -> NoReturn:
msg = "boom"
raise RuntimeError(msg)

fake_sass_embedded = type("FakeModule", (), {"compile_file": staticmethod(fake_compile_file)})()
fake_installer = type("FakeInstaller", (), {"install": staticmethod(lambda: None)})()

monkeypatch.setitem(__import__("sys").modules, "sass_embedded", fake_sass_embedded)
monkeypatch.setitem(
__import__("sys").modules, "sass_embedded.dart_sass.installer", fake_installer
)

with pytest.raises(SystemExit) as exc_info:
utils._compile_scss_embedded(scss_input="/tmp/in.scss", css_output="/tmp/out.css")

assert exc_info.value.code == 1


def test_compile_scss_on_startup_resolves_paths_and_compiles(monkeypatch: MonkeyPatch) -> None:
"""compile_scss_on_startup should resolve absolute paths and delegate to compile_scss."""
calls: list[tuple[str, str]] = []

def fake_compile_scss(scss_input: str, css_output: str) -> None:
calls.append((scss_input, css_output))

monkeypatch.setattr(utils, "_compile_scss", fake_compile_scss)

result = utils.compile_scss_on_startup([("static/scss/main.scss", "static/css/main.css")])

curpath = Path(utils.__file__).parent.resolve()
expected_input = str(curpath / "static/scss/main.scss")
expected_output = str(curpath / "static/css/main.css")

assert calls == [(expected_input, expected_output)]
assert result == [(expected_input, expected_output)]
Loading