- 登录: QQ/163/126/189/搜狐/Gmail/Outlook/Yahoo/iCloud/企业邮/自定义, 自动识别+测试连接 - 收信: IMAP 多文件夹同步/未读/星标/搜索(本地+服务器) - 读信: HTML渲染/CID内嵌图/原文查看/附件下载 - 写信: 收件人/抄送/密送/附件/富文本/草稿/回复转发 - 存储: SQLite + 系统安全存储(DPAPI/Keychain/Keystore) - 测试: 单元测试 + Mock IMAP/SMTP 服务器集成测试(23项全过) - 可迁移: Windows/Android/iOS/macOS/Linux 一套代码
602 lines
19 KiB
Dart
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;
|
|
}
|