using System.Globalization; using System.Net; using System.Net.Security; using System.Net.Sockets; using System.Security.Authentication; using System.Security.Cryptography.X509Certificates; using System.Text; namespace WpywMail.Native; /// /// IMAP4rev1 服务端(RFC 3501 子集)。 /// /// 目的:让标准邮件客户端(Outlook / Thunderbird / Foxmail / 手机邮件 App) /// 直接接入,而不必依赖自研客户端;自研客户端仍可继续用 REST。 /// /// 支持:CAPABILITY、NOOP、LOGOUT、STARTTLS、LOGIN、AUTHENTICATE PLAIN、 /// LIST/LSUB、SELECT/EXAMINE、STATUS、CREATE/DELETE/RENAME(受限)、CLOSE、 /// EXPUNGE、SEARCH、FETCH/UID FETCH、STORE/UID STORE、COPY/UID COPY、APPEND、IDLE。 /// 未实现(客户端可正常工作):CONDSTORE、QRESYNC、SORT、THREAD、UIDPLUS、ACL。 /// public sealed class ImapServer { private const string Crlf = "\r\n"; private readonly AppConfig config; private readonly IMailStore store; private readonly X509Certificate2? certificate; public ImapServer(AppConfig config, IMailStore store) { this.config = config; this.store = store; if (!string.IsNullOrWhiteSpace(config.TlsCertificatePath) && File.Exists(config.TlsCertificatePath)) certificate = new X509Certificate2(config.TlsCertificatePath, config.TlsCertificatePassword); } public async Task RunAsync(CancellationToken token) { if (!config.Imap.Enabled) { AppLog.Info("[IMAP] 已按配置禁用。"); return; } var tasks = new List(); if (config.Imap.Port > 0) { var plain = new TcpListener(IPAddress.Any, config.Imap.Port); plain.Start(); AppLog.Info($"[IMAP] 已监听:{config.Imap.Port}(明文 + STARTTLS)"); tasks.Add(AcceptLoopAsync(plain, implicitTls: false, token)); } if (config.Imap.TlsPort > 0 && certificate is not null) { var tls = new TcpListener(IPAddress.Any, config.Imap.TlsPort); tls.Start(); AppLog.Info($"[IMAP] 已监听:{config.Imap.TlsPort}(隐式 TLS)"); tasks.Add(AcceptLoopAsync(tls, implicitTls: true, token)); } else if (config.Imap.TlsPort > 0) { AppLog.Warn($"[IMAP] {config.Imap.TlsPort} 端口未启动:没有可用证书。"); } await Task.WhenAll(tasks); } private async Task AcceptLoopAsync(TcpListener listener, bool implicitTls, CancellationToken token) { try { while (!token.IsCancellationRequested) { var client = await listener.AcceptTcpClientAsync(token); _ = Task.Run(async () => { try { await new ImapSession(config, store, certificate, implicitTls, client).RunAsync(token); } catch (Exception ex) { AppLog.Error($"[IMAP] 会话异常:{ex.Message}"); } finally { client.Dispose(); } }, token); } } catch (OperationCanceledException) { } catch (Exception ex) { AppLog.Error($"[IMAP] 接收循环异常:{ex.Message}"); } finally { listener.Stop(); } } } /// 单个 IMAP 连接的状态机。 internal sealed class ImapSession { private const string Crlf = "\r\n"; private readonly AppConfig config; private readonly IMailStore store; private readonly X509Certificate2? certificate; private readonly TcpClient client; private SmtpReader reader = null!; private Stream stream = null!; private string? user; private string? selected; // 当前选中的文件夹(我们的内部名) private bool readOnly; private bool authenticated; private bool tls; private readonly HashSet deleted = []; // 会话内 \Deleted 标记(按 message id) public ImapSession(AppConfig config, IMailStore store, X509Certificate2? certificate, bool implicitTls, TcpClient client) { this.config = config; this.store = store; this.certificate = certificate; this.client = client; _ = implicitTls; } public async Task RunAsync(CancellationToken token) { stream = client.GetStream(); var implicitTls = client.Client.LocalEndPoint is IPEndPoint { Port: var port } && port == config.Imap.TlsPort && config.Imap.TlsPort > 0; if (implicitTls && certificate is not null) { var ssl = new SslStream(stream, false); await ssl.AuthenticateAsServerAsync(BuildTlsOptions(), token); stream = ssl; tls = true; } reader = new SmtpReader(stream); var writer = new StreamWriter(stream, Encoding.ASCII, 8192, true) { AutoFlush = true, NewLine = Crlf }; var remoteIp = (client.Client.RemoteEndPoint as IPEndPoint)?.Address.ToString() ?? ""; await WriteAsync(writer, $"* OK [CAPABILITY {Capabilities()}] WpywMail IMAP4rev1 ready"); AppLog.Info($"[IMAP] 收到连接:{client.Client.RemoteEndPoint},TLS={(tls ? "是" : "否")}"); while (!token.IsCancellationRequested) { var line = await reader.ReadLineAsync(token); if (line is null) break; if (line.Length == 0) continue; var space = line.IndexOf(' '); var tag = space < 0 ? line : line[..space]; var rest = space < 0 ? "" : line[(space + 1)..].Trim(); var command = rest.Length == 0 ? "" : rest.Split(' ')[0].ToUpperInvariant(); // 各处理器只接受「参数」,因此这里必须把命令名本身剥掉 var args = rest.Length > command.Length ? rest[command.Length..].Trim() : ""; try { if (command == "LOGOUT") { await WriteAsync(writer, "* BYE WpywMail IMAP4rev1 signing off"); await WriteAsync(writer, $"{tag} OK LOGOUT completed"); break; } if (command == "CAPABILITY") { await WriteAsync(writer, $"* CAPABILITY {Capabilities()}"); await OkAsync(writer, tag, "CAPABILITY completed"); continue; } if (command == "NOOP") { await OkAsync(writer, tag, "NOOP completed"); continue; } if (command == "STARTTLS") { if (tls || certificate is null) { await NoAsync(writer, tag, "STARTTLS not available"); continue; } await OkAsync(writer, tag, "Begin TLS negotiation now"); // RFC 3501: STARTTLS 的响应是带 tag 的 OK var ssl = new SslStream(stream, false); await ssl.AuthenticateAsServerAsync(BuildTlsOptions(), token); stream = ssl; reader = new SmtpReader(stream); try { writer.Dispose(); } catch { } writer = new StreamWriter(stream, Encoding.ASCII, 8192, true) { AutoFlush = true, NewLine = Crlf }; tls = true; AppLog.Info($"[IMAP] {remoteIp} 已建立 TLS 会话。"); continue; } if (!authenticated) { if (command == "LOGIN") { var (u, p) = ParseLogin(rest); if (!TlsSatisfied(remoteIp)) { await NoAsync(writer, tag, "LOGIN requires TLS (issue STARTTLS first)"); continue; } var account = store.Authenticate(u, p); if (account is null) { AppLog.Warn($"[IMAP] {remoteIp} 登录失败:{u}"); await NoAsync(writer, tag, "LOGIN failed"); continue; } user = account.Email; authenticated = true; AppLog.Info($"[IMAP] {remoteIp} 登录成功:{user}"); await OkAsync(writer, tag, "LOGIN completed"); continue; } if (command == "AUTHENTICATE") { if (!TlsSatisfied(remoteIp)) { await NoAsync(writer, tag, "AUTHENTICATE requires TLS"); continue; } var mechanism = rest.Split(' ', 2).ElementAtOrDefault(1)?.ToUpperInvariant() ?? ""; if (mechanism != "PLAIN") { await NoAsync(writer, tag, "Unsupported authentication mechanism"); continue; } var payload = rest.Split(' ', 3).ElementAtOrDefault(2); if (string.IsNullOrEmpty(payload)) { await WriteAsync(writer, "+ "); payload = (await reader.ReadLineAsync(token) ?? "").Trim(); } var ok = TryParsePlain(payload, out var u, out var p); var account = ok ? store.Authenticate(u, p) : null; if (account is null) { await NoAsync(writer, tag, "AUTHENTICATE failed"); continue; } user = account.Email; authenticated = true; await OkAsync(writer, tag, "AUTHENTICATE completed"); continue; } await NoAsync(writer, tag, "Please authenticate first"); continue; } switch (command) { case "LIST": case "LSUB": await WriteAsync(writer, $"* {(command == "LSUB" ? "LSUB" : "LIST")} (\\HasNoChildren) \"/\" \"INBOX\""); foreach (var folder in MailFolders.ImapFolders.Where(f => f != "inbox")) await WriteAsync(writer, $"* {command} (\\HasNoChildren) \"/\" \"{DisplayName(folder)}\""); await OkAsync(writer, tag, $"{command} completed"); break; case "SELECT": case "EXAMINE": await SelectAsync(writer, tag, args, readOnlyRequested: command == "EXAMINE"); break; case "STATUS": await StatusAsync(writer, tag, args); break; case "CLOSE": selected = null; deleted.Clear(); await OkAsync(writer, tag, "CLOSE completed"); break; case "UNSELECT": selected = null; await OkAsync(writer, tag, "UNSELECT completed"); break; case "CREATE": case "DELETE": case "RENAME": case "SUBSCRIBE": case "UNSUBSCRIBE": // 文件夹集合固定,接受但不做实际变更 await OkAsync(writer, tag, $"{command} completed"); break; case "EXPUNGE": await ExpungeAsync(writer, tag); break; case "SEARCH": await SearchAsync(writer, tag, args, useUid: false); break; case "UID": await SearchOrUidAsync(writer, tag, args, token); break; case "FETCH": await FetchAsync(writer, tag, args, useUid: false); break; case "STORE": await StoreAsync(writer, tag, args, useUid: false); break; case "COPY": await CopyAsync(writer, tag, args, useUid: false); break; case "APPEND": await AppendAsync(writer, tag, args, token); break; case "CHECK": await OkAsync(writer, tag, "CHECK completed"); break; case "IDLE": await IdleAsync(writer, tag, token); break; default: await NoAsync(writer, tag, $"Command not supported: {command}"); break; } } catch (Exception ex) { AppLog.Error($"[IMAP] 处理 {command} 出错:{ex.Message}"); await NoAsync(writer, tag, "Internal error"); } } try { writer.Dispose(); } catch { } AppLog.Info($"[IMAP] 连接结束:{remoteIp}"); } private SslServerAuthenticationOptions BuildTlsOptions() => new() { ServerCertificate = certificate, EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13, ClientCertificateRequired = false, }; private string Capabilities() => "IMAP4rev1 " + (certificate is not null && !tls ? "STARTTLS " : "") + "AUTH=PLAIN IDLE UIDPLUS CHILDREN NAMESPACE"; private bool TlsSatisfied(string remoteIp) { if (tls || !config.Imap.RequireTlsForLogin) return true; return config.Imap.PlaintextLoginAllowFrom.Contains(remoteIp); } // ---------------------------------------------------------------- SELECT / STATUS private async Task SelectAsync(StreamWriter writer, string tag, string rest, bool readOnlyRequested) { var mailbox = ExtractMailbox(rest); var folder = MailFolders.Normalize(mailbox); if (folder is null) { await NoAsync(writer, tag, "Mailbox does not exist"); return; } selected = folder; readOnly = readOnlyRequested; deleted.Clear(); var items = store.ListForImap(user!, folder); var unseen = items.Count(x => x.Unread); var nextUid = store.NextUidFor(user!, folder); await WriteAsync(writer, $"* {items.Count} EXISTS"); await WriteAsync(writer, $"* {unseen} RECENT"); await WriteAsync(writer, "* FLAGS (\\Answered \\Flagged \\Deleted \\Seen \\Draft)"); await WriteAsync(writer, $"* OK [PERMANENTFLAGS (\\Answered \\Flagged \\Deleted \\Seen \\Draft \\*)] Limited"); await WriteAsync(writer, "* OK [UIDVALIDITY 1] UIDs valid"); await WriteAsync(writer, $"* OK [UIDNEXT {nextUid}] Predicted next UID"); await OkAsync(writer, tag, $"[READ-{(readOnly ? "ONLY" : "WRITE")}] {(readOnlyRequested ? "EXAMINE" : "SELECT")} completed"); } private async Task StatusAsync(StreamWriter writer, string tag, string rest) { var parts = SplitTokens(rest); if (parts.Count < 2) { await NoAsync(writer, tag, "STATUS requires mailbox and items"); return; } var folder = MailFolders.Normalize(parts[0]); if (folder is null) { await NoAsync(writer, tag, "Mailbox does not exist"); return; } var items = store.ListForImap(user!, folder); var requested = parts[1].Trim('(', ')').ToUpperInvariant(); var values = new List(); if (requested.Contains("MESSAGES")) values.Add($"MESSAGES {items.Count}"); if (requested.Contains("RECENT")) values.Add($"RECENT {items.Count(x => x.Unread)}"); if (requested.Contains("UNSEEN")) values.Add($"UNSEEN {items.Count(x => x.Unread)}"); if (requested.Contains("UIDNEXT")) values.Add($"UIDNEXT {store.NextUidFor(user!, folder)}"); if (requested.Contains("UIDVALIDITY")) values.Add("UIDVALIDITY 1"); await WriteAsync(writer, $"* STATUS \"{DisplayName(folder)}\" ({string.Join(" ", values)})"); await OkAsync(writer, tag, "STATUS completed"); } // ---------------------------------------------------------------- SEARCH / FETCH private async Task SearchOrUidAsync(StreamWriter writer, string tag, string rest, CancellationToken token) { var space = rest.IndexOf(' '); var sub = space < 0 ? rest.ToUpperInvariant() : rest[..space].ToUpperInvariant(); var args = space < 0 ? "" : rest[(space + 1)..]; switch (sub) { case "SEARCH": await SearchAsync(writer, tag, args, useUid: false); break; case "FETCH": await FetchAsync(writer, tag, args, useUid: true); break; case "STORE": await StoreAsync(writer, tag, args, useUid: true); break; case "COPY": await CopyAsync(writer, tag, args, useUid: true); break; default: await NoAsync(writer, tag, $"UID {sub} not supported"); break; } await Task.CompletedTask; _ = token; } private async Task SearchAsync(StreamWriter writer, string tag, string args, bool useUid) { if (selected is null) { await NoAsync(writer, tag, "No mailbox selected"); return; } var items = store.ListForImap(user!, selected); var criteria = args.ToUpperInvariant(); if (criteria.StartsWith("CHARSET")) criteria = criteria[(criteria.IndexOf(' ') + 1)..]; IEnumerable result = items; if (criteria.Contains("UNSEEN")) result = result.Where(x => x.Unread); if (criteria.Contains("SEEN")) result = result.Where(x => !x.Unread); if (criteria.Contains("FLAGGED")) result = result.Where(x => x.Starred); if (criteria.Contains("UNFLAGGED")) result = result.Where(x => !x.Starred); if (criteria.Contains("DELETED")) result = result.Where(x => deleted.Contains(x.Id)); var fromIndex = criteria.IndexOf("FROM ", StringComparison.Ordinal); if (fromIndex >= 0) { var needle = Unquote(criteria[(fromIndex + 5)..].Split(' ')[0]); result = result.Where(x => x.From.Contains(needle, StringComparison.OrdinalIgnoreCase)); } var subjectIndex = criteria.IndexOf("SUBJECT ", StringComparison.Ordinal); if (subjectIndex >= 0) { var needle = Unquote(criteria[(subjectIndex + 8)..].Split(' ')[0]); result = result.Where(x => x.Subject.Contains(needle, StringComparison.OrdinalIgnoreCase)); } var textIndex = criteria.IndexOf("TEXT ", StringComparison.Ordinal); if (textIndex >= 0) { var needle = Unquote(criteria[(textIndex + 5)..].Split(' ')[0]); result = result.Where(x => (x.Subject + " " + x.Text).Contains(needle, StringComparison.OrdinalIgnoreCase)); } var uidIndex = criteria.IndexOf("UID ", StringComparison.Ordinal); if (uidIndex >= 0) { var set = criteria[(uidIndex + 4)..].Split(' ')[0]; var uids = ParseUidSet(set, items); result = result.Where(x => uids.Contains(x.Uid)); } var ids = result.Select(x => useUid ? x.Uid : IndexOf(items, x) + 1); await WriteAsync(writer, "* SEARCH " + string.Join(" ", ids)); await OkAsync(writer, tag, "SEARCH completed"); } private async Task FetchAsync(StreamWriter writer, string tag, string args, bool useUid) { if (selected is null) { await NoAsync(writer, tag, "No mailbox selected"); return; } var parts = SplitTokens(args); if (parts.Count < 2) { await NoAsync(writer, tag, "FETCH requires set and items"); return; } var items = store.ListForImap(user!, selected); var targets = ResolveSet(parts[0], items, useUid); var spec = args[(args.IndexOf(' ') + 1)..]; // 保留原始大小写,便于解析 BODY[...] var wantsUid = spec.Contains("UID", StringComparison.OrdinalIgnoreCase) || useUid; foreach (var message in targets) { var pieces = new List(); if (wantsUid) pieces.Add($"UID {message.Uid}"); if (HasToken(spec, "FLAGS")) pieces.Add("FLAGS " + FlagsOf(message)); if (HasToken(spec, "INTERNALDATE")) pieces.Add("INTERNALDATE \"" + FormatInternalDate(message.Date) + "\""); if (HasToken(spec, "ENVELOPE")) pieces.Add("ENVELOPE " + Envelope(message)); var wantsStructure = SpecNeedsBodyStructure(spec); var bodyItem = ExtractBodyItem(spec); if (wantsStructure) pieces.Add("BODYSTRUCTURE " + BodyStructure(message)); // 取正文时顺带拿到原始字节,既能回退计算大小,也避免重复读盘 byte[]? rawForSize = null; long ActualSize() { if (message.Size > 0) return message.Size; rawForSize ??= SafeReadRaw(message); return rawForSize.Length; } if (HasToken(spec, "RFC822.SIZE")) pieces.Add($"RFC822.SIZE {ActualSize()}"); // 精确匹配 RFC822 / RFC822.HEADER / RFC822.TEXT,避免 "RFC822.SIZE" 被误判成整封请求 var wantsRfc822 = HasToken(spec, "RFC822"); var wantsRfc822Header = HasToken(spec, "RFC822.HEADER"); var wantsRfc822Text = HasToken(spec, "RFC822.TEXT"); if (bodyItem is not null || wantsRfc822 || wantsRfc822Header || wantsRfc822Text) { var (section, peek, label, fields) = bodyItem ?? (wantsRfc822Header ? "HEADER" : wantsRfc822Text ? "TEXT" : "", false, wantsRfc822Header ? "RFC822.HEADER" : wantsRfc822Text ? "RFC822.TEXT" : "RFC822", Array.Empty()); var content = RenderSection(message, section, fields); var responseLabel = label.StartsWith("RFC822") ? label : label; // 除了 PEEK 之外,取正文视为已读 if (!peek && message.Unread) { store.MarkRead(user!, message.Id, true); message.Unread = false; } await WriteAsync(writer, $"* {(useUid ? message.Uid : IndexOf(items, message) + 1)} FETCH ({string.Join(" ", pieces)} {responseLabel} {{{content.Length}}}"); await WriteBytesAsync(writer, content); await WriteAsync(writer, ")"); continue; } await WriteAsync(writer, $"* {(useUid ? message.Uid : IndexOf(items, message) + 1)} FETCH ({string.Join(" ", pieces)})"); } await OkAsync(writer, tag, "FETCH completed"); } private async Task StoreAsync(StreamWriter writer, string tag, string args, bool useUid) { if (selected is null) { await NoAsync(writer, tag, "No mailbox selected"); return; } if (readOnly) { await NoAsync(writer, tag, "Mailbox is read-only"); return; } var parts = SplitTokens(args); if (parts.Count < 3) { await NoAsync(writer, tag, "STORE requires set, item and flags"); return; } var items = store.ListForImap(user!, selected); var targets = ResolveSet(parts[0], items, useUid); var operation = parts[1].ToUpperInvariant(); var silent = operation.EndsWith(".SILENT"); var flags = args[(args.IndexOf(parts[2], StringComparison.Ordinal))..].ToUpperInvariant(); var add = !operation.StartsWith("-FLAGS"); var remove = operation.StartsWith("-FLAGS"); foreach (var message in targets) { if (flags.Contains("\\SEEN")) store.StoreFlags(user!, message.Id, seen: add && !remove, flagged: null); if (flags.Contains("\\FLAGGED")) store.StoreFlags(user!, message.Id, seen: null, flagged: add && !remove); if (flags.Contains("\\DELETED")) { if (add && !remove) deleted.Add(message.Id); else deleted.Remove(message.Id); } var updated = store.GetMessage(user!, message.Id)!; if (!silent) await WriteAsync(writer, $"* {(useUid ? updated.Uid : IndexOf(items, updated) + 1)} FETCH (FLAGS {FlagsOf(updated)})"); } await OkAsync(writer, tag, "STORE completed"); } private async Task CopyAsync(StreamWriter writer, string tag, string args, bool useUid) { if (selected is null) { await NoAsync(writer, tag, "No mailbox selected"); return; } var parts = SplitTokens(args); if (parts.Count < 2) { await NoAsync(writer, tag, "COPY requires set and mailbox"); return; } var folder = MailFolders.Normalize(parts[1]); if (folder is null) { await NoAsync(writer, tag, "TRYCREATE Mailbox does not exist"); return; } var items = store.ListForImap(user!, selected); foreach (var message in ResolveSet(parts[0], items, useUid)) { try { var raw = store.ReadRaw(message.RawPath); store.Append(user!, folder, raw, seen: true); } catch (Exception ex) { AppLog.Warn($"[IMAP] 复制 {message.Id} 失败:{ex.Message}"); } } await OkAsync(writer, tag, "COPY completed"); } private async Task ExpungeAsync(StreamWriter writer, string tag) { if (selected is null) { await NoAsync(writer, tag, "No mailbox selected"); return; } // EXPUNGE 的序号必须随删除动态变化,因此按序号从小到大处理 var items = store.ListForImap(user!, selected).ToList(); var index = 0; while (index < items.Count) { var message = items[index]; if (deleted.Contains(message.Id)) { store.Expunge(user!, message.Id); deleted.Remove(message.Id); items.RemoveAt(index); await WriteAsync(writer, $"* {index + 1} EXPUNGE"); continue; } index++; } await OkAsync(writer, tag, "EXPUNGE completed"); } private async Task AppendAsync(StreamWriter writer, string tag, string rest, CancellationToken token) { var parts = SplitTokens(rest); if (parts.Count == 0) { await NoAsync(writer, tag, "APPEND requires mailbox"); return; } var folder = MailFolders.Normalize(parts[0]); var literalIndex = rest.LastIndexOf('{'); if (folder is null || literalIndex < 0) { await NoAsync(writer, tag, "APPEND syntax error"); return; } var closing = rest.IndexOf('}', literalIndex); if (closing < 0 || !int.TryParse(rest[(literalIndex + 1)..closing], out var size) || size < 0 || size > config.Smtp.MaxMessageBytes) { await NoAsync(writer, tag, "APPEND invalid literal size"); return; } await WriteAsync(writer, "+ Ready for literal data"); var raw = await reader.ReadExactlyAsync(size, token); var flagsArea = rest[..literalIndex].ToUpperInvariant(); var seen = flagsArea.Contains("\\SEEN"); var message = store.Append(user!, folder, raw, seen); AppLog.Info($"[IMAP] {user} APPEND 到 {folder}:{message?.Subject}"); await OkAsync(writer, tag, "APPEND completed"); } private async Task IdleAsync(StreamWriter writer, string tag, CancellationToken token) { await WriteAsync(writer, "+ idling"); var lastCounts = SnapshotCounts(); var deadline = DateTimeOffset.UtcNow.AddMinutes(30); while (DateTimeOffset.UtcNow < deadline && !token.IsCancellationRequested) { // 等待客户端发送 DONE:用带超时的读取探测 var readTask = reader.ReadLineAsync(token); var completed = await Task.WhenAny(readTask, Task.Delay(2000, token)); if (completed == readTask) { var line = await readTask; if (line is null) return; if (line.Trim().Equals("DONE", StringComparison.OrdinalIgnoreCase)) break; continue; } var current = SnapshotCounts(); foreach (var (folder, count) in current) { if (lastCounts.TryGetValue(folder, out var previous) && previous != count) { if (selected is not null && folder == selected) await WriteAsync(writer, $"* {count} EXISTS"); else await WriteAsync(writer, $"* OK [STATUS] {DisplayName(folder)} changed"); } } lastCounts = current; } await OkAsync(writer, tag, "IDLE terminated"); } private Dictionary SnapshotCounts() { var map = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var folder in MailFolders.ImapFolders) map[folder] = store.ListForImap(user!, folder).Count; return map; } // ---------------------------------------------------------------- 渲染辅助 private static string DisplayName(string folder) => folder switch { "inbox" => "INBOX", "sent" => "Sent", "drafts" => "Drafts", "archive" => "Archive", "trash" => "Trash", "spam" => "Junk", _ => folder, }; private static string FlagsOf(MailMessage message) { var flags = new List(); if (!message.Unread) flags.Add("\\Seen"); if (message.Starred) flags.Add("\\Flagged"); return "(" + string.Join(" ", flags) + ")"; } private static string FormatInternalDate(DateTimeOffset value) => value.ToString("dd-MMM-yyyy HH:mm:ss ", CultureInfo.InvariantCulture) + (value.Offset < TimeSpan.Zero ? "-" : "+") + value.Offset.Duration().ToString("hhmm", CultureInfo.InvariantCulture); private static int IndexOf(IReadOnlyList items, MailMessage message) { for (var i = 0; i < items.Count; i++) if (ReferenceEquals(items[i], message) || items[i].Id == message.Id) return i; return 0; } private static bool Matches(string spec, string token) => spec.Contains(token, StringComparison.OrdinalIgnoreCase); /// 按「独立 token」匹配 FETCH 项,避免 "RFC822.SIZE" 被当成 "RFC822"。 private static bool HasToken(string spec, string token) { var upper = spec.ToUpperInvariant(); var needle = token.ToUpperInvariant(); var index = 0; while ((index = upper.IndexOf(needle, index, StringComparison.Ordinal)) >= 0) { var beforeOk = index == 0 || " ()".IndexOf(upper[index - 1]) >= 0; var end = index + needle.Length; var afterOk = end >= upper.Length || " ()".IndexOf(upper[end]) >= 0; if (beforeOk && afterOk) return true; index = end; } return false; } private static bool SpecNeedsBodyStructure(string spec) { var upper = spec.ToUpperInvariant(); return upper.Contains("BODYSTRUCTURE") || (upper.Contains("BODY") && !upper.Contains("BODY[") && !upper.Contains("BODY.PEEK")); } /// 从 FETCH 项里取出 BODY[...] / BODY.PEEK[...] 的 section,并判断是否 PEEK。 private static (string Section, bool Peek, string Label, string[] Fields)? ExtractBodyItem(string spec) { var upper = spec.ToUpperInvariant(); var peekIndex = upper.IndexOf("BODY.PEEK[", StringComparison.Ordinal); var plainIndex = peekIndex < 0 ? upper.IndexOf("BODY[", StringComparison.Ordinal) : -1; var start = peekIndex >= 0 ? peekIndex : plainIndex; if (start < 0) return null; var open = spec.IndexOf('[', start); // HEADER.FIELDS 的括号里还有括号,必须找到与之配对的 ']' var depth = 0; var close = -1; for (var i = open; i >= 0 && i < spec.Length; i++) { if (spec[i] == '[') depth++; else if (spec[i] == ']') { depth--; if (depth == 0) { close = i; break; } } } if (open < 0 || close < 0) return null; var original = spec[(open + 1)..close].Trim(); // 回显客户端请求的原始 section(客户端按标签匹配,不能改写) var label = "BODY[" + original + "]"; var normalized = NormalizeSection(original); var fields = normalized == "HEADER.FIELDS" ? ExtractFieldNames(original) : Array.Empty(); return (normalized, peekIndex >= 0, label, fields); } /// 从 HEADER.FIELDS (A B C) 里取出字段名。 private static string[] ExtractFieldNames(string section) { var open = section.IndexOf('('); var close = section.LastIndexOf(')'); if (open < 0 || close <= open) return []; return section[(open + 1)..close].Split(' ', StringSplitOptions.RemoveEmptyEntries).Select(x => x.Trim()).ToArray(); } /// 把客户端请求的 section 归一化;我们的存储是整封原始报文,多数情况返回整封或头部。 private static string NormalizeSection(string section) { var value = section.Trim(); if (value.Length == 0) return ""; if (value.StartsWith("HEADER.FIELDS", StringComparison.OrdinalIgnoreCase)) return "HEADER.FIELDS"; if (value.StartsWith("HEADER", StringComparison.OrdinalIgnoreCase)) return "HEADER"; if (value.StartsWith("TEXT", StringComparison.OrdinalIgnoreCase)) return "TEXT"; return "FULL"; } /// 按 section 渲染字节内容。 private byte[] SafeReadRaw(MailMessage message) { try { return store.ReadRaw(message.RawPath); } catch { return []; } } private byte[] RenderSection(MailMessage message, string section, string[] fields) { var raw = SafeReadRaw(message); if (raw.Length == 0) raw = Encoding.UTF8.GetBytes($"From: {message.From}{Crlf}To: {message.To}{Crlf}Subject: {message.Subject}{Crlf}{Crlf}{message.Text}"); var text = Encoding.Latin1.GetString(raw); var separator = text.IndexOf("\r\n\r\n", StringComparison.Ordinal); var header = separator >= 0 ? text[..(separator + 4)] : text; var body = separator >= 0 ? text[(separator + 4)..] : ""; return section switch { "HEADER" => Encoding.Latin1.GetBytes(header), "HEADER.FIELDS" => Encoding.Latin1.GetBytes(FilterHeaderFields(header, fields)), "TEXT" => Encoding.Latin1.GetBytes(body), _ => raw, }; } private static string FilterHeaderFields(string header, string[] requested) { var wanted = requested.Length > 0 ? requested : new[] { "From", "To", "Cc", "Subject", "Date", "Message-ID", "Content-Type", "Content-Transfer-Encoding", "MIME-Version" }; var builder = new StringBuilder(); foreach (var line in header.Split("\r\n")) { var colon = line.IndexOf(':'); if (colon > 0 && wanted.Contains(line[..colon].Trim(), StringComparer.OrdinalIgnoreCase)) builder.Append(line).Append(Crlf); else if (line.StartsWith(' ') || line.StartsWith('\t')) { /* 折行忽略 */ } } return builder.Append(Crlf).ToString(); } private static string Envelope(MailMessage message) { var date = message.Date.ToString("ddd, dd MMM yyyy HH:mm:ss ", CultureInfo.InvariantCulture) + FormatZone(message.Date); var from = AddressList(message.From); return $"({Quote(date)} {Quote(Mime.EncodeHeaderValue(message.Subject))} {from} {from} NIL {AddressList(message.To)} {AddressList(message.Cc)} NIL NIL {Quote(message.InReplyTo)} {Quote(message.MessageId)})"; } private static string FormatZone(DateTimeOffset value) => (value.Offset < TimeSpan.Zero ? "-" : "+") + value.Offset.Duration().ToString("hhmm", CultureInfo.InvariantCulture); private static string AddressList(string value) { var addresses = Mime.Addresses(value); if (addresses.Length == 0) return "NIL"; var parts = addresses.Select(a => { var at = a.IndexOf('@'); var mailbox = at > 0 ? a[..at] : a; var host = at > 0 ? a[(at + 1)..] : ""; return $"(NIL NIL {Quote(mailbox)} {Quote(host)})"; }); return "(" + string.Join(" ", parts) + ")"; } private static string Quote(string value) => "\"" + (value ?? "").Replace("\\", "\\\\").Replace("\"", "\\\"") + "\""; /// 生成 BODYSTRUCTURE。附件在客户端能否正常显示取决于这里是否准确。 private string BodyStructure(MailMessage message) { byte[] raw; try { raw = store.ReadRaw(message.RawPath); } catch { raw = []; } var (headers, body) = Mime.SplitMessage(raw); var contentType = headers.FirstOrDefault(h => h.Key.Equals("Content-Type", StringComparison.OrdinalIgnoreCase)).Value ?? ""; var (mediaType, parameters) = Mime.ParseContentType(contentType); var transfer = headers.FirstOrDefault(h => h.Key.Equals("Content-Transfer-Encoding", StringComparison.OrdinalIgnoreCase)).Value ?? "7bit"; if (mediaType.StartsWith("multipart/", StringComparison.OrdinalIgnoreCase) && parameters.TryGetValue("boundary", out var boundary)) { var parts = SplitParts(body, boundary); var rendered = parts.Select(p => BodyStructureOfPart(p)).ToList(); var subtype = mediaType["multipart/".Length..].ToUpperInvariant(); return "(" + string.Join(" ", rendered) + $" {Quote(subtype)} ({Quote("BOUNDARY")} {Quote(boundary)}))"; } return BodyStructureOfPart((headers, body), mediaType, transfer, forceAttachment: message.Attachments.Count > 0); } private string BodyStructureOfPart((List> Headers, byte[] Body) part, string? knownType = null, string? knownTransfer = null, bool forceAttachment = false) { var contentType = knownType ?? part.Headers.FirstOrDefault(h => h.Key.Equals("Content-Type", StringComparison.OrdinalIgnoreCase)).Value ?? "text/plain; charset=us-ascii"; var (mediaType, parameters) = Mime.ParseContentType(contentType); var transfer = knownTransfer ?? part.Headers.FirstOrDefault(h => h.Key.Equals("Content-Transfer-Encoding", StringComparison.OrdinalIgnoreCase)).Value ?? "7bit"; var disposition = part.Headers.FirstOrDefault(h => h.Key.Equals("Content-Disposition", StringComparison.OrdinalIgnoreCase)).Value ?? ""; var (dispType, dispParams) = Mime.ParseContentType(disposition); if (mediaType.StartsWith("multipart/", StringComparison.OrdinalIgnoreCase) && parameters.TryGetValue("boundary", out var boundary)) { var parts = SplitParts(part.Body, boundary).Select(p => BodyStructureOfPart(p)).ToList(); return "(" + string.Join(" ", parts) + $" {Quote(mediaType["multipart/".Length..].ToUpperInvariant())} ({Quote("BOUNDARY")} {Quote(boundary)}))"; } var size = part.Body.Length; var lines = part.Body.Count(b => b == (byte)'\n'); var name = dispParams.GetValueOrDefault("filename") ?? parameters.GetValueOrDefault("name") ?? ""; var upper = mediaType.ToUpperInvariant(); var slash = upper.IndexOf('/'); var main = slash > 0 ? upper[..slash] : upper; var sub = slash > 0 ? upper[(slash + 1)..] : "OCTET-STREAM"; var isText = main == "TEXT"; var id = parameters.GetValueOrDefault("charset", isText ? "UTF-8" : ""); var paramList = string.IsNullOrEmpty(id) && string.IsNullOrEmpty(name) ? "NIL" : "(" + string.Join(" ", new[] { isText ? $"{Quote("CHARSET")} {Quote(id.Length > 0 ? id : "UTF-8")}" : "", string.IsNullOrEmpty(name) ? "" : $"{Quote("NAME")} {Quote(name)}", }.Where(x => x.Length > 0)) + ")"; var disp = forceAttachment || dispType.Length > 0 ? $"({Quote(string.IsNullOrEmpty(dispType) ? "ATTACHMENT" : dispType.ToUpperInvariant())} {(string.IsNullOrEmpty(name) ? "NIL" : "(" + Quote("FILENAME") + " " + Quote(name) + ")")})" : "NIL"; var tail = isText ? $"{Quote(main)} {Quote(sub)} {paramList} NIL NIL {Quote(transfer.ToUpperInvariant())} {size} {lines}" : $"{Quote(main)} {Quote(sub)} {paramList} NIL NIL {Quote(transfer.ToUpperInvariant())} {size}"; return isText ? $"({tail})" : $"({tail} {disp})"; } private static List<(List> Headers, byte[] Body)> SplitParts(byte[] body, string boundary) { var result = new List<(List>, byte[])>(); var delimiter = Encoding.ASCII.GetBytes("--" + boundary); var positions = new List(); for (var i = 0; i + delimiter.Length <= body.Length; i++) { if (body[i] != (byte)'-') continue; var match = true; for (var j = 0; j < delimiter.Length; j++) if (body[i + j] != delimiter[j]) { match = false; break; } if (match) { positions.Add(i); i += delimiter.Length - 1; } } for (var index = 0; index < positions.Count - 1; index++) { var start = positions[index]; var lineEnd = Array.IndexOf(body, (byte)'\n', start); if (lineEnd < 0) continue; start = lineEnd + 1; var end = positions[index + 1]; while (end > start && (body[end - 1] == (byte)'\n' || body[end - 1] == (byte)'\r')) end--; if (end > start) result.Add(Mime.SplitMessage(body[start..end])); } return result; } // ---------------------------------------------------------------- 集合与解析 private static List ResolveSet(string set, IReadOnlyList items, bool useUid) { var result = new List(); foreach (var token in set.Split(',', StringSplitOptions.RemoveEmptyEntries)) { var range = token.Split(':', 2); if (range.Length == 1) { var item = Resolve(range[0], items, useUid); if (item is not null) result.Add(item); continue; } var start = ResolveIndex(range[0], items, useUid); var end = ResolveIndex(range[1], items, useUid); if (start < 0 || end < 0) continue; if (start > end) (start, end) = (end, start); for (var i = start; i <= end && i < items.Count; i++) result.Add(items[i]); } return result; } private static int ResolveIndex(string token, IReadOnlyList items, bool useUid) { if (token.Trim() == "*") return items.Count - 1; if (!int.TryParse(token.Trim(), out var value)) return -1; if (!useUid) return value - 1; for (var i = 0; i < items.Count; i++) if (items[i].Uid == value) return i; return -1; } private static MailMessage? Resolve(string token, IReadOnlyList items, bool useUid) { var index = ResolveIndex(token, items, useUid); return index >= 0 && index < items.Count ? items[index] : null; } private static HashSet ParseUidSet(string set, IReadOnlyList items) { var uids = new HashSet(); foreach (var token in set.Split(',', StringSplitOptions.RemoveEmptyEntries)) { var range = token.Split(':', 2); if (range.Length == 1) { if (int.TryParse(range[0], out var single)) uids.Add(single); continue; } if (!int.TryParse(range[0], out var start)) continue; var end = range[1] == "*" ? items.Select(x => x.Uid).DefaultIfEmpty(0).Max() : (int.TryParse(range[1], out var parsed) ? parsed : start); if (start > end) (start, end) = (end, start); for (var i = start; i <= end; i++) uids.Add(i); } return uids; } private static List SplitTokens(string value) { var tokens = new List(); var current = new StringBuilder(); var depth = 0; var quoted = false; foreach (var c in value) { if (c == '"') quoted = !quoted; if (!quoted) { if (c == '(') depth++; if (c == ')') depth--; if (c == ' ' && depth == 0) { if (current.Length > 0) { tokens.Add(current.ToString()); current.Clear(); } continue; } } current.Append(c); } if (current.Length > 0) tokens.Add(current.ToString()); return tokens; } private static string ExtractMailbox(string rest) { var tokens = SplitTokens(rest); return tokens.Count == 0 ? "" : tokens[^1].Trim('"'); } private static string Unquote(string value) => value.Trim().Trim('"'); private static (string User, string Password) ParseLogin(string rest) { var tokens = SplitTokens(rest); if (tokens.Count < 3) return ("", ""); return (Unquote(tokens[1]), Unquote(tokens[2])); } private static bool TryParsePlain(string payload, out string user, out string password) { user = ""; password = ""; try { var bytes = Convert.FromBase64String(payload.Trim()); var parts = Encoding.UTF8.GetString(bytes).Split('\0'); if (parts.Length < 3) return false; user = parts[1]; password = parts[2]; return true; } catch { return false; } } // ---------------------------------------------------------------- 输出 /// 写出响应。注意:未标记响应必须自带 "* " 前缀,标记响应由调用方拼接 tag。 private static Task WriteAsync(StreamWriter writer, string line) => writer.WriteLineAsync(line); private static Task OkAsync(StreamWriter writer, string tag, string text) => writer.WriteLineAsync($"{tag} OK {text}"); private static Task NoAsync(StreamWriter writer, string tag, string text) => writer.WriteLineAsync($"{tag} NO {text}"); private static async Task WriteBytesAsync(StreamWriter writer, byte[] data) { await writer.FlushAsync(); await writer.BaseStream.WriteAsync(data); await writer.BaseStream.FlushAsync(); } }