Compare commits
No files matched your search
@@ -1,81 +1,132 @@
|
||||
# --- Container images ---
|
||||
# Published to GHCR by .github/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.
|
||||
IMAGE_NAMESPACE=ghcr.io/lucasrguerra/lambdamail
|
||||
# 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 .github/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=ghcr.io/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=
|
||||
@@ -15,7 +15,7 @@ on:
|
||||
workflow_call:
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
group: ci-${{ gitea.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
@@ -57,16 +57,41 @@ jobs:
|
||||
cache-dependency-path: services/protocols/go.sum
|
||||
- name: Run migrations
|
||||
run: |
|
||||
docker run --rm --network host \
|
||||
-v "$(pwd)/../../migrations:/migrations" \
|
||||
migrate/migrate:v4.17.1 \
|
||||
# The job's workspace is a volume of its own, invisible to the Docker
|
||||
# daemon the job talks to, so `-v "$PWD:..."` would mount an empty
|
||||
# directory. Files are copied into the container instead.
|
||||
cid=$(docker create --network host migrate/migrate:v4.17.1 \
|
||||
-path=/migrations \
|
||||
-database "postgres://lambdamail_test:lambdamail_test@localhost:5432/lambdamail_test?sslmode=disable" \
|
||||
up
|
||||
up)
|
||||
docker cp "$(pwd)/../../migrations/." "$cid:/migrations"
|
||||
docker start -a "$cid"
|
||||
status=$(docker inspect -f '{{.State.ExitCode}}' "$cid")
|
||||
docker rm "$cid" >/dev/null
|
||||
exit "$status"
|
||||
- run: go vet ./...
|
||||
- run: go test ./... -v
|
||||
# Module and build caches live under the unprivileged user's home, which
|
||||
# setup-go's cache does not cover, and the embedded Postgres binaries are
|
||||
# otherwise downloaded again by every test that starts one.
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
/home/tester/go/pkg/mod
|
||||
/home/tester/.cache/go-build
|
||||
/home/tester/.embedded-postgres-go
|
||||
key: go-tester-${{ runner.arch }}-${{ hashFiles('services/protocols/go.sum') }}
|
||||
restore-keys: go-tester-${{ runner.arch }}-
|
||||
# The end-to-end suites start an embedded Postgres, and initdb refuses to
|
||||
# run as root, which is what a job container is. Run the tests as a
|
||||
# throwaway unprivileged user (GitHub's hosted runners already are one).
|
||||
- name: Testes
|
||||
env:
|
||||
TEST_DATABASE_URL: postgres://lambdamail_test:lambdamail_test@localhost:5432/lambdamail_test?sslmode=disable
|
||||
run: |
|
||||
id tester >/dev/null 2>&1 || useradd -m -u 1001 tester
|
||||
chown -R tester:tester "$GITHUB_WORKSPACE" /home/tester
|
||||
runuser -u tester -- env "HOME=/home/tester" "PATH=$PATH" \
|
||||
"TEST_DATABASE_URL=$TEST_DATABASE_URL" go test ./... -v
|
||||
|
||||
ts-auth:
|
||||
name: TypeScript (auth)
|
||||
@@ -91,18 +116,27 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v7
|
||||
with: { node-version: "20" }
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: npm
|
||||
cache-dependency-path: services/auth/package-lock.json
|
||||
# The auth suite asserts against real rows: password verification,
|
||||
# lockout, TOTP replay and recovery-code consumption are all decided in
|
||||
# the database, so a mock would prove nothing.
|
||||
- name: Run migrations
|
||||
run: |
|
||||
docker run --rm --network host \
|
||||
-v "$(pwd)/../../migrations:/migrations" \
|
||||
migrate/migrate:v4.17.1 \
|
||||
# The job's workspace is a volume of its own, invisible to the Docker
|
||||
# daemon the job talks to, so `-v "$PWD:..."` would mount an empty
|
||||
# directory. Files are copied into the container instead.
|
||||
cid=$(docker create --network host migrate/migrate:v4.17.1 \
|
||||
-path=/migrations \
|
||||
-database "postgres://lambdamail_test:lambdamail_test@localhost:5432/lambdamail_test?sslmode=disable" \
|
||||
up
|
||||
up)
|
||||
docker cp "$(pwd)/../../migrations/." "$cid:/migrations"
|
||||
docker start -a "$cid"
|
||||
status=$(docker inspect -f '{{.State.ExitCode}}' "$cid")
|
||||
docker rm "$cid" >/dev/null
|
||||
exit "$status"
|
||||
- run: npm install
|
||||
- run: npm run lint
|
||||
- run: npm test
|
||||
@@ -119,13 +153,16 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v7
|
||||
with: { node-version: "20" }
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: npm
|
||||
cache-dependency-path: services/storage/package-lock.json
|
||||
- run: npm install
|
||||
- run: npm run lint
|
||||
- run: npm test
|
||||
|
||||
webmail-build:
|
||||
name: Build (webmail)
|
||||
name: Testes (webmail)
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
@@ -133,11 +170,15 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v7
|
||||
with: { node-version: "20" }
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: npm
|
||||
cache-dependency-path: services/webmail/package-lock.json
|
||||
- run: npm install
|
||||
- run: npm run lint
|
||||
- run: npm test
|
||||
- run: npm run build
|
||||
# No `npm run build` here: the webmail image job runs the same Next build
|
||||
# inside its Dockerfile, so building it twice only doubles the wait.
|
||||
|
||||
security:
|
||||
name: Trivy (dependências, segredos e Dockerfiles)
|
||||
@@ -148,14 +189,20 @@ jobs:
|
||||
# A mail server holds credentials for every mailbox it serves, so the
|
||||
# secret scanner matters as much as the vulnerability one here: a DKIM
|
||||
# key or a Cloudflare token committed by accident is a full compromise.
|
||||
#
|
||||
# Trivy runs from its official image rather than aquasecurity/trivy-action:
|
||||
# that action clones the Trivy repository from the workflow's own server,
|
||||
# which on Gitea is not GitHub.
|
||||
- name: Repositório
|
||||
uses: aquasecurity/trivy-action@v0.36.0
|
||||
with:
|
||||
scan-type: fs
|
||||
scanners: vuln,secret,misconfig
|
||||
severity: HIGH,CRITICAL
|
||||
exit-code: '1'
|
||||
skip-dirs: services/auth/node_modules,services/storage/node_modules,services/webmail/node_modules,services/webmail/.next
|
||||
run: |
|
||||
cid=$(docker create --network host -v trivy-cache:/root/.cache/ -w /src aquasec/trivy:0.70.0 \
|
||||
fs --scanners vuln,secret,misconfig --severity HIGH,CRITICAL --exit-code 1 \
|
||||
--skip-dirs services/auth/node_modules,services/storage/node_modules,services/webmail/node_modules,services/webmail/.next .)
|
||||
docker cp . "$cid:/src"
|
||||
docker start -a "$cid"
|
||||
status=$(docker inspect -f '{{.State.ExitCode}}' "$cid")
|
||||
docker rm "$cid" >/dev/null
|
||||
exit "$status"
|
||||
|
||||
# MEDIUM is reported but does not fail the build.
|
||||
#
|
||||
@@ -168,13 +215,15 @@ jobs:
|
||||
# Reporting rather than gating, because failing on every MEDIUM makes the
|
||||
# scanner something people learn to skip.
|
||||
- name: Repositório (MEDIUM, informativo)
|
||||
uses: aquasecurity/trivy-action@v0.36.0
|
||||
with:
|
||||
scan-type: fs
|
||||
scanners: vuln,secret
|
||||
severity: MEDIUM
|
||||
exit-code: '0'
|
||||
skip-dirs: services/auth/node_modules,services/storage/node_modules,services/webmail/node_modules,services/webmail/.next
|
||||
run: |
|
||||
cid=$(docker create --network host -v trivy-cache:/root/.cache/ -w /src aquasec/trivy:0.70.0 \
|
||||
fs --scanners vuln,secret --severity MEDIUM --exit-code 0 \
|
||||
--skip-dirs services/auth/node_modules,services/storage/node_modules,services/webmail/node_modules,services/webmail/.next .)
|
||||
docker cp . "$cid:/src"
|
||||
docker start -a "$cid"
|
||||
status=$(docker inspect -f '{{.State.ExitCode}}' "$cid")
|
||||
docker rm "$cid" >/dev/null
|
||||
exit "$status"
|
||||
|
||||
image:
|
||||
name: Imagem (${{ matrix.service }})
|
||||
@@ -186,20 +235,21 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: docker/setup-buildx-action@v4
|
||||
|
||||
# Built but not pushed: this proves each Dockerfile still works on every
|
||||
# branch, while publishing stays in release.yml.
|
||||
#
|
||||
# Plain `docker build` on purpose. setup-buildx would start a fresh
|
||||
# BuildKit container per job and throw its layer cache away; the daemon's
|
||||
# own builder keeps the cache in the runner's persistent volume, and the
|
||||
# "Stack completa" job reuses it for the same Dockerfiles.
|
||||
- name: Build
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: ${{ (matrix.service == 'migrations' || matrix.service == 'rspamd') && '.' || format('services/{0}', matrix.service) }}
|
||||
file: services/${{ matrix.service }}/Dockerfile
|
||||
push: false
|
||||
load: true
|
||||
tags: lambdamail-${{ matrix.service }}:ci
|
||||
cache-from: type=gha,scope=${{ matrix.service }}
|
||||
cache-to: type=gha,mode=max,scope=${{ matrix.service }}
|
||||
run: |
|
||||
case '${{ matrix.service }}' in
|
||||
migrations|rspamd) context=. ;;
|
||||
*) context=services/${{ matrix.service }} ;;
|
||||
esac
|
||||
docker build -f services/${{ matrix.service }}/Dockerfile \
|
||||
-t lambdamail-${{ matrix.service }}:ci "$context"
|
||||
|
||||
# Scanned only for the images whose contents we author. migrations and
|
||||
# rspamd add .sql and .conf files to an upstream image and nothing else,
|
||||
@@ -209,11 +259,9 @@ jobs:
|
||||
# that their base images have to be watched.
|
||||
- name: Trivy na imagem
|
||||
if: matrix.service != 'migrations' && matrix.service != 'rspamd'
|
||||
uses: aquasecurity/trivy-action@v0.36.0
|
||||
with:
|
||||
image-ref: lambdamail-${{ matrix.service }}:ci
|
||||
severity: HIGH,CRITICAL
|
||||
exit-code: '1'
|
||||
run: |
|
||||
docker run --rm --network host -v trivy-cache:/root/.cache/ -e DOCKER_HOST=tcp://127.0.0.1:2375 aquasec/trivy:0.70.0 \
|
||||
image --severity HIGH,CRITICAL --exit-code 1 lambdamail-${{ matrix.service }}:ci
|
||||
|
||||
compose-up:
|
||||
name: Stack completa
|
||||
@@ -221,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
|
||||
@@ -0,0 +1,243 @@
|
||||
name: Release
|
||||
|
||||
# Publishes the service images to the Gitea container registry after a merge
|
||||
# into main, and again under semver tags when a v* tag is pushed. The deploy
|
||||
# host only pulls, so nothing has to be built on the server. Needs the user
|
||||
# secret REGISTRY_TOKEN (a Gitea token with the write:package scope).
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ['v*']
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: release
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
REGISTRY: git.lucasrguerra.dev.br
|
||||
|
||||
jobs:
|
||||
# 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".
|
||||
# Lowercasing gitea.repository here keeps the workflow fork-safe instead of
|
||||
# hardcoding the owner.
|
||||
prepare:
|
||||
name: Namespace das imagens
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
namespace: ${{ steps.namespace.outputs.value }}
|
||||
steps:
|
||||
- id: namespace
|
||||
# GITHUB_OUTPUT is the runner's own interface: Gitea sets it under that name.
|
||||
run: |
|
||||
repo='${{ gitea.repository }}'
|
||||
echo "value=${repo,,}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Each architecture is built on a runner of its own kind. QEMU emulation
|
||||
# would also work, but it makes the Go and Next builds roughly an order of
|
||||
# magnitude slower.
|
||||
build:
|
||||
name: Build ${{ matrix.service }} (${{ matrix.platform }})
|
||||
needs: [plan, ci, prepare]
|
||||
if: ${{ needs.plan.outputs.reuse != 'true' }}
|
||||
runs-on: ${{ matrix.runner }}
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# Both are axes, so this is a cross product: four services on two
|
||||
# architectures is eight jobs. Declaring platform only under `include`
|
||||
# would not multiply anything - include can add keys to existing
|
||||
# combinations, but it cannot create the combinations themselves.
|
||||
service: [protocols, auth, storage, webmail, migrations, rspamd]
|
||||
platform: [linux/amd64, linux/arm64]
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runner: linux-amd64
|
||||
arch: amd64
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
arch: arm64
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
# The only runner is arm64; linux/amd64 is built under QEMU emulation
|
||||
# (label linux-amd64 points at the same runner), so it is slower.
|
||||
- uses: docker/setup-qemu-action@v3
|
||||
if: matrix.arch == 'amd64'
|
||||
|
||||
- uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Autentica no registro
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ gitea.actor }}
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
- name: Metadados
|
||||
id: meta
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ needs.prepare.outputs.namespace }}/${{ matrix.service }}
|
||||
|
||||
# Each architecture is pushed under its own tag (no digest artifacts: they
|
||||
# are not portable to Gitea). The final tags are attached later, once both
|
||||
# exist and can be joined into a single manifest list.
|
||||
- name: Build e push por arquitetura
|
||||
id: build
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: ${{ (matrix.service == 'migrations' || matrix.service == 'rspamd') && '.' || format('services/{0}', matrix.service) }}
|
||||
file: services/${{ matrix.service }}/Dockerfile
|
||||
platforms: ${{ matrix.platform }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
push: true
|
||||
tags: ${{ env.REGISTRY }}/${{ needs.prepare.outputs.namespace }}/${{ matrix.service }}:build-${{ gitea.sha }}-${{ matrix.arch }}
|
||||
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ needs.prepare.outputs.namespace }}/${{ matrix.service }}:buildcache-${{ matrix.arch }}
|
||||
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ needs.prepare.outputs.namespace }}/${{ matrix.service }}:buildcache-${{ matrix.arch }},mode=max
|
||||
|
||||
publish:
|
||||
name: Publica ${{ matrix.service }}
|
||||
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
|
||||
packages: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
service: [protocols, auth, storage, webmail, migrations, rspamd]
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Autentica no registro
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ gitea.actor }}
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
# `latest` is what compose pulls. The semver tags only materialise on a
|
||||
# v* tag push and make it possible to pin or roll back to an exact
|
||||
# release; the sha tag does the same for any main build.
|
||||
- name: Metadados
|
||||
id: meta
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ needs.prepare.outputs.namespace }}/${{ matrix.service }}
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=raw,value=latest
|
||||
type=sha,format=long
|
||||
|
||||
# Joins both architectures under one manifest list, so `docker pull`
|
||||
# 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 }}'
|
||||
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: |
|
||||
docker buildx imagetools inspect \
|
||||
"${{ env.REGISTRY }}/${{ needs.prepare.outputs.namespace }}/${{ matrix.service }}:latest" \
|
||||
--format '{{json .Manifest}}' \
|
||||
| jq -r '.manifests[] | select(.platform.os == "linux") | "\(.platform.os)/\(.platform.architecture)"'
|
||||
|
||||
- name: Resumo
|
||||
run: |
|
||||
{
|
||||
echo "### ${{ matrix.service }} publicado (linux/amd64 e linux/arm64)"
|
||||
echo
|
||||
echo '```'
|
||||
echo "${{ steps.meta.outputs.tags }}"
|
||||
echo '```'
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# Cria a entrada em "Releases" do repositório. Só roda quando uma tag v* é
|
||||
# enviada, depois que todas as imagens foram publicadas. Usa o token do próprio
|
||||
# Actions, então não precisa de segredo extra.
|
||||
gitea-release:
|
||||
name: Cria a release no Gitea
|
||||
needs: [publish, prepare]
|
||||
# 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
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Release
|
||||
env:
|
||||
TAG: ${{ gitea.ref_name }}
|
||||
TOKEN: ${{ gitea.token }}
|
||||
API: ${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}
|
||||
NAMESPACE: ${{ env.REGISTRY }}/${{ needs.prepare.outputs.namespace }}
|
||||
run: |
|
||||
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
|
||||
curl -fsS -X POST "$API/releases" -H "Authorization: token $TOKEN" \
|
||||
-H 'Content-Type: application/json' -d @release.json
|
||||
@@ -1,180 +0,0 @@
|
||||
name: Release
|
||||
|
||||
# Publishes the four service images to GHCR after a merge into main, and again
|
||||
# under semver tags when a v* tag is pushed. The deploy host only pulls, so
|
||||
# nothing has to be built on the server.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ['v*']
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: release
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
|
||||
jobs:
|
||||
# Nothing is published unless the full suite passes first.
|
||||
ci:
|
||||
uses: ./.github/workflows/ci.yml
|
||||
|
||||
# GHCR rejects an uppercase path, and this repository is "lucasrguerra/LambdaMail".
|
||||
# Lowercasing github.repository here keeps the workflow fork-safe instead of
|
||||
# hardcoding the owner.
|
||||
prepare:
|
||||
name: Namespace das imagens
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
namespace: ${{ steps.namespace.outputs.value }}
|
||||
steps:
|
||||
- id: namespace
|
||||
run: echo "value=${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Each architecture is built on a runner of its own kind. QEMU emulation
|
||||
# would also work, but it makes the Go and Next builds roughly an order of
|
||||
# magnitude slower.
|
||||
build:
|
||||
name: Build ${{ matrix.service }} (${{ matrix.platform }})
|
||||
needs: [ci, prepare]
|
||||
runs-on: ${{ matrix.runner }}
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# Both are axes, so this is a cross product: four services on two
|
||||
# architectures is eight jobs. Declaring platform only under `include`
|
||||
# would not multiply anything - include can add keys to existing
|
||||
# combinations, but it cannot create the combinations themselves.
|
||||
service: [protocols, auth, storage, webmail, migrations, rspamd]
|
||||
platform: [linux/amd64, linux/arm64]
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-latest
|
||||
arch: amd64
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
arch: arm64
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Autentica no GHCR
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Metadados
|
||||
id: meta
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ needs.prepare.outputs.namespace }}/${{ matrix.service }}
|
||||
|
||||
# Pushed by digest only: the tags are attached later, once both
|
||||
# architectures exist and can be joined into a single manifest list.
|
||||
- name: Build e push por digest
|
||||
id: build
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: ${{ (matrix.service == 'migrations' || matrix.service == 'rspamd') && '.' || format('services/{0}', matrix.service) }}
|
||||
file: services/${{ matrix.service }}/Dockerfile
|
||||
platforms: ${{ matrix.platform }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
outputs: type=image,name=${{ env.REGISTRY }}/${{ needs.prepare.outputs.namespace }}/${{ matrix.service }},push-by-digest=true,name-canonical=true,push=true
|
||||
cache-from: type=gha,scope=${{ matrix.service }}-${{ matrix.arch }}
|
||||
cache-to: type=gha,mode=max,scope=${{ matrix.service }}-${{ matrix.arch }}
|
||||
|
||||
- name: Exporta o digest
|
||||
run: |
|
||||
mkdir -p /tmp/digests
|
||||
# The output already carries the `sha256:` prefix; the file name must
|
||||
# not, since the merge step prepends it again.
|
||||
digest='${{ steps.build.outputs.digest }}'
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
|
||||
- name: Guarda o digest
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
# Slashes are not allowed in artifact names, hence the arch alias.
|
||||
name: digest-${{ matrix.service }}-${{ matrix.arch }}
|
||||
path: /tmp/digests/*
|
||||
retention-days: 1
|
||||
if-no-files-found: error
|
||||
|
||||
publish:
|
||||
name: Publica ${{ matrix.service }}
|
||||
needs: [build, prepare]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
service: [protocols, auth, storage, webmail, migrations, rspamd]
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Autentica no GHCR
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Recupera os digests
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
pattern: digest-${{ matrix.service }}-*
|
||||
merge-multiple: true
|
||||
path: /tmp/digests
|
||||
|
||||
# `latest` is what compose pulls. The semver tags only materialise on a
|
||||
# v* tag push and make it possible to pin or roll back to an exact
|
||||
# release; the sha tag does the same for any main build.
|
||||
- name: Metadados
|
||||
id: meta
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ needs.prepare.outputs.namespace }}/${{ matrix.service }}
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=raw,value=latest
|
||||
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
|
||||
working-directory: /tmp/digests
|
||||
run: |
|
||||
docker buildx imagetools create \
|
||||
$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< '${{ steps.meta.outputs.json }}') \
|
||||
$(printf '${{ env.REGISTRY }}/${{ needs.prepare.outputs.namespace }}/${{ matrix.service }}@sha256:%s ' *)
|
||||
|
||||
- name: Confere o resultado
|
||||
run: |
|
||||
docker buildx imagetools inspect \
|
||||
"${{ env.REGISTRY }}/${{ needs.prepare.outputs.namespace }}/${{ matrix.service }}:latest" \
|
||||
--format '{{json .Manifest}}' \
|
||||
| jq -r '.manifests[] | select(.platform.os == "linux") | "\(.platform.os)/\(.platform.architecture)"'
|
||||
|
||||
- name: Resumo
|
||||
run: |
|
||||
{
|
||||
echo "### ${{ matrix.service }} publicado (linux/amd64 e linux/arm64)"
|
||||
echo
|
||||
echo '```'
|
||||
echo "${{ steps.meta.outputs.tags }}"
|
||||
echo '```'
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
@@ -46,3 +46,6 @@ Thumbs.db
|
||||
*.json.gz
|
||||
*.xml.gz
|
||||
*.zip.gz
|
||||
|
||||
# Configuração local do Superset
|
||||
.superset/
|
||||
@@ -6,22 +6,47 @@ 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
|
||||
|
||||
<!-- Mistakes made and corrected. Each entry prevents the same mistake recurring. -->
|
||||
<!-- Format: [YYYY-MM-DD] Description of what went wrong and what to do instead. -->
|
||||
- [2026-10-22] `git add -A` swept the untracked `.wolf/` and `CLAUDE.md` into a public repo. Add explicit paths (`git add services migrations`), never `-A`.
|
||||
- [2026-10-22] Deploy is two steps: PATCH the Coolify env `IMAGE_TAG` to `sha-<commit>` and only then POST /deploy. A deploy without the PATCH redeploys the old image and still says "finished". Wait for the Release run to be `completed`, not just for `gh run watch` to return.
|
||||
- [2026-10-22] `pkill -f <pattern>` kills the shell that is running it when the pattern appears in the command text. Use a bracket such as `[m]ock.mjs`, and never in a command that also contains the pattern as plain text.
|
||||
- [2026-10-22] `~/.claude/tools/chrome-claude --print-path` launches Chrome. Pass the wrapper only as `executablePath`.
|
||||
- [2026-10-22] `services/protocols` e2e tests apply only migration 0001. Delivery code must not call SQL functions or columns from later migrations.
|
||||
- [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
|
||||
|
||||
<!-- Significant technical decisions with rationale. Why X was chosen over Y. -->
|
||||
- 2026-10-22 Sessions: webmail session is 30 days and renewed daily while in use; each account is also a `lm_acct_<id>` cookie, `lm_user_session` stays the active one so the Go service and the middleware needed no change. Console stays at 8 hours.
|
||||
- 2026-10-22 Trusted senders live in table `trusted_senders`; delivery rescues a Junk verdict only when DMARC passed, or SPF passed for the From domain (a forged From must not be rescued).
|
||||
- 2026-10-22 Conversations: `thread_id` is written at delivery (Message-ID, then In-Reply-To/References, then Re:-subject); NULL means "its own conversation" via COALESCE(thread_id, id). Subject key is computed in Go at delivery and in SQL (`lm_subject_key`) for the backfill; a test holds them equal.
|
||||
- 2026-10-22 Themes: every colour is a CSS variable per theme (`globals.css`), Tailwind scales (slate, indigo, amber, rose, emerald) read them, `ink` is the on-ground colour. Dark is default; the theme is chosen by a script in `<head>` before first paint. Font Geist is self-hosted (@fontsource-variable/geist).
|
||||
- 2026-10-23 Logo: a lambda of two straight strokes of one weight, ends cut square, joined without a seam, flat ultramarine tile with continuous corners. The first version (round caps, gradient, envelope flap) read as childish; do not go back to round-capped strokes. Generator: `scripts/brand/` (mark.py, icons.mjs); `Logo.tsx` carries the same path.
|
||||
- 2026-10-23 Reader: a message written plainly is drawn in the app's colours (transparent frame, text lifted if too dark, white backdrops cleared); a designed one (backgrounds, bgcolor, 3+ tables) keeps a white sheet. Decided by `looksDesigned` in `lib/emailBody.ts`; a reply's quoted history folds behind a button.
|
||||
@@ -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:
|
||||
|
||||
@@ -1,19 +1,28 @@
|
||||
# LambdaMail
|
||||
<p align="center"><img src="docs/img/lambdamail-logo.svg" width="120" alt="LambdaMail"></p>
|
||||
<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>
|
||||
|
||||
A self-hosted mail server with an integrated webmail and admin console, built to
|
||||
run on [Coolify](https://coolify.io) from a single `docker-compose.yaml`.
|
||||
<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>
|
||||
|
||||
<p align="center"><img src="docs/img/webmail-dark-en.png" width="900" alt="The LambdaMail webmail in the dark theme: the inbox as conversations on the left, and the Launch checklist conversation open on the right with the newest message expanded and the older ones as a line each"><br><sub>The real interface running on an invented mailbox: names, addresses and messages are made up.</sub></p>
|
||||
|
||||
---
|
||||
|
||||
LambdaMail is a self-hosted mail server with an integrated webmail and admin
|
||||
console, built to run on [Coolify](https://coolify.io) from a single
|
||||
`docker-compose.yaml`.
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## What it does
|
||||
|
||||
| Area | |
|
||||
@@ -26,6 +35,11 @@ paid tiers and no artificial limits on domains or mailboxes.
|
||||
| **Filing** | Sieve rules built in the UI, custom folders, and an RFC 5230 vacation responder that replies in thread and once per sender per week |
|
||||
| **Identity** | Per-user profile photos with a circular crop editor, shown inside the webmail; BIMI logo hosting and validation for the mark external clients render |
|
||||
| **Web** | Webmail at `/user/*` and an admin console at `/admin/*`, isolated by session audience, with TOTP two-factor, recovery codes, app passwords, and English, Portuguese and Spanish |
|
||||
| **Sessions** | Webmail sessions last 30 days and renew while used; the console stays at 8 hours. Several accounts can be signed in at once and switched from the sidebar, without signing out |
|
||||
| **Trusted senders** | Trusting a sender keeps their mail out of spam and always shows their images, kept on the server per mailbox. "Not spam" in the Spam folder does both in one click. A forged From is not rescued: delivery needs DMARC, or SPF for the From domain |
|
||||
| **Conversations** | Mail is listed one row per conversation, with who wrote, how many, and whether you have answered. The messages stack in the reader and the reply is written under them. One switch turns it off |
|
||||
| **Reading** | A message written plainly takes the app's own colours; a designed one (newsletter, receipt) keeps its white sheet. Quoted history folds away, remote images are blocked until you say so, and a plain message can be seen as sent |
|
||||
| **Appearance** | Dark (default) and light themes, a command menu on `Ctrl K` / `Cmd K`, `c` and `/` shortcuts, and an installable app with its own icon. Fonts are served by the app itself |
|
||||
| **Console** | Server-side paging and filtering, user editing (role, quota, language, password, second factor), per-user aliases, and a diagnostics page at `/admin/tests` |
|
||||
|
||||
## Architecture
|
||||
@@ -33,12 +47,14 @@ paid tiers and no artificial limits on domains or mailboxes.
|
||||
Two runtimes, split by what each is good at:
|
||||
|
||||
- **`protocols`** (Go) — every protocol listener, the delivery worker, TLS
|
||||
certificate handling, DNS reconciliation, and the webmail's mail API. It owns
|
||||
folder, UID, flag and blob semantics.
|
||||
- **`auth`** (TypeScript) — logins, sessions, two-factor, account management and
|
||||
the admin console API.
|
||||
- **`web`** (Next.js) — both web surfaces, and the proxy that gives the browser a
|
||||
single origin.
|
||||
certificate handling, DNS reconciliation, and the webmail's mail API
|
||||
(messages, conversations, drafts). It owns folder, UID, flag and blob
|
||||
semantics, and decides at delivery which conversation a message joins and
|
||||
whether a trusted sender's mail skips spam.
|
||||
- **`auth`** (TypeScript) — logins, sessions and the accounts signed in on a
|
||||
browser, two-factor, trusted senders, preferences, and the admin console API.
|
||||
- **`webmail`** (Next.js) — both web surfaces, and the proxy that gives the
|
||||
browser a single origin.
|
||||
- **`storage`** (TypeScript) — health only today.
|
||||
|
||||
Postgres is the source of truth for everything, including the outbound queue.
|
||||
@@ -46,14 +62,42 @@ Redis is a cache and dispatch index; losing it loses no mail.
|
||||
|
||||
```
|
||||
┌──────────┐
|
||||
browser ─────────▶│ web │──┬─▶ auth (sessions, accounts, admin)
|
||||
└──────────┘ └─▶ protocols (mail read/send, DKIM, TLS)
|
||||
browser ─────────▶│ webmail │──┬─▶ auth (sessions, accounts, trusted senders, admin)
|
||||
└──────────┘ └─▶ protocols (mail read/send, conversations, DKIM, TLS)
|
||||
│
|
||||
internet ──25/587/465/143/993──────────┘
|
||||
│
|
||||
Postgres ─┴─ Redis ─ Rspamd ─ ClamAV
|
||||
```
|
||||
|
||||
## Using the webmail
|
||||
|
||||
The reference is **[docs/WEBMAIL.md](docs/WEBMAIL.md)**. The short version:
|
||||
|
||||
- **Stay signed in.** A session lasts 30 days and renews each day it is used.
|
||||
Add another account from the menu at the foot of the sidebar and switch
|
||||
between them; sign-out leaves the others signed in.
|
||||
- **Trust a sender.** The shield beside a sender's name trusts them: their mail
|
||||
stops going to spam and their images load. In the Spam folder, **Not spam**
|
||||
moves the message to the inbox and trusts the sender.
|
||||
- **Read as conversations.** One row per conversation; open it and reply under
|
||||
the messages. Settings → Language turns this off.
|
||||
- **Press `Ctrl K` (or `Cmd K`).** Folders, Compose, Settings, accounts and
|
||||
appearance, by typing. `c` writes a message and `/` searches.
|
||||
- **Pick a theme** (dark, light or system) from the same menu at the foot of the
|
||||
sidebar. Dark is the default.
|
||||
|
||||
| Key | |
|
||||
| :--- | :--- |
|
||||
| `Ctrl K` / `Cmd K` | Command menu |
|
||||
| `c` | New message |
|
||||
| `/` | Search the list |
|
||||
|
||||
<p align="center"><img src="docs/img/webmail-light-en.png" width="440" alt="The same conversation in the light theme"> <img src="docs/img/designed-light-en.png" width="440" alt="A designed newsletter keeping its own white sheet, in the light theme"></p>
|
||||
<p align="center"><sub>The light theme, and a designed message (a newsletter, a receipt) that keeps the sheet it was drawn for. A message written plainly takes the app's own colours instead.</sub></p>
|
||||
|
||||
<p align="center"><img src="docs/img/menu-dark-en.png" width="620" alt="The command menu open over a conversation, listing actions, folders and appearance"><br><sub>The command menu, on <code>Ctrl K</code> or <code>Cmd K</code>.</sub></p>
|
||||
|
||||
## Deploying
|
||||
|
||||
For a real VPS, follow **[docs/DEPLOYMENT.md](docs/DEPLOYMENT.md)**. It covers the
|
||||
@@ -63,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='...'
|
||||
@@ -122,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
|
||||
@@ -131,9 +175,14 @@ make seed # a postmaster@example.test fixture
|
||||
The webmail is on `http://localhost:3000`. The seeded password is
|
||||
`dev-password-only` — it is a fixture, not a credential.
|
||||
|
||||
To see or work on the interface without any mail services, run the invented
|
||||
mailbox in [`scripts/screenshots/`](scripts/screenshots/README.md) and point the
|
||||
webmail at it; the same stub produced the pictures above.
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
make test-db-up # a throwaway Postgres on :55432; prints the two variables to export
|
||||
make test # Go suite, auth and webmail
|
||||
make lint # go vet, tsc, and the language lint
|
||||
```
|
||||
@@ -141,13 +190,22 @@ make lint # go vet, tsc, and the language lint
|
||||
The Go and auth suites include integration tests that need Postgres. They skip
|
||||
without `TEST_DATABASE_URL` and run against a real database in CI, which is
|
||||
where the security regressions are enforced — wrong password, wrong TOTP code,
|
||||
session revocation, surface isolation, cross-mailbox reads.
|
||||
session revocation, surface isolation, cross-mailbox reads, and now sessions
|
||||
that renew, accounts that switch, trusted senders and which conversation a
|
||||
reply joins. A green run **without** the variable skips all of that, so
|
||||
`make test` says so loudly; run it with the database before trusting it.
|
||||
|
||||
The webmail suite is file- and logic-level (`vitest`): it guards the colour
|
||||
system, the sanitiser, the reader's modes, the composer's undo and discard, and
|
||||
that every string exists in all three languages. The screens themselves are
|
||||
checked in a real browser; see
|
||||
[docs/WEBMAIL.md](docs/WEBMAIL.md#testing-it).
|
||||
|
||||
## 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 | |
|
||||
| :--- | :--- |
|
||||
@@ -298,17 +356,35 @@ Stated plainly, because a mail server that half works loses mail:
|
||||
|
||||
- **Read-only console panels.** The guided domain onboarding checklist reports
|
||||
on state rather than driving it.
|
||||
- **Not implemented.** JMAP, CalDAV/CardDAV, multi-node. Backups are
|
||||
documented but not automated.
|
||||
- **Attachments from the composer.** The picker lists files but nothing is
|
||||
uploaded, and the app says so on screen. Reading and downloading received
|
||||
attachments works.
|
||||
- **Spam filter learning.** "Not spam" stops condemning that sender, but Rspamd
|
||||
does not learn from it, so similar mail from strangers is judged as before.
|
||||
- **Conversations are grouped at delivery.** Older mail is grouped by subject,
|
||||
and only where a reply exists; a conversation whose replies lost their
|
||||
`References` and subject may show as separate rows.
|
||||
- **Not implemented.** IMAP `APPEND`, JMAP, CalDAV/CardDAV, multi-node. Backups
|
||||
are documented but not automated.
|
||||
- **DANE** is off by default. See [DANE](#dane) for turning it on; under
|
||||
`TLS_MODE=traefik` it stays off deliberately, because the proxy's key changes
|
||||
at every renewal and the published TLSA record stops matching.
|
||||
|
||||
## Contributing
|
||||
|
||||
- All code, comments and identifiers are in English. `make lint` enforces it.
|
||||
- All code, comments and identifiers are in English. `make lint` enforces it
|
||||
(and, for code, ASCII only: write `\u00e9`, not an accented letter).
|
||||
- Colours come from the theme variables in `globals.css`, never a literal, and a
|
||||
new one is defined in both themes; see
|
||||
[docs/WEBMAIL.md](docs/WEBMAIL.md#the-colour-system). The logo is generated
|
||||
from [`scripts/brand/`](scripts/brand/README.md).
|
||||
- New migrations are numbered in `migrations/` and run on deploy. Delivery code
|
||||
must not depend on one that may not have run: the end-to-end tests apply only
|
||||
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
|
||||
|
||||
|
||||
@@ -0,0 +1,394 @@
|
||||
<p align="center"><img src="docs/img/lambdamail-logo.svg" width="120" alt="LambdaMail"></p>
|
||||
<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="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>
|
||||
|
||||
<p align="center"><img src="docs/img/webmail-dark-pt.png" width="900" alt="O webmail do LambdaMail no tema escuro: a caixa de entrada em conversas à esquerda, e a conversa Launch checklist aberta à direita, com a mensagem mais recente expandida e as anteriores em uma linha cada"><br><sub>A interface real rodando numa caixa inventada: nomes, endereços e mensagens são fictícios.</sub></p>
|
||||
|
||||
---
|
||||
|
||||
O LambdaMail é um servidor de e-mail próprio, com webmail e console de
|
||||
administração integrados, feito para rodar no [Coolify](https://coolify.io) a
|
||||
partir de um único `docker-compose.yaml`.
|
||||
|
||||
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.
|
||||
|
||||
> **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.
|
||||
|
||||
## O que ele faz
|
||||
|
||||
| Área | |
|
||||
| :--- | :--- |
|
||||
| **Protocolos** | SMTP MX (25), Submission (587 STARTTLS, 465 TLS implícito), IMAP (143/993) com UID, flags, IDLE e CONDSTORE, POP3 (110/995), ManageSieve (4190) |
|
||||
| **Autenticação** | SPF, DKIM (assinatura dupla Ed25519 + RSA), DMARC, selo ARC, DANE/TLSA e MTA-STS na entrega, ingestão de TLS-RPT |
|
||||
| **Filtragem** | Pontuação do Rspamd com greylisting, RBLs e limite de taxa; ClamAV; Sieve |
|
||||
| **Entrega** | Fila de saída durável no Postgres, com a escada de tentativas e os retornos da RFC 3464; relay (smarthost) para hospedagens com a porta 25 bloqueada |
|
||||
| **DNS** | Reconciliação na Cloudflare dos registros esperados de cada domínio ativo, idempotente, com verificação de cada registro em resolvedores públicos |
|
||||
| **Organização** | Regras Sieve criadas na interface, pastas próprias e um respondedor de férias (RFC 5230) que responde dentro da conversa e uma vez por remetente por semana |
|
||||
| **Identidade** | Foto de perfil por usuário, com editor de recorte circular, exibida no webmail; hospedagem e validação do logo BIMI, a marca que os clientes externos exibem |
|
||||
| **Web** | Webmail em `/user/*` e console de administração em `/admin/*`, isolados pela audiência da sessão, com dois fatores TOTP, códigos de recuperação, senhas de aplicativo, em inglês, português e espanhol |
|
||||
| **Sessões** | As sessões do webmail duram 30 dias e se renovam enquanto são usadas; o console continua em 8 horas. Várias contas podem estar abertas ao mesmo tempo e se alternam pela barra lateral, sem sair |
|
||||
| **Remetentes confiáveis** | Confiar num remetente tira o e-mail dele do spam e mostra sempre as imagens, guardado no servidor por caixa. "Não é spam" na pasta Spam faz as duas coisas num clique. Um From forjado não é resgatado: a entrega exige DMARC, ou SPF do domínio do From |
|
||||
| **Conversas** | O e-mail é listado em uma linha por conversa, com quem escreveu, quantas mensagens e se você já respondeu. As mensagens se empilham no leitor e a resposta é escrita embaixo delas. Uma chave desliga |
|
||||
| **Leitura** | Uma mensagem escrita à mão usa as cores do próprio app; uma desenhada (newsletter, recibo) mantém a folha branca. O histórico citado se recolhe, imagens remotas ficam bloqueadas até você permitir, e dá para ver uma mensagem simples como foi enviada |
|
||||
| **Aparência** | Temas escuro (padrão) e claro, menu de comandos em `Ctrl K` / `Cmd K`, atalhos `c` e `/`, e um aplicativo instalável com ícone próprio. As fontes são servidas pelo próprio app |
|
||||
| **Console** | Paginação e filtros no servidor, edição de usuário (papel, cota, idioma, senha, segundo fator), aliases por usuário e uma página de diagnóstico em `/admin/tests` |
|
||||
|
||||
## Arquitetura
|
||||
|
||||
Dois ambientes de execução, divididos pelo que cada um faz melhor:
|
||||
|
||||
- **`protocols`** (Go) — todos os listeners de protocolo, o worker de entrega, o
|
||||
tratamento dos certificados TLS, a reconciliação de DNS e a API de e-mail do
|
||||
webmail (mensagens, conversas, rascunhos). É dono da semântica de pastas, UIDs,
|
||||
flags e blobs, e decide, na entrega, a qual conversa uma mensagem pertence e
|
||||
se o e-mail de um remetente confiável pula o spam.
|
||||
- **`auth`** (TypeScript) — logins, sessões e as contas abertas num navegador,
|
||||
dois fatores, remetentes confiáveis, preferências e a API do console de
|
||||
administração.
|
||||
- **`webmail`** (Next.js) — as duas superfícies web e o proxy que dá ao navegador
|
||||
uma origem única.
|
||||
- **`storage`** (TypeScript) — só a verificação de saúde, por enquanto.
|
||||
|
||||
O Postgres é a fonte da verdade de tudo, inclusive da fila de saída. O Redis é
|
||||
cache e índice de despacho; perdê-lo não perde nenhum e-mail.
|
||||
|
||||
```
|
||||
┌──────────┐
|
||||
navegador ───────▶│ webmail │──┬─▶ auth (sessões, contas, remetentes confiáveis, admin)
|
||||
└──────────┘ └─▶ protocols (ler/enviar e-mail, conversas, DKIM, TLS)
|
||||
│
|
||||
internet ──25/587/465/143/993──────────┘
|
||||
│
|
||||
Postgres ─┴─ Redis ─ Rspamd ─ ClamAV
|
||||
```
|
||||
|
||||
## Usando o webmail
|
||||
|
||||
A referência é o **[docs/WEBMAIL.md](docs/WEBMAIL.md)** (em inglês). Em resumo:
|
||||
|
||||
- **Continue conectado.** Uma sessão dura 30 dias e se renova a cada dia de uso.
|
||||
Adicione outra conta pelo menu no pé da barra lateral e alterne entre elas;
|
||||
sair deixa as outras abertas.
|
||||
- **Confie num remetente.** O escudo ao lado do nome do remetente confia nele: o
|
||||
e-mail dele deixa de ir para o spam e as imagens carregam. Na pasta Spam,
|
||||
**Não é spam** move a mensagem para a caixa de entrada e confia no remetente.
|
||||
- **Leia em conversas.** Uma linha por conversa; abra e responda embaixo das
|
||||
mensagens. Em Configurações → Idioma dá para desligar.
|
||||
- **Aperte `Ctrl K` (ou `Cmd K`).** Pastas, Escrever, Configurações, contas e
|
||||
aparência, digitando. `c` escreve uma mensagem e `/` busca.
|
||||
- **Escolha um tema** (escuro, claro ou do sistema) no mesmo menu do pé da barra
|
||||
lateral. O escuro é o padrão.
|
||||
|
||||
| Tecla | |
|
||||
| :--- | :--- |
|
||||
| `Ctrl K` / `Cmd K` | Menu de comandos |
|
||||
| `c` | Nova mensagem |
|
||||
| `/` | Buscar na lista |
|
||||
|
||||
<p align="center"><img src="docs/img/webmail-light-pt.png" width="440" alt="A mesma conversa no tema claro"> <img src="docs/img/designed-light-pt.png" width="440" alt="Uma newsletter desenhada mantendo a própria folha branca, no tema claro"></p>
|
||||
<p align="center"><sub>O tema claro, e uma mensagem desenhada (uma newsletter, um recibo) que mantém a folha para a qual foi feita. Uma mensagem escrita à mão usa as cores do próprio app.</sub></p>
|
||||
|
||||
<p align="center"><img src="docs/img/menu-dark-pt.png" width="620" alt="O menu de comandos aberto sobre uma conversa, listando ações, pastas e aparência"><br><sub>O menu de comandos, em <code>Ctrl K</code> ou <code>Cmd K</code>.</sub></p>
|
||||
|
||||
## Implantação
|
||||
|
||||
Para uma VPS de verdade, siga o **[docs/DEPLOYMENT.md](docs/DEPLOYMENT.md)** (em
|
||||
inglês). Ele cobre as três coisas que nenhum código faz por você — DNS reverso,
|
||||
a delegação DNSSEC e conseguir a porta 25 liberada — e a ordem em que o resto
|
||||
precisa acontecer.
|
||||
|
||||
A versão curta:
|
||||
|
||||
```bash
|
||||
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='...'
|
||||
```
|
||||
|
||||
`make create-admin` não é opcional: uma instalação nova não tem nenhuma caixa, e
|
||||
todo caminho para o console precisa de uma.
|
||||
|
||||
### Portas
|
||||
|
||||
Tudo abaixo é TCP. "Entrada" é o que o firewall do host e o grupo de segurança do
|
||||
provedor precisam permitir; "saída" é o que o host precisa conseguir alcançar.
|
||||
|
||||
| Porta | Direção | Quem conecta | Obrigatória? | Observações |
|
||||
| ---: | :--- | :--- | :--- | :--- |
|
||||
| 25 | entrada | Qualquer MTA remetente | **Sim**, para receber | Precisa estar aberta ao mundo. Tem limite de taxa e não é autenticada — é assim que o SMTP funciona. |
|
||||
| 25 | **saída** | Este host → MTAs remotos | **Sim**, para enviar | A que a maioria dos provedores bloqueia. Sem ela, configure um relay (`RELAY_HOST`); veja [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md). |
|
||||
| 80 | entrada | Let's Encrypt, navegadores | **Sim** | Desafio ACME HTTP-01 e o redirecionamento para HTTPS. Fechá-la significa não ter certificado. |
|
||||
| 443 | entrada | Navegadores | **Sim** | Webmail, console, política MTA-STS, autodiscover. |
|
||||
| 587 | entrada | Clientes de e-mail | Recomendada | Submission com STARTTLS. A porta que os clientes devem usar. |
|
||||
| 465 | entrada | Clientes de e-mail | Opcional | Submission com TLS implícito, para clientes que insistem. |
|
||||
| 143 | entrada | Clientes de e-mail | Opcional | IMAP com STARTTLS. Prefira a 993. |
|
||||
| 993 | entrada | Clientes de e-mail | Recomendada | IMAPS. |
|
||||
| 110 / 995 | entrada | Clientes de e-mail | Opcional | POP3 / POP3S. Deixe fechada, a menos que algo precise de POP3. |
|
||||
| 4190 | entrada | Clientes de e-mail | Opcional | ManageSieve, para editar filtros num cliente de desktop. |
|
||||
| 53 | saída | Este host → resolvedor | **Sim** | Consultas de MX, SPF, DKIM, DANE e MTA-STS. UDP **e** TCP — respostas DNSSEC estouram o UDP. |
|
||||
| 443 | saída | Este host → APIs | Se usado | Automação de DNS da Cloudflare, busca da política MTA-STS, atualização de assinaturas do ClamAV. |
|
||||
|
||||
As portas 110, 995 e 4190 são publicadas pelo compose, mas vale fechá-las no
|
||||
firewall a menos que algum cliente precise delas — cada porta aberta é mais uma
|
||||
coisa para manter atualizada.
|
||||
|
||||
### O que o host precisa permitir
|
||||
|
||||
- **DNS reverso (PTR)** do IPv4 público resolvendo para `PRIMARY_MAIL_HOST`, e
|
||||
esse nome resolvendo de volta para o mesmo endereço. Quem configura é o dono do
|
||||
bloco de IPs — o painel do seu provedor, nunca a Cloudflare. Gmail e Outlook
|
||||
mandam para o spam ou recusam e-mail sem isso. Se o host tem IPv6 público e
|
||||
publica um registro AAAA, ele também precisa de PTR: remetentes que conectam
|
||||
por IPv6 são julgados pelo reverso do IPv6.
|
||||
- **Porta 25 de saída liberada.** Peça ao provedor; um timeout, e não uma recusa,
|
||||
ao conectar num MX remoto é a assinatura de um bloqueio.
|
||||
- **Docker** com o plugin compose, e um usuário no grupo `docker` ou root.
|
||||
- **Portas abaixo de 1024** são abertas pelo proxy do Docker no host, então o
|
||||
daemon precisa do privilégio — o padrão numa instalação normal. Os contêineres
|
||||
em si rodam sem privilégios.
|
||||
- **O diretório de certificados do proxy**, só com `TLS_MODE=traefik` — veja
|
||||
[Certificados](#certificados) abaixo, e prefira `TLS_MODE=acme` se puder.
|
||||
- **Disco** para o spool de e-mail (`protocols_spool`) e o Postgres. O e-mail é
|
||||
guardado uma vez, com contagem de referências, mas planeje o crescimento.
|
||||
- **Um firewall que não limite a porta 25 até a inutilidade.** Alguns provedores
|
||||
aplicam limites de SYN agressivos que parecem falhas de entrega intermitentes.
|
||||
|
||||
## Rodando localmente
|
||||
|
||||
```bash
|
||||
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
|
||||
```
|
||||
|
||||
O webmail fica em `http://localhost:3000`. A senha do seed é `dev-password-only`
|
||||
— é um dado de teste, não uma credencial.
|
||||
|
||||
Para ver ou trabalhar na interface sem nenhum serviço de e-mail, rode a caixa
|
||||
inventada de [`scripts/screenshots/`](scripts/screenshots/README.md) e aponte o
|
||||
webmail para ela; o mesmo servidor de demonstração gerou as imagens acima.
|
||||
|
||||
## Testes
|
||||
|
||||
```bash
|
||||
make test-db-up # um Postgres descartável na :55432; imprime as duas variáveis a exportar
|
||||
make test # suíte do Go, do auth e do webmail
|
||||
make lint # go vet, tsc e o lint de idioma
|
||||
```
|
||||
|
||||
As suítes do Go e do auth incluem testes de integração que precisam de Postgres.
|
||||
Eles são pulados sem `TEST_DATABASE_URL` e rodam contra um banco de verdade no
|
||||
CI, que é onde as regressões de segurança são garantidas — senha errada, código
|
||||
TOTP errado, revogação de sessão, isolamento entre superfícies, leitura entre
|
||||
caixas e, agora, sessões que se renovam, contas que se alternam, remetentes
|
||||
confiáveis e a qual conversa uma resposta pertence. Uma execução verde **sem** a
|
||||
variável pula tudo isso, por isso o `make test` avisa em voz alta; rode com o
|
||||
banco antes de confiar nela.
|
||||
|
||||
A suíte do webmail é de arquivos e lógica (`vitest`): ela protege o sistema de
|
||||
cores, o sanitizador, os modos do leitor, o desfazer e o descartar do editor, e
|
||||
que toda frase exista nos três idiomas. As telas em si são conferidas num
|
||||
navegador de verdade; veja [docs/WEBMAIL.md](docs/WEBMAIL.md#testing-it).
|
||||
|
||||
## Configuração
|
||||
|
||||
Cada variável está documentada em linha em
|
||||
[`.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 | |
|
||||
| :--- | :--- |
|
||||
| `JWT_SECRET` | Assina as sessões. No mínimo 32 caracteres; o serviço se recusa a iniciar abaixo disso, em vez de cair numa constante. |
|
||||
| `LAMBDAMAIL_MASTER_KEY` | Cifra as chaves DKIM, os segredos TOTP e os tokens de provedores. **Faça backup dela separado do banco** — perdê-la torna irrecuperável todo segredo guardado. |
|
||||
| `POSTGRES_PASSWORD`, `REDIS_PASSWORD` | Sem padrão; o compose falha sem elas. |
|
||||
| `MAIL_DOMAIN`, `PRIMARY_MAIL_HOST`, `PUBLIC_IPV4` | Identidade. `PRIMARY_MAIL_HOST` precisa bater com o PTR de `PUBLIC_IPV4`. |
|
||||
|
||||
### Certificados
|
||||
|
||||
Dois modos, e a escolha importa mais do que parece.
|
||||
|
||||
**`TLS_MODE=acme` (recomendado).** O LambdaMail obtém o próprio certificado para
|
||||
`PRIMARY_MAIL_HOST`, `mta-sts.<domínio>` e `autoconfig.<domínio>` por DNS-01,
|
||||
usando o token da Cloudflare já configurado para o DNS. Precisa de
|
||||
`LAMBDAMAIL_MASTER_KEY` para selar as chaves da conta e do certificado, e de
|
||||
`ACME_EMAIL`. É o único modo em que o DANE funciona, porque é o único em que a
|
||||
chave do próximo certificado existe antes do próprio certificado.
|
||||
|
||||
**`TLS_MODE=traefik`.** Os listeners de e-mail leem o certificado que o proxy
|
||||
reverso já obteve, em `COOLIFY_PROXY_DIR`, montado só para leitura. Duas coisas
|
||||
precisam ser verdade, e nenhuma é óbvia quando não são:
|
||||
|
||||
- o proxy precisa ter uma rota para `PRIMARY_MAIL_HOST`, ou esse nome nunca
|
||||
aparece no `acme.json` e não existe certificado para ler;
|
||||
- o `acme.json` precisa ser legível pelo usuário não root do contêiner. Deixado
|
||||
no modo `600` de propriedade do root — como o proxy o grava — ele não é.
|
||||
|
||||
As duas falhas são silenciosas do mesmo jeito: o HTTPS continua funcionando,
|
||||
porque o proxy o serve, enquanto os listeners de e-mail caem para um **certificado
|
||||
autoassinado** e todo cliente que verifica recusa SMTP, IMAP e POP3. Parece
|
||||
problema de certificado e quase sempre é problema de permissão.
|
||||
|
||||
`/admin/security` informa o emissor e diz de forma direta quando o certificado é
|
||||
autoassinado, que é o jeito mais rápido de distinguir os dois casos.
|
||||
|
||||
### Fotos de perfil e BIMI
|
||||
|
||||
São duas coisas diferentes, e a diferença é a história toda.
|
||||
|
||||
Uma **foto de perfil** é por usuário e aparece dentro do LambdaMail. Nenhum
|
||||
cliente de e-mail externo a lê, e não existe protocolo que permita isso: o avatar
|
||||
que o Gmail ou o Outlook mostra ao lado de uma mensagem vem dos contatos do
|
||||
*destinatário*, ou da conta Google ou Microsoft do remetente. Um servidor próprio
|
||||
não consegue pôr uma imagem ali.
|
||||
|
||||
O **BIMI** é o que os clientes externos leem, e é por domínio: uma marca para
|
||||
todos que enviam dele. Publicar uma exige três coisas, e o console
|
||||
(`/admin/domains`, dentro do domínio) cuida de cada uma:
|
||||
|
||||
1. **O logo.** Envie um PNG ou um SVG. O estúdio o converte para o que o BIMI
|
||||
aceita, SVG Tiny Portable/Secure numa tela quadrada de no máximo 32 KB, sem
|
||||
nada ativo ou externo dentro, e deixa você posicioná-lo como no editor da foto
|
||||
de perfil: arrastar, ampliar, cor de fundo, com uma prévia dentro do círculo
|
||||
ou do quadrado arredondado que os clientes desenham. De um SVG é removido o que
|
||||
o formato proíbe (scripts, links, bitmaps embutidos, metadados de editor) e
|
||||
avisa o que foi; um PNG é vetorizado, o que perde algum detalhe, então envie o
|
||||
SVG quando tiver. O servidor valida o resultado de novo contra todas as regras
|
||||
de uma vez, porque um receptor que rejeita a marca simplesmente não mostra nada.
|
||||
2. **DMARC em `p=quarantine` ou `p=reject`** com `pct=100`, o que este servidor
|
||||
já configura.
|
||||
3. **Um certificado, para o Gmail e o Apple Mail exibirem.** Um Verified Mark
|
||||
Certificate (VMC, para marca registrada) ou um Common Mark Certificate (CMC,
|
||||
para marca em uso) é **emitido por uma autoridade certificadora** (DigiCert,
|
||||
Entrust, SSL.com e outras) depois que ela verifica a organização. Ele não pode
|
||||
ser gerado aqui a partir de uma imagem, e nada assinado aqui seria confiável.
|
||||
O que o console faz: entrega o SVG exato para enviar à autoridade, recebe o
|
||||
`.pem` que ela devolver e recusa o que não funcionaria (o logo dentro dele
|
||||
precisa ser, byte a byte, o publicado; precisa cobrir o domínio, estar na
|
||||
validade e nunca conter uma chave privada). Depois o serve em
|
||||
`https://<host de e-mail>/.well-known/bimi/vmc.pem` e aponta a tag `a=` para
|
||||
lá. Sem certificado o registro continua válido e alguns clientes o usam; o
|
||||
Gmail e o Apple Mail, não.
|
||||
|
||||
"Verificar configuração" no painel olha o que um receptor olharia: o arquivo do
|
||||
logo, o DMARC, o registro BIMI no DNS, a URL pública do logo, o certificado e a
|
||||
URL pública dele, e aponta o que está errado.
|
||||
|
||||
O logo é servido em `https://<host de e-mail>/.well-known/bimi/<domínio>/default.svg`
|
||||
e o certificado em `.../<domínio>/vmc.pem`: um host de e-mail, um caminho por
|
||||
domínio, para que cada domínio hospedado possa publicar o seu. (O antigo
|
||||
`/.well-known/bimi/default.svg` escolhia o domínio pelo nome do host, o que só
|
||||
conseguia nomear o domínio principal; o registro de todo outro domínio apontava
|
||||
para um 404. Ele ainda responde, para os registros já publicados.)
|
||||
|
||||
**O registro de DNS acompanha a imagem.** Com um token da Cloudflare configurado,
|
||||
o registro TXT `default._bimi.<domínio>` faz parte do que o reconciliador de DNS
|
||||
publica, e salvar ou remover o logo ou o certificado no console dispara essa
|
||||
reconciliação sozinho ("Publicar no DNS agora" faz o mesmo sob demanda). O
|
||||
registro é criado quando há um logo, atualizado quando o certificado é
|
||||
adicionado e removido quando o logo é removido, mas só um registro que este
|
||||
reconciliador publicou: um feito à mão é deixado em paz.
|
||||
|
||||
Um certificado guardado só é anunciado (`a=`) enquanto for válido para o logo
|
||||
servido. O certificado atesta um SVG exato, então trocar o logo o encerra, e um
|
||||
registro que continuasse apontando para ele faria todo receptor rejeitar o BIMI,
|
||||
inclusive os que mostrariam o logo sem certificado. O registro então volta a só
|
||||
`l=`, e o console diz por que o certificado foi retido. O painel, a verificação e
|
||||
o reconciliador tomam essa decisão da mesma função (`bimi.Publish`), então não
|
||||
podem discordar sobre ela.
|
||||
|
||||
Uma foto de perfil alterada aparece na hora em todo o webmail: cada foto carrega
|
||||
uma versão que é incrementada ao salvar, nesta aba e nas outras. O servidor deixa
|
||||
o navegador reaproveitar uma por um minuto e depois revalida.
|
||||
|
||||
### DANE
|
||||
|
||||
Desligado por padrão, e vale entender antes de ligar: um registro TLSA que não
|
||||
bate com a chave em uso não degrada a entrega, ele a **interrompe** em todo
|
||||
remetente que valida. Duas coisas precisam ser verdade primeiro.
|
||||
|
||||
- **DNSSEC na zona, com o registro DS publicado no registrador.** O DANE confia
|
||||
no registro TLSA porque o DNSSEC o assina; sem a cadeia não há o que confiar, e
|
||||
os validadores tratam o registro como ausente.
|
||||
- **`TLS_MODE=acme`.** O LambdaMail precisa ser dono da chave para publicar a
|
||||
associação da *próxima* antes de trocar para ela. Com `TLS_MODE=traefik` o
|
||||
proxy renova no próprio ritmo e o registro deixaria de bater em silêncio.
|
||||
|
||||
Então defina `OUTBOUND_DANE_ENABLED=true`. O gerenciador publica `3 1 1` —
|
||||
DANE-EE sobre o SubjectPublicKeyInfo, o único uso que um MTA receptor deveria
|
||||
oferecer — e cuida da troca publicando a associação atual e a próxima juntas,
|
||||
esperando a propagação antes de emitir contra a chave nova.
|
||||
|
||||
`/admin/security` mostra o digest do certificado que está de fato em uso. Esse
|
||||
número e o registro TLSA no DNS precisam ser iguais; quando divergem, o e-mail é
|
||||
recusado e mais nada informa o motivo:
|
||||
|
||||
```bash
|
||||
dig +short TLSA _25._tcp.mail.exemplo.com
|
||||
```
|
||||
|
||||
## Operação
|
||||
|
||||
| | |
|
||||
| :--- | :--- |
|
||||
| `make preflight` | Saída pela porta 25, PTR e FCrDNS, listagens em RBLs, escopo do token da Cloudflare, presença do certificado |
|
||||
| `make create-admin EMAIL=… PASSWORD='…'` | Primeiro administrador numa instalação nova |
|
||||
| `make reset-password EMAIL=… PASSWORD='…'` | Recupera uma conta bloqueada e encerra as sessões dela |
|
||||
| `docker compose logs -f protocols` | Entrega, TLS e reconciliação de DNS |
|
||||
| `GET /health` no `protocols` | `200` saudável, `200` com `X-LambdaMail-Health: degraded` quando um certificado precisa de atenção, `503` quando uma dependência caiu |
|
||||
|
||||
O [docs/OPERATIONS.md](docs/OPERATIONS.md) cobre backups, rotação de
|
||||
certificados, o que significam os alertas e como ler uma falha de entrega.
|
||||
|
||||
## O que não está pronto
|
||||
|
||||
Dito com franqueza, porque um servidor de e-mail que funciona pela metade perde
|
||||
e-mail:
|
||||
|
||||
- **Painéis do console somente de leitura.** O checklist guiado de entrada de
|
||||
domínio relata o estado em vez de conduzi-lo.
|
||||
- **Anexos do editor.** O seletor lista os arquivos, mas nada é enviado, e o app
|
||||
avisa isso na tela. Ler e baixar os anexos recebidos funciona.
|
||||
- **Aprendizado do filtro de spam.** "Não é spam" para de condenar aquele
|
||||
remetente, mas o Rspamd não aprende com isso, então e-mails parecidos de
|
||||
desconhecidos são julgados como antes.
|
||||
- **As conversas são agrupadas na entrega.** E-mails antigos são agrupados por
|
||||
assunto, e só onde existe uma resposta; uma conversa cujas respostas perderam o
|
||||
`References` e o assunto pode aparecer em linhas separadas.
|
||||
- **Não implementado.** IMAP `APPEND`, JMAP, CalDAV/CardDAV, multi-nó. Os backups
|
||||
estão documentados, mas não automatizados.
|
||||
- **DANE** vem desligado. Veja [DANE](#dane) para ligá-lo; com
|
||||
`TLS_MODE=traefik` ele fica desligado de propósito, porque a chave do proxy muda
|
||||
a cada renovação e o registro TLSA publicado deixa de bater.
|
||||
|
||||
## Contribuindo
|
||||
|
||||
- Todo o código, os comentários e os identificadores são em inglês. O
|
||||
`make lint` garante isso (e, no código, só ASCII: escreva `é`, não uma
|
||||
letra acentuada).
|
||||
- As cores vêm das variáveis do tema em `globals.css`, nunca de um valor literal,
|
||||
e uma nova é definida nos dois temas; veja
|
||||
[docs/WEBMAIL.md](docs/WEBMAIL.md#the-colour-system). O logo é gerado por
|
||||
[`scripts/brand/`](scripts/brand/README.md).
|
||||
- Migrations novas são numeradas em `migrations/` e rodam no deploy. O código de
|
||||
entrega não pode depender de uma que talvez não tenha rodado: os testes de ponta
|
||||
a ponta aplicam só a primeira.
|
||||
- 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
|
||||
|
||||
MIT — veja [LICENSE](LICENSE).
|
||||
@@ -60,7 +60,7 @@ services:
|
||||
# probe - a health check on a container that is meant to stop would report
|
||||
# unhealthy exactly when it has done its job.
|
||||
migrate:
|
||||
image: ${IMAGE_NAMESPACE:-ghcr.io/lucasrguerra/lambdamail}/migrations:${IMAGE_TAG:-latest}
|
||||
image: ${IMAGE_NAMESPACE:-git.lucasrguerra.dev.br/lucasrguerra/lambdamail}/migrations:${IMAGE_TAG:-latest}
|
||||
# "latest" is a moving tag and Docker considers a tag it already holds
|
||||
# locally to be satisfied, so recreating this container silently reused
|
||||
# the cached image and the newly published one was never fetched: the
|
||||
@@ -80,7 +80,7 @@ services:
|
||||
|
||||
# ------------------------------------------------------------ RSPAMD
|
||||
rspamd:
|
||||
image: ${IMAGE_NAMESPACE:-ghcr.io/lucasrguerra/lambdamail}/rspamd:${IMAGE_TAG:-latest}
|
||||
image: ${IMAGE_NAMESPACE:-git.lucasrguerra.dev.br/lucasrguerra/lambdamail}/rspamd:${IMAGE_TAG:-latest}
|
||||
# "latest" is a moving tag and Docker considers a tag it already holds
|
||||
# locally to be satisfied, so recreating this container silently reused
|
||||
# the cached image and the newly published one was never fetched: the
|
||||
@@ -140,7 +140,7 @@ services:
|
||||
|
||||
# -------------------------------------------------------------- AUTH
|
||||
auth:
|
||||
image: ${IMAGE_NAMESPACE:-ghcr.io/lucasrguerra/lambdamail}/auth:${IMAGE_TAG:-latest}
|
||||
image: ${IMAGE_NAMESPACE:-git.lucasrguerra.dev.br/lucasrguerra/lambdamail}/auth:${IMAGE_TAG:-latest}
|
||||
# "latest" is a moving tag and Docker considers a tag it already holds
|
||||
# locally to be satisfied, so recreating this container silently reused
|
||||
# the cached image and the newly published one was never fetched: the
|
||||
@@ -178,7 +178,7 @@ services:
|
||||
|
||||
# ----------------------------------------------------------- STORAGE
|
||||
storage:
|
||||
image: ${IMAGE_NAMESPACE:-ghcr.io/lucasrguerra/lambdamail}/storage:${IMAGE_TAG:-latest}
|
||||
image: ${IMAGE_NAMESPACE:-git.lucasrguerra.dev.br/lucasrguerra/lambdamail}/storage:${IMAGE_TAG:-latest}
|
||||
# "latest" is a moving tag and Docker considers a tag it already holds
|
||||
# locally to be satisfied, so recreating this container silently reused
|
||||
# the cached image and the newly published one was never fetched: the
|
||||
@@ -214,7 +214,7 @@ services:
|
||||
|
||||
# --------------------------------------------------------- PROTOCOLS
|
||||
protocols:
|
||||
image: ${IMAGE_NAMESPACE:-ghcr.io/lucasrguerra/lambdamail}/protocols:${IMAGE_TAG:-latest}
|
||||
image: ${IMAGE_NAMESPACE:-git.lucasrguerra.dev.br/lucasrguerra/lambdamail}/protocols:${IMAGE_TAG:-latest}
|
||||
# "latest" is a moving tag and Docker considers a tag it already holds
|
||||
# locally to be satisfied, so recreating this container silently reused
|
||||
# the cached image and the newly published one was never fetched: the
|
||||
@@ -297,7 +297,7 @@ services:
|
||||
|
||||
# -------------------------------------------------------------- WEB
|
||||
web:
|
||||
image: ${IMAGE_NAMESPACE:-ghcr.io/lucasrguerra/lambdamail}/webmail:${IMAGE_TAG:-latest}
|
||||
image: ${IMAGE_NAMESPACE:-git.lucasrguerra.dev.br/lucasrguerra/lambdamail}/webmail:${IMAGE_TAG:-latest}
|
||||
# "latest" is a moving tag and Docker considers a tag it already holds
|
||||
# locally to be satisfied, so recreating this container silently reused
|
||||
# the cached image and the newly published one was never fetched: the
|
||||
|
||||
@@ -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 GHCR 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
|
||||
@@ -198,7 +211,7 @@ every release, which is what forces the pull.
|
||||
IMAGE_TAG=sha-<full commit sha>
|
||||
|
||||
The tags are published by `release.yml`; `docker buildx imagetools inspect
|
||||
ghcr.io/<owner>/lambdamail/webmail:latest` shows what `latest` currently points
|
||||
git.lucasrguerra.dev.br/<owner>/lambdamail/webmail:latest` shows what `latest` currently points
|
||||
at.
|
||||
|
||||
**Pinning has a second edge: bump it on every release.** A pinned tag is an
|
||||
@@ -218,6 +231,19 @@ IMAGE_TAG=sha-$(git rev-parse HEAD)
|
||||
# 2. then deploy
|
||||
```
|
||||
|
||||
On Coolify the two steps are two API calls, and the order matters: set the
|
||||
variable, then deploy, and only after the **Release workflow has finished green**
|
||||
(a deploy while it is still running pulls the previous images):
|
||||
|
||||
```bash
|
||||
# 1. point the tag at the commit that was released
|
||||
curl -X PATCH "$COOLIFY_URL/api/v1/applications/$APP_UUID/envs" \
|
||||
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
|
||||
-d "{\"key\":\"IMAGE_TAG\",\"value\":\"sha-$(git rev-parse HEAD)\"}"
|
||||
# 2. then deploy (a POST; a GET answers 405)
|
||||
curl -X POST "$COOLIFY_URL/api/v1/deploy?uuid=$APP_UUID" -H "Authorization: Bearer $TOKEN"
|
||||
```
|
||||
|
||||
Confirm which one is actually serving before believing a green deployment:
|
||||
|
||||
```bash
|
||||
@@ -225,7 +251,42 @@ curl -s https://<WEBMAIL_HOST>/user/login | grep -o '/_next/static/css/[a-z0-9]*
|
||||
```
|
||||
|
||||
The asset fingerprint changes with every build. If it did not move, the old
|
||||
image is still running whatever the deployment log said.
|
||||
image is still running whatever the deployment log said. A route that exists
|
||||
only in the new version is a surer check: an unauthenticated request to
|
||||
`/api/v1/mail/threads` answers `401` on a version that has conversations and
|
||||
`404` on one that does not.
|
||||
|
||||
### Upgrading to sessions, accounts, trusted senders and conversations
|
||||
|
||||
Three migrations run on the first start (the `migrate` service runs them before
|
||||
anything that needs them):
|
||||
|
||||
| | Adds |
|
||||
| :--- | :--- |
|
||||
| `0016` | `trusted_senders` |
|
||||
| `0017` | The SQL subject helpers and a one-time grouping of older mail into conversations, by subject and only where a reply exists; an index for the conversation list |
|
||||
| `0018` | `mailboxes.conversation_view` (on by default) |
|
||||
|
||||
They are additive and idempotent in effect; on a large mailbox `0017`'s grouping
|
||||
reads the whole `email_messages` table once. Nothing needs configuring.
|
||||
|
||||
What people will notice the first time:
|
||||
|
||||
- Sessions opened before the upgrade keep their old 8-hour expiry and are
|
||||
replaced by 30-day ones at the next sign-in. A session that began before
|
||||
accounts existed is added to the account switcher automatically.
|
||||
- Trusted-sender choices a person made in a browser are moved to the server the
|
||||
first time the webmail loads there.
|
||||
- Right after a deploy, while the proxy is swapping containers, one page load
|
||||
can meet a script from the other version. This was seen once, as a React
|
||||
hydration error in the browser console on the sign-in page, and did not come
|
||||
back on the next loads. If a page misbehaves in the first minute, reload it.
|
||||
|
||||
Nothing about the upgrade needs new environment variables or ports. The webmail
|
||||
now serves `/manifest.webmanifest`, `/icon.svg`, `/favicon.ico` and
|
||||
`/icons/*`; they are public, like the sign-in page, and need no proxy rule
|
||||
beyond the one already sending the host to the web service. Installing the
|
||||
webmail as an app needs HTTPS, which the deployment already has.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
|
||||
@@ -12,6 +12,9 @@ the rules and the data code; none of them has its own copy.
|
||||
| Credentials (password, app password, scopes) | `usecase/credentials.go` |
|
||||
| Sending, and the copy filed in Sent | `ProcessOutboundEmail.Submit` (`FileSentCopy`) |
|
||||
| Default folders of a mailbox | SQL function `provision_default_folders` (migration 0014) |
|
||||
| Which conversation a message joins | `resolveThread`, called by `persistOne` (`conversation_thread.go`), at delivery |
|
||||
| Conversation lists, answered state | `conversations.go` (read side only) |
|
||||
| Whether a trusted sender's mail skips Junk | `usecase/trusted_delivery.go`; the list is `trusted_senders` |
|
||||
|
||||
The IMAP repositories, the webmail repository and the TS auth service are thin
|
||||
adapters: they translate their protocol and call the code above.
|
||||
@@ -45,12 +48,41 @@ operation must follow the same order.
|
||||
2. Make the adapters call it.
|
||||
3. Add it to the contract test and run it through both paths.
|
||||
|
||||
## Conversations
|
||||
|
||||
Every message filed through `persistOne` - delivery, the Sent copy, a draft -
|
||||
gets a `thread_id` and an `in_reply_to`. The order that decides it is in
|
||||
[docs/WEBMAIL.md](WEBMAIL.md#conversations). The invariants:
|
||||
|
||||
- `thread_id` NULL means "a conversation of its own" and is read as
|
||||
`COALESCE(thread_id, id)`. Nothing may assume it is set.
|
||||
- A **copy** keeps its source's conversation (`copyOne` copies
|
||||
`COALESCE(thread_id, id)`), or the same message in two folders would be two
|
||||
conversations.
|
||||
- Delivery computes the subject key in Go and does **not** call the SQL helpers
|
||||
from migration 0017: the end-to-end tests apply only migration 0001, and
|
||||
delivery must work on a schema that has not been migrated yet.
|
||||
`TestGoAndSQLAgreeOnTheSubjectKey` keeps the Go and SQL spellings equal.
|
||||
- Reading a conversation is scoped by mailbox in the SQL, and another mailbox's
|
||||
conversation id answers "not found" exactly like one that does not exist.
|
||||
|
||||
`conversation_thread_test.go` runs each of these against Postgres.
|
||||
|
||||
## Migration 0014
|
||||
|
||||
Creates `provision_default_folders`, creates the missing default folders of
|
||||
existing mailboxes, and recomputes folder counters and `used_bytes` from the
|
||||
messages. It is idempotent and runs with the other migrations at deploy.
|
||||
|
||||
## Migrations after 0014
|
||||
|
||||
| | |
|
||||
| :--- | :--- |
|
||||
| 0015 | BIMI certificate |
|
||||
| 0016 | `trusted_senders` |
|
||||
| 0017 | Conversation grouping: `lm_subject_key`, `lm_has_reply_prefix`, a one-time backfill by subject, and the index the list uses |
|
||||
| 0018 | `mailboxes.conversation_view` |
|
||||
|
||||
## Not done
|
||||
|
||||
IMAP `APPEND` is still a stub. When it is implemented, drop a client-appended
|
||||
|
||||
@@ -132,6 +132,47 @@ DELETE FROM mfa_totp WHERE mailbox_id = (SELECT id FROM mailboxes WHERE email_ad
|
||||
DELETE FROM mfa_recovery_codes WHERE mailbox_id = (SELECT id FROM mailboxes WHERE email_address = 'them@example.com');
|
||||
```
|
||||
|
||||
### Sessions
|
||||
|
||||
A webmail session lasts 30 days and is renewed once it is a day old; the
|
||||
console's lasts 8 hours and is never renewed. The sessions a person holds are
|
||||
listed in Settings, where each can be signed out; a password change or reset
|
||||
revokes every other one, and a reset revokes all of them. To end every session
|
||||
of an account from the database:
|
||||
|
||||
```sql
|
||||
UPDATE web_sessions SET revoked_at = NOW()
|
||||
WHERE mailbox_id = (SELECT id FROM mailboxes WHERE email_address = 'user@example.com')
|
||||
AND revoked_at IS NULL;
|
||||
```
|
||||
|
||||
Renewal retires the old token after a two-minute grace rather than at once
|
||||
(its `revoked_at` is briefly in the future), so a session list can show two rows
|
||||
for a moment after a renewal. Rotating `JWT_SECRET` signs everyone out, webmail
|
||||
and console alike.
|
||||
|
||||
Each account signed in on a browser also has a `lm_acct_<mailbox id>` cookie.
|
||||
They are only the browser's own copy of its sessions: revoking a session on the
|
||||
server makes that account drop out of the switcher the next time it is listed.
|
||||
|
||||
### Trusted senders
|
||||
|
||||
`trusted_senders (mailbox_id, address)` holds what each person has trusted. It
|
||||
is part of the database backup and nothing else needs backing up for it. To see
|
||||
or remove a mailbox's entries:
|
||||
|
||||
```sql
|
||||
SELECT address, created_at FROM trusted_senders
|
||||
WHERE mailbox_id = (SELECT id FROM mailboxes WHERE email_address = 'user@example.com');
|
||||
DELETE FROM trusted_senders WHERE mailbox_id = '<id>' AND address = 'x@example.org';
|
||||
```
|
||||
|
||||
A trusted sender's mail is rescued from Junk only when it proves where it came
|
||||
from (DMARC passed, or SPF passed for the From domain); see
|
||||
[docs/WEBMAIL.md](WEBMAIL.md#trusted-senders). If a trusted sender's mail still
|
||||
lands in Junk, look at the `Authentication-Results` header on it: a message
|
||||
without that proof is deliberately left in Junk.
|
||||
|
||||
## Delivery
|
||||
|
||||
The outbound queue lives in Postgres, so it survives a restart of anything. The
|
||||
@@ -175,10 +216,15 @@ disagree — most often because a record was edited by hand.
|
||||
Rspamd holds its state in Redis and its thresholds in `system_config`, editable
|
||||
from the admin console. The defaults reject at 15, tag at 6 and greylist at 4.
|
||||
|
||||
A message the filter routes to `Junk` is filed there; one over the reject
|
||||
threshold is refused during the SMTP session, so the sender is told. Nothing is
|
||||
accepted and silently discarded — a silent discard loses legitimate mail with
|
||||
nobody aware of it.
|
||||
A message the filter routes to `Junk` is filed there, unless the mailbox trusts
|
||||
the sender and the message proves it came from them (see
|
||||
[Trusted senders](#trusted-senders)); one over the reject threshold is refused
|
||||
during the SMTP session, so the sender is told. Nothing is accepted and
|
||||
silently discarded — a silent discard loses legitimate mail with nobody aware
|
||||
of it.
|
||||
|
||||
"Not spam" in the webmail trusts the sender; it does not train Rspamd. To teach
|
||||
the filter, use Rspamd's own learning (`rspamc learn_ham`) or its web interface.
|
||||
|
||||
## Rotating a DKIM key
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,272 @@
|
||||
# The webmail
|
||||
|
||||
What the webmail at `/user/*` does, how each part behaves, and where it lives in
|
||||
the code. The README has the overview; this is the reference.
|
||||
|
||||
- [Signing in, and staying signed in](#signing-in-and-staying-signed-in)
|
||||
- [Several accounts at once](#several-accounts-at-once)
|
||||
- [Trusted senders](#trusted-senders)
|
||||
- [Conversations](#conversations)
|
||||
- [Reading a message](#reading-a-message)
|
||||
- [Writing a message](#writing-a-message)
|
||||
- [Appearance, the command menu and shortcuts](#appearance-the-command-menu-and-shortcuts)
|
||||
- [For developers](#for-developers)
|
||||
|
||||
## Signing in, and staying signed in
|
||||
|
||||
A webmail session lasts **30 days and is renewed while it is used**: once a
|
||||
session token is a day old, the next request swaps it for a fresh one, so a
|
||||
mailbox opened every few days never asks for the password again. The admin
|
||||
console is deliberately different: **8 hours, fixed**, with a step-up second
|
||||
factor for destructive actions. The two surfaces keep separate cookies
|
||||
(`lm_user_session`, `lm_admin_session`) and separate token audiences.
|
||||
|
||||
The lifetimes are constants in `services/auth/src/accounts.ts`
|
||||
(`USER_SESSION_TTL_SECONDS`, `ADMIN_SESSION_TTL_SECONDS`, `RENEW_AFTER_SECONDS`).
|
||||
They are not environment variables on purpose: a lifetime that can be set to
|
||||
"never" is a setting that will be.
|
||||
|
||||
How renewal works, because it explains what you see in the sessions list:
|
||||
|
||||
- A token carries its own expiry and cannot be extended, so renewal issues a
|
||||
**new token and a new row** in `web_sessions`.
|
||||
- The old token is retired after a **two-minute grace** (`revoked_at` set in the
|
||||
future), so a request already in flight with it does not come back `401`.
|
||||
- Only the cookie is renewed. A bearer token has nowhere to receive a new one.
|
||||
- Signing out, changing the password or resetting it revokes immediately, with
|
||||
no grace. "Sign out other devices" in Settings revokes every other session.
|
||||
|
||||
Sessions that began before 30-day sessions existed keep their 8-hour expiry;
|
||||
they are replaced by 30-day ones the next time the person signs in.
|
||||
|
||||
## Several accounts at once
|
||||
|
||||
Each webmail sign-in is also filed under its own cookie,
|
||||
`lm_acct_<mailbox id>`, next to `lm_user_session`. **`lm_user_session` is always
|
||||
the active account**, which is why the Go service, the Next middleware and every
|
||||
route that existed before accounts needed no change.
|
||||
|
||||
| | |
|
||||
| :--- | :--- |
|
||||
| `GET /api/v1/auth/accounts` | The accounts signed in on this browser whose sessions are still good, and which one is active. A session that predates account cookies is listed too, and filed on the spot. |
|
||||
| `POST /api/v1/auth/switch` `{ "id" }` | Makes one of them active. Refuses an id that is not signed in on this browser. |
|
||||
| `POST /api/v1/auth/logout` `{ "all"?: true }` | Signs out the **active** account only; the next one becomes active. `all: true` signs out everything on this browser. |
|
||||
|
||||
Add an account from the menu at the foot of the sidebar ("Add another
|
||||
account"): it opens the normal sign-in, and the new account joins the others.
|
||||
Switching reloads the page, so nothing from the previous account stays in
|
||||
memory. Each account adds about half a kilobyte of cookie; a handful is fine,
|
||||
and a reverse proxy with a very small header limit is the only thing that would
|
||||
notice more.
|
||||
|
||||
## Trusted senders
|
||||
|
||||
One decision, two effects. Trusting a sender means:
|
||||
|
||||
1. **Their mail is not filed as spam.** If Rspamd's verdict is Junk, delivery
|
||||
files the message in the inbox instead.
|
||||
2. **Their remote images load without asking.**
|
||||
|
||||
The list is kept **on the server**, per mailbox, in `trusted_senders`
|
||||
(migration 0016): it follows the account to every browser. What an earlier
|
||||
version remembered in one browser's storage is moved up the first time the
|
||||
webmail loads, and then cleared.
|
||||
|
||||
How to use it: the shield pill beside the sender's name trusts or untrusts, and
|
||||
in the Spam folder a banner offers **Not spam**, which moves the message to the
|
||||
inbox and trusts the sender in one step.
|
||||
|
||||
**The rescue has a condition, and it is the point.** A From header is the
|
||||
easiest field to forge, so trusting an address would otherwise hand a spammer a
|
||||
way in by writing a contact's address on their mail. Delivery only rescues a
|
||||
message when it proves where it came from:
|
||||
|
||||
- DMARC passed, **or**
|
||||
- SPF passed for the very domain written in From (the envelope sender's domain
|
||||
equals the From domain).
|
||||
|
||||
Anything else stays in Junk. Also:
|
||||
|
||||
- Only the **Junk** verdict is rescued. A message the filter *rejects*, a virus
|
||||
and a greylist deferral are not affected.
|
||||
- The mailbox's own Sieve rules run after the rescue and have the last word.
|
||||
- A failed lookup leaves the message in Junk.
|
||||
- The list holds at most 5000 addresses per mailbox.
|
||||
- Rspamd itself does **not** learn from "Not spam": the sender stops being
|
||||
condemned, but similar mail from strangers is still judged as before.
|
||||
|
||||
API: `GET`, `PUT` and `DELETE /api/v1/user/trusted-senders`.
|
||||
Code: `services/protocols/internal/application/usecase/trusted_delivery.go`.
|
||||
|
||||
## Conversations
|
||||
|
||||
On by default; one switch in **Settings → Language** turns it off for the account
|
||||
(`conversation_view` in preferences).
|
||||
|
||||
The list shows **one row per conversation**: who wrote, in order and with "me"
|
||||
for you, the newest message's subject and preview, the number of messages, an
|
||||
unread dot, and a reply arrow when you have answered. Opening one stacks its
|
||||
messages; the newest and any unread ones start open, the rest are a line each.
|
||||
The reply is written **under the conversation, in the same pane**, so the
|
||||
messages stay in view.
|
||||
|
||||
**How messages are grouped**, in this order, at delivery (`resolveThread` in
|
||||
`conversation_thread.go`):
|
||||
|
||||
1. A message already in the mailbox with the same `Message-ID` (your Sent copy
|
||||
and a copy delivered back to you are one conversation).
|
||||
2. The message it answers, through `In-Reply-To`, then `References` (nearest
|
||||
ancestor first).
|
||||
3. A subject beginning with `Re:`, `Fwd:` or a translation (`Res`, `Enc`, `RV`,
|
||||
`AW`, `WG`, `TR`, `Antw`), when the headers found nothing.
|
||||
4. Otherwise a conversation of its own.
|
||||
|
||||
`thread_id` NULL means "its own conversation" (`COALESCE(thread_id, id)`), so
|
||||
mail from before threading needs no rewrite and a reply simply adopts it.
|
||||
Migration 0017 grouped older mail **by subject, only where a reply is in the
|
||||
mailbox**; two unrelated messages that merely share a subject stay apart. The
|
||||
subject key exists twice, in Go for delivery and in SQL (`lm_subject_key`) for
|
||||
the backfill, and a test holds the two equal.
|
||||
|
||||
**What counts as "answered":** the last message someone else wrote has the
|
||||
`\Answered` flag (set when you reply from the webmail), **or** a message of
|
||||
yours came after it - which is how a reply written in another mail program
|
||||
shows up, since it leaves no flag.
|
||||
|
||||
A conversation seen from one folder includes its messages in the others, but
|
||||
**not Trash, Junk or Drafts** (a reply still being written, or one deleted, is
|
||||
not part of the thread you are reading) unless that is the folder you are in.
|
||||
Delete and Move on a conversation act on its messages that sit in the open
|
||||
folder.
|
||||
|
||||
API: `GET /api/v1/mail/threads?folder=&q=&limit=&offset=` and
|
||||
`GET /api/v1/mail/thread/{id}?folder=`. A server without conversations answers
|
||||
`501` and the webmail falls back to the plain list.
|
||||
|
||||
## Reading a message
|
||||
|
||||
The body is drawn in a **sandboxed frame** (`allow-scripts`, no
|
||||
`allow-same-origin`): an opaque origin that cannot read a cookie or touch the
|
||||
page, running only the few lines that report its height, open links in a new
|
||||
tab and fold quoted history. The HTML is sanitised first (DOMPurify; no script,
|
||||
form, iframe, `link`, `base`; `<style>` kept but stripped of `@import` and the
|
||||
legacy script-in-CSS forms).
|
||||
|
||||
**Two ways of drawing it**, chosen by `looksDesigned` in `src/lib/emailBody.ts`:
|
||||
|
||||
| | Looks like | Drawn |
|
||||
| :--- | :--- | :--- |
|
||||
| **Written** | A letter, a reply, a notice: no coloured backgrounds, no `bgcolor`, fewer than three tables. Plain white is not design (Outlook paints it everywhere). | In the app's own colours, on the page: light text on dark, dark on light, links in the accent. Text too dark for a dark ground is lifted and white backdrops are cleared. "View as sent" shows the original. |
|
||||
| **Designed** | A newsletter, a receipt: any coloured or image background, or a layout of tables. | On the white sheet it was drawn for, with rounded corners and a hairline. |
|
||||
|
||||
A reply's quoted history is folded behind a **···** button, as in Gmail, and
|
||||
opens in a quieter tone.
|
||||
|
||||
**Remote images are blocked by default**: a unique URL per recipient tells the
|
||||
sender the message was opened, when and from roughly where. A bar offers to load
|
||||
them for this message; trusting the sender loads them always. Images embedded in
|
||||
the message (`cid:` parts) are shown, because they make no request.
|
||||
|
||||
Attachments are listed with a type tag and downloaded from
|
||||
`/api/v1/mail/message/{uid}/attachment/{name}`.
|
||||
|
||||
## Writing a message
|
||||
|
||||
One composer, used twice: the full page at `/user/compose` and the inline reply
|
||||
under a conversation (`src/components/Composer.tsx`).
|
||||
|
||||
- **Drafts autosave** 2.5 seconds after you stop typing and are replaced, not
|
||||
duplicated. **Discard** removes the stored draft for good (it does not go to
|
||||
Trash) and stops a save still in flight.
|
||||
- **Undo send:** after Send the message waits ten seconds; **Undo** cancels it.
|
||||
Leaving the screen with a send pending sends it once rather than losing it.
|
||||
- The signature from Settings is inserted into a new message.
|
||||
- **Attachments are not sent yet.** The picker lists the names and says so; the
|
||||
server has no upload endpoint.
|
||||
|
||||
## Appearance, the command menu and shortcuts
|
||||
|
||||
**Two themes, dark by default.** Choose **Dark, Light or System** from the menu
|
||||
at the foot of the sidebar, in the admin console's sidebar, or on either
|
||||
sign-in page. The choice is stored **per browser** (a laptop in a bright office
|
||||
and a phone in bed are different answers). A script in `<head>` applies it
|
||||
before the first paint, so there is no flash of the wrong theme.
|
||||
|
||||
**Command menu: `Ctrl K` / `Cmd K`.** Folders, Compose, Settings, the admin
|
||||
console, switching accounts, adding one, appearance and sign out, all by typing.
|
||||
Text that matches no command offers to search the mail for it, so Enter is never
|
||||
a dead key. It is also reachable from the "Command menu" row in the sidebar.
|
||||
|
||||
| Key | |
|
||||
| :--- | :--- |
|
||||
| `Ctrl K` / `Cmd K` | Open or close the command menu |
|
||||
| `c` | Write a new message |
|
||||
| `/` | Go to the search field |
|
||||
| `Up` / `Down`, `Enter`, `Esc` | Move in the menu, run, close |
|
||||
|
||||
`c` and `/` do nothing while you are typing in a field.
|
||||
|
||||
**Install as an app.** The webmail serves a web manifest and icons, so a browser
|
||||
can install it into its own window (over HTTPS, from the browser's install
|
||||
control). The tab icon, home-screen icon and install icons are all the same mark.
|
||||
|
||||
Fonts (Geist) are **served by the app itself**: opening your mail makes no
|
||||
request to a font host.
|
||||
|
||||
## For developers
|
||||
|
||||
### Where things are
|
||||
|
||||
| | |
|
||||
| :--- | :--- |
|
||||
| Sessions, accounts, trusted-senders API | `services/auth/src/router.ts`, `accounts.ts`, `repository.ts` |
|
||||
| Conversation reads, answered logic | `services/protocols/.../infrastructure/postgres/conversations.go` |
|
||||
| Conversation assignment at delivery | `.../postgres/conversation_thread.go` |
|
||||
| Spam rescue for trusted senders | `.../application/usecase/trusted_delivery.go` |
|
||||
| Reader, thread reader, composer | `services/webmail/src/components/MessageReader.tsx`, `ThreadReader.tsx`, `Composer.tsx` |
|
||||
| Message document and mode | `services/webmail/src/lib/emailBody.ts`, `renderMessage.ts`, `sanitizer.ts` |
|
||||
| Command menu | `services/webmail/src/components/CommandPalette.tsx` |
|
||||
| Theme | `services/webmail/src/lib/theme.ts`, `src/app/globals.css`, `tailwind.config.js` |
|
||||
|
||||
### The colour system
|
||||
|
||||
Every colour is a CSS variable defined **once per theme** in `globals.css`
|
||||
(`[data-theme='dark']` and `[data-theme='light']`). The Tailwind scales the
|
||||
screens already use - `slate` (neutrals), `indigo` (the accent), `amber`
|
||||
(warning), `rose` (danger), `emerald` (done, trusted, answered) - read those
|
||||
variables, so a theme is a change of values and never a change of classes.
|
||||
|
||||
The ramps run the same direction in both themes: **100 is the text you read,
|
||||
950 the ground it sits on, 400 the quieter text, 700 a border.** That is why
|
||||
`text-slate-400` is correct in light and in dark. Three more tokens:
|
||||
|
||||
- `ink` is the colour that sits on the ground (white on dark, near-black on
|
||||
light): hover tints and hairlines are `ink` at low opacity, never `white/..`.
|
||||
- `fill` is the solid fill of the one main action on a screen (`bg-fill`).
|
||||
- `rail` is the sidebar's ground.
|
||||
|
||||
To add a colour, add the variable in **both** blocks. `theme-integrity.test.ts`
|
||||
fails if the two blocks define different variables, if a component tints with
|
||||
`white/..`, or if the boot script stops defaulting to dark.
|
||||
|
||||
### The mark
|
||||
|
||||
A lambda built as two square-cut strokes of one weight on a flat ultramarine
|
||||
tile with continuous corners. The geometry and the PNG/ICO renderer are in
|
||||
[`scripts/brand/`](../scripts/brand/README.md); `Logo.tsx` carries the same path
|
||||
inline. Change the number in `mark.py`, never the SVG.
|
||||
|
||||
### Adding text
|
||||
|
||||
Strings live in `services/webmail/messages/{en,pt-BR,es}.json` and are read with
|
||||
`t("section.key")`. A test requires `pt-BR.json` and `es.json` to have exactly
|
||||
the keys of `en.json`, so a string added in one language and forgotten in
|
||||
another fails the build.
|
||||
|
||||
### Testing it
|
||||
|
||||
`services/webmail` has no browser test runner; the suites that guard this are
|
||||
file-level and logic-level (`vitest`), and the screens were checked in a real
|
||||
browser. To look at the reader or a theme change locally without the mail
|
||||
services, point `AUTH_SERVICE_URL` and `PROTOCOLS_SERVICE_URL` at a small stub
|
||||
that serves the JSON shapes above and run `next dev`.
|
||||
|
After Width: | Height: | Size: 190 KiB |
|
After Width: | Height: | Size: 198 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
|
||||
<!-- a lambda built as two square-cut strokes, on a tile with continuous corners -->
|
||||
<path d="M64.00 32.00 L64.00 38.34 L63.99 40.36 L63.98 41.83 L63.97 43.03 L63.95 44.06 L63.93 44.97 L63.90 45.79 L63.88 46.54 L63.84 47.24 L63.80 47.89 L63.76 48.50 L63.72 49.07 L63.67 49.62 L63.62 50.14 L63.56 50.64 L63.50 51.11 L63.43 51.57 L63.36 52.01 L63.29 52.43 L63.21 52.83 L63.13 53.23 L63.05 53.61 L62.96 53.97 L62.86 54.33 L62.77 54.67 L62.66 55.01 L62.56 55.33 L62.45 55.65 L62.33 55.95 L62.21 56.25 L62.09 56.54 L61.96 56.82 L61.83 57.10 L61.69 57.36 L61.55 57.62 L61.40 57.87 L61.25 58.12 L61.09 58.36 L60.93 58.59 L60.76 58.81 L60.59 59.04 L60.42 59.25 L60.24 59.46 L60.05 59.66 L59.86 59.86 L59.66 60.05 L59.46 60.24 L59.25 60.42 L59.04 60.59 L58.81 60.76 L58.59 60.93 L58.36 61.09 L58.12 61.25 L57.87 61.40 L57.62 61.55 L57.36 61.69 L57.10 61.83 L56.82 61.96 L56.54 62.09 L56.25 62.21 L55.95 62.33 L55.65 62.45 L55.33 62.56 L55.01 62.66 L54.67 62.77 L54.33 62.86 L53.97 62.96 L53.61 63.05 L53.23 63.13 L52.83 63.21 L52.43 63.29 L52.01 63.36 L51.57 63.43 L51.11 63.50 L50.64 63.56 L50.14 63.62 L49.62 63.67 L49.07 63.72 L48.50 63.76 L47.89 63.80 L47.24 63.84 L46.54 63.88 L45.79 63.90 L44.97 63.93 L44.06 63.95 L43.03 63.97 L41.83 63.98 L40.36 63.99 L38.34 64.00 L32.00 64.00 L25.66 64.00 L23.64 63.99 L22.17 63.98 L20.97 63.97 L19.94 63.95 L19.03 63.93 L18.21 63.90 L17.46 63.88 L16.76 63.84 L16.11 63.80 L15.50 63.76 L14.93 63.72 L14.38 63.67 L13.86 63.62 L13.36 63.56 L12.89 63.50 L12.43 63.43 L11.99 63.36 L11.57 63.29 L11.17 63.21 L10.77 63.13 L10.39 63.05 L10.03 62.96 L9.67 62.86 L9.33 62.77 L8.99 62.66 L8.67 62.56 L8.35 62.45 L8.05 62.33 L7.75 62.21 L7.46 62.09 L7.18 61.96 L6.90 61.83 L6.64 61.69 L6.38 61.55 L6.13 61.40 L5.88 61.25 L5.64 61.09 L5.41 60.93 L5.19 60.76 L4.96 60.59 L4.75 60.42 L4.54 60.24 L4.34 60.05 L4.14 59.86 L3.95 59.66 L3.76 59.46 L3.58 59.25 L3.41 59.04 L3.24 58.81 L3.07 58.59 L2.91 58.36 L2.75 58.12 L2.60 57.87 L2.45 57.62 L2.31 57.36 L2.17 57.10 L2.04 56.82 L1.91 56.54 L1.79 56.25 L1.67 55.95 L1.55 55.65 L1.44 55.33 L1.34 55.01 L1.23 54.67 L1.14 54.33 L1.04 53.97 L0.95 53.61 L0.87 53.23 L0.79 52.83 L0.71 52.43 L0.64 52.01 L0.57 51.57 L0.50 51.11 L0.44 50.64 L0.38 50.14 L0.33 49.62 L0.28 49.07 L0.24 48.50 L0.20 47.89 L0.16 47.24 L0.12 46.54 L0.10 45.79 L0.07 44.97 L0.05 44.06 L0.03 43.03 L0.02 41.83 L0.01 40.36 L0.00 38.34 L0.00 32.00 L0.00 25.66 L0.01 23.64 L0.02 22.17 L0.03 20.97 L0.05 19.94 L0.07 19.03 L0.10 18.21 L0.12 17.46 L0.16 16.76 L0.20 16.11 L0.24 15.50 L0.28 14.93 L0.33 14.38 L0.38 13.86 L0.44 13.36 L0.50 12.89 L0.57 12.43 L0.64 11.99 L0.71 11.57 L0.79 11.17 L0.87 10.77 L0.95 10.39 L1.04 10.03 L1.14 9.67 L1.23 9.33 L1.34 8.99 L1.44 8.67 L1.55 8.35 L1.67 8.05 L1.79 7.75 L1.91 7.46 L2.04 7.18 L2.17 6.90 L2.31 6.64 L2.45 6.38 L2.60 6.13 L2.75 5.88 L2.91 5.64 L3.07 5.41 L3.24 5.19 L3.41 4.96 L3.58 4.75 L3.76 4.54 L3.95 4.34 L4.14 4.14 L4.34 3.95 L4.54 3.76 L4.75 3.58 L4.96 3.41 L5.19 3.24 L5.41 3.07 L5.64 2.91 L5.88 2.75 L6.13 2.60 L6.38 2.45 L6.64 2.31 L6.90 2.17 L7.18 2.04 L7.46 1.91 L7.75 1.79 L8.05 1.67 L8.35 1.55 L8.67 1.44 L8.99 1.34 L9.33 1.23 L9.67 1.14 L10.03 1.04 L10.39 0.95 L10.77 0.87 L11.17 0.79 L11.57 0.71 L11.99 0.64 L12.43 0.57 L12.89 0.50 L13.36 0.44 L13.86 0.38 L14.38 0.33 L14.93 0.28 L15.50 0.24 L16.11 0.20 L16.76 0.16 L17.46 0.12 L18.21 0.10 L19.03 0.07 L19.94 0.05 L20.97 0.03 L22.17 0.02 L23.64 0.01 L25.66 0.00 L32.00 0.00 L38.34 0.00 L40.36 0.01 L41.83 0.02 L43.03 0.03 L44.06 0.05 L44.97 0.07 L45.79 0.10 L46.54 0.12 L47.24 0.16 L47.89 0.20 L48.50 0.24 L49.07 0.28 L49.62 0.33 L50.14 0.38 L50.64 0.44 L51.11 0.50 L51.57 0.57 L52.01 0.64 L52.43 0.71 L52.83 0.79 L53.23 0.87 L53.61 0.95 L53.97 1.04 L54.33 1.14 L54.67 1.23 L55.01 1.34 L55.33 1.44 L55.65 1.55 L55.95 1.67 L56.25 1.79 L56.54 1.91 L56.82 2.04 L57.10 2.17 L57.36 2.31 L57.62 2.45 L57.87 2.60 L58.12 2.75 L58.36 2.91 L58.59 3.07 L58.81 3.24 L59.04 3.41 L59.25 3.58 L59.46 3.76 L59.66 3.95 L59.86 4.14 L60.05 4.34 L60.24 4.54 L60.42 4.75 L60.59 4.96 L60.76 5.19 L60.93 5.41 L61.09 5.64 L61.25 5.88 L61.40 6.13 L61.55 6.38 L61.69 6.64 L61.83 6.90 L61.96 7.18 L62.09 7.46 L62.21 7.75 L62.33 8.05 L62.45 8.35 L62.56 8.67 L62.66 8.99 L62.77 9.33 L62.86 9.67 L62.96 10.03 L63.05 10.39 L63.13 10.77 L63.21 11.17 L63.29 11.57 L63.36 11.99 L63.43 12.43 L63.50 12.89 L63.56 13.36 L63.62 13.86 L63.67 14.38 L63.72 14.93 L63.76 15.50 L63.80 16.11 L63.84 16.76 L63.88 17.46 L63.90 18.21 L63.93 19.03 L63.95 19.94 L63.97 20.97 L63.98 22.17 L63.99 23.64 L64.00 25.66Z" fill="#1B2BD0"/>
|
||||
<path transform="translate(-0.9 0)" d="M15.27 10.00 L23.13 10.00 L48.73 54.00 L40.87 54.00Z M29.96 28.49 L34.04 35.51 L24.80 54.00 L17.20 54.00Z" fill="#fff"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 219 KiB |
|
After Width: | Height: | Size: 230 KiB |
|
After Width: | Height: | Size: 217 KiB |
|
After Width: | Height: | Size: 226 KiB |
|
After Width: | Height: | Size: 214 KiB |
|
After Width: | Height: | Size: 224 KiB |
@@ -0,0 +1,15 @@
|
||||
# The LambdaMail mark
|
||||
|
||||
The logo is a lambda drawn as two straight strokes of one weight, each end cut
|
||||
square to the baseline, joined without a seam, on a flat ultramarine tile with
|
||||
continuous (superelliptic) corners. It is deliberately flat and has no rounded
|
||||
strokes: it has to hold at 16 pixels and on both themes.
|
||||
|
||||
`mark.py` holds the geometry. Change a number there, never the SVG by hand.
|
||||
|
||||
python3 scripts/brand/mark.py # writes icon.svg and the logo-*.svg files
|
||||
CHROME_PATH=/path/to/chrome node scripts/brand/icons.mjs # renders the PNGs and favicon.ico
|
||||
|
||||
`icons.mjs` needs `puppeteer-core` and a Chrome. The inline copy of the mark in
|
||||
`services/webmail/src/components/Logo.tsx` carries the same two paths: after a
|
||||
change, paste the `glyph:` line `mark.py` prints into `LAMBDA`.
|
||||
@@ -0,0 +1,26 @@
|
||||
import puppeteer from "puppeteer-core";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const out = path.join(here, "..", "..", "services", "webmail");
|
||||
const tile = fs.readFileSync(path.join(here, "logo.svg"), "utf8"), full = fs.readFileSync(path.join(here, "logo-full.svg"), "utf8"), safe = fs.readFileSync(path.join(here, "logo-safe.svg"), "utf8");
|
||||
const browser = await puppeteer.launch({ executablePath: process.env.CHROME_PATH, headless: true, args: ["--no-sandbox"] });
|
||||
const page = await browser.newPage();
|
||||
async function png(svg, size, file, transparent) {
|
||||
await page.setViewport({ width: size, height: size, deviceScaleFactor: 1 });
|
||||
await page.setContent(`<body style="margin:0;width:${size}px;height:${size}px;background:${transparent ? "transparent" : "#1B2BD0"}">${svg.replace('width="64" height="64"', `width="${size}" height="${size}"`)}</body>`);
|
||||
await page.screenshot({ path: file, omitBackground: transparent });
|
||||
}
|
||||
await png(full, 180, `${out}/src/app/apple-icon.png`, false);
|
||||
await png(tile, 192, `${out}/public/icons/icon-192.png`, true);
|
||||
await png(tile, 512, `${out}/public/icons/icon-512.png`, true);
|
||||
await png(safe, 512, `${out}/public/icons/maskable-512.png`, false);
|
||||
for (const s of [16, 32, 48]) await png(tile, s, path.join(here, `fav${s}.png`), true);
|
||||
await browser.close();
|
||||
const imgs = [16, 32, 48].map((s) => ({ s, data: fs.readFileSync(path.join(here, `fav${s}.png`)) }));
|
||||
const head = Buffer.alloc(6); head.writeUInt16LE(1, 2); head.writeUInt16LE(imgs.length, 4);
|
||||
let offset = 6 + 16 * imgs.length;
|
||||
const dir = Buffer.concat(imgs.map(({ s, data }) => { const e = Buffer.alloc(16); e[0] = s; e[1] = s; e.writeUInt16LE(1, 4); e.writeUInt16LE(32, 6); e.writeUInt32LE(data.length, 8); e.writeUInt32LE(offset, 12); offset += data.length; return e; }));
|
||||
fs.writeFileSync(`${out}/src/app/favicon.ico`, Buffer.concat([head, dir, ...imgs.map((i) => i.data)]));
|
||||
console.log("icons done");
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64"><rect width="64" height="64" fill="#1B2BD0"/><path transform="translate(-0.9 0)" d="M15.27 10.00 L23.13 10.00 L48.73 54.00 L40.87 54.00Z M29.96 28.49 L34.04 35.51 L24.80 54.00 L17.20 54.00Z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 293 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64"><rect width="64" height="64" fill="#1B2BD0"/><g transform="translate(32 32) scale(0.78) translate(-32 -32)"><path transform="translate(-0.9 0)" d="M15.27 10.00 L23.13 10.00 L48.73 54.00 L40.87 54.00Z M29.96 28.49 L34.04 35.51 L24.80 54.00 L17.20 54.00Z" fill="#fff"/></g></svg>
|
||||
|
After Width: | Height: | Size: 360 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
|
||||
<!-- a lambda built as two square-cut strokes, on a tile with continuous corners -->
|
||||
<path d="M64.00 32.00 L64.00 38.34 L63.99 40.36 L63.98 41.83 L63.97 43.03 L63.95 44.06 L63.93 44.97 L63.90 45.79 L63.88 46.54 L63.84 47.24 L63.80 47.89 L63.76 48.50 L63.72 49.07 L63.67 49.62 L63.62 50.14 L63.56 50.64 L63.50 51.11 L63.43 51.57 L63.36 52.01 L63.29 52.43 L63.21 52.83 L63.13 53.23 L63.05 53.61 L62.96 53.97 L62.86 54.33 L62.77 54.67 L62.66 55.01 L62.56 55.33 L62.45 55.65 L62.33 55.95 L62.21 56.25 L62.09 56.54 L61.96 56.82 L61.83 57.10 L61.69 57.36 L61.55 57.62 L61.40 57.87 L61.25 58.12 L61.09 58.36 L60.93 58.59 L60.76 58.81 L60.59 59.04 L60.42 59.25 L60.24 59.46 L60.05 59.66 L59.86 59.86 L59.66 60.05 L59.46 60.24 L59.25 60.42 L59.04 60.59 L58.81 60.76 L58.59 60.93 L58.36 61.09 L58.12 61.25 L57.87 61.40 L57.62 61.55 L57.36 61.69 L57.10 61.83 L56.82 61.96 L56.54 62.09 L56.25 62.21 L55.95 62.33 L55.65 62.45 L55.33 62.56 L55.01 62.66 L54.67 62.77 L54.33 62.86 L53.97 62.96 L53.61 63.05 L53.23 63.13 L52.83 63.21 L52.43 63.29 L52.01 63.36 L51.57 63.43 L51.11 63.50 L50.64 63.56 L50.14 63.62 L49.62 63.67 L49.07 63.72 L48.50 63.76 L47.89 63.80 L47.24 63.84 L46.54 63.88 L45.79 63.90 L44.97 63.93 L44.06 63.95 L43.03 63.97 L41.83 63.98 L40.36 63.99 L38.34 64.00 L32.00 64.00 L25.66 64.00 L23.64 63.99 L22.17 63.98 L20.97 63.97 L19.94 63.95 L19.03 63.93 L18.21 63.90 L17.46 63.88 L16.76 63.84 L16.11 63.80 L15.50 63.76 L14.93 63.72 L14.38 63.67 L13.86 63.62 L13.36 63.56 L12.89 63.50 L12.43 63.43 L11.99 63.36 L11.57 63.29 L11.17 63.21 L10.77 63.13 L10.39 63.05 L10.03 62.96 L9.67 62.86 L9.33 62.77 L8.99 62.66 L8.67 62.56 L8.35 62.45 L8.05 62.33 L7.75 62.21 L7.46 62.09 L7.18 61.96 L6.90 61.83 L6.64 61.69 L6.38 61.55 L6.13 61.40 L5.88 61.25 L5.64 61.09 L5.41 60.93 L5.19 60.76 L4.96 60.59 L4.75 60.42 L4.54 60.24 L4.34 60.05 L4.14 59.86 L3.95 59.66 L3.76 59.46 L3.58 59.25 L3.41 59.04 L3.24 58.81 L3.07 58.59 L2.91 58.36 L2.75 58.12 L2.60 57.87 L2.45 57.62 L2.31 57.36 L2.17 57.10 L2.04 56.82 L1.91 56.54 L1.79 56.25 L1.67 55.95 L1.55 55.65 L1.44 55.33 L1.34 55.01 L1.23 54.67 L1.14 54.33 L1.04 53.97 L0.95 53.61 L0.87 53.23 L0.79 52.83 L0.71 52.43 L0.64 52.01 L0.57 51.57 L0.50 51.11 L0.44 50.64 L0.38 50.14 L0.33 49.62 L0.28 49.07 L0.24 48.50 L0.20 47.89 L0.16 47.24 L0.12 46.54 L0.10 45.79 L0.07 44.97 L0.05 44.06 L0.03 43.03 L0.02 41.83 L0.01 40.36 L0.00 38.34 L0.00 32.00 L0.00 25.66 L0.01 23.64 L0.02 22.17 L0.03 20.97 L0.05 19.94 L0.07 19.03 L0.10 18.21 L0.12 17.46 L0.16 16.76 L0.20 16.11 L0.24 15.50 L0.28 14.93 L0.33 14.38 L0.38 13.86 L0.44 13.36 L0.50 12.89 L0.57 12.43 L0.64 11.99 L0.71 11.57 L0.79 11.17 L0.87 10.77 L0.95 10.39 L1.04 10.03 L1.14 9.67 L1.23 9.33 L1.34 8.99 L1.44 8.67 L1.55 8.35 L1.67 8.05 L1.79 7.75 L1.91 7.46 L2.04 7.18 L2.17 6.90 L2.31 6.64 L2.45 6.38 L2.60 6.13 L2.75 5.88 L2.91 5.64 L3.07 5.41 L3.24 5.19 L3.41 4.96 L3.58 4.75 L3.76 4.54 L3.95 4.34 L4.14 4.14 L4.34 3.95 L4.54 3.76 L4.75 3.58 L4.96 3.41 L5.19 3.24 L5.41 3.07 L5.64 2.91 L5.88 2.75 L6.13 2.60 L6.38 2.45 L6.64 2.31 L6.90 2.17 L7.18 2.04 L7.46 1.91 L7.75 1.79 L8.05 1.67 L8.35 1.55 L8.67 1.44 L8.99 1.34 L9.33 1.23 L9.67 1.14 L10.03 1.04 L10.39 0.95 L10.77 0.87 L11.17 0.79 L11.57 0.71 L11.99 0.64 L12.43 0.57 L12.89 0.50 L13.36 0.44 L13.86 0.38 L14.38 0.33 L14.93 0.28 L15.50 0.24 L16.11 0.20 L16.76 0.16 L17.46 0.12 L18.21 0.10 L19.03 0.07 L19.94 0.05 L20.97 0.03 L22.17 0.02 L23.64 0.01 L25.66 0.00 L32.00 0.00 L38.34 0.00 L40.36 0.01 L41.83 0.02 L43.03 0.03 L44.06 0.05 L44.97 0.07 L45.79 0.10 L46.54 0.12 L47.24 0.16 L47.89 0.20 L48.50 0.24 L49.07 0.28 L49.62 0.33 L50.14 0.38 L50.64 0.44 L51.11 0.50 L51.57 0.57 L52.01 0.64 L52.43 0.71 L52.83 0.79 L53.23 0.87 L53.61 0.95 L53.97 1.04 L54.33 1.14 L54.67 1.23 L55.01 1.34 L55.33 1.44 L55.65 1.55 L55.95 1.67 L56.25 1.79 L56.54 1.91 L56.82 2.04 L57.10 2.17 L57.36 2.31 L57.62 2.45 L57.87 2.60 L58.12 2.75 L58.36 2.91 L58.59 3.07 L58.81 3.24 L59.04 3.41 L59.25 3.58 L59.46 3.76 L59.66 3.95 L59.86 4.14 L60.05 4.34 L60.24 4.54 L60.42 4.75 L60.59 4.96 L60.76 5.19 L60.93 5.41 L61.09 5.64 L61.25 5.88 L61.40 6.13 L61.55 6.38 L61.69 6.64 L61.83 6.90 L61.96 7.18 L62.09 7.46 L62.21 7.75 L62.33 8.05 L62.45 8.35 L62.56 8.67 L62.66 8.99 L62.77 9.33 L62.86 9.67 L62.96 10.03 L63.05 10.39 L63.13 10.77 L63.21 11.17 L63.29 11.57 L63.36 11.99 L63.43 12.43 L63.50 12.89 L63.56 13.36 L63.62 13.86 L63.67 14.38 L63.72 14.93 L63.76 15.50 L63.80 16.11 L63.84 16.76 L63.88 17.46 L63.90 18.21 L63.93 19.03 L63.95 19.94 L63.97 20.97 L63.98 22.17 L63.99 23.64 L64.00 25.66Z" fill="#1B2BD0"/>
|
||||
<path transform="translate(-0.9 0)" d="M15.27 10.00 L23.13 10.00 L48.73 54.00 L40.87 54.00Z M29.96 28.49 L34.04 35.51 L24.80 54.00 L17.20 54.00Z" fill="#fff"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.7 KiB |
@@ -0,0 +1,79 @@
|
||||
import math
|
||||
import os
|
||||
|
||||
def squircle(size=64, n=5.0, steps=90):
|
||||
r = size/2
|
||||
pts=[]
|
||||
for i in range(steps*4):
|
||||
t = 2*math.pi*i/(steps*4)
|
||||
c, s = math.cos(t), math.sin(t)
|
||||
pts.append((r*math.copysign(abs(c)**(2/n), c)+r, r*math.copysign(abs(s)**(2/n), s)+r))
|
||||
return "M"+" L".join(f"{x:.2f} {y:.2f}" for x,y in pts)+"Z"
|
||||
|
||||
def norm(v):
|
||||
l=math.hypot(*v); return (v[0]/l, v[1]/l)
|
||||
|
||||
def lam(top=(19.2,10.0), bot=(44.8,54.0), foot=(21.0,54.0), join_t=0.50, w=6.8):
|
||||
"""A lambda drawn the way a type designer builds one: two straight strokes of
|
||||
one weight, the long one running top-left to bottom-right and the short one
|
||||
leaving it part way down and landing at the bottom-left, every end cut
|
||||
square to the baseline, and no part of one stroke poking out of the other."""
|
||||
(tx,ty),(bx,by)=top,bot
|
||||
# --- the long stroke: a parallelogram cut horizontally at both ends
|
||||
d=norm((bx-tx,by-ty)); phi_h = w/abs(d[1]) # horizontal thickness
|
||||
def long_x(y): return tx+(bx-tx)*(y-ty)/(by-ty)
|
||||
long_poly=[(tx-phi_h/2,ty),(tx+phi_h/2,ty),(bx+phi_h/2,by),(bx-phi_h/2,by)]
|
||||
# --- the short stroke starts on the long one's centre line
|
||||
jy=ty+(by-ty)*join_t; jx=long_x(jy)
|
||||
sd=norm((foot[0]-jx,foot[1]-jy)) # direction of the short stroke
|
||||
sh = w/abs(sd[1]) # its horizontal thickness
|
||||
nrm=(-sd[1], sd[0]) # unit normal
|
||||
# the two edges of the short stroke, as lines through (jx,jy)+-nrm*w/2
|
||||
def edge_pt(sign, y):
|
||||
ox,oy=jx+sign*nrm[0]*w/2, jy+sign*nrm[1]*w/2
|
||||
t=(y-oy)/sd[1]; return (ox+sd[0]*t, y)
|
||||
# its top end follows the long stroke's own direction, through the join point,
|
||||
# so it ends inside the long stroke instead of sticking out of it
|
||||
def on_long_dir(sign):
|
||||
ox,oy=jx+sign*nrm[0]*w/2, jy+sign*nrm[1]*w/2
|
||||
# intersect edge line (o + sd*t) with the line (j + d*s)
|
||||
a=sd[0]*(-d[1]) - sd[1]*(-d[0]);
|
||||
t=((jx-ox)*(-d[1]) - (jy-oy)*(-d[0]))/a
|
||||
return (ox+sd[0]*t, oy+sd[1]*t)
|
||||
p1,p2=on_long_dir(+1),on_long_dir(-1)
|
||||
q1,q2=edge_pt(+1,foot[1]),edge_pt(-1,foot[1])
|
||||
short_poly=[p1,p2,q2,q1]
|
||||
return long_poly, short_poly
|
||||
|
||||
def path(*polys):
|
||||
return " ".join("M"+" L".join(f"{x:.2f} {y:.2f}" for x,y in p)+"Z" for p in polys)
|
||||
|
||||
BLUE="#1B2BD0"
|
||||
def svg(bg=BLUE, fg="#FFFFFF", size=64):
|
||||
l,s=lam()
|
||||
return (f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="{size}" height="{size}">'
|
||||
f'<path d="{squircle()}" fill="{bg}"/><path d="{path(l,s)}" fill="{fg}"/></svg>')
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Writes services/webmail/src/app/icon.svg and the SVGs the PNG step renders.
|
||||
# Run: python3 scripts/brand/mark.py then node scripts/brand/icons.mjs
|
||||
# ---------------------------------------------------------------------------
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
WEBMAIL = os.path.join(HERE, "..", "..", "services", "webmail")
|
||||
|
||||
def write_all():
|
||||
l, s = lam()
|
||||
glyph = path(l, s)
|
||||
shift = 'transform="translate(-0.9 0)"' # centres the glyph, not its bounding box
|
||||
tile = f'<path d="{squircle()}" fill="{BLUE}"/>'
|
||||
head = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">'
|
||||
icon = f'{head}\n <!-- a lambda built as two square-cut strokes, on a tile with continuous corners -->\n {tile}\n <path {shift} d="{glyph}" fill="#fff"/>\n</svg>\n'
|
||||
full = f'{head}<rect width="64" height="64" fill="{BLUE}"/><path {shift} d="{glyph}" fill="#fff"/></svg>'
|
||||
safe = f'{head}<rect width="64" height="64" fill="{BLUE}"/><g transform="translate(32 32) scale(0.78) translate(-32 -32)"><path {shift} d="{glyph}" fill="#fff"/></g></svg>'
|
||||
open(os.path.join(WEBMAIL, "src", "app", "icon.svg"), "w").write(icon)
|
||||
for name, text in (("logo.svg", icon), ("logo-full.svg", full), ("logo-safe.svg", safe)):
|
||||
open(os.path.join(HERE, name), "w").write(text)
|
||||
print("glyph:", glyph)
|
||||
|
||||
if __name__ == "__main__":
|
||||
write_all()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,25 @@
|
||||
# Photographing the interface
|
||||
|
||||
`stub-server.mjs` stands in for the auth and protocols services and serves a
|
||||
small mailbox that is entirely invented (names, addresses on reserved example
|
||||
domains, messages). It exists so the webmail can be run, worked on and
|
||||
photographed without a mail server or anyone's real mail.
|
||||
|
||||
node scripts/screenshots/stub-server.mjs # listens on :4001
|
||||
cd services/webmail
|
||||
AUTH_SERVICE_URL=http://127.0.0.1:4001 \
|
||||
PROTOCOLS_SERVICE_URL=http://127.0.0.1:4001 npx next dev -p 3100
|
||||
|
||||
Open `http://localhost:3100/user/mail/inbox` with a cookie named
|
||||
`lm_user_session` holding any token with the claims the middleware reads (the
|
||||
capture script builds one).
|
||||
|
||||
`capture.mjs` writes the pictures the README uses into `docs/img/`, in English
|
||||
and Portuguese, dark and light:
|
||||
|
||||
cd scripts/screenshots && npm install --no-save puppeteer-core
|
||||
CHROME_PATH=/path/to/chrome node capture.mjs
|
||||
|
||||
It needs a Chrome, and the webmail running as above. The stub keeps its state in
|
||||
memory (opening a message marks it read); the script resets it before every
|
||||
picture through `/__reset`, so each one starts from the same mailbox.
|
||||
@@ -0,0 +1,66 @@
|
||||
// Photographs the webmail running against the invented mailbox of
|
||||
// stub-server.mjs, for the README. Needs puppeteer-core and a Chrome:
|
||||
//
|
||||
// CHROME_PATH=/usr/bin/google-chrome node scripts/screenshots/capture.mjs
|
||||
//
|
||||
// BASE is where the webmail is listening (default http://localhost:3100).
|
||||
import puppeteer from "puppeteer-core";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const out = path.join(here, "..", "..", "docs", "img");
|
||||
const base = process.env.BASE ?? "http://localhost:3100";
|
||||
const stub = process.env.STUB ?? "http://127.0.0.1:4001";
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
// The webmail only checks that a session cookie looks like one; the stub
|
||||
// accepts anything, so a token with the right claims is enough.
|
||||
const b64 = (o) => Buffer.from(JSON.stringify(o)).toString("base64url");
|
||||
const session = `${b64({ alg: "HS256" })}.${b64({ aud: "lambdamail:user", purpose: "session", surface: "user", exp: Math.floor(Date.now() / 1000) + 3600 })}.stub`;
|
||||
|
||||
const browser = await puppeteer.launch({ executablePath: process.env.CHROME_PATH, headless: true, args: ["--no-sandbox"] });
|
||||
|
||||
async function shoot({ name, theme, locale, width = 1440, height = 900, steps }) {
|
||||
// Every picture starts from the mailbox as it began: opening a message marks it read.
|
||||
await fetch(`${stub}/__reset`);
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width, height, deviceScaleFactor: 1 });
|
||||
const host = new URL(base).hostname;
|
||||
await page.setCookie(
|
||||
{ name: "lm_user_session", value: session, domain: host, path: "/" },
|
||||
{ name: "lm_locale", value: locale, domain: host, path: "/" },
|
||||
);
|
||||
await page.evaluateOnNewDocument((t) => localStorage.setItem("lm_theme", t), theme);
|
||||
await page.goto(`${base}/user/mail/inbox`, { waitUntil: "domcontentloaded", timeout: 120000 });
|
||||
await page.waitForSelector("button.lm-row", { timeout: 120000 });
|
||||
// Next's development badge is not part of the product.
|
||||
await page.addStyleTag({ content: "nextjs-portal { display: none !important; }" });
|
||||
await sleep(1500);
|
||||
const open = async (re) => {
|
||||
for (const row of await page.$$("button.lm-row")) {
|
||||
if (re.test(await row.evaluate((e) => e.textContent))) { await row.click(); return; }
|
||||
}
|
||||
throw new Error(`no row matches ${re}`);
|
||||
};
|
||||
await steps(page, open);
|
||||
await page.screenshot({ path: path.join(out, `${name}.png`) });
|
||||
await page.close();
|
||||
console.log("wrote", name);
|
||||
}
|
||||
|
||||
const launch = async (page, open) => { await open(/Launch checklist/); await sleep(3500); };
|
||||
const digest = async (page, open) => { await open(/weekly digest/); await sleep(3500); };
|
||||
const menu = async (page, open) => {
|
||||
await open(/Launch checklist/); await sleep(3000);
|
||||
await page.keyboard.down("Control"); await page.keyboard.press("k"); await page.keyboard.up("Control");
|
||||
await sleep(700);
|
||||
};
|
||||
|
||||
for (const [locale, tag] of [["en", "en"], ["pt-BR", "pt"]]) {
|
||||
await shoot({ name: `webmail-dark-${tag}`, theme: "dark", locale, steps: launch });
|
||||
await shoot({ name: `webmail-light-${tag}`, theme: "light", locale, steps: launch });
|
||||
await shoot({ name: `menu-dark-${tag}`, theme: "dark", locale, steps: menu });
|
||||
await shoot({ name: `designed-light-${tag}`, theme: "light", locale, steps: digest });
|
||||
}
|
||||
await browser.close();
|
||||
@@ -0,0 +1,143 @@
|
||||
// A stand-in for the auth and protocols services that serves a small, entirely
|
||||
// invented mailbox in the JSON shapes the webmail expects. It exists so the
|
||||
// interface can be run and photographed without a mail server or real mail:
|
||||
//
|
||||
// node scripts/screenshots/stub-server.mjs # listens on :4001
|
||||
// cd services/webmail && AUTH_SERVICE_URL=http://127.0.0.1:4001 \
|
||||
// PROTOCOLS_SERVICE_URL=http://127.0.0.1:4001 npx next dev -p 3100
|
||||
//
|
||||
// Nothing here is real: the names, the addresses (all on reserved example
|
||||
// domains) and the messages are made up.
|
||||
import http from "node:http";
|
||||
|
||||
const ME = "mara@lambdamail.example";
|
||||
const prefs = { signature: "", auto_save_drafts: true, conversation_view: true };
|
||||
const trusted = new Set(["helena@studio.example"]);
|
||||
|
||||
const T = {
|
||||
launch: "10000000-0000-4000-8000-000000000001",
|
||||
invoice: "10000000-0000-4000-8000-000000000002",
|
||||
digest: "10000000-0000-4000-8000-000000000003",
|
||||
flight: "10000000-0000-4000-8000-000000000004",
|
||||
review: "10000000-0000-4000-8000-000000000005",
|
||||
contract: "10000000-0000-4000-8000-000000000006",
|
||||
welcome: "10000000-0000-4000-8000-000000000007",
|
||||
standup: "10000000-0000-4000-8000-000000000008",
|
||||
};
|
||||
|
||||
const digestHtml = `<html><head><style>
|
||||
.wrap{max-width:560px;margin:0 auto;font-family:Georgia,serif;color:#1d1d1f}
|
||||
.band{background:#1B2BD0;color:#fff;padding:26px 30px}
|
||||
.band h1{margin:0;font:700 24px/1.2 Arial,sans-serif}
|
||||
.body{padding:22px 30px;background:#fff}
|
||||
.story{border-top:1px solid #e3e5ea;padding:14px 0}
|
||||
.story b{font:700 16px Arial,sans-serif}
|
||||
</style></head><body><div class="wrap">
|
||||
<div class="band"><h1>Field Notes</h1><div style="opacity:.8;font:13px Arial">The weekly digest</div></div>
|
||||
<div class="body">
|
||||
<p>Three short reads for the week, picked by hand.</p>
|
||||
<div class="story"><b>What a slow build taught us about caching</b><p style="margin:.3em 0 0">A look at why the fastest fix was deleting code.</p></div>
|
||||
<div class="story"><b>Small mail servers, big promises</b><p style="margin:.3em 0 0">Running your own, a year in.</p></div>
|
||||
<div class="story"><b>The case for boring dashboards</b><p style="margin:.3em 0 0">Fewer charts, better decisions.</p></div>
|
||||
</div></div></body></html>`;
|
||||
|
||||
const flightHtml = `<table width="100%" cellpadding="0" cellspacing="0" style="font-family:Arial,sans-serif"><tr><td bgcolor="#0b3d91" style="color:#fff;padding:18px 24px;font-size:18px"><b>Booking confirmed</b></td></tr>
|
||||
<tr><td bgcolor="#f4f6fb" style="padding:20px 24px;color:#1d1d1f"><p style="margin:0 0 6px"><b>LIS → GRU</b> · Fri 14 Nov, 22:40</p><p style="margin:0;color:#555">Seat 14A · Reference <b>QX7M2P</b></p></td></tr></table>`;
|
||||
|
||||
const quote = (who, when, text) =>
|
||||
`<div class="gmail_quote"><div class="gmail_attr">On ${when}, ${who} wrote:</div><blockquote class="gmail_quote">${text}</blockquote></div>`;
|
||||
|
||||
let uidSeq = 100;
|
||||
const m = (o) => ({ uid: ++uidSeq, seen: true, answered: false, flagged: false, attach: false, ...o });
|
||||
const msgs = [
|
||||
m({ folder: "INBOX", thread: T.launch, subject: "Launch checklist", from: "Helena Vargas <helena@studio.example>", date: "2026-10-20T09:10:00Z", mid: "l1@x", html: "<p>Hi Mara,</p><p>Here is the checklist for Thursday. Could you take the copy review and the release notes? Marcos has the infra items.</p><p>Helena</p>", answered: true }),
|
||||
m({ folder: "Sent", thread: T.launch, subject: "Re: Launch checklist", from: `Mara Lima <${ME}>`, date: "2026-10-20T11:42:00Z", mid: "l2@x", own: true, html: "<p>On it. I will send the notes by Wednesday noon.</p>" }),
|
||||
m({ folder: "INBOX", thread: T.launch, subject: "Re: Launch checklist", from: "Helena Vargas <helena@studio.example>", date: "2026-10-21T08:30:00Z", mid: "l3@x", html: "<p>Perfect. One more thing: the status page should say \"scheduled maintenance\" until we flip the switch.</p><p>Helena</p>" }),
|
||||
m({ folder: "INBOX", thread: T.launch, subject: "Re: Launch checklist", from: "Marcos Oliveira <marcos@studio.example>", date: "2026-10-22T07:55:00Z", mid: "l4@x", seen: false,
|
||||
html: `<p>Morning both. The staging run finished green, so I think we are fine to go ahead on Thursday as planned.</p><p>I did notice the backup job takes a little longer than it used to (about 11 minutes now). Not a blocker, but I will keep an eye on it and report back after the launch.</p><p>Marcos</p>${quote("Helena Vargas <helena@studio.example>", "Wed, Oct 21, 2026 at 8:30 AM", "<p>Perfect. One more thing: the status page should say \"scheduled maintenance\" until we flip the switch.</p><p>Helena</p>")}` }),
|
||||
m({ folder: "INBOX", thread: T.invoice, subject: "Invoice 2041 for October", from: "Northwind Billing <billing@northwind.example>", date: "2026-10-22T06:20:00Z", mid: "i1@x", seen: false, attach: true, html: "<p>Hello Mara,</p><p>Your invoice for October is ready. The total is due on 5 November.</p><p>Northwind Billing</p>" }),
|
||||
m({ folder: "INBOX", thread: T.digest, subject: "Your weekly digest", from: "Field Notes <hello@fieldnotes.example>", date: "2026-10-21T18:00:00Z", mid: "d1@x", html: digestHtml }),
|
||||
m({ folder: "INBOX", thread: T.flight, subject: "Your booking LIS to GRU is confirmed", from: "Atlas Air <no-reply@atlasair.example>", date: "2026-10-21T13:12:00Z", mid: "f1@x", html: flightHtml }),
|
||||
m({ folder: "INBOX", thread: T.review, subject: "Design review notes", from: "Priya Nair <priya@studio.example>", date: "2026-10-20T16:45:00Z", mid: "r1@x", attach: true, flagged: true, html: "<p>Notes from today's review are attached. The short version: keep the sidebar, simplify the toolbar, and let the reading pane breathe.</p><p>Priya</p>" }),
|
||||
m({ folder: "INBOX", thread: T.contract, subject: "Contract draft v3", from: "Tomas Reyes <tomas@legal.example>", date: "2026-10-19T14:05:00Z", mid: "c1@x", html: "<p>Mara, v3 of the contract is in the shared folder with the changes we agreed on. Tell me if anything looks off before Friday.</p><p>Tomas</p>" }),
|
||||
m({ folder: "INBOX", thread: T.standup, subject: "Standup moves to 10:15", from: "Marcos Oliveira <marcos@studio.example>", date: "2026-10-18T09:00:00Z", mid: "s1@x", html: "<p>Heads up: from Monday the standup is at 10:15.</p>" }),
|
||||
m({ folder: "INBOX", thread: T.welcome, subject: "Welcome to the team workspace", from: "Workspace <team@workspace.example>", date: "2026-10-15T08:00:00Z", mid: "w1@x", html: "<p>Welcome aboard. Your workspace is ready.</p>" }),
|
||||
];
|
||||
|
||||
// Opening a message marks it read, so a run can put the mailbox back as it began.
|
||||
const pristine = structuredClone(msgs);
|
||||
const reset = () => { msgs.splice(0, msgs.length, ...structuredClone(pristine)); };
|
||||
|
||||
const key = (x) => `${x.folder.toLowerCase()}:${x.uid}`;
|
||||
const nameOf = (f) => (f.match(/^"?([^"<]*?)"?\s*</)?.[1] || f).trim();
|
||||
const addrOf = (f) => (f.match(/<([^>]+)>/)?.[1] || f).toLowerCase();
|
||||
const plain = (h) => h.replace(/<style[\s\S]*?<\/style>/gi, " ").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
|
||||
const thread = (id) => msgs.filter((x) => x.thread === id).sort((a, b) => a.date.localeCompare(b.date));
|
||||
|
||||
function summary(id, folder) {
|
||||
const all = thread(id);
|
||||
const here = all.filter((x) => x.folder.toLowerCase() === folder.toLowerCase());
|
||||
if (!here.length) return null;
|
||||
const latest = here[here.length - 1];
|
||||
const incoming = all.filter((x) => !x.own);
|
||||
const last = incoming[incoming.length - 1];
|
||||
const answered = last ? last.answered || all.slice(all.indexOf(last) + 1).some((x) => x.own) : false;
|
||||
const seen = new Set();
|
||||
const participants = [];
|
||||
for (const x of all) {
|
||||
const a = addrOf(x.from);
|
||||
if (seen.has(a) || participants.length === 4) continue;
|
||||
seen.add(a);
|
||||
participants.push({ name: nameOf(x.from), address: a, me: Boolean(x.own) });
|
||||
}
|
||||
return {
|
||||
thread_id: id, uid: latest.uid, subject: latest.subject, sender_address: addrOf(latest.from), from_display_name: nameOf(latest.from),
|
||||
snippet: plain(latest.html).slice(0, 140), received_at: latest.date, has_attachments: all.some((x) => x.attach),
|
||||
message_count: all.length, unseen_count: all.filter((x) => !x.seen).length, seen: all.every((x) => x.seen),
|
||||
flagged: all.some((x) => x.flagged), answered, participants,
|
||||
};
|
||||
}
|
||||
|
||||
const json = (res, code, body) => { res.writeHead(code, { "content-type": "application/json" }); res.end(JSON.stringify(body)); };
|
||||
const readBody = (req) => new Promise((r) => { let b = ""; req.on("data", (c) => (b += c)); req.on("end", () => r(b ? JSON.parse(b) : {})); });
|
||||
|
||||
http.createServer(async (req, res) => {
|
||||
const url = new URL(req.url, "http://x");
|
||||
const p = url.pathname;
|
||||
const q = url.searchParams;
|
||||
const body = ["POST", "PUT", "DELETE"].includes(req.method) ? await readBody(req) : {};
|
||||
if (p === "/__reset") { reset(); return json(res, 200, { ok: true }); }
|
||||
if (p === "/api/v1/user/me" || p === "/api/v1/admin/me") return json(res, 200, { id: "u1", email: ME, role: "USER", locale: "en", mfa_enrolled: true, recovery_codes_left: 8 });
|
||||
if (p === "/api/v1/user/preferences") { if (req.method === "POST") Object.assign(prefs, body); return json(res, 200, prefs); }
|
||||
if (p === "/api/v1/user/trusted-senders") { if (req.method === "PUT") trusted.add(body.address); if (req.method === "DELETE") trusted.delete(q.get("address")); return json(res, 200, { senders: [...trusted] }); }
|
||||
if (p === "/api/v1/auth/accounts") return json(res, 200, { accounts: [{ id: "u1", email: ME, active: true }] });
|
||||
if (p === "/api/v1/mail/folders") {
|
||||
const count = (f) => msgs.filter((x) => x.folder === f).length;
|
||||
const unread = (f) => msgs.filter((x) => x.folder === f && !x.seen).length;
|
||||
return json(res, 200, [["INBOX", "inbox"], ["Sent", "sent"], ["Drafts", "drafts"], ["Archive", "archive"], ["Junk", "junk"], ["Trash", "trash"]]
|
||||
.map(([name, use], i) => ({ id: `f${i}`, name, special_use: use, unread_count: unread(name), total_count: count(name) })));
|
||||
}
|
||||
if (p === "/api/v1/mail/threads") {
|
||||
const f = q.get("folder") || "inbox";
|
||||
const ids = [...new Set(msgs.filter((x) => x.folder.toLowerCase() === f.toLowerCase()).map((x) => x.thread))];
|
||||
return json(res, 200, ids.map((id) => summary(id, f)).filter(Boolean).sort((a, b) => b.received_at.localeCompare(a.received_at)));
|
||||
}
|
||||
if (p.startsWith("/api/v1/mail/thread/")) {
|
||||
const all = thread(p.split("/").pop());
|
||||
if (!all.length) return json(res, 404, { error: "NOT_FOUND" });
|
||||
return json(res, 200, all.map((x, i) => ({
|
||||
uid: x.uid, folder: x.folder, subject: x.subject, sender_address: addrOf(x.from), from_display_name: nameOf(x.from), snippet: plain(x.html).slice(0, 100),
|
||||
received_at: x.date, has_attachments: x.attach, seen: x.seen, flagged: x.flagged, own: Boolean(x.own), answered: !x.own && (x.answered || all.slice(i + 1).some((y) => y.own)),
|
||||
})));
|
||||
}
|
||||
if (p.startsWith("/api/v1/mail/message/")) {
|
||||
const uid = Number(p.split("/").pop());
|
||||
const x = msgs.find((y) => y.uid === uid && y.folder.toLowerCase() === (q.get("folder") || "").toLowerCase());
|
||||
if (!x) return json(res, 404, {});
|
||||
x.seen = true;
|
||||
return json(res, 200, { uid, subject: x.subject, from: x.from, to: x.own ? ["helena@studio.example"] : [ME], cc: [], date: x.date, text: plain(x.html), html: x.html, attachments: x.attach ? ["document.pdf"] : [], message_id: `<${x.mid}>`, in_reply_to: "", important: false, inline_images: {} });
|
||||
}
|
||||
if (p === "/api/v1/mail/events") return json(res, 404, {});
|
||||
if (p.startsWith("/api/v1/mail/")) return json(res, 200, { ok: true, uid: 1 });
|
||||
return json(res, 404, { error: "NOT_FOUND", path: p });
|
||||
}).listen(Number(process.env.PORT ?? 4001), () => console.log("demo mailbox on", process.env.PORT ?? 4001));
|
||||
@@ -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": {
|
||||
|
||||
@@ -2,6 +2,10 @@ import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
// Real Argon2id hashes cost 1-3s each on a shared CI runner, and several
|
||||
// tests chain a dozen of them. The production cost parameters stay as they
|
||||
// are; the default 5s budget is what does not fit.
|
||||
testTimeout: 30_000,
|
||||
// session.ts refuses to load without a real signing secret, and ESM hoists
|
||||
// imports - so the value has to exist before any test file is evaluated,
|
||||
// which an assignment inside a test file cannot guarantee.
|
||||
|
||||
@@ -21,7 +21,7 @@ require (
|
||||
|
||||
require (
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.6 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
|
||||
@@ -23,8 +23,8 @@ github.com/fergusstrange/embedded-postgres v1.30.0 h1:ewv1e6bBlqOIYtgGgRcEnNDpfG
|
||||
github.com/fergusstrange/embedded-postgres v1.30.0/go.mod h1:w0YvnCgf19o6tskInrOOACtnqfVlOvluz3hlNLY7tRk=
|
||||
github.com/go-acme/lego/v4 v4.35.2 h1:uVQg+KC/yj9R2g7Q9W5wDqhvQvxV5SMu5eqFVoN5xZU=
|
||||
github.com/go-acme/lego/v4 v4.35.2/go.mod h1:pX2jN5n8OphMGY1IaMjYm5DAEzguBaKRt8AvJAgJXpc=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/go-jose/go-jose/v4 v4.1.6 h1:sNRkftxFJxwsILiQukqp5z83QBSmntVwmgRY7AqBDO4=
|
||||
github.com/go-jose/go-jose/v4 v4.1.6/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -564,5 +564,31 @@
|
||||
"draftDiscarded": "Reply discarded",
|
||||
"sentReply": "Reply sent",
|
||||
"moreMessages": "Show {count} earlier messages"
|
||||
},
|
||||
"appearance": {
|
||||
"title": "Appearance",
|
||||
"dark": "Dark",
|
||||
"light": "Light",
|
||||
"system": "System"
|
||||
},
|
||||
"palette": {
|
||||
"placeholder": "Type a command, a folder or something to search for",
|
||||
"empty": "Nothing matches. Press Enter to search your mail for this.",
|
||||
"searchFor": "Search mail for \"{query}\"",
|
||||
"goTo": "Go to {name}",
|
||||
"actions": "Actions",
|
||||
"navigate": "Go to",
|
||||
"theme": "Appearance",
|
||||
"accounts": "Accounts",
|
||||
"switchTo": "Switch to {email}",
|
||||
"hintMove": "move",
|
||||
"hintOpen": "open",
|
||||
"hintClose": "close",
|
||||
"open": "Command menu",
|
||||
"signOut": "Sign out"
|
||||
},
|
||||
"viewer": {
|
||||
"asSent": "View as sent",
|
||||
"adapt": "Match the theme"
|
||||
}
|
||||
}
|
||||
@@ -564,5 +564,31 @@
|
||||
"draftDiscarded": "Respuesta descartada",
|
||||
"sentReply": "Respuesta enviada",
|
||||
"moreMessages": "Mostrar {count} mensajes anteriores"
|
||||
},
|
||||
"appearance": {
|
||||
"title": "Apariencia",
|
||||
"dark": "Oscuro",
|
||||
"light": "Claro",
|
||||
"system": "Sistema"
|
||||
},
|
||||
"palette": {
|
||||
"placeholder": "Escribe un comando, una carpeta o algo que buscar",
|
||||
"empty": "Sin resultados. Pulsa Enter para buscar esto en tu correo.",
|
||||
"searchFor": "Buscar \"{query}\" en el correo",
|
||||
"goTo": "Ir a {name}",
|
||||
"actions": "Acciones",
|
||||
"navigate": "Ir a",
|
||||
"theme": "Apariencia",
|
||||
"accounts": "Cuentas",
|
||||
"switchTo": "Cambiar a {email}",
|
||||
"hintMove": "mover",
|
||||
"hintOpen": "abrir",
|
||||
"hintClose": "cerrar",
|
||||
"open": "Menú de comandos",
|
||||
"signOut": "Cerrar sesión"
|
||||
},
|
||||
"viewer": {
|
||||
"asSent": "Ver como se envió",
|
||||
"adapt": "Adaptar al tema"
|
||||
}
|
||||
}
|
||||
@@ -564,5 +564,31 @@
|
||||
"draftDiscarded": "Resposta descartada",
|
||||
"sentReply": "Resposta enviada",
|
||||
"moreMessages": "Mostrar {count} mensagens anteriores"
|
||||
},
|
||||
"appearance": {
|
||||
"title": "Aparência",
|
||||
"dark": "Escuro",
|
||||
"light": "Claro",
|
||||
"system": "Sistema"
|
||||
},
|
||||
"palette": {
|
||||
"placeholder": "Digite um comando, uma pasta ou algo para buscar",
|
||||
"empty": "Nada encontrado. Pressione Enter para buscar este texto nos seus e-mails.",
|
||||
"searchFor": "Buscar \"{query}\" nos e-mails",
|
||||
"goTo": "Ir para {name}",
|
||||
"actions": "Ações",
|
||||
"navigate": "Ir para",
|
||||
"theme": "Aparência",
|
||||
"accounts": "Contas",
|
||||
"switchTo": "Trocar para {email}",
|
||||
"hintMove": "mover",
|
||||
"hintOpen": "abrir",
|
||||
"hintClose": "fechar",
|
||||
"open": "Menu de comandos",
|
||||
"signOut": "Sair"
|
||||
},
|
||||
"viewer": {
|
||||
"asSent": "Ver como enviado",
|
||||
"adapt": "Adaptar ao tema"
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,20 @@
|
||||
{
|
||||
"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",
|
||||
"dompurify": "^3.4.14",
|
||||
"framer-motion": "^11.18.2",
|
||||
"imagetracerjs": "1.2.6",
|
||||
"lucide-react": "^0.474.0",
|
||||
"next": "15.5.27",
|
||||
"next": "15.5.28",
|
||||
"qrcode-generator": "^2.0.4",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
@@ -718,6 +719,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource-variable/geist": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/geist/-/geist-5.3.0.tgz",
|
||||
"integrity": "sha512-j0m+vLQuG5XAYoHtGCVu0spvlGreR3EzpECUVzkFmI1mTVnAO38l/NEPDCFgZ177JxzYJCLSmTQibIiYPilGrA==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/colour": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
|
||||
@@ -1324,15 +1334,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/env": {
|
||||
"version": "15.5.27",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.27.tgz",
|
||||
"integrity": "sha512-WC5hvVqiuFOcRyz6gKPeJkiVvSMf3scNeAV49CdWtVqxmUlzh8UKR6YHt9yiNkh8LngtzqSlVz07/Ma3ZAzchA==",
|
||||
"version": "15.5.28",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.28.tgz",
|
||||
"integrity": "sha512-xxW5EBI8fMPuVTPBW7LL980IRbuDXlbkUAwp9DDhtb4Lnich3nJ13uYpvrX4VE74hWPf6AvHHAMmGCidcHjbfg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@next/swc-darwin-arm64": {
|
||||
"version": "15.5.27",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.27.tgz",
|
||||
"integrity": "sha512-fBA9QurmK4+uj4wNhmRTuO5zMVRaBHCvp6H93zTG/5i6Yi6TewbvbesXB8YQNj8T99Cwrow87hHiFV6wL/hyMA==",
|
||||
"version": "15.5.28",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.28.tgz",
|
||||
"integrity": "sha512-dPoQYXcfSIqx5o+TOXVXO7kONK5qbtNRDeiTfII0bT3puGIT6VF5e0QwmxNZfEZ5Zety5f3m9P/p4xqwfiLlPg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1346,9 +1356,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-darwin-x64": {
|
||||
"version": "15.5.27",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.27.tgz",
|
||||
"integrity": "sha512-FGZwJwry8qZNHxD/nfwrCwCHHrqx6epqItkkxbYcBQEQ2uw5S43bY83fQB5zbAsZE7TELMjoYkbhsQ4oKM7wwg==",
|
||||
"version": "15.5.28",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.28.tgz",
|
||||
"integrity": "sha512-RgCJ7buBivTH+6sddqaVm+/v8kwMHlFkobvdt3DfETutws5HrxnToMIWSN9X3MZ3aAi7BbKrGZZWUDQLEycXqw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1362,9 +1372,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-gnu": {
|
||||
"version": "15.5.27",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.27.tgz",
|
||||
"integrity": "sha512-GQulAaJYNF1RYuatlsw9/uzbKz4if2V7Ic9nVO+P0ljYPm4yBRTFIl6g5YkKTw40lMLbpy9edLoH44g3jSHMYA==",
|
||||
"version": "15.5.28",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.28.tgz",
|
||||
"integrity": "sha512-OHXQe13olE59aKe++zHavGcJPh5SLHXi6Anret3BBeq1+qhSuOJDNAli/EDDfxs6UTqA4Qddo4uyYry2slyjiQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1381,9 +1391,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-musl": {
|
||||
"version": "15.5.27",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.27.tgz",
|
||||
"integrity": "sha512-URFFsUmu8Pi0/1qdnUI7tx7keZw1Mt5zHfrIX1RfDG9Nih8DYxK54WZsDJUX2ZTlii1jgotL7qcbwKnSAES6xQ==",
|
||||
"version": "15.5.28",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.28.tgz",
|
||||
"integrity": "sha512-nTB7bB6vfn4ezCJNYP43ukt2o8L3+WxKF/sTcTxepdXsJh3SM9RhlU7wrZYbH7L4HO5M87dqDTwm8587vAkCiw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1400,9 +1410,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-gnu": {
|
||||
"version": "15.5.27",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.27.tgz",
|
||||
"integrity": "sha512-aJp74z7uvU5jQhyH7lyk+4qSRcTdKbKT9NQE3jiO9kWYkoHZjKi0zzHDbCoZ+v2sJDEiJ/z58R0UlWMGWNF3HA==",
|
||||
"version": "15.5.28",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.28.tgz",
|
||||
"integrity": "sha512-WXCfVI32rW3xCusoUX1RpZKtyChWU2Zwe27JUDmM3KrXwcDNc9vV4zEq0GKFAURnuQ/Ouo/XqPvB0yh445XZCg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1419,9 +1429,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-musl": {
|
||||
"version": "15.5.27",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.27.tgz",
|
||||
"integrity": "sha512-ILl2bQ8MbpvyMpY8AXljb3BXs0MJSC8HXZ6QV5AWpVS7ZELqNPtrHvhUk+SNarvjvICXX8bCS287TCi+gUFVYg==",
|
||||
"version": "15.5.28",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.28.tgz",
|
||||
"integrity": "sha512-Miq+NDb/HWsXuxseYxMM6fuEOQXgTziarkKIRpVKCn8W69cEfhQaQr+90pO+u4q2CbJR2wzXmUuCFyZaJs/Nng==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1438,9 +1448,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-arm64-msvc": {
|
||||
"version": "15.5.27",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.27.tgz",
|
||||
"integrity": "sha512-1MB9Rh5y8TOPN2x7naNTJNkCZQav9c70AI7EBYXxsEewwOffC4DjjwBwKf20NBvcA+u+5jDv7nVmHxunlP2d7Q==",
|
||||
"version": "15.5.28",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.28.tgz",
|
||||
"integrity": "sha512-5LR79lemgDlgxvG7MQJ+zHCkVcrUoQuO7CiIvq3I58vNW4goSOE2b+19Z6FujRWMouvni4gvudYFuFuNxolGWA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1454,9 +1464,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-x64-msvc": {
|
||||
"version": "15.5.27",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.27.tgz",
|
||||
"integrity": "sha512-XtYgpGOswgnyBWXzOEG6q9RhvnSw0CEZ0MbWqCsl5PISgt9EzvJvNW5mU5n1PsphX/lnamzxjgQXH2GUJL8nqg==",
|
||||
"version": "15.5.28",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.28.tgz",
|
||||
"integrity": "sha512-GNipvXUYROSTf4PXEyH95FrWImIB5zdaK6uEOg0RP1/X2drnvoMwiBkJ0VWdzX7w3iiBm3y5g2TxkKDGVTOQcg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -3006,12 +3016,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/next": {
|
||||
"version": "15.5.27",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-15.5.27.tgz",
|
||||
"integrity": "sha512-F82CrlPZ8GRxBH9RIIoR3qU1t5RT3dv1K8moe8SjlDEquiFipvEI13R1Om90TaUOEwShkWCMlg8+gvTNzfMhvg==",
|
||||
"version": "15.5.28",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-15.5.28.tgz",
|
||||
"integrity": "sha512-JjhLBBY1PNnuwPtcZ7t8yxVeJVYvD2Jyp633J+gRCNbzSt7N0E+xqCf6uQiff3+3qmUkwcF/ksfSsK1xsZ76Tw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@next/env": "15.5.27",
|
||||
"@next/env": "15.5.28",
|
||||
"@swc/helpers": "0.5.15",
|
||||
"caniuse-lite": "^1.0.30001579",
|
||||
"postcss": "8.4.31",
|
||||
@@ -3024,15 +3034,15 @@
|
||||
"node": "^18.18.0 || ^19.8.0 || >= 20.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@next/swc-darwin-arm64": "15.5.27",
|
||||
"@next/swc-darwin-x64": "15.5.27",
|
||||
"@next/swc-linux-arm64-gnu": "15.5.27",
|
||||
"@next/swc-linux-arm64-musl": "15.5.27",
|
||||
"@next/swc-linux-x64-gnu": "15.5.27",
|
||||
"@next/swc-linux-x64-musl": "15.5.27",
|
||||
"@next/swc-win32-arm64-msvc": "15.5.27",
|
||||
"@next/swc-win32-x64-msvc": "15.5.27",
|
||||
"sharp": "^0.34.3 || ^0.35.4"
|
||||
"@next/swc-darwin-arm64": "15.5.28",
|
||||
"@next/swc-darwin-x64": "15.5.28",
|
||||
"@next/swc-linux-arm64-gnu": "15.5.28",
|
||||
"@next/swc-linux-arm64-musl": "15.5.28",
|
||||
"@next/swc-linux-x64-gnu": "15.5.28",
|
||||
"@next/swc-linux-x64-musl": "15.5.28",
|
||||
"@next/swc-win32-arm64-msvc": "15.5.28",
|
||||
"@next/swc-win32-x64-msvc": "15.5.28",
|
||||
"sharp": "^0.34.3 || ^0.35.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": "^1.1.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@lambdamail/webmail",
|
||||
"version": "0.1.0",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
@@ -10,12 +10,13 @@
|
||||
"lint": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/geist": "^5.3.0",
|
||||
"clsx": "^2.1.1",
|
||||
"dompurify": "^3.4.14",
|
||||
"framer-motion": "^11.18.2",
|
||||
"imagetracerjs": "1.2.6",
|
||||
"lucide-react": "^0.474.0",
|
||||
"next": "15.5.27",
|
||||
"next": "15.5.28",
|
||||
"qrcode-generator": "^2.0.4",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
|
||||
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 7.2 KiB |
@@ -22,6 +22,8 @@ import { useTranslations } from "../../../i18n/provider";
|
||||
import { LanguageSwitcher } from "../../../i18n/LanguageSwitcher";
|
||||
import { useAccount } from "../../../lib/useAccount";
|
||||
import { initialsFor } from "../../../lib/initials";
|
||||
import { Logo } from "../../../components/Logo";
|
||||
import { ThemeToggle } from "../../../components/ThemeToggle";
|
||||
|
||||
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||
const t = useTranslations();
|
||||
@@ -52,12 +54,10 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
const SidebarContent = (
|
||||
<div className="flex h-full flex-col gap-4 p-3">
|
||||
<div className="flex items-center gap-2.5 px-2 py-1">
|
||||
<div className="flex h-8 w-8 flex-none items-center justify-center rounded-[10px] bg-dark-card text-[17px] leading-none text-indigo-500 shadow-[inset_0_0_0_1px_#9184d9]">
|
||||
{"\u03bb"}
|
||||
</div>
|
||||
<Logo size={32} className="flex-none" />
|
||||
<div className="min-w-0">
|
||||
<div className="text-[15px] font-medium leading-tight">LambdaMail</div>
|
||||
<div className="text-[10.5px] uppercase tracking-[0.08em] text-indigo-500">
|
||||
<div className="text-[11.5px] text-slate-400">
|
||||
{t("common.adminPortal")}
|
||||
</div>
|
||||
</div>
|
||||
@@ -91,7 +91,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
<div className="flex gap-[3px] rounded-[10px] bg-dark-panel p-[3px] shadow-edge">
|
||||
<Link
|
||||
href="/user/mail/inbox"
|
||||
className="flex flex-1 items-center justify-center gap-1.5 rounded-[8px] px-2 py-1.5 text-[12px] leading-snug text-slate-400 transition-colors hover:bg-white/[0.05] hover:text-slate-100"
|
||||
className="flex flex-1 items-center justify-center gap-1.5 rounded-[8px] px-2 py-1.5 text-[12px] leading-snug text-slate-400 transition-colors hover:bg-ink/[0.05] hover:text-slate-100"
|
||||
>
|
||||
<Mail className="h-3.5 w-3.5 flex-none" />
|
||||
<span className="min-w-0 truncate">{t("admin.backToWebmail")}</span>
|
||||
@@ -103,7 +103,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2.5 rounded-xl bg-dark-panel p-2.5 shadow-edge">
|
||||
<span className="flex h-[30px] w-[30px] flex-none items-center justify-center rounded-full bg-indigo-900 text-[12px] uppercase text-indigo-300 shadow-[inset_0_0_0_1px_#5d5294]">
|
||||
<span className="flex h-[30px] w-[30px] flex-none items-center justify-center rounded-full bg-indigo-900 text-[12px] uppercase text-indigo-300 shadow-[inset_0_0_0_1px_rgb(var(--a-700))]">
|
||||
{initialsFor(account?.email)}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
@@ -122,12 +122,13 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
<LanguageSwitcher />
|
||||
<ThemeToggle />
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLogout}
|
||||
title={t("settings.signOut")}
|
||||
aria-label={t("settings.signOut")}
|
||||
className="flex h-8 w-8 flex-none items-center justify-center rounded-[9px] border border-white/[0.14] text-slate-300 transition-colors hover:bg-white/[0.07] hover:text-rose-400"
|
||||
className="flex h-8 w-8 flex-none items-center justify-center rounded-[9px] border border-ink/[0.14] text-slate-300 transition-colors hover:bg-ink/[0.07] hover:text-rose-400"
|
||||
>
|
||||
<LogOut className="h-[15px] w-[15px]" />
|
||||
</button>
|
||||
@@ -139,11 +140,9 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden bg-dark-bg text-slate-100">
|
||||
{/* Mobile Top Header */}
|
||||
<div className="fixed left-0 top-0 z-20 flex w-full items-center justify-between bg-dark-rail px-4 py-3 shadow-[inset_0_-1px_0_0_rgba(233,233,237,0.09)] md:hidden">
|
||||
<div className="fixed left-0 top-0 z-20 flex w-full items-center justify-between bg-dark-rail px-4 py-3 shadow-[inset_0_-1px_0_0_rgb(var(--ink)/0.09)] md:hidden">
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<div className="flex h-8 w-8 flex-none items-center justify-center rounded-[10px] bg-dark-card text-[17px] leading-none text-indigo-500 shadow-[inset_0_0_0_1px_#9184d9]">
|
||||
{"\u03bb"}
|
||||
</div>
|
||||
<Logo size={32} className="flex-none" />
|
||||
<span className="min-w-0 truncate font-medium text-slate-100">
|
||||
{t("common.appName")} {t("common.adminPortal")}
|
||||
</span>
|
||||
@@ -152,14 +151,14 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
onClick={() => setMobileOpen(!mobileOpen)}
|
||||
aria-label={t("ui.toggleMenu")}
|
||||
aria-expanded={mobileOpen}
|
||||
className="flex h-9 w-9 flex-none items-center justify-center rounded-[10px] text-slate-300 transition-colors hover:bg-white/[0.07] hover:text-slate-100"
|
||||
className="flex h-9 w-9 flex-none items-center justify-center rounded-[10px] text-slate-300 transition-colors hover:bg-ink/[0.07] hover:text-slate-100"
|
||||
>
|
||||
{mobileOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Desktop rail */}
|
||||
<aside className="hidden w-[244px] flex-none flex-col bg-dark-rail xl:w-[268px] shadow-[inset_-1px_0_0_0_rgba(233,233,237,0.09)] md:flex">
|
||||
<aside className="hidden w-[244px] flex-none flex-col bg-dark-rail xl:w-[268px] shadow-[inset_-1px_0_0_0_rgb(var(--ink)/0.09)] md:flex">
|
||||
{SidebarContent}
|
||||
</aside>
|
||||
|
||||
@@ -171,7 +170,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: "-100%" }}
|
||||
transition={{ type: "spring", damping: 25, stiffness: 200 }}
|
||||
className="fixed inset-y-0 left-0 z-30 w-[280px] overflow-y-auto bg-dark-rail pt-14 shadow-[inset_-1px_0_0_0_rgba(233,233,237,0.09)] md:hidden"
|
||||
className="fixed inset-y-0 left-0 z-30 w-[280px] overflow-y-auto bg-dark-rail pt-14 shadow-[inset_-1px_0_0_0_rgb(var(--ink)/0.09)] md:hidden"
|
||||
>
|
||||
{SidebarContent}
|
||||
</motion.aside>
|
||||
|
||||
@@ -483,7 +483,7 @@ export default function AdminMailboxesPage() {
|
||||
"w-full min-h-[36px] rounded-[10px] bg-dark-card px-3 py-2 text-[13.5px] text-slate-100 placeholder-slate-500 shadow-edge transition-shadow focus:outline-none focus-visible:shadow-edge-accent";
|
||||
const fieldLabel = "mb-1.5 block text-xs text-slate-400";
|
||||
const iconButton =
|
||||
"flex h-[30px] w-[30px] flex-none items-center justify-center rounded-lg text-slate-400 transition-colors hover:bg-white/[0.07] hover:text-slate-100";
|
||||
"flex h-[30px] w-[30px] flex-none items-center justify-center rounded-lg text-slate-400 transition-colors hover:bg-ink/[0.07] hover:text-slate-100";
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-[1060px] flex-col gap-[18px] px-5 pb-11 pt-7 sm:px-8">
|
||||
@@ -650,14 +650,14 @@ export default function AdminMailboxesPage() {
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t("ui.searchPlaceholder")}
|
||||
aria-label={t("ui.searchPlaceholder")}
|
||||
className="w-full rounded-[10px] border border-white/[0.14] bg-transparent py-2 pl-9 pr-3 text-[13px] text-slate-100 placeholder:text-slate-500"
|
||||
className="w-full rounded-[10px] border border-ink/[0.14] bg-transparent py-2 pl-9 pr-3 text-[13px] text-slate-100 placeholder:text-slate-500"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={roleFilter}
|
||||
onChange={(e) => setRoleFilter(e.target.value)}
|
||||
aria-label={t("ui.role")}
|
||||
className="rounded-[10px] border border-white/[0.14] bg-dark-panel px-3 py-2 text-[13px] text-slate-100"
|
||||
className="rounded-[10px] border border-ink/[0.14] bg-dark-panel px-3 py-2 text-[13px] text-slate-100"
|
||||
>
|
||||
<option value="">{t("ui.all")}</option>
|
||||
<option value="USER">{roleLabel(t, "USER")}</option>
|
||||
@@ -668,7 +668,7 @@ export default function AdminMailboxesPage() {
|
||||
value={activeFilter}
|
||||
onChange={(e) => setActiveFilter(e.target.value)}
|
||||
aria-label={t("ui.accountStatus")}
|
||||
className="rounded-[10px] border border-white/[0.14] bg-dark-panel px-3 py-2 text-[13px] text-slate-100"
|
||||
className="rounded-[10px] border border-ink/[0.14] bg-dark-panel px-3 py-2 text-[13px] text-slate-100"
|
||||
>
|
||||
<option value="">{t("ui.all")}</option>
|
||||
<option value="true">{t("ui.active")}</option>
|
||||
|
||||
@@ -167,14 +167,14 @@ export default function AdminQueuePage() {
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t("ui.searchPlaceholder")}
|
||||
aria-label={t("ui.searchPlaceholder")}
|
||||
className="w-full rounded-[10px] border border-white/[0.14] bg-transparent py-2 pl-9 pr-3 text-[13px] text-slate-100 placeholder:text-slate-500"
|
||||
className="w-full rounded-[10px] border border-ink/[0.14] bg-transparent py-2 pl-9 pr-3 text-[13px] text-slate-100 placeholder:text-slate-500"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value)}
|
||||
aria-label={t("common.status")}
|
||||
className="rounded-[10px] border border-white/[0.14] bg-dark-panel px-3 py-2 text-[13px] text-slate-100"
|
||||
className="rounded-[10px] border border-ink/[0.14] bg-dark-panel px-3 py-2 text-[13px] text-slate-100"
|
||||
>
|
||||
<option value="">{t("ui.all")}</option>
|
||||
{/* Taken from the aggregate, so the choices are the states this
|
||||
|
||||
@@ -180,14 +180,14 @@ export default function AdminTestsPage() {
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t("ui.searchPlaceholder")}
|
||||
aria-label={t("ui.searchPlaceholder")}
|
||||
className="w-full rounded-[10px] border border-white/[0.14] bg-transparent py-2 pl-9 pr-3 text-[13px] text-slate-100 placeholder:text-slate-500"
|
||||
className="w-full rounded-[10px] border border-ink/[0.14] bg-transparent py-2 pl-9 pr-3 text-[13px] text-slate-100 placeholder:text-slate-500"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={selected}
|
||||
onChange={(e) => setSelected(e.target.value)}
|
||||
aria-label={t("admin.userChecks")}
|
||||
className="min-w-[220px] rounded-[10px] border border-white/[0.14] bg-dark-panel px-3 py-2 text-[13px] text-slate-100"
|
||||
className="min-w-[220px] rounded-[10px] border border-ink/[0.14] bg-dark-panel px-3 py-2 text-[13px] text-slate-100"
|
||||
>
|
||||
<option value="">{t("ui.selectUser")}</option>
|
||||
{users.map((u) => (
|
||||
|
||||
@@ -5,6 +5,8 @@ import Link from "next/link";
|
||||
import { useTranslations } from "../../../../i18n/provider";
|
||||
import { TotpEnrolment, RecoveryCodes } from "../../../../components/TotpEnrolment";
|
||||
import { Button } from "../../../../components/ui/Button";
|
||||
import { Logo } from "../../../../components/Logo";
|
||||
import { ThemeToggle } from "../../../../components/ThemeToggle";
|
||||
|
||||
export default function AdminLoginPage() {
|
||||
const t = useTranslations();
|
||||
@@ -101,15 +103,14 @@ export default function AdminLoginPage() {
|
||||
const fieldLabel = "mb-1.5 block text-xs text-slate-400";
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-dark-bg px-4">
|
||||
<div className="relative flex min-h-screen items-center justify-center bg-dark-bg px-4">
|
||||
<ThemeToggle className="absolute right-4 top-4" />
|
||||
<div className="w-full max-w-[400px] rounded-2xl bg-dark-panel p-7 shadow-edge">
|
||||
{/* The console sign-in wears the same mark as the webmail. It used to be
|
||||
green over green, which made the second surface look like a
|
||||
different product. */}
|
||||
<div className="mb-6 flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 flex-none items-center justify-center rounded-xl bg-dark-card text-xl leading-none text-indigo-500 shadow-[inset_0_0_0_1px_#9184d9]">
|
||||
{"\u03bb"}
|
||||
</div>
|
||||
<Logo size={40} className="flex-none" />
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-lg font-medium leading-tight text-slate-100">{t("auth.adminLoginTitle")}</h1>
|
||||
<p className="mt-0.5 text-[12.5px] leading-relaxed text-slate-400">{t("auth.adminStepUpNote")}</p>
|
||||
|
||||
@@ -96,7 +96,7 @@ export default function AdminStepUpPage() {
|
||||
<div className="flex min-h-screen items-center justify-center bg-dark-bg px-4">
|
||||
<div className="w-full max-w-[400px] rounded-2xl bg-dark-panel p-7 shadow-edge">
|
||||
<div className="mb-6 flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 flex-none items-center justify-center rounded-xl bg-dark-card text-indigo-500 shadow-[inset_0_0_0_1px_#9184d9]">
|
||||
<div className="flex h-10 w-10 flex-none items-center justify-center rounded-xl bg-dark-card text-indigo-500 shadow-[inset_0_0_0_1px_rgb(var(--a-500))]">
|
||||
<Sliders className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
|
||||
@@ -4,6 +4,8 @@ import React, { useState } from "react";
|
||||
import { Mail, Lock, AlertCircle } from "lucide-react";
|
||||
import { useTranslations } from "../../../../i18n/provider";
|
||||
import { Button } from "../../../../components/ui/Button";
|
||||
import { Logo } from "../../../../components/Logo";
|
||||
import { ThemeToggle } from "../../../../components/ThemeToggle";
|
||||
|
||||
export default function UserLoginPage() {
|
||||
const t = useTranslations();
|
||||
@@ -55,12 +57,11 @@ export default function UserLoginPage() {
|
||||
/* No ambient glow behind the card any more: two blurred coloured circles
|
||||
under a sign-in form are decoration that costs a full-viewport paint and
|
||||
says nothing. Nocturne's depth is the surface and its edge. */
|
||||
<div className="flex min-h-screen items-center justify-center bg-dark-bg px-4">
|
||||
<div className="relative flex min-h-screen items-center justify-center bg-dark-bg px-4">
|
||||
<ThemeToggle className="absolute right-4 top-4" />
|
||||
<div className="w-full max-w-[400px] rounded-2xl bg-dark-panel p-7 shadow-edge">
|
||||
<div className="mb-6 flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 flex-none items-center justify-center rounded-xl bg-dark-card text-xl leading-none text-indigo-500 shadow-[inset_0_0_0_1px_#9184d9]">
|
||||
{"\u03bb"}
|
||||
</div>
|
||||
<Logo size={40} className="flex-none" />
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-lg font-medium leading-tight text-slate-100">{t("auth.userLoginTitle")}</h1>
|
||||
<p className="mt-0.5 text-[12.5px] text-slate-400">
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
ShieldAlert,
|
||||
SlidersHorizontal,
|
||||
Menu,
|
||||
Search,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "../../../i18n/provider";
|
||||
@@ -28,6 +29,8 @@ import { useAccount, isAdminRole } from "../../../lib/useAccount";
|
||||
import { useFolders } from "../../../lib/useFolders";
|
||||
import { folderBadge, customFolders } from "../../../lib/mailCounts";
|
||||
import { AccountSwitcher } from "../../../components/AccountSwitcher";
|
||||
import { Logo } from "../../../components/Logo";
|
||||
import { CommandPalette } from "../../../components/CommandPalette";
|
||||
|
||||
export default function UserWebmailLayout({ children }: { children: React.ReactNode }) {
|
||||
const t = useTranslations();
|
||||
@@ -87,12 +90,10 @@ export default function UserWebmailLayout({ children }: { children: React.ReactN
|
||||
{/* The brand mark. One mark for both surfaces - the line underneath is
|
||||
the only thing that says which one you are in. */}
|
||||
<div className="flex items-center gap-2.5 px-2 py-1">
|
||||
<div className="flex h-8 w-8 flex-none items-center justify-center rounded-[10px] bg-dark-card text-[17px] leading-none text-indigo-500 shadow-[inset_0_0_0_1px_#9184d9]">
|
||||
{"\u03bb"}
|
||||
</div>
|
||||
<Logo size={32} className="flex-none" />
|
||||
<div className="min-w-0">
|
||||
<div className="text-[15px] font-medium leading-tight">LambdaMail</div>
|
||||
<div className="text-[10.5px] uppercase tracking-[0.08em] text-indigo-500">
|
||||
<div className="text-[11.5px] text-slate-400">
|
||||
{t("common.userPortal")}
|
||||
</div>
|
||||
</div>
|
||||
@@ -101,12 +102,24 @@ export default function UserWebmailLayout({ children }: { children: React.ReactN
|
||||
<Link
|
||||
href="/user/compose"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-[10px] border border-indigo-500 px-3 py-2.5 text-sm font-medium text-indigo-500 transition-colors hover:bg-indigo-500/[0.12] active:bg-indigo-500/[0.22]"
|
||||
className="flex w-full items-center justify-center gap-2 rounded-xl bg-fill px-3 py-2.5 text-sm font-medium text-white shadow-sm transition-colors hover:bg-fill-hover active:brightness-95"
|
||||
>
|
||||
<PenSquare className="h-4 w-4 flex-none" />
|
||||
<span className="min-w-0">{t("mail.compose")}</span>
|
||||
</Link>
|
||||
|
||||
{/* The way into everything that is not on screen. Quiet on purpose: the
|
||||
sidebar keeps to folders, and this is where the rest lives. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => window.dispatchEvent(new KeyboardEvent("keydown", { key: "k", ctrlKey: true }))}
|
||||
className="-mt-1 flex w-full items-center gap-2 rounded-xl px-3 py-2 text-left text-[13px] text-slate-400 shadow-edge transition-colors hover:bg-ink/[0.05] hover:text-slate-200"
|
||||
>
|
||||
<Search className="h-4 w-4 flex-none" aria-hidden />
|
||||
<span className="min-w-0 flex-1 truncate">{t("palette.open")}</span>
|
||||
<kbd className="rounded-md px-1.5 py-0.5 text-[11px] shadow-edge">Ctrl K</kbd>
|
||||
</button>
|
||||
|
||||
{/* Folder navigation. Every row is a mark, an icon, a label that may wrap,
|
||||
and its numbers - nothing is sized by the length of the label, which
|
||||
is what clipped the Portuguese and Spanish names before. */}
|
||||
@@ -192,7 +205,7 @@ export default function UserWebmailLayout({ children }: { children: React.ReactN
|
||||
if (!window.confirm(t("folders.deleteConfirm", { name: folder.name }))) return;
|
||||
void manageFolder({ action: "delete", name: folder.name });
|
||||
}}
|
||||
className="absolute right-1.5 top-1/2 hidden h-6 w-6 -translate-y-1/2 items-center justify-center rounded-md text-slate-500 transition-colors hover:bg-white/[0.09] hover:text-slate-200 focus:flex group-hover/folder:flex"
|
||||
className="absolute right-1.5 top-1/2 hidden h-6 w-6 -translate-y-1/2 items-center justify-center rounded-md text-slate-500 transition-colors hover:bg-ink/[0.09] hover:text-slate-200 focus:flex group-hover/folder:flex"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
@@ -223,7 +236,7 @@ export default function UserWebmailLayout({ children }: { children: React.ReactN
|
||||
type="submit"
|
||||
aria-label={t("folders.new")}
|
||||
title={t("folders.new")}
|
||||
className="flex h-7 w-7 flex-none items-center justify-center rounded-lg text-slate-400 transition-colors hover:bg-white/[0.09] hover:text-slate-100"
|
||||
className="flex h-7 w-7 flex-none items-center justify-center rounded-lg text-slate-400 transition-colors hover:bg-ink/[0.09] hover:text-slate-100"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
@@ -266,7 +279,7 @@ export default function UserWebmailLayout({ children }: { children: React.ReactN
|
||||
href="/admin/step-up"
|
||||
title={t("admin.openAdmin")}
|
||||
aria-label={t("admin.openAdmin")}
|
||||
className="flex flex-1 items-center justify-center gap-1.5 rounded-[8px] px-2 py-1.5 text-[12px] leading-snug text-slate-400 transition-colors hover:bg-white/[0.05] hover:text-slate-100"
|
||||
className="flex flex-1 items-center justify-center gap-1.5 rounded-[8px] px-2 py-1.5 text-[12px] leading-snug text-slate-400 transition-colors hover:bg-ink/[0.05] hover:text-slate-100"
|
||||
>
|
||||
<ShieldCheck className="h-3.5 w-3.5 flex-none" />
|
||||
<span className="min-w-0 truncate">{t("admin.openAdminShort")}</span>
|
||||
@@ -283,7 +296,7 @@ export default function UserWebmailLayout({ children }: { children: React.ReactN
|
||||
onClick={handleLogout}
|
||||
title={t("settings.signOut")}
|
||||
aria-label={t("settings.signOut")}
|
||||
className="flex h-8 w-8 flex-none items-center justify-center rounded-[9px] border border-white/[0.14] text-slate-300 transition-colors hover:bg-white/[0.07] hover:text-rose-400"
|
||||
className="flex h-8 w-8 flex-none items-center justify-center rounded-[9px] border border-ink/[0.14] text-slate-300 transition-colors hover:bg-ink/[0.07] hover:text-rose-400"
|
||||
>
|
||||
<LogOut className="h-[15px] w-[15px]" />
|
||||
</button>
|
||||
@@ -294,26 +307,25 @@ export default function UserWebmailLayout({ children }: { children: React.ReactN
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden bg-dark-bg text-slate-100">
|
||||
<CommandPalette isAdmin={isAdminRole(account?.role)} />
|
||||
{/* Mobile Top Header */}
|
||||
<div className="fixed left-0 top-0 z-20 flex w-full items-center justify-between bg-dark-rail px-4 py-3 shadow-[inset_0_-1px_0_0_rgba(233,233,237,0.09)] md:hidden">
|
||||
<div className="fixed left-0 top-0 z-20 flex w-full items-center justify-between bg-dark-rail px-4 py-3 shadow-[inset_0_-1px_0_0_rgb(var(--ink)/0.09)] md:hidden">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-[10px] bg-dark-card text-[17px] leading-none text-indigo-500 shadow-[inset_0_0_0_1px_#9184d9]">
|
||||
{"\u03bb"}
|
||||
</div>
|
||||
<Logo size={32} className="flex-none" />
|
||||
<span className="font-medium text-slate-100">LambdaMail</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setMobileOpen(!mobileOpen)}
|
||||
aria-label={t("ui.toggleMenu")}
|
||||
aria-expanded={mobileOpen}
|
||||
className="flex h-9 w-9 items-center justify-center rounded-[10px] text-slate-300 transition-colors hover:bg-white/[0.07] hover:text-slate-100"
|
||||
className="flex h-9 w-9 items-center justify-center rounded-[10px] text-slate-300 transition-colors hover:bg-ink/[0.07] hover:text-slate-100"
|
||||
>
|
||||
{mobileOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Desktop rail */}
|
||||
<aside className="hidden w-[244px] flex-none flex-col bg-dark-rail xl:w-[268px] shadow-[inset_-1px_0_0_0_rgba(233,233,237,0.09)] md:flex">
|
||||
<aside className="hidden w-[244px] flex-none flex-col bg-dark-rail xl:w-[268px] shadow-[inset_-1px_0_0_0_rgb(var(--ink)/0.09)] md:flex">
|
||||
{SidebarContent}
|
||||
</aside>
|
||||
|
||||
@@ -325,7 +337,7 @@ export default function UserWebmailLayout({ children }: { children: React.ReactN
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: "-100%" }}
|
||||
transition={{ type: "spring", damping: 25, stiffness: 200 }}
|
||||
className="fixed inset-y-0 left-0 z-30 w-[280px] overflow-y-auto bg-dark-rail pt-14 shadow-[inset_-1px_0_0_0_rgba(233,233,237,0.09)] md:hidden"
|
||||
className="fixed inset-y-0 left-0 z-30 w-[280px] overflow-y-auto bg-dark-rail pt-14 shadow-[inset_-1px_0_0_0_rgb(var(--ink)/0.09)] md:hidden"
|
||||
>
|
||||
{SidebarContent}
|
||||
</motion.aside>
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
} from "../../../../../lib/mailCounts";
|
||||
import { plainSnippet } from "../../../../../lib/snippet";
|
||||
import { useTrustedSenders } from "../../../../../lib/trustedSenders";
|
||||
import { useTheme } from "../../../../../lib/theme";
|
||||
import type { InlineImages } from "../../../../../lib/emailBody";
|
||||
import { messageDocument, hasBlockedRemoteContent } from "../../../../../lib/renderMessage";
|
||||
import {
|
||||
@@ -48,6 +49,7 @@ import {
|
||||
import { useAccount } from "../../../../../lib/useAccount";
|
||||
import { MessageReader } from "../../../../../components/MessageReader";
|
||||
import { ThreadReader } from "../../../../../components/ThreadReader";
|
||||
import { SEARCH_EVENT } from "../../../../../components/CommandPalette";
|
||||
import { Avatar } from "../../../../../components/Avatar";
|
||||
|
||||
interface MessageSummary {
|
||||
@@ -157,6 +159,9 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
|
||||
|
||||
const [messages, setMessages] = useState<MessageSummary[]>([]);
|
||||
const [selected, setSelected] = useState<RenderedMessage | null>(null);
|
||||
// A plainly written message takes the app's colours; this shows it as sent.
|
||||
const [showOriginal, setShowOriginal] = useState(false);
|
||||
const { theme } = useTheme();
|
||||
// The open conversation, when the list is showing conversations.
|
||||
const [selectedThread, setSelectedThread] = useState<MessageSummary | null>(null);
|
||||
const conversations = useConversationView();
|
||||
@@ -259,6 +264,13 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
|
||||
return () => clearTimeout(handle);
|
||||
}, [searchQuery, loadMessages, conversations.ready]);
|
||||
|
||||
// The command menu can ask this list to search.
|
||||
useEffect(() => {
|
||||
const onSearch = (event: Event) => setSearchQuery(String((event as CustomEvent).detail ?? ""));
|
||||
window.addEventListener(SEARCH_EVENT, onSearch);
|
||||
return () => window.removeEventListener(SEARCH_EVENT, onSearch);
|
||||
}, []);
|
||||
|
||||
// Changing between the two lists closes whatever was open in the other.
|
||||
useEffect(() => {
|
||||
setSelected(null);
|
||||
@@ -436,6 +448,7 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
|
||||
}
|
||||
}
|
||||
setLoadRemoteImages(false);
|
||||
setShowOriginal(false);
|
||||
setReaderHeight(420);
|
||||
try {
|
||||
const res = await fetch(`/api/v1/mail/message/${uid}?folder=${encodeURIComponent(folder)}`, {
|
||||
@@ -470,13 +483,13 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
|
||||
* markup, then resolve the message's own inline parts, then block or restore
|
||||
* remote sources - which is the only part the reader chooses.
|
||||
*/
|
||||
const readerDocument = useMemo(() => {
|
||||
const readerBody = useMemo(() => {
|
||||
if (!selected) return null;
|
||||
// A trusted sender's images load without asking; anyone else's wait for
|
||||
// the reader's choice, one message at a time.
|
||||
const showImages = loadRemoteImages || trustedSenders.isTrusted(selected.from ?? "");
|
||||
return messageDocument(selected, showImages);
|
||||
}, [selected, loadRemoteImages, trustedSenders]);
|
||||
return messageDocument(selected, showImages, { theme, original: showOriginal });
|
||||
}, [selected, loadRemoteImages, trustedSenders, theme, showOriginal]);
|
||||
|
||||
// Whether anything was actually held back, so the privacy banner is not shown
|
||||
// over a message that has no remote content at all.
|
||||
@@ -542,6 +555,7 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
|
||||
type="search"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
id="mail-search"
|
||||
placeholder={t("mail.searchPlaceholder")}
|
||||
aria-label={t("common.search")}
|
||||
className="min-h-[38px] w-full rounded-xl bg-dark-panel py-2 pl-[34px] pr-3 text-[13px] text-slate-100 placeholder-slate-500 shadow-edge transition-shadow focus:outline-none focus-visible:shadow-edge-accent"
|
||||
@@ -605,7 +619,7 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
|
||||
className={
|
||||
msg.seen
|
||||
? "bg-dark-card text-slate-400"
|
||||
: "bg-indigo-900 text-indigo-300 shadow-[inset_0_0_0_1px_#5d5294]"
|
||||
: "bg-indigo-900 text-indigo-300 shadow-[inset_0_0_0_1px_rgb(var(--a-700))]"
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -689,7 +703,7 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
|
||||
onClick={() => void (msg.thread_id ? deleteConversationRow(msg) : deleteMessage(msg.uid))}
|
||||
title={t("common.delete")}
|
||||
aria-label={t("common.delete")}
|
||||
className="absolute right-2 top-2 hidden h-7 w-7 items-center justify-center rounded-md text-slate-400 transition-colors hover:bg-white/[0.09] hover:text-slate-100 focus:flex focus-visible:flex group-hover:flex"
|
||||
className="absolute right-2 top-2 hidden h-7 w-7 items-center justify-center rounded-md text-slate-400 transition-colors hover:bg-ink/[0.09] hover:text-slate-100 focus:flex focus-visible:flex group-hover:flex"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
@@ -733,7 +747,7 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
|
||||
e.preventDefault();
|
||||
}}
|
||||
className={`group relative hidden w-1.5 flex-none cursor-col-resize touch-none select-none md:block ${
|
||||
dragging ? "bg-indigo-500/40" : "bg-white/[0.07] hover:bg-indigo-500/30"
|
||||
dragging ? "bg-indigo-500/40" : "bg-ink/[0.07] hover:bg-indigo-500/30"
|
||||
} focus-visible:bg-indigo-500/50 focus-visible:outline-none`}
|
||||
>
|
||||
{/* A wider invisible target, so the thin line is easy to grab. */}
|
||||
@@ -785,7 +799,11 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
|
||||
name: target.name,
|
||||
label: FOLDER_LABELS[(target.special_use || target.name).toLowerCase()] ?? target.name,
|
||||
}))}
|
||||
document={readerDocument}
|
||||
document={readerBody?.document ?? null}
|
||||
paper={readerBody?.paper ?? true}
|
||||
canAdapt={readerBody?.canAdapt ?? false}
|
||||
showingOriginal={showOriginal}
|
||||
onToggleOriginal={() => setShowOriginal((v) => !v)}
|
||||
frameHeight={readerHeight}
|
||||
hasBlockedContent={hasBlockedContent}
|
||||
imagesLoaded={loadRemoteImages || senderTrusted}
|
||||
|
||||
@@ -510,7 +510,7 @@ export default function UserSettingsPage() {
|
||||
type="button"
|
||||
onClick={() => void revokeAppPassword(ap.id)}
|
||||
aria-label={t("common.delete")}
|
||||
className="flex h-[30px] w-[30px] flex-none items-center justify-center rounded-lg text-slate-400 transition-colors hover:bg-white/[0.07] hover:text-rose-400"
|
||||
className="flex h-[30px] w-[30px] flex-none items-center justify-center rounded-lg text-slate-400 transition-colors hover:bg-ink/[0.07] hover:text-rose-400"
|
||||
>
|
||||
<Trash2 className="h-[15px] w-[15px]" />
|
||||
</button>
|
||||
@@ -551,7 +551,7 @@ export default function UserSettingsPage() {
|
||||
type="button"
|
||||
onClick={() => void revokeSession(session.id)}
|
||||
aria-label={t("common.delete")}
|
||||
className="flex h-[30px] w-[30px] flex-none items-center justify-center rounded-lg text-slate-400 transition-colors hover:bg-white/[0.07] hover:text-rose-400"
|
||||
className="flex h-[30px] w-[30px] flex-none items-center justify-center rounded-lg text-slate-400 transition-colors hover:bg-ink/[0.07] hover:text-rose-400"
|
||||
>
|
||||
<Trash2 className="h-[15px] w-[15px]" />
|
||||
</button>
|
||||
@@ -769,7 +769,7 @@ export default function UserSettingsPage() {
|
||||
disabled={index === 0}
|
||||
aria-label={t("settings.ruleMoveUp")}
|
||||
title={t("settings.ruleMoveUp")}
|
||||
className="flex h-[30px] w-[30px] flex-none items-center justify-center rounded-lg text-slate-400 transition-colors hover:bg-white/[0.07] hover:text-slate-100 disabled:opacity-30"
|
||||
className="flex h-[30px] w-[30px] flex-none items-center justify-center rounded-lg text-slate-400 transition-colors hover:bg-ink/[0.07] hover:text-slate-100 disabled:opacity-30"
|
||||
>
|
||||
<ChevronUp className="h-[15px] w-[15px]" />
|
||||
</button>
|
||||
@@ -779,7 +779,7 @@ export default function UserSettingsPage() {
|
||||
disabled={index === rules.length - 1}
|
||||
aria-label={t("settings.ruleMoveDown")}
|
||||
title={t("settings.ruleMoveDown")}
|
||||
className="flex h-[30px] w-[30px] flex-none items-center justify-center rounded-lg text-slate-400 transition-colors hover:bg-white/[0.07] hover:text-slate-100 disabled:opacity-30"
|
||||
className="flex h-[30px] w-[30px] flex-none items-center justify-center rounded-lg text-slate-400 transition-colors hover:bg-ink/[0.07] hover:text-slate-100 disabled:opacity-30"
|
||||
>
|
||||
<ChevronDown className="h-[15px] w-[15px]" />
|
||||
</button>
|
||||
@@ -787,7 +787,7 @@ export default function UserSettingsPage() {
|
||||
type="button"
|
||||
onClick={() => removeSieveRule(r.id)}
|
||||
aria-label={t("common.delete")}
|
||||
className="flex h-[30px] w-[30px] flex-none items-center justify-center rounded-lg text-slate-400 transition-colors hover:bg-white/[0.07] hover:text-rose-400"
|
||||
className="flex h-[30px] w-[30px] flex-none items-center justify-center rounded-lg text-slate-400 transition-colors hover:bg-ink/[0.07] hover:text-rose-400"
|
||||
>
|
||||
<Trash2 className="h-[15px] w-[15px]" />
|
||||
</button>
|
||||
@@ -914,12 +914,12 @@ export default function UserSettingsPage() {
|
||||
aria-label={b.title}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => applySignatureCommand(b.cmd)}
|
||||
className={`h-7 w-7 rounded-md text-[13px] text-slate-300 transition-colors hover:bg-white/[0.09] hover:text-slate-100 ${b.cls}`}
|
||||
className={`h-7 w-7 rounded-md text-[13px] text-slate-300 transition-colors hover:bg-ink/[0.09] hover:text-slate-100 ${b.cls}`}
|
||||
>
|
||||
{b.label}
|
||||
</button>
|
||||
))}
|
||||
<span className="mx-1 h-4 w-px bg-white/10" />
|
||||
<span className="mx-1 h-4 w-px bg-ink/10" />
|
||||
<button
|
||||
type="button"
|
||||
title={t("ui.insertLink")}
|
||||
@@ -929,7 +929,7 @@ export default function UserSettingsPage() {
|
||||
const url = window.prompt(t("ui.insertLink"), "https://");
|
||||
if (url) applySignatureCommand("createLink", url);
|
||||
}}
|
||||
className="h-7 rounded-md px-2 text-[12px] text-slate-300 transition-colors hover:bg-white/[0.09] hover:text-slate-100"
|
||||
className="h-7 rounded-md px-2 text-[12px] text-slate-300 transition-colors hover:bg-ink/[0.09] hover:text-slate-100"
|
||||
>
|
||||
{t("ui.insertLink")}
|
||||
</button>
|
||||
@@ -942,7 +942,7 @@ export default function UserSettingsPage() {
|
||||
const url = window.prompt(t("ui.insertImage"), "https://");
|
||||
if (url) applySignatureCommand("insertImage", url);
|
||||
}}
|
||||
className="h-7 rounded-md px-2 text-[12px] text-slate-300 transition-colors hover:bg-white/[0.09] hover:text-slate-100"
|
||||
className="h-7 rounded-md px-2 text-[12px] text-slate-300 transition-colors hover:bg-ink/[0.09] hover:text-slate-100"
|
||||
>
|
||||
{t("ui.insertImage")}
|
||||
</button>
|
||||
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
@@ -1,95 +1,218 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/**
|
||||
* Nocturne - the one visual language for both surfaces.
|
||||
* LambdaMail's visual language, in two themes.
|
||||
*
|
||||
* The webmail used to be indigo over glass and the console green over glass,
|
||||
* so the two halves of the same product did not look related. Everything below
|
||||
* comes from the Nocturne design system: a near-neutral blue-grey ground, Inter
|
||||
* at weight 500 for headings, solid surfaces with a 1px inset edge instead of
|
||||
* blur, and a single blurple accent used as a line, a mark and a glow rather
|
||||
* than a flood. Tailwind's slate/indigo/emerald scales are remapped onto these
|
||||
* same values in tailwind.config.js, so a utility class and a variable here
|
||||
* cannot drift apart.
|
||||
* Every colour in the interface is a variable below, and the Tailwind scales the
|
||||
* screens already use (slate, indigo, amber, rose, emerald) read those
|
||||
* variables, so a theme is a change of values and not a change of classes.
|
||||
* The ramps run the same direction in both themes: 100 is always the text you
|
||||
* read, 950 always the ground it sits on, 400 the quieter text. That is why a
|
||||
* screen written against `text-slate-400` is correct in light and in dark.
|
||||
*
|
||||
* Dark is the default. The theme is chosen before first paint by the script in
|
||||
* the root layout, so there is no flash of the other one.
|
||||
*/
|
||||
|
||||
/* GENERATED from the ramps in docs/design - edit the ramps, not these lines */
|
||||
:root, [data-theme='dark'] {
|
||||
--n-50: 245 247 251;
|
||||
--n-100: 230 233 240;
|
||||
--n-200: 211 216 227;
|
||||
--n-300: 181 188 204;
|
||||
--n-400: 142 150 168;
|
||||
--n-500: 111 119 138;
|
||||
--n-600: 82 90 108;
|
||||
--n-700: 56 62 77;
|
||||
--n-800: 35 40 52;
|
||||
--n-900: 23 27 37;
|
||||
--n-950: 15 18 24;
|
||||
--a-50: 241 243 255;
|
||||
--a-100: 228 232 255;
|
||||
--a-200: 203 210 255;
|
||||
--a-300: 169 180 255;
|
||||
--a-400: 135 148 255;
|
||||
--a-500: 101 116 255;
|
||||
--a-600: 79 94 240;
|
||||
--a-700: 61 73 196;
|
||||
--a-800: 44 53 144;
|
||||
--a-900: 29 35 96;
|
||||
--a-950: 20 24 66;
|
||||
--w-50: 255 248 230;
|
||||
--w-100: 255 239 194;
|
||||
--w-200: 255 224 138;
|
||||
--w-300: 255 206 82;
|
||||
--w-400: 245 184 46;
|
||||
--w-500: 227 157 15;
|
||||
--w-600: 185 122 8;
|
||||
--w-700: 138 90 10;
|
||||
--w-800: 92 61 11;
|
||||
--w-900: 58 40 16;
|
||||
--w-950: 36 25 9;
|
||||
--b-50: 255 241 242;
|
||||
--b-100: 255 224 227;
|
||||
--b-200: 255 194 201;
|
||||
--b-300: 255 154 166;
|
||||
--b-400: 248 112 127;
|
||||
--b-500: 229 72 90;
|
||||
--b-600: 192 47 66;
|
||||
--b-700: 150 38 55;
|
||||
--b-800: 101 27 40;
|
||||
--b-900: 65 22 32;
|
||||
--b-950: 42 15 22;
|
||||
--g-50: 236 253 245;
|
||||
--g-100: 209 250 229;
|
||||
--g-200: 167 243 208;
|
||||
--g-300: 110 231 183;
|
||||
--g-400: 52 211 153;
|
||||
--g-500: 16 185 129;
|
||||
--g-600: 5 150 105;
|
||||
--g-700: 4 120 87;
|
||||
--g-800: 6 95 70;
|
||||
--g-900: 6 78 59;
|
||||
--g-950: 2 44 34;
|
||||
--a-fill: 79 94 240;
|
||||
--a-fill-hover: 99 113 250;
|
||||
--ink: 233 237 245;
|
||||
--rail: 11 13 18;
|
||||
--lm-shadow: 0 18px 48px rgb(0 0 0 / 0.55);
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
[data-theme='light'] {
|
||||
--n-50: 11 14 20;
|
||||
--n-100: 18 21 28;
|
||||
--n-200: 35 40 52;
|
||||
--n-300: 58 65 82;
|
||||
--n-400: 91 98 114;
|
||||
--n-500: 123 130 148;
|
||||
--n-600: 163 169 184;
|
||||
--n-700: 205 209 219;
|
||||
--n-800: 233 236 241;
|
||||
--n-900: 255 255 255;
|
||||
--n-950: 243 244 247;
|
||||
--a-50: 20 24 66;
|
||||
--a-100: 29 35 96;
|
||||
--a-200: 44 53 144;
|
||||
--a-300: 51 65 194;
|
||||
--a-400: 63 79 230;
|
||||
--a-500: 63 79 230;
|
||||
--a-600: 101 116 255;
|
||||
--a-700: 135 148 255;
|
||||
--a-800: 183 192 255;
|
||||
--a-900: 225 229 255;
|
||||
--a-950: 238 240 255;
|
||||
--w-50: 36 25 9;
|
||||
--w-100: 58 40 16;
|
||||
--w-200: 92 61 11;
|
||||
--w-300: 122 78 6;
|
||||
--w-400: 138 90 10;
|
||||
--w-500: 185 122 8;
|
||||
--w-600: 227 157 15;
|
||||
--w-700: 245 184 46;
|
||||
--w-800: 255 224 138;
|
||||
--w-900: 255 241 201;
|
||||
--w-950: 255 248 230;
|
||||
--b-50: 42 15 22;
|
||||
--b-100: 65 22 32;
|
||||
--b-200: 101 27 40;
|
||||
--b-300: 142 33 51;
|
||||
--b-400: 178 42 62;
|
||||
--b-500: 214 58 78;
|
||||
--b-600: 229 72 90;
|
||||
--b-700: 248 112 127;
|
||||
--b-800: 255 179 187;
|
||||
--b-900: 255 227 230;
|
||||
--b-950: 255 241 242;
|
||||
--g-50: 2 44 34;
|
||||
--g-100: 6 78 59;
|
||||
--g-200: 6 95 70;
|
||||
--g-300: 5 107 75;
|
||||
--g-400: 4 120 87;
|
||||
--g-500: 5 150 105;
|
||||
--g-600: 16 185 129;
|
||||
--g-700: 52 211 153;
|
||||
--g-800: 167 243 208;
|
||||
--g-900: 216 248 233;
|
||||
--g-950: 236 253 245;
|
||||
--a-fill: 63 79 230;
|
||||
--a-fill-hover: 52 66 214;
|
||||
--ink: 17 20 28;
|
||||
--rail: 236 238 243;
|
||||
--lm-shadow: 0 18px 48px rgb(20 28 50 / 0.16);
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
:root {
|
||||
/* Grounds, darkest to lightest: the rail, the page, a panel, a card. */
|
||||
--lm-rail: #12131f;
|
||||
--lm-bg: #161826;
|
||||
--lm-panel: #1b1c28;
|
||||
--lm-surface: #232532;
|
||||
--lm-radius-md: 10px;
|
||||
}
|
||||
|
||||
--lm-text: #e9e9ed;
|
||||
--lm-text-muted: rgba(233, 233, 237, 0.62);
|
||||
|
||||
/* One accent, in the product's own blurple. */
|
||||
--lm-accent: #9184d9;
|
||||
--lm-accent-pale: #d2cefd;
|
||||
|
||||
/* Edges are drawn as an inset ring, not a border, so nothing shifts by 1px
|
||||
when a surface becomes active. */
|
||||
--lm-edge: rgba(233, 233, 237, 0.09);
|
||||
--lm-edge-strong: rgba(233, 233, 237, 0.16);
|
||||
--lm-divider: rgba(233, 233, 237, 0.14);
|
||||
|
||||
--lm-radius-md: 11px;
|
||||
html {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--lm-bg);
|
||||
color: var(--lm-text);
|
||||
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background-color: rgb(var(--n-950));
|
||||
color: rgb(var(--n-100));
|
||||
font-family: 'Geist Variable', 'Geist', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
font-feature-settings: 'cv11', 'ss01';
|
||||
overflow-x: hidden;
|
||||
/* A theme change is a crossfade, not a flash. Skipped for people who ask for
|
||||
less motion, below. */
|
||||
transition: background-color 0.2s ease, color 0.2s ease;
|
||||
}
|
||||
|
||||
/* Hierarchy here is size and space; headings never go past weight 500. */
|
||||
/* Hierarchy is size and weight; headings are tight and never shout. */
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.015em;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
/* Keyboard focus is themed everywhere rather than left to the browser. */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--lm-accent);
|
||||
outline: 2px solid rgb(var(--a-400));
|
||||
outline-offset: 2px;
|
||||
}
|
||||
::selection {
|
||||
background: rgba(145, 132, 217, 0.3);
|
||||
background: rgb(var(--a-500) / 0.28);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------
|
||||
Surfaces
|
||||
-------------------------------------------------------------------- */
|
||||
|
||||
/** A code or DNS-record block: the darkest ground, monospace, accent ink. */
|
||||
/** A code or DNS-record block. */
|
||||
.lm-code {
|
||||
background: var(--lm-rail);
|
||||
box-shadow: inset 0 0 0 1px var(--lm-edge);
|
||||
background: rgb(var(--rail));
|
||||
box-shadow: inset 0 0 0 1px rgb(var(--ink) / 0.09);
|
||||
border-radius: var(--lm-radius-md);
|
||||
font-family: ui-monospace, Menlo, Consolas, monospace;
|
||||
font-family: ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: var(--lm-accent-pale);
|
||||
color: rgb(var(--a-300));
|
||||
}
|
||||
|
||||
/**
|
||||
* A rule that fades to transparent at both ends rather than stopping cleanly -
|
||||
* the one Nocturne signature that has to be CSS, because Tailwind has no
|
||||
* gradient border.
|
||||
*/
|
||||
/** A rule that fades out at both ends instead of stopping dead. */
|
||||
.lm-rule {
|
||||
height: 1px;
|
||||
border: 0;
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
transparent,
|
||||
var(--lm-divider) 24px,
|
||||
var(--lm-divider) calc(100% - 24px),
|
||||
rgb(var(--ink) / 0.14) 24px,
|
||||
rgb(var(--ink) / 0.14) calc(100% - 24px),
|
||||
transparent
|
||||
);
|
||||
}
|
||||
@@ -99,58 +222,51 @@ h1, h2, h3, h4, h5, h6 {
|
||||
-------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A sidebar item. The 3px mark on the left is the accent's whole appearance
|
||||
* here - an active item is not a filled pill. Nothing in this block is sized
|
||||
* by its text: the label wraps to a second line instead of being clipped,
|
||||
* which is what broke when the interface was read in Portuguese or Spanish.
|
||||
* A sidebar item. Active is a tint of the accent behind the label, the way
|
||||
* system sidebars do it; the text does not shift and nothing is sized by it, so
|
||||
* a long label wraps instead of being clipped.
|
||||
*/
|
||||
.lm-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 9px 8px 4px;
|
||||
padding: 7px 10px;
|
||||
border-radius: var(--lm-radius-md);
|
||||
color: var(--lm-text-muted);
|
||||
color: rgb(var(--n-300));
|
||||
transition: background-color 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
.lm-nav:hover {
|
||||
background: rgba(233, 233, 237, 0.05);
|
||||
color: var(--lm-text);
|
||||
background: rgb(var(--ink) / 0.06);
|
||||
color: rgb(var(--n-100));
|
||||
}
|
||||
.lm-nav[data-active='true'] {
|
||||
background: var(--lm-surface);
|
||||
color: var(--lm-text);
|
||||
box-shadow: inset 0 0 0 1px rgba(145, 132, 217, 0.35);
|
||||
background: rgb(var(--a-500) / 0.15);
|
||||
color: rgb(var(--a-300));
|
||||
font-weight: 500;
|
||||
}
|
||||
/* The old left-hand mark is gone; the tint carries "active" now. */
|
||||
.lm-nav-mark {
|
||||
width: 3px;
|
||||
flex: none;
|
||||
align-self: stretch;
|
||||
border-radius: 9999px;
|
||||
background: transparent;
|
||||
}
|
||||
.lm-nav[data-active='true'] .lm-nav-mark {
|
||||
background: var(--lm-accent);
|
||||
display: none;
|
||||
}
|
||||
|
||||
/** A message row in the list pane. */
|
||||
.lm-row {
|
||||
display: flex;
|
||||
gap: 11px;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
padding: 12px;
|
||||
padding: 11px 12px;
|
||||
border-radius: 12px;
|
||||
transition: background-color 0.12s ease;
|
||||
}
|
||||
.lm-row:hover {
|
||||
background: rgba(233, 233, 237, 0.04);
|
||||
background: rgb(var(--ink) / 0.045);
|
||||
}
|
||||
.lm-row[data-active='true'] {
|
||||
background: var(--lm-surface);
|
||||
box-shadow: inset 0 0 0 1px rgba(145, 132, 217, 0.4);
|
||||
background: rgb(var(--a-500) / 0.13);
|
||||
box-shadow: inset 0 0 0 1px rgb(var(--a-500) / 0.32);
|
||||
}
|
||||
|
||||
/** A filter chip: an outline that takes the accent when it is the one chosen. */
|
||||
/** A filter chip: quiet until it is the one chosen. */
|
||||
.lm-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -159,16 +275,18 @@ h1, h2, h3, h4, h5, h6 {
|
||||
border-radius: 9999px;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.3;
|
||||
color: var(--lm-text-muted);
|
||||
box-shadow: inset 0 0 0 1px var(--lm-edge-strong);
|
||||
transition: color 0.15s ease, box-shadow 0.15s ease;
|
||||
color: rgb(var(--n-300));
|
||||
box-shadow: inset 0 0 0 1px rgb(var(--ink) / 0.16);
|
||||
transition: color 0.15s ease, box-shadow 0.15s ease, background-color 0.15s ease;
|
||||
}
|
||||
.lm-chip:hover {
|
||||
box-shadow: inset 0 0 0 1px var(--lm-accent);
|
||||
background: rgb(var(--ink) / 0.05);
|
||||
color: rgb(var(--n-100));
|
||||
}
|
||||
.lm-chip[data-active='true'] {
|
||||
color: var(--lm-accent);
|
||||
box-shadow: inset 0 0 0 1px var(--lm-accent);
|
||||
color: rgb(var(--a-300));
|
||||
background: rgb(var(--a-500) / 0.14);
|
||||
box-shadow: inset 0 0 0 1px rgb(var(--a-500) / 0.4);
|
||||
}
|
||||
|
||||
/** A tab inside the segmented strip the settings, domain and mailbox screens use. */
|
||||
@@ -180,17 +298,17 @@ h1, h2, h3, h4, h5, h6 {
|
||||
border-radius: 9px;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.3;
|
||||
color: var(--lm-text-muted);
|
||||
color: rgb(var(--n-300));
|
||||
transition: background-color 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
.lm-tab:hover {
|
||||
color: var(--lm-text);
|
||||
background: rgba(233, 233, 237, 0.05);
|
||||
color: rgb(var(--n-100));
|
||||
background: rgb(var(--ink) / 0.06);
|
||||
}
|
||||
.lm-tab[data-active='true'] {
|
||||
background: var(--lm-surface);
|
||||
color: var(--lm-accent);
|
||||
box-shadow: inset 0 0 0 1px rgba(145, 132, 217, 0.4);
|
||||
background: rgb(var(--n-800));
|
||||
color: rgb(var(--n-50));
|
||||
box-shadow: inset 0 0 0 1px rgb(var(--ink) / 0.12), 0 1px 2px rgb(0 0 0 / 0.12);
|
||||
}
|
||||
.lm-tabstrip {
|
||||
display: flex;
|
||||
@@ -198,12 +316,12 @@ h1, h2, h3, h4, h5, h6 {
|
||||
gap: 6px;
|
||||
padding: 4px;
|
||||
border-radius: 12px;
|
||||
background: var(--lm-panel);
|
||||
box-shadow: inset 0 0 0 1px var(--lm-edge);
|
||||
background: rgb(var(--n-900));
|
||||
box-shadow: inset 0 0 0 1px rgb(var(--ink) / 0.09);
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------
|
||||
Tables - the header and the row rules fade at their ends, as the rules do
|
||||
Tables
|
||||
-------------------------------------------------------------------- */
|
||||
|
||||
.lm-table {
|
||||
@@ -213,11 +331,9 @@ h1, h2, h3, h4, h5, h6 {
|
||||
}
|
||||
.lm-table th {
|
||||
text-align: left;
|
||||
font-size: 11px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(233, 233, 237, 0.6);
|
||||
color: rgb(var(--n-400));
|
||||
padding: 8px 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -225,34 +341,25 @@ h1, h2, h3, h4, h5, h6 {
|
||||
padding: 10px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.lm-table thead tr {
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
transparent,
|
||||
var(--lm-divider) 24px,
|
||||
var(--lm-divider) calc(100% - 24px),
|
||||
transparent
|
||||
)
|
||||
no-repeat bottom / 100% 1px;
|
||||
}
|
||||
.lm-table thead tr,
|
||||
.lm-table tbody tr {
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
transparent,
|
||||
rgba(233, 233, 237, 0.08) 24px,
|
||||
rgba(233, 233, 237, 0.08) calc(100% - 24px),
|
||||
rgb(var(--ink) / 0.1) 24px,
|
||||
rgb(var(--ink) / 0.1) calc(100% - 24px),
|
||||
transparent
|
||||
)
|
||||
no-repeat bottom / 100% 1px;
|
||||
}
|
||||
.lm-table tbody tr:hover {
|
||||
background:
|
||||
linear-gradient(rgba(233, 233, 237, 0.04), rgba(233, 233, 237, 0.04)) no-repeat 0 0 / 100% 100%,
|
||||
linear-gradient(rgb(var(--ink) / 0.045), rgb(var(--ink) / 0.045)) no-repeat 0 0 / 100% 100%,
|
||||
linear-gradient(
|
||||
to right,
|
||||
transparent,
|
||||
rgba(233, 233, 237, 0.08) 24px,
|
||||
rgba(233, 233, 237, 0.08) calc(100% - 24px),
|
||||
rgb(var(--ink) / 0.1) 24px,
|
||||
rgb(var(--ink) / 0.1) calc(100% - 24px),
|
||||
transparent
|
||||
)
|
||||
no-repeat bottom / 100% 1px;
|
||||
@@ -266,21 +373,17 @@ h1, h2, h3, h4, h5, h6 {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #3f424d;
|
||||
background: rgb(var(--n-700));
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #595d6c;
|
||||
background: rgb(var(--n-600));
|
||||
}
|
||||
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #3f424d transparent;
|
||||
scrollbar-color: rgb(var(--n-700)) transparent;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
|
||||
<!-- a lambda built as two square-cut strokes, on a tile with continuous corners -->
|
||||
<path d="M64.00 32.00 L64.00 38.34 L63.99 40.36 L63.98 41.83 L63.97 43.03 L63.95 44.06 L63.93 44.97 L63.90 45.79 L63.88 46.54 L63.84 47.24 L63.80 47.89 L63.76 48.50 L63.72 49.07 L63.67 49.62 L63.62 50.14 L63.56 50.64 L63.50 51.11 L63.43 51.57 L63.36 52.01 L63.29 52.43 L63.21 52.83 L63.13 53.23 L63.05 53.61 L62.96 53.97 L62.86 54.33 L62.77 54.67 L62.66 55.01 L62.56 55.33 L62.45 55.65 L62.33 55.95 L62.21 56.25 L62.09 56.54 L61.96 56.82 L61.83 57.10 L61.69 57.36 L61.55 57.62 L61.40 57.87 L61.25 58.12 L61.09 58.36 L60.93 58.59 L60.76 58.81 L60.59 59.04 L60.42 59.25 L60.24 59.46 L60.05 59.66 L59.86 59.86 L59.66 60.05 L59.46 60.24 L59.25 60.42 L59.04 60.59 L58.81 60.76 L58.59 60.93 L58.36 61.09 L58.12 61.25 L57.87 61.40 L57.62 61.55 L57.36 61.69 L57.10 61.83 L56.82 61.96 L56.54 62.09 L56.25 62.21 L55.95 62.33 L55.65 62.45 L55.33 62.56 L55.01 62.66 L54.67 62.77 L54.33 62.86 L53.97 62.96 L53.61 63.05 L53.23 63.13 L52.83 63.21 L52.43 63.29 L52.01 63.36 L51.57 63.43 L51.11 63.50 L50.64 63.56 L50.14 63.62 L49.62 63.67 L49.07 63.72 L48.50 63.76 L47.89 63.80 L47.24 63.84 L46.54 63.88 L45.79 63.90 L44.97 63.93 L44.06 63.95 L43.03 63.97 L41.83 63.98 L40.36 63.99 L38.34 64.00 L32.00 64.00 L25.66 64.00 L23.64 63.99 L22.17 63.98 L20.97 63.97 L19.94 63.95 L19.03 63.93 L18.21 63.90 L17.46 63.88 L16.76 63.84 L16.11 63.80 L15.50 63.76 L14.93 63.72 L14.38 63.67 L13.86 63.62 L13.36 63.56 L12.89 63.50 L12.43 63.43 L11.99 63.36 L11.57 63.29 L11.17 63.21 L10.77 63.13 L10.39 63.05 L10.03 62.96 L9.67 62.86 L9.33 62.77 L8.99 62.66 L8.67 62.56 L8.35 62.45 L8.05 62.33 L7.75 62.21 L7.46 62.09 L7.18 61.96 L6.90 61.83 L6.64 61.69 L6.38 61.55 L6.13 61.40 L5.88 61.25 L5.64 61.09 L5.41 60.93 L5.19 60.76 L4.96 60.59 L4.75 60.42 L4.54 60.24 L4.34 60.05 L4.14 59.86 L3.95 59.66 L3.76 59.46 L3.58 59.25 L3.41 59.04 L3.24 58.81 L3.07 58.59 L2.91 58.36 L2.75 58.12 L2.60 57.87 L2.45 57.62 L2.31 57.36 L2.17 57.10 L2.04 56.82 L1.91 56.54 L1.79 56.25 L1.67 55.95 L1.55 55.65 L1.44 55.33 L1.34 55.01 L1.23 54.67 L1.14 54.33 L1.04 53.97 L0.95 53.61 L0.87 53.23 L0.79 52.83 L0.71 52.43 L0.64 52.01 L0.57 51.57 L0.50 51.11 L0.44 50.64 L0.38 50.14 L0.33 49.62 L0.28 49.07 L0.24 48.50 L0.20 47.89 L0.16 47.24 L0.12 46.54 L0.10 45.79 L0.07 44.97 L0.05 44.06 L0.03 43.03 L0.02 41.83 L0.01 40.36 L0.00 38.34 L0.00 32.00 L0.00 25.66 L0.01 23.64 L0.02 22.17 L0.03 20.97 L0.05 19.94 L0.07 19.03 L0.10 18.21 L0.12 17.46 L0.16 16.76 L0.20 16.11 L0.24 15.50 L0.28 14.93 L0.33 14.38 L0.38 13.86 L0.44 13.36 L0.50 12.89 L0.57 12.43 L0.64 11.99 L0.71 11.57 L0.79 11.17 L0.87 10.77 L0.95 10.39 L1.04 10.03 L1.14 9.67 L1.23 9.33 L1.34 8.99 L1.44 8.67 L1.55 8.35 L1.67 8.05 L1.79 7.75 L1.91 7.46 L2.04 7.18 L2.17 6.90 L2.31 6.64 L2.45 6.38 L2.60 6.13 L2.75 5.88 L2.91 5.64 L3.07 5.41 L3.24 5.19 L3.41 4.96 L3.58 4.75 L3.76 4.54 L3.95 4.34 L4.14 4.14 L4.34 3.95 L4.54 3.76 L4.75 3.58 L4.96 3.41 L5.19 3.24 L5.41 3.07 L5.64 2.91 L5.88 2.75 L6.13 2.60 L6.38 2.45 L6.64 2.31 L6.90 2.17 L7.18 2.04 L7.46 1.91 L7.75 1.79 L8.05 1.67 L8.35 1.55 L8.67 1.44 L8.99 1.34 L9.33 1.23 L9.67 1.14 L10.03 1.04 L10.39 0.95 L10.77 0.87 L11.17 0.79 L11.57 0.71 L11.99 0.64 L12.43 0.57 L12.89 0.50 L13.36 0.44 L13.86 0.38 L14.38 0.33 L14.93 0.28 L15.50 0.24 L16.11 0.20 L16.76 0.16 L17.46 0.12 L18.21 0.10 L19.03 0.07 L19.94 0.05 L20.97 0.03 L22.17 0.02 L23.64 0.01 L25.66 0.00 L32.00 0.00 L38.34 0.00 L40.36 0.01 L41.83 0.02 L43.03 0.03 L44.06 0.05 L44.97 0.07 L45.79 0.10 L46.54 0.12 L47.24 0.16 L47.89 0.20 L48.50 0.24 L49.07 0.28 L49.62 0.33 L50.14 0.38 L50.64 0.44 L51.11 0.50 L51.57 0.57 L52.01 0.64 L52.43 0.71 L52.83 0.79 L53.23 0.87 L53.61 0.95 L53.97 1.04 L54.33 1.14 L54.67 1.23 L55.01 1.34 L55.33 1.44 L55.65 1.55 L55.95 1.67 L56.25 1.79 L56.54 1.91 L56.82 2.04 L57.10 2.17 L57.36 2.31 L57.62 2.45 L57.87 2.60 L58.12 2.75 L58.36 2.91 L58.59 3.07 L58.81 3.24 L59.04 3.41 L59.25 3.58 L59.46 3.76 L59.66 3.95 L59.86 4.14 L60.05 4.34 L60.24 4.54 L60.42 4.75 L60.59 4.96 L60.76 5.19 L60.93 5.41 L61.09 5.64 L61.25 5.88 L61.40 6.13 L61.55 6.38 L61.69 6.64 L61.83 6.90 L61.96 7.18 L62.09 7.46 L62.21 7.75 L62.33 8.05 L62.45 8.35 L62.56 8.67 L62.66 8.99 L62.77 9.33 L62.86 9.67 L62.96 10.03 L63.05 10.39 L63.13 10.77 L63.21 11.17 L63.29 11.57 L63.36 11.99 L63.43 12.43 L63.50 12.89 L63.56 13.36 L63.62 13.86 L63.67 14.38 L63.72 14.93 L63.76 15.50 L63.80 16.11 L63.84 16.76 L63.88 17.46 L63.90 18.21 L63.93 19.03 L63.95 19.94 L63.97 20.97 L63.98 22.17 L63.99 23.64 L64.00 25.66Z" fill="#1B2BD0"/>
|
||||
<path transform="translate(-0.9 0)" d="M15.27 10.00 L23.13 10.00 L48.73 54.00 L40.87 54.00Z M29.96 28.49 L34.04 35.51 L24.80 54.00 L17.20 54.00Z" fill="#fff"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.7 KiB |
@@ -1,12 +1,30 @@
|
||||
import "@fontsource-variable/geist";
|
||||
import "./globals.css";
|
||||
import React from "react";
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { I18nProvider } from "../i18n/provider";
|
||||
import { LOCALE_COOKIE, isLocale, negotiateLocale } from "../i18n/config";
|
||||
import { THEME_BOOT_SCRIPT } from "../lib/theme";
|
||||
|
||||
export const metadata = {
|
||||
title: "LambdaMail - Secure Self-Hosted Webmail & Admin Console",
|
||||
description: "Modern, open-source email server web interface with 2FA TOTP and surface isolation.",
|
||||
export const metadata: Metadata = {
|
||||
title: { default: "LambdaMail", template: "%s - LambdaMail" },
|
||||
description: "Your own mail server, with a webmail that is a pleasure to use.",
|
||||
applicationName: "LambdaMail",
|
||||
// The tab icon, the home-screen icon and the install manifest all come from
|
||||
// the files next to this one (icon.svg, favicon.ico, apple-icon.png) and
|
||||
// from manifest.ts.
|
||||
appleWebApp: { capable: true, title: "LambdaMail", statusBarStyle: "black-translucent" },
|
||||
};
|
||||
|
||||
// The browser's own chrome follows the theme: the address bar on a phone is
|
||||
// the page's ground in dark, and the paper in light.
|
||||
export const viewport: Viewport = {
|
||||
themeColor: [
|
||||
{ media: "(prefers-color-scheme: light)", color: "#F3F4F7" },
|
||||
{ media: "(prefers-color-scheme: dark)", color: "#0F1218" },
|
||||
],
|
||||
colorScheme: "dark light",
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -27,7 +45,12 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
||||
const locale = await resolveLocale();
|
||||
|
||||
return (
|
||||
<html lang={locale} className="dark">
|
||||
// data-theme is set by the script below before the page paints, and React
|
||||
// must not "correct" it back when it hydrates.
|
||||
<html lang={locale} data-theme="dark" suppressHydrationWarning>
|
||||
<head>
|
||||
<script dangerouslySetInnerHTML={{ __html: THEME_BOOT_SCRIPT }} />
|
||||
</head>
|
||||
<body className="min-h-screen bg-dark-bg font-sans text-slate-100 antialiased">
|
||||
<I18nProvider locale={locale}>{children}</I18nProvider>
|
||||
</body>
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
|
||||
/**
|
||||
* What lets the webmail be installed like an app: its own window, its own
|
||||
* icon, and a splash that matches the ground it opens onto.
|
||||
*/
|
||||
export default function manifest(): MetadataRoute.Manifest {
|
||||
return {
|
||||
name: "LambdaMail",
|
||||
short_name: "LambdaMail",
|
||||
description: "Your own mail server, with a webmail that is a pleasure to use.",
|
||||
start_url: "/user/mail/inbox",
|
||||
scope: "/",
|
||||
display: "standalone",
|
||||
background_color: "#0F1218",
|
||||
theme_color: "#0F1218",
|
||||
icons: [
|
||||
{ src: "/icons/icon-192.png", sizes: "192x192", type: "image/png" },
|
||||
{ src: "/icons/icon-512.png", sizes: "512x512", type: "image/png" },
|
||||
{ src: "/icons/maskable-512.png", sizes: "512x512", type: "image/png", purpose: "maskable" },
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import Link from "next/link";
|
||||
import { Check, ChevronsUpDown, LogOut, Plus, Settings } from "lucide-react";
|
||||
import { useTranslations } from "../i18n/provider";
|
||||
import { Avatar } from "./Avatar";
|
||||
import { ThemeToggle } from "./ThemeToggle";
|
||||
|
||||
interface SignedInAccount {
|
||||
id: string;
|
||||
@@ -73,7 +74,7 @@ export function AccountSwitcher({ onNavigate }: { onNavigate?: () => void }) {
|
||||
};
|
||||
|
||||
const item =
|
||||
"flex w-full items-center gap-2.5 rounded-lg px-2.5 py-2 text-left text-[13px] transition-colors hover:bg-white/[0.07]";
|
||||
"flex w-full items-center gap-2.5 rounded-lg px-2.5 py-2 text-left text-[13px] transition-colors hover:bg-ink/[0.07]";
|
||||
|
||||
return (
|
||||
<div ref={root} className="relative">
|
||||
@@ -85,7 +86,7 @@ export function AccountSwitcher({ onNavigate }: { onNavigate?: () => void }) {
|
||||
title={t("accounts.switchAccount")}
|
||||
className="flex w-full items-center gap-2.5 rounded-xl bg-dark-panel p-2.5 text-left shadow-edge transition-colors hover:bg-dark-card"
|
||||
>
|
||||
<Avatar address={active?.email} size={30} className="bg-indigo-900 text-indigo-300 shadow-[inset_0_0_0_1px_#5d5294]" />
|
||||
<Avatar address={active?.email} size={30} className="bg-indigo-900 text-indigo-300 shadow-[inset_0_0_0_1px_rgb(var(--a-700))]" />
|
||||
<span className="min-w-0 flex-1 truncate text-[12.5px]">{active?.email ?? t("common.loading")}</span>
|
||||
<ChevronsUpDown className="h-3.5 w-3.5 flex-none text-slate-400" />
|
||||
</button>
|
||||
@@ -93,7 +94,7 @@ export function AccountSwitcher({ onNavigate }: { onNavigate?: () => void }) {
|
||||
{open && (
|
||||
<div
|
||||
role="menu"
|
||||
className="absolute inset-x-0 bottom-full z-30 mb-2 rounded-xl bg-dark-panel p-1.5 shadow-edge ring-1 ring-white/10"
|
||||
className="absolute inset-x-0 bottom-full z-30 mb-2 rounded-xl bg-dark-panel p-1.5 shadow-edge ring-1 ring-ink/10"
|
||||
>
|
||||
{accounts.map((account) => (
|
||||
<button
|
||||
@@ -112,7 +113,13 @@ export function AccountSwitcher({ onNavigate }: { onNavigate?: () => void }) {
|
||||
<Plus className="h-4 w-4 flex-none text-slate-400" />
|
||||
{t("accounts.addAccount")}
|
||||
</Link>
|
||||
<div className="my-1 h-px bg-white/10" />
|
||||
<div className="my-1 h-px bg-ink/10" />
|
||||
{/* Three choices in one row, so changing the look is one click. */}
|
||||
<div className="flex items-center gap-2 px-2.5 py-1.5">
|
||||
<span className="flex-1 text-[12.5px] text-slate-400">{t("appearance.title")}</span>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
<div className="my-1 h-px bg-ink/10" />
|
||||
<Link href="/user/settings" role="menuitem" onClick={onNavigate} className={item}>
|
||||
<Settings className="h-4 w-4 flex-none text-slate-400" />
|
||||
{t("accounts.settings")}
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
Archive,
|
||||
AlertTriangle,
|
||||
CornerDownLeft,
|
||||
FileText,
|
||||
Folder as FolderIcon,
|
||||
Inbox,
|
||||
Laptop,
|
||||
LogOut,
|
||||
Moon,
|
||||
PenSquare,
|
||||
Plus,
|
||||
Search,
|
||||
Send,
|
||||
Settings,
|
||||
ShieldCheck,
|
||||
Sun,
|
||||
Trash2,
|
||||
UserRound,
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "../i18n/provider";
|
||||
import { useFolders } from "../lib/useFolders";
|
||||
import { customFolders } from "../lib/mailCounts";
|
||||
import { setThemeChoice } from "../lib/theme";
|
||||
|
||||
/** Fired to ask the open mail list to show a search. */
|
||||
export const SEARCH_EVENT = "lm:search";
|
||||
|
||||
interface Command {
|
||||
id: string;
|
||||
group: string;
|
||||
label: string;
|
||||
/** Extra words the label should also be found by. */
|
||||
keywords?: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
hint?: string;
|
||||
run: () => void;
|
||||
}
|
||||
|
||||
interface SignedIn {
|
||||
id: string;
|
||||
email: string;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The one place to reach anything: Ctrl+K or Cmd+K opens it.
|
||||
*
|
||||
* The screens stay quiet - a few folders, one main button - because everything
|
||||
* else is a few keystrokes away here. Typing text that matches no command
|
||||
* offers to search the mail for it, so it is also the search box.
|
||||
*
|
||||
* `c` writes a new message and `/` goes to the search field, from anywhere that
|
||||
* is not itself a place to type.
|
||||
*/
|
||||
export function CommandPalette({ isAdmin }: { isAdmin: boolean }) {
|
||||
const t = useTranslations();
|
||||
const router = useRouter();
|
||||
const { folders } = useFolders();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const [cursor, setCursor] = useState(0);
|
||||
const [accounts, setAccounts] = useState<SignedIn[]>([]);
|
||||
const input = useRef<HTMLInputElement>(null);
|
||||
const previouslyFocused = useRef<HTMLElement | null>(null);
|
||||
|
||||
const close = useCallback(() => {
|
||||
setOpen(false);
|
||||
setQuery("");
|
||||
setCursor(0);
|
||||
previouslyFocused.current?.focus?.();
|
||||
}, []);
|
||||
|
||||
// Opening, and the two single-key shortcuts.
|
||||
useEffect(() => {
|
||||
const typingIn = (target: EventTarget | null) => {
|
||||
const el = target as HTMLElement | null;
|
||||
if (!el) return false;
|
||||
return el.isContentEditable || ["INPUT", "TEXTAREA", "SELECT"].includes(el.tagName);
|
||||
};
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
|
||||
event.preventDefault();
|
||||
previouslyFocused.current = document.activeElement as HTMLElement | null;
|
||||
setOpen((v) => !v);
|
||||
return;
|
||||
}
|
||||
if (event.metaKey || event.ctrlKey || event.altKey || typingIn(event.target)) return;
|
||||
if (event.key === "c") {
|
||||
event.preventDefault();
|
||||
router.push("/user/compose");
|
||||
} else if (event.key === "/") {
|
||||
const field = document.getElementById("mail-search");
|
||||
if (field) {
|
||||
event.preventDefault();
|
||||
field.focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
input.current?.focus();
|
||||
fetch("/api/v1/auth/accounts", { cache: "no-store" })
|
||||
.then((res) => (res.ok ? res.json() : { accounts: [] }))
|
||||
.then((data) => setAccounts(data.accounts ?? []))
|
||||
.catch(() => undefined);
|
||||
}, [open]);
|
||||
|
||||
const go = useCallback(
|
||||
(href: string) => () => {
|
||||
close();
|
||||
router.push(href);
|
||||
},
|
||||
[close, router],
|
||||
);
|
||||
|
||||
const commands = useMemo<Command[]>(() => {
|
||||
const list: Command[] = [
|
||||
{ id: "compose", group: t("palette.actions"), label: t("mail.compose"), keywords: "new write message nova escrever", icon: PenSquare, hint: "C", run: go("/user/compose") },
|
||||
{ id: "settings", group: t("palette.actions"), label: t("settings.title"), keywords: "preferences signature filters", icon: Settings, run: go("/user/settings") },
|
||||
{ id: "inbox", group: t("palette.navigate"), label: t("mail.inbox"), icon: Inbox, run: go("/user/mail/inbox") },
|
||||
{ id: "sent", group: t("palette.navigate"), label: t("mail.sent"), icon: Send, run: go("/user/mail/sent") },
|
||||
{ id: "drafts", group: t("palette.navigate"), label: t("mail.drafts"), icon: FileText, run: go("/user/mail/drafts") },
|
||||
{ id: "archive", group: t("palette.navigate"), label: t("mail.archive"), icon: Archive, run: go("/user/mail/archive") },
|
||||
{ id: "junk", group: t("palette.navigate"), label: t("mail.junk"), keywords: "spam", icon: AlertTriangle, run: go("/user/mail/junk") },
|
||||
{ id: "trash", group: t("palette.navigate"), label: t("mail.trash"), keywords: "bin lixeira papelera", icon: Trash2, run: go("/user/mail/trash") },
|
||||
{ id: "reports", group: t("palette.navigate"), label: t("mail.reports"), icon: ShieldCheck, run: go("/user/mail/reports") },
|
||||
...customFolders(folders).map<Command>((folder) => ({
|
||||
id: `folder-${folder.name}`,
|
||||
group: t("palette.navigate"),
|
||||
label: folder.name,
|
||||
icon: FolderIcon,
|
||||
run: go(`/user/mail/${encodeURIComponent(folder.name)}`),
|
||||
})),
|
||||
{ id: "theme-dark", group: t("palette.theme"), label: t("appearance.dark"), keywords: "theme tema", icon: Moon, run: () => { setThemeChoice("dark"); close(); } },
|
||||
{ id: "theme-light", group: t("palette.theme"), label: t("appearance.light"), keywords: "theme tema", icon: Sun, run: () => { setThemeChoice("light"); close(); } },
|
||||
{ id: "theme-system", group: t("palette.theme"), label: t("appearance.system"), keywords: "theme tema auto", icon: Laptop, run: () => { setThemeChoice("system"); close(); } },
|
||||
...accounts
|
||||
.filter((a) => !a.active)
|
||||
.map<Command>((a) => ({
|
||||
id: `account-${a.id}`,
|
||||
group: t("palette.accounts"),
|
||||
label: t("palette.switchTo", { email: a.email }),
|
||||
keywords: a.email,
|
||||
icon: UserRound,
|
||||
run: () => {
|
||||
void fetch("/api/v1/auth/switch", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ id: a.id }),
|
||||
}).then((res) => {
|
||||
if (res.ok) window.location.href = "/user/mail/inbox";
|
||||
});
|
||||
},
|
||||
})),
|
||||
{ id: "add-account", group: t("palette.accounts"), label: t("accounts.addAccount"), icon: Plus, run: go("/user/login?add=1") },
|
||||
{
|
||||
id: "signout",
|
||||
group: t("palette.accounts"),
|
||||
label: t("palette.signOut"),
|
||||
icon: LogOut,
|
||||
run: () => {
|
||||
void fetch("/api/v1/auth/logout", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
.then((res) => (res.ok ? res.json() : { remaining: [] as unknown[] }))
|
||||
.then((data: { remaining?: unknown[] }) => {
|
||||
window.location.href = data.remaining?.length ? "/user/mail/inbox" : "/user/login";
|
||||
});
|
||||
},
|
||||
},
|
||||
];
|
||||
if (isAdmin) {
|
||||
// Next to the other actions, not at the end of the list, so the group stays whole.
|
||||
list.splice(2, 0, { id: "admin", group: t("palette.actions"), label: t("admin.openAdminShort"), icon: ShieldCheck, run: go("/admin/dashboard") });
|
||||
}
|
||||
return list;
|
||||
}, [t, go, close, folders, accounts, isAdmin]);
|
||||
|
||||
const shown = useMemo(() => {
|
||||
const words = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
|
||||
if (words.length === 0) return commands;
|
||||
return commands.filter((c) => {
|
||||
const haystack = `${c.label} ${c.keywords ?? ""} ${c.group}`.toLowerCase();
|
||||
return words.every((w) => haystack.includes(w));
|
||||
});
|
||||
}, [commands, query]);
|
||||
|
||||
// Typing something no command matches is a search, so Enter is never a dead key.
|
||||
const searchText = query.trim();
|
||||
const items: Command[] = useMemo(() => {
|
||||
if (searchText.length === 0) return shown;
|
||||
const search: Command = {
|
||||
id: "search",
|
||||
group: t("palette.actions"),
|
||||
label: t("palette.searchFor", { query: searchText }),
|
||||
icon: Search,
|
||||
run: () => {
|
||||
close();
|
||||
router.push("/user/mail/inbox");
|
||||
// The list mounts after the route changes, so the request waits a beat.
|
||||
setTimeout(() => window.dispatchEvent(new CustomEvent(SEARCH_EVENT, { detail: searchText })), 250);
|
||||
},
|
||||
};
|
||||
return shown.length === 0 ? [search] : [...shown, search];
|
||||
}, [shown, searchText, t, close, router]);
|
||||
|
||||
useEffect(() => setCursor(0), [query]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const onKeyDown = (event: React.KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
close();
|
||||
} else if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
setCursor((c) => (c + 1) % Math.max(items.length, 1));
|
||||
} else if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
setCursor((c) => (c - 1 + items.length) % Math.max(items.length, 1));
|
||||
} else if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
items[cursor]?.run();
|
||||
}
|
||||
};
|
||||
|
||||
// Group headings appear where the group changes, in the order given.
|
||||
let lastGroup = "";
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-start justify-center px-4 pt-[14vh]" onKeyDown={onKeyDown}>
|
||||
<div className="absolute inset-0 bg-black/45 backdrop-blur-[2px]" onClick={close} aria-hidden />
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t("palette.open")}
|
||||
className="relative flex max-h-[min(520px,70vh)] w-full max-w-[580px] flex-col overflow-hidden rounded-2xl bg-dark-panel shadow-lift"
|
||||
>
|
||||
<div className="flex items-center gap-3 px-4 shadow-[inset_0_-1px_0_0_rgb(var(--ink)/0.09)]">
|
||||
<Search className="h-4 w-4 flex-none text-slate-400" aria-hidden />
|
||||
<input
|
||||
ref={input}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={t("palette.placeholder")}
|
||||
aria-label={t("palette.placeholder")}
|
||||
role="combobox"
|
||||
aria-expanded="true"
|
||||
aria-controls="lm-palette-list"
|
||||
aria-activedescendant={items[cursor] ? `lm-cmd-${items[cursor].id}` : undefined}
|
||||
className="h-12 min-w-0 flex-1 bg-transparent text-[15px] text-slate-100 placeholder-slate-500 focus:outline-none"
|
||||
/>
|
||||
<kbd className="hidden rounded-md px-1.5 py-0.5 text-[11px] text-slate-400 shadow-edge sm:block">Esc</kbd>
|
||||
</div>
|
||||
|
||||
<div id="lm-palette-list" role="listbox" className="overflow-y-auto p-1.5">
|
||||
{items.length === 0 && <div className="px-3 py-6 text-center text-[13px] text-slate-400">{t("palette.empty")}</div>}
|
||||
{items.map((item, index) => {
|
||||
const heading = item.group !== lastGroup ? item.group : null;
|
||||
lastGroup = item.group;
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<React.Fragment key={item.id}>
|
||||
{heading && <div className="px-3 pb-1 pt-2.5 text-[12px] font-medium text-slate-400">{heading}</div>}
|
||||
<button
|
||||
id={`lm-cmd-${item.id}`}
|
||||
role="option"
|
||||
aria-selected={index === cursor}
|
||||
type="button"
|
||||
onMouseMove={() => setCursor(index)}
|
||||
onClick={item.run}
|
||||
className={`flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left text-[14px] ${
|
||||
index === cursor ? "bg-indigo-500/15 text-slate-50" : "text-slate-200"
|
||||
}`}
|
||||
>
|
||||
<Icon className={`h-4 w-4 flex-none ${index === cursor ? "text-indigo-300" : "text-slate-400"}`} />
|
||||
<span className="min-w-0 flex-1 truncate">{item.label}</span>
|
||||
{item.hint && <kbd className="rounded-md px-1.5 py-0.5 text-[11px] text-slate-400 shadow-edge">{item.hint}</kbd>}
|
||||
</button>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="hidden items-center gap-4 px-4 py-2 text-[12px] text-slate-400 shadow-[inset_0_1px_0_0_rgb(var(--ink)/0.09)] sm:flex">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<kbd className="rounded px-1 shadow-edge">↑</kbd>
|
||||
<kbd className="rounded px-1 shadow-edge">↓</kbd>
|
||||
{t("palette.hintMove")}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<CornerDownLeft className="h-3 w-3" aria-hidden />
|
||||
{t("palette.hintOpen")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -378,12 +378,12 @@ export function Composer({ initial, variant = "page", onSent, onDiscarded }: Com
|
||||
};
|
||||
|
||||
/** One row of the compose sheet: a fixed-width label beside the control. */
|
||||
const fieldRow = "flex flex-wrap items-center gap-x-3 gap-y-2 px-4 py-2.5 shadow-[inset_0_-1px_0_0_rgba(233,233,237,0.07)]";
|
||||
const fieldRow = "flex flex-wrap items-center gap-x-3 gap-y-2 px-4 py-2.5 shadow-[inset_0_-1px_0_0_rgb(var(--ink)/0.07)]";
|
||||
const fieldLabel = "w-14 flex-none text-[12.5px] leading-snug text-slate-400";
|
||||
const bareInput =
|
||||
"min-h-[30px] min-w-[160px] flex-1 border-0 bg-transparent text-sm text-slate-100 placeholder-slate-500 focus:outline-none";
|
||||
const toolButton =
|
||||
"flex h-8 w-8 items-center justify-center rounded-lg text-slate-300 transition-colors hover:bg-white/[0.07] hover:text-slate-100";
|
||||
"flex h-8 w-8 items-center justify-center rounded-lg text-slate-300 transition-colors hover:bg-ink/[0.07] hover:text-slate-100";
|
||||
|
||||
return (
|
||||
<div className={inline ? "" : "flex-1 overflow-y-auto bg-dark-bg px-5 pb-11 pt-7 sm:px-8"}>
|
||||
@@ -409,7 +409,7 @@ export function Composer({ initial, variant = "page", onSent, onDiscarded }: Com
|
||||
disabled={sending}
|
||||
title={t("mail.highImportanceHint")}
|
||||
className={`inline-flex h-10 items-center gap-2 rounded-xl px-3.5 text-sm shadow-edge transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400 ${
|
||||
important ? "bg-rose-900/70 text-rose-100" : "bg-dark-card text-slate-200 hover:bg-white/[0.07]"
|
||||
important ? "bg-rose-900/70 text-rose-100" : "bg-dark-card text-slate-200 hover:bg-ink/[0.07]"
|
||||
}`}
|
||||
>
|
||||
<Flag className="h-4 w-4" fill={important ? "currentColor" : "none"} />
|
||||
@@ -531,7 +531,7 @@ export function Composer({ initial, variant = "page", onSent, onDiscarded }: Com
|
||||
)}
|
||||
|
||||
{/* Formatting Toolbar */}
|
||||
<div className="flex flex-wrap items-center gap-0.5 px-3 py-2 shadow-[inset_0_-1px_0_0_rgba(233,233,237,0.07)]">
|
||||
<div className="flex flex-wrap items-center gap-0.5 px-3 py-2 shadow-[inset_0_-1px_0_0_rgb(var(--ink)/0.07)]">
|
||||
<button type="button" onClick={() => executeFormat("bold")} className={toolButton} title={t("mail.bold")}>
|
||||
<Bold className="h-4 w-4" />
|
||||
</button>
|
||||
@@ -554,7 +554,7 @@ export function Composer({ initial, variant = "page", onSent, onDiscarded }: Com
|
||||
>
|
||||
<Strikethrough className="h-4 w-4" />
|
||||
</button>
|
||||
<span className="mx-1.5 h-[18px] w-px bg-white/[0.14]" />
|
||||
<span className="mx-1.5 h-[18px] w-px bg-ink/[0.14]" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => executeFormat("insertUnorderedList")}
|
||||
@@ -571,7 +571,7 @@ export function Composer({ initial, variant = "page", onSent, onDiscarded }: Com
|
||||
>
|
||||
<ListOrdered className="h-4 w-4" />
|
||||
</button>
|
||||
<span className="mx-1.5 h-[18px] w-px bg-white/[0.14]" />
|
||||
<span className="mx-1.5 h-[18px] w-px bg-ink/[0.14]" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
@@ -598,8 +598,8 @@ export function Composer({ initial, variant = "page", onSent, onDiscarded }: Com
|
||||
/>
|
||||
|
||||
{/* Attachment Selector & Display */}
|
||||
<div className="flex flex-wrap items-center gap-2.5 px-4 py-3.5 shadow-[inset_0_1px_0_0_rgba(233,233,237,0.07)]">
|
||||
<label className="flex flex-none cursor-pointer items-center gap-2 rounded-[10px] border border-white/[0.14] px-3 py-2 text-[13px] text-slate-200 transition-colors hover:bg-white/[0.07]">
|
||||
<div className="flex flex-wrap items-center gap-2.5 px-4 py-3.5 shadow-[inset_0_1px_0_0_rgb(var(--ink)/0.07)]">
|
||||
<label className="flex flex-none cursor-pointer items-center gap-2 rounded-[10px] border border-ink/[0.14] px-3 py-2 text-[13px] text-slate-200 transition-colors hover:bg-ink/[0.07]">
|
||||
<Paperclip className="h-[15px] w-[15px]" />
|
||||
<span>{t("mail.addFiles")}</span>
|
||||
<input type="file" multiple onChange={handleFileUpload} className="hidden" />
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import React from "react";
|
||||
|
||||
/**
|
||||
* The LambdaMail mark: a lambda built as two square-cut strokes of one weight,
|
||||
* white on an ultramarine tile with continuous corners. The same drawing as
|
||||
* app/icon.svg and the generated icons, inline so it costs no request and takes
|
||||
* any size. Flat on purpose: it has to hold at 16 pixels and on either theme.
|
||||
*/
|
||||
const TILE = "M64.00 32.00 L64.00 38.34 L63.99 40.36 L63.98 41.83 L63.97 43.03 L63.95 44.06 L63.93 44.97 L63.90 45.79 L63.88 46.54 L63.84 47.24 L63.80 47.89 L63.76 48.50 L63.72 49.07 L63.67 49.62 L63.62 50.14 L63.56 50.64 L63.50 51.11 L63.43 51.57 L63.36 52.01 L63.29 52.43 L63.21 52.83 L63.13 53.23 L63.05 53.61 L62.96 53.97 L62.86 54.33 L62.77 54.67 L62.66 55.01 L62.56 55.33 L62.45 55.65 L62.33 55.95 L62.21 56.25 L62.09 56.54 L61.96 56.82 L61.83 57.10 L61.69 57.36 L61.55 57.62 L61.40 57.87 L61.25 58.12 L61.09 58.36 L60.93 58.59 L60.76 58.81 L60.59 59.04 L60.42 59.25 L60.24 59.46 L60.05 59.66 L59.86 59.86 L59.66 60.05 L59.46 60.24 L59.25 60.42 L59.04 60.59 L58.81 60.76 L58.59 60.93 L58.36 61.09 L58.12 61.25 L57.87 61.40 L57.62 61.55 L57.36 61.69 L57.10 61.83 L56.82 61.96 L56.54 62.09 L56.25 62.21 L55.95 62.33 L55.65 62.45 L55.33 62.56 L55.01 62.66 L54.67 62.77 L54.33 62.86 L53.97 62.96 L53.61 63.05 L53.23 63.13 L52.83 63.21 L52.43 63.29 L52.01 63.36 L51.57 63.43 L51.11 63.50 L50.64 63.56 L50.14 63.62 L49.62 63.67 L49.07 63.72 L48.50 63.76 L47.89 63.80 L47.24 63.84 L46.54 63.88 L45.79 63.90 L44.97 63.93 L44.06 63.95 L43.03 63.97 L41.83 63.98 L40.36 63.99 L38.34 64.00 L32.00 64.00 L25.66 64.00 L23.64 63.99 L22.17 63.98 L20.97 63.97 L19.94 63.95 L19.03 63.93 L18.21 63.90 L17.46 63.88 L16.76 63.84 L16.11 63.80 L15.50 63.76 L14.93 63.72 L14.38 63.67 L13.86 63.62 L13.36 63.56 L12.89 63.50 L12.43 63.43 L11.99 63.36 L11.57 63.29 L11.17 63.21 L10.77 63.13 L10.39 63.05 L10.03 62.96 L9.67 62.86 L9.33 62.77 L8.99 62.66 L8.67 62.56 L8.35 62.45 L8.05 62.33 L7.75 62.21 L7.46 62.09 L7.18 61.96 L6.90 61.83 L6.64 61.69 L6.38 61.55 L6.13 61.40 L5.88 61.25 L5.64 61.09 L5.41 60.93 L5.19 60.76 L4.96 60.59 L4.75 60.42 L4.54 60.24 L4.34 60.05 L4.14 59.86 L3.95 59.66 L3.76 59.46 L3.58 59.25 L3.41 59.04 L3.24 58.81 L3.07 58.59 L2.91 58.36 L2.75 58.12 L2.60 57.87 L2.45 57.62 L2.31 57.36 L2.17 57.10 L2.04 56.82 L1.91 56.54 L1.79 56.25 L1.67 55.95 L1.55 55.65 L1.44 55.33 L1.34 55.01 L1.23 54.67 L1.14 54.33 L1.04 53.97 L0.95 53.61 L0.87 53.23 L0.79 52.83 L0.71 52.43 L0.64 52.01 L0.57 51.57 L0.50 51.11 L0.44 50.64 L0.38 50.14 L0.33 49.62 L0.28 49.07 L0.24 48.50 L0.20 47.89 L0.16 47.24 L0.12 46.54 L0.10 45.79 L0.07 44.97 L0.05 44.06 L0.03 43.03 L0.02 41.83 L0.01 40.36 L0.00 38.34 L0.00 32.00 L0.00 25.66 L0.01 23.64 L0.02 22.17 L0.03 20.97 L0.05 19.94 L0.07 19.03 L0.10 18.21 L0.12 17.46 L0.16 16.76 L0.20 16.11 L0.24 15.50 L0.28 14.93 L0.33 14.38 L0.38 13.86 L0.44 13.36 L0.50 12.89 L0.57 12.43 L0.64 11.99 L0.71 11.57 L0.79 11.17 L0.87 10.77 L0.95 10.39 L1.04 10.03 L1.14 9.67 L1.23 9.33 L1.34 8.99 L1.44 8.67 L1.55 8.35 L1.67 8.05 L1.79 7.75 L1.91 7.46 L2.04 7.18 L2.17 6.90 L2.31 6.64 L2.45 6.38 L2.60 6.13 L2.75 5.88 L2.91 5.64 L3.07 5.41 L3.24 5.19 L3.41 4.96 L3.58 4.75 L3.76 4.54 L3.95 4.34 L4.14 4.14 L4.34 3.95 L4.54 3.76 L4.75 3.58 L4.96 3.41 L5.19 3.24 L5.41 3.07 L5.64 2.91 L5.88 2.75 L6.13 2.60 L6.38 2.45 L6.64 2.31 L6.90 2.17 L7.18 2.04 L7.46 1.91 L7.75 1.79 L8.05 1.67 L8.35 1.55 L8.67 1.44 L8.99 1.34 L9.33 1.23 L9.67 1.14 L10.03 1.04 L10.39 0.95 L10.77 0.87 L11.17 0.79 L11.57 0.71 L11.99 0.64 L12.43 0.57 L12.89 0.50 L13.36 0.44 L13.86 0.38 L14.38 0.33 L14.93 0.28 L15.50 0.24 L16.11 0.20 L16.76 0.16 L17.46 0.12 L18.21 0.10 L19.03 0.07 L19.94 0.05 L20.97 0.03 L22.17 0.02 L23.64 0.01 L25.66 0.00 L32.00 0.00 L38.34 0.00 L40.36 0.01 L41.83 0.02 L43.03 0.03 L44.06 0.05 L44.97 0.07 L45.79 0.10 L46.54 0.12 L47.24 0.16 L47.89 0.20 L48.50 0.24 L49.07 0.28 L49.62 0.33 L50.14 0.38 L50.64 0.44 L51.11 0.50 L51.57 0.57 L52.01 0.64 L52.43 0.71 L52.83 0.79 L53.23 0.87 L53.61 0.95 L53.97 1.04 L54.33 1.14 L54.67 1.23 L55.01 1.34 L55.33 1.44 L55.65 1.55 L55.95 1.67 L56.25 1.79 L56.54 1.91 L56.82 2.04 L57.10 2.17 L57.36 2.31 L57.62 2.45 L57.87 2.60 L58.12 2.75 L58.36 2.91 L58.59 3.07 L58.81 3.24 L59.04 3.41 L59.25 3.58 L59.46 3.76 L59.66 3.95 L59.86 4.14 L60.05 4.34 L60.24 4.54 L60.42 4.75 L60.59 4.96 L60.76 5.19 L60.93 5.41 L61.09 5.64 L61.25 5.88 L61.40 6.13 L61.55 6.38 L61.69 6.64 L61.83 6.90 L61.96 7.18 L62.09 7.46 L62.21 7.75 L62.33 8.05 L62.45 8.35 L62.56 8.67 L62.66 8.99 L62.77 9.33 L62.86 9.67 L62.96 10.03 L63.05 10.39 L63.13 10.77 L63.21 11.17 L63.29 11.57 L63.36 11.99 L63.43 12.43 L63.50 12.89 L63.56 13.36 L63.62 13.86 L63.67 14.38 L63.72 14.93 L63.76 15.50 L63.80 16.11 L63.84 16.76 L63.88 17.46 L63.90 18.21 L63.93 19.03 L63.95 19.94 L63.97 20.97 L63.98 22.17 L63.99 23.64 L64.00 25.66Z";
|
||||
const LAMBDA = "M15.27 10.00 L23.13 10.00 L48.73 54.00 L40.87 54.00Z M29.96 28.49 L34.04 35.51 L24.80 54.00 L17.20 54.00Z";
|
||||
|
||||
export function Logo({ size = 32, className = "" }: { size?: number; className?: string }) {
|
||||
return (
|
||||
<svg viewBox="0 0 64 64" width={size} height={size} className={className} role="img" aria-label="LambdaMail">
|
||||
<path d={TILE} fill="#1B2BD0" />
|
||||
{/* Shifted left a little so the glyph, not its bounding box, is centred. */}
|
||||
<path d={LAMBDA} fill="#fff" transform="translate(-0.9 0)" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import { useTranslations } from "../i18n/provider";
|
||||
* that sent it. The sandbox has no allow-same-origin: the frame stays on an
|
||||
* opaque origin and can read nothing of this page.
|
||||
*/
|
||||
export function MessageFrame({ document: html }: { document: string }) {
|
||||
export function MessageFrame({ document: html, paper = true }: { document: string; paper?: boolean }) {
|
||||
const t = useTranslations();
|
||||
const frame = useRef<HTMLIFrameElement>(null);
|
||||
const [height, setHeight] = useState(160);
|
||||
@@ -30,7 +30,7 @@ export function MessageFrame({ document: html }: { document: string }) {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-xl bg-white shadow-edge">
|
||||
<div className={paper ? "overflow-hidden rounded-xl bg-white shadow-sm ring-1 ring-ink/10" : ""}>
|
||||
<iframe
|
||||
ref={frame}
|
||||
title={t("ui.messageBody")}
|
||||
@@ -38,7 +38,7 @@ export function MessageFrame({ document: html }: { document: string }) {
|
||||
sandbox="allow-scripts allow-popups allow-popups-to-escape-sandbox"
|
||||
referrerPolicy="no-referrer"
|
||||
style={{ height: `${height}px` }}
|
||||
className="block w-full border-0"
|
||||
className="block w-full border-0 bg-transparent"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -70,6 +70,12 @@ interface Props {
|
||||
moveTargets: MoveTarget[];
|
||||
/** The document the body frame renders, or null for a text-only message. */
|
||||
document: string | null;
|
||||
/** Drawn on a white sheet: designed mail, or a plain one shown as sent. */
|
||||
paper: boolean;
|
||||
/** A plainly written message that is taking the app's colours. */
|
||||
canAdapt: boolean;
|
||||
showingOriginal: boolean;
|
||||
onToggleOriginal: () => void;
|
||||
frameHeight: number;
|
||||
/** Whether anything was held back, so the privacy bar is not always shown. */
|
||||
hasBlockedContent: boolean;
|
||||
@@ -105,21 +111,21 @@ function initialsOf(name: string, address: string): string {
|
||||
/** A short type tag and colour for an attachment, from its extension. */
|
||||
function attachmentKind(filename: string): { tag: string; className: string } {
|
||||
const ext = (filename.split(".").pop() ?? "").toLowerCase();
|
||||
if (ext === "pdf") return { tag: "PDF", className: "bg-rose-700" };
|
||||
if (["png", "jpg", "jpeg", "gif", "webp", "bmp"].includes(ext)) return { tag: "IMG", className: "bg-sky-700" };
|
||||
if (["doc", "docx", "odt", "rtf", "txt"].includes(ext)) return { tag: "DOC", className: "bg-blue-700" };
|
||||
if (["xls", "xlsx", "ods", "csv"].includes(ext)) return { tag: "XLS", className: "bg-emerald-700" };
|
||||
if (["zip", "rar", "7z", "gz", "tar"].includes(ext)) return { tag: "ZIP", className: "bg-amber-700" };
|
||||
return { tag: (ext || "FILE").slice(0, 4).toUpperCase(), className: "bg-slate-600" };
|
||||
if (ext === "pdf") return { tag: "PDF", className: "bg-[#c2352f]" };
|
||||
if (["png", "jpg", "jpeg", "gif", "webp", "bmp"].includes(ext)) return { tag: "IMG", className: "bg-[#1f7aa8]" };
|
||||
if (["doc", "docx", "odt", "rtf", "txt"].includes(ext)) return { tag: "DOC", className: "bg-[#2f5fb8]" };
|
||||
if (["xls", "xlsx", "ods", "csv"].includes(ext)) return { tag: "XLS", className: "bg-[#1f8a5b]" };
|
||||
if (["zip", "rar", "7z", "gz", "tar"].includes(ext)) return { tag: "ZIP", className: "bg-[#a8690f]" };
|
||||
return { tag: (ext || "FILE").slice(0, 4).toUpperCase(), className: "bg-[#5b6272]" };
|
||||
}
|
||||
|
||||
// One look for every control on the command bar, so the bar reads as a single
|
||||
// strip. The label hides on narrow screens and the icon stays, which is what
|
||||
// keeps six actions on one row at phone width.
|
||||
const commandClass =
|
||||
"inline-flex h-10 min-w-10 items-center justify-center gap-1.5 rounded-lg px-2.5 text-[13px] text-slate-200 transition-colors hover:bg-white/[0.07] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400 md:px-3";
|
||||
"inline-flex h-10 min-w-10 items-center justify-center gap-1.5 rounded-lg px-2.5 text-[13px] text-slate-200 transition-colors hover:bg-ink/[0.07] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400 md:px-3";
|
||||
const infoLinkClass =
|
||||
"rounded-md px-1.5 py-1 text-[12.5px] font-medium text-indigo-300 transition-colors hover:bg-white/[0.07] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400";
|
||||
"rounded-md px-1.5 py-1 text-[12.5px] font-medium text-indigo-300 transition-colors hover:bg-ink/[0.07] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400";
|
||||
|
||||
export function MessageReader(props: Props) {
|
||||
const { message, row, folder, actions } = props;
|
||||
@@ -181,7 +187,7 @@ export function MessageReader(props: Props) {
|
||||
<div
|
||||
role="toolbar"
|
||||
aria-label={t("mail.messageActions")}
|
||||
className="flex flex-none flex-wrap items-center gap-0.5 bg-dark-bg px-2 py-1.5 shadow-[0_1px_0_0_rgba(233,233,237,0.09)] sm:px-4"
|
||||
className="flex flex-none flex-wrap items-center gap-0.5 bg-dark-bg px-2 py-1.5 shadow-[0_1px_0_0_rgb(var(--ink)/0.09)] sm:px-4"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
@@ -218,7 +224,7 @@ export function MessageReader(props: Props) {
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<span aria-hidden className="mx-1 h-5 w-px bg-white/[0.12]" />
|
||||
<span aria-hidden className="mx-1 h-5 w-px bg-ink/[0.12]" />
|
||||
|
||||
<button type="button" onClick={props.onDelete} className={commandClass} title={deleteLabel}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
@@ -252,7 +258,7 @@ export function MessageReader(props: Props) {
|
||||
setMoveOpen(false);
|
||||
props.onMove(target.name);
|
||||
}}
|
||||
className="block w-full px-3.5 py-2.5 text-left text-[13px] text-slate-200 transition-colors hover:bg-white/[0.07] focus-visible:bg-white/[0.07] focus-visible:outline-none"
|
||||
className="block w-full px-3.5 py-2.5 text-left text-[13px] text-slate-200 transition-colors hover:bg-ink/[0.07] focus-visible:bg-ink/[0.07] focus-visible:outline-none"
|
||||
>
|
||||
{target.label}
|
||||
</button>
|
||||
@@ -302,14 +308,14 @@ export function MessageReader(props: Props) {
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="mx-auto flex w-full max-w-[1000px] flex-col gap-4 px-4 pb-10 pt-5 sm:px-7 2xl:max-w-[1140px]">
|
||||
<div className="mx-auto flex w-full max-w-[820px] flex-col gap-5 px-4 pb-14 pt-6 sm:px-8">
|
||||
{message.important && (
|
||||
<span className="inline-flex w-fit items-center gap-1.5 rounded-full bg-rose-900/60 px-2.5 py-0.5 text-xs font-medium text-rose-200 shadow-[inset_0_0_0_1px_rgba(244,63,94,0.45)]">
|
||||
<AlertCircle className="h-3.5 w-3.5" aria-hidden />
|
||||
{t("mail.highImportanceBadge")}
|
||||
</span>
|
||||
)}
|
||||
<h1 className="text-[19px] font-semibold leading-snug tracking-[-0.01em] text-slate-100 [overflow-wrap:anywhere] [text-wrap:pretty] sm:text-[22px]">
|
||||
<h1 className="text-[22px] font-semibold leading-[1.22] tracking-[-0.022em] text-slate-50 [overflow-wrap:anywhere] [text-wrap:balance] sm:text-[27px]">
|
||||
{message.subject || t("mail.noSubject")}
|
||||
</h1>
|
||||
|
||||
@@ -321,7 +327,7 @@ export function MessageReader(props: Props) {
|
||||
address={row?.sender_address ?? address}
|
||||
initials={initialsOf(displayName, address)}
|
||||
size={44}
|
||||
className="bg-indigo-900 text-indigo-300 shadow-[inset_0_0_0_1px_#5d5294]"
|
||||
className="bg-indigo-900 text-indigo-300 shadow-[inset_0_0_0_1px_rgb(var(--a-700))]"
|
||||
/>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
|
||||
@@ -339,7 +345,7 @@ export function MessageReader(props: Props) {
|
||||
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11.5px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400 ${
|
||||
props.senderTrusted
|
||||
? "bg-indigo-500/20 text-indigo-200 hover:bg-indigo-500/30"
|
||||
: "text-slate-400 shadow-[inset_0_0_0_1px_rgba(233,233,237,0.18)] hover:bg-white/[0.07] hover:text-slate-200"
|
||||
: "text-slate-400 shadow-[inset_0_0_0_1px_rgb(var(--ink)/0.18)] hover:bg-ink/[0.07] hover:text-slate-200"
|
||||
}`}
|
||||
>
|
||||
<ShieldCheck className="h-3 w-3" aria-hidden />
|
||||
@@ -364,7 +370,7 @@ export function MessageReader(props: Props) {
|
||||
type="button"
|
||||
onClick={() => setRecipientsOpen((open) => !open)}
|
||||
aria-expanded={recipientsOpen}
|
||||
className="ml-1 inline-flex items-center gap-0.5 rounded px-1 text-indigo-300 hover:bg-white/[0.07] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400"
|
||||
className="ml-1 inline-flex items-center gap-0.5 rounded px-1 text-indigo-300 hover:bg-ink/[0.07] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400"
|
||||
>
|
||||
{recipientsOpen ? t("mail.showFewerRecipients") : `+${hiddenCount}`}
|
||||
<ChevronDown className={`h-3 w-3 transition-transform ${recipientsOpen ? "rotate-180" : ""}`} />
|
||||
@@ -439,8 +445,22 @@ export function MessageReader(props: Props) {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{props.document && props.canAdapt && (
|
||||
<div className="-mb-2 flex justify-end">
|
||||
<button type="button" onClick={props.onToggleOriginal} className={infoLinkClass}>
|
||||
{props.showingOriginal ? t("viewer.adapt") : t("viewer.asSent")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{props.document ? (
|
||||
<div className="overflow-hidden rounded-xl bg-white shadow-edge">
|
||||
<div
|
||||
className={
|
||||
props.paper
|
||||
? "overflow-hidden rounded-2xl bg-white shadow-sm ring-1 ring-ink/10"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
<iframe
|
||||
title={t("ui.messageBody")}
|
||||
srcDoc={props.document}
|
||||
@@ -452,15 +472,13 @@ export function MessageReader(props: Props) {
|
||||
sandbox="allow-scripts allow-popups allow-popups-to-escape-sandbox"
|
||||
referrerPolicy="no-referrer"
|
||||
style={{ height: `${props.frameHeight}px` }}
|
||||
className="block w-full border-0"
|
||||
className="block w-full border-0 bg-transparent"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl bg-dark-panel px-5 py-5 shadow-edge sm:px-6">
|
||||
<pre className="whitespace-pre-wrap break-words font-sans text-[14.5px] leading-relaxed text-slate-200">
|
||||
{message.text || t("mail.emptyBody")}
|
||||
</pre>
|
||||
</div>
|
||||
<pre className="whitespace-pre-wrap break-words font-sans text-[15.5px] leading-[1.68] text-slate-100">
|
||||
{message.text || t("mail.emptyBody")}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -103,7 +103,7 @@ export function RecipientInput({ id, value, onChange, placeholder, disabled, inv
|
||||
title={parsed.valid ? parsed.address : t("mail.invalidAddress")}
|
||||
className={`inline-flex max-w-full items-center gap-1 rounded-full py-0.5 pl-2.5 pr-1 text-[12.5px] ${
|
||||
parsed.valid
|
||||
? "bg-indigo-900/70 text-indigo-100 shadow-[inset_0_0_0_1px_#5d5294]"
|
||||
? "bg-indigo-900/70 text-indigo-100 shadow-[inset_0_0_0_1px_rgb(var(--a-700))]"
|
||||
: "bg-rose-900/60 text-rose-100 shadow-[inset_0_0_0_1px_rgba(244,63,94,0.5)]"
|
||||
}`}
|
||||
>
|
||||
@@ -116,7 +116,7 @@ export function RecipientInput({ id, value, onChange, placeholder, disabled, inv
|
||||
remove(index);
|
||||
}}
|
||||
aria-label={t("mail.removeRecipient", { name: parsed.label })}
|
||||
className="flex h-5 w-5 flex-none items-center justify-center rounded-full hover:bg-white/15 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-300"
|
||||
className="flex h-5 w-5 flex-none items-center justify-center rounded-full hover:bg-ink/15 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-300"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Laptop, Moon, Sun } from "lucide-react";
|
||||
import { useTranslations } from "../i18n/provider";
|
||||
import { useTheme, type ThemeChoice } from "../lib/theme";
|
||||
|
||||
/** Dark, light or follow the system: three buttons, one click each. */
|
||||
export function ThemeToggle({ className = "" }: { className?: string }) {
|
||||
const t = useTranslations();
|
||||
const theme = useTheme();
|
||||
const options: [ThemeChoice, React.ComponentType<{ className?: string }>, string][] = [
|
||||
["dark", Moon, t("appearance.dark")],
|
||||
["light", Sun, t("appearance.light")],
|
||||
["system", Laptop, t("appearance.system")],
|
||||
];
|
||||
return (
|
||||
<div role="radiogroup" aria-label={t("appearance.title")} className={`flex rounded-lg p-0.5 shadow-edge ${className}`}>
|
||||
{options.map(([value, Icon, label]) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={theme.choice === value}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
onClick={() => theme.set(value)}
|
||||
className={`flex h-7 w-8 items-center justify-center rounded-md transition-colors ${
|
||||
theme.choice === value ? "bg-indigo-500/20 text-indigo-300" : "text-slate-400 hover:text-slate-100"
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
type ThreadSummary,
|
||||
} from "../lib/conversations";
|
||||
import { hasBlockedRemoteContent, messageDocument } from "../lib/renderMessage";
|
||||
import { useTheme } from "../lib/theme";
|
||||
import { offersReplyAll, replyAllRecipients, replyRecipients } from "../lib/recipients";
|
||||
import { plainSnippet } from "../lib/snippet";
|
||||
import { notifyMailStateChanged } from "../lib/useFolders";
|
||||
@@ -75,11 +76,11 @@ interface Props {
|
||||
}
|
||||
|
||||
const commandClass =
|
||||
"inline-flex h-10 min-w-10 items-center justify-center gap-1.5 rounded-lg px-2.5 text-[13px] text-slate-200 transition-colors hover:bg-white/[0.07] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400 md:px-3";
|
||||
"inline-flex h-10 min-w-10 items-center justify-center gap-1.5 rounded-lg px-2.5 text-[13px] text-slate-200 transition-colors hover:bg-ink/[0.07] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400 md:px-3";
|
||||
const infoLinkClass =
|
||||
"rounded-md px-1.5 py-1 text-[12.5px] font-medium text-indigo-300 transition-colors hover:bg-white/[0.07] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400";
|
||||
"rounded-md px-1.5 py-1 text-[12.5px] font-medium text-indigo-300 transition-colors hover:bg-ink/[0.07] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400";
|
||||
const replyButtonClass =
|
||||
"inline-flex h-10 items-center gap-2 rounded-xl px-4 text-[13.5px] text-slate-200 shadow-[inset_0_0_0_1px_rgba(233,233,237,0.18)] transition-colors hover:bg-white/[0.07] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400";
|
||||
"inline-flex h-10 items-center gap-2 rounded-xl px-4 text-[13.5px] text-slate-200 shadow-[inset_0_0_0_1px_rgb(var(--ink)/0.18)] transition-colors hover:bg-ink/[0.07] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400";
|
||||
|
||||
/** The name and address in a header like `Jenny <jenny@x.test>`. */
|
||||
function splitSender(from: string): { name: string; address: string } {
|
||||
@@ -97,6 +98,9 @@ function initialsOf(name: string, address: string): string {
|
||||
export function ThreadReader(props: Props) {
|
||||
const { thread, folder, ownAddress, trusted } = props;
|
||||
const { t, locale } = useI18n();
|
||||
const { theme } = useTheme();
|
||||
// Plain messages that are shown as sent instead of in the app's colours.
|
||||
const [originals, setOriginals] = useState<Set<string>>(new Set());
|
||||
|
||||
const [infos, setInfos] = useState<ThreadMessageInfo[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -306,7 +310,7 @@ export function ThreadReader(props: Props) {
|
||||
aria-label={thread.subject}
|
||||
>
|
||||
{/* The commands that apply to the whole conversation. */}
|
||||
<div className="flex flex-wrap items-center gap-0.5 px-2 py-1.5 shadow-[inset_0_-1px_0_0_rgba(233,233,237,0.09)] sm:px-4">
|
||||
<div className="flex flex-wrap items-center gap-0.5 px-2 py-1.5 shadow-[inset_0_-1px_0_0_rgb(var(--ink)/0.09)] sm:px-4">
|
||||
<button type="button" onClick={props.onClose} className={`${commandClass} md:hidden`} aria-label={t("mail.backToList")}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</button>
|
||||
@@ -320,7 +324,7 @@ export function ThreadReader(props: Props) {
|
||||
<span className="hidden sm:inline">{t("mail.replyAll")}</span>
|
||||
</button>
|
||||
)}
|
||||
<span className="mx-1 hidden h-5 w-px bg-white/10 sm:block" />
|
||||
<span className="mx-1 hidden h-5 w-px bg-ink/10 sm:block" />
|
||||
<button type="button" onClick={() => void deleteThread()} disabled={busy || inThisFolder.length === 0} className={commandClass}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<span className="hidden lg:inline">{props.deleteIsPermanent ? t("mail.deleteForever") : t("common.delete")}</span>
|
||||
@@ -339,14 +343,14 @@ export function ThreadReader(props: Props) {
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</button>
|
||||
{moveOpen && (
|
||||
<div role="menu" className="absolute left-0 top-full z-30 mt-1 min-w-[180px] rounded-xl bg-dark-panel p-1.5 shadow-edge ring-1 ring-white/10">
|
||||
<div role="menu" className="absolute left-0 top-full z-30 mt-1 min-w-[180px] rounded-xl bg-dark-panel p-1.5 shadow-edge ring-1 ring-ink/10">
|
||||
{props.moveTargets.map((target) => (
|
||||
<button
|
||||
key={target.name}
|
||||
role="menuitem"
|
||||
type="button"
|
||||
onClick={() => void moveThread(target.name)}
|
||||
className="flex w-full items-center rounded-lg px-2.5 py-2 text-left text-[13px] text-slate-200 hover:bg-white/[0.07]"
|
||||
className="flex w-full items-center rounded-lg px-2.5 py-2 text-left text-[13px] text-slate-200 hover:bg-ink/[0.07]"
|
||||
>
|
||||
{target.label}
|
||||
</button>
|
||||
@@ -365,13 +369,15 @@ export function ThreadReader(props: Props) {
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
<div className="mx-auto flex w-full max-w-[1000px] flex-col gap-3 px-4 pb-10 pt-5 sm:px-7 2xl:max-w-[1140px]">
|
||||
<div className="mx-auto flex w-full max-w-[820px] flex-col gap-3 px-4 pb-14 pt-6 sm:px-8">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="text-[19px] font-semibold leading-snug tracking-[-0.01em] text-slate-100 [overflow-wrap:anywhere] [text-wrap:pretty] sm:text-[22px]">
|
||||
<h1 className="text-[22px] font-semibold leading-[1.22] tracking-[-0.022em] text-slate-50 [overflow-wrap:anywhere] [text-wrap:balance] sm:text-[27px]">
|
||||
{thread.subject || t("mail.noSubject")}
|
||||
</h1>
|
||||
<div className="flex flex-wrap items-center gap-2 text-[12.5px] text-slate-400">
|
||||
<span>{t("conversations.messagesInConversation", { count: infos?.length ?? thread.message_count })}</span>
|
||||
{(infos?.length ?? thread.message_count) > 1 && (
|
||||
<span>{t("conversations.messagesInConversation", { count: infos?.length ?? thread.message_count })}</span>
|
||||
)}
|
||||
{infos && waitingOnMe && lastIncoming && (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11.5px] font-medium ${
|
||||
@@ -404,7 +410,7 @@ export function ThreadReader(props: Props) {
|
||||
const displayName = info.own ? t("conversations.me") : sender.name || info.from_display_name || sender.address;
|
||||
const trustedHere = loaded ? trusted.isTrusted(loaded.from) : false;
|
||||
const showImages = trustedHere || imagesOnce.has(key);
|
||||
const doc = loaded ? messageDocument(loaded, showImages) : null;
|
||||
const rendered = loaded ? messageDocument(loaded, showImages, { theme, original: originals.has(key) }) : null;
|
||||
const blocked = loaded ? hasBlockedRemoteContent(loaded) : false;
|
||||
|
||||
return (
|
||||
@@ -422,7 +428,7 @@ export function ThreadReader(props: Props) {
|
||||
address={info.sender_address}
|
||||
initials={initialsOf(displayName, info.sender_address)}
|
||||
size={isOpen ? 40 : 30}
|
||||
className="bg-indigo-900 text-indigo-300 shadow-[inset_0_0_0_1px_#5d5294]"
|
||||
className="bg-indigo-900 text-indigo-300 shadow-[inset_0_0_0_1px_rgb(var(--a-700))]"
|
||||
/>
|
||||
<span className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="flex items-baseline gap-2">
|
||||
@@ -430,7 +436,7 @@ export function ThreadReader(props: Props) {
|
||||
{displayName}
|
||||
</span>
|
||||
{isOpen && !info.own && sender.address && (
|
||||
<span className="min-w-0 truncate text-[12.5px] text-slate-400"><{sender.address}></span>
|
||||
<span className="hidden min-w-0 truncate text-[12.5px] text-slate-400 sm:inline"><{sender.address}></span>
|
||||
)}
|
||||
</span>
|
||||
{!isOpen && (
|
||||
@@ -477,7 +483,7 @@ export function ThreadReader(props: Props) {
|
||||
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11.5px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400 ${
|
||||
trustedHere
|
||||
? "bg-indigo-500/20 text-indigo-200 hover:bg-indigo-500/30"
|
||||
: "text-slate-400 shadow-[inset_0_0_0_1px_rgba(233,233,237,0.18)] hover:bg-white/[0.07] hover:text-slate-200"
|
||||
: "text-slate-400 shadow-[inset_0_0_0_1px_rgb(var(--ink)/0.18)] hover:bg-ink/[0.07] hover:text-slate-200"
|
||||
}`}
|
||||
>
|
||||
<ShieldCheck className="h-3 w-3" aria-hidden />
|
||||
@@ -486,7 +492,7 @@ export function ThreadReader(props: Props) {
|
||||
)}
|
||||
<Link
|
||||
href={`/user/compose?mode=forward&subject=${encodeURIComponent(loaded.subject)}`}
|
||||
className="ml-auto inline-flex items-center gap-1 rounded-md px-1.5 py-1 text-indigo-300 hover:bg-white/[0.07]"
|
||||
className="ml-auto inline-flex items-center gap-1 rounded-md px-1.5 py-1 text-indigo-300 hover:bg-ink/[0.07]"
|
||||
>
|
||||
<Forward className="h-3.5 w-3.5" aria-hidden />
|
||||
{t("mail.forward")}
|
||||
@@ -532,10 +538,29 @@ export function ThreadReader(props: Props) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{doc ? (
|
||||
<MessageFrame document={doc} />
|
||||
{rendered?.canAdapt && (
|
||||
<div className="-mb-1 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setOriginals((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
return next;
|
||||
})
|
||||
}
|
||||
className={infoLinkClass}
|
||||
>
|
||||
{originals.has(key) ? t("viewer.adapt") : t("viewer.asSent")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rendered ? (
|
||||
<MessageFrame document={rendered.document} paper={rendered.paper} />
|
||||
) : (
|
||||
<pre className="whitespace-pre-wrap break-words rounded-xl bg-dark-bg px-4 py-4 font-sans text-[14.5px] leading-relaxed text-slate-200">
|
||||
<pre className="whitespace-pre-wrap break-words font-sans text-[15.5px] leading-[1.68] text-slate-100">
|
||||
{loaded.text || t("mail.emptyBody")}
|
||||
</pre>
|
||||
)}
|
||||
|
||||
@@ -86,7 +86,7 @@ export function TotpEnrolment({ secret, uri }: { secret: string; uri: string | n
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void copySecret()}
|
||||
className="flex-none rounded-[10px] border border-white/[0.14] px-3 py-2 text-xs text-slate-200 transition-colors hover:bg-white/[0.07] hover:text-slate-100"
|
||||
className="flex-none rounded-[10px] border border-ink/[0.14] px-3 py-2 text-xs text-slate-200 transition-colors hover:bg-ink/[0.07] hover:text-slate-100"
|
||||
>
|
||||
{copied ? t("common.copied") : t("common.copy")}
|
||||
</button>
|
||||
@@ -132,7 +132,7 @@ export function RecoveryCodes({
|
||||
<p className="mb-3 leading-relaxed">{t("settings.saveRecoveryCodes")}</p>
|
||||
<div className="lm-code grid grid-cols-2 gap-1.5 p-3 text-center text-[11px] sm:grid-cols-3">
|
||||
{codes.map((code) => (
|
||||
<span key={code} className="rounded bg-white/[0.05] p-1">
|
||||
<span key={code} className="rounded bg-ink/[0.05] p-1">
|
||||
{code}
|
||||
</span>
|
||||
))}
|
||||
@@ -143,7 +143,7 @@ export function RecoveryCodes({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void copyAll()}
|
||||
className="flex-1 rounded-[10px] border border-white/[0.14] py-2.5 text-sm font-medium text-slate-200 transition-colors hover:bg-white/[0.07]"
|
||||
className="flex-1 rounded-[10px] border border-ink/[0.14] py-2.5 text-sm font-medium text-slate-200 transition-colors hover:bg-ink/[0.07]"
|
||||
>
|
||||
{copied ? t("common.copied") : t("common.copy")}
|
||||
</button>
|
||||
|
||||
@@ -30,11 +30,11 @@ export function Button({
|
||||
|
||||
const variants = {
|
||||
primary:
|
||||
'border-indigo-500 text-indigo-500 bg-transparent hover:bg-indigo-500/[0.12] active:bg-indigo-500/[0.22]',
|
||||
'border-transparent bg-fill text-white shadow-sm hover:bg-fill-hover active:brightness-95',
|
||||
secondary:
|
||||
'border-white/[0.14] text-slate-100 bg-transparent hover:bg-white/[0.07] active:bg-white/[0.14]',
|
||||
'border-ink/[0.14] text-slate-100 bg-transparent hover:bg-ink/[0.07] active:bg-ink/[0.14]',
|
||||
outline:
|
||||
'border-white/[0.14] text-slate-300 bg-transparent hover:text-slate-100 hover:bg-white/[0.07] active:bg-white/[0.14]',
|
||||
'border-ink/[0.14] text-slate-300 bg-transparent hover:text-slate-100 hover:bg-ink/[0.07] active:bg-ink/[0.14]',
|
||||
danger:
|
||||
'border-rose-400 text-rose-400 bg-transparent hover:bg-rose-400/[0.12] active:bg-rose-400/[0.22]',
|
||||
ghost:
|
||||
|
||||
@@ -99,3 +99,96 @@ describe("the document the reader frame is given", () => {
|
||||
expect(doc).toContain("lm:reader-height");
|
||||
});
|
||||
});
|
||||
|
||||
import { looksDesigned } from "./lib/emailBody.js";
|
||||
|
||||
describe("telling a designed message from a written one", () => {
|
||||
it("takes a letter between two people for written", () => {
|
||||
expect(looksDesigned("<p>Hi Ana,</p><p>Friday works for me.</p><p>Lucas</p>")).toBe(false);
|
||||
});
|
||||
|
||||
it("takes a coloured background, in any of its spellings, for designed", () => {
|
||||
expect(looksDesigned('<table bgcolor="#336699"><tr><td>x</td></tr></table>')).toBe(true);
|
||||
expect(looksDesigned('<div style="background-color: #0c6">x</div>')).toBe(true);
|
||||
expect(looksDesigned("<style>.hero{background:#112233}</style><p>x</p>")).toBe(true);
|
||||
expect(looksDesigned('<td background="hero.png">x</td>')).toBe(true);
|
||||
expect(looksDesigned('<div style="background:url(https://x.test/a.png)">x</div>')).toBe(true);
|
||||
});
|
||||
|
||||
// Outlook paints white behind everything it sends; that is not a design.
|
||||
it("does not count plain white or nothing at all as a background", () => {
|
||||
expect(looksDesigned('<div style="background-color: rgb(255, 255, 255)">hi</div>')).toBe(false);
|
||||
expect(looksDesigned('<div style="background:#ffffff">hi</div>')).toBe(false);
|
||||
expect(looksDesigned('<div style="background-color: white;">hi</div>')).toBe(false);
|
||||
expect(looksDesigned('<p style="background:transparent">hi</p>')).toBe(false);
|
||||
expect(looksDesigned('<body bgcolor="#FFF"><p>hi</p></body>')).toBe(false);
|
||||
});
|
||||
|
||||
it("takes a layout built from tables for designed even with no colour", () => {
|
||||
expect(looksDesigned("<table><tr><td><table><tr><td><table><tr><td>x</td></tr></table></td></tr></table></td></tr></table>")).toBe(true);
|
||||
expect(looksDesigned("<table><tr><td>a</td><td>b</td></tr></table>")).toBe(false);
|
||||
});
|
||||
|
||||
it("is false for nothing", () => {
|
||||
expect(looksDesigned("")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a written message takes the app's colours", () => {
|
||||
it("is drawn without a white sheet in dark, with light text", () => {
|
||||
const doc = buildReaderDocument("<p>Hi</p>", { theme: "dark", paper: false });
|
||||
expect(doc).toContain('content="dark"');
|
||||
expect(doc).toContain("#e6e9f0");
|
||||
expect(doc).not.toContain("background:#ffffff");
|
||||
expect(doc).toContain("background:transparent");
|
||||
});
|
||||
|
||||
it("is drawn with dark text in light", () => {
|
||||
const doc = buildReaderDocument("<p>Hi</p>", { theme: "light", paper: false });
|
||||
expect(doc).toContain('content="light"');
|
||||
expect(doc).toContain("#12151c");
|
||||
});
|
||||
|
||||
// Black text its own program gave it must not vanish on a dark ground.
|
||||
it("carries the script that lifts text too dark for the ground", () => {
|
||||
const dark = buildReaderDocument("<p>Hi</p>", { theme: "dark", paper: false });
|
||||
expect(dark).toContain("luminance");
|
||||
expect(buildReaderDocument("<p>Hi</p>", { paper: true })).not.toContain("luminance");
|
||||
});
|
||||
|
||||
it("folds the quoted history of a reply behind a button, in both modes", () => {
|
||||
for (const paper of [true, false]) {
|
||||
const doc = buildReaderDocument("<p>ok</p><blockquote>x</blockquote>", { theme: "dark", paper });
|
||||
expect(doc).toContain("lm-quote-toggle");
|
||||
expect(doc).toContain("blockquote");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// The script that lifts dark text runs inside a template literal, where `\d`
|
||||
// silently means "d". The regular expression it reads colours with must not
|
||||
// depend on a backslash, or it matches nothing and a plain message stays a
|
||||
// white slab on a dark ground.
|
||||
describe("the colour-reading script in a written message", () => {
|
||||
it("reads numbers without relying on an escape", () => {
|
||||
const doc = buildReaderDocument("<p>Hi</p>", { theme: "dark", paper: false });
|
||||
const line = doc.split("\n").find((l) => l.includes("const channels")) ?? "";
|
||||
expect(line).toContain("[0-9.]+");
|
||||
expect(line).not.toMatch(/\\d/);
|
||||
});
|
||||
|
||||
it("actually lifts too-dark text and clears white backdrops", () => {
|
||||
const html = `<div style="background-color:rgb(255,255,255)"><p style="color:#000">Hi</p></div>`;
|
||||
const doc = buildReaderDocument(html, { theme: "dark", paper: false });
|
||||
const dom = new DOMParser().parseFromString(doc, "text/html");
|
||||
document.body.innerHTML = dom.body.innerHTML;
|
||||
// The same script, run against a document that has the styles applied.
|
||||
const script = dom.querySelector("script")!.textContent!;
|
||||
const rewritten = script.replace(/parent\.postMessage[\s\S]*?"\*"\);/, "0;").replace("new ResizeObserver(report).observe(document.body)", "0");
|
||||
new Function(rewritten)();
|
||||
const div = document.querySelector("div")! as HTMLElement;
|
||||
const p = document.querySelector("p")! as HTMLElement;
|
||||
expect(div.style.getPropertyValue("background-color")).toBe("transparent");
|
||||
expect(p.style.getPropertyValue("color")).toBe("inherit");
|
||||
});
|
||||
});
|
||||
@@ -33,18 +33,69 @@ export function resolveInlineImages(html: string, images: InlineImages): string
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a message was designed, as a newsletter or a receipt is, rather than
|
||||
* just written, as a letter between two people is.
|
||||
*
|
||||
* A designed message brings its own colours, tables and background images, and
|
||||
* they only make sense on the white sheet it was drawn for. A written one has
|
||||
* nothing of the kind and reads best when it takes the app's own colours - on a
|
||||
* dark ground, a plain note as a bright white slab is the loudest thing on the
|
||||
* screen and says nothing the words do not.
|
||||
*
|
||||
* Plain white is not design: Outlook paints it behind everything it sends.
|
||||
*/
|
||||
export function looksDesigned(html: string): boolean {
|
||||
if (!html) return false;
|
||||
const neutral = /^\s*(?:#f{3}(?:f{3})?|white|transparent|none|rgba?\(\s*255\s*,\s*255\s*,\s*255(?:\s*,\s*(?:1|1\.0|0?\.\d+))?\s*\)|rgba?\(\s*0\s*,\s*0\s*,\s*0\s*,\s*0\s*\))\s*(?:!important)?\s*;?\s*$/i;
|
||||
|
||||
// bgcolor="#336699" and background="hero.png" are always a statement.
|
||||
for (const m of html.matchAll(/\b(?:bgcolor|background)\s*=\s*(["']?)([^"'\s>]+)\1/gi)) {
|
||||
if (!neutral.test(m[2])) return true;
|
||||
}
|
||||
// background / background-color in a style attribute or a <style> block.
|
||||
for (const m of html.matchAll(/background(?:-color|-image)?\s*:\s*([^;}"']+)/gi)) {
|
||||
const value = m[1].trim();
|
||||
if (/url\(/i.test(value) || !neutral.test(value)) return true;
|
||||
}
|
||||
// A layout built from tables is a design even where nothing is coloured.
|
||||
return (html.match(/<table\b/gi) ?? []).length >= 3;
|
||||
}
|
||||
|
||||
/** The colours a message is drawn in, by the way it is shown. */
|
||||
const SURFACES = {
|
||||
paper: { fg: "#1d1d1f", muted: "#5b6272", link: "#3f4fe6", rule: "#d6d9e0", wash: "rgba(20,28,50,.05)", scheme: "light" },
|
||||
dark: { fg: "#e6e9f0", muted: "#8e96a8", link: "#8794ff", rule: "#383e4d", wash: "rgba(233,237,245,.07)", scheme: "dark" },
|
||||
light: { fg: "#12151c", muted: "#5b6272", link: "#3f4fe6", rule: "#cdd1db", wash: "rgba(20,28,50,.05)", scheme: "light" },
|
||||
} as const;
|
||||
|
||||
export interface ReaderDocumentOptions {
|
||||
/** The app's theme, which a message written plainly takes the colours of. */
|
||||
theme?: "dark" | "light";
|
||||
/**
|
||||
* Draw the message on a white sheet, as it was written. A designed message
|
||||
* always is; a plain one only when the reader asks to see it as sent.
|
||||
*/
|
||||
paper?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a message body in the document the reader frame renders.
|
||||
*
|
||||
* The frame is sandboxed with an opaque origin, so it cannot inherit anything
|
||||
* from the app: every style, the light surface mail assumes, and the height
|
||||
* report the host page sizes the frame from all have to travel inside it.
|
||||
* from the app: every style, the surface mail assumes, the height report the
|
||||
* host page sizes the frame from and the little script that tidies a message
|
||||
* all have to travel inside it.
|
||||
*/
|
||||
export function buildReaderDocument(bodyHtml: string): string {
|
||||
export function buildReaderDocument(bodyHtml: string, options: ReaderDocumentOptions = {}): string {
|
||||
const paper = options.paper ?? true;
|
||||
const surface = paper ? SURFACES.paper : SURFACES[options.theme ?? "dark"];
|
||||
const native = !paper;
|
||||
return `<!doctype html>
|
||||
<html><head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="${surface.scheme}">
|
||||
<!-- Every link leaves this document: it has an opaque origin, so navigating in
|
||||
place strands the reader on a blank pane with no way back. Declared here
|
||||
rather than only in script so it holds even if script does not run. -->
|
||||
@@ -53,11 +104,13 @@ export function buildReaderDocument(bodyHtml: string): string {
|
||||
with it, and this holds without script. -->
|
||||
<meta name="referrer" content="no-referrer">
|
||||
<style>
|
||||
html { background:#ffffff; }
|
||||
:root { color-scheme:${surface.scheme}; }
|
||||
html { background:${native ? "transparent" : "#ffffff"}; }
|
||||
body {
|
||||
margin:0; padding:24px 28px; background:#ffffff; color:#1d1d1f;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
font-size:15px; line-height:1.55; -webkit-font-smoothing:antialiased;
|
||||
margin:0; padding:${native ? "2px 0 6px" : "26px 30px"}; background:${native ? "transparent" : "#ffffff"}; color:${surface.fg};
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
font-size:${native ? "15.5px" : "15px"}; line-height:${native ? "1.68" : "1.55"};
|
||||
-webkit-font-smoothing:antialiased; text-rendering:optimizeLegibility;
|
||||
/* Mail is written against desktop widths. Without these a newsletter
|
||||
pushes a horizontal scrollbar across its own content. */
|
||||
overflow-wrap:anywhere; word-break:break-word;
|
||||
@@ -68,12 +121,24 @@ export function buildReaderDocument(bodyHtml: string): string {
|
||||
/* A layout table keeps the width its author gave it - forcing "auto" is what
|
||||
collapsed designed messages into a narrow column. max-width above is what
|
||||
stops one from overflowing a small screen. */
|
||||
p { margin:0 0 .9em; }
|
||||
${native ? "img { border-radius:8px; }" : ""}
|
||||
a { color:${surface.link}; text-decoration-thickness:1px; text-underline-offset:2px; }
|
||||
pre, code { font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; font-size:.9em; }
|
||||
pre { white-space:pre-wrap; background:${surface.wash}; padding:12px 14px; border-radius:8px; }
|
||||
blockquote {
|
||||
margin:0 0 0 4px; padding-left:12px;
|
||||
border-left:3px solid #cbd5e1; color:#475569;
|
||||
margin:.4em 0 .9em; padding:2px 0 2px 14px;
|
||||
border-left:3px solid ${surface.rule}; color:${surface.muted};
|
||||
}
|
||||
a { color:#4f46e5; }
|
||||
pre { white-space:pre-wrap; }
|
||||
hr { border:0; border-top:1px solid ${surface.rule}; margin:1.2em 0; }
|
||||
/* The button that stands in for a reply's quoted history. */
|
||||
.lm-quote-toggle {
|
||||
display:inline-flex; align-items:center; justify-content:center; height:20px; padding:0 9px; margin:2px 0 8px;
|
||||
border:0; border-radius:10px; background:${surface.wash}; color:${surface.muted};
|
||||
font:600 13px/1 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; letter-spacing:.08em; cursor:pointer;
|
||||
}
|
||||
.lm-quote-toggle:hover { color:${surface.fg}; }
|
||||
.lm-quote-toggle:focus-visible { outline:2px solid ${surface.link}; outline-offset:2px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -85,6 +150,47 @@ ${bodyHtml}
|
||||
link.setAttribute("rel", "noopener noreferrer");
|
||||
}
|
||||
|
||||
${native ? `// A message written plainly is drawn in the app's colours, but it may still
|
||||
// carry its own: black text it was given by the program that wrote it, and a
|
||||
// white backdrop behind every block. Dark text on a dark ground is the one
|
||||
// thing this must never produce, so any colour too close to the ground goes.
|
||||
(function () {
|
||||
const dark = ${surface.scheme === "dark"};
|
||||
const channels = (value) => (value.match(/[0-9.]+/g) || []).map(Number);
|
||||
const luminance = ([r, g, b]) => (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
|
||||
for (const el of document.body.querySelectorAll("*")) {
|
||||
const style = getComputedStyle(el);
|
||||
const [r, g, b, a = 1] = channels(style.backgroundColor);
|
||||
if (a > 0 && r > 244 && g > 244 && b > 244) el.style.setProperty("background-color", "transparent", "important");
|
||||
const [fr, fg, fb] = channels(style.color);
|
||||
const lum = luminance([fr, fg, fb]);
|
||||
if ((dark && lum < 0.42) || (!dark && lum > 0.62)) el.style.removeProperty("color"), el.removeAttribute("color"), el.style.setProperty("color", "inherit", "important");
|
||||
}
|
||||
})();` : ""}
|
||||
|
||||
// A reply carries the whole conversation under it. The history is one click
|
||||
// away rather than a screenful in the way - and in a conversation, where each
|
||||
// message is shown anyway, it is a duplicate.
|
||||
(function () {
|
||||
const quotes = [...document.querySelectorAll("blockquote")].filter((q) => !q.parentElement.closest("blockquote"));
|
||||
for (const quote of quotes) {
|
||||
if ((quote.textContent || "").trim().length < 60) continue;
|
||||
const toggle = document.createElement("button");
|
||||
toggle.type = "button";
|
||||
toggle.className = "lm-quote-toggle";
|
||||
toggle.textContent = "\u00b7\u00b7\u00b7";
|
||||
toggle.setAttribute("aria-expanded", "false");
|
||||
toggle.title = "...";
|
||||
quote.style.display = "none";
|
||||
toggle.addEventListener("click", () => {
|
||||
const open = quote.style.display === "none";
|
||||
quote.style.display = open ? "" : "none";
|
||||
toggle.setAttribute("aria-expanded", String(open));
|
||||
});
|
||||
quote.parentNode.insertBefore(toggle, quote);
|
||||
}
|
||||
})();
|
||||
|
||||
// The host sizes the frame from this rather than guessing: a fixed height
|
||||
// either clips a long message or leaves a slab of empty white under a short
|
||||
// one, and a sandboxed frame cannot be measured from outside.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { sanitizeEmailHtml, blockRemoteImages, unblockRemoteImages } from "./sanitizer";
|
||||
import { resolveInlineImages, buildReaderDocument, type InlineImages } from "./emailBody";
|
||||
import { resolveInlineImages, buildReaderDocument, looksDesigned, type InlineImages } from "./emailBody";
|
||||
|
||||
/** What it takes to draw a message's body. */
|
||||
export interface RenderableBody {
|
||||
@@ -14,13 +14,34 @@ export interface RenderableBody {
|
||||
* markup, then resolve the message's own inline parts, then block or restore
|
||||
* remote sources - which is the only part the reader chooses.
|
||||
*/
|
||||
export function messageDocument(message: RenderableBody, showImages: boolean): string | null {
|
||||
export interface RenderedBody {
|
||||
/** The document for the reader frame. */
|
||||
document: string;
|
||||
/** Drawn on a white sheet (designed mail, or the reader asked for the original). */
|
||||
paper: boolean;
|
||||
/** A plain message that could have taken the app's colours but was kept as sent. */
|
||||
canAdapt: boolean;
|
||||
}
|
||||
|
||||
export interface RenderOptions {
|
||||
theme: "dark" | "light";
|
||||
/** Show a plainly written message as it was sent instead of in the app's colours. */
|
||||
original?: boolean;
|
||||
}
|
||||
|
||||
export function messageDocument(message: RenderableBody, showImages: boolean, options: RenderOptions): RenderedBody | null {
|
||||
if (!message.html) return null;
|
||||
const sanitized = sanitizeEmailHtml(message.html);
|
||||
if (!sanitized) return null;
|
||||
const withInline = resolveInlineImages(sanitized, message.inline_images ?? {});
|
||||
const body = showImages ? unblockRemoteImages(withInline) : blockRemoteImages(withInline);
|
||||
return buildReaderDocument(body);
|
||||
const designed = looksDesigned(sanitized);
|
||||
const paper = designed || Boolean(options.original);
|
||||
return {
|
||||
document: buildReaderDocument(body, { theme: options.theme, paper }),
|
||||
paper,
|
||||
canAdapt: !designed,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -16,7 +16,21 @@ const NAMED: Record<string, string> = {
|
||||
nbsp: " ",
|
||||
};
|
||||
|
||||
/**
|
||||
* Turns `á`, `—`, `'` and the rest into the characters they
|
||||
* stand for. The browser already knows all of them (HTML defines over two
|
||||
* thousand), so it is asked rather than given a short list: a list stops at
|
||||
* `&` and leaves `ç` and `·` printed in a Portuguese or a
|
||||
* marketing preview. A textarea holds text only, so nothing in it runs.
|
||||
*/
|
||||
function decodeEntities(text: string): string {
|
||||
if (!text.includes("&")) return text;
|
||||
if (typeof document !== "undefined") {
|
||||
const holder = document.createElement("textarea");
|
||||
holder.innerHTML = text;
|
||||
return holder.value;
|
||||
}
|
||||
// No DOM (rendered on the server): the few that matter most.
|
||||
return text.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (whole, body: string) => {
|
||||
if (body[0] === "#") {
|
||||
const code = body[1].toLowerCase() === "x" ? parseInt(body.slice(2), 16) : parseInt(body.slice(1), 10);
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
/**
|
||||
* The interface has two themes, dark and light, and a third choice that
|
||||
* follows the system. Dark is what a first visit gets.
|
||||
*
|
||||
* The choice is stored in this browser, not on the account: a laptop in a
|
||||
* bright office and a phone in bed are different answers to the same question.
|
||||
*/
|
||||
|
||||
export type ThemeChoice = "dark" | "light" | "system";
|
||||
export type Theme = "dark" | "light";
|
||||
|
||||
export const THEME_KEY = "lm_theme";
|
||||
const CHANGED = "lm:theme-changed";
|
||||
|
||||
/**
|
||||
* Runs in <head>, before anything is painted, so the page never shows the
|
||||
* wrong theme for a moment. It is a string rather than a function because it
|
||||
* has to run as written, outside React, and must not throw: a browser that
|
||||
* refuses storage still gets the default.
|
||||
*/
|
||||
export const THEME_BOOT_SCRIPT = `(function(){var d=document.documentElement,t="dark";try{var c=localStorage.getItem("${THEME_KEY}");if(c==="light")t="light";else if(c==="system")t=matchMedia("(prefers-color-scheme: light)").matches?"light":"dark"}catch(e){}d.setAttribute("data-theme",t);d.style.colorScheme=t})();`;
|
||||
|
||||
export function readChoice(): ThemeChoice {
|
||||
try {
|
||||
const stored = window.localStorage.getItem(THEME_KEY);
|
||||
return stored === "light" || stored === "system" ? stored : "dark";
|
||||
} catch {
|
||||
return "dark";
|
||||
}
|
||||
}
|
||||
|
||||
/** What a choice means right now: "system" is whatever the system is set to. */
|
||||
export function resolve(choice: ThemeChoice): Theme {
|
||||
if (choice === "system") {
|
||||
return window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark";
|
||||
}
|
||||
return choice;
|
||||
}
|
||||
|
||||
function apply(theme: Theme) {
|
||||
const root = document.documentElement;
|
||||
root.setAttribute("data-theme", theme);
|
||||
root.style.colorScheme = theme;
|
||||
// Keeps the browser's own bar in step with a choice made on the page, which
|
||||
// the viewport metadata alone cannot follow.
|
||||
const meta = document.querySelector<HTMLMetaElement>('meta[name="theme-color"]:not([media])');
|
||||
if (meta) meta.content = theme === "light" ? "#F3F4F7" : "#0F1218";
|
||||
}
|
||||
|
||||
export function setThemeChoice(choice: ThemeChoice) {
|
||||
try {
|
||||
window.localStorage.setItem(THEME_KEY, choice);
|
||||
} catch {
|
||||
// The choice holds for this visit and is not remembered.
|
||||
}
|
||||
apply(resolve(choice));
|
||||
window.dispatchEvent(new Event(CHANGED));
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const [choice, setChoice] = useState<ThemeChoice>("dark");
|
||||
const [theme, setTheme] = useState<Theme>("dark");
|
||||
|
||||
useEffect(() => {
|
||||
const sync = () => {
|
||||
const next = readChoice();
|
||||
setChoice(next);
|
||||
setTheme(resolve(next));
|
||||
};
|
||||
sync();
|
||||
const media = window.matchMedia("(prefers-color-scheme: light)");
|
||||
const onSystem = () => {
|
||||
if (readChoice() === "system") {
|
||||
apply(resolve("system"));
|
||||
sync();
|
||||
}
|
||||
};
|
||||
window.addEventListener(CHANGED, sync);
|
||||
window.addEventListener("storage", sync);
|
||||
media.addEventListener("change", onSystem);
|
||||
return () => {
|
||||
window.removeEventListener(CHANGED, sync);
|
||||
window.removeEventListener("storage", sync);
|
||||
media.removeEventListener("change", onSystem);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const set = useCallback((next: ThemeChoice) => setThemeChoice(next), []);
|
||||
return { choice, theme, set };
|
||||
}
|
||||
@@ -26,4 +26,16 @@ describe("plainSnippet", () => {
|
||||
it("keeps an entity it does not know", () => {
|
||||
expect(plainSnippet("a &madeup; b")).toBe("a &madeup; b");
|
||||
});
|
||||
|
||||
// The preview of a Portuguese or a marketing message is full of these, and a
|
||||
// short list of entities printed them as text.
|
||||
it("decodes any named entity, not only the common few", () => {
|
||||
expect(plainSnippet("Atualização — número 3 → ok · fim")).toBe("Atualiza\u00e7\u00e3o \u2014 n\u00famero 3 \u2192 ok \u00b7 fim");
|
||||
expect(plainSnippet("Café & crème …")).toBe("Caf\u00e9 & cr\u00e8me \u2026");
|
||||
});
|
||||
|
||||
it("does not run markup that an entity decodes into", () => {
|
||||
expect(plainSnippet("<img src=x onerror=alert(1)> safe")).toBe("<img src=x onerror=alert(1)> safe");
|
||||
expect(document.querySelector("img")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import { readFileSync, readdirSync, statSync, existsSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
import { THEME_BOOT_SCRIPT, THEME_KEY } from "./lib/theme";
|
||||
|
||||
/**
|
||||
* The two themes, and what keeps them from drifting apart.
|
||||
*
|
||||
* Every colour is a variable defined once per theme, and the screens read the
|
||||
* Tailwind scales that point at those variables. The failures worth guarding
|
||||
* are the quiet ones: a variable one theme forgot, a class that bypasses the
|
||||
* variables and is right in dark and unreadable in light, and a first paint in
|
||||
* the wrong theme.
|
||||
*/
|
||||
|
||||
const root = process.cwd();
|
||||
const read = (path: string) => readFileSync(resolve(root, path), "utf8");
|
||||
|
||||
function sourceFiles(dir: string): string[] {
|
||||
const out: string[] = [];
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const path = join(dir, entry);
|
||||
if (statSync(path).isDirectory()) out.push(...sourceFiles(path));
|
||||
else if (path.endsWith(".tsx")) out.push(path);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe("the theme variables", () => {
|
||||
const css = read("src/app/globals.css");
|
||||
const block = (selector: string) => {
|
||||
const start = css.indexOf(selector);
|
||||
return css.slice(start, css.indexOf("\n}", start));
|
||||
};
|
||||
const names = (text: string) => [...text.matchAll(/--([a-z0-9-]+):/g)].map((m) => m[1]).sort();
|
||||
|
||||
it("defines the same variables in dark and in light", () => {
|
||||
const dark = names(block(":root, [data-theme='dark']"));
|
||||
const light = names(block("[data-theme='light']"));
|
||||
expect(dark.length).toBeGreaterThan(50);
|
||||
expect(light).toEqual(dark);
|
||||
});
|
||||
|
||||
it("keeps the ramps running the same way: 100 is the text, 950 the ground", () => {
|
||||
const channel = (selector: string, name: string) =>
|
||||
block(selector)
|
||||
.match(new RegExp(`--${name}: (\\d+) (\\d+) (\\d+);`))!
|
||||
.slice(1)
|
||||
.map(Number)
|
||||
.reduce((a, b) => a + b, 0);
|
||||
const dark = ":root, [data-theme='dark']";
|
||||
const light = "[data-theme='light']";
|
||||
// In dark the text is lighter than the ground; in light it is darker.
|
||||
expect(channel(dark, "n-100")).toBeGreaterThan(channel(dark, "n-950"));
|
||||
expect(channel(light, "n-100")).toBeLessThan(channel(light, "n-950"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("the first paint", () => {
|
||||
afterEach(() => {
|
||||
document.documentElement.removeAttribute("data-theme");
|
||||
localStorage.clear();
|
||||
});
|
||||
const boot = () => new Function(THEME_BOOT_SCRIPT)();
|
||||
|
||||
it("is dark when nothing was ever chosen", () => {
|
||||
boot();
|
||||
expect(document.documentElement.getAttribute("data-theme")).toBe("dark");
|
||||
});
|
||||
|
||||
it("is light when light was chosen", () => {
|
||||
localStorage.setItem(THEME_KEY, "light");
|
||||
boot();
|
||||
expect(document.documentElement.getAttribute("data-theme")).toBe("light");
|
||||
});
|
||||
|
||||
it("is dark for anything it does not recognise", () => {
|
||||
localStorage.setItem(THEME_KEY, "purple");
|
||||
boot();
|
||||
expect(document.documentElement.getAttribute("data-theme")).toBe("dark");
|
||||
});
|
||||
|
||||
it("is dark, and does not throw, when storage refuses", () => {
|
||||
const original = Storage.prototype.getItem;
|
||||
Storage.prototype.getItem = () => {
|
||||
throw new Error("blocked");
|
||||
};
|
||||
try {
|
||||
expect(() => boot()).not.toThrow();
|
||||
expect(document.documentElement.getAttribute("data-theme")).toBe("dark");
|
||||
} finally {
|
||||
Storage.prototype.getItem = original;
|
||||
}
|
||||
});
|
||||
|
||||
it("is set before anything renders: the layout puts it in the head", () => {
|
||||
expect(read("src/app/layout.tsx")).toMatch(/<head>[\s\S]*THEME_BOOT_SCRIPT[\s\S]*<\/head>/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("colours that would only be right in one theme", () => {
|
||||
const files = sourceFiles(resolve(root, "src"));
|
||||
|
||||
// white at low opacity is a tint that vanishes on a light ground; `ink` is the
|
||||
// colour that sits on whichever ground there is.
|
||||
it("does not tint with white", () => {
|
||||
for (const file of files) {
|
||||
expect(readFileSync(file, "utf8"), file).not.toMatch(/\b(bg|border|text|ring|shadow|divide)-white\/\d/);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not hard-code the old accent", () => {
|
||||
for (const file of files) {
|
||||
expect(readFileSync(file, "utf8"), file).not.toMatch(/#9184d9|#5d5294/i);
|
||||
}
|
||||
});
|
||||
|
||||
it("reads colours from variables in the Tailwind config", () => {
|
||||
const config = read("tailwind.config.js");
|
||||
expect(config).toContain("var(--${name}-${step})");
|
||||
expect(config).toContain("[data-theme='dark']");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the app's identity", () => {
|
||||
it("has a tab icon, a home-screen icon and an install manifest", () => {
|
||||
for (const file of ["src/app/icon.svg", "src/app/favicon.ico", "src/app/apple-icon.png", "src/app/manifest.ts"]) {
|
||||
expect(existsSync(resolve(root, file)), file).toBe(true);
|
||||
}
|
||||
for (const file of ["icon-192.png", "icon-512.png", "maskable-512.png"]) {
|
||||
expect(existsSync(resolve(root, "public/icons", file)), file).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("serves its font itself rather than asking a third party on every visit", () => {
|
||||
expect(read("src/app/globals.css")).not.toMatch(/fonts\.googleapis\.com/);
|
||||
expect(read("src/app/layout.tsx")).toContain("@fontsource-variable/geist");
|
||||
});
|
||||
|
||||
it("offers the choice of theme on the account menu, the admin console and both sign-ins", () => {
|
||||
expect(read("src/components/AccountSwitcher.tsx")).toContain("ThemeToggle");
|
||||
expect(read("src/app/(admin)/admin/layout.tsx")).toContain("ThemeToggle");
|
||||
expect(read("src/app/(auth)/user/login/page.tsx")).toContain("ThemeToggle");
|
||||
expect(read("src/app/(auth)/admin/login/page.tsx")).toContain("ThemeToggle");
|
||||
});
|
||||
});
|
||||
@@ -1,83 +1,31 @@
|
||||
/**
|
||||
* Nocturne, expressed as Tailwind scales.
|
||||
* The interface's colours, as Tailwind scales that read CSS variables.
|
||||
*
|
||||
* The redesign replaces two visual languages with one: the webmail's indigo and
|
||||
* the console's green both become the single blurple accent, and every grey
|
||||
* comes from one perceptual ramp instead of Tailwind's stock slate. Rather than
|
||||
* rename several thousand utility classes across the screens, the scales those
|
||||
* classes already name are remapped onto the design system's own values - so
|
||||
* `bg-slate-900`, `text-emerald-400` and `border-indigo-500/30` all resolve
|
||||
* into Nocturne, and a colour cannot enter the interface from outside it.
|
||||
*
|
||||
* The values here are the same ones globals.css carries as CSS variables.
|
||||
* The values live in globals.css, once per theme. The scales the screens
|
||||
* already name - slate for neutrals, indigo for the accent, amber, rose and
|
||||
* emerald for the signals - point at those variables, so switching theme
|
||||
* changes what `text-slate-400` is without touching a class. `ink` is the
|
||||
* colour that sits on the ground (white on dark, near-black on light): hover
|
||||
* tints and hairlines are `ink` at low opacity.
|
||||
*
|
||||
* @type {import('tailwindcss').Config}
|
||||
*/
|
||||
const ramp = (name) =>
|
||||
Object.fromEntries(
|
||||
[50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950].map((step) => [
|
||||
step,
|
||||
`rgb(var(--${name}-${step}) / <alpha-value>)`,
|
||||
]),
|
||||
);
|
||||
|
||||
// The neutral ramp, ordered the way Tailwind expects (light to dark) so the
|
||||
// existing `text-slate-400` / `bg-slate-900` idiom keeps meaning what it meant.
|
||||
const neutral = {
|
||||
50: '#f3f5fe',
|
||||
100: '#e9e9ed', // body text
|
||||
200: '#e4e7f5',
|
||||
300: '#cfd3e5',
|
||||
400: '#9397ab', // muted text
|
||||
500: '#75798c',
|
||||
600: '#595d6c',
|
||||
700: '#3f424d', // borders
|
||||
800: '#232532', // card
|
||||
900: '#1b1c28', // panel
|
||||
950: '#161826', // page ground
|
||||
};
|
||||
|
||||
// The one accent, in the product's own blurple (OKLCH hue 289).
|
||||
const accent = {
|
||||
50: '#faf9ff',
|
||||
100: '#f5f4ff',
|
||||
200: '#e7e5fe',
|
||||
300: '#d2cefd',
|
||||
400: '#b5abfc',
|
||||
500: '#9184d9',
|
||||
600: '#796cbf',
|
||||
700: '#5d5294',
|
||||
800: '#423a6a',
|
||||
900: '#2b2741',
|
||||
950: '#211d33',
|
||||
};
|
||||
|
||||
// Warning and danger are the only other hues, and both are desaturated: on this
|
||||
// ground a saturated amber or rose reads as an error in the design, not in the
|
||||
// data.
|
||||
const warn = {
|
||||
50: '#fbf8ee',
|
||||
100: '#f6efd8',
|
||||
200: '#ecdfb2',
|
||||
300: '#dfcb8b',
|
||||
400: '#cbb46a',
|
||||
500: '#b39b52',
|
||||
600: '#8f7b41',
|
||||
700: '#6b5c31',
|
||||
800: '#473d21',
|
||||
900: '#2f2917',
|
||||
950: '#1f1b10',
|
||||
};
|
||||
|
||||
const bad = {
|
||||
50: '#fcf3f4',
|
||||
100: '#f8e3e5',
|
||||
200: '#f0c8cc',
|
||||
300: '#e2a8ae',
|
||||
400: '#d08a92',
|
||||
500: '#b96f78',
|
||||
600: '#985860',
|
||||
700: '#74434a',
|
||||
800: '#4e2e33',
|
||||
900: '#3a2a2d',
|
||||
950: '#241a1c',
|
||||
};
|
||||
const neutral = ramp('n');
|
||||
const accent = ramp('a');
|
||||
const warn = ramp('w');
|
||||
const bad = ramp('b');
|
||||
const good = ramp('g');
|
||||
|
||||
module.exports = {
|
||||
darkMode: 'class',
|
||||
darkMode: ['selector', "[data-theme='dark']"],
|
||||
content: [
|
||||
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
@@ -90,46 +38,51 @@ module.exports = {
|
||||
gray: neutral,
|
||||
zinc: neutral,
|
||||
neutral: neutral,
|
||||
// Every accent name in the codebase collapses onto the one accent. The
|
||||
// console's emerald and teal are the point of the exercise: the two
|
||||
// surfaces are one product and now look like it.
|
||||
// Every blue, violet and teal name in the codebase is the one accent.
|
||||
indigo: accent,
|
||||
violet: accent,
|
||||
purple: accent,
|
||||
emerald: accent,
|
||||
teal: accent,
|
||||
cyan: accent,
|
||||
sky: accent,
|
||||
blue: accent,
|
||||
brand: accent,
|
||||
// Green means answered, trusted and healthy, and nothing else.
|
||||
emerald: good,
|
||||
teal: good,
|
||||
green: good,
|
||||
amber: warn,
|
||||
yellow: warn,
|
||||
orange: warn,
|
||||
rose: bad,
|
||||
red: bad,
|
||||
pink: bad,
|
||||
ink: 'rgb(var(--ink) / <alpha-value>)',
|
||||
// The solid fill of the one main action on a screen.
|
||||
fill: {
|
||||
DEFAULT: 'rgb(var(--a-fill) / <alpha-value>)',
|
||||
hover: 'rgb(var(--a-fill-hover) / <alpha-value>)',
|
||||
},
|
||||
dark: {
|
||||
rail: '#12131f',
|
||||
bg: '#161826',
|
||||
panel: '#1b1c28',
|
||||
card: '#232532',
|
||||
border: 'rgba(233, 233, 237, 0.09)',
|
||||
rail: 'rgb(var(--rail) / <alpha-value>)',
|
||||
bg: 'rgb(var(--n-950) / <alpha-value>)',
|
||||
panel: 'rgb(var(--n-900) / <alpha-value>)',
|
||||
card: 'rgb(var(--n-800) / <alpha-value>)',
|
||||
border: 'rgb(var(--ink) / 0.09)',
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'system-ui', '-apple-system', 'sans-serif'],
|
||||
mono: ['ui-monospace', 'Menlo', 'Consolas', 'monospace'],
|
||||
sans: ['"Geist Variable"', 'Geist', 'system-ui', '-apple-system', 'sans-serif'],
|
||||
mono: ['ui-monospace', '"SF Mono"', 'Menlo', 'Consolas', 'monospace'],
|
||||
},
|
||||
borderRadius: {
|
||||
xl: '12px',
|
||||
'2xl': '14px',
|
||||
'2xl': '16px',
|
||||
},
|
||||
boxShadow: {
|
||||
// On a dark ground elevation is an edge plus ambient darkness, never a
|
||||
// stack of coloured glows.
|
||||
edge: 'inset 0 0 0 1px rgba(233, 233, 237, 0.09)',
|
||||
'edge-accent': 'inset 0 0 0 1px rgba(145, 132, 217, 0.4)',
|
||||
lift: '0 0 0 1px #3f424d, 0 16px 40px rgba(0, 0, 0, 0.5)',
|
||||
// Elevation is a hairline plus ambient shade; never a coloured glow.
|
||||
edge: 'inset 0 0 0 1px rgb(var(--ink) / 0.09)',
|
||||
'edge-accent': 'inset 0 0 0 1px rgb(var(--a-500) / 0.45)',
|
||||
lift: '0 0 0 1px rgb(var(--ink) / 0.12), var(--lm-shadow)',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||