Files
mail-hub/lib/core/service/email_service.dart
hz4th_coder 6c1225f588 MailHub v1.0.0: 跨平台邮箱客户端(纯 Dart IMAP/SMTP/MIME 协议栈)
- 登录: QQ/163/126/189/搜狐/Gmail/Outlook/Yahoo/iCloud/企业邮/自定义, 自动识别+测试连接
- 收信: IMAP 多文件夹同步/未读/星标/搜索(本地+服务器)
- 读信: HTML渲染/CID内嵌图/原文查看/附件下载
- 写信: 收件人/抄送/密送/附件/富文本/草稿/回复转发
- 存储: SQLite + 系统安全存储(DPAPI/Keychain/Keystore)
- 测试: 单元测试 + Mock IMAP/SMTP 服务器集成测试(23项全过)
- 可迁移: Windows/Android/iOS/macOS/Linux 一套代码
2026-08-18 01:10:11 +08:00

602 lines
19 KiB
Dart

import 'dart:typed_data';
import '../models/account.dart';
import '../models/address.dart';
import '../models/email.dart';
import '../protocol/imap/imap_client.dart';
import '../protocol/imap/imap_exception.dart';
import '../protocol/imap/imap_parser.dart';
import '../protocol/mime/mime_builder.dart';
import '../protocol/mime/mime_header.dart';
import '../protocol/mime/mime_parser.dart';
import '../protocol/smtp/smtp_client.dart';
import '../storage/stores.dart';
/// 连接配置
class ServerConfig {
final String host;
final int port;
final bool ssl;
const ServerConfig(this.host, this.port, {this.ssl = true});
}
/// 邮箱服务:连接测试 / 同步 / 收信 / 发信 / 操作
class EmailService {
final AccountStore _accountStore = AccountStore();
final FolderStore _folderStore = FolderStore();
final EmailStore _emailStore = EmailStore();
final ContactStore _contactStore = ContactStore();
// ---------- 连接测试 ----------
/// 测试 IMAP 登录(登录页"测试连接")
static Future<String> testImap(ServerConfig imap, String username,
String password) async {
final client = ImapClient();
try {
await client.connect(imap.host, imap.port, ssl: imap.ssl);
if (!imap.ssl && imap.port != 993) {
try {
await client.startTls();
} catch (_) {}
}
await client.login(username, password);
final folders = await client.listFolders();
client.logout();
client.disconnect();
return '✅ 连接成功,发现 ${folders.length} 个文件夹';
} on ImapAuthException catch (e) {
return '❌ 认证失败:${e.message}';
} on ImapException catch (e) {
return '❌ ${e.message}';
} catch (e) {
return '❌ 连接失败:$e';
} finally {
client.disconnect();
}
}
// ---------- 同步 ----------
/// 全量同步一个账户的文件夹列表
Future<List<MailFolder>> syncFolders(EmailAccount account,
String password) async {
final client = await _connectImap(account, password);
try {
final list = await client.listFolders();
final folders = list
.where((f) => !f.attrs.contains(r'\Noselect'))
.map((f) => MailFolder(
accountId: account.id!,
name: f.name,
delim: f.delim.isEmpty ? '/' : f.delim,
attrs: f.attrs,
subscribed: true,
))
.toList();
await _folderStore.replaceAll(account.id!, folders);
return folders;
} finally {
client.logout();
client.disconnect();
}
}
/// 同步一个文件夹:抓取新邮件元数据(增量,已存在的自动跳过)
/// 返回新增/更新的邮件列表
Future<List<Email>> syncFolder(EmailAccount account, String password,
String folder, {bool fullResync = false}) async {
final client = await _connectImap(account, password);
try {
return await _syncFolderWithClient(client, account, folder,
fullResync: fullResync);
} finally {
client.logout();
client.disconnect();
}
}
/// 使用已有连接同步单文件夹
Future<List<Email>> _syncFolderWithClient(ImapClient client,
EmailAccount account, String folder,
{bool fullResync = false}) async {
await client.select(folder);
final uids = await client.search('ALL');
if (uids.isEmpty) return [];
// 分页抓取元数据(每批 100 条)
final emails = <Email>[];
final batchSize = 100;
for (var i = 0; i < uids.length; i += batchSize) {
final batch =
uids.sublist(i, i + batchSize > uids.length ? uids.length : i + batchSize);
final metas = await client.fetchMeta(batch);
for (final m in metas) {
final e = _metaToEmail(account, folder, m);
if (e != null) emails.add(e);
}
}
await _emailStore.upsertAll(emails);
return emails;
}
/// 同步账户所有已订阅文件夹(单连接)
Future<SyncResult> syncAll(EmailAccount account, String password,
{bool fullResync = false}) async {
final client = await _connectImap(account, password);
final result = SyncResult();
try {
// 刷新文件夹列表
final list = await client.listFolders();
final folders = list
.where((f) => !f.attrs.contains(r'\Noselect'))
.map((f) => MailFolder(
accountId: account.id!,
name: f.name,
delim: f.delim.isEmpty ? '/' : f.delim,
attrs: f.attrs,
subscribed: true,
))
.toList();
await _folderStore.replaceAll(account.id!, folders);
for (final folder in folders) {
if (!folder.isSelectable) continue;
try {
final added = await _syncFolderWithClient(client, account, folder.name,
fullResync: fullResync);
result.foldersSynced++;
result.emailsSynced += added.length;
result.unreadTotal += await _emailStore.countUnread(account.id!, folder.name);
} catch (e) {
result.errors.add('${folder.name}: $e');
}
}
await _accountStore.update(account.copyWith(lastSyncAt: DateTime.now()));
return result;
} finally {
client.logout();
client.disconnect();
}
}
// ---------- 读信 ----------
/// 获取完整邮件(本地缓存优先,未抓取则从服务器拉取)
Future<Email> getFullEmail(EmailAccount account, String password, Email meta,
{bool forceRefresh = false}) async {
if (!forceRefresh && meta.fetched) return meta;
final client = await _connectImap(account, password);
try {
await client.select(meta.folder);
final fd = await client.fetchFull(meta.uid);
final raw = _fetchToRaw(fd);
final parsed = MimeParser.parse(raw);
final email = _mergeParsed(meta, parsed, fd);
await _emailStore.upsert(email);
// 沉淀联系人
if (!email.from.isEmpty) {
await _contactStore.touch(account.id, email.from.email, email.from.name);
}
for (final a in email.to) {
await _contactStore.touch(account.id, a.email, a.name);
}
return email;
} finally {
client.logout();
client.disconnect();
}
}
/// 下载附件到内存
Future<MailAttachment> downloadAttachment(EmailAccount account,
String password, Email meta, MailAttachment attachment) async {
if (attachment.data != null) return attachment;
final client = await _connectImap(account, password);
try {
await client.select(meta.folder);
final fd = await client.fetchFull(meta.uid);
final raw = _fetchToRaw(fd);
final parsed = MimeParser.parse(raw);
for (final a in parsed.attachments) {
if (a.fileName == attachment.fileName && a.size == attachment.size) {
return a;
}
}
throw Exception('附件不存在或已过期');
} finally {
client.logout();
client.disconnect();
}
}
// ---------- 发信 ----------
/// 发送邮件
Future<void> sendEmail(
EmailAccount account,
String password, {
required List<MailAddress> to,
List<MailAddress> cc = const [],
List<MailAddress> bcc = const [],
required String subject,
String? textBody,
String? htmlBody,
List<({String name, List<int> bytes, String contentType})> attachments = const [],
String? inReplyTo,
String? references,
}) async {
if (to.isEmpty) throw Exception('收件人不能为空');
final from = MailAddress(account.displayName, account.email);
final raw = MimeBuilder.build(
from: from,
to: to,
cc: cc,
bcc: bcc,
subject: subject,
textBody: textBody ?? '',
htmlBody: htmlBody,
attachments: attachments,
inReplyTo: inReplyTo,
references: references,
);
// 收集所有收件人(含 Bcc)用于 SMTP
final rcpts = <String>{
...to.map((a) => a.email.trim()),
...cc.map((a) => a.email.trim()),
...bcc.map((a) => a.email.trim()),
}..removeWhere((e) => e.isEmpty);
final smtp = SmtpClient();
try {
await smtp.connect(account.smtpHost, account.smtpPort,
ssl: account.smtpSsl);
if (!account.smtpSsl && account.smtpPort == 587) {
try {
await smtp.startTls();
} catch (_) {}
}
await smtp.auth(account.username.isEmpty ? account.email : account.username,
password);
await smtp.sendMail(account.email, rcpts.toList(), raw);
smtp.quit();
} finally {
smtp.disconnect();
}
// 存入已发送(服务器若无 Sent 文件夹则跳过)
try {
final imap = await _connectImap(account, password);
try {
final folders = await _folderStore.getByAccount(account.id!);
String? sentFolder;
for (final f in folders) {
if (f.kind == 'sent') {
sentFolder = f.name;
break;
}
}
if (sentFolder != null) {
await imap.select(sentFolder);
await imap.append(sentFolder, raw);
}
} finally {
imap.logout();
imap.disconnect();
}
} catch (_) {
// 存已发送失败不影响发送结果
}
}
/// 保存草稿(本地 + 服务器 Drafts)
Future<void> saveDraft(
EmailAccount account,
String password, {
required List<MailAddress> to,
List<MailAddress> cc = const [],
required String subject,
String? textBody,
}) async {
// 本地保存
await _emailStore.upsert(Email(
accountId: account.id!,
folder: 'Drafts',
uid: DateTime.now().millisecondsSinceEpoch % 0x7fffffff,
subject: subject,
to: to,
cc: cc,
date: DateTime.now(),
draft: true,
bodyText: textBody,
fetched: true,
seen: true,
));
// 服务器保存
try {
final imap = await _connectImap(account, password);
try {
final folders = await _folderStore.getByAccount(account.id!);
String? draftsFolder;
for (final f in folders) {
if (f.kind == 'drafts') {
draftsFolder = f.name;
break;
}
}
if (draftsFolder != null) {
final raw = MimeBuilder.build(
from: MailAddress(account.displayName, account.email),
to: to,
cc: cc,
subject: subject,
textBody: textBody ?? '',
);
await imap.append(draftsFolder, raw, flags: [r'\Draft']);
}
} finally {
imap.logout();
imap.disconnect();
}
} catch (_) {}
}
// ---------- 邮件操作 ----------
Future<void> markSeen(EmailAccount account, String password,
List<Email> emails, {bool seen = true}) async {
final client = await _connectImap(account, password);
try {
for (final folder in _groupByFolder(emails).entries) {
await client.select(folder.key);
await client.markSeen(folder.value, seen: seen);
for (final e in emails.where((x) => x.folder == folder.key)) {
await _emailStore.upsert(e.copyWith(seen: seen));
}
}
} finally {
client.logout();
client.disconnect();
}
}
Future<void> toggleFlag(EmailAccount account, String password, Email email,
{bool? flagged}) async {
final target = flagged ?? !email.flagged;
final client = await _connectImap(account, password);
try {
await client.select(email.folder);
await client.markFlagged([email.uid], flagged: target);
await _emailStore.upsert(email.copyWith(flagged: target));
} finally {
client.logout();
client.disconnect();
}
}
/// 删除(移入废纸篓;无废纸篓则直接标记删除+EXPUNGE)
Future<void> deleteEmails(EmailAccount account, String password,
List<Email> emails) async {
final client = await _connectImap(account, password);
try {
final folders = await _folderStore.getByAccount(account.id!);
String? trash;
for (final f in folders) {
if (f.kind == 'trash') {
trash = f.name;
break;
}
}
for (final group in _groupByFolder(emails).entries) {
await client.select(group.key);
if (trash != null && group.key != trash) {
await client.move(group.value, trash);
} else {
await client.markDeleted(group.value);
await client.expunge();
}
await _emailStore.deleteForFolder(account.id!, group.key, group.value);
}
} finally {
client.logout();
client.disconnect();
}
}
/// 移动文件夹
Future<void> moveEmails(EmailAccount account, String password,
List<Email> emails, String destFolder) async {
final client = await _connectImap(account, password);
try {
for (final group in _groupByFolder(emails).entries) {
if (group.key == destFolder) continue;
await client.select(group.key);
await client.move(group.value, destFolder);
await _emailStore.deleteForFolder(account.id!, group.key, group.value);
}
} finally {
client.logout();
client.disconnect();
}
}
// ---------- 搜索 ----------
/// IMAP 服务器搜索
Future<List<Email>> searchRemote(EmailAccount account, String password,
String query, {String folder = 'INBOX'}) async {
final client = await _connectImap(account, password);
try {
await client.select(folder);
final escaped = query.replaceAll('"', '').replaceAll('\\', '');
final criteria =
'OR (OR (SUBJECT "$escaped") (FROM "$escaped")) (OR (TO "$escaped") (TEXT "$escaped"))';
final uids = await client.search(criteria);
if (uids.isEmpty) return [];
final emails = <Email>[];
final metas = await client.fetchMeta(
uids.length > 200 ? uids.sublist(0, 200) : uids);
for (final m in metas) {
final e = _metaToEmail(account, folder, m);
if (e != null) emails.add(e);
}
await _emailStore.upsertAll(emails);
return emails;
} finally {
client.logout();
client.disconnect();
}
}
// ---------- 内部 ----------
Future<ImapClient> _connectImap(EmailAccount account, String password) async {
final client = ImapClient();
await client.connect(account.imapHost, account.imapPort, ssl: account.imapSsl);
if (!account.imapSsl) {
try {
await client.startTls();
} catch (_) {}
}
await client.login(
account.username.isEmpty ? account.email : account.username, password);
return client;
}
Map<String, List<int>> _groupByFolder(List<Email> emails) {
final map = <String, List<int>>{};
for (final e in emails) {
map.putIfAbsent(e.folder, () => []).add(e.uid);
}
return map;
}
Email? _metaToEmail(EmailAccount account, String folder, FetchData m) {
if (m.uid == null) return null;
// 从 HEADER.FIELDS 字面量解析基本信息(键名以 HEADER 开头即可)
List<int>? headerRaw;
for (final entry in m.bodies.entries) {
if (entry.key.toUpperCase().startsWith('HEADER')) {
headerRaw = entry.value;
break;
}
}
final headerBytes = headerRaw ?? const <int>[];
final headers = MimeHeader.parse(headerBytes);
final subject = (headers.decoded('subject') ?? '')
.replaceAll(RegExp(r'\s+'), ' ')
.trim();
final from = _parseAddr(headers.decoded('from'));
final to = _parseAddrList(headers.decoded('to'));
final cc = _parseAddrList(headers.decoded('cc'));
final date = m.internalDate ?? _parseHeaderDate(headers.first('date'));
final size = m.size ?? 0;
// snippet: 用 subject 截断即可(列表页不拉正文)
final snippet = subject.length > 80 ? subject.substring(0, 80) : subject;
return Email(
accountId: account.id!,
folder: folder,
uid: m.uid!,
messageId: (headers.first('message-id') ?? '').trim(),
inReplyTo: (headers.first('in-reply-to') ?? '').trim(),
references: (headers.first('references') ?? '').trim(),
subject: subject,
from: from,
to: to,
cc: cc,
date: date,
size: size,
seen: m.seen,
flagged: m.flagged,
answered: m.answered,
deleted: m.deleted,
snippet: snippet,
fetched: false,
);
}
MailAddress _parseAddr(String? raw) {
if (raw == null || raw.trim().isEmpty) return const MailAddress('', '');
return MailAddress.parse(raw);
}
List<MailAddress> _parseAddrList(String? raw) {
if (raw == null || raw.trim().isEmpty) return const [];
return MailAddress.parseList(raw);
}
DateTime? _parseHeaderDate(String? raw) {
if (raw == null) return null;
try {
final m = RegExp(
r'^(?:\w+,\s*)?(\d{1,2})\s+([A-Za-z]{3})\s+(\d{4})\s+(\d{1,2}):(\d{2})(?::(\d{2}))?\s*([+-]\d{4})?')
.firstMatch(raw.trim());
if (m == null) return null;
const months = {
'JAN': 1, 'FEB': 2, 'MAR': 3, 'APR': 4, 'MAY': 5, 'JUN': 6,
'JUL': 7, 'AUG': 8, 'SEP': 9, 'OCT': 10, 'NOV': 11, 'DEC': 12,
};
final month = months[m.group(2)!.toUpperCase()];
if (month == null) return null;
var dt = DateTime.utc(
int.parse(m.group(3)!),
month,
int.parse(m.group(1)!),
int.parse(m.group(4)!),
int.parse(m.group(5)!),
int.tryParse(m.group(6) ?? '0') ?? 0,
);
final tz = m.group(7);
if (tz != null && tz.isNotEmpty) {
dt = dt.subtract(Duration(
hours: int.parse(tz.substring(0, 3)),
minutes: int.parse(tz.substring(3))));
}
return dt.toLocal();
} catch (_) {
return null;
}
}
/// 将 FetchData 的 HEADER/TEXT 字面量拼成完整原始邮件
Uint8List _fetchToRaw(FetchData fd) {
final header = fd.bodies['HEADER'] ?? const <int>[];
final text = fd.bodies['TEXT'] ?? const <int>[];
return Uint8List.fromList([...header, 13, 10, ...text]);
}
Email _mergeParsed(Email meta, ParsedMessage parsed, FetchData fd) {
final attachments = parsed.attachments;
return meta.copyWith(
subject: parsed.subject ?? meta.subject,
from: parsed.from ?? meta.from,
to: parsed.to.isNotEmpty ? parsed.to : meta.to,
cc: parsed.cc.isNotEmpty ? parsed.cc : meta.cc,
bcc: parsed.bcc,
replyTo: parsed.replyTo,
messageId: parsed.messageId.isNotEmpty ? parsed.messageId : meta.messageId,
inReplyTo: parsed.inReplyTo.isNotEmpty ? parsed.inReplyTo : meta.inReplyTo,
references:
parsed.references.isNotEmpty ? parsed.references : meta.references,
date: parsed.date ?? meta.date,
bodyHtml: parsed.htmlBody,
bodyText: parsed.textBody,
attachments: attachments,
hasAttachments: attachments.isNotEmpty,
extraHeaders: parsed.extraHeaders,
fetched: true,
);
}
}
class SyncResult {
int foldersSynced = 0;
int emailsSynced = 0;
int unreadTotal = 0;
final List<String> errors = [];
bool get hasError => errors.isNotEmpty;
}