feat: replace mail protocol with smolmail and rebuild the UI

This commit is contained in:
randogoth 2026-09-26 22:45:38 +03:00
parent cb24b68069
commit e94e4158cc
93 changed files with 6387 additions and 3479 deletions

107
README.md
View file

@ -1,80 +1,67 @@
# FlashMail
# kirakira
FlashMail is a Mobile application designed to provide users with a temporary email solution powered by the mail.tm platform. With FlashMail, users can receive and view emails conveniently while maintaining their privacy. The app utilizes Riverpod as its state management solution for efficient and organized data handling.
A mobile client for [Smol Mail](../smolmail), the minimalist end-to-end encrypted mail protocol: one Ed25519 identity, five operations, Noise_NX transport, sealed and signed messages. The sibling of the reference CLI client (`../smolmail`) and the browser client (`../gsmol`) — everything they do, in an Android app.
This repository contains the source code for FlashMail developed using the Flutter framework. The app is designed to provide various features and functionalities to enhance user experience. Below, you will find information about how to download the app, its features, dependencies, license, and more.
## How it works
## Download
```
app (all crypto, all keys) ⇄ smolmaild (TCP :1961, Noise_NX)
```
You can download the latest version of the app from the [GitHub Releases](https://github.com/sarthakkimtani/flash-mail/releases) page. Look for the most recent release and download the corresponding APK or installer file for your device.
There is no bridge and no server-side account: the Noise session, sealing, signing, pinning and trust-on-first-use all run on the device. The seed lives in Hive app storage — the phone's app sandbox is the trust boundary, as with the CLI client's `identity.key` file.
## Requirements
## First use
- Any Operating System (ie. MacOS X, Linux, Windows)
- Any IDE with Flutter SDK installed (ie. IntelliJ, Android Studio, VSCode, etc)
- A little knowledge of Dart and Flutter
1. Create an identity (or restore from a seed) and back the seed up — it is the only secret.
2. Pin your home server's public key, obtained from the operator through a trusted channel (SPEC.md §4: registration and fetching refuse unpinned servers).
3. Register an address, then fetch. Restoring a seed on a new device? Enter the address alongside the seed — or "Recall" from settings — and it rebinds by RESOLVE + key check, without re-registering.
## Features
## What it implements
- Instantly create temporary email addresses to receive messages without revealing your personal email.
- Enjoy a seamless user experience with an intuitive and user-friendly interface, making it easy to navigate through your emails effortlessly.
- Access and download attachments with ease, allowing you to view and save important files directly from your emails.
- Access and read your emails effortlessly with a seamless viewing experience.
- Benefit from a compact APK size of just 22 MB, ensuring that FlashMail takes up minimal storage space on your device.
- **Identity** (§2): one 32-byte seed; the X25519 agreement keys are derived from the Ed25519 keypair. Superseded seeds are kept after rotation, since mail sealed to them is readable with nothing else.
- **Addressing** (§3): short `user@host[:1961]` and self-certifying `smol://user@host/key` addresses; base32 fingerprints.
- **Trust** (§4, §8): server keys pinned explicitly; mismatch aborts the handshake; registration and fetching require a pin.
- **Mail** (§5): sealed and signed envelopes with 1 KiB padding, flat frontmatter bodies, sent copies sealed to self. Fetch verifies id and signature before acknowledging — anything unreadable stays on the server.
- **Contacts**: outgoing mail carries a signed `Reply-To` field with the sender's full `smol://` address (an "anonymous" switch omits it); a first-contact claim binds only when its key matches the message's signer, and never over an address already pinned to a different key. The contacts screen shows each bound key with its trust badge and the keys it displaced — the only local record that a contact rotated — and a re-resolve applies §8: a chain-validated rotation is accepted and announced, an unexplained key change is refused until verified out of band. Trust warnings (unpinned sessions, rotations) persist on screen until dismissed, as the spec's §4/§8 messages demand.
- **Rotation** (§7): rotate with a signed certificate; contacts accept the change from the chain.
- **Backup**: settings can export the inbox, sent mail, contacts and server pins to one shareable file (and import it back) — the same JSON shape as gsmol's export, so backups move between the two clients. It never contains the seed, which has its own reveal-and-copy flow. Import never overwrites a pin or contact that already differs locally; malformed entries are skipped and counted.
## Screenshots
## Layout
<img src="screenshots/1.png" height="300em" /> <img src="screenshots/2.png" height="300em" /> <img src="screenshots/3.png" height="300em" />
<img src="screenshots/4.png" height="300em" />
```
lib/smol/crypto.dart SHA-2, HKDF, ChaCha20-Poly1305, X25519, Ed25519, §2 conversions
lib/smol/noise.dart Noise_NX_25519_ChaChaPoly_SHA256 initiator
lib/smol/proto.dart addresses, seal/unseal, frontmatter, rotation, op framing
lib/smol/transport.dart TCP byte pipe (dart:io)
lib/smol/store.dart Hive: identity, pins, contacts, sealed mail, export/import
lib/smol/config.dart deploy-time preset server pin (--dart-define)
lib/smol/client.dart connect/fetch/send/register/rotate flows
lib/presentation/ the UI (Riverpod + auto_route)
test/smol_test.dart byte-exact vectors + protocol cases
test/store_test.dart contact key history, import
test/widget_test.dart UI smoke test
test/e2e_test.dart live round-trip against smolmaild
```
## Installation
The crypto is pure Dart, mirroring gsmol's dependency-free modules, so `test/vectors.json` — generated from the reference stack (PyNaCl, noiseprotocol) by `../gsmol/test/gen_vectors.py` — pins every operation byte for byte.
To run the app locally and make modifications, follow these steps:
## Run and verify
1. Ensure you have Flutter SDK installed on your machine. You can download it from the official Flutter website: https://flutter.dev.
2. Clone this repository to your local machine using the following command:<br>
```bash
git clone https://github.com/sarthakkimtani/flash-mail.git
```
3. Navigate to the project directory:<br><br>
```bash
cd flash-mail
```
4. Fetch the app's dependencies by running the following command:<br>
```bash
flutter pub get
```
5. Connect your device or start an emulator.
6. Run the app using the following command:<br>
```bash
flutter run
```
```
devbox run analyze
devbox run test
```
## Dependencies
Then `flutter run` with a device or emulator attached. `test/e2e_test.dart` additionally runs a live round-trip (register, send to self, fetch, unseal, drain) against `../smolmail/smolmaild.py` on `127.0.0.1:1961`, and self-skips when no server is listening.
| Name | Usage |
| ------------------------------------------------------------------------- | --------------------------- |
| [**Auto Route**](https://pub.dev/packages/auto_route) | Navigation & Routing |
| [**Dio**](https://pub.dev/packages/dio) | HTTP Requests |
| [**External Path**](https://pub.dev/packages/external_path) | External Storage Path |
| [**Flash**](https://pub.dev/packages/flash) | Alerts & Dialogs |
| [**Flutter Downloader**](https://pub.dev/packages/flutter_downloader) | Download Files |
| [**Flutter InAppWebView**](https://pub.dev/packages/flutter_inappwebview) | In-App WebView |
| [**Flutter Riverpod**](https://pub.dev/packages/flutter_riverpod) | Global State Management |
| [**Freezed**](https://pub.dev/packages/freezed) | Code Generation |
| [**Hive**](https://pub.dev/packages/hive) | Local Database |
| [**Intl**](https://pub.dev/packages/intl) | Internationalization |
| [**Shimmer**](https://pub.dev/packages/shimmer) | Shimmer for Loading Screens |
| [**URL Launcher**](https://pub.dev/packages/url_launcher) | URL Launcher |
Not verified here: clicking through the app on a real device — the logic under every button is what the tests exercise, as with gsmol.
## Contributing
## Notes and limits
Contributions to this app are welcome! If you find any issues or have ideas for improvements, please open an issue or submit a pull request. Make sure to follow the repository's guidelines for contributing.
- No server push or notifications in v1 (SPEC.md §13): tap fetch.
- The seed sits in app storage: a compromised device is game over, same as a stolen `identity.key` file for the CLI client.
- Rotation is not revocation (§7): a stolen key can rotate onward and the chain validates. The settings screen surfaces rotations instead of applying them invisibly; out-of-band re-verification is the only defence.
## License
This app is distributed under the [MIT License](https://github.com/sarthakkimtani/flash-mail/blob/main/LICENSE). Feel free to modify and use it as per your requirements.
## Disclaimer
FlashMail is an independent project and is not affiliated with or endorsed by the mail.tm platform. Please review and comply with the terms of service of mail.tm when using this application.
Distributed under the MIT License; see LICENSE.md.

View file

@ -2,8 +2,7 @@ include: package:flutter_lints/flutter.yaml
analyzer:
exclude:
- lib/data/models/*/*.g.dart
- lib/data/models/*/*.freezed.dart
- lib/presentation/routes/app_router.gr.dart
linter:
rules:

View file

@ -1,88 +0,0 @@
def localProperties = new Properties()
def localPropertiesFile = rootProject.file('local.properties')
if (localPropertiesFile.exists()) {
localPropertiesFile.withReader('UTF-8') { reader ->
localProperties.load(reader)
}
}
def flutterRoot = localProperties.getProperty('flutter.sdk')
if (flutterRoot == null) {
throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.")
}
def flutterVersionCode = localProperties.getProperty('flutter.versionCode')
if (flutterVersionCode == null) {
flutterVersionCode = '1'
}
def flutterVersionName = localProperties.getProperty('flutter.versionName')
if (flutterVersionName == null) {
flutterVersionName = '1.0'
}
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle"
def keystoreProperties = new Properties()
def keystorePropertiesFile = rootProject.file('key.properties')
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
}
android {
namespace "com.example.flash_mail"
compileSdkVersion 34
ndkVersion flutter.ndkVersion
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = '1.8'
}
sourceSets {
main.java.srcDirs += 'src/main/kotlin'
}
defaultConfig {
applicationId "com.app.flash_mail"
minSdkVersion flutter.minSdkVersion
targetSdkVersion flutter.targetSdkVersion
versionCode flutterVersionCode.toInteger()
versionName flutterVersionName
signingConfigs {
release {
keyAlias keystoreProperties['keyAlias']
keyPassword keystoreProperties['keyPassword']
storeFile file(keystoreProperties['storeFile'])
storePassword keystoreProperties['storePassword']
}
}
buildTypes {
release {
signingConfig signingConfigs.release
}
}
}
buildTypes {
release {
signingConfig signingConfigs.debug
}
}
}
flutter {
source '../..'
}
dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
}

View file

@ -0,0 +1,40 @@
plugins {
id("com.android.application")
id("kotlin-android")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
android {
namespace = "com.example.smol_mail"
compileSdk = flutter.compileSdkVersion
ndkVersion = "27.0.12077973"
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_11.toString()
}
defaultConfig {
applicationId = "com.app.smol_mail"
minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
}
buildTypes {
release {
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
}
}
}
flutter {
source = "../.."
}

View file

@ -1,6 +1,6 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:label="FlashMail"
android:label="kirakira"
android:name="${applicationName}"
android:icon="@mipmap/launcher_icon">
<activity
@ -8,7 +8,6 @@
android:exported="true"
android:launchMode="singleTop"
android:theme="@style/LaunchTheme"
android:requestLegacyExternalStorage="true"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
@ -24,33 +23,6 @@
<meta-data
android:name="flutterEmbedding"
android:value="2" />
<provider
android:name="vn.hunghd.flutterdownloader.DownloadedFileProvider"
android:authorities="${applicationId}.flutter_downloader.provider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/provider_paths"/>
</provider>
</application>
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<queries>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="https" />
</intent>
<intent>
<action android:name="android.intent.action.DIAL" />
<data android:scheme="tel" />
</intent>
<intent>
<action android:name="android.intent.action.SEND" />
<data android:mimeType="*/*" />
</intent>
</queries>
</manifest>

View file

@ -1,4 +1,4 @@
package com.example.flash_mail
package com.example.smol_mail
import io.flutter.embedding.android.FlutterActivity

View file

@ -1,31 +0,0 @@
buildscript {
ext.kotlin_version = '1.7.10'
repositories {
google()
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:7.3.0'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
}
}
allprojects {
repositories {
google()
mavenCentral()
}
}
rootProject.buildDir = '../build'
subprojects {
project.buildDir = "${rootProject.buildDir}/${project.name}"
}
subprojects {
project.evaluationDependsOn(':app')
}
tasks.register("clean", Delete) {
delete rootProject.buildDir
}

33
android/build.gradle.kts Normal file
View file

@ -0,0 +1,33 @@
allprojects {
repositories {
google()
mavenCentral()
}
}
// NDK 28 (flutter.ndkVersion default) is not installed; override all library
// modules to use the installed NDK 27.
subprojects {
afterEvaluate {
if (plugins.hasPlugin("com.android.library")) {
configure<com.android.build.gradle.LibraryExtension> {
ndkVersion = "27.0.12077973"
}
}
}
}
val newBuildDir: Directory = rootProject.layout.buildDirectory.dir("../../build").get()
rootProject.layout.buildDirectory.value(newBuildDir)
subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir)
}
subprojects {
project.evaluationDependsOn(":app")
}
tasks.register<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}

View file

@ -1,3 +1,2 @@
org.gradle.jvmargs=-Xmx1536M
org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
android.enableJetifier=true

View file

@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-all.zip

136
android/nix-android-sdk/flake.lock generated Normal file
View file

@ -0,0 +1,136 @@
{
"nodes": {
"android-nixpkgs": {
"inputs": {
"devshell": "devshell",
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
},
"locked": {
"lastModified": 1768249546,
"narHash": "sha256-cpNxcSNveCBSDmocYlPrayaP8CX8ik0SKR4aNbrxOqA=",
"owner": "tadfisher",
"repo": "android-nixpkgs",
"rev": "001ffcb40e86888d38acf7b851f9a1bfac7d3a81",
"type": "github"
},
"original": {
"owner": "tadfisher",
"ref": "stable",
"repo": "android-nixpkgs",
"type": "github"
}
},
"devshell": {
"inputs": {
"nixpkgs": [
"android-nixpkgs",
"nixpkgs"
]
},
"locked": {
"lastModified": 1764011051,
"narHash": "sha256-M7SZyPZiqZUR/EiiBJnmyUbOi5oE/03tCeFrTiUZchI=",
"owner": "numtide",
"repo": "devshell",
"rev": "17ed8d9744ebe70424659b0ef74ad6d41fc87071",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "devshell",
"type": "github"
}
},
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"flake-utils_2": {
"inputs": {
"systems": "systems_2"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1768127708,
"narHash": "sha256-1Sm77VfZh3mU0F5OqKABNLWxOuDeHIlcFjsXeeiPazs=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "ffbc9f8cbaacfb331b6017d5a5abb21a492c9a38",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"android-nixpkgs": "android-nixpkgs",
"flake-utils": "flake-utils_2"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
},
"systems_2": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

View file

@ -0,0 +1,27 @@
{
description = "A Flake for the Android SDK";
inputs = {
flake-utils.url = "github:numtide/flake-utils";
android-nixpkgs.url = "github:tadfisher/android-nixpkgs/stable";
};
outputs = { self, flake-utils, android-nixpkgs }:
flake-utils.lib.eachSystem ["x86_64-linux" "x86_64-darwin" "aarch64-darwin"] (system: {
packages.android-sdk = android-nixpkgs.sdk."${system}" (sdkPkgs: with sdkPkgs; [
cmdline-tools-latest
build-tools-33-0-0
build-tools-34-0-0
build-tools-35-0-0
build-tools-36-1-0
cmake-3-22-1
platform-tools
platforms-android-33
platforms-android-34
platforms-android-35
platforms-android-36
emulator
ndk-27-0-12077973
]);
});
}

View file

@ -1,11 +0,0 @@
include ':app'
def localPropertiesFile = new File(rootProject.projectDir, "local.properties")
def properties = new Properties()
assert localPropertiesFile.exists()
localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) }
def flutterSdkPath = properties.getProperty("flutter.sdk")
assert flutterSdkPath != null, "flutter.sdk not set in local.properties"
apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle"

View file

@ -0,0 +1,25 @@
pluginManagement {
val flutterSdkPath = run {
val properties = java.util.Properties()
file("local.properties").inputStream().use { properties.load(it) }
val flutterSdkPath = properties.getProperty("flutter.sdk")
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
flutterSdkPath
}
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.9.1" apply false
id("org.jetbrains.kotlin.android") version "2.1.0" apply false
}
include(":app")

24
devbox.json Normal file
View file

@ -0,0 +1,24 @@
{
"$schema": "https://raw.githubusercontent.com/jetify-com/devbox/main/packages/devbox.schema.json",
"packages": [
"flutter@3.41.9-sdk-links",
"nixpkgs#android-tools",
"nixpkgs#openjdk17",
"path:./android/nix-android-sdk#android-sdk"
],
"shell": {
"init_hook": [
"flutter config --jdk-dir \"$(dirname \"$(dirname \"$(command -v java)\")\")\" > /dev/null",
"flutter --version"
],
"scripts": {
"analyze": "flutter analyze",
"test": "flutter test",
"link-android-tools": "if [ -n \"$ANDROID_HOME\" ] && [ ! -d \"$ANDROID_HOME/cmdline-tools/latest\" ] && [ -d \"$ANDROID_HOME/cmdline-tools/8.0\" ]; then ln -s \"$ANDROID_HOME/cmdline-tools/8.0\" \"$ANDROID_HOME/cmdline-tools/latest\"; fi"
}
},
"env": {
"ANDROID_SDK_ROOT": "$DEVBOX_PACKAGES_DIR/share/android-sdk",
"ANDROID_HOME": "$DEVBOX_PACKAGES_DIR/share/android-sdk"
}
}

65
devbox.lock Normal file
View file

@ -0,0 +1,65 @@
{
"lockfile_version": "1",
"packages": {
"flutter@3.41.9-sdk-links": {
"last_modified": "2026-05-21T08:15:18Z",
"resolved": "github:NixOS/nixpkgs/4a29d733e8a7d5b824c3d8c958a946a9867b3eb2#flutter",
"source": "devbox-search",
"version": "3.41.9-sdk-links",
"systems": {
"aarch64-darwin": {
"outputs": [
{
"name": "out",
"path": "/nix/store/mp9x3mn0x5hs11m8z4m20w8gndkkccb7-flutter-wrapped-3.41.9-sdk-links",
"default": true
}
],
"store_path": "/nix/store/mp9x3mn0x5hs11m8z4m20w8gndkkccb7-flutter-wrapped-3.41.9-sdk-links"
},
"aarch64-linux": {
"outputs": [
{
"name": "out",
"path": "/nix/store/aidhap5ilmmf4fzyr0gwxxls0d7b9h7z-flutter-wrapped-3.41.9-sdk-links",
"default": true
}
],
"store_path": "/nix/store/aidhap5ilmmf4fzyr0gwxxls0d7b9h7z-flutter-wrapped-3.41.9-sdk-links"
},
"x86_64-darwin": {
"outputs": [
{
"name": "out",
"path": "/nix/store/27gbcgwqbaaf81i792zssg862xcwzwzg-flutter-wrapped-3.41.9-sdk-links",
"default": true
}
],
"store_path": "/nix/store/27gbcgwqbaaf81i792zssg862xcwzwzg-flutter-wrapped-3.41.9-sdk-links"
},
"x86_64-linux": {
"outputs": [
{
"name": "out",
"path": "/nix/store/fqcjikcpdcn123csd805bmg05jv75szj-flutter-wrapped-3.41.9-sdk-links",
"default": true
}
],
"store_path": "/nix/store/fqcjikcpdcn123csd805bmg05jv75szj-flutter-wrapped-3.41.9-sdk-links"
}
}
},
"github:NixOS/nixpkgs/nixpkgs-unstable": {
"last_modified": "2026-08-27T07:16:00Z",
"resolved": "github:NixOS/nixpkgs/c27cdad491a991b11ed731760aa2ef8db0cb0410?lastModified=1787814960&narHash=sha256-PYZq1qzCJXC2zGI0mH07vrZBsw6DRBAOX0jN1pPtqOQ%3D"
},
"nixpkgs#android-tools": {
"last_modified": "1970-01-01T00:00:00Z",
"resolved": "path:/nix/store/v8ynlbrldn7k8byb1kx0j0wka8dsbrw9-source?narHash=sha256-BZWCi9ZRJiARTuKTbbtvFTj7t1TK4G3UEckT3HyNfRg%3D#android-tools"
},
"nixpkgs#openjdk17": {
"last_modified": "1970-01-01T00:00:00Z",
"resolved": "path:/nix/store/v8ynlbrldn7k8byb1kx0j0wka8dsbrw9-source?narHash=sha256-BZWCi9ZRJiARTuKTbbtvFTj7t1TK4G3UEckT3HyNfRg%3D#openjdk17"
}
}
}

View file

@ -1,123 +0,0 @@
import "dart:convert";
import "package:dio/dio.dart";
import "package:flash_mail/data/providers/providers.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:flash_mail/data/models/account/account.dart";
import "package:flash_mail/data/models/message/message.dart";
import "package:flash_mail/data/models/message_detail/message_detail.dart";
import "package:flash_mail/shared/utils/random_string.dart";
class ApiClient {
final Dio dio;
final Ref ref;
ApiClient(this.dio, this.ref);
Future<void> generateToken(String address, String password) async {
final tokenResponse = await dio.post(
"/token",
data: jsonEncode({"address": address, "password": password}),
);
final Map<String, dynamic> tokenData =
tokenResponse.data as Map<String, dynamic>;
ref.read(tokenProvider.notifier).state = tokenData["token"];
}
Future<TMAccount> createAccount() async {
final response = await dio.get("/domains");
final Map<String, dynamic> data = response.data as Map<String, dynamic>;
final List<Map<String, dynamic>> domains = List.from(data["hydra:member"]);
final username = randomString(7);
final domain = domains[0]["domain"];
Map<String, String> payload = {
"address": "$username@$domain",
"password": randomString(10),
};
final userResponse = await dio.post(
"/accounts",
data: jsonEncode(payload),
);
final Map<String, dynamic> info = userResponse.data as Map<String, dynamic>;
payload["address"] = info["address"];
await generateToken(
payload["address"] as String,
payload["password"] as String,
);
return TMAccount(
id: info["id"],
email: info["address"],
password: payload["password"]!,
quota: info["quota"],
used: info["used"],
);
}
Future<TMAccount> login(String address, String password) async {
final response = await dio.post(
"/token",
data: jsonEncode({"address": address, "password": password}),
);
final Map<String, dynamic> tokenData =
response.data as Map<String, dynamic>;
ref.read(tokenProvider.notifier).state = tokenData["token"];
final userId = tokenData["id"];
final options = Options(headers: {
"Authorization": "Bearer ${tokenData["token"]}",
});
final userResponse = await dio.get("/accounts/$userId", options: options);
final Map<String, dynamic> info = userResponse.data as Map<String, dynamic>;
return TMAccount(
id: info["id"],
email: address,
password: password,
quota: info["quota"],
used: info["used"],
);
}
Future<List<TMMessage>> fetchMessages(String email, String password) async {
if (ref.read(tokenProvider) == null) {
await generateToken(email, password);
}
final token = ref.read(tokenProvider);
final options = Options(headers: {"Authorization": "Bearer $token"});
final response = await dio.get("/messages", options: options);
Map<String, dynamic> messageData = response.data as Map<String, dynamic>;
final messages =
List<Map<String, dynamic>>.from(messageData["hydra:member"]);
return messages.map((message) => TMMessage.fromJson(message)).toList();
}
Future<TMMessageDetail> fetchMessageDetail(String id) async {
if (ref.read(tokenProvider) == null) throw Exception("Token not found");
final token = ref.read(tokenProvider);
final options = Options(headers: {"Authorization": "Bearer $token"});
final response = await dio.get("/messages/$id", options: options);
Map<String, dynamic> messageData = response.data as Map<String, dynamic>;
return TMMessageDetail.fromJson(messageData);
}
Future<void> deleteMessage(String id) async {
if (ref.read(tokenProvider) == null) throw Exception("Token not found");
final token = ref.read(tokenProvider);
final options = Options(headers: {"Authorization": "Bearer $token"});
await dio.delete("/messages/$id", options: options);
}
}

View file

@ -1,20 +0,0 @@
import "package:freezed_annotation/freezed_annotation.dart";
import "package:hive/hive.dart";
part "account.freezed.dart";
part "account.g.dart";
@freezed
class TMAccount with _$TMAccount {
@HiveType(typeId: 0, adapterName: "AccountAdapter")
factory TMAccount({
@HiveField(0) required String id,
@HiveField(1) required String email,
@HiveField(2) required String password,
@HiveField(3) required int quota,
@HiveField(4) required int used,
}) = _TMAccount;
factory TMAccount.fromJson(Map<String, dynamic> json) =>
_$TMAccountFromJson(json);
}

View file

@ -1,252 +0,0 @@
// coverage:ignore-file
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'account.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
T _$identity<T>(T value) => value;
final _privateConstructorUsedError = UnsupportedError(
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#custom-getters-and-methods');
TMAccount _$TMAccountFromJson(Map<String, dynamic> json) {
return _TMAccount.fromJson(json);
}
/// @nodoc
mixin _$TMAccount {
@HiveField(0)
String get id => throw _privateConstructorUsedError;
@HiveField(1)
String get email => throw _privateConstructorUsedError;
@HiveField(2)
String get password => throw _privateConstructorUsedError;
@HiveField(3)
int get quota => throw _privateConstructorUsedError;
@HiveField(4)
int get used => throw _privateConstructorUsedError;
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
@JsonKey(ignore: true)
$TMAccountCopyWith<TMAccount> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $TMAccountCopyWith<$Res> {
factory $TMAccountCopyWith(TMAccount value, $Res Function(TMAccount) then) =
_$TMAccountCopyWithImpl<$Res, TMAccount>;
@useResult
$Res call(
{@HiveField(0) String id,
@HiveField(1) String email,
@HiveField(2) String password,
@HiveField(3) int quota,
@HiveField(4) int used});
}
/// @nodoc
class _$TMAccountCopyWithImpl<$Res, $Val extends TMAccount>
implements $TMAccountCopyWith<$Res> {
_$TMAccountCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
@pragma('vm:prefer-inline')
@override
$Res call({
Object? id = null,
Object? email = null,
Object? password = null,
Object? quota = null,
Object? used = null,
}) {
return _then(_value.copyWith(
id: null == id
? _value.id
: id // ignore: cast_nullable_to_non_nullable
as String,
email: null == email
? _value.email
: email // ignore: cast_nullable_to_non_nullable
as String,
password: null == password
? _value.password
: password // ignore: cast_nullable_to_non_nullable
as String,
quota: null == quota
? _value.quota
: quota // ignore: cast_nullable_to_non_nullable
as int,
used: null == used
? _value.used
: used // ignore: cast_nullable_to_non_nullable
as int,
) as $Val);
}
}
/// @nodoc
abstract class _$$TMAccountImplCopyWith<$Res>
implements $TMAccountCopyWith<$Res> {
factory _$$TMAccountImplCopyWith(
_$TMAccountImpl value, $Res Function(_$TMAccountImpl) then) =
__$$TMAccountImplCopyWithImpl<$Res>;
@override
@useResult
$Res call(
{@HiveField(0) String id,
@HiveField(1) String email,
@HiveField(2) String password,
@HiveField(3) int quota,
@HiveField(4) int used});
}
/// @nodoc
class __$$TMAccountImplCopyWithImpl<$Res>
extends _$TMAccountCopyWithImpl<$Res, _$TMAccountImpl>
implements _$$TMAccountImplCopyWith<$Res> {
__$$TMAccountImplCopyWithImpl(
_$TMAccountImpl _value, $Res Function(_$TMAccountImpl) _then)
: super(_value, _then);
@pragma('vm:prefer-inline')
@override
$Res call({
Object? id = null,
Object? email = null,
Object? password = null,
Object? quota = null,
Object? used = null,
}) {
return _then(_$TMAccountImpl(
id: null == id
? _value.id
: id // ignore: cast_nullable_to_non_nullable
as String,
email: null == email
? _value.email
: email // ignore: cast_nullable_to_non_nullable
as String,
password: null == password
? _value.password
: password // ignore: cast_nullable_to_non_nullable
as String,
quota: null == quota
? _value.quota
: quota // ignore: cast_nullable_to_non_nullable
as int,
used: null == used
? _value.used
: used // ignore: cast_nullable_to_non_nullable
as int,
));
}
}
/// @nodoc
@JsonSerializable()
@HiveType(typeId: 0, adapterName: "AccountAdapter")
class _$TMAccountImpl implements _TMAccount {
_$TMAccountImpl(
{@HiveField(0) required this.id,
@HiveField(1) required this.email,
@HiveField(2) required this.password,
@HiveField(3) required this.quota,
@HiveField(4) required this.used});
factory _$TMAccountImpl.fromJson(Map<String, dynamic> json) =>
_$$TMAccountImplFromJson(json);
@override
@HiveField(0)
final String id;
@override
@HiveField(1)
final String email;
@override
@HiveField(2)
final String password;
@override
@HiveField(3)
final int quota;
@override
@HiveField(4)
final int used;
@override
String toString() {
return 'TMAccount(id: $id, email: $email, password: $password, quota: $quota, used: $used)';
}
@override
bool operator ==(dynamic other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$TMAccountImpl &&
(identical(other.id, id) || other.id == id) &&
(identical(other.email, email) || other.email == email) &&
(identical(other.password, password) ||
other.password == password) &&
(identical(other.quota, quota) || other.quota == quota) &&
(identical(other.used, used) || other.used == used));
}
@JsonKey(ignore: true)
@override
int get hashCode =>
Object.hash(runtimeType, id, email, password, quota, used);
@JsonKey(ignore: true)
@override
@pragma('vm:prefer-inline')
_$$TMAccountImplCopyWith<_$TMAccountImpl> get copyWith =>
__$$TMAccountImplCopyWithImpl<_$TMAccountImpl>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$$TMAccountImplToJson(
this,
);
}
}
abstract class _TMAccount implements TMAccount {
factory _TMAccount(
{@HiveField(0) required final String id,
@HiveField(1) required final String email,
@HiveField(2) required final String password,
@HiveField(3) required final int quota,
@HiveField(4) required final int used}) = _$TMAccountImpl;
factory _TMAccount.fromJson(Map<String, dynamic> json) =
_$TMAccountImpl.fromJson;
@override
@HiveField(0)
String get id;
@override
@HiveField(1)
String get email;
@override
@HiveField(2)
String get password;
@override
@HiveField(3)
int get quota;
@override
@HiveField(4)
int get used;
@override
@JsonKey(ignore: true)
_$$TMAccountImplCopyWith<_$TMAccountImpl> get copyWith =>
throw _privateConstructorUsedError;
}

View file

@ -1,75 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'account.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class AccountAdapter extends TypeAdapter<_$TMAccountImpl> {
@override
final int typeId = 0;
@override
_$TMAccountImpl read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return _$TMAccountImpl(
id: fields[0] as String,
email: fields[1] as String,
password: fields[2] as String,
quota: fields[3] as int,
used: fields[4] as int,
);
}
@override
void write(BinaryWriter writer, _$TMAccountImpl obj) {
writer
..writeByte(5)
..writeByte(0)
..write(obj.id)
..writeByte(1)
..write(obj.email)
..writeByte(2)
..write(obj.password)
..writeByte(3)
..write(obj.quota)
..writeByte(4)
..write(obj.used);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is AccountAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_$TMAccountImpl _$$TMAccountImplFromJson(Map<String, dynamic> json) =>
_$TMAccountImpl(
id: json['id'] as String,
email: json['email'] as String,
password: json['password'] as String,
quota: json['quota'] as int,
used: json['used'] as int,
);
Map<String, dynamic> _$$TMAccountImplToJson(_$TMAccountImpl instance) =>
<String, dynamic>{
'id': instance.id,
'email': instance.email,
'password': instance.password,
'quota': instance.quota,
'used': instance.used,
};

View file

@ -1,23 +0,0 @@
import "package:freezed_annotation/freezed_annotation.dart";
part "message.freezed.dart";
part "message.g.dart";
@freezed
class TMMessage with _$TMMessage {
factory TMMessage({
required String id,
required Map<String, String> from,
required List<Map<String, String>> to,
required String? intro,
required String subject,
required bool seen,
required bool isDeleted,
required bool hasAttachments,
required String downloadUrl,
required DateTime createdAt,
}) = _TMMessage;
factory TMMessage.fromJson(Map<String, dynamic> json) =>
_$TMMessageFromJson(json);
}

View file

@ -1,363 +0,0 @@
// coverage:ignore-file
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'message.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
T _$identity<T>(T value) => value;
final _privateConstructorUsedError = UnsupportedError(
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#custom-getters-and-methods');
TMMessage _$TMMessageFromJson(Map<String, dynamic> json) {
return _TMMessage.fromJson(json);
}
/// @nodoc
mixin _$TMMessage {
String get id => throw _privateConstructorUsedError;
Map<String, String> get from => throw _privateConstructorUsedError;
List<Map<String, String>> get to => throw _privateConstructorUsedError;
String? get intro => throw _privateConstructorUsedError;
String get subject => throw _privateConstructorUsedError;
bool get seen => throw _privateConstructorUsedError;
bool get isDeleted => throw _privateConstructorUsedError;
bool get hasAttachments => throw _privateConstructorUsedError;
String get downloadUrl => throw _privateConstructorUsedError;
DateTime get createdAt => throw _privateConstructorUsedError;
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
@JsonKey(ignore: true)
$TMMessageCopyWith<TMMessage> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $TMMessageCopyWith<$Res> {
factory $TMMessageCopyWith(TMMessage value, $Res Function(TMMessage) then) =
_$TMMessageCopyWithImpl<$Res, TMMessage>;
@useResult
$Res call(
{String id,
Map<String, String> from,
List<Map<String, String>> to,
String? intro,
String subject,
bool seen,
bool isDeleted,
bool hasAttachments,
String downloadUrl,
DateTime createdAt});
}
/// @nodoc
class _$TMMessageCopyWithImpl<$Res, $Val extends TMMessage>
implements $TMMessageCopyWith<$Res> {
_$TMMessageCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
@pragma('vm:prefer-inline')
@override
$Res call({
Object? id = null,
Object? from = null,
Object? to = null,
Object? intro = freezed,
Object? subject = null,
Object? seen = null,
Object? isDeleted = null,
Object? hasAttachments = null,
Object? downloadUrl = null,
Object? createdAt = null,
}) {
return _then(_value.copyWith(
id: null == id
? _value.id
: id // ignore: cast_nullable_to_non_nullable
as String,
from: null == from
? _value.from
: from // ignore: cast_nullable_to_non_nullable
as Map<String, String>,
to: null == to
? _value.to
: to // ignore: cast_nullable_to_non_nullable
as List<Map<String, String>>,
intro: freezed == intro
? _value.intro
: intro // ignore: cast_nullable_to_non_nullable
as String?,
subject: null == subject
? _value.subject
: subject // ignore: cast_nullable_to_non_nullable
as String,
seen: null == seen
? _value.seen
: seen // ignore: cast_nullable_to_non_nullable
as bool,
isDeleted: null == isDeleted
? _value.isDeleted
: isDeleted // ignore: cast_nullable_to_non_nullable
as bool,
hasAttachments: null == hasAttachments
? _value.hasAttachments
: hasAttachments // ignore: cast_nullable_to_non_nullable
as bool,
downloadUrl: null == downloadUrl
? _value.downloadUrl
: downloadUrl // ignore: cast_nullable_to_non_nullable
as String,
createdAt: null == createdAt
? _value.createdAt
: createdAt // ignore: cast_nullable_to_non_nullable
as DateTime,
) as $Val);
}
}
/// @nodoc
abstract class _$$TMMessageImplCopyWith<$Res>
implements $TMMessageCopyWith<$Res> {
factory _$$TMMessageImplCopyWith(
_$TMMessageImpl value, $Res Function(_$TMMessageImpl) then) =
__$$TMMessageImplCopyWithImpl<$Res>;
@override
@useResult
$Res call(
{String id,
Map<String, String> from,
List<Map<String, String>> to,
String? intro,
String subject,
bool seen,
bool isDeleted,
bool hasAttachments,
String downloadUrl,
DateTime createdAt});
}
/// @nodoc
class __$$TMMessageImplCopyWithImpl<$Res>
extends _$TMMessageCopyWithImpl<$Res, _$TMMessageImpl>
implements _$$TMMessageImplCopyWith<$Res> {
__$$TMMessageImplCopyWithImpl(
_$TMMessageImpl _value, $Res Function(_$TMMessageImpl) _then)
: super(_value, _then);
@pragma('vm:prefer-inline')
@override
$Res call({
Object? id = null,
Object? from = null,
Object? to = null,
Object? intro = freezed,
Object? subject = null,
Object? seen = null,
Object? isDeleted = null,
Object? hasAttachments = null,
Object? downloadUrl = null,
Object? createdAt = null,
}) {
return _then(_$TMMessageImpl(
id: null == id
? _value.id
: id // ignore: cast_nullable_to_non_nullable
as String,
from: null == from
? _value._from
: from // ignore: cast_nullable_to_non_nullable
as Map<String, String>,
to: null == to
? _value._to
: to // ignore: cast_nullable_to_non_nullable
as List<Map<String, String>>,
intro: freezed == intro
? _value.intro
: intro // ignore: cast_nullable_to_non_nullable
as String?,
subject: null == subject
? _value.subject
: subject // ignore: cast_nullable_to_non_nullable
as String,
seen: null == seen
? _value.seen
: seen // ignore: cast_nullable_to_non_nullable
as bool,
isDeleted: null == isDeleted
? _value.isDeleted
: isDeleted // ignore: cast_nullable_to_non_nullable
as bool,
hasAttachments: null == hasAttachments
? _value.hasAttachments
: hasAttachments // ignore: cast_nullable_to_non_nullable
as bool,
downloadUrl: null == downloadUrl
? _value.downloadUrl
: downloadUrl // ignore: cast_nullable_to_non_nullable
as String,
createdAt: null == createdAt
? _value.createdAt
: createdAt // ignore: cast_nullable_to_non_nullable
as DateTime,
));
}
}
/// @nodoc
@JsonSerializable()
class _$TMMessageImpl implements _TMMessage {
_$TMMessageImpl(
{required this.id,
required final Map<String, String> from,
required final List<Map<String, String>> to,
required this.intro,
required this.subject,
required this.seen,
required this.isDeleted,
required this.hasAttachments,
required this.downloadUrl,
required this.createdAt})
: _from = from,
_to = to;
factory _$TMMessageImpl.fromJson(Map<String, dynamic> json) =>
_$$TMMessageImplFromJson(json);
@override
final String id;
final Map<String, String> _from;
@override
Map<String, String> get from {
if (_from is EqualUnmodifiableMapView) return _from;
// ignore: implicit_dynamic_type
return EqualUnmodifiableMapView(_from);
}
final List<Map<String, String>> _to;
@override
List<Map<String, String>> get to {
if (_to is EqualUnmodifiableListView) return _to;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_to);
}
@override
final String? intro;
@override
final String subject;
@override
final bool seen;
@override
final bool isDeleted;
@override
final bool hasAttachments;
@override
final String downloadUrl;
@override
final DateTime createdAt;
@override
String toString() {
return 'TMMessage(id: $id, from: $from, to: $to, intro: $intro, subject: $subject, seen: $seen, isDeleted: $isDeleted, hasAttachments: $hasAttachments, downloadUrl: $downloadUrl, createdAt: $createdAt)';
}
@override
bool operator ==(dynamic other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$TMMessageImpl &&
(identical(other.id, id) || other.id == id) &&
const DeepCollectionEquality().equals(other._from, _from) &&
const DeepCollectionEquality().equals(other._to, _to) &&
(identical(other.intro, intro) || other.intro == intro) &&
(identical(other.subject, subject) || other.subject == subject) &&
(identical(other.seen, seen) || other.seen == seen) &&
(identical(other.isDeleted, isDeleted) ||
other.isDeleted == isDeleted) &&
(identical(other.hasAttachments, hasAttachments) ||
other.hasAttachments == hasAttachments) &&
(identical(other.downloadUrl, downloadUrl) ||
other.downloadUrl == downloadUrl) &&
(identical(other.createdAt, createdAt) ||
other.createdAt == createdAt));
}
@JsonKey(ignore: true)
@override
int get hashCode => Object.hash(
runtimeType,
id,
const DeepCollectionEquality().hash(_from),
const DeepCollectionEquality().hash(_to),
intro,
subject,
seen,
isDeleted,
hasAttachments,
downloadUrl,
createdAt);
@JsonKey(ignore: true)
@override
@pragma('vm:prefer-inline')
_$$TMMessageImplCopyWith<_$TMMessageImpl> get copyWith =>
__$$TMMessageImplCopyWithImpl<_$TMMessageImpl>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$$TMMessageImplToJson(
this,
);
}
}
abstract class _TMMessage implements TMMessage {
factory _TMMessage(
{required final String id,
required final Map<String, String> from,
required final List<Map<String, String>> to,
required final String? intro,
required final String subject,
required final bool seen,
required final bool isDeleted,
required final bool hasAttachments,
required final String downloadUrl,
required final DateTime createdAt}) = _$TMMessageImpl;
factory _TMMessage.fromJson(Map<String, dynamic> json) =
_$TMMessageImpl.fromJson;
@override
String get id;
@override
Map<String, String> get from;
@override
List<Map<String, String>> get to;
@override
String? get intro;
@override
String get subject;
@override
bool get seen;
@override
bool get isDeleted;
@override
bool get hasAttachments;
@override
String get downloadUrl;
@override
DateTime get createdAt;
@override
@JsonKey(ignore: true)
_$$TMMessageImplCopyWith<_$TMMessageImpl> get copyWith =>
throw _privateConstructorUsedError;
}

View file

@ -1,37 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'message.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_$TMMessageImpl _$$TMMessageImplFromJson(Map<String, dynamic> json) =>
_$TMMessageImpl(
id: json['id'] as String,
from: Map<String, String>.from(json['from'] as Map),
to: (json['to'] as List<dynamic>)
.map((e) => Map<String, String>.from(e as Map))
.toList(),
intro: json['intro'] as String?,
subject: json['subject'] as String,
seen: json['seen'] as bool,
isDeleted: json['isDeleted'] as bool,
hasAttachments: json['hasAttachments'] as bool,
downloadUrl: json['downloadUrl'] as String,
createdAt: DateTime.parse(json['createdAt'] as String),
);
Map<String, dynamic> _$$TMMessageImplToJson(_$TMMessageImpl instance) =>
<String, dynamic>{
'id': instance.id,
'from': instance.from,
'to': instance.to,
'intro': instance.intro,
'subject': instance.subject,
'seen': instance.seen,
'isDeleted': instance.isDeleted,
'hasAttachments': instance.hasAttachments,
'downloadUrl': instance.downloadUrl,
'createdAt': instance.createdAt.toIso8601String(),
};

View file

@ -1,25 +0,0 @@
import "package:freezed_annotation/freezed_annotation.dart";
part "message_detail.freezed.dart";
part "message_detail.g.dart";
@freezed
class TMMessageDetail with _$TMMessageDetail {
factory TMMessageDetail({
required String id,
required Map<String, String> from,
required List<Map<String, String>> to,
required List<String> cc,
required List<String> bcc,
required String subject,
required String? text,
required List<String> html,
required bool hasAttachments,
required List<Map<String, dynamic>>? attachments,
required String downloadUrl,
required DateTime createdAt,
}) = _TMMessageDetail;
factory TMMessageDetail.fromJson(Map<String, dynamic> json) =>
_$TMMessageDetailFromJson(json);
}

View file

@ -1,438 +0,0 @@
// coverage:ignore-file
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'message_detail.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
T _$identity<T>(T value) => value;
final _privateConstructorUsedError = UnsupportedError(
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#custom-getters-and-methods');
TMMessageDetail _$TMMessageDetailFromJson(Map<String, dynamic> json) {
return _TMMessageDetail.fromJson(json);
}
/// @nodoc
mixin _$TMMessageDetail {
String get id => throw _privateConstructorUsedError;
Map<String, String> get from => throw _privateConstructorUsedError;
List<Map<String, String>> get to => throw _privateConstructorUsedError;
List<String> get cc => throw _privateConstructorUsedError;
List<String> get bcc => throw _privateConstructorUsedError;
String get subject => throw _privateConstructorUsedError;
String? get text => throw _privateConstructorUsedError;
List<String> get html => throw _privateConstructorUsedError;
bool get hasAttachments => throw _privateConstructorUsedError;
List<Map<String, dynamic>>? get attachments =>
throw _privateConstructorUsedError;
String get downloadUrl => throw _privateConstructorUsedError;
DateTime get createdAt => throw _privateConstructorUsedError;
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
@JsonKey(ignore: true)
$TMMessageDetailCopyWith<TMMessageDetail> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $TMMessageDetailCopyWith<$Res> {
factory $TMMessageDetailCopyWith(
TMMessageDetail value, $Res Function(TMMessageDetail) then) =
_$TMMessageDetailCopyWithImpl<$Res, TMMessageDetail>;
@useResult
$Res call(
{String id,
Map<String, String> from,
List<Map<String, String>> to,
List<String> cc,
List<String> bcc,
String subject,
String? text,
List<String> html,
bool hasAttachments,
List<Map<String, dynamic>>? attachments,
String downloadUrl,
DateTime createdAt});
}
/// @nodoc
class _$TMMessageDetailCopyWithImpl<$Res, $Val extends TMMessageDetail>
implements $TMMessageDetailCopyWith<$Res> {
_$TMMessageDetailCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
@pragma('vm:prefer-inline')
@override
$Res call({
Object? id = null,
Object? from = null,
Object? to = null,
Object? cc = null,
Object? bcc = null,
Object? subject = null,
Object? text = freezed,
Object? html = null,
Object? hasAttachments = null,
Object? attachments = freezed,
Object? downloadUrl = null,
Object? createdAt = null,
}) {
return _then(_value.copyWith(
id: null == id
? _value.id
: id // ignore: cast_nullable_to_non_nullable
as String,
from: null == from
? _value.from
: from // ignore: cast_nullable_to_non_nullable
as Map<String, String>,
to: null == to
? _value.to
: to // ignore: cast_nullable_to_non_nullable
as List<Map<String, String>>,
cc: null == cc
? _value.cc
: cc // ignore: cast_nullable_to_non_nullable
as List<String>,
bcc: null == bcc
? _value.bcc
: bcc // ignore: cast_nullable_to_non_nullable
as List<String>,
subject: null == subject
? _value.subject
: subject // ignore: cast_nullable_to_non_nullable
as String,
text: freezed == text
? _value.text
: text // ignore: cast_nullable_to_non_nullable
as String?,
html: null == html
? _value.html
: html // ignore: cast_nullable_to_non_nullable
as List<String>,
hasAttachments: null == hasAttachments
? _value.hasAttachments
: hasAttachments // ignore: cast_nullable_to_non_nullable
as bool,
attachments: freezed == attachments
? _value.attachments
: attachments // ignore: cast_nullable_to_non_nullable
as List<Map<String, dynamic>>?,
downloadUrl: null == downloadUrl
? _value.downloadUrl
: downloadUrl // ignore: cast_nullable_to_non_nullable
as String,
createdAt: null == createdAt
? _value.createdAt
: createdAt // ignore: cast_nullable_to_non_nullable
as DateTime,
) as $Val);
}
}
/// @nodoc
abstract class _$$TMMessageDetailImplCopyWith<$Res>
implements $TMMessageDetailCopyWith<$Res> {
factory _$$TMMessageDetailImplCopyWith(_$TMMessageDetailImpl value,
$Res Function(_$TMMessageDetailImpl) then) =
__$$TMMessageDetailImplCopyWithImpl<$Res>;
@override
@useResult
$Res call(
{String id,
Map<String, String> from,
List<Map<String, String>> to,
List<String> cc,
List<String> bcc,
String subject,
String? text,
List<String> html,
bool hasAttachments,
List<Map<String, dynamic>>? attachments,
String downloadUrl,
DateTime createdAt});
}
/// @nodoc
class __$$TMMessageDetailImplCopyWithImpl<$Res>
extends _$TMMessageDetailCopyWithImpl<$Res, _$TMMessageDetailImpl>
implements _$$TMMessageDetailImplCopyWith<$Res> {
__$$TMMessageDetailImplCopyWithImpl(
_$TMMessageDetailImpl _value, $Res Function(_$TMMessageDetailImpl) _then)
: super(_value, _then);
@pragma('vm:prefer-inline')
@override
$Res call({
Object? id = null,
Object? from = null,
Object? to = null,
Object? cc = null,
Object? bcc = null,
Object? subject = null,
Object? text = freezed,
Object? html = null,
Object? hasAttachments = null,
Object? attachments = freezed,
Object? downloadUrl = null,
Object? createdAt = null,
}) {
return _then(_$TMMessageDetailImpl(
id: null == id
? _value.id
: id // ignore: cast_nullable_to_non_nullable
as String,
from: null == from
? _value._from
: from // ignore: cast_nullable_to_non_nullable
as Map<String, String>,
to: null == to
? _value._to
: to // ignore: cast_nullable_to_non_nullable
as List<Map<String, String>>,
cc: null == cc
? _value._cc
: cc // ignore: cast_nullable_to_non_nullable
as List<String>,
bcc: null == bcc
? _value._bcc
: bcc // ignore: cast_nullable_to_non_nullable
as List<String>,
subject: null == subject
? _value.subject
: subject // ignore: cast_nullable_to_non_nullable
as String,
text: freezed == text
? _value.text
: text // ignore: cast_nullable_to_non_nullable
as String?,
html: null == html
? _value._html
: html // ignore: cast_nullable_to_non_nullable
as List<String>,
hasAttachments: null == hasAttachments
? _value.hasAttachments
: hasAttachments // ignore: cast_nullable_to_non_nullable
as bool,
attachments: freezed == attachments
? _value._attachments
: attachments // ignore: cast_nullable_to_non_nullable
as List<Map<String, dynamic>>?,
downloadUrl: null == downloadUrl
? _value.downloadUrl
: downloadUrl // ignore: cast_nullable_to_non_nullable
as String,
createdAt: null == createdAt
? _value.createdAt
: createdAt // ignore: cast_nullable_to_non_nullable
as DateTime,
));
}
}
/// @nodoc
@JsonSerializable()
class _$TMMessageDetailImpl implements _TMMessageDetail {
_$TMMessageDetailImpl(
{required this.id,
required final Map<String, String> from,
required final List<Map<String, String>> to,
required final List<String> cc,
required final List<String> bcc,
required this.subject,
required this.text,
required final List<String> html,
required this.hasAttachments,
required final List<Map<String, dynamic>>? attachments,
required this.downloadUrl,
required this.createdAt})
: _from = from,
_to = to,
_cc = cc,
_bcc = bcc,
_html = html,
_attachments = attachments;
factory _$TMMessageDetailImpl.fromJson(Map<String, dynamic> json) =>
_$$TMMessageDetailImplFromJson(json);
@override
final String id;
final Map<String, String> _from;
@override
Map<String, String> get from {
if (_from is EqualUnmodifiableMapView) return _from;
// ignore: implicit_dynamic_type
return EqualUnmodifiableMapView(_from);
}
final List<Map<String, String>> _to;
@override
List<Map<String, String>> get to {
if (_to is EqualUnmodifiableListView) return _to;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_to);
}
final List<String> _cc;
@override
List<String> get cc {
if (_cc is EqualUnmodifiableListView) return _cc;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_cc);
}
final List<String> _bcc;
@override
List<String> get bcc {
if (_bcc is EqualUnmodifiableListView) return _bcc;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_bcc);
}
@override
final String subject;
@override
final String? text;
final List<String> _html;
@override
List<String> get html {
if (_html is EqualUnmodifiableListView) return _html;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_html);
}
@override
final bool hasAttachments;
final List<Map<String, dynamic>>? _attachments;
@override
List<Map<String, dynamic>>? get attachments {
final value = _attachments;
if (value == null) return null;
if (_attachments is EqualUnmodifiableListView) return _attachments;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(value);
}
@override
final String downloadUrl;
@override
final DateTime createdAt;
@override
String toString() {
return 'TMMessageDetail(id: $id, from: $from, to: $to, cc: $cc, bcc: $bcc, subject: $subject, text: $text, html: $html, hasAttachments: $hasAttachments, attachments: $attachments, downloadUrl: $downloadUrl, createdAt: $createdAt)';
}
@override
bool operator ==(dynamic other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$TMMessageDetailImpl &&
(identical(other.id, id) || other.id == id) &&
const DeepCollectionEquality().equals(other._from, _from) &&
const DeepCollectionEquality().equals(other._to, _to) &&
const DeepCollectionEquality().equals(other._cc, _cc) &&
const DeepCollectionEquality().equals(other._bcc, _bcc) &&
(identical(other.subject, subject) || other.subject == subject) &&
(identical(other.text, text) || other.text == text) &&
const DeepCollectionEquality().equals(other._html, _html) &&
(identical(other.hasAttachments, hasAttachments) ||
other.hasAttachments == hasAttachments) &&
const DeepCollectionEquality()
.equals(other._attachments, _attachments) &&
(identical(other.downloadUrl, downloadUrl) ||
other.downloadUrl == downloadUrl) &&
(identical(other.createdAt, createdAt) ||
other.createdAt == createdAt));
}
@JsonKey(ignore: true)
@override
int get hashCode => Object.hash(
runtimeType,
id,
const DeepCollectionEquality().hash(_from),
const DeepCollectionEquality().hash(_to),
const DeepCollectionEquality().hash(_cc),
const DeepCollectionEquality().hash(_bcc),
subject,
text,
const DeepCollectionEquality().hash(_html),
hasAttachments,
const DeepCollectionEquality().hash(_attachments),
downloadUrl,
createdAt);
@JsonKey(ignore: true)
@override
@pragma('vm:prefer-inline')
_$$TMMessageDetailImplCopyWith<_$TMMessageDetailImpl> get copyWith =>
__$$TMMessageDetailImplCopyWithImpl<_$TMMessageDetailImpl>(
this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$$TMMessageDetailImplToJson(
this,
);
}
}
abstract class _TMMessageDetail implements TMMessageDetail {
factory _TMMessageDetail(
{required final String id,
required final Map<String, String> from,
required final List<Map<String, String>> to,
required final List<String> cc,
required final List<String> bcc,
required final String subject,
required final String? text,
required final List<String> html,
required final bool hasAttachments,
required final List<Map<String, dynamic>>? attachments,
required final String downloadUrl,
required final DateTime createdAt}) = _$TMMessageDetailImpl;
factory _TMMessageDetail.fromJson(Map<String, dynamic> json) =
_$TMMessageDetailImpl.fromJson;
@override
String get id;
@override
Map<String, String> get from;
@override
List<Map<String, String>> get to;
@override
List<String> get cc;
@override
List<String> get bcc;
@override
String get subject;
@override
String? get text;
@override
List<String> get html;
@override
bool get hasAttachments;
@override
List<Map<String, dynamic>>? get attachments;
@override
String get downloadUrl;
@override
DateTime get createdAt;
@override
@JsonKey(ignore: true)
_$$TMMessageDetailImplCopyWith<_$TMMessageDetailImpl> get copyWith =>
throw _privateConstructorUsedError;
}

View file

@ -1,45 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'message_detail.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_$TMMessageDetailImpl _$$TMMessageDetailImplFromJson(
Map<String, dynamic> json) =>
_$TMMessageDetailImpl(
id: json['id'] as String,
from: Map<String, String>.from(json['from'] as Map),
to: (json['to'] as List<dynamic>)
.map((e) => Map<String, String>.from(e as Map))
.toList(),
cc: (json['cc'] as List<dynamic>).map((e) => e as String).toList(),
bcc: (json['bcc'] as List<dynamic>).map((e) => e as String).toList(),
subject: json['subject'] as String,
text: json['text'] as String?,
html: (json['html'] as List<dynamic>).map((e) => e as String).toList(),
hasAttachments: json['hasAttachments'] as bool,
attachments: (json['attachments'] as List<dynamic>?)
?.map((e) => e as Map<String, dynamic>)
.toList(),
downloadUrl: json['downloadUrl'] as String,
createdAt: DateTime.parse(json['createdAt'] as String),
);
Map<String, dynamic> _$$TMMessageDetailImplToJson(
_$TMMessageDetailImpl instance) =>
<String, dynamic>{
'id': instance.id,
'from': instance.from,
'to': instance.to,
'cc': instance.cc,
'bcc': instance.bcc,
'subject': instance.subject,
'text': instance.text,
'html': instance.html,
'hasAttachments': instance.hasAttachments,
'attachments': instance.attachments,
'downloadUrl': instance.downloadUrl,
'createdAt': instance.createdAt.toIso8601String(),
};

View file

@ -1,86 +0,0 @@
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:hive_flutter/adapters.dart";
import "package:flash_mail/data/models/account/account.dart";
import "package:flash_mail/data/providers/providers.dart";
class AccountState {
final bool isLoading;
final TMAccount? user;
const AccountState({
this.isLoading = false,
this.user,
});
bool isAuthenticated() {
return user != null;
}
}
class AccountNotifier extends StateNotifier<AccountState> {
static const _boxName = "accounts";
static const _key = "user";
final Ref ref;
AccountNotifier(this.ref) : super(const AccountState());
Future<void> fetchAccount() async {
var box = await Hive.openBox<TMAccount>(_boxName);
final user = box.get(_key, defaultValue: null);
if (user != null) {
ref.read(emailProvider.notifier).state = user.email;
ref.read(passwordProvider.notifier).state = user.password;
state = AccountState(isLoading: false, user: user);
}
}
Future<void> createAccount() async {
state = const AccountState(isLoading: true);
try {
TMAccount account = await ref.read(clientProvider).createAccount();
var box = Hive.box<TMAccount>(_boxName);
await box.put(_key, account);
ref.read(emailProvider.notifier).state = account.email;
ref.read(passwordProvider.notifier).state = account.password;
state = AccountState(isLoading: false, user: account);
} catch (err) {
state = const AccountState(isLoading: false);
rethrow;
}
}
Future<void> login(String? address, String? password) async {
state = const AccountState(isLoading: true);
try {
if (address != null && password != null) {
TMAccount account =
await ref.read(clientProvider).login(address, password);
var box = Hive.box<TMAccount>(_boxName);
await box.put(_key, account);
state = AccountState(isLoading: false, user: account);
}
} catch (err) {
state = const AccountState(isLoading: false);
rethrow;
}
}
Future<void> logout() async {
AccountState previous = state;
try {
var box = Hive.box<TMAccount>(_boxName);
await box.delete(_key);
state = const AccountState();
} catch (err) {
state = previous;
rethrow;
}
}
}

View file

@ -1,40 +1,57 @@
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:dio/dio.dart";
import "package:flash_mail/data/api/api_client.dart";
import "package:flash_mail/data/models/message/message.dart";
import "package:flash_mail/data/models/message_detail/message_detail.dart";
import "package:flash_mail/data/notifiers/account_notifier.dart";
import "package:smol_mail/smol/client.dart";
import "package:smol_mail/smol/proto.dart";
import "package:smol_mail/smol/store.dart";
final emailProvider = StateProvider<String?>((ref) => null);
final passwordProvider = StateProvider<String?>((ref) => null);
/// Overridden in main() with the Hive-backed store opened at boot.
final storeProvider = Provider<SmolStore>((ref) => throw UnimplementedError());
final dioProvider = Provider<Dio>(
(ref) => Dio(
BaseOptions(
baseUrl: "https://api.mail.tm",
connectTimeout: 2000,
),
),
final clientProvider = Provider<SmolClient>(
(ref) => SmolClient(ref.watch(storeProvider)),
);
final clientProvider = Provider<ApiClient>(
(ref) => ApiClient(ref.read(dioProvider), ref),
);
/// Hive gives no change notifications; every mail or state mutation bumps this,
/// which is what the list, badge, identity and account providers watch.
class Revision extends Notifier<int> {
@override
int build() => 0;
final accountProvider = StateNotifierProvider<AccountNotifier, AccountState>(
(ref) => AccountNotifier(ref),
);
void bump() => state++;
}
final tokenProvider = StateProvider<String?>((ref) => null);
final revisionProvider = NotifierProvider<Revision, int>(Revision.new);
final messagesProvider = FutureProvider.autoDispose<List<TMMessage>>((ref) {
final email = ref.read(emailProvider);
final password = ref.read(passwordProvider);
return ref.read(clientProvider).fetchMessages(email!, password!);
final identityProvider = Provider<SmolIdentity?>((ref) {
ref.watch(revisionProvider);
return ref.watch(storeProvider).identity();
});
final messageDetailProvider = FutureProvider.family<TMMessageDetail, String>(
(ref, id) => ref.read(clientProvider).fetchMessageDetail(id),
);
final accountProvider = Provider<SmolAddress?>((ref) {
ref.watch(revisionProvider);
return ref.watch(clientProvider).accountAddress();
});
class Folder extends Notifier<String> {
@override
String build() => "inbox";
void select(String folder) => state = folder;
}
final folderProvider = NotifierProvider<Folder, String>(Folder.new);
final messagesProvider = Provider<List<MailRecord>>((ref) {
ref.watch(revisionProvider);
return ref.watch(storeProvider).listMessages(ref.watch(folderProvider));
});
final unreadProvider = Provider<int>((ref) {
ref.watch(revisionProvider);
return ref.watch(storeProvider).unreadCount();
});
final contactsProvider = Provider<List<(String, StoredContact)>>((ref) {
ref.watch(revisionProvider);
return ref.watch(storeProvider).allContacts();
});

View file

@ -1,23 +1,21 @@
import "package:flutter/material.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:flutter_downloader/flutter_downloader.dart";
import "package:hive_flutter/adapters.dart";
import "package:hive_flutter/hive_flutter.dart";
import "package:flash_mail/data/models/account/account.dart";
import "package:flash_mail/data/providers/providers.dart";
import "package:flash_mail/presentation/app_widget.dart";
import "package:smol_mail/data/providers/providers.dart";
import "package:smol_mail/smol/config.dart";
import "package:smol_mail/smol/store.dart";
import "package:smol_mail/presentation/app_widget.dart";
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Hive.initFlutter();
Hive.registerAdapter(AccountAdapter());
ProviderContainer container = ProviderContainer();
await container.read(accountProvider.notifier).fetchAccount();
await FlutterDownloader.initialize();
final store = await SmolStore.open();
applyPresetServer(store);
runApp(
UncontrolledProviderScope(
container: container,
ProviderScope(
overrides: [storeProvider.overrideWithValue(store)],
child: const AppWidget(),
),
);

View file

@ -1,18 +1,53 @@
import "package:flutter/material.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:flash_mail/presentation/theme/theme.dart";
import "package:flash_mail/presentation/routes/app_router.dart";
import "package:smol_mail/data/providers/providers.dart";
import "package:smol_mail/presentation/theme/theme.dart";
import "package:smol_mail/presentation/routes/app_router.dart";
class AppWidget extends ConsumerWidget {
/// SnackBars need no context through this key, so the client's trust
/// warnings can be shown from anywhere in the protocol stack.
final rootScaffoldMessengerKey = GlobalKey<ScaffoldMessengerState>();
class AppWidget extends ConsumerStatefulWidget {
const AppWidget({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
ConsumerState<AppWidget> createState() => _AppWidgetState();
}
class _AppWidgetState extends ConsumerState<AppWidget> {
@override
void initState() {
super.initState();
// §4/§8 trust warnings stay on screen until dismissed; ordinary notices
// do not use this channel.
ref.read(clientProvider).onWarning = (message) {
rootScaffoldMessengerKey.currentState?.showSnackBar(
SnackBar(
backgroundColor: const Color.fromRGBO(198, 40, 40, 1),
content: Text(message, style: const TextStyle(color: Colors.white)),
duration: const Duration(days: 1),
action: SnackBarAction(
label: "Dismiss",
textColor: Colors.white,
onPressed: () =>
rootScaffoldMessengerKey.currentState?.hideCurrentSnackBar(),
),
),
);
};
}
@override
Widget build(BuildContext context) {
return MaterialApp.router(
title: "FlashMail",
theme: appTheme,
title: "kirakira",
theme: appThemeLight,
darkTheme: appThemeDark,
themeMode: ThemeMode.system,
debugShowCheckedModeBanner: false,
scaffoldMessengerKey: rootScaffoldMessengerKey,
routerConfig: AppRouter(ref).config(),
);
}

View file

@ -1,18 +0,0 @@
import "package:auto_route/auto_route.dart";
import "package:flash_mail/data/providers/providers.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
class AccountGuard extends AutoRouteGuard {
final WidgetRef ref;
AccountGuard(this.ref);
@override
void onNavigation(NavigationResolver resolver, StackRouter router) {
if (!ref.read(accountProvider).isAuthenticated()) {
router.replaceNamed("/");
} else {
resolver.next(true);
}
}
}

View file

@ -1,13 +1,12 @@
import "package:auto_route/auto_route.dart";
import "package:flash_mail/presentation/routes/account_guard.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:flash_mail/presentation/routes/auth_guard.dart";
import "package:smol_mail/presentation/routes/home_guard.dart";
import "package:smol_mail/presentation/routes/identity_guard.dart";
import "app_router.gr.dart";
@AutoRouterConfig(replaceInRouteName: "Screen,Route")
class AppRouter extends $AppRouter {
class AppRouter extends RootStackRouter {
final WidgetRef ref;
AppRouter(this.ref);
@ -15,23 +14,40 @@ class AppRouter extends $AppRouter {
@override
List<AutoRoute> get routes => [
AutoRoute(
page: AccountRoute.page,
page: OnboardingRoute.page,
path: "/",
initial: true,
guards: [AuthGuard(ref)],
),
AutoRoute(
page: LoginRoute.page,
path: "/login",
guards: [HomeGuard(ref)],
),
AutoRoute(
page: InboxRoute.page,
path: "/inbox",
guards: [AccountGuard(ref)],
guards: [IdentityGuard(ref)],
),
AutoRoute(
page: MessageDetailRoute.page,
path: "/message",
)
guards: [IdentityGuard(ref)],
),
AutoRoute(
page: ComposeRoute.page,
path: "/compose",
guards: [IdentityGuard(ref)],
),
AutoRoute(
page: ContactsRoute.page,
path: "/contacts",
guards: [IdentityGuard(ref)],
),
AutoRoute(
page: ContactDetailRoute.page,
path: "/contact",
guards: [IdentityGuard(ref)],
),
AutoRoute(
page: SettingsRoute.page,
path: "/settings",
guards: [IdentityGuard(ref)],
),
];
}

View file

@ -1,3 +1,4 @@
// dart format width=80
// GENERATED CODE - DO NOT MODIFY BY HAND
// **************************************************************************
@ -8,132 +9,240 @@
// coverage:ignore-file
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:auto_route/auto_route.dart' as _i5;
import 'package:flash_mail/presentation/screens/account_screen.dart' as _i1;
import 'package:flash_mail/presentation/screens/inbox_screen.dart' as _i2;
import 'package:flash_mail/presentation/screens/login_screen.dart' as _i3;
import 'package:flash_mail/presentation/screens/message_detail_screen.dart'
as _i4;
import 'package:flutter/material.dart' as _i6;
abstract class $AppRouter extends _i5.RootStackRouter {
$AppRouter({super.navigatorKey});
import 'package:auto_route/auto_route.dart' as _i8;
import 'package:flutter/material.dart' as _i9;
import 'package:smol_mail/presentation/screens/compose_screen.dart' as _i1;
import 'package:smol_mail/presentation/screens/contact_detail_screen.dart'
as _i2;
import 'package:smol_mail/presentation/screens/contacts_screen.dart' as _i3;
import 'package:smol_mail/presentation/screens/inbox_screen.dart' as _i4;
import 'package:smol_mail/presentation/screens/message_detail_screen.dart'
as _i5;
import 'package:smol_mail/presentation/screens/onboarding_screen.dart' as _i6;
import 'package:smol_mail/presentation/screens/settings_screen.dart' as _i7;
/// generated route for
/// [_i1.ComposeScreen]
class ComposeRoute extends _i8.PageRouteInfo<ComposeRouteArgs> {
ComposeRoute({
_i9.Key? key,
String? to,
String? subject,
List<_i8.PageRouteInfo>? children,
}) : super(
ComposeRoute.name,
args: ComposeRouteArgs(key: key, to: to, subject: subject),
initialChildren: children,
);
static const String name = 'ComposeRoute';
static _i8.PageInfo page = _i8.PageInfo(
name,
builder: (data) {
final args = data.argsAs<ComposeRouteArgs>(
orElse: () => const ComposeRouteArgs(),
);
return _i1.ComposeScreen(
key: args.key,
to: args.to,
subject: args.subject,
);
},
);
}
class ComposeRouteArgs {
const ComposeRouteArgs({this.key, this.to, this.subject});
final _i9.Key? key;
final String? to;
final String? subject;
@override
final Map<String, _i5.PageFactory> pagesMap = {
AccountRoute.name: (routeData) {
return _i5.AutoRoutePage<dynamic>(
routeData: routeData,
child: const _i1.AccountScreen(),
);
},
InboxRoute.name: (routeData) {
return _i5.AutoRoutePage<dynamic>(
routeData: routeData,
child: const _i2.InboxScreen(),
);
},
LoginRoute.name: (routeData) {
return _i5.AutoRoutePage<dynamic>(
routeData: routeData,
child: const _i3.LoginScreen(),
);
},
MessageDetailRoute.name: (routeData) {
final args = routeData.argsAs<MessageDetailRouteArgs>();
return _i5.AutoRoutePage<dynamic>(
routeData: routeData,
child: _i4.MessageDetailScreen(
key: args.key,
id: args.id,
subject: args.subject,
),
);
},
};
String toString() {
return 'ComposeRouteArgs{key: $key, to: $to, subject: $subject}';
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
if (other is! ComposeRouteArgs) return false;
return key == other.key && to == other.to && subject == other.subject;
}
@override
int get hashCode => key.hashCode ^ to.hashCode ^ subject.hashCode;
}
/// generated route for
/// [_i1.AccountScreen]
class AccountRoute extends _i5.PageRouteInfo<void> {
const AccountRoute({List<_i5.PageRouteInfo>? children})
: super(
AccountRoute.name,
initialChildren: children,
);
/// [_i2.ContactDetailScreen]
class ContactDetailRoute extends _i8.PageRouteInfo<ContactDetailRouteArgs> {
ContactDetailRoute({
_i9.Key? key,
required String address,
List<_i8.PageRouteInfo>? children,
}) : super(
ContactDetailRoute.name,
args: ContactDetailRouteArgs(key: key, address: address),
initialChildren: children,
);
static const String name = 'AccountRoute';
static const String name = 'ContactDetailRoute';
static const _i5.PageInfo<void> page = _i5.PageInfo<void>(name);
static _i8.PageInfo page = _i8.PageInfo(
name,
builder: (data) {
final args = data.argsAs<ContactDetailRouteArgs>();
return _i2.ContactDetailScreen(key: args.key, address: args.address);
},
);
}
class ContactDetailRouteArgs {
const ContactDetailRouteArgs({this.key, required this.address});
final _i9.Key? key;
final String address;
@override
String toString() {
return 'ContactDetailRouteArgs{key: $key, address: $address}';
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
if (other is! ContactDetailRouteArgs) return false;
return key == other.key && address == other.address;
}
@override
int get hashCode => key.hashCode ^ address.hashCode;
}
/// generated route for
/// [_i2.InboxScreen]
class InboxRoute extends _i5.PageRouteInfo<void> {
const InboxRoute({List<_i5.PageRouteInfo>? children})
: super(
InboxRoute.name,
initialChildren: children,
);
/// [_i3.ContactsScreen]
class ContactsRoute extends _i8.PageRouteInfo<void> {
const ContactsRoute({List<_i8.PageRouteInfo>? children})
: super(ContactsRoute.name, initialChildren: children);
static const String name = 'ContactsRoute';
static _i8.PageInfo page = _i8.PageInfo(
name,
builder: (data) {
return const _i3.ContactsScreen();
},
);
}
/// generated route for
/// [_i4.InboxScreen]
class InboxRoute extends _i8.PageRouteInfo<void> {
const InboxRoute({List<_i8.PageRouteInfo>? children})
: super(InboxRoute.name, initialChildren: children);
static const String name = 'InboxRoute';
static const _i5.PageInfo<void> page = _i5.PageInfo<void>(name);
static _i8.PageInfo page = _i8.PageInfo(
name,
builder: (data) {
return const _i4.InboxScreen();
},
);
}
/// generated route for
/// [_i3.LoginScreen]
class LoginRoute extends _i5.PageRouteInfo<void> {
const LoginRoute({List<_i5.PageRouteInfo>? children})
: super(
LoginRoute.name,
initialChildren: children,
);
static const String name = 'LoginRoute';
static const _i5.PageInfo<void> page = _i5.PageInfo<void>(name);
}
/// generated route for
/// [_i4.MessageDetailScreen]
class MessageDetailRoute extends _i5.PageRouteInfo<MessageDetailRouteArgs> {
/// [_i5.MessageDetailScreen]
class MessageDetailRoute extends _i8.PageRouteInfo<MessageDetailRouteArgs> {
MessageDetailRoute({
_i6.Key? key,
_i9.Key? key,
required String folder,
required String id,
required String subject,
List<_i5.PageRouteInfo>? children,
List<_i8.PageRouteInfo>? children,
}) : super(
MessageDetailRoute.name,
args: MessageDetailRouteArgs(
key: key,
id: id,
subject: subject,
),
initialChildren: children,
);
MessageDetailRoute.name,
args: MessageDetailRouteArgs(key: key, folder: folder, id: id),
initialChildren: children,
);
static const String name = 'MessageDetailRoute';
static const _i5.PageInfo<MessageDetailRouteArgs> page =
_i5.PageInfo<MessageDetailRouteArgs>(name);
static _i8.PageInfo page = _i8.PageInfo(
name,
builder: (data) {
final args = data.argsAs<MessageDetailRouteArgs>();
return _i5.MessageDetailScreen(
key: args.key,
folder: args.folder,
id: args.id,
);
},
);
}
class MessageDetailRouteArgs {
const MessageDetailRouteArgs({
this.key,
required this.folder,
required this.id,
required this.subject,
});
final _i6.Key? key;
final _i9.Key? key;
final String folder;
final String id;
final String subject;
@override
String toString() {
return 'MessageDetailRouteArgs{key: $key, id: $id, subject: $subject}';
return 'MessageDetailRouteArgs{key: $key, folder: $folder, id: $id}';
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
if (other is! MessageDetailRouteArgs) return false;
return key == other.key && folder == other.folder && id == other.id;
}
@override
int get hashCode => key.hashCode ^ folder.hashCode ^ id.hashCode;
}
/// generated route for
/// [_i6.OnboardingScreen]
class OnboardingRoute extends _i8.PageRouteInfo<void> {
const OnboardingRoute({List<_i8.PageRouteInfo>? children})
: super(OnboardingRoute.name, initialChildren: children);
static const String name = 'OnboardingRoute';
static _i8.PageInfo page = _i8.PageInfo(
name,
builder: (data) {
return const _i6.OnboardingScreen();
},
);
}
/// generated route for
/// [_i7.SettingsScreen]
class SettingsRoute extends _i8.PageRouteInfo<void> {
const SettingsRoute({List<_i8.PageRouteInfo>? children})
: super(SettingsRoute.name, initialChildren: children);
static const String name = 'SettingsRoute';
static _i8.PageInfo page = _i8.PageInfo(
name,
builder: (data) {
return const _i7.SettingsScreen();
},
);
}

View file

@ -1,18 +0,0 @@
import "package:auto_route/auto_route.dart";
import "package:flash_mail/data/providers/providers.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
class AuthGuard extends AutoRouteGuard {
final WidgetRef ref;
AuthGuard(this.ref);
@override
void onNavigation(NavigationResolver resolver, StackRouter router) {
if (ref.read(accountProvider).isAuthenticated()) {
router.replaceNamed("/inbox");
} else {
resolver.next(true);
}
}
}

View file

@ -0,0 +1,23 @@
import "package:auto_route/auto_route.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:smol_mail/data/providers/providers.dart";
import "package:smol_mail/presentation/routes/app_router.gr.dart";
/// The landing screen is only for users who cannot use the app yet: no
/// identity, or no registered account.
class HomeGuard extends AutoRouteGuard {
final WidgetRef ref;
HomeGuard(this.ref);
@override
void onNavigation(NavigationResolver resolver, StackRouter router) {
if (ref.read(identityProvider) != null &&
ref.read(accountProvider) != null) {
router.replace(InboxRoute());
} else {
resolver.next(true);
}
}
}

View file

@ -0,0 +1,22 @@
import "package:auto_route/auto_route.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:smol_mail/data/providers/providers.dart";
import "package:smol_mail/presentation/routes/app_router.gr.dart";
/// Everything past onboarding needs an identity; the account can still be
/// registered from settings.
class IdentityGuard extends AutoRouteGuard {
final WidgetRef ref;
IdentityGuard(this.ref);
@override
void onNavigation(NavigationResolver resolver, StackRouter router) {
if (ref.read(identityProvider) == null) {
router.replace(OnboardingRoute());
} else {
resolver.next(true);
}
}
}

View file

@ -1,34 +0,0 @@
import "package:flutter/material.dart";
import "package:auto_route/auto_route.dart";
import "package:flash_mail/presentation/widgets/auth/account_buttons.dart";
@RoutePage()
class AccountScreen extends StatelessWidget {
const AccountScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: Column(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: <Widget>[
const SizedBox(height: 20),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Image(
image: AssetImage("assets/images/logo-small.png"),
),
Text(
"FlashMail",
style: Theme.of(context).textTheme.titleLarge,
)
],
),
const AccountButtons()
],
),
);
}
}

View file

@ -0,0 +1,127 @@
import "package:auto_route/auto_route.dart";
import "package:flutter/material.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:smol_mail/data/providers/providers.dart";
import "package:smol_mail/presentation/widgets/primary_button.dart";
import "package:smol_mail/presentation/widgets/small_loading_spinner.dart";
import "package:smol_mail/presentation/theme/app_colors.dart";
import "package:smol_mail/shared/utils/snackbar.dart";
import "package:smol_mail/smol/errors.dart";
/// One tap seals one envelope; the button disables while the send is in
/// flight so a second tap cannot seal a second message.
@RoutePage()
class ComposeScreen extends ConsumerStatefulWidget {
final String? to;
final String? subject;
const ComposeScreen({
super.key,
this.to,
this.subject,
});
@override
ConsumerState<ComposeScreen> createState() => _ComposeScreenState();
}
class _ComposeScreenState extends ConsumerState<ComposeScreen> {
late final toController = TextEditingController(text: widget.to ?? "");
late final subjectController =
TextEditingController(text: widget.subject ?? "");
final bodyController = TextEditingController();
bool anonymous = false;
bool busy = false;
@override
void dispose() {
toController.dispose();
subjectController.dispose();
bodyController.dispose();
super.dispose();
}
Future<void> _send() async {
final client = ref.read(clientProvider);
setState(() => busy = true);
try {
final to = await client.send(
toController.text,
subjectController.text,
bodyController.text,
anonymous: anonymous,
);
ref.read(revisionProvider.notifier).bump();
if (mounted) {
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text("sent to $to")));
AutoRouter.of(context).pop();
}
} on SmolError catch (err) {
if (mounted) {
showErrorSnackBar(context, err.message);
}
} finally {
if (mounted) setState(() => busy = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text("Write")),
body: SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: toController,
decoration: InputDecoration(
labelText: "To",
hintText: "user@host[:1961] or smol://user@host/key",
filled: true,
fillColor: Theme.of(context).extension<AppColors>()!.cardFill,
),
),
const SizedBox(height: 15),
TextField(
controller: subjectController,
decoration: InputDecoration(
labelText: "Subject",
filled: true,
fillColor: Theme.of(context).extension<AppColors>()!.cardFill,
),
),
const SizedBox(height: 15),
TextField(
controller: bodyController,
maxLines: 12,
decoration: InputDecoration(
labelText: "Body",
filled: true,
fillColor: Theme.of(context).extension<AppColors>()!.cardFill,
),
),
SwitchListTile(
value: anonymous,
onChanged: (value) => setState(() => anonymous = value),
title: Text(
"Anonymous (omit the signed Reply-To)",
style: Theme.of(context).textTheme.bodySmall,
),
),
const SizedBox(height: 10),
Center(
child: PrimaryButton(
onPressed: busy ? () {} : _send,
child: busy ? const SmallLoadingSpinner() : const Text("Send"),
),
),
],
),
),
);
}
}

View file

@ -0,0 +1,158 @@
import "package:auto_route/auto_route.dart";
import "package:flutter/material.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:smol_mail/data/providers/providers.dart";
import "package:smol_mail/shared/utils/format.dart";
import "package:smol_mail/shared/utils/snackbar.dart";
import "package:smol_mail/smol/errors.dart";
import "package:smol_mail/smol/proto.dart";
/// The full key, the address to hand out, and any key this one replaced —
/// the row can only afford a fingerprint, and §8 turns on the user being able
/// to inspect exactly what changed.
@RoutePage()
class ContactDetailScreen extends ConsumerStatefulWidget {
final String address;
const ContactDetailScreen({super.key, required this.address});
@override
ConsumerState<ContactDetailScreen> createState() =>
_ContactDetailScreenState();
}
class _ContactDetailScreenState extends ConsumerState<ContactDetailScreen> {
bool resolving = false;
Future<void> _reResolve() async {
final client = ref.read(clientProvider);
setState(() => resolving = true);
try {
final outcome = await client.refreshContact(widget.address);
ref.read(revisionProvider.notifier).bump();
if (mounted) {
// A rotation or an unexplained key change is §8 material; it stays
// until dismissed and stands out like any other warning.
if (outcome.warn) {
showErrorSnackBar(context, outcome.message,
duration: const Duration(days: 1));
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(outcome.message)),
);
}
}
} on SmolError catch (err) {
if (mounted) {
showErrorSnackBar(context, err.message);
}
} finally {
if (mounted) setState(() => resolving = false);
}
}
@override
Widget build(BuildContext context) {
final contact = ref.watch(storeProvider).contact(widget.address);
return Scaffold(
appBar: AppBar(title: Text(widget.address)),
body: contact == null
? Center(
child: Text("no such contact",
style: Theme.of(context).textTheme.labelSmall))
: ListView(
padding: const EdgeInsets.all(20),
children: [
_chip(context, contact.verified
? "verified key"
: "key pinned on first use"),
const SizedBox(height: 20),
_field(context, "fingerprint", fingerprint(contact.key)),
_field(context, "public key", b32encode(contact.key)),
Row(
children: [
Expanded(
child: _field(
context,
"address",
parseAddress(widget.address)
.uri(contact.key)),
),
IconButton(
icon: const Icon(Icons.copy, size: 18),
onPressed: () => copyText(
context,
parseAddress(widget.address).uri(contact.key)),
),
],
),
if (contact.history.isNotEmpty) ...[
const SizedBox(height: 30),
Text("previous keys (${contact.history.length})",
style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 5),
Text(
"Replaced by a signed rotation chain (SPEC.md §7). Mail signed with these "
"was sent before the change; rotation is not revocation, so a stolen key "
"can still rotate onward.",
style: Theme.of(context).textTheme.labelSmall,
),
const SizedBox(height: 10),
for (final entry in contact.history)
Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(fingerprint(entry.key)),
Text(b32encode(entry.key),
style: Theme.of(context).textTheme.labelSmall),
Text(
"replaced ${formatTime(entry.until ~/ 1000)}",
style: Theme.of(context).textTheme.labelSmall,
),
],
),
),
],
const SizedBox(height: 30),
resolving
? const Center(child: CircularProgressIndicator())
: TextButton.icon(
onPressed: _reResolve,
icon: const Icon(Icons.refresh, size: 18),
label: const Text("Re-resolve"),
),
],
),
);
}
Widget _chip(BuildContext context, String text) => Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
border: Border.all(color: Theme.of(context).primaryColor),
borderRadius: BorderRadius.circular(10),
),
child: Text(
text,
style: Theme.of(context)
.textTheme
.labelSmall!
.copyWith(color: Theme.of(context).primaryColor),
),
);
Widget _field(BuildContext context, String label, String value) => Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: Theme.of(context).textTheme.labelSmall),
SelectableText(value, style: Theme.of(context).textTheme.bodySmall),
],
),
);
}

View file

@ -0,0 +1,130 @@
import "package:auto_route/auto_route.dart";
import "package:flutter/material.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:smol_mail/data/providers/providers.dart";
import "package:smol_mail/presentation/routes/app_router.gr.dart";
import "package:smol_mail/presentation/theme/app_colors.dart";
import "package:smol_mail/shared/utils/snackbar.dart";
import "package:smol_mail/smol/errors.dart";
import "package:smol_mail/smol/proto.dart";
/// Contacts: the addresses bound to keys, with the trust badge and the number
/// of keys each has replaced — the §8 surface for noticing rotations.
@RoutePage()
class ContactsScreen extends ConsumerWidget {
const ContactsScreen({super.key});
Future<void> _import(BuildContext context, WidgetRef ref) async {
final client = ref.read(clientProvider);
final controller = TextEditingController();
final text = await showDialog<String>(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: Theme.of(context).extension<AppColors>()!.cardFill,
title: const Text("Import contact"),
content: TextField(
controller: controller,
decoration: InputDecoration(
hintText: "smol://user@host/key",
filled: true,
fillColor: Theme.of(context).colorScheme.surface,
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(controller.text),
child: const Text("Import"),
),
],
),
);
if (text == null || text.trim().isEmpty) return;
try {
client.importContact(text);
ref.read(revisionProvider.notifier).bump();
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text("contact imported (verified key)")),
);
}
} on SmolError catch (err) {
if (context.mounted) {
showErrorSnackBar(context, err.message);
}
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final contacts = ref.watch(contactsProvider);
return Scaffold(
appBar: AppBar(
title: const Text("Contacts"),
actions: [
IconButton(
tooltip: "Import smol:// address",
onPressed: () => _import(context, ref),
icon: const Icon(Icons.person_add),
),
],
),
body: contacts.isEmpty
? Center(
child: Text(
"no contacts yet — import a smol:// address, or write to someone",
style: Theme.of(context).textTheme.labelSmall,
textAlign: TextAlign.center,
),
)
: ListView.builder(
itemCount: contacts.length,
itemBuilder: (ctx, i) {
final (address, contact) = contacts[i];
return ListTile(
splashColor: Theme.of(context).extension<AppColors>()!.highlight,
onTap: () => AutoRouter.of(context)
.push(ContactDetailRoute(address: address)),
title: Text(address),
subtitle: Text(
fingerprint(contact.key),
style: Theme.of(context).textTheme.labelSmall,
),
trailing: Wrap(
spacing: 6,
children: [
_chip(context, contact.verified ? "verified" : "tofu",
ok: contact.verified),
if (contact.history.isNotEmpty)
_chip(context, "${contact.history.length} previous",
ok: false),
],
),
);
},
),
);
}
Widget _chip(BuildContext context, String text, {required bool ok}) =>
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
border: Border.all(
color: ok
? Theme.of(context).primaryColor
: Theme.of(context).textTheme.labelSmall!.color!,
),
borderRadius: BorderRadius.circular(8),
),
child: Text(
text,
style: Theme.of(context).textTheme.labelSmall!.copyWith(
color: ok
? Theme.of(context).primaryColor
: Theme.of(context).textTheme.labelSmall!.color,
),
),
);
}

View file

@ -1,62 +1,82 @@
import "dart:async";
import "package:auto_route/auto_route.dart";
import "package:flutter/material.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:flash_mail/data/providers/providers.dart";
import "package:flash_mail/presentation/widgets/drawer/app_drawer.dart";
import "package:flash_mail/presentation/widgets/image_banner.dart";
import "package:flash_mail/presentation/widgets/message/message_list.dart";
import "package:flash_mail/presentation/widgets/shimmer/message_shimmer.dart";
import "package:smol_mail/data/providers/providers.dart";
import "package:smol_mail/presentation/routes/app_router.gr.dart";
import "package:smol_mail/presentation/widgets/drawer/app_drawer.dart";
import "package:smol_mail/presentation/widgets/image_banner.dart";
import "package:smol_mail/presentation/widgets/message/message_list.dart";
import "package:smol_mail/shared/utils/snackbar.dart";
import "package:smol_mail/smol/errors.dart";
/// No server push in v1 (SPEC.md §13): new mail arrives when the user taps
/// fetch, which pulls everything and acknowledges what verifies.
@RoutePage()
class InboxScreen extends ConsumerStatefulWidget {
class InboxScreen extends ConsumerWidget {
const InboxScreen({super.key});
@override
ConsumerState<InboxScreen> createState() => _InboxScreenState();
}
class _InboxScreenState extends ConsumerState<InboxScreen> {
Timer? _timer;
@override
void initState() {
super.initState();
_timer = Timer.periodic(
const Duration(seconds: 30),
(timer) => ref.refresh(messagesProvider),
);
Future<void> _fetch(BuildContext context, WidgetRef ref) async {
final client = ref.read(clientProvider);
try {
final summary = await client.fetch();
ref.read(revisionProvider.notifier).bump();
if (context.mounted) {
final tail = summary.rejected.isEmpty
? ""
: "\n${summary.rejected.length} left on the server";
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("${summary.stored} new$tail")),
);
}
} on SmolError catch (err) {
if (context.mounted) {
showErrorSnackBar(context, err.message);
}
}
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final messages = ref.watch(messagesProvider);
final folder = ref.watch(folderProvider);
return Scaffold(
appBar: AppBar(
title: const Text("Inbox"),
title: Text(folder == "inbox" ? "Inbox" : "Sent"),
actions: [
if (folder == "inbox")
IconButton(
tooltip: "Fetch",
onPressed: () => _fetch(context, ref),
icon: const Icon(Icons.cloud_download),
),
],
),
drawer: const AppDrawer(),
body: messages.when(
data: (data) => MessageList(
messages: data,
onRefresh: () => ref.refresh(messagesProvider.future),
),
error: (err, _) => const ImageBanner(
imgSrc: "assets/images/error.png",
text: "Something went wrong!",
),
loading: () => const MessageShimmer(),
skipLoadingOnRefresh: true,
floatingActionButton: FloatingActionButton(
onPressed: () => AutoRouter.of(context).push(ComposeRoute()),
child: const Icon(Icons.edit),
),
body: messages.isEmpty
? RefreshIndicator(
onRefresh: () => folder == "inbox"
? _fetch(context, ref)
: Future.value(),
child: ListView(
children: const [
ImageBanner(
imgSrc: "assets/images/empty.png",
text: "Nothing here yet!",
),
],
),
)
: MessageList(
onRefresh: () => folder == "inbox"
? _fetch(context, ref)
: Future.value(),
),
);
}
}

View file

@ -1,31 +0,0 @@
import "package:flutter/material.dart";
import "package:auto_route/auto_route.dart";
import "package:flash_mail/presentation/widgets/auth/login_form.dart";
@RoutePage()
class LoginScreen extends StatelessWidget {
const LoginScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(),
body: Padding(
padding: const EdgeInsets.all(25),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
"Login",
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 25),
LoginForm(),
],
),
),
);
}
}

View file

@ -1,69 +1,128 @@
import "dart:typed_data";
import "package:auto_route/auto_route.dart";
import "package:flutter/material.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:flash_mail/data/providers/providers.dart";
import "package:flash_mail/presentation/widgets/image_banner.dart";
import "package:flash_mail/presentation/widgets/message/message_view.dart";
import "package:smol_mail/data/providers/providers.dart";
import "package:smol_mail/presentation/widgets/image_banner.dart";
import "package:smol_mail/presentation/widgets/message/message_view.dart";
import "package:smol_mail/presentation/theme/app_colors.dart";
import "package:smol_mail/shared/utils/snackbar.dart";
import "package:smol_mail/smol/errors.dart";
/// Fetched mail is acknowledged off the server during fetch, so deleting here
/// removes only the local sealed copy.
@RoutePage()
class MessageDetailScreen extends ConsumerWidget {
final String folder;
final String id;
final String subject;
const MessageDetailScreen({
super.key,
required this.folder,
required this.id,
required this.subject,
});
void deleteMessage(BuildContext context, WidgetRef ref) async {
await ref.read(clientProvider).deleteMessage(id);
ref.invalidate(messagesProvider);
Future<void> _delete(BuildContext context, WidgetRef ref) async {
await ref.read(storeProvider).deleteMessage(folder, id);
ref.read(revisionProvider.notifier).bump();
if (context.mounted) {
AutoRouter.of(context).pop();
}
}
Future<void> _nameSender(BuildContext context, WidgetRef ref,
Uint8List senderKey) async {
final client = ref.read(clientProvider);
final controller = TextEditingController();
final address = await showDialog<String>(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: Theme.of(context).extension<AppColors>()!.cardFill,
content: TextField(
controller: controller,
decoration: InputDecoration(
hintText: "user@host or smol://…",
filled: true,
fillColor: Theme.of(context).colorScheme.surface,
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(controller.text),
child: const Text("Save"),
),
],
),
);
if (address == null || address.trim().isEmpty) return;
try {
await client.nameSender(address, senderKey);
ref.read(revisionProvider.notifier).bump();
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text("Address saved for this sender")),
);
}
} on SmolError catch (err) {
if (context.mounted) {
showErrorSnackBar(context, err.message);
}
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final message = ref.watch(messageDetailProvider(id));
final record = ref.watch(storeProvider).getMessage(folder, id);
if (record == null) {
return Scaffold(
appBar: AppBar(),
body: const ImageBanner(
imgSrc: "assets/images/error.png",
text: "Message not found!",
),
);
}
final client = ref.read(clientProvider);
final opened = client.describe(record);
final isSent = folder == "sent";
final who = isSent
? (record.recipient ?? "")
: (opened.sender == null
? ""
: ref.watch(storeProvider).addressForKey(opened.sender!) ?? "");
return Scaffold(
appBar: AppBar(
title: who.isEmpty
? null
: Text(
who,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontFamily: "Inter",
fontWeight: FontWeight.w500,
fontSize: 18,
color: Theme.of(context).colorScheme.onSurface,
),
),
actions: [
IconButton(
onPressed: () => deleteMessage(context, ref),
icon: const Icon(Icons.delete, color: Colors.white),
)
tooltip: "Delete local copy",
onPressed: () => _delete(context, ref),
icon: const Icon(Icons.delete),
),
],
),
body: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
child: Column(
children: [
Container(
alignment: Alignment.centerLeft,
child: Text(
subject,
style: Theme.of(context)
.textTheme
.bodyLarge!
.copyWith(fontSize: 20),
),
),
const SizedBox(height: 30),
message.when(
data: (data) => MessageView(message: data),
error: (err, _) => const ImageBanner(
imgSrc: "assets/images/error.png",
text: "Something went wrong!",
),
loading: () => const SizedBox.shrink(),
),
],
),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
child: MessageView(
record: record,
opened: opened,
onNameSender: opened.sender == null
? null
: (key) => _nameSender(context, ref, key),
),
),
);

View file

@ -0,0 +1,324 @@
import "package:auto_route/auto_route.dart";
import "package:flutter/material.dart";
import "package:flutter/services.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:smol_mail/data/providers/providers.dart";
import "package:smol_mail/presentation/routes/app_router.gr.dart";
import "package:smol_mail/presentation/widgets/primary_button.dart";
import "package:smol_mail/presentation/widgets/secondary_button.dart";
import "package:smol_mail/presentation/widgets/small_loading_spinner.dart";
import "package:smol_mail/smol/crypto.dart";
import "package:smol_mail/smol/errors.dart";
import "package:smol_mail/smol/proto.dart";
import "package:smol_mail/presentation/theme/app_colors.dart";
import "package:smol_mail/shared/utils/snackbar.dart";
/// First-run flow: create or restore an identity, back the seed up, pin the
/// home server's key and register an address (SPEC.md §4: registration against
/// an unpinned server is not permitted).
@RoutePage()
class OnboardingScreen extends ConsumerStatefulWidget {
const OnboardingScreen({super.key});
@override
ConsumerState<OnboardingScreen> createState() => _OnboardingScreenState();
}
enum _Step { welcome, backup, restore, register }
class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
_Step step = _Step.welcome;
Uint8List? createdSeed;
bool busy = false;
final seedController = TextEditingController();
final restoreAddressController = TextEditingController();
final addressController = TextEditingController();
final serverKeyController = TextEditingController();
final tokenController = TextEditingController();
@override
void dispose() {
seedController.dispose();
restoreAddressController.dispose();
addressController.dispose();
serverKeyController.dispose();
tokenController.dispose();
super.dispose();
}
void _showError(Object error) {
if (!mounted) return;
// Replace rather than queue: a fresh error must not wait behind a stale
// one — a queued SnackBar is indistinguishable from "nothing happened".
ScaffoldMessenger.of(context).hideCurrentSnackBar();
showErrorSnackBar(
context, error is SmolError ? error.message : error.toString());
}
void _createIdentity() {
final client = ref.read(clientProvider);
final fresh = client.createIdentity();
setState(() {
createdSeed = fresh.seed;
step = _Step.backup;
});
}
void _restoreIdentity() {
setState(() => step = _Step.restore);
}
// Restoring must succeed on its own even if recall fails (server not
// pinned yet, offline, typo) — recall can always be retried from the
// register step or settings afterward.
void _submitRestore() {
final client = ref.read(clientProvider);
try {
client.restoreIdentity(seedController.text);
} on Exception catch (err) {
_showError(err);
return;
}
final addressText = restoreAddressController.text.trim();
if (addressText.isEmpty) {
setState(() => step = _Step.register);
return;
}
() async {
try {
await client.recallAccount(addressText);
if (!mounted) return;
ref.read(revisionProvider.notifier).bump();
AutoRouter.of(context).replace(InboxRoute());
} on Exception catch (err) {
if (!mounted) return;
_showError(err);
setState(() => step = _Step.register);
}
}();
}
Future<void> _submitRegistration() async {
await _submit(recall: false);
}
// Restoring a seed on a new device knows the identity but not the address it
// was registered under; recall binds it without re-REGISTER.
Future<void> _submitRecall() async {
await _submit(recall: true);
}
Future<void> _submit({required bool recall}) async {
final client = ref.read(clientProvider);
setState(() => busy = true);
try {
final address = parseAddress(addressController.text);
// Recall needs a pin too (SPEC.md §4), and the key for it is right
// here in the form — pin it before either path. An empty field means
// "already pinned, e.g. by a previous attempt or a preset server".
final serverKey = serverKeyController.text.trim();
if (serverKey.isNotEmpty) {
client.pinServer(address.host, serverKey);
}
if (recall) {
await client.recallAccount(address.short);
} else {
await client.registerAccount(address.short,
token: tokenController.text.trim());
}
if (mounted) {
ref.read(revisionProvider.notifier).bump();
AutoRouter.of(context).replace(InboxRoute());
}
} catch (err) {
_showError(err);
} finally {
if (mounted) setState(() => busy = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Padding(
padding: const EdgeInsets.symmetric(horizontal: 25),
child: switch (step) {
_Step.welcome => _welcome(context),
_Step.backup => _backup(context),
_Step.restore => _restore(context),
_Step.register => _register(context),
},
),
);
}
Widget _header(BuildContext context, String title) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 60),
Row(
children: [
const Image(image: AssetImage("assets/images/logo-small.png")),
const SizedBox(width: 10),
Text("kirakira", style: Theme.of(context).textTheme.titleLarge),
],
),
const SizedBox(height: 40),
Text(title, style: Theme.of(context).textTheme.titleMedium),
],
);
Widget _welcome(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_header(context,
"One keypair is the whole identity: an address, a key and a message."),
const Spacer(),
PrimaryButton(
onPressed: _createIdentity,
child: const Text("Create Identity"),
),
const SizedBox(height: 5),
SecondaryButton(
text: "Restore From Seed",
onPressed: _restoreIdentity,
),
const SizedBox(height: 80),
],
);
}
Widget _backup(BuildContext context) {
final seed = createdSeed!;
final seedHex = hex(seed);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_header(context, "Back up this seed; it is the only secret."),
const SizedBox(height: 20),
Text(
"fingerprint:\n${fingerprint(ed25519PublicKey(seed))}",
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 20),
SelectableText(
seedHex,
style: Theme.of(context)
.textTheme
.bodySmall!
.copyWith(color: Theme.of(context).colorScheme.primary),
),
TextButton(
onPressed: () async {
await Clipboard.setData(ClipboardData(text: seedHex));
if (mounted) {
ScaffoldMessenger.of(this.context).showSnackBar(
const SnackBar(content: Text("Seed copied to clipboard")),
);
}
},
child: const Text("Copy seed"),
),
const Spacer(),
PrimaryButton(
onPressed: () => setState(() => step = _Step.register),
child: const Text("I have backed it up"),
),
const SizedBox(height: 80),
],
);
}
Widget _restore(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_header(context, "Enter the 32-byte seed as 64 hex characters."),
const SizedBox(height: 20),
TextField(
controller: seedController,
decoration: InputDecoration(
hintText: "64 hex characters",
filled: true,
fillColor: Theme.of(context).extension<AppColors>()!.cardFill,
),
),
const SizedBox(height: 15),
TextField(
controller: restoreAddressController,
decoration: InputDecoration(
labelText: "Address (if already registered)",
hintText: "alice@example.org",
filled: true,
fillColor: Theme.of(context).extension<AppColors>()!.cardFill,
),
),
const SizedBox(height: 30),
PrimaryButton(
onPressed: _submitRestore,
child: const Text("Restore"),
),
const SizedBox(height: 5),
SecondaryButton(
text: "Back",
onPressed: () => setState(() => step = _Step.welcome),
),
const Spacer(),
],
);
}
Widget _register(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_header(context,
"Register an address. Pin the server's public key first — obtain it from the operator through a trusted channel."),
const SizedBox(height: 30),
TextField(
controller: addressController,
decoration: InputDecoration(
labelText: "Address",
hintText: "you@example.org[:1961]",
filled: true,
fillColor: Theme.of(context).extension<AppColors>()!.cardFill,
),
),
const SizedBox(height: 15),
TextField(
controller: serverKeyController,
decoration: InputDecoration(
labelText: "Server public key",
hintText: "base32, 52 characters",
filled: true,
fillColor: Theme.of(context).extension<AppColors>()!.cardFill,
),
),
const SizedBox(height: 15),
TextField(
controller: tokenController,
decoration: InputDecoration(
labelText: "Invite token (optional)",
filled: true,
fillColor: Theme.of(context).extension<AppColors>()!.cardFill,
),
),
const Spacer(),
PrimaryButton(
onPressed: busy ? () {} : _submitRegistration,
child: busy
? const SmallLoadingSpinner()
: const Text("Pin and Register"),
),
TextButton(
onPressed: busy ? () {} : _submitRecall,
child: const Text("Already registered? Recall"),
),
const SizedBox(height: 80),
],
);
}
}

View file

@ -0,0 +1,425 @@
import "dart:convert";
import "dart:io";
import "package:auto_route/auto_route.dart";
import "package:file_picker/file_picker.dart";
import "package:flutter/material.dart";
import "package:flutter/services.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:share_plus/share_plus.dart";
import "package:smol_mail/data/providers/providers.dart";
import "package:smol_mail/presentation/routes/app_router.gr.dart";
import "package:smol_mail/presentation/theme/app_colors.dart";
import "package:smol_mail/shared/utils/snackbar.dart";
import "package:smol_mail/smol/crypto.dart";
import "package:smol_mail/smol/errors.dart";
import "package:smol_mail/smol/proto.dart";
/// Identity card, server pins, rotation and the wipe. The seed is the only
/// secret; revealing it is always an explicit action.
@RoutePage()
class SettingsScreen extends ConsumerStatefulWidget {
const SettingsScreen({super.key});
@override
ConsumerState<SettingsScreen> createState() => _SettingsScreenState();
}
class _SettingsScreenState extends ConsumerState<SettingsScreen> {
bool seedShown = false;
final hostController = TextEditingController();
final keyController = TextEditingController();
final addressController = TextEditingController();
final tokenController = TextEditingController();
@override
void dispose() {
hostController.dispose();
keyController.dispose();
addressController.dispose();
tokenController.dispose();
super.dispose();
}
void _showError(Object error) {
if (!mounted) return;
showErrorSnackBar(context,
error is SmolError ? error.message : "Something went wrong!");
}
Future<void> _register() async {
final client = ref.read(clientProvider);
try {
await client.registerAccount(addressController.text,
token: tokenController.text.trim());
ref.read(revisionProvider.notifier).bump();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text("registered")),
);
}
} catch (err) {
_showError(err);
}
}
// Mail, once fetched, exists only on this device — the server deletes it as
// it is picked up (SPEC.md §5). This backs up inbox, sent mail, contacts and
// pinned servers into one shareable file; never the seed.
Future<void> _export() async {
final data = ref.read(storeProvider).exportData();
final stamp = DateTime.now().toIso8601String().substring(0, 10);
final file = File("${Directory.systemTemp.path}/smolmail-export-$stamp.json");
await file.writeAsString(jsonEncode(data));
await SharePlus.instance.share(ShareParams(
files: [XFile(file.path)], text: "kirakira backup $stamp"));
if (mounted) {
final messages = (data["inbox"] as List).length + (data["sent"] as List).length;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("exported $messages messages, "
"${(data["contacts"] as Map).length} contacts, "
"${(data["servers"] as Map).length} server keys")),
);
}
}
Future<void> _import() async {
final files = await FilePicker.pickFiles(
type: FileType.custom, allowedExtensions: ["json"]);
final path = files.isEmpty ? null : files.single.path;
if (path == null) return;
try {
final data = jsonDecode(File(path).readAsStringSync()) as Map;
final summary = await ref.read(storeProvider).importData(data);
ref.read(revisionProvider.notifier).bump();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("imported: $summary")),
);
}
} on Exception catch (err) {
_showError(err);
} on TypeError catch (err) {
_showError(err);
}
}
// Bind an address this seed was already registered under, without REGISTER.
Future<void> _recall() async {
final client = ref.read(clientProvider);
try {
final addr = await client.recallAccount(addressController.text);
ref.read(revisionProvider.notifier).bump();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("recalled ${addr.short}")),
);
}
} catch (err) {
_showError(err);
}
}
Future<void> _rotate() async {
final client = ref.read(clientProvider);
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: Theme.of(context).extension<AppColors>()!.cardFill,
title: const Text("Rotate the identity key?"),
content: const Text(
"Contacts accept the change from the signed chain. The old key is kept, "
"since mail sealed to it stays readable with nothing else."),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text("Cancel")),
TextButton(
onPressed: () => Navigator.of(ctx).pop(true),
child: const Text("Rotate")),
],
),
);
if (confirmed != true) return;
try {
final fresh = await client.rotateIdentity();
ref.read(revisionProvider.notifier).bump();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("rotated; new key ${b32encode(fresh.publicKey)}")),
);
}
} catch (err) {
_showError(err);
}
}
Future<void> _wipe() async {
final client = ref.read(clientProvider);
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: Theme.of(context).extension<AppColors>()!.cardFill,
title: const Text("Wipe everything?"),
content: const Text(
"Deletes the identity, every pinned key and every stored message from "
"this device. Mail left on servers stays there."),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text("Cancel")),
TextButton(
onPressed: () => Navigator.of(ctx).pop(true),
child: const Text("Wipe")),
],
),
);
if (confirmed != true) return;
await client.wipe();
ref.read(revisionProvider.notifier).bump();
if (mounted) {
AutoRouter.of(context).replace(OnboardingRoute());
}
}
@override
Widget build(BuildContext context) {
final store = ref.watch(storeProvider);
final me = ref.watch(identityProvider);
final account = ref.watch(accountProvider);
if (me == null) return Scaffold(appBar: AppBar(title: const Text("Settings")));
final seedHex = hex(me.seed);
final share = account?.uri(me.publicKey);
final pins = store.allPins();
return Scaffold(
appBar: AppBar(title: const Text("Settings")),
body: ListView(
padding: const EdgeInsets.all(20),
children: [
_card(
context,
children: [
Text("fingerprint", style: Theme.of(context).textTheme.labelSmall),
Text(fingerprint(me.publicKey)),
const SizedBox(height: 15),
Text("address", style: Theme.of(context).textTheme.labelSmall),
Text(account?.short ?? "(not registered)"),
const SizedBox(height: 15),
Text("share", style: Theme.of(context).textTheme.labelSmall),
Row(
children: [
Expanded(
child: Text(
share ?? "(register to get a shareable address)",
overflow: TextOverflow.ellipsis,
),
),
if (share != null)
IconButton(
icon: const Icon(Icons.copy, size: 18),
onPressed: () =>
Clipboard.setData(ClipboardData(text: share)),
),
],
),
],
),
const SizedBox(height: 20),
_card(
context,
borderColor: Theme.of(context).colorScheme.error,
children: [
Text("seed", style: Theme.of(context).textTheme.labelSmall),
Row(
children: [
Expanded(
child: SelectableText(
seedShown ? seedHex : "•" * 64,
style: Theme.of(context).textTheme.bodySmall,
),
),
TextButton(
onPressed: () =>
setState(() => seedShown = !seedShown),
child: Text(seedShown ? "hide" : "reveal"),
),
IconButton(
icon: const Icon(Icons.copy, size: 18),
onPressed: () =>
Clipboard.setData(ClipboardData(text: seedHex)),
),
],
),
Text(
"back this seed up; it is the only secret",
style: Theme.of(context)
.textTheme
.labelSmall!
.copyWith(color: Theme.of(context).colorScheme.error),
),
const SizedBox(height: 10),
Text(
"retired keys: ${store.identities().length - 1} (kept to read old mail)",
style: Theme.of(context).textTheme.labelSmall),
],
),
const SizedBox(height: 30),
const Divider(height: 1),
const SizedBox(height: 20),
Text("pinned servers", style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 10),
for (final (host, key) in pins)
ListTile(
contentPadding: EdgeInsets.zero,
title: Text(host),
subtitle: Text(
b32encode(key),
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.labelSmall,
),
trailing: IconButton(
icon: const Icon(Icons.delete_outline, size: 18),
onPressed: () {
store.unpinServer(host);
ref.read(revisionProvider.notifier).bump();
setState(() {});
},
),
),
if (pins.isEmpty)
Text("no pinned servers", style: Theme.of(context).textTheme.labelSmall),
const SizedBox(height: 10),
TextField(
controller: hostController,
decoration: InputDecoration(
labelText: "Host",
filled: true,
fillColor: Theme.of(context).extension<AppColors>()!.cardFill,
),
),
const SizedBox(height: 10),
TextField(
controller: keyController,
decoration: InputDecoration(
labelText: "Server public key (base32)",
filled: true,
fillColor: Theme.of(context).extension<AppColors>()!.cardFill,
),
),
const SizedBox(height: 10),
TextButton(
onPressed: () {
try {
ref
.read(clientProvider)
.pinServer(hostController.text, keyController.text);
keyController.clear();
setState(() {});
} on SmolError catch (err) {
_showError(err);
} catch (err) {
_showError(err);
}
},
child: const Text("Pin server"),
),
if (account == null) ...[
const SizedBox(height: 30),
const Divider(height: 1),
const SizedBox(height: 20),
Text("register an address",
style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 10),
TextField(
controller: addressController,
decoration: InputDecoration(
labelText: "Address",
hintText: "you@example.org[:1961]",
filled: true,
fillColor: Theme.of(context).extension<AppColors>()!.cardFill,
),
),
const SizedBox(height: 10),
TextField(
controller: tokenController,
decoration: InputDecoration(
labelText: "Invite token (optional)",
filled: true,
fillColor: Theme.of(context).extension<AppColors>()!.cardFill,
),
),
const SizedBox(height: 10),
TextButton(
onPressed: _register,
child: const Text("Register"),
),
TextButton(
onPressed: _recall,
child: const Text("Already registered? Recall"),
),
],
const SizedBox(height: 30),
const Divider(height: 1),
const SizedBox(height: 20),
Text("backup", style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 5),
Text(
"Mail, once fetched, exists only on this device. Export backs up the "
"inbox, sent mail, contacts and server pins — never the seed.",
style: Theme.of(context).textTheme.labelSmall,
),
const SizedBox(height: 10),
Row(
children: [
TextButton.icon(
onPressed: _export,
icon: const Icon(Icons.share, size: 18),
label: const Text("Export"),
),
TextButton.icon(
onPressed: _import,
icon: const Icon(Icons.file_upload, size: 18),
label: const Text("Import"),
),
],
),
const SizedBox(height: 30),
const Divider(height: 1),
const SizedBox(height: 10),
ListTile(
contentPadding: EdgeInsets.zero,
title: const Text("Rotate identity key"),
trailing: const Icon(Icons.rotate_right),
onTap: _rotate,
),
ListTile(
contentPadding: EdgeInsets.zero,
title: Text("Wipe identity and messages",
style: TextStyle(
color: Theme.of(context).colorScheme.error)),
trailing: Icon(Icons.delete_forever,
color: Theme.of(context).colorScheme.error),
onTap: _wipe,
),
],
),
);
}
Widget _card(BuildContext context,
{required List<Widget> children, Color? borderColor}) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).extension<AppColors>()!.cardFill,
borderRadius: BorderRadius.circular(10),
border: borderColor == null ? null : Border.all(color: borderColor),
),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: children),
);
}
}

View file

@ -0,0 +1,78 @@
import "package:flutter/material.dart";
/// Theme-aware colors that don't map onto a standard [ColorScheme] role.
/// Access via `Theme.of(context).extension<AppColors>()!`.
class AppColors extends ThemeExtension<AppColors> {
const AppColors({
required this.cardFill,
required this.highlight,
required this.snippetText,
required this.avatarPalette,
});
/// Elevated surface used for input fields, list tiles, and cards.
final Color cardFill;
/// Pressed/selected state highlight (e.g. active drawer item).
final Color highlight;
/// Dimmed body text, e.g. message preview snippets.
final Color snippetText;
/// Deterministic background colors for contact avatars.
final List<Color> avatarPalette;
static const dark = AppColors(
cardFill: Color.fromRGBO(22, 39, 56, 1),
highlight: Color.fromRGBO(40, 70, 100, 1),
snippetText: Color.fromRGBO(193, 203, 213, 1),
avatarPalette: [
Color.fromRGBO(63, 207, 142, 1),
Color.fromRGBO(90, 156, 240, 1),
Color.fromRGBO(240, 176, 90, 1),
Color.fromRGBO(214, 110, 140, 1),
Color.fromRGBO(154, 133, 230, 1),
Color.fromRGBO(90, 200, 210, 1),
],
);
static const light = AppColors(
cardFill: Color.fromRGBO(236, 240, 243, 1),
highlight: Color.fromRGBO(210, 231, 248, 1),
snippetText: Color.fromRGBO(91, 100, 112, 1),
avatarPalette: [
Color.fromRGBO(43, 175, 116, 1),
Color.fromRGBO(56, 116, 203, 1),
Color.fromRGBO(196, 132, 41, 1),
Color.fromRGBO(180, 76, 108, 1),
Color.fromRGBO(115, 92, 199, 1),
Color.fromRGBO(46, 152, 163, 1),
],
);
@override
AppColors copyWith({
Color? cardFill,
Color? highlight,
Color? snippetText,
List<Color>? avatarPalette,
}) {
return AppColors(
cardFill: cardFill ?? this.cardFill,
highlight: highlight ?? this.highlight,
snippetText: snippetText ?? this.snippetText,
avatarPalette: avatarPalette ?? this.avatarPalette,
);
}
@override
AppColors lerp(ThemeExtension<AppColors>? other, double t) {
if (other is! AppColors) return this;
return AppColors(
cardFill: Color.lerp(cardFill, other.cardFill, t)!,
highlight: Color.lerp(highlight, other.highlight, t)!,
snippetText: Color.lerp(snippetText, other.snippetText, t)!,
avatarPalette: t < 0.5 ? avatarPalette : other.avatarPalette,
);
}
}

View file

@ -1,95 +1,143 @@
import "package:flutter/material.dart";
final ThemeData appTheme = ThemeData(
colorScheme: ColorScheme.fromSwatch().copyWith(
primary: const Color.fromRGBO(63, 207, 142, 1),
background: const Color.fromRGBO(1, 17, 29, 1),
),
appBarTheme: const AppBarTheme(
backgroundColor: Color.fromRGBO(1, 17, 29, 1),
iconTheme: IconThemeData(
color: Colors.white,
size: 30,
),
titleTextStyle: TextStyle(
color: Colors.white,
fontFamily: "Inter",
fontWeight: FontWeight.w900,
fontSize: 30,
),
elevation: 0.0,
),
textTheme: const TextTheme(
bodySmall: TextStyle(
import "package:smol_mail/presentation/theme/app_colors.dart";
const _seedColor = Color.fromRGBO(63, 207, 142, 1);
const _darkSurface = Color.fromRGBO(1, 17, 29, 1);
TextTheme _textTheme(TextTheme base, Color mutedColor) {
return base.copyWith(
bodySmall: base.bodySmall?.copyWith(
fontFamily: "Inter",
fontSize: 16,
color: Colors.white,
fontWeight: FontWeight.normal,
),
bodyMedium: TextStyle(
bodyMedium: base.bodyMedium?.copyWith(
fontFamily: "Inter",
fontSize: 20,
color: Colors.white,
fontWeight: FontWeight.w500,
),
bodyLarge: TextStyle(
bodyLarge: base.bodyLarge?.copyWith(
fontFamily: "Inter",
fontSize: 24,
color: Colors.white,
fontWeight: FontWeight.bold,
),
titleMedium: TextStyle(
color: Colors.white,
titleMedium: base.titleMedium?.copyWith(
fontSize: 14,
fontWeight: FontWeight.w300,
),
titleLarge: TextStyle(
titleLarge: base.titleLarge?.copyWith(
fontFamily: "Inter",
fontSize: 35,
color: Colors.white,
fontWeight: FontWeight.w900,
),
labelSmall: TextStyle(
labelSmall: base.labelSmall?.copyWith(
fontFamily: "Inter",
fontSize: 16,
color: Color.fromRGBO(127, 133, 140, 1),
color: mutedColor,
fontWeight: FontWeight.w700,
),
),
snackBarTheme: const SnackBarThemeData(
backgroundColor: Colors.red,
contentTextStyle: TextStyle(
fontFamily: "Inter",
fontSize: 16,
color: Colors.white,
),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: const Color.fromRGBO(63, 207, 142, 1),
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(5),
),
textStyle: const TextStyle(
);
}
ThemeData _buildTheme(ColorScheme scheme, AppColors appColors) {
return ThemeData(
colorScheme: scheme,
// ThemeData defaults this legacy field to Colors.grey[900] on a dark
// theme when unset, which is nearly invisible against our dark surfaces —
// several screens still read it via Theme.of(context).primaryColor.
primaryColor: scheme.primary,
scaffoldBackgroundColor: scheme.surface,
extensions: [appColors],
appBarTheme: AppBarTheme(
backgroundColor: scheme.surface,
iconTheme: IconThemeData(color: scheme.onSurface, size: 30),
titleTextStyle: TextStyle(
color: scheme.onSurface,
fontFamily: "Inter",
fontSize: 17,
fontWeight: FontWeight.w500,
fontWeight: FontWeight.w900,
fontSize: 30,
),
elevation: 0.0,
),
),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
foregroundColor: const Color.fromRGBO(63, 207, 412, 1),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(5),
),
textStyle: const TextStyle(
textTheme: _textTheme(
ThemeData(brightness: scheme.brightness).textTheme,
scheme.onSurfaceVariant,
),
snackBarTheme: SnackBarThemeData(
backgroundColor: scheme.inverseSurface,
contentTextStyle: TextStyle(
fontFamily: "Inter",
fontWeight: FontWeight.w500,
fontSize: 17,
fontSize: 16,
color: scheme.onInverseSurface,
),
),
),
useMaterial3: true,
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: scheme.primary,
foregroundColor: scheme.onPrimary,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)),
textStyle: const TextStyle(
fontFamily: "Inter",
fontSize: 17,
fontWeight: FontWeight.w500,
),
),
),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
foregroundColor: scheme.primary,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)),
textStyle: const TextStyle(
fontFamily: "Inter",
fontWeight: FontWeight.w500,
fontSize: 17,
),
),
),
// Without this, an empty/unfocused field's resting label inherits
// bodyLarge (24px bold) — that's what made "Host" etc. look huge.
inputDecorationTheme: InputDecorationTheme(
labelStyle: TextStyle(
fontFamily: "Inter",
fontSize: 16,
fontWeight: FontWeight.normal,
color: scheme.onSurfaceVariant,
),
floatingLabelStyle: TextStyle(
fontFamily: "Inter",
fontSize: 14,
fontWeight: FontWeight.w500,
color: scheme.primary,
),
hintStyle: TextStyle(
fontFamily: "Inter",
fontSize: 16,
fontWeight: FontWeight.normal,
color: scheme.onSurfaceVariant,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide.none,
),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
),
useMaterial3: true,
);
}
final ThemeData appThemeDark = _buildTheme(
ColorScheme.fromSeed(
seedColor: _seedColor,
brightness: Brightness.dark,
).copyWith(surface: _darkSurface),
AppColors.dark,
);
final ThemeData appThemeLight = _buildTheme(
ColorScheme.fromSeed(
seedColor: _seedColor,
brightness: Brightness.light,
),
AppColors.light,
);

View file

@ -1,80 +0,0 @@
import "package:external_path/external_path.dart";
import "package:flash/flash.dart";
import "package:flutter/material.dart";
import "package:flutter_downloader/flutter_downloader.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:flash_mail/data/providers/providers.dart";
import "package:flash_mail/presentation/widgets/drawer/toast_message.dart";
class AttachmentButton extends ConsumerWidget {
final Map<String, dynamic> file;
const AttachmentButton({super.key, required this.file});
Future<void> downloadFile(BuildContext ctx, WidgetRef ref, String url) async {
final token = ref.read(tokenProvider);
showFlash(
context: ctx,
duration: const Duration(seconds: 2),
builder: (context, controller) => ToastMessage(
controller: controller,
icon: Icons.arrow_downward,
text: "Starting download!",
),
);
await FlutterDownloader.enqueue(
headers: {"Authorization": "Bearer $token"},
url: "https://api.mail.tm$url",
savedDir: await ExternalPath.getExternalStoragePublicDirectory(
ExternalPath.DIRECTORY_DOWNLOADS,
),
saveInPublicStorage: true,
);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 5),
decoration: BoxDecoration(
border: Border.all(
color: const Color.fromRGBO(137, 147, 164, 1),
),
borderRadius: BorderRadius.circular(10),
),
child: Row(
children: [
const Icon(
Icons.file_open_rounded,
color: Colors.red,
size: 24,
),
const SizedBox(width: 10),
Text(
file["filename"],
style: Theme.of(context).textTheme.bodySmall!.copyWith(
fontWeight: FontWeight.w500,
),
),
Expanded(
child: Container(
alignment: Alignment.centerRight,
child: IconButton(
splashColor: const Color.fromRGBO(40, 70, 100, 1),
onPressed: () =>
downloadFile(context, ref, file["downloadUrl"]),
icon: const Icon(
Icons.arrow_downward,
size: 24,
color: Colors.white,
),
),
),
)
],
),
);
}
}

View file

@ -1,18 +0,0 @@
import "package:flutter/material.dart";
import "package:flash_mail/presentation/widgets/attachment/attachment_button.dart";
class AttachmentList extends StatelessWidget {
final List<Map<String, dynamic>> attachments;
const AttachmentList({super.key, required this.attachments});
@override
Widget build(BuildContext context) {
return ListView.builder(
shrinkWrap: true,
itemCount: attachments.length,
itemBuilder: (ctx, i) => AttachmentButton(file: attachments[i]),
);
}
}

View file

@ -1,67 +0,0 @@
import "package:auto_route/auto_route.dart";
import "package:dio/dio.dart";
import "package:flutter/material.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:flash_mail/data/providers/providers.dart";
import "package:flash_mail/presentation/widgets/primary_button.dart";
import "package:flash_mail/presentation/widgets/secondary_button.dart";
import "package:flash_mail/presentation/widgets/small_loading_spinner.dart";
class AccountButtons extends StatelessWidget {
const AccountButtons({super.key});
void showErrorSnackBar(BuildContext ctx, String message) {
ScaffoldMessenger.of(ctx).showSnackBar(
SnackBar(
content: Text(message),
behavior: SnackBarBehavior.floating,
),
);
}
void _handleSubmit(BuildContext context, WidgetRef ref) async {
try {
await ref.read(accountProvider.notifier).createAccount();
if (context.mounted) {
AutoRouter.of(context).replaceNamed("/inbox");
}
} on DioError catch (err) {
if (context.mounted) {
showErrorSnackBar(context, err.message);
}
} catch (err) {
if (context.mounted) {
showErrorSnackBar(context, "Something went wrong!");
}
}
}
@override
Widget build(BuildContext context) {
return Column(
children: <Widget>[
Text(
"Your Inbox, Your Privacy, Zero Ads.",
style: Theme.of(context).textTheme.labelSmall,
),
const SizedBox(height: 15),
Consumer(
builder: (context, ref, child) {
return PrimaryButton(
child: ref.watch(accountProvider).isLoading
? const SmallLoadingSpinner()
: const Text("Create New Account"),
onPressed: () => _handleSubmit(context, ref),
);
},
),
const SizedBox(height: 5),
SecondaryButton(
text: "Use Existing",
onPressed: () => AutoRouter.of(context).pushNamed("/login"),
),
],
);
}
}

View file

@ -1,58 +0,0 @@
import "package:flutter/material.dart";
class InputField extends StatelessWidget {
final String label;
final String placeholder;
final TextInputAction inputAction;
final String? Function(String?) validator;
final void Function(String?) onSaved;
final TextInputType? inputType;
final bool? obscureText;
const InputField({
super.key,
required this.label,
required this.placeholder,
required this.inputAction,
required this.validator,
required this.onSaved,
this.inputType,
this.obscureText,
});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
label,
style: Theme.of(context).textTheme.bodySmall!.copyWith(
color: Theme.of(context).primaryColor,
),
),
const SizedBox(height: 10),
TextFormField(
keyboardType: inputType,
textInputAction: inputAction,
obscureText: obscureText ?? false,
validator: validator,
onSaved: onSaved,
style: Theme.of(context).textTheme.bodySmall!.copyWith(fontSize: 18),
decoration: InputDecoration(
filled: true,
fillColor: const Color.fromRGBO(22, 39, 56, 1),
hintText: placeholder,
hintStyle: const TextStyle(
color: Color.fromRGBO(127, 133, 141, 1),
),
errorStyle: const TextStyle(
fontFamily: "Inter",
fontSize: 16,
),
),
),
],
);
}
}

View file

@ -1,107 +0,0 @@
import "package:auto_route/auto_route.dart";
import "package:dio/dio.dart";
import "package:flutter/material.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:flash_mail/data/providers/providers.dart";
import "package:flash_mail/presentation/widgets/auth/input_field.dart";
import "package:flash_mail/presentation/widgets/primary_button.dart";
import "package:flash_mail/presentation/widgets/small_loading_spinner.dart";
import "package:flash_mail/shared/utils/regex_patterns.dart";
class LoginForm extends ConsumerWidget {
final GlobalKey<FormState> _formKey = GlobalKey();
LoginForm({super.key});
String? _validateEmail(String? value) {
if (value!.isEmpty || !emailRegex.hasMatch(value)) {
return "Invalid email address";
}
return null;
}
String? _validatePassword(String? value) {
if (value!.isEmpty || value.length < 8) {
return "Password too short";
}
return null;
}
void showErrorSnackBar(BuildContext ctx, String message) {
ScaffoldMessenger.of(ctx).showSnackBar(
SnackBar(
content: Text(message),
behavior: SnackBarBehavior.floating,
),
);
}
void _handleSubmit(BuildContext context, WidgetRef ref) async {
if (!_formKey.currentState!.validate()) {
return;
}
FocusScope.of(context).unfocus();
_formKey.currentState!.save();
try {
String? email = ref.read(emailProvider.notifier).state;
String? password = ref.read(passwordProvider.notifier).state;
await ref.read(accountProvider.notifier).login(email, password);
if (context.mounted) {
AutoRouter.of(context).replaceNamed("/inbox");
}
} on DioError catch (err) {
if (context.mounted) {
showErrorSnackBar(context, err.message);
}
} catch (_) {
if (context.mounted) {
showErrorSnackBar(context, "Something went wrong!");
}
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final account = ref.watch(accountProvider);
return Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
InputField(
label: "Email Address",
placeholder: "Enter email",
inputType: TextInputType.emailAddress,
inputAction: TextInputAction.next,
validator: _validateEmail,
onSaved: (value) => ref.read(emailProvider.notifier).state = value,
),
const SizedBox(height: 25),
InputField(
label: "Password",
placeholder: "Enter password",
obscureText: true,
inputAction: TextInputAction.done,
validator: _validatePassword,
onSaved: (value) =>
ref.read(passwordProvider.notifier).state = value,
),
const SizedBox(height: 30),
Center(
child: PrimaryButton(
onPressed: () => _handleSubmit(context, ref),
child: account.isLoading
? const SmallLoadingSpinner()
: const Text("Login"),
),
),
],
),
);
}
}

View file

@ -1,34 +1,32 @@
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:flutter/material.dart";
import "package:flash_mail/data/providers/providers.dart";
import "package:flash_mail/presentation/widgets/drawer/user_profile.dart";
import "package:flash_mail/presentation/widgets/drawer/drawer_list.dart";
import "package:flash_mail/presentation/widgets/drawer/user_quota.dart";
import "package:smol_mail/data/providers/providers.dart";
import "package:smol_mail/smol/proto.dart";
import "package:smol_mail/presentation/widgets/drawer/identity_header.dart";
import "package:smol_mail/presentation/widgets/drawer/drawer_list.dart";
import "package:smol_mail/presentation/theme/app_colors.dart";
class AppDrawer extends ConsumerWidget {
const AppDrawer({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final account = ref.read(accountProvider);
final identity = ref.watch(identityProvider);
final account = ref.watch(accountProvider);
final address = account?.short ?? "";
final fp = identity == null ? "" : fingerprint(identity.publicKey);
return Drawer(
backgroundColor: const Color.fromRGBO(22, 39, 56, 1),
backgroundColor: Theme.of(context).extension<AppColors>()!.cardFill,
child: Padding(
padding: const EdgeInsets.only(top: 90),
padding: const EdgeInsets.only(top: 60),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
UserProfile(
email: account.user!.email,
password: account.user!.password,
),
IdentityHeader(address: address, fingerprint: fp),
const SizedBox(height: 50),
const DrawerList(),
UserQuota(
value: account.user!.used / account.user!.quota,
),
],
),
),

View file

@ -1,24 +1,19 @@
import "package:flutter/material.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:auto_route/auto_route.dart";
import "package:flash_mail/data/providers/providers.dart";
import "package:flash_mail/shared/configs/flash_mail_icons.dart";
import "package:flash_mail/presentation/widgets/drawer/drawer_list_tile.dart";
import "package:smol_mail/data/providers/providers.dart";
import "package:smol_mail/presentation/routes/app_router.gr.dart";
import "package:auto_route/auto_route.dart";
import "package:smol_mail/shared/configs/flash_mail_icons.dart";
import "package:smol_mail/presentation/widgets/drawer/drawer_list_tile.dart";
class DrawerList extends ConsumerWidget {
const DrawerList({super.key});
Future<void> signOut(BuildContext context, WidgetRef ref) async {
Navigator.of(context).pop();
await ref.read(accountProvider.notifier).logout();
if (context.mounted) {
AutoRouter.of(context).replaceNamed("/");
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final unread = ref.watch(unreadProvider);
return Padding(
padding: const EdgeInsets.only(left: 25),
child: Column(
@ -26,16 +21,40 @@ class DrawerList extends ConsumerWidget {
DrawerListTile(
color: Theme.of(context).primaryColor,
icon: FlashMailIcons.inbox,
title: "Inbox",
onTap: () => Navigator.of(context).pop(),
title: "Inbox${unread > 0 ? " ($unread)" : ""}",
onTap: () {
ref.read(folderProvider.notifier).select("inbox");
ref.read(revisionProvider.notifier).bump();
Navigator.of(context).pop();
},
),
Consumer(
builder: (context, ref, _) => DrawerListTile(
color: Theme.of(context).colorScheme.error,
icon: FlashMailIcons.sign_out,
title: "Sign out",
onTap: () => signOut(context, ref),
),
DrawerListTile(
color: Theme.of(context).primaryColor,
icon: Icons.outgoing_mail,
title: "Sent",
onTap: () {
ref.read(folderProvider.notifier).select("sent");
ref.read(revisionProvider.notifier).bump();
Navigator.of(context).pop();
},
),
DrawerListTile(
color: Theme.of(context).primaryColor,
icon: Icons.contacts,
title: "Contacts",
onTap: () {
Navigator.of(context).pop();
AutoRouter.of(context).push(ContactsRoute());
},
),
DrawerListTile(
color: Theme.of(context).primaryColor,
icon: Icons.settings,
title: "Settings",
onTap: () {
Navigator.of(context).pop();
AutoRouter.of(context).push(SettingsRoute());
},
),
],
),

View file

@ -1,5 +1,7 @@
import "package:flutter/material.dart";
import "package:smol_mail/presentation/theme/app_colors.dart";
class DrawerListTile extends StatelessWidget {
final Color color;
final IconData icon;
@ -16,10 +18,11 @@ class DrawerListTile extends StatelessWidget {
@override
Widget build(BuildContext context) {
final appColors = Theme.of(context).extension<AppColors>()!;
return Theme(
data: Theme.of(context).copyWith(
splashColor: const Color.fromRGBO(40, 70, 100, 1),
highlightColor: const Color.fromRGBO(22, 39, 56, 1),
splashColor: appColors.highlight,
highlightColor: appColors.cardFill,
),
child: ListTile(
onTap: onTap,

View file

@ -0,0 +1,64 @@
import "package:flutter/material.dart";
import "package:smol_mail/shared/utils/format.dart";
/// Identity summary in the drawer: the address is what gets shared, the
/// fingerprint is what gets verified.
class IdentityHeader extends StatelessWidget {
final String address;
final String fingerprint;
const IdentityHeader({
super.key,
required this.address,
required this.fingerprint,
});
@override
Widget build(BuildContext context) {
return Column(
children: <Widget>[
CircleAvatar(
backgroundColor: Theme.of(context).primaryColor,
radius: 35,
child: Text(
address.isEmpty ? "?" : address[0].toUpperCase(),
style:
Theme.of(context).textTheme.bodyLarge!.copyWith(fontSize: 30),
),
),
const SizedBox(height: 20),
InkWell(
onTap: () => copyText(context, address),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
address.isEmpty ? "(not registered)" : address,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w900,
),
),
),
),
const SizedBox(height: 5),
InkWell(
onTap: () => copyText(context, fingerprint),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
"fp: $fingerprint",
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontSize: 14,
fontWeight: FontWeight.w700,
),
),
),
),
],
);
}
}

View file

@ -1,46 +0,0 @@
import "package:flutter/material.dart";
import "package:flash/flash.dart";
class ToastMessage extends StatelessWidget {
final FlashController controller;
final IconData icon;
final String text;
const ToastMessage({
super.key,
required this.controller,
required this.icon,
required this.text,
});
@override
Widget build(BuildContext context) {
return Flash(
position: FlashPosition.bottom,
controller: controller,
child: AlertDialog(
alignment: Alignment.bottomCenter,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
backgroundColor: const Color.fromRGBO(217, 217, 217, 1),
content: Row(
children: [
Icon(
icon,
color: Colors.black,
),
const SizedBox(width: 20),
Text(
text,
style: Theme.of(context)
.textTheme
.bodySmall!
.copyWith(color: Colors.black),
),
],
),
),
);
}
}

View file

@ -1,75 +0,0 @@
import "package:flutter/material.dart";
import "package:flutter/services.dart";
import "package:flash/flash.dart";
import "package:flash_mail/presentation/widgets/drawer/toast_message.dart";
class UserProfile extends StatelessWidget {
final String email;
final String password;
const UserProfile({
super.key,
required this.email,
required this.password,
});
Future<void> copyClipboard(BuildContext context, String text) async {
await Clipboard.setData(ClipboardData(text: text));
if (context.mounted) {
showFlash(
context: context,
duration: const Duration(seconds: 2),
builder: (context, controller) => ToastMessage(
controller: controller,
icon: Icons.check,
text: "Copied to Clipboard!",
),
);
}
}
@override
Widget build(BuildContext context) {
return Column(
children: <Widget>[
CircleAvatar(
backgroundColor: Theme.of(context).primaryColor,
radius: 35,
child: Text(
email[0].toUpperCase(),
style:
Theme.of(context).textTheme.bodyLarge!.copyWith(fontSize: 30),
),
),
const SizedBox(height: 20),
InkWell(
onTap: () => copyClipboard(context, email),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
email,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w900,
),
),
),
),
const SizedBox(height: 5),
InkWell(
onTap: () => copyClipboard(context, password),
child: Text(
"password: $password",
style: const TextStyle(
color: Color.fromRGBO(127, 133, 141, 1),
fontSize: 18,
fontWeight: FontWeight.w900,
),
),
),
],
);
}
}

View file

@ -1,71 +0,0 @@
import "package:flutter/material.dart";
import "package:url_launcher/url_launcher.dart";
import "package:flash_mail/shared/configs/flash_mail_icons.dart";
class UserQuota extends StatelessWidget {
final String _url = "https://mail.tm/";
final double value;
const UserQuota({super.key, required this.value});
Future<void> openUrl(Uri url) async {
if (await canLaunchUrl(url)) {
launchUrl(url);
}
}
@override
Widget build(BuildContext context) {
return Expanded(
child: Padding(
padding: const EdgeInsets.only(bottom: 30, left: 40, right: 40),
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"Quota",
style: TextStyle(
color: Theme.of(context).primaryColor,
fontSize: 18,
),
),
const SizedBox(height: 15),
LinearProgressIndicator(
backgroundColor: const Color.fromRGBO(85, 97, 112, 1),
color: Theme.of(context).primaryColor,
value: value,
minHeight: 10,
borderRadius: BorderRadius.circular(5),
),
const SizedBox(height: 25),
InkWell(
onTap: () => openUrl(Uri.parse(_url)),
highlightColor: Colors.transparent,
splashColor: Colors.transparent,
child: Row(
children: [
Text(
"Powered By Mail.tm",
style: TextStyle(
color: Theme.of(context).primaryColor,
fontSize: 18,
),
),
const SizedBox(width: 10),
Icon(
FlashMailIcons.redirect,
color: Theme.of(context).primaryColor,
size: 16,
)
],
),
)
],
),
),
);
}
}

View file

@ -5,10 +5,10 @@ class ImageBanner extends StatelessWidget {
final String text;
const ImageBanner({
Key? key,
super.key,
required this.imgSrc,
required this.text,
}) : super(key: key);
});
@override
Widget build(BuildContext context) {

View file

@ -1,53 +0,0 @@
import "package:flutter/widgets.dart";
import "package:flutter_inappwebview/flutter_inappwebview.dart";
import "package:url_launcher/url_launcher.dart";
class MessageBody extends StatefulWidget {
final String html;
const MessageBody({super.key, required this.html});
@override
State<MessageBody> createState() => _MessageBodyState();
}
class _MessageBodyState extends State<MessageBody> {
double webviewHeight = 1;
@override
Widget build(BuildContext context) {
return SizedBox(
height: webviewHeight,
child: InAppWebView(
initialData: InAppWebViewInitialData(data: widget.html),
initialSettings: InAppWebViewSettings(
transparentBackground: true,
forceDark: ForceDark.ON,
algorithmicDarkeningAllowed: true,
supportZoom: false,
javaScriptEnabled: true,
disableHorizontalScroll: false,
disableVerticalScroll: true,
useWideViewPort: false,
useHybridComposition: false,
preferredContentMode: UserPreferredContentMode.MOBILE,
),
onLoadStop: (controller, url) async {
final contentHeight = await controller.getContentHeight();
if (contentHeight != null) {
setState(() {
webviewHeight = contentHeight.toDouble();
});
}
},
shouldOverrideUrlLoading: (controller, navigationAction) async {
final url = navigationAction.request.url;
if (url != null && await canLaunchUrl(url)) {
await launchUrl(url);
}
return NavigationActionPolicy.CANCEL;
},
),
);
}
}

View file

@ -1,82 +0,0 @@
import "package:flutter/material.dart";
import "package:intl/intl.dart";
class MessageInfo extends StatelessWidget {
final String senderAddress;
final String receiverAddress;
final DateTime date;
const MessageInfo({
super.key,
required this.senderAddress,
required this.receiverAddress,
required this.date,
});
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.symmetric(vertical: 15),
padding: const EdgeInsets.all(15),
decoration: BoxDecoration(
border: Border.all(
color: const Color.fromRGBO(127, 133, 140, 1),
),
borderRadius: const BorderRadius.all(Radius.circular(10)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
"From",
style: Theme.of(context).textTheme.labelSmall,
),
const SizedBox(width: 10),
SizedBox(
width: MediaQuery.of(context).size.width * 0.65,
child: Text(
senderAddress,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall,
),
)
],
),
Row(
children: [
Text(
"To",
style: Theme.of(context).textTheme.labelSmall,
),
const SizedBox(width: 10),
SizedBox(
width: MediaQuery.of(context).size.width * 0.65,
child: Text(
receiverAddress,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall,
),
)
],
),
Row(
children: [
Text(
"Date",
style: Theme.of(context).textTheme.labelSmall,
),
const SizedBox(width: 10),
Text(
DateFormat.yMMMMd().add_jm().format(date.toLocal()),
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall,
)
],
),
],
),
);
}
}

View file

@ -1,32 +1,27 @@
import "package:flutter/material.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:flash_mail/data/models/message/message.dart";
import "package:flash_mail/presentation/widgets/image_banner.dart";
import "package:flash_mail/presentation/widgets/message/message_tile.dart";
import "package:smol_mail/data/providers/providers.dart";
import "package:smol_mail/presentation/widgets/message/message_tile.dart";
class MessageList extends StatelessWidget {
final List<TMMessage> messages;
class MessageList extends ConsumerWidget {
final Future<void> Function() onRefresh;
const MessageList({
super.key,
required this.messages,
required this.onRefresh,
});
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final messages = ref.watch(messagesProvider);
return RefreshIndicator(
onRefresh: onRefresh,
child: messages.isEmpty
? const ImageBanner(
imgSrc: "assets/images/empty.png",
text: "Your inbox is empty!",
)
: ListView.builder(
itemBuilder: (ctx, i) => MessageTile(message: messages[i]),
itemCount: messages.length,
),
child: ListView.builder(
itemBuilder: (ctx, i) => MessageTile(record: messages[i]),
itemCount: messages.length,
),
);
}
}

View file

@ -1,84 +0,0 @@
import "package:flutter/material.dart";
import "package:flash_mail/presentation/widgets/message/message_info.dart";
class MessageSender extends StatefulWidget {
final String senderName;
final String senderAddress;
final String receiverAddress;
final DateTime date;
const MessageSender({
super.key,
required this.senderName,
required this.senderAddress,
required this.receiverAddress,
required this.date,
});
@override
State<MessageSender> createState() => _MessageSenderState();
}
class _MessageSenderState extends State<MessageSender> {
bool showInfo = false;
void toggleInfo() {
setState(() {
showInfo = !showInfo;
});
}
@override
Widget build(BuildContext context) {
return Column(
children: [
Row(
children: [
Image.asset("assets/images/avatar.png", width: 50, height: 50),
const SizedBox(width: 15),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.senderName,
style: Theme.of(context).textTheme.bodyMedium!.copyWith(
fontWeight: FontWeight.bold,
),
),
GestureDetector(
onTap: () => toggleInfo(),
child: Row(
children: [
SizedBox(
width: MediaQuery.of(context).size.width * 0.6,
child: Text(
widget.senderAddress,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.labelSmall,
),
),
Icon(
showInfo
? Icons.keyboard_arrow_up_outlined
: Icons.keyboard_arrow_down_outlined,
color: const Color.fromRGBO(127, 133, 140, 1),
),
],
),
),
],
)
],
),
showInfo
? MessageInfo(
senderAddress: widget.senderAddress,
receiverAddress: widget.receiverAddress,
date: widget.date,
)
: const SizedBox.shrink(),
],
);
}
}

View file

@ -1,77 +1,101 @@
import "package:auto_route/auto_route.dart";
import "package:flutter/material.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:flash_mail/data/models/message/message.dart";
import "package:flash_mail/presentation/routes/app_router.gr.dart";
import "package:flash_mail/shared/utils/random_color.dart";
import "package:smol_mail/data/providers/providers.dart";
import "package:smol_mail/presentation/routes/app_router.gr.dart";
import "package:smol_mail/presentation/theme/app_colors.dart";
import "package:smol_mail/shared/utils/avatar_color.dart";
import "package:smol_mail/shared/utils/format.dart";
import "package:smol_mail/smol/proto.dart";
import "package:smol_mail/smol/store.dart";
class MessageTile extends StatefulWidget {
final TMMessage message;
class MessageTile extends ConsumerWidget {
final MailRecord record;
const MessageTile({super.key, required this.message});
const MessageTile({super.key, required this.record});
@override
State<MessageTile> createState() => _MessageTileState();
}
Widget build(BuildContext context, WidgetRef ref) {
final folder = ref.watch(folderProvider);
final store = ref.watch(storeProvider);
final opened = ref.read(clientProvider).describe(record);
final appColors = Theme.of(context).extension<AppColors>()!;
class _MessageTileState extends State<MessageTile> {
late Color randomColor;
String who;
if (folder == "sent") {
who = record.recipient ?? "";
} else if (opened.sender != null) {
final known = store.addressForKey(opened.sender!);
who = known ?? "unknown · ${b32encode(opened.sender!).substring(0, 4)}";
} else {
who = "?";
}
final unread = folder == "inbox" && !store.isRead(record.id);
final errorText = opened.error != null ? "<unreadable>" : null;
final subject =
errorText ?? (opened.subject.isEmpty ? "(no subject)" : opened.subject);
final snippet = errorText == null
? opened.body.replaceAll(RegExp(r"\s+"), " ").trim()
: "";
final avatarColor = colorForKey(who, appColors.avatarPalette);
@override
void initState() {
super.initState();
randomColor = getRandomColor();
}
@override
Widget build(BuildContext context) {
return ListTile(
splashColor: const Color.fromRGBO(40, 70, 100, 1),
focusColor: const Color.fromRGBO(22, 39, 56, 1),
onTap: () => AutoRouter.of(context).push(
MessageDetailRoute(
id: widget.message.id,
subject: widget.message.subject,
),
),
splashColor: appColors.highlight,
focusColor: appColors.cardFill,
onTap: () {
if (folder == "inbox") {
store.markRead(record.id);
ref.read(revisionProvider.notifier).bump();
}
AutoRouter.of(context).push(
MessageDetailRoute(folder: folder, id: record.id),
);
},
leading: CircleAvatar(
radius: 25,
backgroundColor: randomColor,
backgroundColor: avatarColor,
child: Text(
widget.message.from["name"]!.toUpperCase()[0],
who.isEmpty ? "?" : who[0].toUpperCase(),
style: Theme.of(context).textTheme.bodyLarge!.copyWith(
color: Colors.black,
color: contrastingTextColor(avatarColor),
fontWeight: FontWeight.w500,
),
),
),
title: Text(
widget.message.from["name"] as String,
who,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodyMedium!.copyWith(
fontWeight:
widget.message.seen ? FontWeight.w500 : FontWeight.bold,
fontWeight: unread ? FontWeight.bold : FontWeight.w500,
),
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.message.subject,
subject,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight:
widget.message.seen ? FontWeight.w300 : FontWeight.w500,
fontWeight: unread ? FontWeight.w500 : FontWeight.w300,
),
),
Text(
widget.message.intro ?? widget.message.subject,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: const Color.fromRGBO(193, 203, 213, 1),
),
)
if (snippet.isNotEmpty)
Text(
snippet.length > 140 ? snippet.substring(0, 140) : snippet,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: appColors.snippetText,
),
)
],
),
trailing: Text(
formatShortTime(folder == "sent" ? record.sentAt : opened.time),
style: Theme.of(context).textTheme.labelSmall,
),
);
}
}

View file

@ -1,39 +1,190 @@
import "package:flutter/widgets.dart";
import "dart:typed_data";
import "package:auto_route/auto_route.dart";
import "package:flutter/material.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:flash_mail/data/models/message_detail/message_detail.dart";
import "package:flash_mail/data/providers/providers.dart";
import "package:flash_mail/presentation/widgets/attachment/attachment_list.dart";
import "package:flash_mail/presentation/widgets/message/message_body.dart";
import "package:flash_mail/presentation/widgets/message/message_sender.dart";
import "package:smol_mail/data/providers/providers.dart";
import "package:smol_mail/presentation/routes/app_router.gr.dart";
import "package:smol_mail/shared/utils/format.dart";
import "package:smol_mail/shared/utils/snackbar.dart";
import "package:smol_mail/smol/client.dart";
import "package:smol_mail/smol/proto.dart";
import "package:smol_mail/smol/store.dart";
class MessageView extends StatelessWidget {
final TMMessageDetail message;
/// The opened message: trust row (fingerprint and how the sender's key is
/// known), frontmatter fields, and the plain body — smol mail has no HTML.
class MessageView extends ConsumerWidget {
final MailRecord record;
final OpenedRecord opened;
final void Function(Uint8List senderKey)? onNameSender;
const MessageView({super.key, required this.message});
const MessageView({
super.key,
required this.record,
required this.opened,
this.onNameSender,
});
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
if (opened.error != null) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text("<unreadable: ${opened.error}>",
style: TextStyle(color: Theme.of(context).colorScheme.error)),
],
);
}
final folder = ref.watch(folderProvider);
final store = ref.watch(storeProvider);
final client = ref.read(clientProvider);
// Sent copies carry no signature from a third party; the trust row then
// just says what they are.
final isSent = folder == "sent";
final senderKey = opened.sender;
final known = senderKey == null ? null : store.addressForKey(senderKey);
final contact = known == null ? null : store.contact(known);
final verifiedSender = contact?.verified ?? false;
final replyTo = isSent ? null : client.replyAddress(opened);
final subject =
opened.subject.isEmpty ? "(no subject)" : opened.subject;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Consumer(
builder: (context, ref, child) => MessageSender(
senderName: message.from["name"] as String,
senderAddress: message.from["address"] as String,
receiverAddress: ref.read(emailProvider) as String,
date: message.createdAt,
Text(subject, style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 14),
if (isSent) _badge(context, "your own sealed copy") else
_badge(
context,
verifiedSender
? "verified sender key"
: known != null
? "key pinned on first use"
: "key bound to no address",
alert: known == null,
),
if (!isSent && senderKey != null) ...[
const SizedBox(height: 10),
_keyBlock(context, "${fingerprint(senderKey)}\n${b32encode(senderKey)}"),
],
const SizedBox(height: 18),
_field(context, isSent ? "To" : "From",
isSent ? (record.recipient ?? "") : (known ?? "unknown sender")),
_field(context, "Date", formatTime(opened.time)),
for (final entry in opened.fields.entries)
if (entry.key != "Subject" && entry.key != "Reply-To")
_field(context, entry.key, entry.value),
if (!isSent && replyTo != null)
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: _field(context, "Reply-To", replyTo.short)),
TextButton(
onPressed: () async {
try {
await client.saveReplyAddress(replyTo, senderKey!);
ref.read(revisionProvider.notifier).bump();
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text("Address saved for this sender")),
);
}
} catch (err) {
if (context.mounted) {
showErrorSnackBar(context, err.toString());
}
}
},
child: const Text("Save"),
),
],
),
if (!isSent && senderKey != null && onNameSender != null)
Align(
alignment: Alignment.centerLeft,
child: TextButton(
onPressed: () => onNameSender!(senderKey),
child: const Text("Name this sender"),
),
),
const SizedBox(height: 10),
const Divider(height: 1),
const SizedBox(height: 20),
SelectableText(
opened.body,
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 25),
MessageBody(
html: message.html[0],
),
message.hasAttachments
? AttachmentList(
attachments: message.attachments!,
)
: const SizedBox.shrink()
if (!isSent && senderKey != null)
Align(
alignment: Alignment.centerRight,
child: OutlinedButton.icon(
icon: const Icon(Icons.reply, size: 18),
label: const Text("Reply"),
onPressed: () => AutoRouter.of(context).push(
ComposeRoute(
to: known ?? replyTo?.short ?? "",
subject: replySubject(opened.subject),
),
),
),
),
],
);
}
Widget _badge(BuildContext context, String text, {bool alert = false}) {
final scheme = Theme.of(context).colorScheme;
final background = alert ? scheme.errorContainer : scheme.primaryContainer;
final foreground = alert ? scheme.onErrorContainer : scheme.onPrimaryContainer;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: background,
borderRadius: BorderRadius.circular(10),
),
child: Text(
text,
style: Theme.of(context)
.textTheme
.labelSmall!
.copyWith(color: foreground, fontWeight: FontWeight.w500),
),
);
}
/// Technical detail (fingerprint, full key) — quiet by design, so it never
/// competes with the subject or body for attention.
Widget _keyBlock(BuildContext context, String text) => Text(
text,
style: Theme.of(context).textTheme.bodySmall!.copyWith(
fontSize: 12,
height: 1.5,
fontWeight: FontWeight.normal,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
);
Widget _field(BuildContext context, String label, String value) => Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 90,
child: Text(label, style: Theme.of(context).textTheme.labelSmall),
),
Expanded(
child: Text(value, style: Theme.of(context).textTheme.bodySmall),
),
],
),
);
}

View file

@ -1,45 +0,0 @@
import "package:flutter/material.dart";
class ListShimmer extends StatelessWidget {
const ListShimmer({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Container(
width: 50,
height: 50,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(25),
color: const Color.fromRGBO(160, 176, 186, 1),
),
),
const SizedBox(width: 15),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Container(
color: const Color.fromRGBO(160, 176, 186, 1),
width: MediaQuery.of(context).size.width * 0.65,
height: 18,
),
),
const SizedBox(height: 10),
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Container(
color: const Color.fromRGBO(160, 176, 186, 1),
width: MediaQuery.of(context).size.width * 0.65,
height: 18,
),
),
],
)
],
);
}
}

View file

@ -1,28 +0,0 @@
import "package:flutter/material.dart";
import "package:shimmer/shimmer.dart";
import "package:flash_mail/presentation/widgets/shimmer/list_shimmer.dart";
class MessageShimmer extends StatelessWidget {
const MessageShimmer({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(15),
child: Shimmer.fromColors(
baseColor: Colors.grey[500]!,
highlightColor: Colors.grey[300]!,
child: ListView.builder(
itemBuilder: (ctx, i) => const Column(
children: [
ListShimmer(),
SizedBox(height: 20),
],
),
itemCount: 8,
),
),
);
}
}

View file

@ -1,17 +1,17 @@
import "package:flutter/material.dart";
class SmallLoadingSpinner extends StatelessWidget {
const SmallLoadingSpinner({Key? key}) : super(key: key);
const SmallLoadingSpinner({super.key});
@override
Widget build(BuildContext context) {
return const SizedBox(
return SizedBox(
width: 20,
height: 20,
child: Center(
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
color: Theme.of(context).colorScheme.onPrimary,
),
),
);

View file

@ -1,17 +1,17 @@
// ignore_for_file: constant_identifier_names
///
/// Flutter icons FlashMailIcons
/// Copyright (C) 2023 by original authors @ fluttericon.com, fontello.com
/// This font was generated by FlutterIcon.com, which is derived from Fontello.
///
/// To use this font, place it in your fonts/ directory and include the
/// following in your pubspec.yaml
///
/// flutter:
/// fonts:
/// - family: FlashMailIcons
/// fonts:
/// - asset: fonts/FlashMailIcons.ttf
//
// Flutter icons FlashMailIcons
// Copyright (C) 2023 by original authors @ fluttericon.com, fontello.com
// This font was generated by FlutterIcon.com, which is derived from Fontello.
//
// To use this font, place it in your fonts/ directory and include the
// following in your pubspec.yaml
//
// flutter:
// fonts:
// - family: FlashMailIcons
// fonts:
// - asset: fonts/FlashMailIcons.ttf
import "package:flutter/widgets.dart";

View file

@ -0,0 +1,15 @@
import "package:flutter/material.dart";
/// A deterministic color for [key] (e.g. a contact address), picked from
/// [palette] — same contact always gets the same avatar color, and every
/// choice comes from the app's own palette instead of arbitrary RGB space.
Color colorForKey(String key, List<Color> palette) {
if (palette.isEmpty) return Colors.grey;
final hash = key.codeUnits.fold<int>(0, (acc, c) => (acc * 31 + c) & 0x7fffffff);
return palette[hash % palette.length];
}
/// Black or white, whichever reads clearly on [background].
Color contrastingTextColor(Color background) {
return background.computeLuminance() > 0.5 ? Colors.black : Colors.white;
}

View file

@ -0,0 +1,37 @@
import "package:flutter/material.dart";
import "package:flutter/services.dart";
import "package:intl/intl.dart";
/// One path for all replies so the Re: rule lives once.
String replySubject(String subject) =>
subject.isEmpty || subject.startsWith("Re:") ? subject : "Re: $subject";
String formatTime(int? seconds) {
if (seconds == null) return "";
final date = DateTime.fromMillisecondsSinceEpoch(seconds * 1000, isUtc: true);
return DateFormat.yMMMMd().add_jm().format(date.toLocal());
}
/// The full date is too wide for a list row's trailing slot — this is what
/// message_tile.dart shows instead: just a time for today, else a short date.
String formatShortTime(int? seconds) {
if (seconds == null) return "";
final date =
DateTime.fromMillisecondsSinceEpoch(seconds * 1000, isUtc: true).toLocal();
final now = DateTime.now();
if (date.year == now.year && date.month == now.month && date.day == now.day) {
return DateFormat.jm().format(date);
}
if (date.year == now.year) {
return DateFormat.MMMd().format(date);
}
return DateFormat.yMd().format(date);
}
Future<void> copyText(BuildContext context, String text) async {
await Clipboard.setData(ClipboardData(text: text));
if (context.mounted) {
ScaffoldMessenger.of(context)
.showSnackBar(const SnackBar(content: Text("Copied to clipboard")));
}
}

View file

@ -1,12 +0,0 @@
import "dart:math";
import "dart:ui";
Color getRandomColor() {
Random random = Random();
return Color.fromRGBO(
random.nextInt(256),
random.nextInt(256),
random.nextInt(256),
1,
);
}

View file

@ -1,12 +0,0 @@
import "dart:math";
String randomString(int length) {
const charset =
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
final random = Random.secure();
final codeUnits = List<int>.generate(
length,
(index) => charset.codeUnitAt(random.nextInt(charset.length)),
);
return String.fromCharCodes(codeUnits);
}

View file

@ -1,3 +0,0 @@
final RegExp emailRegex = RegExp(
r'^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$',
);

View file

@ -0,0 +1,15 @@
import "package:flutter/material.dart";
/// Errors get the error color explicitly — the theme's default SnackBar is
/// neutral, so a plain SnackBar(content: ...) is *not* red anymore.
void showErrorSnackBar(BuildContext context, String message,
{Duration? duration}) {
final scheme = Theme.of(context).colorScheme;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
backgroundColor: scheme.error,
duration: duration ?? const Duration(seconds: 4),
content: Text(message, style: TextStyle(color: scheme.onError)),
),
);
}

426
lib/smol/client.dart Normal file
View file

@ -0,0 +1,426 @@
// App-level client: the flows of gsmol's app.js — connect with pinning, fetch
// with verification and acknowledgment, send with sent copies, contacts and
// rotation — on top of the pure protocol modules.
import "dart:convert";
import "dart:typed_data";
import "package:smol_mail/smol/crypto.dart";
import "package:smol_mail/smol/errors.dart";
import "package:smol_mail/smol/proto.dart";
import "package:smol_mail/smol/store.dart";
import "package:smol_mail/smol/transport.dart";
class RefreshOutcome {
final String message;
final bool warn;
const RefreshOutcome(this.message, this.warn);
}
class FetchSummary {
final int stored;
final List<String> rejected;
const FetchSummary(this.stored, this.rejected);
}
class OpenedRecord {
final String id;
final Uint8List? sender;
final int? time;
final Map<String, String> fields;
final String body;
final String? error;
const OpenedRecord(this.id,
{this.sender, this.time, this.fields = const {}, this.body = "", this.error});
String get subject => error == null ? (fields["Subject"] ?? "") : "";
}
class SmolClient {
final SmolStore store;
/// Trust warnings (§4/§8) the UI must not let the user miss; the app wires
/// this to a persistent banner in app_widget.dart.
void Function(String message)? onWarning;
SmolClient(this.store);
void _warn(String message) => onWarning?.call(message);
// Opening an envelope costs an X25519 agreement and an Ed25519
// verification, and an envelope's plaintext never changes — so the result
// is kept. Failures are cached too, so one bad message is not retried on
// every render. Rotation clears it, since the key set grew.
final _openedCache = <String, OpenedRecord>{};
SmolIdentity? get identity => store.identity();
SmolAddress? accountAddress() {
final account = store.account();
if (account == null) return null;
final suffix = account.port == defaultPort ? "" : ":${account.port}";
return parseAddress("${account.user}@${account.host}$suffix");
}
// §4: registration and fetching demand a pinned key; sending to a recipient
// whose key we already hold tolerates an unpinned server.
Future<OpenedSession> connect(SmolAddress addr,
{required bool requirePin}) async {
final pinned = store.serverPin(addr.host);
if (requirePin && pinned == null) {
throw SmolError("no pinned key for ${addr.host}. Obtain it from the "
"operator through a trusted channel, then pin it in settings.");
}
final wire = await TcpWire.connect(addr.host, addr.port);
try {
final opened = await openSession(wire, addr.host, pinned: pinned);
if (pinned == null) {
_warn("${addr.host} is not pinned; its key is "
"${b32encode(opened.serverStatic)}.\n"
"RESOLVE results from this session are UNVERIFIED (SPEC.md §8).");
}
return opened;
} catch (_) {
wire.close();
rethrow;
}
}
// --- identity setup ------------------------------------------------------------
SmolIdentity createIdentity() {
final fresh = newIdentity();
store.setIdentity(fresh.seed);
return fresh;
}
SmolIdentity restoreIdentity(String seedHex) {
Uint8List seed;
try {
seed = unhex(seedHex.trim());
} on Exception {
throw const SmolError("seed must be 64 hex characters");
}
if (seed.length != keyLen) {
throw SmolError("seed is ${seed.length} bytes, expected $keyLen");
}
final restored = identityFromSeed(seed);
store.setIdentity(seed);
return restored;
}
void pinServer(String host, String keyB32) {
final key = b32decode(keyB32);
if (key.length != keyLen) {
throw SmolError("server key is ${key.length} bytes, expected $keyLen");
}
store.pinServer(host.trim().toLowerCase(), key);
}
Future<void> registerAccount(String addressText, {String token = ""}) async {
final me = identity;
if (me == null) throw const SmolError("no identity yet");
final addr = parseAddress(addressText);
final opened = await connect(addr, requirePin: true);
try {
await registerOp(opened.session, addr.user, me,
RegisterOptions(token: token));
} finally {
opened.session.wire.close();
}
store.setAccount(addr);
}
/// Restoring a seed brings back the identity, not the memory of what
/// address a *different device* registered it under — "account" is
/// local-only state, never asked of the server. This binds it without
/// REGISTER: RESOLVE the address and require it name this exact key, so a
/// typo or someone else's address cannot misfile fetch and Reply-To.
Future<SmolAddress> recallAccount(String addressText) async {
final me = identity;
if (me == null) throw const SmolError("no identity yet");
final addr = parseAddress(addressText);
final opened = await connect(addr, requirePin: true);
Uint8List current;
try {
current = (await resolveOp(opened.session, addr.user)).identity;
} finally {
opened.session.wire.close();
}
if (!timingSafeEqual(current, me.publicKey)) {
throw SmolError(
"${addr.short} resolves to a different key — not this identity");
}
store.setAccount(addr);
return addr;
}
// --- fetch ----------------------------------------------------------------------
Future<FetchSummary> fetch() async {
final me = identity;
final addr = accountAddress();
if (me == null) throw const SmolError("no identity yet");
if (addr == null) {
throw const SmolError("not registered; register an address first");
}
var stored = 0;
final rejected = <String>[];
final opened = await connect(addr, requirePin: true);
try {
await authenticate(opened.session, opened.handshakeHash, addr.user, me);
while (true) {
final records = await fetchOp(opened.session);
if (records.isEmpty) break;
final acked = <Uint8List>[];
for (final record in records) {
try {
if (!timingSafeEqual(messageId(record.envelope), record.id)) {
throw const SmolError("id does not match the envelope");
}
unseal(store.identities(), record.envelope);
} on SmolError catch (err) {
// Left on the server rather than destroyed, so a client-side bug
// cannot lose mail.
rejected.add("${hex(record.id)}: ${err.message}");
continue;
}
final fresh = await store.storeIfNew("inbox", MailRecord(hex(record.id), record.envelope,
receivedAt: record.receivedAt));
if (fresh != null) stored++;
acked.add(record.id);
}
if (acked.isEmpty) break;
await deleteOp(opened.session, acked);
}
} finally {
opened.session.wire.close();
}
return FetchSummary(stored, rejected);
}
// --- compose and send -----------------------------------------------------------
// Prefer a key we already trust; fall back to RESOLVE with trust on first
// use.
Future<Uint8List> resolveRecipient(SmolAddress addr) async {
if (addr.identity != null) {
store.saveContact(addr.short, addr.identity!, true);
return addr.identity!;
}
final known = store.contact(addr.short);
if (known != null) return known.key;
final opened = await connect(addr, requirePin: false);
Uint8List current;
try {
current = (await resolveOp(opened.session, addr.user)).identity;
} finally {
opened.session.wire.close();
}
store.saveContact(addr.short, current, false);
return current;
}
Future<String> send(String toText, String subject, String body,
{String? replyTo, bool anonymous = false}) async {
final me = identity;
if (me == null) throw const SmolError("no identity yet");
final addr = parseAddress(toText);
final recipient = await resolveRecipient(addr);
final account = accountAddress();
final fields = <String, String>{"Subject": subject, "In-Reply-To": replyTo ?? ""};
// A signed Reply-To lets a first-time recipient name and answer us
// (§5.5 allows unknown keys); "anonymous" omits it.
if (account != null && !anonymous) {
fields["Reply-To"] = account.uri(me.publicKey);
}
final bodyBytes = utf8Bytes(buildFrontmatter(
fields, "${body.replaceFirst(RegExp(r"\s+$"), "")}\n"));
final envelope = seal(me, recipient, bodyBytes);
final opened = await connect(addr, requirePin: false);
try {
await sendOp(opened.session, envelope);
} finally {
opened.session.wire.close();
}
// §5.6: the ephemeral is gone, so keep a copy sealed to ourselves.
await store.storeMessage("sent", MailRecord(hex(messageId(envelope)),
seal(me, me.publicKey, bodyBytes),
recipient: addr.short, sentAt: nowSeconds()));
return addr.short;
}
// --- reading -----------------------------------------------------------------
// What is known about a sender changes as the user binds addresses to keys,
// so this layer sits over the cached envelope and is recomputed per call —
// it is a map lookup, not crypto.
OpenedRecord describe(MailRecord row) {
final opened = _openEnvelope(row);
if (opened.error != null) return opened;
return OpenedRecord(
row.id,
sender: opened.sender,
time: opened.time,
fields: opened.fields,
body: opened.body,
);
}
OpenedRecord _openEnvelope(MailRecord row) {
var entry = _openedCache[row.id];
if (entry == null) {
try {
final opened = unseal(store.identities(), row.envelope);
final parsed = parseFrontmatter(utf8.decode(opened.body, allowMalformed: true));
entry = OpenedRecord(row.id,
sender: opened.sender,
time: opened.time,
fields: parsed.fields,
body: parsed.body);
} on SmolError catch (err) {
entry = OpenedRecord(row.id, error: err.message);
}
_openedCache[row.id] = entry;
}
return entry;
}
/// The Reply-To address carried inside the message, but only when it is a
/// full smol:// URI whose key matches the signer (§5.7); anything else is
/// ordinary text.
SmolAddress? replyAddress(OpenedRecord opened) {
final claim = opened.fields["Reply-To"];
if (claim == null || opened.sender == null) return null;
try {
final parsed = parseAddress(claim);
if (parsed.identity != null &&
timingSafeEqual(parsed.identity!, opened.sender!)) {
return parsed;
}
} on SmolError {
// malformed claim: display, never bind
}
return null;
}
/// Bind a user-supplied address to the key that signed a message. A smol://
/// address carries its own key (verified); a short address is resolved and
/// the result kept on first use. Anything that binds a different key is
/// refused.
Future<void> nameSender(String text, Uint8List senderKey) async {
final addr = parseAddress(text.trim());
Uint8List key;
var verified = true;
if (addr.identity == null) {
final opened = await connect(addr, requirePin: false);
try {
key = (await resolveOp(opened.session, addr.user)).identity;
} finally {
opened.session.wire.close();
}
verified = false; // trust on first use, as with any RESOLVE
} else {
key = addr.identity!;
}
if (!timingSafeEqual(key, senderKey)) {
throw const SmolError(
"that address carries a different key than this message's sender");
}
store.saveContact(addr.short, senderKey, verified);
}
/// A signed Reply-To is the sender's own claim, so it saves as verified —
/// but never over an address already pinned to a different key (§8: a key
/// change without a rotation chain needs out-of-band confirmation).
Future<void> saveReplyAddress(SmolAddress addr, Uint8List senderKey) async {
final existing = store.contact(addr.short);
if (existing != null && !timingSafeEqual(existing.key, senderKey)) {
throw SmolError("${addr.short} is already known with a different key — "
"verify out of band before replying");
}
store.saveContact(addr.short, senderKey, true);
}
// Re-resolve a contact and apply §8: a valid rotation chain is accepted and
// surfaced; anything else requires out-of-band verification.
Future<RefreshOutcome> refreshContact(String address) async {
final addr = parseAddress(address);
if (addr.identity != null) {
throw const SmolError("that address already carries a key; use import instead");
}
final known = store.contact(addr.short);
final opened = await connect(addr, requirePin: false);
Resolved resolved;
try {
resolved = await resolveOp(opened.session, addr.user);
} finally {
opened.session.wire.close();
}
if (known == null) {
store.saveContact(addr.short, resolved.identity, false);
return RefreshOutcome(
"${addr.short} pinned (trust on first use"
"${opened.pinned ? "" : ", UNVERIFIED server"})",
!opened.pinned);
}
if (timingSafeEqual(known.key, resolved.identity)) {
return RefreshOutcome("${addr.short}: key unchanged", false);
}
if (walkChain(known.key, resolved.identity, resolved.chain)) {
store.saveContact(addr.short, resolved.identity, known.verified);
return RefreshOutcome(
"${addr.short} rotated its key; a signed chain confirms it.\n"
"now ${b32encode(resolved.identity)}",
true);
}
return RefreshOutcome(
"${addr.short} presents a different key with no valid rotation chain.\n"
"Verify out of band, then import the new smol:// address.",
true);
}
// Bind a smol:// address to the key it carries (§8's strong path); the
// displaced key, if any, lands in the contact's history.
void importContact(String text) {
final addr = parseAddress(text.trim());
if (addr.identity == null) {
throw const SmolError("import needs a smol:// address carrying a key");
}
store.saveContact(addr.short, addr.identity!, true);
}
// --- rotation -----------------------------------------------------------------
// §7: rotate to a fresh seed and rebind the account with a signed
// certificate. The old seed is kept by the store, since mail sealed to it
// stays readable with nothing else.
Future<SmolIdentity> rotateIdentity() async {
final me = identity;
final addr = accountAddress();
if (me == null || addr == null) {
throw const SmolError("rotate needs a registered account");
}
final fresh = newIdentity();
final cert = makeCert(me, fresh.seed);
final opened = await connect(addr, requirePin: true);
try {
await registerOp(opened.session, addr.user, fresh,
RegisterOptions(cert: cert));
} finally {
opened.session.wire.close();
}
store.rotateIdentity(fresh.seed);
_openedCache.clear();
return fresh;
}
// A full wipe: every secret and every stored envelope. The UI must confirm.
Future<void> wipe() async {
await store.wipe();
_openedCache.clear();
}
}

26
lib/smol/config.dart Normal file
View file

@ -0,0 +1,26 @@
import "package:smol_mail/smol/proto.dart";
import "package:smol_mail/smol/errors.dart";
import "package:smol_mail/smol/store.dart";
// Deploy-time configuration, empty by default (mirrors gsmol's config.js).
// A release may bake in a server key with:
// flutter build apk --dart-define=SMOL_PRESET_SERVER=example.org \
// --dart-define=SMOL_PRESET_SERVER_KEY=base32key
// It only makes sense when whoever ships this app and whoever runs that
// smolmaild are the same trusted party: the value arrives with the app
// itself, which is the trusted channel SPEC.md §4 asks a pin to come from.
// This only seeds the first run — once written it is an ordinary pin,
// removable in settings like any other, and never overwrites a host the user
// (or a previous install) already pinned.
const _presetHost = String.fromEnvironment("SMOL_PRESET_SERVER");
const _presetKey = String.fromEnvironment("SMOL_PRESET_SERVER_KEY");
void applyPresetServer(SmolStore store) {
if (_presetHost.isEmpty || _presetKey.isEmpty) return;
if (store.serverPin(_presetHost) != null) return;
try {
store.pinServer(_presetHost, b32decode(_presetKey));
} on SmolError {
// malformed preset: leave unpinned rather than block boot
}
}

435
lib/smol/crypto.dart Normal file
View file

@ -0,0 +1,435 @@
// Smol Mail primitives (SPEC.md §1): SHA-2, HMAC/HKDF-SHA256, ChaCha20-Poly1305,
// X25519, Ed25519, and the §2 key conversions between the two curves. Pure
// Dart rather than PointyCastle so the byte-exact vectors from the reference
// client (test/vectors.json) can pin every operation.
import "dart:convert";
import "dart:math";
import "dart:typed_data";
import "package:crypto/crypto.dart" as hashes;
import "package:smol_mail/smol/errors.dart";
// --- bytes --------------------------------------------------------------------
Uint8List concat(List<List<int>> parts) {
final out = Uint8List(parts.fold(0, (n, p) => n + p.length));
var off = 0;
for (final p in parts) {
out.setRange(off, off + p.length, p);
off += p.length;
}
return out;
}
Uint8List utf8Bytes(String text) => Uint8List.fromList(utf8.encode(text));
String hex(List<int> bytes) =>
bytes.map((b) => b.toRadixString(16).padLeft(2, "0")).join();
Uint8List unhex(String text) {
if (text.length.isOdd) throw ArgumentError("odd-length hex string: $text");
final out = Uint8List(text.length ~/ 2);
for (var i = 0; i < out.length; i++) {
out[i] = int.parse(text.substring(i * 2, i * 2 + 2), radix: 16);
}
return out;
}
BigInt leBytesToBigInt(Uint8List bytes) {
var n = BigInt.zero;
for (var i = bytes.length - 1; i >= 0; i--) {
n = (n << 8) | BigInt.from(bytes[i]);
}
return n;
}
Uint8List bigIntToLeBytes(BigInt value, int length) {
final out = Uint8List(length);
var v = value;
for (var i = 0; i < length; i++) {
out[i] = (v & BigInt.from(0xff)).toInt();
v >>= 8;
}
return out;
}
Uint8List randomBytes(int n) {
final out = Uint8List(n);
final rng = Random.secure();
for (var i = 0; i < n; i++) {
out[i] = rng.nextInt(256);
}
return out;
}
bool timingSafeEqual(List<int> a, List<int> b) {
if (a.length != b.length) return false;
var diff = 0;
for (var i = 0; i < a.length; i++) {
diff |= a[i] ^ b[i];
}
return diff == 0;
}
// --- SHA-256 / SHA-512 / HMAC-SHA256 / HKDF (RFC 2104, RFC 5869) ---------------
Uint8List sha256(List<int> message) =>
Uint8List.fromList(hashes.sha256.convert(message).bytes);
Uint8List sha512(List<int> message) =>
Uint8List.fromList(hashes.sha512.convert(message).bytes);
Uint8List hmacSha256(List<int> key, List<int> message) =>
Uint8List.fromList(hashes.Hmac(hashes.sha256, key).convert(message).bytes);
Uint8List hkdfSha256(List<int> ikm, List<int> salt, List<int> info,
[int length = 32]) {
final prk = hmacSha256(salt, ikm);
var out = <int>[];
var block = <int>[];
var counter = 1;
while (out.length < length) {
block = hmacSha256(prk, concat([block, info, [counter]]));
out.addAll(block);
counter++;
}
return Uint8List.fromList(out.sublist(0, length));
}
// --- ChaCha20-Poly1305 AEAD (RFC 8439) -----------------------------------------
const _mask32 = 0xFFFFFFFF;
int _rotl32(int x, int n) => ((x << n) | (x >>> (32 - n))) & _mask32;
Uint8List _chachaBlock(Uint8List key, int counter, Uint8List nonce) {
final state = Uint32List(16);
state.setAll(0, [0x61707865, 0x3320646e, 0x79622d32, 0x6b206574]);
final kview = ByteData.view(key.buffer, key.offsetInBytes, key.length);
for (var i = 0; i < 8; i++) {
state[4 + i] = kview.getUint32(i * 4, Endian.little);
}
state[12] = counter & _mask32;
final nview = ByteData.view(nonce.buffer, nonce.offsetInBytes, nonce.length);
for (var i = 0; i < 3; i++) {
state[13 + i] = nview.getUint32(i * 4, Endian.little);
}
final x = Uint32List.fromList(state);
void qr(int a, int b, int c, int d) {
x[a] = (x[a] + x[b]) & _mask32;
x[d] = _rotl32(x[d] ^ x[a], 16);
x[c] = (x[c] + x[d]) & _mask32;
x[b] = _rotl32(x[b] ^ x[c], 12);
x[a] = (x[a] + x[b]) & _mask32;
x[d] = _rotl32(x[d] ^ x[a], 8);
x[c] = (x[c] + x[d]) & _mask32;
x[b] = _rotl32(x[b] ^ x[c], 7);
}
for (var i = 0; i < 10; i++) {
qr(0, 4, 8, 12);
qr(1, 5, 9, 13);
qr(2, 6, 10, 14);
qr(3, 7, 11, 15);
qr(0, 5, 10, 15);
qr(1, 6, 11, 12);
qr(2, 7, 8, 13);
qr(3, 4, 9, 14);
}
final out = Uint8List(64);
final view = ByteData.view(out.buffer);
for (var i = 0; i < 16; i++) {
view.setUint32(i * 4, (x[i] + state[i]) & _mask32, Endian.little);
}
return out;
}
Uint8List _chacha20Xor(Uint8List key, int counter, Uint8List nonce, Uint8List data) {
final out = Uint8List(data.length);
for (var off = 0; off < data.length; off += 64) {
final stream = _chachaBlock(key, counter + off ~/ 64, nonce);
final n = min(64, data.length - off);
for (var i = 0; i < n; i++) {
out[off + i] = data[off + i] ^ stream[i];
}
}
return out;
}
// Poly1305 over BigInt; correctness over speed, messages here stay small.
Uint8List _poly1305(Uint8List key, List<int> message) {
final p = (BigInt.one << 130) - BigInt.from(5);
final r = leBytesToBigInt(key.sublist(0, 16)) &
BigInt.parse("0x0ffffffc0ffffffc0ffffffc0fffffff");
final s = leBytesToBigInt(key.sublist(16, 32));
var acc = BigInt.zero;
for (var off = 0; off < message.length; off += 16) {
final block = message.sublist(off, min(off + 16, message.length));
acc = (acc + leBytesToBigInt(Uint8List.fromList(block)) +
(BigInt.one << (8 * block.length))) *
r %
p;
}
return bigIntToLeBytes((acc + s) & ((BigInt.one << 128) - BigInt.one), 16);
}
Uint8List _pad16(int n) => Uint8List((16 - (n % 16)) % 16);
Uint8List _le64(int n) => bigIntToLeBytes(BigInt.from(n), 8);
Uint8List aeadEncrypt(Uint8List key, Uint8List nonce, Uint8List plaintext,
Uint8List aad) {
final polyKey = _chachaBlock(key, 0, nonce).sublist(0, 32);
final ciphertext = _chacha20Xor(key, 1, nonce, plaintext);
final mac = _poly1305(polyKey,
concat([aad, _pad16(aad.length), ciphertext, _pad16(ciphertext.length), _le64(aad.length), _le64(ciphertext.length)]));
return concat([ciphertext, mac]);
}
Uint8List aeadDecrypt(Uint8List key, Uint8List nonce, Uint8List sealed, Uint8List aad) {
if (sealed.length < 16) {
throw const SmolError("ciphertext shorter than the Poly1305 tag");
}
final ciphertext = sealed.sublist(0, sealed.length - 16);
final polyKey = _chachaBlock(key, 0, nonce).sublist(0, 32);
final expect = _poly1305(polyKey,
concat([aad, _pad16(aad.length), ciphertext, _pad16(ciphertext.length), _le64(aad.length), _le64(ciphertext.length)]));
if (!timingSafeEqual(expect, sealed.sublist(sealed.length - 16))) {
throw const SmolError("decryption failed: bad Poly1305 tag");
}
return _chacha20Xor(key, 1, nonce, ciphertext);
}
// --- X25519 (RFC 7748) ---------------------------------------------------------
final BigInt _p = (BigInt.one << 255) - BigInt.from(19);
final BigInt _mask255 = (BigInt.one << 255) - BigInt.one;
BigInt _mod(BigInt value, [BigInt? p]) {
final m = p ?? _p;
return ((value % m) + m) % m;
}
BigInt _powMod(BigInt base, BigInt exponent, [BigInt? p]) {
final m = p ?? _p;
var out = BigInt.one;
base = _mod(base, m);
while (exponent > BigInt.zero) {
if (exponent & BigInt.one == BigInt.one) out = out * base % m;
base = base * base % m;
exponent >>= 1;
}
return out;
}
Uint8List clampScalar(Uint8List scalar) {
final k = Uint8List.fromList(scalar);
k[0] &= 248;
k[31] &= 127;
k[31] |= 64;
return k;
}
BigInt _x25519Raw(Uint8List scalar, Uint8List u) {
final k = leBytesToBigInt(clampScalar(scalar));
final x1 = leBytesToBigInt(u) & _mask255;
const a24 = 121665;
var x2 = BigInt.one, z2 = BigInt.zero, x3 = x1, z3 = BigInt.one;
var swap = BigInt.zero;
for (var t = 254; t >= 0; t--) {
final kt = (k >> t) & BigInt.one;
swap ^= kt;
if (swap == BigInt.one) {
var tmp = x2;
x2 = x3;
x3 = tmp;
tmp = z2;
z2 = z3;
z3 = tmp;
}
swap = kt;
final a = _mod(x2 + z2), aa = a * a % _p;
final b = _mod(x2 - z2), bb = b * b % _p;
final e = _mod(aa - bb);
final c = _mod(x3 + z3), d = _mod(x3 - z3);
final da = d * a % _p, cb = c * b % _p;
final sum = _mod(da + cb), diff = _mod(da - cb);
x3 = sum * sum % _p;
z3 = x1 * diff * diff % _p;
x2 = aa * bb % _p;
z2 = e * _mod(aa + BigInt.from(a24) * e) % _p;
}
if (swap == BigInt.one) {
var tmp = x2;
x2 = x3;
x3 = tmp;
tmp = z2;
z2 = z3;
z3 = tmp;
}
return x2 * _powMod(z2, _p - BigInt.two) % _p;
}
// §2's low-order rejection: a clamped scalar is a multiple of 8, so any
// low-order peer point yields an all-zero shared secret — rejecting the zero
// output rejects all of them.
Uint8List x25519(Uint8List scalar, Uint8List peerPublic) {
final shared = bigIntToLeBytes(_x25519Raw(scalar, peerPublic), 32);
if (shared.every((b) => b == 0)) {
throw const SmolError("rejected low-order key agreement point");
}
return shared;
}
Uint8List x25519Base(Uint8List scalar) => bigIntToLeBytes(
_x25519Raw(scalar, unhex("0900000000000000000000000000000000000000000000000000000000000000")),
32);
// --- Ed25519 (RFC 8032) --------------------------------------------------------
final BigInt _l = (BigInt.one << 252) +
BigInt.parse("27742317777372353535851937790883648493");
final BigInt _d = _mod(-BigInt.from(121665) * _powMod(BigInt.from(121666), _p - BigInt.two));
final _Point _b = _Point.fromAffine(
BigInt.parse(
"15112221349535400772501151409588531511454012693041857206046113283949847762202"),
_mod(BigInt.from(4) * _powMod(BigInt.from(5), _p - BigInt.two)));
class _Point {
final BigInt x, y, z, t;
const _Point(this.x, this.y, this.z, this.t);
_Point.fromAffine(BigInt x, BigInt y)
: this(x, y, BigInt.one, _mod(x * y));
}
final _Point _identity = _Point(
BigInt.zero, BigInt.one, BigInt.one, BigInt.zero);
_Point _pointAdd(_Point p, _Point q) {
final a = _mod(p.y - p.x) * _mod(q.y - q.x) % _p;
final b = _mod(p.y + p.x) * _mod(q.y + q.x) % _p;
final c = BigInt.two * p.t * q.t % _p * _d % _p;
final d = BigInt.two * p.z * q.z % _p;
final e = _mod(b - a), f = _mod(d - c), g = _mod(d + c);
final h = b + a;
return _Point(e * f % _p, g * h % _p, f * g % _p, e * h % _p);
}
_Point _pointDouble(_Point p) {
final a = p.x * p.x % _p;
final b = p.y * p.y % _p;
final c = BigInt.two * p.z * p.z % _p;
final d = _p - a; // a = -1 on this curve, so d = -A
final e = _mod(_mod(p.x + p.y) * _mod(p.x + p.y) - a - b);
final g = _mod(d + b);
final f = _mod(g - c);
final h = _mod(d - b);
return _Point(e * f % _p, g * h % _p, f * g % _p, e * h % _p);
}
_Point _scalarMult(BigInt scalar, _Point point) {
var result = _identity;
for (var t = 254; t >= 0; t--) {
result = _pointDouble(result);
if ((scalar >> t) & BigInt.one == BigInt.one) result = _pointAdd(result, point);
}
return result;
}
Uint8List _encodePoint(_Point p) {
final zInv = _powMod(p.z, _p - BigInt.two);
final x = p.x * zInv % _p, y = p.y * zInv % _p;
final out = bigIntToLeBytes(y, 32);
out[31] |= (x & BigInt.one).toInt() << 7;
return out;
}
_Point _decodePoint(Uint8List bytes) {
if (bytes.length != 32) {
throw const SmolError("Ed25519 public key must be 32 bytes");
}
final sign = bytes[31] >> 7;
final y = leBytesToBigInt(bytes) & _mask255;
if (y >= _p) {
throw const SmolError("non-canonical Ed25519 public key");
}
final u = _mod(y * y - BigInt.one), v = _mod(_d * y * y + BigInt.one);
final v2 = v * v % _p, v3 = v2 * v % _p, v4 = v2 * v2 % _p;
var x = u * v3 % _p * _powMod(u * v4 % _p * v3 % _p, (_p - BigInt.from(5)) ~/ BigInt.from(8)) % _p;
if (_mod(v * x % _p * x) != u) {
if (_mod(v * x % _p * x) == _mod(-u)) {
x = x * _powMod(BigInt.two, (_p - BigInt.one) ~/ BigInt.from(4)) % _p;
} else {
throw const SmolError("not a point on the Ed25519 curve");
}
}
if (x == BigInt.zero && sign == 1) {
throw const SmolError("invalid sign bit on x = 0");
}
if ((x & BigInt.one).toInt() != sign) x = _p - x;
return _Point.fromAffine(x, y);
}
BigInt _seedToScalar(Uint8List seed) {
final h = sha512(seed);
return leBytesToBigInt(clampScalar(h.sublist(0, 32)));
}
Uint8List ed25519PublicKey(Uint8List seed) {
if (seed.length != 32) {
throw const SmolError("identity seed must be 32 bytes");
}
return _encodePoint(_scalarMult(_seedToScalar(seed), _Point.fromAffine(_b.x, _b.y)));
}
Uint8List ed25519Sign(Uint8List seed, List<int> message) {
final h = sha512(seed);
final a = leBytesToBigInt(clampScalar(h.sublist(0, 32)));
final publicKey =
_encodePoint(_scalarMult(a, _Point.fromAffine(_b.x, _b.y)));
final r = leBytesToBigInt(sha512(concat([h.sublist(32), message]))) % _l;
final rEnc = _encodePoint(_scalarMult(r, _Point.fromAffine(_b.x, _b.y)));
final k = leBytesToBigInt(sha512(concat([rEnc, publicKey, message]))) % _l;
return concat([rEnc, bigIntToLeBytes((r + k * a) % _l, 32)]);
}
bool ed25519Verify(Uint8List publicKey, List<int> message, Uint8List signature) {
if (signature.length != 64) return false;
try {
final decodedPk = _decodePoint(publicKey);
final decodedR = _decodePoint(signature.sublist(0, 32));
final a = _Point.fromAffine(decodedPk.x, decodedPk.y);
final r = _Point.fromAffine(decodedR.x, decodedR.y);
final s = leBytesToBigInt(signature.sublist(32, 64));
if (s >= _l) return false;
final k = leBytesToBigInt(sha512(concat([signature.sublist(0, 32), publicKey, message]))) % _l;
final lhs = _scalarMult(s, _Point.fromAffine(_b.x, _b.y));
final rhs = _pointAdd(_scalarMult(k, a), r);
return lhs.x * rhs.z % _p == rhs.x * lhs.z % _p &&
lhs.y * rhs.z % _p == rhs.y * lhs.z % _p;
} on Exception {
return false;
}
}
// --- §2 conversions between the identity key and X25519 -------------------------
Uint8List ed25519ToX25519(Uint8List publicKey) {
final y = leBytesToBigInt(publicKey) & _mask255;
if (y >= _p) {
throw const SmolError("non-canonical Ed25519 public key");
}
if (_mod(BigInt.one - y) == BigInt.zero) {
throw const SmolError("identity element has no X25519 image");
}
return bigIntToLeBytes(
_mod(BigInt.one + y) * _powMod(BigInt.one - y, _p - BigInt.two) % _p, 32);
}
Uint8List ed25519SeedToX25519(Uint8List seed) =>
clampScalar(sha512(seed).sublist(0, 32));

10
lib/smol/errors.dart Normal file
View file

@ -0,0 +1,10 @@
/// Raised for protocol-level failures (malformed envelopes, bad statuses,
/// unpinned servers); programmer errors keep throwing normally.
class SmolError implements Exception {
final String message;
const SmolError(this.message);
@override
String toString() => message;
}

118
lib/smol/noise.dart Normal file
View file

@ -0,0 +1,118 @@
// Noise_NX_25519_ChaChaPoly_SHA256 initiator (SPEC.md §4), rev-34 semantics.
// The initiator is anonymous; the responder's static key arrives encrypted in
// message two, which is what server pinning checks.
import "dart:typed_data";
import "package:smol_mail/smol/crypto.dart";
import "package:smol_mail/smol/errors.dart";
const String _protocol = "Noise_NX_25519_ChaChaPoly_SHA256"; // exactly 32 bytes, so h = name
Uint8List _prologue() => utf8Bytes("smolmail/1");
// Noise's ChaChaPoly nonce: 4 zero bytes then the counter as u64 LE.
Uint8List _nonce(int n) {
final out = Uint8List(12);
ByteData.view(out.buffer).setUint64(4, n, Endian.little);
return out;
}
/// One direction of the post-handshake transport; tests substitute a
/// passthrough so framing guards can be exercised without crypto.
abstract class SessionCipher {
Uint8List encrypt(Uint8List plaintext);
Uint8List decrypt(Uint8List sealed);
}
// The key is unique per session, so the counter starting at zero is safe.
class CipherState implements SessionCipher {
final Uint8List key;
int counter = 0;
CipherState(this.key);
@override
Uint8List encrypt(Uint8List plaintext) {
final sealed = aeadEncrypt(key, _nonce(counter), plaintext, Uint8List(0));
counter++;
return sealed;
}
@override
Uint8List decrypt(Uint8List sealed) {
final plaintext = aeadDecrypt(key, _nonce(counter), sealed, Uint8List(0));
counter++;
return plaintext;
}
}
class NxResult {
final CipherState send, recv;
final Uint8List serverStatic;
final Uint8List handshakeHash;
const NxResult(this.send, this.recv, this.serverStatic, this.handshakeHash);
}
class NxInitiator {
late Uint8List h;
late Uint8List ck;
Uint8List? key;
late Uint8List esk;
late Uint8List epk;
NxInitiator() {
h = utf8Bytes(_protocol);
ck = Uint8List.fromList(h);
mixHash(_prologue());
}
void mixHash(Uint8List data) {
h = sha256(concat([h, data]));
}
void mixKey(Uint8List ikm) {
final okm = hkdfSha256(ikm, ck, Uint8List(0), 64);
ck = okm.sublist(0, 32);
key = okm.sublist(32);
}
// Message one is just our ephemeral public key. No key is set yet, so the
// empty payload travels in the clear — and is still mixed into h.
Uint8List writeMessage1([Uint8List? esk]) {
this.esk = esk ?? randomBytes(32);
epk = x25519Base(this.esk);
mixHash(epk);
mixHash(Uint8List(0));
return Uint8List.fromList(epk);
}
// Message two: e (plaintext), ee, then the responder's static and the
// (empty) payload as AEAD ciphertexts chained through h. Each MixKey
// restarts the nonce at zero.
NxResult readMessage2(Uint8List message) {
if (message.length != 32 + 48 + 16) {
throw SmolError("unexpected NX message length ${message.length}");
}
final re = message.sublist(0, 32);
mixHash(re);
mixKey(x25519(esk, re));
final serverStatic = decryptAndHash(message.sublist(32, 80));
mixKey(x25519(esk, serverStatic)); // es
final payload = decryptAndHash(message.sublist(80));
if (payload.isNotEmpty) throw const SmolError("unexpected payload in handshake");
final handshakeHash = h;
// Split(): two transport keys from the final chaining key, zero-length ikm
final okm = hkdfSha256(Uint8List(0), ck, Uint8List(0), 64);
return NxResult(CipherState(okm.sublist(0, 32)), CipherState(okm.sublist(32)),
serverStatic, handshakeHash);
}
Uint8List decryptAndHash(Uint8List sealed) {
final plaintext = aeadDecrypt(key!, _nonce(0), sealed, h);
mixHash(sealed);
return plaintext;
}
}

580
lib/smol/proto.dart Normal file
View file

@ -0,0 +1,580 @@
// Smol Mail protocol, version 1 (../smolmail SPEC.md): addresses, sealed and
// signed envelopes, body frontmatter, key rotation, and the framed request
// and response bodies of the five operations.
import "dart:math";
import "dart:typed_data";
import "package:smol_mail/smol/crypto.dart";
import "package:smol_mail/smol/errors.dart";
import "package:smol_mail/smol/noise.dart";
const defaultPort = 1961;
const keyLen = 32, sigLen = 64, certLen = 136, idLen = 16;
const maxFrame = 1 << 20, noisePayload = 65535 - 16, padTo = 1024;
const envelopeHeader = 69, payloadHeader = 45, maxChain = 16;
const _frontmatterMax = 4096, _frontmatterKeys = 64;
const opAuth = 0x00, opResolve = 0x01, opSend = 0x02, opFetch = 0x03,
opDelete = 0x04, opRegister = 0x05;
const _statusNames = {
0: "ok", 1: "malformed", 2: "bad version", 3: "unknown user",
4: "auth required", 5: "auth failed", 6: "quota exceeded", 7: "too large",
8: "rate limited", 9: "not permitted", 10: "internal error",
};
String statusName(int status) => _statusNames[status] ?? "$status";
final _label = (
auth: utf8Bytes("smolmail/1 auth"),
seal: utf8Bytes("smolmail/1 seal"),
msg: utf8Bytes("smolmail/1 msg"),
id: utf8Bytes("smolmail/1 id"),
rotate: utf8Bytes("smolmail/1 rotate"),
);
// --- encoding helpers ---------------------------------------------------------
const _b32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
String b32encode(List<int> bytes) {
var out = "";
var value = 0, bits = 0;
for (final b in bytes) {
value = (value << 8) | b;
bits += 8;
while (bits >= 5) {
bits -= 5;
out += _b32[(value >>> bits) & 31];
}
}
if (bits > 0) out += _b32[(value << (5 - bits)) & 31];
return out.toLowerCase();
}
Uint8List b32decode(String text) {
final out = <int>[];
var value = 0, bits = 0;
final clean = text.trim().toUpperCase().replaceAll(RegExp(r"=+$"), "");
for (final ch in clean.split("")) {
final idx = _b32.indexOf(ch);
if (idx < 0) throw SmolError("invalid base32 character '$ch'");
value = (value << 5) | idx;
bits += 5;
if (bits >= 8) {
bits -= 8;
out.add((value >>> bits) & 0xff);
}
}
return Uint8List.fromList(out);
}
// §3: the first 20 base32 characters of the identity, in groups of four.
String fingerprint(Uint8List identity) {
final s = b32encode(identity).substring(0, 20);
return RegExp(".{4}").allMatches(s).map((m) => m[0]).join(" ");
}
Uint8List u16be(int n) => Uint8List.fromList([(n >> 8) & 0xff, n & 0xff]);
Uint8List u32be(int n) => Uint8List.fromList(
[(n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff]);
// Dart 3.2's ByteData has no setBigInt, so write big-endian manually.
Uint8List i64be(BigInt n) {
final out = Uint8List(8);
for (var i = 0; i < 8; i++) {
out[i] = ((n >> (8 * (7 - i))) & BigInt.from(0xff)).toInt();
}
return out;
}
int nowSeconds() => DateTime.now().millisecondsSinceEpoch ~/ 1000;
// Fail-closed reader; every parse raises rather than reading past the end.
class Reader {
final Uint8List buf;
int pos = 0;
Reader(this.buf);
Uint8List take(int n) {
if (n < 0 || pos + n > buf.length) throw const SmolError("truncated message");
final out = buf.sublist(pos, pos + n);
pos += n;
return out;
}
int u8() => take(1)[0];
int u16() {
final b = take(2);
return (b[0] << 8) | b[1];
}
int u32() => ByteData.view(take(4).buffer).getUint32(0);
int i64() => ByteData.view(take(8).buffer).getInt64(0);
int get left => buf.length - pos;
}
// --- identity -----------------------------------------------------------------
// §2: an Ed25519 keypair with the X25519 agreement keys derived from it.
class SmolIdentity {
final Uint8List seed;
final Uint8List publicKey;
const SmolIdentity(this.seed, this.publicKey);
}
SmolIdentity identityFromSeed(Uint8List seed) {
if (seed.length != keyLen) {
throw const SmolError("identity seed must be $keyLen bytes");
}
return SmolIdentity(seed, ed25519PublicKey(seed));
}
SmolIdentity newIdentity() => identityFromSeed(randomBytes(keyLen));
// --- addressing (§3) -----------------------------------------------------------
final _address =
RegExp(r"^(?<user>[a-z0-9._-]{1,63})@(?<host>[^/:]+)(?::(?<port>\d+))?$");
class SmolAddress {
final String user;
final String host;
final int port;
/// The key carried by a `smol://` address; null for short addresses.
final Uint8List? identity;
const SmolAddress(this.user, this.host, this.port, this.identity);
String get short =>
"$user@$host${port == defaultPort ? "" : ":$port"}";
String uri(Uint8List key) =>
"smol://$user@$host${port == defaultPort ? "" : ":$port"}/${b32encode(key)}";
}
SmolAddress parseAddress(String text) {
text = text.trim();
Uint8List? identity;
if (text.startsWith("smol://")) {
final rest = text.substring("smol://".length);
final slash = rest.lastIndexOf("/");
if (slash < 0) throw SmolError("$text: smol:// address carries no key");
identity = b32decode(rest.substring(slash + 1));
if (identity.length != keyLen) {
throw SmolError(
"$text: key is ${identity.length} bytes, expected $keyLen");
}
text = rest.substring(0, slash);
}
final m = _address.firstMatch(text.toLowerCase());
if (m == null) throw SmolError("'$text' is not a valid address");
final user = m.namedGroup("user")!;
final host = m.namedGroup("host")!;
if ("._-".contains(user[0]) || "._-".contains(user[user.length - 1])) {
throw SmolError("$user may not begin or end with a separator");
}
final portText = m.namedGroup("port");
final port = portText != null ? int.parse(portText) : defaultPort;
return SmolAddress(user, host, port, identity);
}
// --- message format (§5) -------------------------------------------------------
Uint8List messageId(List<int> envelope) =>
sha256(concat([_label.id, envelope])).sublist(0, idLen);
class OpenedMessage {
final Uint8List sender;
final int time;
final Uint8List body;
final Uint8List id;
const OpenedMessage(this.sender, this.time, this.body, this.id);
}
/// Options for [seal]; [esk] and [pad] exist so tests can pin them, mirroring
/// the spec's fixed-ephemeral vectors.
class SealOptions {
final Uint8List? esk;
final bool pad;
const SealOptions({this.esk, this.pad = true});
}
// §5.2 and §5.3. The ephemeral key is thrown away after sealing, so the sender
// cannot decrypt what they sent.
Uint8List seal(SmolIdentity identity, Uint8List recipient, Uint8List body,
[int? when, SealOptions opts = const SealOptions()]) {
final esk = opts.esk ?? randomBytes(keyLen);
final epk = x25519Base(esk);
final key = hkdfSha256(x25519(esk, ed25519ToX25519(recipient)),
concat([epk, recipient]), _label.seal);
final header = concat([
Uint8List.fromList([1]),
identity.publicKey,
i64be(BigInt.from(when ?? nowSeconds())),
u32be(body.length),
]);
var plaintext = concat([
header,
body,
ed25519Sign(identity.seed, concat([_label.msg, recipient, epk, header, body])),
]);
if (opts.pad) {
plaintext = concat(
[plaintext, Uint8List((padTo - plaintext.length % padTo) % padTo)]);
}
final aad = concat([utf8Bytes("SMOL"), Uint8List.fromList([1]), recipient, epk]);
return concat([aad, aeadEncrypt(key, Uint8List(12), plaintext, aad)]);
}
// Inverse of seal(); throws unless the signature and the recipient both check
// out. [identities] may include retired keys, per §7.
OpenedMessage unseal(List<SmolIdentity> identities, Uint8List envelope) {
if (envelope.length < envelopeHeader + 16) {
throw const SmolError("envelope too short");
}
final magic = utf8Bytes("SMOL");
for (var i = 0; i < 4; i++) {
if (magic[i] != envelope[i]) {
throw const SmolError("not a Smol Mail envelope");
}
}
if (envelope[4] != 1) {
throw SmolError("unsupported envelope version ${envelope[4]}");
}
final to = envelope.sublist(5, 37), epk = envelope.sublist(37, 69);
final sealed = envelope.sublist(69);
SmolIdentity? me;
for (final i in identities) {
if (timingSafeEqual(i.publicKey, to)) {
me = i;
break;
}
}
if (me == null) {
throw SmolError(
"addressed to ${b32encode(to).substring(0, 16)}…, not one of our keys");
}
final key = hkdfSha256(
x25519(ed25519SeedToX25519(me.seed), epk), concat([epk, to]), _label.seal);
Uint8List plaintext;
try {
plaintext = aeadDecrypt(key, Uint8List(12), sealed, envelope.sublist(0, envelopeHeader));
} on SmolError {
throw const SmolError("decryption failed: wrong key or corrupt envelope");
}
final r = Reader(plaintext);
if (r.u8() != 1) throw const SmolError("unsupported payload version");
final sender = r.take(keyLen);
final when = r.i64();
final bodyLen = r.u32();
if (bodyLen > r.left) {
throw const SmolError("payload body length exceeds the payload");
}
final body = r.take(bodyLen);
final signature = r.take(sigLen); // trailing bytes are padding
if (!ed25519Verify(sender,
concat([_label.msg, to, epk, plaintext.sublist(0, payloadHeader), body]),
signature)) {
throw const SmolError("signature does not verify");
}
return OpenedMessage(sender, when, body, messageId(envelope));
}
// --- body frontmatter (§5.5) ---------------------------------------------------
final _fmKey = RegExp(r"^[A-Za-z0-9-]{1,64}$");
class Frontmatter {
final Map<String, String> fields;
final String body;
const Frontmatter(this.fields, this.body);
}
// A flat `Key: value` block, deliberately not YAML. Any malformed line
// invalidates the whole block, which is then returned as ordinary body text:
// frontmatter fails closed toward display, never toward silent discard.
Frontmatter parseFrontmatter(String text) {
if (!text.startsWith("---\n")) return Frontmatter(const {}, text);
final lines = text.split("\n");
final close = lines.indexOf("---", 1);
if (close < 0) return Frontmatter(const {}, text);
final block = lines.sublist(1, close);
final rest = lines.sublist(close + 1).join("\n");
var encoded = 0;
for (final line in block) {
encoded += utf8Bytes(line).length + 1;
}
if (block.length > _frontmatterKeys || encoded > _frontmatterMax) {
return Frontmatter(const {}, text);
}
final fields = <String, String>{};
for (final line in block) {
final colon = line.indexOf(":");
final head = colon < 0 ? "" : line.substring(0, colon);
if (colon < 0 || !_fmKey.hasMatch(head)) {
return Frontmatter(const {}, text);
}
// first occurrence wins
fields.putIfAbsent(head, () => line.substring(colon + 1).trim());
}
return Frontmatter(fields, rest);
}
// Emit a block only when needed, including to escape a body that genuinely
// begins with `---` (§5.5).
String buildFrontmatter(Map<String, String> fields, String body) {
final entries = fields.entries.where((e) => e.value.isNotEmpty).toList();
if (entries.isEmpty && !body.startsWith("---\n")) return body;
final block = entries.map((e) => "${e.key}: ${e.value}\n").join();
return "---\n$block---\n$body";
}
// --- key rotation (§7) ---------------------------------------------------------
Uint8List makeCert(SmolIdentity oldIdentity, Uint8List newSeed, [int? when]) {
final newPub = ed25519PublicKey(newSeed);
final time = i64be(BigInt.from(when ?? nowSeconds()));
return concat([
oldIdentity.publicKey,
newPub,
time,
ed25519Sign(oldIdentity.seed,
concat([_label.rotate, oldIdentity.publicKey, newPub, time])),
]);
}
// Accept a key change only when a signed chain leads from the key we hold to
// the one the server now returns (§7).
bool walkChain(Uint8List pinned, Uint8List current, List<Uint8List> chain) {
if (timingSafeEqual(pinned, current)) return true;
if (chain.isEmpty || chain.length > maxChain) return false;
var key = pinned;
var started = false;
for (final cert in chain) {
final old = cert.sublist(0, 32), next = cert.sublist(32, 64);
final when = cert.sublist(64, 72), sig = cert.sublist(72);
if (!started) {
if (!timingSafeEqual(old, key)) continue; // a link predating the key we hold
started = true;
} else if (!timingSafeEqual(old, key)) {
return false; // the chain is not continuous
}
if (!ed25519Verify(old, concat([_label.rotate, old, next, when]), sig)) {
return false;
}
key = next;
}
return started && timingSafeEqual(key, current);
}
// --- framing and operations (§4, §6) -------------------------------------------
/// An ordered byte pipe (TCP socket, or an in-memory queue in tests).
abstract class Wire {
void send(Uint8List bytes);
void close();
Future<Uint8List> readExact(int n);
}
// One Noise session: application frames split across u16-prefixed Noise
// messages, requests and responses as in §6.1.
class Session {
final Wire wire;
final SessionCipher send, recv;
Session(this.wire, this.send, this.recv);
Future<Uint8List> _readNoise() async {
final head = await wire.readExact(2);
final length = (head[0] << 8) | head[1];
if (length < 16) throw SmolError("server sent a $length-byte Noise message");
return recv.decrypt(await wire.readExact(length));
}
Future<Response> call(int op, [Uint8List? body]) async {
final payload = body ?? Uint8List(0);
final frame = concat([u32be(1 + payload.length), Uint8List.fromList([op]), payload]);
if (frame.length > maxFrame + 4) {
throw const SmolError("request exceeds the maximum frame size");
}
for (var off = 0; off < frame.length; off += noisePayload) {
final packet = send.encrypt(frame.sublist(off, min(off + noisePayload, frame.length)));
wire.send(concat([u16be(packet.length), packet]));
}
var length = -1;
var have = <int>[];
while (length < 0 || have.length < 4 + length) {
have.addAll(await _readNoise());
if (length < 0 && have.length >= 4) {
length = (have[0] << 24) | (have[1] << 16) | (have[2] << 8) | have[3];
// §6.1: the shortest response is a type byte and a status byte.
if (length < 2 || length > maxFrame) {
throw SmolError("server sent a frame of length $length");
}
}
}
final payloadOut = Uint8List.fromList(have.sublist(4, 4 + length));
// §6.1: a response reuses the request's type byte. A mismatch means the
// session desynchronised, which must not be mistaken for a status.
if (payloadOut[0] != op) {
throw SmolError(
"server answered op 0x${payloadOut[0].toRadixString(16)}, expected 0x${op.toRadixString(16)}");
}
return Response(payloadOut[1], payloadOut.sublist(2));
}
}
class Response {
final int status;
final Uint8List body;
const Response(this.status, this.body);
}
class OpenedSession {
final Session session;
final Uint8List serverStatic;
final bool pinned;
final Uint8List handshakeHash;
const OpenedSession(this.session, this.serverStatic, this.pinned, this.handshakeHash);
}
// Handshake plus §4 pinning. Returns the session, the server's static key as
// revealed by the handshake, and whether that key was already pinned.
Future<OpenedSession> openSession(Wire wire, String host,
{Uint8List? pinned}) async {
final nx = NxInitiator();
final m1 = nx.writeMessage1();
wire.send(concat([u16be(m1.length), m1]));
final head = await wire.readExact(2);
final result = nx.readMessage2(await wire.readExact((head[0] << 8) | head[1]));
if (pinned != null && !timingSafeEqual(pinned, result.serverStatic)) {
throw SmolError("$host presented a different key than the one pinned\n"
" pinned: ${b32encode(pinned)}\n"
" presented: ${b32encode(result.serverStatic)}");
}
return OpenedSession(
Session(wire, result.send, result.recv),
result.serverStatic,
pinned != null,
result.handshakeHash);
}
void expectOk(int status, String what) {
if (status != 0) {
throw SmolError("$what failed: ${statusName(status)} ($status)");
}
}
// §4 session authentication: sign the handshake hash, which binds the
// signature to this session's server ephemeral and cannot be replayed.
Future<void> authenticate(
Session session, Uint8List handshakeHash, String username, SmolIdentity identity) async {
final name = utf8Bytes(username);
if (name.length > 255) throw const SmolError("username too long");
final body = concat([
Uint8List.fromList([name.length]),
name,
identity.publicKey,
ed25519Sign(identity.seed, concat([_label.auth, handshakeHash])),
]);
expectOk((await session.call(opAuth, body)).status, "authentication");
}
class Resolved {
final Uint8List identity;
final List<Uint8List> chain;
const Resolved(this.identity, this.chain);
}
// RESOLVE, returning the current key and its rotation chain (§6.1).
Future<Resolved> resolveOp(Session session, String user) async {
final name = utf8Bytes(user);
if (name.length > 255) throw const SmolError("username too long");
final response =
await session.call(opResolve, concat([Uint8List.fromList([name.length]), name]));
expectOk(response.status, "resolving $user");
final r = Reader(response.body);
return Resolved(
r.take(keyLen),
List.generate(r.u8(), (_) => r.take(certLen)));
}
Future<Uint8List> sendOp(Session session, Uint8List envelope) async {
final response = await session.call(opSend, envelope);
expectOk(response.status, "sending");
return response.body.length == idLen
? response.body
: messageId(envelope);
}
class FetchedRecord {
final Uint8List id;
final int receivedAt;
final Uint8List envelope;
const FetchedRecord(this.id, this.receivedAt, this.envelope);
}
Future<List<FetchedRecord>> fetchOp(Session session) async {
final response = await session.call(opFetch);
expectOk(response.status, "fetching");
final r = Reader(response.body);
return List.generate(r.u16(), (_) {
final id = r.take(idLen);
final receivedAt = r.i64();
return FetchedRecord(id, receivedAt, r.take(r.u32()));
});
}
Future<int> deleteOp(Session session, List<Uint8List> ids) async {
if (ids.length > 0xffff) throw const SmolError("too many ids for one DELETE");
final body = concat([u16be(ids.length), ...ids]);
final response = await session.call(opDelete, body);
expectOk(response.status, "acknowledging");
return Reader(response.body).u16();
}
class RegisterOptions {
final String token;
final Uint8List? cert;
const RegisterOptions({this.token = "", this.cert});
}
Future<void> registerOp(
Session session, String username, SmolIdentity identity,
[RegisterOptions opts = const RegisterOptions()]) async {
final name = utf8Bytes(username);
final tokenBytes = utf8Bytes(opts.token);
final cert = opts.cert ?? Uint8List(0);
if (name.length > 255 || tokenBytes.length > 255 || cert.length > 255) {
throw const SmolError("REGISTER field too long");
}
final body = concat([
Uint8List.fromList([name.length]),
name,
identity.publicKey,
Uint8List.fromList([tokenBytes.length]),
tokenBytes,
Uint8List.fromList([cert.length]),
cert,
]);
expectOk((await session.call(opRegister, body)).status, "registering $username");
}

463
lib/smol/store.dart Normal file
View file

@ -0,0 +1,463 @@
// Device state: identity, pins, contacts and read markers in one JSON blob;
// sealed envelopes in a second Hive box, opened only on demand, so nothing at
// rest is plaintext (the seed excepted — the device's app storage is the trust
// boundary, like gsmol's browser profile).
import "dart:convert";
import "dart:typed_data";
import "package:hive_flutter/hive_flutter.dart";
import "package:smol_mail/smol/crypto.dart";
import "package:smol_mail/smol/errors.dart";
import "package:smol_mail/smol/proto.dart";
const _stateBox = "smol";
const _mailBox = "mail";
const _stateKey = "state";
class StoredAccount {
final String user;
final String host;
final int port;
const StoredAccount(this.user, this.host, this.port);
}
/// A key this contact replaced, per §7/§8 — the only local record that a
/// rotation happened, kept so the user can notice such changes.
class ContactHistoryEntry {
final Uint8List key;
final int until; // epoch ms of the displacement
const ContactHistoryEntry(this.key, this.until);
}
class StoredContact {
final Uint8List key;
final bool verified;
final List<ContactHistoryEntry> history;
const StoredContact(this.key, this.verified, [this.history = const []]);
}
class MailRecord {
final String id; // hex of the 16-byte message id
final Uint8List envelope;
final int? receivedAt;
final String? recipient; // sent copies only
final int? sentAt;
const MailRecord(this.id, this.envelope,
{this.receivedAt, this.recipient, this.sentAt});
}
class ImportSummary {
int pinsAdded = 0, pinsConflicted = 0, contactsAdded = 0,
contactsConflicted = 0, mailAdded = 0, malformed = 0;
@override
String toString() =>
"$mailAdded messages, $contactsAdded contacts ($contactsConflicted conflicted), "
"$pinsAdded server keys ($pinsConflicted conflicted), $malformed malformed";
}
class SmolStore {
final Box _state;
final Box _mail;
SmolStore(this._state, this._mail);
/// [stateBox]/[mailBox] exist so tests can hold several isolated stores
/// in one process; production always uses the defaults.
static Future<SmolStore> open(
{String stateBox = _stateBox, String mailBox = _mailBox}) async {
final state = await Hive.openBox(stateBox);
final mail = await Hive.openBox(mailBox);
return SmolStore(state, mail);
}
Map _load() {
final blob = _state.get(_stateKey);
return blob is Map ? blob : <String, dynamic>{};
}
void _update(Map Function(Map state) fn) {
final next = fn(_load());
_state.put(_stateKey, next);
}
// --- identity --------------------------------------------------------------
Uint8List? seed() {
final raw = _load()["seed"];
return raw == null ? null : unhex(raw as String);
}
/// The active identity, or null before the user creates or restores one.
SmolIdentity? identity() {
final s = seed();
return s == null ? null : identityFromSeed(s);
}
void setIdentity(Uint8List newSeed) {
if (seed() != null) {
throw const SmolIdentityExistsException();
}
_update((state) => state..["seed"] = hex(newSeed));
}
// Rotation (§7): the old seed is retained, since mail sealed to a
// superseded key is readable with nothing else.
void rotateIdentity(Uint8List newSeed) {
final old = seed();
if (old == null) throw const SmolNoIdentityException();
_update((state) {
final retired = (state["retired"] as List? ?? [])
..add({"seed": hex(old), "at": DateTime.now().millisecondsSinceEpoch});
state["retired"] = retired;
state["seed"] = hex(newSeed);
return state;
});
}
/// §7: seeds rotated away from are retained, since mail sealed to a
/// superseded key is readable with nothing else.
List<SmolIdentity> identities() {
final s = seed();
if (s == null) return const [];
final retired = (_load()["retired"] as List? ?? const [])
.whereType<Map>()
.map((entry) => identityFromSeed(unhex(entry["seed"] as String)));
return [identityFromSeed(s), ...retired];
}
// --- account and server pins -------------------------------------------------
StoredAccount? account() {
final a = _load()["account"];
if (a is! Map) return null;
return StoredAccount(
a["user"] as String, a["host"] as String, a["port"] as int);
}
void setAccount(SmolAddress address) {
_update((state) => state
..["account"] = {
"user": address.user,
"host": address.host,
"port": address.port,
});
}
Uint8List? serverPin(String host) {
final raw = ((_load()["servers"] as Map?) ?? {})[host];
return raw == null ? null : b32decode(raw as String);
}
void pinServer(String host, Uint8List key) {
_update((state) {
final servers = (state["servers"] as Map? ?? {}).cast<String, String>();
servers[host] = b32encode(key);
state["servers"] = servers;
return state;
});
}
void unpinServer(String host) {
_update((state) {
(state["servers"] as Map?)?.remove(host);
return state;
});
}
List<(String, Uint8List)> allPins() {
final servers = ((_load()["servers"] as Map?) ?? {}).cast<String, String>();
return [for (final e in servers.entries) (e.key, b32decode(e.value))];
}
// --- contacts ------------------------------------------------------------------
StoredContact? contact(String address) {
final c = ((_load()["contacts"] as Map?) ?? {})[address];
if (c is! Map) return null;
final history = ((c["history"] as List?) ?? const [])
.whereType<Map>()
.map((e) => ContactHistoryEntry(
b32decode(e["key"] as String), e["until"] as int))
.toList();
return StoredContact(b32decode(c["key"] as String),
c["verified"] as bool, history);
}
// A key that displaces another is kept in the history (§8): it is the
// only local record that the contact rotated. Re-saving the same key is
// not a rotation and must not add an entry.
void saveContact(String address, Uint8List key, bool verified) {
_update((state) {
final contacts =
(state["contacts"] as Map? ?? {}).cast<String, Map>();
final wanted = b32encode(key);
final previous = contacts[address];
final history = ((previous?["history"] as List?) ?? const [])
.whereType<Map>()
.toList();
if (previous != null && previous["key"] != wanted) {
history.add({
"key": previous["key"],
"until": DateTime.now().millisecondsSinceEpoch,
});
}
contacts[address] = {
"key": wanted,
"verified": verified,
"seenAt": DateTime.now().millisecondsSinceEpoch,
if (history.isNotEmpty) "history": history,
};
state["contacts"] = contacts;
return state;
});
}
String? addressForKey(Uint8List key) {
final contacts = ((_load()["contacts"] as Map?) ?? {}).cast<String, Map>();
final wanted = b32encode(key);
for (final entry in contacts.entries) {
if (entry.value["key"] == wanted) return entry.key;
}
return null;
}
List<(String, StoredContact)> allContacts() {
final contacts = ((_load()["contacts"] as Map?) ?? {}).cast<String, Map>();
return [for (final entry in contacts.entries) (entry.key, contact(entry.key)!)];
}
// --- read markers ---------------------------------------------------------------
void markRead(String idHex) {
_update((state) {
final read = (state["read"] as Map? ?? {}).cast<String, bool>();
read[idHex] = true;
state["read"] = read;
return state;
});
}
bool isRead(String idHex) =>
((_load()["read"] as Map?) ?? {})[idHex] == true;
// --- sealed mail ------------------------------------------------------------
Map _recordToMap(MailRecord record) => {
"id": record.id,
"envelope": record.envelope,
"receivedAt": record.receivedAt,
"recipient": record.recipient,
"sentAt": record.sentAt,
};
MailRecord _mapToRecord(Map map) => MailRecord(
map["id"] as String,
(map["envelope"] as Uint8List),
receivedAt: map["receivedAt"] as int?,
recipient: map["recipient"] as String?,
sentAt: map["sentAt"] as int?,
);
static String mailKey(String folder, String id) => "$folder/$id";
Future<void> storeMessage(String folder, MailRecord record) =>
_mail.put(mailKey(folder, record.id), _recordToMap(record));
/// Returns null when the id already exists, so fetch can leave server
/// state alone.
Future<MailRecord?> storeIfNew(String folder, MailRecord record) async {
if (_mail.containsKey(mailKey(folder, record.id))) return null;
await storeMessage(folder, record);
return record;
}
List<MailRecord> listMessages(String folder) {
final prefix = "$folder/";
final rows = <MailRecord>[];
for (final key in _mail.keys.cast<String>()) {
if (!key.startsWith(prefix)) continue;
final row = _mail.get(key);
if (row is Map) rows.add(_mapToRecord(row));
}
rows.sort((a, b) =>
(b.receivedAt ?? b.sentAt ?? 0).compareTo(a.receivedAt ?? a.sentAt ?? 0));
return rows;
}
MailRecord? getMessage(String folder, String id) {
final row = _mail.get(mailKey(folder, id));
return row is Map ? _mapToRecord(row) : null;
}
Future<void> deleteMessage(String folder, String id) =>
_mail.delete(mailKey(folder, id));
// --- export / import: mail, contacts, pins — never the seed --------------------
/// The marker matches gsmol's web export, so backups move between the two
/// clients. Deliberately excludes the seed: it has its own reveal-and-copy
/// flow in settings, meant for a password manager, not a shareable file.
Map<String, dynamic> exportData() {
final state = _load();
final contacts = ((state["contacts"] as Map?) ?? {}).cast<String, Map>();
return {
"gsmolExport": 1,
"exportedAt": DateTime.now().millisecondsSinceEpoch,
"servers": ((state["servers"] as Map?) ?? {}).cast<String, String>(),
"contacts": {
for (final entry in contacts.entries)
entry.key: {
"key": entry.value["key"],
"verified": entry.value["verified"],
if ((entry.value["history"] as List?)?.isNotEmpty == true)
"history": entry.value["history"],
}
},
"inbox": [
for (final row in listMessages("inbox"))
{
"id": row.id,
"receivedAt": row.receivedAt,
"envelope": base64Encode(row.envelope),
}
],
"sent": [
for (final row in listMessages("sent"))
{
"id": row.id,
"recipient": row.recipient,
"sentAt": row.sentAt,
"envelope": base64Encode(row.envelope),
}
],
};
}
/// Never overwrites a trust binding that already differs locally — the same
/// rule refreshContact()/saveReplyAddress() apply elsewhere. A malformed
/// entry is skipped and counted, not fatal: one bad record cannot abort the
/// rest of the import.
Future<ImportSummary> importData(Map data) async {
if (data["gsmolExport"] != 1) {
throw const SmolError("not a SmolMail export file");
}
final summary = ImportSummary();
_update((state) {
final servers = (state["servers"] as Map? ?? {}).cast<String, String>();
final incomingPins = data["servers"] is Map ? data["servers"] as Map : null;
if (data["servers"] != null && incomingPins == null) summary.malformed++;
for (final entry in (incomingPins ?? const {}).entries) {
final host = entry.key, key = entry.value;
if (host is! String || key is! String || _pinKeyOk(key) != true) {
summary.malformed++;
continue;
}
if (!servers.containsKey(host)) {
servers[host] = key;
summary.pinsAdded++;
} else if (servers[host] != key) {
summary.pinsConflicted++;
}
}
state["servers"] = servers;
final contacts = (state["contacts"] as Map? ?? {}).cast<String, Map>();
final incoming = data["contacts"] is Map ? data["contacts"] as Map : null;
if (data["contacts"] != null && incoming == null) summary.malformed++;
for (final entry in (incoming ?? const {}).entries) {
final address = entry.key, contact = entry.value;
if (address is! String ||
contact is! Map ||
contact["key"] is! String ||
_pinKeyOk(contact["key"] as String) != true) {
summary.malformed++;
continue;
}
if (!contacts.containsKey(address)) {
contacts[address] = {
"key": contact["key"],
"verified": contact["verified"] == true,
"seenAt": DateTime.now().millisecondsSinceEpoch,
if (contact["history"] is List && (contact["history"] as List).isNotEmpty)
"history": contact["history"],
};
summary.contactsAdded++;
} else if (contacts[address]!["key"] != contact["key"]) {
summary.contactsConflicted++;
}
}
state["contacts"] = contacts;
return state;
});
for (final folder in ["inbox", "sent"]) {
final rows = data[folder] is List ? data[folder] as List : null;
if (data[folder] != null && rows == null) summary.malformed++;
for (final row in rows ?? const []) {
try {
final map = row as Map;
final record = MailRecord(
map["id"] as String,
base64Decode(map["envelope"] as String),
receivedAt: folder == "inbox" ? map["receivedAt"] as int? : null,
recipient: folder == "sent" ? map["recipient"] as String? : null,
sentAt: folder == "sent" ? map["sentAt"] as int? : null,
);
if (await storeIfNew(folder, record) != null) summary.mailAdded++;
} on Exception {
summary.malformed++;
} on TypeError {
summary.malformed++;
}
}
}
return summary;
}
// A pin key must decode to exactly 32 bytes of base32.
bool _pinKeyOk(String key) {
try {
return b32decode(key).length == keyLen;
} on SmolError {
return false;
}
}
/// Remove every secret and every stored envelope; the UI must confirm first.
Future<void> wipe() async {
await _state.delete(_stateKey);
await _mail.clear();
}
int unreadCount() {
var count = 0;
for (final row in listMessages("inbox")) {
if (!isRead(row.id)) count++;
}
return count;
}
}
/// The store throws these typed errors so the UI can tell "no identity yet"
/// and "an identity already exists" apart without string matching.
class SmolIdentityExistsException implements Exception {
const SmolIdentityExistsException();
@override
String toString() => "an identity already exists; rotate it instead";
}
class SmolNoIdentityException implements Exception {
const SmolNoIdentityException();
@override
String toString() => "no identity to rotate";
}

115
lib/smol/transport.dart Normal file
View file

@ -0,0 +1,115 @@
// The byte pipe to a smolmaild server: raw TCP (dart:io), framed elsewhere.
// Mobile is this app's only target platform, so dart:io is fine here.
import "dart:async";
import "dart:io";
import "dart:typed_data";
import "package:smol_mail/smol/errors.dart";
import "package:smol_mail/smol/proto.dart";
/// Buffers the socket's stream and serves exact-length reads, so protocol
/// code never sees a partial frame.
class TcpWire implements Wire {
final Socket _socket;
final _chunks = <Uint8List>[];
final _waiters = <_ReadRequest>[];
int _buffered = 0;
Object? _closed;
late final StreamSubscription<Uint8List> _subscription;
TcpWire(this._socket) {
_subscription = _socket.listen(_onData,
onError: (Object error) => _fail(error),
onDone: () => _fail(const SmolError("server closed the connection")));
}
static Future<TcpWire> connect(String host, int port) async {
try {
// Mobile networks routinely need longer than a LAN handshake; 30s keeps
// flaky handovers from surfacing as user-facing timeouts.
return TcpWire(await Socket.connect(host, port,
timeout: const Duration(seconds: 30)));
} on SocketException catch (error) {
throw SmolError("cannot reach $host:$port (${error.message})");
}
}
void _onData(Uint8List data) {
_chunks.add(data);
_buffered += data.length;
_wake();
}
void _wake() {
_waiters.removeWhere((w) {
if (_closed != null) {
w.completer.completeError(_closed!);
return true;
}
if (_buffered >= w.need) {
w.completer.complete();
return true;
}
return false;
});
}
void _fail(Object error) {
_closed = error;
for (final w in _waiters) {
w.completer.completeError(error);
}
_waiters.clear();
}
@override
void send(Uint8List bytes) {
if (_closed != null) throw _closed!;
_socket.add(bytes);
}
@override
void close() {
_subscription.cancel();
_socket.destroy();
_fail(const SmolError("connection closed"));
}
@override
Future<Uint8List> readExact(int n) async {
if (_closed != null) throw _closed!;
if (_buffered < n) {
final request = _ReadRequest(n);
_waiters.add(request);
try {
await request.completer.future;
} finally {
_waiters.remove(request);
}
if (_closed != null) throw _closed!;
}
final out = Uint8List(n);
var off = 0;
while (off < n) {
final chunk = _chunks.first;
final take = chunk.length < n - off ? chunk.length : n - off;
out.setRange(off, off + take, chunk);
if (take == chunk.length) {
_chunks.removeAt(0);
} else {
_chunks[0] = Uint8List.sublistView(chunk, take);
}
off += take;
_buffered -= take;
}
return out;
}
}
class _ReadRequest {
final int need;
final completer = Completer<void>();
_ReadRequest(this.need);
}

File diff suppressed because it is too large Load diff

View file

@ -1,42 +1,33 @@
name: flash_mail
description: Temporary E-mail App, powered by mail.tm
name: smol_mail
description: Smol Mail client for the minimalist end-to-end encrypted mail protocol (../smolmail)
publish_to: "none"
version: 1.0.0+1
environment:
sdk: ">=3.0.1 <4.0.0"
sdk: ^3.11.0
dependencies:
flutter:
sdk: flutter
crypto: ^3.0.3
hive: ^2.2.3
hive_flutter: ^1.1.0
auto_route: ^7.4.0
flutter_riverpod: ^2.4.0
url_launcher: ^6.2.1
flash: ^3.0.5+2
freezed_annotation: ^2.4.1
json_annotation: ^4.8.1
dio: ^4.0.6
shimmer: ^3.0.0
flutter_inappwebview: ^6.0.0
flutter_downloader: ^1.11.6
external_path: ^1.0.3
intl: ^0.19.0
auto_route: ">=11.1.0 <11.2.0"
flutter_riverpod: ^3.3.2
intl: ^0.20.3
share_plus: ^13.3.0
file_picker: ^13.1.0
dev_dependencies:
flutter_test:
sdk: flutter
build_runner: ^2.4.6
flutter_lints: ^2.0.0
auto_route_generator: ^7.3.0
flutter_lints: ^6.0.0
auto_route_generator: ^10.5.0
flutter_native_splash: ^2.3.4
flutter_launcher_icons: ^0.13.1
freezed: ^2.4.5
json_serializable: ^6.7.1
hive_generator: ^2.0.1
flutter_launcher_icons: ^0.14.4
flutter_launcher_icons:
android: "launcher_icon"

61
test/dbg_test.dart Normal file
View file

@ -0,0 +1,61 @@
import "dart:io";
import "package:auto_route/auto_route.dart";
import "package:flutter/material.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:flutter_test/flutter_test.dart";
import "package:hive_flutter/hive_flutter.dart";
import "package:smol_mail/data/providers/providers.dart";
import "package:smol_mail/presentation/app_widget.dart";
import "package:smol_mail/presentation/routes/app_router.gr.dart";
import "package:smol_mail/smol/client.dart";
import "package:smol_mail/smol/crypto.dart";
import "package:smol_mail/smol/proto.dart";
import "package:smol_mail/smol/store.dart";
class StubClient extends SmolClient {
StubClient(super.store);
@override
Future<SmolAddress> recallAccount(String addressText) async {
final addr = parseAddress(addressText);
store.setAccount(addr);
return addr;
}
}
void main() {
late SmolStore store;
setUpAll(() async {
TestWidgetsFlutterBinding.ensureInitialized();
final dir = await Directory.systemTemp.createTemp("dbg4");
Hive.init(dir.path);
store = await SmolStore.open(stateBox: "dbg-state", mailBox: "dbg-mail");
});
testWidgets("direct replace", (tester) async {
final seed = randomBytes(32);
await tester.pumpWidget(ProviderScope(
overrides: [
storeProvider.overrideWithValue(store),
clientProvider.overrideWithValue(StubClient(store)),
],
child: const AppWidget(),
));
await tester.pumpAndSettle();
await tester.tap(find.text("Restore From Seed"));
await tester.pumpAndSettle();
final fields = find.byType(TextField);
await tester.enterText(fields.at(0), hex(seed));
await tester.enterText(fields.at(1), "randogoth@smol.place");
await tester.tap(find.text("Restore"));
await tester.pumpAndSettle();
expect(store.account(), isNotNull, reason: "recall stub ran");
final element = tester.element(find.text("Back"));
final router = AutoRouter.of(element);
await router.replace(InboxRoute());
await tester.pumpAndSettle();
expect(find.text("Inbox"), findsOneWidget);
});
}

121
test/e2e_test.dart Normal file
View file

@ -0,0 +1,121 @@
// End-to-end against a live reference server: register an address on a
// locally running smolmaild, send a sealed message to ourselves, fetch it back,
// and check the server is drained afterwards. Skips when nothing listens on
// 127.0.0.1:1961, so `devbox run test` does not depend on a server.
//
// To run it: (cd ../smolmail && uv run smolmaild.py keygen --key server.key &&
// uv run smolmaild.py serve --key server.key --db mail.db)
// then `devbox run test`.
import "dart:io";
import "package:flutter_test/flutter_test.dart";
import "package:hive_flutter/hive_flutter.dart";
import "package:smol_mail/smol/client.dart";
import "package:smol_mail/smol/crypto.dart";
import "package:smol_mail/smol/errors.dart";
import "package:smol_mail/smol/proto.dart";
import "package:smol_mail/smol/store.dart";
const host = "127.0.0.1";
const port = 1961;
Future<bool> serverUp() async {
try {
final probe = await Socket.connect(host, port,
timeout: const Duration(seconds: 2));
probe.destroy();
return true;
} catch (_) {
return false;
}
}
void main() {
test("register, send to self, fetch, unseal, drain", () async {
if (!await serverUp()) {
markTestSkipped("no smolmaild on $host:$port");
return;
}
final dir = await Directory.systemTemp.createTemp("smol-e2e");
Hive.init(dir.path);
final store = await SmolStore.open();
final client = SmolClient(store);
// First contact is trust-on-first-use: learn the key the handshake reveals,
// then pin it — the flow a user with an operator-supplied key skips.
final warnings = <String>[];
client.onWarning = warnings.add;
final me = client.createIdentity();
final user = "e2e${hex(randomBytes(4))}";
final address = parseAddress("$user@$host");
final learned = await client.connect(address, requirePin: false);
// §8: the first, unpinned session must be announced as unverified.
expect(warnings, isNotEmpty);
expect(warnings.single, contains("not pinned"));
store.pinServer(host, learned.serverStatic);
learned.session.wire.close();
await client.registerAccount(address.short);
expect(store.account()?.user, user);
await client.send(address.short, "hello e2e", "sealed and signed");
await client.send(address.short, "second", "another sealed envelope");
final summary = await client.fetch();
expect(summary.stored, 2);
expect(summary.rejected, isEmpty);
final inbox = store.listMessages("inbox");
expect(inbox.length, 2);
// receivedAt has second granularity, so the order of the two is not
// guaranteed; assert on the pair, then open the one we care about.
final subjects = inbox.map((m) => client.describe(m).subject).toSet();
expect(subjects, {"hello e2e", "second"});
final hello = inbox.firstWhere(
(m) => client.describe(m).subject == "hello e2e");
final opened = client.describe(hello);
expect(opened.error, isNull);
expect(opened.body, "sealed and signed\n");
expect(hex(opened.sender!), hex(me.publicKey));
// The server must be drained: everything that verified was acknowledged.
final again = await client.fetch();
expect(again.stored, 0);
// The sent copy is sealed to ourselves and readable (§5.6).
final sent = store.listMessages("sent");
expect(sent.length, 2);
expect(client.describe(sent.first).error, isNull);
// A restored seed can rebind the address without REGISTER; the address
// must resolve to this identity's key.
final recalled = await client.recallAccount(address.short);
expect(recalled.short, address.short);
expect(
() => client.recallAccount("nobody@$host"), throwsA(isA<SmolError>()));
// Restore on a second device: same seed, fresh store, no pin. Unpinned
// recall is refused; re-registering a taken name is refused; recall with
// the operator-supplied key then binds the account without REGISTER.
final restored = await SmolStore.open(
stateBox: "e2e-restore-state", mailBox: "e2e-restore-mail");
final secondDevice = SmolClient(restored);
restored.setIdentity(me.seed);
expect(
secondDevice.recallAccount(address.short), throwsA(isA<SmolError>()));
restored.pinServer(host, learned.serverStatic);
await expectLater(
secondDevice.registerAccount(address.short), throwsA(isA<SmolError>()));
final bound = await secondDevice.recallAccount(address.short);
expect(bound.short, address.short);
expect(restored.account()!.user, user);
// Re-resolving our own address finds the same key and says so quietly.
final outcome = await client.refreshContact(address.short);
expect(outcome.warn, isFalse);
expect(outcome.message, contains("key unchanged"));
expect(store.contact(address.short)!.history, isEmpty);
}, timeout: const Timeout(Duration(minutes: 2)));
}

104
test/recall_flow_test.dart Normal file
View file

@ -0,0 +1,104 @@
// Regression tests for the onboarding recall flows: restoring a seed and
// recalling the registered address must land the user in the inbox. The
// client's network operations are stubbed; what is under test is the UI and
// router flow itself.
import "dart:io";
import "package:flutter/material.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:flutter_test/flutter_test.dart";
import "package:hive_flutter/hive_flutter.dart";
import "package:smol_mail/data/providers/providers.dart";
import "package:smol_mail/presentation/app_widget.dart";
import "package:smol_mail/smol/client.dart";
import "package:smol_mail/smol/crypto.dart";
import "package:smol_mail/smol/proto.dart";
import "package:smol_mail/smol/store.dart";
/// A [SmolClient] whose recall completes instantly, so the test exercises
/// the flow rather than the network.
class StubClient extends SmolClient {
StubClient(super.store);
@override
Future<SmolAddress> recallAccount(String addressText) async {
final addr = parseAddress(addressText);
store.setAccount(addr);
return addr;
}
}
void main() {
// Hive box opening is real file IO and must happen outside testWidgets'
// fake-async zone — including the per-test stores.
late final SmolStore storeA, storeB;
setUpAll(() async {
TestWidgetsFlutterBinding.ensureInitialized();
final dir = await Directory.systemTemp.createTemp("smol-recall-flow");
Hive.init(dir.path);
storeA = await SmolStore.open(
stateBox: "recall-a-state", mailBox: "recall-a-mail");
storeB = await SmolStore.open(
stateBox: "recall-b-state", mailBox: "recall-b-mail");
});
Widget app(SmolStore store) => ProviderScope(
overrides: [
storeProvider.overrideWithValue(store),
clientProvider.overrideWithValue(StubClient(store)),
],
child: const AppWidget(),
);
testWidgets("restore with an address recalls and lands in the inbox",
(tester) async {
final store = storeA;
final seed = randomBytes(32);
await tester.pumpWidget(app(store));
await tester.pumpAndSettle();
expect(find.text("Create Identity"), findsOneWidget);
await tester.tap(find.text("Restore From Seed"));
await tester.pumpAndSettle();
final fields = find.byType(TextField);
expect(fields, findsNWidgets(2));
await tester.enterText(fields.at(0), hex(seed));
await tester.enterText(fields.at(1), "randogoth@smol.place");
await tester.tap(find.text("Restore"));
await tester.pumpAndSettle();
expect(find.text("Inbox"), findsOneWidget);
expect(store.seed(), seed);
expect(store.account()!.user, "randogoth");
});
testWidgets("restore without an address recalls from the register step",
(tester) async {
final store = storeB;
final seed = randomBytes(32);
await tester.pumpWidget(app(store));
await tester.pumpAndSettle();
await tester.tap(find.text("Restore From Seed"));
await tester.pumpAndSettle();
var fields = find.byType(TextField);
await tester.enterText(fields.at(0), hex(seed));
await tester.tap(find.text("Restore"));
await tester.pumpAndSettle();
// The register step: address, server key, optional invite token.
fields = find.byType(TextField);
expect(fields, findsNWidgets(3));
await tester.enterText(fields.at(0), "randogoth@smol.place");
await tester.enterText(fields.at(1), b32encode(randomBytes(32)));
await tester.tap(find.text("Already registered? Recall"));
await tester.pumpAndSettle();
expect(find.text("Inbox"), findsOneWidget);
expect(store.account()!.user, "randogoth");
});
}

306
test/smol_test.dart Normal file
View file

@ -0,0 +1,306 @@
// Unit tests: lib/smol must reproduce test/vectors.json byte for byte (the
// vectors are generated from the reference stack — PyNaCl, noiseprotocol — by
// ../gsmol/test/gen_vectors.py), plus protocol-level checks for frontmatter,
// addresses, rotation chains and response framing.
// Run: devbox run test
import "dart:convert";
import "dart:io";
import "dart:typed_data";
import "package:flutter_test/flutter_test.dart";
import "package:smol_mail/smol/crypto.dart";
import "package:smol_mail/smol/errors.dart";
import "package:smol_mail/smol/noise.dart";
import "package:smol_mail/smol/proto.dart";
final vectors =
jsonDecode(File("test/vectors.json").readAsStringSync()) as Map<String, dynamic>;
Uint8List vhex(String text) => unhex(text);
String vstring(dynamic value) => value as String;
void main() {
test("hashes, HMAC/HKDF and AEAD match the reference vectors", () {
for (final v in vectors["sha256"] as List) {
final m = v as Map;
expect(hex(sha256(vhex(vstring(m["in"])))), vstring(m["out"]));
}
for (final v in vectors["sha512"] as List) {
final m = v as Map;
expect(hex(sha512(vhex(vstring(m["in"])))), vstring(m["out"]));
}
for (final v in vectors["hkdf"] as List) {
final m = v as Map;
expect(
hex(hkdfSha256(vhex(vstring(m["ikm"])), vhex(vstring(m["salt"])),
vhex(vstring(m["info"])), m["len"] as int)),
vstring(m["out"]));
}
for (final v in vectors["aead"] as List) {
final m = v as Map;
final key = vhex(vstring(m["key"]));
final nonce = vhex(vstring(m["nonce"]));
final aad = vhex(vstring(m["aad"]));
final sealed = aeadEncrypt(
key, nonce, vhex(vstring(m["plaintext"])), aad);
expect(hex(sealed), vstring(m["sealed"]), reason: "aead-seal ${m["name"]}");
expect(
hex(aeadDecrypt(key, nonce, vhex(vstring(m["sealed"])), aad)),
vstring(m["plaintext"]));
expect(
() => aeadDecrypt(
key, nonce, Uint8List.fromList(vhex(vstring(m["sealed"])).sublist(0, vhex(vstring(m["sealed"])).length - 1)), aad),
throwsA(isA<SmolError>()));
}
});
test("X25519 matches and rejects low-order points", () {
final byName = <String, Map>{};
for (final v in vectors["x25519"] as List) {
final m = v as Map;
byName[m["name"] as String] = m;
}
expect(hex(x25519Base(vhex(vstring(byName["alice"]!["priv"])))), byName["alice"]!["pub"]);
expect(hex(x25519Base(vhex(vstring(byName["bob"]!["priv"])))), byName["bob"]!["pub"]);
expect(
hex(x25519(vhex(vstring(byName["alice"]!["priv"])), vhex(vstring(byName["bob"]!["pub"])))),
byName["agree"]!["shared"]);
for (final v in vectors["x25519"] as List) {
final m = v as Map;
if ((m["name"] as String).startsWith("low-order")) {
expect(
() => x25519(vhex(vstring(byName["alice"]!["priv"])), vhex(vstring(m["peer"]))),
throwsA(isA<SmolError>()));
}
}
});
test("Ed25519 signs and verifies like the reference stack", () {
for (final v in vectors["ed25519"] as List) {
final m = v as Map;
final seed = vhex(vstring(m["seed"]));
expect(hex(ed25519PublicKey(seed)), vstring(m["pub"]), reason: "ed25519-pub ${m["name"]}");
final sig = ed25519Sign(seed, vhex(vstring(m["message"])));
if (m["valid"] as bool) {
expect(hex(sig), vstring(m["signature"]), reason: "ed25519-sign ${m["name"]}");
expect(ed25519Verify(vhex(vstring(m["pub"])), vhex(vstring(m["message"])), sig), isTrue);
expect(
ed25519Verify(vhex(vstring(m["pub"])), vhex(vstring(m["message"])),
vhex(vstring(m["signature"]))),
isTrue,
reason: "ed25519-verify-pynacl ${m["name"]}");
} else {
expect(
ed25519Verify(vhex(vstring(m["pub"])), vhex(vstring(m["message"])),
vhex(vstring(m["signature"]))),
isFalse,
reason: "ed25519-reject ${m["name"]}");
}
}
});
test("§2 conversions between the identity key and X25519", () {
for (final v in vectors["ed_to_x25519"] as List) {
final m = v as Map;
expect(hex(ed25519SeedToX25519(vhex(vstring(m["seed"])))), vstring(m["x_priv"]));
expect(hex(ed25519ToX25519(ed25519PublicKey(vhex(vstring(m["seed"]))))), vstring(m["x_pub"]));
}
});
test("§5 envelope seals, ids and unseals byte for byte", () {
final v = vectors["envelope"] as Map;
final sender = identityFromSeed(vhex(vstring(v["sender_seed"])));
final recipient = identityFromSeed(vhex(vstring(v["recipient_seed"])));
Uint8List sealWith(bool pad) => seal(sender, recipient.publicKey,
vhex(vstring(v["body"])), v["time"] as int,
SealOptions(esk: vhex(vstring(v["esk"])), pad: pad));
expect(hex(sealWith(false)), vstring(v["envelope"]));
expect(hex(messageId(vhex(vstring(v["envelope"])))), vstring(v["id"]));
final opened = unseal([recipient], vhex(vstring(v["envelope"])));
expect(hex(opened.sender), hex(sender.publicKey));
expect(opened.time, v["time"] as int);
expect(hex(opened.body), vstring(v["body"]));
expect(
() => unseal([identityFromSeed(vhex(vstring(v["esk"])))],
vhex(vstring(v["envelope"]))),
throwsA(isA<SmolError>()));
// padding round-trips and is ignored by the receiver (§5.3)
final padded = sealWith(true);
expect((padded.length - envelopeHeader - 16) % padTo, 0);
expect(padded.length > vstring(v["envelope"]).length ~/ 2, isTrue);
expect(hex(unseal([recipient], padded).body), vstring(v["body"]));
});
test("Noise NX transcript matches the reference", () {
final v = vectors["noise"] as Map;
final nx = NxInitiator();
expect(hex(nx.writeMessage1(vhex(vstring(v["initiator_eph_priv"])))), vstring(v["message1"]));
final result = nx.readMessage2(vhex(vstring(v["message2"])));
expect(hex(result.serverStatic), vstring(v["server_static_pub"]));
expect(hex(result.handshakeHash), vstring(v["handshake_hash"]));
final initiatorFrames = (v["initiator_frames"] as List).cast<Map>();
final responderFrames = (v["responder_frames"] as List).cast<Map>();
for (var i = 0; i < initiatorFrames.length; i++) {
expect(hex(result.send.encrypt(vhex(vstring(initiatorFrames[i]["plaintext"])))),
vstring(initiatorFrames[i]["sealed"]),
reason: "noise-frame-i$i");
}
for (var i = 0; i < responderFrames.length; i++) {
expect(hex(result.recv.decrypt(vhex(vstring(responderFrames[i]["sealed"])))),
vstring(responderFrames[i]["plaintext"]),
reason: "noise-frame-r$i");
}
// The responder direction must also produce identical ciphertexts (AEAD is
// deterministic), so the recv cipher can be checked in both directions.
final mirrored = NxInitiator();
mirrored.writeMessage1(vhex(vstring(v["initiator_eph_priv"])));
final mirror = mirrored.readMessage2(vhex(vstring(v["message2"])));
expect(hex(mirror.recv.encrypt(vhex(vstring(responderFrames[0]["plaintext"])))),
vstring(responderFrames[0]["sealed"]));
});
test("frontmatter parses and fails closed (§5.5)", () {
const spec =
"---\nSubject: Re: the thing\nIn-Reply-To: 4f2a1c9e8b7d6a5f3e2d1c0b9a8f7e6d\nX-Mood: cautiously optimistic\n---\nBody text starts here.";
final parsed = parseFrontmatter(spec);
expect(parsed.fields["Subject"], "Re: the thing");
expect(parsed.fields["In-Reply-To"], "4f2a1c9e8b7d6a5f3e2d1c0b9a8f7e6d");
expect(parsed.body, "Body text starts here.");
// a malformed line invalidates the whole block, which fails closed toward display
expect(parseFrontmatter("---\nno colon here\n---\nrest").body,
"---\nno colon here\n---\nrest");
expect(parseFrontmatter("---\nSubject: x\nno end").body,
"---\nSubject: x\nno end");
expect(parseFrontmatter("---\nA: 1\nA: 2\n---\ntext").fields["A"], "1");
expect(buildFrontmatter(const {}, "---\nactual body"),
"---\n---\n---\nactual body");
expect(buildFrontmatter({"Subject": "hi"}, "there"), "---\nSubject: hi\n---\nthere");
expect(buildFrontmatter(const {}, "plain"), "plain");
expect(
parseFrontmatter("---\n${"X: y\n" * 65}---\nbody").fields["Subject"],
isNull);
});
test("addresses parse per §3 and round-trip through smol://", () {
final a = parseAddress("Alice@Example.ORG:1961");
expect(a.user, "alice");
expect(a.port, 1961);
expect(a.short, "alice@example.org"); // a default port is dropped
expect(parseAddress("bob@host").port, defaultPort);
final key = vhex(vstring((vectors["ed25519"] as List).cast<Map>().first["pub"]));
final parsed = parseAddress(parseAddress("bob@h").uri(key));
expect(parsed.identity, key);
expect(parsed.user, "bob");
expect(() => parseAddress("-bob@h"), throwsA(isA<SmolError>()));
// §3: fingerprints are the first 20 base32 characters in groups of four
final b32 = b32encode(key);
expect(
fingerprint(key),
[
b32.substring(0, 4), b32.substring(4, 8), b32.substring(8, 12),
b32.substring(12, 16), b32.substring(16, 20)
].join(" "));
for (final n in [1, 2, 5, 32, 52, 64]) {
final raw = randomBytes(n);
expect(b32decode(b32encode(raw)), raw, reason: "b32[$n]");
}
});
test("rotation chains validate, break and oversize per §7", () {
final old = identityFromSeed(randomBytes(32));
final mid = randomBytes(32);
final fresh = randomBytes(32);
final when = nowSeconds();
final chain = [
makeCert(old, mid, when),
makeCert(identityFromSeed(mid), fresh, when),
];
expect(walkChain(old.publicKey, ed25519PublicKey(fresh), chain), isTrue);
expect(walkChain(old.publicKey, old.publicKey, []), isTrue);
expect(
walkChain(old.publicKey, ed25519PublicKey(fresh), chain.sublist(1)),
isFalse);
final forged = List<Uint8List>.from(chain);
forged[1] = makeCert(identityFromSeed(mid), randomBytes(32), when);
expect(
walkChain(old.publicKey, ed25519PublicKey(fresh), forged), isFalse);
expect(
walkChain(old.publicKey, ed25519PublicKey(fresh),
List.filled(17, chain[0])),
isFalse);
expect(chain[0].length, certLen);
});
test("response framing guards (§6.1)", () async {
Session scripted(Uint8List frame) {
// readNoise wants a u16 length prefix and at least 16 bytes of Noise
// message; the frame is padded up to that floor.
final message = Uint8List.fromList([
...frame,
...List.filled(frame.length < 16 ? 16 - frame.length : 0, 0),
]);
final session = Session(_ScriptedWire(concat([u16be(message.length), message])),
_PassthroughCipher(), _PassthroughCipher());
return session;
}
Future<void> refuses(String name, Uint8List frame, int op) async {
try {
await scripted(frame).call(op);
fail("$name: call resolved instead of throwing");
} on TestFailure {
rethrow;
} catch (_) {
// expected
}
}
// The shortest legal response is a type byte and a status byte.
await refuses("frame-too-short", concat([u32be(1), Uint8List.fromList([opFetch])]), opFetch);
// A response reuses the request's type byte; a mismatch means the session
// desynchronised, which must not be read as a status.
await refuses("frame-op-mismatch",
concat([u32be(2), Uint8List.fromList([opResolve, 0])]), opFetch);
// The same frame with the right echo still passes, so the guard is not
// simply rejecting everything.
final okSession =
scripted(concat([u32be(2), Uint8List.fromList([opFetch, 0])]));
expect((await okSession.call(opFetch)).status, 0);
});
}
/// Serves a scripted response and ignores sends, so framing can be tested
/// without a server.
class _ScriptedWire implements Wire {
final Uint8List _queue;
int _pos = 0;
_ScriptedWire(this._queue);
@override
void send(Uint8List bytes) {}
@override
void close() {}
@override
Future<Uint8List> readExact(int n) async {
if (_pos + n > _queue.length) {
throw const SmolError("script exhausted");
}
final out = Uint8List.fromList(_queue.sublist(_pos, _pos + n));
_pos += n;
return out;
}
}
class _PassthroughCipher implements SessionCipher {
@override
Uint8List encrypt(Uint8List plaintext) => Uint8List.fromList(plaintext);
@override
Uint8List decrypt(Uint8List sealed) => Uint8List.fromList(sealed);
}

140
test/store_test.dart Normal file
View file

@ -0,0 +1,140 @@
// Store-level behavior: contact key history (§8), import binding, and the
// export/import backup file.
import "dart:convert";
import "dart:io";
import "package:flutter_test/flutter_test.dart";
import "package:hive_flutter/hive_flutter.dart";
import "package:smol_mail/smol/client.dart";
import "package:smol_mail/smol/crypto.dart";
import "package:smol_mail/smol/errors.dart";
import "package:smol_mail/smol/proto.dart";
import "package:smol_mail/smol/store.dart";
// Each store gets its own boxes; Hive is a per-process singleton, so without
// this the "exporting" and "importing" stores would be the same store.
Future<SmolStore> freshStore(String tag) =>
SmolStore.open(stateBox: "test-$tag-state", mailBox: "test-$tag-mail");
void main() {
setUpAll(() async {
TestWidgetsFlutterBinding.ensureInitialized();
final dir = await Directory.systemTemp.createTemp("smol-store-test");
Hive.init(dir.path);
});
test("contact history keeps displaced keys, not re-saves", () async {
final store = await freshStore("history");
final a = randomBytes(32), b = randomBytes(32), c = randomBytes(32);
store.saveContact("alice@example.org", a, true);
expect(store.contact("alice@example.org")!.history, isEmpty);
store.saveContact("alice@example.org", b, false);
final rotated = store.contact("alice@example.org")!;
expect(rotated.key, b);
expect(rotated.history.length, 1);
expect(rotated.history.first.key, a);
expect(rotated.history.first.until, greaterThan(0));
// Re-saving the same key is not a rotation and must not add an entry.
store.saveContact("alice@example.org", b, true);
expect(store.contact("alice@example.org")!.history.length, 1);
// A second displacement appends, oldest first.
store.saveContact("alice@example.org", c, false);
final history = store.contact("alice@example.org")!.history;
expect(history.length, 2);
expect(history[0].key, a);
expect(history[1].key, b);
expect(store.allContacts().single.$2.history.length, 2);
});
test("importContact binds a smol:// address to the key it carries", () async {
final store = await freshStore("import-contact");
final client = SmolClient(store);
final identity = identityFromSeed(randomBytes(32));
client.importContact(
"smol://bob@example.org/${b32encode(identity.publicKey)}");
final saved = store.contact("bob@example.org")!;
expect(saved.verified, isTrue);
expect(saved.key, identity.publicKey);
});
test("export never contains the seed and round-trips through import",
() async {
final a = await freshStore("export");
final b = await freshStore("round-trip");
a.setIdentity(randomBytes(32));
a.pinServer("example.org", randomBytes(32));
a.saveContact("alice@example.org", randomBytes(32), true);
a.saveContact("alice@example.org", randomBytes(32), false); // history grows
await a.storeMessage(
"inbox", MailRecord("aa", randomBytes(64), receivedAt: 5));
await a.storeMessage("sent",
MailRecord("bb", randomBytes(64), recipient: "bob@example.org", sentAt: 6));
final data = a.exportData();
expect(data["gsmolExport"], 1); // gsmol-compatible marker
expect(jsonEncode(data).contains(hex(a.seed()!)), isFalse);
final summary = await b.importData(data);
expect(summary.mailAdded, 2);
expect(summary.pinsAdded, 1);
expect(summary.contactsAdded, 1);
expect(b.serverPin("example.org"), a.serverPin("example.org"));
expect(b.contact("alice@example.org")!.history.length, 1);
expect(b.getMessage("inbox", "aa"), isNotNull);
expect(b.getMessage("sent", "bb"), isNotNull);
expect(b.seed(), isNull); // never the seed
});
test("import never overwrites a differing trust binding", () async {
final store = await freshStore("conflict");
final mine = randomBytes(32), other = randomBytes(32);
store.pinServer("example.org", mine);
store.saveContact("alice@example.org", mine, true);
final summary = await store.importData({
"gsmolExport": 1,
"servers": {"example.org": b32encode(other)},
"contacts": {
"alice@example.org": {"key": b32encode(other), "verified": true},
"bob@example.org": {"key": b32encode(randomBytes(32)), "verified": false},
},
});
expect(summary.pinsConflicted, 1);
expect(summary.pinsAdded, 0);
expect(summary.contactsConflicted, 1);
expect(summary.contactsAdded, 1);
expect(store.serverPin("example.org"), mine);
expect(store.contact("alice@example.org")!.key, mine);
expect(store.contact("bob@example.org"), isNotNull);
});
test("import skips malformed entries and rejects wrong files", () async {
final store = await freshStore("malformed");
final summary = await store.importData({
"gsmolExport": 1,
"servers": {"bad": "notbase32!", "short": b32encode(randomBytes(8))},
"contacts": {
"x": {"key": "nope"},
"y": "not a record",
},
"inbox": [
{"id": "ok", "envelope": base64Encode(randomBytes(64)), "receivedAt": 1},
{"id": "bad", "envelope": "!!!not base64!!!"},
"not a record",
],
"sent": "not a list",
});
expect(summary.malformed, 7); // 2 pins, 2 contacts, 2 mail, 1 sent
expect(summary.pinsAdded, 0);
expect(summary.mailAdded, 1);
expect(store.getMessage("inbox", "ok"), isNotNull);
expect(store.getMessage("inbox", "bad"), isNull);
expect(() => store.importData({"nope": 1}), throwsA(isA<SmolError>()));
});
}

226
test/vectors.json Normal file
View file

@ -0,0 +1,226 @@
{
"sha256": [
{
"in": "",
"out": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
},
{
"in": "616263",
"out": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
},
{
"in": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f",
"out": "fdeab9acf3710362bd2658cdc9a29e8f9c757fcf9811603a8c447cd1d9151108"
},
{
"in": "736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c",
"out": "58a0adce483c517ae1b37025dbe3b534880972be4d9d10b78959a13fb8b13462"
}
],
"sha512": [
{
"in": "",
"out": "cf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e"
},
{
"in": "616263",
"out": "ddaf35a193617abacc417349ae20413112e6fa4e89a97ea20a9eeee64b55d39a2192992a274fc1a836ba3c23a3feebbd454d4423643ce80e2a9ac94fa54ca49f"
},
{
"in": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f",
"out": "ee4320ebaf3fdb4f2c832b137200c08e235e0fa7bbd0eb1740c7063ba8a0d151da77e003398e1714a955d475b05e3e950b639503b452ec185de4229bc4873949"
}
],
"hkdf": [
{
"name": "rfc5869-1",
"ikm": "0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b",
"salt": "000102030405060708090a0b0c",
"info": "f0f1f2f3f4f5f6f7f8f9",
"len": 42,
"out": "3cb25f25faacd57a90434f64d0362f2a2d2d0a90cf1a5a4c5db02d56ecc4c5bf34007208d5b887185865"
},
{
"name": "seal-shaped",
"ikm": "61677265656d656e7420736861726564",
"salt": "65706b726563697069656e74",
"info": "736d6f6c6d61696c2f31207365616c",
"len": 32,
"out": "b9f729e45d7bab89598edbce35f3f5bb91d6f403a5ff85943a838defbfcd7a0c"
}
],
"aead": [
{
"name": "empty",
"key": "831386140c054287c460b9f6092d43a5bb6eb2d2c549b00fc3fce0e7cca1a19d",
"nonce": "3edd69829394f0807df7b01d",
"aad": "",
"plaintext": "",
"sealed": "941b286ea0ee86bb66236fc3c16b2e48"
},
{
"name": "short",
"key": "831386140c054287c460b9f6092d43a5bb6eb2d2c549b00fc3fce0e7cca1a19d",
"nonce": "3edd69829394f0807df7b01d",
"aad": "50515253c0c1c2c3c4c5c6c7",
"plaintext": "68656c6c6f",
"sealed": "7dbede6dd11ffa046f102dedea535912be561e3c29"
},
{
"name": "multiline",
"key": "831386140c054287c460b9f6092d43a5bb6eb2d2c549b00fc3fce0e7cca1a19d",
"nonce": "3edd69829394f0807df7b01d",
"aad": "50515253c0c1c2c3c4c5c6c7",
"plaintext": "4c616469657320616e642047656e746c656d656e206f662074686520636c617373206f66202739393a206966204920636f756c64206f6666657220796f75206f6e6c79206f6e652074697020666f7220746865206675747572652c2073756e73637265656e20776f756c642062652069742e",
"sealed": "59bad668dbf00561dd86add05afe35b269f9997d57e46cee0e42fd69693c3df16b681f3905bbea4135cb379f4a0c730b5dbb3ba06d1231ce396660a16f8cadb8b422d745b932df3c1eed2df9636750551a0333b3d06877582f172f3ec2d437061143699bfc7258aa98e319f23a1f31f88fd15a24a97b46fd2e05c266d31ee96f5e24"
}
],
"x25519": [
{
"name": "alice",
"priv": "c63095403fea13cb2c43380599e669ebfb41ab184b04df28a143472e4283aa33",
"pub": "712e68cefc13d0e1778226b7f7aaeb59042225e7a4def3816344da256e031664"
},
{
"name": "bob",
"priv": "33485a5b40b70730b3c3e468a8262543e5a4d9dc98de06399de66a4d579e02a7",
"pub": "b8540c30e8fb348aed0abc8189cf8025ce09a9c5d74e0681c4e50f8d281da252"
},
{
"name": "agree",
"shared": "e6a3b1d2ae10c29b51519a71255af4bd20deee697c6ced1a44eadff428439e13"
},
{
"name": "low-order-0",
"peer": "0000000000000000000000000000000000000000000000000000000000000000",
"peer_rejected": true,
"raised": true
},
{
"name": "low-order-1",
"peer": "0100000000000000000000000000000000000000000000000000000000000000",
"peer_rejected": true,
"raised": true
}
],
"ed25519": [
{
"name": "vector-seed-a",
"seed": "81712c4bef43282ffd12909479bece9da17d404bdbc629bec3a6fdf246f847fb",
"pub": "7ce749eb2966b9b747543236ef5f61b46328a82f92677b82470439db065154e0",
"message": "",
"signature": "b20543ac2e0ba66c1b437de2afda7ca8ca6f9feb2af3e3e7ac3183e388d1786c9c027bfe549639140d0e45e7e850999b3fb992c7c003946c1c5aaeb09892cc0c",
"valid": true
},
{
"name": "vector-seed-a",
"seed": "81712c4bef43282ffd12909479bece9da17d404bdbc629bec3a6fdf246f847fb",
"pub": "7ce749eb2966b9b747543236ef5f61b46328a82f92677b82470439db065154e0",
"message": "736d6f6c6d61696c2f3120617574680000000000000000000000000000000000000000000000000000000000000000",
"signature": "f7e74a0540e05843b52efb7dee4f3622ab8a88333142f6dd1d5386612f7f0f1410bd0b1f1ff09dea9419922b756920a4c1fb31a95eac3cc55a410d0d6f1dab03",
"valid": true
},
{
"name": "vector-seed-a",
"seed": "81712c4bef43282ffd12909479bece9da17d404bdbc629bec3a6fdf246f847fb",
"pub": "7ce749eb2966b9b747543236ef5f61b46328a82f92677b82470439db065154e0",
"message": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f",
"signature": "e55b07b43bed9938bdf401d264226d22ea87f85867dc2f3a22edf129088e0a100dda4000c9c2075b946c12a645115911a041493bd3a6c6d6e3892233c7f3e209",
"valid": true
},
{
"name": "vector-seed-a",
"seed": "81712c4bef43282ffd12909479bece9da17d404bdbc629bec3a6fdf246f847fb",
"pub": "7ce749eb2966b9b747543236ef5f61b46328a82f92677b82470439db065154e0",
"message": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f",
"signature": "e45b07b43bed9938bdf401d264226d22ea87f85867dc2f3a22edf129088e0a100dda4000c9c2075b946c12a645115911a041493bd3a6c6d6e3892233c7f3e209",
"valid": false
},
{
"name": "vector-seed-b",
"seed": "053a9de5a92ac01208edcd9f9d585ffa1462b60e895567d13a262ebbd5138b5d",
"pub": "7068fc61a59adcb58e856393df939349389af09095fffdfb7374d07be183201e",
"message": "",
"signature": "2dfb4b1e65dfd4bf991ef50be88acddf2d59fe158daf2879bec5641ab80b1e0c542b9ea2bd9a6bc76f2020b76b14dc80ae9f68c62ea58dbd8f5b1990ff069e08",
"valid": true
},
{
"name": "vector-seed-b",
"seed": "053a9de5a92ac01208edcd9f9d585ffa1462b60e895567d13a262ebbd5138b5d",
"pub": "7068fc61a59adcb58e856393df939349389af09095fffdfb7374d07be183201e",
"message": "736d6f6c6d61696c2f3120617574680000000000000000000000000000000000000000000000000000000000000000",
"signature": "f6d398cd25fec0ba882af13b85c6addcc2f546181fba84f8925e6e196b759897337a2291f4b73c40a062b0a2283ef096ded790154af58e9d4bd39c32b3db2f05",
"valid": true
},
{
"name": "vector-seed-b",
"seed": "053a9de5a92ac01208edcd9f9d585ffa1462b60e895567d13a262ebbd5138b5d",
"pub": "7068fc61a59adcb58e856393df939349389af09095fffdfb7374d07be183201e",
"message": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f",
"signature": "24f8405160d7f8eca42d9bc5e703bc81e58082b34e5d0f28404e5a3518b500b46d48a9bd64c69f5dde38c978ae06c9ce5dfeabdfacd410753176ea11f171140e",
"valid": true
},
{
"name": "vector-seed-b",
"seed": "053a9de5a92ac01208edcd9f9d585ffa1462b60e895567d13a262ebbd5138b5d",
"pub": "7068fc61a59adcb58e856393df939349389af09095fffdfb7374d07be183201e",
"message": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f",
"signature": "25f8405160d7f8eca42d9bc5e703bc81e58082b34e5d0f28404e5a3518b500b46d48a9bd64c69f5dde38c978ae06c9ce5dfeabdfacd410753176ea11f171140e",
"valid": false
}
],
"ed_to_x25519": [
{
"name": "vector-seed-a",
"seed": "81712c4bef43282ffd12909479bece9da17d404bdbc629bec3a6fdf246f847fb",
"x_priv": "30dc8ba3a49892d4b8a626cd371fd43bff4e5651c2a45f1be16e89d84fa89e68",
"x_pub": "77bc3dae84c9b4085862725a21e0a366c09563d6da8f29c408ac2b6928937910"
},
{
"name": "vector-seed-b",
"seed": "053a9de5a92ac01208edcd9f9d585ffa1462b60e895567d13a262ebbd5138b5d",
"x_priv": "b03deb6f9f4ab25d15471a355ff4ee23ea98f7d24695c500ed80b6af4b7b9963",
"x_pub": "253d4b5b14df341a5dde486ddd588e292444118ed8eed1fe0738823b82e4243d"
}
],
"envelope": {
"sender_seed": "89c16df9e4352e706abe701928c230d8bd169cd31633bf4589c2d410cb3ae8cc",
"recipient_seed": "6f845ccc435252d39dd08e964d219258e92c3d6c54af0376fa45d5e55eec0aaf",
"esk": "c31fee505964c44b711cf354b431f9716c644a3dbf8c1d29c5e3cedf884d0a48",
"body": "2d2d2d0a5375626a6563743a20766563746f720a2d2d2d0a68656c6c6f20626f620a",
"time": 1730000000,
"envelope": "534d4f4c01e048814b56d9b82e54fd367d3c980661313cc6a3d81a80315561fc0ac87f81184e49921528d72321669ae1b275229800d8786c1ecdd7d9331bcb2cc64dc27c0399b106b5061e9105d8adf82f6b7464930fa31cb08ba85dba4764ce14cf3ddc32599f43fea73ced76ffa3a3b768e5a127034f5e82d667687369c19f060e8eafb09da0e212683290f4e1a73ce072b94f053c9088652109d3639f7b9aa0d48619626ecefe33855aade6ab8bf9de14ebb7cf07eec0ef9c193ae4537c370183e0c8f7cd7230471b9d8e7389ac654e1b09dc9b0160c875270fdad218f0c9da735bab",
"id": "b05d15a2ab5293164eda564de02a6901",
"unpadded_plaintext_len": 143
},
"noise": {
"initiator_eph_priv": "af5f15993914cfd4e308856810d483ab25a383bfb2d708a0e15f80275f1fe6c2",
"responder_eph_priv": "1c21927bb3e579efb69c937a2bf2e299ca1da9c83a62fb7f8ea1a375eb6f1c80",
"server_static_priv": "c7b206a746c9b167da412a6b1a235869e316e9f08dbbc8478430b2998130f79e",
"server_static_pub": "fa111eca8e853320f8e076674143fd4d1cd181bddeda7d9950a65922b9eafc32",
"message1": "b8b73e6f132dab5659270e6496d2c575ae7daf45a0961ae8e19405a12ed1cc2e",
"message2": "0b03ec78fd19cf7b8480881f33c6b4ca1094fac03c87860095c87c6737349c28256e498747bcf2018420d145c378e6605e63b217f92925ae5771dbe387b94cf9c995f7a3a8314fe2e671ca8fa1463e0642c1d7974f326737cadf630b8aac8ed9",
"handshake_hash": "a039471479680a596a8a61b8827afb01853274b03de2870095edb9b7dd4e2c72",
"assert_hash_equal": true,
"initiator_frames": [
{
"plaintext": "70696e67",
"sealed": "f1885096d9b9383b0bc38b08dff77c005d69f0f0"
},
{
"plaintext": "7365636f6e64206672616d6520746f20746573742074686520636f756e746572",
"sealed": "4a6481a2f7d0c909d9b321bc097e09a1929aeaf8647751f64d65b5e1f92abe960796dbf1c619bef48845aea257f2c73c"
}
],
"responder_frames": [
{
"plaintext": "706f6e67",
"sealed": "a7dda7fe3ef32435b3e72fda49714e9977318f0b"
},
{
"plaintext": "787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878",
"sealed": "9aa3af13c00a8a0cd81167b48a5443541e0c862297638e4b7e5c71196657bc565aad46c2d147b23f752ac48c29b69699209e252d3e840c13fc466dd0445515dec2d289902c7177a237f9256afe9508a45b64ea12cdb597a8c38b6ce0c43891ec772c21ca360980094268686642eae8e01ccf89650a427297dbab052aabfeb08adbb2087ae303dd168ceb3beffa63c8d3ccd1c5b2687c2a9c0d43eaab237fde648bf8b0f347b4952ff6da2212360b4a2a5b1316e9e776d82961c498e51f35a58c999f080ac1c12d7ac08f6ddd7addb70c37ea6bef42ed2c498362f4fd75a222068035a7f372c4f57e613427193ffd8ed40a2d0765ba62cbfd6cb5103165dc15be7ad2db723a4edc73cefe9f7fe5ff78a8ea06ecbc7c5135e5d810a1bd4e47f0cd4a1b6e8dd88b85236dd4b41296e00b7054139ee4fd4faa5876e01d62"
}
]
}
}

View file

@ -1,19 +1,35 @@
import "package:flutter/material.dart";
import "package:flutter_test/flutter_test.dart";
import "dart:io";
import "package:flash_mail/presentation/app_widget.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:flutter_test/flutter_test.dart";
import "package:hive_flutter/hive_flutter.dart";
import "package:smol_mail/data/providers/providers.dart";
import "package:smol_mail/presentation/app_widget.dart";
import "package:smol_mail/smol/store.dart";
late final SmolStore store;
void main() {
testWidgets("Counter increments smoke test", (WidgetTester tester) async {
await tester.pumpWidget(const AppWidget());
// Hive's box opening is real file IO, which never completes inside a
// testWidgets fake-async zone — so it happens here, outside one.
setUpAll(() async {
TestWidgetsFlutterBinding.ensureInitialized();
final dir = await Directory.systemTemp.createTemp("smol-widget-test");
Hive.init(dir.path);
store = await SmolStore.open();
});
expect(find.text("0"), findsOneWidget);
expect(find.text("1"), findsNothing);
await tester.tap(find.byIcon(Icons.add));
await tester.pump();
expect(find.text("0"), findsNothing);
expect(find.text("1"), findsOneWidget);
testWidgets("onboarding invites to create or restore an identity",
(tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [storeProvider.overrideWithValue(store)],
child: const AppWidget(),
),
);
await tester.pumpAndSettle();
expect(find.text("Create Identity"), findsOneWidget);
expect(find.text("Restore From Seed"), findsOneWidget);
});
}