Compare commits

...
Author SHA1 Message Date
Thomas SharedInboxandClaude Sonnet 4.6 58272186c8 feat(U1): show Unsubscribe chip for emails with List-Unsubscribe header
- Add listUnsubscribeHeader nullable text column to Emails table (schema v23)
- Parse List-Unsubscribe header from IMAP (BODY.PEEK[HEADER.FIELDS]) and JMAP (header:List-Unsubscribe:asText property)
- Show ActionChip in EmailDetailScreen that launches the unsubscribe URI (prefers mailto:, falls back to https:)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 00:05:05 +02:00
4 changed files with 62 additions and 2 deletions
+8
View File
@@ -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,
);
}
}
+7 -1
View File
@@ -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,
);
}
+39
View File
@@ -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'};