Merge branch 'main' into migrate-to-dagger

This commit is contained in:
GuettliBot2
2026-05-17 06:29:04 +02:00
49 changed files with 4018 additions and 472 deletions
+2
View File
@@ -115,3 +115,5 @@ test/widget/failures/
.claude*
dagger-certs
.Xauthority
.sharedinbox-agent-state.json
+190
View File
@@ -0,0 +1,190 @@
# Development Environment Setup
This document explains how to set up a development environment for SharedInbox.
## ⚠️ Security Recommendation: Use a Dedicated Linux User
For enhanced security, especially when working with autonomous coding agents (like Gemini CLI in YOLO mode), we **strongly recommend** using a dedicated Linux user for this project. This isolates the project environment and prevents any potential accidental damage to your main system.
### 1. Create a Dedicated User
Set the user name variable (default is `si` for SharedInbox):
```bash
export DEV_USER=si
```
Create the user and add them to the `sudo` group:
```bash
sudo adduser --disabled-password newuser $DEV_USER
```
Set up SSH public key login (replace with your actual public key):
```bash
sudo mkdir -p /home/$DEV_USER/.ssh
sudo chmod 700 /home/$DEV_USER/.ssh
echo "ssh-ed25519 AAAA... your-key-comment" | sudo tee /home/$DEV_USER/.ssh/authorized_keys
sudo chmod 600 /home/$DEV_USER/.ssh/authorized_keys
sudo chown -R $DEV_USER:$DEV_USER /home/$DEV_USER/.ssh
```
### 2. Switch to the Dedicated User
```bash
ssh $DEV_USER@localhost
```
### Create ssh-keypair
```bash
ssh-keygen
```
### 3. Clone the Repository
Clone the project into your new user's home directory:
```bash^
git clone ssh://git@codeberg.org/guettli/sharedinbox.git
# Move git directory into $HOME
# This user only works on the git repo. Avoid "cd sharedinbox" after each login...
mv sharedinbox/* .
mv sharedinbox/.??* .
rmdir sharedinbox/
```
### 3b. Configure Git Identity
The new user needs a Git identity for commits and some scripts:
```bash
git config --global user.name "Your Name"
git config --global user.email "your.email@example.com"
```
### 4. Install System Dependencies
This project uses **Nix** with flakes to manage its toolchain (Flutter, Dart, Stalwart, etc.).
```
mkdir -p .config/nix
echo "experimental-features = nix-command flakes" >> ~/.config/nix/nix.conf
nix profile add nixpkgs#direnv
nix profile add nixpkgs#nix-direnv
echo 'eval "$(direnv hook bash)"' >> ~/.bashrc
source ~/.bashrc
.config/direnv/direnv.toml
```
[global]
hide_env_diff = true
#log_filter = "^$"
[whitelist]
prefix = [ "/home/DEV_USER-CHANGE_THAT" ]
```
### 4b. Additional Permissions (GUI & Android)
1. **GUI Access**: To run the Linux app (`task run`) from the `si` user, you must allow it to access your X server. Run this **from your main user terminal**:
```bash
xhost +local:$DEV_USER
```
2. **Android Emulator (KVM)**: If you plan to use the Android emulator, add the user to the `kvm` group:
```bash
sudo usermod -aG kvm $DEV_USER
```
### 5. Project Setup
Once you are in the project directory and have the dependencies installed:
1. **Initialize Environment**:
```bash
cp .env.example .env
```
2. **Allow direnv**:
```bash
direnv allow
```
*This will trigger Nix to download and set up the environment (Flutter, Android SDK, etc.). It might take some time on the first run.*
3. **Install Flutter (via FVM)**:
Nix provides FVM, which manages the pinned Flutter version.
```bash
fvm install
```
4. **Initial Setup**:
Run the comprehensive setup command which handles `pub get`, code generation, and git hooks:
```bash
task setup
```
### 6. Verify the Setup
Run the full check suite to ensure everything is working correctly:
```bash
task check
```
### 7. Running the App
To run the app on your Linux desktop:
```bash
task run
```
---
## Working with VS Code
To maintain isolation, it is recommended to run VS Code "remotely" on the dedicated development user.
### Preferred Method: VS Code Remote - SSH
The most robust way to work with a separate user is using the **VS Code Remote - SSH** extension. This allows you to run the VS Code Server as the `si` user while using your main user's GUI.
1. **Install the Extension**: Install "Remote - SSH" from the VS Code Marketplace.
2. **Enable SSH for the Dev User**:
From your main user, copy your SSH public key to the dev user:
```bash
# As your main user:
sudo mkdir -p /home/$DEV_USER/.ssh
sudo cp ~/.ssh/id_rsa.pub /home/$DEV_USER/.ssh/authorized_keys
sudo chown -R $DEV_USER:$DEV_USER /home/$DEV_USER/.ssh
sudo chmod 700 /home/$DEV_USER/.ssh
sudo chmod 600 /home/$DEV_USER/.ssh/authorized_keys
```
3. **Connect**:
In VS Code, open the Command Palette (`Ctrl+Shift+P`) and select `Remote-SSH: Connect to Host...`.
Enter: `si@localhost` (or `$DEV_USER@localhost`).
4. **Install Extensions in the Remote**:
Once connected, you will need to install the following extensions *on the remote user*:
* **Dart** / **Flutter**
* **direnv**: (by mkhl) Highly recommended to automatically load the Nix environment inside VS Code.
* **Nix IDE**: For syntax highlighting.
### Why SSH?
Using SSH to `localhost` is preferred over complex X11/Wayland permission hacks. It provides a clean boundary for the VS Code process and any integrated terminal or coding agents, ensuring they cannot access your personal files in `/home/$YOUR_USER`.
> **Note on Security:** While these instructions add the user to the `sudo` group for convenience during setup, you can remove it later with `sudo gpasswd -d $DEV_USER sudo` to further restrict the user and any coding agents.
---
## Daily Workflow
Refer to the [README.md](./README.md#daily-workflow) for common development tasks and commands.
+2 -2
View File
@@ -348,7 +348,7 @@ tasks:
generates:
- build/app/outputs/flutter-apk/app-release.apk
cmds:
- ANDROID_HOME=${ANDROID_HOME:-$HOME/Android/Sdk} fvm flutter build apk --release --no-pub | grep -Ev "was tree-shaken|Tree-shaking can be disabled"
- ANDROID_HOME=${ANDROID_HOME:-$HOME/Android/Sdk} fvm flutter build apk --release --no-pub --dart-define=GIT_HASH=$(git rev-parse --short HEAD) | grep -Ev "was tree-shaken|Tree-shaking can be disabled"
deploy-android-bundle:
desc: Build release AAB and upload to Play Store internal track
@@ -370,7 +370,7 @@ tasks:
generates:
- build/app/outputs/bundle/release/app-release.aab
cmds:
- ANDROID_HOME=${ANDROID_HOME:-$HOME/Android/Sdk} fvm flutter build appbundle --release --no-pub --build-number $(date +%s) --build-name $(date +%y%m%d-%H%M) | grep -Ev "was tree-shaken|Tree-shaking can be disabled"
- ANDROID_HOME=${ANDROID_HOME:-$HOME/Android/Sdk} fvm flutter build appbundle --release --no-pub --build-number $(date +%s) --build-name $(date +%y%m%d-%H%M) --dart-define=GIT_HASH=$(git rev-parse --short HEAD) | grep -Ev "was tree-shaken|Tree-shaking can be disabled"
deploy-android:
desc: Build release APK and upload via scp to $ANDROID_APK_SCP_USER@$ANDROID_APK_SCP_HOST:$ANDROID_APK_SCP_PATH
+2
View File
@@ -1,5 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.CAMERA"/>
<uses-feature android:name="android.hardware.camera" android:required="false"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/>
+1 -1
View File
@@ -11,7 +11,7 @@
outputs = { self, nixpkgs, flake-utils, dagger }:
flake-utils.lib.eachDefaultSystem (system:
let
pkgs = import nixpkgs { inherit system; };
pkgs = nixpkgs.legacyPackages.${system};
# All Linux desktop runtime libraries needed by flutter build linux and
# the UI integration tests (xvfb-run). Kept as a list so we can reuse
+1 -1
View File
@@ -179,7 +179,7 @@ void main() {
};
addTearDown(() => FlutterError.onError = bindingError);
await pumpUntil(tester, find.text('Welcome to SharedInbox'));
await pumpUntil(tester, find.text('Welcome to sharedinbox.de'));
_log('app settled');
// ── Add account ────────────────────────────────────────────────────────
@@ -0,0 +1,13 @@
import 'dart:typed_data';
import 'package:sharedinbox/core/services/share_encryption_service.dart';
/// Stores and retrieves ephemeral X25519 key pairs for secure account sharing.
abstract class ShareKeyRepository {
/// Generates a new key pair and persists it with a 20-minute expiry.
Future<ShareKeyMaterial> createKeyPair();
/// Returns the key pair whose ID matches [keyId], or null if not found /
/// expired.
Future<ShareKeyMaterial?> findByKeyId(Uint8List keyId);
}
@@ -0,0 +1,295 @@
import 'dart:convert';
import 'dart:math';
import 'dart:typed_data';
import 'package:cryptography/cryptography.dart';
const _pubKeyPrefix = 'sharedinbox.de:pubkey:v1:';
const _encAccountsPrefix = 'sharedinbox.de:encrypted-accounts:v1:';
// ECIES wire sizes (bytes).
const _keyIdLen = 16;
const _pubKeyLen = 32;
const _nonceLen = 12;
const _macLen = 16;
/// Describes a freshly generated key pair before it is written to the database.
class ShareKeyMaterial {
const ShareKeyMaterial({
required this.keyId,
required this.publicKeyBytes,
required this.privateKeyBytes,
});
/// Random 16-byte identifier (hex-encoded when stored / included in QR).
final Uint8List keyId;
/// X25519 public key, 32 bytes.
final Uint8List publicKeyBytes;
/// X25519 private key, 32 bytes.
final Uint8List privateKeyBytes;
}
/// An account + password pair, used in the plaintext payload before encryption.
class AccountPayload {
const AccountPayload({required this.accountJson, required this.password});
final Map<String, dynamic> accountJson;
final String password;
}
/// Pure-Dart cryptographic helpers for the secure account-sharing flow.
///
/// Protocol:
/// Receiver generates an X25519 key pair with 20-minute lifetime and shows
/// its public key as a QR code. The sender scans that QR, encrypts the
/// selected account(s) using ECIES (X25519-ECDH + HKDF-SHA256 + AES-256-GCM)
/// and shows the encrypted payload as a QR code. The receiver scans that QR,
/// looks up the private key by the embedded key-ID, and decrypts.
class ShareEncryptionService {
static final _x25519 = X25519();
static final _aesGcm = AesGcm.with256bits();
static final _hkdf = Hkdf(hmac: Hmac.sha256(), outputLength: 32);
static final _rng = Random.secure();
// ── Key generation ──────────────────────────────────────────────────────────
static Future<ShareKeyMaterial> generateKeyPair() async {
final keyId = Uint8List(_keyIdLen);
for (var i = 0; i < _keyIdLen; i++) {
keyId[i] = _rng.nextInt(256);
}
final keyPair = await _x25519.newKeyPair();
final pub = await keyPair.extractPublicKey();
final priv = await keyPair.extractPrivateKeyBytes();
return ShareKeyMaterial(
keyId: keyId,
publicKeyBytes: Uint8List.fromList(pub.bytes),
privateKeyBytes: Uint8List.fromList(priv),
);
}
// ── Public-key QR encoding / parsing ────────────────────────────────────────
/// Encodes the receiver's public key as a QR-code string.
///
/// Format: `sharedinbox.de:pubkey:v1:<base64(keyId[16] || pubKey[32])>`
static String encodePublicKeyQr(Uint8List keyId, Uint8List publicKeyBytes) {
assert(keyId.length == _keyIdLen);
assert(publicKeyBytes.length == _pubKeyLen);
final data = Uint8List(_keyIdLen + _pubKeyLen)
..setAll(0, keyId)
..setAll(_keyIdLen, publicKeyBytes);
return '$_pubKeyPrefix${base64.encode(data)}';
}
/// Parses a public-key QR string. Returns null if the format is invalid.
static ({Uint8List keyId, Uint8List publicKeyBytes})? parsePublicKeyQr(
String s,
) {
if (!s.startsWith(_pubKeyPrefix)) return null;
try {
final data =
Uint8List.fromList(base64.decode(s.substring(_pubKeyPrefix.length)));
if (data.length != _keyIdLen + _pubKeyLen) return null;
return (
keyId: data.sublist(0, _keyIdLen),
publicKeyBytes: data.sublist(_keyIdLen),
);
} catch (_) {
return null;
}
}
// ── Encryption ───────────────────────────────────────────────────────────────
/// Encrypts [accounts] for the given recipient key pair using ECIES.
///
/// Returns the QR-code string to show on the sender device.
///
/// Wire format (base64-encoded):
/// keyId[16] || ephPubKey[32] || nonce[12] || ciphertext || mac[16]
static Future<String> encryptAccounts({
required Uint8List recipientKeyId,
required Uint8List recipientPublicKeyBytes,
required List<AccountPayload> accounts,
}) async {
// Build plaintext JSON.
final plaintext = utf8.encode(
jsonEncode({
'v': 2,
'issuedAt': DateTime.now().toUtc().toIso8601String(),
'accounts': accounts
.map((a) => {'account': a.accountJson, 'password': a.password})
.toList(),
}),
);
// Ephemeral sender key pair for forward-secrecy.
final ephKeyPair = await _x25519.newKeyPair();
final ephPub = await ephKeyPair.extractPublicKey();
// ECDH: shared secret = X25519(ephPriv, recipientPub).
final sharedSecret = await _x25519.sharedSecretKey(
keyPair: ephKeyPair,
remotePublicKey: SimplePublicKey(
recipientPublicKeyBytes,
type: KeyPairType.x25519,
),
);
// Derive AES key via HKDF-SHA256.
final aesKey = await _hkdf.deriveKey(
secretKey: sharedSecret,
nonce: recipientKeyId,
info: utf8.encode('sharedinbox-account-transfer'),
);
// Encrypt with AES-256-GCM.
final nonce = Uint8List(_nonceLen);
for (var i = 0; i < _nonceLen; i++) {
nonce[i] = _rng.nextInt(256);
}
final box = await _aesGcm.encrypt(
plaintext,
secretKey: aesKey,
nonce: nonce,
);
// Pack wire format.
final ephPubBytes = Uint8List.fromList(ephPub.bytes);
final cipherBytes = Uint8List.fromList(box.cipherText);
final macBytes = Uint8List.fromList(box.mac.bytes);
final out = Uint8List(
_keyIdLen + _pubKeyLen + _nonceLen + cipherBytes.length + _macLen,
)
..setAll(0, recipientKeyId)
..setAll(_keyIdLen, ephPubBytes)
..setAll(_keyIdLen + _pubKeyLen, nonce)
..setAll(_keyIdLen + _pubKeyLen + _nonceLen, cipherBytes)
..setAll(
_keyIdLen + _pubKeyLen + _nonceLen + cipherBytes.length,
macBytes,
);
return '$_encAccountsPrefix${base64.encode(out)}';
}
// ── Decryption ───────────────────────────────────────────────────────────────
/// Parses and decrypts an encrypted-accounts QR string.
///
/// Throws [FormatException] if the format is invalid.
/// Throws [SecretBoxAuthenticationError] if authentication fails (tampered).
static Future<List<AccountPayload>> decryptAccounts({
required String qrString,
required Uint8List privateKeyBytes,
required Uint8List publicKeyBytes,
required Uint8List keyId,
}) async {
if (!qrString.startsWith(_encAccountsPrefix)) {
throw const FormatException('Not an encrypted-accounts QR code');
}
final Uint8List data;
try {
data = Uint8List.fromList(
base64.decode(qrString.substring(_encAccountsPrefix.length)),
);
} catch (_) {
throw const FormatException('Invalid base64 in encrypted-accounts QR');
}
// Minimum: keyId + ephPubKey + nonce + mac (no ciphertext is valid but odd).
if (data.length < _keyIdLen + _pubKeyLen + _nonceLen + _macLen) {
throw const FormatException('Encrypted-accounts payload too short');
}
final embeddedKeyId = data.sublist(0, _keyIdLen);
// Verify that this payload was encrypted for the right key pair.
for (var i = 0; i < _keyIdLen; i++) {
if (embeddedKeyId[i] != keyId[i]) {
throw const FormatException(
'Key ID mismatch — please scan a fresh public-key QR code',
);
}
}
final ephPubBytes = data.sublist(_keyIdLen, _keyIdLen + _pubKeyLen);
final nonce = data.sublist(
_keyIdLen + _pubKeyLen,
_keyIdLen + _pubKeyLen + _nonceLen,
);
final cipherText = data.sublist(
_keyIdLen + _pubKeyLen + _nonceLen,
data.length - _macLen,
);
final mac = data.sublist(data.length - _macLen);
// Reconstruct key pair.
final keyPair = SimpleKeyPairData(
privateKeyBytes,
publicKey: SimplePublicKey(publicKeyBytes, type: KeyPairType.x25519),
type: KeyPairType.x25519,
);
// ECDH.
final sharedSecret = await _x25519.sharedSecretKey(
keyPair: keyPair,
remotePublicKey: SimplePublicKey(ephPubBytes, type: KeyPairType.x25519),
);
// Re-derive AES key.
final aesKey = await _hkdf.deriveKey(
secretKey: sharedSecret,
nonce: keyId,
info: utf8.encode('sharedinbox-account-transfer'),
);
// Decrypt — throws SecretBoxAuthenticationError if tampered.
final plaintext = await _aesGcm.decrypt(
SecretBox(cipherText, nonce: nonce, mac: Mac(mac)),
secretKey: aesKey,
);
// Parse JSON.
final Map<String, dynamic> json;
try {
json = jsonDecode(utf8.decode(plaintext)) as Map<String, dynamic>;
} catch (_) {
throw const FormatException('Decrypted payload is not valid JSON');
}
if ((json['v'] as int?) != 2) {
throw const FormatException('Unsupported encrypted-accounts version');
}
// Verify issuedAt is within 20 minutes.
final issuedAtRaw = json['issuedAt'] as String?;
if (issuedAtRaw != null) {
final issuedAt = DateTime.tryParse(issuedAtRaw);
if (issuedAt != null) {
final age = DateTime.now().toUtc().difference(issuedAt.toUtc());
if (age.abs() > const Duration(minutes: 20)) {
throw const FormatException(
'The encrypted payload has expired (older than 20 minutes)',
);
}
}
}
final rawAccounts = json['accounts'] as List<dynamic>;
return rawAccounts.map((entry) {
final m = entry as Map<String, dynamic>;
return AccountPayload(
accountJson: m['account'] as Map<String, dynamic>,
password: m['password'] as String,
);
}).toList();
}
}
+17
View File
@@ -0,0 +1,17 @@
sealed class SieveAction {}
final class FileIntoAction extends SieveAction {
FileIntoAction(this.folder);
final String folder;
}
final class KeepAction extends SieveAction {}
final class DiscardAction extends SieveAction {}
final class MarkAsSeenAction extends SieveAction {}
final class FlagAction extends SieveAction {
FlagAction(this.flags);
final List<String> flags;
}
+14
View File
@@ -0,0 +1,14 @@
sealed class SieveCondition {}
final class HeaderCondition extends SieveCondition {
HeaderCondition(this.headers, this.matchType, this.keyList);
final List<String> headers;
final String matchType; // ':contains', ':is', ':matches'
final List<String> keyList;
}
final class SizeCondition extends SieveCondition {
SizeCondition(this.comparison, this.bytes);
final String comparison; // ':over' or ':under'
final int bytes;
}
+135
View File
@@ -0,0 +1,135 @@
import 'package:sharedinbox/core/sieve/sieve_actions.dart';
import 'package:sharedinbox/core/sieve/sieve_conditions.dart';
import 'package:sharedinbox/core/sieve/sieve_rule.dart';
/// A lightweight email representation used by [SieveInterpreter].
/// Header names are lower-cased.
class SieveEmailContext {
const SieveEmailContext({required this.headers, this.sizeBytes = 0});
final Map<String, List<String>> headers;
final int sizeBytes;
List<String> getHeader(String name) =>
headers[name.toLowerCase()] ?? const [];
}
/// Tracks the outcome of running a Sieve script against one email.
class SieveExecutionContext {
bool isCancelled = false;
Set<String> targetFolders = {};
Set<String> flagsToAdd = {};
bool keepInInbox = true;
}
/// Evaluates a compiled list of [SieveRule]s against a [SieveEmailContext].
class SieveInterpreter {
/// Executes [rules] and returns the resulting [SieveExecutionContext].
///
/// Rules produced by [SieveParser] may carry a [SieveRule.branchGroupId]
/// to represent if/elsif/else chains; at most one branch per group fires.
SieveExecutionContext execute(
List<SieveRule> rules,
SieveEmailContext email,
) {
final ctx = SieveExecutionContext();
final firedGroups = <int>{};
for (final rule in rules) {
if (ctx.isCancelled) break;
final groupId = rule.branchGroupId;
if (groupId != null && firedGroups.contains(groupId)) continue;
bool matches;
if (rule.isElseBranch) {
matches = true; // else fires unconditionally (group not yet consumed)
} else {
matches = _evaluateConditions(rule, email);
}
if (matches) {
_applyActions(rule.actions, ctx);
if (groupId != null) firedGroups.add(groupId);
if (ctx.isCancelled) break;
}
}
// Implicit keep: if no fileinto/discard was reached, email stays in inbox.
return ctx;
}
bool _evaluateConditions(SieveRule rule, SieveEmailContext email) {
if (rule.conditions.isEmpty) return true;
return switch (rule.joinType) {
'allof' => rule.conditions.every((c) => _evalCondition(c, email)),
'anyof' => rule.conditions.any((c) => _evalCondition(c, email)),
_ => rule.conditions.length == 1 &&
_evalCondition(rule.conditions.first, email),
};
}
bool _evalCondition(SieveCondition cond, SieveEmailContext email) {
return switch (cond) {
final HeaderCondition c => _evalHeader(c, email),
final SizeCondition c => _evalSize(c, email),
};
}
bool _evalHeader(HeaderCondition cond, SieveEmailContext email) {
for (final header in cond.headers) {
final values = email.getHeader(header);
for (final value in values) {
for (final key in cond.keyList) {
if (_matchString(value, cond.matchType, key)) return true;
}
}
}
return false;
}
bool _evalSize(SizeCondition cond, SieveEmailContext email) {
return switch (cond.comparison) {
':over' => email.sizeBytes > cond.bytes,
':under' => email.sizeBytes < cond.bytes,
_ => false,
};
}
bool _matchString(String value, String matchType, String key) {
final v = value.toLowerCase();
final k = key.toLowerCase();
return switch (matchType) {
':contains' => k.isEmpty || v.contains(k),
':is' => v == k,
':matches' => _globMatch(v, k),
_ => false,
};
}
bool _globMatch(String value, String pattern) {
final regexStr =
RegExp.escape(pattern).replaceAll(r'\*', '.*').replaceAll(r'\?', '.');
return RegExp('^$regexStr\$').hasMatch(value);
}
void _applyActions(List<SieveAction> actions, SieveExecutionContext ctx) {
for (final action in actions) {
switch (action) {
case final FileIntoAction a:
ctx.targetFolders.add(a.folder);
ctx.keepInInbox = false;
case DiscardAction():
ctx.isCancelled = true;
ctx.keepInInbox = false;
return;
case KeepAction():
ctx.keepInInbox = true;
case MarkAsSeenAction():
ctx.flagsToAdd.add(r'\Seen');
case final FlagAction a:
ctx.flagsToAdd.addAll(a.flags);
}
}
}
}
+593
View File
@@ -0,0 +1,593 @@
import 'package:sharedinbox/core/sieve/sieve_actions.dart';
import 'package:sharedinbox/core/sieve/sieve_conditions.dart';
import 'package:sharedinbox/core/sieve/sieve_rule.dart';
/// Parses a Sieve script (RFC 5228 subset) into a flat list of [SieveRule]s.
///
/// Supported commands: require, if, elsif, else, fileinto, keep, discard,
/// flag, setflag, addflag, stop.
/// Supported tests: header, address, size, exists, allof, anyof, not, true.
/// Supported match types: :contains, :is, :matches.
class SieveParser {
List<SieveRule> parse(String script) {
final scanner = _Scanner(script);
final rules = <SieveRule>[];
_parseStatements(scanner, rules);
return rules;
}
void _parseStatements(_Scanner s, List<SieveRule> out) {
while (!s.isAtEnd) {
s.skipWhitespaceAndComments();
if (s.isAtEnd) break;
final word = s.peekWord();
if (word == null) break;
if (word == 'require') {
_parseRequire(s);
} else if (word == 'if') {
_parseIf(s, out);
} else if (word == 'elsif' || word == 'else') {
// Reached by _parseIf, should not appear at top level.
break;
} else if (word == '}') {
break;
} else {
final action = _tryParseAction(s);
if (action != null) {
out.add(
SieveRule(
joinType: 'single',
conditions: const [],
actions: [action],
),
);
} else {
s.skipToNextSemicolon();
}
}
}
}
void _parseRequire(_Scanner s) {
s.expectWord('require');
s.skipWhitespaceAndComments();
_parseStringOrList(s); // discard capability list
s.skipWhitespaceAndComments();
s.expectChar(';');
}
// Monotonically increasing id shared per parse run, threaded via closure.
int _groupCounter = 0;
void _parseIf(_Scanner s, List<SieveRule> out) {
final groupId = ++_groupCounter;
s.expectWord('if');
s.skipWhitespaceAndComments();
final (joinType, conditions) = _parseTest(s);
s.skipWhitespaceAndComments();
final ifActions = _parseBlock(s);
out.add(
SieveRule(
joinType: joinType,
conditions: conditions,
actions: ifActions,
branchGroupId: groupId,
),
);
// Parse zero or more elsif branches.
while (true) {
s.skipWhitespaceAndComments();
if (s.peekWord() != 'elsif') break;
s.expectWord('elsif');
s.skipWhitespaceAndComments();
final (ej, ec) = _parseTest(s);
s.skipWhitespaceAndComments();
final elsifActions = _parseBlock(s);
out.add(
SieveRule(
joinType: ej,
conditions: ec,
actions: elsifActions,
branchGroupId: groupId,
),
);
}
// Optional else branch.
s.skipWhitespaceAndComments();
if (s.peekWord() == 'else') {
s.expectWord('else');
s.skipWhitespaceAndComments();
final elseActions = _parseBlock(s);
out.add(
SieveRule(
joinType: 'single',
conditions: const [],
actions: elseActions,
branchGroupId: groupId,
isElseBranch: true,
),
);
}
}
List<SieveAction> _parseBlock(_Scanner s) {
s.expectChar('{');
final blockRules = <SieveRule>[];
_parseStatements(s, blockRules);
s.skipWhitespaceAndComments();
s.expectChar('}');
return blockRules.expand((r) => r.actions).toList();
}
/// Returns (joinType, conditions).
(String, List<SieveCondition>) _parseTest(_Scanner s) {
s.skipWhitespaceAndComments();
final word = s.peekWord();
if (word == 'allof' || word == 'anyof') {
s.readWord();
s.skipWhitespaceAndComments();
s.expectChar('(');
final conditions = <SieveCondition>[];
while (true) {
s.skipWhitespaceAndComments();
if (s.peek() == ')') break;
final (_, conds) = _parseTest(s);
conditions.addAll(conds);
s.skipWhitespaceAndComments();
if (s.peek() == ',') {
s.advance();
} else {
break;
}
}
s.skipWhitespaceAndComments();
s.expectChar(')');
return (word!, conditions);
}
final cond = _parseSingleTest(s);
return ('single', cond != null ? [cond] : []);
}
SieveCondition? _parseSingleTest(_Scanner s) {
s.skipWhitespaceAndComments();
final word = s.peekWord()?.toLowerCase();
if (word == null) return null;
if (word == 'not') {
s.readWord();
s.skipWhitespaceAndComments();
// Negation is not represented in the flat rule model; the caller
// should handle the negated condition separately. For now we parse
// and return the inner condition unchanged (best-effort for this subset).
return _parseSingleTest(s);
}
if (word == 'true') {
s.readWord();
return null; // no condition = always matches
}
if (word == 'header' || word == 'address') {
s.readWord();
s.skipWhitespaceAndComments();
final matchType = _parseMatchType(s);
s.skipWhitespaceAndComments();
// Consume optional :comparator "..." tagged argument.
if (s.peekTaggedArg() == ':comparator') {
s.readWord();
s.skipWhitespaceAndComments();
_parseStringOrList(s); // discard comparator value
s.skipWhitespaceAndComments();
}
final headers = _parseStringOrList(s);
s.skipWhitespaceAndComments();
final keys = _parseStringOrList(s);
return HeaderCondition(headers, matchType, keys);
}
if (word == 'exists') {
s.readWord();
s.skipWhitespaceAndComments();
final headers = _parseStringOrList(s);
// Represent exists as :contains "" so any non-empty value matches.
return HeaderCondition(headers, ':contains', const ['']);
}
if (word == 'size') {
s.readWord();
s.skipWhitespaceAndComments();
final comp = s.readTaggedArg(); // :over or :under
s.skipWhitespaceAndComments();
final bytes = _parseSizeNumber(s);
return SizeCondition(comp, bytes);
}
// Unknown test — skip to closing paren or brace.
s.readWord();
return null;
}
String _parseMatchType(_Scanner s) {
s.skipWhitespaceAndComments();
final tag = s.peekTaggedArg();
if (tag == ':contains' || tag == ':is' || tag == ':matches') {
s.readWord();
return tag!;
}
// Default per RFC 5228 is :is.
return ':is';
}
List<String> _parseStringOrList(_Scanner s) {
s.skipWhitespaceAndComments();
if (s.peek() == '[') {
s.advance(); // consume '['
final items = <String>[];
while (true) {
s.skipWhitespaceAndComments();
if (s.peek() == ']') {
s.advance();
break;
}
items.add(_parseString(s));
s.skipWhitespaceAndComments();
if (s.peek() == ',') {
s.advance();
}
}
return items;
}
return [_parseString(s)];
}
String _parseString(_Scanner s) {
s.skipWhitespaceAndComments();
if (s.peek() == '"') {
return s.readQuotedString();
}
// Multi-line text: text:...\r\n.\r\n (RFC 5228 §2.4.2)
if (s.peekWord()?.toLowerCase() == 'text:') {
return s.readTextBlock();
}
throw SieveParseException(
'Expected string at position ${s.position}: "${s.remaining.substring(0, 20)}"',
);
}
int _parseSizeNumber(_Scanner s) {
final digits = s.readDigits();
final value = int.parse(digits);
final unit = s.peekSizeUnit();
if (unit != null) {
s.advance();
return switch (unit.toUpperCase()) {
'K' => value * 1024,
'M' => value * 1024 * 1024,
'G' => value * 1024 * 1024 * 1024,
_ => value,
};
}
return value;
}
SieveAction? _tryParseAction(_Scanner s) {
s.skipWhitespaceAndComments();
final word = s.peekWord()?.toLowerCase();
if (word == null) return null;
if (word == 'fileinto') {
s.readWord();
s.skipWhitespaceAndComments();
final folder = _parseString(s);
s.skipWhitespaceAndComments();
s.expectChar(';');
return FileIntoAction(folder);
}
if (word == 'keep') {
s.readWord();
s.skipWhitespaceAndComments();
s.expectChar(';');
return KeepAction();
}
if (word == 'discard') {
s.readWord();
s.skipWhitespaceAndComments();
s.expectChar(';');
return DiscardAction();
}
if (word == 'stop') {
s.readWord();
s.skipWhitespaceAndComments();
s.expectChar(';');
return KeepAction(); // stop with no prior action = implicit keep
}
if (word == 'flag' || word == 'setflag' || word == 'addflag') {
s.readWord();
s.skipWhitespaceAndComments();
// Optional variable name (string arg before the flag list).
final peek = s.peek();
List<String> flags;
if (peek == '"') {
final first = _parseString(s);
s.skipWhitespaceAndComments();
if (s.peek() == '[' || s.peek() == '"') {
// first was the variable name, next is the flag list
flags = _parseStringOrList(s);
} else {
flags = [first];
}
} else {
flags = _parseStringOrList(s);
}
s.skipWhitespaceAndComments();
s.expectChar(';');
if (flags.any(
(f) => f.toLowerCase() == r'\seen' || f.toLowerCase() == r'\\seen',
)) {
return MarkAsSeenAction();
}
return FlagAction(flags);
}
if (word == 'mark') {
s.readWord();
s.skipWhitespaceAndComments();
s.expectChar(';');
return MarkAsSeenAction();
}
return null;
}
}
// ---------------------------------------------------------------------------
// Low-level scanner
// ---------------------------------------------------------------------------
class SieveParseException implements Exception {
SieveParseException(this.message);
final String message;
@override
String toString() => 'SieveParseException: $message';
}
class _Scanner {
_Scanner(this._src);
final String _src;
int _pos = 0;
int get position => _pos;
bool get isAtEnd => _pos >= _src.length;
String get remaining => _pos < _src.length ? _src.substring(_pos) : '';
String? peek() {
if (isAtEnd) return null;
return _src[_pos];
}
String advance() {
if (isAtEnd) throw SieveParseException('Unexpected end of input');
return _src[_pos++];
}
void skipWhitespaceAndComments() {
while (!isAtEnd) {
final ch = _src[_pos];
if (ch == ' ' || ch == '\t' || ch == '\r' || ch == '\n') {
_pos++;
} else if (ch == '#') {
// Line comment — skip to end of line.
while (!isAtEnd && _src[_pos] != '\n') {
_pos++;
}
} else if (_pos + 1 < _src.length && ch == '/' && _src[_pos + 1] == '*') {
// Block comment.
_pos += 2;
while (_pos + 1 < _src.length) {
if (_src[_pos] == '*' && _src[_pos + 1] == '/') {
_pos += 2;
break;
}
_pos++;
}
} else {
break;
}
}
}
/// Peeks at the next word-like token (letters/digits/underscores/colons for
/// tagged args, and special single-char tokens like `{`, `}`, `;`).
String? peekWord() {
if (isAtEnd) return null;
final ch = _src[_pos];
if ('{}();[],'.contains(ch)) return ch;
if (ch == ':') {
// Tagged arg like :contains
final start = _pos;
var end = _pos + 1;
while (end < _src.length && _isWordChar(_src[end])) {
end++;
}
return _src.substring(start, end).toLowerCase();
}
if (_isWordChar(ch)) {
final start = _pos;
var end = _pos + 1;
while (
end < _src.length && (_isWordChar(_src[end]) || _src[end] == ':')) {
// Include trailing colon for "text:" multiline token.
if (_src[end] == ':') {
end++;
break;
}
end++;
}
return _src.substring(start, end).toLowerCase();
}
return null;
}
String readWord() {
final start = _pos;
final ch = _src[_pos];
if ('{}();[],'.contains(ch)) {
_pos++;
return ch;
}
if (ch == ':') {
_pos++;
while (!isAtEnd && _isWordChar(_src[_pos])) {
_pos++;
}
} else {
while (!isAtEnd && (_isWordChar(_src[_pos]) || _src[_pos] == ':')) {
if (_src[_pos] == ':') {
_pos++;
break;
}
_pos++;
}
}
return _src.substring(start, _pos).toLowerCase();
}
String? peekTaggedArg() {
if (!isAtEnd && _src[_pos] == ':') return peekWord();
return null;
}
String readTaggedArg() {
if (!isAtEnd && _src[_pos] == ':') return readWord();
throw SieveParseException(
'Expected tagged argument at position $_pos',
);
}
String? peekSizeUnit() {
if (isAtEnd) return null;
final ch = _src[_pos].toUpperCase();
if (ch == 'K' || ch == 'M' || ch == 'G') return ch;
return null;
}
String readDigits() {
if (isAtEnd || !_isDigit(_src[_pos])) {
throw SieveParseException(
'Expected number at position $_pos',
);
}
final start = _pos;
while (!isAtEnd && _isDigit(_src[_pos])) {
_pos++;
}
return _src.substring(start, _pos);
}
String readQuotedString() {
if (_src[_pos] != '"') {
throw SieveParseException(
'Expected " at position $_pos',
);
}
_pos++; // skip opening quote
final buf = StringBuffer();
while (!isAtEnd) {
final ch = _src[_pos];
if (ch == '"') {
_pos++;
return buf.toString();
}
if (ch == '\\' && _pos + 1 < _src.length) {
_pos++;
buf.write(_src[_pos]);
_pos++;
} else {
buf.write(ch);
_pos++;
}
}
throw SieveParseException('Unterminated string');
}
/// Parses a `text:` multi-line block (RFC 5228 §2.4.2).
/// Format: `text:\r\n<lines>\r\n.\r\n`
String readTextBlock() {
// Consume "text:"
while (!isAtEnd && _src[_pos] != ':') {
_pos++;
}
if (!isAtEnd) _pos++; // skip ':'
// Skip optional whitespace then newline.
while (!isAtEnd && (_src[_pos] == ' ' || _src[_pos] == '\t')) {
_pos++;
}
if (!isAtEnd && _src[_pos] == '\r') _pos++;
if (!isAtEnd && _src[_pos] == '\n') _pos++;
final buf = StringBuffer();
while (!isAtEnd) {
// Check for terminator: a lone "." on its own line.
if (_src[_pos] == '.' &&
(_pos + 1 >= _src.length ||
_src[_pos + 1] == '\r' ||
_src[_pos + 1] == '\n')) {
_pos++;
if (!isAtEnd && _src[_pos] == '\r') _pos++;
if (!isAtEnd && _src[_pos] == '\n') _pos++;
break;
}
buf.write(_src[_pos]);
_pos++;
}
return buf.toString();
}
void expectChar(String ch) {
skipWhitespaceAndComments();
if (isAtEnd || _src[_pos] != ch) {
throw SieveParseException(
'Expected "$ch" at position $_pos, got '
'"${isAtEnd ? "EOF" : _src[_pos]}"',
);
}
_pos++;
}
void expectWord(String word) {
skipWhitespaceAndComments();
final got = readWord();
if (got.toLowerCase() != word.toLowerCase()) {
throw SieveParseException(
'Expected "$word" at position $_pos, got "$got"',
);
}
}
void skipToNextSemicolon() {
while (!isAtEnd && _src[_pos] != ';') {
_pos++;
}
if (!isAtEnd) _pos++; // skip ';'
}
static bool _isWordChar(String ch) {
final c = ch.codeUnitAt(0);
return (c >= 0x41 && c <= 0x5A) || // A-Z
(c >= 0x61 && c <= 0x7A) || // a-z
(c >= 0x30 && c <= 0x39) || // 0-9
c == 0x5F || // _
c == 0x2D; // -
}
static bool _isDigit(String ch) {
final c = ch.codeUnitAt(0);
return c >= 0x30 && c <= 0x39;
}
}
+21
View File
@@ -0,0 +1,21 @@
import 'package:sharedinbox/core/sieve/sieve_actions.dart';
import 'package:sharedinbox/core/sieve/sieve_conditions.dart';
class SieveRule {
const SieveRule({
required this.joinType,
required this.conditions,
required this.actions,
this.branchGroupId,
this.isElseBranch = false,
});
// 'allof', 'anyof', or 'single'
final String joinType;
final List<SieveCondition> conditions;
final List<SieveAction> actions;
// Non-null groups this rule into an if/elsif/else chain.
final int? branchGroupId;
// True for the unconditional else branch.
final bool isElseBranch;
}
+24 -1
View File
@@ -238,6 +238,25 @@ class Drafts extends Table {
TextColumn get imapServerId => text().nullable()();
}
/// Ephemeral public/private key pair generated for secure account sharing.
/// Expires after 20 minutes; used to decrypt an incoming encrypted-accounts QR.
@DataClassName('ShareKeyRow')
class ShareKeys extends Table {
/// Random 16-byte key ID, hex-encoded. Identifies which key pair the sender
/// used so the receiver can look it up even if multiple pairs exist.
TextColumn get id => text()();
/// Base64-encoded X25519 public key (32 bytes).
TextColumn get publicKey => text()();
/// Base64-encoded X25519 private key (32 bytes).
TextColumn get privateKey => text()();
DateTimeColumn get expiresAt => dateTime()();
@override
Set<Column> get primaryKey => {id};
}
@DataClassName('SearchHistoryRow')
class SearchHistoryEntries extends Table {
IntColumn get id => integer().autoIncrement()();
@@ -286,13 +305,14 @@ class UndoActions extends Table {
UndoActions,
SearchHistoryEntries,
LocalSieveScripts,
ShareKeys,
],
)
class AppDatabase extends _$AppDatabase {
AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());
@override
int get schemaVersion => 30;
int get schemaVersion => 31;
Future<void> _createEmailFts() async {
await customStatement('''
@@ -527,6 +547,9 @@ class AppDatabase extends _$AppDatabase {
if (from >= 12 && from < 30) {
await m.addColumn(syncLogMailboxes, syncLogMailboxes.durationMs);
}
if (from < 31) {
await m.createTable(shareKeys);
}
},
);
}
@@ -0,0 +1,67 @@
import 'dart:convert';
import 'package:drift/drift.dart';
import 'package:sharedinbox/core/repositories/share_key_repository.dart';
import 'package:sharedinbox/core/services/share_encryption_service.dart';
import 'package:sharedinbox/data/db/database.dart';
/// Drift-backed implementation of [ShareKeyRepository].
///
/// Each key pair lives for 20 minutes. Expired rows are pruned whenever a
/// new key pair is created or looked up.
class ShareKeyRepositoryImpl implements ShareKeyRepository {
ShareKeyRepositoryImpl(this._db);
final AppDatabase _db;
@override
Future<ShareKeyMaterial> createKeyPair() async {
await _pruneExpired();
final material = await ShareEncryptionService.generateKeyPair();
final keyIdHex = _hex(material.keyId);
final expiresAt = DateTime.now().toUtc().add(const Duration(minutes: 20));
await _db.into(_db.shareKeys).insert(
ShareKeysCompanion.insert(
id: keyIdHex,
publicKey: base64.encode(material.publicKeyBytes),
privateKey: base64.encode(material.privateKeyBytes),
expiresAt: expiresAt,
),
);
return material;
}
@override
Future<ShareKeyMaterial?> findByKeyId(Uint8List keyId) async {
await _pruneExpired();
final keyIdHex = _hex(keyId);
final row = await (_db.select(_db.shareKeys)
..where((t) => t.id.equals(keyIdHex)))
.getSingleOrNull();
if (row == null) return null;
if (row.expiresAt.isBefore(DateTime.now().toUtc())) return null;
return ShareKeyMaterial(
keyId: keyId,
publicKeyBytes: Uint8List.fromList(base64.decode(row.publicKey)),
privateKeyBytes: Uint8List.fromList(base64.decode(row.privateKey)),
);
}
Future<void> _pruneExpired() async {
await (_db.delete(_db.shareKeys)
..where(
(t) => t.expiresAt.isSmallerThanValue(DateTime.now().toUtc()),
))
.go();
}
static String _hex(Uint8List bytes) =>
bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
}
+6
View File
@@ -10,6 +10,7 @@ import 'package:sharedinbox/core/repositories/draft_repository.dart';
import 'package:sharedinbox/core/repositories/email_repository.dart';
import 'package:sharedinbox/core/repositories/mailbox_repository.dart';
import 'package:sharedinbox/core/repositories/search_history_repository.dart';
import 'package:sharedinbox/core/repositories/share_key_repository.dart';
import 'package:sharedinbox/core/repositories/undo_repository.dart';
import 'package:sharedinbox/core/services/account_discovery_service.dart';
import 'package:sharedinbox/core/services/connection_test_service.dart';
@@ -28,6 +29,7 @@ import 'package:sharedinbox/data/repositories/draft_repository_impl.dart';
import 'package:sharedinbox/data/repositories/email_repository_impl.dart';
import 'package:sharedinbox/data/repositories/mailbox_repository_impl.dart';
import 'package:sharedinbox/data/repositories/search_history_repository_impl.dart';
import 'package:sharedinbox/data/repositories/share_key_repository_impl.dart';
import 'package:sharedinbox/data/repositories/sync_log_repository_impl.dart';
import 'package:sharedinbox/data/repositories/undo_repository_impl.dart';
import 'package:sharedinbox/data/storage/flutter_secure_storage_impl.dart';
@@ -61,6 +63,10 @@ final accountRepositoryProvider = Provider<AccountRepository>((ref) {
);
});
final shareKeyRepositoryProvider = Provider<ShareKeyRepository>((ref) {
return ShareKeyRepositoryImpl(ref.watch(dbProvider));
});
final mailboxRepositoryProvider = Provider<MailboxRepository>((ref) {
return MailboxRepositoryImpl(
ref.watch(dbProvider),
+1 -1
View File
@@ -70,7 +70,7 @@ class _SharedInboxAppState extends ConsumerState<SharedInboxApp> {
@override
Widget build(BuildContext context) {
return MaterialApp.router(
title: 'SharedInbox',
title: 'sharedinbox.de',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
useMaterial3: true,
+13 -10
View File
@@ -2,9 +2,10 @@ import 'package:go_router/go_router.dart';
import 'package:sharedinbox/core/models/sieve_script.dart';
import 'package:sharedinbox/ui/screens/account_export_screen.dart';
import 'package:sharedinbox/ui/screens/account_import_screen.dart';
import 'package:sharedinbox/ui/screens/about_screen.dart';
import 'package:sharedinbox/ui/screens/account_list_screen.dart';
import 'package:sharedinbox/ui/screens/account_receive_screen.dart';
import 'package:sharedinbox/ui/screens/account_send_screen.dart';
import 'package:sharedinbox/ui/screens/add_account_screen.dart';
import 'package:sharedinbox/ui/screens/address_emails_screen.dart';
import 'package:sharedinbox/ui/screens/changelog_screen.dart';
@@ -36,8 +37,12 @@ final router = GoRouter(
builder: (ctx, state) => const AddAccountScreen(),
),
GoRoute(
path: 'import',
builder: (ctx, state) => const AccountImportScreen(),
path: 'receive',
builder: (ctx, state) => const AccountReceiveScreen(),
),
GoRoute(
path: 'send',
builder: (ctx, state) => const AccountSendScreen(),
),
GoRoute(
path: 'undo-log',
@@ -48,14 +53,12 @@ final router = GoRouter(
builder: (ctx, state) => const ChangeLogScreen(),
),
GoRoute(
path: ':accountId/edit',
builder: (ctx, state) => EditAccountScreen(
accountId: state.pathParameters['accountId']!,
),
path: 'about',
builder: (ctx, state) => const AboutScreen(),
),
GoRoute(
path: ':accountId/export',
builder: (ctx, state) => AccountExportScreen(
path: ':accountId/edit',
builder: (ctx, state) => EditAccountScreen(
accountId: state.pathParameters['accountId']!,
),
),
+212
View File
@@ -0,0 +1,212 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:sharedinbox/core/models/account.dart';
import 'package:sharedinbox/di.dart';
import 'package:url_launcher/url_launcher.dart';
class AboutScreen extends ConsumerStatefulWidget {
const AboutScreen({super.key});
@override
ConsumerState<AboutScreen> createState() => _AboutScreenState();
}
class _AboutScreenState extends ConsumerState<AboutScreen> {
final Future<PackageInfo> _packageInfoFuture = PackageInfo.fromPlatform();
late final Stream<List<Account>> _accountsStream;
static const _gitHash = String.fromEnvironment('GIT_HASH');
@override
void initState() {
super.initState();
_accountsStream = ref.read(accountRepositoryProvider).observeAccounts();
}
String _buildMarkdown(
BuildContext context,
PackageInfo? pkg,
int imapCount,
int jmapCount,
) {
final size = MediaQuery.of(context).size;
final pixelRatio = MediaQuery.of(context).devicePixelRatio;
final physW = (size.width * pixelRatio).toInt();
final physH = (size.height * pixelRatio).toInt();
final version =
pkg != null ? '${pkg.version}+${pkg.buildNumber}' : 'unknown';
final versionDisplay = _gitHash.isNotEmpty
? '[$version](https://codeberg.org/guettli/sharedinbox/commit/$_gitHash)'
: version;
final osName = _capitalize(Platform.operatingSystem);
final isDark = MediaQuery.of(context).platformBrightness == Brightness.dark;
return '## sharedinbox.de\n\n'
'| Property | Value |\n'
'|----------|-------|\n'
'| App Version | $versionDisplay |\n'
'| Platform | ${Platform.operatingSystem} |\n'
'| $osName Version | ${Platform.operatingSystemVersion} |\n'
'| Resolution | ${physW}x$physH px'
' (logical: ${size.width.toInt()}x${size.height.toInt()} pt,'
' ratio: ${pixelRatio.toStringAsFixed(1)}x) |\n'
'| Dart Version | ${Platform.version.split(' ').first} |\n'
'| Processors | ${Platform.numberOfProcessors} |\n'
'| Dark Mode | ${isDark ? 'yes' : 'no'} |\n'
'| IMAP Accounts | $imapCount |\n'
'| JMAP Accounts | $jmapCount |\n';
}
static String _capitalize(String s) =>
s.isEmpty ? s : '${s[0].toUpperCase()}${s.substring(1)}';
Future<void> _copyToClipboard(
BuildContext context,
int imapCount,
int jmapCount,
) async {
PackageInfo? pkg;
try {
pkg = await _packageInfoFuture;
} catch (_) {}
if (!context.mounted) return;
await Clipboard.setData(
ClipboardData(
text: _buildMarkdown(context, pkg, imapCount, jmapCount),
),
);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
duration: Duration(seconds: 5),
content: Text('Copied to clipboard'),
),
);
}
}
Future<void> _createIssue(
BuildContext context,
int imapCount,
int jmapCount,
) async {
PackageInfo? pkg;
try {
pkg = await _packageInfoFuture;
} catch (_) {}
if (!context.mounted) return;
final body = Uri.encodeComponent(
_buildMarkdown(context, pkg, imapCount, jmapCount),
);
final url = Uri.parse(
'https://codeberg.org/guettli/sharedinbox/issues/new?body=$body',
);
try {
final launched =
await launchUrl(url, mode: LaunchMode.externalApplication);
if (!launched && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
duration: Duration(seconds: 5),
content: Text('Could not open browser.'),
),
);
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
duration: const Duration(seconds: 5),
content: Text('Error: $e'),
),
);
}
}
}
@override
Widget build(BuildContext context) {
return StreamBuilder<List<Account>>(
stream: _accountsStream,
builder: (context, accountSnapshot) {
final accounts = accountSnapshot.data ?? [];
final imapCount =
accounts.where((a) => a.type == AccountType.imap).length;
final jmapCount =
accounts.where((a) => a.type == AccountType.jmap).length;
return Scaffold(
appBar: AppBar(title: const Text('About')),
body: Column(
children: [
Expanded(
child: FutureBuilder<PackageInfo>(
future: _packageInfoFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
return Markdown(
data: _buildMarkdown(
context,
snapshot.data,
imapCount,
jmapCount,
),
selectable: true,
onTapLink: (text, href, title) {
if (href != null) {
unawaited(
launchUrl(
Uri.parse(href),
mode: LaunchMode.externalApplication,
),
);
}
},
);
},
),
),
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
child: Row(
children: [
Expanded(
child: OutlinedButton.icon(
icon: const Icon(Icons.copy),
label: const Text('Copy to clipboard'),
onPressed: () => unawaited(
_copyToClipboard(context, imapCount, jmapCount),
),
),
),
const SizedBox(width: 8),
Expanded(
child: FilledButton.icon(
icon: const Icon(Icons.bug_report),
label: const Text('Create issue'),
onPressed: () => unawaited(
_createIssue(context, imapCount, jmapCount),
),
),
),
],
),
),
],
),
);
},
);
}
}
-129
View File
@@ -1,129 +0,0 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:qr_flutter/qr_flutter.dart';
import 'package:sharedinbox/di.dart';
class AccountExportScreen extends ConsumerStatefulWidget {
const AccountExportScreen({super.key, required this.accountId});
final String accountId;
@override
ConsumerState<AccountExportScreen> createState() =>
_AccountExportScreenState();
}
class _AccountExportScreenState extends ConsumerState<AccountExportScreen> {
bool _loading = true;
String? _exportCode;
String? _error;
@override
void initState() {
super.initState();
unawaited(_load());
}
Future<void> _load() async {
try {
final repo = ref.read(accountRepositoryProvider);
final account = await repo.getAccount(widget.accountId);
if (account == null) {
setState(() {
_error = 'Account not found';
_loading = false;
});
return;
}
final password = await repo.getPassword(widget.accountId);
final payload = jsonEncode({
'v': 1,
'account': account.toJson(),
'password': password,
});
setState(() {
_exportCode = payload;
_loading = false;
});
} catch (e) {
setState(() {
_error = e.toString();
_loading = false;
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Export account')),
body: _buildBody(context),
);
}
Widget _buildBody(BuildContext context) {
if (_loading) return const Center(child: CircularProgressIndicator());
if (_error != null) {
return Center(child: Text('Error: $_error'));
}
final theme = Theme.of(context);
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Card(
color: theme.colorScheme.errorContainer,
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
Icon(Icons.warning_amber, color: theme.colorScheme.error),
const SizedBox(width: 8),
const Expanded(
child: Text(
'This code contains your password. Keep it private.',
),
),
],
),
),
),
const SizedBox(height: 24),
Center(
child: QrImageView(
key: const Key('accountQrCode'),
data: _exportCode!,
size: 260,
),
),
const SizedBox(height: 24),
OutlinedButton.icon(
key: const Key('copyCodeButton'),
icon: const Icon(Icons.copy),
label: const Text('Copy code'),
onPressed: () {
unawaited(Clipboard.setData(ClipboardData(text: _exportCode!)));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Code copied to clipboard')),
);
},
),
const SizedBox(height: 8),
const Text(
'Scan the QR code on your other device, or tap "Copy code" and '
'paste it into the "Import account" screen.',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 12),
),
],
),
);
}
}
-172
View File
@@ -1,172 +0,0 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:sharedinbox/core/models/account.dart';
import 'package:sharedinbox/di.dart';
class AccountImportScreen extends ConsumerStatefulWidget {
const AccountImportScreen({super.key});
@override
ConsumerState<AccountImportScreen> createState() =>
_AccountImportScreenState();
}
class _AccountImportScreenState extends ConsumerState<AccountImportScreen> {
final _ctrl = TextEditingController();
Account? _parsed;
String? _parsedPassword;
String? _parseError;
bool _saving = false;
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
void _onTextChanged(String value) {
final text = value.trim();
if (text.isEmpty) {
setState(() {
_parsed = null;
_parsedPassword = null;
_parseError = null;
});
return;
}
try {
final json = jsonDecode(text) as Map<String, dynamic>;
if ((json['v'] as int?) != 1) {
throw const FormatException('Unknown version');
}
final account = Account.fromJson(
json['account'] as Map<String, dynamic>,
);
final password = json['password'] as String;
setState(() {
_parsed = Account(
id: DateTime.now().millisecondsSinceEpoch.toString(),
displayName: account.displayName,
email: account.email,
username: account.username,
type: account.type,
imapHost: account.imapHost,
imapPort: account.imapPort,
imapSsl: account.imapSsl,
smtpHost: account.smtpHost,
smtpPort: account.smtpPort,
smtpSsl: account.smtpSsl,
manageSieveHost: account.manageSieveHost,
manageSievePort: account.manageSievePort,
manageSieveSsl: account.manageSieveSsl,
jmapUrl: account.jmapUrl,
);
_parsedPassword = password;
_parseError = null;
});
} catch (_) {
setState(() {
_parsed = null;
_parsedPassword = null;
_parseError =
'Invalid code — paste the full text from "Export account"';
});
}
}
Future<void> _import() async {
if (_parsed == null || _parsedPassword == null) return;
setState(() => _saving = true);
try {
await ref
.read(accountRepositoryProvider)
.addAccount(_parsed!, _parsedPassword!);
if (mounted) context.pop();
} catch (e) {
if (mounted) {
setState(() => _saving = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Import failed: $e')),
);
}
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(title: const Text('Import account')),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text(
'On your other device, open the account menu and tap '
'"Export account". Then copy the code and paste it below.',
),
const SizedBox(height: 16),
TextField(
key: const Key('importCodeField'),
controller: _ctrl,
maxLines: 6,
onChanged: _onTextChanged,
decoration: const InputDecoration(
labelText: 'Account code',
border: OutlineInputBorder(),
hintText: 'Paste code here',
),
),
if (_parseError != null) ...[
const SizedBox(height: 8),
Text(
_parseError!,
style: TextStyle(color: theme.colorScheme.error),
),
],
if (_parsed != null) ...[
const SizedBox(height: 16),
Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Ready to import:',
style: theme.textTheme.titleSmall,
),
const SizedBox(height: 4),
Text(_parsed!.displayName),
Text(_parsed!.email),
Text(
_parsed!.type == AccountType.jmap ? 'JMAP' : 'IMAP',
),
],
),
),
),
],
const SizedBox(height: 16),
FilledButton(
key: const Key('importButton'),
onPressed: (_parsed != null && !_saving) ? _import : null,
child: _saving
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Import'),
),
],
),
),
);
}
}
+25 -9
View File
@@ -15,7 +15,7 @@ class AccountListScreen extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
return Scaffold(
appBar: AppBar(
title: const Text('SharedInbox'),
title: const Text('sharedinbox.de'),
actions: [
IconButton(
icon: const Icon(Icons.search),
@@ -30,10 +30,18 @@ class AccountListScreen extends ConsumerWidget {
const DrawerHeader(
decoration: BoxDecoration(color: Colors.blueGrey),
child: Text(
'SharedInbox',
'sharedinbox.de',
style: TextStyle(color: Colors.white, fontSize: 24),
),
),
ListTile(
leading: const Icon(Icons.qr_code_scanner),
title: const Text('Receive accounts'),
onTap: () {
Navigator.pop(context);
unawaited(context.push('/accounts/receive'));
},
),
ListTile(
leading: const Icon(Icons.history),
title: const Text('Undo Log'),
@@ -50,6 +58,14 @@ class AccountListScreen extends ConsumerWidget {
unawaited(context.push('/accounts/changelog'));
},
),
ListTile(
leading: const Icon(Icons.info_outline),
title: const Text('About'),
onTap: () {
Navigator.pop(context); // Close drawer
unawaited(context.push('/accounts/about'));
},
),
],
),
),
@@ -172,8 +188,8 @@ class _AccountTile extends ConsumerWidget {
child: Text('Local email filters'),
),
const PopupMenuItem(
value: _AccountAction.export,
child: Text('Export account'),
value: _AccountAction.send,
child: Text('Send accounts'),
),
const PopupMenuDivider(),
const PopupMenuItem(
@@ -245,8 +261,8 @@ class _AccountTile extends ConsumerWidget {
case _AccountAction.emailFiltersLocal:
await context.push('/accounts/${account.id}/sieve/local');
break;
case _AccountAction.export:
await context.push('/accounts/${account.id}/export');
case _AccountAction.send:
await context.push('/accounts/send');
break;
case _AccountAction.delete:
final confirmed = await showDialog<bool>(
@@ -296,7 +312,7 @@ class _OnboardingView extends StatelessWidget {
),
const SizedBox(height: 16),
Text(
'Welcome to SharedInbox',
'Welcome to sharedinbox.de',
style: theme.textTheme.headlineSmall,
textAlign: TextAlign.center,
),
@@ -316,7 +332,7 @@ class _OnboardingView extends StatelessWidget {
number: '2',
title: 'Wait for sync',
description:
'SharedInbox downloads your messages in the background.',
'sharedinbox.de downloads your messages in the background.',
),
const _Step(
number: '3',
@@ -390,7 +406,7 @@ enum _AccountAction {
edit,
emailFiltersRemote,
emailFiltersLocal,
export,
send,
delete,
}
+391
View File
@@ -0,0 +1,391 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:mobile_scanner/mobile_scanner.dart';
import 'package:qr_flutter/qr_flutter.dart';
import 'package:sharedinbox/core/models/account.dart';
import 'package:sharedinbox/core/services/share_encryption_service.dart';
import 'package:sharedinbox/di.dart';
/// Receiving side of the secure account-sharing flow.
///
/// Step 1 generates an X25519 key pair with a 20-minute lifetime and shows
/// the public key as a QR code to be scanned by the sender.
///
/// Step 2 scans the encrypted-accounts QR code shown by the sender, decrypts
/// it using the private key, and imports the accounts.
class AccountReceiveScreen extends ConsumerStatefulWidget {
const AccountReceiveScreen({super.key});
@override
ConsumerState<AccountReceiveScreen> createState() =>
_AccountReceiveScreenState();
}
enum _Step { generatingKey, showingPubKey, scanning, importing, done, error }
class _AccountReceiveScreenState extends ConsumerState<AccountReceiveScreen> {
_Step _step = _Step.generatingKey;
ShareKeyMaterial? _keyMaterial;
String? _pubKeyQr;
String? _errorMessage;
bool _scannerActive = false;
MobileScannerController? _scannerController;
@override
void initState() {
super.initState();
unawaited(_generateKey());
}
@override
void dispose() {
final ctrl = _scannerController;
if (ctrl != null) unawaited(ctrl.dispose());
super.dispose();
}
Future<void> _generateKey() async {
try {
final repo = ref.read(shareKeyRepositoryProvider);
final material = await repo.createKeyPair();
final qr = ShareEncryptionService.encodePublicKeyQr(
material.keyId,
material.publicKeyBytes,
);
setState(() {
_keyMaterial = material;
_pubKeyQr = qr;
_step = _Step.showingPubKey;
});
} catch (e) {
setState(() {
_errorMessage = e.toString();
_step = _Step.error;
});
}
}
void _startScanning() {
setState(() {
_step = _Step.scanning;
_scannerActive = true;
_scannerController = MobileScannerController();
});
}
Future<void> _onScanned(String rawValue) async {
if (!_scannerActive) return;
_scannerActive = false;
await _scannerController?.stop();
setState(() => _step = _Step.importing);
try {
final material = _keyMaterial!;
final accounts = await ShareEncryptionService.decryptAccounts(
qrString: rawValue,
privateKeyBytes: material.privateKeyBytes,
publicKeyBytes: material.publicKeyBytes,
keyId: material.keyId,
);
final repo = ref.read(accountRepositoryProvider);
for (final ap in accounts) {
final account = Account.fromJson(ap.accountJson);
final newAccount = Account(
id: DateTime.now().millisecondsSinceEpoch.toString(),
displayName: account.displayName,
email: account.email,
username: account.username,
type: account.type,
imapHost: account.imapHost,
imapPort: account.imapPort,
imapSsl: account.imapSsl,
smtpHost: account.smtpHost,
smtpPort: account.smtpPort,
smtpSsl: account.smtpSsl,
manageSieveHost: account.manageSieveHost,
manageSievePort: account.manageSievePort,
manageSieveSsl: account.manageSieveSsl,
jmapUrl: account.jmapUrl,
);
await repo.addAccount(newAccount, ap.password);
}
if (mounted) {
setState(() => _step = _Step.done);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Imported ${accounts.length} account${accounts.length == 1 ? '' : 's'} successfully.',
),
),
);
context.pop();
}
} catch (e) {
if (mounted) {
setState(() {
_errorMessage = _friendlyError(e);
_scannerActive = false;
// Let user retry from the pubkey step.
_step = _Step.showingPubKey;
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(_friendlyError(e)),
backgroundColor: Theme.of(context).colorScheme.error,
),
);
}
}
}
String _friendlyError(Object e) {
final s = e.toString();
if (s.contains('expired') || s.contains('older than')) {
return 'The QR code has expired. Ask the sender to generate a new one.';
}
if (s.contains('Key ID mismatch') || s.contains('Unknown')) {
return 'QR code does not match this session. Regenerate the public key and try again.';
}
if (s.contains('authentication') ||
s.contains('mac') ||
s.contains('SecretBox')) {
return 'Authentication failed — the QR code may have been tampered with.';
}
return 'Import failed: $s';
}
// ── Build ──────────────────────────────────────────────────────────────────
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Receive accounts')),
body: switch (_step) {
_Step.generatingKey => const Center(child: CircularProgressIndicator()),
_Step.showingPubKey => _buildPubKeyView(context),
_Step.scanning => _buildScannerView(context),
_Step.importing => const Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('Importing accounts…'),
],
),
),
_Step.done => const Center(
child: Icon(
Icons.check_circle,
size: 64,
color: Colors.green,
),
),
_Step.error => Center(
child: Padding(
padding: const EdgeInsets.all(16),
child: Text('Error: $_errorMessage'),
),
),
},
);
}
Widget _buildPubKeyView(BuildContext context) {
final theme = Theme.of(context);
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Step 1 of 2 — Show this QR code to the sender',
style: theme.textTheme.titleMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
'The sender scans this code, selects the account(s) to transfer, '
'and shows an encrypted QR code. Then come back here for step 2.',
style: theme.textTheme.bodySmall,
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
Center(
child: Container(
color: Colors.white,
padding: const EdgeInsets.all(8),
child: QrImageView(
key: const Key('pubKeyQrCode'),
data: _pubKeyQr!,
size: 260,
),
),
),
const SizedBox(height: 16),
OutlinedButton.icon(
icon: const Icon(Icons.copy),
label: const Text('Copy public key'),
onPressed: () {
unawaited(Clipboard.setData(ClipboardData(text: _pubKeyQr!)));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Public key copied to clipboard')),
);
},
),
const SizedBox(height: 8),
const _ExpiryHint(),
const SizedBox(height: 32),
if (_errorMessage != null) ...[
Text(
_errorMessage!,
style: TextStyle(color: theme.colorScheme.error),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
],
FilledButton.icon(
key: const Key('scanEncryptedButton'),
icon: const Icon(Icons.qr_code_scanner),
label: const Text('Step 2 — Scan encrypted QR code'),
onPressed: _startScanning,
),
],
),
);
}
Widget _buildScannerView(BuildContext context) {
// On platforms where the camera scanner is not available (Linux desktop),
// fall back to a text-input field.
if (!_cameraScanSupported()) {
return _buildTextFallbackView(context);
}
return Stack(
children: [
MobileScanner(
controller: _scannerController!,
onDetect: (capture) {
final raw = capture.barcodes.firstOrNull?.rawValue;
if (raw != null) unawaited(_onScanned(raw));
},
),
Positioned(
top: 0,
left: 0,
right: 0,
child: Container(
color: Colors.black54,
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
child: const Text(
'Point the camera at the encrypted QR code from the sender\'s device',
style: TextStyle(color: Colors.white),
textAlign: TextAlign.center,
),
),
),
Positioned(
bottom: 32,
left: 16,
right: 16,
child: OutlinedButton(
style: OutlinedButton.styleFrom(
backgroundColor: Colors.black54,
foregroundColor: Colors.white,
),
onPressed: () {
final ctrl = _scannerController;
if (ctrl != null) unawaited(ctrl.dispose());
_scannerController = null;
setState(() {
_scannerActive = false;
_step = _Step.showingPubKey;
});
},
child: const Text('Cancel'),
),
),
],
);
}
Widget _buildTextFallbackView(BuildContext context) {
final ctrl = TextEditingController();
final theme = Theme.of(context);
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Paste the encrypted code from the sender\'s device',
style: theme.textTheme.titleMedium,
),
const SizedBox(height: 16),
TextField(
key: const Key('encryptedCodeField'),
controller: ctrl,
maxLines: 6,
decoration: const InputDecoration(
labelText: 'Encrypted code',
border: OutlineInputBorder(),
hintText: 'sharedinbox.de:encrypted-accounts:v1:…',
),
),
const SizedBox(height: 16),
FilledButton(
onPressed: () {
final text = ctrl.text.trim();
if (text.isNotEmpty) unawaited(_onScanned(text));
},
child: const Text('Import'),
),
const SizedBox(height: 8),
OutlinedButton(
onPressed: () => setState(() {
_scannerActive = false;
_step = _Step.showingPubKey;
}),
child: const Text('Cancel'),
),
],
),
);
}
}
bool _cameraScanSupported() =>
Platform.isAndroid ||
Platform.isIOS ||
Platform.isMacOS ||
Platform.isWindows;
class _ExpiryHint extends StatelessWidget {
const _ExpiryHint();
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.timer_outlined, size: 14, color: Colors.grey[600]),
const SizedBox(width: 4),
Text(
'This key expires in 20 minutes',
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
),
],
);
}
}
+355
View File
@@ -0,0 +1,355 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:mobile_scanner/mobile_scanner.dart';
import 'package:qr_flutter/qr_flutter.dart';
import 'package:sharedinbox/core/models/account.dart';
import 'package:sharedinbox/core/services/share_encryption_service.dart';
import 'package:sharedinbox/di.dart';
/// Sending side of the secure account-sharing flow.
///
/// Step 1 scans (or pastes) the receiver's public-key QR code.
///
/// Step 2 if more than one account exists, the user selects which accounts
/// to transfer (auto-selected when only one account is present).
///
/// Step 3 shows the encrypted-accounts QR code for the receiver to scan.
class AccountSendScreen extends ConsumerStatefulWidget {
const AccountSendScreen({super.key});
@override
ConsumerState<AccountSendScreen> createState() => _AccountSendScreenState();
}
enum _Step { scanning, selectAccounts, showEncrypted, error }
class _AccountSendScreenState extends ConsumerState<AccountSendScreen> {
_Step _step = _Step.scanning;
// Set after scanning the pubkey QR.
Uint8List? _recipientKeyId;
Uint8List? _recipientPublicKey;
// All available accounts + the selection (for step 2).
List<Account> _accounts = [];
final Set<String> _selectedIds = {};
// Set after encryption (step 3).
String? _encryptedQr;
String? _errorMessage;
bool _scannerActive = true;
MobileScannerController? _scannerController;
@override
void initState() {
super.initState();
if (_cameraScanSupported()) {
_scannerController = MobileScannerController();
}
}
@override
void dispose() {
final ctrl = _scannerController;
if (ctrl != null) unawaited(ctrl.dispose());
super.dispose();
}
// ── Step 1: scan pubkey QR ──────────────────────────────────────────────────
Future<void> _onPubKeyScanned(String rawValue) async {
if (!_scannerActive) return;
_scannerActive = false;
await _scannerController?.stop();
final parsed = ShareEncryptionService.parsePublicKeyQr(rawValue);
if (parsed == null) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Not a valid sharedinbox.de public-key QR code. '
'Ask the receiver to show step 1 of "Receive accounts".',
),
),
);
// Allow retry.
setState(() => _scannerActive = true);
await _scannerController?.start();
}
return;
}
// Load all available accounts.
final accounts =
await ref.read(accountRepositoryProvider).observeAccounts().first;
if (!mounted) return;
if (accounts.isEmpty) {
setState(() {
_errorMessage = 'No accounts to send.';
_step = _Step.error;
});
return;
}
setState(() {
_recipientKeyId = parsed.keyId;
_recipientPublicKey = parsed.publicKeyBytes;
_accounts = accounts;
});
if (accounts.length == 1) {
// Auto-select the only account; skip the selection step.
_selectedIds.add(accounts.first.id);
await _encryptAndShow();
} else {
setState(() {
_selectedIds.addAll(accounts.map((a) => a.id));
_step = _Step.selectAccounts;
});
}
}
// ── Step 2: account selection ───────────────────────────────────────────────
Future<void> _encryptAndShow() async {
final repo = ref.read(accountRepositoryProvider);
final selected = _accounts.where((a) => _selectedIds.contains(a.id));
final payloads = <AccountPayload>[];
for (final account in selected) {
final password = await repo.getPassword(account.id);
payloads.add(
AccountPayload(
accountJson: account.toJson(),
password: password,
),
);
}
try {
final qr = await ShareEncryptionService.encryptAccounts(
recipientKeyId: _recipientKeyId!,
recipientPublicKeyBytes: _recipientPublicKey!,
accounts: payloads,
);
if (mounted) {
setState(() {
_encryptedQr = qr;
_step = _Step.showEncrypted;
});
}
} catch (e) {
if (mounted) {
setState(() {
_errorMessage = e.toString();
_step = _Step.error;
});
}
}
}
// ── Build ───────────────────────────────────────────────────────────────────
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Send accounts')),
body: switch (_step) {
_Step.scanning => _buildScanStep(context),
_Step.selectAccounts => _buildSelectStep(context),
_Step.showEncrypted => _buildEncryptedQrStep(context),
_Step.error => Center(
child: Padding(
padding: const EdgeInsets.all(16),
child: Text('Error: $_errorMessage'),
),
),
},
);
}
Widget _buildScanStep(BuildContext context) {
if (!_cameraScanSupported()) {
return _buildTextFallbackView(context);
}
return Stack(
children: [
MobileScanner(
controller: _scannerController!,
onDetect: (capture) {
final raw = capture.barcodes.firstOrNull?.rawValue;
if (raw != null) unawaited(_onPubKeyScanned(raw));
},
),
Positioned(
top: 0,
left: 0,
right: 0,
child: Container(
color: Colors.black54,
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
child: const Text(
'Point the camera at the public-key QR code shown by the receiver',
style: TextStyle(color: Colors.white),
textAlign: TextAlign.center,
),
),
),
],
);
}
Widget _buildTextFallbackView(BuildContext context) {
final ctrl = TextEditingController();
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text(
'Paste the public key shown by the receiver\'s "Receive accounts" screen.',
),
const SizedBox(height: 16),
TextField(
key: const Key('pubKeyInputField'),
controller: ctrl,
maxLines: 4,
decoration: const InputDecoration(
labelText: 'Public key',
border: OutlineInputBorder(),
hintText: 'sharedinbox.de:pubkey:v1:…',
),
),
const SizedBox(height: 16),
FilledButton(
onPressed: () {
final text = ctrl.text.trim();
if (text.isNotEmpty) unawaited(_onPubKeyScanned(text));
},
child: const Text('Continue'),
),
],
),
);
}
Widget _buildSelectStep(BuildContext context) {
final theme = Theme.of(context);
return Column(
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Text(
'Select accounts to send',
style: theme.textTheme.titleMedium,
),
),
Expanded(
child: ListView(
children: _accounts.map((account) {
final selected = _selectedIds.contains(account.id);
return CheckboxListTile(
value: selected,
title: Text(account.displayName),
subtitle: Text(account.email),
onChanged: (v) {
setState(() {
if (v == true) {
_selectedIds.add(account.id);
} else {
_selectedIds.remove(account.id);
}
});
},
);
}).toList(),
),
),
Padding(
padding: const EdgeInsets.all(16),
child: FilledButton(
key: const Key('sendSelectedButton'),
onPressed: _selectedIds.isEmpty
? null
: () => unawaited(_encryptAndShow()),
child: const Text('Encrypt & show QR'),
),
),
],
);
}
Widget _buildEncryptedQrStep(BuildContext context) {
final theme = Theme.of(context);
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Step 3 — Show this QR code to the receiver',
style: theme.textTheme.titleMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
'The receiver taps "Step 2 — Scan encrypted QR code" and scans this.',
style: theme.textTheme.bodySmall,
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
Center(
child: Container(
color: Colors.white,
padding: const EdgeInsets.all(8),
child: QrImageView(
key: const Key('encryptedAccountsQrCode'),
data: _encryptedQr!,
size: 280,
),
),
),
const SizedBox(height: 16),
OutlinedButton.icon(
key: const Key('copyEncryptedButton'),
icon: const Icon(Icons.copy),
label: const Text('Copy encrypted code'),
onPressed: () {
unawaited(Clipboard.setData(ClipboardData(text: _encryptedQr!)));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Encrypted code copied to clipboard',
),
),
);
},
),
const SizedBox(height: 8),
Text(
'This code contains encrypted account data. It is safe to display '
'briefly — only the receiver\'s device can decrypt it.',
style: theme.textTheme.bodySmall,
textAlign: TextAlign.center,
),
],
),
);
}
}
bool _cameraScanSupported() =>
Platform.isAndroid ||
Platform.isIOS ||
Platform.isMacOS ||
Platform.isWindows;
+2 -2
View File
@@ -299,8 +299,8 @@ class _AddAccountScreenState extends ConsumerState<AddAccountScreen> {
OutlinedButton.icon(
key: const Key('importAccountButton'),
icon: const Icon(Icons.qr_code_scanner),
label: const Text('Import account'),
onPressed: () => context.push('/accounts/import'),
label: const Text('Receive account'),
onPressed: () => context.push('/accounts/receive'),
),
],
),
+1 -1
View File
@@ -45,7 +45,7 @@ class CrashScreen extends StatelessWidget {
const Icon(Icons.error_outline, color: Colors.red, size: 64),
const SizedBox(height: 16),
Text(
'SharedInbox encountered an unexpected error and needs to be restarted.',
'sharedinbox.de encountered an unexpected error and needs to be restarted.',
style: Theme.of(context).textTheme.titleMedium,
textAlign: TextAlign.center,
),
+15 -9
View File
@@ -9,6 +9,7 @@ import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
import 'package:open_filex/open_filex.dart';
import 'package:path_provider/path_provider.dart';
import 'package:share_plus/share_plus.dart';
import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/core/models/undo_action.dart';
@@ -495,7 +496,10 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
child: const Text('Copy'),
),
TextButton(
onPressed: () => unawaited(_downloadRaw(ctx, header, raw)),
onPressed: () async {
await _downloadRaw(ctx, header, raw);
if (ctx.mounted) Navigator.pop(ctx);
},
child: const Text('Download'),
),
TextButton(
@@ -514,13 +518,7 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
String raw,
) async {
try {
Directory dir;
try {
dir =
(await getDownloadsDirectory()) ?? (await getTemporaryDirectory());
} catch (_) {
dir = await getTemporaryDirectory();
}
final dir = await getTemporaryDirectory();
final subject = (header?.subject ?? 'email')
.replaceAll(RegExp(r'[^\w\s-]'), '_')
.trim();
@@ -529,7 +527,15 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
await file.writeAsString(raw);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Saved $filename to ${dir.path}')),
SnackBar(
content: Text('Saved $filename'),
action: SnackBarAction(
label: 'Share',
onPressed: () => SharePlus.instance.share(
ShareParams(files: [XFile(file.path)]),
),
),
),
);
} catch (e) {
if (!context.mounted) return;
+7 -6
View File
@@ -138,7 +138,7 @@ class _SieveScriptsScreenState extends ConsumerState<SieveScriptsScreen> {
return Scaffold(
appBar: AppBar(
title: Text(
widget.isLocal ? 'Local email filters' : 'Server email filters',
widget.isLocal ? 'Local Filters' : 'Remote Filters',
),
),
body: _buildBody(),
@@ -178,7 +178,7 @@ class _SieveScriptsScreenState extends ConsumerState<SieveScriptsScreen> {
Expanded(
child: scripts.isEmpty
? const Center(
child: Text('No Sieve scripts. Tap + to create one.'),
child: Text('No filters yet. Tap + to create one.'),
)
: RefreshIndicator(
onRefresh: _load,
@@ -208,10 +208,11 @@ class _SieveSourceBanner extends StatelessWidget {
@override
Widget build(BuildContext context) {
final text = isLocal
? 'These scripts run locally on this device. '
'Server email filters are separate and independent.'
: 'These scripts run on the mail server (ManageSieve / JMAP). '
'Local email filters are separate and independent.';
? 'Local Filters run Sieve scripts directly on this device. '
'Remote Filters, which run on the mail server, are configured separately.'
: 'Remote Filters run Sieve scripts on the mail server '
'(ManageSieve or JMAP). '
'Local Filters, which run on this device, are configured separately.';
return Container(
width: double.infinity,
color: Theme.of(context).colorScheme.surfaceContainerHighest,
+10 -2
View File
@@ -70,13 +70,21 @@ class FolderDrawer extends ConsumerWidget {
},
),
ListTile(
leading: const Icon(Icons.filter_list),
title: const Text('Email filters'),
leading: const Icon(Icons.dns),
title: const Text('Remote Filters'),
onTap: () {
Navigator.pop(context);
unawaited(context.push('/accounts/$accountId/sieve'));
},
),
ListTile(
leading: const Icon(Icons.phone_android),
title: const Text('Local Filters'),
onTap: () {
Navigator.pop(context);
unawaited(context.push('/accounts/$accountId/sieve/local'));
},
),
const Divider(height: 1),
Expanded(
child: StreamBuilder(
+6 -1
View File
@@ -13,7 +13,12 @@ class UndoShell extends ConsumerWidget {
ref.listen<List<UndoAction>>(undoServiceProvider, (previous, next) {
if (next.isNotEmpty &&
(previous == null || previous.length < next.length)) {
_showUndoSnackbar(context, ref, next.last);
final action = next.last;
// Don't show a snackbar for actions loaded from persistence on app
// startup — only for actions pushed in this session.
if (DateTime.now().difference(action.timestamp).inSeconds < 30) {
_showUndoSnackbar(context, ref, action);
}
}
});
+8
View File
@@ -43,6 +43,12 @@ dependencies:
# QR code generation for account sharing
qr_flutter: ^4.1.0
# Public-key encryption for secure account sharing (ECIES: X25519 + AES-256-GCM)
cryptography: ^2.7.0
# QR code scanning (camera) for secure account import
mobile_scanner: ^5.0.0
# HTML rendering for email bodies
webview_flutter: ^4.0.0
url_launcher: ^6.3.2
@@ -54,6 +60,7 @@ dependencies:
# App version metadata for crash reports
package_info_plus: ^8.0.0
share_plus: ^12.0.2
dev_dependencies:
flutter_test:
@@ -66,6 +73,7 @@ dev_dependencies:
test: ^1.25.0
mockito: ^5.4.4
fake_async: ^1.3.1
path_provider_platform_interface: ^2.1.2
sqlite3: any # used directly in test/unit/db_test_helper.dart
url_launcher_platform_interface: ^2.3.2
plugin_platform_interface: ^2.1.8
+4 -2
View File
@@ -15,6 +15,7 @@ const _noCode = {
'lib/core/repositories/draft_repository.dart',
'lib/core/repositories/email_repository.dart',
'lib/core/repositories/mailbox_repository.dart',
'lib/core/repositories/share_key_repository.dart',
'lib/core/repositories/sync_log_repository.dart',
'lib/core/repositories/undo_repository.dart',
'lib/core/repositories/search_history_repository.dart',
@@ -32,9 +33,9 @@ const _excluded = {
'lib/di.dart',
'lib/main.dart',
'lib/ui/router.dart',
'lib/ui/screens/account_export_screen.dart',
'lib/ui/screens/account_import_screen.dart',
'lib/ui/screens/account_list_screen.dart',
'lib/ui/screens/account_receive_screen.dart',
'lib/ui/screens/account_send_screen.dart',
'lib/ui/screens/add_account_screen.dart',
'lib/ui/screens/address_emails_screen.dart',
'lib/ui/screens/changelog_screen.dart',
@@ -63,6 +64,7 @@ const _excluded = {
'lib/data/repositories/account_repository_impl.dart',
'lib/data/repositories/email_repository_impl.dart',
'lib/data/repositories/mailbox_repository_impl.dart',
'lib/data/repositories/share_key_repository_impl.dart',
'lib/data/repositories/sync_log_repository_impl.dart',
'lib/data/repositories/undo_repository_impl.dart',
'lib/data/repositories/search_history_repository_impl.dart',
+108
View File
@@ -0,0 +1,108 @@
# Shared Flutter & Dart Pub Cache Configuration
This guide provides the instructions to configure a centralized, robust `pub-cache` for a Linux
environment acting as both a local development workstation and a Dagger CI runner.
The `pub-cache` is the local directory where Dart and Flutter store downloaded packages
(dependencies) fetched from `pub.dev` or other package repositories. By default, it resides in
`~/.pub-cache` (or `~/.local/share/pub-cache` on some Linux setups) for each individual user. When
multiple users or CI runners operate on the same machine, they end up downloading the same packages
redundantly, wasting disk space and network bandwidth.
This setup aggressively prevents permission drift between local user accounts and CI service
accounts. It also strictly forbids `pub global activate` via OS-level directory permissions to
guarantee a 100% collision-free environment, effectively forcing
roject-level dependency
management.
---
## Prerequisites
- Root (`sudo`) access to the Linux host machine.
- The `acl` package installed (standard on most modern distributions like Ubuntu).
## Step 1: Create the Dedicated Group and Directory
Establish a shared user group for all human developers and CI service accounts, and provision the
central cache directory.
```bash
# Create the shared group
sudo groupadd flutter-devs
# Add your local user to the group
sudo usermod -aG flutter-devs $USER
# Add the CI runner service account to the group (e.g., 'dagger' or 'gitlab-runner')
# sudo usermod -aG flutter-devs <ci-service-user>
# Create the centralized cache directory in /opt
sudo mkdir -p /opt/pub-cache
sudo chown root:flutter-devs /opt/pub-cache
Step 2: Enforce Strict Group Permissions (ACLs)
Standard Linux permissions result in the creator of a file owning it exclusively. To prevent permission drift when Dagger or the local user pulls dependencies, apply Access Control Lists (ACLs). This forces all newly created subdirectories and files to inherit read, write, and execute permissions for the flutter-devs group.
Bash
# Set the SetGID bit so new files inherit the 'flutter-devs' group
sudo chmod 2775 /opt/pub-cache
# Apply default ACLs to enforce rwx for the group on all future files/folders
sudo setfacl -d -m g:flutter-devs:rwx /opt/pub-cache
# Apply the same ACLs to the directory itself immediately
sudo setfacl -m g:flutter-devs:rwx /opt/pub-cache
Step 3: Export the Environment Variable
You must instruct Dart and Flutter to utilize this central location instead of the default ~/.pub-cache.
A. Global Host Setup
For system-wide application, drop an environment script into /etc/profile.d/.
Bash
echo 'export PUB_CACHE=/opt/pub-cache' | sudo tee /etc/profile.d/flutter-pub-cache.sh
echo 'export PATH="$PATH:$PUB_CACHE/bin"' | sudo tee -a /etc/profile.d/flutter-pub-cache.sh
(Note: Users will need to log out and log back in, or source the profile, for this to take effect).
B. Dagger Pipeline Integration (Go SDK)
When writing your Dagger pipeline controller, mount the host directory directly into the container so the CI runner uses the identical cache pool:
Go
// In your Dagger CI logic, mount the shared host cache into the container
WithMountedDirectory("/root/.pub-cache", dag.Host().Directory("/opt/pub-cache")).
WithEnvVariable("PUB_CACHE", "/root/.pub-cache")
Step 4: The 100% Strict Lockdown for Global Activations
Running dart pub global activate <package> in a shared cache causes severe conflicts by overwriting global executables. To guarantee this never happens, we revoke write access to the specific global activation subdirectories.
By implementing this OS-level constraint, any attempt to globally activate a package—regardless of multiline bash scripts, variables, or clever aliases—will be unconditionally rejected by the Linux kernel with a Permission denied error. Standard pub get commands for project dependencies will continue working without issue.
Bash
# Ensure the target subdirectories exist
sudo mkdir -p /opt/pub-cache/bin
sudo mkdir -p /opt/pub-cache/global_packages
# Change ownership of exclusively these two directories to root
sudo chown root:root /opt/pub-cache/bin
sudo chown root:root /opt/pub-cache/global_packages
# Remove write permissions for everyone else
sudo chmod 755 /opt/pub-cache/bin
sudo chmod 755 /opt/pub-cache/global_packages
Developer Workflow Impact
Because global activations are now entirely disabled on this host, developers and CI scripts must manage CLI tools locally.
If a tool like melos, slidy, or coverage is required:
Add it to the dev_dependencies of your pubspec.yaml.
Invoke it project-locally using dart run <package_name>.
+4
View File
@@ -17,6 +17,10 @@ command -v stalwart >/dev/null || {
exit 1
}
# Kill any stalwart left over from a previous run (the CI self-hosted runner
# keeps processes alive across jobs when a run is killed externally).
pkill -x stalwart 2>/dev/null && sleep 0.5 || true
# Pre-seed spam-filter version so Stalwart does not fetch it on first boot.
mkdir -p "$STALWART_TMPDIR"
sqlite3 "${STALWART_TMPDIR}/data.sqlite" \
+3 -2
View File
@@ -14,7 +14,7 @@ void main() {
group('Migration', () {
test('schemaVersion matches expected value', () async {
final db = AppDatabase(NativeDatabase.memory());
expect(db.schemaVersion, 30);
expect(db.schemaVersion, 31);
await db.close();
});
@@ -382,7 +382,7 @@ void main() {
if (dbFile.existsSync()) dbFile.deleteSync();
});
test('fresh install creates all tables at schemaVersion 30', () async {
test('fresh install creates all tables at schemaVersion 31', () async {
final db = AppDatabase(NativeDatabase.memory());
await db.select(db.accounts).get();
@@ -407,6 +407,7 @@ void main() {
'undo_actions',
'search_history_entries',
'local_sieve_scripts', // v29
'share_keys', // v31
]),
);
@@ -0,0 +1,247 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:sharedinbox/core/services/share_encryption_service.dart';
import 'package:test/test.dart';
void main() {
group('ShareEncryptionService', () {
// ── generateKeyPair ─────────────────────────────────────────────────────
test('generateKeyPair returns 16-byte key ID and 32-byte keys', () async {
final m = await ShareEncryptionService.generateKeyPair();
expect(m.keyId.length, 16);
expect(m.publicKeyBytes.length, 32);
expect(m.privateKeyBytes.length, 32);
});
test('generateKeyPair returns unique key IDs', () async {
final a = await ShareEncryptionService.generateKeyPair();
final b = await ShareEncryptionService.generateKeyPair();
expect(a.keyId, isNot(equals(b.keyId)));
});
// ── encodePublicKeyQr / parsePublicKeyQr ────────────────────────────────
test('encodePublicKeyQr produces expected prefix', () async {
final m = await ShareEncryptionService.generateKeyPair();
final qr = ShareEncryptionService.encodePublicKeyQr(
m.keyId,
m.publicKeyBytes,
);
expect(qr, startsWith('sharedinbox.de:pubkey:v1:'));
});
test('parsePublicKeyQr round-trips correctly', () async {
final m = await ShareEncryptionService.generateKeyPair();
final qr = ShareEncryptionService.encodePublicKeyQr(
m.keyId,
m.publicKeyBytes,
);
final parsed = ShareEncryptionService.parsePublicKeyQr(qr);
expect(parsed, isNotNull);
expect(parsed!.keyId, equals(m.keyId));
expect(parsed.publicKeyBytes, equals(m.publicKeyBytes));
});
test('parsePublicKeyQr returns null for invalid input', () {
expect(ShareEncryptionService.parsePublicKeyQr('not-valid'), isNull);
expect(
ShareEncryptionService.parsePublicKeyQr(
'sharedinbox.de:pubkey:v1:!!!',
),
isNull,
);
expect(
ShareEncryptionService.parsePublicKeyQr(
'sharedinbox.de:pubkey:v1:${base64.encode(Uint8List(10))}',
),
isNull,
);
});
// ── encrypt / decrypt round-trip ────────────────────────────────────────
test('encrypt + decrypt round-trip restores account payload', () async {
final m = await ShareEncryptionService.generateKeyPair();
final accounts = [
const AccountPayload(
accountJson: {
'id': 'acc-1',
'displayName': 'Alice',
'email': 'alice@example.com',
'username': '',
'type': 'imap',
'imapHost': 'imap.example.com',
'imapPort': 993,
'imapSsl': true,
'smtpHost': 'smtp.example.com',
'smtpPort': 465,
'smtpSsl': true,
'manageSieveHost': '',
'manageSievePort': 4190,
'manageSieveSsl': true,
'manageSieveAvailable': null,
'jmapUrl': null,
'verbose': false,
},
password: 'hunter2',
),
];
final qr = await ShareEncryptionService.encryptAccounts(
recipientKeyId: m.keyId,
recipientPublicKeyBytes: m.publicKeyBytes,
accounts: accounts,
);
expect(qr, startsWith('sharedinbox.de:encrypted-accounts:v1:'));
final decrypted = await ShareEncryptionService.decryptAccounts(
qrString: qr,
privateKeyBytes: m.privateKeyBytes,
publicKeyBytes: m.publicKeyBytes,
keyId: m.keyId,
);
expect(decrypted, hasLength(1));
expect(decrypted.first.password, 'hunter2');
expect(decrypted.first.accountJson['email'], 'alice@example.com');
expect(decrypted.first.accountJson['displayName'], 'Alice');
});
test('decrypt multiple accounts', () async {
final m = await ShareEncryptionService.generateKeyPair();
final accounts = [
const AccountPayload(
accountJson: {
'id': '1',
'email': 'a@x.com',
'displayName': 'A',
'username': '',
'type': 'imap',
'imapHost': 'h',
'imapPort': 993,
'imapSsl': true,
'smtpHost': 'h',
'smtpPort': 465,
'smtpSsl': true,
'manageSieveHost': '',
'manageSievePort': 4190,
'manageSieveSsl': true,
'manageSieveAvailable': null,
'jmapUrl': null,
'verbose': false,
},
password: 'pw1',
),
const AccountPayload(
accountJson: {
'id': '2',
'email': 'b@x.com',
'displayName': 'B',
'username': '',
'type': 'imap',
'imapHost': 'h',
'imapPort': 993,
'imapSsl': true,
'smtpHost': 'h',
'smtpPort': 465,
'smtpSsl': true,
'manageSieveHost': '',
'manageSievePort': 4190,
'manageSieveSsl': true,
'manageSieveAvailable': null,
'jmapUrl': null,
'verbose': false,
},
password: 'pw2',
),
];
final qr = await ShareEncryptionService.encryptAccounts(
recipientKeyId: m.keyId,
recipientPublicKeyBytes: m.publicKeyBytes,
accounts: accounts,
);
final decrypted = await ShareEncryptionService.decryptAccounts(
qrString: qr,
privateKeyBytes: m.privateKeyBytes,
publicKeyBytes: m.publicKeyBytes,
keyId: m.keyId,
);
expect(decrypted, hasLength(2));
expect(decrypted.map((a) => a.password), containsAll(['pw1', 'pw2']));
});
test('decrypt rejects wrong key ID', () async {
final sender = await ShareEncryptionService.generateKeyPair();
final other = await ShareEncryptionService.generateKeyPair();
final qr = await ShareEncryptionService.encryptAccounts(
recipientKeyId: sender.keyId,
recipientPublicKeyBytes: sender.publicKeyBytes,
accounts: [const AccountPayload(accountJson: {}, password: 'pw')],
);
expect(
() => ShareEncryptionService.decryptAccounts(
qrString: qr,
privateKeyBytes: other.privateKeyBytes,
publicKeyBytes: other.publicKeyBytes,
keyId: other.keyId, // different key ID
),
throwsA(isA<FormatException>()),
);
});
test('decrypt rejects invalid prefix', () async {
final m = await ShareEncryptionService.generateKeyPair();
expect(
() => ShareEncryptionService.decryptAccounts(
qrString: 'not-valid',
privateKeyBytes: m.privateKeyBytes,
publicKeyBytes: m.publicKeyBytes,
keyId: m.keyId,
),
throwsA(isA<FormatException>()),
);
});
test('decrypt rejects tampered ciphertext', () async {
final m = await ShareEncryptionService.generateKeyPair();
final qr = await ShareEncryptionService.encryptAccounts(
recipientKeyId: m.keyId,
recipientPublicKeyBytes: m.publicKeyBytes,
accounts: [
const AccountPayload(
accountJson: {'id': '1', 'email': 'a@x.com'},
password: 'pw',
),
],
);
// Flip a byte in the base64 payload.
const prefix = 'sharedinbox.de:encrypted-accounts:v1:';
final raw = qr.substring(prefix.length);
final bytes = base64.decode(raw);
bytes[40] ^= 0xFF; // Corrupt a byte in the ciphertext area.
final tampered = '$prefix${base64.encode(bytes)}';
await expectLater(
ShareEncryptionService.decryptAccounts(
qrString: tampered,
privateKeyBytes: m.privateKeyBytes,
publicKeyBytes: m.publicKeyBytes,
keyId: m.keyId,
),
throwsA(anything),
);
});
});
}
+343
View File
@@ -0,0 +1,343 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:sharedinbox/core/sieve/sieve_actions.dart';
import 'package:sharedinbox/core/sieve/sieve_conditions.dart';
import 'package:sharedinbox/core/sieve/sieve_interpreter.dart';
import 'package:sharedinbox/core/sieve/sieve_rule.dart';
SieveEmailContext _email({
String subject = '',
String from = '',
String to = '',
int size = 0,
Map<String, List<String>> extra = const {},
}) {
return SieveEmailContext(
headers: {
if (subject.isNotEmpty) 'subject': [subject],
if (from.isNotEmpty) 'from': [from],
if (to.isNotEmpty) 'to': [to],
...extra,
},
sizeBytes: size,
);
}
void main() {
final interp = SieveInterpreter();
group('SieveInterpreter — no rules', () {
test('empty rule list keeps inbox', () {
final ctx = interp.execute([], _email());
expect(ctx.keepInInbox, isTrue);
expect(ctx.isCancelled, isFalse);
});
});
group('HeaderCondition :contains', () {
test('matches subject substring (case-insensitive)', () {
final rules = [
SieveRule(
joinType: 'single',
conditions: [
HeaderCondition(['subject'], ':contains', ['spam']),
],
actions: [DiscardAction()],
),
];
final ctx = interp.execute(rules, _email(subject: 'This is SPAM!'));
expect(ctx.isCancelled, isTrue);
expect(ctx.keepInInbox, isFalse);
});
test('does not match unrelated subject', () {
final rules = [
SieveRule(
joinType: 'single',
conditions: [
HeaderCondition(['subject'], ':contains', ['spam']),
],
actions: [DiscardAction()],
),
];
final ctx = interp.execute(rules, _email(subject: 'Meeting tomorrow'));
expect(ctx.isCancelled, isFalse);
expect(ctx.keepInInbox, isTrue);
});
});
group('HeaderCondition :is', () {
test('exact match on from header', () {
final rules = [
SieveRule(
joinType: 'single',
conditions: [
HeaderCondition(
['from', 'reply-to'],
':is',
['boss@work.com'],
),
],
actions: [
FlagAction([r'\Important']),
FileIntoAction('Work'),
],
),
];
final ctx = interp.execute(rules, _email(from: 'boss@work.com'));
expect(ctx.flagsToAdd, contains(r'\Important'));
expect(ctx.targetFolders, contains('Work'));
expect(ctx.keepInInbox, isFalse);
});
test('does not match partial from address', () {
final rules = [
SieveRule(
joinType: 'single',
conditions: [
HeaderCondition(['from'], ':is', ['boss@work.com']),
],
actions: [FileIntoAction('Work')],
),
];
final ctx = interp.execute(rules, _email(from: 'other-boss@work.com'));
expect(ctx.targetFolders, isEmpty);
expect(ctx.keepInInbox, isTrue);
});
});
group('HeaderCondition :matches (glob)', () {
test('* matches any substring', () {
final rules = [
SieveRule(
joinType: 'single',
conditions: [
HeaderCondition(['subject'], ':matches', ['*newsletter*']),
],
actions: [FileIntoAction('Bulk')],
),
];
final ctx =
interp.execute(rules, _email(subject: 'Weekly Newsletter Issue'));
expect(ctx.targetFolders, contains('Bulk'));
});
});
group('SizeCondition', () {
test(':over threshold fires', () {
final rules = [
SieveRule(
joinType: 'single',
conditions: [SizeCondition(':over', 1024)],
actions: [FileIntoAction('Large')],
),
];
final ctx = interp.execute(rules, _email(size: 2048));
expect(ctx.targetFolders, contains('Large'));
});
test(':under threshold fires', () {
final rules = [
SieveRule(
joinType: 'single',
conditions: [SizeCondition(':under', 500)],
actions: [FileIntoAction('Small')],
),
];
final ctx = interp.execute(rules, _email(size: 100));
expect(ctx.targetFolders, contains('Small'));
});
test(':over threshold does not fire when size equals threshold', () {
final rules = [
SieveRule(
joinType: 'single',
conditions: [SizeCondition(':over', 1024)],
actions: [DiscardAction()],
),
];
final ctx = interp.execute(rules, _email(size: 1024));
expect(ctx.isCancelled, isFalse);
});
});
group('allof / anyof join types', () {
test('allof fires only when all conditions match', () {
final rules = [
SieveRule(
joinType: 'allof',
conditions: [
HeaderCondition(['subject'], ':contains', ['deal']),
HeaderCondition(['from'], ':contains', ['shop.com']),
],
actions: [FileIntoAction('Deals')],
),
];
// Both match.
var ctx = interp.execute(
rules,
_email(subject: 'Big deal today', from: 'offers@shop.com'),
);
expect(ctx.targetFolders, contains('Deals'));
// Only subject matches.
ctx = interp.execute(
rules,
_email(subject: 'Big deal today', from: 'friend@example.com'),
);
expect(ctx.targetFolders, isEmpty);
});
test('anyof fires when any condition matches', () {
final rules = [
SieveRule(
joinType: 'anyof',
conditions: [
HeaderCondition(['subject'], ':contains', ['spam']),
HeaderCondition(['subject'], ':contains', ['advertisement']),
],
actions: [DiscardAction()],
),
];
final ctx = interp.execute(rules, _email(subject: 'Huge advertisement!'));
expect(ctx.isCancelled, isTrue);
});
});
group('discard stops processing', () {
test('rules after discard are skipped', () {
final rules = [
SieveRule(
joinType: 'single',
conditions: [
HeaderCondition(['subject'], ':contains', ['spam']),
],
actions: [DiscardAction()],
),
SieveRule(
joinType: 'single',
conditions: const [],
actions: [FileIntoAction('Inbox')],
),
];
final ctx = interp.execute(rules, _email(subject: 'Spam'));
expect(ctx.isCancelled, isTrue);
expect(ctx.targetFolders, isEmpty);
});
});
group('MarkAsSeenAction', () {
test('adds \\Seen flag', () {
final rules = [
SieveRule(
joinType: 'single',
conditions: const [],
actions: [MarkAsSeenAction()],
),
];
final ctx = interp.execute(rules, _email());
expect(ctx.flagsToAdd, contains(r'\Seen'));
});
});
group('if/elsif/else branch groups', () {
final rules = [
SieveRule(
joinType: 'single',
conditions: [
HeaderCondition(['subject'], ':contains', ['spam']),
],
actions: [DiscardAction()],
branchGroupId: 1,
),
SieveRule(
joinType: 'single',
conditions: [
HeaderCondition(['subject'], ':contains', ['work']),
],
actions: [FileIntoAction('Work')],
branchGroupId: 1,
),
SieveRule(
joinType: 'single',
conditions: const [],
actions: [KeepAction()],
branchGroupId: 1,
isElseBranch: true,
),
];
test('first branch fires, subsequent branches are skipped', () {
final ctx = interp.execute(rules, _email(subject: 'spam message'));
expect(ctx.isCancelled, isTrue);
expect(ctx.targetFolders, isEmpty);
});
test('second branch fires when first does not match', () {
final ctx = interp.execute(rules, _email(subject: 'Work update'));
expect(ctx.isCancelled, isFalse);
expect(ctx.targetFolders, contains('Work'));
});
test('else branch fires when no branch matched', () {
final ctx = interp.execute(rules, _email(subject: 'Hello'));
expect(ctx.isCancelled, isFalse);
expect(ctx.targetFolders, isEmpty);
expect(ctx.keepInInbox, isTrue);
});
});
group('multiple independent rules', () {
test('both rules fire when conditions match', () {
final rules = [
SieveRule(
joinType: 'single',
conditions: [
HeaderCondition(['subject'], ':contains', ['invoice']),
],
actions: [FileIntoAction('Finance')],
),
SieveRule(
joinType: 'single',
conditions: [
HeaderCondition(['from'], ':contains', ['boss@']),
],
actions: [
FlagAction([r'\Important']),
],
),
];
final ctx = interp.execute(
rules,
_email(subject: 'Invoice #123', from: 'boss@corp.com'),
);
expect(ctx.targetFolders, contains('Finance'));
expect(ctx.flagsToAdd, contains(r'\Important'));
});
});
group('implicit keep', () {
test('keepInInbox stays true when no action changes routing', () {
final rules = [
SieveRule(
joinType: 'single',
conditions: [
HeaderCondition(['subject'], ':contains', ['nope']),
],
actions: [DiscardAction()],
),
];
final ctx = interp.execute(rules, _email(subject: 'Hi there'));
expect(ctx.keepInInbox, isTrue);
expect(ctx.isCancelled, isFalse);
});
});
}
+305
View File
@@ -0,0 +1,305 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:sharedinbox/core/sieve/sieve_actions.dart';
import 'package:sharedinbox/core/sieve/sieve_conditions.dart';
import 'package:sharedinbox/core/sieve/sieve_interpreter.dart';
import 'package:sharedinbox/core/sieve/sieve_parser.dart';
SieveEmailContext _email({
String subject = '',
String from = '',
String to = '',
int size = 0,
}) {
return SieveEmailContext(
headers: {
if (subject.isNotEmpty) 'subject': [subject],
if (from.isNotEmpty) 'from': [from],
if (to.isNotEmpty) 'to': [to],
},
sizeBytes: size,
);
}
void main() {
final parser = SieveParser();
final interp = SieveInterpreter();
SieveExecutionContext run(String script, SieveEmailContext email) {
return interp.execute(parser.parse(script), email);
}
group('SieveParser — require', () {
test('require is parsed without error', () {
expect(
() => parser.parse('require ["fileinto", "imap4flags"];'),
returnsNormally,
);
});
test('require produces no rules', () {
final rules = parser.parse('require ["fileinto"];');
expect(rules, isEmpty);
});
});
group('SieveParser — basic if/discard', () {
test('discard on subject :contains', () {
const script = '''
require ["fileinto"];
if header :contains "Subject" "Spam" {
discard;
}
''';
var ctx = run(script, _email(subject: 'This is Spam!'));
expect(ctx.isCancelled, isTrue);
ctx = run(script, _email(subject: 'Hello'));
expect(ctx.isCancelled, isFalse);
});
});
group('SieveParser — fileinto + flag', () {
test('flag and fileinto on :is match across multiple headers', () {
const script = '''
require ["fileinto", "imap4flags"];
if header :is ["From", "Reply-To"] "boss@work.com" {
flag ["\\\\Important"];
fileinto "Work";
}
''';
final ctx = run(script, _email(from: 'boss@work.com'));
expect(ctx.targetFolders, contains('Work'));
expect(ctx.keepInInbox, isFalse);
});
test('no match means inbox is kept', () {
const script = '''
if header :is "From" "boss@work.com" {
fileinto "Work";
}
''';
final ctx = run(script, _email(from: 'other@example.com'));
expect(ctx.targetFolders, isEmpty);
expect(ctx.keepInInbox, isTrue);
});
});
group('SieveParser — anyof', () {
test('anyof fires when any sub-condition matches', () {
const script = '''
if anyof (
header :contains "Subject" "Newsletter",
header :contains "Subject" "Promotion"
) {
fileinto "Bulk";
}
''';
var ctx = run(script, _email(subject: 'Weekly Newsletter'));
expect(ctx.targetFolders, contains('Bulk'));
ctx = run(script, _email(subject: 'Big Promotion Inside'));
expect(ctx.targetFolders, contains('Bulk'));
ctx = run(script, _email(subject: 'Normal message'));
expect(ctx.targetFolders, isEmpty);
});
});
group('SieveParser — allof', () {
test('allof fires only when all conditions match', () {
const script = '''
if allof (
header :contains "From" "shop.com",
header :contains "Subject" "deal"
) {
fileinto "Deals";
}
''';
var ctx = run(
script,
_email(from: 'offers@shop.com', subject: 'Hot deal today'),
);
expect(ctx.targetFolders, contains('Deals'));
ctx = run(
script,
_email(from: 'friend@example.com', subject: 'Hot deal today'),
);
expect(ctx.targetFolders, isEmpty);
});
});
group('SieveParser — size condition', () {
test(':over with K suffix', () {
const script = '''
if size :over 100K {
fileinto "Large";
}
''';
var ctx = run(script, _email(size: 200 * 1024));
expect(ctx.targetFolders, contains('Large'));
ctx = run(script, _email(size: 50 * 1024));
expect(ctx.targetFolders, isEmpty);
});
test(':under fires for small messages', () {
const script = '''
if size :under 1K {
fileinto "Tiny";
}
''';
final ctx = run(script, _email(size: 500));
expect(ctx.targetFolders, contains('Tiny'));
});
});
group('SieveParser — if/elsif/else', () {
const script = '''
if header :contains "Subject" "Spam" {
discard;
} elsif header :contains "Subject" "Work" {
fileinto "Work";
} else {
keep;
}
''';
test('if branch fires', () {
final ctx = run(script, _email(subject: 'Spam message'));
expect(ctx.isCancelled, isTrue);
});
test('elsif branch fires when if does not match', () {
final ctx = run(script, _email(subject: 'Work update'));
expect(ctx.isCancelled, isFalse);
expect(ctx.targetFolders, contains('Work'));
});
test('else branch fires when no condition matched', () {
final ctx = run(script, _email(subject: 'Hello'));
expect(ctx.isCancelled, isFalse);
expect(ctx.targetFolders, isEmpty);
expect(ctx.keepInInbox, isTrue);
});
});
group('SieveParser — multiple independent if blocks', () {
test('both fire independently', () {
const script = '''
if header :contains "Subject" "invoice" {
fileinto "Finance";
}
if header :contains "From" "boss@" {
flag ["\\\\Important"];
}
''';
final ctx = run(
script,
_email(subject: 'Invoice #42', from: 'boss@corp.com'),
);
expect(ctx.targetFolders, contains('Finance'));
expect(ctx.flagsToAdd, contains(r'\Important'));
});
});
group('SieveParser — keep and stop', () {
test('keep action keeps inbox', () {
const script = 'keep;';
final ctx = run(script, _email());
expect(ctx.keepInInbox, isTrue);
expect(ctx.isCancelled, isFalse);
});
test('stop acts as implicit keep', () {
const script = 'stop;';
final ctx = run(script, _email());
expect(ctx.keepInInbox, isTrue);
});
});
group('SieveParser — comments', () {
test('line comments are ignored', () {
const script = '''
# This is a comment
if header :contains "Subject" "Spam" {
discard; # inline comment
}
''';
final ctx = run(script, _email(subject: 'Spam'));
expect(ctx.isCancelled, isTrue);
});
test('block comments are ignored', () {
const script = '''
/* block comment */
if /* inline block */ header :contains "Subject" "Spam" {
discard;
}
''';
final ctx = run(script, _email(subject: 'Spam'));
expect(ctx.isCancelled, isTrue);
});
});
group('SieveParser — exists test', () {
test('exists fires when header is present and non-empty', () {
const script = '''
if exists "X-Spam-Flag" {
discard;
}
''';
const email = SieveEmailContext(
headers: {
'x-spam-flag': ['YES'],
},
);
final ctx = interp.execute(parser.parse(script), email);
expect(ctx.isCancelled, isTrue);
});
});
group('SieveParser — rule model', () {
test('simple if produces one rule with branchGroupId', () {
final rules =
parser.parse('if header :contains "Subject" "x" { discard; }');
expect(rules, hasLength(1));
expect(rules.first.branchGroupId, isNotNull);
expect(rules.first.conditions, hasLength(1));
expect(rules.first.actions, hasLength(1));
expect(rules.first.actions.first, isA<DiscardAction>());
});
test('if/elsif produces two rules with the same branchGroupId', () {
const script = '''
if header :contains "Subject" "a" { discard; }
elsif header :contains "Subject" "b" { keep; }
''';
final rules = parser.parse(script);
expect(rules, hasLength(2));
expect(rules[0].branchGroupId, equals(rules[1].branchGroupId));
expect(rules[0].isElseBranch, isFalse);
expect(rules[1].isElseBranch, isFalse);
});
test('else rule has isElseBranch=true', () {
const script = '''
if header :contains "Subject" "a" { discard; }
else { keep; }
''';
final rules = parser.parse(script);
expect(rules, hasLength(2));
expect(rules.last.isElseBranch, isTrue);
});
test('HeaderCondition fields are populated', () {
final rules = parser.parse(
'if header :contains ["From","Reply-To"] "x@y.com" { keep; }',
);
final cond = rules.first.conditions.first as HeaderCondition;
expect(cond.headers, containsAll(['From', 'Reply-To']));
expect(cond.matchType, ':contains');
expect(cond.keyList, contains('x@y.com'));
});
});
}
+179
View File
@@ -0,0 +1,179 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:plugin_platform_interface/plugin_platform_interface.dart';
import 'package:sharedinbox/core/models/account.dart';
import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/screens/about_screen.dart';
import 'package:url_launcher_platform_interface/url_launcher_platform_interface.dart';
import 'helpers.dart';
class MockUrlLauncher extends Mock
with MockPlatformInterfaceMixin
implements UrlLauncherPlatform {
String? launchedUrl;
@override
Future<bool> canLaunch(String? url) async => true;
@override
Future<bool> launchUrl(String? url, LaunchOptions? options) async {
launchedUrl = url;
return true;
}
}
Widget _buildScreen({List<Account> accounts = const []}) {
return ProviderScope(
overrides: [
accountRepositoryProvider
.overrideWithValue(FakeAccountRepository(accounts)),
],
child: const MaterialApp(home: AboutScreen()),
);
}
void main() {
setUpAll(() {
PackageInfo.setMockInitialValues(
appName: 'SharedInbox',
packageName: 'org.sharedinbox',
version: '1.2.3',
buildNumber: '99',
buildSignature: '',
);
});
testWidgets('AboutScreen shows title and info table', (tester) async {
tester.view.physicalSize = const Size(800, 1200);
tester.view.devicePixelRatio = 2.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(_buildScreen());
await tester.pumpAndSettle();
expect(find.text('About'), findsOneWidget);
expect(find.textContaining('App Version'), findsWidgets);
expect(find.textContaining('1.2.3+99'), findsOneWidget);
expect(find.textContaining('Resolution'), findsWidgets);
expect(find.textContaining('Dark Mode'), findsWidgets);
expect(find.textContaining('IMAP Accounts'), findsWidgets);
expect(find.textContaining('JMAP Accounts'), findsWidgets);
// Buttons are in the body, not in the AppBar actions
expect(find.byIcon(Icons.copy), findsOneWidget);
expect(find.byIcon(Icons.bug_report), findsOneWidget);
expect(find.text('Copy to clipboard'), findsOneWidget);
expect(find.text('Create issue'), findsOneWidget);
});
testWidgets('AboutScreen shows correct IMAP and JMAP account counts', (
tester,
) async {
tester.view.physicalSize = const Size(800, 1200);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
_buildScreen(
accounts: [
const Account(
id: 'imap-1',
displayName: 'Alice',
email: 'alice@example.com',
imapHost: 'imap.example.com',
smtpHost: 'smtp.example.com',
),
const Account(
id: 'imap-2',
displayName: 'Bob',
email: 'bob@example.com',
imapHost: 'imap.example.com',
smtpHost: 'smtp.example.com',
),
const Account(
id: 'jmap-1',
displayName: 'Carol',
email: 'carol@example.com',
type: AccountType.jmap,
jmapUrl: 'https://jmap.example.com',
),
],
),
);
await tester.pumpAndSettle();
expect(find.textContaining('IMAP Accounts'), findsWidgets);
expect(find.textContaining('JMAP Accounts'), findsWidgets);
});
testWidgets('AboutScreen copy button puts markdown in clipboard', (
tester,
) async {
tester.view.physicalSize = const Size(800, 1200);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
String? clipboardText;
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
SystemChannels.platform,
(MethodCall call) async {
if (call.method == 'Clipboard.setData') {
clipboardText =
(call.arguments as Map<dynamic, dynamic>)['text'] as String?;
}
return null;
},
);
addTearDown(
() => tester.binding.defaultBinaryMessenger
.setMockMethodCallHandler(SystemChannels.platform, null),
);
await tester.pumpWidget(_buildScreen());
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.copy));
await tester.pump();
await tester.pump();
await tester.pumpAndSettle();
expect(clipboardText, isNotNull);
expect(clipboardText, contains('1.2.3+99'));
expect(clipboardText, contains('App Version'));
expect(clipboardText, contains('Resolution'));
expect(clipboardText, contains('Dark Mode'));
expect(clipboardText, contains('IMAP Accounts'));
expect(clipboardText, contains('JMAP Accounts'));
});
testWidgets('AboutScreen create-issue button opens Codeberg URL', (
tester,
) async {
tester.view.physicalSize = const Size(800, 1200);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final mock = MockUrlLauncher();
UrlLauncherPlatform.instance = mock;
await tester.pumpWidget(_buildScreen());
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.bug_report));
await tester.pumpAndSettle();
expect(
mock.launchedUrl,
contains('https://codeberg.org/guettli/sharedinbox/issues/new'),
);
expect(mock.launchedUrl, contains('1.2.3%2B99'));
});
}
+126 -133
View File
@@ -1,13 +1,120 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sharedinbox/core/models/account.dart';
import 'package:sharedinbox/core/services/share_encryption_service.dart';
import 'helpers.dart';
void main() {
group('AccountExportScreen', () {
group('AccountReceiveScreen', () {
testWidgets('shows pubkey QR code and scan button after key generation', (
tester,
) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/receive',
overrides: baseOverrides(),
),
);
// Allow async key generation to complete.
await tester.pumpAndSettle();
expect(find.byKey(const Key('pubKeyQrCode')), findsOneWidget);
expect(find.byKey(const Key('scanEncryptedButton')), findsOneWidget);
});
testWidgets('shows 20-minute expiry hint', (tester) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/receive',
overrides: baseOverrides(),
),
);
await tester.pumpAndSettle();
expect(find.textContaining('20 minutes'), findsOneWidget);
});
});
group('AccountSendScreen', () {
testWidgets('shows camera scanner (or text fallback) on load', (
tester,
) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/send',
overrides: baseOverrides(
accounts: [
const Account(
id: 'acc-1',
displayName: 'Alice',
email: 'alice@example.com',
imapHost: 'imap.example.com',
smtpHost: 'smtp.example.com',
),
],
),
),
);
await tester.pumpAndSettle();
// On Linux (desktop without camera), the text-fallback field appears.
// On mobile, the MobileScanner widget would be shown.
// Either way the screen renders without crash.
expect(find.byType(Scaffold), findsAtLeastNWidgets(1));
});
testWidgets('shows account selection when multiple accounts present', (
tester,
) async {
const account1 = Account(
id: 'acc-1',
displayName: 'Alice',
email: 'alice@example.com',
imapHost: 'imap.example.com',
smtpHost: 'smtp.example.com',
);
const account2 = Account(
id: 'acc-2',
displayName: 'Bob',
email: 'bob@example.com',
imapHost: 'imap.example.com',
smtpHost: 'smtp.example.com',
);
// Generate a real key pair and a valid pubkey QR string to feed in.
final material = await ShareEncryptionService.generateKeyPair();
final pubKeyQr = ShareEncryptionService.encodePublicKeyQr(
material.keyId,
material.publicKeyBytes,
);
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/send',
overrides: baseOverrides(accounts: [account1, account2]),
),
);
await tester.pumpAndSettle();
// On desktop the text fallback is shown — simulate pasting the pubkey.
final field = find.byKey(const Key('pubKeyInputField'));
if (field.evaluate().isNotEmpty) {
await tester.enterText(field, pubKeyQr);
await tester.tap(find.text('Continue'));
await tester.pumpAndSettle();
// With two accounts the selection list should appear.
expect(find.byKey(const Key('sendSelectedButton')), findsOneWidget);
expect(find.text('Alice'), findsOneWidget);
expect(find.text('Bob'), findsOneWidget);
}
// On mobile the MobileScanner handles this; we skip it in widget tests.
});
testWidgets('shows encrypted QR after single account auto-select', (
tester,
) async {
const account = Account(
id: 'acc-1',
displayName: 'Alice',
@@ -16,147 +123,33 @@ void main() {
smtpHost: 'smtp.example.com',
);
testWidgets('shows QR code and copy button after loading', (tester) async {
final material = await ShareEncryptionService.generateKeyPair();
final pubKeyQr = ShareEncryptionService.encodePublicKeyQr(
material.keyId,
material.publicKeyBytes,
);
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/export',
initialLocation: '/accounts/send',
overrides: baseOverrides(accounts: [account]),
),
);
await tester.pumpAndSettle();
expect(find.byKey(const Key('accountQrCode')), findsOneWidget);
expect(find.byKey(const Key('copyCodeButton')), findsOneWidget);
});
testWidgets('shows password warning', (tester) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/export',
overrides: baseOverrides(accounts: [account]),
),
);
final field = find.byKey(const Key('pubKeyInputField'));
if (field.evaluate().isNotEmpty) {
await tester.enterText(field, pubKeyQr);
await tester.tap(find.text('Continue'));
await tester.pumpAndSettle();
// Single account → auto-selected → encrypted QR shown immediately.
expect(
find.textContaining('password'),
findsAtLeastNWidgets(1),
find.byKey(const Key('encryptedAccountsQrCode')),
findsOneWidget,
);
});
});
group('AccountImportScreen', () {
testWidgets('shows instruction text and disabled import button', (
tester,
) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/import',
overrides: baseOverrides(),
),
);
await tester.pumpAndSettle();
expect(find.text('Import account'), findsOneWidget);
expect(find.byKey(const Key('importCodeField')), findsOneWidget);
final importBtn = tester.widget<FilledButton>(
find.byKey(const Key('importButton')),
);
expect(importBtn.onPressed, isNull);
});
testWidgets('invalid JSON shows error message', (tester) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/import',
overrides: baseOverrides(),
),
);
await tester.pumpAndSettle();
await tester.enterText(
find.byKey(const Key('importCodeField')),
'not valid json',
);
await tester.pumpAndSettle();
expect(find.textContaining('Invalid code'), findsOneWidget);
});
testWidgets('valid code shows account preview and enables import', (
tester,
) async {
const account = Account(
id: 'acc-99',
displayName: 'Bob',
email: 'bob@example.com',
imapHost: 'imap.example.com',
smtpHost: 'smtp.example.com',
);
final code = jsonEncode({
'v': 1,
'account': account.toJson(),
'password': 'secret',
});
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/import',
overrides: baseOverrides(),
),
);
await tester.pumpAndSettle();
await tester.enterText(
find.byKey(const Key('importCodeField')),
code,
);
await tester.pumpAndSettle();
expect(find.text('Bob'), findsOneWidget);
expect(find.text('bob@example.com'), findsOneWidget);
final importBtn = tester.widget<FilledButton>(
find.byKey(const Key('importButton')),
);
expect(importBtn.onPressed, isNotNull);
});
testWidgets('successful import navigates back to accounts list', (
tester,
) async {
const account = Account(
id: 'acc-99',
displayName: 'Bob',
email: 'bob@example.com',
imapHost: 'imap.example.com',
smtpHost: 'smtp.example.com',
);
final code = jsonEncode({
'v': 1,
'account': account.toJson(),
'password': 'secret',
});
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/import',
overrides: baseOverrides(),
),
);
await tester.pumpAndSettle();
await tester.enterText(
find.byKey(const Key('importCodeField')),
code,
);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('importButton')));
await tester.pumpAndSettle();
expect(find.text('SharedInbox'), findsOneWidget);
expect(find.byKey(const Key('copyEncryptedButton')), findsOneWidget);
}
});
});
}
+5 -5
View File
@@ -13,7 +13,7 @@ void main() {
);
await tester.pumpAndSettle();
expect(find.text('Welcome to SharedInbox'), findsOneWidget);
expect(find.text('Welcome to sharedinbox.de'), findsOneWidget);
expect(find.text('Add account'), findsOneWidget);
});
@@ -86,7 +86,7 @@ void main() {
);
await tester.pumpAndSettle();
expect(find.text('SharedInbox'), findsOneWidget);
expect(find.text('sharedinbox.de'), findsOneWidget);
});
testWidgets(
@@ -136,7 +136,7 @@ void main() {
expect(find.text('Add account'), findsOneWidget);
});
testWidgets('account popup menu contains Export account item', (
testWidgets('account popup menu contains Send accounts item', (
tester,
) async {
await tester.pumpWidget(
@@ -150,7 +150,7 @@ void main() {
await tester.tap(find.byIcon(Icons.more_vert));
await tester.pumpAndSettle();
expect(find.text('Export account'), findsOneWidget);
expect(find.text('Send accounts'), findsOneWidget);
});
testWidgets('account popup menu contains Force full sync item', (
@@ -204,7 +204,7 @@ void main() {
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
expect(find.text('SharedInbox'), findsOneWidget);
expect(find.text('sharedinbox.de'), findsOneWidget);
});
});
}
+4 -3
View File
@@ -7,13 +7,14 @@ import 'helpers.dart';
void main() {
group('AddAccountScreen', () {
testWidgets('step 1: shows Import account button', (tester) async {
testWidgets('step 1: shows Receive account button', (tester) async {
await tester.pumpWidget(
buildApp(initialLocation: '/accounts/add', overrides: baseOverrides()),
);
await tester.pumpAndSettle();
expect(find.byKey(const Key('importAccountButton')), findsOneWidget);
expect(find.text('Receive account'), findsOneWidget);
});
testWidgets('step 1: shows email field and Continue button', (
@@ -212,7 +213,7 @@ void main() {
await tester.tap(find.text('Save'));
await tester.pumpAndSettle();
expect(find.text('Welcome to SharedInbox'), findsOneWidget);
expect(find.text('Welcome to sharedinbox.de'), findsOneWidget);
});
testWidgets('JMAP connection failure shows error message', (tester) async {
@@ -293,7 +294,7 @@ void main() {
await tester.tap(find.text('Save'));
await tester.pumpAndSettle();
expect(find.text('Welcome to SharedInbox'), findsOneWidget);
expect(find.text('Welcome to sharedinbox.de'), findsOneWidget);
});
testWidgets(
+93
View File
@@ -1,14 +1,50 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/di.dart';
import 'helpers.dart';
// Fake PathProviderPlatform so _downloadRaw resolves getTemporaryDirectory
// via pure microtasks instead of calling xdg-user-dir.
class _FakePathProviderPlatform extends PathProviderPlatform {
@override
Future<String?> getTemporaryPath() async => '/tmp';
}
// IOOverrides subclass that stubs File creation so _downloadRaw completes
// without real dart:io — writeAsString becomes a no-op microtask.
base class _FakeIOOverrides extends IOOverrides {
@override
File createFile(String path) => _FakeFile(path);
}
// Fake File whose writeAsString is a no-op so _downloadRaw completes without
// real I/O. Other methods are unused and left to Fake's noSuchMethod handler.
class _FakeFile extends Fake implements File {
_FakeFile(this._path);
final String _path;
@override
String get path => _path;
@override
Future<File> writeAsString(
String contents, {
FileMode mode = FileMode.write,
Encoding encoding = utf8,
bool flush = false,
}) async =>
this;
}
// Shared overrides for email detail tests.
List<Override> _overrides({required EmailBody body, Email? email}) => [
accountRepositoryProvider.overrideWithValue(
@@ -178,6 +214,63 @@ void main() {
expect(find.text('2.0 KB'), findsOneWidget);
});
testWidgets('Download Raw Email closes dialog after download', (
tester,
) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
overrides: [
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([kTestAccount]),
),
mailboxRepositoryProvider
.overrideWithValue(FakeMailboxRepository()),
emailRepositoryProvider.overrideWithValue(
FakeEmailRepository(
emailDetail: testEmail(),
emailBody:
const EmailBody(emailId: 'acc-1:42', attachments: []),
rawRfc822: 'Subject: test\r\n\r\nBody',
),
),
],
),
);
await tester.pumpAndSettle();
await tester.tap(find.byType(PopupMenuButton<String>));
await tester.pumpAndSettle();
await tester.tap(find.text('Show Raw Email'));
await tester.pumpAndSettle();
expect(find.text('Raw Email'), findsOneWidget);
// Replace path_provider and File I/O with pure-microtask fakes so the
// entire _downloadRaw → Navigator.pop chain completes within pump loops.
final prevPathProvider = PathProviderPlatform.instance;
PathProviderPlatform.instance = _FakePathProviderPlatform();
IOOverrides.global = _FakeIOOverrides();
addTearDown(() {
PathProviderPlatform.instance = prevPathProvider;
IOOverrides.global = null;
});
await tester.tap(find.text('Download'));
// Each pump drains one microtask level: getTemporaryDirectory, then
// writeAsString, then _downloadRaw return, then Navigator.pop.
for (var i = 0; i < 10; i++) {
await tester.pump(Duration.zero);
}
await tester.pumpAndSettle();
// Dialog must be dismissed after download completes.
expect(find.text('Raw Email'), findsNothing);
// SnackBar with Share action must be visible.
expect(find.text('Share'), findsOneWidget);
});
testWidgets('Show Mail Structure opens dialog with MIME parts', (
tester,
) async {
+24 -10
View File
@@ -18,13 +18,15 @@ import 'package:sharedinbox/core/repositories/draft_repository.dart';
import 'package:sharedinbox/core/repositories/email_repository.dart';
import 'package:sharedinbox/core/repositories/mailbox_repository.dart';
import 'package:sharedinbox/core/repositories/search_history_repository.dart';
import 'package:sharedinbox/core/repositories/share_key_repository.dart';
import 'package:sharedinbox/core/services/account_discovery_service.dart';
import 'package:sharedinbox/core/services/connection_test_service.dart';
import 'package:sharedinbox/core/services/managesieve_probe_service.dart';
import 'package:sharedinbox/core/services/share_encryption_service.dart';
import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/screens/account_export_screen.dart';
import 'package:sharedinbox/ui/screens/account_import_screen.dart';
import 'package:sharedinbox/ui/screens/account_list_screen.dart';
import 'package:sharedinbox/ui/screens/account_receive_screen.dart';
import 'package:sharedinbox/ui/screens/account_send_screen.dart';
import 'package:sharedinbox/ui/screens/add_account_screen.dart';
import 'package:sharedinbox/ui/screens/address_emails_screen.dart';
import 'package:sharedinbox/ui/screens/compose_screen.dart';
@@ -74,6 +76,19 @@ class FakeAccountRepository implements AccountRepository {
Future<String> getPassword(String accountId) async => 'test-password';
}
class FakeShareKeyRepository implements ShareKeyRepository {
ShareKeyMaterial? _material;
@override
Future<ShareKeyMaterial> createKeyPair() async {
_material = await ShareEncryptionService.generateKeyPair();
return _material!;
}
@override
Future<ShareKeyMaterial?> findByKeyId(dynamic keyId) async => _material;
}
class FakeDraftRepository implements DraftRepository {
int _nextId = 1;
final Map<int, SavedDraft> _drafts = {};
@@ -375,8 +390,12 @@ Widget buildApp({
builder: (ctx, state) => const AddAccountScreen(),
),
GoRoute(
path: 'import',
builder: (ctx, state) => const AccountImportScreen(),
path: 'receive',
builder: (ctx, state) => const AccountReceiveScreen(),
),
GoRoute(
path: 'send',
builder: (ctx, state) => const AccountSendScreen(),
),
GoRoute(
path: ':accountId/edit',
@@ -384,12 +403,6 @@ Widget buildApp({
accountId: state.pathParameters['accountId']!,
),
),
GoRoute(
path: ':accountId/export',
builder: (ctx, state) => AccountExportScreen(
accountId: state.pathParameters['accountId']!,
),
),
GoRoute(
path: ':accountId/search',
builder: (ctx, state) =>
@@ -499,6 +512,7 @@ List<Override> baseOverrides({
connectionTestServiceProvider.overrideWithValue(
FakeConnectionTestService(error: connectionError),
),
shareKeyRepositoryProvider.overrideWithValue(FakeShareKeyRepository()),
];
// ---------------------------------------------------------------------------
@@ -0,0 +1,76 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:sharedinbox/core/models/sieve_script.dart';
import 'package:sharedinbox/data/db/local_sieve_repository.dart';
import 'package:sharedinbox/data/jmap/sieve_repository.dart';
import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/screens/sieve_scripts_screen.dart';
import '../unit/db_test_helper.dart';
import 'helpers.dart';
class _FakeSieveRepository extends SieveRepository {
_FakeSieveRepository() : super(FakeAccountRepository(), http.Client());
@override
Future<List<SieveScript>> listScripts(String accountId) async => [];
}
void main() {
configureSqliteForTests();
testWidgets('Remote Filters page shows correct title and banner', (
tester,
) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
sieveRepositoryProvider.overrideWith(
(ref) => _FakeSieveRepository(),
),
],
child: const MaterialApp(
home: SieveScriptsScreen(accountId: 'acc-1'),
),
),
);
await tester.pumpAndSettle();
expect(find.text('Remote Filters'), findsOneWidget);
expect(
find.textContaining('Remote Filters run Sieve scripts'),
findsOneWidget,
);
expect(find.textContaining('Local Filters'), findsOneWidget);
});
testWidgets('Local Filters page shows correct title and banner', (
tester,
) async {
final db = openTestDatabase();
addTearDown(db.close);
await tester.pumpWidget(
ProviderScope(
overrides: [
localSieveRepositoryProvider.overrideWith(
(ref) => LocalSieveRepository(db),
),
],
child: const MaterialApp(
home: SieveScriptsScreen(accountId: 'acc-1', isLocal: true),
),
),
);
await tester.pumpAndSettle();
expect(find.text('Local Filters'), findsOneWidget);
expect(
find.textContaining('Local Filters run Sieve scripts'),
findsOneWidget,
);
expect(find.textContaining('Remote Filters'), findsOneWidget);
});
}
+98
View File
@@ -0,0 +1,98 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';
import 'package:sharedinbox/core/models/undo_action.dart';
import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/widgets/undo_shell.dart';
import '../unit/undo_service_test.mocks.dart';
void main() {
late MockUndoRepository mockUndoRepo;
setUp(() {
mockUndoRepo = MockUndoRepository();
when(mockUndoRepo.saveAction(any)).thenAnswer((_) async {});
when(mockUndoRepo.deleteAction(any)).thenAnswer((_) async {});
when(mockUndoRepo.clearHistory()).thenAnswer((_) async {});
});
Widget buildShell(MockUndoRepository repo) {
return ProviderScope(
overrides: [undoRepositoryProvider.overrideWithValue(repo)],
child: const MaterialApp(
home: UndoShell(child: Scaffold(body: Text('content'))),
),
);
}
testWidgets(
'does not show snackbar for stale action loaded from persistence on startup',
(tester) async {
final staleAction = UndoAction(
id: '1',
accountId: 'acc1',
type: UndoType.move,
emailIds: ['e1'],
sourceMailboxPath: 'INBOX',
timestamp: DateTime.now().subtract(const Duration(hours: 1)),
);
when(mockUndoRepo.getHistory(limit: anyNamed('limit')))
.thenAnswer((_) async => [staleAction]);
await tester.pumpWidget(buildShell(mockUndoRepo));
await tester.pumpAndSettle();
expect(find.text('1 email(s) moved'), findsNothing);
},
);
testWidgets('shows snackbar for fresh action pushed in current session',
(tester) async {
when(mockUndoRepo.getHistory(limit: anyNamed('limit')))
.thenAnswer((_) async => []);
await tester.pumpWidget(buildShell(mockUndoRepo));
await tester.pumpAndSettle();
final context = tester.element(find.byType(UndoShell));
final freshAction = UndoAction(
id: '1',
accountId: 'acc1',
type: UndoType.move,
emailIds: ['e1'],
sourceMailboxPath: 'INBOX',
);
await ProviderScope.containerOf(context)
.read(undoServiceProvider.notifier)
.pushAction(freshAction);
await tester.pumpAndSettle();
expect(find.text('1 email(s) moved'), findsOneWidget);
});
testWidgets('shows correct text for delete action (moved to Trash)',
(tester) async {
when(mockUndoRepo.getHistory(limit: anyNamed('limit')))
.thenAnswer((_) async => []);
await tester.pumpWidget(buildShell(mockUndoRepo));
await tester.pumpAndSettle();
final context = tester.element(find.byType(UndoShell));
final deleteAction = UndoAction(
id: '2',
accountId: 'acc1',
type: UndoType.delete,
emailIds: ['e1', 'e2'],
sourceMailboxPath: 'INBOX',
);
await ProviderScope.containerOf(context)
.read(undoServiceProvider.notifier)
.pushAction(deleteAction);
await tester.pumpAndSettle();
expect(find.text('2 email(s) moved to Trash'), findsOneWidget);
});
}
+2 -2
View File
@@ -1,7 +1,7 @@
---
title: 'Welcome to SharedInbox'
title: 'Welcome to sharedinbox.de'
date: 2026-05-12T08:00:00Z
draft: false
---
Welcome to the official website of SharedInbox!
Welcome to the official website of sharedinbox.de!
+3 -3
View File
@@ -12,10 +12,10 @@ Thomas Güttler Email: info@thomas-guettler.de
## 2. Overview
SharedInbox is an email client for desktop and mobile. This policy explains what data we process and
sharedinbox.de is an email client for desktop and mobile. This policy explains what data we process and
how.
## 3. The SharedInbox Mail App
## 3. The sharedinbox.de Mail App
### What data the app processes
@@ -30,7 +30,7 @@ The app processes the following data **exclusively on your device**:
### Network connections
The app connects only to the email servers you configure (IMAP/SMTP). It does not connect to any
SharedInbox servers, analytics services, or third-party tracking services.
sharedinbox.de servers, analytics services, or third-party tracking services.
### No telemetry
+4 -4
View File
@@ -1,16 +1,16 @@
baseURL = 'https://sharedinbox.de/'
languageCode = 'en-us'
title = 'SharedInbox'
title = 'sharedinbox.de'
theme = 'PaperMod'
[params]
env = 'production'
title = 'SharedInbox'
title = 'sharedinbox.de'
description = 'IMAP/SMTP Email Client'
author = 'SharedInbox Team'
author = 'sharedinbox.de Team'
[params.homeInfoParams]
Title = "SharedInbox"
Title = "sharedinbox.de"
Content = "The modern IMAP/SMTP email client for your desktop and mobile."
[[params.socialIcons]]