A minimal email TUI where you read with Markdown and write in Neovim. neomd.ssp.sh/docs
email markdown neovim tui
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request #3 from notthatjesus/openauth2

Adding OpenAuth2 authentication support

authored by

Simon Späti and committed by
GitHub
27cb7f09 e4bf8532

+668 -78
+1 -1
README.md
··· 138 138 139 139 Use an app-specific password (Gmail, Fastmail, Hostpoint, etc.) rather than your main account password. 140 140 141 - For the full configuration reference including multiple accounts, `[[senders]]` aliases, folder customization, signatures, and UI options, see [docs/configuration.md](docs/configuration.md). 141 + For the full configuration reference including multiple accounts, OAuth2 authentication, `[[senders]]` aliases, folder customization, signatures, and UI options, see [docs/configuration.md](docs/configuration.md). 142 142 143 143 ### Onboarding 144 144
+42 -6
cmd/neomd/main.go
··· 2 2 package main 3 3 4 4 import ( 5 + "context" 5 6 "flag" 6 7 "fmt" 7 8 "os" 9 + "os/signal" 8 10 "strings" 9 11 10 12 tea "github.com/charmbracelet/bubbletea" 11 13 "github.com/sspaeti/neomd/internal/config" 12 14 goIMAP "github.com/sspaeti/neomd/internal/imap" 15 + "github.com/sspaeti/neomd/internal/oauth2" 13 16 "github.com/sspaeti/neomd/internal/screener" 14 17 "github.com/sspaeti/neomd/internal/ui" 15 18 ) ··· 26 29 fmt.Println("neomd", version) 27 30 return 28 31 } 32 + 33 + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) 34 + defer cancel() 29 35 30 36 cfg, err := config.Load(*cfgPath) 31 37 if err != nil { ··· 46 52 // Build one IMAP client per account. 47 53 imapClients := make([]*goIMAP.Client, 0, len(accounts)) 48 54 for _, acc := range accounts { 49 - if acc.User == "" || acc.Password == "" { 50 - fmt.Fprintf(os.Stderr, "neomd: account %q: user/password not set\n", acc.Name) 51 - os.Exit(1) 52 - } 53 55 h, p := splitAddr(acc.IMAP) 54 - imapClients = append(imapClients, goIMAP.New(goIMAP.Config{ 56 + imapCfg := goIMAP.Config{ 55 57 Host: h, 56 58 Port: p, 57 59 User: acc.User, 58 60 Password: acc.Password, 59 61 TLS: p == "993", 60 62 STARTTLS: p == "143", 61 - })) 63 + } 64 + if acc.IsOAuth2() { 65 + if acc.OAuth2ClientID == "" { 66 + fmt.Fprintf(os.Stderr, "neomd: account %q: oauth2_client_id is required\n", acc.Name) 67 + os.Exit(1) 68 + } 69 + if acc.OAuth2IssuerURL == "" && (acc.OAuth2AuthURL == "" || acc.OAuth2TokenURL == "") { 70 + fmt.Fprintf(os.Stderr, "neomd: account %q: set oauth2_issuer_url or both oauth2_auth_url and oauth2_token_url\n", acc.Name) 71 + os.Exit(1) 72 + } 73 + tokenFile, err := config.TokenFilePath(acc.Name) 74 + if err != nil { 75 + fmt.Fprintf(os.Stderr, "neomd: account %q: %v\n", acc.Name, err) 76 + os.Exit(1) 77 + } 78 + ts, err := oauth2.TokenSource(ctx, oauth2.Config{ 79 + ClientID: acc.OAuth2ClientID, 80 + ClientSecret: acc.OAuth2ClientSecret, 81 + IssuerURL: acc.OAuth2IssuerURL, 82 + AuthURL: acc.OAuth2AuthURL, 83 + TokenURL: acc.OAuth2TokenURL, 84 + Scopes: acc.OAuth2Scopes, 85 + RedirectPort: acc.OAuth2RedirectPort, 86 + TokenFile: tokenFile, 87 + }) 88 + if err != nil { 89 + fmt.Fprintf(os.Stderr, "neomd: account %q: oauth2: %v\n", acc.Name, err) 90 + os.Exit(1) 91 + } 92 + imapCfg.TokenSource = ts 93 + } else if acc.User == "" || acc.Password == "" { 94 + fmt.Fprintf(os.Stderr, "neomd: account %q: user/password not set\n", acc.Name) 95 + os.Exit(1) 96 + } 97 + imapClients = append(imapClients, goIMAP.New(imapCfg)) 62 98 } 63 99 defer func() { 64 100 for _, c := range imapClients {
+31
docs/configuration.md
··· 13 13 password = "app-password" 14 14 from = "Me <me@example.com>" 15 15 16 + # OAuth2 authenticated accounts are supported, it just need the relevant fields. Note that the password field is not required. 17 + [[accounts]] 18 + name = "Personal" 19 + imap = "imap.example.com:993" # :993 = TLS, :143 = STARTTLS 20 + smtp = "smtp.example.com:587" 21 + user = "me@example.com" 22 + from = "Me <me@example.com>" 23 + oauth2_client_id = "" 24 + oauth2_client_secret = "" 25 + oauth2_issuer_url = "" 26 + oauth2_scopes = ["", ""] 27 + 16 28 # Multiple accounts supported — add more [[accounts]] blocks 17 29 # Switch between them with `ctrl+a` in the inbox 18 30 ··· 75 87 The `signature` field in `[ui]` is appended automatically when opening a new compose buffer (`c`). It is **not** added for replies. The separator `--` is inserted for you — just write the signature body in Markdown. 76 88 77 89 Use TOML triple-quoted strings (`"""`) to preserve line breaks. The signature appears at the end of the buffer — you can edit or delete it before saving. 90 + 91 + ## OAuth2 Authentication 92 + 93 + Neomd supports OpenAuth2 authenticated accounts, you just need to add `oauth2_client_id`, `oauth2_client_secret`, `oauth2_scopes` and `oauth2_issuer_url`. 94 + 95 + Note that when using oauth2 authentication, the password field is not required in the account configuration. 96 + 97 + ### Issuer URL 98 + 99 + By default, if an issuer URL is provided, i.e.: `https://login.microsoftonline.com/common/v2.0` for Office265 accounts, neomd will search for the OpenID Connect discovery URL: `/.well-known/openid-configuration` resolving then the `oauth2_token_url` and `oauth2_auth_url`. These parameters can be provided manually as well. 100 + 101 + ### Scopes 102 + 103 + The scopes required depends on the provider and is better confirmed by your email provider. As an example, for Office365 acounts, the following scopes are required for IMAP: `"https://outlook.office365.com/IMAP.AccessAsUser.All", "offline_access"`. 104 + 105 + ### Reference documentation for GMAIL and Office365 106 + 107 + - To enable OAuth2 authentication for Office365 accounts, follow the documentation [here]("https://outlook.office365.com/IMAP.AccessAsUser.All", "offline_access") 108 + - For GMAIL, follow the documentation [here](https://developers.google.com/workspace/gmail/imap/xoauth2-protocol)
+3 -2
go.mod
··· 4 4 5 5 require ( 6 6 github.com/BurntSushi/toml v1.6.0 7 + github.com/JohannesKaufmann/html-to-markdown v1.6.0 7 8 github.com/charmbracelet/bubbles v1.0.0 8 9 github.com/charmbracelet/bubbletea v1.3.10 9 10 github.com/charmbracelet/glamour v0.9.1 10 11 github.com/charmbracelet/lipgloss v1.1.0 11 12 github.com/emersion/go-imap/v2 v2.0.0-beta.8 12 13 github.com/emersion/go-message v0.18.2 14 + github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 13 15 github.com/yuin/goldmark v1.7.8 16 + golang.org/x/oauth2 v0.35.0 14 17 ) 15 18 16 19 require ( 17 - github.com/JohannesKaufmann/html-to-markdown v1.6.0 // indirect 18 20 github.com/PuerkitoBio/goquery v1.9.2 // indirect 19 21 github.com/alecthomas/chroma/v2 v2.14.0 // indirect 20 22 github.com/andybalholm/cascadia v1.3.2 // indirect ··· 29 31 github.com/clipperhouse/stringish v0.1.1 // indirect 30 32 github.com/clipperhouse/uax29/v2 v2.5.0 // indirect 31 33 github.com/dlclark/regexp2 v1.11.0 // indirect 32 - github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect 33 34 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect 34 35 github.com/gorilla/css v1.0.1 // indirect 35 36 github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
+6
go.sum
··· 85 85 github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= 86 86 github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= 87 87 github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 88 + github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 88 89 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 89 90 github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 90 91 github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= ··· 92 93 github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 93 94 github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= 94 95 github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= 96 + github.com/sebdah/goldie/v2 v2.5.3 h1:9ES/mNN+HNUbNWpVAlrzuZ7jE+Nrczbj8uFRjM7624Y= 95 97 github.com/sebdah/goldie/v2 v2.5.3/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvKI/NNtssI= 96 98 github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= 99 + github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= 97 100 github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= 98 101 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 99 102 github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= ··· 126 129 golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= 127 130 golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= 128 131 golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= 132 + golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= 133 + golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= 129 134 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 130 135 golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 131 136 golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= ··· 171 176 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 172 177 gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 173 178 gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 179 + gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 174 180 gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
+48 -8
internal/config/config.go
··· 5 5 "fmt" 6 6 "os" 7 7 "path/filepath" 8 + "runtime" 8 9 "strings" 9 10 10 11 "github.com/BurntSushi/toml" ··· 23 24 // AccountConfig holds IMAP/SMTP connection settings. 24 25 type AccountConfig struct { 25 26 Name string `toml:"name"` 26 - IMAP string `toml:"imap"` // host:port (993 = TLS, 143 = STARTTLS) 27 - SMTP string `toml:"smtp"` // host:port (587 = STARTTLS, 465 = TLS) 27 + IMAP string `toml:"imap"` // host:port (993 = TLS, 143 = STARTTLS) 28 + SMTP string `toml:"smtp"` // host:port (587 = STARTTLS, 465 = TLS) 28 29 User string `toml:"user"` 29 30 Password string `toml:"password"` 30 31 From string `toml:"from"` // "Name <email@example.com>" 31 32 STARTTLS bool `toml:"starttls"` 33 + 34 + // OAuth2 fields — only used when auth_type = "oauth2". 35 + AuthType string `toml:"auth_type"` // "plain" (default) | "oauth2" 36 + OAuth2ClientID string `toml:"oauth2_client_id"` 37 + OAuth2ClientSecret string `toml:"oauth2_client_secret"` 38 + OAuth2IssuerURL string `toml:"oauth2_issuer_url"` // OIDC discovery endpoint (e.g. "https://accounts.google.com") 39 + OAuth2AuthURL string `toml:"oauth2_auth_url"` // manual override; skips discovery 40 + OAuth2TokenURL string `toml:"oauth2_token_url"` // manual override; skips discovery 41 + OAuth2Scopes []string `toml:"oauth2_scopes"` 42 + OAuth2RedirectPort int `toml:"oauth2_redirect_port"` // local callback port; default 8085 43 + } 44 + 45 + // IsOAuth2 reports whether this account uses OAuth2 instead of password auth. 46 + func (a AccountConfig) IsOAuth2() bool { 47 + return strings.EqualFold(a.AuthType, "oauth2") 32 48 } 33 49 34 50 // ScreenerConfig points to the allowlist/blocklist files. ··· 101 117 102 118 // UIConfig holds display preferences. 103 119 type UIConfig struct { 104 - Theme string `toml:"theme"` // dark | light | auto 105 - InboxCount int `toml:"inbox_count"` // number of messages to fetch 106 - Signature string `toml:"signature"` // appended to new compose buffers (markdown) 107 - AutoScreenOnLoad *bool `toml:"auto_screen_on_load"` // screen inbox on every load (default true) 108 - BgSyncInterval int `toml:"bg_sync_interval"` // background sync interval in minutes (0 = disabled, default 5) 109 - BulkProgressThreshold int `toml:"bulk_progress_threshold"` // show progress counter for batches larger than this (default 10) 120 + Theme string `toml:"theme"` // dark | light | auto 121 + InboxCount int `toml:"inbox_count"` // number of messages to fetch 122 + Signature string `toml:"signature"` // appended to new compose buffers (markdown) 123 + AutoScreenOnLoad *bool `toml:"auto_screen_on_load"` // screen inbox on every load (default true) 124 + BgSyncInterval int `toml:"bg_sync_interval"` // background sync interval in minutes (0 = disabled, default 5) 125 + BulkProgressThreshold int `toml:"bulk_progress_threshold"` // show progress counter for batches larger than this (default 10) 110 126 } 111 127 112 128 // BulkThreshold returns the configured bulk progress threshold (default 10). ··· 342 358 } 343 359 return path 344 360 } 361 + 362 + func TokenFilePath(accountName string) (string, error) { 363 + var configDir string 364 + if runtime.GOOS == "windows" { 365 + var err error 366 + configDir, err = os.UserConfigDir() 367 + if err != nil { 368 + return "", fmt.Errorf("resolve config directory: %w", err) 369 + } 370 + } else { 371 + home, err := os.UserHomeDir() 372 + if err != nil { 373 + return "", fmt.Errorf("resolve home directory: %w", err) 374 + } 375 + configDir = filepath.Join(home, ".config") 376 + } 377 + safe := strings.Map(func(r rune) rune { 378 + if r == '/' || r == '\\' || r == ':' { 379 + return '_' 380 + } 381 + return r 382 + }, accountName) 383 + return filepath.Join(configDir, cacheDirName, "tokens", safe+".json"), nil 384 + }
+36 -9
internal/imap/client.go
··· 20 20 "github.com/emersion/go-imap/v2/imapclient" 21 21 "github.com/emersion/go-message" 22 22 "github.com/emersion/go-message/mail" 23 + "github.com/sspaeti/neomd/internal/oauth2" 23 24 ) 24 25 25 26 // Email is a fully parsed email message. ··· 48 49 49 50 // Config holds connection parameters. 50 51 type Config struct { 51 - Host string // e.g. "imap.example.com" 52 - Port string // e.g. "993" or "143" 53 - User string 54 - Password string 55 - TLS bool // implicit TLS (port 993) 56 - STARTTLS bool // STARTTLS upgrade (port 143) 52 + Host string // e.g. "imap.example.com" 53 + Port string // e.g. "993" or "143" 54 + User string 55 + Password string 56 + TLS bool // implicit TLS (port 993) 57 + STARTTLS bool // STARTTLS upgrade (port 143) 58 + TokenSource func() (string, error) // The token is used instead of the password for OAuth2 Accounts 57 59 } 58 60 59 61 // Client wraps an IMAP connection with reconnection management. ··· 97 99 if err != nil { 98 100 return fmt.Errorf("dial %s: %w", addr, err) 99 101 } 100 - if err := conn.Login(c.cfg.User, c.cfg.Password).Wait(); err != nil { 102 + 103 + if err := c.authenticate(conn); err != nil { 101 104 _ = conn.Close() 102 - return fmt.Errorf("IMAP login: %w", err) 105 + return err 103 106 } 104 107 c.conn = conn 105 108 c.selectedMailbox = "" 106 109 return nil 107 110 } 108 111 112 + // Dedicated authenticate function. It manages authentication for both plain and OAuth2 if a TokenSource exists. 113 + func (c *Client) authenticate(conn *imapclient.Client) error { 114 + if c.cfg.TokenSource == nil { 115 + if err := conn.Login(c.cfg.User, c.cfg.Password).Wait(); err != nil { 116 + return fmt.Errorf("IMAP login: %w", err) 117 + } 118 + return nil 119 + } 120 + 121 + token, err := c.cfg.TokenSource() 122 + if err != nil { 123 + return fmt.Errorf("get OAuth2 token: %w", err) 124 + } 125 + saslClient := oauth2.XOAuth2Client(c.cfg.User, token) 126 + if err := conn.Authenticate(saslClient); err != nil { 127 + return fmt.Errorf("IMAP XOAUTH2: %w", err) 128 + } 129 + return nil 130 + } 131 + 109 132 func (c *Client) reconnect(ctx context.Context) error { 110 133 if c.conn != nil { 111 134 _ = c.conn.Close() ··· 153 176 c.conn = nil 154 177 } 155 178 } 179 + 180 + // TokenSource returns the OAuth2 token source for this client, or nil for 181 + // password-authenticated accounts. 182 + func (c *Client) TokenSource() func() (string, error) { return c.cfg.TokenSource } 156 183 157 184 // Addr returns the IMAP server address (host:port). 158 185 func (c *Client) Addr() string { return c.addr() } ··· 930 957 // cidImgRe matches <img ...src="cid:XYZ"...> tags (with or without alt). 931 958 var cidImgRe = regexp.MustCompile(`(?i)<img\b([^>]*?)src="cid:([^"]+)"([^>]*?)>`) 932 959 933 - // emptyAltRe matches alt="" or alt='' (empty alt attribute). 960 + // emptyAltRe matches alt="" or alt=” (empty alt attribute). 934 961 var emptyAltRe = regexp.MustCompile(`(?i)\s*alt=["']\s*["']`) 935 962 936 963 // injectCIDAlt adds alt="filename" to <img src="cid:..."> tags that lack an alt
+299
internal/oauth2/oauth2.go
··· 1 + // Package oauth2 manages OAuth2 tokens for neomd accounts. 2 + // It runs the authorization code flow on first use (opening the user's browser), 3 + // persists the token to a JSON file, and refreshes it automatically on expiry. 4 + // 5 + // Endpoints can be discovered automatically from an OIDC issuer URL 6 + // (e.g. "https://accounts.google.com") or provided manually via AuthURL/TokenURL. 7 + package oauth2 8 + 9 + import ( 10 + "cmp" 11 + "context" 12 + "crypto/rand" 13 + _ "embed" 14 + "encoding/hex" 15 + "encoding/json" 16 + "fmt" 17 + "io" 18 + "net" 19 + "net/http" 20 + "os" 21 + "os/exec" 22 + "path/filepath" 23 + "runtime" 24 + "time" 25 + 26 + "github.com/emersion/go-sasl" 27 + "golang.org/x/oauth2" 28 + ) 29 + 30 + //go:embed static/oauth2_success.html 31 + var successHTML string 32 + 33 + // Config holds OAuth2 settings for a single account. 34 + // Either IssuerURL (OIDC discovery) or both AuthURL+TokenURL must be set. 35 + // If all three are set, AuthURL and TokenURL take precedence. 36 + type Config struct { 37 + ClientID string 38 + ClientSecret string 39 + IssuerURL string // OIDC issuer; discovers AuthURL+TokenURL automatically 40 + AuthURL string // manual override (skips discovery) 41 + TokenURL string // manual override (skips discovery) 42 + Scopes []string 43 + RedirectPort int // local callback port; defaults to 8085 44 + TokenFile string // path to persist the token JSON 45 + 46 + DiscoveryTimeout time.Duration // Timeout for the discovery OIDC HTTP request. Defaults to 10s 47 + AuthFlowTimeout time.Duration // Timeout for the AuthFlow to be completed. Defaults to 5m 48 + } 49 + 50 + func (c *Config) redirectPort() int { 51 + if c.RedirectPort == 0 { 52 + return 8085 53 + } 54 + return c.RedirectPort 55 + } 56 + 57 + func (c *Config) redirectURL() string { 58 + return fmt.Sprintf("http://localhost:%d/callback", c.redirectPort()) 59 + } 60 + 61 + // Default OIDC discovery timeout: 10 seconds 62 + func (c *Config) discoveryTimeout() time.Duration { 63 + return cmp.Or(c.DiscoveryTimeout, 10*time.Second) 64 + } 65 + 66 + // Default Authflow timeout: 5 minutes 67 + func (c *Config) authFlowTimeout() time.Duration { 68 + return cmp.Or(c.AuthFlowTimeout, 5*time.Minute) 69 + } 70 + 71 + // resolve returns the final AuthURL and TokenURL, discovering them from the 72 + // OIDC issuer document if manual URLs are not provided. 73 + func (c *Config) resolve(ctx context.Context, timeout time.Duration) (string, string, error) { 74 + if c.AuthURL != "" && c.TokenURL != "" { 75 + return c.AuthURL, c.TokenURL, nil 76 + } 77 + if c.IssuerURL == "" { 78 + return "", "", fmt.Errorf("oauth2: set oauth2_issuer_url or both oauth2_auth_url and oauth2_token_url") 79 + } 80 + 81 + ctx, cancel := context.WithTimeout(ctx, timeout) 82 + defer cancel() 83 + 84 + authURL, tokenURL, err := discoverEndpoints(ctx, c.IssuerURL) 85 + if err != nil { 86 + return "", "", err 87 + } 88 + return cmp.Or(c.AuthURL, authURL), cmp.Or(c.TokenURL, tokenURL), nil 89 + } 90 + 91 + // discoverEndpoints fetches {issuer}/.well-known/openid-configuration and 92 + // returns the authorization_endpoint and token_endpoint values. 93 + func discoverEndpoints(ctx context.Context, issuerURL string) (string, string, error) { 94 + discoveryURL := issuerURL + "/.well-known/openid-configuration" 95 + req, err := http.NewRequestWithContext(ctx, http.MethodGet, discoveryURL, nil) 96 + if err != nil { 97 + return "", "", fmt.Errorf("build discovery request: %w", err) 98 + } 99 + resp, err := http.DefaultClient.Do(req) 100 + if err != nil { 101 + return "", "", fmt.Errorf("fetch OIDC discovery document from %s: %w", discoveryURL, err) 102 + } 103 + defer resp.Body.Close() 104 + if resp.StatusCode != http.StatusOK { 105 + return "", "", fmt.Errorf("OIDC discovery %s: HTTP %d", discoveryURL, resp.StatusCode) 106 + } 107 + 108 + var doc struct { 109 + AuthorizationEndpoint string `json:"authorization_endpoint"` 110 + TokenEndpoint string `json:"token_endpoint"` 111 + } 112 + const maxBody = 1 << 20 // 1 MiB 113 + if err := json.NewDecoder(io.LimitReader(resp.Body, maxBody)).Decode(&doc); err != nil { 114 + return "", "", fmt.Errorf("parse OIDC discovery document: %w", err) 115 + } 116 + if doc.AuthorizationEndpoint == "" || doc.TokenEndpoint == "" { 117 + return "", "", fmt.Errorf("OIDC discovery document missing authorization_endpoint or token_endpoint") 118 + } 119 + return doc.AuthorizationEndpoint, doc.TokenEndpoint, nil 120 + } 121 + 122 + // TokenSource returns a function that always provides a valid access token. 123 + // On the first call it loads the token from TokenFile; if none exists it runs 124 + // the full browser-based authorization code flow. Subsequent calls refresh the 125 + // token automatically when it is expired. 126 + func TokenSource(ctx context.Context, cfg Config) (func() (string, error), error) { 127 + authURL, tokenURL, err := cfg.resolve(ctx, cfg.discoveryTimeout()) 128 + if err != nil { 129 + return nil, err 130 + } 131 + oc := &oauth2.Config{ 132 + ClientID: cfg.ClientID, 133 + ClientSecret: cfg.ClientSecret, 134 + Endpoint: oauth2.Endpoint{ 135 + AuthURL: authURL, 136 + TokenURL: tokenURL, 137 + AuthStyle: oauth2.AuthStyleInParams, 138 + }, 139 + RedirectURL: cfg.redirectURL(), 140 + Scopes: cfg.Scopes, 141 + } 142 + 143 + tok, err := loadToken(cfg.TokenFile) 144 + if err != nil { 145 + flowCtx, flowCancel := context.WithTimeout(ctx, cfg.authFlowTimeout()) 146 + defer flowCancel() 147 + 148 + tok, err = runAuthFlow(flowCtx, cfg, oc) 149 + if err != nil { 150 + return nil, fmt.Errorf("oauth2 auth flow: %w", err) 151 + } 152 + if err := saveToken(cfg.TokenFile, tok); err != nil { 153 + return nil, fmt.Errorf("save oauth2 token: %w", err) 154 + } 155 + } 156 + 157 + ts := oc.TokenSource(ctx, tok) 158 + 159 + return func() (string, error) { 160 + t, err := ts.Token() 161 + if err != nil { 162 + return "", err 163 + } 164 + _ = saveToken(cfg.TokenFile, t) 165 + return t.AccessToken, nil 166 + }, nil 167 + } 168 + 169 + // runAuthFlow starts a local callback server, opens the browser at the 170 + // authorization URL, and waits for the provider to redirect back with a code. 171 + func runAuthFlow(ctx context.Context, cfg Config, oc *oauth2.Config) (*oauth2.Token, error) { 172 + state, err := randomState() 173 + if err != nil { 174 + return nil, fmt.Errorf("generate oauth2 state: %w", err) 175 + } 176 + 177 + codeCh := make(chan string, 1) 178 + errCh := make(chan error, 1) 179 + 180 + ln, err := net.Listen("tcp", fmt.Sprintf("localhost:%d", cfg.redirectPort())) 181 + if err != nil { 182 + return nil, fmt.Errorf("listen on redirect port %d: %w", cfg.redirectPort(), err) 183 + } 184 + 185 + mux := http.NewServeMux() 186 + srv := &http.Server{Handler: mux} 187 + mux.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) { 188 + if r.URL.Query().Get("state") != state { 189 + http.Error(w, "state mismatch", http.StatusBadRequest) 190 + errCh <- fmt.Errorf("oauth2 state mismatch") 191 + return 192 + } 193 + code := r.URL.Query().Get("code") 194 + if code == "" { 195 + http.Error(w, "missing code", http.StatusBadRequest) 196 + errCh <- fmt.Errorf("oauth2 callback: missing code parameter") 197 + return 198 + } 199 + w.Header().Set("Content-Type", "text/html; charset=utf-8") 200 + fmt.Fprintln(w, successHTML) 201 + codeCh <- code 202 + }) 203 + 204 + go func() { 205 + if err := srv.Serve(ln); err != nil && err != http.ErrServerClosed { 206 + errCh <- err 207 + } 208 + }() 209 + defer srv.Close() 210 + 211 + authURL := oc.AuthCodeURL(state, oauth2.AccessTypeOffline) 212 + fmt.Printf("\nOpening browser for OAuth2 authorization...\n%s\n\nIf the browser did not open, paste the URL above manually.\nWaiting for authorization...\n\n", authURL) 213 + openBrowser(authURL) 214 + 215 + var code string 216 + select { 217 + case code = <-codeCh: 218 + case err = <-errCh: 219 + return nil, err 220 + case <-ctx.Done(): 221 + return nil, fmt.Errorf("oauth2 auth flow: %w", ctx.Err()) 222 + } 223 + 224 + tok, err := oc.Exchange(ctx, code) 225 + if err != nil { 226 + return nil, fmt.Errorf("exchange authorization code: %w", err) 227 + } 228 + return tok, nil 229 + } 230 + 231 + func randomState() (string, error) { 232 + b := make([]byte, 16) 233 + if _, err := rand.Read(b); err != nil { 234 + return "", err 235 + } 236 + return hex.EncodeToString(b), nil 237 + } 238 + 239 + // Opens a browser to initiate the AuthFlow 240 + func openBrowser(url string) { 241 + var cmd string 242 + var args []string 243 + switch runtime.GOOS { 244 + case "darwin": 245 + cmd, args = "open", []string{url} 246 + case "windows": 247 + cmd, args = "rundll32", []string{"url.dll,FileProtocolHandler", url} 248 + default: 249 + cmd, args = "xdg-open", []string{url} 250 + } 251 + _ = exec.Command(cmd, args...).Start() 252 + } 253 + 254 + func saveToken(path string, tok *oauth2.Token) error { 255 + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { 256 + return err 257 + } 258 + f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600) 259 + if err != nil { 260 + return err 261 + } 262 + defer f.Close() 263 + return json.NewEncoder(f).Encode(tok) 264 + } 265 + 266 + // XOAuth2Client returns a sasl.Client that implements the XOAUTH2 mechanism. 267 + // Both Google and Microsoft Exchange Online support XOAUTH2; it is more 268 + // broadly compatible than the RFC 7628 OAUTHBEARER mechanism. 269 + func XOAuth2Client(username, token string) sasl.Client { 270 + return &xoauth2Client{username: username, token: token} 271 + } 272 + 273 + type xoauth2Client struct { 274 + username string 275 + token string 276 + } 277 + 278 + func (c *xoauth2Client) Start() (string, []byte, error) { 279 + ir := fmt.Sprintf("user=%s\x01auth=Bearer %s\x01\x01", c.username, c.token) 280 + return "XOAUTH2", []byte(ir), nil 281 + } 282 + 283 + // Next is called when the server sends a challenge after a failed auth. 284 + // We return an empty response to cleanly abort the exchange. 285 + func (c *xoauth2Client) Next(_ []byte) ([]byte, error) { 286 + return []byte{}, nil 287 + } 288 + 289 + func loadToken(path string) (*oauth2.Token, error) { 290 + data, err := os.ReadFile(path) 291 + if err != nil { 292 + return nil, fmt.Errorf("read token file: %w", err) 293 + } 294 + var tok oauth2.Token 295 + if err := json.Unmarshal(data, &tok); err != nil { 296 + return nil, fmt.Errorf("parse token file: %w", err) 297 + } 298 + return &tok, nil 299 + }
+55
internal/oauth2/static/oauth2_success.html
··· 1 + <!doctype html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="UTF-8" /> 5 + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 6 + <title>neomd — authenticated</title> 7 + <style> 8 + * { 9 + box-sizing: border-box; 10 + margin: 0; 11 + padding: 0; 12 + } 13 + body { 14 + font-family: 15 + ui-monospace, "SFMono-Regular", Menlo, Monaco, Consolas, 16 + monospace; 17 + background: #1f1f28; 18 + color: #dcd7ba; 19 + display: flex; 20 + align-items: center; 21 + justify-content: center; 22 + min-height: 100vh; 23 + } 24 + .card { 25 + text-align: center; 26 + padding: 3rem 4rem; 27 + border: 1px solid #363646; 28 + border-radius: 8px; 29 + background: #2a2a37; 30 + } 31 + .check { 32 + font-size: 3rem; 33 + margin-bottom: 1rem; 34 + } 35 + h1 { 36 + font-size: 1.4rem; 37 + font-weight: 600; 38 + color: #98bb6c; 39 + margin-bottom: 0.75rem; 40 + } 41 + p { 42 + font-size: 0.95rem; 43 + color: #9cabca; 44 + line-height: 1.6; 45 + } 46 + </style> 47 + </head> 48 + <body> 49 + <div class="card"> 50 + <div class="check">✓</div> 51 + <h1>Authentication successful</h1> 52 + <p>You can close this tab and return to neomd.</p> 53 + </div> 54 + </body> 55 + </html>
+45 -8
internal/smtp/sender.go
··· 32 32 User string 33 33 Password string 34 34 From string // "Name <email>" 35 + 36 + // TokenSource is used for OAuth2 accounts instead of Password. 37 + TokenSource func() (string, error) 38 + } 39 + 40 + func (c Config) auth(host string) (smtp.Auth, error) { 41 + if c.TokenSource != nil { 42 + token, err := c.TokenSource() 43 + if err != nil { 44 + return nil, fmt.Errorf("get OAuth2 token: %w", err) 45 + } 46 + return &xoauth2Auth{user: c.User, token: token}, nil 47 + } 48 + return smtp.PlainAuth("", c.User, c.Password, host), nil 49 + } 50 + 51 + type xoauth2Auth struct { 52 + user string 53 + token string 54 + } 55 + 56 + func (a *xoauth2Auth) Start(_ *smtp.ServerInfo) (string, []byte, error) { 57 + ir := fmt.Sprintf("user=%s\x01auth=Bearer %s\x01\x01", a.user, a.token) 58 + return "XOAUTH2", []byte(ir), nil 59 + } 60 + 61 + func (a *xoauth2Auth) Next(_ []byte, more bool) ([]byte, error) { 62 + if more { 63 + return []byte{}, nil 64 + } 65 + return nil, nil 35 66 } 36 67 37 68 // Send composes and sends an email. ··· 59 90 } 60 91 fromAddr := extractAddr(cfg.From) 61 92 93 + auth, err := cfg.auth(cfg.Host) 94 + if err != nil { 95 + return err 96 + } 62 97 addr := cfg.Host + ":" + cfg.Port 63 98 switch cfg.Port { 64 99 case "465": // Implicit TLS (SMTPS) 65 - return sendTLS(addr, cfg.Host, cfg.User, cfg.Password, fromAddr, toAddrs, raw) 100 + return sendTLS(addr, cfg.Host, auth, fromAddr, toAddrs, raw) 66 101 default: // STARTTLS (587) or plain (25) 67 - return sendSTARTTLS(addr, cfg.Host, cfg.User, cfg.Password, fromAddr, toAddrs, raw) 102 + return sendSTARTTLS(addr, auth, fromAddr, toAddrs, raw) 68 103 } 69 104 } 70 105 ··· 73 108 // This lets the caller build the message once and reuse it (e.g. to save to Sent). 74 109 func SendRaw(cfg Config, toAddrs []string, raw []byte) error { 75 110 fromAddr := extractAddr(cfg.From) 111 + auth, err := cfg.auth(cfg.Host) 112 + if err != nil { 113 + return err 114 + } 76 115 addr := cfg.Host + ":" + cfg.Port 77 116 switch cfg.Port { 78 117 case "465": 79 - return sendTLS(addr, cfg.Host, cfg.User, cfg.Password, fromAddr, toAddrs, raw) 118 + return sendTLS(addr, cfg.Host, auth, fromAddr, toAddrs, raw) 80 119 default: 81 - return sendSTARTTLS(addr, cfg.Host, cfg.User, cfg.Password, fromAddr, toAddrs, raw) 120 + return sendSTARTTLS(addr, auth, fromAddr, toAddrs, raw) 82 121 } 83 122 } 84 123 85 124 // sendSTARTTLS sends via STARTTLS upgrade (port 587). 86 - func sendSTARTTLS(addr, host, user, password, from string, to []string, msg []byte) error { 87 - auth := smtp.PlainAuth("", user, password, host) 125 + func sendSTARTTLS(addr string, auth smtp.Auth, from string, to []string, msg []byte) error { 88 126 return smtp.SendMail(addr, auth, from, to, msg) 89 127 } 90 128 91 129 // sendTLS sends via implicit TLS (port 465 / SMTPS). 92 - func sendTLS(addr, host, user, password, from string, to []string, msg []byte) error { 130 + func sendTLS(addr, host string, auth smtp.Auth, from string, to []string, msg []byte) error { 93 131 tlsCfg := &tls.Config{ServerName: host} 94 132 conn, err := tls.Dial("tcp", addr, tlsCfg) 95 133 if err != nil { ··· 102 140 } 103 141 defer c.Close() 104 142 105 - auth := smtp.PlainAuth("", user, password, host) 106 143 if err := c.Auth(auth); err != nil { 107 144 return fmt.Errorf("SMTP auth: %w", err) 108 145 }
+102 -44
internal/ui/model.go
··· 50 50 webURL string // canonical "view online" URL (List-Post header or plain-text preamble) 51 51 attachments []imap.Attachment 52 52 } 53 - sendDoneMsg struct{ err error; warning string } 53 + sendDoneMsg struct { 54 + err error 55 + warning string 56 + } 54 57 screenDoneMsg struct{ err error } 55 - autoScreenDoneMsg struct{ moved int; err error } 56 - deepScreenReadyMsg struct { 58 + autoScreenDoneMsg struct { 59 + moved int 60 + err error 61 + } 62 + deepScreenReadyMsg struct { 57 63 moves []autoScreenMove 58 64 total int 59 65 } ··· 73 79 // folderCountsMsg carries unseen counts for watched folder tabs. 74 80 folderCountsMsg struct{ counts map[string]int } 75 81 // deleteAllReadyMsg carries UIDs to permanently delete after y/n confirm. 76 - deleteAllReadyMsg struct{ uids []uint32; folder string } 82 + deleteAllReadyMsg struct { 83 + uids []uint32 84 + folder string 85 + } 77 86 // ensureFoldersDoneMsg reports which folders were created. 78 - ensureFoldersDoneMsg struct{ created []string; err error } 79 - moveDoneMsg struct{ err error; undo []undoMove } 80 - batchDoneMsg struct{ err error; undo []undoMove } 87 + ensureFoldersDoneMsg struct { 88 + created []string 89 + err error 90 + } 91 + moveDoneMsg struct { 92 + err error 93 + undo []undoMove 94 + } 95 + batchDoneMsg struct { 96 + err error 97 + undo []undoMove 98 + } 81 99 undoDoneMsg struct{} 82 - toggleSeenDoneMsg struct{ uid uint32; seen bool; err error } 83 - errMsg struct{ err error } 100 + toggleSeenDoneMsg struct { 101 + uid uint32 102 + seen bool 103 + err error 104 + } 105 + errMsg struct{ err error } 84 106 // background sync (runs every bgSyncInterval while neomd is open) 85 107 bgSyncTickMsg struct{} 86 108 bgInboxFetchedMsg struct{ emails []imap.Email } ··· 88 110 // attachPickDoneMsg carries paths selected via the file picker (yazi etc.) 89 111 attachPickDoneMsg struct{ paths []string } 90 112 // bulkProgressMsg is sent during long-running batch operations to update the status bar. 91 - bulkProgressMsg struct{ moved, total int; label string } 92 - saveDraftDoneMsg struct{ err error } 93 - attachOpenDoneMsg struct{ path string; err error } 94 - editorDoneMsg struct { 113 + bulkProgressMsg struct { 114 + moved, total int 115 + label string 116 + } 117 + saveDraftDoneMsg struct{ err error } 118 + attachOpenDoneMsg struct { 119 + path string 120 + err error 121 + } 122 + editorDoneMsg struct { 95 123 to, cc, bcc, subject, body string 96 124 err error 97 125 aborted bool // true when file was unchanged (ZQ / :q!) ··· 334 362 spinner spinner.Model 335 363 336 364 // Reader 337 - reader viewport.Model 365 + reader viewport.Model 338 366 openEmail *imap.Email 339 - openBody string // markdown body used by the TUI reader 340 - openHTMLBody string // original HTML part; used by openInExternalViewer when available 341 - openWebURL string // canonical "view online" URL for ctrl+o (may be empty) 367 + openBody string // markdown body used by the TUI reader 368 + openHTMLBody string // original HTML part; used by openInExternalViewer when available 369 + openWebURL string // canonical "view online" URL for ctrl+o (may be empty) 342 370 openAttachments []imap.Attachment // attachments of the currently open email 343 371 openLinks []emailLink // extracted links from the email body 344 372 readerPending string // chord prefix in reader (space for link open) ··· 422 450 compose.knownAddrs = sc.AllAddresses() 423 451 424 452 return Model{ 425 - cfg: cfg, 426 - accounts: cfg.ActiveAccounts(), 427 - clients: clients, 428 - screener: sc, 429 - state: stateInbox, 430 - loading: true, 431 - folders: cfg.Folders.TabLabels(), 432 - cmdHistory: loadCmdHistory(config.HistoryPath()), 433 - cmdHistI: -1, 453 + cfg: cfg, 454 + accounts: cfg.ActiveAccounts(), 455 + clients: clients, 456 + screener: sc, 457 + state: stateInbox, 458 + loading: true, 459 + folders: cfg.Folders.TabLabels(), 460 + cmdHistory: loadCmdHistory(config.HistoryPath()), 461 + cmdHistI: -1, 434 462 // Note: Spam is intentionally excluded from tabs — use :go-spam to visit. 435 463 compose: compose, 436 464 spinner: sp, ··· 438 466 sortField: "date", 439 467 sortReverse: true, // newest first 440 468 } 469 + } 470 + 471 + // tokenSourceFor returns the OAuth2 token source for the account with the 472 + // given name, or nil if the account uses plain password authentication. 473 + func (m Model) tokenSourceFor(accountName string) func() (string, error) { 474 + for i, acc := range m.accounts { 475 + if acc.Name == accountName && i < len(m.clients) { 476 + return m.clients[i].TokenSource() 477 + } 478 + } 479 + return nil 441 480 } 442 481 443 482 // activeAccount returns the currently selected AccountConfig. ··· 569 608 func (m Model) sendEmailCmd(smtpAcct config.AccountConfig, from, to, cc, bcc, subject, body string, attachments []string) tea.Cmd { 570 609 h, p := splitAddr(smtpAcct.SMTP) 571 610 cfg := smtp.Config{ 572 - Host: h, 573 - Port: p, 574 - User: smtpAcct.User, 575 - Password: smtpAcct.Password, 576 - From: from, 611 + Host: h, 612 + Port: p, 613 + User: smtpAcct.User, 614 + Password: smtpAcct.Password, 615 + From: from, 616 + TokenSource: m.tokenSourceFor(smtpAcct.Name), 577 617 } 578 618 cli := m.imapCli() 579 619 sentFolder := m.cfg.Folders.Sent ··· 656 696 657 697 // batchMoveCmd moves a slice of emails to dst, emitting batchDoneMsg. 658 698 func (m Model) batchMoveCmd(emails []imap.Email, dst string) tea.Cmd { 659 - type mv struct{ folder string; uid uint32 } 699 + type mv struct { 700 + folder string 701 + uid uint32 702 + } 660 703 moves := make([]mv, len(emails)) 661 704 for i, e := range emails { 662 705 moves[i] = mv{e.Folder, e.UID} ··· 696 739 func (m Model) batchScreenerCmd(emails []imap.Email, action string) tea.Cmd { 697 740 sc := m.screener 698 741 cfg := m.cfg 699 - type op struct{ from, srcFolder string; uid uint32; dst string } 742 + type op struct { 743 + from, srcFolder string 744 + uid uint32 745 + dst string 746 + } 700 747 ops := make([]op, 0, len(emails)) 701 748 for _, e := range emails { 702 749 var dst string ··· 749 796 750 797 // markAllSeenCmd marks every currently loaded email in the folder as \Seen. 751 798 func (m Model) markAllSeenCmd() tea.Cmd { 752 - type op struct{ folder string; uid uint32 } 799 + type op struct { 800 + folder string 801 + uid uint32 802 + } 753 803 var ops []op 754 804 for _, e := range m.emails { 755 805 if !e.Seen { ··· 771 821 772 822 // batchToggleSeenCmd toggles \Seen on multiple emails, emitting batchDoneMsg. 773 823 func (m Model) batchToggleSeenCmd(emails []imap.Email) tea.Cmd { 774 - type op struct{ folder string; uid uint32; markSeen bool } 824 + type op struct { 825 + folder string 826 + uid uint32 827 + markSeen bool 828 + } 775 829 ops := make([]op, len(emails)) 776 830 for i, e := range emails { 777 831 ops[i] = op{e.Folder, e.UID, !e.Seen} ··· 1253 1307 // Include partial undo info so user can reverse already-moved emails. 1254 1308 if len(msg.undo) > 0 { 1255 1309 m.undoStack = append(m.undoStack, msg.undo) 1256 - if len(m.undoStack) > maxUndoStack { 1257 - m.undoStack = m.undoStack[len(m.undoStack)-maxUndoStack:] 1258 - } 1310 + if len(m.undoStack) > maxUndoStack { 1311 + m.undoStack = m.undoStack[len(m.undoStack)-maxUndoStack:] 1312 + } 1259 1313 } 1260 1314 m.status = msg.err.Error() 1261 1315 m.isError = true ··· 1993 2047 return m, nil 1994 2048 } 1995 2049 if len(key) == 1 && key >= "1" && key <= "9" { 1996 - idx := int(key[0]-'1') // 0-based 2050 + idx := int(key[0] - '1') // 0-based 1997 2051 if idx < len(m.folders) { 1998 2052 if idx == m.activeFolderI { 1999 2053 return m, nil ··· 2081 2135 m.status = fmt.Sprintf("unknown: M%s", key) 2082 2136 2083 2137 case ",": 2084 - type sortSpec struct{ field string; rev bool } 2138 + type sortSpec struct { 2139 + field string 2140 + rev bool 2141 + } 2085 2142 specs := map[string]sortSpec{ 2086 2143 "m": {"date", true}, 2087 2144 "M": {"date", false}, ··· 2171 2228 return m.launchForwardCmd() 2172 2229 } 2173 2230 case "1", "2", "3", "4", "5", "6", "7", "8", "9": 2174 - idx := int(msg.String()[0]-'1') // 0-based 2231 + idx := int(msg.String()[0] - '1') // 0-based 2175 2232 if idx < len(m.openAttachments) { 2176 2233 return m, m.downloadOpenAttachmentCmd(m.openAttachments[idx]) 2177 2234 } ··· 3038 3095 3039 3096 // extractInlineAttachments scans body for [attach] /path lines inserted by the 3040 3097 // neomd Lua helper in neovim (<leader>a). 3041 - // - Image files (.png, .jpg, …) are converted to ![](path) markdown refs so 3042 - // goldmark renders them as <img> tags inline; the sender embeds them via CID. 3043 - // - Non-image files are returned as file attachment paths (appended at bottom). 3098 + // - Image files (.png, .jpg, …) are converted to ![](path) markdown refs so 3099 + // goldmark renders them as <img> tags inline; the sender embeds them via CID. 3100 + // - Non-image files are returned as file attachment paths (appended at bottom). 3101 + // 3044 3102 // Returns (filePaths, cleanBody). 3045 3103 func extractInlineAttachments(body string) (files []string, clean string) { 3046 3104 const prefix = "[attach] "