diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..af2fd92 --- /dev/null +++ b/.envrc @@ -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 diff --git a/PLAN.md b/PLAN.md index 318d985..3da811f 100644 --- a/PLAN.md +++ b/PLAN.md @@ -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 diff --git a/Taskfile.yml b/Taskfile.yml new file mode 100644 index 0000000..fc6edc8 --- /dev/null +++ b/Taskfile.yml @@ -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] diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..ebd1b62 --- /dev/null +++ b/flake.nix @@ -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" + ''; + }; + } + ); +} diff --git a/pubspec.yaml b/pubspec.yaml index 9167c78..1065b7c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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 diff --git a/stalwart-dev/config.toml b/stalwart-dev/config.toml new file mode 100644 index 0000000..7dae4a8 --- /dev/null +++ b/stalwart-dev/config.toml @@ -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" diff --git a/stalwart-dev/start b/stalwart-dev/start new file mode 100755 index 0000000..b75e56d --- /dev/null +++ b/stalwart-dev/start @@ -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" <&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" diff --git a/stalwart-dev/test.sh b/stalwart-dev/test.sh new file mode 100755 index 0000000..375c9dc --- /dev/null +++ b/stalwart-dev/test.sh @@ -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/ diff --git a/test/integration/imap_sync_test.dart b/test/integration/imap_sync_test.dart new file mode 100644 index 0000000..b59a3f0 --- /dev/null +++ b/test/integration/imap_sync_test.dart @@ -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 _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)); + }); +}