已知问题
记录用户反馈的问题及处理进度。
问题列表
再次添加同服务商(Outlook)OAuth 邮箱会复用上一次认证结果
- 状态:✅ 已修复未发布
- 记录时间:2026-09-21
- 问题描述:使用 OAuth2(微软设备码)方式添加 Outlook/Hotmail 邮箱后,再次添加同一服务商的另一个邮箱时,向导不会发起新的授权,而是直接套用上一次认证结果(旧的 email + refresh_token),导致无法添加新邮箱。
- 根因分析:服务端 SSE broker 在每次新建 SSE 连接时都会重放历史事件(
StreamHandler→ReplayHistory)。oauth.authorized/oauth.expired这两个一次性授权结果事件被错误地列入replayableEvents。第一次授权成功后,该事件(含上一个邮箱的 email + refresh_token)被存入用户历史;第二次添加邮箱时向导为本次授权新建 SSE 连接,立刻重放出这条陈旧事件,前端onOAuthAuthorized直接把旧结果当作本次授权成功 → 复用上一次认证结果。broker.go中原本就有注释说明"一次性结果/通知类事件不应重放",但这两个事件被遗漏了。 - 修复方案:
server/sse/broker.go— 将oauth.authorized、oauth.expired从replayableEvents中移除,OAuth 授权结果只在"实时发生"时推送给当前等待中的连接,不再历史重放;web/src/components/WizardOAuth2Flow.vue— 给onOAuthAuthorized/onOAuthExpired回调加守卫:仅当status === 'pending'(本次授权流程确实在等待)时才处理,避免任何陈旧或来自其它会话的事件误触发,作为防御性兜底。
- 涉及文件:
server/sse/broker.go、web/src/components/WizardOAuth2Flow.vue - 附注:
oauth.authorized等一次性事件本来只应提示一次;account.health、account.sync_started/done在此之前已因"重放会误报/骚扰"被正确移除,本次补齐了 OAuth 事件的遗漏。
Outlook 刷新 RefreshToken 解密失败(密钥不匹配或密文损坏)
状态:✅ 已修复未发布
记录时间:2026-09-21
问题描述:添加 Outlook/Hotmail 邮箱(OAuth 认证)后,删除一封邮件再重新同步,日志反复报错:
❌ [OAuth2] 账号 xxx@hotmail.com 的 RefreshToken 解密失败(密钥不匹配或密文损坏),无法刷新 AccessToken,需用户重新授权 ❌ 同步错误 (xxx@hotmail.com): RefreshToken 解密失败(密钥不匹配或密文损坏),需用户重新授权删除邮件当时能成功,但随后同步(Access Token 过期需刷新时)即失败。
根因分析:
server/imap/client.go的persistOAuthTokens(每次 OAuth 同步刷新 Token 后落库)原本依赖 GORM 的BeforeUpdate钩子来加密。但其写法db.Model(&models.MailAccount{}).Where(...).Updates(&updates)中,GORM 把BeforeUpdate钩子作用在了Model()传入的空模型&MailAccount{}上,而非承载数据的&updates结构体,导致encryptSensitiveFields跑在空结构体上,refresh_token/access_token被以明文原样写入数据库。复现测试已确认此行为。为何"删除邮件能成功、重新同步才失败":
- 删除邮件时
access_token仍在有效期内,resolveAccessToken走 early-return 分支(不读取refresh_token),故删除成功; - 重新同步时
access_token已过期,必须刷新 → 读取refresh_token→AfterFind对明文做Decrypt→!IsEncrypted→ 报"RefreshToken 解密失败"。
- 删除邮件时
修复方案:
persistOAuthTokens改为写入前显式加密(带IsEncrypted守卫防双重加密),并改用map更新只写指定列,不再依赖不可靠的钩子(与account_service.Update既有的防御式写法一致)。新增回归测试server/imap/client_persist_test.go(已通过)。涉及文件:
server/imap/client.go、server/imap/client_persist_test.go自愈与补救:启动时的迁移
encryptPlaintextSecrets(database/database.go)会扫描并把明文的refresh_token/password重新加密。因此重启一次应用,账号已有的明文refresh_token会被自动加密修复,之后即可正常刷新;也可在账号设置里"重新授权"该邮箱。
点击「立即同步」无反应,终端日志完全静默
状态:✅ 已修复未发布
记录时间:2026-09-06
问题描述:添加企业微信邮箱(腾讯企业邮)后点击「立即同步」,接口返回
200,但此后终端不再输出任何日志,前端一直停留在同步中。典型日志片段:2026/09/06 13:15:34 /workspace/server/services/account_service.go:337 [0.180ms] [rows:1] SELECT * FROM `mail_accounts` WHERE user_id = 1 AND `mail_accounts`.`id` = 1 ... 13:15:34 | 200 | 647.375µs | 127.0.0.1 | POST | /api/v1/accounts/1/sync | -排查过程(两个关键判据):
- 请求仅耗时 647µs 就返回 200 —— 说明
AccountService.TriggerSync只是投递了一个唤醒信号便返回,同步在后台异步执行,接口本身没有阻塞,符合预期; - 此后一条 SQL 都没有 —— 而
syncOnce()的第一步就是db.First(&fresh, account.ID)(必然产生一条 GORM 日志)。没有这条日志 ⇒syncOnce根本没被调用 ⇒ Worker 当时不在任何监听wakeCh的 select 上。
由此把范围从「同步逻辑有问题」缩小到「唤醒信号没送达 / 送达了但 Worker 正在阻塞 IO 中」。
- 请求仅耗时 647µs 就返回 200 —— 说明
根因分析(三种可能,前两种与协议无关):
- Worker 正卡在一次同步里(最常见):
syncOnce全程没有任何日志,而企业邮邮箱邮件量大时,fetcher.syncMailbox会先 FETCH 全部 envelope,再对每封新邮件单独 FETCH 正文与附件,首次全量同步可持续数十分钟。期间手动同步信号只能在缓冲为 1 的wakeCh中排队,等本轮结束后才执行,且界面无任何进度反馈; - Worker 已退出但仍残留在 Worker 表中:
Run()返回后WorkerPool.workers不会自动清理该项,WakeWorker只判断「表中是否存在」便返回true,Wake()投递的信号进入无人消费的通道 —— 表现为永久无响应、零日志; - POP3 账号被服务端吊住:
pop3客户端此前完全没有超时(net.Dial无Timeout、textproto读取无 deadline),服务端建立 TCP 后不响应时读取会永久阻塞,Worker 卡死在syncOnce内既不报错也不退出。
- Worker 正卡在一次同步里(最常见):
修复方案:
- 唤醒可靠性:
AccountWorker新增doneCh,Run()返回时关闭;WakeWorker投递前先做存活判定,识别到已退出的 Worker 即从表中移除并返回false,让TriggerSync回退为RestartWorker(原来只能干等); - 唤醒可观测:
Run()主循环中三个唤醒分支(IDLE / 轮询 / IDLE 退避)全部补日志🔔 收到手动同步请求,TriggerSync区分两条路径输出🔔 已唤醒 Worker 执行同步与🔔 账号无运行中的 Worker,回退为重启同步。该日志是否出现,是判断信号有没有送达 Worker 的分水岭; - 同步过程可观测:
syncOnce增加🔄 开始同步/🏁 同步结束(含耗时);fetcher的 FETCH 循环每 50 封输出⏳ 同步进度 (邮箱): 已扫描 x/y 封,新增 z 封,大邮箱首次同步不再像卡死; - 停止信号不再丢失:
stopCh由无缓冲改为缓冲 1。原实现是无缓冲通道 + 非阻塞发送,Worker 正忙(如正在syncOnce)时停止信号会被default分支直接丢弃,导致重启后新旧两个 Worker 并存、竞态同步; - POP3 全链路超时:建连与 TLS 握手 30s、普通命令 60s、
LIST全量列表 2min、单封邮件下载 5min,任何挂起都会在可预期时间内变成一条明确的错误日志。顺带将fmt.Sprintf("%s:%d", host, port)改为net.JoinHostPort(),修复 IPv6 主机下地址非法的问题。
- 唤醒可靠性:
涉及文件:
server/imap/worker.go—doneCh存活判定与表项清理、唤醒日志、syncOnce起止日志、stopCh缓冲server/imap/fetcher.go— FETCH 循环进度日志server/pop3/client.go— 建连/命令/下载超时、IPv6 地址拼接server/services/account_service.go—TriggerSync唤醒/重启路径日志
复现验证:重新编译后点击同步,正常应依次出现:
🔔 已唤醒 Worker 执行同步: xxx@xx (account_id=1) 🔔 收到手动同步请求: xxx@xx 🔄 开始同步 (xxx@xx) [手动触发] ⏳ 同步进度 (xxx@xx): 已扫描 50/832 封,新增 12 封 🏁 同步结束 (xxx@xx) [手动触发]: 耗时 3m12s判定方式:
- 只有第一行 ⇒ Worker 卡住或已死;再看
🔄 开始同步与🏁 同步结束是否成对 —— 有开始无结束即卡在连接/认证/拉取某一步; - 出现
♻️ 检测到已退出的 Worker,已从表中移除⇒ 命中根因 2,现已自动重启恢复; - 停在
📬 开始同步 xxx: 模式=..., 收件箱共 N 封邮件⇒ 命中根因 1,属正常的首次全量同步(之后为增量),等进度日志推进即可。
- 只有第一行 ⇒ Worker 卡住或已死;再看
附注:IMAP 路径下
go-imap/v2自带命令级读超时(普通响应 30s、literal5min、IDLE期间为 0),因此不会永久挂起;POP3 无此保护,是本次补超时的重点。
189.cn 等邮箱历史邮件无法通过 IMAP 收取(服务商限制)
- 状态:🚫 服务商限制,客户端无解
- 记录时间:2026-09-03
- 问题描述:天翼邮箱(189.cn,Coremail/21cn 实现)添加成功后应用内一直为空,但网页端能看到大量历史邮件(如中国电信广东分公司的账单邮件)。应用日志显示
✅ IMAP 认证成功紧跟着📭 收件箱为空,认证本身没有问题。 - 排查过程(可复用于任何"收不到信"的账号,工具用法见 IMAP 诊断工具):
go run ./cmd/imapdiag -host imap.189.cn -user <账号> -pass <授权码> -all;- 三条独立路径计数一致为 0:
STATUS INBOX的MESSAGES=0、SELECT的EXISTS=0、UID SEARCH ALL返回空集合——三路一致可排除协议解析问题; -all扫描全部 9 个目录(INBOX / 草稿箱 / 已发送 / 垃圾箱 / 已删除 / 广告文件夹 / 我的账单 / 我的发票 / 官方活动)全部为 0,说明邮件也不是被归类到了别的文件夹;- 从外部邮箱发一封测试邮件后立刻复查:
EXISTS=1,且该邮件的 UID=115——一封刚到达的邮件却拿到 115,证明服务器上存在过 114 个更早的 UID,只是不向 IMAP 暴露(这是定性证据); - 在网页端把历史邮件转发给自己后应用能正常收到——确认 IMAP 链路与代码均无问题。
- 根因:服务商策略。IMAP/POP3 只投递"开通服务之后"新到达的邮件,历史邮件不对第三方客户端开放。这是运营商邮箱的常见做法,客户端在协议层无法绕过。
- 变通方案:网页端把需要的历史邮件转发给自己,转发件会作为全新邮件进入 IMAP 并被正常收取。仅限个别重要邮件,批量操作不现实。
- 涉及文件:无(非代码问题)。诊断工具见
server/cmd/imapdiag/main.go。 - 附注:该服务器
SELECT返回的UIDNEXT恒为0(违反 RFC 3501,规范要求 ≥1),UIDVALIDITY也是 1、2、3… 这类小整数。业务代码不读取这两个字段,故不受影响。
IMAP/POP3 邮件缺失 Message-ID 时被反复重复入库
- 状态:✅ 已修复未发布
- 记录时间:2026-07-06
- 问题描述:当邮件没有真实的
Message-ID头部时(如阿里云通知类邮件),同一封邮件在每次 IMAP/POP3 同步时都会被当作新邮件重复入库。数据库中可看到同一封邮件出现数十条记录(subject/from/sent_at/message_uid完全一致,但id和created_at不同),并重复触发mail.receivedwebhook。 - 根因分析:
- 旧版 fallback 逻辑在邮件缺失
Message-ID时使用time.Now()生成伪唯一 ID:<auto-{uid}-{timestamp}@proxy>。由于时间戳每次同步都不同,生成的message_id每次都不一样,导致去重查询WHERE message_id = ? AND account_id = ?永远匹配不到已有记录; - 去重查询未包含
folder字段,同一Message-ID在 inbox/sent 等不同文件夹中可能产生冲突; - 数据库层面
message_id字段上的全局唯一索引过于宽泛,会阻止不同账号收到相同Message-ID的正常邮件。
- 旧版 fallback 逻辑在邮件缺失
- 修复方案:
- 稳定 fallback Message-ID:不再使用
time.Now(),改为基于account_id + folder + uid(IMAP)或account_id + seq(POP3)生成稳定标识,确保同一封邮件每次同步生成相同的message_id; - 去重查询增加 folder:
WHERE message_id = ? AND account_id = ? AND folder = ?,避免不同文件夹间的误判; - 索引调整:将
message_id从uniqueIndex(全局唯一)改为普通index,新增message_uid索引提升查询性能; - 数据库迁移自动清理:启动时自动执行
cleanupDuplicateMails(),删除历史重复记录(按account_id + folder + message_uid去重,保留最早入库的一条),将旧格式<auto-*@proxy>的message_id更新为新稳定格式,移除过宽的旧唯一索引,创建复合唯一索引idx_mails_account_folder_uid。
- 稳定 fallback Message-ID:不再使用
- 涉及文件:
server/imap/fetcher.go— IMAP fallback Message-ID 生成 + 去重查询server/pop3/fetcher.go— POP3 fallback Message-ID 生成 + 去重查询 + Folder 字段server/models/mail.go— 索引调整server/database/database.go— 迁移后清理函数cleanupDuplicateMails()
- 影响范围:IMAP 和 POP3 协议均受影响;使用阿里云、部分企业邮箱等不发
Message-ID的邮件时尤为明显。修复后历史重复数据在下次启动时自动清理,后续同步不再产生重复。
企业微信邮件中文乱码 (GBK/GB18030 编码)
- 状态:✅ 已修复未发布
- 记录时间:2026-07-02
- 问题描述:接收企业微信邮箱(
@exmail.weixin.qq.com)的邮件时,邮件正文中的中文字符显示为乱码(如浣犲ソ锛岀粏鐣岀箒鏄�),而标题和发件人/收件人显示正常。该问题仅影响使用gb18030/gbk字符集编码的非 UTF-8 邮件。 - 根因分析:
- Go 标准库
mime.WordDecoder默认只支持 UTF-8,无法解码 RFC 2047 头部中声明的 GBK/GB18030 字符集; go-message库的全局message.CharsetReader虽然被正确调用并返回了 GBK 解码器,但其内部对 CharsetReader 返回值的处理存在缺陷,导致双重编码(Mojibake):原始 GBK 字节 → 正确解码为 UTF-8 → 又被当作 Latin-1 读入 → 再次编码为 UTF-8 = 最终乱码;- 含大量 ASCII HTML 标签的正文在自动检测时会被误判为有效 UTF-8,跳过了手动解码步骤。
- Go 标准库
- 修复方案:
- 全局 CharsetReader 改为透传:将
message.CharsetReader设为始终返回原始输入流,阻止go-message自行解码 body; - 自定义解码函数
decodeTextContentWithCharset():根据 MIME 头部声明的 charset 参数精确选择解码器(simplifiedchinese.GBK.NewDecoder()),一次性正确转换为目标 UTF-8 字符串; - 头部字段解码:使用带
CharsetReader回调的mime.WordDecoder解码 Subject、From/To 显示名等 RFC 2047 编码头部; - Charset 值清理:所有读取 charset 的位置均添加
strings.Trim(charset, "\"' \t"),处理 MIME 声明中可能包裹的引号。
- 全局 CharsetReader 改为透传:将
- 涉及文件:
server/main.go— 全局message.CharsetReader设置(透传模式)server/imap/fetcher.go— IMAP 邮件正文/头部解码逻辑server/pop3/fetcher.go— POP3 邮件正文/头部解码逻辑server/services/attachment_service.go— 附件名 RFC 2047 解码
- 依赖新增:
golang.org/x/text v0.21.0(simplifiedchinese.GBK解码器)
163/126 等网易邮箱无法发送邮件
- 状态:✅ 已修复并于v1.1.0发布
- 记录时间:2026-07-01
- 问题描述:使用 163、126 等网易邮箱时,虽然可以正常收信(IMAP),但无法发送邮件(SMTP)。原因是用户在添加账号时通常只填写 IMAP 服务器地址,系统未能正确推断对应的 SMTP 服务器地址和端口,导致 SMTP 连接失败。
- 修复方案:
- 新增常见邮箱服务商的 SMTP 服务器映射表(
smtpHostMap),支持 163、126、QQ、新浪、阿里云、Gmail、Yahoo、Outlook 等主流邮箱; - 新增 SMTP 端口映射表(
smtpPortMap),针对不同服务商使用正确的端口(如网易系使用 465 SSL/TLS,Gmail/Outlook 使用 587 STARTTLS); - 实现
inferSMTPHost()函数,根据 IMAP 地址自动推断 SMTP 服务器地址(优先匹配已知服务商,否则将imap.替换为smtp.); - 实现
DefaultSmtpPort()函数,根据 SMTP 服务器地址返回正确的默认端口。
- 新增常见邮箱服务商的 SMTP 服务器映射表(
- 涉及文件:
server/models/mail_account.go
IMAP IDLE 不兼容部分邮件服务器
- 状态:✅ 已修复(v1.1.0 发布,2026-09-03 进一步增强,未发布)
- 记录时间:2026-07-01(2026-09-03 更新)
- 问题描述:部分邮件服务器不支持 IMAP IDLE 命令(RFC 2177),当 Worker 尝试使用 IDLE 实时监听时会报错并反复重试,导致日志大量错误信息,且每次重启都会重新尝试。
- 修复方案:
- 新增 IDLE 支持服务器白名单(
idleVerifiedServers),仅对 Gmail、Yahoo、Outlook、QQ 等已验证服务器启用 IDLE; - 新增运行时学习机制(
idleLearnedUnsupported),首次遇到不支持的 IDLE 错误(包含 "BAD"、"not support"、"not allowed" 关键字)时,自动将该服务器标记为不支持并加入全局黑名单; - 后续同服务器的其他 Worker 可共享黑名单信息,避免重复尝试。
- 新增 IDLE 支持服务器白名单(
- 涉及文件:
server/imap/worker.go - 备注:未知服务器仍会首次尝试 IDLE,符合渐进式兼容策略。失败后的处理已分为两类:
- 服务器明确返回不支持(
BAD/not support/not allowed):写入全局黑名单,同服务器的其他账号一并降级为轮询; - 网络抖动等瞬时故障:指数退避重连(30s → 60s → 120s,上限 15 分钟),连续失败 4 次后才降级为轮询,且只影响当前 Worker、不写入全局黑名单(避免误伤同服务器的其他账号)。
- 服务器明确返回不支持(
- 补充(2026-09-03 增强):v1.1.0 的判定方式(白名单 + 错误文本匹配)在 189.cn 这类服务器上失效——它不会回
BAD,而是返回违反 RFC 2177 的畸形响应,错误文本命中不了黑名单,只能被当成"瞬时故障"走指数退避,白试 4 次(约 3.5 分钟内 4 次无效登录)才降级,且黑名单在进程重启后清空。现改为三层判定:- 能力探测(第一道防线):认证后检查
CAPABILITY是否声明IDLE(RFC 2177 要求支持者必须声明)。189.cn 能力集仅IMAP4 IMAP4rev1 ID XLIST XAPPLEPUSHSERVICE,无 IDLE,一次判定即转轮询。注意必须在认证之后读取——部分服务器登录前后能力集不同; - 协议解析错误识别:
imapwire/cannot read tag/expected atom等解析失败属确定性失败(重试多少次都一样),与"明确不支持"同等对待,一次失败即入全局黑名单; - 静态域名黑名单(兜底):收拾"声明支持但响应不合规"的服务器(如部分 Coremail 部署),连首次尝试都不做。
- 能力探测(第一道防线):认证后检查
- 诊断工具:
go run ./cmd/imapdiag -host <服务器> -user <账号> -pass <密码>可直接报告服务器是否声明 IDLE,完整用法见 IMAP 诊断工具。
SMTP 邮件头部 CRLF 注入漏洞 (CWE-93)
- 状态:✅ 已修复并于v1.1.0发布
- 记录时间:2026-06-26
- 安全等级:中等(CVSS ≈ 5.3)
- 问题描述:发送邮件时,
buildMessage函数直接将用户输入的收件人地址、主题等字段拼接到 RFC822 邮件头部中,未对\r、\n等控制字符进行过滤。攻击者可在收件人或主题字段中注入换行符,从而注入额外的 SMTP 头部或篡改邮件内容。 - 修复方案:
- 新增
sanitizeHeaderValue和sanitizeEmailAddr函数,在构建邮件头部时清洗所有用户输入,移除控制字符; - 在
Sendhandler 入口处增加输入校验,检测并拒绝包含\r\n的字段值(纵深防御)。
- 新增
- 涉及文件:
server/smtp/client.go,server/handlers/mail_handler.go - 参考:同类漏洞 (CVE form-data v4.0.5) — 本项目虽不依赖该库,但存在相同的攻击面
密码解密失败导致账号列表查询异常
- 状态:✅ 已修复并于v1.1.0发布
- 记录时间:2026-06-26
- 问题描述:当某个邮箱账号的密码加密数据损坏或密钥不匹配时,
AfterFind钩子中的解密失败会阻断整个账号列表查询接口(500 错误),导致用户无法查看任何账号。 - 修复方案:
- 解密失败时仅记录警告日志并清空密码字段,不再返回错误阻断查询;
- 新增
AccountListDTO专用列表查询模型,避免列表场景触发AfterFind解密逻辑; - 新增账号健康检查接口
/api/v1/accounts/health,便于排查异常账号。
- 涉及文件:
server/models/mail_account.go,server/services/account_service.go,server/handlers/account_handler.go,server/services/health_check_service.go,web/src/stores/accountStore.js
163/126 网易邮箱登录失败 (Unsafe Login)
- 状态:✅ 已修复并于v1.1.0发布
- 记录时间:2026-06-25
- 问题描述:使用 163、126 等网易邮箱时,IMAP 登录返回 "SELECT Unsafe Login" 错误,导致无法正常收信。原因是网易邮箱要求客户端在登录前必须发送 ID 命令声明身份(符合 RFC 2971 规范)。
- 修复方案:在 IMAP 登录前主动发送 ID 命令,声明客户端信息(Name: MagicMail, Version: 1.0.0, Vendor: MagicCode)。若服务器不支持 ID 命令则仅记录日志,不阻塞登录流程。
- 涉及文件:
server/imap/client.go
邮箱管理页面中等宽度下信息与按钮重叠
- 状态:✅ 已修复并于v1.1.0发布
- 记录时间:2026-06-26
- 问题描述:在 768px ~ 900px 宽度区间,邮箱管理页面的桌面端 Grid 布局会导致邮箱地址信息与右侧操作按钮(编辑、同步、删除)发生重叠,影响使用体验。
- 修复方案:将
AccountManage.vue的响应式断点从768px调整为900px,使中等屏幕更早切换到卡片布局。 - 涉及文件:
web/src/views/AccountManage.vue