diff --git a/README.md b/README.md index c758d23..568449e 100644 --- a/README.md +++ b/README.md @@ -157,6 +157,8 @@ docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build | `LANQIN_EXTERNAL_IMAP_SECRET_KEY` | 外部 IMAP 密码加密密钥,启用接入前必须设置 | 随机长字符串 | | `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` | 空 | ## 架构 diff --git a/apps/api/go.mod b/apps/api/go.mod index f74d7c0..62fda36 100644 --- a/apps/api/go.mod +++ b/apps/api/go.mod @@ -24,6 +24,7 @@ require ( github.com/ncruces/go-strftime v0.1.9 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect golang.org/x/net v0.26.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sys v0.23.0 // indirect modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect modernc.org/libc v1.55.3 // indirect diff --git a/apps/api/go.sum b/apps/api/go.sum index 62a956a..1f7e27c 100644 --- a/apps/api/go.sum +++ b/apps/api/go.sum @@ -45,6 +45,8 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ= golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= diff --git a/apps/api/internal/app/app.go b/apps/api/internal/app/app.go index ce94504..80bfc72 100644 --- a/apps/api/internal/app/app.go +++ b/apps/api/internal/app/app.go @@ -357,6 +357,12 @@ func (a *App) migrate(ctx context.Context) error { tls_mode TEXT NOT NULL CHECK(tls_mode IN ('tls','starttls','plain')), username TEXT NOT NULL, password_ciphertext TEXT NOT NULL, + auth_mode TEXT NOT NULL DEFAULT 'password' CHECK(auth_mode IN ('password','oauth2')), + oauth_provider TEXT NOT NULL DEFAULT '', + oauth_email TEXT NOT NULL DEFAULT '', + oauth_access_token_ciphertext TEXT NOT NULL DEFAULT '', + oauth_refresh_token_ciphertext TEXT NOT NULL DEFAULT '', + oauth_expiry TEXT, storage_mode TEXT NOT NULL DEFAULT 'local' CHECK(storage_mode IN ('local','remote')), sync_read_state INTEGER NOT NULL DEFAULT 1, enabled INTEGER NOT NULL DEFAULT 1, @@ -395,6 +401,7 @@ func (a *App) migrate(ctx context.Context) error { `CREATE TABLE IF NOT EXISTS external_imap_sync_runs ( id TEXT PRIMARY KEY, account_id TEXT NOT NULL REFERENCES external_imap_accounts(id) ON DELETE CASCADE, + folder TEXT NOT NULL DEFAULT '', status TEXT NOT NULL, imported INTEGER NOT NULL DEFAULT 0, skipped INTEGER NOT NULL DEFAULT 0, @@ -512,6 +519,9 @@ func (a *App) migrate(ctx context.Context) error { if err := a.migrateFolderSortOrder(ctx); err != nil { return err } + if err := a.migrateExternalIMAP(ctx); err != nil { + return err + } if err := a.ensureDefaultPermissionGroups(ctx); err != nil { return err } diff --git a/apps/api/internal/app/config.go b/apps/api/internal/app/config.go index f08c373..a1f38e6 100644 --- a/apps/api/internal/app/config.go +++ b/apps/api/internal/app/config.go @@ -8,83 +8,91 @@ import ( ) type Config struct { - Addr string - DBPath string - DataDir string - CookieName string - SessionTTLHours int - AdminEmail string - AdminPassword string - PublicHostname string - PublicBaseURL string - SMTPHost string - SMTPPort string - SMTPUsername string - SMTPPassword string - SMTPRequireTLS bool - SubmissionAddr string - SubmissionTLSAddr string - SubmissionMaxMessageMB int - TLSCertFile string - TLSKeyFile string - MaildirRoot string - MaildirScanSeconds int - AllowInsecureHTTP bool - OpenRegistration bool - TwoFactorEnabled bool - TurnstileEnabled bool - TurnstileSiteKey string - TurnstileSecretKey string - CatchAllEnabled bool - MailAutoRefresh bool - MailRefreshSeconds int - UserMailboxApplyEnabled bool - UserMailboxDomainIDs string - ReservedMailboxPrefixes string - ExternalIMAPSecretKey string - ExternalIMAPSyncSeconds int - ExternalIMAPAllowPrivateHosts bool + Addr string + DBPath string + DataDir string + CookieName string + SessionTTLHours int + AdminEmail string + AdminPassword string + PublicHostname string + PublicBaseURL string + SMTPHost string + SMTPPort string + SMTPUsername string + SMTPPassword string + SMTPRequireTLS bool + SubmissionAddr string + SubmissionTLSAddr string + SubmissionMaxMessageMB int + TLSCertFile string + TLSKeyFile string + MaildirRoot string + MaildirScanSeconds int + AllowInsecureHTTP bool + OpenRegistration bool + TwoFactorEnabled bool + TurnstileEnabled bool + TurnstileSiteKey string + TurnstileSecretKey string + CatchAllEnabled bool + MailAutoRefresh bool + MailRefreshSeconds int + UserMailboxApplyEnabled bool + UserMailboxDomainIDs string + ReservedMailboxPrefixes string + ExternalIMAPSecretKey string + ExternalIMAPSyncSeconds int + ExternalIMAPAllowPrivateHosts bool + ExternalIMAPGmailClientID string + ExternalIMAPGmailClientSecret string + ExternalIMAPOutlookClientID string + ExternalIMAPOutlookClientSecret string } func LoadConfig() Config { dataDir := getenv("LANQIN_DATA_DIR", "./data") return Config{ - Addr: getenv("LANQIN_ADDR", ":8080"), - DBPath: getenv("LANQIN_DB_PATH", filepath.Join(dataDir, "lanqin.db")), - DataDir: dataDir, - CookieName: getenv("LANQIN_COOKIE_NAME", "lanqin_session"), - SessionTTLHours: getenvInt("LANQIN_SESSION_TTL_HOURS", 24*7), - AdminEmail: strings.ToLower(getenv("LANQIN_ADMIN_EMAIL", "admin@lanqin.local")), - AdminPassword: getenv("LANQIN_ADMIN_PASSWORD", ""), - PublicHostname: getenv("LANQIN_PUBLIC_HOSTNAME", "mail.lanqin.local"), - PublicBaseURL: getenv("LANQIN_PUBLIC_BASE_URL", "http://localhost:5173"), - SMTPHost: getenv("LANQIN_SMTP_HOST", ""), - SMTPPort: getenv("LANQIN_SMTP_PORT", "25"), - SMTPUsername: getenv("LANQIN_SMTP_USERNAME", ""), - SMTPPassword: getenv("LANQIN_SMTP_PASSWORD", ""), - SMTPRequireTLS: getenvBool("LANQIN_SMTP_REQUIRE_TLS", false), - SubmissionAddr: getenv("LANQIN_SUBMISSION_ADDR", ""), - SubmissionTLSAddr: getenv("LANQIN_SUBMISSION_TLS_ADDR", ""), - SubmissionMaxMessageMB: getenvInt("LANQIN_SUBMISSION_MAX_MESSAGE_MB", 35), - TLSCertFile: getenv("LANQIN_TLS_CERT_FILE", ""), - TLSKeyFile: getenv("LANQIN_TLS_KEY_FILE", ""), - MaildirRoot: getenv("LANQIN_MAILDIR_ROOT", ""), - MaildirScanSeconds: getenvInt("LANQIN_MAILDIR_SCAN_SECONDS", 30), - AllowInsecureHTTP: getenvBool("LANQIN_ALLOW_INSECURE_HTTP", true), - OpenRegistration: getenvBool("LANQIN_OPEN_REGISTRATION", false), - TwoFactorEnabled: getenvBool("LANQIN_TWO_FACTOR_ENABLED", false), - TurnstileEnabled: getenvBool("LANQIN_TURNSTILE_ENABLED", false), - TurnstileSiteKey: getenv("LANQIN_TURNSTILE_SITE_KEY", ""), - TurnstileSecretKey: getenv("LANQIN_TURNSTILE_SECRET_KEY", ""), - CatchAllEnabled: getenvBool("LANQIN_CATCH_ALL_ENABLED", false), - MailAutoRefresh: getenvBool("LANQIN_MAIL_AUTO_REFRESH", true), - MailRefreshSeconds: getenvInt("LANQIN_MAIL_REFRESH_SECONDS", 30), - UserMailboxApplyEnabled: getenvBool("LANQIN_USER_MAILBOX_APPLY_ENABLED", false), - UserMailboxDomainIDs: getenv("LANQIN_USER_MAILBOX_DOMAIN_IDS", ""), - ReservedMailboxPrefixes: getenv("LANQIN_RESERVED_MAILBOX_PREFIXES", "admin,postmaster,abuse,hostmaster,webmaster,root,security,noreply,no-reply,mailer-daemon"), - ExternalIMAPSecretKey: getenv("LANQIN_EXTERNAL_IMAP_SECRET_KEY", ""), - ExternalIMAPSyncSeconds: getenvInt("LANQIN_EXTERNAL_IMAP_SYNC_SECONDS", 300), - ExternalIMAPAllowPrivateHosts: getenvBool("LANQIN_EXTERNAL_IMAP_ALLOW_PRIVATE_HOSTS", false), + Addr: getenv("LANQIN_ADDR", ":8080"), + DBPath: getenv("LANQIN_DB_PATH", filepath.Join(dataDir, "lanqin.db")), + DataDir: dataDir, + CookieName: getenv("LANQIN_COOKIE_NAME", "lanqin_session"), + SessionTTLHours: getenvInt("LANQIN_SESSION_TTL_HOURS", 24*7), + AdminEmail: strings.ToLower(getenv("LANQIN_ADMIN_EMAIL", "admin@lanqin.local")), + AdminPassword: getenv("LANQIN_ADMIN_PASSWORD", ""), + PublicHostname: getenv("LANQIN_PUBLIC_HOSTNAME", "mail.lanqin.local"), + PublicBaseURL: getenv("LANQIN_PUBLIC_BASE_URL", "http://localhost:5173"), + SMTPHost: getenv("LANQIN_SMTP_HOST", ""), + SMTPPort: getenv("LANQIN_SMTP_PORT", "25"), + SMTPUsername: getenv("LANQIN_SMTP_USERNAME", ""), + SMTPPassword: getenv("LANQIN_SMTP_PASSWORD", ""), + SMTPRequireTLS: getenvBool("LANQIN_SMTP_REQUIRE_TLS", false), + SubmissionAddr: getenv("LANQIN_SUBMISSION_ADDR", ""), + SubmissionTLSAddr: getenv("LANQIN_SUBMISSION_TLS_ADDR", ""), + SubmissionMaxMessageMB: getenvInt("LANQIN_SUBMISSION_MAX_MESSAGE_MB", 35), + TLSCertFile: getenv("LANQIN_TLS_CERT_FILE", ""), + TLSKeyFile: getenv("LANQIN_TLS_KEY_FILE", ""), + MaildirRoot: getenv("LANQIN_MAILDIR_ROOT", ""), + MaildirScanSeconds: getenvInt("LANQIN_MAILDIR_SCAN_SECONDS", 30), + AllowInsecureHTTP: getenvBool("LANQIN_ALLOW_INSECURE_HTTP", true), + OpenRegistration: getenvBool("LANQIN_OPEN_REGISTRATION", false), + TwoFactorEnabled: getenvBool("LANQIN_TWO_FACTOR_ENABLED", false), + TurnstileEnabled: getenvBool("LANQIN_TURNSTILE_ENABLED", false), + TurnstileSiteKey: getenv("LANQIN_TURNSTILE_SITE_KEY", ""), + TurnstileSecretKey: getenv("LANQIN_TURNSTILE_SECRET_KEY", ""), + CatchAllEnabled: getenvBool("LANQIN_CATCH_ALL_ENABLED", false), + MailAutoRefresh: getenvBool("LANQIN_MAIL_AUTO_REFRESH", true), + MailRefreshSeconds: getenvInt("LANQIN_MAIL_REFRESH_SECONDS", 30), + UserMailboxApplyEnabled: getenvBool("LANQIN_USER_MAILBOX_APPLY_ENABLED", false), + UserMailboxDomainIDs: getenv("LANQIN_USER_MAILBOX_DOMAIN_IDS", ""), + ReservedMailboxPrefixes: getenv("LANQIN_RESERVED_MAILBOX_PREFIXES", "admin,postmaster,abuse,hostmaster,webmaster,root,security,noreply,no-reply,mailer-daemon"), + ExternalIMAPSecretKey: getenv("LANQIN_EXTERNAL_IMAP_SECRET_KEY", ""), + ExternalIMAPSyncSeconds: getenvInt("LANQIN_EXTERNAL_IMAP_SYNC_SECONDS", 300), + ExternalIMAPAllowPrivateHosts: getenvBool("LANQIN_EXTERNAL_IMAP_ALLOW_PRIVATE_HOSTS", false), + ExternalIMAPGmailClientID: getenv("LANQIN_EXTERNAL_IMAP_GMAIL_CLIENT_ID", ""), + ExternalIMAPGmailClientSecret: getenv("LANQIN_EXTERNAL_IMAP_GMAIL_CLIENT_SECRET", ""), + ExternalIMAPOutlookClientID: getenv("LANQIN_EXTERNAL_IMAP_OUTLOOK_CLIENT_ID", ""), + ExternalIMAPOutlookClientSecret: getenv("LANQIN_EXTERNAL_IMAP_OUTLOOK_CLIENT_SECRET", ""), } } diff --git a/apps/api/internal/app/external_imap.go b/apps/api/internal/app/external_imap.go index 9152c5e..1a8af34 100644 --- a/apps/api/internal/app/external_imap.go +++ b/apps/api/internal/app/external_imap.go @@ -9,17 +9,24 @@ import ( "crypto/tls" "database/sql" "encoding/base64" + "encoding/json" "errors" "fmt" + "io" + "mime" + "mime/quotedprintable" "net" "net/http" + "sort" "strconv" "strings" "time" "github.com/emersion/go-imap/v2" "github.com/emersion/go-imap/v2/imapclient" + "github.com/emersion/go-sasl" "github.com/go-chi/chi/v5" + "golang.org/x/oauth2" ) const ( @@ -29,6 +36,10 @@ const ( externalIMAPStartTLS = "starttls" externalIMAPPlain = "plain" externalIMAPMaxFetch = 30 + externalIMAPAuthPassword = "password" + externalIMAPAuthOAuth2 = "oauth2" + externalIMAPOAuthGmail = "gmail" + externalIMAPOAuthOutlook = "outlook" ) type externalIMAPClientFactory interface { @@ -38,15 +49,19 @@ type externalIMAPClientFactory interface { type externalIMAPClient interface { Close() error ListFolders(ctx context.Context) ([]externalIMAPRemoteFolder, error) - FetchSummaries(ctx context.Context, folder string, cursor uint32, limit int) ([]externalIMAPRemoteMessage, string, error) + FetchSummaries(ctx context.Context, folder string, query string, cursor string, limit int) ([]externalIMAPRemoteMessage, string, error) FetchNew(ctx context.Context, folder string, afterUID uint32, limit int) ([]externalIMAPRemoteMessage, error) FetchRaw(ctx context.Context, folder string, uid uint32) ([]byte, externalIMAPRemoteMessage, error) + FetchAttachments(ctx context.Context, folder string, uid uint32) ([]Attachment, error) + FetchPart(ctx context.Context, folder string, uid uint32, partID string) ([]byte, Attachment, error) SetRead(ctx context.Context, folder string, uid uint32, read bool) error } type externalIMAPAccountRecord struct { ExternalIMAPAccount - PasswordCiphertext string + PasswordCiphertext string + OAuthAccessTokenCiphertext string + OAuthRefreshTokenCiphertext string } type externalIMAPRemoteFolder struct { @@ -73,6 +88,12 @@ type externalIMAPRemoteMessage struct { IsRead bool SizeBytes int64 Raw []byte + Attachments []Attachment +} + +type externalIMAPAttachmentPart struct { + Attachment + Encoding string } type externalIMAPPayload struct { @@ -88,6 +109,28 @@ type externalIMAPPayload struct { Enabled *bool `json:"enabled"` } +type externalIMAPOAuthStartPayload struct { + MailboxID string `json:"mailboxId"` + Name string `json:"name"` + Email string `json:"email"` + StorageMode string `json:"storageMode"` + SyncReadState *bool `json:"syncReadState"` + Enabled *bool `json:"enabled"` +} + +type externalIMAPOAuthState struct { + UserID string `json:"userId"` + MailboxID string `json:"mailboxId"` + Provider string `json:"provider"` + Name string `json:"name"` + Email string `json:"email"` + StorageMode string `json:"storageMode"` + SyncReadState bool `json:"syncReadState"` + Enabled bool `json:"enabled"` + Nonce string `json:"nonce"` + ExpiresAt int64 `json:"expiresAt"` +} + func (a *App) externalIMAPWorker(ctx context.Context) { interval := time.Duration(a.cfg.ExternalIMAPSyncSeconds) * time.Second if interval <= 0 { @@ -136,7 +179,7 @@ func (a *App) handleListExternalIMAPAccounts(w http.ResponseWriter, r *http.Requ where += " AND mailbox_id=?" args = append(args, mailboxID) } - rows, err := a.db.QueryContext(r.Context(), `SELECT id,user_id,mailbox_id,name,host,port,tls_mode,username,password_ciphertext,storage_mode,sync_read_state,enabled,last_sync_at,last_status,last_error,created_at,updated_at FROM external_imap_accounts WHERE `+where+` ORDER BY created_at DESC`, args...) + rows, err := a.db.QueryContext(r.Context(), `SELECT 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_sync_at,last_status,last_error,created_at,updated_at FROM external_imap_accounts WHERE `+where+` ORDER BY created_at DESC`, args...) if err != nil { respondError(w, http.StatusInternalServerError, "failed to load external accounts") return @@ -301,9 +344,186 @@ func (a *App) handleSyncExternalIMAPAccount(w http.ResponseWriter, r *http.Reque respondJSON(w, http.StatusOK, run) } +func (a *App) handleExternalIMAPSyncRuns(w http.ResponseWriter, r *http.Request) { + user := currentUser(r) + account, err := a.externalIMAPAccountForUser(r.Context(), user.ID, chi.URLParam(r, "id")) + if err != nil { + respondError(w, http.StatusNotFound, "external account not found") + return + } + rows, err := a.db.QueryContext(r.Context(), `SELECT id,account_id,folder,status,imported,skipped,failed,error,started_at,finished_at FROM external_imap_sync_runs WHERE account_id=? ORDER BY started_at DESC LIMIT 20`, account.ID) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to load sync runs") + return + } + defer rows.Close() + items := []ExternalIMAPSyncRun{} + for rows.Next() { + run, err := scanExternalIMAPSyncRun(rows) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to scan sync runs") + return + } + items = append(items, run) + } + respondJSON(w, http.StatusOK, map[string]any{"items": items}) +} + +func (a *App) handleSyncExternalIMAPFolder(w http.ResponseWriter, r *http.Request) { + user := currentUser(r) + account, err := a.externalIMAPAccountForUser(r.Context(), user.ID, chi.URLParam(r, "id")) + if err != nil { + respondError(w, http.StatusNotFound, "external account not found") + return + } + if account.StorageMode != externalIMAPStorageLocal { + badRequest(w, errors.New("remote storage accounts do not sync into local mailbox")) + return + } + var req struct { + Folder string `json:"folder"` + } + if err := decodeJSON(r, &req); err != nil { + badRequest(w, err) + return + } + folder := strings.TrimSpace(req.Folder) + if folder == "" { + badRequest(w, errors.New("folder is required")) + return + } + run, err := a.syncExternalIMAPAccountFolder(r.Context(), account.ID, folder) + if err != nil { + respondError(w, http.StatusBadRequest, err.Error()) + return + } + respondJSON(w, http.StatusOK, run) +} + +func (a *App) handleStartExternalIMAPOAuth(w http.ResponseWriter, r *http.Request) { + user := currentUser(r) + provider := strings.ToLower(strings.TrimSpace(chi.URLParam(r, "provider"))) + conf, profile, err := a.externalIMAPOAuthConfig(provider) + if err != nil { + badRequest(w, err) + return + } + var req externalIMAPOAuthStartPayload + if err := decodeJSON(r, &req); err != nil { + badRequest(w, err) + return + } + mb, err := a.mailboxForUserByID(r.Context(), user.ID, strings.TrimSpace(req.MailboxID)) + if err != nil { + respondError(w, http.StatusNotFound, "mailbox not found") + return + } + email := strings.TrimSpace(req.Email) + if email == "" { + email = mb.Address + } + req.StorageMode = strings.ToLower(strings.TrimSpace(req.StorageMode)) + if req.StorageMode == "" { + req.StorageMode = externalIMAPStorageLocal + } + if req.StorageMode != externalIMAPStorageLocal && req.StorageMode != externalIMAPStorageRemote { + badRequest(w, errors.New("invalid storage mode")) + return + } + syncRead := true + if req.SyncReadState != nil { + syncRead = *req.SyncReadState + } + enabled := true + if req.Enabled != nil { + enabled = *req.Enabled + } + state := externalIMAPOAuthState{ + UserID: user.ID, + MailboxID: mb.ID, + Provider: provider, + Name: strings.Join(strings.Fields(req.Name), " "), + Email: email, + StorageMode: req.StorageMode, + SyncReadState: syncRead, + Enabled: enabled, + Nonce: newID("oauth"), + ExpiresAt: a.now().Add(10 * time.Minute).Unix(), + } + if state.Name == "" { + state.Name = profile.Name + } + stateValue, err := a.encryptExternalIMAPOAuthState(state) + if err != nil { + respondError(w, http.StatusBadRequest, err.Error()) + return + } + respondJSON(w, http.StatusOK, map[string]any{"url": conf.AuthCodeURL(stateValue, oauth2.AccessTypeOffline)}) +} + +func (a *App) handleExternalIMAPOAuthCallback(w http.ResponseWriter, r *http.Request) { + provider := strings.ToLower(strings.TrimSpace(chi.URLParam(r, "provider"))) + conf, profile, err := a.externalIMAPOAuthConfig(provider) + if err != nil { + respondError(w, http.StatusBadRequest, err.Error()) + return + } + state, err := a.decryptExternalIMAPOAuthState(r.URL.Query().Get("state")) + if err != nil || state.Provider != provider || state.ExpiresAt < a.now().Unix() { + respondError(w, http.StatusBadRequest, "invalid oauth state") + return + } + if oauthErr := strings.TrimSpace(r.URL.Query().Get("error")); oauthErr != "" { + respondError(w, http.StatusBadRequest, "oauth failed: "+oauthErr) + return + } + code := strings.TrimSpace(r.URL.Query().Get("code")) + if code == "" { + respondError(w, http.StatusBadRequest, "missing oauth code") + return + } + ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second) + defer cancel() + token, err := conf.Exchange(ctx, code) + if err != nil { + respondError(w, http.StatusBadRequest, "oauth exchange failed") + return + } + accessCipher, err := a.encryptExternalIMAPPassword(token.AccessToken) + if err != nil { + respondError(w, http.StatusBadRequest, err.Error()) + return + } + refreshCipher := "" + if token.RefreshToken != "" { + refreshCipher, err = a.encryptExternalIMAPPassword(token.RefreshToken) + if err != nil { + respondError(w, http.StatusBadRequest, err.Error()) + return + } + } + name := state.Name + if name == "" { + name = profile.Name + } + now := a.now().UTC().Format(time.RFC3339Nano) + expiry := "" + if !token.Expiry.IsZero() { + expiry = token.Expiry.UTC().Format(time.RFC3339Nano) + } + 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) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to save oauth account") + return + } + http.Redirect(w, r, strings.TrimRight(a.cfg.PublicBaseURL, "/")+"/profile?tab=mailboxes", http.StatusFound) +} + func (a *App) handleMailExternalAccounts(w http.ResponseWriter, r *http.Request) { user := currentUser(r) - rows, err := a.db.QueryContext(r.Context(), `SELECT id,user_id,mailbox_id,name,host,port,tls_mode,username,password_ciphertext,storage_mode,sync_read_state,enabled,last_sync_at,last_status,last_error,created_at,updated_at FROM external_imap_accounts WHERE user_id=? AND enabled=1 ORDER BY name`, user.ID) + rows, err := a.db.QueryContext(r.Context(), `SELECT 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_sync_at,last_status,last_error,created_at,updated_at FROM external_imap_accounts WHERE user_id=? AND enabled=1 ORDER BY name`, user.ID) if err != nil { respondError(w, http.StatusInternalServerError, "failed to load external accounts") return @@ -353,14 +573,15 @@ func (a *App) handleExternalIMAPMessages(w http.ResponseWriter, r *http.Request) if folder == "" { folder = "INBOX" } - cursor, _ := strconv.ParseUint(strings.TrimSpace(r.URL.Query().Get("cursor")), 10, 32) + query := strings.TrimSpace(r.URL.Query().Get("q")) + cursor := strings.TrimSpace(r.URL.Query().Get("cursor")) client, err := a.externalIMAP.openExternalIMAPClient(r.Context(), account) if err != nil { respondError(w, http.StatusBadRequest, "connection failed: "+err.Error()) return } defer client.Close() - remote, next, err := client.FetchSummaries(r.Context(), folder, uint32(cursor), externalIMAPMaxFetch) + remote, next, err := client.FetchSummaries(r.Context(), folder, query, cursor, externalIMAPMaxFetch) if err != nil { respondError(w, http.StatusBadRequest, "failed to load remote messages") return @@ -398,11 +619,20 @@ func (a *App) handleExternalIMAPMessage(w http.ResponseWriter, r *http.Request) msg.BodyText = stored.BodyText msg.BodyHTML = stored.BodyHTML msg.Snippet = stored.Snippet - msg.Attachments = []Attachment{{ID: "raw", MessageID: msg.ID, Filename: safeExternalEMLFilename(msg.Subject), ContentType: "message/rfc822", SizeBytes: int64(len(raw)), CreatedAt: a.now().UTC()}} if len(attachments) > 0 { msg.HasAttachments = true } } + if parts, err := client.FetchAttachments(r.Context(), folder, uid); err == nil { + for i := range parts { + parts[i].MessageID = msg.ID + } + msg.Attachments = parts + msg.HasAttachments = len(parts) > 0 + } + if len(msg.Attachments) == 0 { + msg.Attachments = []Attachment{{ID: "raw", MessageID: msg.ID, Filename: safeExternalEMLFilename(msg.Subject), ContentType: "message/rfc822", SizeBytes: int64(len(raw)), CreatedAt: a.now().UTC()}} + } respondJSON(w, http.StatusOK, msg) } @@ -411,10 +641,7 @@ func (a *App) handleExternalIMAPAttachment(w http.ResponseWriter, r *http.Reques if !ok { return } - if chi.URLParam(r, "partId") != "raw" { - respondError(w, http.StatusNotFound, "attachment not found") - return - } + partID := chi.URLParam(r, "partId") folder, uid, ok := decodeExternalRemoteID(w, chi.URLParam(r, "remoteId")) if !ok { return @@ -425,6 +652,18 @@ func (a *App) handleExternalIMAPAttachment(w http.ResponseWriter, r *http.Reques return } defer client.Close() + if partID != "raw" { + data, att, err := client.FetchPart(r.Context(), folder, uid, partID) + if err != nil { + respondError(w, http.StatusNotFound, "attachment not found") + return + } + w.Header().Set("Content-Type", att.ContentType) + w.Header().Set("Content-Disposition", `attachment; filename="`+escapeDownloadFilename(att.Filename)+`"`) + w.Header().Set("Content-Length", strconv.Itoa(len(data))) + _, _ = w.Write(data) + return + } raw, remote, err := client.FetchRaw(r.Context(), folder, uid) if err != nil { respondError(w, http.StatusBadRequest, "failed to load remote message") @@ -615,8 +854,76 @@ func (a *App) externalIMAPKey() ([]byte, error) { return sum[:], nil } +type externalIMAPOAuthProvider struct { + Name string + Host string + Port int +} + +func (a *App) externalIMAPOAuthConfig(provider string) (*oauth2.Config, externalIMAPOAuthProvider, error) { + callback := strings.TrimRight(a.cfg.PublicBaseURL, "/") + "/api/external-imap-oauth/" + provider + "/callback" + switch provider { + case externalIMAPOAuthGmail: + if a.cfg.ExternalIMAPGmailClientID == "" || a.cfg.ExternalIMAPGmailClientSecret == "" { + return nil, externalIMAPOAuthProvider{}, errors.New("gmail oauth is not configured") + } + return &oauth2.Config{ + ClientID: a.cfg.ExternalIMAPGmailClientID, + ClientSecret: a.cfg.ExternalIMAPGmailClientSecret, + RedirectURL: callback, + Scopes: []string{"https://mail.google.com/"}, + Endpoint: oauth2.Endpoint{ + AuthURL: "https://accounts.google.com/o/oauth2/v2/auth", + TokenURL: "https://oauth2.googleapis.com/token", + }, + }, externalIMAPOAuthProvider{Name: "Gmail", Host: "imap.gmail.com", Port: 993}, nil + case externalIMAPOAuthOutlook: + if a.cfg.ExternalIMAPOutlookClientID == "" || a.cfg.ExternalIMAPOutlookClientSecret == "" { + return nil, externalIMAPOAuthProvider{}, errors.New("outlook oauth is not configured") + } + return &oauth2.Config{ + ClientID: a.cfg.ExternalIMAPOutlookClientID, + ClientSecret: a.cfg.ExternalIMAPOutlookClientSecret, + RedirectURL: callback, + Scopes: []string{"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", + }, + }, externalIMAPOAuthProvider{Name: "Outlook", Host: "outlook.office365.com", Port: 993}, nil + default: + return nil, externalIMAPOAuthProvider{}, errors.New("unsupported oauth provider") + } +} + +func (a *App) encryptExternalIMAPOAuthState(state externalIMAPOAuthState) (string, error) { + raw, err := json.Marshal(state) + if err != nil { + return "", err + } + ciphertext, err := a.encryptExternalIMAPPassword(string(raw)) + if err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString([]byte(ciphertext)), nil +} + +func (a *App) decryptExternalIMAPOAuthState(value string) (externalIMAPOAuthState, error) { + var state externalIMAPOAuthState + raw, err := base64.RawURLEncoding.DecodeString(strings.TrimSpace(value)) + if err != nil { + return state, err + } + plain, err := a.decryptExternalIMAPPassword(string(raw)) + if err != nil { + return state, err + } + err = json.Unmarshal([]byte(plain), &state) + return state, err +} + func (a *App) externalIMAPAccountForUser(ctx context.Context, userID, id string) (externalIMAPAccountRecord, error) { - row := a.db.QueryRowContext(ctx, `SELECT id,user_id,mailbox_id,name,host,port,tls_mode,username,password_ciphertext,storage_mode,sync_read_state,enabled,last_sync_at,last_status,last_error,created_at,updated_at FROM external_imap_accounts WHERE id=? AND user_id=?`, id, userID) + row := a.db.QueryRowContext(ctx, `SELECT 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_sync_at,last_status,last_error,created_at,updated_at FROM external_imap_accounts WHERE id=? AND user_id=?`, id, userID) return scanExternalIMAPAccount(row) } @@ -637,12 +944,16 @@ type externalIMAPScanner interface { func scanExternalIMAPAccount(row externalIMAPScanner) (externalIMAPAccountRecord, error) { var item externalIMAPAccountRecord var syncRead, enabled int - var lastSync sql.NullString + var lastSync, oauthExpiry sql.NullString var created, updated string - err := row.Scan(&item.ID, &item.UserID, &item.MailboxID, &item.Name, &item.Host, &item.Port, &item.TLSMode, &item.Username, &item.PasswordCiphertext, &item.StorageMode, &syncRead, &enabled, &lastSync, &item.LastStatus, &item.LastError, &created, &updated) + err := row.Scan(&item.ID, &item.UserID, &item.MailboxID, &item.Name, &item.Host, &item.Port, &item.TLSMode, &item.Username, &item.PasswordCiphertext, &item.AuthMode, &item.OAuthProvider, &item.OAuthEmail, &item.OAuthAccessTokenCiphertext, &item.OAuthRefreshTokenCiphertext, &oauthExpiry, &item.StorageMode, &syncRead, &enabled, &lastSync, &item.LastStatus, &item.LastError, &created, &updated) if err != nil { return item, err } + if item.AuthMode == "" { + item.AuthMode = externalIMAPAuthPassword + } + item.OAuthConfigured = item.AuthMode == externalIMAPAuthOAuth2 && item.OAuthAccessTokenCiphertext != "" item.SyncReadState = syncRead != 0 item.Enabled = enabled != 0 if lastSync.Valid && strings.TrimSpace(lastSync.String) != "" { @@ -658,6 +969,28 @@ func (a *App) updateExternalIMAPStatus(ctx context.Context, accountID, status, e _, _ = a.db.ExecContext(ctx, `UPDATE external_imap_accounts SET last_status=?,last_error=?,updated_at=? WHERE id=?`, status, trimExternalIMAPError(errText), a.now().UTC().Format(time.RFC3339Nano), accountID) } +func (a *App) migrateExternalIMAP(ctx context.Context) error { + columns := []struct { + table string + name string + sql string + }{ + {"external_imap_sync_runs", "folder", `ALTER TABLE external_imap_sync_runs ADD COLUMN folder TEXT NOT NULL DEFAULT ''`}, + {"external_imap_accounts", "auth_mode", `ALTER TABLE external_imap_accounts ADD COLUMN auth_mode TEXT NOT NULL DEFAULT 'password'`}, + {"external_imap_accounts", "oauth_provider", `ALTER TABLE external_imap_accounts ADD COLUMN oauth_provider TEXT NOT NULL DEFAULT ''`}, + {"external_imap_accounts", "oauth_email", `ALTER TABLE external_imap_accounts ADD COLUMN oauth_email TEXT NOT NULL DEFAULT ''`}, + {"external_imap_accounts", "oauth_access_token_ciphertext", `ALTER TABLE external_imap_accounts ADD COLUMN oauth_access_token_ciphertext TEXT NOT NULL DEFAULT ''`}, + {"external_imap_accounts", "oauth_refresh_token_ciphertext", `ALTER TABLE external_imap_accounts ADD COLUMN oauth_refresh_token_ciphertext TEXT NOT NULL DEFAULT ''`}, + {"external_imap_accounts", "oauth_expiry", `ALTER TABLE external_imap_accounts ADD COLUMN oauth_expiry TEXT`}, + } + for _, column := range columns { + if err := a.ensureTableColumn(ctx, column.table, column.name, column.sql); err != nil { + return err + } + } + return nil +} + func (a *App) syncExternalIMAPAccount(ctx context.Context, accountID string) (ExternalIMAPSyncRun, error) { account, err := a.externalIMAPAccountByID(ctx, accountID) if err != nil { @@ -667,7 +1000,7 @@ func (a *App) syncExternalIMAPAccount(ctx context.Context, accountID string) (Ex return ExternalIMAPSyncRun{}, errors.New("account is not configured for local storage") } run := ExternalIMAPSyncRun{ID: newID("ximrun"), AccountID: account.ID, Status: "running", StartedAt: a.now().UTC()} - _, _ = a.db.ExecContext(ctx, `INSERT INTO external_imap_sync_runs(id,account_id,status,started_at) VALUES(?,?,?,?)`, run.ID, run.AccountID, run.Status, run.StartedAt.Format(time.RFC3339Nano)) + _, _ = a.db.ExecContext(ctx, `INSERT INTO external_imap_sync_runs(id,account_id,folder,status,started_at) VALUES(?,?,?,?,?)`, run.ID, run.AccountID, run.Folder, run.Status, run.StartedAt.Format(time.RFC3339Nano)) client, err := a.externalIMAP.openExternalIMAPClient(ctx, account) if err != nil { return a.finishExternalIMAPRun(ctx, run, "failed", err) @@ -694,8 +1027,51 @@ func (a *App) syncExternalIMAPAccount(ctx context.Context, accountID string) (Ex return a.finishExternalIMAPRun(ctx, run, status, nil) } +func (a *App) syncExternalIMAPAccountFolder(ctx context.Context, accountID, folderName string) (ExternalIMAPSyncRun, error) { + account, err := a.externalIMAPAccountByID(ctx, accountID) + if err != nil { + return ExternalIMAPSyncRun{}, err + } + if account.StorageMode != externalIMAPStorageLocal { + return ExternalIMAPSyncRun{}, errors.New("account is not configured for local storage") + } + run := ExternalIMAPSyncRun{ID: newID("ximrun"), AccountID: account.ID, Folder: folderName, Status: "running", StartedAt: a.now().UTC()} + _, _ = a.db.ExecContext(ctx, `INSERT INTO external_imap_sync_runs(id,account_id,folder,status,started_at) VALUES(?,?,?,?,?)`, run.ID, run.AccountID, run.Folder, run.Status, run.StartedAt.Format(time.RFC3339Nano)) + client, err := a.externalIMAP.openExternalIMAPClient(ctx, account) + if err != nil { + return a.finishExternalIMAPRun(ctx, run, "failed", err) + } + defer client.Close() + folders, err := client.ListFolders(ctx) + if err != nil { + return a.finishExternalIMAPRun(ctx, run, "failed", err) + } + var selected externalIMAPRemoteFolder + for _, folder := range folders { + if strings.EqualFold(folder.Name, folderName) { + selected = folder + break + } + } + if strings.TrimSpace(selected.Name) == "" { + return a.finishExternalIMAPRun(ctx, run, "failed", errors.New("remote folder not found")) + } + imported, skipped, failed, err := a.syncExternalIMAPFolder(ctx, account, client, selected) + run.Imported = imported + run.Skipped = skipped + run.Failed = failed + status := "ok" + if failed > 0 { + status = "partial" + } + if err != nil { + status = "failed" + } + return a.finishExternalIMAPRun(ctx, run, status, err) +} + func (a *App) externalIMAPAccountByID(ctx context.Context, id string) (externalIMAPAccountRecord, error) { - row := a.db.QueryRowContext(ctx, `SELECT id,user_id,mailbox_id,name,host,port,tls_mode,username,password_ciphertext,storage_mode,sync_read_state,enabled,last_sync_at,last_status,last_error,created_at,updated_at FROM external_imap_accounts WHERE id=? AND enabled=1`, id) + row := a.db.QueryRowContext(ctx, `SELECT 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_sync_at,last_status,last_error,created_at,updated_at FROM external_imap_accounts WHERE id=? AND enabled=1`, id) return scanExternalIMAPAccount(row) } @@ -822,6 +1198,22 @@ func (a *App) finishExternalIMAPRun(ctx context.Context, run ExternalIMAPSyncRun return run, err } +func scanExternalIMAPSyncRun(row externalIMAPScanner) (ExternalIMAPSyncRun, error) { + var run ExternalIMAPSyncRun + var started, finished sql.NullString + if err := row.Scan(&run.ID, &run.AccountID, &run.Folder, &run.Status, &run.Imported, &run.Skipped, &run.Failed, &run.Error, &started, &finished); err != nil { + return run, err + } + if started.Valid && strings.TrimSpace(started.String) != "" { + run.StartedAt = parseTime(started.String) + } + if finished.Valid && strings.TrimSpace(finished.String) != "" { + t := parseTime(finished.String) + run.FinishedAt = &t + } + return run, nil +} + func trimExternalIMAPError(value string) string { value = strings.TrimSpace(value) if len(value) > 500 { @@ -923,20 +1315,123 @@ func safeExternalEMLFilename(subject string) string { return name + ".eml" } +func externalIMAPAttachmentsFromBodyStructure(body imap.BodyStructure) []Attachment { + parts := externalIMAPAttachmentPartsFromBodyStructure(body) + items := make([]Attachment, 0, len(parts)) + for _, part := range parts { + items = append(items, part.Attachment) + } + return items +} + +func externalIMAPAttachmentPartsFromBodyStructure(body imap.BodyStructure) []externalIMAPAttachmentPart { + now := time.Now().UTC() + items := []externalIMAPAttachmentPart{} + body.Walk(func(path []int, part imap.BodyStructure) bool { + single, ok := part.(*imap.BodyStructureSinglePart) + if !ok { + return true + } + filename := strings.TrimSpace(single.Filename()) + disposition := "" + if disp := single.Disposition(); disp != nil { + disposition = strings.ToLower(strings.TrimSpace(disp.Value)) + } + if filename == "" && disposition != "attachment" { + return true + } + if filename == "" { + filename = "attachment" + } + contentType := single.MediaType() + if contentType == "/" || contentType == "" { + contentType = "application/octet-stream" + } + items = append(items, externalIMAPAttachmentPart{ + Attachment: Attachment{ + ID: encodeExternalIMAPPartID(path), + Filename: filename, + ContentType: contentType, + SizeBytes: int64(single.Size), + CreatedAt: now, + }, + Encoding: strings.ToLower(strings.TrimSpace(single.Encoding)), + }) + return true + }) + return items +} + +func encodeExternalIMAPPartID(path []int) string { + parts := make([]string, 0, len(path)) + for _, n := range path { + parts = append(parts, strconv.Itoa(n)) + } + return strings.Join(parts, ".") +} + +func decodeExternalIMAPPartID(value string) ([]int, error) { + value = strings.TrimSpace(value) + if value == "" || value == "raw" { + return nil, errors.New("invalid part id") + } + rawParts := strings.Split(value, ".") + path := make([]int, 0, len(rawParts)) + for _, part := range rawParts { + n, err := strconv.Atoi(part) + if err != nil || n <= 0 { + return nil, errors.New("invalid part id") + } + path = append(path, n) + } + return path, nil +} + +func decodeExternalIMAPPartData(data []byte, encoding string) ([]byte, error) { + switch strings.ToLower(strings.TrimSpace(encoding)) { + case "base64": + compact := strings.NewReplacer("\r", "", "\n", "", " ", "", "\t", "").Replace(string(data)) + decoded := make([]byte, base64.StdEncoding.DecodedLen(len(compact))) + n, err := base64.StdEncoding.Decode(decoded, []byte(compact)) + if err != nil { + return nil, err + } + return decoded[:n], nil + case "quoted-printable": + return io.ReadAll(quotedprintable.NewReader(strings.NewReader(string(data)))) + default: + return data, nil + } +} + +func escapeDownloadFilename(name string) string { + name = strings.TrimSpace(name) + if name == "" { + name = "attachment" + } + name = strings.Map(func(r rune) rune { + if r < 32 || r == '"' || r == '\\' { + return '-' + } + return r + }, name) + if len([]rune(name)) > 120 { + name = string([]rune(name)[:120]) + } + return mime.QEncoding.Encode("utf-8", name) +} + func (a *App) openExternalIMAPClient(ctx context.Context, account externalIMAPAccountRecord) (externalIMAPClient, error) { if err := a.validateExternalIMAPHost(ctx, account.Host); err != nil { return nil, err } - password, err := a.decryptExternalIMAPPassword(account.PasswordCiphertext) - if err != nil { - return nil, err - } addr := net.JoinHostPort(account.Host, strconv.Itoa(account.Port)) options := &imapclient.Options{ Dialer: &net.Dialer{Timeout: 10 * time.Second}, TLSConfig: &tls.Config{ServerName: account.Host, MinVersion: tls.VersionTLS12}, } var c *imapclient.Client + var err error switch account.TLSMode { case externalIMAPTLS: c, err = imapclient.DialTLS(addr, options) @@ -948,13 +1443,75 @@ func (a *App) openExternalIMAPClient(ctx context.Context, account externalIMAPAc if err != nil { return nil, err } - if err := c.Login(account.Username, password).Wait(); err != nil { - c.Close() - return nil, err + if account.AuthMode == externalIMAPAuthOAuth2 { + token, err := a.externalIMAPOAuthAccessToken(ctx, account) + if err != nil { + c.Close() + return nil, err + } + if err := c.Authenticate(sasl.NewOAuthBearerClient(&sasl.OAuthBearerOptions{Username: account.Username, Token: token, Host: account.Host, Port: account.Port})); err != nil { + c.Close() + return nil, err + } + } else { + password, err := a.decryptExternalIMAPPassword(account.PasswordCiphertext) + if err != nil { + c.Close() + return nil, err + } + if err := c.Login(account.Username, password).Wait(); err != nil { + c.Close() + return nil, err + } } return &goExternalIMAPClient{client: c}, nil } +func (a *App) externalIMAPOAuthAccessToken(ctx context.Context, account externalIMAPAccountRecord) (string, error) { + access, err := a.decryptExternalIMAPPassword(account.OAuthAccessTokenCiphertext) + if err != nil { + return "", err + } + var expiry time.Time + var expiryRaw sql.NullString + _ = a.db.QueryRowContext(ctx, `SELECT oauth_expiry FROM external_imap_accounts WHERE id=?`, account.ID).Scan(&expiryRaw) + if expiryRaw.Valid && strings.TrimSpace(expiryRaw.String) != "" { + expiry = parseTime(expiryRaw.String) + } + if expiry.IsZero() || expiry.After(a.now().Add(2*time.Minute)) || account.OAuthRefreshTokenCiphertext == "" { + return access, nil + } + refresh, err := a.decryptExternalIMAPPassword(account.OAuthRefreshTokenCiphertext) + if err != nil { + return "", err + } + conf, _, err := a.externalIMAPOAuthConfig(account.OAuthProvider) + if err != nil { + return "", err + } + token, err := conf.TokenSource(ctx, &oauth2.Token{AccessToken: access, RefreshToken: refresh, Expiry: expiry}).Token() + if err != nil { + return "", err + } + accessCipher, err := a.encryptExternalIMAPPassword(token.AccessToken) + if err != nil { + return "", err + } + refreshCipher := account.OAuthRefreshTokenCiphertext + if token.RefreshToken != "" && token.RefreshToken != refresh { + refreshCipher, err = a.encryptExternalIMAPPassword(token.RefreshToken) + if err != nil { + return "", err + } + } + expiryText := "" + if !token.Expiry.IsZero() { + expiryText = token.Expiry.UTC().Format(time.RFC3339Nano) + } + _, _ = a.db.ExecContext(ctx, `UPDATE external_imap_accounts SET oauth_access_token_ciphertext=?,oauth_refresh_token_ciphertext=?,oauth_expiry=?,updated_at=? WHERE id=?`, accessCipher, refreshCipher, expiryText, a.now().UTC().Format(time.RFC3339Nano), account.ID) + return token.AccessToken, nil +} + type goExternalIMAPClient struct { client *imapclient.Client } @@ -1006,42 +1563,65 @@ func mailboxHasNoSelect(attrs []imap.MailboxAttr) bool { return false } -func (c *goExternalIMAPClient) FetchSummaries(ctx context.Context, folder string, cursor uint32, limit int) ([]externalIMAPRemoteMessage, string, error) { +func (c *goExternalIMAPClient) FetchSummaries(ctx context.Context, folder string, query string, cursor string, limit int) ([]externalIMAPRemoteMessage, string, error) { selected, err := c.client.Select(folder, nil).Wait() if err != nil { return nil, "", err } - if selected.NumMessages == 0 { - return nil, "", nil - } if limit <= 0 || limit > 100 { limit = externalIMAPMaxFetch } - start := selected.NumMessages - if cursor > 0 { - start = cursor - } - if start == 0 { + if selected.NumMessages == 0 { return nil, "", nil } - stop := uint32(1) - if start > uint32(limit) { - stop = start - uint32(limit) + 1 + maxUID := uint32(0) + if strings.TrimSpace(cursor) != "" { + parsed, _ := strconv.ParseUint(strings.TrimPrefix(cursor, "uid:"), 10, 32) + maxUID = uint32(parsed) } - var set imap.SeqSet - set.AddRange(stop, start) + if maxUID == 0 { + if selected.UIDNext == 0 { + return nil, "", nil + } + maxUID = uint32(selected.UIDNext - 1) + } + criteria := &imap.SearchCriteria{} + var uidRange imap.UIDSet + uidRange.AddRange(1, imap.UID(maxUID)) + criteria.UID = []imap.UIDSet{uidRange} + if q := strings.TrimSpace(query); q != "" { + criteria.Text = []string{q} + } + data, err := c.client.UIDSearch(criteria, nil).Wait() + if err != nil { + return nil, "", err + } + uids := data.AllUIDs() + if len(uids) == 0 { + return nil, "", nil + } + sort.Slice(uids, func(i, j int) bool { return uids[i] > uids[j] }) + if len(uids) > limit { + uids = uids[:limit] + } + var set imap.UIDSet + set.AddNum(uids...) bodySection := &imap.FetchItemBodySection{Specifier: imap.PartSpecifierHeader, Peek: true} messages, err := c.client.Fetch(set, &imap.FetchOptions{UID: true, Flags: true, Envelope: true, InternalDate: true, RFC822Size: true, BodySection: []*imap.FetchItemBodySection{bodySection}}).Collect() if err != nil { return nil, "", err } out := []externalIMAPRemoteMessage{} - for i := len(messages) - 1; i >= 0 && len(out) < limit; i-- { - out = append(out, fetchBufferToExternalMessage(folder, selected.UIDValidity, messages[i], nil)) + for _, message := range messages { + out = append(out, fetchBufferToExternalMessage(folder, selected.UIDValidity, message, nil)) } + sort.Slice(out, func(i, j int) bool { return out[i].UID > out[j].UID }) next := "" - if stop > 1 { - next = strconv.FormatUint(uint64(stop-1), 10) + if len(uids) == limit { + last := uint32(uids[len(uids)-1]) + if last > 1 { + next = "uid:" + strconv.FormatUint(uint64(last-1), 10) + } } return out, next, nil } @@ -1088,6 +1668,77 @@ func (c *goExternalIMAPClient) FetchRaw(ctx context.Context, folder string, uid return raw, fetchBufferToExternalMessage(folder, selected.UIDValidity, messages[0], raw), nil } +func (c *goExternalIMAPClient) FetchAttachments(ctx context.Context, folder string, uid uint32) ([]Attachment, error) { + parts, err := c.fetchAttachmentParts(ctx, folder, uid) + if err != nil { + return nil, err + } + out := make([]Attachment, 0, len(parts)) + for _, part := range parts { + out = append(out, part.Attachment) + } + return out, nil +} + +func (c *goExternalIMAPClient) fetchAttachmentParts(ctx context.Context, folder string, uid uint32) ([]externalIMAPAttachmentPart, error) { + if _, err := c.client.Select(folder, nil).Wait(); err != nil { + return nil, err + } + messages, err := c.client.Fetch(imap.UIDSetNum(imap.UID(uid)), &imap.FetchOptions{ + UID: true, + BodyStructure: &imap.FetchItemBodyStructure{Extended: true}, + }).Collect() + if err != nil { + return nil, err + } + if len(messages) == 0 || messages[0].BodyStructure == nil { + return nil, sql.ErrNoRows + } + return externalIMAPAttachmentPartsFromBodyStructure(messages[0].BodyStructure), nil +} + +func (c *goExternalIMAPClient) FetchPart(ctx context.Context, folder string, uid uint32, partID string) ([]byte, Attachment, error) { + path, err := decodeExternalIMAPPartID(partID) + if err != nil { + return nil, Attachment{}, err + } + if _, err := c.client.Select(folder, nil).Wait(); err != nil { + return nil, Attachment{}, err + } + attachments, err := c.fetchAttachmentParts(ctx, folder, uid) + if err != nil { + return nil, Attachment{}, err + } + var att externalIMAPAttachmentPart + for _, item := range attachments { + if item.ID == partID { + att = item + break + } + } + if att.ID == "" { + return nil, Attachment{}, sql.ErrNoRows + } + bodySection := &imap.FetchItemBodySection{Part: path, Peek: true} + messages, err := c.client.Fetch(imap.UIDSetNum(imap.UID(uid)), &imap.FetchOptions{UID: true, BodySection: []*imap.FetchItemBodySection{bodySection}}).Collect() + if err != nil { + return nil, Attachment{}, err + } + if len(messages) == 0 { + return nil, Attachment{}, sql.ErrNoRows + } + data := messages[0].FindBodySection(bodySection) + if data == nil { + return nil, Attachment{}, sql.ErrNoRows + } + data, err = decodeExternalIMAPPartData(data, att.Encoding) + if err != nil { + return nil, Attachment{}, err + } + att.SizeBytes = int64(len(data)) + return data, att.Attachment, nil +} + func (c *goExternalIMAPClient) SetRead(ctx context.Context, folder string, uid uint32, read bool) error { if _, err := c.client.Select(folder, nil).Wait(); err != nil { return err diff --git a/apps/api/internal/app/router_auth.go b/apps/api/internal/app/router_auth.go index 95ccb86..118b8ec 100644 --- a/apps/api/internal/app/router_auth.go +++ b/apps/api/internal/app/router_auth.go @@ -64,7 +64,11 @@ func (a *App) Router() http.Handler { r.With(a.requireAuth, a.requirePermission(PermissionMailAccess)).Post("/me/external-imap-accounts/{id}", a.handleUpdateExternalIMAPAccount) r.With(a.requireAuth, a.requirePermission(PermissionMailAccess)).Delete("/me/external-imap-accounts/{id}", a.handleDeleteExternalIMAPAccount) r.With(a.requireAuth, a.requirePermission(PermissionMailAccess)).Post("/me/external-imap-accounts/{id}/test", a.handleTestExternalIMAPAccount) + r.With(a.requireAuth, a.requirePermission(PermissionMailAccess)).Get("/me/external-imap-accounts/{id}/runs", a.handleExternalIMAPSyncRuns) r.With(a.requireAuth, a.requirePermission(PermissionMailAccess)).Post("/me/external-imap-accounts/{id}/sync", a.handleSyncExternalIMAPAccount) + r.With(a.requireAuth, a.requirePermission(PermissionMailAccess)).Post("/me/external-imap-accounts/{id}/sync-folder", a.handleSyncExternalIMAPFolder) + r.With(a.requireAuth, a.requirePermission(PermissionMailAccess)).Post("/me/external-imap-oauth/{provider}/start", a.handleStartExternalIMAPOAuth) + r.Get("/external-imap-oauth/{provider}/callback", a.handleExternalIMAPOAuthCallback) r.With(a.requireAuth).Get("/events", a.handleEvents) r.Group(func(r chi.Router) { diff --git a/apps/api/internal/app/types.go b/apps/api/internal/app/types.go index 615d637..bd502e8 100644 --- a/apps/api/internal/app/types.go +++ b/apps/api/internal/app/types.go @@ -229,22 +229,26 @@ type MailStatsFolderCount struct { } type ExternalIMAPAccount struct { - ID string `json:"id"` - UserID string `json:"userId,omitempty"` - MailboxID string `json:"mailboxId"` - Name string `json:"name"` - Host string `json:"host"` - Port int `json:"port"` - TLSMode string `json:"tlsMode"` - Username string `json:"username"` - StorageMode string `json:"storageMode"` - SyncReadState bool `json:"syncReadState"` - Enabled bool `json:"enabled"` - LastSyncAt *time.Time `json:"lastSyncAt,omitempty"` - LastStatus string `json:"lastStatus"` - LastError string `json:"lastError,omitempty"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` + ID string `json:"id"` + UserID string `json:"userId,omitempty"` + MailboxID string `json:"mailboxId"` + Name string `json:"name"` + Host string `json:"host"` + Port int `json:"port"` + TLSMode string `json:"tlsMode"` + Username string `json:"username"` + AuthMode string `json:"authMode"` + OAuthProvider string `json:"oauthProvider,omitempty"` + OAuthEmail string `json:"oauthEmail,omitempty"` + OAuthConfigured bool `json:"oauthConfigured,omitempty"` + StorageMode string `json:"storageMode"` + SyncReadState bool `json:"syncReadState"` + Enabled bool `json:"enabled"` + LastSyncAt *time.Time `json:"lastSyncAt,omitempty"` + LastStatus string `json:"lastStatus"` + LastError string `json:"lastError,omitempty"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` } type ExternalIMAPFolder struct { @@ -257,6 +261,7 @@ type ExternalIMAPFolder struct { type ExternalIMAPSyncRun struct { ID string `json:"id"` AccountID string `json:"accountId"` + Folder string `json:"folder,omitempty"` Status string `json:"status"` Imported int `json:"imported"` Skipped int `json:"skipped"` diff --git a/apps/web/src/lib/api-types.ts b/apps/web/src/lib/api-types.ts index 552d9a1..263817b 100644 --- a/apps/web/src/lib/api-types.ts +++ b/apps/web/src/lib/api-types.ts @@ -127,8 +127,10 @@ 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 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 } export type ExternalImapFolder = { name: string; role: string; unreadCount: number; totalCount: number } -export type ExternalImapSyncRun = { id: string; accountId: string; status: string; imported: number; skipped: number; failed: number; error?: string; startedAt: string; finishedAt?: string } +export type ExternalImapSyncRun = { id: string; accountId: string; folder?: string; status: string; imported: number; skipped: number; failed: number; error?: string; startedAt: string; finishedAt?: string } export type MailTemplate = { key: string; name: string; subject: string; bodyText: string; bodyHtml: string; updatedAt: string } export type MailboxApplyOptions = { enabled: boolean; domains: Domain[]; reservedPrefixes?: string[] } export type MaildirSyncCounts = { filesScanned: number; imported: number; backfilled: number; cleaned: number; fileErrors: number } diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 4508a37..d3f337e 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -1,4 +1,4 @@ -import type { User, AdminUser, AdminOverview, Domain, Mailbox, Alias, MailFolder, Attachment, MailLabel, MailMessage, DNSRecord, DNSCheckResult, ListResponse, SendPayload, DraftPayload, ScheduleSendPayload, ScheduledSend, SendQueueItem, SendQueueAuditEvent, SendQueueStatus, Contact, MailSignature, MailRule, MailRuleCondition, MailRuleAction, BlockedSender, MailStats, ExternalImapAccount, ExternalImapAccountPayload, ExternalImapFolder, ExternalImapSyncRun, MailboxApplyOptions, MailTemplate, MaildirSyncHealth, SystemSettings, SystemSettingsPayload, PublicSettings, LoginPayload, LoginResponse, RegisterPayload, PermissionGroup, PermissionInfo, PermissionKey, PermissionLimits } from "./api-types" +import type { User, AdminUser, AdminOverview, Domain, Mailbox, Alias, MailFolder, Attachment, MailLabel, MailMessage, DNSRecord, DNSCheckResult, ListResponse, SendPayload, DraftPayload, ScheduleSendPayload, ScheduledSend, SendQueueItem, SendQueueAuditEvent, SendQueueStatus, Contact, MailSignature, MailRule, MailRuleCondition, MailRuleAction, BlockedSender, MailStats, ExternalImapAccount, ExternalImapAccountPayload, ExternalImapFolder, ExternalImapOAuthProvider, ExternalImapOAuthStartPayload, ExternalImapSyncRun, MailboxApplyOptions, MailTemplate, MaildirSyncHealth, SystemSettings, SystemSettingsPayload, PublicSettings, LoginPayload, LoginResponse, RegisterPayload, PermissionGroup, PermissionInfo, PermissionKey, PermissionLimits } from "./api-types" export * from "./api-types" const REQUEST_TIMEOUT_MS = 15_000 @@ -65,8 +65,11 @@ export const api = { createExternalImapAccount: (payload: ExternalImapAccountPayload) => request("/api/me/external-imap-accounts", { method: "POST", body: JSON.stringify(payload) }), updateExternalImapAccount: (id: string, payload: ExternalImapAccountPayload) => request(`/api/me/external-imap-accounts/${id}`, { method: "POST", body: JSON.stringify(payload) }), deleteExternalImapAccount: (id: string) => request<{ ok: boolean }>(`/api/me/external-imap-accounts/${id}`, { method: "DELETE" }), + startExternalImapOAuth: (provider: ExternalImapOAuthProvider, payload: ExternalImapOAuthStartPayload) => request<{ url: string }>(`/api/me/external-imap-oauth/${provider}/start`, { method: "POST", body: JSON.stringify(payload) }), testExternalImapAccount: (id: string) => request<{ ok: boolean; folders: number }>(`/api/me/external-imap-accounts/${id}/test`, { method: "POST", timeoutMs: MAIL_DELIVERY_TIMEOUT_MS }), + externalImapSyncRuns: (id: string) => request>(`/api/me/external-imap-accounts/${id}/runs`), syncExternalImapAccount: (id: string) => request(`/api/me/external-imap-accounts/${id}/sync`, { method: "POST", timeoutMs: MAIL_DELIVERY_TIMEOUT_MS }), + syncExternalImapFolder: (id: string, folder: string) => request(`/api/me/external-imap-accounts/${id}/sync-folder`, { method: "POST", body: JSON.stringify({ folder }), timeoutMs: MAIL_DELIVERY_TIMEOUT_MS }), adminOverview: () => request("/api/admin/overview"), users: () => request>("/api/admin/users"), permissionGroups: () => request & { catalog: PermissionInfo[] }>("/api/admin/permission-groups"), @@ -123,8 +126,8 @@ export const api = { myMailboxes: () => request>("/api/mail/mailboxes"), externalMailAccounts: () => request>("/api/mail/external-accounts"), externalFolders: (id: string) => request>(`/api/mail/external-accounts/${id}/folders`), - externalMessages: (id: string, folder: string, cursor = "") => { - const params = new URLSearchParams({ folder, cursor }) + externalMessages: (id: string, folder: string, cursor = "", q = "") => { + const params = new URLSearchParams({ folder, cursor, q }) return request>(`/api/mail/external-accounts/${id}/messages?${params.toString()}`) }, externalMessage: (id: string, remoteId: string) => request(`/api/mail/external-accounts/${id}/messages/${encodeURIComponent(remoteId)}`), diff --git a/apps/web/src/pages/mail.tsx b/apps/web/src/pages/mail.tsx index 267cc6f..6aff9c6 100644 --- a/apps/web/src/pages/mail.tsx +++ b/apps/web/src/pages/mail.tsx @@ -180,8 +180,8 @@ export function MailPage() { enabled: !!activeMailboxId && canReadMail && mailView !== "scheduled" && mailView !== "sendQueue" && (mailView !== "label" || !!selectedLabelId), }) const externalMessages = useInfiniteQuery({ - queryKey: ["external-messages", selectedExternalAccountId, externalFolder], - queryFn: ({ pageParam }) => api.externalMessages(selectedExternalAccountId, externalFolder, typeof pageParam === "string" ? pageParam : ""), + queryKey: ["external-messages", selectedExternalAccountId, externalFolder, query], + queryFn: ({ pageParam }) => api.externalMessages(selectedExternalAccountId, externalFolder, typeof pageParam === "string" ? pageParam : "", query), initialPageParam: "", getNextPageParam: (lastPage) => lastPage.nextCursor || undefined, enabled: !!selectedExternalAccountId && canReadMail && mailView === "external", @@ -1326,7 +1326,7 @@ export function MailPage() { {canSendMail && }
- setQuery(e.target.value)} disabled={mailView === "external"} placeholder={mailView === "external" ? "远端直连暂不支持搜索" : mailView === "sendQueue" ? "搜索发送队列" : mailView === "scheduled" ? "搜索待发送" : "搜索邮件"} className="h-10 pl-9" /> + setQuery(e.target.value)} placeholder={mailView === "external" ? "搜索远端邮件" : mailView === "sendQueue" ? "搜索发送队列" : mailView === "scheduled" ? "搜索待发送" : "搜索邮件"} className="h-10 pl-9" />
)} @@ -1370,7 +1370,7 @@ export function MailPage() {
- setQuery(e.target.value)} disabled={mailView === "external"} placeholder={mailView === "external" ? "远端直连暂不支持搜索" : mailView === "sendQueue" ? "搜索发送队列" : mailView === "scheduled" ? "搜索待发送" : "搜索邮件"} className="pl-9" /> + setQuery(e.target.value)} placeholder={mailView === "external" ? "搜索远端邮件" : mailView === "sendQueue" ? "搜索发送队列" : mailView === "scheduled" ? "搜索待发送" : "搜索邮件"} className="pl-9" />
{contentView} diff --git a/apps/web/src/pages/profile.tsx b/apps/web/src/pages/profile.tsx index c474472..7a0f96c 100644 --- a/apps/web/src/pages/profile.tsx +++ b/apps/web/src/pages/profile.tsx @@ -4,7 +4,7 @@ import type { ImperativePanelHandle } from "react-resizable-panels" import { useNavigate, useSearchParams } from "react-router-dom" import { ArrowLeft, BarChart3, Ban, Contact, Copy, Info, KeyRound, Laptop, Link2, LogOut, Mail, MailCheck, MailX, Moon, PanelLeftClose, PanelLeftOpen, PencilLine, Plus, RefreshCcw, Settings, ShieldCheck, SlidersHorizontal, Sun, Trash2, X } from "lucide-react" import { QRCodeSVG } from "qrcode.react" -import { api, ExternalImapAccount, ExternalImapAccountPayload, ExternalImapStorageMode, ExternalImapTlsMode, MailLabel, MailRule, MailRuleAction, MailRuleCondition, Mailbox, MailboxApplyOptions, MailSignature, MailStats, PermissionLimits } from "@/lib/api" +import { api, ExternalImapAccount, ExternalImapAccountPayload, ExternalImapFolder, ExternalImapOAuthProvider, ExternalImapStorageMode, ExternalImapSyncRun, ExternalImapTlsMode, MailLabel, MailRule, MailRuleAction, MailRuleCondition, Mailbox, MailboxApplyOptions, MailSignature, MailStats, PermissionLimits } from "@/lib/api" import { cn, formatBytes } from "@/lib/utils" import { applyTheme, getInitialTheme } from "@/lib/theme" import { DisplayMode, useDisplayMode } from "@/lib/display-mode" @@ -64,6 +64,7 @@ export function ProfilePage() { const [blockedMailboxId, setBlockedMailboxId] = React.useState("all") const [ruleDialogOpen, setRuleDialogOpen] = React.useState(false) const [mobileSidebarOpen, setMobileSidebarOpen] = React.useState(false) + const [externalRunAccountId, setExternalRunAccountId] = React.useState("") const isMobile = useIsMobile() const themeMountedRef = React.useRef(false) @@ -102,6 +103,14 @@ export function ProfilePage() { const selectedMailbox = React.useMemo(() => mailboxes.data?.items.find((m) => m.id === mailboxId), [mailboxes.data?.items, mailboxId]) const activeMailboxId = selectedMailbox?.id || "" const externalImapAccounts = useQuery({ queryKey: ["external-imap-accounts", activeMailboxId], queryFn: () => api.externalImapAccounts(activeMailboxId), enabled: !!activeMailboxId && canAccessMail }) + React.useEffect(() => { + if (!externalRunAccountId) return + if (externalImapAccounts.data?.items.some((item) => item.id === externalRunAccountId)) return + setExternalRunAccountId("") + }, [externalImapAccounts.data?.items, externalRunAccountId]) + const selectedExternalRunAccount = externalImapAccounts.data?.items.find((item) => item.id === externalRunAccountId) + const externalRunFolders = useQuery({ queryKey: ["external-imap-run-folders", externalRunAccountId], queryFn: () => api.externalFolders(externalRunAccountId), enabled: !!externalRunAccountId && !!selectedExternalRunAccount && canAccessMail }) + const externalSyncRuns = useQuery({ queryKey: ["external-imap-sync-runs", externalRunAccountId], queryFn: () => api.externalImapSyncRuns(externalRunAccountId), enabled: !!externalRunAccountId && !!selectedExternalRunAccount && canAccessMail }) const ruleLabels = useQuery({ queryKey: ["labels", "rules", activeMailboxId], queryFn: () => api.labels(activeMailboxId), enabled: !!activeMailboxId && canManageRules && (canReadMail || canManageLabels) }) const stats = useQuery({ queryKey: ["mail-stats", activeMailboxId], queryFn: () => api.mailStats(activeMailboxId), enabled: !!activeMailboxId && canViewStats }) @@ -221,9 +230,19 @@ export function ProfilePage() { }) const syncExternalImap = useMutation({ mutationFn: api.syncExternalImapAccount, - onSuccess: (run) => { qc.invalidateQueries({ queryKey: ["external-imap-accounts"] }); qc.invalidateQueries({ queryKey: ["folders"] }); qc.invalidateQueries({ queryKey: ["messages"] }); toast({ title: `同步完成:导入 ${run.imported},跳过 ${run.skipped}` }) }, + onSuccess: (run) => { qc.invalidateQueries({ queryKey: ["external-imap-accounts"] }); qc.invalidateQueries({ queryKey: ["external-imap-sync-runs"] }); qc.invalidateQueries({ queryKey: ["folders"] }); qc.invalidateQueries({ queryKey: ["messages"] }); toast({ title: `同步完成:导入 ${run.imported},跳过 ${run.skipped}` }) }, onError: (error) => toast({ title: "同步失败", description: error.message }), }) + const syncExternalImapFolder = useMutation({ + mutationFn: ({ id, folder }: { id: string; folder: string }) => api.syncExternalImapFolder(id, folder), + onSuccess: (run) => { qc.invalidateQueries({ queryKey: ["external-imap-accounts"] }); qc.invalidateQueries({ queryKey: ["external-imap-sync-runs"] }); qc.invalidateQueries({ queryKey: ["folders"] }); qc.invalidateQueries({ queryKey: ["messages"] }); toast({ title: `${run.folder || "文件夹"} 同步完成:导入 ${run.imported},跳过 ${run.skipped}` }) }, + 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 }), + onSuccess: (res) => { window.location.href = res.url }, + onError: (error) => toast({ title: "授权失败", description: error.message }), + }) React.useEffect(() => { if (!mailboxes.isSuccess) return @@ -317,7 +336,31 @@ export function ProfilePage() { ) function renderTab() { - if (tab === "mailboxes") return { if (!canAccessMail) return; setMailboxId(id); navigate("/") }} onApply={(payload) => applyMailbox.mutateAsync(payload).then(() => undefined)} onCreateExternal={(payload) => createExternalImap.mutate(payload)} onUpdateExternal={(id, payload) => updateExternalImap.mutate({ id, payload })} onDeleteExternal={(id) => deleteExternalImap.mutate(id)} onTestExternal={(id) => testExternalImap.mutate(id)} onSyncExternal={(id) => syncExternalImap.mutate(id)} /> + if (tab === "mailboxes") return ( + { 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 })} + onUpdateExternal={(id, payload) => updateExternalImap.mutate({ id, payload })} + onDeleteExternal={(id) => deleteExternalImap.mutate(id)} + onTestExternal={(id) => testExternalImap.mutate(id)} + onSyncExternal={(id) => syncExternalImap.mutate(id)} + onSyncExternalFolder={(id, folder) => syncExternalImapFolder.mutate({ id, folder })} + /> + ) if (tab === "clients") return if (tab === "signatures") return createSignature.mutate(form)} onUpdate={(id, form) => updateSignature.mutate({ id, form })} onSetDefault={(id) => setDefaultSignature.mutate(id)} onDelete={(id) => deleteSignature.mutate(id)} /> if (tab === "contacts") return createContact.mutate(form)} onDelete={(id) => deleteContact.mutate(id)} onCopy={copy} /> @@ -518,15 +561,21 @@ function MailboxManagement({ selectedMailboxId, externalAccounts, externalPending, + selectedExternalRunAccountId, + externalRunFolders, + externalSyncRuns, + onSelectExternalRunAccount, onSelect, onCopy, onOpen, onApply, onCreateExternal, + onStartExternalOAuth, onUpdateExternal, onDeleteExternal, onTestExternal, onSyncExternal, + onSyncExternalFolder, }: { mailboxes: Mailbox[] applyOptions?: MailboxApplyOptions @@ -534,15 +583,21 @@ function MailboxManagement({ selectedMailboxId: string externalAccounts: ExternalImapAccount[] externalPending: boolean + selectedExternalRunAccountId: string + externalRunFolders: ExternalImapFolder[] + externalSyncRuns: ExternalImapSyncRun[] + onSelectExternalRunAccount: (id: string) => void onSelect: (id: string) => void onCopy: (text: string) => void onOpen: (id: string) => void onApply: (payload: { domainId: string; localPart: string; displayName: string }) => Promise onCreateExternal: (payload: ExternalImapAccountPayload) => void + onStartExternalOAuth: (provider: ExternalImapOAuthProvider, mailboxId: string) => void onUpdateExternal: (id: string, payload: ExternalImapAccountPayload) => void onDeleteExternal: (id: string) => void onTestExternal: (id: string) => void onSyncExternal: (id: string) => void + onSyncExternalFolder: (id: string, folder: string) => void }) { const canApply = !!applyOptions?.enabled && (applyOptions.domains || []).length > 0 const selectedMailbox = mailboxes.find((item) => item.id === selectedMailboxId) @@ -562,32 +617,43 @@ function MailboxManagement({ 外部 IMAP 接入
接入其他邮箱,可选择同步到本地,或每次打开时直接从远端读取。
- +
+ + + +
{!selectedMailbox && } {selectedMailbox && externalAccounts.length === 0 && } - {selectedMailbox && externalAccounts.map((account) => ( -
-
-
-
{account.name}
- {account.enabled ? "已启用" : "已停用"} - {account.storageMode === "local" ? "本地存储" : "远端直连"} + {selectedMailbox && externalAccounts.map((account) => { + const selectedForRuns = selectedExternalRunAccountId === account.id + return ( +
+
+
+
+
{account.name}
+ {account.enabled ? "已启用" : "已停用"} + {account.storageMode === "local" ? "本地存储" : "远端直连"} +
+
{account.username} · {account.host}:{account.port} · {account.tlsMode.toUpperCase()}
+
状态:{externalStatusLabel(account.lastStatus)}{account.lastSyncAt ? ` · 最近同步 ${formatDateTime(account.lastSyncAt)}` : ""}{account.lastError ? ` · ${account.lastError}` : ""}
+
+
+ + {account.storageMode === "local" && } + {account.storageMode === "local" && } + onUpdateExternal(account.id, payload)} /> + + +
-
{account.username} · {account.host}:{account.port} · {account.tlsMode.toUpperCase()}
-
状态:{externalStatusLabel(account.lastStatus)}{account.lastSyncAt ? ` · 最近同步 ${formatDateTime(account.lastSyncAt)}` : ""}{account.lastError ? ` · ${account.lastError}` : ""}
+ {selectedForRuns && }
-
- - {account.storageMode === "local" && } - onUpdateExternal(account.id, payload)} /> - - -
-
- ))} + ) + })}
@@ -722,6 +788,46 @@ function externalStatusLabel(status: string) { return ({ idle: "未同步", ok: "正常", partial: "部分成功", error: "错误", running: "同步中" } as Record)[status] || status || "未知" } +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(() => { + if (folder && folders.some((item) => item.name === folder)) return + setFolder(folders[0]?.name || "INBOX") + }, [folder, folders]) + return ( +
+
+ + + + +
+
+
最近同步记录
+ {runs.length === 0 &&
暂无同步记录
} + {runs.slice(0, 6).map((run) => ( +
+
+
+ {externalStatusLabel(run.status)} + {run.folder ? folderLabel(run.folder) : "全部文件夹"} +
+ {run.error &&
{run.error}
} +
+
+
导入 {run.imported} · 跳过 {run.skipped} · 失败 {run.failed}
+
{formatDateTime(run.startedAt)}
+
+
+ ))} +
+
+ ) +} + function formatDateTime(value: string) { const date = new Date(value) if (Number.isNaN(date.getTime())) return value diff --git a/deploy/.env.example b/deploy/.env.example index 49d4bbb..5de7bc1 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -141,6 +141,14 @@ LANQIN_EXTERNAL_IMAP_SYNC_SECONDS=300 # 是否允许用户配置 localhost / 内网 / link-local IMAP 主机。默认 false,避免 SSRF 风险。 LANQIN_EXTERNAL_IMAP_ALLOW_PRIVATE_HOSTS=false +# Gmail 外部 IMAP OAuth2。回调地址:${LANQIN_PUBLIC_BASE_URL}/api/external-imap-oauth/gmail/callback +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 +LANQIN_EXTERNAL_IMAP_OUTLOOK_CLIENT_ID= +LANQIN_EXTERNAL_IMAP_OUTLOOK_CLIENT_SECRET= + # ========================= # 系统 # ========================= diff --git a/deploy/README.md b/deploy/README.md index db3f459..7b84760 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 主机。 +- 用户可在个人邮箱管理中接入外部 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`。 - send-as v1 支持本人邮箱、启用的别名转发 source 指向本人邮箱,或数据库表 `send_as_grants` 中显式授权的地址。 ## 邮件客户端 TLS 证书