Add Nix flake, .envrc, Taskfile, and Stalwart dev server

- flake.nix: Flutter 3.41.6, Android SDK, Stalwart, GTK3/build
  tools for Linux desktop, go-task
- .envrc: copied from sharedinbox — use flake + dotenv_if_exists
- Taskfile.yml: analyze, test, integration, codegen, run tasks
- stalwart-dev/: IMAP+SMTP dev server reused from sharedinbox
- test/integration/imap_sync_test.dart: login, list mailboxes,
  send via SMTP and receive via IMAP
- pubspec.yaml: add flutter_secure_storage

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Thomas Güttler
2026-04-16 07:40:34 +02:00
co-authored by Claude Sonnet 4.6
parent 5ebda521d6
commit 22db4a2dd6
9 changed files with 473 additions and 5 deletions
+12
View File
@@ -0,0 +1,12 @@
# shellcheck shell=bash
# .envrc file of direnv.
# If you use vsode, pleaes use the `direnv` extension.
# Use nix-direnv
# https://github.com/nix-community/nix-direnv
# Ensures that flake.nix gets evaluated.
use flake
# Load variables from .env
dotenv_if_exists
+6 -5
View File
@@ -2,7 +2,7 @@
## Architecture
```
```text
IMAP/SMTP server
AccountSyncManager (IMAP IDLE per account)
@@ -17,7 +17,7 @@ UI never touches the network. The sync layer runs independently.
## Phases
| Phase | Scope | Status |
|---|---|---|
| --- | --- | --- |
| 0 — Scaffold | pubspec, Drift schema, DI, router, enough_mail vendored | Done |
| 1 — Core models | `Account`, `Mailbox`, `Email`, `EmailBody`, repository interfaces | Done |
| 2 — DB layer | Drift tables, `AccountRepositoryImpl`, `MailboxRepositoryImpl`, `EmailRepositoryImpl` | Done |
@@ -25,9 +25,10 @@ UI never touches the network. The sync layer runs independently.
| 4 — IMAP IDLE | `AccountSyncManager` with exponential-backoff reconnect | Done |
| 5 — SMTP send | `connectSmtp`, `EmailRepositoryImpl.sendEmail` | Done |
| 6 — UI | All screens: AccountList, AddAccount, MailboxList, EmailList, EmailDetail, Compose, Settings | Done |
| 7 — Code-gen | Run `dart run build_runner build` to generate `database.g.dart` | Pending |
| 8 — Platform targets | Android, iOS, Linux, macOS, Windows entry points | Pending |
| 9 — Polish | Reply prefill, attachment open, thread view, search | Next |
| 7 — Dev tooling | Nix flake, `.envrc`, Taskfile, Stalwart dev server (IMAP+SMTP), integration tests | Done |
| 8 — Code-gen | Run `task codegen` to generate `database.g.dart` and Riverpod providers | Pending |
| 9 — Platform targets | Android, iOS, Linux, macOS, Windows entry points | Pending |
| 10 — Polish | Reply prefill, attachment open, thread view, search | Next |
## Next candidates
+69
View File
@@ -0,0 +1,69 @@
version: "3"
tasks:
default:
desc: Run all checks (analyze + test, in parallel)
deps: [check]
_nix-check:
internal: true
run: once
preconditions:
- sh: test "${DIRENV_DIR#-}" = "{{.TASKFILE_DIR}}"
msg: "Not in nix dev shell. Run: nix develop"
codegen:
desc: Run build_runner to generate Drift and Riverpod code
deps: [_nix-check]
cmds:
- |
START=$(date +%s)
dart run build_runner build --delete-conflicting-outputs
END=$(date +%s)
echo "codegen: $((END - START))s"
analyze:
desc: Run flutter analyze
deps: [_nix-check]
cmds:
- |
START=$(date +%s)
flutter analyze
END=$(date +%s)
echo "analyze: $((END - START))s"
analyze-fix:
desc: Auto-fix with dart fix --apply
deps: [_nix-check]
cmds:
- dart fix --apply
test:
desc: Run unit tests
deps: [_nix-check]
cmds:
- |
START=$(date +%s)
flutter test
END=$(date +%s)
echo "test: $((END - START))s"
integration:
desc: Run integration tests (starts and stops Stalwart automatically)
deps: [_nix-check]
cmds:
- |
START=$(date +%s)
stalwart-dev/test.sh
END=$(date +%s)
echo "integration: $((END - START))s"
run:
desc: Run the app on Linux desktop
deps: [_nix-check]
cmds:
- flutter run -d linux
check:
desc: All checks — analyze + unit tests in parallel
deps: [analyze, test]
+97
View File
@@ -0,0 +1,97 @@
{
description = "SharedInbox IMAP/SMTP Flutter client";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11";
android-nixpkgs = {
url = "github:tadfisher/android-nixpkgs/stable";
inputs.nixpkgs.follows = "nixpkgs";
};
flake-utils.url = "github:numtide/flake-utils";
};
outputs = { self, nixpkgs, android-nixpkgs, flake-utils }:
flake-utils.lib.eachDefaultSystem (system:
let
pkgs = import nixpkgs {
inherit system;
config.allowUnfree = true;
config.android_sdk.accept_license = true;
};
androidSdk = android-nixpkgs.sdk.${system} (s: with s; [
cmdline-tools-latest
build-tools-35-0-0
platform-tools
platforms-android-36
platforms-android-35
emulator
]);
in {
devShells.default = pkgs.mkShell {
buildInputs = with pkgs; [
# Flutter / Dart toolchain
flutter
# Android
androidSdk
# Linux desktop build deps (Flutter GTK backend)
pkg-config
cmake
ninja
clang
gtk3
glib
pcre2
libepoxy
at-spi2-atk
at-spi2-core
# Local IMAP/SMTP dev server for integration tests
stalwart-mail
# Task runner
go-task
# Utilities
git
curl
jq
sqlite
python3 # used by stalwart-dev/start to pick random ports
];
shellHook = ''
export ANDROID_HOME="${androidSdk}/share/android-sdk"
export ANDROID_SDK_ROOT="$ANDROID_HOME"
export PATH="$ANDROID_HOME/platform-tools:$ANDROID_HOME/cmdline-tools/latest/bin:$PATH"
# Disable Flutter telemetry inside dev shell
export FLUTTER_SUPPRESS_ANALYTICS=true
# Stalwart integration tests choose fresh random ports per run.
export STALWART_PORT="''${STALWART_PORT:-0}"
export STALWART_URL="http://localhost:$STALWART_PORT"
export STALWART_IMAP_PORT="''${STALWART_IMAP_PORT:-0}"
export STALWART_SMTP_PORT="''${STALWART_SMTP_PORT:-0}"
export STALWART_USER_A="admin"
export STALWART_PASS_A="admin"
export STALWART_USER_B="alice"
export STALWART_PASS_B="secret"
export STALWART_USER_C="bob"
export STALWART_PASS_C="secret"
echo "SharedInbox Flutter dev environment ready."
echo " Analyze : task analyze"
echo " Unit tests : task test"
echo " Integration : task integration"
echo " All checks : task check"
echo " Run (Linux) : task run"
echo " Start Stalwart : stalwart-dev/start"
'';
};
}
);
}
+3
View File
@@ -28,6 +28,9 @@ dependencies:
# Navigation
go_router: ^14.8.1
# Secure credential storage (passwords)
flutter_secure_storage: ^9.2.4
# Utilities
freezed_annotation: ^2.4.4
json_annotation: ^4.9.0
+63
View File
@@ -0,0 +1,63 @@
# Minimal Stalwart Mail configuration for local development and integration tests.
#
# Do not start directly — use stalwart-dev/start, which substitutes $STALWART_PORT
# and writes a per-clone config into /tmp/stalwart-dev-PORT/ before starting.
#
# Check: curl http://localhost:$STALWART_PORT/.well-known/jmap
#
# HTTP only — localhost testing, no TLS.
# Two test accounts (alice, bob) for multi-account sync tests.
[server]
hostname = "localhost"
[[server.listener]]
id = "jmap"
bind = ["127.0.0.1:8080"]
protocol = "http"
[[server.listener]]
id = "imap"
bind = ["127.0.0.1:1430"]
protocol = "imap"
[[server.listener]]
id = "smtp"
bind = ["127.0.0.1:1025"]
protocol = "smtp"
[store."db"]
type = "sqlite"
path = "/tmp/stalwart-dev/data.sqlite"
[storage]
data = "db"
fts = "db"
blob = "db"
lookup = "db"
directory = "memory"
[tracer."stdout"]
type = "stdout"
level = "warn"
ansi = false
enable = true
[directory."memory"]
type = "memory"
[[directory."memory".principals]]
class = "individual"
name = "alice"
secret = "secret"
email = ["alice@localhost"]
[[directory."memory".principals]]
class = "individual"
name = "bob"
secret = "secret"
email = ["bob@localhost"]
[authentication.fallback-admin]
user = "admin"
secret = "admin"
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env bash
# Starts a Stalwart instance for local development and integration tests.
#
# By default it uses STALWART_PORT from the environment. When STALWART_PORT=0
# or STALWART_RANDOM_PORTS=1, three free random ports are chosen and written to
# STALWART_TMPDIR/ports.env for other scripts to source.
set -euo pipefail
command -v stalwart >/dev/null || {
echo "stalwart not in PATH — run inside nix develop"
exit 1
}
command -v ss >/dev/null || {
echo "ss not in PATH — cannot verify Stalwart ports"
exit 1
}
if [ "${STALWART_RANDOM_PORTS:-0}" = "1" ] || [ "${STALWART_PORT:-0}" = "0" ]; then
command -v python3 >/dev/null || {
echo "python3 not in PATH — cannot choose random Stalwart ports"
exit 1
}
read -r STALWART_PORT STALWART_IMAP_PORT STALWART_SMTP_PORT < <(
python3 - <<'PY'
import socket
ports = []
for _ in range(3):
sock = socket.socket()
sock.bind(("127.0.0.1", 0))
ports.append(str(sock.getsockname()[1]))
sock.close()
print(" ".join(ports))
PY
)
else
: "${STALWART_PORT:?STALWART_PORT is not set — run this inside nix develop}"
STALWART_IMAP_PORT="${STALWART_IMAP_PORT:-$((STALWART_PORT + 1))}"
STALWART_SMTP_PORT="${STALWART_SMTP_PORT:-$((STALWART_PORT + 2))}"
fi
export STALWART_PORT STALWART_IMAP_PORT STALWART_SMTP_PORT
export STALWART_URL="http://127.0.0.1:${STALWART_PORT}"
TMPDIR="${STALWART_TMPDIR:-/tmp/stalwart-dev-${STALWART_PORT}}"
mkdir -p "$TMPDIR"
for port in "$STALWART_PORT" "$STALWART_IMAP_PORT" "$STALWART_SMTP_PORT"; do
ss -ltnH "sport = :$port" | grep -q . && {
echo "Stalwart port $port is already in use"
exit 1
}
done
cat >"${TMPDIR}/ports.env" <<EOF
export STALWART_PORT=${STALWART_PORT}
export STALWART_IMAP_PORT=${STALWART_IMAP_PORT}
export STALWART_SMTP_PORT=${STALWART_SMTP_PORT}
export STALWART_URL=${STALWART_URL}
EOF
echo "Stalwart ports: JMAP=${STALWART_PORT} IMAP=${STALWART_IMAP_PORT} SMTP=${STALWART_SMTP_PORT}" >&2
echo "Stalwart is running in the foreground. Press Ctrl+C to stop." >&2
echo "Connection info written to ${TMPDIR}/ports.env" >&2
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
sed -e "s|127.0.0.1:8080|127.0.0.1:${STALWART_PORT}|" \
-e "s|127.0.0.1:1430|127.0.0.1:${STALWART_IMAP_PORT}|" \
-e "s|127.0.0.1:1025|127.0.0.1:${STALWART_SMTP_PORT}|" \
-e "s|/tmp/stalwart-dev|${TMPDIR}|" \
"${REPO_ROOT}/stalwart-dev/config.toml" >"${TMPDIR}/config.toml"
exec stalwart --config "${TMPDIR}/config.toml"
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env bash
# Starts Stalwart in the background on fresh random ports, runs Flutter
# integration tests, then stops it.
set -Eeuo pipefail
trap 'echo "Warning: A command failed ($0:$LINENO)"; exit 3' ERR
export STALWART_USER_C="${STALWART_USER_C:-bob}"
export STALWART_PASS_C="${STALWART_PASS_C:-secret}"
export STALWART_RANDOM_PORTS=1
export STALWART_TMPDIR="$(mktemp -d /tmp/stalwart-dev-XXXXXX)"
command -v stalwart >/dev/null || {
echo "stalwart not in PATH — run inside nix develop"
exit 1
}
# Pre-seed spam-filter version so Stalwart does not fetch it on first boot.
mkdir -p "$STALWART_TMPDIR"
sqlite3 "${STALWART_TMPDIR}/data.sqlite" \
"CREATE TABLE IF NOT EXISTS s (k BLOB PRIMARY KEY, v BLOB NOT NULL);
INSERT OR REPLACE INTO s VALUES ('version.spam-filter', 'dev');" 2>/dev/null || true
LOGFILE="${STALWART_TMPDIR}/stalwart.log"
rm -f "$LOGFILE"
"$(dirname "$0")/start" >"$LOGFILE" 2>&1 &
STALWART_PID=$!
trap 'kill "$STALWART_PID" 2>/dev/null || true; wait "$STALWART_PID" 2>/dev/null || true' EXIT
# Wait until Stalwart is accepting connections (up to 10 s).
for _i in $(seq 1 20); do
[ -f "${STALWART_TMPDIR}/ports.env" ] && . "${STALWART_TMPDIR}/ports.env"
grep -E "Configuration build error|Build error for key|already in use" "$LOGFILE" >/dev/null 2>&1 && {
cat "$LOGFILE"; echo "Stalwart reported a startup error"; exit 1
}
kill -0 "$STALWART_PID" 2>/dev/null || {
cat "$LOGFILE"; echo "Stalwart process died unexpectedly"; exit 1
}
if [ -n "${STALWART_URL:-}" ] && \
curl -s --max-time 1 -o /dev/null "${STALWART_URL}/.well-known/jmap" 2>/dev/null; then
break
fi
sleep 0.5
done
[ -n "${STALWART_URL:-}" ] || { cat "$LOGFILE"; echo "Stalwart did not publish its chosen ports"; exit 1; }
curl -s --max-time 1 -o /dev/null "${STALWART_URL}/.well-known/jmap" || {
cat "$LOGFILE"; echo "Stalwart did not become ready"; exit 1
}
echo "Stalwart ready — IMAP=:${STALWART_IMAP_PORT} SMTP=:${STALWART_SMTP_PORT}"
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
# Export vars so dart test can read them.
export STALWART_IMAP_HOST="127.0.0.1"
export STALWART_SMTP_HOST="127.0.0.1"
dart test test/integration/
+89
View File
@@ -0,0 +1,89 @@
// Integration tests — requires a running Stalwart instance.
// Run via: stalwart-dev/test.sh (sets the env vars below)
//
// STALWART_IMAP_HOST, STALWART_IMAP_PORT, STALWART_SMTP_HOST, STALWART_SMTP_PORT
// STALWART_USER_B / STALWART_PASS_B (alice@localhost)
// STALWART_USER_C / STALWART_PASS_C (bob@localhost)
import 'dart:io';
import 'package:enough_mail/enough_mail.dart';
import 'package:test/test.dart';
String _env(String key) {
final v = Platform.environment[key];
if (v == null || v.isEmpty) throw StateError('$key is not set');
return v;
}
ImapClient _makeClient() => ImapClient(isLogEnabled: false);
Future<ImapClient> _connect(
String user,
String pass, {
String host = '127.0.0.1',
int? port,
}) async {
final p = port ?? int.parse(_env('STALWART_IMAP_PORT'));
final client = _makeClient();
await client.connectToServer(host, p, isSecure: false);
await client.login(user, pass);
return client;
}
void main() {
final imapHost = Platform.environment['STALWART_IMAP_HOST'] ?? '127.0.0.1';
late int imapPort;
late int smtpPort;
late String userA, passA, userB, passB;
setUpAll(() {
imapPort = int.parse(_env('STALWART_IMAP_PORT'));
smtpPort = int.parse(_env('STALWART_SMTP_PORT'));
userA = _env('STALWART_USER_B'); // alice
passA = _env('STALWART_PASS_B');
userB = _env('STALWART_USER_C'); // bob
passB = _env('STALWART_PASS_C');
});
test('login and list mailboxes', () async {
final client = await _connect(userA, passA, host: imapHost, port: imapPort);
addTearDown(() => client.logout().ignore());
final response = await client.listMailboxes();
expect(response.mailboxes, isNotEmpty);
expect(
response.mailboxes!.map((m) => m.name),
contains('INBOX'),
);
});
test('send via SMTP and receive via IMAP', () async {
final smtpClient = SmtpClient('test', isLogEnabled: false);
await smtpClient.connectToServer(imapHost, smtpPort, isSecure: false);
await smtpClient.ehlo();
await smtpClient.authenticate(
'$userA@localhost',
passA,
AuthMechanism.plain,
);
final builder = MessageBuilder()
..from = [MailAddress('Alice', '$userA@localhost')]
..to = [MailAddress('Bob', '$userB@localhost')]
..subject = 'Integration test ${DateTime.now().millisecondsSinceEpoch}'
..text = 'Hello from SharedInbox integration test.';
await smtpClient.sendMessage(builder.buildMimeMessage());
smtpClient.disconnect();
// Give Stalwart a moment to deliver the message.
await Future.delayed(const Duration(milliseconds: 500));
final imapClient =
await _connect(userB, passB, host: imapHost, port: imapPort);
addTearDown(() => imapClient.logout().ignore());
final mailbox = await imapClient.selectMailboxByPath('INBOX');
expect(mailbox.messagesExists, greaterThan(0));
});
}