feat(U1): show Unsubscribe chip in email detail (#26)
This commit was merged in pull request #26.
This commit is contained in:
@@ -21,6 +21,8 @@ class Email {
|
||||
final String? references;
|
||||
final DateTime? snoozedUntil;
|
||||
final String? snoozedFromMailboxPath;
|
||||
// RFC 2369 List-Unsubscribe header value, e.g. "<mailto:...>, <https://...>".
|
||||
final String? listUnsubscribeHeader;
|
||||
|
||||
const Email({
|
||||
required this.id,
|
||||
@@ -43,6 +45,7 @@ class Email {
|
||||
this.references,
|
||||
this.snoozedUntil,
|
||||
this.snoozedFromMailboxPath,
|
||||
this.listUnsubscribeHeader,
|
||||
});
|
||||
|
||||
factory Email.fromJson(Map<String, dynamic> json) {
|
||||
@@ -77,6 +80,7 @@ class Email {
|
||||
? DateTime.parse(json['snoozedUntil'] as String)
|
||||
: null,
|
||||
snoozedFromMailboxPath: json['snoozedFromMailboxPath'] as String?,
|
||||
listUnsubscribeHeader: json['listUnsubscribeHeader'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -102,6 +106,7 @@ class Email {
|
||||
'references': references,
|
||||
'snoozedUntil': snoozedUntil?.toIso8601String(),
|
||||
'snoozedFromMailboxPath': snoozedFromMailboxPath,
|
||||
'listUnsubscribeHeader': listUnsubscribeHeader,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -126,6 +131,7 @@ class Email {
|
||||
String? references,
|
||||
DateTime? snoozedUntil,
|
||||
String? snoozedFromMailboxPath,
|
||||
String? listUnsubscribeHeader,
|
||||
}) {
|
||||
return Email(
|
||||
id: id ?? this.id,
|
||||
@@ -149,6 +155,8 @@ class Email {
|
||||
snoozedUntil: snoozedUntil ?? this.snoozedUntil,
|
||||
snoozedFromMailboxPath:
|
||||
snoozedFromMailboxPath ?? this.snoozedFromMailboxPath,
|
||||
listUnsubscribeHeader:
|
||||
listUnsubscribeHeader ?? this.listUnsubscribeHeader,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,6 +88,9 @@ class Emails extends Table {
|
||||
DateTimeColumn get snoozedUntil => dateTime().nullable()();
|
||||
TextColumn get snoozedFromMailboxPath => text().nullable()();
|
||||
|
||||
// Added in schema v23: RFC 2369 List-Unsubscribe header value.
|
||||
TextColumn get listUnsubscribeHeader => text().nullable()();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {id};
|
||||
}
|
||||
@@ -264,7 +267,7 @@ class AppDatabase extends _$AppDatabase {
|
||||
AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());
|
||||
|
||||
@override
|
||||
int get schemaVersion => 22;
|
||||
int get schemaVersion => 23;
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration => MigrationStrategy(
|
||||
@@ -420,6 +423,9 @@ class AppDatabase extends _$AppDatabase {
|
||||
),
|
||||
);
|
||||
}
|
||||
if (from < 23) {
|
||||
await m.addColumn(emails, emails.listUnsubscribeHeader);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -528,7 +528,7 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
imap.MessageSequence sequence,
|
||||
) async {
|
||||
const fetchItems =
|
||||
'(UID FLAGS ENVELOPE BODYSTRUCTURE RFC822.SIZE BODY.PEEK[HEADER.FIELDS (REFERENCES)])';
|
||||
'(UID FLAGS ENVELOPE BODYSTRUCTURE RFC822.SIZE BODY.PEEK[HEADER.FIELDS (REFERENCES LIST-UNSUBSCRIBE)])';
|
||||
final fetch = sequence.isUidSequence
|
||||
? await client.uidFetchMessages(sequence, fetchItems)
|
||||
: await client.fetchMessages(sequence, fetchItems);
|
||||
@@ -569,6 +569,7 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
final msgId = envelope.messageId?.trim();
|
||||
final inReplyTo = envelope.inReplyTo?.trim();
|
||||
final refs = msg.getHeaderValue('References')?.trim();
|
||||
final listUnsubscribe = msg.getHeaderValue('List-Unsubscribe')?.trim();
|
||||
final threadId = _computeThreadId(
|
||||
emailId: emailId,
|
||||
messageId: msgId,
|
||||
@@ -612,6 +613,7 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
inReplyTo: Value(inReplyTo),
|
||||
references: Value(refs),
|
||||
snoozedUntil: Value(snoozedUntil),
|
||||
listUnsubscribeHeader: Value(listUnsubscribe),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -950,6 +952,7 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
'htmlBody',
|
||||
'bodyValues',
|
||||
'attachments',
|
||||
'header:List-Unsubscribe:asText',
|
||||
];
|
||||
|
||||
static const _emailGetBodyOptions = {
|
||||
@@ -1151,6 +1154,8 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
final jmapReferences = _joinJmapStringList(
|
||||
m['references'] as List<dynamic>?,
|
||||
);
|
||||
final jmapListUnsubscribe =
|
||||
(m['header:List-Unsubscribe:asText'] as String?)?.trim();
|
||||
|
||||
await _db.into(_db.emails).insertOnConflictUpdate(
|
||||
EmailsCompanion.insert(
|
||||
@@ -1173,6 +1178,7 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
inReplyTo: Value(jmapInReplyTo),
|
||||
references: Value(jmapReferences),
|
||||
snoozedUntil: Value(snoozedUntil),
|
||||
listUnsubscribeHeader: Value(jmapListUnsubscribe),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -2663,6 +2669,7 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
references: row.references,
|
||||
snoozedUntil: row.snoozedUntil,
|
||||
snoozedFromMailboxPath: row.snoozedFromMailboxPath,
|
||||
listUnsubscribeHeader: row.listUnsubscribeHeader,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import 'package:sharedinbox/core/utils/format_utils.dart';
|
||||
import 'package:sharedinbox/core/utils/html_utils.dart';
|
||||
import 'package:sharedinbox/di.dart';
|
||||
import 'package:sharedinbox/ui/widgets/snooze_picker.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
final _dateFmt = DateFormat('EEE, MMM d yyyy, HH:mm');
|
||||
|
||||
@@ -267,6 +268,11 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
_dateFmt.format(email.sentAt!),
|
||||
style: Theme.of(ctx).textTheme.bodySmall,
|
||||
),
|
||||
if (email.listUnsubscribeHeader != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: _UnsubscribeChip(header: email.listUnsubscribeHeader!),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -462,6 +468,39 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses a List-Unsubscribe header and returns the first usable URI.
|
||||
/// Prefers mailto: so unsubscribing sends an email; falls back to https:.
|
||||
Uri? _parseUnsubscribeUri(String header) {
|
||||
final matches = RegExp(r'<([^>]+)>').allMatches(header);
|
||||
Uri? fallback;
|
||||
for (final m in matches) {
|
||||
final raw = m.group(1)!.trim();
|
||||
final uri = Uri.tryParse(raw);
|
||||
if (uri == null) continue;
|
||||
if (uri.scheme == 'mailto') return uri;
|
||||
if ((uri.scheme == 'https' || uri.scheme == 'http') && fallback == null) {
|
||||
fallback = uri;
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
class _UnsubscribeChip extends StatelessWidget {
|
||||
const _UnsubscribeChip({required this.header});
|
||||
final String header;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final uri = _parseUnsubscribeUri(header);
|
||||
if (uri == null) return const SizedBox.shrink();
|
||||
return ActionChip(
|
||||
avatar: const Icon(Icons.unsubscribe_outlined, size: 16),
|
||||
label: const Text('Unsubscribe'),
|
||||
onPressed: () => launchUrl(uri, mode: LaunchMode.externalApplication),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BlockRemoteImagesExtension extends HtmlExtension {
|
||||
@override
|
||||
Set<String> get supportedTags => {'img'};
|
||||
|
||||
Reference in New Issue
Block a user