Fix Issues 1, 2, and 3: Headers, Undo, and Crash Reporting #6
@@ -78,3 +78,12 @@ sharedinbox-runner/runner-data/
|
||||
snap/
|
||||
.gitconfig
|
||||
.gemini/
|
||||
.dartServer/
|
||||
.pub-cache/
|
||||
.android/
|
||||
.gradle/
|
||||
.emulator_console_auth_token
|
||||
.lesshst
|
||||
.metadata
|
||||
.tmux.conf
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 [],
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -42,6 +42,7 @@ dependencies:
|
||||
|
||||
# HTML rendering for email bodies
|
||||
flutter_html: ^3.0.0
|
||||
url_launcher: ^6.3.2
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
Reference in New Issue
Block a user