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
Thomas SharedInboxandClaude Sonnet 4.6 c4634936ae feat(S2): validate IMAP/SMTP hostnames against injection characters
Add validateHostname / validateOptionalHostname helpers to host_utils.dart
that reject values containing @, /, \, or control characters. Wire them
into AddAccountScreen and EditAccountScreen for all host fields.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 23:45:37 +02:00
7 changed files with 95 additions and 12 deletions
+8
View File
@@ -21,6 +21,8 @@ class Email {
final String? references; final String? references;
final DateTime? snoozedUntil; final DateTime? snoozedUntil;
final String? snoozedFromMailboxPath; final String? snoozedFromMailboxPath;
// RFC 2369 List-Unsubscribe header value, e.g. "<mailto:...>, <https://...>".
final String? listUnsubscribeHeader;
const Email({ const Email({
required this.id, required this.id,
@@ -43,6 +45,7 @@ class Email {
this.references, this.references,
this.snoozedUntil, this.snoozedUntil,
this.snoozedFromMailboxPath, this.snoozedFromMailboxPath,
this.listUnsubscribeHeader,
}); });
factory Email.fromJson(Map<String, dynamic> json) { factory Email.fromJson(Map<String, dynamic> json) {
@@ -77,6 +80,7 @@ class Email {
? DateTime.parse(json['snoozedUntil'] as String) ? DateTime.parse(json['snoozedUntil'] as String)
: null, : null,
snoozedFromMailboxPath: json['snoozedFromMailboxPath'] as String?, snoozedFromMailboxPath: json['snoozedFromMailboxPath'] as String?,
listUnsubscribeHeader: json['listUnsubscribeHeader'] as String?,
); );
} }
@@ -102,6 +106,7 @@ class Email {
'references': references, 'references': references,
'snoozedUntil': snoozedUntil?.toIso8601String(), 'snoozedUntil': snoozedUntil?.toIso8601String(),
'snoozedFromMailboxPath': snoozedFromMailboxPath, 'snoozedFromMailboxPath': snoozedFromMailboxPath,
'listUnsubscribeHeader': listUnsubscribeHeader,
}; };
} }
@@ -126,6 +131,7 @@ class Email {
String? references, String? references,
DateTime? snoozedUntil, DateTime? snoozedUntil,
String? snoozedFromMailboxPath, String? snoozedFromMailboxPath,
String? listUnsubscribeHeader,
}) { }) {
return Email( return Email(
id: id ?? this.id, id: id ?? this.id,
@@ -149,6 +155,8 @@ class Email {
snoozedUntil: snoozedUntil ?? this.snoozedUntil, snoozedUntil: snoozedUntil ?? this.snoozedUntil,
snoozedFromMailboxPath: snoozedFromMailboxPath:
snoozedFromMailboxPath ?? this.snoozedFromMailboxPath, snoozedFromMailboxPath ?? this.snoozedFromMailboxPath,
listUnsubscribeHeader:
listUnsubscribeHeader ?? this.listUnsubscribeHeader,
); );
} }
} }
+18
View File
@@ -2,3 +2,21 @@ bool isLocalhost(String host) {
final h = host.trim().toLowerCase(); final h = host.trim().toLowerCase();
return h == 'localhost' || h == '127.0.0.1' || h == '::1'; return h == 'localhost' || h == '127.0.0.1' || h == '::1';
} }
String? validateHostname(String? value) {
if (value == null || value.trim().isEmpty) return 'Required';
return _checkHostChars(value.trim());
}
String? validateOptionalHostname(String? value) {
if (value == null || value.trim().isEmpty) return null;
return _checkHostChars(value.trim());
}
String? _checkHostChars(String h) {
if (h.contains(RegExp(r'[@/\\]')) ||
h.codeUnits.any((c) => c < 32 || c == 127)) {
return 'Invalid hostname';
}
return null;
}
+7 -1
View File
@@ -88,6 +88,9 @@ class Emails extends Table {
DateTimeColumn get snoozedUntil => dateTime().nullable()(); DateTimeColumn get snoozedUntil => dateTime().nullable()();
TextColumn get snoozedFromMailboxPath => text().nullable()(); TextColumn get snoozedFromMailboxPath => text().nullable()();
// Added in schema v23: RFC 2369 List-Unsubscribe header value.
TextColumn get listUnsubscribeHeader => text().nullable()();
@override @override
Set<Column> get primaryKey => {id}; Set<Column> get primaryKey => {id};
} }
@@ -264,7 +267,7 @@ class AppDatabase extends _$AppDatabase {
AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection()); AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());
@override @override
int get schemaVersion => 22; int get schemaVersion => 23;
@override @override
MigrationStrategy get migration => MigrationStrategy( 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, imap.MessageSequence sequence,
) async { ) async {
const fetchItems = 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 final fetch = sequence.isUidSequence
? await client.uidFetchMessages(sequence, fetchItems) ? await client.uidFetchMessages(sequence, fetchItems)
: await client.fetchMessages(sequence, fetchItems); : await client.fetchMessages(sequence, fetchItems);
@@ -569,6 +569,7 @@ class EmailRepositoryImpl implements EmailRepository {
final msgId = envelope.messageId?.trim(); final msgId = envelope.messageId?.trim();
final inReplyTo = envelope.inReplyTo?.trim(); final inReplyTo = envelope.inReplyTo?.trim();
final refs = msg.getHeaderValue('References')?.trim(); final refs = msg.getHeaderValue('References')?.trim();
final listUnsubscribe = msg.getHeaderValue('List-Unsubscribe')?.trim();
final threadId = _computeThreadId( final threadId = _computeThreadId(
emailId: emailId, emailId: emailId,
messageId: msgId, messageId: msgId,
@@ -612,6 +613,7 @@ class EmailRepositoryImpl implements EmailRepository {
inReplyTo: Value(inReplyTo), inReplyTo: Value(inReplyTo),
references: Value(refs), references: Value(refs),
snoozedUntil: Value(snoozedUntil), snoozedUntil: Value(snoozedUntil),
listUnsubscribeHeader: Value(listUnsubscribe),
), ),
); );
} }
@@ -950,6 +952,7 @@ class EmailRepositoryImpl implements EmailRepository {
'htmlBody', 'htmlBody',
'bodyValues', 'bodyValues',
'attachments', 'attachments',
'header:List-Unsubscribe:asText',
]; ];
static const _emailGetBodyOptions = { static const _emailGetBodyOptions = {
@@ -1151,6 +1154,8 @@ class EmailRepositoryImpl implements EmailRepository {
final jmapReferences = _joinJmapStringList( final jmapReferences = _joinJmapStringList(
m['references'] as List<dynamic>?, m['references'] as List<dynamic>?,
); );
final jmapListUnsubscribe =
(m['header:List-Unsubscribe:asText'] as String?)?.trim();
await _db.into(_db.emails).insertOnConflictUpdate( await _db.into(_db.emails).insertOnConflictUpdate(
EmailsCompanion.insert( EmailsCompanion.insert(
@@ -1173,6 +1178,7 @@ class EmailRepositoryImpl implements EmailRepository {
inReplyTo: Value(jmapInReplyTo), inReplyTo: Value(jmapInReplyTo),
references: Value(jmapReferences), references: Value(jmapReferences),
snoozedUntil: Value(snoozedUntil), snoozedUntil: Value(snoozedUntil),
listUnsubscribeHeader: Value(jmapListUnsubscribe),
), ),
); );
@@ -2663,6 +2669,7 @@ class EmailRepositoryImpl implements EmailRepository {
references: row.references, references: row.references,
snoozedUntil: row.snoozedUntil, snoozedUntil: row.snoozedUntil,
snoozedFromMailboxPath: row.snoozedFromMailboxPath, snoozedFromMailboxPath: row.snoozedFromMailboxPath,
listUnsubscribeHeader: row.listUnsubscribeHeader,
); );
} }
+7 -5
View File
@@ -408,7 +408,7 @@ class _AddAccountScreenState extends ConsumerState<AddAccountScreen> {
_field(_passwordCtrl, 'Password', obscure: true), _field(_passwordCtrl, 'Password', obscure: true),
const Divider(height: 32), const Divider(height: 32),
Text('IMAP', style: Theme.of(context).textTheme.titleSmall), Text('IMAP', style: Theme.of(context).textTheme.titleSmall),
_field(_imapHostCtrl, 'Host'), _field(_imapHostCtrl, 'Host', validator: validateHostname),
_field(_imapPortCtrl, 'Port', keyboardType: TextInputType.number), _field(_imapPortCtrl, 'Port', keyboardType: TextInputType.number),
if (isLocalhost(_imapHostCtrl.text.trim())) if (isLocalhost(_imapHostCtrl.text.trim()))
SwitchListTile( SwitchListTile(
@@ -418,7 +418,7 @@ class _AddAccountScreenState extends ConsumerState<AddAccountScreen> {
), ),
const Divider(height: 32), const Divider(height: 32),
Text('SMTP', style: Theme.of(context).textTheme.titleSmall), Text('SMTP', style: Theme.of(context).textTheme.titleSmall),
_field(_smtpHostCtrl, 'Host'), _field(_smtpHostCtrl, 'Host', validator: validateHostname),
_field(_smtpPortCtrl, 'Port', keyboardType: TextInputType.number), _field(_smtpPortCtrl, 'Port', keyboardType: TextInputType.number),
if (isLocalhost(_smtpHostCtrl.text.trim())) if (isLocalhost(_smtpHostCtrl.text.trim()))
SwitchListTile( SwitchListTile(
@@ -475,6 +475,7 @@ class _AddAccountScreenState extends ConsumerState<AddAccountScreen> {
bool obscure = false, bool obscure = false,
bool required = true, bool required = true,
TextInputType? keyboardType, TextInputType? keyboardType,
String? Function(String?)? validator,
}) { }) {
return Padding( return Padding(
padding: const EdgeInsets.symmetric(vertical: 6), padding: const EdgeInsets.symmetric(vertical: 6),
@@ -486,9 +487,10 @@ class _AddAccountScreenState extends ConsumerState<AddAccountScreen> {
labelText: label, labelText: label,
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
), ),
validator: required validator: validator ??
? (v) => (v == null || v.trim().isEmpty) ? 'Required' : null (required
: null, ? (v) => (v == null || v.trim().isEmpty) ? 'Required' : null
: null),
), ),
); );
} }
+8 -5
View File
@@ -324,11 +324,11 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
'IMAP (SSL/TLS)', 'IMAP (SSL/TLS)',
style: Theme.of(context).textTheme.titleSmall, style: Theme.of(context).textTheme.titleSmall,
), ),
_field(_imapHostCtrl, 'Host'), _field(_imapHostCtrl, 'Host', validator: validateHostname),
_field(_imapPortCtrl, 'Port', keyboardType: TextInputType.number), _field(_imapPortCtrl, 'Port', keyboardType: TextInputType.number),
const Divider(height: 32), const Divider(height: 32),
Text('SMTP', style: Theme.of(context).textTheme.titleSmall), Text('SMTP', style: Theme.of(context).textTheme.titleSmall),
_field(_smtpHostCtrl, 'Host'), _field(_smtpHostCtrl, 'Host', validator: validateHostname),
_field(_smtpPortCtrl, 'Port', keyboardType: TextInputType.number), _field(_smtpPortCtrl, 'Port', keyboardType: TextInputType.number),
if (isLocalhost(_smtpHostCtrl.text.trim())) if (isLocalhost(_smtpHostCtrl.text.trim()))
SwitchListTile( SwitchListTile(
@@ -348,6 +348,7 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
_sieveHostCtrl, _sieveHostCtrl,
'Host (leave blank to use IMAP host)', 'Host (leave blank to use IMAP host)',
required: false, required: false,
validator: validateOptionalHostname,
), ),
_field( _field(
_sievePortCtrl, _sievePortCtrl,
@@ -408,6 +409,7 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
bool obscure = false, bool obscure = false,
bool required = true, bool required = true,
TextInputType? keyboardType, TextInputType? keyboardType,
String? Function(String?)? validator,
}) { }) {
return Padding( return Padding(
padding: const EdgeInsets.symmetric(vertical: 6), padding: const EdgeInsets.symmetric(vertical: 6),
@@ -420,9 +422,10 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
labelText: label, labelText: label,
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
), ),
validator: required validator: validator ??
? (v) => (v == null || v.trim().isEmpty) ? 'Required' : null (required
: null, ? (v) => (v == null || v.trim().isEmpty) ? 'Required' : null
: null),
), ),
); );
} }
+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/core/utils/html_utils.dart';
import 'package:sharedinbox/di.dart'; import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/widgets/snooze_picker.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'); final _dateFmt = DateFormat('EEE, MMM d yyyy, HH:mm');
@@ -267,6 +268,11 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
_dateFmt.format(email.sentAt!), _dateFmt.format(email.sentAt!),
style: Theme.of(ctx).textTheme.bodySmall, 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 { class _BlockRemoteImagesExtension extends HtmlExtension {
@override @override
Set<String> get supportedTags => {'img'}; Set<String> get supportedTags => {'img'};