feat(mail): 增强外部 IMAP 接入能力。
- 新增 Gmail/Outlook OAuth2 授权接入、附件分片下载、服务端搜索与单文件夹同步历史。 - 扩展外部 IMAP 账户与同步任务数据结构,补充相关路由、配置和前端管理界面。 - 更新部署示例与文档,说明 OAuth 回调地址及后续邮件系统规划。
This commit is contained in:
@@ -157,6 +157,8 @@ docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build
|
||||
| `LANQIN_EXTERNAL_IMAP_SECRET_KEY` | 外部 IMAP 密码加密密钥,启用接入前必须设置 | 随机长字符串 |
|
||||
| `LANQIN_EXTERNAL_IMAP_SYNC_SECONDS` | 外部 IMAP 本地存储模式同步间隔 | `300` |
|
||||
| `LANQIN_EXTERNAL_IMAP_ALLOW_PRIVATE_HOSTS` | 是否允许外部 IMAP 连接内网/localhost 主机 | `false` |
|
||||
| `LANQIN_EXTERNAL_IMAP_GMAIL_CLIENT_ID` / `LANQIN_EXTERNAL_IMAP_GMAIL_CLIENT_SECRET` | Gmail 外部 IMAP OAuth2,回调为 `/api/external-imap-oauth/gmail/callback` | 空 |
|
||||
| `LANQIN_EXTERNAL_IMAP_OUTLOOK_CLIENT_ID` / `LANQIN_EXTERNAL_IMAP_OUTLOOK_CLIENT_SECRET` | Outlook 外部 IMAP OAuth2,回调为 `/api/external-imap-oauth/outlook/callback` | 空 |
|
||||
|
||||
## 架构
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ require (
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/net v0.26.0 // indirect
|
||||
golang.org/x/oauth2 v0.36.0 // indirect
|
||||
golang.org/x/sys v0.23.0 // indirect
|
||||
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
|
||||
modernc.org/libc v1.55.3 // indirect
|
||||
|
||||
@@ -45,6 +45,8 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ=
|
||||
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
|
||||
@@ -357,6 +357,12 @@ func (a *App) migrate(ctx context.Context) error {
|
||||
tls_mode TEXT NOT NULL CHECK(tls_mode IN ('tls','starttls','plain')),
|
||||
username TEXT NOT NULL,
|
||||
password_ciphertext TEXT NOT NULL,
|
||||
auth_mode TEXT NOT NULL DEFAULT 'password' CHECK(auth_mode IN ('password','oauth2')),
|
||||
oauth_provider TEXT NOT NULL DEFAULT '',
|
||||
oauth_email TEXT NOT NULL DEFAULT '',
|
||||
oauth_access_token_ciphertext TEXT NOT NULL DEFAULT '',
|
||||
oauth_refresh_token_ciphertext TEXT NOT NULL DEFAULT '',
|
||||
oauth_expiry TEXT,
|
||||
storage_mode TEXT NOT NULL DEFAULT 'local' CHECK(storage_mode IN ('local','remote')),
|
||||
sync_read_state INTEGER NOT NULL DEFAULT 1,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
@@ -395,6 +401,7 @@ func (a *App) migrate(ctx context.Context) error {
|
||||
`CREATE TABLE IF NOT EXISTS external_imap_sync_runs (
|
||||
id TEXT PRIMARY KEY,
|
||||
account_id TEXT NOT NULL REFERENCES external_imap_accounts(id) ON DELETE CASCADE,
|
||||
folder TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL,
|
||||
imported INTEGER NOT NULL DEFAULT 0,
|
||||
skipped INTEGER NOT NULL DEFAULT 0,
|
||||
@@ -512,6 +519,9 @@ func (a *App) migrate(ctx context.Context) error {
|
||||
if err := a.migrateFolderSortOrder(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.migrateExternalIMAP(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.ensureDefaultPermissionGroups(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -44,6 +44,10 @@ type Config struct {
|
||||
ExternalIMAPSecretKey string
|
||||
ExternalIMAPSyncSeconds int
|
||||
ExternalIMAPAllowPrivateHosts bool
|
||||
ExternalIMAPGmailClientID string
|
||||
ExternalIMAPGmailClientSecret string
|
||||
ExternalIMAPOutlookClientID string
|
||||
ExternalIMAPOutlookClientSecret string
|
||||
}
|
||||
|
||||
func LoadConfig() Config {
|
||||
@@ -85,6 +89,10 @@ func LoadConfig() Config {
|
||||
ExternalIMAPSecretKey: getenv("LANQIN_EXTERNAL_IMAP_SECRET_KEY", ""),
|
||||
ExternalIMAPSyncSeconds: getenvInt("LANQIN_EXTERNAL_IMAP_SYNC_SECONDS", 300),
|
||||
ExternalIMAPAllowPrivateHosts: getenvBool("LANQIN_EXTERNAL_IMAP_ALLOW_PRIVATE_HOSTS", false),
|
||||
ExternalIMAPGmailClientID: getenv("LANQIN_EXTERNAL_IMAP_GMAIL_CLIENT_ID", ""),
|
||||
ExternalIMAPGmailClientSecret: getenv("LANQIN_EXTERNAL_IMAP_GMAIL_CLIENT_SECRET", ""),
|
||||
ExternalIMAPOutlookClientID: getenv("LANQIN_EXTERNAL_IMAP_OUTLOOK_CLIENT_ID", ""),
|
||||
ExternalIMAPOutlookClientSecret: getenv("LANQIN_EXTERNAL_IMAP_OUTLOOK_CLIENT_SECRET", ""),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,17 +9,24 @@ import (
|
||||
"crypto/tls"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"mime/quotedprintable"
|
||||
"net"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/emersion/go-imap/v2"
|
||||
"github.com/emersion/go-imap/v2/imapclient"
|
||||
"github.com/emersion/go-sasl"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -29,6 +36,10 @@ const (
|
||||
externalIMAPStartTLS = "starttls"
|
||||
externalIMAPPlain = "plain"
|
||||
externalIMAPMaxFetch = 30
|
||||
externalIMAPAuthPassword = "password"
|
||||
externalIMAPAuthOAuth2 = "oauth2"
|
||||
externalIMAPOAuthGmail = "gmail"
|
||||
externalIMAPOAuthOutlook = "outlook"
|
||||
)
|
||||
|
||||
type externalIMAPClientFactory interface {
|
||||
@@ -38,15 +49,19 @@ type externalIMAPClientFactory interface {
|
||||
type externalIMAPClient interface {
|
||||
Close() error
|
||||
ListFolders(ctx context.Context) ([]externalIMAPRemoteFolder, error)
|
||||
FetchSummaries(ctx context.Context, folder string, cursor uint32, limit int) ([]externalIMAPRemoteMessage, string, error)
|
||||
FetchSummaries(ctx context.Context, folder string, query string, cursor string, limit int) ([]externalIMAPRemoteMessage, string, error)
|
||||
FetchNew(ctx context.Context, folder string, afterUID uint32, limit int) ([]externalIMAPRemoteMessage, error)
|
||||
FetchRaw(ctx context.Context, folder string, uid uint32) ([]byte, externalIMAPRemoteMessage, error)
|
||||
FetchAttachments(ctx context.Context, folder string, uid uint32) ([]Attachment, error)
|
||||
FetchPart(ctx context.Context, folder string, uid uint32, partID string) ([]byte, Attachment, error)
|
||||
SetRead(ctx context.Context, folder string, uid uint32, read bool) error
|
||||
}
|
||||
|
||||
type externalIMAPAccountRecord struct {
|
||||
ExternalIMAPAccount
|
||||
PasswordCiphertext string
|
||||
OAuthAccessTokenCiphertext string
|
||||
OAuthRefreshTokenCiphertext string
|
||||
}
|
||||
|
||||
type externalIMAPRemoteFolder struct {
|
||||
@@ -73,6 +88,12 @@ type externalIMAPRemoteMessage struct {
|
||||
IsRead bool
|
||||
SizeBytes int64
|
||||
Raw []byte
|
||||
Attachments []Attachment
|
||||
}
|
||||
|
||||
type externalIMAPAttachmentPart struct {
|
||||
Attachment
|
||||
Encoding string
|
||||
}
|
||||
|
||||
type externalIMAPPayload struct {
|
||||
@@ -88,6 +109,28 @@ type externalIMAPPayload struct {
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
|
||||
type externalIMAPOAuthStartPayload struct {
|
||||
MailboxID string `json:"mailboxId"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
StorageMode string `json:"storageMode"`
|
||||
SyncReadState *bool `json:"syncReadState"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
|
||||
type externalIMAPOAuthState struct {
|
||||
UserID string `json:"userId"`
|
||||
MailboxID string `json:"mailboxId"`
|
||||
Provider string `json:"provider"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
StorageMode string `json:"storageMode"`
|
||||
SyncReadState bool `json:"syncReadState"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Nonce string `json:"nonce"`
|
||||
ExpiresAt int64 `json:"expiresAt"`
|
||||
}
|
||||
|
||||
func (a *App) externalIMAPWorker(ctx context.Context) {
|
||||
interval := time.Duration(a.cfg.ExternalIMAPSyncSeconds) * time.Second
|
||||
if interval <= 0 {
|
||||
@@ -136,7 +179,7 @@ func (a *App) handleListExternalIMAPAccounts(w http.ResponseWriter, r *http.Requ
|
||||
where += " AND mailbox_id=?"
|
||||
args = append(args, mailboxID)
|
||||
}
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT id,user_id,mailbox_id,name,host,port,tls_mode,username,password_ciphertext,storage_mode,sync_read_state,enabled,last_sync_at,last_status,last_error,created_at,updated_at FROM external_imap_accounts WHERE `+where+` ORDER BY created_at DESC`, args...)
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT id,user_id,mailbox_id,name,host,port,tls_mode,username,password_ciphertext,auth_mode,oauth_provider,oauth_email,oauth_access_token_ciphertext,oauth_refresh_token_ciphertext,oauth_expiry,storage_mode,sync_read_state,enabled,last_sync_at,last_status,last_error,created_at,updated_at FROM external_imap_accounts WHERE `+where+` ORDER BY created_at DESC`, args...)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load external accounts")
|
||||
return
|
||||
@@ -301,9 +344,186 @@ func (a *App) handleSyncExternalIMAPAccount(w http.ResponseWriter, r *http.Reque
|
||||
respondJSON(w, http.StatusOK, run)
|
||||
}
|
||||
|
||||
func (a *App) handleExternalIMAPSyncRuns(w http.ResponseWriter, r *http.Request) {
|
||||
user := currentUser(r)
|
||||
account, err := a.externalIMAPAccountForUser(r.Context(), user.ID, chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
respondError(w, http.StatusNotFound, "external account not found")
|
||||
return
|
||||
}
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT id,account_id,folder,status,imported,skipped,failed,error,started_at,finished_at FROM external_imap_sync_runs WHERE account_id=? ORDER BY started_at DESC LIMIT 20`, account.ID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load sync runs")
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []ExternalIMAPSyncRun{}
|
||||
for rows.Next() {
|
||||
run, err := scanExternalIMAPSyncRun(rows)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to scan sync runs")
|
||||
return
|
||||
}
|
||||
items = append(items, run)
|
||||
}
|
||||
respondJSON(w, http.StatusOK, map[string]any{"items": items})
|
||||
}
|
||||
|
||||
func (a *App) handleSyncExternalIMAPFolder(w http.ResponseWriter, r *http.Request) {
|
||||
user := currentUser(r)
|
||||
account, err := a.externalIMAPAccountForUser(r.Context(), user.ID, chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
respondError(w, http.StatusNotFound, "external account not found")
|
||||
return
|
||||
}
|
||||
if account.StorageMode != externalIMAPStorageLocal {
|
||||
badRequest(w, errors.New("remote storage accounts do not sync into local mailbox"))
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Folder string `json:"folder"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
folder := strings.TrimSpace(req.Folder)
|
||||
if folder == "" {
|
||||
badRequest(w, errors.New("folder is required"))
|
||||
return
|
||||
}
|
||||
run, err := a.syncExternalIMAPAccountFolder(r.Context(), account.ID, folder)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, run)
|
||||
}
|
||||
|
||||
func (a *App) handleStartExternalIMAPOAuth(w http.ResponseWriter, r *http.Request) {
|
||||
user := currentUser(r)
|
||||
provider := strings.ToLower(strings.TrimSpace(chi.URLParam(r, "provider")))
|
||||
conf, profile, err := a.externalIMAPOAuthConfig(provider)
|
||||
if err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
var req externalIMAPOAuthStartPayload
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
mb, err := a.mailboxForUserByID(r.Context(), user.ID, strings.TrimSpace(req.MailboxID))
|
||||
if err != nil {
|
||||
respondError(w, http.StatusNotFound, "mailbox not found")
|
||||
return
|
||||
}
|
||||
email := strings.TrimSpace(req.Email)
|
||||
if email == "" {
|
||||
email = mb.Address
|
||||
}
|
||||
req.StorageMode = strings.ToLower(strings.TrimSpace(req.StorageMode))
|
||||
if req.StorageMode == "" {
|
||||
req.StorageMode = externalIMAPStorageLocal
|
||||
}
|
||||
if req.StorageMode != externalIMAPStorageLocal && req.StorageMode != externalIMAPStorageRemote {
|
||||
badRequest(w, errors.New("invalid storage mode"))
|
||||
return
|
||||
}
|
||||
syncRead := true
|
||||
if req.SyncReadState != nil {
|
||||
syncRead = *req.SyncReadState
|
||||
}
|
||||
enabled := true
|
||||
if req.Enabled != nil {
|
||||
enabled = *req.Enabled
|
||||
}
|
||||
state := externalIMAPOAuthState{
|
||||
UserID: user.ID,
|
||||
MailboxID: mb.ID,
|
||||
Provider: provider,
|
||||
Name: strings.Join(strings.Fields(req.Name), " "),
|
||||
Email: email,
|
||||
StorageMode: req.StorageMode,
|
||||
SyncReadState: syncRead,
|
||||
Enabled: enabled,
|
||||
Nonce: newID("oauth"),
|
||||
ExpiresAt: a.now().Add(10 * time.Minute).Unix(),
|
||||
}
|
||||
if state.Name == "" {
|
||||
state.Name = profile.Name
|
||||
}
|
||||
stateValue, err := a.encryptExternalIMAPOAuthState(state)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, map[string]any{"url": conf.AuthCodeURL(stateValue, oauth2.AccessTypeOffline)})
|
||||
}
|
||||
|
||||
func (a *App) handleExternalIMAPOAuthCallback(w http.ResponseWriter, r *http.Request) {
|
||||
provider := strings.ToLower(strings.TrimSpace(chi.URLParam(r, "provider")))
|
||||
conf, profile, err := a.externalIMAPOAuthConfig(provider)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
state, err := a.decryptExternalIMAPOAuthState(r.URL.Query().Get("state"))
|
||||
if err != nil || state.Provider != provider || state.ExpiresAt < a.now().Unix() {
|
||||
respondError(w, http.StatusBadRequest, "invalid oauth state")
|
||||
return
|
||||
}
|
||||
if oauthErr := strings.TrimSpace(r.URL.Query().Get("error")); oauthErr != "" {
|
||||
respondError(w, http.StatusBadRequest, "oauth failed: "+oauthErr)
|
||||
return
|
||||
}
|
||||
code := strings.TrimSpace(r.URL.Query().Get("code"))
|
||||
if code == "" {
|
||||
respondError(w, http.StatusBadRequest, "missing oauth code")
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
|
||||
defer cancel()
|
||||
token, err := conf.Exchange(ctx, code)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusBadRequest, "oauth exchange failed")
|
||||
return
|
||||
}
|
||||
accessCipher, err := a.encryptExternalIMAPPassword(token.AccessToken)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
refreshCipher := ""
|
||||
if token.RefreshToken != "" {
|
||||
refreshCipher, err = a.encryptExternalIMAPPassword(token.RefreshToken)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
name := state.Name
|
||||
if name == "" {
|
||||
name = profile.Name
|
||||
}
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
expiry := ""
|
||||
if !token.Expiry.IsZero() {
|
||||
expiry = token.Expiry.UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
id := newID("ximap")
|
||||
_, err = a.db.ExecContext(r.Context(), `INSERT INTO external_imap_accounts(id,user_id,mailbox_id,name,host,port,tls_mode,username,password_ciphertext,auth_mode,oauth_provider,oauth_email,oauth_access_token_ciphertext,oauth_refresh_token_ciphertext,oauth_expiry,storage_mode,sync_read_state,enabled,last_status,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, id, state.UserID, state.MailboxID, name, profile.Host, profile.Port, externalIMAPTLS, state.Email, "", externalIMAPAuthOAuth2, provider, state.Email, accessCipher, refreshCipher, expiry, state.StorageMode, boolInt(state.SyncReadState), boolInt(state.Enabled), "idle", now, now)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to save oauth account")
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, strings.TrimRight(a.cfg.PublicBaseURL, "/")+"/profile?tab=mailboxes", http.StatusFound)
|
||||
}
|
||||
|
||||
func (a *App) handleMailExternalAccounts(w http.ResponseWriter, r *http.Request) {
|
||||
user := currentUser(r)
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT id,user_id,mailbox_id,name,host,port,tls_mode,username,password_ciphertext,storage_mode,sync_read_state,enabled,last_sync_at,last_status,last_error,created_at,updated_at FROM external_imap_accounts WHERE user_id=? AND enabled=1 ORDER BY name`, user.ID)
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT id,user_id,mailbox_id,name,host,port,tls_mode,username,password_ciphertext,auth_mode,oauth_provider,oauth_email,oauth_access_token_ciphertext,oauth_refresh_token_ciphertext,oauth_expiry,storage_mode,sync_read_state,enabled,last_sync_at,last_status,last_error,created_at,updated_at FROM external_imap_accounts WHERE user_id=? AND enabled=1 ORDER BY name`, user.ID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load external accounts")
|
||||
return
|
||||
@@ -353,14 +573,15 @@ func (a *App) handleExternalIMAPMessages(w http.ResponseWriter, r *http.Request)
|
||||
if folder == "" {
|
||||
folder = "INBOX"
|
||||
}
|
||||
cursor, _ := strconv.ParseUint(strings.TrimSpace(r.URL.Query().Get("cursor")), 10, 32)
|
||||
query := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||
cursor := strings.TrimSpace(r.URL.Query().Get("cursor"))
|
||||
client, err := a.externalIMAP.openExternalIMAPClient(r.Context(), account)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusBadRequest, "connection failed: "+err.Error())
|
||||
return
|
||||
}
|
||||
defer client.Close()
|
||||
remote, next, err := client.FetchSummaries(r.Context(), folder, uint32(cursor), externalIMAPMaxFetch)
|
||||
remote, next, err := client.FetchSummaries(r.Context(), folder, query, cursor, externalIMAPMaxFetch)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusBadRequest, "failed to load remote messages")
|
||||
return
|
||||
@@ -398,11 +619,20 @@ func (a *App) handleExternalIMAPMessage(w http.ResponseWriter, r *http.Request)
|
||||
msg.BodyText = stored.BodyText
|
||||
msg.BodyHTML = stored.BodyHTML
|
||||
msg.Snippet = stored.Snippet
|
||||
msg.Attachments = []Attachment{{ID: "raw", MessageID: msg.ID, Filename: safeExternalEMLFilename(msg.Subject), ContentType: "message/rfc822", SizeBytes: int64(len(raw)), CreatedAt: a.now().UTC()}}
|
||||
if len(attachments) > 0 {
|
||||
msg.HasAttachments = true
|
||||
}
|
||||
}
|
||||
if parts, err := client.FetchAttachments(r.Context(), folder, uid); err == nil {
|
||||
for i := range parts {
|
||||
parts[i].MessageID = msg.ID
|
||||
}
|
||||
msg.Attachments = parts
|
||||
msg.HasAttachments = len(parts) > 0
|
||||
}
|
||||
if len(msg.Attachments) == 0 {
|
||||
msg.Attachments = []Attachment{{ID: "raw", MessageID: msg.ID, Filename: safeExternalEMLFilename(msg.Subject), ContentType: "message/rfc822", SizeBytes: int64(len(raw)), CreatedAt: a.now().UTC()}}
|
||||
}
|
||||
respondJSON(w, http.StatusOK, msg)
|
||||
}
|
||||
|
||||
@@ -411,10 +641,7 @@ func (a *App) handleExternalIMAPAttachment(w http.ResponseWriter, r *http.Reques
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if chi.URLParam(r, "partId") != "raw" {
|
||||
respondError(w, http.StatusNotFound, "attachment not found")
|
||||
return
|
||||
}
|
||||
partID := chi.URLParam(r, "partId")
|
||||
folder, uid, ok := decodeExternalRemoteID(w, chi.URLParam(r, "remoteId"))
|
||||
if !ok {
|
||||
return
|
||||
@@ -425,6 +652,18 @@ func (a *App) handleExternalIMAPAttachment(w http.ResponseWriter, r *http.Reques
|
||||
return
|
||||
}
|
||||
defer client.Close()
|
||||
if partID != "raw" {
|
||||
data, att, err := client.FetchPart(r.Context(), folder, uid, partID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusNotFound, "attachment not found")
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", att.ContentType)
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="`+escapeDownloadFilename(att.Filename)+`"`)
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(data)))
|
||||
_, _ = w.Write(data)
|
||||
return
|
||||
}
|
||||
raw, remote, err := client.FetchRaw(r.Context(), folder, uid)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusBadRequest, "failed to load remote message")
|
||||
@@ -615,8 +854,76 @@ func (a *App) externalIMAPKey() ([]byte, error) {
|
||||
return sum[:], nil
|
||||
}
|
||||
|
||||
type externalIMAPOAuthProvider struct {
|
||||
Name string
|
||||
Host string
|
||||
Port int
|
||||
}
|
||||
|
||||
func (a *App) externalIMAPOAuthConfig(provider string) (*oauth2.Config, externalIMAPOAuthProvider, error) {
|
||||
callback := strings.TrimRight(a.cfg.PublicBaseURL, "/") + "/api/external-imap-oauth/" + provider + "/callback"
|
||||
switch provider {
|
||||
case externalIMAPOAuthGmail:
|
||||
if a.cfg.ExternalIMAPGmailClientID == "" || a.cfg.ExternalIMAPGmailClientSecret == "" {
|
||||
return nil, externalIMAPOAuthProvider{}, errors.New("gmail oauth is not configured")
|
||||
}
|
||||
return &oauth2.Config{
|
||||
ClientID: a.cfg.ExternalIMAPGmailClientID,
|
||||
ClientSecret: a.cfg.ExternalIMAPGmailClientSecret,
|
||||
RedirectURL: callback,
|
||||
Scopes: []string{"https://mail.google.com/"},
|
||||
Endpoint: oauth2.Endpoint{
|
||||
AuthURL: "https://accounts.google.com/o/oauth2/v2/auth",
|
||||
TokenURL: "https://oauth2.googleapis.com/token",
|
||||
},
|
||||
}, externalIMAPOAuthProvider{Name: "Gmail", Host: "imap.gmail.com", Port: 993}, nil
|
||||
case externalIMAPOAuthOutlook:
|
||||
if a.cfg.ExternalIMAPOutlookClientID == "" || a.cfg.ExternalIMAPOutlookClientSecret == "" {
|
||||
return nil, externalIMAPOAuthProvider{}, errors.New("outlook oauth is not configured")
|
||||
}
|
||||
return &oauth2.Config{
|
||||
ClientID: a.cfg.ExternalIMAPOutlookClientID,
|
||||
ClientSecret: a.cfg.ExternalIMAPOutlookClientSecret,
|
||||
RedirectURL: callback,
|
||||
Scopes: []string{"offline_access", "https://outlook.office.com/IMAP.AccessAsUser.All"},
|
||||
Endpoint: oauth2.Endpoint{
|
||||
AuthURL: "https://login.microsoftonline.com/common/oauth2/v2.0/authorize",
|
||||
TokenURL: "https://login.microsoftonline.com/common/oauth2/v2.0/token",
|
||||
},
|
||||
}, externalIMAPOAuthProvider{Name: "Outlook", Host: "outlook.office365.com", Port: 993}, nil
|
||||
default:
|
||||
return nil, externalIMAPOAuthProvider{}, errors.New("unsupported oauth provider")
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) encryptExternalIMAPOAuthState(state externalIMAPOAuthState) (string, error) {
|
||||
raw, err := json.Marshal(state)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
ciphertext, err := a.encryptExternalIMAPPassword(string(raw))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString([]byte(ciphertext)), nil
|
||||
}
|
||||
|
||||
func (a *App) decryptExternalIMAPOAuthState(value string) (externalIMAPOAuthState, error) {
|
||||
var state externalIMAPOAuthState
|
||||
raw, err := base64.RawURLEncoding.DecodeString(strings.TrimSpace(value))
|
||||
if err != nil {
|
||||
return state, err
|
||||
}
|
||||
plain, err := a.decryptExternalIMAPPassword(string(raw))
|
||||
if err != nil {
|
||||
return state, err
|
||||
}
|
||||
err = json.Unmarshal([]byte(plain), &state)
|
||||
return state, err
|
||||
}
|
||||
|
||||
func (a *App) externalIMAPAccountForUser(ctx context.Context, userID, id string) (externalIMAPAccountRecord, error) {
|
||||
row := a.db.QueryRowContext(ctx, `SELECT id,user_id,mailbox_id,name,host,port,tls_mode,username,password_ciphertext,storage_mode,sync_read_state,enabled,last_sync_at,last_status,last_error,created_at,updated_at FROM external_imap_accounts WHERE id=? AND user_id=?`, id, userID)
|
||||
row := a.db.QueryRowContext(ctx, `SELECT id,user_id,mailbox_id,name,host,port,tls_mode,username,password_ciphertext,auth_mode,oauth_provider,oauth_email,oauth_access_token_ciphertext,oauth_refresh_token_ciphertext,oauth_expiry,storage_mode,sync_read_state,enabled,last_sync_at,last_status,last_error,created_at,updated_at FROM external_imap_accounts WHERE id=? AND user_id=?`, id, userID)
|
||||
return scanExternalIMAPAccount(row)
|
||||
}
|
||||
|
||||
@@ -637,12 +944,16 @@ type externalIMAPScanner interface {
|
||||
func scanExternalIMAPAccount(row externalIMAPScanner) (externalIMAPAccountRecord, error) {
|
||||
var item externalIMAPAccountRecord
|
||||
var syncRead, enabled int
|
||||
var lastSync sql.NullString
|
||||
var lastSync, oauthExpiry sql.NullString
|
||||
var created, updated string
|
||||
err := row.Scan(&item.ID, &item.UserID, &item.MailboxID, &item.Name, &item.Host, &item.Port, &item.TLSMode, &item.Username, &item.PasswordCiphertext, &item.StorageMode, &syncRead, &enabled, &lastSync, &item.LastStatus, &item.LastError, &created, &updated)
|
||||
err := row.Scan(&item.ID, &item.UserID, &item.MailboxID, &item.Name, &item.Host, &item.Port, &item.TLSMode, &item.Username, &item.PasswordCiphertext, &item.AuthMode, &item.OAuthProvider, &item.OAuthEmail, &item.OAuthAccessTokenCiphertext, &item.OAuthRefreshTokenCiphertext, &oauthExpiry, &item.StorageMode, &syncRead, &enabled, &lastSync, &item.LastStatus, &item.LastError, &created, &updated)
|
||||
if err != nil {
|
||||
return item, err
|
||||
}
|
||||
if item.AuthMode == "" {
|
||||
item.AuthMode = externalIMAPAuthPassword
|
||||
}
|
||||
item.OAuthConfigured = item.AuthMode == externalIMAPAuthOAuth2 && item.OAuthAccessTokenCiphertext != ""
|
||||
item.SyncReadState = syncRead != 0
|
||||
item.Enabled = enabled != 0
|
||||
if lastSync.Valid && strings.TrimSpace(lastSync.String) != "" {
|
||||
@@ -658,6 +969,28 @@ func (a *App) updateExternalIMAPStatus(ctx context.Context, accountID, status, e
|
||||
_, _ = a.db.ExecContext(ctx, `UPDATE external_imap_accounts SET last_status=?,last_error=?,updated_at=? WHERE id=?`, status, trimExternalIMAPError(errText), a.now().UTC().Format(time.RFC3339Nano), accountID)
|
||||
}
|
||||
|
||||
func (a *App) migrateExternalIMAP(ctx context.Context) error {
|
||||
columns := []struct {
|
||||
table string
|
||||
name string
|
||||
sql string
|
||||
}{
|
||||
{"external_imap_sync_runs", "folder", `ALTER TABLE external_imap_sync_runs ADD COLUMN folder TEXT NOT NULL DEFAULT ''`},
|
||||
{"external_imap_accounts", "auth_mode", `ALTER TABLE external_imap_accounts ADD COLUMN auth_mode TEXT NOT NULL DEFAULT 'password'`},
|
||||
{"external_imap_accounts", "oauth_provider", `ALTER TABLE external_imap_accounts ADD COLUMN oauth_provider TEXT NOT NULL DEFAULT ''`},
|
||||
{"external_imap_accounts", "oauth_email", `ALTER TABLE external_imap_accounts ADD COLUMN oauth_email TEXT NOT NULL DEFAULT ''`},
|
||||
{"external_imap_accounts", "oauth_access_token_ciphertext", `ALTER TABLE external_imap_accounts ADD COLUMN oauth_access_token_ciphertext TEXT NOT NULL DEFAULT ''`},
|
||||
{"external_imap_accounts", "oauth_refresh_token_ciphertext", `ALTER TABLE external_imap_accounts ADD COLUMN oauth_refresh_token_ciphertext TEXT NOT NULL DEFAULT ''`},
|
||||
{"external_imap_accounts", "oauth_expiry", `ALTER TABLE external_imap_accounts ADD COLUMN oauth_expiry TEXT`},
|
||||
}
|
||||
for _, column := range columns {
|
||||
if err := a.ensureTableColumn(ctx, column.table, column.name, column.sql); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) syncExternalIMAPAccount(ctx context.Context, accountID string) (ExternalIMAPSyncRun, error) {
|
||||
account, err := a.externalIMAPAccountByID(ctx, accountID)
|
||||
if err != nil {
|
||||
@@ -667,7 +1000,7 @@ func (a *App) syncExternalIMAPAccount(ctx context.Context, accountID string) (Ex
|
||||
return ExternalIMAPSyncRun{}, errors.New("account is not configured for local storage")
|
||||
}
|
||||
run := ExternalIMAPSyncRun{ID: newID("ximrun"), AccountID: account.ID, Status: "running", StartedAt: a.now().UTC()}
|
||||
_, _ = a.db.ExecContext(ctx, `INSERT INTO external_imap_sync_runs(id,account_id,status,started_at) VALUES(?,?,?,?)`, run.ID, run.AccountID, run.Status, run.StartedAt.Format(time.RFC3339Nano))
|
||||
_, _ = a.db.ExecContext(ctx, `INSERT INTO external_imap_sync_runs(id,account_id,folder,status,started_at) VALUES(?,?,?,?,?)`, run.ID, run.AccountID, run.Folder, run.Status, run.StartedAt.Format(time.RFC3339Nano))
|
||||
client, err := a.externalIMAP.openExternalIMAPClient(ctx, account)
|
||||
if err != nil {
|
||||
return a.finishExternalIMAPRun(ctx, run, "failed", err)
|
||||
@@ -694,8 +1027,51 @@ func (a *App) syncExternalIMAPAccount(ctx context.Context, accountID string) (Ex
|
||||
return a.finishExternalIMAPRun(ctx, run, status, nil)
|
||||
}
|
||||
|
||||
func (a *App) syncExternalIMAPAccountFolder(ctx context.Context, accountID, folderName string) (ExternalIMAPSyncRun, error) {
|
||||
account, err := a.externalIMAPAccountByID(ctx, accountID)
|
||||
if err != nil {
|
||||
return ExternalIMAPSyncRun{}, err
|
||||
}
|
||||
if account.StorageMode != externalIMAPStorageLocal {
|
||||
return ExternalIMAPSyncRun{}, errors.New("account is not configured for local storage")
|
||||
}
|
||||
run := ExternalIMAPSyncRun{ID: newID("ximrun"), AccountID: account.ID, Folder: folderName, Status: "running", StartedAt: a.now().UTC()}
|
||||
_, _ = a.db.ExecContext(ctx, `INSERT INTO external_imap_sync_runs(id,account_id,folder,status,started_at) VALUES(?,?,?,?,?)`, run.ID, run.AccountID, run.Folder, run.Status, run.StartedAt.Format(time.RFC3339Nano))
|
||||
client, err := a.externalIMAP.openExternalIMAPClient(ctx, account)
|
||||
if err != nil {
|
||||
return a.finishExternalIMAPRun(ctx, run, "failed", err)
|
||||
}
|
||||
defer client.Close()
|
||||
folders, err := client.ListFolders(ctx)
|
||||
if err != nil {
|
||||
return a.finishExternalIMAPRun(ctx, run, "failed", err)
|
||||
}
|
||||
var selected externalIMAPRemoteFolder
|
||||
for _, folder := range folders {
|
||||
if strings.EqualFold(folder.Name, folderName) {
|
||||
selected = folder
|
||||
break
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(selected.Name) == "" {
|
||||
return a.finishExternalIMAPRun(ctx, run, "failed", errors.New("remote folder not found"))
|
||||
}
|
||||
imported, skipped, failed, err := a.syncExternalIMAPFolder(ctx, account, client, selected)
|
||||
run.Imported = imported
|
||||
run.Skipped = skipped
|
||||
run.Failed = failed
|
||||
status := "ok"
|
||||
if failed > 0 {
|
||||
status = "partial"
|
||||
}
|
||||
if err != nil {
|
||||
status = "failed"
|
||||
}
|
||||
return a.finishExternalIMAPRun(ctx, run, status, err)
|
||||
}
|
||||
|
||||
func (a *App) externalIMAPAccountByID(ctx context.Context, id string) (externalIMAPAccountRecord, error) {
|
||||
row := a.db.QueryRowContext(ctx, `SELECT id,user_id,mailbox_id,name,host,port,tls_mode,username,password_ciphertext,storage_mode,sync_read_state,enabled,last_sync_at,last_status,last_error,created_at,updated_at FROM external_imap_accounts WHERE id=? AND enabled=1`, id)
|
||||
row := a.db.QueryRowContext(ctx, `SELECT id,user_id,mailbox_id,name,host,port,tls_mode,username,password_ciphertext,auth_mode,oauth_provider,oauth_email,oauth_access_token_ciphertext,oauth_refresh_token_ciphertext,oauth_expiry,storage_mode,sync_read_state,enabled,last_sync_at,last_status,last_error,created_at,updated_at FROM external_imap_accounts WHERE id=? AND enabled=1`, id)
|
||||
return scanExternalIMAPAccount(row)
|
||||
}
|
||||
|
||||
@@ -822,6 +1198,22 @@ func (a *App) finishExternalIMAPRun(ctx context.Context, run ExternalIMAPSyncRun
|
||||
return run, err
|
||||
}
|
||||
|
||||
func scanExternalIMAPSyncRun(row externalIMAPScanner) (ExternalIMAPSyncRun, error) {
|
||||
var run ExternalIMAPSyncRun
|
||||
var started, finished sql.NullString
|
||||
if err := row.Scan(&run.ID, &run.AccountID, &run.Folder, &run.Status, &run.Imported, &run.Skipped, &run.Failed, &run.Error, &started, &finished); err != nil {
|
||||
return run, err
|
||||
}
|
||||
if started.Valid && strings.TrimSpace(started.String) != "" {
|
||||
run.StartedAt = parseTime(started.String)
|
||||
}
|
||||
if finished.Valid && strings.TrimSpace(finished.String) != "" {
|
||||
t := parseTime(finished.String)
|
||||
run.FinishedAt = &t
|
||||
}
|
||||
return run, nil
|
||||
}
|
||||
|
||||
func trimExternalIMAPError(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if len(value) > 500 {
|
||||
@@ -923,12 +1315,114 @@ func safeExternalEMLFilename(subject string) string {
|
||||
return name + ".eml"
|
||||
}
|
||||
|
||||
func (a *App) openExternalIMAPClient(ctx context.Context, account externalIMAPAccountRecord) (externalIMAPClient, error) {
|
||||
if err := a.validateExternalIMAPHost(ctx, account.Host); err != nil {
|
||||
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
|
||||
}
|
||||
password, err := a.decryptExternalIMAPPassword(account.PasswordCiphertext)
|
||||
if err != nil {
|
||||
return decoded[:n], nil
|
||||
case "quoted-printable":
|
||||
return io.ReadAll(quotedprintable.NewReader(strings.NewReader(string(data))))
|
||||
default:
|
||||
return data, nil
|
||||
}
|
||||
}
|
||||
|
||||
func escapeDownloadFilename(name string) string {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
name = "attachment"
|
||||
}
|
||||
name = strings.Map(func(r rune) rune {
|
||||
if r < 32 || r == '"' || r == '\\' {
|
||||
return '-'
|
||||
}
|
||||
return r
|
||||
}, name)
|
||||
if len([]rune(name)) > 120 {
|
||||
name = string([]rune(name)[:120])
|
||||
}
|
||||
return mime.QEncoding.Encode("utf-8", name)
|
||||
}
|
||||
|
||||
func (a *App) openExternalIMAPClient(ctx context.Context, account externalIMAPAccountRecord) (externalIMAPClient, error) {
|
||||
if err := a.validateExternalIMAPHost(ctx, account.Host); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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},
|
||||
}
|
||||
var c *imapclient.Client
|
||||
var err error
|
||||
switch account.TLSMode {
|
||||
case externalIMAPTLS:
|
||||
c, err = imapclient.DialTLS(addr, options)
|
||||
@@ -948,13 +1443,75 @@ func (a *App) openExternalIMAPClient(ctx context.Context, account externalIMAPAc
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if account.AuthMode == externalIMAPAuthOAuth2 {
|
||||
token, err := a.externalIMAPOAuthAccessToken(ctx, account)
|
||||
if err != nil {
|
||||
c.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := c.Authenticate(sasl.NewOAuthBearerClient(&sasl.OAuthBearerOptions{Username: account.Username, Token: token, Host: account.Host, Port: account.Port})); err != nil {
|
||||
c.Close()
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
password, err := a.decryptExternalIMAPPassword(account.PasswordCiphertext)
|
||||
if err != nil {
|
||||
c.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := c.Login(account.Username, password).Wait(); err != nil {
|
||||
c.Close()
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return &goExternalIMAPClient{client: c}, nil
|
||||
}
|
||||
|
||||
func (a *App) externalIMAPOAuthAccessToken(ctx context.Context, account externalIMAPAccountRecord) (string, error) {
|
||||
access, err := a.decryptExternalIMAPPassword(account.OAuthAccessTokenCiphertext)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var expiry time.Time
|
||||
var expiryRaw sql.NullString
|
||||
_ = a.db.QueryRowContext(ctx, `SELECT oauth_expiry FROM external_imap_accounts WHERE id=?`, account.ID).Scan(&expiryRaw)
|
||||
if expiryRaw.Valid && strings.TrimSpace(expiryRaw.String) != "" {
|
||||
expiry = parseTime(expiryRaw.String)
|
||||
}
|
||||
if expiry.IsZero() || expiry.After(a.now().Add(2*time.Minute)) || account.OAuthRefreshTokenCiphertext == "" {
|
||||
return access, nil
|
||||
}
|
||||
refresh, err := a.decryptExternalIMAPPassword(account.OAuthRefreshTokenCiphertext)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
conf, _, err := a.externalIMAPOAuthConfig(account.OAuthProvider)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
token, err := conf.TokenSource(ctx, &oauth2.Token{AccessToken: access, RefreshToken: refresh, Expiry: expiry}).Token()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
accessCipher, err := a.encryptExternalIMAPPassword(token.AccessToken)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
refreshCipher := account.OAuthRefreshTokenCiphertext
|
||||
if token.RefreshToken != "" && token.RefreshToken != refresh {
|
||||
refreshCipher, err = a.encryptExternalIMAPPassword(token.RefreshToken)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
expiryText := ""
|
||||
if !token.Expiry.IsZero() {
|
||||
expiryText = token.Expiry.UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
_, _ = a.db.ExecContext(ctx, `UPDATE external_imap_accounts SET oauth_access_token_ciphertext=?,oauth_refresh_token_ciphertext=?,oauth_expiry=?,updated_at=? WHERE id=?`, accessCipher, refreshCipher, expiryText, a.now().UTC().Format(time.RFC3339Nano), account.ID)
|
||||
return token.AccessToken, nil
|
||||
}
|
||||
|
||||
type goExternalIMAPClient struct {
|
||||
client *imapclient.Client
|
||||
}
|
||||
@@ -1006,42 +1563,65 @@ func mailboxHasNoSelect(attrs []imap.MailboxAttr) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (c *goExternalIMAPClient) FetchSummaries(ctx context.Context, folder string, cursor uint32, limit int) ([]externalIMAPRemoteMessage, string, error) {
|
||||
func (c *goExternalIMAPClient) FetchSummaries(ctx context.Context, folder string, query string, cursor string, limit int) ([]externalIMAPRemoteMessage, string, error) {
|
||||
selected, err := c.client.Select(folder, nil).Wait()
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if selected.NumMessages == 0 {
|
||||
return nil, "", nil
|
||||
}
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = externalIMAPMaxFetch
|
||||
}
|
||||
start := selected.NumMessages
|
||||
if cursor > 0 {
|
||||
start = cursor
|
||||
}
|
||||
if start == 0 {
|
||||
if selected.NumMessages == 0 {
|
||||
return nil, "", nil
|
||||
}
|
||||
stop := uint32(1)
|
||||
if start > uint32(limit) {
|
||||
stop = start - uint32(limit) + 1
|
||||
maxUID := uint32(0)
|
||||
if strings.TrimSpace(cursor) != "" {
|
||||
parsed, _ := strconv.ParseUint(strings.TrimPrefix(cursor, "uid:"), 10, 32)
|
||||
maxUID = uint32(parsed)
|
||||
}
|
||||
var set imap.SeqSet
|
||||
set.AddRange(stop, start)
|
||||
if maxUID == 0 {
|
||||
if selected.UIDNext == 0 {
|
||||
return nil, "", nil
|
||||
}
|
||||
maxUID = uint32(selected.UIDNext - 1)
|
||||
}
|
||||
criteria := &imap.SearchCriteria{}
|
||||
var uidRange imap.UIDSet
|
||||
uidRange.AddRange(1, imap.UID(maxUID))
|
||||
criteria.UID = []imap.UIDSet{uidRange}
|
||||
if q := strings.TrimSpace(query); q != "" {
|
||||
criteria.Text = []string{q}
|
||||
}
|
||||
data, err := c.client.UIDSearch(criteria, nil).Wait()
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
uids := data.AllUIDs()
|
||||
if len(uids) == 0 {
|
||||
return nil, "", nil
|
||||
}
|
||||
sort.Slice(uids, func(i, j int) bool { return uids[i] > uids[j] })
|
||||
if len(uids) > limit {
|
||||
uids = uids[:limit]
|
||||
}
|
||||
var set imap.UIDSet
|
||||
set.AddNum(uids...)
|
||||
bodySection := &imap.FetchItemBodySection{Specifier: imap.PartSpecifierHeader, Peek: true}
|
||||
messages, err := c.client.Fetch(set, &imap.FetchOptions{UID: true, Flags: true, Envelope: true, InternalDate: true, RFC822Size: true, BodySection: []*imap.FetchItemBodySection{bodySection}}).Collect()
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
out := []externalIMAPRemoteMessage{}
|
||||
for i := len(messages) - 1; i >= 0 && len(out) < limit; i-- {
|
||||
out = append(out, fetchBufferToExternalMessage(folder, selected.UIDValidity, messages[i], nil))
|
||||
for _, message := range messages {
|
||||
out = append(out, fetchBufferToExternalMessage(folder, selected.UIDValidity, message, nil))
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].UID > out[j].UID })
|
||||
next := ""
|
||||
if stop > 1 {
|
||||
next = strconv.FormatUint(uint64(stop-1), 10)
|
||||
if len(uids) == limit {
|
||||
last := uint32(uids[len(uids)-1])
|
||||
if last > 1 {
|
||||
next = "uid:" + strconv.FormatUint(uint64(last-1), 10)
|
||||
}
|
||||
}
|
||||
return out, next, nil
|
||||
}
|
||||
@@ -1088,6 +1668,77 @@ func (c *goExternalIMAPClient) FetchRaw(ctx context.Context, folder string, uid
|
||||
return raw, fetchBufferToExternalMessage(folder, selected.UIDValidity, messages[0], raw), nil
|
||||
}
|
||||
|
||||
func (c *goExternalIMAPClient) FetchAttachments(ctx context.Context, folder string, uid uint32) ([]Attachment, error) {
|
||||
parts, err := c.fetchAttachmentParts(ctx, folder, uid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]Attachment, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
out = append(out, part.Attachment)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *goExternalIMAPClient) fetchAttachmentParts(ctx context.Context, folder string, uid uint32) ([]externalIMAPAttachmentPart, error) {
|
||||
if _, err := c.client.Select(folder, nil).Wait(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
messages, err := c.client.Fetch(imap.UIDSetNum(imap.UID(uid)), &imap.FetchOptions{
|
||||
UID: true,
|
||||
BodyStructure: &imap.FetchItemBodyStructure{Extended: true},
|
||||
}).Collect()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(messages) == 0 || messages[0].BodyStructure == nil {
|
||||
return nil, sql.ErrNoRows
|
||||
}
|
||||
return externalIMAPAttachmentPartsFromBodyStructure(messages[0].BodyStructure), nil
|
||||
}
|
||||
|
||||
func (c *goExternalIMAPClient) FetchPart(ctx context.Context, folder string, uid uint32, partID string) ([]byte, Attachment, error) {
|
||||
path, err := decodeExternalIMAPPartID(partID)
|
||||
if err != nil {
|
||||
return nil, Attachment{}, err
|
||||
}
|
||||
if _, err := c.client.Select(folder, nil).Wait(); err != nil {
|
||||
return nil, Attachment{}, err
|
||||
}
|
||||
attachments, err := c.fetchAttachmentParts(ctx, folder, uid)
|
||||
if err != nil {
|
||||
return nil, Attachment{}, err
|
||||
}
|
||||
var att externalIMAPAttachmentPart
|
||||
for _, item := range attachments {
|
||||
if item.ID == partID {
|
||||
att = item
|
||||
break
|
||||
}
|
||||
}
|
||||
if att.ID == "" {
|
||||
return nil, Attachment{}, sql.ErrNoRows
|
||||
}
|
||||
bodySection := &imap.FetchItemBodySection{Part: path, Peek: true}
|
||||
messages, err := c.client.Fetch(imap.UIDSetNum(imap.UID(uid)), &imap.FetchOptions{UID: true, BodySection: []*imap.FetchItemBodySection{bodySection}}).Collect()
|
||||
if err != nil {
|
||||
return nil, Attachment{}, err
|
||||
}
|
||||
if len(messages) == 0 {
|
||||
return nil, Attachment{}, sql.ErrNoRows
|
||||
}
|
||||
data := messages[0].FindBodySection(bodySection)
|
||||
if data == nil {
|
||||
return nil, Attachment{}, sql.ErrNoRows
|
||||
}
|
||||
data, err = decodeExternalIMAPPartData(data, att.Encoding)
|
||||
if err != nil {
|
||||
return nil, Attachment{}, err
|
||||
}
|
||||
att.SizeBytes = int64(len(data))
|
||||
return data, att.Attachment, nil
|
||||
}
|
||||
|
||||
func (c *goExternalIMAPClient) SetRead(ctx context.Context, folder string, uid uint32, read bool) error {
|
||||
if _, err := c.client.Select(folder, nil).Wait(); err != nil {
|
||||
return err
|
||||
|
||||
@@ -64,7 +64,11 @@ func (a *App) Router() http.Handler {
|
||||
r.With(a.requireAuth, a.requirePermission(PermissionMailAccess)).Post("/me/external-imap-accounts/{id}", a.handleUpdateExternalIMAPAccount)
|
||||
r.With(a.requireAuth, a.requirePermission(PermissionMailAccess)).Delete("/me/external-imap-accounts/{id}", a.handleDeleteExternalIMAPAccount)
|
||||
r.With(a.requireAuth, a.requirePermission(PermissionMailAccess)).Post("/me/external-imap-accounts/{id}/test", a.handleTestExternalIMAPAccount)
|
||||
r.With(a.requireAuth, a.requirePermission(PermissionMailAccess)).Get("/me/external-imap-accounts/{id}/runs", a.handleExternalIMAPSyncRuns)
|
||||
r.With(a.requireAuth, a.requirePermission(PermissionMailAccess)).Post("/me/external-imap-accounts/{id}/sync", a.handleSyncExternalIMAPAccount)
|
||||
r.With(a.requireAuth, a.requirePermission(PermissionMailAccess)).Post("/me/external-imap-accounts/{id}/sync-folder", a.handleSyncExternalIMAPFolder)
|
||||
r.With(a.requireAuth, a.requirePermission(PermissionMailAccess)).Post("/me/external-imap-oauth/{provider}/start", a.handleStartExternalIMAPOAuth)
|
||||
r.Get("/external-imap-oauth/{provider}/callback", a.handleExternalIMAPOAuthCallback)
|
||||
r.With(a.requireAuth).Get("/events", a.handleEvents)
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
|
||||
@@ -237,6 +237,10 @@ type ExternalIMAPAccount struct {
|
||||
Port int `json:"port"`
|
||||
TLSMode string `json:"tlsMode"`
|
||||
Username string `json:"username"`
|
||||
AuthMode string `json:"authMode"`
|
||||
OAuthProvider string `json:"oauthProvider,omitempty"`
|
||||
OAuthEmail string `json:"oauthEmail,omitempty"`
|
||||
OAuthConfigured bool `json:"oauthConfigured,omitempty"`
|
||||
StorageMode string `json:"storageMode"`
|
||||
SyncReadState bool `json:"syncReadState"`
|
||||
Enabled bool `json:"enabled"`
|
||||
@@ -257,6 +261,7 @@ type ExternalIMAPFolder struct {
|
||||
type ExternalIMAPSyncRun struct {
|
||||
ID string `json:"id"`
|
||||
AccountID string `json:"accountId"`
|
||||
Folder string `json:"folder,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Imported int `json:"imported"`
|
||||
Skipped int `json:"skipped"`
|
||||
|
||||
@@ -127,8 +127,10 @@ export type ExternalImapStorageMode = "local" | "remote"
|
||||
export type ExternalImapTlsMode = "tls" | "starttls" | "plain"
|
||||
export type ExternalImapAccount = { id: string; mailboxId: string; name: string; host: string; port: number; tlsMode: ExternalImapTlsMode; username: string; storageMode: ExternalImapStorageMode; syncReadState: boolean; enabled: boolean; lastSyncAt?: string; lastStatus: string; lastError?: string; createdAt: string; updatedAt: string }
|
||||
export type ExternalImapAccountPayload = { mailboxId: string; name: string; host: string; port: number; tlsMode: ExternalImapTlsMode; username: string; password?: string; storageMode: ExternalImapStorageMode; syncReadState: boolean; enabled: boolean }
|
||||
export type ExternalImapOAuthProvider = "gmail" | "outlook"
|
||||
export type ExternalImapOAuthStartPayload = { mailboxId: string; name?: string; email?: string; storageMode: ExternalImapStorageMode; syncReadState: boolean; enabled: boolean }
|
||||
export type ExternalImapFolder = { name: string; role: string; unreadCount: number; totalCount: number }
|
||||
export type ExternalImapSyncRun = { id: string; accountId: string; status: string; imported: number; skipped: number; failed: number; error?: string; startedAt: string; finishedAt?: string }
|
||||
export type ExternalImapSyncRun = { id: string; accountId: string; folder?: string; status: string; imported: number; skipped: number; failed: number; error?: string; startedAt: string; finishedAt?: string }
|
||||
export type MailTemplate = { key: string; name: string; subject: string; bodyText: string; bodyHtml: string; updatedAt: string }
|
||||
export type MailboxApplyOptions = { enabled: boolean; domains: Domain[]; reservedPrefixes?: string[] }
|
||||
export type MaildirSyncCounts = { filesScanned: number; imported: number; backfilled: number; cleaned: number; fileErrors: number }
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { User, AdminUser, AdminOverview, Domain, Mailbox, Alias, MailFolder, Attachment, MailLabel, MailMessage, DNSRecord, DNSCheckResult, ListResponse, SendPayload, DraftPayload, ScheduleSendPayload, ScheduledSend, SendQueueItem, SendQueueAuditEvent, SendQueueStatus, Contact, MailSignature, MailRule, MailRuleCondition, MailRuleAction, BlockedSender, MailStats, ExternalImapAccount, ExternalImapAccountPayload, ExternalImapFolder, ExternalImapSyncRun, MailboxApplyOptions, MailTemplate, MaildirSyncHealth, SystemSettings, SystemSettingsPayload, PublicSettings, LoginPayload, LoginResponse, RegisterPayload, PermissionGroup, PermissionInfo, PermissionKey, PermissionLimits } from "./api-types"
|
||||
import type { User, AdminUser, AdminOverview, Domain, Mailbox, Alias, MailFolder, Attachment, MailLabel, MailMessage, DNSRecord, DNSCheckResult, ListResponse, SendPayload, DraftPayload, ScheduleSendPayload, ScheduledSend, SendQueueItem, SendQueueAuditEvent, SendQueueStatus, Contact, MailSignature, MailRule, MailRuleCondition, MailRuleAction, BlockedSender, MailStats, ExternalImapAccount, ExternalImapAccountPayload, ExternalImapFolder, ExternalImapOAuthProvider, ExternalImapOAuthStartPayload, ExternalImapSyncRun, MailboxApplyOptions, MailTemplate, MaildirSyncHealth, SystemSettings, SystemSettingsPayload, PublicSettings, LoginPayload, LoginResponse, RegisterPayload, PermissionGroup, PermissionInfo, PermissionKey, PermissionLimits } from "./api-types"
|
||||
export * from "./api-types"
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 15_000
|
||||
@@ -65,8 +65,11 @@ export const api = {
|
||||
createExternalImapAccount: (payload: ExternalImapAccountPayload) => request<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) }),
|
||||
deleteExternalImapAccount: (id: string) => request<{ ok: boolean }>(`/api/me/external-imap-accounts/${id}`, { method: "DELETE" }),
|
||||
startExternalImapOAuth: (provider: ExternalImapOAuthProvider, payload: ExternalImapOAuthStartPayload) => request<{ url: string }>(`/api/me/external-imap-oauth/${provider}/start`, { method: "POST", body: JSON.stringify(payload) }),
|
||||
testExternalImapAccount: (id: string) => request<{ ok: boolean; folders: number }>(`/api/me/external-imap-accounts/${id}/test`, { method: "POST", timeoutMs: MAIL_DELIVERY_TIMEOUT_MS }),
|
||||
externalImapSyncRuns: (id: string) => request<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 }),
|
||||
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"),
|
||||
users: () => request<ListResponse<AdminUser>>("/api/admin/users"),
|
||||
permissionGroups: () => request<ListResponse<PermissionGroup> & { catalog: PermissionInfo[] }>("/api/admin/permission-groups"),
|
||||
@@ -123,8 +126,8 @@ export const api = {
|
||||
myMailboxes: () => request<ListResponse<Mailbox>>("/api/mail/mailboxes"),
|
||||
externalMailAccounts: () => request<ListResponse<ExternalImapAccount>>("/api/mail/external-accounts"),
|
||||
externalFolders: (id: string) => request<ListResponse<ExternalImapFolder>>(`/api/mail/external-accounts/${id}/folders`),
|
||||
externalMessages: (id: string, folder: string, cursor = "") => {
|
||||
const params = new URLSearchParams({ folder, cursor })
|
||||
externalMessages: (id: string, folder: string, cursor = "", q = "") => {
|
||||
const params = new URLSearchParams({ folder, cursor, q })
|
||||
return request<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)}`),
|
||||
|
||||
@@ -180,8 +180,8 @@ export function MailPage() {
|
||||
enabled: !!activeMailboxId && canReadMail && mailView !== "scheduled" && mailView !== "sendQueue" && (mailView !== "label" || !!selectedLabelId),
|
||||
})
|
||||
const externalMessages = useInfiniteQuery({
|
||||
queryKey: ["external-messages", selectedExternalAccountId, externalFolder],
|
||||
queryFn: ({ pageParam }) => api.externalMessages(selectedExternalAccountId, externalFolder, typeof pageParam === "string" ? pageParam : ""),
|
||||
queryKey: ["external-messages", selectedExternalAccountId, externalFolder, query],
|
||||
queryFn: ({ pageParam }) => api.externalMessages(selectedExternalAccountId, externalFolder, typeof pageParam === "string" ? pageParam : "", query),
|
||||
initialPageParam: "",
|
||||
getNextPageParam: (lastPage) => lastPage.nextCursor || undefined,
|
||||
enabled: !!selectedExternalAccountId && canReadMail && mailView === "external",
|
||||
@@ -1326,7 +1326,7 @@ export function MailPage() {
|
||||
{canSendMail && <Button type="button" size="icon" onClick={() => openCompose()} disabled={!selectedMailbox} aria-label="写邮件"><PencilLine className="h-4 w-4" /></Button>}
|
||||
<div className="relative basis-full">
|
||||
<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>
|
||||
</header>
|
||||
)}
|
||||
@@ -1370,7 +1370,7 @@ export function MailPage() {
|
||||
</div>
|
||||
<div className="relative w-full max-w-md">
|
||||
<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>
|
||||
</header>
|
||||
{contentView}
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { ImperativePanelHandle } from "react-resizable-panels"
|
||||
import { useNavigate, useSearchParams } from "react-router-dom"
|
||||
import { ArrowLeft, BarChart3, Ban, Contact, Copy, Info, KeyRound, Laptop, Link2, LogOut, Mail, MailCheck, MailX, Moon, PanelLeftClose, PanelLeftOpen, PencilLine, Plus, RefreshCcw, Settings, ShieldCheck, SlidersHorizontal, Sun, Trash2, X } from "lucide-react"
|
||||
import { QRCodeSVG } from "qrcode.react"
|
||||
import { api, ExternalImapAccount, ExternalImapAccountPayload, ExternalImapStorageMode, ExternalImapTlsMode, MailLabel, MailRule, MailRuleAction, MailRuleCondition, Mailbox, MailboxApplyOptions, MailSignature, MailStats, PermissionLimits } from "@/lib/api"
|
||||
import { api, ExternalImapAccount, ExternalImapAccountPayload, ExternalImapFolder, ExternalImapOAuthProvider, ExternalImapStorageMode, ExternalImapSyncRun, ExternalImapTlsMode, MailLabel, MailRule, MailRuleAction, MailRuleCondition, Mailbox, MailboxApplyOptions, MailSignature, MailStats, PermissionLimits } from "@/lib/api"
|
||||
import { cn, formatBytes } from "@/lib/utils"
|
||||
import { applyTheme, getInitialTheme } from "@/lib/theme"
|
||||
import { DisplayMode, useDisplayMode } from "@/lib/display-mode"
|
||||
@@ -64,6 +64,7 @@ export function ProfilePage() {
|
||||
const [blockedMailboxId, setBlockedMailboxId] = React.useState("all")
|
||||
const [ruleDialogOpen, setRuleDialogOpen] = React.useState(false)
|
||||
const [mobileSidebarOpen, setMobileSidebarOpen] = React.useState(false)
|
||||
const [externalRunAccountId, setExternalRunAccountId] = React.useState("")
|
||||
const isMobile = useIsMobile()
|
||||
const themeMountedRef = React.useRef(false)
|
||||
|
||||
@@ -102,6 +103,14 @@ export function ProfilePage() {
|
||||
const selectedMailbox = React.useMemo(() => mailboxes.data?.items.find((m) => m.id === mailboxId), [mailboxes.data?.items, mailboxId])
|
||||
const activeMailboxId = selectedMailbox?.id || ""
|
||||
const externalImapAccounts = useQuery({ queryKey: ["external-imap-accounts", activeMailboxId], queryFn: () => api.externalImapAccounts(activeMailboxId), enabled: !!activeMailboxId && canAccessMail })
|
||||
React.useEffect(() => {
|
||||
if (!externalRunAccountId) return
|
||||
if (externalImapAccounts.data?.items.some((item) => item.id === externalRunAccountId)) return
|
||||
setExternalRunAccountId("")
|
||||
}, [externalImapAccounts.data?.items, externalRunAccountId])
|
||||
const selectedExternalRunAccount = externalImapAccounts.data?.items.find((item) => item.id === externalRunAccountId)
|
||||
const externalRunFolders = useQuery({ queryKey: ["external-imap-run-folders", externalRunAccountId], queryFn: () => api.externalFolders(externalRunAccountId), enabled: !!externalRunAccountId && !!selectedExternalRunAccount && canAccessMail })
|
||||
const externalSyncRuns = useQuery({ queryKey: ["external-imap-sync-runs", externalRunAccountId], queryFn: () => api.externalImapSyncRuns(externalRunAccountId), enabled: !!externalRunAccountId && !!selectedExternalRunAccount && canAccessMail })
|
||||
const ruleLabels = useQuery({ queryKey: ["labels", "rules", activeMailboxId], queryFn: () => api.labels(activeMailboxId), enabled: !!activeMailboxId && canManageRules && (canReadMail || canManageLabels) })
|
||||
const stats = useQuery({ queryKey: ["mail-stats", activeMailboxId], queryFn: () => api.mailStats(activeMailboxId), enabled: !!activeMailboxId && canViewStats })
|
||||
|
||||
@@ -221,9 +230,19 @@ export function ProfilePage() {
|
||||
})
|
||||
const syncExternalImap = useMutation({
|
||||
mutationFn: api.syncExternalImapAccount,
|
||||
onSuccess: (run) => { qc.invalidateQueries({ queryKey: ["external-imap-accounts"] }); qc.invalidateQueries({ queryKey: ["folders"] }); qc.invalidateQueries({ queryKey: ["messages"] }); toast({ title: `同步完成:导入 ${run.imported},跳过 ${run.skipped}` }) },
|
||||
onSuccess: (run) => { qc.invalidateQueries({ queryKey: ["external-imap-accounts"] }); qc.invalidateQueries({ queryKey: ["external-imap-sync-runs"] }); qc.invalidateQueries({ queryKey: ["folders"] }); qc.invalidateQueries({ queryKey: ["messages"] }); toast({ title: `同步完成:导入 ${run.imported},跳过 ${run.skipped}` }) },
|
||||
onError: (error) => toast({ title: "同步失败", description: error.message }),
|
||||
})
|
||||
const syncExternalImapFolder = useMutation({
|
||||
mutationFn: ({ id, folder }: { id: string; folder: string }) => api.syncExternalImapFolder(id, folder),
|
||||
onSuccess: (run) => { qc.invalidateQueries({ queryKey: ["external-imap-accounts"] }); qc.invalidateQueries({ queryKey: ["external-imap-sync-runs"] }); qc.invalidateQueries({ queryKey: ["folders"] }); qc.invalidateQueries({ queryKey: ["messages"] }); toast({ title: `${run.folder || "文件夹"} 同步完成:导入 ${run.imported},跳过 ${run.skipped}` }) },
|
||||
onError: (error) => toast({ title: "同步失败", description: error.message }),
|
||||
})
|
||||
const startExternalOAuth = useMutation({
|
||||
mutationFn: ({ provider, mailboxId }: { provider: ExternalImapOAuthProvider; mailboxId: string }) => api.startExternalImapOAuth(provider, { mailboxId, storageMode: "local", syncReadState: true, enabled: true }),
|
||||
onSuccess: (res) => { window.location.href = res.url },
|
||||
onError: (error) => toast({ title: "授权失败", description: error.message }),
|
||||
})
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!mailboxes.isSuccess) return
|
||||
@@ -317,7 +336,31 @@ export function ProfilePage() {
|
||||
</div>
|
||||
)
|
||||
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 === "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} />
|
||||
@@ -518,15 +561,21 @@ function MailboxManagement({
|
||||
selectedMailboxId,
|
||||
externalAccounts,
|
||||
externalPending,
|
||||
selectedExternalRunAccountId,
|
||||
externalRunFolders,
|
||||
externalSyncRuns,
|
||||
onSelectExternalRunAccount,
|
||||
onSelect,
|
||||
onCopy,
|
||||
onOpen,
|
||||
onApply,
|
||||
onCreateExternal,
|
||||
onStartExternalOAuth,
|
||||
onUpdateExternal,
|
||||
onDeleteExternal,
|
||||
onTestExternal,
|
||||
onSyncExternal,
|
||||
onSyncExternalFolder,
|
||||
}: {
|
||||
mailboxes: Mailbox[]
|
||||
applyOptions?: MailboxApplyOptions
|
||||
@@ -534,15 +583,21 @@ function MailboxManagement({
|
||||
selectedMailboxId: string
|
||||
externalAccounts: ExternalImapAccount[]
|
||||
externalPending: boolean
|
||||
selectedExternalRunAccountId: string
|
||||
externalRunFolders: ExternalImapFolder[]
|
||||
externalSyncRuns: ExternalImapSyncRun[]
|
||||
onSelectExternalRunAccount: (id: string) => void
|
||||
onSelect: (id: string) => void
|
||||
onCopy: (text: string) => void
|
||||
onOpen: (id: string) => void
|
||||
onApply: (payload: { domainId: string; localPart: string; displayName: string }) => Promise<void>
|
||||
onCreateExternal: (payload: ExternalImapAccountPayload) => void
|
||||
onStartExternalOAuth: (provider: ExternalImapOAuthProvider, mailboxId: string) => void
|
||||
onUpdateExternal: (id: string, payload: ExternalImapAccountPayload) => void
|
||||
onDeleteExternal: (id: string) => void
|
||||
onTestExternal: (id: string) => void
|
||||
onSyncExternal: (id: string) => void
|
||||
onSyncExternalFolder: (id: string, folder: string) => void
|
||||
}) {
|
||||
const canApply = !!applyOptions?.enabled && (applyOptions.domains || []).length > 0
|
||||
const selectedMailbox = mailboxes.find((item) => item.id === selectedMailboxId)
|
||||
@@ -562,14 +617,21 @@ function MailboxManagement({
|
||||
<CardTitle>外部 IMAP 接入</CardTitle>
|
||||
<div className="mt-1 text-sm text-muted-foreground">接入其他邮箱,可选择同步到本地,或每次打开时直接从远端读取。</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} />
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{!selectedMailbox && <EmptyState text="请先选择一个本地邮箱" />}
|
||||
{selectedMailbox && externalAccounts.length === 0 && <EmptyState text="暂无外部 IMAP 账号" />}
|
||||
{selectedMailbox && externalAccounts.map((account) => (
|
||||
<div key={account.id} className="flex flex-wrap items-center justify-between gap-3 rounded-lg border p-3">
|
||||
{selectedMailbox && externalAccounts.map((account) => {
|
||||
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="flex flex-wrap items-center gap-2">
|
||||
<div className="truncate font-medium">{account.name}</div>
|
||||
@@ -582,12 +644,16 @@ function MailboxManagement({
|
||||
<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>
|
||||
))}
|
||||
{selectedForRuns && <ExternalImapSyncPanel account={account} folders={externalRunFolders} runs={externalSyncRuns} pending={externalPending} onSyncFolder={onSyncExternalFolder} />}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -722,6 +788,46 @@ function externalStatusLabel(status: string) {
|
||||
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) {
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return value
|
||||
|
||||
@@ -141,6 +141,14 @@ LANQIN_EXTERNAL_IMAP_SYNC_SECONDS=300
|
||||
# 是否允许用户配置 localhost / 内网 / link-local IMAP 主机。默认 false,避免 SSRF 风险。
|
||||
LANQIN_EXTERNAL_IMAP_ALLOW_PRIVATE_HOSTS=false
|
||||
|
||||
# Gmail 外部 IMAP OAuth2。回调地址:${LANQIN_PUBLIC_BASE_URL}/api/external-imap-oauth/gmail/callback
|
||||
LANQIN_EXTERNAL_IMAP_GMAIL_CLIENT_ID=
|
||||
LANQIN_EXTERNAL_IMAP_GMAIL_CLIENT_SECRET=
|
||||
|
||||
# Outlook 外部 IMAP OAuth2。回调地址:${LANQIN_PUBLIC_BASE_URL}/api/external-imap-oauth/outlook/callback
|
||||
LANQIN_EXTERNAL_IMAP_OUTLOOK_CLIENT_ID=
|
||||
LANQIN_EXTERNAL_IMAP_OUTLOOK_CLIENT_SECRET=
|
||||
|
||||
# =========================
|
||||
# 系统
|
||||
# =========================
|
||||
|
||||
+1
-1
@@ -129,7 +129,7 @@ docker compose -f docker-compose.stack.yml -f docker-compose.stack.build.yml up
|
||||
- Go API 是 Webmail 和管理后台入口;浏览器不直接连接 SMTP/IMAP/POP3。
|
||||
- Go API 会读取 `LANQIN_MAILDIR_ROOT=/var/mail/vhosts`,周期扫描 Maildir,把 Postfix/Dovecot 入站邮件同步成 Webmail 索引。
|
||||
- 第三方客户端可通过 LanQin API 提供的 SMTP `465/587` 发信;Webmail/API 和第三方客户端的“已发送”都由 API 写入,外发投递进入发送队列并由 API worker relay/retry,客户端后续 IMAP APPEND 到 Sent 会按 `Message-ID` 去重。
|
||||
- 用户可在个人邮箱管理中接入外部 IMAP 账号;本地存储模式会同步到 LanQin,远端直连模式每次从远端读取。启用前必须配置 `LANQIN_EXTERNAL_IMAP_SECRET_KEY`,默认不允许连接 localhost / 内网 / link-local IMAP 主机。
|
||||
- 用户可在个人邮箱管理中接入外部 IMAP 账号;本地存储模式会同步到 LanQin,远端直连模式每次从远端读取。启用前必须配置 `LANQIN_EXTERNAL_IMAP_SECRET_KEY`,默认不允许连接 localhost / 内网 / link-local IMAP 主机。Gmail / Outlook OAuth2 需要在对应控制台配置回调地址:`/api/external-imap-oauth/gmail/callback` 或 `/api/external-imap-oauth/outlook/callback`。
|
||||
- send-as v1 支持本人邮箱、启用的别名转发 source 指向本人邮箱,或数据库表 `send_as_grants` 中显式授权的地址。
|
||||
|
||||
## 邮件客户端 TLS 证书
|
||||
|
||||
Reference in New Issue
Block a user