feat(external_imap): 强化 OAuth 外部邮箱授权校验并完善 Outlook 文案

- OAuth 授权改为以服务商返回的真实邮箱为准,补充 ID Token 解析与邮箱一致性校验,避免默认落到本地邮箱。
- 为 Gmail 和 Microsoft 365 / Outlook OAuth 增加必要的 OIDC scope,并补充相关测试覆盖。
- Web 端新增 OAuth 授权弹窗,支持填写外部邮箱与存储模式;同步更新账号展示信息。
- 同步修正 README 与部署说明中的 Outlook 表述。
This commit is contained in:
LanQin
2026-06-25 21:17:04 +08:00
parent 39f5249008
commit 081cfad02d
7 changed files with 211 additions and 16 deletions
+97
View File
@@ -31,6 +31,7 @@ import (
"github.com/emersion/go-sasl"
smtpclient "github.com/emersion/go-smtp"
"golang.org/x/crypto/bcrypt"
"golang.org/x/oauth2"
)
func newTestApp(t *testing.T) *App {
@@ -503,6 +504,102 @@ func TestExternalIMAPRejectsPrivateHostsByDefault(t *testing.T) {
}
}
func TestExternalIMAPOAuthStateDoesNotDefaultToLocalMailbox(t *testing.T) {
dir := t.TempDir()
a := newTestAppWithConfig(t, Config{
Addr: ":0",
DBPath: filepath.Join(dir, "lanqin.db"),
DataDir: filepath.Join(dir, "data"),
CookieName: "lanqin_test",
SessionTTLHours: 24,
AdminEmail: "admin@lanqin.local",
AdminPassword: "ChangeMe123!",
PublicHostname: "mail.example.test",
PublicBaseURL: "http://localhost:5173",
AllowInsecureHTTP: true,
ExternalIMAPSecretKey: "test-secret",
ExternalIMAPOutlookClientID: "client-id",
ExternalIMAPOutlookClientSecret: "client-secret",
})
ts := httptest.NewServer(a.Router())
defer ts.Close()
admin := &testClient{t: t, server: ts}
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, nil); code != http.StatusOK {
t.Fatalf("login code=%d", code)
}
_, mb := defaultAdminUserAndMailbox(t, a)
var start struct {
URL string `json:"url"`
}
if code := admin.do("POST", "/api/me/external-imap-oauth/outlook/start", map[string]any{"mailboxId": mb.ID, "storageMode": "local", "syncReadState": true, "enabled": true}, &start); code != http.StatusOK {
t.Fatalf("start oauth code=%d url=%q", code, start.URL)
}
stateValue := mustOAuthStateFromURL(t, start.URL)
state, err := a.decryptExternalIMAPOAuthState(stateValue)
if err != nil {
t.Fatal(err)
}
if state.Email != "" {
t.Fatalf("oauth state defaulted to local mailbox email: %q", state.Email)
}
if code := admin.do("POST", "/api/me/external-imap-oauth/outlook/start", map[string]any{"mailboxId": mb.ID, "email": "User@Example.COM", "storageMode": "remote", "syncReadState": true, "enabled": true}, &start); code != http.StatusOK {
t.Fatalf("start oauth with email code=%d url=%q", code, start.URL)
}
stateValue = mustOAuthStateFromURL(t, start.URL)
state, err = a.decryptExternalIMAPOAuthState(stateValue)
if err != nil {
t.Fatal(err)
}
if state.Email != "user@example.com" {
t.Fatalf("oauth state did not preserve requested external email, got %q", state.Email)
}
}
func TestExternalIMAPOAuthEmailFromIDToken(t *testing.T) {
token := (&oauth2.Token{AccessToken: "access"}).WithExtra(map[string]any{
"id_token": testIDToken(map[string]any{"preferred_username": "User@Example.COM"}),
})
email, err := externalIMAPOAuthEmail(externalIMAPOAuthOutlook, token)
if err != nil {
t.Fatal(err)
}
if email != "user@example.com" {
t.Fatalf("unexpected outlook oauth email %q", email)
}
token = (&oauth2.Token{AccessToken: "access"}).WithExtra(map[string]any{
"id_token": testIDToken(map[string]any{"email": "Person@Gmail.COM"}),
})
email, err = externalIMAPOAuthEmail(externalIMAPOAuthGmail, token)
if err != nil {
t.Fatal(err)
}
if email != "person@gmail.com" {
t.Fatalf("unexpected gmail oauth email %q", email)
}
}
func mustOAuthStateFromURL(t *testing.T, rawURL string) string {
t.Helper()
u, err := url.Parse(rawURL)
if err != nil {
t.Fatal(err)
}
state := u.Query().Get("state")
if state == "" {
t.Fatalf("oauth url missing state: %s", rawURL)
}
return state
}
func testIDToken(claims map[string]any) string {
header, _ := json.Marshal(map[string]any{"alg": "none"})
payload, _ := json.Marshal(claims)
return base64.RawURLEncoding.EncodeToString(header) + "." + base64.RawURLEncoding.EncodeToString(payload) + "."
}
func TestExternalIMAPAccountOwnershipIsolation(t *testing.T) {
dir := t.TempDir()
a := newTestAppWithConfig(t, Config{