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:
co-authored by
Claude Sonnet 4.6
parent
5ebda521d6
commit
22db4a2dd6
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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]
|
||||
@@ -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"
|
||||
'';
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
Executable
+74
@@ -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"
|
||||
Executable
+60
@@ -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/
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user