diff --git a/README.md b/README.md index 568449e..4b708b0 100644 --- a/README.md +++ b/README.md @@ -158,7 +158,7 @@ docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build | `LANQIN_EXTERNAL_IMAP_SYNC_SECONDS` | 外部 IMAP 本地存储模式同步间隔 | `300` | | `LANQIN_EXTERNAL_IMAP_ALLOW_PRIVATE_HOSTS` | 是否允许外部 IMAP 连接内网/localhost 主机 | `false` | | `LANQIN_EXTERNAL_IMAP_GMAIL_CLIENT_ID` / `LANQIN_EXTERNAL_IMAP_GMAIL_CLIENT_SECRET` | Gmail 外部 IMAP OAuth2,回调为 `/api/external-imap-oauth/gmail/callback` | 空 | -| `LANQIN_EXTERNAL_IMAP_OUTLOOK_CLIENT_ID` / `LANQIN_EXTERNAL_IMAP_OUTLOOK_CLIENT_SECRET` | Outlook 外部 IMAP OAuth2,回调为 `/api/external-imap-oauth/outlook/callback` | 空 | +| `LANQIN_EXTERNAL_IMAP_OUTLOOK_CLIENT_ID` / `LANQIN_EXTERNAL_IMAP_OUTLOOK_CLIENT_SECRET` | Microsoft 365 / Outlook 外部 IMAP OAuth2,回调为 `/api/external-imap-oauth/outlook/callback` | 空 | ## 架构 diff --git a/apps/api/internal/app/app_test.go b/apps/api/internal/app/app_test.go index e7206ed..dc84dc1 100644 --- a/apps/api/internal/app/app_test.go +++ b/apps/api/internal/app/app_test.go @@ -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{ diff --git a/apps/api/internal/app/external_imap.go b/apps/api/internal/app/external_imap.go index 1a8af34..77dcea8 100644 --- a/apps/api/internal/app/external_imap.go +++ b/apps/api/internal/app/external_imap.go @@ -418,9 +418,10 @@ func (a *App) handleStartExternalIMAPOAuth(w http.ResponseWriter, r *http.Reques respondError(w, http.StatusNotFound, "mailbox not found") return } - email := strings.TrimSpace(req.Email) - if email == "" { - email = mb.Address + email := normalizeEmail(req.Email) + if email != "" && !strings.Contains(email, "@") { + badRequest(w, errors.New("invalid oauth email")) + return } req.StorageMode = strings.ToLower(strings.TrimSpace(req.StorageMode)) if req.StorageMode == "" { @@ -511,9 +512,18 @@ func (a *App) handleExternalIMAPOAuthCallback(w http.ResponseWriter, r *http.Req if !token.Expiry.IsZero() { expiry = token.Expiry.UTC().Format(time.RFC3339Nano) } + authorizedEmail, err := externalIMAPOAuthEmail(provider, token) + if err != nil { + respondError(w, http.StatusBadRequest, err.Error()) + return + } + if state.Email != "" && !strings.EqualFold(normalizeEmail(state.Email), authorizedEmail) { + respondError(w, http.StatusBadRequest, "oauth account email does not match requested external email") + return + } id := newID("ximap") _, err = a.db.ExecContext(r.Context(), `INSERT INTO external_imap_accounts(id,user_id,mailbox_id,name,host,port,tls_mode,username,password_ciphertext,auth_mode,oauth_provider,oauth_email,oauth_access_token_ciphertext,oauth_refresh_token_ciphertext,oauth_expiry,storage_mode,sync_read_state,enabled,last_status,created_at,updated_at) - VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, id, state.UserID, state.MailboxID, name, profile.Host, profile.Port, externalIMAPTLS, state.Email, "", externalIMAPAuthOAuth2, provider, state.Email, accessCipher, refreshCipher, expiry, state.StorageMode, boolInt(state.SyncReadState), boolInt(state.Enabled), "idle", now, now) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, id, state.UserID, state.MailboxID, name, profile.Host, profile.Port, externalIMAPTLS, authorizedEmail, "", externalIMAPAuthOAuth2, provider, authorizedEmail, accessCipher, refreshCipher, expiry, state.StorageMode, boolInt(state.SyncReadState), boolInt(state.Enabled), "idle", now, now) if err != nil { respondError(w, http.StatusInternalServerError, "failed to save oauth account") return @@ -871,7 +881,7 @@ func (a *App) externalIMAPOAuthConfig(provider string) (*oauth2.Config, external ClientID: a.cfg.ExternalIMAPGmailClientID, ClientSecret: a.cfg.ExternalIMAPGmailClientSecret, RedirectURL: callback, - Scopes: []string{"https://mail.google.com/"}, + Scopes: []string{"openid", "email", "profile", "https://mail.google.com/"}, Endpoint: oauth2.Endpoint{ AuthURL: "https://accounts.google.com/o/oauth2/v2/auth", TokenURL: "https://oauth2.googleapis.com/token", @@ -885,7 +895,7 @@ func (a *App) externalIMAPOAuthConfig(provider string) (*oauth2.Config, external ClientID: a.cfg.ExternalIMAPOutlookClientID, ClientSecret: a.cfg.ExternalIMAPOutlookClientSecret, RedirectURL: callback, - Scopes: []string{"offline_access", "https://outlook.office.com/IMAP.AccessAsUser.All"}, + Scopes: []string{"openid", "email", "profile", "offline_access", "https://outlook.office.com/IMAP.AccessAsUser.All"}, Endpoint: oauth2.Endpoint{ AuthURL: "https://login.microsoftonline.com/common/oauth2/v2.0/authorize", TokenURL: "https://login.microsoftonline.com/common/oauth2/v2.0/token", @@ -896,6 +906,45 @@ func (a *App) externalIMAPOAuthConfig(provider string) (*oauth2.Config, external } } +func externalIMAPOAuthEmail(provider string, token *oauth2.Token) (string, error) { + if token == nil { + return "", errors.New("oauth token is missing") + } + idToken, _ := token.Extra("id_token").(string) + claims, err := externalIMAPOIDCClaims(idToken) + if err != nil { + return "", err + } + fields := []string{"email"} + if provider == externalIMAPOAuthOutlook { + fields = []string{"email", "preferred_username", "upn"} + } + for _, field := range fields { + value, _ := claims[field].(string) + email := normalizeEmail(value) + if email != "" && strings.Contains(email, "@") { + return email, nil + } + } + return "", errors.New("oauth provider did not return an email address") +} + +func externalIMAPOIDCClaims(idToken string) (map[string]any, error) { + parts := strings.Split(idToken, ".") + if len(parts) < 2 { + return nil, errors.New("oauth provider did not return an id token") + } + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return nil, errors.New("invalid oauth id token") + } + var claims map[string]any + if err := json.Unmarshal(payload, &claims); err != nil { + return nil, errors.New("invalid oauth id token") + } + return claims, nil +} + func (a *App) encryptExternalIMAPOAuthState(state externalIMAPOAuthState) (string, error) { raw, err := json.Marshal(state) if err != nil { diff --git a/apps/web/src/lib/api-types.ts b/apps/web/src/lib/api-types.ts index 263817b..34aee97 100644 --- a/apps/web/src/lib/api-types.ts +++ b/apps/web/src/lib/api-types.ts @@ -125,7 +125,8 @@ export type BlockedSender = { id: string; mailboxId: string; email: string; reas export type MailStats = { totalMessages: number; unreadMessages: number; starredMessages: number; attachmentCount: number; attachmentBytes: number; storageBytes: number; quotaBytes: number; quotaUsedPct: number; byFolder: { folder: string; role: string; count: number; unread: number; bytes: number }[] } export type ExternalImapStorageMode = "local" | "remote" export type ExternalImapTlsMode = "tls" | "starttls" | "plain" -export type ExternalImapAccount = { id: string; mailboxId: string; name: string; host: string; port: number; tlsMode: ExternalImapTlsMode; username: string; storageMode: ExternalImapStorageMode; syncReadState: boolean; enabled: boolean; lastSyncAt?: string; lastStatus: string; lastError?: string; createdAt: string; updatedAt: string } +export type ExternalImapAuthMode = "password" | "oauth2" +export type ExternalImapAccount = { id: string; mailboxId: string; name: string; host: string; port: number; tlsMode: ExternalImapTlsMode; username: string; authMode: ExternalImapAuthMode; oauthProvider?: ExternalImapOAuthProvider; oauthEmail?: string; oauthConfigured?: boolean; storageMode: ExternalImapStorageMode; syncReadState: boolean; enabled: boolean; lastSyncAt?: string; lastStatus: string; lastError?: string; createdAt: string; updatedAt: string } export type ExternalImapAccountPayload = { mailboxId: string; name: string; host: string; port: number; tlsMode: ExternalImapTlsMode; username: string; password?: string; storageMode: ExternalImapStorageMode; syncReadState: boolean; enabled: boolean } export type ExternalImapOAuthProvider = "gmail" | "outlook" export type ExternalImapOAuthStartPayload = { mailboxId: string; name?: string; email?: string; storageMode: ExternalImapStorageMode; syncReadState: boolean; enabled: boolean } diff --git a/apps/web/src/pages/profile.tsx b/apps/web/src/pages/profile.tsx index 7a0f96c..544e2b1 100644 --- a/apps/web/src/pages/profile.tsx +++ b/apps/web/src/pages/profile.tsx @@ -239,7 +239,7 @@ export function ProfilePage() { onError: (error) => toast({ title: "同步失败", description: error.message }), }) const startExternalOAuth = useMutation({ - mutationFn: ({ provider, mailboxId }: { provider: ExternalImapOAuthProvider; mailboxId: string }) => api.startExternalImapOAuth(provider, { mailboxId, storageMode: "local", syncReadState: true, enabled: true }), + mutationFn: ({ provider, mailboxId, email, storageMode }: { provider: ExternalImapOAuthProvider; mailboxId: string; email: string; storageMode: ExternalImapStorageMode }) => api.startExternalImapOAuth(provider, { mailboxId, email, storageMode, syncReadState: true, enabled: true }), onSuccess: (res) => { window.location.href = res.url }, onError: (error) => toast({ title: "授权失败", description: error.message }), }) @@ -353,7 +353,7 @@ export function ProfilePage() { onOpen={(id) => { if (!canAccessMail) return; setMailboxId(id); navigate("/") }} onApply={(payload) => applyMailbox.mutateAsync(payload).then(() => undefined)} onCreateExternal={(payload) => createExternalImap.mutate(payload)} - onStartExternalOAuth={(provider, mailboxId) => startExternalOAuth.mutate({ provider, mailboxId })} + onStartExternalOAuth={(provider, payload) => startExternalOAuth.mutate({ provider, ...payload })} onUpdateExternal={(id, payload) => updateExternalImap.mutate({ id, payload })} onDeleteExternal={(id) => deleteExternalImap.mutate(id)} onTestExternal={(id) => testExternalImap.mutate(id)} @@ -592,7 +592,7 @@ function MailboxManagement({ onOpen: (id: string) => void onApply: (payload: { domainId: string; localPart: string; displayName: string }) => Promise onCreateExternal: (payload: ExternalImapAccountPayload) => void - onStartExternalOAuth: (provider: ExternalImapOAuthProvider, mailboxId: string) => void + onStartExternalOAuth: (provider: ExternalImapOAuthProvider, payload: { mailboxId: string; email: string; storageMode: ExternalImapStorageMode }) => void onUpdateExternal: (id: string, payload: ExternalImapAccountPayload) => void onDeleteExternal: (id: string) => void onTestExternal: (id: string) => void @@ -618,8 +618,8 @@ function MailboxManagement({
接入其他邮箱,可选择同步到本地,或每次打开时直接从远端读取。
- - + +
@@ -638,7 +638,7 @@ function MailboxManagement({ {account.enabled ? "已启用" : "已停用"} {account.storageMode === "local" ? "本地存储" : "远端直连"} -
{account.username} · {account.host}:{account.port} · {account.tlsMode.toUpperCase()}
+
{account.username} · {account.host}:{account.port} · {account.tlsMode.toUpperCase()}{account.authMode === "oauth2" ? ` · ${externalOAuthProviderLabel(account.oauthProvider)}` : ""}
状态:{externalStatusLabel(account.lastStatus)}{account.lastSyncAt ? ` · 最近同步 ${formatDateTime(account.lastSyncAt)}` : ""}{account.lastError ? ` · ${account.lastError}` : ""}
@@ -706,6 +706,50 @@ function ApplyMailboxDialog({ options, pending, onApply }: { options: MailboxApp ) } + +function ExternalImapOAuthDialog({ provider, selectedMailbox, disabled, pending, onStart }: { provider: ExternalImapOAuthProvider; selectedMailbox?: Mailbox; disabled?: boolean; pending: boolean; onStart: (provider: ExternalImapOAuthProvider, payload: { mailboxId: string; email: string; storageMode: ExternalImapStorageMode }) => void }) { + const [open, setOpen] = React.useState(false) + const [storageMode, setStorageMode] = React.useState("local") + const label = provider === "gmail" ? "Gmail OAuth" : "Microsoft 365 / Outlook OAuth" + + function submit(event: React.FormEvent) { + event.preventDefault() + if (!selectedMailbox) return + const form = new FormData(event.currentTarget) + onStart(provider, { + mailboxId: selectedMailbox.id, + email: String(form.get("email") || ""), + storageMode, + }) + } + + return ( + + + + {label} +
+
+ OAuth 只适用于 {provider === "gmail" ? "Google Gmail" : "Microsoft 365 / Outlook / Exchange Online"} 托管邮箱。自建域名邮箱请使用“添加外部邮箱”的普通 IMAP 方式。 +
+ +
留空时会以 OAuth 服务商返回的真实授权邮箱为准;填写后,回调时会校验它和真实授权邮箱一致。
+ + + + + + + +
+
+
+ ) +} + function ExternalImapDialog({ account, mailboxId, disabled, pending, onSubmit }: { account?: ExternalImapAccount; mailboxId: string; disabled?: boolean; pending: boolean; onSubmit: (payload: ExternalImapAccountPayload) => void }) { const [open, setOpen] = React.useState(false) const [tlsMode, setTlsMode] = React.useState(account?.tlsMode || "tls") @@ -788,6 +832,10 @@ function externalStatusLabel(status: string) { return ({ idle: "未同步", ok: "正常", partial: "部分成功", error: "错误", running: "同步中" } as Record)[status] || status || "未知" } +function externalOAuthProviderLabel(provider?: ExternalImapOAuthProvider) { + return provider === "gmail" ? "Gmail OAuth" : provider === "outlook" ? "Microsoft 365 / Outlook OAuth" : "OAuth" +} + function ExternalImapSyncPanel({ account, folders, runs, pending, onSyncFolder }: { account: ExternalImapAccount; folders: ExternalImapFolder[]; runs: ExternalImapSyncRun[]; pending: boolean; onSyncFolder: (id: string, folder: string) => void }) { const [folder, setFolder] = React.useState("") React.useEffect(() => { diff --git a/deploy/.env.example b/deploy/.env.example index 5de7bc1..032806d 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -145,7 +145,7 @@ LANQIN_EXTERNAL_IMAP_ALLOW_PRIVATE_HOSTS=false LANQIN_EXTERNAL_IMAP_GMAIL_CLIENT_ID= LANQIN_EXTERNAL_IMAP_GMAIL_CLIENT_SECRET= -# Outlook 外部 IMAP OAuth2。回调地址:${LANQIN_PUBLIC_BASE_URL}/api/external-imap-oauth/outlook/callback +# Microsoft 365 / Outlook 外部 IMAP OAuth2。回调地址:${LANQIN_PUBLIC_BASE_URL}/api/external-imap-oauth/outlook/callback LANQIN_EXTERNAL_IMAP_OUTLOOK_CLIENT_ID= LANQIN_EXTERNAL_IMAP_OUTLOOK_CLIENT_SECRET= diff --git a/deploy/README.md b/deploy/README.md index 7b84760..2a4772b 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -129,7 +129,7 @@ docker compose -f docker-compose.stack.yml -f docker-compose.stack.build.yml up - Go API 是 Webmail 和管理后台入口;浏览器不直接连接 SMTP/IMAP/POP3。 - Go API 会读取 `LANQIN_MAILDIR_ROOT=/var/mail/vhosts`,周期扫描 Maildir,把 Postfix/Dovecot 入站邮件同步成 Webmail 索引。 - 第三方客户端可通过 LanQin API 提供的 SMTP `465/587` 发信;Webmail/API 和第三方客户端的“已发送”都由 API 写入,外发投递进入发送队列并由 API worker relay/retry,客户端后续 IMAP APPEND 到 Sent 会按 `Message-ID` 去重。 -- 用户可在个人邮箱管理中接入外部 IMAP 账号;本地存储模式会同步到 LanQin,远端直连模式每次从远端读取。启用前必须配置 `LANQIN_EXTERNAL_IMAP_SECRET_KEY`,默认不允许连接 localhost / 内网 / link-local IMAP 主机。Gmail / Outlook OAuth2 需要在对应控制台配置回调地址:`/api/external-imap-oauth/gmail/callback` 或 `/api/external-imap-oauth/outlook/callback`。 +- 用户可在个人邮箱管理中接入外部 IMAP 账号;本地存储模式会同步到 LanQin,远端直连模式每次从远端读取。启用前必须配置 `LANQIN_EXTERNAL_IMAP_SECRET_KEY`,默认不允许连接 localhost / 内网 / link-local IMAP 主机。Gmail / Microsoft 365 / Outlook OAuth2 需要在对应控制台配置回调地址:`/api/external-imap-oauth/gmail/callback` 或 `/api/external-imap-oauth/outlook/callback`。 - send-as v1 支持本人邮箱、启用的别名转发 source 指向本人邮箱,或数据库表 `send_as_grants` 中显式授权的地址。 ## 邮件客户端 TLS 证书