feat(mail): 增强外部 IMAP 接入能力。

- 新增 Gmail/Outlook OAuth2 授权接入、附件分片下载、服务端搜索与单文件夹同步历史。
- 扩展外部 IMAP 账户与同步任务数据结构,补充相关路由、配置和前端管理界面。
- 更新部署示例与文档,说明 OAuth 回调地址及后续邮件系统规划。
This commit is contained in:
LanQin_
2026-06-25 20:20:37 +08:00
parent 11734bf119
commit 39f5249008
14 changed files with 962 additions and 160 deletions
+2
View File
@@ -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_SECRET_KEY` | 外部 IMAP 密码加密密钥,启用接入前必须设置 | 随机长字符串 |
| `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_OUTLOOK_CLIENT_ID` / `LANQIN_EXTERNAL_IMAP_OUTLOOK_CLIENT_SECRET` | Outlook 外部 IMAP OAuth2,回调为 `/api/external-imap-oauth/outlook/callback` | 空 |
## 架构 ## 架构
+1
View File
@@ -24,6 +24,7 @@ require (
github.com/ncruces/go-strftime v0.1.9 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/net v0.26.0 // 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 golang.org/x/sys v0.23.0 // indirect
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
modernc.org/libc v1.55.3 // indirect modernc.org/libc v1.55.3 // indirect
+2
View File
@@ -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.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 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ=
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= 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-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.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+10
View File
@@ -357,6 +357,12 @@ func (a *App) migrate(ctx context.Context) error {
tls_mode TEXT NOT NULL CHECK(tls_mode IN ('tls','starttls','plain')), tls_mode TEXT NOT NULL CHECK(tls_mode IN ('tls','starttls','plain')),
username TEXT NOT NULL, username TEXT NOT NULL,
password_ciphertext 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')), storage_mode TEXT NOT NULL DEFAULT 'local' CHECK(storage_mode IN ('local','remote')),
sync_read_state INTEGER NOT NULL DEFAULT 1, sync_read_state INTEGER NOT NULL DEFAULT 1,
enabled 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 ( `CREATE TABLE IF NOT EXISTS external_imap_sync_runs (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
account_id TEXT NOT NULL REFERENCES external_imap_accounts(id) ON DELETE CASCADE, account_id TEXT NOT NULL REFERENCES external_imap_accounts(id) ON DELETE CASCADE,
folder TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL, status TEXT NOT NULL,
imported INTEGER NOT NULL DEFAULT 0, imported INTEGER NOT NULL DEFAULT 0,
skipped 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 { if err := a.migrateFolderSortOrder(ctx); err != nil {
return err return err
} }
if err := a.migrateExternalIMAP(ctx); err != nil {
return err
}
if err := a.ensureDefaultPermissionGroups(ctx); err != nil { if err := a.ensureDefaultPermissionGroups(ctx); err != nil {
return err return err
} }
+80 -72
View File
@@ -8,83 +8,91 @@ import (
) )
type Config struct { type Config struct {
Addr string Addr string
DBPath string DBPath string
DataDir string DataDir string
CookieName string CookieName string
SessionTTLHours int SessionTTLHours int
AdminEmail string AdminEmail string
AdminPassword string AdminPassword string
PublicHostname string PublicHostname string
PublicBaseURL string PublicBaseURL string
SMTPHost string SMTPHost string
SMTPPort string SMTPPort string
SMTPUsername string SMTPUsername string
SMTPPassword string SMTPPassword string
SMTPRequireTLS bool SMTPRequireTLS bool
SubmissionAddr string SubmissionAddr string
SubmissionTLSAddr string SubmissionTLSAddr string
SubmissionMaxMessageMB int SubmissionMaxMessageMB int
TLSCertFile string TLSCertFile string
TLSKeyFile string TLSKeyFile string
MaildirRoot string MaildirRoot string
MaildirScanSeconds int MaildirScanSeconds int
AllowInsecureHTTP bool AllowInsecureHTTP bool
OpenRegistration bool OpenRegistration bool
TwoFactorEnabled bool TwoFactorEnabled bool
TurnstileEnabled bool TurnstileEnabled bool
TurnstileSiteKey string TurnstileSiteKey string
TurnstileSecretKey string TurnstileSecretKey string
CatchAllEnabled bool CatchAllEnabled bool
MailAutoRefresh bool MailAutoRefresh bool
MailRefreshSeconds int MailRefreshSeconds int
UserMailboxApplyEnabled bool UserMailboxApplyEnabled bool
UserMailboxDomainIDs string UserMailboxDomainIDs string
ReservedMailboxPrefixes string ReservedMailboxPrefixes string
ExternalIMAPSecretKey string ExternalIMAPSecretKey string
ExternalIMAPSyncSeconds int ExternalIMAPSyncSeconds int
ExternalIMAPAllowPrivateHosts bool ExternalIMAPAllowPrivateHosts bool
ExternalIMAPGmailClientID string
ExternalIMAPGmailClientSecret string
ExternalIMAPOutlookClientID string
ExternalIMAPOutlookClientSecret string
} }
func LoadConfig() Config { func LoadConfig() Config {
dataDir := getenv("LANQIN_DATA_DIR", "./data") dataDir := getenv("LANQIN_DATA_DIR", "./data")
return Config{ return Config{
Addr: getenv("LANQIN_ADDR", ":8080"), Addr: getenv("LANQIN_ADDR", ":8080"),
DBPath: getenv("LANQIN_DB_PATH", filepath.Join(dataDir, "lanqin.db")), DBPath: getenv("LANQIN_DB_PATH", filepath.Join(dataDir, "lanqin.db")),
DataDir: dataDir, DataDir: dataDir,
CookieName: getenv("LANQIN_COOKIE_NAME", "lanqin_session"), CookieName: getenv("LANQIN_COOKIE_NAME", "lanqin_session"),
SessionTTLHours: getenvInt("LANQIN_SESSION_TTL_HOURS", 24*7), SessionTTLHours: getenvInt("LANQIN_SESSION_TTL_HOURS", 24*7),
AdminEmail: strings.ToLower(getenv("LANQIN_ADMIN_EMAIL", "admin@lanqin.local")), AdminEmail: strings.ToLower(getenv("LANQIN_ADMIN_EMAIL", "admin@lanqin.local")),
AdminPassword: getenv("LANQIN_ADMIN_PASSWORD", ""), AdminPassword: getenv("LANQIN_ADMIN_PASSWORD", ""),
PublicHostname: getenv("LANQIN_PUBLIC_HOSTNAME", "mail.lanqin.local"), PublicHostname: getenv("LANQIN_PUBLIC_HOSTNAME", "mail.lanqin.local"),
PublicBaseURL: getenv("LANQIN_PUBLIC_BASE_URL", "http://localhost:5173"), PublicBaseURL: getenv("LANQIN_PUBLIC_BASE_URL", "http://localhost:5173"),
SMTPHost: getenv("LANQIN_SMTP_HOST", ""), SMTPHost: getenv("LANQIN_SMTP_HOST", ""),
SMTPPort: getenv("LANQIN_SMTP_PORT", "25"), SMTPPort: getenv("LANQIN_SMTP_PORT", "25"),
SMTPUsername: getenv("LANQIN_SMTP_USERNAME", ""), SMTPUsername: getenv("LANQIN_SMTP_USERNAME", ""),
SMTPPassword: getenv("LANQIN_SMTP_PASSWORD", ""), SMTPPassword: getenv("LANQIN_SMTP_PASSWORD", ""),
SMTPRequireTLS: getenvBool("LANQIN_SMTP_REQUIRE_TLS", false), SMTPRequireTLS: getenvBool("LANQIN_SMTP_REQUIRE_TLS", false),
SubmissionAddr: getenv("LANQIN_SUBMISSION_ADDR", ""), SubmissionAddr: getenv("LANQIN_SUBMISSION_ADDR", ""),
SubmissionTLSAddr: getenv("LANQIN_SUBMISSION_TLS_ADDR", ""), SubmissionTLSAddr: getenv("LANQIN_SUBMISSION_TLS_ADDR", ""),
SubmissionMaxMessageMB: getenvInt("LANQIN_SUBMISSION_MAX_MESSAGE_MB", 35), SubmissionMaxMessageMB: getenvInt("LANQIN_SUBMISSION_MAX_MESSAGE_MB", 35),
TLSCertFile: getenv("LANQIN_TLS_CERT_FILE", ""), TLSCertFile: getenv("LANQIN_TLS_CERT_FILE", ""),
TLSKeyFile: getenv("LANQIN_TLS_KEY_FILE", ""), TLSKeyFile: getenv("LANQIN_TLS_KEY_FILE", ""),
MaildirRoot: getenv("LANQIN_MAILDIR_ROOT", ""), MaildirRoot: getenv("LANQIN_MAILDIR_ROOT", ""),
MaildirScanSeconds: getenvInt("LANQIN_MAILDIR_SCAN_SECONDS", 30), MaildirScanSeconds: getenvInt("LANQIN_MAILDIR_SCAN_SECONDS", 30),
AllowInsecureHTTP: getenvBool("LANQIN_ALLOW_INSECURE_HTTP", true), AllowInsecureHTTP: getenvBool("LANQIN_ALLOW_INSECURE_HTTP", true),
OpenRegistration: getenvBool("LANQIN_OPEN_REGISTRATION", false), OpenRegistration: getenvBool("LANQIN_OPEN_REGISTRATION", false),
TwoFactorEnabled: getenvBool("LANQIN_TWO_FACTOR_ENABLED", false), TwoFactorEnabled: getenvBool("LANQIN_TWO_FACTOR_ENABLED", false),
TurnstileEnabled: getenvBool("LANQIN_TURNSTILE_ENABLED", false), TurnstileEnabled: getenvBool("LANQIN_TURNSTILE_ENABLED", false),
TurnstileSiteKey: getenv("LANQIN_TURNSTILE_SITE_KEY", ""), TurnstileSiteKey: getenv("LANQIN_TURNSTILE_SITE_KEY", ""),
TurnstileSecretKey: getenv("LANQIN_TURNSTILE_SECRET_KEY", ""), TurnstileSecretKey: getenv("LANQIN_TURNSTILE_SECRET_KEY", ""),
CatchAllEnabled: getenvBool("LANQIN_CATCH_ALL_ENABLED", false), CatchAllEnabled: getenvBool("LANQIN_CATCH_ALL_ENABLED", false),
MailAutoRefresh: getenvBool("LANQIN_MAIL_AUTO_REFRESH", true), MailAutoRefresh: getenvBool("LANQIN_MAIL_AUTO_REFRESH", true),
MailRefreshSeconds: getenvInt("LANQIN_MAIL_REFRESH_SECONDS", 30), MailRefreshSeconds: getenvInt("LANQIN_MAIL_REFRESH_SECONDS", 30),
UserMailboxApplyEnabled: getenvBool("LANQIN_USER_MAILBOX_APPLY_ENABLED", false), UserMailboxApplyEnabled: getenvBool("LANQIN_USER_MAILBOX_APPLY_ENABLED", false),
UserMailboxDomainIDs: getenv("LANQIN_USER_MAILBOX_DOMAIN_IDS", ""), 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"), 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", ""), ExternalIMAPSecretKey: getenv("LANQIN_EXTERNAL_IMAP_SECRET_KEY", ""),
ExternalIMAPSyncSeconds: getenvInt("LANQIN_EXTERNAL_IMAP_SYNC_SECONDS", 300), ExternalIMAPSyncSeconds: getenvInt("LANQIN_EXTERNAL_IMAP_SYNC_SECONDS", 300),
ExternalIMAPAllowPrivateHosts: getenvBool("LANQIN_EXTERNAL_IMAP_ALLOW_PRIVATE_HOSTS", false), 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", ""),
} }
} }
+692 -41
View File
@@ -9,17 +9,24 @@ import (
"crypto/tls" "crypto/tls"
"database/sql" "database/sql"
"encoding/base64" "encoding/base64"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"io"
"mime"
"mime/quotedprintable"
"net" "net"
"net/http" "net/http"
"sort"
"strconv" "strconv"
"strings" "strings"
"time" "time"
"github.com/emersion/go-imap/v2" "github.com/emersion/go-imap/v2"
"github.com/emersion/go-imap/v2/imapclient" "github.com/emersion/go-imap/v2/imapclient"
"github.com/emersion/go-sasl"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"golang.org/x/oauth2"
) )
const ( const (
@@ -29,6 +36,10 @@ const (
externalIMAPStartTLS = "starttls" externalIMAPStartTLS = "starttls"
externalIMAPPlain = "plain" externalIMAPPlain = "plain"
externalIMAPMaxFetch = 30 externalIMAPMaxFetch = 30
externalIMAPAuthPassword = "password"
externalIMAPAuthOAuth2 = "oauth2"
externalIMAPOAuthGmail = "gmail"
externalIMAPOAuthOutlook = "outlook"
) )
type externalIMAPClientFactory interface { type externalIMAPClientFactory interface {
@@ -38,15 +49,19 @@ type externalIMAPClientFactory interface {
type externalIMAPClient interface { type externalIMAPClient interface {
Close() error Close() error
ListFolders(ctx context.Context) ([]externalIMAPRemoteFolder, 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) FetchNew(ctx context.Context, folder string, afterUID uint32, limit int) ([]externalIMAPRemoteMessage, error)
FetchRaw(ctx context.Context, folder string, uid uint32) ([]byte, 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 SetRead(ctx context.Context, folder string, uid uint32, read bool) error
} }
type externalIMAPAccountRecord struct { type externalIMAPAccountRecord struct {
ExternalIMAPAccount ExternalIMAPAccount
PasswordCiphertext string PasswordCiphertext string
OAuthAccessTokenCiphertext string
OAuthRefreshTokenCiphertext string
} }
type externalIMAPRemoteFolder struct { type externalIMAPRemoteFolder struct {
@@ -73,6 +88,12 @@ type externalIMAPRemoteMessage struct {
IsRead bool IsRead bool
SizeBytes int64 SizeBytes int64
Raw []byte Raw []byte
Attachments []Attachment
}
type externalIMAPAttachmentPart struct {
Attachment
Encoding string
} }
type externalIMAPPayload struct { type externalIMAPPayload struct {
@@ -88,6 +109,28 @@ type externalIMAPPayload struct {
Enabled *bool `json:"enabled"` 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) { func (a *App) externalIMAPWorker(ctx context.Context) {
interval := time.Duration(a.cfg.ExternalIMAPSyncSeconds) * time.Second interval := time.Duration(a.cfg.ExternalIMAPSyncSeconds) * time.Second
if interval <= 0 { if interval <= 0 {
@@ -136,7 +179,7 @@ func (a *App) handleListExternalIMAPAccounts(w http.ResponseWriter, r *http.Requ
where += " AND mailbox_id=?" where += " AND mailbox_id=?"
args = append(args, mailboxID) 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 { if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load external accounts") respondError(w, http.StatusInternalServerError, "failed to load external accounts")
return return
@@ -301,9 +344,186 @@ func (a *App) handleSyncExternalIMAPAccount(w http.ResponseWriter, r *http.Reque
respondJSON(w, http.StatusOK, run) 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) { func (a *App) handleMailExternalAccounts(w http.ResponseWriter, r *http.Request) {
user := currentUser(r) 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 { if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load external accounts") respondError(w, http.StatusInternalServerError, "failed to load external accounts")
return return
@@ -353,14 +573,15 @@ func (a *App) handleExternalIMAPMessages(w http.ResponseWriter, r *http.Request)
if folder == "" { if folder == "" {
folder = "INBOX" 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) client, err := a.externalIMAP.openExternalIMAPClient(r.Context(), account)
if err != nil { if err != nil {
respondError(w, http.StatusBadRequest, "connection failed: "+err.Error()) respondError(w, http.StatusBadRequest, "connection failed: "+err.Error())
return return
} }
defer client.Close() 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 { if err != nil {
respondError(w, http.StatusBadRequest, "failed to load remote messages") respondError(w, http.StatusBadRequest, "failed to load remote messages")
return return
@@ -398,11 +619,20 @@ func (a *App) handleExternalIMAPMessage(w http.ResponseWriter, r *http.Request)
msg.BodyText = stored.BodyText msg.BodyText = stored.BodyText
msg.BodyHTML = stored.BodyHTML msg.BodyHTML = stored.BodyHTML
msg.Snippet = stored.Snippet 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 { if len(attachments) > 0 {
msg.HasAttachments = true 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) respondJSON(w, http.StatusOK, msg)
} }
@@ -411,10 +641,7 @@ func (a *App) handleExternalIMAPAttachment(w http.ResponseWriter, r *http.Reques
if !ok { if !ok {
return return
} }
if chi.URLParam(r, "partId") != "raw" { partID := chi.URLParam(r, "partId")
respondError(w, http.StatusNotFound, "attachment not found")
return
}
folder, uid, ok := decodeExternalRemoteID(w, chi.URLParam(r, "remoteId")) folder, uid, ok := decodeExternalRemoteID(w, chi.URLParam(r, "remoteId"))
if !ok { if !ok {
return return
@@ -425,6 +652,18 @@ func (a *App) handleExternalIMAPAttachment(w http.ResponseWriter, r *http.Reques
return return
} }
defer client.Close() 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) raw, remote, err := client.FetchRaw(r.Context(), folder, uid)
if err != nil { if err != nil {
respondError(w, http.StatusBadRequest, "failed to load remote message") respondError(w, http.StatusBadRequest, "failed to load remote message")
@@ -615,8 +854,76 @@ func (a *App) externalIMAPKey() ([]byte, error) {
return sum[:], nil 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) { 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) return scanExternalIMAPAccount(row)
} }
@@ -637,12 +944,16 @@ type externalIMAPScanner interface {
func scanExternalIMAPAccount(row externalIMAPScanner) (externalIMAPAccountRecord, error) { func scanExternalIMAPAccount(row externalIMAPScanner) (externalIMAPAccountRecord, error) {
var item externalIMAPAccountRecord var item externalIMAPAccountRecord
var syncRead, enabled int var syncRead, enabled int
var lastSync sql.NullString var lastSync, oauthExpiry sql.NullString
var created, updated string 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 { if err != nil {
return item, err return item, err
} }
if item.AuthMode == "" {
item.AuthMode = externalIMAPAuthPassword
}
item.OAuthConfigured = item.AuthMode == externalIMAPAuthOAuth2 && item.OAuthAccessTokenCiphertext != ""
item.SyncReadState = syncRead != 0 item.SyncReadState = syncRead != 0
item.Enabled = enabled != 0 item.Enabled = enabled != 0
if lastSync.Valid && strings.TrimSpace(lastSync.String) != "" { 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) _, _ = 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) { func (a *App) syncExternalIMAPAccount(ctx context.Context, accountID string) (ExternalIMAPSyncRun, error) {
account, err := a.externalIMAPAccountByID(ctx, accountID) account, err := a.externalIMAPAccountByID(ctx, accountID)
if err != nil { 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") 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()} 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) client, err := a.externalIMAP.openExternalIMAPClient(ctx, account)
if err != nil { if err != nil {
return a.finishExternalIMAPRun(ctx, run, "failed", err) 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) 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) { 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) return scanExternalIMAPAccount(row)
} }
@@ -822,6 +1198,22 @@ func (a *App) finishExternalIMAPRun(ctx context.Context, run ExternalIMAPSyncRun
return run, err 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 { func trimExternalIMAPError(value string) string {
value = strings.TrimSpace(value) value = strings.TrimSpace(value)
if len(value) > 500 { if len(value) > 500 {
@@ -923,20 +1315,123 @@ func safeExternalEMLFilename(subject string) string {
return name + ".eml" 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) { func (a *App) openExternalIMAPClient(ctx context.Context, account externalIMAPAccountRecord) (externalIMAPClient, error) {
if err := a.validateExternalIMAPHost(ctx, account.Host); err != nil { if err := a.validateExternalIMAPHost(ctx, account.Host); err != nil {
return nil, err return nil, err
} }
password, err := a.decryptExternalIMAPPassword(account.PasswordCiphertext)
if err != nil {
return nil, err
}
addr := net.JoinHostPort(account.Host, strconv.Itoa(account.Port)) addr := net.JoinHostPort(account.Host, strconv.Itoa(account.Port))
options := &imapclient.Options{ options := &imapclient.Options{
Dialer: &net.Dialer{Timeout: 10 * time.Second}, Dialer: &net.Dialer{Timeout: 10 * time.Second},
TLSConfig: &tls.Config{ServerName: account.Host, MinVersion: tls.VersionTLS12}, TLSConfig: &tls.Config{ServerName: account.Host, MinVersion: tls.VersionTLS12},
} }
var c *imapclient.Client var c *imapclient.Client
var err error
switch account.TLSMode { switch account.TLSMode {
case externalIMAPTLS: case externalIMAPTLS:
c, err = imapclient.DialTLS(addr, options) c, err = imapclient.DialTLS(addr, options)
@@ -948,13 +1443,75 @@ func (a *App) openExternalIMAPClient(ctx context.Context, account externalIMAPAc
if err != nil { if err != nil {
return nil, err return nil, err
} }
if err := c.Login(account.Username, password).Wait(); err != nil { if account.AuthMode == externalIMAPAuthOAuth2 {
c.Close() token, err := a.externalIMAPOAuthAccessToken(ctx, account)
return nil, err 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 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 { type goExternalIMAPClient struct {
client *imapclient.Client client *imapclient.Client
} }
@@ -1006,42 +1563,65 @@ func mailboxHasNoSelect(attrs []imap.MailboxAttr) bool {
return false 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() selected, err := c.client.Select(folder, nil).Wait()
if err != nil { if err != nil {
return nil, "", err return nil, "", err
} }
if selected.NumMessages == 0 {
return nil, "", nil
}
if limit <= 0 || limit > 100 { if limit <= 0 || limit > 100 {
limit = externalIMAPMaxFetch limit = externalIMAPMaxFetch
} }
start := selected.NumMessages if selected.NumMessages == 0 {
if cursor > 0 {
start = cursor
}
if start == 0 {
return nil, "", nil return nil, "", nil
} }
stop := uint32(1) maxUID := uint32(0)
if start > uint32(limit) { if strings.TrimSpace(cursor) != "" {
stop = start - uint32(limit) + 1 parsed, _ := strconv.ParseUint(strings.TrimPrefix(cursor, "uid:"), 10, 32)
maxUID = uint32(parsed)
} }
var set imap.SeqSet if maxUID == 0 {
set.AddRange(stop, start) 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} 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() 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 { if err != nil {
return nil, "", err return nil, "", err
} }
out := []externalIMAPRemoteMessage{} out := []externalIMAPRemoteMessage{}
for i := len(messages) - 1; i >= 0 && len(out) < limit; i-- { for _, message := range messages {
out = append(out, fetchBufferToExternalMessage(folder, selected.UIDValidity, messages[i], nil)) 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 := "" next := ""
if stop > 1 { if len(uids) == limit {
next = strconv.FormatUint(uint64(stop-1), 10) last := uint32(uids[len(uids)-1])
if last > 1 {
next = "uid:" + strconv.FormatUint(uint64(last-1), 10)
}
} }
return out, next, nil 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 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 { func (c *goExternalIMAPClient) SetRead(ctx context.Context, folder string, uid uint32, read bool) error {
if _, err := c.client.Select(folder, nil).Wait(); err != nil { if _, err := c.client.Select(folder, nil).Wait(); err != nil {
return err return err
+4
View File
@@ -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)).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)).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)).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", 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.With(a.requireAuth).Get("/events", a.handleEvents)
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
+21 -16
View File
@@ -229,22 +229,26 @@ type MailStatsFolderCount struct {
} }
type ExternalIMAPAccount struct { type ExternalIMAPAccount struct {
ID string `json:"id"` ID string `json:"id"`
UserID string `json:"userId,omitempty"` UserID string `json:"userId,omitempty"`
MailboxID string `json:"mailboxId"` MailboxID string `json:"mailboxId"`
Name string `json:"name"` Name string `json:"name"`
Host string `json:"host"` Host string `json:"host"`
Port int `json:"port"` Port int `json:"port"`
TLSMode string `json:"tlsMode"` TLSMode string `json:"tlsMode"`
Username string `json:"username"` Username string `json:"username"`
StorageMode string `json:"storageMode"` AuthMode string `json:"authMode"`
SyncReadState bool `json:"syncReadState"` OAuthProvider string `json:"oauthProvider,omitempty"`
Enabled bool `json:"enabled"` OAuthEmail string `json:"oauthEmail,omitempty"`
LastSyncAt *time.Time `json:"lastSyncAt,omitempty"` OAuthConfigured bool `json:"oauthConfigured,omitempty"`
LastStatus string `json:"lastStatus"` StorageMode string `json:"storageMode"`
LastError string `json:"lastError,omitempty"` SyncReadState bool `json:"syncReadState"`
CreatedAt time.Time `json:"createdAt"` Enabled bool `json:"enabled"`
UpdatedAt time.Time `json:"updatedAt"` 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 { type ExternalIMAPFolder struct {
@@ -257,6 +261,7 @@ type ExternalIMAPFolder struct {
type ExternalIMAPSyncRun struct { type ExternalIMAPSyncRun struct {
ID string `json:"id"` ID string `json:"id"`
AccountID string `json:"accountId"` AccountID string `json:"accountId"`
Folder string `json:"folder,omitempty"`
Status string `json:"status"` Status string `json:"status"`
Imported int `json:"imported"` Imported int `json:"imported"`
Skipped int `json:"skipped"` Skipped int `json:"skipped"`
+3 -1
View File
@@ -127,8 +127,10 @@ 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 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 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 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 MailTemplate = { key: string; name: string; subject: string; bodyText: string; bodyHtml: string; updatedAt: string }
export type MailboxApplyOptions = { enabled: boolean; domains: Domain[]; reservedPrefixes?: string[] } export type MailboxApplyOptions = { enabled: boolean; domains: Domain[]; reservedPrefixes?: string[] }
export type MaildirSyncCounts = { filesScanned: number; imported: number; backfilled: number; cleaned: number; fileErrors: number } export type MaildirSyncCounts = { filesScanned: number; imported: number; backfilled: number; cleaned: number; fileErrors: number }
+6 -3
View File
@@ -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" export * from "./api-types"
const REQUEST_TIMEOUT_MS = 15_000 const REQUEST_TIMEOUT_MS = 15_000
@@ -65,8 +65,11 @@ export const api = {
createExternalImapAccount: (payload: ExternalImapAccountPayload) => request<ExternalImapAccount>("/api/me/external-imap-accounts", { method: "POST", body: JSON.stringify(payload) }), createExternalImapAccount: (payload: ExternalImapAccountPayload) => request<ExternalImapAccount>("/api/me/external-imap-accounts", { method: "POST", body: JSON.stringify(payload) }),
updateExternalImapAccount: (id: string, payload: ExternalImapAccountPayload) => request<ExternalImapAccount>(`/api/me/external-imap-accounts/${id}`, { method: "POST", body: JSON.stringify(payload) }), updateExternalImapAccount: (id: string, payload: ExternalImapAccountPayload) => request<ExternalImapAccount>(`/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" }), 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 }), 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<ListResponse<ExternalImapSyncRun>>(`/api/me/external-imap-accounts/${id}/runs`),
syncExternalImapAccount: (id: string) => request<ExternalImapSyncRun>(`/api/me/external-imap-accounts/${id}/sync`, { method: "POST", timeoutMs: MAIL_DELIVERY_TIMEOUT_MS }), syncExternalImapAccount: (id: string) => request<ExternalImapSyncRun>(`/api/me/external-imap-accounts/${id}/sync`, { method: "POST", timeoutMs: MAIL_DELIVERY_TIMEOUT_MS }),
syncExternalImapFolder: (id: string, folder: string) => request<ExternalImapSyncRun>(`/api/me/external-imap-accounts/${id}/sync-folder`, { method: "POST", body: JSON.stringify({ folder }), timeoutMs: MAIL_DELIVERY_TIMEOUT_MS }),
adminOverview: () => request<AdminOverview>("/api/admin/overview"), adminOverview: () => request<AdminOverview>("/api/admin/overview"),
users: () => request<ListResponse<AdminUser>>("/api/admin/users"), users: () => request<ListResponse<AdminUser>>("/api/admin/users"),
permissionGroups: () => request<ListResponse<PermissionGroup> & { catalog: PermissionInfo[] }>("/api/admin/permission-groups"), permissionGroups: () => request<ListResponse<PermissionGroup> & { catalog: PermissionInfo[] }>("/api/admin/permission-groups"),
@@ -123,8 +126,8 @@ export const api = {
myMailboxes: () => request<ListResponse<Mailbox>>("/api/mail/mailboxes"), myMailboxes: () => request<ListResponse<Mailbox>>("/api/mail/mailboxes"),
externalMailAccounts: () => request<ListResponse<ExternalImapAccount>>("/api/mail/external-accounts"), externalMailAccounts: () => request<ListResponse<ExternalImapAccount>>("/api/mail/external-accounts"),
externalFolders: (id: string) => request<ListResponse<ExternalImapFolder>>(`/api/mail/external-accounts/${id}/folders`), externalFolders: (id: string) => request<ListResponse<ExternalImapFolder>>(`/api/mail/external-accounts/${id}/folders`),
externalMessages: (id: string, folder: string, cursor = "") => { externalMessages: (id: string, folder: string, cursor = "", q = "") => {
const params = new URLSearchParams({ folder, cursor }) const params = new URLSearchParams({ folder, cursor, q })
return request<ListResponse<MailMessage>>(`/api/mail/external-accounts/${id}/messages?${params.toString()}`) return request<ListResponse<MailMessage>>(`/api/mail/external-accounts/${id}/messages?${params.toString()}`)
}, },
externalMessage: (id: string, remoteId: string) => request<MailMessage>(`/api/mail/external-accounts/${id}/messages/${encodeURIComponent(remoteId)}`), externalMessage: (id: string, remoteId: string) => request<MailMessage>(`/api/mail/external-accounts/${id}/messages/${encodeURIComponent(remoteId)}`),
+4 -4
View File
@@ -180,8 +180,8 @@ export function MailPage() {
enabled: !!activeMailboxId && canReadMail && mailView !== "scheduled" && mailView !== "sendQueue" && (mailView !== "label" || !!selectedLabelId), enabled: !!activeMailboxId && canReadMail && mailView !== "scheduled" && mailView !== "sendQueue" && (mailView !== "label" || !!selectedLabelId),
}) })
const externalMessages = useInfiniteQuery({ const externalMessages = useInfiniteQuery({
queryKey: ["external-messages", selectedExternalAccountId, externalFolder], queryKey: ["external-messages", selectedExternalAccountId, externalFolder, query],
queryFn: ({ pageParam }) => api.externalMessages(selectedExternalAccountId, externalFolder, typeof pageParam === "string" ? pageParam : ""), queryFn: ({ pageParam }) => api.externalMessages(selectedExternalAccountId, externalFolder, typeof pageParam === "string" ? pageParam : "", query),
initialPageParam: "", initialPageParam: "",
getNextPageParam: (lastPage) => lastPage.nextCursor || undefined, getNextPageParam: (lastPage) => lastPage.nextCursor || undefined,
enabled: !!selectedExternalAccountId && canReadMail && mailView === "external", enabled: !!selectedExternalAccountId && canReadMail && mailView === "external",
@@ -1326,7 +1326,7 @@ export function MailPage() {
{canSendMail && <Button type="button" size="icon" onClick={() => openCompose()} disabled={!selectedMailbox} aria-label="写邮件"><PencilLine className="h-4 w-4" /></Button>} {canSendMail && <Button type="button" size="icon" onClick={() => openCompose()} disabled={!selectedMailbox} aria-label="写邮件"><PencilLine className="h-4 w-4" /></Button>}
<div className="relative basis-full"> <div className="relative basis-full">
<Search className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" /> <Search className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
<Input value={query} onChange={(e) => setQuery(e.target.value)} disabled={mailView === "external"} placeholder={mailView === "external" ? "远端直连暂不支持搜索" : mailView === "sendQueue" ? "搜索发送队列" : mailView === "scheduled" ? "搜索待发送" : "搜索邮件"} className="h-10 pl-9" /> <Input value={query} onChange={(e) => setQuery(e.target.value)} placeholder={mailView === "external" ? "搜索远端邮件" : mailView === "sendQueue" ? "搜索发送队列" : mailView === "scheduled" ? "搜索待发送" : "搜索邮件"} className="h-10 pl-9" />
</div> </div>
</header> </header>
)} )}
@@ -1370,7 +1370,7 @@ export function MailPage() {
</div> </div>
<div className="relative w-full max-w-md"> <div className="relative w-full max-w-md">
<Search className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" /> <Search className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
<Input value={query} onChange={(e) => setQuery(e.target.value)} disabled={mailView === "external"} placeholder={mailView === "external" ? "远端直连暂不支持搜索" : mailView === "sendQueue" ? "搜索发送队列" : mailView === "scheduled" ? "搜索待发送" : "搜索邮件"} className="pl-9" /> <Input value={query} onChange={(e) => setQuery(e.target.value)} placeholder={mailView === "external" ? "搜索远端邮件" : mailView === "sendQueue" ? "搜索发送队列" : mailView === "scheduled" ? "搜索待发送" : "搜索邮件"} className="pl-9" />
</div> </div>
</header> </header>
{contentView} {contentView}
+128 -22
View File
@@ -4,7 +4,7 @@ import type { ImperativePanelHandle } from "react-resizable-panels"
import { useNavigate, useSearchParams } from "react-router-dom" 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 { 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 { 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 { cn, formatBytes } from "@/lib/utils"
import { applyTheme, getInitialTheme } from "@/lib/theme" import { applyTheme, getInitialTheme } from "@/lib/theme"
import { DisplayMode, useDisplayMode } from "@/lib/display-mode" import { DisplayMode, useDisplayMode } from "@/lib/display-mode"
@@ -64,6 +64,7 @@ export function ProfilePage() {
const [blockedMailboxId, setBlockedMailboxId] = React.useState("all") const [blockedMailboxId, setBlockedMailboxId] = React.useState("all")
const [ruleDialogOpen, setRuleDialogOpen] = React.useState(false) const [ruleDialogOpen, setRuleDialogOpen] = React.useState(false)
const [mobileSidebarOpen, setMobileSidebarOpen] = React.useState(false) const [mobileSidebarOpen, setMobileSidebarOpen] = React.useState(false)
const [externalRunAccountId, setExternalRunAccountId] = React.useState("")
const isMobile = useIsMobile() const isMobile = useIsMobile()
const themeMountedRef = React.useRef(false) 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 selectedMailbox = React.useMemo(() => mailboxes.data?.items.find((m) => m.id === mailboxId), [mailboxes.data?.items, mailboxId])
const activeMailboxId = selectedMailbox?.id || "" const activeMailboxId = selectedMailbox?.id || ""
const externalImapAccounts = useQuery({ queryKey: ["external-imap-accounts", activeMailboxId], queryFn: () => api.externalImapAccounts(activeMailboxId), enabled: !!activeMailboxId && canAccessMail }) 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 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 }) const stats = useQuery({ queryKey: ["mail-stats", activeMailboxId], queryFn: () => api.mailStats(activeMailboxId), enabled: !!activeMailboxId && canViewStats })
@@ -221,9 +230,19 @@ export function ProfilePage() {
}) })
const syncExternalImap = useMutation({ const syncExternalImap = useMutation({
mutationFn: api.syncExternalImapAccount, 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 }), 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(() => { React.useEffect(() => {
if (!mailboxes.isSuccess) return if (!mailboxes.isSuccess) return
@@ -317,7 +336,31 @@ export function ProfilePage() {
</div> </div>
) )
function renderTab() { function renderTab() {
if (tab === "mailboxes") return <MailboxManagement mailboxes={canAccessMail ? mailboxes.data?.items || [] : []} applyOptions={mailboxApplyOptions.data} applyPending={applyMailbox.isPending} selectedMailboxId={mailboxId} externalAccounts={externalImapAccounts.data?.items || []} externalPending={createExternalImap.isPending || updateExternalImap.isPending || deleteExternalImap.isPending || testExternalImap.isPending || syncExternalImap.isPending} onSelect={setMailboxId} onCopy={copy} onOpen={(id) => { 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 (
<MailboxManagement
mailboxes={canAccessMail ? mailboxes.data?.items || [] : []}
applyOptions={mailboxApplyOptions.data}
applyPending={applyMailbox.isPending}
selectedMailboxId={mailboxId}
externalAccounts={externalImapAccounts.data?.items || []}
externalPending={createExternalImap.isPending || updateExternalImap.isPending || deleteExternalImap.isPending || testExternalImap.isPending || syncExternalImap.isPending || syncExternalImapFolder.isPending || startExternalOAuth.isPending}
selectedExternalRunAccountId={externalRunAccountId}
externalRunFolders={externalRunFolders.data?.items || []}
externalSyncRuns={externalSyncRuns.data?.items || []}
onSelectExternalRunAccount={setExternalRunAccountId}
onSelect={setMailboxId}
onCopy={copy}
onOpen={(id) => { if (!canAccessMail) return; setMailboxId(id); navigate("/") }}
onApply={(payload) => applyMailbox.mutateAsync(payload).then(() => undefined)}
onCreateExternal={(payload) => createExternalImap.mutate(payload)}
onStartExternalOAuth={(provider, mailboxId) => startExternalOAuth.mutate({ provider, mailboxId })}
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 <ClientSettingsSection mailboxes={mailboxes.data?.items || []} selectedMailboxId={mailboxId} hostname={publicSettings.data?.publicHostname} onSelectMailbox={setMailboxId} onCopy={copy} /> if (tab === "clients") return <ClientSettingsSection mailboxes={mailboxes.data?.items || []} selectedMailboxId={mailboxId} hostname={publicSettings.data?.publicHostname} onSelectMailbox={setMailboxId} onCopy={copy} />
if (tab === "signatures") return <SignaturesSection items={signatures.data?.items || []} mailboxes={mailboxes.data?.items || []} loading={signatures.isLoading} pending={createSignature.isPending || updateSignature.isPending || setDefaultSignature.isPending || deleteSignature.isPending} onCreate={(form) => createSignature.mutate(form)} onUpdate={(id, form) => updateSignature.mutate({ id, form })} onSetDefault={(id) => setDefaultSignature.mutate(id)} onDelete={(id) => deleteSignature.mutate(id)} /> if (tab === "signatures") return <SignaturesSection items={signatures.data?.items || []} mailboxes={mailboxes.data?.items || []} loading={signatures.isLoading} pending={createSignature.isPending || updateSignature.isPending || setDefaultSignature.isPending || deleteSignature.isPending} onCreate={(form) => createSignature.mutate(form)} onUpdate={(id, form) => updateSignature.mutate({ id, form })} onSetDefault={(id) => setDefaultSignature.mutate(id)} onDelete={(id) => deleteSignature.mutate(id)} />
if (tab === "contacts") return <ContactsSection items={contacts.data?.items || []} loading={contacts.isLoading} pending={createContact.isPending} onCreate={(form) => createContact.mutate(form)} onDelete={(id) => deleteContact.mutate(id)} onCopy={copy} /> if (tab === "contacts") return <ContactsSection items={contacts.data?.items || []} loading={contacts.isLoading} pending={createContact.isPending} onCreate={(form) => createContact.mutate(form)} onDelete={(id) => deleteContact.mutate(id)} onCopy={copy} />
@@ -518,15 +561,21 @@ function MailboxManagement({
selectedMailboxId, selectedMailboxId,
externalAccounts, externalAccounts,
externalPending, externalPending,
selectedExternalRunAccountId,
externalRunFolders,
externalSyncRuns,
onSelectExternalRunAccount,
onSelect, onSelect,
onCopy, onCopy,
onOpen, onOpen,
onApply, onApply,
onCreateExternal, onCreateExternal,
onStartExternalOAuth,
onUpdateExternal, onUpdateExternal,
onDeleteExternal, onDeleteExternal,
onTestExternal, onTestExternal,
onSyncExternal, onSyncExternal,
onSyncExternalFolder,
}: { }: {
mailboxes: Mailbox[] mailboxes: Mailbox[]
applyOptions?: MailboxApplyOptions applyOptions?: MailboxApplyOptions
@@ -534,15 +583,21 @@ function MailboxManagement({
selectedMailboxId: string selectedMailboxId: string
externalAccounts: ExternalImapAccount[] externalAccounts: ExternalImapAccount[]
externalPending: boolean externalPending: boolean
selectedExternalRunAccountId: string
externalRunFolders: ExternalImapFolder[]
externalSyncRuns: ExternalImapSyncRun[]
onSelectExternalRunAccount: (id: string) => void
onSelect: (id: string) => void onSelect: (id: string) => void
onCopy: (text: string) => void onCopy: (text: string) => void
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
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
onSyncExternal: (id: string) => void onSyncExternal: (id: string) => void
onSyncExternalFolder: (id: string, folder: string) => void
}) { }) {
const canApply = !!applyOptions?.enabled && (applyOptions.domains || []).length > 0 const canApply = !!applyOptions?.enabled && (applyOptions.domains || []).length > 0
const selectedMailbox = mailboxes.find((item) => item.id === selectedMailboxId) const selectedMailbox = mailboxes.find((item) => item.id === selectedMailboxId)
@@ -562,32 +617,43 @@ function MailboxManagement({
<CardTitle> IMAP </CardTitle> <CardTitle> IMAP </CardTitle>
<div className="mt-1 text-sm text-muted-foreground"></div> <div className="mt-1 text-sm text-muted-foreground"></div>
</div> </div>
<ExternalImapDialog mailboxId={selectedMailboxId} disabled={!selectedMailbox} pending={externalPending} onSubmit={onCreateExternal} /> <div className="flex flex-wrap gap-2">
<Button type="button" variant="outline" disabled={!selectedMailbox || externalPending} onClick={() => onStartExternalOAuth("gmail", selectedMailboxId)}>Gmail OAuth</Button>
<Button type="button" variant="outline" disabled={!selectedMailbox || externalPending} onClick={() => onStartExternalOAuth("outlook", selectedMailboxId)}>Outlook OAuth</Button>
<ExternalImapDialog mailboxId={selectedMailboxId} disabled={!selectedMailbox} pending={externalPending} onSubmit={onCreateExternal} />
</div>
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
{!selectedMailbox && <EmptyState text="请先选择一个本地邮箱" />} {!selectedMailbox && <EmptyState text="请先选择一个本地邮箱" />}
{selectedMailbox && externalAccounts.length === 0 && <EmptyState text="暂无外部 IMAP 账号" />} {selectedMailbox && externalAccounts.length === 0 && <EmptyState text="暂无外部 IMAP 账号" />}
{selectedMailbox && externalAccounts.map((account) => ( {selectedMailbox && externalAccounts.map((account) => {
<div key={account.id} className="flex flex-wrap items-center justify-between gap-3 rounded-lg border p-3"> const selectedForRuns = selectedExternalRunAccountId === account.id
<div className="min-w-0"> return (
<div className="flex flex-wrap items-center gap-2"> <div key={account.id} className="space-y-3 rounded-lg border p-3">
<div className="truncate font-medium">{account.name}</div> <div className="flex flex-wrap items-center justify-between gap-3">
<Badge variant={account.enabled ? "secondary" : "outline"}>{account.enabled ? "已启用" : "已停用"}</Badge> <div className="min-w-0">
<Badge variant="outline">{account.storageMode === "local" ? "本地存储" : "远端直连"}</Badge> <div className="flex flex-wrap items-center gap-2">
<div className="truncate font-medium">{account.name}</div>
<Badge variant={account.enabled ? "secondary" : "outline"}>{account.enabled ? "已启用" : "已停用"}</Badge>
<Badge variant="outline">{account.storageMode === "local" ? "本地存储" : "远端直连"}</Badge>
</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 text-xs text-muted-foreground">{externalStatusLabel(account.lastStatus)}{account.lastSyncAt ? ` · 最近同步 ${formatDateTime(account.lastSyncAt)}` : ""}{account.lastError ? ` · ${account.lastError}` : ""}</div>
</div>
<div className="flex flex-wrap gap-2">
<Button type="button" variant="outline" size="sm" disabled={externalPending} onClick={() => onTestExternal(account.id)}><Link2 className="h-4 w-4" /></Button>
{account.storageMode === "local" && <Button type="button" variant="outline" size="sm" disabled={externalPending} onClick={() => onSyncExternal(account.id)}><RefreshCcw className="h-4 w-4" /></Button>}
{account.storageMode === "local" && <Button type="button" variant="ghost" size="sm" onClick={() => onSelectExternalRunAccount(selectedForRuns ? "" : account.id)}></Button>}
<ExternalImapDialog account={account} mailboxId={selectedMailboxId} pending={externalPending} onSubmit={(payload) => onUpdateExternal(account.id, payload)} />
<Button type="button" variant="outline" size="sm" disabled={externalPending} onClick={() => onUpdateExternal(account.id, { ...externalPayloadFromAccount(account), enabled: !account.enabled })}>{account.enabled ? "停用" : "启用"}</Button>
<Button type="button" variant="destructive" size="sm" disabled={externalPending} onClick={() => onDeleteExternal(account.id)}></Button>
</div>
</div> </div>
<div className="mt-1 truncate text-xs text-muted-foreground">{account.username} · {account.host}:{account.port} · {account.tlsMode.toUpperCase()}</div> {selectedForRuns && <ExternalImapSyncPanel account={account} folders={externalRunFolders} runs={externalSyncRuns} pending={externalPending} onSyncFolder={onSyncExternalFolder} />}
<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"> )
<Button type="button" variant="outline" size="sm" disabled={externalPending} onClick={() => onTestExternal(account.id)}><Link2 className="h-4 w-4" /></Button> })}
{account.storageMode === "local" && <Button type="button" variant="outline" size="sm" disabled={externalPending} onClick={() => onSyncExternal(account.id)}><RefreshCcw className="h-4 w-4" /></Button>}
<ExternalImapDialog account={account} mailboxId={selectedMailboxId} pending={externalPending} onSubmit={(payload) => onUpdateExternal(account.id, payload)} />
<Button type="button" variant="outline" size="sm" disabled={externalPending} onClick={() => onUpdateExternal(account.id, { ...externalPayloadFromAccount(account), enabled: !account.enabled })}>{account.enabled ? "停用" : "启用"}</Button>
<Button type="button" variant="destructive" size="sm" disabled={externalPending} onClick={() => onDeleteExternal(account.id)}></Button>
</div>
</div>
))}
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
@@ -722,6 +788,46 @@ 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 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 (
<div className="rounded-lg bg-muted/40 p-3">
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr)_auto] md:items-end">
<Field label="单文件夹同步">
<Select value={folder} onValueChange={setFolder}>
<SelectTrigger><SelectValue placeholder="选择远端文件夹" /></SelectTrigger>
<SelectContent>{folders.map((item) => <SelectItem key={item.name} value={item.name}>{folderLabel(item.name)}</SelectItem>)}</SelectContent>
</Select>
</Field>
<Button type="button" variant="outline" disabled={pending || !folder} onClick={() => onSyncFolder(account.id, folder)}><RefreshCcw className="h-4 w-4" /></Button>
</div>
<div className="mt-3 space-y-2">
<div className="text-xs font-medium text-muted-foreground"></div>
{runs.length === 0 && <div className="rounded-md border bg-background p-3 text-sm text-muted-foreground"></div>}
{runs.slice(0, 6).map((run) => (
<div key={run.id} className="grid gap-2 rounded-md border bg-background p-3 text-sm md:grid-cols-[minmax(0,1fr)_auto] md:items-center">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<Badge variant={run.status === "ok" ? "secondary" : run.status === "failed" ? "destructive" : "outline"}>{externalStatusLabel(run.status)}</Badge>
<span className="truncate">{run.folder ? folderLabel(run.folder) : "全部文件夹"}</span>
</div>
{run.error && <div className="mt-1 truncate text-xs text-destructive">{run.error}</div>}
</div>
<div className="text-xs text-muted-foreground md:text-right">
<div> {run.imported} · {run.skipped} · {run.failed}</div>
<div>{formatDateTime(run.startedAt)}</div>
</div>
</div>
))}
</div>
</div>
)
}
function formatDateTime(value: string) { function formatDateTime(value: string) {
const date = new Date(value) const date = new Date(value)
if (Number.isNaN(date.getTime())) return value if (Number.isNaN(date.getTime())) return value
+8
View File
@@ -141,6 +141,14 @@ LANQIN_EXTERNAL_IMAP_SYNC_SECONDS=300
# 是否允许用户配置 localhost / 内网 / link-local IMAP 主机。默认 false,避免 SSRF 风险。 # 是否允许用户配置 localhost / 内网 / link-local IMAP 主机。默认 false,避免 SSRF 风险。
LANQIN_EXTERNAL_IMAP_ALLOW_PRIVATE_HOSTS=false 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=
# ========================= # =========================
# 系统 # 系统
# ========================= # =========================
+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 主机。 - 用户可在个人邮箱管理中接入外部 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` 中显式授权的地址。 - send-as v1 支持本人邮箱、启用的别名转发 source 指向本人邮箱,或数据库表 `send_as_grants` 中显式授权的地址。
## 邮件客户端 TLS 证书 ## 邮件客户端 TLS 证书