Fix Issues 1, 2, and 3: Headers, Undo, and Crash Reporting (#6)

## Overview
This PR implements several fixes and enhancements requested in the latest session:

### Fixes
1. **Issue 1: Raw Email Headers**
   - Added database support for raw headers.
   - Added a new Headers tab in the email detail screen with a zebra-colored table display.
2. **Issue 2: Exception on Undo of Delete**
   - Added `toJson` and `fromJson` to `EmailAddress` model to fix serialization during undo.
3. **Issue 3: Crash Reporting**
   - Added a button to the Crash Screen to report issues directly on Codeberg.

### Infrastructure
- Added Nix experimental features check to `Taskfile.yml` to ensure a consistent dev environment.

## Verification
- Manually verified the Headers display on Linux.
- Verified Undo for IMAP and JMAP accounts.
- Verified the Crash Screen button.

Co-authored-by: Thomas SharedInbox <sharedinbox@thomas-guettler.de>
Reviewed-on: https://codeberg.org/guettli/sharedinbox/pulls/6
This commit was merged in pull request #6.
This commit is contained in:
guettlibot
2026-05-09 18:49:34 +02:00
co-authored by Thomas SharedInbox
parent d405b37308
commit 6d58ee1e00
8 changed files with 188 additions and 1 deletions
+9
View File
@@ -78,3 +78,12 @@ sharedinbox-runner/runner-data/
snap/
.gitconfig
.gemini/
.dartServer/
.pub-cache/
.android/
.gradle/
.emulator_console_auth_token
.lesshst
.metadata
.tmux.conf
+24
View File
@@ -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"
+32
View File
@@ -123,22 +123,54 @@ class EmailAddress {
const EmailAddress({this.name, required this.email});
factory EmailAddress.fromJson(Map<String, dynamic> json) {
return EmailAddress(
name: json['name'] as String?,
email: json['email'] as String,
);
}
Map<String, dynamic> 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<String, dynamic> json) {
return EmailHeader(
name: json['name'] as String,
value: json['value'] as String,
);
}
Map<String, dynamic> 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<EmailAttachment> attachments;
final List<EmailHeader> headers;
const EmailBody({
required this.emailId,
this.textBody,
this.htmlBody,
required this.attachments,
this.headers = const [],
});
}
+6 -1
View File
@@ -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<Column> 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);
}
},
);
}
@@ -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<dynamic>? ?? [];
final headersJson = jsonEncode(
rawHeaders.map((h) {
final map = h as Map<String, dynamic>;
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<model.EmailHeader> _parseHeaders(String? jsonStr) {
if (jsonStr == null || jsonStr.isEmpty) return [];
try {
final list = jsonDecode(jsonStr) as List;
return list
.cast<Map<String, dynamic>>()
.map((m) => model.EmailHeader.fromJson(m))
.toList();
} catch (e) {
return [];
}
}
List<model.EmailAttachment> _parseAttachments(String json) {
final list = jsonDecode(json) as List<dynamic>;
return list
+14
View File
@@ -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'),
),
],
),
),
+69
View File
@@ -152,6 +152,19 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
if (context.mounted) context.pop();
},
),
PopupMenuButton<String>(
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<EmailDetailScreen> {
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<void>(
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 {
+1
View File
@@ -42,6 +42,7 @@ dependencies:
# HTML rendering for email bodies
flutter_html: ^3.0.0
url_launcher: ^6.3.2
dev_dependencies:
flutter_test: