diff --git a/apps/board/members.py b/apps/board/members.py
index e4d58f7..d6d2218 100644
--- a/apps/board/members.py
+++ b/apps/board/members.py
@@ -129,6 +129,10 @@ def new():
# The form says so before it is filled in; offering a ticked checkbox
# and reporting the problem on submit wastes the work of filling it.
can_create_accounts=bool(current_app.config.get("ADMIN_TOKEN")),
+ # Same courtesy for mail: without a server configured the invitation
+ # cannot leave the box, and the admin has to pass the link on by
+ # hand. Worth knowing before filling the form rather than after.
+ can_send_mail=mail.configured(),
gitea_url=current_app.config["GITEA_URL"].rstrip("/"),
)
@@ -175,19 +179,25 @@ def new():
return redirect(url_for("members.new"))
invite_link = None
+ mail_problem = None
if create_account:
link = auth.invite_url(invites.issue(cursor.lastrowid, "invite"))
try:
mail.send_invite(email, display_name or login, link)
- except (mail.MailFailed, mail.MailNotConfigured):
+ except mail.MailNotConfigured:
+ # Nothing is broken — this server has simply never been given a mail
+ # server. Told apart from a failure on purpose: an admin sent looking
+ # for an SMTP error that does not exist is an afternoon wasted.
+ invite_link, mail_problem = link, "unconfigured"
+ except mail.MailFailed:
# The account exists and the member cannot reach it. Showing the
# admin the link is the difference between a delayed invitation and
# a person who simply never gets in.
- invite_link = link
+ invite_link, mail_problem = link, "failed"
return render_template("member_created.html", login=login,
email=email, created=create_account,
- invite_link=invite_link,
+ invite_link=invite_link, mail_problem=mail_problem,
role_label=ROLE_LABELS[role])
diff --git a/apps/board/templates/member_created.html b/apps/board/templates/member_created.html
index 6b8bad9..2c24020 100644
--- a/apps/board/templates/member_created.html
+++ b/apps/board/templates/member_created.html
@@ -12,14 +12,28 @@
{# The account exists but the invitation never left the building. Showing the
link is the difference between a delayed invitation and a person who
simply never gets in. #}
-
- No se pudo enviar el correo. Pásale este enlace por un canal privado —
- sirve una sola vez y caduca en 7 días.
+ {% if mail_problem == 'unconfigured' %}
+
+ Este servidor todavía no envía correo. Pásale este enlace por un canal
+ privado — sirve una sola vez y caduca en 7 días.
+ {% else %}
+
+ No se pudo enviar el correo: el servidor de correo rechazó el envío o no
+ respondió. Pásale este enlace por un canal privado — sirve una sola vez y
+ caduca en 7 días.
+
+ {% endif %}
{{ invite_link }}
+ {% if mail_problem == 'unconfigured' %}
+ Añade los valores MAIL_* en
+ /srv/board/.env para que la próxima invitación
+ salga sola.
+ {% else %}
Revisa la configuración de correo del servidor para que la próxima
invitación salga sola.
+ {% endif %}
{% else %}
diff --git a/apps/board/templates/member_new.html b/apps/board/templates/member_new.html
index e9ed03d..3e522b4 100644
--- a/apps/board/templates/member_new.html
+++ b/apps/board/templates/member_new.html
@@ -37,6 +37,16 @@
{% endif %}
+ {% if can_create_accounts and not can_send_mail %}
+ {# Not an error: the account is still created and the invitation still
+ issued. But it leaves by hand, and finding that out after filling the
+ form is how an invitation ends up believed sent and never delivered. #}
+
+ Este servidor todavía no envía correo, así que la invitación no se
+ enviará sola: al terminar te mostraremos el enlace para que se lo pases.
+
+ {% endif %}
+
{% if can_make_admin %}
Rol
diff --git a/apps/board/tests/test_deployment.py b/apps/board/tests/test_deployment.py
new file mode 100644
index 0000000..aef1b59
--- /dev/null
+++ b/apps/board/tests/test_deployment.py
@@ -0,0 +1,46 @@
+"""Guards on the gap between what the app reads and what the server hands it.
+
+This is the one class of bug the rest of the suite cannot see. Every test here
+runs against a Flask config built in-process, so a setting can be documented in
+`.env.example`, read in `app.py`, and still reach the container as nothing at
+all — because Compose does not pass `.env` to a container, it only substitutes
+into the compose file. The symptom is a feature reporting itself unconfigured
+with the settings sitting right there on disk, which is a miserable thing to
+debug from the outside.
+"""
+
+from __future__ import annotations
+
+import re
+from pathlib import Path
+
+INFRA = Path(__file__).resolve().parents[3] / "infra" / "board"
+ENV_EXAMPLE = INFRA / ".env.example"
+COMPOSE = INFRA / "docker-compose.yml"
+
+SETTING = re.compile(r"^([A-Z][A-Z0-9_]*)=", re.MULTILINE)
+
+
+def documented() -> set[str]:
+ return set(SETTING.findall(ENV_EXAMPLE.read_text(encoding="utf-8")))
+
+
+def test_every_documented_setting_reaches_the_container():
+ compose = COMPOSE.read_text(encoding="utf-8")
+ missing = sorted(
+ name for name in documented()
+ if f"${{{name}}}" not in compose and f"${{{name}:-" not in compose
+ )
+ assert not missing, (
+ "these are in .env.example but never substituted into "
+ f"docker-compose.yml, so the container never sees them: {missing}"
+ )
+
+
+def test_the_mail_settings_are_optional_at_the_compose_level():
+ """`${MAIL_HOST}` without a default makes Compose warn on every command for
+ a server that has deliberately not configured mail. Running without it is a
+ supported state, so it must be a quiet one."""
+ compose = COMPOSE.read_text(encoding="utf-8")
+ for name in sorted(n for n in documented() if n.startswith("MAIL_")):
+ assert f"${{{name}:-" in compose, f"{name} has no default in docker-compose.yml"
diff --git a/apps/board/tests/test_invites.py b/apps/board/tests/test_invites.py
index 6e16f97..845a99c 100644
--- a/apps/board/tests/test_invites.py
+++ b/apps/board/tests/test_invites.py
@@ -262,3 +262,35 @@ def test_linking_an_existing_account_sends_nothing(
"role": "user", "create_account": "",
})
assert outbox == []
+
+
+# --- when the server cannot send at all -----------------------------------
+
+def test_an_unconfigured_server_does_not_blame_the_mail_server(
+ app, client, post, owner_id, sign_in, monkeypatch):
+ """No MAIL_HOST is not a failure, and saying "no se pudo enviar" sends the
+ admin hunting for an SMTP error that was never produced."""
+ monkeypatch.setattr(gitea, "admin_create_user",
+ lambda login, email, name, password: None)
+ app.config["MAIL_HOST"] = ""
+ sign_in(owner_id)
+
+ page = post("/comunidad/miembros/nuevo", {
+ "login": "maria", "display_name": "María", "email": "m@example.com",
+ "role": "user", "create_account": "on",
+ }).get_data(as_text=True)
+
+ assert "todavía no envía correo" in page
+ assert "No se pudo enviar el correo" not in page
+ assert "/comunidad/invitacion/" in page
+
+
+def test_the_form_warns_before_it_is_filled_in(app, client, owner_id, sign_in):
+ app.config["MAIL_HOST"] = ""
+ sign_in(owner_id)
+ assert "no envía correo" in client.get(
+ "/comunidad/miembros/nuevo").get_data(as_text=True)
+
+ app.config["MAIL_HOST"] = "smtp.example.com"
+ assert "no envía correo" not in client.get(
+ "/comunidad/miembros/nuevo").get_data(as_text=True)
diff --git a/docs/server-setup.md b/docs/server-setup.md
index 4fb9f6d..4c0a378 100644
--- a/docs/server-setup.md
+++ b/docs/server-setup.md
@@ -193,9 +193,16 @@ your laptop):
cd ~/vienalatina
git remote add gitea https://git.vienalatina.com/pablo/vienalatina.git
git push gitea main
+
+# Name the GitHub remote too, while you are here. The clone in step 5 called it
+# `origin`, but `main` came to track `gitea/main` — so a bare `git pull` asks
+# Gitea, and new work arrives on GitHub. Having both named saves you from typing
+# the URL every time you deploy.
+git remote add github https://github.com/pablovolenski/vienalatina.git
+git remote -v
```
-(It will ask for your Gitea username/password.)
+(Gitea will ask for your Gitea username/password.)
In Woodpecker (**https://ci.vienalatina.com**):
@@ -372,6 +379,51 @@ It cannot be used to take ownership later: that is deliberate, because otherwise
editing a file on disk would be a quieter route to the top than asking for it.
Ownership moves only through *Transferir titularidad* inside the app.
+#### Updating it later — a pull is not a deploy
+
+The app's code is **inside the image**: `docker/board/Dockerfile` ends with
+`COPY apps /srv/apps`. So pulling new commits into `~/vienalatina` changes
+nothing that is running, and neither does `docker compose up -d
+--force-recreate` — same image tag, same layers, same old code. Everything
+reports success and the server behaves exactly as it did before, which is the
+most expensive kind of nothing.
+
+**And `git pull` on its own will not fetch it.** This is worth knowing before
+it costs you an afternoon: `main` on the server tracks `gitea/main`, while new
+work is pushed to a branch on **GitHub**. So a bare `git pull` asks Gitea,
+finds Gitea level with your local `main`, and answers *"Already up to date."* —
+which is true about the wrong remote, and reads exactly like there is nothing
+to do.
+
+An update is a named pull, a push to Gitea so the build pipeline sees it, and a
+rebuild:
+
+```sh
+cd ~/vienalatina
+git pull --no-edit github # e.g. claude/relaxed-faraday-h4zd09
+git push gitea main
+sudo bash scripts/deploy-board.sh
+```
+
+`--no-edit` accepts the default merge message. Without it git opens an editor,
+which is a strange place to find yourself mid-deploy.
+
+If `github` is not a remote yet, add it once — see the end of step 8:
+
+```sh
+git remote add github https://github.com/pablovolenski/vienalatina.git
+```
+
+That rebuilds the image, copies the compose file across, restarts, and prints
+the log. It never touches `/srv/board/.env` — that file holds the secrets and
+lives only on the server — but it does compare it against `.env.example` and
+name any setting that has appeared in the repository and is missing from yours.
+New settings are always added by hand.
+
+The database schema is applied at start-up with `CREATE TABLE IF NOT EXISTS`,
+so a release that adds a table needs no migration step: the table appears when
+the new code does.
+
### 11.4 Route it through Caddy
Add to the `vienalatina.com` block in `/etc/caddy/Caddyfile` (already present in
@@ -622,9 +674,20 @@ perfectly in a mail client fails here, and the error it produces is a timeout
rather than anything that names the cause.
```sh
-cd /srv/board && sudo docker compose up -d --force-recreate
+cd ~/vienalatina && sudo bash scripts/deploy-board.sh
```
+Not `docker compose up -d --force-recreate` on its own: if the code that reads
+these settings arrived in the same pull, that restart runs the old image and
+mail stays unconfigured with the settings sitting right there in `.env`. The
+script rebuilds first.
+
+The variables also have to be listed in `/srv/board/docker-compose.yml`, which
+they now are. Compose does not hand `.env` to a container — it substitutes into
+the compose file — so a setting added to `.env` and not to the compose file is
+read by nobody. `apps/board/tests/test_deployment.py` fails the build if the
+two ever drift apart again.
+
Test it by adding a member with an address you can read. If the mail cannot be
sent, the screen says so and shows you the invitation link to pass on by hand —
the account is created either way, so a mail problem delays somebody rather
diff --git a/infra/board/docker-compose.yml b/infra/board/docker-compose.yml
index 8d5b08f..4defeeb 100644
--- a/infra/board/docker-compose.yml
+++ b/infra/board/docker-compose.yml
@@ -35,6 +35,23 @@ services:
- GITEA_ADMIN_TOKEN=${GITEA_ADMIN_TOKEN:-}
- BOARD_DB=/data/board.db
+
+ # Outgoing mail, for invitations and password resets. Empty MAIL_HOST is
+ # a supported state: the members area still works, but nobody can be
+ # invited or recover a password, and the screens say so.
+ #
+ # Every one of these has to be listed here. Compose does not hand the
+ # contents of .env to the container by itself — it only substitutes into
+ # this file — so a variable added to .env and not added here is read by
+ # nobody, and the app reports mail as unconfigured with the settings
+ # sitting right there on disk.
+ - MAIL_HOST=${MAIL_HOST:-}
+ - MAIL_PORT=${MAIL_PORT:-587}
+ - MAIL_SECURITY=${MAIL_SECURITY:-starttls}
+ - MAIL_USER=${MAIL_USER:-}
+ - MAIL_PASSWORD=${MAIL_PASSWORD:-}
+ - "MAIL_FROM=${MAIL_FROM:-Viena Latina }"
+
volumes:
- ./data:/data
ports:
diff --git a/scripts/deploy-board.sh b/scripts/deploy-board.sh
new file mode 100755
index 0000000..17c8a5e
--- /dev/null
+++ b/scripts/deploy-board.sh
@@ -0,0 +1,67 @@
+#!/usr/bin/env bash
+# Put the current checkout of the members area onto the server.
+#
+# The app's code is baked into the image by docker/board/Dockerfile (`COPY apps
+# /srv/apps`), so pulling new commits changes nothing on its own — the container
+# keeps running the code that was in the image when it was built. `docker
+# compose up -d --force-recreate` does not help either: same tag, same layers,
+# same old code. That is a quiet failure, because everything reports success and
+# the site behaves exactly as it did before.
+#
+# This script is the whole update, in the order that matters:
+#
+# git pull # done by you, first
+# sudo bash scripts/deploy-board.sh
+#
+# It never touches /srv/board/.env. That file holds the secrets and exists only
+# on the server; the repository has .env.example instead, and the two drifting
+# apart is expected — new settings appear in the example and have to be copied
+# across by hand.
+
+set -euo pipefail
+
+REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+TARGET="${BOARD_DIR:-/srv/board}"
+IMAGE="vienalatina/board:1"
+
+cd "$REPO"
+
+echo "==> Building $IMAGE from $(git rev-parse --short HEAD)"
+docker build -t "$IMAGE" -f docker/board/Dockerfile .
+
+echo "==> Syncing compose files into $TARGET (never .env)"
+mkdir -p "$TARGET"
+cp "$REPO/infra/board/docker-compose.yml" "$TARGET/docker-compose.yml"
+cp "$REPO/infra/board/.env.example" "$TARGET/.env.example"
+
+if [ ! -f "$TARGET/.env" ]; then
+ echo "No $TARGET/.env — copy .env.example to .env and fill it in first." >&2
+ exit 1
+fi
+
+# Settings that appear in the example and not in the live file. Nothing is
+# copied automatically: some of them are secrets, and a blank line silently
+# added to .env is worse than a line missing loudly.
+missing="$(comm -23 \
+ <(grep -oE '^[A-Z][A-Z0-9_]*=' "$TARGET/.env.example" | sort -u) \
+ <(grep -oE '^[A-Z][A-Z0-9_]*=' "$TARGET/.env" | sort -u) || true)"
+if [ -n "$missing" ]; then
+ echo
+ echo "!! These settings exist in .env.example but not in your .env:"
+ echo "$missing" | sed 's/^/ /'
+ echo " Add them to $TARGET/.env and run this again if the feature needs them."
+ echo
+fi
+
+echo "==> Restarting"
+cd "$TARGET"
+docker compose up -d --force-recreate
+
+# The schema is applied at start-up with CREATE TABLE IF NOT EXISTS, so a new
+# table arrives with the new code. If the container is not up a few seconds
+# later it died during that, and the log says why.
+sleep 3
+docker compose ps
+echo
+echo "Recent log:"
+docker compose logs --tail 20 board