Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 109 additions & 0 deletions cmd/msgvault/cmd/addo365.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package cmd

import (
"fmt"

"github.com/spf13/cobra"
imapclient "github.com/wesm/msgvault/internal/imap"
"github.com/wesm/msgvault/internal/microsoft"
"github.com/wesm/msgvault/internal/store"
)

var o365TenantID string

var addO365Cmd = &cobra.Command{
Use: "add-o365 <email>",
Short: "Add a Microsoft 365 account via OAuth",
Long: `Add a Microsoft 365 / Outlook.com email account using OAuth2 authentication.

This opens a browser for Microsoft authorization, then configures IMAP access
to outlook.office365.com automatically using the XOAUTH2 SASL mechanism.

Requires a [microsoft] section in config.toml with your Azure AD app's client_id.
See the docs for Azure AD app registration setup.

Examples:
msgvault add-o365 user@outlook.com
msgvault add-o365 user@company.com --tenant my-tenant-id`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
email := args[0]

if cfg.Microsoft.ClientID == "" {
return fmt.Errorf("Microsoft OAuth not configured.\n\n" +
"Add to your config.toml:\n\n" +
" [microsoft]\n" +
" client_id = \"your-azure-app-client-id\"\n\n" +
"See docs for Azure AD app registration setup.")
}

tenantID := cfg.Microsoft.EffectiveTenantID()
if o365TenantID != "" {
tenantID = o365TenantID
}

msMgr := microsoft.NewManager(
cfg.Microsoft.ClientID,
tenantID,
cfg.TokensDir(),
logger,
)

fmt.Printf("Authorizing %s with Microsoft...\n", email)
if err := msMgr.Authorize(cmd.Context(), email); err != nil {
return fmt.Errorf("authorization failed: %w", err)
}

// Auto-configure IMAP for outlook.office365.com
imapCfg := &imapclient.Config{
Host: "outlook.office365.com",
Port: 993,
TLS: true,
Username: email,
AuthMethod: imapclient.AuthXOAuth2,
}

dbPath := cfg.DatabaseDSN()
s, err := store.Open(dbPath)
if err != nil {
return fmt.Errorf("open database: %w", err)
}
defer s.Close()

if err := s.InitSchema(); err != nil {
return fmt.Errorf("init schema: %w", err)
}

identifier := imapCfg.Identifier()
source, err := s.GetOrCreateSource("imap", identifier)
if err != nil {
return fmt.Errorf("create source: %w", err)
}

cfgJSON, err := imapCfg.ToJSON()
if err != nil {
return fmt.Errorf("serialize config: %w", err)
}
if err := s.UpdateSourceSyncConfig(source.ID, cfgJSON); err != nil {
return fmt.Errorf("store config: %w", err)
}
if err := s.UpdateSourceDisplayName(source.ID, email); err != nil {
return fmt.Errorf("set display name: %w", err)
}

fmt.Printf("\nMicrosoft 365 account added successfully!\n")
fmt.Printf(" Email: %s\n", email)
fmt.Printf(" Identifier: %s\n", identifier)
fmt.Println()
fmt.Println("You can now run:")
fmt.Printf(" msgvault sync-full %s\n", identifier)

return nil
},
}

func init() {
addO365Cmd.Flags().StringVar(&o365TenantID, "tenant", "",
"Azure AD tenant ID (default: \"common\" for multi-tenant)")
rootCmd.AddCommand(addO365Cmd)
}
18 changes: 18 additions & 0 deletions cmd/msgvault/cmd/remove_account.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (

"github.com/spf13/cobra"
imaplib "github.com/wesm/msgvault/internal/imap"
"github.com/wesm/msgvault/internal/microsoft"
"github.com/wesm/msgvault/internal/oauth"
"github.com/wesm/msgvault/internal/store"
)
Expand Down Expand Up @@ -139,6 +140,23 @@ func runRemoveAccount(cmd *cobra.Command, args []string) error {
credPath, err,
)
}
// Also clean up Microsoft OAuth token if this was an XOAUTH2 source
if source.SyncConfig.Valid && source.SyncConfig.String != "" {
imapCfg, parseErr := imaplib.ConfigFromJSON(source.SyncConfig.String)
if parseErr == nil && imapCfg.EffectiveAuthMethod() == imaplib.AuthXOAuth2 {
msMgr := microsoft.NewManager(
cfg.Microsoft.ClientID,
cfg.Microsoft.EffectiveTenantID(),
cfg.TokensDir(),
logger,
)
if err := msMgr.DeleteToken(imapCfg.Username); err != nil {
fmt.Fprintf(os.Stderr,
"Warning: could not remove Microsoft token: %v\n", err,
)
}
}
}
}

// Remove analytics cache (shared across accounts, needs full rebuild)
Expand Down
18 changes: 16 additions & 2 deletions cmd/msgvault/cmd/sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"github.com/spf13/cobra"
"github.com/wesm/msgvault/internal/gmail"
imaplib "github.com/wesm/msgvault/internal/imap"
"github.com/wesm/msgvault/internal/microsoft"
"github.com/wesm/msgvault/internal/oauth"
"github.com/wesm/msgvault/internal/store"
"github.com/wesm/msgvault/internal/sync"
Expand Down Expand Up @@ -145,8 +146,21 @@ Examples:
}
gmailTargets = append(gmailTargets, syncTarget{source: src, email: src.Identifier})
case "imap":
if !imaplib.HasCredentials(cfg.TokensDir(), src.Identifier) {
fmt.Printf("Skipping %s (no credentials - run 'add-imap' first)\n", src.Identifier)
hasAuth := imaplib.HasCredentials(cfg.TokensDir(), src.Identifier)
if !hasAuth && src.SyncConfig.Valid {
imapCfg, parseErr := imaplib.ConfigFromJSON(src.SyncConfig.String)
if parseErr == nil && imapCfg.EffectiveAuthMethod() == imaplib.AuthXOAuth2 {
msMgr := microsoft.NewManager(
cfg.Microsoft.ClientID,
cfg.Microsoft.EffectiveTenantID(),
cfg.TokensDir(),
logger,
)
hasAuth = msMgr.HasToken(imapCfg.Username)
}
}
if !hasAuth {
fmt.Printf("Skipping %s (no credentials - run 'add-imap' or 'add-o365' first)\n", src.Identifier)
continue
}
imapTargets = append(imapTargets, src)
Expand Down
46 changes: 40 additions & 6 deletions cmd/msgvault/cmd/syncfull.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"github.com/spf13/cobra"
"github.com/wesm/msgvault/internal/gmail"
imaplib "github.com/wesm/msgvault/internal/imap"
"github.com/wesm/msgvault/internal/microsoft"
"github.com/wesm/msgvault/internal/oauth"
"github.com/wesm/msgvault/internal/store"
"github.com/wesm/msgvault/internal/sync"
Expand Down Expand Up @@ -133,8 +134,21 @@ Examples:
continue
}
case "imap":
if !imaplib.HasCredentials(cfg.TokensDir(), src.Identifier) {
fmt.Printf("Skipping %s (no credentials - run 'add-imap' first)\n", src.Identifier)
hasAuth := imaplib.HasCredentials(cfg.TokensDir(), src.Identifier)
if !hasAuth && src.SyncConfig.Valid && src.SyncConfig.String != "" {
imapCfg, parseErr := imaplib.ConfigFromJSON(src.SyncConfig.String)
if parseErr == nil && imapCfg.EffectiveAuthMethod() == imaplib.AuthXOAuth2 {
msMgr := microsoft.NewManager(
cfg.Microsoft.ClientID,
cfg.Microsoft.EffectiveTenantID(),
cfg.TokensDir(),
logger,
)
hasAuth = msMgr.HasToken(imapCfg.Username)
}
}
if !hasAuth {
fmt.Printf("Skipping %s (no credentials - run 'add-imap' or 'add-o365' first)\n", src.Identifier)
continue
}
default:
Expand Down Expand Up @@ -220,11 +234,31 @@ func buildAPIClient(ctx context.Context, src *store.Source, oauthMgr *oauth.Mana
if err != nil {
return nil, fmt.Errorf("parse IMAP config: %w", err)
}
password, err := imaplib.LoadCredentials(cfg.TokensDir(), src.Identifier)
if err != nil {
return nil, fmt.Errorf("load IMAP credentials: %w (run 'add-imap' first)", err)

var opts []imaplib.Option
opts = append(opts, imaplib.WithLogger(logger))

switch imapCfg.EffectiveAuthMethod() {
case imaplib.AuthXOAuth2:
msMgr := microsoft.NewManager(
cfg.Microsoft.ClientID,
cfg.Microsoft.EffectiveTenantID(),
cfg.TokensDir(),
logger,
)
tokenFn, err := msMgr.TokenSource(ctx, imapCfg.Username)
if err != nil {
return nil, fmt.Errorf("load Microsoft token: %w (run 'add-o365' first)", err)
}
opts = append(opts, imaplib.WithTokenSource(tokenFn))
return imaplib.NewClient(imapCfg, "", opts...), nil
default:
password, err := imaplib.LoadCredentials(cfg.TokensDir(), src.Identifier)
if err != nil {
return nil, fmt.Errorf("load IMAP credentials: %w (run 'add-imap' first)", err)
}
return imaplib.NewClient(imapCfg, password, opts...), nil
}
return imaplib.NewClient(imapCfg, password, imaplib.WithLogger(logger)), nil

default:
return nil, fmt.Errorf("unsupported source type %q", src.SourceType)
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ require (
github.com/charmbracelet/lipgloss v1.1.0
github.com/charmbracelet/x/ansi v0.11.6
github.com/emersion/go-imap/v2 v2.0.0-beta.8
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6
github.com/go-chi/chi/v5 v5.2.5
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f
github.com/google/go-cmp v0.7.0
Expand Down Expand Up @@ -45,7 +46,6 @@ require (
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
github.com/emersion/go-message v0.18.2 // indirect
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/go-viper/mapstructure/v2 v2.3.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
Expand Down
30 changes: 23 additions & 7 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,13 +70,14 @@ type RemoteConfig struct {

// Config represents the msgvault configuration.
type Config struct {
Data DataConfig `toml:"data"`
OAuth OAuthConfig `toml:"oauth"`
Sync SyncConfig `toml:"sync"`
Chat ChatConfig `toml:"chat"`
Server ServerConfig `toml:"server"`
Remote RemoteConfig `toml:"remote"`
Accounts []AccountSchedule `toml:"accounts"`
Data DataConfig `toml:"data"`
OAuth OAuthConfig `toml:"oauth"`
Microsoft MicrosoftConfig `toml:"microsoft"`
Sync SyncConfig `toml:"sync"`
Chat ChatConfig `toml:"chat"`
Server ServerConfig `toml:"server"`
Remote RemoteConfig `toml:"remote"`
Accounts []AccountSchedule `toml:"accounts"`

// Computed paths (not from config file)
HomeDir string `toml:"-"`
Expand All @@ -94,6 +95,21 @@ type OAuthConfig struct {
ClientSecrets string `toml:"client_secrets"`
}

// MicrosoftConfig holds Microsoft 365 / Azure AD OAuth configuration.
type MicrosoftConfig struct {
ClientID string `toml:"client_id"`
TenantID string `toml:"tenant_id"`
}

// EffectiveTenantID returns the tenant ID, defaulting to "common"
// (multi-tenant, works for personal + org accounts).
func (c *MicrosoftConfig) EffectiveTenantID() string {
if c.TenantID == "" {
return "common"
}
return c.TenantID
}

// SyncConfig holds sync-related configuration.
type SyncConfig struct {
RateLimitQPS int `toml:"rate_limit_qps"`
Expand Down
29 changes: 29 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1168,3 +1168,32 @@
t.Error("AllowInsecure should be true after saving with true")
}
}

func TestMicrosoftConfig(t *testing.T) {
tmpDir := t.TempDir()
configContent := `
[microsoft]
client_id = "test-client-id-123"
tenant_id = "my-tenant"
`
configPath := filepath.Join(tmpDir, "config.toml")
os.WriteFile(configPath, []byte(configContent), 0644)

Check failure on line 1180 in internal/config/config_test.go

View workflow job for this annotation

GitHub Actions / test

Error return value of `os.WriteFile` is not checked (errcheck)

cfg, err := Load(configPath, tmpDir)
if err != nil {
t.Fatal(err)
}
if cfg.Microsoft.ClientID != "test-client-id-123" {
t.Errorf("Microsoft.ClientID = %q, want %q", cfg.Microsoft.ClientID, "test-client-id-123")
}
if cfg.Microsoft.TenantID != "my-tenant" {
t.Errorf("Microsoft.TenantID = %q, want %q", cfg.Microsoft.TenantID, "my-tenant")
}
}

func TestMicrosoftConfig_DefaultTenant(t *testing.T) {
cfg := NewDefaultConfig()
if cfg.Microsoft.EffectiveTenantID() != "common" {
t.Errorf("EffectiveTenantID() = %q, want %q", cfg.Microsoft.EffectiveTenantID(), "common")
}
}
37 changes: 31 additions & 6 deletions internal/imap/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ func WithLogger(logger *slog.Logger) Option {
return func(c *Client) { c.logger = logger }
}

// WithTokenSource sets a callback that provides OAuth2 access tokens
// for XOAUTH2 SASL authentication. Required when Config.AuthMethod is AuthXOAuth2.
func WithTokenSource(fn func(ctx context.Context) (string, error)) Option {
return func(c *Client) { c.tokenSource = fn }
}

// fetchChunkSize is the maximum number of UIDs per UID FETCH command.
// Large FETCH sets cause server-side timeouts on big mailboxes; chunking
// keeps each round-trip short.
Expand All @@ -33,9 +39,10 @@ const listPageSize = 500

// Client implements gmail.API for IMAP servers.
type Client struct {
config *Config
password string
logger *slog.Logger
config *Config
password string
tokenSource func(ctx context.Context) (string, error) // XOAUTH2 token callback
logger *slog.Logger

mu sync.Mutex
conn *imapclient.Client
Expand Down Expand Up @@ -87,9 +94,27 @@ func (c *Client) connect(ctx context.Context) error {
return fmt.Errorf("dial IMAP %s: %w", addr, err)
}

if err := conn.Login(c.config.Username, c.password).Wait(); err != nil {
_ = conn.Close()
return fmt.Errorf("IMAP login: %w", err)
switch c.config.EffectiveAuthMethod() {
case AuthXOAuth2:
if c.tokenSource == nil {
_ = conn.Close()
return fmt.Errorf("XOAUTH2 auth requires a token source (use WithTokenSource)")
}
token, err := c.tokenSource(ctx)
if err != nil {
_ = conn.Close()
return fmt.Errorf("get XOAUTH2 token: %w", err)
}
saslClient := NewXOAuth2Client(c.config.Username, token)
if err := conn.Authenticate(saslClient); err != nil {
_ = conn.Close()
return fmt.Errorf("XOAUTH2 authenticate: %w", err)
}
default:
if err := conn.Login(c.config.Username, c.password).Wait(); err != nil {
_ = conn.Close()
return fmt.Errorf("IMAP login: %w", err)
}
}

c.conn = conn
Expand Down
Loading
Loading