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
} }
+8
View File
@@ -44,6 +44,10 @@ type Config struct {
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 {
@@ -85,6 +89,10 @@ func LoadConfig() Config {
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", ""),
} }
} }
+688 -37
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,12 +1315,114 @@ func safeExternalEMLFilename(subject string) string {
return name + ".eml" return name + ".eml"
} }
func (a *App) openExternalIMAPClient(ctx context.Context, account externalIMAPAccountRecord) (externalIMAPClient, error) { func externalIMAPAttachmentsFromBodyStructure(body imap.BodyStructure) []Attachment {
if err := a.validateExternalIMAPHost(ctx, account.Host); err != nil { 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 nil, err
} }
password, err := a.decryptExternalIMAPPassword(account.PasswordCiphertext) return decoded[:n], nil
if err != nil { case "quoted-printable":
return io.ReadAll(quotedprintable.NewReader(strings.NewReader(string(data))))
default:
return data, nil
}
}
func escapeDownloadFilename(name string) string {
name = strings.TrimSpace(name)
if name == "" {
name = "attachment"
}
name = strings.Map(func(r rune) rune {
if r < 32 || r == '"' || r == '\\' {
return '-'
}
return r
}, name)
if len([]rune(name)) > 120 {
name = string([]rune(name)[:120])
}
return mime.QEncoding.Encode("utf-8", name)
}
func (a *App) openExternalIMAPClient(ctx context.Context, account externalIMAPAccountRecord) (externalIMAPClient, error) {
if err := a.validateExternalIMAPHost(ctx, account.Host); err != nil {
return nil, err return nil, err
} }
addr := net.JoinHostPort(account.Host, strconv.Itoa(account.Port)) addr := net.JoinHostPort(account.Host, strconv.Itoa(account.Port))
@@ -937,6 +1431,7 @@ func (a *App) openExternalIMAPClient(ctx context.Context, account externalIMAPAc
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 account.AuthMode == externalIMAPAuthOAuth2 {
token, err := a.externalIMAPOAuthAccessToken(ctx, account)
if err != nil {
c.Close()
return nil, err
}
if err := c.Authenticate(sasl.NewOAuthBearerClient(&sasl.OAuthBearerOptions{Username: account.Username, Token: token, Host: account.Host, Port: account.Port})); err != nil {
c.Close()
return nil, err
}
} else {
password, err := a.decryptExternalIMAPPassword(account.PasswordCiphertext)
if err != nil {
c.Close()
return nil, err
}
if err := c.Login(account.Username, password).Wait(); err != nil { if err := c.Login(account.Username, password).Wait(); err != nil {
c.Close() c.Close()
return nil, err 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) {
+5
View File
@@ -237,6 +237,10 @@ type ExternalIMAPAccount struct {
Port int `json:"port"` Port int `json:"port"`
TLSMode string `json:"tlsMode"` TLSMode string `json:"tlsMode"`
Username string `json:"username"` Username string `json:"username"`
AuthMode string `json:"authMode"`
OAuthProvider string `json:"oauthProvider,omitempty"`
OAuthEmail string `json:"oauthEmail,omitempty"`
OAuthConfigured bool `json:"oauthConfigured,omitempty"`
StorageMode string `json:"storageMode"` StorageMode string `json:"storageMode"`
SyncReadState bool `json:"syncReadState"` SyncReadState bool `json:"syncReadState"`
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
@@ -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}
+112 -6
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,14 +617,21 @@ 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>
<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} /> <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
return (
<div key={account.id} className="space-y-3 rounded-lg border p-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0"> <div className="min-w-0">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<div className="truncate font-medium">{account.name}</div> <div className="truncate font-medium">{account.name}</div>
@@ -582,12 +644,16 @@ function MailboxManagement({
<div className="flex flex-wrap gap-2"> <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> <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="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)} /> <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="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> <Button type="button" variant="destructive" size="sm" disabled={externalPending} onClick={() => onDeleteExternal(account.id)}></Button>
</div> </div>
</div> </div>
))} {selectedForRuns && <ExternalImapSyncPanel account={account} folders={externalRunFolders} runs={externalSyncRuns} pending={externalPending} onSyncFolder={onSyncExternalFolder} />}
</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 证书