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
+1 -1
View File
@@ -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_SYNC_SECONDS` | 外部 IMAP 本地存储模式同步间隔 | `300` |
| `LANQIN_EXTERNAL_IMAP_ALLOW_PRIVATE_HOSTS` | 是否允许外部 IMAP 连接内网/localhost 主机 | `false` | | `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_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` | 空 |
## 架构 ## 架构
+97
View File
@@ -31,6 +31,7 @@ import (
"github.com/emersion/go-sasl" "github.com/emersion/go-sasl"
smtpclient "github.com/emersion/go-smtp" smtpclient "github.com/emersion/go-smtp"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
"golang.org/x/oauth2"
) )
func newTestApp(t *testing.T) *App { 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) { func TestExternalIMAPAccountOwnershipIsolation(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
a := newTestAppWithConfig(t, Config{ a := newTestAppWithConfig(t, Config{
+55 -6
View File
@@ -418,9 +418,10 @@ func (a *App) handleStartExternalIMAPOAuth(w http.ResponseWriter, r *http.Reques
respondError(w, http.StatusNotFound, "mailbox not found") respondError(w, http.StatusNotFound, "mailbox not found")
return return
} }
email := strings.TrimSpace(req.Email) email := normalizeEmail(req.Email)
if email == "" { if email != "" && !strings.Contains(email, "@") {
email = mb.Address badRequest(w, errors.New("invalid oauth email"))
return
} }
req.StorageMode = strings.ToLower(strings.TrimSpace(req.StorageMode)) req.StorageMode = strings.ToLower(strings.TrimSpace(req.StorageMode))
if req.StorageMode == "" { if req.StorageMode == "" {
@@ -511,9 +512,18 @@ func (a *App) handleExternalIMAPOAuthCallback(w http.ResponseWriter, r *http.Req
if !token.Expiry.IsZero() { if !token.Expiry.IsZero() {
expiry = token.Expiry.UTC().Format(time.RFC3339Nano) 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") 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) _, 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 { if err != nil {
respondError(w, http.StatusInternalServerError, "failed to save oauth account") respondError(w, http.StatusInternalServerError, "failed to save oauth account")
return return
@@ -871,7 +881,7 @@ func (a *App) externalIMAPOAuthConfig(provider string) (*oauth2.Config, external
ClientID: a.cfg.ExternalIMAPGmailClientID, ClientID: a.cfg.ExternalIMAPGmailClientID,
ClientSecret: a.cfg.ExternalIMAPGmailClientSecret, ClientSecret: a.cfg.ExternalIMAPGmailClientSecret,
RedirectURL: callback, RedirectURL: callback,
Scopes: []string{"https://mail.google.com/"}, Scopes: []string{"openid", "email", "profile", "https://mail.google.com/"},
Endpoint: oauth2.Endpoint{ Endpoint: oauth2.Endpoint{
AuthURL: "https://accounts.google.com/o/oauth2/v2/auth", AuthURL: "https://accounts.google.com/o/oauth2/v2/auth",
TokenURL: "https://oauth2.googleapis.com/token", TokenURL: "https://oauth2.googleapis.com/token",
@@ -885,7 +895,7 @@ func (a *App) externalIMAPOAuthConfig(provider string) (*oauth2.Config, external
ClientID: a.cfg.ExternalIMAPOutlookClientID, ClientID: a.cfg.ExternalIMAPOutlookClientID,
ClientSecret: a.cfg.ExternalIMAPOutlookClientSecret, ClientSecret: a.cfg.ExternalIMAPOutlookClientSecret,
RedirectURL: callback, 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{ Endpoint: oauth2.Endpoint{
AuthURL: "https://login.microsoftonline.com/common/oauth2/v2.0/authorize", AuthURL: "https://login.microsoftonline.com/common/oauth2/v2.0/authorize",
TokenURL: "https://login.microsoftonline.com/common/oauth2/v2.0/token", 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) { func (a *App) encryptExternalIMAPOAuthState(state externalIMAPOAuthState) (string, error) {
raw, err := json.Marshal(state) raw, err := json.Marshal(state)
if err != nil { if err != nil {
+2 -1
View File
@@ -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 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 ExternalImapStorageMode = "local" | "remote"
export type ExternalImapTlsMode = "tls" | "starttls" | "plain" 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 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 ExternalImapOAuthProvider = "gmail" | "outlook"
export type ExternalImapOAuthStartPayload = { mailboxId: string; name?: string; email?: string; storageMode: ExternalImapStorageMode; syncReadState: boolean; enabled: boolean } export type ExternalImapOAuthStartPayload = { mailboxId: string; name?: string; email?: string; storageMode: ExternalImapStorageMode; syncReadState: boolean; enabled: boolean }
+54 -6
View File
@@ -239,7 +239,7 @@ export function ProfilePage() {
onError: (error) => toast({ title: "同步失败", description: error.message }), onError: (error) => toast({ title: "同步失败", description: error.message }),
}) })
const startExternalOAuth = useMutation({ 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 }, onSuccess: (res) => { window.location.href = res.url },
onError: (error) => toast({ title: "授权失败", description: error.message }), onError: (error) => toast({ title: "授权失败", description: error.message }),
}) })
@@ -353,7 +353,7 @@ export function ProfilePage() {
onOpen={(id) => { if (!canAccessMail) return; setMailboxId(id); navigate("/") }} onOpen={(id) => { if (!canAccessMail) return; setMailboxId(id); navigate("/") }}
onApply={(payload) => applyMailbox.mutateAsync(payload).then(() => undefined)} onApply={(payload) => applyMailbox.mutateAsync(payload).then(() => undefined)}
onCreateExternal={(payload) => createExternalImap.mutate(payload)} 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 })} onUpdateExternal={(id, payload) => updateExternalImap.mutate({ id, payload })}
onDeleteExternal={(id) => deleteExternalImap.mutate(id)} onDeleteExternal={(id) => deleteExternalImap.mutate(id)}
onTestExternal={(id) => testExternalImap.mutate(id)} onTestExternal={(id) => testExternalImap.mutate(id)}
@@ -592,7 +592,7 @@ function MailboxManagement({
onOpen: (id: string) => void onOpen: (id: string) => void
onApply: (payload: { domainId: string; localPart: string; displayName: string }) => Promise<void> onApply: (payload: { domainId: string; localPart: string; displayName: string }) => Promise<void>
onCreateExternal: (payload: ExternalImapAccountPayload) => void 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 onUpdateExternal: (id: string, payload: ExternalImapAccountPayload) => void
onDeleteExternal: (id: string) => void onDeleteExternal: (id: string) => void
onTestExternal: (id: string) => void onTestExternal: (id: string) => void
@@ -618,8 +618,8 @@ function MailboxManagement({
<div className="mt-1 text-sm text-muted-foreground"></div> <div className="mt-1 text-sm text-muted-foreground"></div>
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Button type="button" variant="outline" disabled={!selectedMailbox || externalPending} onClick={() => onStartExternalOAuth("gmail", selectedMailboxId)}>Gmail OAuth</Button> <ExternalImapOAuthDialog provider="gmail" selectedMailbox={selectedMailbox} disabled={!selectedMailbox} pending={externalPending} onStart={onStartExternalOAuth} />
<Button type="button" variant="outline" disabled={!selectedMailbox || externalPending} onClick={() => onStartExternalOAuth("outlook", selectedMailboxId)}>Outlook OAuth</Button> <ExternalImapOAuthDialog provider="outlook" selectedMailbox={selectedMailbox} disabled={!selectedMailbox} pending={externalPending} onStart={onStartExternalOAuth} />
<ExternalImapDialog mailboxId={selectedMailboxId} disabled={!selectedMailbox} pending={externalPending} onSubmit={onCreateExternal} /> <ExternalImapDialog mailboxId={selectedMailboxId} disabled={!selectedMailbox} pending={externalPending} onSubmit={onCreateExternal} />
</div> </div>
</div> </div>
@@ -638,7 +638,7 @@ function MailboxManagement({
<Badge variant={account.enabled ? "secondary" : "outline"}>{account.enabled ? "已启用" : "已停用"}</Badge> <Badge variant={account.enabled ? "secondary" : "outline"}>{account.enabled ? "已启用" : "已停用"}</Badge>
<Badge variant="outline">{account.storageMode === "local" ? "本地存储" : "远端直连"}</Badge> <Badge variant="outline">{account.storageMode === "local" ? "本地存储" : "远端直连"}</Badge>
</div> </div>
<div className="mt-1 truncate text-xs text-muted-foreground">{account.username} · {account.host}:{account.port} · {account.tlsMode.toUpperCase()}</div> <div className="mt-1 truncate text-xs text-muted-foreground">{account.username} · {account.host}:{account.port} · {account.tlsMode.toUpperCase()}{account.authMode === "oauth2" ? ` · ${externalOAuthProviderLabel(account.oauthProvider)}` : ""}</div>
<div className="mt-1 text-xs text-muted-foreground">{externalStatusLabel(account.lastStatus)}{account.lastSyncAt ? ` · 最近同步 ${formatDateTime(account.lastSyncAt)}` : ""}{account.lastError ? ` · ${account.lastError}` : ""}</div> <div className="mt-1 text-xs text-muted-foreground">{externalStatusLabel(account.lastStatus)}{account.lastSyncAt ? ` · 最近同步 ${formatDateTime(account.lastSyncAt)}` : ""}{account.lastError ? ` · ${account.lastError}` : ""}</div>
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
@@ -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<ExternalImapStorageMode>("local")
const label = provider === "gmail" ? "Gmail OAuth" : "Microsoft 365 / Outlook OAuth"
function submit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
if (!selectedMailbox) return
const form = new FormData(event.currentTarget)
onStart(provider, {
mailboxId: selectedMailbox.id,
email: String(form.get("email") || ""),
storageMode,
})
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<Button type="button" variant="outline" disabled={disabled || pending} onClick={() => setOpen(true)}>{label}</Button>
<DialogContent className="max-h-[92dvh] overflow-y-auto sm:max-w-lg">
<DialogHeader><DialogTitle>{label}</DialogTitle></DialogHeader>
<form className="space-y-4" onSubmit={submit}>
<div className="rounded-lg border bg-muted/30 p-3 text-sm text-muted-foreground">
OAuth {provider === "gmail" ? "Google Gmail" : "Microsoft 365 / Outlook / Exchange Online"} 使 IMAP
</div>
<Field label="外部邮箱地址(可选)"><Input name="email" type="email" placeholder={selectedMailbox?.address || "name@example.com"} /></Field>
<div className="text-xs text-muted-foreground"> OAuth </div>
<Field label="存储模式">
<Select value={storageMode} onValueChange={(value) => setStorageMode(value as ExternalImapStorageMode)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent><SelectItem value="local"></SelectItem><SelectItem value="remote"></SelectItem></SelectContent>
</Select>
</Field>
<DialogFooter className="gap-2 [&>button]:w-full sm:[&>button]:w-auto">
<Button type="button" variant="outline" onClick={() => setOpen(false)}></Button>
<Button disabled={pending || !selectedMailbox}>{pending ? "跳转中..." : "前往授权"}</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
function ExternalImapDialog({ account, mailboxId, disabled, pending, onSubmit }: { account?: ExternalImapAccount; mailboxId: string; disabled?: boolean; pending: boolean; onSubmit: (payload: ExternalImapAccountPayload) => void }) { 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 [open, setOpen] = React.useState(false)
const [tlsMode, setTlsMode] = React.useState<ExternalImapTlsMode>(account?.tlsMode || "tls") const [tlsMode, setTlsMode] = React.useState<ExternalImapTlsMode>(account?.tlsMode || "tls")
@@ -788,6 +832,10 @@ function externalStatusLabel(status: string) {
return ({ idle: "未同步", ok: "正常", partial: "部分成功", error: "错误", running: "同步中" } as Record<string, string>)[status] || status || "未知" return ({ idle: "未同步", ok: "正常", partial: "部分成功", error: "错误", running: "同步中" } as Record<string, string>)[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 }) { 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("") const [folder, setFolder] = React.useState("")
React.useEffect(() => { React.useEffect(() => {
+1 -1
View File
@@ -145,7 +145,7 @@ LANQIN_EXTERNAL_IMAP_ALLOW_PRIVATE_HOSTS=false
LANQIN_EXTERNAL_IMAP_GMAIL_CLIENT_ID= LANQIN_EXTERNAL_IMAP_GMAIL_CLIENT_ID=
LANQIN_EXTERNAL_IMAP_GMAIL_CLIENT_SECRET= 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_ID=
LANQIN_EXTERNAL_IMAP_OUTLOOK_CLIENT_SECRET= LANQIN_EXTERNAL_IMAP_OUTLOOK_CLIENT_SECRET=
+1 -1
View File
@@ -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 是 Webmail 和管理后台入口;浏览器不直接连接 SMTP/IMAP/POP3。
- Go API 会读取 `LANQIN_MAILDIR_ROOT=/var/mail/vhosts`,周期扫描 Maildir,把 Postfix/Dovecot 入站邮件同步成 Webmail 索引。 - 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` 去重。 - 第三方客户端可通过 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` 中显式授权的地址。 - send-as v1 支持本人邮箱、启用的别名转发 source 指向本人邮箱,或数据库表 `send_as_grants` 中显式授权的地址。
## 邮件客户端 TLS 证书 ## 邮件客户端 TLS 证书