Compare commits
7
Commits
1aac8be531
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
448bb59672 | ||
|
|
a6719c6018 | ||
|
|
1bcbe4d774 | ||
|
|
a3e071bb03 | ||
|
|
2db5915061 | ||
|
|
5bbbb85128 | ||
|
|
f500534ac6 |
No files matched your search
+95
-44
@@ -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=
|
||||
@@ -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
@@ -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
|
||||
@@ -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 }}') \
|
||||
"$image:build-${{ gitea.sha }}-amd64" "$image:build-${{ gitea.sha }}-arm64"
|
||||
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
|
||||
|
||||
@@ -46,3 +46,6 @@ Thumbs.db
|
||||
*.json.gz
|
||||
*.xml.gz
|
||||
*.zip.gz
|
||||
|
||||
# Configuração local do Superset
|
||||
.superset/
|
||||
+10
-1
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
+23
-10
@@ -70,17 +70,28 @@ 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`.
|
||||
3. Create the environment on your own machine, then paste it into
|
||||
**Environment Variables**:
|
||||
|
||||
Generate each secret separately:
|
||||
```bash
|
||||
scripts/init-env.sh --production # writes .env with random secrets
|
||||
```
|
||||
|
||||
```bash
|
||||
openssl rand -base64 36
|
||||
```
|
||||
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
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
Executable
+65
@@ -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
|
||||
Executable
+39
@@ -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
|
||||
Generated
+2
-2
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@lambdamail/auth",
|
||||
"version": "0.1.0",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
Generated
+2
-2
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@lambdamail/storage",
|
||||
"version": "0.1.0",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
Generated
+2
-2
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@lambdamail/webmail",
|
||||
"version": "0.1.0",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
Reference in new issue
Block a user