fix(external_imap): 修复 IMAP 文件夹列表兼容性

- 在测试外部 IMAP 账号时,将文件夹列表失败提示改为更准确的错误信息。
- 为 OAuth XOAUTH2 认证和文件夹列表补充更明确的错误包装,便于定位问题。
- 对不支持 `LIST RETURN (STATUS ...)` 的 Outlook/Exchange 服务降级为普通 `LIST`,再单独查询文件夹状态。
This commit is contained in:
LanQin
2026-06-25 22:25:30 +08:00
parent efcdc691de
commit cc75382869
+36 -4
View File
@@ -317,7 +317,7 @@ func (a *App) handleTestExternalIMAPAccount(w http.ResponseWriter, r *http.Reque
folders, err := client.ListFolders(r.Context()) folders, err := client.ListFolders(r.Context())
if err != nil { if err != nil {
a.updateExternalIMAPStatus(r.Context(), account.ID, "error", err.Error()) a.updateExternalIMAPStatus(r.Context(), account.ID, "error", err.Error())
respondError(w, http.StatusBadRequest, "connection failed: "+err.Error()) respondError(w, http.StatusBadRequest, "list folders failed: "+err.Error())
return return
} }
a.updateExternalIMAPStatus(r.Context(), account.ID, "ok", "") a.updateExternalIMAPStatus(r.Context(), account.ID, "ok", "")
@@ -1499,7 +1499,7 @@ func (a *App) openExternalIMAPClient(ctx context.Context, account externalIMAPAc
} }
if err := c.Authenticate(newExternalIMAPXOAUTH2Client(account.Username, token)); err != nil { if err := c.Authenticate(newExternalIMAPXOAUTH2Client(account.Username, token)); err != nil {
c.Close() c.Close()
return nil, err return nil, fmt.Errorf("oauth xoauth2 authenticate failed: %w", err)
} }
} else { } else {
password, err := a.decryptExternalIMAPPassword(account.PasswordCiphertext) password, err := a.decryptExternalIMAPPassword(account.PasswordCiphertext)
@@ -1525,13 +1525,17 @@ func newExternalIMAPXOAUTH2Client(username, token string) externalIMAPXOAUTH2Cli
} }
func (c externalIMAPXOAUTH2Client) Start() (string, []byte, error) { func (c externalIMAPXOAUTH2Client) Start() (string, []byte, error) {
return "XOAUTH2", []byte("user=" + c.username + "\x01auth=Bearer " + c.token + "\x01\x01"), nil return "XOAUTH2", externalIMAPXOAUTH2Response(c.username, c.token), nil
} }
func (c externalIMAPXOAUTH2Client) Next(challenge []byte) ([]byte, error) { func (c externalIMAPXOAUTH2Client) Next(challenge []byte) ([]byte, error) {
return []byte{}, nil return []byte{}, nil
} }
func externalIMAPXOAUTH2Response(username, token string) []byte {
return []byte("user=" + username + "\x01auth=Bearer " + token + "\x01\x01")
}
func (a *App) externalIMAPOAuthAccessToken(ctx context.Context, account externalIMAPAccountRecord) (string, error) { func (a *App) externalIMAPOAuthAccessToken(ctx context.Context, account externalIMAPAccountRecord) (string, error) {
access, err := a.decryptExternalIMAPPassword(account.OAuthAccessTokenCiphertext) access, err := a.decryptExternalIMAPPassword(account.OAuthAccessTokenCiphertext)
if err != nil { if err != nil {
@@ -1590,7 +1594,20 @@ func (c *goExternalIMAPClient) Close() error {
} }
func (c *goExternalIMAPClient) ListFolders(ctx context.Context) ([]externalIMAPRemoteFolder, error) { func (c *goExternalIMAPClient) ListFolders(ctx context.Context) ([]externalIMAPRemoteFolder, error) {
list, err := c.client.List("", "*", &imap.ListOptions{ReturnStatus: &imap.StatusOptions{NumMessages: true, NumUnseen: true}}).Collect() caps := c.client.Caps()
var options *imap.ListOptions
if caps.Has(imap.CapIMAP4rev2) || caps.Has(imap.CapListStatus) {
options = &imap.ListOptions{ReturnStatus: &imap.StatusOptions{NumMessages: true, NumUnseen: true}}
}
list, err := c.client.List("", "*", options).Collect()
if err != nil && options != nil {
// Some Outlook/Exchange IMAP deployments advertise extended LIST
// capabilities but reject LIST RETURN (STATUS ...) with
// "BAD Command Argument Error. 12". Fall back to a plain LIST and
// fetch counts with STATUS separately.
options = nil
list, err = c.client.List("", "*", nil).Collect()
}
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -1613,6 +1630,21 @@ func (c *goExternalIMAPClient) ListFolders(ctx context.Context) ([]externalIMAPR
} }
folders = append(folders, f) folders = append(folders, f)
} }
if options == nil {
statusOptions := &imap.StatusOptions{NumMessages: true, NumUnseen: true}
for i := range folders {
status, err := c.client.Status(folders[i].Name, statusOptions).Wait()
if err != nil {
continue
}
if status.NumMessages != nil {
folders[i].TotalCount = int(*status.NumMessages)
}
if status.NumUnseen != nil {
folders[i].UnreadCount = int(*status.NumUnseen)
}
}
}
if len(folders) == 0 { if len(folders) == 0 {
folders = append(folders, externalIMAPRemoteFolder{Name: "INBOX", Role: "Inbox"}) folders = append(folders, externalIMAPRemoteFolder{Name: "INBOX", Role: "Inbox"})
} }