Compare commits

..
7 Commits
Author SHA1 Message Date
Lucas RayanandClaude Sonnet 5.5 448bb59672 chore(wolf): record the CI, branch and release conventions
Release / Plano (reaproveitar as imagens?) (push) Successful in 4s
Release / ci (push) Skipped
Release / Build auth (linux/amd64) (push) Skipped
Release / Build migrations (linux/amd64) (push) Skipped
Release / Build protocols (linux/amd64) (push) Skipped
Release / Build rspamd (linux/amd64) (push) Skipped
Release / Build storage (linux/amd64) (push) Skipped
Release / Build webmail (linux/amd64) (push) Skipped
Release / Build auth (linux/arm64) (push) Skipped
Release / Build migrations (linux/arm64) (push) Skipped
Release / Build protocols (linux/arm64) (push) Skipped
Release / Build rspamd (linux/arm64) (push) Skipped
Release / Build storage (linux/arm64) (push) Skipped
Release / Build webmail (linux/arm64) (push) Skipped
Release / Namespace das imagens (push) Successful in 0s
Release / Publica auth (push) Successful in 17s
Release / Publica migrations (push) Successful in 19s
Release / Publica protocols (push) Successful in 17s
Release / Publica rspamd (push) Successful in 20s
Release / Publica storage (push) Successful in 21s
Release / Publica webmail (push) Successful in 16s
Release / Cria a release no Gitea (push) Skipped
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-30 12:43:52 -03:00
Lucas RayanandClaude Sonnet 5.5 a6719c6018 ci: fix the release job being skipped, and take the notes from the changelog
Release / Plano (reaproveitar as imagens?) (push) Successful in 4s
Release / ci (push) Skipped
Release / Namespace das imagens (push) Successful in 1s
Release / Build auth (linux/amd64) (push) Skipped
Release / Build migrations (linux/amd64) (push) Skipped
Release / Build protocols (linux/amd64) (push) Skipped
Release / Build rspamd (linux/amd64) (push) Skipped
Release / Build storage (linux/amd64) (push) Skipped
Release / Build webmail (linux/amd64) (push) Skipped
Release / Build auth (linux/arm64) (push) Skipped
Release / Build migrations (linux/arm64) (push) Skipped
Release / Build protocols (linux/arm64) (push) Skipped
Release / Build rspamd (linux/arm64) (push) Skipped
Release / Build storage (linux/arm64) (push) Skipped
Release / Build webmail (linux/arm64) (push) Skipped
Release / Publica migrations (push) Successful in 20s
Release / Publica auth (push) Successful in 20s
Release / Publica protocols (push) Successful in 20s
Release / Publica rspamd (push) Successful in 20s
Release / Publica storage (push) Successful in 21s
Release / Publica webmail (push) Successful in 21s
Release / Cria a release no Gitea (push) Skipped
The job inherited the implicit success() and was skipped whenever the build job
upstream was skipped (images reused). It now checks publish explicitly, like
publish does. Notes come from the matching CHANGELOG section through
scripts/release-notes.sh, not from the commit log of the whole history.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-30 12:27:08 -03:00
Lucas RayanandClaude Sonnet 5.5 1bcbe4d774 Merge ci/reuse-images: tags and docs-only merges reuse the published images
Release / Namespace das imagens (push) Successful in 1s
Release / Plano (reaproveitar as imagens?) (push) Successful in 4s
Release / ci (push) Skipped
Release / Build auth (linux/amd64) (push) Skipped
Release / Build migrations (linux/amd64) (push) Skipped
Release / Build protocols (linux/amd64) (push) Skipped
Release / Build rspamd (linux/amd64) (push) Skipped
Release / Build storage (linux/amd64) (push) Skipped
Release / Build webmail (linux/amd64) (push) Skipped
Release / Build auth (linux/arm64) (push) Skipped
Release / Build migrations (linux/arm64) (push) Skipped
Release / Build protocols (linux/arm64) (push) Skipped
Release / Build rspamd (linux/arm64) (push) Skipped
Release / Build storage (linux/arm64) (push) Skipped
Release / Build webmail (linux/arm64) (push) Skipped
Release / Publica auth (push) Successful in 20s
Release / Publica migrations (push) Successful in 20s
Release / Publica rspamd (push) Successful in 21s
Release / Publica protocols (push) Successful in 22s
Release / Publica webmail (push) Successful in 20s
Release / Publica storage (push) Successful in 21s
Release / Cria a release no Gitea (push) Skipped
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-30 12:20:51 -03:00
Lucas RayanandClaude Sonnet 5.5 a3e071bb03 ci: reuse published images when the image sources are unchanged
Release hashes services/, migrations/ and config/; when images for that hash
already exist it skips CI and the build and only applies the new tags. Tagging
a release no longer recompiles the webmail.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-30 12:20:51 -03:00
Lucas RayanandClaude Sonnet 5.5 2db5915061 Merge release/v1.0.0: changelog, one .env.example, release docs, faster CI, Gitea release job
CI / Lint de idioma (R9) (push) Successful in 3s
CI / Stack completa (push) Canceled after 0s
CI / Imagem (webmail) (push) Canceled after 0s
CI / Imagem (storage) (push) Canceled after 0s
CI / Imagem (rspamd) (push) Canceled after 0s
CI / Imagem (protocols) (push) Canceled after 0s
CI / Imagem (migrations) (push) Canceled after 0s
CI / Imagem (auth) (push) Canceled after 0s
CI / Trivy (dependências, segredos e Dockerfiles) (push) Canceled after 0s
CI / Testes (webmail) (push) Canceled after 0s
CI / TypeScript (storage) (push) Canceled after 0s
CI / TypeScript (auth) (push) Canceled after 2m6s
CI / Go (protocols) (push) Canceled after 2m8s
Release / ci (push) Canceled after 2m9s
Release / Namespace das imagens (push) Canceled after 0s
Release / Build auth (linux/amd64) (push) Canceled after 0s
Release / Build auth (linux/arm64) (push) Canceled after 0s
Release / Build migrations (linux/amd64) (push) Canceled after 0s
Release / Build migrations (linux/arm64) (push) Canceled after 0s
Release / Build protocols (linux/amd64) (push) Canceled after 0s
Release / Build protocols (linux/arm64) (push) Canceled after 0s
Release / Build rspamd (linux/amd64) (push) Canceled after 0s
Release / Build rspamd (linux/arm64) (push) Canceled after 0s
Release / Build storage (linux/amd64) (push) Canceled after 0s
Release / Build storage (linux/arm64) (push) Canceled after 0s
Release / Build webmail (linux/amd64) (push) Canceled after 0s
Release / Build webmail (linux/arm64) (push) Canceled after 0s
Release / Publica auth (push) Canceled after 0s
Release / Publica migrations (push) Canceled after 0s
Release / Publica protocols (push) Canceled after 0s
Release / Publica rspamd (push) Canceled after 0s
Release / Publica storage (push) Canceled after 0s
Release / Publica webmail (push) Canceled after 0s
Release / Cria a release no Gitea (push) Canceled after 0s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-30 11:44:19 -03:00
Lucas RayanandClaude Sonnet 5.5 5bbbb85128 release: 1.0.0, a changelog, one .env.example and the docs to match
CI / Lint de idioma (R9) (push) Successful in 5s
CI / Go (protocols) (push) Successful in 2m34s
CI / TypeScript (storage) (push) Successful in 15s
CI / Testes (webmail) (push) Successful in 51s
CI / TypeScript (auth) (push) Successful in 2m39s
CI / Trivy (dependências, segredos e Dockerfiles) (push) Successful in 9s
CI / Imagem (migrations) (push) Successful in 5s
CI / Stack completa (push) Successful in 1m11s
CI / Imagem (auth) (push) Successful in 17s
CI / Imagem (protocols) (push) Successful in 7s
CI / Imagem (rspamd) (push) Successful in 5s
CI / Imagem (storage) (push) Successful in 17s
CI / Imagem (webmail) (push) Successful in 1m52s
- CHANGELOG.md and CHANGELOG.pt-BR.md, from the project history.
- .env.example now serves development and production; scripts/init-env.sh
  (make env) creates .env with random secrets. .env.production.example is gone.
- docs/RELEASING.md: the pipeline, how a version is cut and deployed, the runners.
- DEPLOYMENT.md and both READMEs updated for Gitea and the new env flow.
- Service versions set to 1.0.0.
- CI: the proxy network step is idempotent and the stack is torn down always,
  since the runner's Docker daemon now outlives a job.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-30 11:36:22 -03:00
Lucas RayanandClaude Sonnet 5.5 f500534ac6 chore: keep .superset out of the repository
It is local workspace configuration for a tool, not part of the project.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-30 11:23:00 -03:00
21 changed files with 651 additions and 205 deletions

No files matched your search

+95 -44
View File
@@ -1,81 +1,132 @@
# --- Container images ---
# Published to the Gitea registry by .gitea/workflows/release.yml, multi-architecture
# (linux/amd64 and linux/arm64). The deploy host pulls these instead of
# compiling: `docker compose pull && docker compose up -d`. Running
# `docker compose build` locally still overrides them with a local build.
# Pin IMAGE_TAG to a released version (e.g. 1.2.3) or a sha- tag to roll back.
# LambdaMail - environment
#
# One file for local development and for production.
#
# scripts/init-env.sh copy this to .env and generate the secrets
# scripts/init-env.sh --production the same, plus a list of what you still
# have to set before this is a real server
#
# Values below are the local-development defaults. Lines marked PRODUCTION say
# what a real deployment needs. In Coolify, paste the finished .env into the
# environment editor instead of keeping a file on the host.
#
# The secrets are left empty on purpose. The script fills them with random
# values, and compose refuses to start while one is empty, because a default
# secret in a published repository is not a secret.
# --- Container images -------------------------------------------------------
# Published to the Gitea registry by .gitea/workflows/release.yml for
# linux/amd64 and linux/arm64. The deploy host pulls instead of compiling:
# `docker compose pull && docker compose up -d`. `make up` builds from source
# through the build overlay and ignores these.
# PRODUCTION: pin IMAGE_TAG to a release (1.0.0) or to a sha-<commit> tag. Never
# leave `latest` on a server: a redeploy then silently changes what runs.
IMAGE_NAMESPACE=git.lucasrguerra.dev.br/lucasrguerra/lambdamail
IMAGE_TAG=latest
# --- Postgres ---
POSTGRES_DB=lambdamail
POSTGRES_USER=lambdamail
POSTGRES_PASSWORD=change-me-in-local-dev
# --- Redis ---
REDIS_PASSWORD=change-me-in-local-dev
# --- Shared secrets (local dev values — never reuse in production) ---
JWT_SECRET=local-dev-jwt-secret-change-me-at-least-32-chars
# Encrypts DKIM private keys and provider tokens at rest. Losing it makes every
# stored secret unrecoverable — back it up separately from the database.
LAMBDAMAIL_MASTER_KEY=local-dev-master-key-32-bytes-minimum-value
# --- Host identity ---
PRIMARY_MAIL_HOST=mail.localhost
# --- Identity ---------------------------------------------------------------
# MAIL_DOMAIN is the domain you send and receive as.
# PRIMARY_MAIL_HOST is the host the MX record points at; its PTR must match it.
# PRODUCTION: MAIL_DOMAIN=example.com PRIMARY_MAIL_HOST=mail.example.com
# WEBMAIL_HOST=mail.example.com PUBLIC_IPV4=<the VPS public IPv4>
MAIL_DOMAIN=localhost
PRIMARY_MAIL_HOST=mail.localhost
WEBMAIL_HOST=localhost:3000
# Optional separate host for /admin.
ADMIN_HOST=
PUBLIC_IPV4=127.0.0.1
# Leave empty unless IPv6 has a matching PTR and is covered by SPF. A half
# configured IPv6 is a common cause of rejection (PLAN.md premises).
PUBLIC_IPV6=
# --- DNS automation (Cloudflare) ---
# --- Secrets ----------------------------------------------------------------
# Filled in by scripts/init-env.sh. To do it by hand:
#
# POSTGRES_PASSWORD, REDIS_PASSWORD openssl rand -hex 24
# JWT_SECRET, LAMBDAMAIL_MASTER_KEY openssl rand -hex 32
#
# The first two are substituted into connection URIs (postgres://user:PASS@db/...
# and redis://:PASS@redis:6379), so they must hold no character that means
# something in a URL: use hex, NOT `openssl rand -base64`, whose alphabet has "/"
# and "+". A "/" ends the authority component, the host is parsed as part of the
# password and every connection fails authentication - which shows up as the
# migrations container exiting 1 while Postgres itself reports healthy.
POSTGRES_DB=lambdamail
POSTGRES_USER=lambdamail
POSTGRES_PASSWORD=
REDIS_PASSWORD=
# Signs webmail and admin sessions. At least 32 characters; the auth service
# refuses to start below that.
JWT_SECRET=
# Encrypts DKIM private keys, TOTP secrets and provider tokens at rest.
# LOSING THIS MAKES EVERY STORED SECRET UNRECOVERABLE: back it up separately
# from the database (PLAN.md risk R9).
LAMBDAMAIL_MASTER_KEY=
# --- DNS automation (Cloudflare) --------------------------------------------
# The token needs Zone.DNS:Edit plus Zone.Zone:Read on the zone. Leave it empty
# to disable reconciliation; the service then logs that DNS sync is off.
# to manage the records by hand; the service then logs that DNS sync is off.
CLOUDFLARE_API_TOKEN=
DNS_SYNC_INTERVAL_SECONDS=21600
# --- TLS ---
# traefik: read the certificate Traefik manages (default, DANE not possible).
# acme: obtain the certificate ourselves via DNS-01. Required for DANE,
# because only this mode can reuse a pre-generated key.
# --- TLS --------------------------------------------------------------------
# traefik: read the certificate the proxy already manages (default; DANE is not
# possible in this mode).
# acme: obtain the certificate here via DNS-01. Required for DANE, because
# only this mode can reuse a pre-generated key.
TLS_MODE=traefik
# Used only when TLS_MODE=acme. Leave ACME_DIRECTORY_URL empty for Let's
# Encrypt production; point it at the staging directory while testing, since
# production rate limits can lock a domain out for a week.
# Used only when TLS_MODE=acme. Leave ACME_DIRECTORY_URL empty for Let's Encrypt
# production; point it at the staging directory while testing, since production
# rate limits can lock a domain out for a week.
ACME_EMAIL=
ACME_DIRECTORY_URL=
# Where the proxy keeps its certificates, and the Docker network it listens on.
# PRODUCTION (Coolify): COOLIFY_PROXY_DIR=/data/coolify/proxy
COOLIFY_PROXY_DIR=./local/acme
COOLIFY_PROXY_NETWORK=coolify
TRAEFIK_ACME_FILENAME=acme.json
CERT_POLL_INTERVAL_SECONDS=60
# Static certificate files, used when no ACME store is available (local dev).
# Static certificate files, used when no ACME store is available (local dev;
# `make gen-dev-cert` writes them).
PROTOCOLS_CERT_PATH=/etc/lambdamail/certs/cert.pem
PROTOCOLS_KEY_PATH=/etc/lambdamail/certs/key.pem
PROTOCOLS_SPOOL_DIR=/var/lambdamail/spool
# --- Transport security policy ---
# --- Transport security -----------------------------------------------------
# Start at "testing": senders honour the policy but do not hard-fail on it.
# Promote to "enforce" only once TLS-RPT reports come back clean.
# Promote to "enforce" only once TLS-RPT reports come back clean; enforce with a
# broken certificate silently blackholes inbound mail.
MTA_STS_MODE=testing
OUTBOUND_MTA_STS_ENABLED=true
# DANE stays off unless TLS_MODE=acme. Under Traefik-managed certificates the
# key changes at every renewal and the published TLSA record stops matching,
# which permanently rejects mail at every validating MTA.
# DANE stays off unless TLS_MODE=acme. Under proxy-managed certificates the key
# changes at every renewal and the published TLSA record stops matching, which
# permanently rejects mail at every validating MTA (PLAN.md section 5.1).
OUTBOUND_DANE_ENABLED=false
DNSSEC_RESOLVER=1.1.1.1:53
MAX_MESSAGE_BYTES=52428800
# ARC seals accepted messages so a downstream receiver can still tell that the
# mail authenticated at the first hop. Only worth enabling on a host that
# forwards mail onward.
ARC_SEAL_ENABLED=false
# --- Smarthost (for hosts where outbound port 25 is blocked) ---
# Setting RELAY_HOST routes all delivery through the relay and adds it to the
# published SPF record, without which every relayed message fails SPF.
# --- Smarthost (hosts where outbound port 25 is blocked) --------------------
# Most VPS providers block port 25. If `make preflight` reports it blocked, set a
# relay here: all delivery goes through it and it is added to the published SPF,
# without which every relayed message fails SPF.
RELAY_HOST=
RELAY_PORT=587
RELAY_USER=
RELAY_PASS=
# The SPF term that authorises the relay, exactly as the provider documents it.
# It cannot be derived from RELAY_HOST, and getting it wrong is worse than
# leaving it out. Brevo's relay is smtp-relay.brevo.com, but brevo.com publishes
# the SPF of their corporate mail: the sending ranges are at spf.brevo.com, so
# the value there is "include:spf.brevo.com".
RELAY_SPF_INCLUDE=
# --- Webmail ---
WEBMAIL_HOST=localhost:3000
ADMIN_HOST=
# --- Storage ----------------------------------------------------------------
STORAGE_DRIVER=local
S3_ENDPOINT=
S3_BUCKET=
S3_ACCESS_KEY=
S3_SECRET_KEY=
-104
View File
@@ -1,104 +0,0 @@
# LambdaMail - production environment
#
# Copy this into Coolify's environment editor, or to .env on the host, and
# replace every value marked CHANGE. The stack refuses to start without the
# required ones rather than falling back to a default, because a default
# secret in a published repository is not a secret.
#
# Generate each secret with: openssl rand -base64 36
# except POSTGRES_PASSWORD and REDIS_PASSWORD, which go inside connection URIs
# and must be URL-safe - see the note above them.
# --- Container images -------------------------------------------------------
# Published by .gitea/workflows/release.yml for linux/amd64 and linux/arm64.
# Pin IMAGE_TAG to a released version or a sha- tag to roll back.
IMAGE_NAMESPACE=git.lucasrguerra.dev.br/lucasrguerra/lambdamail
IMAGE_TAG=latest
# --- Identity ---------------------------------------------------------------
# MAIL_DOMAIN is the domain you send and receive as.
# PRIMARY_MAIL_HOST is the host the MX record points at; its PTR must match.
MAIL_DOMAIN=example.com # CHANGE
PRIMARY_MAIL_HOST=mail.example.com # CHANGE
WEBMAIL_HOST=mail.example.com # CHANGE - where the web UI is served
ADMIN_HOST= # optional separate host for /admin
PUBLIC_IPV4=203.0.113.10 # CHANGE - the VPS public IPv4
# Leave empty unless IPv6 has a matching PTR and is covered by SPF. Half
# configured IPv6 is a common cause of rejection (PLAN.md premises).
PUBLIC_IPV6=
# --- Secrets ----------------------------------------------------------------
# These two are substituted into connection URIs - postgres://user:PASS@db/... and
# redis://:PASS@redis:6379 - so they must contain no character that means
# something in a URL. Use hex:
#
# openssl rand -hex 24
#
# NOT `openssl rand -base64`, whose alphabet includes "/" and "+". A "/" ends
# the authority component, so the host is parsed as part of the password and
# every connection fails authentication - which surfaces as the migrations
# container exiting 1 while Postgres itself reports healthy.
POSTGRES_DB=lambdamail
POSTGRES_USER=lambdamail
POSTGRES_PASSWORD=CHANGE-generate-with-openssl-rand-hex-24
REDIS_PASSWORD=CHANGE-generate-with-openssl-rand-hex-24
# Signs webmail and admin sessions. Minimum 32 characters; the auth service
# refuses to start below that.
JWT_SECRET=CHANGE-generate-with-openssl-rand-at-least-32-chars
# Encrypts DKIM private keys, TOTP secrets and provider tokens at rest.
# LOSING THIS MAKES EVERY STORED SECRET UNRECOVERABLE - back it up separately
# from the database (PLAN.md risk R9).
LAMBDAMAIL_MASTER_KEY=CHANGE-generate-with-openssl-rand-at-least-32-chars
# --- DNS automation ---------------------------------------------------------
# Token scope: Zone.DNS:Edit plus Zone.Zone:Read on the zone. Leave empty to
# manage the 13 records by hand; the service then logs that sync is off.
CLOUDFLARE_API_TOKEN=
DNS_SYNC_INTERVAL_SECONDS=21600
# --- TLS --------------------------------------------------------------------
# traefik: read the certificate Coolify's proxy already manages (default).
# acme: obtain it here via DNS-01. Required for DANE, and only then.
TLS_MODE=traefik
COOLIFY_PROXY_DIR=/data/coolify/proxy
COOLIFY_PROXY_NETWORK=coolify
TRAEFIK_ACME_FILENAME=acme.json
CERT_POLL_INTERVAL_SECONDS=60
PROTOCOLS_SPOOL_DIR=/var/lambdamail/spool
# Used only when TLS_MODE=acme.
ACME_EMAIL=
ACME_DIRECTORY_URL=
# --- Transport security -----------------------------------------------------
# Start at testing. Promote to enforce only once TLS-RPT reports come back
# clean: enforce with a broken certificate silently blackholes inbound mail.
MTA_STS_MODE=testing
OUTBOUND_MTA_STS_ENABLED=true
# DANE stays off under TLS_MODE=traefik. The key changes at every renewal and
# the published TLSA record stops matching, which permanently rejects mail at
# every validating MTA (PLAN.md section 5.1).
OUTBOUND_DANE_ENABLED=false
DNSSEC_RESOLVER=1.1.1.1:53
MAX_MESSAGE_BYTES=52428800
ARC_SEAL_ENABLED=false
# --- Smarthost --------------------------------------------------------------
# Most VPS providers block outbound port 25. If the preflight reports it
# blocked, set a relay here; it is added to the published SPF automatically.
RELAY_HOST=
RELAY_PORT=587
RELAY_USER=
RELAY_PASS=
# The SPF term that authorises the relay, exactly as your provider documents
# it. Set this: it cannot be derived from RELAY_HOST, and getting it wrong is
# worse than leaving it out. Brevo's relay is smtp-relay.brevo.com, but
# brevo.com publishes the SPF of their corporate mail - the sending ranges are
# at spf.brevo.com, so the value here is "include:spf.brevo.com".
RELAY_SPF_INCLUDE=
# --- Storage ----------------------------------------------------------------
STORAGE_DRIVER=local
S3_ENDPOINT=
S3_BUCKET=
S3_ACCESS_KEY=
S3_SECRET_KEY=
+12 -3
View File
@@ -269,14 +269,23 @@ jobs:
needs: [go-protocols, ts-auth, ts-storage, webmail-build]
steps:
- uses: actions/checkout@v7
- run: cp .env.example .env
- run: scripts/init-env.sh
# The proxy network is declared external because Coolify owns it on the
# deploy host. Nothing creates it here, and without it compose refuses to
# start the stack at all.
#
# Idempotent on purpose: the runner's Docker daemon outlives the job, so
# the network may already exist from an earlier run.
- name: Cria a rede do proxy
run: docker network create "${COOLIFY_PROXY_NETWORK:-coolify}"
run: |
net="${COOLIFY_PROXY_NETWORK:-coolify}"
docker network inspect "$net" >/dev/null 2>&1 || docker network create "$net"
# The build overlay is explicit here: CI has to exercise the code in the
# branch, not the images already published from main.
- run: docker compose -f docker-compose.yaml -f docker-compose.build.yaml --env-file .env up --build --wait
- run: docker compose -f docker-compose.yaml -f docker-compose.build.yaml ps
- run: docker compose -f docker-compose.yaml -f docker-compose.build.yaml down -v
# Always, or a failed `up` leaves containers and volumes behind in the
# daemon the next run starts from.
- name: Derruba a stack
if: always()
run: docker compose -f docker-compose.yaml -f docker-compose.build.yaml down -v
+59 -23
View File
@@ -18,8 +18,46 @@ env:
REGISTRY: git.lucasrguerra.dev.br
jobs:
# Nothing is published unless the full suite passes first.
# The images are built from services/, migrations/ and config/ and nothing
# else, so those three trees identify them. When an image for the same trees
# is already in the registry (a docs-only merge, a workflow change, or a tag on
# a commit that was just released), there is nothing to build: the new tags
# are pointed at the existing manifest. This is what keeps tagging a release
# from compiling the webmail a second time.
plan:
name: Plano (reaproveitar as imagens?)
runs-on: ubuntu-latest
outputs:
inputs: ${{ steps.plan.outputs.inputs }}
reuse: ${{ steps.plan.outputs.reuse }}
steps:
- uses: actions/checkout@v7
- id: plan
env:
REGISTRY: ${{ env.REGISTRY }}
REPO: ${{ gitea.repository }}
run: |
inputs=$(git rev-parse HEAD:services HEAD:migrations HEAD:config | sha256sum | cut -c1-16)
ns="${REPO,,}"
reuse=true
for s in protocols auth storage webmail migrations rspamd; do
token=$(curl -fsS "https://$REGISTRY/v2/token?service=container_registry&scope=repository:$ns/$s:pull" | jq -r .token)
code=$(curl -s -o /dev/null -w '%{http_code}' -I \
-H "Authorization: Bearer $token" \
-H 'Accept: application/vnd.oci.image.index.v1+json,application/vnd.docker.distribution.manifest.list.v2+json' \
"https://$REGISTRY/v2/$ns/$s/manifests/inputs-$inputs")
echo "$s: inputs-$inputs -> HTTP $code"
[ "$code" = 200 ] || reuse=false
done
echo "inputs=$inputs" >> "$GITHUB_OUTPUT"
echo "reuse=$reuse" >> "$GITHUB_OUTPUT"
echo "reuse the published images: $reuse"
# Nothing is published unless the full suite passes first. Skipped when the
# images are reused: the same trees already went through it.
ci:
needs: plan
if: ${{ needs.plan.outputs.reuse != 'true' }}
uses: ./.gitea/workflows/ci.yml
# Registry paths must be lowercase, and this repository is "lucasrguerra/LambdaMail".
@@ -42,7 +80,8 @@ jobs:
# magnitude slower.
build:
name: Build ${{ matrix.service }} (${{ matrix.platform }})
needs: [ci, prepare]
needs: [plan, ci, prepare]
if: ${{ needs.plan.outputs.reuse != 'true' }}
runs-on: ${{ matrix.runner }}
permissions:
contents: read
@@ -104,7 +143,9 @@ jobs:
publish:
name: Publica ${{ matrix.service }}
needs: [build, prepare]
needs: [plan, build, prepare]
# build is skipped, not successful, when the images are reused.
if: ${{ !cancelled() && needs.plan.result == 'success' && needs.prepare.result == 'success' && (needs.build.result == 'success' || needs.plan.outputs.reuse == 'true') }}
runs-on: ubuntu-latest
permissions:
contents: read
@@ -141,13 +182,20 @@ jobs:
type=sha,format=long
# Joins both architectures under one manifest list, so `docker pull`
# resolves to the right one automatically.
- name: Cria o manifesto multi-arquitetura
# resolves to the right one automatically. The inputs-<hash> tag is what
# lets a later run recognise these images and skip the build.
- name: Aplica as tags
run: |
image='${{ env.REGISTRY }}/${{ needs.prepare.outputs.namespace }}/${{ matrix.service }}'
docker buildx imagetools create \
$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< '${{ steps.meta.outputs.json }}') \
tags=$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< '${{ steps.meta.outputs.json }}')
inputs='${{ needs.plan.outputs.inputs }}'
if [ '${{ needs.plan.outputs.reuse }}' = true ]; then
# Same manifest under new names: no layer is read or written.
docker buildx imagetools create $tags "$image:inputs-$inputs"
else
docker buildx imagetools create $tags -t "$image:inputs-$inputs" \
"$image:build-${{ gitea.sha }}-amd64" "$image:build-${{ gitea.sha }}-arm64"
fi
- name: Confere o resultado
run: |
@@ -172,7 +220,9 @@ jobs:
gitea-release:
name: Cria a release no Gitea
needs: [publish, prepare]
if: startsWith(gitea.ref, 'refs/tags/v')
# Explicit, like publish: the build job upstream is skipped when the images
# are reused, and the implicit success() would then skip this job too.
if: ${{ !cancelled() && needs.publish.result == 'success' && startsWith(gitea.ref, 'refs/tags/v') }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
@@ -185,21 +235,7 @@ jobs:
API: ${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}
NAMESPACE: ${{ env.REGISTRY }}/${{ needs.prepare.outputs.namespace }}
run: |
prev=$(git describe --tags --abbrev=0 "$TAG^" 2>/dev/null || true)
range=${prev:+$prev..}$TAG
{
echo "## Imagens"
echo
echo '```'
for s in protocols auth storage webmail migrations rspamd; do
echo "$NAMESPACE/$s:${TAG#v}"
done
echo '```'
echo
echo "## Mudanças${prev:+ desde $prev}"
echo
git log --no-merges --format='- %s (%h)' "$range"
} > notes.md
scripts/release-notes.sh "$TAG" "$NAMESPACE" > notes.md
pre=false; case "$TAG" in *-*) pre=true ;; esac
jq -n --arg tag "$TAG" --arg name "LambdaMail ${TAG#v}" --rawfile body notes.md --argjson pre "$pre" \
'{tag_name:$tag, name:$name, body:$body, prerelease:$pre}' > release.json
+3
View File
@@ -46,3 +46,6 @@ Thumbs.db
*.json.gz
*.xml.gz
*.zip.gz
# Configuração local do Superset
.superset/
+10 -1
View File
@@ -6,18 +6,23 @@ budget_tokens: 2000
> OpenWolf's learning memory. Updated automatically as the AI learns from interactions.
> Do not edit manually unless correcting an error.
> Last updated: 2026-10-06
> Last updated: 2026-10-30
## User Preferences
<!-- How the user likes things done. Code style, tools, patterns, communication. -->
- Writes in Portuguese (pt-BR); answer in Portuguese. Wants things finished end to end: merged, released, deployed and checked in production, without being asked again (said so on 2026-10-22).
- Likes a clean interface with every function reachable (Apple / Anthropic as references, Outlook over Gmail), dark theme by default with a light one.
- [2026-10-30] CI must be efficient: never redo work already done for the same content. Hash what goes into the artifact (the `inputs-<hash>` image tag, or a filtered `git ls-tree` hash), ask the registry, and skip CI and build when it exists; a version tag or a docs/workflow-only merge only re-points tags. Do not run `ci.yml` on `main` by push when `release.yml` already calls it. Use `paths-ignore` for docs and tool state (`.wolf`, `.claude`, `.superset`). The user repeated this several times and was angry when a tag was about to recompile the Next.js build.
- [2026-10-30] Keep repositories clean: after a branch is merged, delete it on Gitea and locally (`git push gitea --delete <b>`, `git branch -d <b>`). Never touch the user's own unmerged branches.
- [2026-10-30] Every tool gets semver tags and a Gitea Release (Releases tab, not just container images) with notes from the matching `CHANGELOG.md` section (`scripts/release-notes.sh`). Keep the changelog updated under Unreleased. A tag job that creates the release must use an explicit `if: ${{ !cancelled() && needs.<job>.result == 'success' && startsWith(gitea.ref, 'refs/tags/v') }}`.
- [2026-10-30] Source of truth is Gitea (`git.lucasrguerra.dev.br`, remote `gitea`, SSH on port 22 through the host). The GitHub account is suspended (404): never push to or depend on GitHub. Workflows live in `.gitea/workflows` and use the `gitea.*` context.
## Key Learnings
- **Project:** LambdaMail
- **Description:** A self-hosted mail server with an integrated webmail and admin console, built to
- [2026-10-30] Release flow: `plan` job hashes `services/ migrations/ config/` → `inputs-<hash>`; reuse takes ~1 min instead of 30. A job downstream of a skipped `build` needs explicit status checks in its `if`. Images: `git.lucasrguerra.dev.br/lucasrguerra/lambdamail/<service>`. v1.0.0 was the first tag (no history survived from GitHub). Production deploy is blocked until the Coolify app's source is switched from Public GitHub to Gitea in the Coolify UI (API cannot); deploy only when the user is assured everything is right. Single `.env.example` + `scripts/init-env.sh` (`make env`).
## Do-Not-Repeat
@@ -31,6 +36,10 @@ budget_tokens: 2000
- [2026-10-22] A `<Button>` inside a `<form>` submits it unless `type="button"` (Undo send used to resend the message).
- [2026-10-22] Never run side effects inside a React state updater (it can run twice).
- [2026-10-23] Inside a JS template literal `\\d` silently becomes `d`. For scripts embedded in template strings write `[0-9]` instead of `\\d`, and test the script's behaviour, not only that it is present.
- [2026-10-30] On the Gitea runners never mount the job workspace into a container with `-v "$PWD:..."`: the Docker daemon (DinD) cannot see it and mounts an empty directory. Use `docker create` + `docker cp` + `docker start -a`.
- [2026-10-30] Do not use `aquasecurity/trivy-action` (clones Trivy from the Gitea server), `actions/upload-artifact` v4+, the `gha` buildx cache or `rustsec/audit-check`. Run Trivy from `aquasec/trivy` with `-v trivy-cache:/root/.cache/`, use the registry for cache, `cargo audit` for Rust. The runner sets `GITHUB_OUTPUT`/`GITHUB_STEP_SUMMARY` but NOT `GITEA_OUTPUT` etc.
- [2026-10-30] Pushing old tags to Gitea triggers the `.github/workflows/release.yml` found in those old commits (Gitea falls back to `.github` where `.gitea` is absent). They queue forever on missing macOS/Windows runners: cancel them (`POST /repos/{o}/{r}/actions/runs/{id}/cancel`) right after pushing tags.
- [2026-10-30] The runner Docker daemons persist between jobs: steps that create networks/containers must be idempotent and clean up with `if: always()`.
## Decision Log
+107
View File
@@ -0,0 +1,107 @@
# Changelog
All notable changes to LambdaMail are listed here. The format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project follows
[Semantic Versioning](https://semver.org/).
[Português](CHANGELOG.pt-BR.md)
## [Unreleased]
## [1.0.0] - 2026-10-30
The first tagged release. Earlier work was never tagged, so this entry sums up
everything from the first commit on 2026-07-23 to the move to Gitea. Gaps that
remain are listed in the README under
[What is not finished](README.md#what-is-not-finished).
### Mail server
- SMTP MX and Submission (587 STARTTLS, 465 implicit TLS), IMAP with UID, flags,
IDLE, CONDSTORE, `SEARCH`, `EXPUNGE`, `COPY` and `MOVE`, POP3 and ManageSieve.
- SPF, DKIM (Ed25519 and RSA dual signature), DMARC, ARC sealing, MTA-STS and
DANE on delivery, and TLS-RPT report ingestion.
- Rspamd scoring with greylisting, RBLs and rate limiting, and ClamAV.
- A durable outbound queue in Postgres with the retry ladder and RFC 3464
bounces, a smarthost fallback for hosts with port 25 blocked, and a
configurable SPF term for the relay (`RELAY_SPF_INCLUDE`).
- Mail addressed to this server is delivered locally instead of going out to the
internet and back.
- Every submitted message is kept in the sender's Sent folder; app passwords
sign in over SMTP, IMAP and POP3.
- Sieve rules evaluated at delivery, custom folders, and an RFC 5230 vacation
responder that replies in thread and once per sender per week.
### Webmail and console
- A complete redesign of both surfaces (the "Nocturne" interface): dark and light
themes, a command menu on `Ctrl K` / `Cmd K`, `c` and `/` shortcuts, an
installable app with its own icon, and a drawn logo.
- Conversations in cascade, with who wrote, how many, and whether you have
answered; reply, reply all, important mail, undo send, and drafts that replace
the previous autosave.
- A reader that suits both themes: plain messages take the app's colours,
designed ones keep their white sheet, quoted history folds away, remote images
are blocked until allowed.
- Trusted senders, kept on the server per mailbox: their mail skips spam and
always shows images.
- Webmail sessions of 30 days that renew while used, several accounts signed in
at once and switchable from the sidebar; the console stays at 8 hours.
- Profile photos with a circular crop editor, and per-domain BIMI logo hosting
and validation, published to DNS and kept in step with the logo.
- Admin console with server-side paging and filtering, user editing (role,
quota, language, password, second factor), aliases, a diagnostics page, and
isolation between the user and admin surfaces. Domain admins are confined to
their own domain.
- English, Portuguese and Spanish, with a test that every string exists
in all of them.
### DNS and TLS
- Cloudflare reconciliation of the expected records for every active domain,
idempotent, with per-record verification against public resolvers; a DKIM
record is never published with an empty key.
- A TLS panel that states the digest the TLSA record must carry, and documented
steps for turning DANE on. `TLS_MODE=traefik` (default) or `acme`.
### Security
- Two-factor sign-in with TOTP and recovery codes; enrolment can no longer
replace a secret the authenticator already has, enable MFA without recovery
codes, or kill the auth service. `make reset-mfa` recovers a broken enrolment.
- Argon2 concurrency is bounded, so a login flood cannot take the auth service
down.
- All service containers run as non-root; images are scanned with Trivy and the
build fails on HIGH or CRITICAL findings.
- Dependencies updated for two published advisories: `next` 15.5.28
(unauthenticated remote code execution in image optimisation,
GHSA-2464-fhr4-w9xw) and `go-jose` 4.1.6 (denial of service).
### Deployment and CI
- The deploy host pulls images that CI built and tested; nothing is compiled on
the server. The compose file pins Traefik to the proxy network by name.
- **Moved to Gitea.** The source, the workflows (`.gitea/workflows`) and the
container images now live on `git.lucasrguerra.dev.br`; the images are
published for `linux/amd64` and `linux/arm64` under
`git.lucasrguerra.dev.br/lucasrguerra/lambdamail/<service>` and the `v*` tag
creates a release with notes. See [docs/RELEASING.md](docs/RELEASING.md).
- CI caches npm, Go modules, the embedded Postgres, Trivy's database and image
layers. Release recognises images it already published for the same sources
(`inputs-<hash>` tag), so a documentation change or a version tag never
rebuilds them.
- One `.env.example` for development and production, and `scripts/init-env.sh`
to create `.env` with random secrets (`make env`). It replaces
`.env.production.example`.
### Fixed
- Webmail healthcheck: Next binds IPv4 only and `wget` resolved `localhost` to
`::1`.
- The admin lockout, a draft that did not discard, folder counters, a vacation
reply that was not a reply, DNS panels that reported published records as
missing, and the reconcile button that created nothing.
- Migrations: a default partition for `email_messages`.
[Unreleased]: https://git.lucasrguerra.dev.br/lucasrguerra/LambdaMail/compare/v1.0.0...HEAD
[1.0.0]: https://git.lucasrguerra.dev.br/lucasrguerra/LambdaMail/releases/tag/v1.0.0
+108
View File
@@ -0,0 +1,108 @@
# Registro de mudanças
Tudo o que muda de relevante no LambdaMail está aqui. O formato segue o
[Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/) e o projeto segue o
[Versionamento Semântico](https://semver.org/lang/pt-BR/).
[English](CHANGELOG.md)
## [Não lançado]
## [1.0.0] - 2026-10-30
A primeira versão com tag. O trabalho anterior nunca foi tagueado, então esta
entrada resume tudo desde o primeiro commit, em 2026-07-23, até a mudança para o
Gitea. O que ainda falta está no README, em
[O que não está pronto](README.pt-BR.md#o-que-não-está-pronto).
### Servidor de e-mail
- SMTP MX e Submission (587 STARTTLS, 465 TLS implícito), IMAP com UID, flags,
IDLE, CONDSTORE, `SEARCH`, `EXPUNGE`, `COPY` e `MOVE`, POP3 e ManageSieve.
- SPF, DKIM (assinatura dupla Ed25519 e RSA), DMARC, selo ARC, MTA-STS e DANE na
entrega, e ingestão de relatórios TLS-RPT.
- Pontuação do Rspamd com greylisting, RBLs e limite de taxa, e ClamAV.
- Fila de saída durável no Postgres, com a escada de tentativas e devoluções RFC
3464, retransmissão por smarthost para hosts com a porta 25 bloqueada, e termo
SPF do relay configurável (`RELAY_SPF_INCLUDE`).
- O e-mail endereçado a este servidor é entregue localmente, sem sair para a
internet e voltar.
- Toda mensagem enviada fica na pasta Enviados de quem enviou; as senhas de
aplicativo funcionam em SMTP, IMAP e POP3.
- Regras Sieve avaliadas na entrega, pastas próprias e resposta de ausência RFC
5230, que responde dentro da conversa e uma vez por remetente por semana.
### Webmail e console
- Redesenho completo das duas interfaces ("Nocturne"): temas escuro e claro,
menu de comandos em `Ctrl K` / `Cmd K`, atalhos `c` e `/`, app instalável com
ícone próprio e um logo desenhado.
- Conversas em cascata, com quem escreveu, quantas mensagens e se você já
respondeu; responder, responder a todos, importantes, desfazer o envio, e
rascunhos que substituem o salvamento automático anterior.
- Um leitor que combina com os dois temas: mensagens simples usam as cores do
app, as desenhadas mantêm a folha branca, o histórico citado se recolhe e as
imagens remotas ficam bloqueadas até serem liberadas.
- Remetentes confiáveis, guardados no servidor por caixa: o e-mail deles não vai
para o spam e sempre mostra as imagens.
- Sessões do webmail de 30 dias que se renovam com o uso, várias contas abertas
ao mesmo tempo e trocáveis pela barra lateral; o console continua em 8 horas.
- Foto de perfil com editor de recorte circular, e hospedagem e validação do
logotipo BIMI por domínio, publicado no DNS e mantido em dia com o logo.
- Console de administração com paginação e filtros no servidor, edição de
usuários (papel, cota, idioma, senha, segundo fator), apelidos, uma página de
diagnóstico e isolamento entre as superfícies de usuário e de administração.
Administradores de domínio ficam restritos ao próprio domínio.
- Inglês, português e espanhol, com um teste de que toda string existe nos três.
### DNS e TLS
- Reconciliação com a Cloudflare dos registros esperados de cada domínio ativo,
idempotente, com verificação de cada registro em resolvedores públicos; um
registro DKIM nunca é publicado com a chave vazia.
- Um painel de TLS que mostra o digest que o registro TLSA precisa carregar, e
passos documentados para ligar o DANE. `TLS_MODE=traefik` (padrão) ou `acme`.
### Segurança
- Login em duas etapas com TOTP e códigos de recuperação; o cadastro não
substitui mais um segredo que o autenticador já tem, não ativa o MFA sem
códigos de recuperação e não derruba o serviço de autenticação. O
`make reset-mfa` recupera um cadastro quebrado.
- A concorrência do Argon2 é limitada, então uma enxurrada de logins não derruba
o serviço de autenticação.
- Todos os containers de serviço rodam sem root; as imagens passam pelo Trivy e
o build falha em achados HIGH ou CRITICAL.
- Dependências atualizadas por dois avisos publicados: `next` 15.5.28 (execução
remota de código sem autenticação na otimização de imagens,
GHSA-2464-fhr4-w9xw) e `go-jose` 4.1.6 (negação de serviço).
### Implantação e CI
- O servidor de produção só puxa imagens que o CI construiu e testou; nada é
compilado nele. O compose fixa o Traefik na rede do proxy pelo nome.
- **Mudança para o Gitea.** O código, os workflows (`.gitea/workflows`) e as
imagens de container agora ficam em `git.lucasrguerra.dev.br`; as imagens são
publicadas para `linux/amd64` e `linux/arm64` em
`git.lucasrguerra.dev.br/lucasrguerra/lambdamail/<serviço>`, e a tag `v*` cria
uma release com notas. Veja [docs/RELEASING.md](docs/RELEASING.md).
- O CI guarda em cache o npm, os módulos Go, o Postgres embutido, o banco do
Trivy e as camadas das imagens. O Release reconhece imagens que já publicou
para os mesmos fontes (tag `inputs-<hash>`), então uma mudança de documentação
ou uma tag de versão nunca as reconstrói.
- Um único `.env.example` para desenvolvimento e produção, e o
`scripts/init-env.sh` para criar o `.env` com segredos aleatórios
(`make env`). Ele substitui o `.env.production.example`.
### Corrigido
- Healthcheck do webmail: o Next escuta só em IPv4 e o `wget` resolvia
`localhost` para `::1`.
- O bloqueio do administrador, o rascunho que não descartava, os contadores de
pastas, uma resposta de ausência que não era resposta, painéis de DNS que
diziam faltar registros já publicados e o botão de reconciliar que não criava
nada.
- Migrações: partição padrão para `email_messages`.
[Não lançado]: https://git.lucasrguerra.dev.br/lucasrguerra/LambdaMail/compare/v1.0.0...HEAD
[1.0.0]: https://git.lucasrguerra.dev.br/lucasrguerra/LambdaMail/releases/tag/v1.0.0
+5 -1
View File
@@ -1,4 +1,8 @@
.PHONY: up down logs test test-db-up test-db-down lint migrate-up migrate-down gen-dev-cert seed preflight create-admin reset-password reset-mfa
.PHONY: env up down logs test test-db-up test-db-down lint migrate-up migrate-down gen-dev-cert seed preflight create-admin reset-password reset-mfa
# .env from .env.example with fresh secrets; refuses to overwrite an existing one.
env:
scripts/init-env.sh
# Local development builds from source; production pulls what CI published.
up:
+8 -6
View File
@@ -2,7 +2,7 @@
<h1 align="center">LambdaMail</h1>
<p align="center">Your own mail server, with a webmail that is a pleasure to use.<br>SMTP, IMAP and POP3 · SPF, DKIM, DMARC and ARC · Webmail and admin console · One <code>docker-compose.yaml</code>, made for Coolify.</p>
<p align="center"><a href="docs/WEBMAIL.md">Webmail</a> · <a href="docs/DEPLOYMENT.md">Deploying</a> · <a href="docs/OPERATIONS.md">Operating</a> · <a href="docs/MAILBOX-CORE.md">Mailbox core</a> · <a href="LICENSE">License</a></p>
<p align="center"><a href="docs/WEBMAIL.md">Webmail</a> · <a href="docs/DEPLOYMENT.md">Deploying</a> · <a href="docs/OPERATIONS.md">Operating</a> · <a href="docs/MAILBOX-CORE.md">Mailbox core</a> · <a href="docs/RELEASING.md">Releasing</a> · <a href="CHANGELOG.md">Changelog</a> · <a href="LICENSE">License</a></p>
<p align="center"><b>English</b> · <a href="README.pt-BR.md">Português</a></p>
@@ -18,7 +18,7 @@ SMTP, IMAP, POP3 and ManageSieve, with SPF, DKIM, DMARC, ARC, MTA-STS and
TLS-RPT, DNS automation through Cloudflare, and spam and virus filtering. No
paid tiers and no artificial limits on domains or mailboxes.
> **Status: pre-release.** The mail path, the security posture and the web
> **Version 1.0.0** ([changelog](CHANGELOG.md)). The mail path, the security posture and the web
> interfaces work end to end, and there is an automated test suite behind them.
> See [What is not finished](#what-is-not-finished) before putting real mail
> through it.
@@ -107,7 +107,7 @@ getting port 25 unblocked — and the order the rest has to happen in.
The short version:
```bash
cp .env.production.example .env # then replace every CHANGE
scripts/init-env.sh --production # .env with random secrets; lists what to set
make preflight # checks port 25, PTR, RBLs, token scope
docker compose up -d
make create-admin EMAIL=you@example.com PASSWORD='...'
@@ -166,7 +166,7 @@ more thing to keep patched.
## Running locally
```bash
cp .env.example .env
make env # .env from .env.example, with random secrets
make gen-dev-cert # self-signed certificate for the listeners
make up # builds from source via the build overlay
make seed # a postmaster@example.test fixture
@@ -204,8 +204,8 @@ checked in a real browser; see
## Configuration
Every variable is documented inline in
[`.env.production.example`](.env.production.example). The ones without a safe
default:
[`.env.example`](.env.example), the one file for development and production.
The ones without a safe default (`scripts/init-env.sh` generates the secrets):
| Variable | |
| :--- | :--- |
@@ -383,6 +383,8 @@ Stated plainly, because a mail server that half works loses mail:
the first.
- Tests come with the change that needs them; the suites above must stay green.
- Commits reference their issue: `#N: what changed and why`.
- A change someone would notice goes under *Unreleased* in [CHANGELOG.md](CHANGELOG.md)
(and its Portuguese twin). How a version is cut: [docs/RELEASING.md](docs/RELEASING.md).
## License
+8 -5
View File
@@ -2,7 +2,7 @@
<h1 align="center">LambdaMail</h1>
<p align="center">Seu próprio servidor de e-mail, com um webmail que dá gosto de usar.<br>SMTP, IMAP e POP3 · SPF, DKIM, DMARC e ARC · Webmail e console de administração · Um único <code>docker-compose.yaml</code>, feito para o Coolify.</p>
<p align="center"><a href="docs/WEBMAIL.md">Webmail</a> · <a href="docs/DEPLOYMENT.md">Implantação</a> · <a href="docs/OPERATIONS.md">Operação</a> · <a href="docs/MAILBOX-CORE.md">Núcleo da caixa</a> · <a href="LICENSE">Licença</a></p>
<p align="center"><a href="docs/WEBMAIL.md">Webmail</a> · <a href="docs/DEPLOYMENT.md">Implantação</a> · <a href="docs/OPERATIONS.md">Operação</a> · <a href="docs/MAILBOX-CORE.md">Núcleo da caixa</a> · <a href="docs/RELEASING.md">Lançamentos</a> · <a href="CHANGELOG.pt-BR.md">Mudanças</a> · <a href="LICENSE">Licença</a></p>
<p align="center"><a href="README.md">English</a> · <b>Português</b></p>
@@ -18,7 +18,7 @@ SMTP, IMAP, POP3 e ManageSieve, com SPF, DKIM, DMARC, ARC, MTA-STS e TLS-RPT,
automação de DNS pela Cloudflare e filtro de spam e de vírus. Sem planos pagos e
sem limites artificiais de domínios ou caixas.
> **Estado: pré-lançamento.** O caminho do e-mail, a postura de segurança e as
> **Versão 1.0.0** ([mudanças](CHANGELOG.pt-BR.md)). O caminho do e-mail, a postura de segurança e as
> interfaces web funcionam de ponta a ponta, e há uma suíte de testes
> automatizados por trás. Leia [O que não está pronto](#o-que-não-está-pronto)
> antes de colocar e-mail de verdade nele.
@@ -109,7 +109,7 @@ precisa acontecer.
A versão curta:
```bash
cp .env.production.example .env # depois troque cada CHANGE
scripts/init-env.sh --production # .env com segredos aleatórios; lista o que definir
make preflight # confere porta 25, PTR, RBLs, escopo do token
docker compose up -d
make create-admin EMAIL=voce@exemplo.com PASSWORD='...'
@@ -166,7 +166,7 @@ coisa para manter atualizada.
## Rodando localmente
```bash
cp .env.example .env
make env # .env a partir do .env.example, com segredos aleatórios
make gen-dev-cert # certificado autoassinado para os listeners
make up # compila a partir do código pelo overlay de build
make seed # uma caixa postmaster@example.test de teste
@@ -204,7 +204,8 @@ navegador de verdade; veja [docs/WEBMAIL.md](docs/WEBMAIL.md#testing-it).
## Configuração
Cada variável está documentada em linha em
[`.env.production.example`](.env.production.example). As que não têm padrão seguro:
[`.env.example`](.env.example), o arquivo único para desenvolvimento e produção.
As que não têm padrão seguro (o `scripts/init-env.sh` gera os segredos):
| Variável | |
| :--- | :--- |
@@ -385,6 +386,8 @@ e-mail:
- Os testes acompanham a mudança que precisa deles; as suítes acima precisam
continuar verdes.
- Os commits citam a issue: `#N: o que mudou e por quê`.
- Uma mudança que alguém vai notar entra em *Não lançado* no [CHANGELOG.pt-BR.md](CHANGELOG.pt-BR.md)
(e no equivalente em inglês). Como se lança uma versão: [docs/RELEASING.md](docs/RELEASING.md).
## Licença
+22 -9
View File
@@ -70,18 +70,29 @@ Cloudflare token, or you can add them by hand later.
## Step 2 — Create the application in Coolify
1. **New Resource → Docker Compose**, pointed at this repository.
1. **New Resource → Docker Compose**, pointed at this repository. It lives on
Gitea: choose **Private Repository (with Deploy Key)** and use
`git@git.lucasrguerra.dev.br:lucasrguerra/LambdaMail.git` with a key that is
registered in Gitea. An application that was created from a GitHub source
keeps cloning from GitHub until its source is changed in the Coolify UI; the
API cannot change it.
2. Coolify will read `docker-compose.yaml`. Leave the compose file alone; all
configuration is environment.
3. Under **Environment Variables**, paste the contents of
`.env.production.example` and replace every value marked `CHANGE`.
Generate each secret separately:
3. Create the environment on your own machine, then paste it into
**Environment Variables**:
```bash
openssl rand -base64 36
scripts/init-env.sh --production # writes .env with random secrets
```
The script lists every value that is still at a development default
(`MAIL_DOMAIN`, `PRIMARY_MAIL_HOST`, `WEBMAIL_HOST`, `PUBLIC_IPV4`,
`IMAGE_TAG`); set those in `.env`, then paste. Every variable is explained
inline in [`.env.example`](../.env.example).
`POSTGRES_PASSWORD` and `REDIS_PASSWORD` are generated as hex on purpose: they
go inside connection URIs, and the `/` and `+` of base64 break them.
`LAMBDAMAIL_MASTER_KEY` deserves particular care. It encrypts every DKIM
private key, TOTP secret and provider token in the database. **Store a copy
somewhere other than the database backup.** If you lose it, those secrets are
@@ -176,8 +187,9 @@ have propagated and the IP has some sending history.
## Upgrading
Images are published to the Gitea container registry for `linux/amd64` and `linux/arm64` on every merge
to `main`.
Images are published to the Gitea container registry for `linux/amd64` and
`linux/arm64` on every merge to `main`, and again under the version number when
a `v*` tag is pushed (see [RELEASING.md](RELEASING.md)).
```bash
docker compose pull
@@ -189,7 +201,8 @@ Deploys pull the images CI published rather than building on the server;
source instead.
Migrations run automatically on start, before the services that depend on them.
**Pin `IMAGE_TAG` to a `sha-` tag rather than leaving it on `latest`.** Not
**Pin `IMAGE_TAG` to a release (`1.0.0`) or a `sha-` tag rather than leaving it
on `latest`.** Not
only for rollback: `docker compose up` does not re-pull a tag it already has
locally, so a deployment left on `latest` will happily keep running the image
it pulled the first time while reporting success. An immutable tag changes on
+101
View File
@@ -0,0 +1,101 @@
# Releasing LambdaMail
How a version gets from a commit to a server, and what runs where. The history
of what changed is in the [CHANGELOG](../CHANGELOG.md).
## The pipeline
Everything runs on **Gitea Actions** (`.gitea/workflows/`), on runners hosted on
the Oracle server.
| Workflow | Runs on | Does |
| :--- | :--- | :--- |
| `ci.yml` | every push and pull request | language lint, Go and TypeScript suites against a real Postgres, webmail tests, a Trivy scan of the repository and of each image, and the whole stack brought up with compose |
| `release.yml` | a push to `main`, a `v*` tag, or by hand | builds the six images for `linux/amd64` and `linux/arm64` after `ci.yml` passes, publishes them, and on a tag creates the release |
Nothing is published unless the full suite passes first.
### Images are reused, not rebuilt
The images are built from `services/`, `migrations/` and `config/` and nothing
else. `release.yml` hashes those three trees (the `inputs-<hash>` tag) and asks
the registry whether images for that hash already exist. If they do, it skips
the CI run and the build and only points the new tags (`latest`, `sha-<commit>`,
the version) at the existing manifests, which takes about a minute.
So these never recompile anything: a documentation or workflow change merged
to `main`, and a version tag pushed on a commit whose images were just
published. A change under the three trees rebuilds everything, as it should.
Images go to the Gitea registry, one package per service:
```
git.lucasrguerra.dev.br/lucasrguerra/lambdamail/{protocols,auth,storage,webmail,migrations,rspamd}
```
Tags on every image: `latest`, `sha-<full commit>`, and on a version tag also
`1.2.3`, `1.2` and `1`. The packages are public, so a server pulls them without
logging in.
## Cutting a release
1. Update [CHANGELOG.md](../CHANGELOG.md) and [CHANGELOG.pt-BR.md](../CHANGELOG.pt-BR.md):
move what is under *Unreleased* to a new `## [X.Y.Z] - date` section.
2. Set `"version"` in `services/{auth,storage,webmail}/package.json` and their
lockfiles to the same number.
3. Merge to `main` and wait for the `Release` run of that merge to go green.
4. Tag the merge commit and push the tag:
```bash
git tag -a v1.2.3 -m "LambdaMail 1.2.3"
git push gitea v1.2.3
```
The tag triggers `release.yml` once more. Because the commit was already
released from `main`, the images are reused: it adds the tags `1.2.3`, `1.2` and
`1` to them without building, and creates the entry under **Releases** with the
image list and the commit log since the previous tag. Tagging a commit whose
`main` run has not finished (or never ran) builds it from scratch. A tag with a hyphen (`v1.3.0-rc.1`) is marked as a pre-release.
Versions follow [Semantic Versioning](https://semver.org/): a change to a
migration, a required environment variable, or a published port is at least a
minor release, and anything that needs manual steps on upgrade is called out in
the changelog.
## Deploying a version
Coolify pulls; it does not build. See the *Upgrading* section of
[DEPLOYMENT.md](DEPLOYMENT.md) for the two-step deploy (set `IMAGE_TAG`, then
deploy). In short:
1. `IMAGE_TAG` = the release (`1.2.3`) or `sha-<commit>`. Never `latest`.
2. Deploy. Migrations run before the services that need them.
3. Check `GET /health` on `protocols` and sign in to the webmail.
To roll back, set `IMAGE_TAG` back to the previous release and deploy again.
Migrations are not reverted: write them so the previous version still runs
against the new schema.
## Runners
Two `act_runner` instances run on the Oracle server (Ampere ARM, 4 cores), each
with its own Docker-in-Docker daemon, so two jobs never share a network or a
port. The jobs run unprivileged.
- `linux/arm64` images build natively; `linux/amd64` images build under QEMU
emulation, which is slower. Layers are cached in the registry.
- The Docker daemon of each runner keeps its layer cache between runs, and the
toolcache volume keeps downloaded toolchains.
- The workspace of a job is not visible to the Docker daemon, so workflows copy
files into containers with `docker cp` instead of mounting them with `-v`.
- Windows and macOS jobs cannot run here.
## Why some actions are not used
- `aquasecurity/trivy-action` clones Trivy from the workflow's own server,
which is Gitea; Trivy runs from its official image instead.
- `actions/upload-artifact` v4+ and the `gha` build cache are GitHub services;
per-architecture images are told apart by tag, and the cache lives in the
registry.
- Workflows read `GITHUB_OUTPUT` and `GITHUB_STEP_SUMMARY` under those names:
the `GITEA_*` equivalents are not set by the runner.
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env bash
# Creates .env from .env.example and fills the empty secrets with random values.
#
# scripts/init-env.sh local development
# scripts/init-env.sh --production also lists what is still at a dev default
# scripts/init-env.sh --force overwrite an existing .env
#
# An existing .env is never touched without --force: regenerating
# LAMBDAMAIL_MASTER_KEY makes every stored secret unrecoverable.
set -euo pipefail
cd "$(dirname "$0")/.."
production=0
force=0
for arg in "$@"; do
case "$arg" in
--production) production=1 ;;
--force) force=1 ;;
-h|--help) sed -n '2,10p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) echo "unknown option: $arg" >&2; exit 2 ;;
esac
done
if [ -e .env ] && [ "$force" -ne 1 ]; then
echo ".env already exists; leaving it alone (use --force to overwrite)." >&2
exit 1
fi
command -v openssl >/dev/null || { echo "openssl is required to generate secrets" >&2; exit 1; }
cp .env.example .env
chmod 600 .env
# set_var KEY VALUE: replaces the line KEY=... in .env. The value is hex, so it
# needs no escaping for sed.
set_var() {
sed -i.bak "s|^$1=.*|$1=$2|" .env && rm -f .env.bak
}
set_var POSTGRES_PASSWORD "$(openssl rand -hex 24)"
set_var REDIS_PASSWORD "$(openssl rand -hex 24)"
set_var JWT_SECRET "$(openssl rand -hex 32)"
set_var LAMBDAMAIL_MASTER_KEY "$(openssl rand -hex 32)"
echo ".env written (mode 600) with fresh secrets."
if [ "$production" -eq 1 ]; then
set_var COOLIFY_PROXY_DIR /data/coolify/proxy
echo
echo "Production: still at their development defaults, set these in .env:"
pending=0
check() {
if grep -qE "^$1=$2\$" .env; then echo " - $1 (now $2)"; pending=1; fi
}
check MAIL_DOMAIN localhost
check PRIMARY_MAIL_HOST mail.localhost
check WEBMAIL_HOST 'localhost:3000'
check PUBLIC_IPV4 127.0.0.1
check IMAGE_TAG latest
[ "$pending" -eq 0 ] && echo " (nothing)"
echo
echo "Back up LAMBDAMAIL_MASTER_KEY separately from the database."
echo "Next: make preflight, then docker compose up -d (docs/DEPLOYMENT.md)."
fi
+39
View File
@@ -0,0 +1,39 @@
#!/usr/bin/env bash
# Prints the release notes for a tag: the images it publishes, then the matching
# section of CHANGELOG.md (the commit log since the previous tag if the changelog
# has no section for that version).
#
# scripts/release-notes.sh v1.2.3 git.example.com/owner/project
set -euo pipefail
tag=${1:?usage: release-notes.sh <tag> <image-namespace>}
namespace=${2:?usage: release-notes.sh <tag> <image-namespace>}
version=${tag#v}
echo "## Images"
echo
echo '```'
for service in protocols auth storage webmail migrations rspamd; do
echo "$namespace/$service:$version"
done
echo '```'
echo
section=""
if [ -f CHANGELOG.md ]; then
section=$(awk -v head="## [$version]" '
index($0, head) == 1 { found = 1; next }
found && /^## \[/ { exit }
found && /^\[[^]]+\]: / { next }
found { print }
' CHANGELOG.md)
fi
if [ -n "$(printf '%s' "$section" | tr -d '[:space:]')" ]; then
printf '%s\n' "$section"
else
previous=$(git describe --tags --abbrev=0 "$tag^" 2>/dev/null || true)
echo "## Changes${previous:+ since $previous}"
echo
git log --no-merges --format='- %s (%h)' "${previous:+$previous..}$tag"
fi
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@lambdamail/auth",
"version": "0.1.0",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@lambdamail/auth",
"version": "0.1.0",
"version": "1.0.0",
"dependencies": {
"hash-wasm": "^4.12.0",
"ioredis": "^5.4.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@lambdamail/auth",
"version": "0.1.0",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@lambdamail/storage",
"version": "0.1.0",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@lambdamail/storage",
"version": "0.1.0",
"version": "1.0.0",
"dependencies": {
"ioredis": "^5.4.1",
"pg": "^8.12.0"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@lambdamail/storage",
"version": "0.1.0",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@lambdamail/webmail",
"version": "0.1.0",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@lambdamail/webmail",
"version": "0.1.0",
"version": "1.0.0",
"dependencies": {
"@fontsource-variable/geist": "^5.3.0",
"clsx": "^2.1.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@lambdamail/webmail",
"version": "0.1.0",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev",