package app import ( "context" "crypto/aes" "crypto/cipher" "crypto/rand" "crypto/sha256" "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/go-chi/chi/v5" "golang.org/x/oauth2" ) const ( externalIMAPStorageLocal = "local" externalIMAPStorageRemote = "remote" externalIMAPTLS = "tls" externalIMAPStartTLS = "starttls" externalIMAPPlain = "plain" externalIMAPMaxFetch = 30 externalIMAPAuthPassword = "password" externalIMAPAuthOAuth2 = "oauth2" externalIMAPOAuthGmail = "gmail" externalIMAPOAuthOutlook = "outlook" ) type externalIMAPClientFactory interface { openExternalIMAPClient(ctx context.Context, account externalIMAPAccountRecord) (externalIMAPClient, error) } type externalIMAPClient interface { Close() error ListFolders(ctx context.Context) ([]externalIMAPRemoteFolder, 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 { Name string Role string UnreadCount int TotalCount int UIDValidity uint32 } type externalIMAPRemoteMessage struct { UID uint32 UIDValidity uint32 Folder string MessageID string Subject string From string FromName string To []string CC []string SentAt time.Time ReceivedAt time.Time Snippet string IsRead bool SizeBytes int64 Raw []byte Attachments []Attachment } type externalIMAPAttachmentPart struct { Attachment Encoding string } type externalIMAPPayload struct { MailboxID string `json:"mailboxId"` Name string `json:"name"` Host string `json:"host"` Port int `json:"port"` TLSMode string `json:"tlsMode"` Username string `json:"username"` Password string `json:"password"` StorageMode string `json:"storageMode"` SyncReadState *bool `json:"syncReadState"` 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.config().ExternalIMAPSyncSeconds) * time.Second if interval <= 0 { interval = 5 * time.Minute } ticker := time.NewTicker(interval) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: a.syncDueExternalIMAPAccounts(ctx) } } } func (a *App) syncDueExternalIMAPAccounts(ctx context.Context) { if !a.config().ExternalIMAPEnabled { return } rows, err := a.db.QueryContext(ctx, `SELECT id FROM external_imap_accounts WHERE enabled=1 AND storage_mode=? ORDER BY COALESCE(last_sync_at, created_at) ASC LIMIT 10`, externalIMAPStorageLocal) if err != nil { a.log.Warn("failed to list external imap accounts", "error", err) return } defer rows.Close() for rows.Next() { var id string if err := rows.Scan(&id); err != nil { continue } runCtx, cancel := context.WithTimeout(ctx, 2*time.Minute) _, _ = a.syncExternalIMAPAccount(runCtx, id) cancel() } } func (a *App) requireExternalIMAPEnabled(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if !a.config().ExternalIMAPEnabled { respondError(w, http.StatusForbidden, "external imap is disabled") return } next.ServeHTTP(w, r) }) } func (a *App) handleListExternalIMAPAccounts(w http.ResponseWriter, r *http.Request) { user := currentUser(r) mailboxID := strings.TrimSpace(r.URL.Query().Get("mailboxId")) args := []any{user.ID} where := "user_id=?" if mailboxID != "" { if _, err := a.mailboxForUserByID(r.Context(), user.ID, mailboxID); err != nil { respondError(w, http.StatusNotFound, "mailbox not found") return } 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,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 } defer rows.Close() items := []ExternalIMAPAccount{} for rows.Next() { item, err := scanExternalIMAPAccount(rows) if err != nil { respondError(w, http.StatusInternalServerError, "failed to scan external accounts") return } items = append(items, item.ExternalIMAPAccount) } respondJSON(w, http.StatusOK, map[string]any{"items": items}) } func (a *App) handleCreateExternalIMAPAccount(w http.ResponseWriter, r *http.Request) { user := currentUser(r) var req externalIMAPPayload 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 } req.MailboxID = mb.ID if strings.TrimSpace(req.Password) == "" { badRequest(w, errors.New("password is required")) return } normalized, err := a.normalizeExternalIMAPPayload(r.Context(), req, true) if err != nil { badRequest(w, err) return } ciphertext, err := a.encryptExternalIMAPPassword(normalized.Password) if err != nil { respondError(w, http.StatusBadRequest, err.Error()) return } now := a.now().UTC().Format(time.RFC3339Nano) id := newID("ximap") if _, err := a.db.ExecContext(r.Context(), `INSERT INTO external_imap_accounts(id,user_id,mailbox_id,name,host,port,tls_mode,username,password_ciphertext,storage_mode,sync_read_state,enabled,last_status,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, id, user.ID, normalized.MailboxID, normalized.Name, normalized.Host, normalized.Port, normalized.TLSMode, normalized.Username, ciphertext, normalized.StorageMode, boolInt(*normalized.SyncReadState), boolInt(*normalized.Enabled), "idle", now, now); err != nil { respondError(w, http.StatusInternalServerError, "failed to create external account") return } account, err := a.externalIMAPAccountForUser(r.Context(), user.ID, id) if err != nil { respondError(w, http.StatusInternalServerError, "failed to load external account") return } respondJSON(w, http.StatusCreated, account.ExternalIMAPAccount) } func (a *App) handleUpdateExternalIMAPAccount(w http.ResponseWriter, r *http.Request) { user := currentUser(r) id := chi.URLParam(r, "id") current, err := a.externalIMAPAccountForUser(r.Context(), user.ID, id) if err != nil { respondError(w, http.StatusNotFound, "external account not found") return } var req externalIMAPPayload if err := decodeJSON(r, &req); err != nil { badRequest(w, err) return } if strings.TrimSpace(req.MailboxID) == "" { req.MailboxID = current.MailboxID } if _, err := a.mailboxForUserByID(r.Context(), user.ID, strings.TrimSpace(req.MailboxID)); err != nil { respondError(w, http.StatusNotFound, "mailbox not found") return } normalized, err := a.normalizeExternalIMAPPayload(r.Context(), req, false) if err != nil { badRequest(w, err) return } ciphertext := current.PasswordCiphertext if strings.TrimSpace(normalized.Password) != "" { ciphertext, err = a.encryptExternalIMAPPassword(normalized.Password) if err != nil { respondError(w, http.StatusBadRequest, err.Error()) return } } now := a.now().UTC().Format(time.RFC3339Nano) _, err = a.db.ExecContext(r.Context(), `UPDATE external_imap_accounts SET mailbox_id=?,name=?,host=?,port=?,tls_mode=?,username=?,password_ciphertext=?,storage_mode=?,sync_read_state=?,enabled=?,updated_at=? WHERE id=? AND user_id=?`, normalized.MailboxID, normalized.Name, normalized.Host, normalized.Port, normalized.TLSMode, normalized.Username, ciphertext, normalized.StorageMode, boolInt(*normalized.SyncReadState), boolInt(*normalized.Enabled), now, id, user.ID) if err != nil { respondError(w, http.StatusInternalServerError, "failed to update external account") return } account, err := a.externalIMAPAccountForUser(r.Context(), user.ID, id) if err != nil { respondError(w, http.StatusInternalServerError, "failed to load external account") return } respondJSON(w, http.StatusOK, account.ExternalIMAPAccount) } func (a *App) handleDeleteExternalIMAPAccount(w http.ResponseWriter, r *http.Request) { user := currentUser(r) res, err := a.db.ExecContext(r.Context(), `DELETE FROM external_imap_accounts WHERE id=? AND user_id=?`, chi.URLParam(r, "id"), user.ID) if err != nil { respondError(w, http.StatusInternalServerError, "failed to delete external account") return } if n, _ := res.RowsAffected(); n == 0 { respondError(w, http.StatusNotFound, "external account not found") return } respondJSON(w, http.StatusOK, map[string]any{"ok": true}) } func (a *App) handleTestExternalIMAPAccount(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 } client, err := a.externalIMAP.openExternalIMAPClient(r.Context(), account) if err != nil { a.updateExternalIMAPStatus(r.Context(), account.ID, "error", err.Error()) respondError(w, http.StatusBadRequest, "connection failed: "+err.Error()) return } defer client.Close() folders, err := client.ListFolders(r.Context()) if err != nil { a.updateExternalIMAPStatus(r.Context(), account.ID, "error", err.Error()) respondError(w, http.StatusBadRequest, "list folders failed: "+err.Error()) return } a.updateExternalIMAPStatus(r.Context(), account.ID, "ok", "") respondJSON(w, http.StatusOK, map[string]any{"ok": true, "folders": len(folders)}) } func (a *App) handleSyncExternalIMAPAccount(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 } run, err := a.syncExternalIMAPAccount(r.Context(), account.ID) if err != nil { respondError(w, http.StatusBadRequest, err.Error()) return } 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 := normalizeEmail(req.Email) if email != "" && !strings.Contains(email, "@") { badRequest(w, errors.New("invalid oauth email")) return } 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) } authorizedEmail, err := externalIMAPOAuthEmail(provider, token) if err != nil { respondError(w, http.StatusBadRequest, err.Error()) return } if state.Email != "" && !strings.EqualFold(normalizeEmail(state.Email), authorizedEmail) { respondError(w, http.StatusBadRequest, "oauth account email does not match requested external email") return } id := newID("ximap") _, 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, authorizedEmail, "", externalIMAPAuthOAuth2, provider, authorizedEmail, 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.config().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,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 } defer rows.Close() items := []ExternalIMAPAccount{} for rows.Next() { item, err := scanExternalIMAPAccount(rows) if err != nil { respondError(w, http.StatusInternalServerError, "failed to scan external accounts") return } items = append(items, item.ExternalIMAPAccount) } respondJSON(w, http.StatusOK, map[string]any{"items": items}) } func (a *App) handleExternalIMAPFolders(w http.ResponseWriter, r *http.Request) { account, ok := a.externalIMAPAccountForMailRequest(w, r) if !ok { return } client, err := a.externalIMAP.openExternalIMAPClient(r.Context(), account) if err != nil { respondError(w, http.StatusBadRequest, "connection failed: "+err.Error()) return } defer client.Close() folders, err := client.ListFolders(r.Context()) if err != nil { respondError(w, http.StatusBadRequest, "failed to list folders") return } items := make([]ExternalIMAPFolder, 0, len(folders)) for _, folder := range folders { items = append(items, ExternalIMAPFolder{Name: folder.Name, Role: folder.Role, UnreadCount: folder.UnreadCount, TotalCount: folder.TotalCount}) } respondJSON(w, http.StatusOK, map[string]any{"items": items}) } func (a *App) handleExternalIMAPMessages(w http.ResponseWriter, r *http.Request) { account, ok := a.externalIMAPAccountForMailRequest(w, r) if !ok { return } folder := strings.TrimSpace(r.URL.Query().Get("folder")) if folder == "" { folder = "INBOX" } 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, query, cursor, externalIMAPMaxFetch) if err != nil { respondError(w, http.StatusBadRequest, "failed to load remote messages") return } items := make([]MailMessage, 0, len(remote)) for _, msg := range remote { items = append(items, externalRemoteMessageToMailMessage(account, msg, false)) } respondJSON(w, http.StatusOK, map[string]any{"items": items, "nextCursor": next}) } func (a *App) handleExternalIMAPMessage(w http.ResponseWriter, r *http.Request) { account, ok := a.externalIMAPAccountForMailRequest(w, r) if !ok { return } folder, uid, ok := decodeExternalRemoteID(w, chi.URLParam(r, "remoteId")) if !ok { return } client, err := a.externalIMAP.openExternalIMAPClient(r.Context(), account) if err != nil { respondError(w, http.StatusBadRequest, "connection failed: "+err.Error()) return } defer client.Close() raw, remote, err := client.FetchRaw(r.Context(), folder, uid) if err != nil { respondError(w, http.StatusBadRequest, "failed to load remote message") return } stored, attachments, err := a.parseMaildirMessage(raw, account.Username) msg := externalRemoteMessageToMailMessage(account, remote, true) if err == nil { msg.BodyText = stored.BodyText msg.BodyHTML = stored.BodyHTML msg.Snippet = stored.Snippet 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) } func (a *App) handleExternalIMAPAttachment(w http.ResponseWriter, r *http.Request) { account, ok := a.externalIMAPAccountForMailRequest(w, r) if !ok { return } partID := chi.URLParam(r, "partId") folder, uid, ok := decodeExternalRemoteID(w, chi.URLParam(r, "remoteId")) if !ok { return } client, err := a.externalIMAP.openExternalIMAPClient(r.Context(), account) if err != nil { respondError(w, http.StatusBadRequest, "connection failed: "+err.Error()) 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") return } w.Header().Set("Content-Type", "message/rfc822") w.Header().Set("Content-Disposition", `attachment; filename="`+safeExternalEMLFilename(remote.Subject)+`"`) w.Header().Set("Content-Length", strconv.Itoa(len(raw))) _, _ = w.Write(raw) } func (a *App) handleExternalIMAPMarkRead(w http.ResponseWriter, r *http.Request) { account, ok := a.externalIMAPAccountForMailRequest(w, r) if !ok { return } if !account.SyncReadState { badRequest(w, errors.New("read state sync is disabled")) return } folder, uid, ok := decodeExternalRemoteID(w, chi.URLParam(r, "remoteId")) if !ok { return } var req struct { Read *bool `json:"read"` } _ = decodeJSON(r, &req) read := true if req.Read != nil { read = *req.Read } client, err := a.externalIMAP.openExternalIMAPClient(r.Context(), account) if err != nil { respondError(w, http.StatusBadRequest, "connection failed: "+err.Error()) return } defer client.Close() if err := client.SetRead(r.Context(), folder, uid, read); err != nil { respondError(w, http.StatusBadRequest, "failed to update remote message") return } respondJSON(w, http.StatusOK, map[string]any{"ok": true, "read": read}) } func (a *App) normalizeExternalIMAPPayload(ctx context.Context, req externalIMAPPayload, create bool) (externalIMAPPayload, error) { req.Name = strings.Join(strings.Fields(req.Name), " ") if req.Name == "" { req.Name = req.Username } if req.Name == "" { req.Name = req.Host } if len([]rune(req.Name)) > 80 { return req, errors.New("name is too long") } req.Host = strings.ToLower(strings.TrimSpace(req.Host)) if req.Host == "" { return req, errors.New("host is required") } if err := a.validateExternalIMAPHost(ctx, req.Host); err != nil { return req, err } req.TLSMode = strings.ToLower(strings.TrimSpace(req.TLSMode)) if req.TLSMode == "" { req.TLSMode = externalIMAPTLS } if req.TLSMode != externalIMAPTLS && req.TLSMode != externalIMAPStartTLS && req.TLSMode != externalIMAPPlain { return req, errors.New("invalid TLS mode") } if req.Port <= 0 { if req.TLSMode == externalIMAPTLS { req.Port = 993 } else { req.Port = 143 } } if req.Port <= 0 || req.Port > 65535 { return req, errors.New("invalid port") } req.Username = strings.TrimSpace(req.Username) if req.Username == "" { return req, errors.New("username is required") } req.StorageMode = strings.ToLower(strings.TrimSpace(req.StorageMode)) if req.StorageMode == "" { req.StorageMode = externalIMAPStorageLocal } if req.StorageMode != externalIMAPStorageLocal && req.StorageMode != externalIMAPStorageRemote { return req, errors.New("invalid storage mode") } if req.SyncReadState == nil { v := true req.SyncReadState = &v } if req.Enabled == nil { v := true req.Enabled = &v } return req, nil } func (a *App) validateExternalIMAPHost(ctx context.Context, host string) error { if a.config().ExternalIMAPAllowPrivateHosts { return nil } if strings.EqualFold(host, "localhost") { return errors.New("localhost is not allowed") } ips, err := net.DefaultResolver.LookupIP(ctx, "ip", host) if err != nil { if ip := net.ParseIP(host); ip != nil { ips = []net.IP{ip} } else { return fmt.Errorf("failed to resolve host: %w", err) } } for _, ip := range ips { if !isPublicExternalIMAPIP(ip) { return errors.New("private or local IMAP hosts are not allowed") } } return nil } func isPublicExternalIMAPIP(ip net.IP) bool { if ip == nil { return false } return !(ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsUnspecified()) } func (a *App) encryptExternalIMAPPassword(password string) (string, error) { key, err := a.externalIMAPKey() if err != nil { return "", err } block, err := aes.NewCipher(key) if err != nil { return "", err } gcm, err := cipher.NewGCM(block) if err != nil { return "", err } nonce := make([]byte, gcm.NonceSize()) if _, err := rand.Read(nonce); err != nil { return "", err } out := append(nonce, gcm.Seal(nil, nonce, []byte(password), nil)...) return base64.StdEncoding.EncodeToString(out), nil } func (a *App) decryptExternalIMAPPassword(ciphertext string) (string, error) { key, err := a.externalIMAPKey() if err != nil { return "", err } raw, err := base64.StdEncoding.DecodeString(ciphertext) if err != nil { return "", err } block, err := aes.NewCipher(key) if err != nil { return "", err } gcm, err := cipher.NewGCM(block) if err != nil { return "", err } if len(raw) < gcm.NonceSize() { return "", errors.New("invalid encrypted password") } nonce, data := raw[:gcm.NonceSize()], raw[gcm.NonceSize():] plain, err := gcm.Open(nil, nonce, data, nil) if err != nil { return "", err } return string(plain), nil } func (a *App) externalIMAPKey() ([]byte, error) { secret := strings.TrimSpace(a.config().ExternalIMAPSecretKey) if secret == "" { return nil, errors.New("LANQIN_EXTERNAL_IMAP_SECRET_KEY is required") } sum := sha256.Sum256([]byte(secret)) 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.config().PublicBaseURL, "/") + "/api/external-imap-oauth/" + provider + "/callback" switch provider { case externalIMAPOAuthGmail: if a.config().ExternalIMAPGmailClientID == "" || a.config().ExternalIMAPGmailClientSecret == "" { return nil, externalIMAPOAuthProvider{}, errors.New("gmail oauth is not configured") } return &oauth2.Config{ ClientID: a.config().ExternalIMAPGmailClientID, ClientSecret: a.config().ExternalIMAPGmailClientSecret, RedirectURL: callback, Scopes: []string{"openid", "email", "profile", "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.config().ExternalIMAPOutlookClientID == "" || a.config().ExternalIMAPOutlookClientSecret == "" { return nil, externalIMAPOAuthProvider{}, errors.New("outlook oauth is not configured") } return &oauth2.Config{ ClientID: a.config().ExternalIMAPOutlookClientID, ClientSecret: a.config().ExternalIMAPOutlookClientSecret, RedirectURL: callback, Scopes: []string{"openid", "email", "profile", "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 externalIMAPOAuthEmail(provider string, token *oauth2.Token) (string, error) { if token == nil { return "", errors.New("oauth token is missing") } idToken, _ := token.Extra("id_token").(string) claims, err := externalIMAPOIDCClaims(idToken) if err != nil { return "", err } fields := []string{"email"} if provider == externalIMAPOAuthOutlook { fields = []string{"email", "preferred_username", "upn"} } for _, field := range fields { value, _ := claims[field].(string) email := normalizeEmail(value) if email != "" && strings.Contains(email, "@") { return email, nil } } return "", errors.New("oauth provider did not return an email address") } func externalIMAPOIDCClaims(idToken string) (map[string]any, error) { parts := strings.Split(idToken, ".") if len(parts) < 2 { return nil, errors.New("oauth provider did not return an id token") } payload, err := base64.RawURLEncoding.DecodeString(parts[1]) if err != nil { return nil, errors.New("invalid oauth id token") } var claims map[string]any if err := json.Unmarshal(payload, &claims); err != nil { return nil, errors.New("invalid oauth id token") } return claims, nil } func (a *App) encryptExternalIMAPOAuthState(state externalIMAPOAuthState) (string, error) { 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,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) } func (a *App) externalIMAPAccountForMailRequest(w http.ResponseWriter, r *http.Request) (externalIMAPAccountRecord, bool) { user := currentUser(r) account, err := a.externalIMAPAccountForUser(r.Context(), user.ID, chi.URLParam(r, "id")) if err != nil || !account.Enabled { respondError(w, http.StatusNotFound, "external account not found") return externalIMAPAccountRecord{}, false } return account, true } type externalIMAPScanner interface { Scan(dest ...any) error } func scanExternalIMAPAccount(row externalIMAPScanner) (externalIMAPAccountRecord, error) { var item externalIMAPAccountRecord var syncRead, enabled int 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.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) != "" { t := parseTime(lastSync.String) item.LastSyncAt = &t } item.CreatedAt = parseTime(created) item.UpdatedAt = parseTime(updated) return item, nil } func (a *App) updateExternalIMAPStatus(ctx context.Context, accountID, status, errText string) { _, _ = 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 { 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, 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) } for _, folder := range folders { imported, skipped, failed, err := a.syncExternalIMAPFolder(ctx, account, client, folder) run.Imported += imported run.Skipped += skipped run.Failed += failed if err != nil { run.Failed++ a.log.Warn("external imap folder sync failed", "account", account.ID, "folder", folder.Name, "error", err) } } status := "ok" if run.Failed > 0 { status = "partial" } 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,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) } func (a *App) syncExternalIMAPFolder(ctx context.Context, account externalIMAPAccountRecord, client externalIMAPClient, folder externalIMAPRemoteFolder) (int, int, int, error) { localFolderName := normalizeExternalIMAPFolderName(folder.Name) localFolderID, err := a.ensureFolder(ctx, account.MailboxID, localFolderName) if err != nil { return 0, 0, 0, err } state := a.loadExternalIMAPFolderState(ctx, account.ID, folder.Name) remote, err := client.FetchNew(ctx, folder.Name, state.LastUID, 100) if err != nil { return 0, 0, 0, err } imported, skipped, failed := 0, 0, 0 maxUID := state.LastUID now := a.now().UTC().Format(time.RFC3339Nano) for _, item := range remote { if item.UID > maxUID { maxUID = item.UID } if a.externalIMAPRemoteMessageExists(ctx, account.ID, folder.Name, item.UIDValidity, item.UID) { skipped++ continue } raw, item, err := client.FetchRaw(ctx, folder.Name, item.UID) if err != nil { failed++ continue } stored, attachments, err := a.parseMaildirMessage(raw, account.Username) if err != nil { failed++ continue } stored.MailboxID = account.MailboxID stored.FolderID = localFolderID stored.RecipientAddr = account.Username stored.IsRead = item.IsRead msgID, err := a.insertExternalIMAPMessageOnce(ctx, account, folder.Name, item, stored, attachments) if err != nil { failed++ continue } if msgID != "" { if err := a.writeStoredMessageToMaildir(ctx, msgID, stored, attachments); err != nil { a.log.Warn("failed to write external imap message to maildir", "message", msgID, "error", err) } if state.Initialized && strings.EqualFold(localFolderName, "Inbox") { a.enqueueTelegramMailNotification(ctx, msgID, stored, attachments) } imported++ } else { skipped++ } } _, _ = a.db.ExecContext(ctx, `INSERT INTO external_imap_folder_states(account_id,remote_folder,local_folder_id,uid_validity,last_uid,last_sync_at,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?) ON CONFLICT(account_id,remote_folder) DO UPDATE SET local_folder_id=excluded.local_folder_id,uid_validity=excluded.uid_validity,last_uid=MAX(last_uid,excluded.last_uid),last_sync_at=excluded.last_sync_at,updated_at=excluded.updated_at`, account.ID, folder.Name, localFolderID, folder.UIDValidity, maxUID, now, now, now) return imported, skipped, failed, nil } type externalIMAPFolderState struct { LastUID uint32 Initialized bool } func (a *App) loadExternalIMAPFolderState(ctx context.Context, accountID, folder string) externalIMAPFolderState { var state externalIMAPFolderState if err := a.db.QueryRowContext(ctx, `SELECT last_uid FROM external_imap_folder_states WHERE account_id=? AND remote_folder=?`, accountID, folder).Scan(&state.LastUID); err == nil { state.Initialized = true } return state } func (a *App) externalIMAPRemoteMessageExists(ctx context.Context, accountID, folder string, uidValidity, uid uint32) bool { var exists int _ = a.db.QueryRowContext(ctx, `SELECT 1 FROM external_imap_messages WHERE account_id=? AND remote_folder=? AND uid_validity=? AND uid=?`, accountID, folder, uidValidity, uid).Scan(&exists) return exists == 1 } func (a *App) insertExternalIMAPMessageOnce(ctx context.Context, account externalIMAPAccountRecord, folder string, item externalIMAPRemoteMessage, msg storedMessage, attachments []AttachmentInput) (string, error) { now := a.now().UTC().Format(time.RFC3339Nano) tx, err := a.db.BeginTx(ctx, nil) if err != nil { return "", err } defer tx.Rollback() res, err := tx.ExecContext(ctx, `INSERT OR IGNORE INTO external_imap_messages(account_id,remote_folder,uid_validity,uid,message_id,is_read,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?)`, account.ID, folder, item.UIDValidity, item.UID, msg.MessageID, boolInt(item.IsRead), now, now) if err != nil { return "", err } if n, _ := res.RowsAffected(); n == 0 { return "", tx.Commit() } if strings.TrimSpace(msg.MessageID) != "" { var existing string if err := tx.QueryRowContext(ctx, `SELECT id FROM messages WHERE mailbox_id=? AND folder_id=? AND message_id=? LIMIT 1`, msg.MailboxID, msg.FolderID, msg.MessageID).Scan(&existing); err == nil { _, _ = tx.ExecContext(ctx, `UPDATE external_imap_messages SET local_message_id=?,updated_at=? WHERE account_id=? AND remote_folder=? AND uid_validity=? AND uid=?`, existing, now, account.ID, folder, item.UIDValidity, item.UID) return "", tx.Commit() } } id, err := a.insertMessageWithDB(ctx, tx, msg, attachments) if err != nil { return "", err } _, err = tx.ExecContext(ctx, `UPDATE external_imap_messages SET local_message_id=?,updated_at=? WHERE account_id=? AND remote_folder=? AND uid_validity=? AND uid=?`, id, now, account.ID, folder, item.UIDValidity, item.UID) if err != nil { return "", err } return id, tx.Commit() } func (a *App) finishExternalIMAPRun(ctx context.Context, run ExternalIMAPSyncRun, status string, err error) (ExternalIMAPSyncRun, error) { run.Status = status if err != nil { run.Error = trimExternalIMAPError(err.Error()) } finished := a.now().UTC() run.FinishedAt = &finished _, _ = a.db.ExecContext(ctx, `UPDATE external_imap_sync_runs SET status=?,imported=?,skipped=?,failed=?,error=?,finished_at=? WHERE id=?`, run.Status, run.Imported, run.Skipped, run.Failed, run.Error, finished.Format(time.RFC3339Nano), run.ID) lastStatus := status if status == "failed" { lastStatus = "error" } _, _ = a.db.ExecContext(ctx, `UPDATE external_imap_accounts SET last_sync_at=?,last_status=?,last_error=?,updated_at=? WHERE id=?`, finished.Format(time.RFC3339Nano), lastStatus, run.Error, finished.Format(time.RFC3339Nano), run.AccountID) 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 { return value[:500] } return value } func normalizeExternalIMAPFolderName(name string) string { switch strings.ToLower(strings.TrimSpace(name)) { case "inbox": return "Inbox" case "sent", "sent items", "sent messages": return "Sent" case "drafts", "draft": return "Drafts" case "archive", "archives": return "Archive" case "spam", "junk", "junk email": return "Spam" case "trash", "deleted", "deleted items": return "Trash" default: folder, err := normalizeCustomFolderName(name) if err != nil { return "Imported" } return folder } } func encodeExternalRemoteID(folder string, uid uint32) string { return base64.RawURLEncoding.EncodeToString([]byte(folder + "\x00" + strconv.FormatUint(uint64(uid), 10))) } func decodeExternalRemoteID(w http.ResponseWriter, raw string) (string, uint32, bool) { data, err := base64.RawURLEncoding.DecodeString(raw) if err != nil { respondError(w, http.StatusBadRequest, "invalid remote id") return "", 0, false } parts := strings.SplitN(string(data), "\x00", 2) if len(parts) != 2 || strings.TrimSpace(parts[0]) == "" { respondError(w, http.StatusBadRequest, "invalid remote id") return "", 0, false } uid, err := strconv.ParseUint(parts[1], 10, 32) if err != nil || uid == 0 { respondError(w, http.StatusBadRequest, "invalid remote id") return "", 0, false } return parts[0], uint32(uid), true } func externalRemoteMessageToMailMessage(account externalIMAPAccountRecord, msg externalIMAPRemoteMessage, includeBody bool) MailMessage { id := encodeExternalRemoteID(msg.Folder, msg.UID) messageID := msg.MessageID if messageID != "" && !strings.HasPrefix(messageID, "<") { messageID = "<" + messageID + ">" } return MailMessage{ ID: id, MailboxID: account.MailboxID, MailboxAddress: account.Name, FolderID: msg.Folder, Folder: msg.Folder, MessageUID: id, IMAPUID: int64(msg.UID), MessageID: messageID, Subject: msg.Subject, From: msg.From, FromName: msg.FromName, To: msg.To, CC: msg.CC, SentAt: msg.SentAt, ReceivedAt: msg.ReceivedAt, Snippet: msg.Snippet, IsRead: msg.IsRead, SizeBytes: msg.SizeBytes, HasAttachments: includeBody, ExternalAccountID: account.ID, } } func safeExternalEMLFilename(subject string) string { name := strings.TrimSpace(subject) if name == "" { name = "message" } name = strings.Map(func(r rune) rune { if r < 32 || strings.ContainsRune(`\/:*?"<>|`, r) { return '-' } return r }, name) if len([]rune(name)) > 80 { name = string([]rune(name)[:80]) } return name + ".eml" } func externalIMAPAttachmentPartsFromBodyStructure(body imap.BodyStructure) []externalIMAPAttachmentPart { now := time.Now().UTC() items := []externalIMAPAttachmentPart{} body.Walk(func(path []int, part imap.BodyStructure) bool { single, ok := part.(*imap.BodyStructureSinglePart) if !ok { return true } filename := strings.TrimSpace(single.Filename()) disposition := "" if disp := single.Disposition(); disp != nil { disposition = strings.ToLower(strings.TrimSpace(disp.Value)) } if filename == "" && disposition != "attachment" { return true } if filename == "" { filename = "attachment" } contentType := single.MediaType() if contentType == "/" || contentType == "" { contentType = "application/octet-stream" } items = append(items, externalIMAPAttachmentPart{ Attachment: Attachment{ ID: encodeExternalIMAPPartID(path), Filename: filename, ContentType: contentType, SizeBytes: int64(single.Size), CreatedAt: now, }, Encoding: strings.ToLower(strings.TrimSpace(single.Encoding)), }) return true }) return items } func encodeExternalIMAPPartID(path []int) string { parts := make([]string, 0, len(path)) for _, n := range path { parts = append(parts, strconv.Itoa(n)) } return strings.Join(parts, ".") } func decodeExternalIMAPPartID(value string) ([]int, error) { value = strings.TrimSpace(value) if value == "" || value == "raw" { return nil, errors.New("invalid part id") } rawParts := strings.Split(value, ".") path := make([]int, 0, len(rawParts)) for _, part := range rawParts { n, err := strconv.Atoi(part) if err != nil || n <= 0 { return nil, errors.New("invalid part id") } path = append(path, n) } return path, nil } func decodeExternalIMAPPartData(data []byte, encoding string) ([]byte, error) { switch strings.ToLower(strings.TrimSpace(encoding)) { case "base64": compact := strings.NewReplacer("\r", "", "\n", "", " ", "", "\t", "").Replace(string(data)) decoded := make([]byte, base64.StdEncoding.DecodedLen(len(compact))) n, err := base64.StdEncoding.Decode(decoded, []byte(compact)) if err != nil { return nil, err } return decoded[:n], nil case "quoted-printable": return io.ReadAll(quotedprintable.NewReader(strings.NewReader(string(data)))) default: return data, nil } } func escapeDownloadFilename(name string) string { name = strings.TrimSpace(name) if name == "" { name = "attachment" } name = strings.Map(func(r rune) rune { if r < 32 || r == '"' || r == '\\' { return '-' } return r }, name) if len([]rune(name)) > 120 { name = string([]rune(name)[:120]) } return mime.QEncoding.Encode("utf-8", name) } func (a *App) openExternalIMAPClient(ctx context.Context, account externalIMAPAccountRecord) (externalIMAPClient, error) { if err := a.validateExternalIMAPHost(ctx, account.Host); err != nil { return nil, err } addr := net.JoinHostPort(account.Host, strconv.Itoa(account.Port)) options := &imapclient.Options{ Dialer: &net.Dialer{Timeout: 10 * time.Second}, TLSConfig: &tls.Config{ServerName: account.Host, MinVersion: tls.VersionTLS12}, } var c *imapclient.Client var err error switch account.TLSMode { case externalIMAPTLS: c, err = imapclient.DialTLS(addr, options) case externalIMAPStartTLS: c, err = imapclient.DialStartTLS(addr, options) default: c, err = imapclient.DialInsecure(addr, options) } 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(newExternalIMAPXOAUTH2Client(account.Username, token)); err != nil { c.Close() return nil, fmt.Errorf("oauth xoauth2 authenticate failed: %w", 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 } type externalIMAPXOAUTH2Client struct { username string token string } func newExternalIMAPXOAUTH2Client(username, token string) externalIMAPXOAUTH2Client { return externalIMAPXOAUTH2Client{username: username, token: token} } func (c externalIMAPXOAUTH2Client) Start() (string, []byte, error) { return "XOAUTH2", externalIMAPXOAUTH2Response(c.username, c.token), nil } func (c externalIMAPXOAUTH2Client) Next(challenge []byte) ([]byte, error) { return []byte{}, nil } func externalIMAPXOAUTH2Response(username, token string) []byte { return []byte("user=" + username + "\x01auth=Bearer " + token + "\x01\x01") } 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 } func (c *goExternalIMAPClient) Close() error { if c.client == nil { return nil } _ = c.client.Logout().Wait() return c.client.Close() } func (c *goExternalIMAPClient) ListFolders(ctx context.Context) ([]externalIMAPRemoteFolder, error) { caps := c.client.Caps() var options *imap.ListOptions if caps.Has(imap.CapIMAP4rev2) || caps.Has(imap.CapListStatus) { options = &imap.ListOptions{ReturnStatus: &imap.StatusOptions{NumMessages: true, NumUnseen: true}} } list, err := c.client.List("", "*", options).Collect() if err != nil && options != nil { // Some Outlook/Exchange IMAP deployments advertise extended LIST // capabilities but reject LIST RETURN (STATUS ...) with // "BAD Command Argument Error. 12". Fall back to a plain LIST and // fetch counts with STATUS separately. options = nil list, err = c.client.List("", "*", nil).Collect() } if err != nil { return nil, err } folders := []externalIMAPRemoteFolder{} for _, item := range list { if strings.TrimSpace(item.Mailbox) == "" || mailboxHasNoSelect(item.Attrs) { continue } f := externalIMAPRemoteFolder{Name: item.Mailbox, Role: normalizeExternalIMAPFolderName(item.Mailbox)} if item.Status != nil { if item.Status.NumMessages != nil { f.TotalCount = int(*item.Status.NumMessages) } if item.Status.NumUnseen != nil { f.UnreadCount = int(*item.Status.NumUnseen) } } if strings.EqualFold(item.Mailbox, "INBOX") { f.Name = "INBOX" } folders = append(folders, f) } if options == nil { statusOptions := &imap.StatusOptions{NumMessages: true, NumUnseen: true} for i := range folders { status, err := c.client.Status(folders[i].Name, statusOptions).Wait() if err != nil { continue } if status.NumMessages != nil { folders[i].TotalCount = int(*status.NumMessages) } if status.NumUnseen != nil { folders[i].UnreadCount = int(*status.NumUnseen) } } } if len(folders) == 0 { folders = append(folders, externalIMAPRemoteFolder{Name: "INBOX", Role: "Inbox"}) } return folders, nil } func mailboxHasNoSelect(attrs []imap.MailboxAttr) bool { for _, attr := range attrs { if strings.EqualFold(string(attr), `\Noselect`) { return true } } return false } 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 limit <= 0 || limit > 100 { limit = externalIMAPMaxFetch } if selected.NumMessages == 0 { return nil, "", nil } maxUID := uint32(0) if strings.TrimSpace(cursor) != "" { parsed, _ := strconv.ParseUint(strings.TrimPrefix(cursor, "uid:"), 10, 32) maxUID = uint32(parsed) } 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 _, 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 len(uids) == limit { last := uint32(uids[len(uids)-1]) if last > 1 { next = "uid:" + strconv.FormatUint(uint64(last-1), 10) } } return out, next, nil } func (c *goExternalIMAPClient) FetchNew(ctx context.Context, folder string, afterUID uint32, limit int) ([]externalIMAPRemoteMessage, error) { selected, err := c.client.Select(folder, nil).Wait() if err != nil { return nil, err } if selected.NumMessages == 0 || selected.UIDNext <= imap.UID(afterUID+1) { return nil, nil } if limit <= 0 || limit > 100 { limit = 100 } var set imap.UIDSet set.AddRange(imap.UID(afterUID+1), selected.UIDNext-1) 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 := 0; i < len(messages) && len(out) < limit; i++ { out = append(out, fetchBufferToExternalMessage(folder, selected.UIDValidity, messages[i], nil)) } return out, nil } func (c *goExternalIMAPClient) FetchRaw(ctx context.Context, folder string, uid uint32) ([]byte, externalIMAPRemoteMessage, error) { selected, err := c.client.Select(folder, nil).Wait() if err != nil { return nil, externalIMAPRemoteMessage{}, err } bodySection := &imap.FetchItemBodySection{Peek: true} messages, err := c.client.Fetch(imap.UIDSetNum(imap.UID(uid)), &imap.FetchOptions{UID: true, Flags: true, Envelope: true, InternalDate: true, RFC822Size: true, BodySection: []*imap.FetchItemBodySection{bodySection}}).Collect() if err != nil { return nil, externalIMAPRemoteMessage{}, err } if len(messages) == 0 { return nil, externalIMAPRemoteMessage{}, sql.ErrNoRows } raw := messages[0].FindBodySection(bodySection) 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 } op := imap.StoreFlagsDel if read { op = imap.StoreFlagsAdd } return c.client.Store(imap.UIDSetNum(imap.UID(uid)), &imap.StoreFlags{Op: op, Flags: []imap.Flag{imap.FlagSeen}, Silent: true}, nil).Close() } func fetchBufferToExternalMessage(folder string, uidValidity uint32, msg *imapclient.FetchMessageBuffer, raw []byte) externalIMAPRemoteMessage { out := externalIMAPRemoteMessage{Folder: folder, UIDValidity: uidValidity, UID: uint32(msg.UID), ReceivedAt: time.Now().UTC(), Raw: raw} if msg.Envelope != nil { out.MessageID = msg.Envelope.MessageID out.Subject = msg.Envelope.Subject out.SentAt = msg.Envelope.Date out.From, out.FromName = firstIMAPAddress(msg.Envelope.From) out.To = imapAddresses(msg.Envelope.To) out.CC = imapAddresses(msg.Envelope.Cc) } if out.SentAt.IsZero() { out.SentAt = out.ReceivedAt } if !msg.InternalDate.IsZero() { out.ReceivedAt = msg.InternalDate } out.SizeBytes = msg.RFC822Size for _, flag := range msg.Flags { if flag == imap.FlagSeen { out.IsRead = true break } } if len(raw) > 0 { if stored, _, err := parseExternalIMAPRawForSnippet(raw); err == nil { if out.Subject == "" { out.Subject = stored.Subject } if out.MessageID == "" { out.MessageID = strings.Trim(stored.MessageID, "<>") } out.Snippet = stored.Snippet } } return out } func parseExternalIMAPRawForSnippet(raw []byte) (storedMessage, []AttachmentInput, error) { tmp := &App{now: time.Now, policy: NewHTMLPolicy()} return tmp.parseMaildirMessage(raw, "") } func firstIMAPAddress(addrs []imap.Address) (string, string) { for _, addr := range addrs { if email := addr.Addr(); email != "" { return normalizeEmail(email), addr.Name } } return "", "" } func imapAddresses(addrs []imap.Address) []string { out := []string{} for _, addr := range addrs { if email := addr.Addr(); email != "" { out = append(out, normalizeEmail(email)) } } return out }