From 9fef9da830e33fffa7dcc46a7ac6e5c29754b48c Mon Sep 17 00:00:00 2001 From: dsh Date: Wed, 19 Aug 2026 10:56:26 -0400 Subject: [PATCH] =?UTF-8?q?fix(imap):=20=E6=96=B0=E9=82=AE=E4=BB=B6?= =?UTF-8?q?=E6=8E=A8=E9=80=81=E8=A1=A5=E5=8F=91=20EXISTS=20=E9=80=9A?= =?UTF-8?q?=E7=9F=A5=EF=BC=8C=E4=BF=AE=E5=A4=8D=20Apple=20Mail=20=E7=AD=89?= =?UTF-8?q?=E5=AE=A2=E6=88=B7=E7=AB=AF=E9=9C=80=E6=89=8B=E5=8A=A8=E5=90=8C?= =?UTF-8?q?=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题:go-imap 更新通道只能表达 FETCH/EXPUNGE 类更新,新邮件到达时 客户端(IDLE 中)只收到裸 FETCH。不少客户端(如 Apple Mail)只认 RFC 2177 要求的 EXISTS 才会唤醒拉取,表现为'必须手动点击同步'。 修复:PushNewMessage 先向所有已选中该邮箱的匹配连接直接写入 '* N EXISTS'(Dovecot 同款行为),再广播 FETCH 更新。 实测 IDLE 客户端按序收到 '* 21 EXISTS' + '* 1 FETCH (UID ...)'。 --- internal/imap_server/server.go | 41 ++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/internal/imap_server/server.go b/internal/imap_server/server.go index 74e73c1..daed02b 100644 --- a/internal/imap_server/server.go +++ b/internal/imap_server/server.go @@ -14,6 +14,7 @@ import ( "mail_go/internal/store" "mail_go/internal/tlsutil" + "github.com/emersion/go-imap" "github.com/emersion/go-imap/backend" imapserver "github.com/emersion/go-imap/server" ) @@ -65,6 +66,12 @@ func (s *IMAPServer) PushNewMessage(userEmail string, msg *db.Message) { if update == nil { return } + // 先发 EXISTS 通知再发 FETCH 更新:RFC 2177(IDLE)要求新邮件到达 + // 时服务器发送 EXISTS,不少客户端(如 Apple Mail)只认 EXISTS 才会 + // 唤醒并主动拉取,仅裸 FETCH 更新会被忽略(表现为"必须手动同步")。 + if count, err := s.stores.Mails.CountByUserAndFolder(msg.UserID, "INBOX"); err == nil { + s.pushExists(userEmail, "INBOX", uint32(count)) + } s.broadcastUpdate(update, userEmail, msg.ID) } @@ -112,6 +119,40 @@ func (s *IMAPServer) broadcastUpdate(update backend.Update, userEmail string, ms } } +// pushExists 向所有监听器中「已登录该用户且已选中该邮箱」的连接直接写入 +// 未请求的 "* N EXISTS" 响应(绕过 go-imap 更新通道——其仅支持 FETCH/ +// EXPUNGE 类更新,无法表达 EXISTS)。通道满时非阻塞丢弃,与广播一致。 +func (s *IMAPServer) pushExists(userEmail, mailbox string, exists uint32) { + s.beMu.Lock() + srvs := append([]*imapserver.Server(nil), s.srvs...) + s.beMu.Unlock() + + for _, srv := range srvs { + srv.ForEachConn(func(conn imapserver.Conn) { + ctx := conn.Context() + if ctx == nil || ctx.User == nil || ctx.Mailbox == nil { + return + } + if ctx.User.Username() != userEmail || ctx.Mailbox.Name() != mailbox { + return + } + select { + case ctx.Responses <- existsResponse(exists): + default: + log.Printf("IMAP: EXISTS 推送通道已满,丢弃 (user=%s mailbox=%s)", userEmail, mailbox) + } + }) + } +} + +// existsResponse 序列化为 "* N EXISTS\r\n"。 +type existsResponse uint32 + +func (n existsResponse) WriteTo(w *imap.Writer) error { + _, err := fmt.Fprintf(w, "* %d EXISTS\r\n", uint32(n)) + return err +} + // cloneUpdate 按类型复制一条 backend.Update:载荷(消息/序号)共享, // 但 Username/Mailbox/Done channel 重置为独立实例。 func cloneUpdate(u backend.Update) backend.Update {