diff --git a/.gitignore b/.gitignore index 1d4cd31..0e3a50e 100644 --- a/.gitignore +++ b/.gitignore @@ -78,3 +78,12 @@ sharedinbox-runner/runner-data/ snap/ .gitconfig .gemini/ +.dartServer/ +.pub-cache/ +.android/ +.gradle/ +.emulator_console_auth_token +.lesshst +.metadata +.tmux.conf + diff --git a/Taskfile.yml b/Taskfile.yml index 6306529..112f4bb 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -6,9 +6,33 @@ tasks: desc: Run all checks (analyze + unit tests + widget tests + integration, in parallel) deps: [check] + _nix-check: + internal: true + run: once + cmds: + - cmd: | + if ! nix show-config experimental-features 2>/dev/null | grep -q "nix-command" || ! nix show-config experimental-features 2>/dev/null | grep -q "flakes"; then + echo "CRITICAL: Nix experimental features 'nix-command' and 'flakes' must be enabled." + echo "Attempting to fix by updating ~/.config/nix/nix.conf ..." + mkdir -p ~/.config/nix + CONF=~/.config/nix/nix.conf + if [ ! -f "$CONF" ]; then + echo "experimental-features = nix-command flakes" > "$CONF" + elif ! grep -q "experimental-features" "$CONF"; then + echo "experimental-features = nix-command flakes" >> "$CONF" + elif ! grep -E "experimental-features.*nix-command" "$CONF" || ! grep -E "experimental-features.*flakes" "$CONF"; then + echo "Your $CONF already has experimental-features but is missing 'nix-command' or 'flakes'." + echo "Please add them manually to the 'experimental-features' line." + exit 1 + fi + echo "Local configuration updated. Please re-run your command." + exit 1 + fi + _preflight: internal: true run: once + deps: [_nix-check] preconditions: - sh: test "${DIRENV_DIR#-}" = "{{.TASKFILE_DIR}}" msg: "Not in nix dev shell. Run: nix develop" diff --git a/lib/core/models/email.dart b/lib/core/models/email.dart index 7c12e8c..83b040c 100644 --- a/lib/core/models/email.dart +++ b/lib/core/models/email.dart @@ -123,22 +123,54 @@ class EmailAddress { const EmailAddress({this.name, required this.email}); + factory EmailAddress.fromJson(Map json) { + return EmailAddress( + name: json['name'] as String?, + email: json['email'] as String, + ); + } + + Map toJson() { + return { + if (name != null) 'name': name, + 'email': email, + }; + } + @override String toString() => name != null ? '$name <$email>' : email; } +class EmailHeader { + final String name; + final String value; + + const EmailHeader({required this.name, required this.value}); + + factory EmailHeader.fromJson(Map json) { + return EmailHeader( + name: json['name'] as String, + value: json['value'] as String, + ); + } + + Map toJson() => {'name': name, 'value': value}; +} + /// Full message body — fetched on demand, cached in the local DB. class EmailBody { final String emailId; final String? textBody; final String? htmlBody; final List attachments; + final List headers; const EmailBody({ required this.emailId, this.textBody, this.htmlBody, required this.attachments, + this.headers = const [], }); } diff --git a/lib/data/db/database.dart b/lib/data/db/database.dart index f9fb7e0..8f99e66 100644 --- a/lib/data/db/database.dart +++ b/lib/data/db/database.dart @@ -98,6 +98,8 @@ class EmailBodies extends Table { // Added in schema v9: when the body was last fetched from the server. // Null for rows cached before this column was added (treated as expired). DateTimeColumn get cachedAt => dateTime().nullable()(); + // Added in schema v20: raw or parsed headers + TextColumn get headersJson => text().nullable()(); @override Set get primaryKey => {emailId}; @@ -244,7 +246,7 @@ class AppDatabase extends _$AppDatabase { AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection()); @override - int get schemaVersion => 19; + int get schemaVersion => 20; @override MigrationStrategy get migration => MigrationStrategy( @@ -370,6 +372,9 @@ class AppDatabase extends _$AppDatabase { if (from < 19) { await m.createTable(syncHealth); } + if (from < 20) { + await m.addColumn(emailBodies, emailBodies.headersJson); + } }, ); } diff --git a/lib/data/repositories/email_repository_impl.dart b/lib/data/repositories/email_repository_impl.dart index b086b58..9550b3c 100644 --- a/lib/data/repositories/email_repository_impl.dart +++ b/lib/data/repositories/email_repository_impl.dart @@ -242,12 +242,19 @@ class EmailRepositoryImpl implements EmailRepository { .toList(), ); + final headersJson = jsonEncode( + (msg.headers ?? []) + .map((h) => {'name': h.name, 'value': h.value}) + .toList(), + ); + await _db.into(_db.emailBodies).insertOnConflictUpdate( EmailBodiesCompanion.insert( emailId: emailId, textBody: Value(textBody), htmlBody: Value(htmlBody), attachmentsJson: Value(attachmentsJson), + headersJson: Value(headersJson), cachedAt: Value(DateTime.now()), ), ); @@ -256,6 +263,7 @@ class EmailRepositoryImpl implements EmailRepository { textBody: textBody, htmlBody: htmlBody, attachments: _parseAttachments(attachmentsJson), + headers: _parseHeaders(headersJson), ); } finally { await client.logout(); @@ -287,6 +295,7 @@ class EmailRepositoryImpl implements EmailRepository { 'ids': [jmapEmailId], 'properties': [ 'id', + 'headers', 'textBody', 'htmlBody', 'bodyValues', @@ -305,12 +314,21 @@ class EmailRepositoryImpl implements EmailRepository { final (textBody, htmlBody, attachmentsJson) = _parseJmapBody(emailData); + final rawHeaders = emailData['headers'] as List? ?? []; + final headersJson = jsonEncode( + rawHeaders.map((h) { + final map = h as Map; + return {'name': map['name'] ?? '', 'value': map['value'] ?? ''}; + }).toList(), + ); + await _db.into(_db.emailBodies).insertOnConflictUpdate( EmailBodiesCompanion.insert( emailId: emailId, textBody: Value(textBody), htmlBody: Value(htmlBody), attachmentsJson: Value(attachmentsJson), + headersJson: Value(headersJson), cachedAt: Value(DateTime.now()), ), ); @@ -320,6 +338,7 @@ class EmailRepositoryImpl implements EmailRepository { textBody: textBody, htmlBody: htmlBody, attachments: _parseAttachments(attachmentsJson), + headers: _parseHeaders(headersJson), ); } @@ -2372,8 +2391,22 @@ class EmailRepositoryImpl implements EmailRepository { textBody: row.textBody, htmlBody: row.htmlBody, attachments: _parseAttachments(row.attachmentsJson), + headers: _parseHeaders(row.headersJson), ); + List _parseHeaders(String? jsonStr) { + if (jsonStr == null || jsonStr.isEmpty) return []; + try { + final list = jsonDecode(jsonStr) as List; + return list + .cast>() + .map((m) => model.EmailHeader.fromJson(m)) + .toList(); + } catch (e) { + return []; + } + } + List _parseAttachments(String json) { final list = jsonDecode(json) as List; return list diff --git a/lib/ui/screens/crash_screen.dart b/lib/ui/screens/crash_screen.dart index 04ec106..432ee6d 100644 --- a/lib/ui/screens/crash_screen.dart +++ b/lib/ui/screens/crash_screen.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:url_launcher/url_launcher.dart'; class CrashScreen extends StatelessWidget { const CrashScreen({ @@ -86,6 +87,19 @@ class CrashScreen extends StatelessWidget { icon: const Icon(Icons.copy), label: const Text('Copy to Clipboard'), ), + const SizedBox(height: 16), + OutlinedButton.icon( + onPressed: () async { + final url = Uri.parse( + 'https://codeberg.org/guettli/sharedinbox/issues/new', + ); + if (await canLaunchUrl(url)) { + await launchUrl(url, mode: LaunchMode.externalApplication); + } + }, + icon: const Icon(Icons.bug_report), + label: const Text('Report Issue on Codeberg'), + ), ], ), ), diff --git a/lib/ui/screens/email_detail_screen.dart b/lib/ui/screens/email_detail_screen.dart index f8e20ed..dd251df 100644 --- a/lib/ui/screens/email_detail_screen.dart +++ b/lib/ui/screens/email_detail_screen.dart @@ -152,6 +152,19 @@ class _EmailDetailScreenState extends ConsumerState { if (context.mounted) context.pop(); }, ), + PopupMenuButton( + itemBuilder: (ctx) => [ + const PopupMenuItem( + value: 'headers', + child: Text('Show Mail Headers'), + ), + ], + onSelected: (value) { + if (value == 'headers' && body != null) { + _showHeaders(context, body); + } + }, + ), ], ), body: snap.connectionState == ConnectionState.waiting @@ -372,6 +385,62 @@ class _EmailDetailScreenState extends ConsumerState { if (context.mounted) context.pop(); } + + void _showHeaders(BuildContext context, EmailBody body) { + if (body.headers.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('No headers available. Try re-syncing the email.')), + ); + return; + } + + unawaited( + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Mail Headers'), + content: SizedBox( + width: double.maxFinite, + child: ListView.builder( + shrinkWrap: true, + itemCount: body.headers.length, + itemBuilder: (ctx, i) { + final header = body.headers[i]; + return Container( + color: i.isEven + ? Theme.of(ctx).colorScheme.surfaceContainerHighest + : Theme.of(ctx).colorScheme.surface, + padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: SelectableText( + header.name, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + ), + const SizedBox(width: 8), + Expanded( + flex: 2, + child: SelectableText(header.value), + ), + ], + ), + ); + }, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('Close'), + ), + ], + ), + ), + ); + } } class _BlockRemoteImagesExtension extends HtmlExtension { diff --git a/pubspec.yaml b/pubspec.yaml index baf0918..366ce97 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -42,6 +42,7 @@ dependencies: # HTML rendering for email bodies flutter_html: ^3.0.0 + url_launcher: ^6.3.2 dev_dependencies: flutter_test: